diff --git a/.claude/CLAUDE.md b/.claude/CLAUDE.md index 3e88a8ad..3e8b1aa8 100644 --- a/.claude/CLAUDE.md +++ b/.claude/CLAUDE.md @@ -6,19 +6,20 @@ required beyond the repo itself (Nix dev shell via `just`). ## Skills -| Skill | What it does | -| ---------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `hackathon-e2e` | Deterministic end-to-end suite: boots the whole stack from scratch (Keycloak, Postgres, backend, frontend), then runs Playwright (Firefox) as a 15-person cast. Projects: `smoke` (seeded fixture), `journey` (the full lifecycle recipe on an empty DB), `mobile` (phone-viewport battery), `openreplay` (session-replay privacy proof), `tunnel` (login through the public URL), `docs` (documentation screenshots). | -| `devcontainer-up` | Spins up the docker-compose devcontainer and gets it ready (Nix, toolchain warmed). `scripts/e2e.sh` runs the e2e suite inside the container; `scripts/start.sh` is the one-command path from nothing to a running (optionally public, optionally seeded) stack. | -| `cloudflare-tunnel` | Exposes the locally running stack through a Cloudflare quick tunnel. One public hostname serves frontend and Keycloak (caddy path-mux), so `up.sh --with-auth` gives working OIDC login/registration through the tunnel; plain `up.sh` is anonymous view-only. | -| `dbml-diagrams` | Builds and validates the dbdiagram.io DBML (`docs/backend/schema.dbml`) from the ent schema; `scripts/validate.sh` runs the official parser. | -| `docs-bundle` | Builds `docs/` into ONE self-contained HTML (`out/hackagon-docs.html`): images re-encoded to webp and inlined, mermaid pre-rendered to SVG, cross-doc links anchored. No network needed to read it; prints to PDF. | -| `openreplay-stack` | Self-hosted OpenReplay (session replay) via docker compose behind a Cloudflare quick tunnel. Vendors the upstream compose into the skill, prepares secrets non-interactively, points the stack at the tunnel URL, wires the app at it and back (`wire-frontend.sh`), and purges expired sessions (`retention.sh` — upstream has no retention setting). Debug rig — needs 8 GB RAM of its own. | -| `seed-past-hackathons` | Populates a running instance with SDSC's real past hackathons — one source-cited JSON per edition under `data/` (details, phases, tracks, markdown pages, images). Uploads the pictures into the instance's object store, sets each event's cover, rewrites page markdown to the uploaded paths, and gives every edition a prize table with drawn (not photographed) badge art. | +| Skill | What it does | +| ---------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `hackathon-e2e` | Deterministic end-to-end suite: boots the whole stack from scratch (Keycloak, Postgres, backend, frontend), then runs Playwright (Firefox) as a 15-person cast. Projects: `smoke` (seeded fixture), `journey` (the full lifecycle recipe on an empty DB), `mobile` (phone-viewport battery), `openreplay` (session-replay privacy proof), `tunnel` (login through the public URL), `docs` (documentation screenshots). | +| `devcontainer-up` | Spins up the docker-compose devcontainer and gets it ready (Nix, toolchain warmed). `scripts/e2e.sh` runs the e2e suite inside the container; `scripts/start.sh` is the one-command path from nothing to a running (optionally public, optionally seeded) stack. | +| `cloudflare-tunnel` | Exposes the locally running stack through a Cloudflare quick tunnel. One public hostname serves frontend and Keycloak (caddy path-mux), so `up.sh --with-auth` gives working OIDC login/registration through the tunnel; plain `up.sh` is anonymous view-only. | +| `dbml-diagrams` | Builds and validates the dbdiagram.io DBML (`docs/backend/schema.dbml`) from the ent schema; `scripts/validate.sh` runs the official parser. | +| `docs-bundle` | Builds `docs/` into ONE self-contained HTML (`out/hackagon-docs.html`): images re-encoded to webp and inlined, mermaid pre-rendered to SVG, cross-doc links anchored. No network needed to read it; prints to PDF. | +| `openreplay-stack` | Self-hosted OpenReplay (session replay) via docker compose behind a Cloudflare quick tunnel. Vendors the upstream compose into the skill, prepares secrets non-interactively, points the stack at the tunnel URL, wires the app at it and back (`wire-frontend.sh`), and purges expired sessions (`retention.sh` — upstream has no retention setting). Debug rig — needs 8 GB RAM of its own. | +| `plausible-stack` | Self-hosted Plausible Analytics (CE v3.2.1) via docker compose behind its own Cloudflare quick tunnel — Plausible plus its OWN Postgres and ClickHouse, never the app's database. Prepares secrets and the owner account non-interactively (the signup form is a LiveView, so it goes through `bin/plausible rpc`), wires the app at it and back (`wire-frontend.sh`, the THIRD writer of `config.local.yaml`), and proves a page view lands end to end with a real browser and Plausible's own Stats API. ~750 MB RSS — coexists with the openreplay rig. | +| `seed-past-hackathons` | Populates a running instance with SDSC's real past hackathons — one source-cited JSON per edition under `data/` (details, phases, tracks, markdown pages, images). Uploads the pictures into the instance's object store, sets each event's cover, rewrites page markdown to the uploaded paths, and gives every edition a prize table with drawn (not photographed) badge art. | ## The recipe = the product spec -`skills/hackathon-e2e/recipe.jsonl` — **463 actions, one JSON per line**, +`skills/hackathon-e2e/recipe.jsonl` — **465 actions, one JSON per line**, covering platform setup → publication → configuration → registration (13-person wave, forms, waitlist) → the capacity pilot (a capped side sprint: FCFS seats, queue fairness, over-capacity approval, the Join race) → proposals → teams → @@ -27,8 +28,8 @@ event days (no-show, same-day walk-in, deadline overrides) → voting (winners, gallery uploads, wrap-up blog, profile churn). Executed in order by `tests/journey/recipe.spec.ts` via `helpers/recipe.ts`. -Each action carries: `priority` (P1 323 / P2 131 / P3 9), `outcome` -(human-readable expectation), an optional `todo` (placeholder note, 64 actions) +Each action carries: `priority` (P1 325 / P2 131 / P3 9), `outcome` +(human-readable expectation), an optional `todo` (placeholder note, 69 actions) and an optional `gate` (24 actions — skip until the listed RPCs exist, capability-probed at runtime by `scripts/probe.sh`, so actions wake up automatically as the backend lands). `implement: false` meant "deliberately @@ -37,29 +38,81 @@ deferred"; **no action sets it any more** — nothing in the recipe is deferred. **The organiser's own screens (2026-08-12, +119 actions).** The manage hub (tiles derived from `manageNav`, the Now/Next box and its ONE action in all three cases — start the first phase, declare the live one, advance past it — -plus Review N waiting and Edit details), the folded Manage nav, the capability -panel, `StorageService.ListObjects` across every scope and refusal, the markdown -toolbar and its paste-a-table converter, bulk team import, Manage Pages -reordering, and the Join gate with the sign-in interstitial. Two states in that -set are **unreachable from outside and therefore not asserted**: a capability -that is `UNGOVERNED` (`Create` seeds a row for all six, so only a hackathon with -no rows at all reaches that code path — `act5.cap.ungoverned` uses one), and a -WAITLISTED owner (`AddOwner` answers `FailedPrecondition` for anyone on the -waiting list, so `canEditHackathon`'s narrower gate cannot be exercised -end-to-end). Both are written down in the actions' own `todo`s rather than -faked. - -**One product divergence the new actions found, pinned as it stands.** The hub's -plan-vs-reality warning is computed from `currentAndNextPhase`, which falls back -to the DATES when no phase is declared — but the `Enable it` button behind it -posts `applyPhaseCapabilities`, which looks the phase up by `current_phase_id` -and answers `400 "This hackathon has no current phase to take settings from"` -when that is empty. So the warning is offered in a state where its one action -cannot work. `act5.pilot.cap.unmet.bydates` pins that refusal; the working path -(declare the phase, switch its capability off by hand, then click) is -`act5.pilot.phase.declare` → `act5.pilot.cap.plan.again` → -`act5.pilot.cap.unmet`. Fixing it will turn the first of those red on purpose — -re-specify it, do not delete it. +plus Review N waiting and Edit details), the Manage nav (folded then, flat now — +see the develop merge below), the capability panel, `StorageService.ListObjects` +across every scope and refusal, the markdown toolbar and its paste-a-table +converter, bulk team import, Manage Pages reordering, and the Join gate with the +sign-in interstitial. One state in that set is **unreachable from outside and +therefore not asserted end-to-end**: a WAITLISTED owner (`AddOwner` answers +`FailedPrecondition` for anyone on the waiting list, so `canEditHackathon`'s +narrower gate cannot be exercised end-to-end). It is written down in the +action's own `todo` rather than faked. + +### Three manage-panel bugs, fixed 2026-08-13 + +All three were found by the recipe and had been left pinned as they stood. + +**1. The hub offered a button that could not work.** The plan-vs-reality warning +is computed from `currentAndNextPhase`, which falls back to the DATES when no +phase is declared — while the `Enable it` button behind it posted +`applyPhaseCapabilities`, which looked the phase up by `current_phase_id` alone +and answered `400 "no current phase to take settings from"` whenever that was +empty. Declaring a phase is an explicit act nobody has to perform, so the state +where the two disagreed is the state most events are in. **The action resolves +"current" the same way the page does now** — one definition of the word across +the product. Hiding the warning instead was the alternative and is worse: it is +TRUE in that state, and gating a true, actionable warning on a marker nobody is +required to set reports the gap in fewer situations than it exists in. The 400 +survives for the case that is genuinely empty under BOTH meanings. + +`act5.pilot.cap.unmet.bydates` used to pin the refusal and now asserts the +switches move, with `nowBadge: "By dates"` as its positive control — without it +the action passes against a DECLARED phase, which is what `act5.pilot.cap.unmet` +already covers. **Two actions had to be ADDED with it** +(`act5.pilot.cap.bydates.reset` + `.readback`): the by-dates click now switches +team preferences on, and `act5.pilot.phase.declare.applied` asserts that +ADVANCING is what switches them on — so without putting the switch back first, +that claim would have been green whatever `AdvancePhase` did. Re-specifying an +action can quietly make its NEIGHBOURS vacuous; check what the state it leaves +behind is the premise of. + +**2. `SetCapabilities` refused a whole batch over one ungoverned row.** It +answered `NotFound` if any capability in the batch had no stored row, and the +panel posts all six on every save — so one absent row made the capability screen +unusable, with a 404 as its only explanation and no RPC anywhere that could +create the missing row. **It creates the row now.** Skipping was the dangerous +alternative: `UNGOVERNED` is ALLOWED (`capability.State.Allowed` returns true +for it), so dropping a row the caller asked to set to `false` would report a +successful save while participants kept the permission — a silent no-op on a +gate. Refusing-with-a-name is honest and still leaves the panel dead. The schema +already calls a full set the invariant ("one row per capability per hackathon, +pre-created on hackathon creation"), so a missing row is a data gap, never a +decision. The hackathon's existence is checked first, so a bogus id still +answers `NotFound` — about the HACKATHON, which is the true statement. + +That state is unreachable from the API (`Create` seeds all six, nothing deletes +one), so it is pinned in Go — `hackathon_service_test.go`, "SetCapabilities with +an ungoverned capability", which deletes a row to get there. +`act5.cap.ungoverned` was re-specified to what it can actually reach and gained +`expect.errorMatches`, a new field: the same request answered the same code for +a different reason before and after, and a status code alone cannot tell those +apart. The panel's copy changed with it — it used to warn that the save would be +refused, which stopped being true. + +**3. `.chip:hover` (0,2,0) beat `.chip-active` (0,1,0)**, so pointing at the tab +you were already on erased its accent tint. Fixed with a `.chip-active:hover` +rule of its own, at (0,2,0) so it also covers the `btn btn-icon btn-quiet` that +wears `chip-active` in the markdown editor — `.btn-quiet:hover` was erasing that +one the same way. It wins its tie on SOURCE ORDER and must stay last in the +layer. `tests/smoke/24-chip-states.spec.ts` asserts the **computed style**, +never the class: `chip-active` was on the element the whole time the bug +shipped, so every class-based assertion that could have been written would have +passed. It measures the pixel the browser actually paints (a 1×1 canvas +composited over the page background) because Firefox reports +`color-mix(in oklab, …)` back as `oklab(…)` and `--color-raised` as `rgb(…)` — +two syntaxes for the same kind of fact, and `fillStyle` silently keeps its old +value on a colour it cannot parse, which is why the measurement carries a +sentinel. `recipe-player.html` — self-contained animated replay of the recipe (open in any browser). Rebuild after recipe edits with `node scripts/splice-player.mjs`, @@ -67,14 +120,50 @@ which re-splices the JSONL between the ` + + diff --git a/.claude/skills/hackathon-e2e/recipe-player.html b/.claude/skills/hackathon-e2e/recipe-player.html index 14ad95bd..3bae2e58 100644 --- a/.claude/skills/hackathon-e2e/recipe-player.html +++ b/.claude/skills/hackathon-e2e/recipe-player.html @@ -1,96 +1,68 @@ Hackathon Lifecycle Player

Hackathon Lifecycle Player

- replaying recipe.jsonl — - every action of one full hackathon, T-4 months → T+1 week + replaying recipe.jsonl — every action of one full hackathon, T-4 months → T+1 week
- + + + ACT — press play @@ -1416,20 +932,15 @@

Hackathon Lifecycle Player

- + +
- Press ▶ to replay the full hackathon: publication, a 13-person - registration wave, approvals and dropouts, event days, voting, and the - archive. - + Press ▶ to replay the full hackathon: publication, a 13-person registration wave, approvals and dropouts, event days, voting, and the archive. +
@@ -1439,40 +950,27 @@

Hackathon Lifecycle Player

Progress

0played
-
- 0denied ✕ -
-
- 0noted ◌ -
+
0denied ✕
+
0noted ◌

Action kinds

- create / propose - register - approve - edit - remove / delete - vote - browse / check + create / propose + register + approve + edit + remove / delete + vote + browse / check denied ✕ carries an open note ◌
-

- These are the beam colours on the stage. The timeline's own colours - are legended under it. +

+ These are the beam colours on the stage, and they are fixed. The + timeline below encodes whatever the ⚙ Colours setting says, + and is legended under itself.

@@ -1489,9 +987,7 @@

Event log

- + 0 / 0
-
+
- +
+

-
-

What happened

-
    -
    -
    -

    What comes next

    -
      -
      +

      What happened

        +

        What comes next

          @@ -1542,64 +1030,50 @@

          What comes next

          + + + + + + + + + + diff --git a/.claude/skills/hackathon-e2e/recipe.jsonl b/.claude/skills/hackathon-e2e/recipe.jsonl index 044fdfb2..3ea7b881 100644 --- a/.claude/skills/hackathon-e2e/recipe.jsonl +++ b/.claude/skills/hackathon-e2e/recipe.jsonl @@ -129,7 +129,7 @@ {"id": "act2.whoami.bob", "priority": "P1", "implement": true, "outcome": "Succeeds.", "act": 2, "t": "T-3mo", "title": "bob's platform account is live (WhoAmI)", "actor": "bob", "action": "rpc", "method": "user.UserService/WhoAmI", "params": {}, "expect": {"ok": true}} {"id": "act2.ui.waitlisted", "priority": "P1", "implement": true, "outcome": "The dashboard shows 'SDSC Open Research Data Hackathon 2027' with the 'Waitlisted' membership badge.", "act": 2, "t": "T-3mo", "title": "bob's dashboard shows the event as Waitlisted", "actor": "bob", "action": "ui.assert", "assert": "dashboardBadge", "params": {"name": "SDSC Open Research Data Hackathon 2027", "badge": "Waitlisted"}} {"id": "act2.ui.locked", "priority": "P1", "implement": true, "outcome": "Opening the member view returns HTTP 403.", "act": 2, "t": "T-3mo", "title": "waitlisted users cannot open the member view", "actor": "bob", "action": "ui.assert", "assert": "memberViewStatus", "params": {"status": 403}} -{"id": "act2.flow.bob", "priority": "P1", "implement": true, "outcome": "The 8-step browsing chain completes, ending at a URL matching '/dashboard$'.", "act": 2, "t": "T-3mo", "title": "waitlisted chain: fresh login → dashboard (Waitlisted) → click my event → 403 → back home", "actor": "bob", "action": "ui.flow", "fresh": true, "steps": [{"login": true}, {"expectUrl": "/dashboard$"}, {"expectText": "Waitlisted"}, {"clickLink": "SDSC Open Research Data Hackathon 2027"}, {"expectText": "403"}, {"expectText": "not a confirmed member"}, {"clickLink": "Go back to Homepage"}, {"expectUrl": "(localhost:8081|trycloudflare\\.com)/$"}]} +{"id": "act2.flow.bob", "priority": "P1", "implement": true, "outcome": "The 9-step browsing chain completes: the refusal is not a dead end, and its way out lands on the public page of the SAME event - the one that offers Join.", "act": 2, "t": "T-3mo", "title": "waitlisted chain: fresh login → dashboard (Waitlisted) → click my event → 403 → back to the event's public page", "actor": "bob", "action": "ui.flow", "fresh": true, "steps": [{"login": true}, {"expectUrl": "/dashboard$"}, {"expectText": "Waitlisted"}, {"clickLink": "SDSC Open Research Data Hackathon 2027"}, {"expectText": "403"}, {"expectText": "not a confirmed member"}, {"clickLink": "Back to this hackathon"}, {"expectUrl": "/hackathon/[0-9a-f-]+$"}, {"expectText": "SDSC Open Research Data Hackathon 2027"}], "todo": "Re-specified 2026-08-14 for develop's +error.svelte. The refusal page used to offer one always-Home button; it now reads the hackathon id off the URL and offers 'Back to this hackathon', landing on the PUBLIC event page - which for a waitlisted person is the page that offers Join, i.e. the one place the refusal should send them. The claim is unchanged and is why the action exists: a 403 must not be a dead end. Strengthened while re-writing - it now asserts WHICH event it landed on, because 'some link was clicked and the URL changed' would pass against a link back to anywhere."} {"id": "act2.flow.anxious", "priority": "P1", "implement": true, "outcome": "The 4-step browsing chain completes, ending showing 'Waitlisted'.", "act": 2, "t": "T-3mo", "title": "charles anxiously re-checks his waitlist status (dashboard → reload → still Waitlisted)", "actor": "charles", "action": "ui.flow", "steps": [{"goto": "/dashboard"}, {"expectText": "Waitlisted"}, {"goto": "/dashboard"}, {"expectText": "Waitlisted"}]} {"comment": "── ACT 2b — T-3 months: THE CAPACITY PILOT (a capped side sprint) ──"} {"id": "act2.cap.create", "priority": "P1", "implement": true, "outcome": "Succeeds. Returns capHackId for the capacity plot.", "act": 2, "t": "T-3mo", "title": "admin opens a capped side sprint - capacity will be enforced here, not prose", "actor": "hackagon-admin", "action": "rpc", "method": "hackathon.HackathonService/Create", "params": {"name": "SDSC Capacity Pilot Sprint", "description": "A small evening sprint piloting REAL capacity enforcement: 3 seats, first-come-first-served, waiting list for the overflow.", "visibility": "VISIBILITY_PUBLIC"}, "save": {"capHackId": "hackathonId"}, "expect": {"ok": true}} @@ -220,15 +220,15 @@ {"id": "act5.backfill.access", "priority": "P1", "implement": true, "outcome": "Succeeds.", "act": 5, "t": "T-1wk", "title": "Jonas has member access now", "actor": "jonas.weber", "action": "rpc", "method": "hackathon.HackathonService/Get", "params": {"hackathonId": "{{hackathonId}}"}, "expect": {"ok": true}} {"id": "act5.backfill.team", "priority": "P1", "implement": true, "outcome": "Succeeds.", "act": 5, "t": "T-1wk", "title": "Jonas takes Fatima's seat on Team Bernina", "actor": "hackagon-admin", "action": "rpc", "method": "hackathon.TeamService/AssignUser", "params": {"teamId": "{{var:teamBernina}}", "userId": "{{userId:jonas.weber}}"}, "expect": {"ok": true}} {"id": "act5.roster.final", "priority": "P1", "implement": true, "outcome": "Succeeds; roster shows 12 on the list, 8 approved, 4 waitlisted.", "act": 5, "t": "T-1wk", "title": "final list confirmed: 8 approved, 4 waitlisted, 12 total (roster includes the organizer)", "actor": "hackagon-admin", "action": "rpc", "method": "hackathon.HackathonService/Get", "params": {"hackathonId": "{{hackathonId}}"}, "expect": {"ok": true, "check": "roster", "checkArgs": {"total": 13, "approved": 9, "waiting": 4}}} -{"comment": "── ACT 5 (cont.) — the organizer's own screens: the manage hub, the folded nav, the capability switches, bulk team composition ──"} +{"comment": "── ACT 5 (cont.) — the organizer's own screens: the manage hub, the flat Manage nav, the capability switches, bulk team composition ──"} {"id": "act5.hub.tiles", "priority": "P1", "implement": true, "outcome": "The hub tiles all nine organizer destinations and never itself, states 'Review 4 waiting' beside the approval queue, offers Edit details - and with no phases on the schedule the Now/Next box says so and offers no action at all.", "act": 5, "t": "T-1wk", "title": "MANAGE HUB: the organizer's landing page tiles every screen it leads to", "actor": "hackagon-admin", "action": "ui.assert", "assert": "manageHub", "params": {"tiles": [{"label": "Manage Tracks", "href": "/tracks"}, {"label": "Manage Teams", "href": "/teams/manage"}, {"label": "New Phase", "href": "/timeline/new"}, {"label": "Manage Pages", "href": "/pages"}, {"label": "Prizes", "href": "/prizes"}, {"label": "Deadlines", "href": "/windows"}, {"label": "Manage Forms", "href": "/forms"}, {"label": "Notifications", "href": "/email"}, {"label": "Invitation Links", "href": "/invites"}], "tileCount": 9, "reviewWaiting": 4, "mayEdit": true, "nowBadge": null, "next": null, "phaseAction": null, "clearMarker": false}, "todo": "The tiles are asserted INSIDE the tile grid: the sidebar renders the same nine labels on this very page, so a page-wide locator is satisfied by the nav and would pass with no tiles rendered at all."} {"id": "act5.hub.rogue", "priority": "P1", "implement": true, "outcome": "403 - the hub is gated on the same owner-or-admin rule that decides whether the sidebar offers it, so the nav can never offer a link the page then refuses.", "act": 5, "t": "T-1wk", "title": "a participant opening the manage hub is politely refused", "actor": "bob", "action": "ui.flow", "steps": [{"goto": "/my/hackathon/{{hackathonId}}/manage", "status": 403}]} {"id": "act5.hub.review.click", "priority": "P1", "implement": true, "outcome": "Clicking the prompt lands on the participants screen where the four waiting people can actually be approved.", "act": 5, "t": "T-1wk", "title": "MANAGE HUB: the approval prompt leads to the queue it counts", "actor": "hackagon-admin", "action": "ui.flow", "steps": [{"goto": "/my/hackathon/{{hackathonId}}/manage"}, {"clickLink": "Review 4 waiting"}, {"expectUrl": "/participants$"}, {"expectHeading": "All Participants"}, {"expectText": "Charles Whitfield"}]} {"id": "act5.hub.edit.click", "priority": "P1", "implement": true, "outcome": "Edit details opens the hackathon's own record - the one organizer form that is not among the tiles, because it changes the event rather than something inside it.", "act": 5, "t": "T-1wk", "title": "MANAGE HUB: Edit details opens the event's own record", "actor": "hackagon-admin", "action": "ui.flow", "steps": [{"goto": "/my/hackathon/{{hackathonId}}/manage"}, {"clickLink": "Edit details"}, {"expectUrl": "/manage/edit$"}, {"expectHeading": "Edit Hackathon"}, {"expectText": "SDSC Open Research Data Hackathon 2027"}]} -{"id": "act5.flow.reach.manage", "priority": "P1", "implement": true, "outcome": "The organizer gets from the dashboard to a deadline form entirely by CLICKING: event row, then the folded Manage section, then the hub, then the tile - and the back button returns to the hub.", "act": 5, "t": "T-1wk", "title": "REACHABILITY: dashboard → event → unfold Manage → hub → Deadlines, by clicking", "actor": "hackagon-admin", "action": "ui.flow", "steps": [{"goto": "/dashboard"}, {"clickLink": "SDSC Open Research Data Hackathon 2027"}, {"expectUrl": "/my/hackathon/[0-9a-f-]+/overview$"}, {"clickButton": "Show Manage Hackathon pages"}, {"clickLink": "Manage Hackathon"}, {"expectUrl": "/manage$"}, {"expectHeading": "Manage Hackathon"}, {"clickSelector": "xpath=//main//section[.//span[normalize-space()='Manage']]//a[normalize-space()='Deadlines']"}, {"expectUrl": "/windows$"}, {"expectHeading": "Deadlines"}, {"back": true}, {"expectUrl": "/manage$"}], "todo": "page.goto proves a route works, not that anyone can get there - three bugs once hid behind 'clicking my hackathons does nothing'. The tile is reached by an XPath scoped to the tile grid because the sidebar carries a link with the same accessible name."} +{"id": "act5.flow.reach.manage", "priority": "P1", "implement": true, "outcome": "The organizer gets from the dashboard to a deadline form entirely by CLICKING: event row, then the hub on the Manage rail, then the tile - and the back button returns to the hub.", "act": 5, "t": "T-1wk", "title": "REACHABILITY: dashboard → event → Manage Hackathon → hub → Deadlines, by clicking", "actor": "hackagon-admin", "action": "ui.flow", "steps": [{"goto": "/dashboard"}, {"clickLink": "SDSC Open Research Data Hackathon 2027"}, {"expectUrl": "/my/hackathon/[0-9a-f-]+/overview$"}, {"clickLink": "Manage Hackathon"}, {"expectUrl": "/manage$"}, {"expectHeading": "Manage Hackathon"}, {"clickSelector": "xpath=//main//section[.//span[normalize-space()='Manage']]//a[normalize-space()='Deadlines']"}, {"expectUrl": "/windows$"}, {"expectHeading": "Deadlines"}, {"back": true}, {"expectUrl": "/manage$"}], "todo": "Re-specified 2026-08-14 for develop's 942b60a7: the Manage rail is flat, so there is no disclosure to open on the way through and the chain is one click shorter. The claim is unchanged and is the reason the action exists - a goto proves the route works, only a click proves you can GET there (.claude/CLAUDE.md, pass 1)."} {"id": "act5.flow.hub.back", "priority": "P2", "implement": true, "outcome": "Opening Edit details and pressing Back returns to the hub with the event untouched - the form is a detour, not a commitment.", "act": 5, "t": "T-1wk", "title": "SECOND THOUGHTS: the organizer opens Edit details and backs out", "actor": "hackagon-admin", "action": "ui.flow", "steps": [{"goto": "/my/hackathon/{{hackathonId}}/manage"}, {"clickLink": "Edit details"}, {"expectHeading": "Edit Hackathon"}, {"back": true}, {"expectUrl": "/manage$"}, {"expectHeading": "Manage Hackathon"}]} {"id": "act5.hub.title.check", "priority": "P2", "implement": true, "outcome": "Succeeds; the event's name is exactly as act 1 left it - backing out of a form must not have written anything.", "act": 5, "t": "T-1wk", "title": "END STATE: backing out of the edit form changed nothing", "actor": "hackagon-admin", "action": "rpc", "method": "hackathon.HackathonService/Get", "params": {"hackathonId": "{{hackathonId}}"}, "expect": {"ok": true, "check": "hackathonField", "checkArgs": {"nameEquals": "SDSC Open Research Data Hackathon 2027"}}} -{"id": "act5.nav.fold", "priority": "P1", "implement": true, "outcome": "The Manage section starts folded with only its hub on the rail, opens and closes from the chevron, remembers the choice across a page load, and unfolds itself when a page inside it is opened.", "act": 5, "t": "T-1wk", "title": "SIDEBAR: the ten organizer entries fold behind the hub instead of doubling the nav", "actor": "hackagon-admin", "action": "ui.assert", "assert": "sidebarManageFold", "params": {"entry": "Manage Pages", "insidePath": "tracks"}, "todo": "Asserted through getByRole: the entries stay in the DOM while folded and are hidden by inert + aria-hidden, so the accessibility tree is the only place that reads as hidden. A CSS check would agree with a fold that leaves ten links as the next thing Tab reaches."} +{"id": "act5.nav.fold", "priority": "P1", "implement": true, "outcome": "The Manage section is flat: every organizer entry sits on the rail directly, on a participant page and inside the section alike, with no disclosure to open and nothing about the rail that changes when you walk into Manage.", "act": 5, "t": "T-1wk", "title": "SIDEBAR: the organizer entries are drawn flat, with no fold to open", "actor": "hackagon-admin", "action": "ui.assert", "assert": "sidebarManageFold", "params": {"entry": "Manage Pages", "insidePath": "tracks"}, "todo": "Re-specified 2026-08-14 for develop's 942b60a7, which removed the fold this action used to pin (it force-opened on entering /manage/*, so the disclosure was already open wherever an organizer used it). The claim carrying the weight is the EQUALITY of the rail outside and inside Manage: a per-page presence check passes against a fold too, once it is open, and this one opened itself on exactly those pages. If a disclosure is ever reintroduced, re-specify this action again rather than delete it."} {"id": "act5.pilot.join.offered", "priority": "P1", "implement": true, "outcome": "The pilot sprint is open, so alice's dashboard offers Join and says nothing about being closed. This is the control for the refusal asserted below.", "act": 5, "t": "T-1wk", "title": "CONTROL: while registration is open the dashboard offers Join on the pilot sprint", "actor": "alice", "action": "ui.assert", "assert": "dashboardJoinOffered", "params": {"name": "SDSC Capacity Pilot Sprint"}} {"id": "act5.pilot.phase.wrap", "priority": "P2", "implement": true, "outcome": "Succeeds. Returns pilotWrapPhase - a phase entirely in the future, so nothing is running on the pilot yet.", "act": 5, "t": "T-1wk", "title": "PILOT: the side sprint gets a wrap-up phase, next week", "actor": "hackagon-admin", "action": "rpc", "method": "hackathon.PhaseService/Create", "params": {"hackathonId": "{{var:capHackId}}", "name": "Pilot Wrap-up", "startsAt": "{{now+2d}}", "endsAt": "{{now+3d}}", "description": "Retrospective and hand-over of the capacity pilot."}, "save": {"pilotWrapPhase": "phaseId"}, "expect": {"ok": true}} {"id": "act5.pilot.hub.start", "priority": "P1", "implement": true, "outcome": "Nothing is running and nothing is declared, so the one action offered is 'Start Pilot Wrap-up' - the case a single 'Advance to X' label used to collapse into a sentence naming a phase that appears nowhere on the page.", "act": 5, "t": "T-1wk", "title": "MANAGE HUB (case 3): with no phase live, the action is to START the first one", "actor": "hackagon-admin", "action": "ui.assert", "assert": "manageHub", "params": {"hackathonId": "{{var:capHackId}}", "tiles": [{"label": "Manage Tracks", "href": "/tracks"}, {"label": "Deadlines", "href": "/windows"}], "tileCount": 9, "reviewWaiting": 3, "mayEdit": true, "nowBadge": null, "next": "Pilot Wrap-up", "phaseAction": "Start Pilot Wrap-up", "clearMarker": false}} @@ -245,7 +245,9 @@ {"id": "act5.pilot.cap.save", "priority": "P1", "implement": true, "outcome": "The form saves the whole set from the checkboxes: proposing closes, voting opens, and the panel confirms it in words before the badges are re-read.", "act": 5, "t": "T-1wk", "title": "CAPABILITIES: ticking two boxes and pressing Save changes the switches", "actor": "hackagon-admin", "action": "ui.assert", "assert": "capabilitySave", "params": {"hackathonId": "{{var:capHackId}}", "uncheck": ["Propose projects"], "check": ["Vote"], "after": {"Propose projects": "Closed", "Vote": "Open"}}, "todo": "Unchecked boxes submit NOTHING, so the six are rebuilt from the enum order server-side. A save that dropped that would look identical on screen until the badges are read back."} {"id": "act5.pilot.cap.save.readback", "priority": "P1", "implement": true, "outcome": "Succeeds; proposing is CLOSED, voting OPEN, registration still CLOSED and results still COMING - a whole-set save must not flatten the scheduled one into a plain closed switch.", "act": 5, "t": "T-1wk", "title": "END STATE: the saved form kept the scheduled capability scheduled", "actor": "hackagon-admin", "action": "rpc", "method": "hackathon.HackathonService/Get", "params": {"hackathonId": "{{var:capHackId}}"}, "expect": {"ok": true, "check": "capabilityStates", "checkArgs": {"states": {"CAPABILITY_PROPOSE_PROJECTS": "CAPABILITY_STATE_CLOSED", "CAPABILITY_VOTE": "CAPABILITY_STATE_OPEN", "CAPABILITY_REGISTER": "CAPABILITY_STATE_CLOSED", "CAPABILITY_VIEW_RESULTS": "CAPABILITY_STATE_COMING"}}}} {"id": "act5.pilot.cap.plan", "priority": "P1", "implement": true, "outcome": "Succeeds; the live phase now NAMES a capability that is switched off, which is the plan-vs-reality gap the hub warns about.", "act": 5, "t": "T-1wk", "title": "PILOT: team preferences are planned for the phase that is running, and switched off", "actor": "hackagon-admin", "action": "rpc", "method": "hackathon.HackathonService/EditCapability", "params": {"hackathonId": "{{var:capHackId}}", "capability": "CAPABILITY_SET_TEAM_PREFERENCES", "enabled": false, "openInPhaseId": "{{var:pilotDayPhase}}"}, "expect": {"ok": true}} -{"id": "act5.pilot.cap.unmet.bydates", "priority": "P1", "implement": true, "outcome": "The warning is OFFERED while its button cannot work: the panel names the phase the DATES say is live, and the action behind Enable refuses because no phase is DECLARED. Pinned as it stands - the two halves disagree about what 'current' means.", "act": 5, "t": "T-1wk", "title": "BUG PINNED: Enable-it is offered for a phase resolved by dates, and refuses", "actor": "hackagon-admin", "action": "ui.flow", "steps": [{"goto": "/my/hackathon/{{var:capHackId}}/manage"}, {"expectText": "is meant to include"}, {"clickButton": "Enable it"}, {"expectText": "no current phase to take settings from"}], "todo": "The panel computes `unmet` from currentAndNextPhase, which falls back to the DATES; applyPhaseCapabilities looks the phase up by current_phase_id and fails when it is empty. Either the action should fall back the same way, or the warning should only appear against a declaration. When it is fixed, flip this action to assert the switches moved."} +{"id": "act5.pilot.cap.unmet.bydates", "priority": "P1", "implement": true, "outcome": "The warning is offered for the phase the DATES say is live, with no marker set anywhere - and its one click works: team preferences come on, registration and proposing are untouched, and the warning goes away because it has nothing left to say.", "act": 5, "t": "T-1wk", "title": "CAPABILITIES: Enable-it works for a phase resolved by dates, with nothing declared", "actor": "hackagon-admin", "action": "ui.assert", "assert": "capabilityEnableUnmet", "params": {"hackathonId": "{{var:capHackId}}", "nowBadge": "By dates", "phase": "Pilot Sprint Evening", "names": ["set team preferences"], "button": "Enable it", "after": {"Set team preferences": "Open"}, "untouched": {"Register": "Closed", "Propose projects": "Closed"}}, "todo": "RE-SPECIFIED (was 'BUG PINNED: Enable-it is offered for a phase resolved by dates, and refuses'). It used to assert the 400 'no current phase to take settings from': the panel resolved the live phase with currentAndNextPhase, which falls back to the dates, while applyPhaseCapabilities looked it up by current_phase_id alone - so the control was offered exactly where it could not work. The action resolves it the same way now. `nowBadge` is the positive control: without it this passes against a DECLARED phase, which is the case act5.pilot.cap.unmet already covers."} +{"id": "act5.pilot.cap.bydates.reset", "priority": "P1", "implement": true, "outcome": "Succeeds; team preferences go back off, so the two actions below are once again about what ADVANCING does rather than about what the click above already did.", "act": 5, "t": "T-1wk", "title": "PILOT: the organizer undoes the by-dates enable before declaring anything", "actor": "hackagon-admin", "action": "rpc", "method": "hackathon.HackathonService/EditCapability", "params": {"hackathonId": "{{var:capHackId}}", "capability": "CAPABILITY_SET_TEAM_PREFERENCES", "enabled": false, "openInPhaseId": "{{var:pilotDayPhase}}"}, "expect": {"ok": true}, "todo": "Added when act5.pilot.cap.unmet.bydates was re-specified from a refusal into a working click. Without it act5.pilot.phase.declare.applied would assert OPEN against a capability that was already open - green whatever AdvancePhase did with it."} +{"id": "act5.pilot.cap.bydates.reset.readback", "priority": "P1", "implement": true, "outcome": "Succeeds; team preferences read CLOSED. This is the precondition the next two actions are judged against, so it is asserted rather than assumed.", "act": 5, "t": "T-1wk", "title": "END STATE: the by-dates enable really was undone", "actor": "hackagon-admin", "action": "rpc", "method": "hackathon.HackathonService/Get", "params": {"hackathonId": "{{var:capHackId}}"}, "expect": {"ok": true, "check": "capabilityStates", "checkArgs": {"states": {"CAPABILITY_SET_TEAM_PREFERENCES": "CAPABILITY_STATE_CLOSED"}}}} {"id": "act5.pilot.phase.declare", "priority": "P1", "implement": true, "outcome": "Succeeds; the marker lands on the running phase AND applies what that phase opens - team preferences come on, which is the transaction advancing is supposed to carry.", "act": 5, "t": "T-1wk", "title": "PILOT: the organizer declares the running phase current, which applies its plan", "actor": "hackagon-admin", "action": "rpc", "method": "hackathon.HackathonService/AdvancePhase", "params": {"hackathonId": "{{var:capHackId}}", "phaseId": "{{var:pilotDayPhase}}"}, "expect": {"ok": true}} {"id": "act5.pilot.phase.declare.applied", "priority": "P1", "implement": true, "outcome": "Succeeds; team preferences are OPEN because the phase the marker landed on names them - advancing applies the phase's plan in the same transaction that moves the marker, which is what makes the panel's copy true.", "act": 5, "t": "T-1wk", "title": "END STATE: advancing applied what the phase opens", "actor": "hackagon-admin", "action": "rpc", "method": "hackathon.HackathonService/Get", "params": {"hackathonId": "{{var:capHackId}}"}, "expect": {"ok": true, "check": "capabilityStates", "checkArgs": {"states": {"CAPABILITY_SET_TEAM_PREFERENCES": "CAPABILITY_STATE_OPEN"}}}} {"id": "act5.pilot.cap.plan.again", "priority": "P1", "implement": true, "outcome": "Succeeds; the organizer switches the phase's own capability back off by hand, which is how a real gap between plan and reality appears - the timeline moved, then somebody changed their mind.", "act": 5, "t": "T-1wk", "title": "PILOT: …and then switches team preferences off again by hand", "actor": "hackagon-admin", "action": "rpc", "method": "hackathon.HackathonService/EditCapability", "params": {"hackathonId": "{{var:capHackId}}", "capability": "CAPABILITY_SET_TEAM_PREFERENCES", "enabled": false, "openInPhaseId": "{{var:pilotDayPhase}}"}, "expect": {"ok": true}, "todo": "That it really went off is read back by the action below: the plan-vs-reality warning can only appear while this capability is switched off AND named by the live phase."} @@ -258,7 +260,7 @@ {"id": "act5.cap.empty", "priority": "P2", "implement": true, "outcome": "Succeeds and changes nothing - an empty batch is a no-op, not an error, so a form that submits no ticks cannot fail on its way to closing everything.", "act": 5, "t": "T-1wk", "title": "PARAMS: an empty capability batch", "actor": "hackagon-admin", "action": "rpc", "method": "hackathon.HackathonService/SetCapabilities", "params": {"hackathonId": "{{var:capHackId}}", "capabilities": []}, "expect": {"ok": true, "check": "capabilityStates", "checkArgs": {"states": {"CAPABILITY_VOTE": "CAPABILITY_STATE_CLOSED", "CAPABILITY_REGISTER": "CAPABILITY_STATE_CLOSED"}}}} {"id": "act5.cap.unknown", "priority": "P2", "implement": true, "outcome": "Rejected with InvalidArgument, and nothing in the batch is written: every capability is resolved BEFORE the first row is touched, so a typo cannot apply the prefix before it.", "act": 5, "t": "T-1wk", "title": "PARAMS: a batch naming a capability the enum does not have", "actor": "hackagon-admin", "action": "rpc", "method": "hackathon.HackathonService/SetCapabilities", "params": {"hackathonId": "{{var:capHackId}}", "capabilities": [{"capability": "CAPABILITY_UNSPECIFIED", "enabled": true}]}, "expect": {"error": "InvalidArgument"}} {"id": "act5.cap.badid", "priority": "P2", "implement": true, "outcome": "Rejected with InvalidArgument - a malformed hackathon id fails before any permission is even considered.", "act": 5, "t": "T-1wk", "title": "PARAMS: a malformed hackathon id on the capability batch", "actor": "hackagon-admin", "action": "rpc", "method": "hackathon.HackathonService/SetCapabilities", "params": {"hackathonId": "not-a-uuid", "capabilities": [{"capability": "CAPABILITY_VOTE", "enabled": true}]}, "expect": {"error": "InvalidArgument"}} -{"id": "act5.cap.ungoverned", "priority": "P2", "implement": true, "outcome": "Rejected with NotFound, and the WHOLE batch is refused: a capability with no stored row cannot be set, and the panel says so before an organizer spends a save on it.", "act": 5, "t": "T-1wk", "title": "PARAMS: setting a capability that has no stored row refuses the whole batch", "actor": "hackagon-admin", "action": "rpc", "method": "hackathon.HackathonService/SetCapabilities", "params": {"hackathonId": "00000000-0000-0000-0000-000000000000", "capabilities": [{"capability": "CAPABILITY_VOTE", "enabled": true}]}, "expect": {"error": "NotFound"}, "todo": "A hackathon with NO capability rows is the reachable case for this code path - every event created through the API is seeded with all six, so an individually ungoverned capability cannot be produced from the outside."} +{"id": "act5.cap.ungoverned", "priority": "P2", "implement": true, "outcome": "Rejected with NotFound, and the message names the HACKATHON. A batch against an id that exists is never refused over a missing capability row any more - the row is created - so the only NotFound left here is 'no such hackathon', and nothing may be written for an id that names nothing.", "act": 5, "t": "T-1wk", "title": "PARAMS: a capability batch against a hackathon that does not exist", "actor": "hackagon-admin", "action": "rpc", "method": "hackathon.HackathonService/SetCapabilities", "params": {"hackathonId": "00000000-0000-0000-0000-000000000000", "capabilities": [{"capability": "CAPABILITY_VOTE", "enabled": true}]}, "expect": {"error": "NotFound", "errorMatches": "hackathon .* not found"}, "todo": "RE-SPECIFIED (was 'setting a capability that has no stored row refuses the whole batch'). SetCapabilities used to answer NotFound if ANY capability in the batch was ungoverned, and the organiser's panel posts all six - so one missing row made the whole capability screen unusable. It creates the row now. This id still refuses, but for the other reason, and `errorMatches` is what keeps the two apart: the same code from the same request meant a missing capability before and a missing hackathon now. The upsert itself is unreachable from the API (Create seeds all six and no RPC deletes one), so it is pinned in Go instead - hackathon_service_test.go, 'SetCapabilities with an ungoverned capability', which deletes a row to get there."} {"id": "act5.cap.rogue", "priority": "P1", "implement": true, "outcome": "Rejected with PermissionDenied - the switches are hackathon Write, and a participant does not hold it.", "act": 5, "t": "T-1wk", "title": "a participant cannot change what participants may do", "actor": "bob", "action": "rpc", "method": "hackathon.HackathonService/SetCapabilities", "params": {"hackathonId": "{{var:capHackId}}", "capabilities": [{"capability": "CAPABILITY_REGISTER", "enabled": true}]}, "expect": {"error": "PermissionDenied"}} {"id": "act5.cap.anon", "priority": "P1", "implement": true, "outcome": "Rejected with Unauthenticated - 'who are you' and 'not you' are different answers, and an anonymous caller gets the first one.", "act": 5, "t": "T-1wk", "title": "an anonymous caller cannot change the capability switches", "actor": "anonymous", "action": "rpc", "method": "hackathon.HackathonService/SetCapabilities", "params": {"hackathonId": "{{var:capHackId}}", "capabilities": [{"capability": "CAPABILITY_REGISTER", "enabled": true}]}, "expect": {"error": "Unauthenticated"}} {"id": "act5.import.template", "priority": "P1", "implement": true, "outcome": "Both formats download as files, carry the columns the importer reads and this event's REAL roster - and the importer accepts the very file the page just handed out, with nothing left to apply.", "act": 5, "t": "T-1wk", "title": "TEAM IMPORT: the template downloads, and its own importer accepts it", "actor": "hackagon-admin", "action": "ui.assert", "assert": "teamImportTemplate", "params": {"mustContain": ["bob@mail.org", "hiro.tanaka@example.org", "FAIR Pipeline Builder", "Team Matterhorn"], "roundTrip": true}, "todo": "Two halves shipped separately drift apart in one commit, and the failure surfaces only when a real organizer downloads one - so the template is not merely parsed here, it is fed straight back in through the UI."} @@ -427,7 +429,7 @@ {"id": "act8.prizes.edit", "priority": "P3", "implement": true, "outcome": "Succeeds.", "act": 8, "t": "T+1wk", "title": "PRIZES: admin edits the awarded prize text (adds the sponsor credit)", "actor": "hackagon-admin", "action": "rpc", "method": "hackathon.PrizeService/Edit", "params": {"hackathonId": "{{hackathonId}}", "rank": 1, "title": "1st — CHF 5'000 + SDSC mentoring (sponsored by the Innovation Unit)"}, "expect": {"ok": true}} {"id": "act8.prizes.rogue", "priority": "P3", "implement": true, "outcome": "Rejected with PermissionDenied - no state change.", "act": 8, "t": "T+1wk", "title": "a member cannot touch the prize table", "actor": "bob", "action": "rpc", "method": "hackathon.PrizeService/Edit", "params": {"hackathonId": "{{hackathonId}}", "rank": 1, "title": "1st — a lifetime supply of pizza"}, "expect": {"error": "PermissionDenied"}} {"id": "act8.retention", "priority": "P1", "implement": true, "outcome": "Opening the member view returns HTTP 200.", "act": 8, "t": "T+1wk", "title": "confirmed members keep access to the event history", "actor": "bob", "action": "ui.assert", "assert": "memberViewStatus", "params": {"status": 200}} -{"id": "act8.flow.charles", "priority": "P1", "implement": true, "outcome": "The 7-step browsing chain completes, ending at a URL matching '/dashboard$'.", "act": 8, "t": "T+1wk", "title": "post-event waitlisted chain: fresh login → dashboard (still Waitlisted) → click event → still 403 → back home", "actor": "charles", "action": "ui.flow", "fresh": true, "steps": [{"login": true}, {"expectUrl": "/dashboard$"}, {"expectText": "Waitlisted"}, {"clickLink": "SDSC Open Research Data Hackathon 2027"}, {"expectText": "403"}, {"clickLink": "Go back to Homepage"}, {"expectUrl": "(localhost:8081|trycloudflare\\.com)/$"}]} +{"id": "act8.flow.charles", "priority": "P1", "implement": true, "outcome": "The 8-step browsing chain completes: the refusal is not a dead end, and its way out lands on the public page of the SAME event - the one that offers Join.", "act": 8, "t": "T+1wk", "title": "post-event waitlisted chain: fresh login → dashboard (still Waitlisted) → click event → still 403 → back to the event's public page", "actor": "charles", "action": "ui.flow", "fresh": true, "steps": [{"login": true}, {"expectUrl": "/dashboard$"}, {"expectText": "Waitlisted"}, {"clickLink": "SDSC Open Research Data Hackathon 2027"}, {"expectText": "403"}, {"clickLink": "Back to this hackathon"}, {"expectUrl": "/hackathon/[0-9a-f-]+$"}, {"expectText": "SDSC Open Research Data Hackathon 2027"}], "todo": "Re-specified 2026-08-14 for develop's +error.svelte. The refusal page used to offer one always-Home button; it now reads the hackathon id off the URL and offers 'Back to this hackathon', landing on the PUBLIC event page - which for a waitlisted person is the page that offers Join, i.e. the one place the refusal should send them. The claim is unchanged and is why the action exists: a 403 must not be a dead end. Strengthened while re-writing - it now asserts WHICH event it landed on, because 'some link was clicked and the URL changed' would pass against a link back to anywhere."} {"id": "act8.photos", "priority": "P1", "implement": true, "outcome": "Succeeds. [Skips until the gated capability lands.]", "act": 8, "t": "T+1wk", "title": "photos published + winners announced on the website", "actor": "hackagon-admin", "action": "rpc", "method": "hackathon.PageService/Create", "params": {"hackathonId": "{{hackathonId}}", "title": "Photos & Winners", "content": "Winners: 1st Team Matterhorn (FAIR Pipeline Builder), 2nd Team Bernina (LitData Extractor). Photo material: generated posters from helpers/files.ts by default, or CC files fetched by scripts/fetch-cc-assets.sh — keep .state/uploads/cc/ATTRIBUTION.md content on the page.", "visible": true}, "expect": {"ok": true}, "todo": "TODO: runs once PageService.Create lands; image embedding needs the upload channel from act6.submit.draft."} {"id": "act8.media.presign", "priority": "P1", "implement": true, "outcome": "Succeeds - a presigned PUT for a gallery photo.", "act": 8, "t": "T+1wk", "title": "MEDIA: the organizer gets an upload URL for a gallery photo", "actor": "hackagon-admin", "action": "rpc", "method": "storage.StorageService/CreateUploadUrl", "params": {"kind": "UPLOAD_KIND_HACKATHON_MEDIA", "ownerId": "{{hackathonId}}", "filename": "day-two.webp", "contentType": "image/webp", "sizeBytes": 98028}, "expect": {"ok": true}, "todo": "The page editor's Insert image control calls this. Uploads are re-encoded to WebP in the browser first, so the declared type is what the signature is built for."} {"id": "act8.media.rogue", "priority": "P1", "implement": true, "outcome": "Rejected with PermissionDenied - gallery media needs hackathon Write.", "act": 8, "t": "T+1wk", "title": "MEDIA: a member cannot upload gallery photos", "actor": "bob", "action": "rpc", "method": "storage.StorageService/CreateUploadUrl", "params": {"kind": "UPLOAD_KIND_HACKATHON_MEDIA", "ownerId": "{{hackathonId}}", "filename": "day-two.webp", "contentType": "image/webp", "sizeBytes": 1024}, "expect": {"error": "PermissionDenied"}} @@ -467,7 +469,7 @@ {"id": "act8.profile.blank", "priority": "P2", "implement": true, "outcome": "Rejected with InvalidArgument - a blank name renders as an empty byline everywhere.", "act": 8, "t": "T+3w", "title": "VALIDATION: alice cannot blank out her display name", "actor": "alice", "action": "rpc", "method": "user.UserService/EditProfile", "gate": ["user.UserService/EditProfile"], "params": {"displayName": " "}, "expect": {"error": "InvalidArgument"}} {"id": "act8.menu.alice", "priority": "P1", "implement": true, "outcome": "The account menu opens on the FIRST click and reaches /account - the only route to it.", "act": 8, "t": "T+3w", "title": "NAVIGATION: alice reaches her account from the top bar", "actor": "alice", "action": "ui.flow", "steps": [{"login": true}, {"expectUrl": "/dashboard$"}, {"clickLink": "Your account"}, {"expectUrl": "/account$"}, {"expectHeading": "Your account"}], "fresh": true} {"id": "act8.menu.admin", "priority": "P2", "implement": true, "outcome": "Admins reach the platform CMS from the menu; the PLATFORM section is role-gated.", "act": 8, "t": "T+3w", "title": "NAVIGATION: the admin reaches /manage/pages from the dashboard", "actor": "hackagon-admin", "action": "ui.flow", "steps": [{"goto": "/dashboard"}, {"clickLink": "Pages"}, {"expectUrl": "/manage/pages$"}]} -{"id": "act8.form.ui.edit", "priority": "P2", "implement": true, "outcome": "A participant can FIND their registration answers from the event page and change them.", "act": 8, "t": "T+3w", "title": "FORMS: bob reaches his registration answers through the UI", "actor": "bob", "action": "ui.flow", "steps": [{"goto": "/my/hackathon/{{hackathonId}}/overview"}, {"clickLink": "View or edit"}, {"expectUrl": "/register/"}, {"expectText": "You've already filled this in"}]} +{"id": "act8.form.ui.edit", "priority": "P2", "implement": true, "outcome": "A participant can FIND their registration answers from the event and change them - now via the participants roster, where his own row's View opens his editable form.", "act": 8, "t": "T+3w", "title": "FORMS: bob reaches his registration answers through the UI", "actor": "bob", "action": "ui.flow", "steps": [{"goto": "/my/hackathon/{{hackathonId}}/overview"}, {"clickLink": "Participants"}, {"expectUrl": "/participants$"}, {"clickSelector": "xpath=//main//a[starts-with(normalize-space(@aria-label),'View') and not(contains(@href,'userId'))]"}, {"expectUrl": "/register/"}, {"expectText": "You've already filled this in"}], "todo": "Re-specified 2026-08-14. develop's c596683c redesigned the overview and removed the 'Your registration answers -> View or edit' block; its 76037844 moved the entry point to the participants roster, where View opens /register/{id} for your own row and /register/{id}?userId= for someone else's (organizers only). The PRODUCT rule is unchanged and is the one this action exists for - SubmitRegistrationForm is an upsert precisely so the first typo is not permanent, which needs a way in from the UI - so the locator moved and the claim did not. The account page used to send people to that same removed block; fixed 2026-08-14 - it names Participants -> View now, and smoke/07-account-menu follows that sentence out of the page's own markup rather than reading it."} {"id": "act8.account.liam", "priority": "P3", "implement": true, "outcome": "Succeeds.", "act": 8, "t": "T+1wk", "title": "CHURN: Liam (never got off the waitlist) deletes his profile and leaves the platform", "actor": "liam.obrien", "action": "rpc", "method": "user.UserService/DeleteAccount", "params": {}, "expect": {"ok": true}} {"id": "act8.account.mei", "priority": "P3", "implement": true, "outcome": "Succeeds.", "act": 8, "t": "T+1wk", "title": "CHURN: Mei deletes her profile too", "actor": "mei.chen", "action": "rpc", "method": "user.UserService/DeleteAccount", "params": {}, "expect": {"ok": true}} {"id": "act8.account.check", "priority": "P3", "implement": true, "outcome": "Succeeds; the deleted profiles no longer appear in the user list.", "act": 8, "t": "T+1wk", "title": "the departed profiles are gone from the platform user list", "actor": "hackagon-admin", "action": "rpc", "method": "user.UserService/List", "params": {}, "expect": {"ok": true, "check": "usersLackNames", "checkArgs": {"names": ["Liam O'Brien", "Mei Chen"]}}} diff --git a/.claude/skills/hackathon-e2e/scripts/build-quality-report.mjs b/.claude/skills/hackathon-e2e/scripts/build-quality-report.mjs new file mode 100644 index 00000000..106b584b --- /dev/null +++ b/.claude/skills/hackathon-e2e/scripts/build-quality-report.mjs @@ -0,0 +1,2178 @@ +#!/usr/bin/env node +/** + * Build `quality-report.html` — a status report you could hand someone: what is + * tested, how well, what is not, and what is known-broken. + * + * This is NOT recipe-player.html. The player is an animated REPLAY of the + * recipe; this is the report about it. The report links to the player. + * + * ── The one rule ─────────────────────────────────────────────────────────── + * Nothing in the output is hand-typed. Every number is read from a file on + * disk at build time, rendered with the source it came from, and then READ + * BACK OUT of the finished HTML and re-derived by a second, independent code + * path before the build is allowed to succeed. A stale hand-written count is + * the failure mode this exists to make impossible — that is why `claim()` + * stamps `data-claim`/`data-value` on every figure, and why `rederive()` + * counts with textual scans rather than reusing the parsed objects. + * + * ── The check GATES the write; it does not follow it ──────────────────────── + * The document is assembled in memory, every read-back check runs against that + * string, and only a clean pass reaches the disk — through a temp file in the + * same directory and a rename, so an interrupted run cannot leave half a file + * and a failing run leaves the previous report byte-for-byte intact. + * + * This used to be the other way round: `writeFileSync` first, `read(OUT)` and + * re-derive after. **A validator that runs after the write certifies nothing** + * — the bad artefact is on disk either way, its exit code is the only thing + * standing between it and a commit, and one run whose CLAUDE.md row disagreed + * with results.json left an `undefined`-filled report that had to be reverted + * by hand. Reading the string rather than the file loses nothing: the string + * IS the finished document, and the bytes that land are compared against it + * after the rename, so "what was checked" and "what is on disk" stay one thing. + * + * Sources (all repo-relative, all read fresh): + * A .claude/skills/hackathon-e2e/recipe.jsonl + * B .claude/skills/hackathon-e2e/.artifacts/results.json + * C .claude/skills/hackathon-e2e/mutations/manifest.jsonl + * D .claude/skills/hackathon-e2e/mutations/.state/verify.json + * E docs/testing.md + * F .claude/CLAUDE.md + * G api/proto/ ** /*_service.proto + * H components/frontend/src + * I the named spec / Go files under components/ and tests/ + * + * ── Traps this script is built around ────────────────────────────────────── + * 1. An inline + + +` + +/* ══════════ read back, re-derive, and refuse to WRITE on a disagreement ═══ */ +/* Nothing has touched the disk at this point, and nothing will until every + check below has passed. See the header note: the write is the last thing + this script does, not the first. */ + +/** + * The second code path. Deliberately textual where the first was structural: + * if both were `JSON.parse` + the same reduce, agreeing would prove nothing. + */ +function rederive() { + const out = {} + const raw = read(P.recipe) + const lines = raw.split(/\r?\n/).filter((l) => l.trim()) + const count = (re) => lines.filter((l) => re.test(l)).length + + out["recipe.actions"] = count(/"id"\s*:/) + out["recipe.actionsLede"] = out["recipe.actions"] + for (const k of KINDS) + out[`recipe.kind.${k.key}`] = count( + new RegExp(`"action"\\s*:\\s*"${k.key.replace(".", "\\.")}"`), + ) + for (const p of ["P1", "P2", "P3"]) + out[`recipe.${p}`] = count(new RegExp(`"priority"\\s*:\\s*"${p}"`)) + out["recipe.gates"] = count(/"gate"\s*:/) + out["recipe.todos"] = count(/"todo"\s*:/) + out["recipe.grpc"] = out["recipe.kind.rpc"] + out["recipe.kind.rpc.race"] + out["recipe.browser"] = + out["recipe.kind.ui.flow"] + out["recipe.kind.ui.assert"] + out["recipe.browserAgain"] = out["recipe.browser"] + out["recipe.files"] = out["recipe.kind.files.generate"] + let denials = 0 + for (const code of errCount.keys()) { + const c = count(new RegExp(`"error"\\s*:\\s*"${code}"`)) + out[`recipe.denial.${code}`] = c + denials += c + } + out["recipe.denials"] = denials + out["recipe.denialsTable"] = denials + out["recipe.actors"] = new Set( + lines + .map((l) => (l.match(/"actor"\s*:\s*"([^"]+)"/) || [])[1]) + .filter(Boolean), + ).size + const methods = new Set() + for (const m of raw.matchAll(/"method"\s*:\s*"([^"]+)"/g)) methods.add(m[1]) + out["recipe.methods"] = methods.size + out["recipe.services"] = new Set( + [...methods].map((m) => m.split("/")[0]), + ).size + + // per-section counts: split the raw text on ACT banners and count id lines. + // Both JSONL files here are written with a space after the colon, so every + // regex below allows for it — a `"key":"value"` pattern silently matches + // nothing against `"key": "value"`, which is a zero that looks like an answer. + const chunks = raw.split(/\n(?=\{"comment":\s*"─+ ACT )/) + const secCounts = [] + for (const ch of chunks) { + if (!/^\{"comment":\s*"─+ ACT /.test(ch)) continue + secCounts.push(ch.split(/\r?\n/).filter((l) => /"id"\s*:/.test(l)).length) + } + const secKinds = [] + for (const ch of chunks) { + if (!/^\{"comment":\s*"─+ ACT /.test(ch)) continue + const m = {} + for (const k of KINDS) + m[k.key] = ch + .split(/\r?\n/) + .filter((l) => + new RegExp(`"action"\\s*:\\s*"${k.key.replace(".", "\\.")}"`).test(l), + ).length + secKinds.push(m) + } + sections.forEach((s, i) => { + const key = s.label.toLowerCase().replace(/[^a-z0-9]+/g, "") + out[`recipe.section.${key}`] = secCounts[i] + for (const k of KINDS) + if (secKinds[i][k.key]) + out[`recipe.section.${key}.${k.key}`] = secKinds[i][k.key] + }) + + // B — results.json, read by regex off the raw text rather than the object tree + const rr = read(P.results) + const stats = JSON.parse( + rr.slice(rr.lastIndexOf('"stats":') + 8).replace(/}\s*$/, ""), + ) + out["journey.expected"] = stats.expected + out["journey.unexpected"] = stats.unexpected + out["journey.unexpectedHero"] = stats.unexpected + out["journey.skipped"] = stats.skipped + out["journey.skippedHero"] = stats.skipped + out["journey.flaky"] = stats.flaky + out["journey.minutes"] = (stats.duration / 60000).toFixed(1) + out["journey.when"] = stats.startTime.slice(0, 10) + out["journey.day"] = out["journey.when"] + out["journey.setupSpecs"] = ( + rr.match(/"projectName":\s*"setup"/g) || [] + ).length + out["journey.recipeSpecs"] = ( + rr.match(/"projectName":\s*"journey"/g) || [] + ).length + out["journey.recipeSpecsHero"] = out["journey.recipeSpecs"] + out["xcheck.runIds"] = (rr.match(/"title":\s*"\[[^\]"]+\]/g) || []).length + out["xcheck.recipeIds"] = out["recipe.actions"] + out["xcheck.same"] = out["recipe.actions"] + out["open.bootstrapCommit"] = git( + "log", + "-1", + "--format=%h", + "--", + rel(P.storageBoot), + ) + + // C — manifest, counted line by line + const ml = read(P.manifest) + .split(/\r?\n/) + .filter((l) => /"id"\s*:/.test(l)) + out["mut.total"] = ml.length + out["mut.totalTile"] = ml.length + out["mut.totalTile2"] = ml.length + out["mut.gaps"] = ml.filter((l) => /"gap"\s*:\s*true/.test(l)).length + out["mut.gaps2"] = out["mut.gaps"] + out["mut.gaps3"] = out["mut.gaps"] + out["mut.gapsHeading"] = out["mut.gaps"] + out["mut.caught"] = ml.length - out["mut.gaps"] + out["mut.go"] = ml.filter((l) => /"arena"\s*:\s*"go"/.test(l)).length + out["mut.vitest"] = ml.filter((l) => /"arena"\s*:\s*"vitest"/.test(l)).length + out["mut.vitest2"] = out["mut.vitest"] + out["mut.gaps.crossRef"] = ml.filter( + (l) => /"gap"\s*:\s*true/.test(l) && /"crossRef"\s*:\s*\[/.test(l), + ).length + out["mut.gaps.noWitness"] = ml.filter( + (l) => /"gap"\s*:\s*true/.test(l) && /no journey witness/i.test(l), + ).length + out["mut.gaps.proseOnly"] = + out["mut.gaps"] - out["mut.gaps.crossRef"] - out["mut.gaps.noWitness"] + out["mut.manifestDay"] = mtimeDay(P.manifest) + for (const l of ml) { + const id = (l.match(/"id"\s*:\s*"([^"]+)"/) || [])[1] + const cr = l.match(/"crossRef"\s*:\s*\[([^\]]*)\]/) + if (id && cr) + out[`mut.gap.${id}.refs`] = (cr[1].match(/"/g) || []).length / 2 + } + for (const c of clusterList) { + const rows = ml.filter((l) => + new RegExp(`"id"\\s*:\\s*"${c.key}\\.`).test(l), + ) + const g = rows.filter((l) => /"gap"\s*:\s*true/.test(l)).length + out[`mut.cluster.${c.key}.total`] = rows.length + out[`mut.cluster.${c.key}.gaps`] = g + out[`mut.cluster.${c.key}.caught`] = rows.length - g + } + const major = clusterList.filter( + (c) => out[`mut.cluster.${c.key}.gaps`] >= MAJOR_GAP, + ) + for (const c of major) { + const t = out[`mut.cluster.${c.key}.total`], + g = out[`mut.cluster.${c.key}.gaps`] + if (t === g) out[`mut.major.${c.key}.all`] = t + else { + out[`mut.major.${c.key}.gaps`] = g + out[`mut.major.${c.key}.total`] = t + } + } + out["mut.majorClusters"] = major.length + out["mut.majorClustersHeading"] = major.length + out["mut.totalTile3"] = ml.length + out["mut.maxClusterGaps"] = clusterList.reduce( + (a, c) => Math.max(a, out[`mut.cluster.${c.key}.gaps`]), + 0, + ) + out["mut.clusterGapShare"] = major.reduce( + (a, c) => a + out[`mut.cluster.${c.key}.gaps`], + 0, + ) + out["mut.clusterGapShareHeading"] = out["mut.clusterGapShare"] + out["mut.gaps4"] = out["mut.gaps"] + + // D — verify.json, counted by verdict string + const vr = read(P.verify) + for (const [v] of [["EXACT"], ["MISMATCH"], ["GAP"], ["GAP CLOSED"]]) { + const re = new RegExp(`"verdict"\\s*:\\s*"${v}"`, "g") + out[`mut.verdict.${v.replace(/\s/g, "")}`] = (vr.match(re) || []).length + } + // "GAP" also matches inside "GAP CLOSED"? No — the quote terminates it. Verify: + out["mut.verdict.GAP"] = (vr.match(/"verdict"\s*:\s*"GAP"/g) || []).length + out["mut.verdict.GAP2"] = out["mut.verdict.GAP"] + out["mut.gaps5"] = ml.filter((l) => /"gap"\s*:\s*true/.test(l)).length + out["mut.verifyRows"] = (vr.match(/"verdict"\s*:/g) || []).length + out["src.verifyRows"] = out["mut.verifyRows"] + out["src.actionLines"] = out["recipe.actions"] + out["src.bannerLines"] = + read(P.recipe) + .split(/\r?\n/) + .filter((l) => l.trim()).length - out["recipe.actions"] + out["src.actSections"] = ( + read(P.recipe).match(/^\{"comment":\s*"─+ ACT /gm) || [] + ).length + const at = (vr.match(/"at"\s*:\s*"([^"]+)"/) || [])[1] + out["mut.verifyDay"] = at.slice(0, 10) + out["mut.verifyDay2"] = out["mut.verifyDay"] + out["mut.verifyDate"] = out["mut.verifyDay"] + out["mut.mismatch.superseded"] = supersededMismatch.length + out["mut.mismatch.rows"] = ( + vr.match(/"verdict"\s*:\s*"MISMATCH"/g) || [] + ).length + out["mut.mismatch.rows2"] = out["mut.mismatch.rows"] + out["mut.mismatch.rows3"] = out["mut.mismatch.rows"] + out["mut.manifestTime"] = + fs.statSync(P.manifest).mtime.toISOString().slice(0, 19).replace("T", " ") + + "Z" + out["mut.verifyTime"] = at.slice(0, 19).replace("T", " ") + "Z" + + // E/G/H — proto + frontend, recounted with different expressions + let decls = 0 + for (const f of protoFiles) + decls += (read(f).match(/^[ \t]*rpc[ \t]+[A-Za-z0-9_]+/gm) || []).length + out["proto.declarations"] = decls + out["proto.declarationsAudit"] = decls + out["proto.declarationsAudit2"] = decls + out["proto.names"] = new Set( + protoFiles.flatMap((f) => + [...read(f).matchAll(/^[ \t]*rpc[ \t]+([A-Za-z0-9_]+)/gm)].map( + (m) => m[1], + ), + ), + ).size + out["api.uncalledNow"] = [...methodNames].filter( + (m) => !new RegExp(`\\.${lcFirst(m)}\\s*\\(`).test(frontendCorpus), + ).length + const doc = read(P.testingDoc) + out["api.uncalledDoc"] = (doc.split(/^## API-to-UI coverage$/m)[1] || "") + .split(/\r?\n/) + .filter((l) => /^\|\s*`[A-Za-z]+\.[A-Za-z]+`/.test(l)).length + const cm = doc.match(/\*\*(\w+) RPC declarations have no frontend caller\*\*/) + if (cm) { + const words = { Seven: 7, Eight: 8, Nine: 9, Six: 6, Five: 5 } + if (words[cm[1]] && words[cm[1]] !== out["api.uncalledDoc"]) + throw new Error( + `docs/testing.md says ${cm[1]} uncalled RPCs but its table lists ${out["api.uncalledDoc"]}`, + ) + } + + // F — the suite table, re-parsed with a stricter row regex + const rows = [ + ...claudeMd.matchAll( + /^\|\s*([^|]+?)\s*\|\s*([^|]+?)\s*\|\s*(\d{4}-\d{2}-\d{2})\s*\|$/gm, + ), + ] + const find = (k) => + rows.find((r) => r[1].toLowerCase().replace(/`/g, "").startsWith(k)) + const num = (s, re) => Number((String(s).match(re) || [])[1]) + const sm = find("smoke"), + mo = find("mobile"), + or = find("openreplay"), + fe = find("frontend"), + be = find("backend"), + jo = find("journey") + out["smoke.passed"] = num(sm[2], /(\d+) passed/) + out["smoke.failed"] = num(sm[2], /(\d+) failed/) + out["smoke.notrun"] = num(sm[2], /(\d+) did not run/) + out["smoke.when"] = sm[3] + out["open.smokeFailedRepeat"] = out["smoke.failed"] + out["open.smokeNotRunRepeat"] = out["smoke.notrun"] + out["open.smokeSum"] = + `${out["smoke.passed"]} + ${out["smoke.failed"]} + ${out["smoke.notrun"]}` + out["open.smokeTotal"] = + out["smoke.passed"] + out["smoke.failed"] + out["smoke.notrun"] + out["repro.smokeTotal"] = out["open.smokeTotal"] + out["repro.actions"] = out["recipe.actions"] + out["mobile.passed"] = num(mo[2], /(\d+) passed/) + out["mobile.when"] = mo[3] + out["openreplay.passed"] = num(or[2], /(\d+) passed/) + out["openreplay.skipped"] = num(or[2], /(\d+) skipped/) + out["openreplay.when"] = or[3] + out["feunits.passed"] = num(fe[2], /(\d+) passed/) + out["feunits.files"] = num(fe[1], /\((\d+) files\)/) + out["feunits.when"] = fe[3] + out["backend.serviceSpecs"] = num(be[2], /service (\d+)\//) + out["backend.serviceTotal"] = num(be[2], /service \d+\/(\d+)/) + out["backend.capability"] = num(be[2], /capability (\d+)/) + out["backend.middleware"] = num(be[2], /middleware (\d+)/) + out["backend.when"] = be[3] + if (num(jo[2], /(\d+) passed/) !== out["journey.expected"]) + throw new Error( + `.claude/CLAUDE.md claims journey ${num(jo[2], /(\d+) passed/)} but results.json recorded ${ + out["journey.expected"] + } — one of them is stale`, + ) + const cu = claudeMd + .replace(/\s+/g, " ") + .match( + /coverage: \**(\d+) of (\d+) RPC declarations have a frontend caller/, + ) + if (!cu) throw new Error("the API-to-UI sentence moved in .claude/CLAUDE.md") + out["claudemd.uncalled"] = Number(cu[2]) - Number(cu[1]) + out["claudemd.total"] = Number(cu[2]) + + // I — the two code sites, located by a different anchor + const ds = read(P.dragSpec).split(/\r?\n/) + out["open.dragLine"] = + ds.findIndex((l) => /dragging a row saves the whole new order/.test(l)) + 1 + out["open.endYLine"] = ds.findIndex((l) => /\bendY\b\s*=/.test(l)) + 1 + return out +} + +/** The finished document, checked as a string. It is not read from `OUT` — + * `OUT` still holds the PREVIOUS report and must keep holding it if any check + * below fails. `writeChecked()` compares the bytes it lands against this. */ +const back = html + +// One script block, exactly — a second close tag means data truncated the page. +const opens = (back.match(//g) || []).length +if (opens !== 1 || closes !== 1) + throw new Error( + `expected exactly one script block, found ${opens} open / ${closes} close`, + ) + +// No figure may have been escaped INTO the page as text. This is the shape a +// stamped number takes after esc() has run over it, and it is invisible to the +// data-claim scan (the escaped stamp simply leaves the checked set) — it showed +// up first as raw `` printed inside a tile. +const leaked = back.match(/<span class="num"/g) +if (leaked) + throw new Error( + `${leaked.length} stamped figure(s) were HTML-escaped into visible text — a field holding ` + + `n()'s markup was passed through esc()`, + ) + +const stamped = new Map() +for (const m of back.matchAll(/data-claim="([^"]+)"\s+data-value="([^"]*)"/g)) + stamped.set( + m[1], + m[2] + .replace(/&/g, "&") + .replace(/'/g, "'") + .replace(/"/g, '"'), + ) + +const truth = rederive() +const problems = [] +for (const [id, shown] of stamped) { + if (!(id in truth)) { + problems.push( + `${id}: rendered ${shown} but the re-derivation has no value for it`, + ) + continue + } + if (String(truth[id]) !== String(shown)) + problems.push(`${id}: rendered ${shown}, re-derived ${truth[id]}`) +} +/* Every registered claim must be re-derivable, stamped or not. A figure that + only lives inside a chip is still a figure, and "the checker had nothing to + say about it" is the shape every silent-green bug in this repo has had. */ +for (const [id, v] of CLAIMS) { + if (!(id in truth)) { + problems.push( + `${id}: built ${v} but rederive() covers it nowhere — add it or drop the claim`, + ) + continue + } + if (!stamped.has(id) && String(truth[id]) !== String(v)) + problems.push(`${id} (unstamped): built ${v}, re-derived ${truth[id]}`) +} + +if (!idsIdentical) + problems.push( + `the run report and recipe.jsonl are not the same list of ids (` + + `${missingFromRun.length} never ran, ${extraInRun.length} unknown)`, + ) + +/* ── print what was embedded, the way splice-player.mjs does ─────────────── */ +const line = (k, v) => console.log(" " + String(k).padEnd(34) + String(v)) +console.log( + `built quality-report.html — ${(back.length / 1024).toFixed(1)} KiB, ${branch} @ ${headSha}`, +) +line( + "recipe actions", + `${actions.length} in ${actCount.size} acts, ${sections.length} banner sections`, +) +line( + " by kind", + KINDS.map((k) => `${k.label} ${kindCount.get(k.key) || 0}`).join(", "), +) +line(" by priority", [...prioCount].map(([k, v]) => `${k} ${v}`).join(", ")) +line( + " driver", + `${grpcActions} gRPC, ${uiActions} browser, ${fileActions} fixture`, +) +line(" gates / todos", `${gateCount} / ${todoCount}`) +line( + " expected refusals", + `${denialTotal} across ${errCount.size} status codes`, +) +line( + " reaches", + `${recipeCovered.length} of ${declarations.length} declared RPCs, ${recipeServices.size} services`, +) +line( + "journey run", + `${results.stats.expected} passed, ${results.stats.unexpected} failed, ${results.stats.skipped} skipped (${runDay})`, +) +line( + " spec ids == recipe ids", + idsIdentical ? `yes, all ${runIds.length}, same order` : "NO", +) +line( + "mutations", + `${manifest.length} entries — ${caught.length} with a witness, ${gaps.length} gaps`, +) +line( + " verdicts", + [...verdictCount].map(([k, v]) => `${k} ${v}`).join(", ") + ` (${verifyDay})`, +) +line( + " gap clusters", + majorGapClusters.map((c) => `${c.key} ${c.gaps}/${c.total}`).join(", ") || + "none", +) +line( + "API-to-UI", + `${uncalledNow.length} of ${methodNames.size} method names uncalled; ${declarations.length} declarations`, +) +line("suite table rows", suiteRows.map((r) => r.suite.split(" ")[0]).join(", ")) +line("figures stamped", `${stamped.size} (${CLAIMS.size} claims registered)`) + +if (problems.length) { + console.error(`\n✗ ${problems.length} figure(s) disagree with their source:`) + for (const p of problems) console.error(" " + p) + console.error( + `\n✗ NOTHING WAS WRITTEN. ${rel(OUT)} still holds the previous report, ` + + `byte for byte — fix the source (or the figure) and run this again.`, + ) + process.exit(1) +} + +/** + * Land the checked bytes, or land nothing at all. + * + * The temp file goes in the SAME directory: a rename across filesystems is a + * copy, and a copy is precisely the interruptible write this exists to avoid. + * fsync before the rename, so the rename cannot publish a name pointing at + * contents still sitting in a buffer. Then read the destination back and + * require it to equal the string every check above ran against — otherwise + * "verified" and "on disk" are two different documents and only one of them + * was ever inspected. + * + * The rename retries on EPERM: renames on this repo's 9p bind mount + * intermittently refuse with nothing holding the file (CLAUDE.md, container + * trap 5) and succeed a moment later. Every failure path removes the temp, so + * a refused build leaves the directory exactly as it found it. + */ +function writeChecked(dest, text) { + const tmp = path.join( + path.dirname(dest), + `.${path.basename(dest)}.tmp-${process.pid}`, + ) + const sleep = (ms) => + Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms) + try { + const fd = fs.openSync(tmp, "w") + try { + fs.writeFileSync(fd, text) + fs.fsyncSync(fd) + } finally { + fs.closeSync(fd) + } + for (let attempt = 1; ; attempt++) { + try { + fs.renameSync(tmp, dest) + break + } catch (e) { + if (attempt >= 3 || e.code !== "EPERM") throw e + sleep(250) + } + } + } catch (e) { + try { + fs.unlinkSync(tmp) + } catch {} + throw e + } + const landed = read(dest) + if (landed !== text) + throw new Error( + `${rel(dest)} does not hold the bytes that were checked ` + + `(${landed.length} chars on disk vs ${text.length} verified) — do not trust it`, + ) +} + +writeChecked(OUT, html) + +console.log( + `\n✓ ${CLAIMS.size} figures re-derived from their sources by a second code path and matched ` + + `(${stamped.size} of them also stamped in the HTML as data-claim/data-value)`, +) +console.log(`✓ checked first, then written: ${rel(OUT)}`) diff --git a/.claude/skills/hackathon-e2e/scripts/check-reconnect.sh b/.claude/skills/hackathon-e2e/scripts/check-reconnect.sh new file mode 100644 index 00000000..af237aa2 --- /dev/null +++ b/.claude/skills/hackathon-e2e/scripts/check-reconnect.sh @@ -0,0 +1,145 @@ +#!/usr/bin/env bash +# THE regression test for "the frontend cannot see the seed data". +# +# Restart the backend under a running :8081, then assert the browse page still +# lists its events. +# +# Why this is a test of its own rather than a Playwright spec: the subject is a +# process-level fact (one module-scope gRPC channel in the built server, outliving +# a backend restart), it needs to STOP a service the rest of the suite depends on, +# and the assertion is a count in HTML — no browser adds anything. Running it +# inside smoke would perturb 140 tests to check one. +# +# WHAT WENT WRONG, so the shape of the check is not mysterious. On 2026-08-13 the +# browse page rendered ZERO events while grpcurl returned eight from the same +# database. Two independent causes, both fixed, both regressions worth catching: +# +# 1. lib/server/grpc/client.ts creates ONE channel at module load. grpc-js +# reconnects on its own but on a backoff that doubles to a 120s cap, and +# every RPC issued while it waits fails immediately. Measured with a 7-minute +# outage: the page was still wrong 51 SECONDS after the backend was +# demonstrably healthy, and the lag grows with the outage toward that cap. +# The channel now caps the backoff at 2s; the same measurement is 0s. +# 2. The page's load turned any error into `hackathons: []`, which renders "No +# hackathons have been published yet." — so "the database is empty" and "I +# cannot reach the backend" were the same page. It now carries +# `listUnavailable` and says which. +# +# The check asserts BOTH: the count comes back, and while the backend is down the +# page says unavailable rather than empty. The second half is the one that would +# have saved the hours — without it, half of this script's assertions pass on a +# page that is lying. +# +# Usage: check-reconnect.sh (needs a stack up, seeded, and :8081 serving) +set -euo pipefail +trap 'echo "check-reconnect.sh: aborted at line $LINENO (status $?)" >&2' ERR +HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +source "$HERE/lib.sh" +ensure_toolchain "${BASH_SOURCE[0]}" "$@" + +PC_SOCKET_FILE="$ROOT_DIR/tools/deploy/process-compose/.socket-path-test-services" +SOCK="$(cat "$PC_SOCKET_FILE" 2>/dev/null || true)" +BROWSE="$FRONTEND_URL/hackathon" +FAILED=0 + +pc() { process-compose --unix-socket "$SOCK" "$@" >/dev/null 2>&1; } + +# One per listed event. +page_html() { curl -fsS --max-time 15 "$BROWSE" 2>/dev/null || true; } +page_count() { + page_html | grep -oE 'href="/hackathon/[0-9a-f-]{36}"' | sort -u | wc -l | tr -d ' ' +} +# jq, not a grep for `"id"`. Every hackathon in this response also carries +# nested ids (capability modifiers, creator, …), so a naive count reported 16 for +# 8 events — and an indentation-anchored grep reported 0, which this script's own +# guard would have read as "nothing to test" and exited cleanly having tested +# nothing. Ask the structure. +rpc_count() { + grpcurl -plaintext -d '{"visibility_filter":1}' "$GRPC_ADDR" \ + hackathon.HackathonService/List 2>/dev/null | + jq -r '.hackathons | length' 2>/dev/null || echo 0 +} + +fail() { + echo " ✗ $1" >&2 + FAILED=1 +} + +if [ -z "$SOCK" ] || [ ! -S "$SOCK" ]; then + echo "error: no process-compose socket — start the stack first (scripts/up.sh)." >&2 + exit 1 +fi + +echo "==> Baseline" +BEFORE_RPC="$(rpc_count)" +BEFORE_PAGE="$(page_count)" +echo " gRPC lists $BEFORE_RPC public events; the browse page renders $BEFORE_PAGE" +if [ "$BEFORE_RPC" -eq 0 ]; then + echo "error: no public hackathons to check against — seed the instance first." >&2 + echo " (An assertion whose subject is absent verifies nothing.)" >&2 + exit 1 +fi +[ "$BEFORE_PAGE" -eq "$BEFORE_RPC" ] || + fail "before any restart the page already disagrees with gRPC ($BEFORE_PAGE vs $BEFORE_RPC)" + +echo "==> Stopping the backend" +pc process stop backend +for _ in $(seq 1 15); do + [ "$(rpc_count)" -eq 0 ] && break + sleep 1 +done + +echo "==> While the backend is down the page must say UNAVAILABLE, not EMPTY" +DOWN_HTML="$(page_html)" +if echo "$DOWN_HTML" | grep -q 'data-testid="listUnavailable"'; then + echo " ✓ the page reports the outage" +else + if echo "$DOWN_HTML" | grep -q "No hackathons have been published yet"; then + fail "the page claims an EMPTY PLATFORM while the backend is down — this is the bug" + elif [ -z "$DOWN_HTML" ]; then + fail "the page did not render at all while the backend was down (it used to degrade)" + else + fail "the page neither reported the outage nor rendered the empty state" + fi +fi + +echo "==> Starting the backend" +pc process start backend +S=$(date +%s) +while :; do + [ "$(rpc_count)" -gt 0 ] && break + if [ $(($(date +%s) - S)) -gt 600 ]; then + echo "error: the backend did not come back within 600s — not a channel problem." >&2 + exit 1 + fi + sleep 5 +done +HEALTHY_AT=$(date +%s) +echo " gRPC answers again after $((HEALTHY_AT - S))s" + +# THE assertion. The lag allowed here is a channel reconnect, not a boot: the +# capped backoff is 2s, so 60s is ~30x headroom and still an order of magnitude +# below the 120s default that caused the incident. If this ever needs raising, +# the channel options in client.ts regressed — raise those, not this. +echo "==> The page must agree with gRPC again" +LAG=-1 +for _ in $(seq 1 20); do + if [ "$(page_count)" -eq "$(rpc_count)" ] && [ "$(page_count)" -gt 0 ]; then + LAG=$(($(date +%s) - HEALTHY_AT)) + break + fi + sleep 3 +done +if [ "$LAG" -lt 0 ]; then + fail "the page still disagrees with gRPC 60s after the backend was healthy (page=$(page_count) grpc=$(rpc_count)) — the channel did not reconnect" +else + echo " ✓ the page recovered ${LAG}s after the backend was healthy" +fi + +if [ "$FAILED" -ne 0 ]; then + echo "" + echo "FAILED — a backend restart is visible to users of :8081." >&2 + exit 1 +fi +echo "" +echo "PASSED — a backend restart heals itself, and an outage never reads as an empty platform." diff --git a/.claude/skills/hackathon-e2e/scripts/embed-run-report.mjs b/.claude/skills/hackathon-e2e/scripts/embed-run-report.mjs new file mode 100644 index 00000000..0a8730dc --- /dev/null +++ b/.claude/skills/hackathon-e2e/scripts/embed-run-report.mjs @@ -0,0 +1,160 @@ +#!/usr/bin/env node +// Bake a real journey run into recipe-player.html, so its `run outcome` colour +// mode answers "did it pass" on open instead of "nobody has told me". +// +// bash scripts/run.sh journey # writes .artifacts/results.json +// node scripts/embed-run-report.mjs # splices it in, reduced +// node scripts/embed-run-report.mjs +// +// Note what is NOT needed here: `--reporter=json > report.json`. The json +// reporter is already in playwright.config.ts (it writes +// .artifacts/results.json on every run), and redirecting stdout in this +// container captures the Nix/devenv/quitsh banner ahead of the JSON, so the +// file does not parse. run.sh does forward a --reporter flag if you pass one, +// but the file on disk is the thing to read. +// +// REDUCED, on purpose. The player joins on exactly three things — the action +// id, the outcome bucket and how long it took — while a full report carries +// stdout, attachments, stack frames and error snippets. Those snippets are also +// the hazard: an inline script block ends at the first LITERAL close tag even +// inside a JSON string, so every ` /"id"\s*:/.test(l)) + .map((l) => JSON.parse(l).id), +) + +// recipe.spec.ts titles every test `[] `, which is the join key +const BUCKET = { + passed: "passed", + expected: "passed", + failed: "failed", + unexpected: "failed", + timedOut: "failed", + interrupted: "failed", + skipped: "skipped", + flaky: "flaky", +} +const status = {} +const totals = { passed: 0, failed: 0, skipped: 0, flaky: 0, other: 0 } +let specs = 0 +let unmatched = 0 +let maxEnd = 0 +;(function walk(node) { + if (!node || typeof node !== "object") return + for (const sp of node.specs || []) { + specs++ + const m = /^\[([^\]]+)\]/.exec(String(sp.title || "")) + if (!m) { + unmatched++ + continue + } + const test = (sp.tests || [])[0] || {} + const results = test.results || [] + const last = results[results.length - 1] || {} + const s = String(last.status || test.status || "unknown") + const dur = Math.round(+last.duration || 0) + if (!ids.has(m[1])) { + unmatched++ + continue + } + status[m[1]] = [s, dur] + const b = BUCKET[s] || "other" + totals[b]++ + const start = Date.parse(last.startTime || "") + if (!isNaN(start)) maxEnd = Math.max(maxEnd, start + dur) + } + for (const s of node.suites || []) walk(s) +})(report) + +const matched = Object.keys(status).length +if (!matched) + throw new Error( + `no spec title in ${reportPath} starts with a recipe action id`, + ) + +const startedAt = Date.parse(report.stats?.startTime || "") || 0 +const payload = { + kind: "hackagon-journey-run", + suite: "journey", + // the run's own clock, not this script's — a snapshot must be dated by when + // it was PRODUCED, or its date says nothing about the code it describes + generatedAt: new Date(startedAt || Date.now()).toISOString(), + recipeActions: ids.size, + specs, + durationMs: Math.round( + report.stats?.duration || (maxEnd && startedAt ? maxEnd - startedAt : 0), + ), + totals, + status, +} + +const json = JSON.stringify(payload) +const escaped = json.split("</").join("<\\/") + +const html = fs.readFileSync(playerPath, "utf8") +const open = '<script id="run-report" type="application/json">' +const start = html.indexOf(open) +if (start < 0) + throw new Error("run-report open marker not found in recipe-player.html") +const bodyStart = start + open.length +const close = "</" + "script>" +const end = html.indexOf(close, bodyStart) +if (end < 0) throw new Error("run-report close marker not found") +fs.writeFileSync( + playerPath, + html.slice(0, bodyStart) + "\n" + escaped + "\n" + html.slice(end), +) + +// read it back and prove it parses in place, and that the file still has the +// three close tags it is supposed to have — a splice nobody verified is how the +// player once showed 10 actions of 274 +const back = fs.readFileSync(playerPath, "utf8") +const s2 = back.indexOf(open) + open.length +const e2 = back.indexOf(close, s2) +const round = JSON.parse(back.slice(s2, e2).trim().split("<\\/").join("</")) +const tags = back.split(close).length - 1 +if (Object.keys(round.status).length !== matched) + throw new Error( + `embedded ${Object.keys(round.status).length} entries, expected ${matched}`, + ) +if (tags !== 3) + throw new Error(`expected 3 literal close tags in the player, found ${tags}`) + +const pct = (100 * escaped.length) / raw.length +console.log( + `embedded ${matched} of ${ids.size} actions from ${path.basename(reportPath)} — ` + + `${totals.passed} passed / ${totals.failed} failed / ${totals.skipped} skipped` + + (totals.flaky ? ` / ${totals.flaky} flaky` : "") + + (unmatched ? ` (${unmatched} specs not recipe actions)` : ""), +) +console.log( + `reduced ${(raw.length / 1024).toFixed(1)} KiB → ${(escaped.length / 1024).toFixed(1)} KiB ` + + `(${pct.toFixed(1)}% of the report) · run of ${payload.generatedAt} · ` + + `${Math.round(payload.durationMs / 1000)}s · 3 literal close tags`, +) diff --git a/.claude/skills/hackathon-e2e/scripts/mutate.sh b/.claude/skills/hackathon-e2e/scripts/mutate.sh new file mode 100644 index 00000000..c5a66506 --- /dev/null +++ b/.claude/skills/hackathon-e2e/scripts/mutate.sh @@ -0,0 +1,76 @@ +#!/usr/bin/env bash +# Mutation testing: break the product on purpose and check the suite notices. +# +# mutate.sh list # the manifest, one line per mutation +# mutate.sh run # every FAST mutation (go + vitest) +# mutate.sh run cap.ungoverned-flat # one, by id +# mutate.sh run cap # every id under the `cap.` prefix +# mutate.sh run --tier all # includes the e2e tier (minutes each) +# mutate.sh run --arena go +# mutate.sh run --record # print the reds instead of judging them +# mutate.sh restore # undo a run that was killed outright +# +# A mutation that produces NO REDS fails the run. That is the whole point: it +# means nothing in the suite is holding that property. +# +# Two things this script is responsible for that the runner cannot be: +# +# 1. A SECOND, INDEPENDENT restore path. The runner journals every edit before +# making it and restores on exit and on signal; this trap restores from the +# same journal even if node dies in a way that runs none of its handlers. +# A mutation left in the tree that then gets committed is the worst outcome +# this tool can produce, so it gets two locks rather than one. +# 2. Keeping `nix develop` out of the loop. Every other script here calls +# ensure_toolchain, which re-enters the dev shell — a repo-wide mutex, +# ~5s unopposed and serializing under contention (container trap 4). +# devenv's profile has go, node and pnpm already and costs nothing, so the +# fast tier spends its seconds on tests rather than on shell entries. +set -euo pipefail +HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +SKILL_DIR="$(dirname "$HERE")" +ROOT_DIR="$(cd "$SKILL_DIR/../../.." && pwd)" +RUNNER="$SKILL_DIR/mutations/run.mjs" +JOURNAL="$SKILL_DIR/mutations/.state/journal.json" + +DEVENV_BIN="$ROOT_DIR/.devenv/profile/bin" +if [ -d "$DEVENV_BIN" ]; then + export PATH="$DEVENV_BIN:$PATH" +fi + +if ! command -v node >/dev/null 2>&1; then + echo "error: node is not on PATH and $DEVENV_BIN does not exist." >&2 + echo " Run this inside the dev container (devcontainer-up/scripts/mutate.sh)," >&2 + echo " or enter the Nix dev shell first." >&2 + exit 1 +fi + +# The belt to the runner's braces. `[ -s ]` rather than `[ -f ]`: the runner +# writes an empty array when it has nothing outstanding, and restoring from an +# empty journal would print a scary message about a run that ended cleanly. +on_exit() { + if [ -s "$JOURNAL" ] && ! grep -q '^\[\]$' "$JOURNAL" 2>/dev/null; then + echo + echo "!! mutations were still applied when this script exited — restoring" >&2 + node "$RUNNER" restore || true + fi + # Verified, not assumed. Scoped to components/, which is the only tree the + # runner may touch; the repo-wide check can never pass in this container + # (three git-lfs pointer files read as permanently modified — trap 4). + local left + left="$(git -C "$ROOT_DIR" status --porcelain -- components/ || true)" + if [ -n "$left" ]; then + echo >&2 + echo "!! components/ IS NOT CLEAN after the run:" >&2 + echo "$left" >&2 + echo "!! Do not commit. Inspect, then: git -C '$ROOT_DIR' checkout -- components/" >&2 + exit 1 + fi +} +trap on_exit EXIT + +# NOT `exec`. `exec` replaces this shell with node, and a process that no longer +# exists runs no EXIT trap — the second restore path above would have been +# decoration. Run it as a child, keep its status, and let the trap fire. +STATUS=0 +node "$RUNNER" "$@" || STATUS=$? +exit "$STATUS" diff --git a/.claude/skills/hackathon-e2e/scripts/prod-frontend.sh b/.claude/skills/hackathon-e2e/scripts/prod-frontend.sh index 05d167ad..792c5265 100644 --- a/.claude/skills/hackathon-e2e/scripts/prod-frontend.sh +++ b/.claude/skills/hackathon-e2e/scripts/prod-frontend.sh @@ -31,7 +31,7 @@ # this container localhost is ::1. # AUTH_URL must accompany ORIGIN, or login completes and then does nothing. # -# :8081 rather than :8082 because Keycloak's hackagon-dev client only allows +# :8081 rather than :8082 because Keycloak's hackagon-frontend client only allows # redirect URIs on 8081; moving the app dies at login with # "Invalid parameter: redirect_uri". :8082 belongs to the cloudflare-tunnel # skill's own built server, which is why everything here is scoped to servers @@ -55,7 +55,7 @@ FRONTEND_DIR="$ROOT_DIR/components/frontend" ENTRY="build/service/index.js" PIDFILE="$ROOT_DIR/.output/run/e2e-prod-frontend.pid" LOG="$ROOT_DIR/.output/run/e2e-prod-frontend.log" -BUILD_LOG="$ROOT_DIR/.output/run/e2e-prod-frontend-build.log" +# The build log belongs to the build, which is shared — see FRONTEND_BUILD below. # Written by `just deploy::up` (tools/deploy/process-compose/justfile); holds # the path of the process-compose control socket. PC_SOCKET_FILE="$ROOT_DIR/tools/deploy/process-compose/.socket-path-test-services" @@ -118,6 +118,25 @@ resolve_pid() { return 0 } +# Put process-compose's `frontend` (vite) DOWN and keep it down. +# +# This is not tidiness — an un-stopped vite next to our server on :8081 is the +# single most expensive failure mode this container has. vite cannot bind, exits +# 1, and `availability.restart` sends it round again; each round is a full +# `just develop` = `nix develop`, which takes the repo-wide fetch lock on +# `git+file:///workspaces/hackagon`. Entering that shell is ~5 s unopposed +# (re-measured 2026-08-14) and serializes everything else that wants the lock, +# and a stack found in this state had 54 restarts in 50 minutes — a lock +# acquisition every ~55 s, forever. Everything else that enters the shell then queues behind it: the +# backend's own start command is `just develop just run`, and its readiness +# budget is spent WAITING FOR NIX rather than on the server. When the budget +# runs out process-compose SIGTERMs it, the Go server shuts down gracefully, +# exit code 0 — which `restart: on_failure` does not consider a failure, so the +# backend stays down and everything downstream reads as connection refused. +# +# It is invisible from `process list`, which reported `frontend Running Ready` +# throughout: the readiness probe is `curl http://localhost:8081` and OUR server +# was answering it. The probe measures the PORT, not the PROCESS. stop_vite() { local sock sock="$(cat "$PC_SOCKET_FILE" 2>/dev/null || true)" @@ -158,13 +177,18 @@ stop() { # vite served source; the build is a snapshot, so it has to be rebuilt when the # source moved under it. Skipping this is how a suite silently tests yesterday's # frontend and reports green. +# +# Both the question and the build now live in .claude/skills/lib/frontend-build.sh, +# because this script is not the only caller: cloudflare-tunnel/prod-serve.sh +# builds and serves the SAME build/service tree on :8082. Two concurrent +# `pnpm build`s into one output directory is not a theoretical race — it +# corrupted that tree three times in one day (`Unexpected end of JSON input`, +# then a missing build/service/server/index.js at boot). The helper holds an +# exclusive lock and swaps a COMPLETE tree into place; nothing here needs to +# know that, which is the point. +FRONTEND_BUILD="$ROOT_DIR/.claude/skills/lib/frontend-build.sh" needs_build() { - [ -f "$FRONTEND_DIR/$ENTRY" ] || return 0 - local newer - newer="$(cd "$FRONTEND_DIR" && - find src static package.json pnpm-lock.yaml svelte.config.js vite.config.ts \ - -newer "$ENTRY" -print -quit 2>/dev/null || true)" - [ -n "$newer" ] + bash "$FRONTEND_BUILD" stale } launch() { @@ -196,13 +220,12 @@ start() { mkdir -p "$(dirname "$PIDFILE")" stop - if needs_build; then - echo "==> Building the frontend (build/ is missing or older than src/)..." - if ! (cd "$FRONTEND_DIR" && pnpm build) >"$BUILD_LOG" 2>&1; then - echo "error: pnpm build failed — see $BUILD_LOG" >&2 - tail -30 "$BUILD_LOG" >&2 - return 1 - fi + # `if-stale` re-asks the question INSIDE the lock, so two harnesses starting at + # once produce one build and the loser serves it rather than rebuilding over + # the winner. Do not hoist the staleness check back out here. + if ! bash "$FRONTEND_BUILD" if-stale; then + echo "error: the frontend build failed — see $ROOT_DIR/.output/run/frontend-build.log" >&2 + return 1 fi for attempt in 1 2 3; do @@ -241,6 +264,15 @@ ensure) # and current, is left alone. if ours_is_up && serving && ! needs_build; then echo "==> The built frontend already serves :$PORT — leaving it alone." + # "Leaving it alone" is about OUR server, never about vite. This branch + # used to return without touching process-compose at all, and that is the + # whole of how the crash loop documented above survived: `just deploy::up` + # starts vite on every boot, our server already holds :8081 whenever a + # previous run left one up (the common case — nothing stops it between + # runs), vite therefore exits 1 and is restarted forever, and this fast + # path was the one place that would have stopped it. `stop_vite` is + # idempotent and costs one socket call, so it is unconditional now. + stop_vite else start "${1:-}" fi diff --git a/.claude/skills/hackathon-e2e/scripts/run.sh b/.claude/skills/hackathon-e2e/scripts/run.sh index ed346649..0f80705d 100644 --- a/.claude/skills/hackathon-e2e/scripts/run.sh +++ b/.claude/skills/hackathon-e2e/scripts/run.sh @@ -18,6 +18,15 @@ # --grep <p> filter tests by title # --until-act <n> journey only: play the story up to act <n> and leave the # stack frozen in that state for inspection (1..8) +# --reporter=<r> passed straight to Playwright, as is anything after `--` +# +# On reporters: you almost certainly do not need one. playwright.config.ts +# already runs the json reporter and writes .artifacts/results.json on EVERY +# run, which is what scripts/embed-run-report.mjs reads. Do not do +# `run.sh journey --reporter=json > report.json`: everything in this container +# prints Nix/devenv/quitsh banners to stdout ahead of the test output, so the +# redirected file does not parse. If you want Playwright to write a second copy +# itself, set PLAYWRIGHT_JSON_OUTPUT_NAME and pass --reporter=json. set -euo pipefail HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" source "$HERE/lib.sh" @@ -27,6 +36,10 @@ SUITE="smoke" RESET=1 HEADED=0 GREP="" +# extra flags forwarded verbatim to `playwright test`. --reporter used to hit +# the catch-all below and exit 2, so the documented "run.sh journey +# --reporter=json" failed before it started. +PW_EXTRA=() while [ $# -gt 0 ]; do case "$1" in @@ -41,8 +54,18 @@ while [ $# -gt 0 ]; do shift export JOURNEY_UNTIL_ACT="${1:?--until-act needs an act number (1..8)}" ;; + --reporter=*) PW_EXTRA+=("$1") ;; + --reporter) + shift + PW_EXTRA+=("--reporter=${1:?--reporter needs a value}") + ;; + --) + shift + PW_EXTRA+=("$@") + break + ;; -h | --help) - sed -n '2,20p' "${BASH_SOURCE[0]}" | sed 's/^# \{0,1\}//' + sed -n '2,30p' "${BASH_SOURCE[0]}" | sed 's/^# \{0,1\}//' exit 0 ;; *) @@ -59,6 +82,7 @@ if [ "$SUITE" = "all" ]; then args=() [ "$HEADED" -eq 1 ] && args+=(--headed) [ -n "$GREP" ] && args+=(--grep "$GREP") + args+=("${PW_EXTRA[@]+"${PW_EXTRA[@]}"}") bash "${BASH_SOURCE[0]}" smoke "${args[@]+"${args[@]}"}" bash "${BASH_SOURCE[0]}" journey "${args[@]+"${args[@]}"}" exit 0 @@ -247,6 +271,7 @@ pnpm exec playwright install --with-deps firefox 2>/dev/null || PW_ARGS=(test --project="$SUITE") [ "$HEADED" -eq 1 ] && PW_ARGS+=(--headed) [ -n "$GREP" ] && PW_ARGS+=(--grep "$GREP") +PW_ARGS+=("${PW_EXTRA[@]+"${PW_EXTRA[@]}"}") # Inside the Nix dev shell, ldd is Nix's glibc ldd whose linker does not # search /usr/lib — Playwright's host validation then reports every system diff --git a/.claude/skills/hackathon-e2e/scripts/wait-ready.sh b/.claude/skills/hackathon-e2e/scripts/wait-ready.sh index 5fa21022..4bd93950 100644 --- a/.claude/skills/hackathon-e2e/scripts/wait-ready.sh +++ b/.claude/skills/hackathon-e2e/scripts/wait-ready.sh @@ -8,6 +8,62 @@ ensure_toolchain "${BASH_SOURCE[0]}" "$@" TIMEOUT="${E2E_READY_TIMEOUT:-300}" +# A crash-looping service is not a "ready" stack, and process-compose will not +# tell you unasked. +# +# On 2026-08-13 this stack ran for 50 minutes with its `frontend` process at 54 +# restarts — vite exiting 1 on "Port 8081 is already in use" roughly once a +# minute, each round a full `just develop`, i.e. one acquisition of the repo-wide +# `git+file:///workspaces/hackagon` fetch lock. That starved every other startup +# on the machine; the backend's own start command is `just develop just run` and +# its readiness budget is spent WAITING FOR NIX. When the budget ran out +# process-compose SIGTERMed a perfectly healthy backend, which exited 0, which +# `restart: on_failure` did not treat as a failure — so it stayed down and the +# suite reported connection-refused failures at four different points in four +# runs. +# +# Nothing reported any of it. `process list` said `frontend Running Ready` +# because the readiness probe is a GET of :8081 and the OTHER server was +# answering — a probe on a PORT cannot say which PROCESS holds it. The RESTARTS +# column said 54 the whole time and no one read it. This reads it. +# +# A warning rather than a hard failure: a restart or two during boot is normal +# (`restart: on_failure` retrying a service whose dependency was a second late), +# and refusing to run the suite over that would be its own kind of flake. What is +# never normal is a number that keeps climbing, so print the count AND the exit +# code, which together name the cause. +report_restarts() { + local sock pc line name restarts exitcode noisy=0 + sock="$(cat "$ROOT_DIR/tools/deploy/process-compose/.socket-path-test-services" 2>/dev/null || true)" + [ -n "$sock" ] && [ -S "$sock" ] || return 0 + command -v process-compose >/dev/null 2>&1 || return 0 + pc="$(process-compose --unix-socket "$sock" process list -o wide 2>/dev/null || true)" + [ -n "$pc" ] || return 0 + while read -r line; do + name="$(echo "$line" | awk '{print $2}')" + # RESTARTS and EXITCODE are the LAST TWO columns, counted from the end on + # purpose: HEALTH is "Not Ready" — TWO whitespace-separated words — for every + # service that is starting up, so fixed field numbers ($6/$7) read the health + # text as the restart count on exactly the rows worth reading. Counting from + # NF is stable across both widths, and "Disabled" rows (`- -`) too. + restarts="$(echo "$line" | awk '{print $(NF - 1)}')" + exitcode="$(echo "$line" | awk '{print $NF}')" + case "$restarts" in '' | *[!0-9]*) continue ;; esac + if [ "$restarts" -ge 3 ]; then + echo " ⚠ $name has restarted $restarts times (last exit code $exitcode)." >&2 + noisy=1 + fi + done <<EOF +$(echo "$pc" | awk 'NR>1 && NF>=7') +EOF + if [ "$noisy" -eq 1 ]; then + echo " ⚠ A service that keeps restarting is a service whose every attempt" >&2 + echo " re-enters the Nix dev shell and takes the repo-wide fetch lock," >&2 + echo " which starves the startup of everything else. Check its log under" >&2 + echo " .output/run/process-compose/ before trusting this run's results." >&2 + fi +} + echo "==> Waiting for the stack to be ready (timeout ${TIMEOUT}s per service)..." wait_for "postgres" "$TIMEOUT" pg_isready -h 127.0.0.1 -p 5432 -U postgres wait_for "keycloak" "$TIMEOUT" curl -fsS \ @@ -36,4 +92,7 @@ if ! wait_for "frontend" "$TIMEOUT" curl -fsS --max-time 10 "$FRONTEND_URL"; the echo " cd components/frontend && just serve" >&2 exit 1 fi +# After everything answers, not before: a restart count taken mid-boot is mostly +# noise, and the question this answers is "is what I am about to test stable". +report_restarts echo "==> Stack is ready." diff --git a/.claude/skills/hackathon-e2e/tests/smoke/02-login.spec.ts b/.claude/skills/hackathon-e2e/tests/smoke/02-login.spec.ts index 05e95281..240859a9 100644 --- a/.claude/skills/hackathon-e2e/tests/smoke/02-login.spec.ts +++ b/.claude/skills/hackathon-e2e/tests/smoke/02-login.spec.ts @@ -31,7 +31,24 @@ for (const persona of ALL_PERSONAS) { ).toBeVisible() }) - test(`the public shell links to the hackathon list`, async ({ page }) => { + // ⚠ Re-specified 2026-08-14 for develop's `143a9612`. The header nav is TWO + // entries now — Dashboard and "All Hackathons" — and About has been dropped + // from it: the page stays, the entry does not. "Hackathons" was renamed + // because the wordmark to its left already reads Hackathons, so the bare + // noun appeared twice in one row, once as the platform and once as a page + // inside it. + // + // The PROPERTY these two tests were written for is untouched and is still + // what they assert: one meaning per entry, and the same set of entries on + // both sides of the login. Only the membership of that set moved. + const NAV_ENTRIES = [ + ["Dashboard", /\/dashboard$/], + ["All Hackathons", /\/hackathon$/], + ] as const + + test(`the public shell links to the hackathon list and the dashboard`, async ({ + page, + }) => { await page.goto("/") // The footer is a <nav> too — scope to the header. const nav = page.locator("header").getByRole("navigation").first() @@ -39,21 +56,41 @@ for (const persona of ALL_PERSONAS) { // There is no separate "Home" entry: the logo is the platform instance // and doubles as home, which is why it was dropped rather than added. // - // "All Hackathons", not "Hackathons": the bar's own wordmark already reads - // "Hackathons", and the scope is what tells this entry apart from the - // Dashboard beside it. Same label signed in or out — a word that renames - // itself with your session is the same bug as one that re-points itself. - await expect( - nav.getByRole("link", { name: "All Hackathons", exact: true }), - ).toHaveAttribute("href", "/hackathon") + // Your own events are a SEPARATE entry from everyone's, not the same word + // pointing somewhere else once you sign in: "Hackathons" named a list and + // reached the dashboard, so the browse page was unreachable from the + // chrome for exactly the people who had an account. The label states the + // scope now, which is the third fix to the same confusion. + for (const [label, href] of NAV_ENTRIES) { + await expect( + nav.getByRole("link", { name: label, exact: true }), + `the header nav has no "${label}" entry`, + ).toHaveAttribute("href", href) + } - // Your own events are a SEPARATE entry, not the same word pointing - // somewhere else once you sign in: "Hackathons" named a list and reached - // the dashboard, so the browse page was unreachable from the chrome for - // exactly the people who had an account. + // About left the header and did NOT leave the product: it is a SitePage + // and the footer is its way in. Asserted positively, because "no About in + // the header" is satisfied just as well by an About that was deleted, and + // an absence with no positive control agrees with everything + // (.claude/CLAUDE.md, "Ways a test reported green while proving nothing"). + // + // The absence half is matched by PREFIX, the presence half by the exact + // name. The link is called "About Hackagon" now — it names its own + // destination, so it cannot be confused with the SDSC column's + // datascience.ch/about — and an exact `"About"` here would have gone on + // reporting zero header entries whatever the header did with the new + // spelling. Exactly the shape of a check that agrees with everything. + await expect( + nav.getByRole("link", { name: /^About/ }), + "About is deliberately not a header entry any more", + ).toHaveCount(0) await expect( - nav.getByRole("link", { name: "Dashboard", exact: true }), - ).toHaveAttribute("href", /\/dashboard$/) + page + .locator("footer") + .getByRole("navigation", { name: "Platform" }) + .getByRole("link", { name: "About Hackagon", exact: true }), + "…but it must still be reachable, and the footer is now the only way in", + ).toHaveAttribute("href", "/about") // The logo goes home for everyone, signed in or not. await expect( @@ -65,60 +102,16 @@ for (const persona of ALL_PERSONAS) { await page.goto("/dashboard") const nav = page.locator("header").getByRole("navigation").first() - // Re-specified: About used to be hidden inside the app shell as a - // "marketing link", so the nav had three entries on the way in and two - // once you arrived — it changed shape under you. Every entry it carries is - // present on every page now, which is the whole point of a top nav. - for (const [label, href] of [ - ["Dashboard", /\/dashboard$/], - ["All Hackathons", /\/hackathon$/], - ] as const) { + // The original point, unchanged: About used to be hidden inside the app + // shell as a "marketing link", so the nav had three entries on the way in + // and two once you arrived — it changed shape under you. It is two + // entries on both sides now, and this is what says they stay the same + // two. + for (const [label, href] of NAV_ENTRIES) { await expect( nav.getByRole("link", { name: label, exact: true }), ).toHaveAttribute("href", href) } - - // And the bar carries NOTHING else: the two entries people use daily read - // as a pair to tell apart, not as items in a list. This is the assertion - // that About left the navbar for good — a count, so a third entry added - // later has to argue for itself here first. - await expect(nav.getByRole("link")).toHaveCount(2) - }) - - test(`About left the navbar but is still reachable from the footer`, async ({ - page, - }) => { - // Re-specified rather than deleted (the nav used to assert About WAS in - // the bar). The page is CMS-backed and organiser-editable, so what had to - // be re-checked is not that the link is gone but that removing it stranded - // nothing: the footer is the inbound link, and AppShell mounts that footer - // for the signed-in (app) group as well as (public) — the split once gave - // only (public) a footer, which is exactly how this could regress. - // - // Scoped to the footer's PLATFORM column, not the footer: the SDSC column - // beside it links datascience.ch's own About, so a footer-wide "About" - // matches two links and a page-wide one would also match the nav entry - // this test says is gone. - const platformAbout = () => - page - .locator("footer") - .getByRole("navigation", { name: "Platform" }) - .getByRole("link", { name: "About", exact: true }) - - for (const path of ["/", "/dashboard"]) { - await page.goto(path) - - const nav = page.locator("header").getByRole("navigation").first() - await expect(nav.getByRole("link", { name: "About", exact: true })).toHaveCount(0) - - await expect(page.locator("footer")).toBeVisible() - await expect(platformAbout()).toHaveAttribute("href", "/about") - } - - // Followed, not just asserted: a link's href proves where it points, not - // that the destination answers. - await platformAbout().click() - await expect(page).toHaveURL(/\/about$/) }) }) } diff --git a/.claude/skills/hackathon-e2e/tests/smoke/07-account-menu.spec.ts b/.claude/skills/hackathon-e2e/tests/smoke/07-account-menu.spec.ts index 2a44be9a..9c1648ba 100644 --- a/.claude/skills/hackathon-e2e/tests/smoke/07-account-menu.spec.ts +++ b/.claude/skills/hackathon-e2e/tests/smoke/07-account-menu.spec.ts @@ -1,6 +1,7 @@ import { test, expect, type Page } from "@playwright/test" -import { ALL_PERSONAS, PERSONAS } from "../../personas.js" +import { ALL_PERSONAS, PERSONAS, SEED_HACKATHONS } from "../../personas.js" import { storageStatePath } from "../../helpers/state.js" +import { rpcAnonymous } from "../../helpers/api.js" // Reaching your own account, and signing out. // @@ -19,6 +20,90 @@ function header(page: Page) { return page.locator("header") } +let h1Id = "" + +test.beforeAll(() => { + // Discovered the way a visitor would rather than hard-coded: seed UUIDs are + // regenerated on every reset. + const listed = rpcAnonymous("hackathon.HackathonService/List", { + visibilityFilter: 1, + }) + if (!listed.ok) throw new Error(`HackathonService.List failed: ${listed.raw}`) + const found = ( + listed.data.hackathons as { id: string; name: string }[] | undefined + )?.find((h) => h.name === SEED_HACKATHONS.h1.name) + if (!found) { + throw new Error( + `seed hackathon "${SEED_HACKATHONS.h1.name}" not found — this spec needs ` + + `the seed fixture (scripts/run.sh smoke seeds it)`, + ) + } + h1Id = found.id +}) + +/** + * Walk the trail the account page describes, using the names IT gives. + * + * The labels are an argument rather than constants on purpose: the caller reads + * them out of the page's own copy, so this asserts the sentence a person is + * asked to follow, not a sentence a test author remembered. Feeding it labels + * that name nothing is how the control below proves it can fail. + */ +async function expectTrailExists( + page: Page, + hackathonId: string, + destination: string, + control: string, +) { + await page.goto(`/my/hackathon/${hackathonId}/overview`) + await page.waitForLoadState("networkidle").catch(() => {}) + + const entry = page + .getByRole("link", { name: destination, exact: true }) + .filter({ visible: true }) + .first() + await expect( + entry, + `the account page sends people to "${destination}" inside an event, and ` + + `this event offers no such destination`, + ).toBeVisible({ timeout: 10_000 }) + + // Clicked, never `goto`-ed: goto proves a route exists, a click proves you + // can GET there by doing what you were told (.claude/CLAUDE.md, pass 1). + await entry.click() + await expect(page).toHaveURL(/\/participants$/) + + // Asked of the SERVER, not inferred from what rendered: SvelteKit's error + // page is a rendered page too, and a heading assertion cannot tell a 200 from + // a 404 that happens to look tidy. + const landed = await page.request.get(page.url()) + expect( + landed.status(), + `"${destination}" resolved to ${page.url()}, which did not answer 200`, + ).toBe(200) + + // Your OWN row. The same control on someone else's row carries `?userId=` + // and opens their answers read-only for an organiser — alice is one here, so + // the page is full of those and picking `.first()` would prove the wrong + // thing. The name is matched loosely because the row control is + // `<a aria-label="View <name> profile">View</a>`: "View" is what a person + // reads and what the copy can honestly name. + const hrefs = await page + .locator("main") + .getByRole("link", { name: control }) + .evaluateAll((els) => els.map((e) => e.getAttribute("href") ?? "")) + const mine = hrefs.filter( + (h) => h.includes("/register/") && !h.includes("userId="), + ) + expect( + mine, + `the account page tells people to use "${control}" on their own row of ` + + `${page.url()}; no such control links to their own registration there ` + + `(found: ${hrefs.join(", ") || "no links with that name at all"})`, + ).toHaveLength(1) + expect(mine[0]).toBe(`/register/${hackathonId}`) +} + test.describe("account, from the top bar", () => { test.use({ storageState: storageStatePath("alice") }) @@ -68,6 +153,70 @@ test.describe("account, from the top bar", () => { await expect(out).toBeVisible() await expect(out).toHaveAttribute("href", /\/realms\/hackagon\/account$/) }) + + // ─── The one thing this page sends you AWAY for ──────────────────────────── + // + // Event-specific answers — an event's own questions, and the consents it + // asked for — are not on the profile, and the page says where they are + // instead. That sentence had gone stale: it named "Your registration answers + // → View or edit", a block on the event overview that develop's `c596683c` + // deleted, so anyone following it looked for a control that was not there. + // `76037844` had moved the way in to the participants roster. + // + // Copy that names a control is a promise about the UI, and this repo has now + // shipped the broken version of that promise several times (routes with no + // inbound link; RPCs with no caller). So the check does not read the + // sentence — it FOLLOWS it, and asserts the far end answers 200. + + test("the answers this page does not hold are where it says they are", async ({ + page, + }) => { + await page.goto("/account") + + const note = page.getByTestId("event-answers-note") + await expect( + note, + "the account page no longer says where event-specific answers live — if " + + "that paragraph was removed on purpose, remove this test with it; if it " + + "was renamed, this is the check that was supposed to notice", + ).toBeVisible() + + // The <strong>s ARE the claim: exactly the destination and the control a + // person is told to look for. Read back rather than duplicated here, so a + // re-wording is followed instead of silently diverging from what is tested. + const named = await note.locator("strong").allInnerTexts() + expect( + named.map((s) => s.trim()), + "this note is supposed to name exactly two things — the destination " + + "inside the event, then the control on your own row. Emphasising a " + + "third would make it ambiguous which one this test should follow, so " + + "change the test deliberately rather than let it guess", + ).toHaveLength(2) + + await expectTrailExists(page, h1Id, named[0].trim(), named[1].trim()) + }) + + test("CONTROL: copy naming a control that does not exist fails the check", async ({ + page, + }) => { + // Positive control: the trail as the page describes it today. + await expectTrailExists(page, h1Id, "Participants", "View") + + // And the trail as it described it until this fix — the exact words of the + // block develop removed. An assertion that cannot reject the state that was + // actually shipped is not guarding anything. + await expect( + expectTrailExists( + page, + h1Id, + "Your registration answers", + "View or edit", + ), + "the stale instruction named a destination no event has; following it has " + + "to fail, or this spec would have agreed with the copy it was written " + + "to replace", + ).rejects.toThrow() + }) }) test.describe("sign out", () => { diff --git a/.claude/skills/hackathon-e2e/tests/smoke/15-media-upload.spec.ts b/.claude/skills/hackathon-e2e/tests/smoke/15-media-upload.spec.ts index 8f5ead82..945f47fc 100644 --- a/.claude/skills/hackathon-e2e/tests/smoke/15-media-upload.spec.ts +++ b/.claude/skills/hackathon-e2e/tests/smoke/15-media-upload.spec.ts @@ -44,13 +44,11 @@ test.describe("media upload", () => { // The control is a <label> wrapping a hidden input — a styled button cannot // open a file picker — so the input is set directly. - await page - .locator("input[type=file]") - .setInputFiles({ - name: "venue-photo.png", - mimeType: "image/png", - buffer: PNG, - }) + await page.locator("input[type=file]").setInputFiles({ + name: "venue-photo.png", + mimeType: "image/png", + buffer: PNG, + }) // Check the editor's own error line FIRST. Without this the failure is // "the textarea is still empty", which is the symptom; the component diff --git a/.claude/skills/hackathon-e2e/tests/smoke/17-site-page-editor.spec.ts b/.claude/skills/hackathon-e2e/tests/smoke/17-site-page-editor.spec.ts index 1f96a4ef..d0ead516 100644 --- a/.claude/skills/hackathon-e2e/tests/smoke/17-site-page-editor.spec.ts +++ b/.claude/skills/hackathon-e2e/tests/smoke/17-site-page-editor.spec.ts @@ -228,13 +228,11 @@ test.describe("platform page editor", () => { // The control is a <label> wrapping a hidden input — a styled button cannot // open a file picker — so the input is set directly. - await form - .locator("input[type=file]") - .setInputFiles({ - name: "venue-photo.png", - mimeType: "image/png", - buffer: PNG, - }) + await form.locator("input[type=file]").setInputFiles({ + name: "venue-photo.png", + mimeType: "image/png", + buffer: PNG, + }) // The editor's own error line FIRST. Without it the failure reads "the // textarea did not change", which is the symptom; the component already diff --git a/.claude/skills/hackathon-e2e/tests/smoke/21-footer.spec.ts b/.claude/skills/hackathon-e2e/tests/smoke/21-footer.spec.ts index 806235a8..3ce89740 100644 --- a/.claude/skills/hackathon-e2e/tests/smoke/21-footer.spec.ts +++ b/.claude/skills/hackathon-e2e/tests/smoke/21-footer.spec.ts @@ -2,7 +2,12 @@ import { test, expect, type Page } from "@playwright/test" import { PERSONAS, SEED_HACKATHONS } from "../../personas.js" import { storageStatePath } from "../../helpers/state.js" import { rpcAnonymous } from "../../helpers/api.js" -import { expectFooterOperable, FOOTER_LINKS } from "../../helpers/reflow.js" +import { + expectFooterLinkNamesUnique, + expectFooterOperable, + expectNewTabLinksAnnounced, + footerLinkNames, +} from "../../helpers/reflow.js" // The site footer on the SIGNED-IN half of the app, and what it is FOR. // @@ -28,11 +33,39 @@ import { expectFooterOperable, FOOTER_LINKS } from "../../helpers/reflow.js" // the hackathon sidebar is anchored to the VIEWPORT, so it can be drawn over a // footer that is at the bottom of the document. -/** slug -> the <h1> the SitePage renders (cmd/seed/main.go, seedSitePages). */ +/** + * slug -> the <h1> the SitePage renders (cmd/seed/main.go, seedSitePages), and + * the footer nav landmark that links to it. + * + * ⚠ `label` and `title` are the SAME STRING on all three rows now, and that is + * the fix rather than a coincidence. develop's rebuilt footer (`02658384`) put + * the SDSC org site's links beside ours with one of THEM named exactly "About" + * (datascience.ch/about), so footer-wide `name: "About"` matched two links + * pointing at two different places — undisambiguatable in a screen reader's + * link list, which is a flat list of names with the column headings thrown + * away. Our link carries the SitePage's own title now, so the two names differ + * at the source: a page and its inbound link cannot drift while the rule is + * "name the destination the way it names itself". "Terms" → "Terms of use" was + * the same move, made by develop for the same reason. + * + * `nav` stays because it costs nothing and says WHICH link a bare name means. + * The property that keeps the collision gone is asserted directly, on the + * footer as a whole, by `expectFooterLinkNamesUnique` further down. + */ const SITE_PAGES = [ - { label: "Privacy", href: "/privacy", title: "Privacy" }, - { label: "Terms", href: "/terms", title: "Terms of use" }, - { label: "About", href: "/about", title: "About Hackagon" }, + { label: "Privacy", nav: "Legal", href: "/privacy", title: "Privacy" }, + { + label: "Terms of use", + nav: "Legal", + href: "/terms", + title: "Terms of use", + }, + { + label: "About Hackagon", + nav: "Platform", + href: "/about", + title: "About Hackagon", + }, ] let h1Id = "" @@ -183,11 +216,13 @@ test.describe("the footer's links resolve from inside the app", () => { await page.goto("/dashboard") await page.waitForLoadState("networkidle").catch(() => {}) - // Scoped to the <footer>, never page-wide: "Privacy" and "About" also - // appear in the consent sentence and in the header nav, and a check that - // matches those would pass with no footer at all. + // Scoped to the <footer> and then to its nav landmark, never page-wide: + // "Privacy" and "About" also appear in the consent sentence, and a check + // that matches those would pass with no footer at all. The landmark is + // the second half of that — see SITE_PAGES on the duplicate "About". const link = page .locator("footer") + .getByRole("navigation", { name: target.nav }) .getByRole("link", { name: target.label, exact: true }) await expect(link).toHaveAttribute("href", target.href) @@ -207,19 +242,97 @@ test.describe("the footer's links resolve from inside the app", () => { } }) - test("the GitHub link points off-site and nowhere else", async ({ page }) => { - // Not followed — an external navigation in a suite that has no network - // contract with github.com is a flake waiting to happen. The href is the - // whole claim. + test("every off-site link points off-site, and can be named", async ({ + page, + }) => { + // Re-specified for develop's rebuilt footer (`02658384`). There is no + // GitHub link any more — the off-site row is SDSC's own channels, three + // ICON-ONLY anchors — so the old test is retired rather than repaired: its + // subject left the product. What replaced it is a stronger claim about the + // same row, and one this footer can actually break. + // + // 1. every off-site anchor is absolute https. Same reason as before: a + // relative href here would silently resolve against our own origin. + // 2. every one of them has an ACCESSIBLE NAME. That is new and it is the + // point: an icon-only link whose aria-label is dropped is invisible to + // a screen reader and to every name-based locator, and it looks + // completely fine on screen. `getByRole("link")` returns it either way, + // so the name has to be read back explicitly. + // + // Not followed — an external navigation in a suite with no network contract + // with linkedin.com is a flake waiting to happen. The href is the claim. + // + // The name computation moved to helpers/reflow.ts (`footerLinkNames`) when + // the uniqueness check below needed the same thing. It is shared rather + // than copied for one reason: it is the piece that has already been WRONG + // once — reading textContent alone called the two parent-institution logos + // nameless, because they are `<a><img alt="ETH Zurich"></a>` and the alt + // text is what names them. One copy can be corrected; two drift. await page.goto("/dashboard") - const href = await page - .locator("footer") - .getByRole("link", { name: "GitHub", exact: true }) - .getAttribute("href") + const offsite = (await footerLinkNames(page)).filter((l) => + /^https?:/.test(l.href), + ) + + // Positive control: a footer whose off-site row went missing would satisfy + // both assertions below with an empty array, which is the vacuous shape + // this repo keeps paying for. + expect( + offsite.length, + "the footer carries no off-site links at all — SDSC's channels and the " + + "two parent-institution logos should all be here", + ).toBeGreaterThanOrEqual(4) + + expect( + offsite.filter((l) => !l.href.startsWith("https://")), + "an off-site footer link is not absolute https", + ).toEqual([]) expect( - href, - "the footer's GitHub link must be an absolute https URL", - ).toMatch(/^https:\/\/github\.com/) + offsite.filter((l) => l.name === "").map((l) => l.href), + "an off-site footer link has no accessible name — icon-only anchors need " + + "aria-label, and nothing on screen shows when one is lost", + ).toEqual([]) + }) + + test("every footer link that opens in a new tab says so in its name", async ({ + page, + }) => { + // `target="_blank"` moves you to a new tab and announces NOTHING. Sighted + // visitors get no icon; a screen reader gets no word. The nav landmark is + // named "Swiss Data Science Center", which would say it — and a link list is + // a flat list of NAMES with landmark context thrown away, which is the same + // reason two "About"s collided one column apart (the test below). + // + // ⚠ Asserted as a PROPERTY over whatever opens a new tab, never against the + // five datascience.ch links it was written for. A five-entry list would be a + // claim about how many off-site links the footer HAS — exactly the mistake + // the uniqueness check already made once, when a five-entry constant broke + // the day develop grew the footer to fourteen links. + // + // Both shells, same reason as the uniqueness check below: the footer is + // mounted by AppShell and a page adding a link of its own would break this + // on one side only. + for (const path of ["/hackathon", "/dashboard"]) { + await page.goto(path) + await page.waitForLoadState("networkidle").catch(() => {}) + await expectNewTabLinksAnnounced(page, `smoke ${path}`) + } + }) + + test("no two footer links answer to the same name", async ({ page }) => { + // The one thing a screen reader's link list is: a flat list of NAMES. Our + // /about and datascience.ch/about were both "About" in it (develop's + // `02658384`), one column apart on screen and side by side in that list. + // + // Asserted on BOTH shells, because the property is about a region and the + // region is mounted by AppShell — a page that added a link of its own into + // the footer would break this on one side only, and the equality test + // further down compares the two footers to each OTHER, so it would agree + // with two identical broken ones. + for (const path of ["/hackathon", "/dashboard"]) { + await page.goto(path) + await page.waitForLoadState("networkidle").catch(() => {}) + await expectFooterLinkNamesUnique(page, `smoke ${path}`) + } }) // ─── Controls: each assertion above, shown failing ───────────────────────── @@ -301,6 +414,92 @@ test.describe("the footer's links resolve from inside the app", () => { ).rejects.toThrow(/drawn over the footer/) }) + test("CONTROL: two links with one name fails the name check", async ({ + page, + }) => { + await page.goto("/dashboard") + await page.waitForLoadState("networkidle").catch(() => {}) + + // Positive control first, on the footer as it ships. Without it a helper + // that threw unconditionally would look like a good check. + await expectFooterLinkNamesUnique(page, "control (names distinct)") + + // Now put the footer back in exactly the state develop's rebuild shipped + // in: our own About page's link named "About", one column away from + // datascience.ch's. Reproduced in the live DOM rather than by reverting the + // component, so the proof runs on every suite rather than once for whoever + // happened to be watching. + // + // ⚠ It takes TWO mutations now, and that is the point rather than an + // inconvenience: TWO independent fixes hold this pair apart. `fbc81add` + // renamed ours to "About Hackagon", and the off-site suffix added later + // renames theirs to "About (datascience.ch, opens in a new tab)". Undoing + // only one leaves the names distinct — which is how this control started + // reporting green the day the suffix landed, having stopped reproducing + // anything. A control that no longer reaches the defect is the same + // vacuous shape as the check it is guarding. + const collided = await page.evaluate(() => { + const own = document.querySelector('footer a[href="/about"]') + const sdsc = document.querySelector( + 'footer a[href="https://datascience.ch/about"]', + ) + if (!own || !sdsc) return false + own.textContent = "About" + sdsc.querySelector(".sr-only")?.remove() + sdsc.textContent = "About" + return true + }) + expect( + collided, + "the footer is missing /about or datascience.ch/about — this control " + + "cannot reproduce the collision, so it is not proving the check can fail", + ).toBe(true) + + await expect( + expectFooterLinkNamesUnique(page, "control (duplicate name)"), + 'both links are present, visible and correctly labelled on screen — "About" ' + + "under a Platform heading and under an SDSC one. Only the flat list of " + + "names shows the clash, which is why this check reads names rather than " + + "columns", + ).rejects.toThrow(/"About" does not name exactly one link/) + }) + + test("CONTROL: a new-tab link with no announcement fails the check", async ({ + page, + }) => { + await page.goto("/dashboard") + await page.waitForLoadState("networkidle").catch(() => {}) + + // Positive control first, on the footer as it ships — otherwise a helper + // that threw unconditionally would look like a good check. + await expectNewTabLinksAnnounced(page, "control (all announced)") + + // Now put ONE link back in the state every off-site link shipped in until + // this was fixed: target="_blank" and nothing that says so. Stripping the + // suffix in the live DOM rather than reverting the component means the + // proof runs on every suite instead of once for whoever was watching. + const stripped = await page.evaluate(() => { + const a = document.querySelector<HTMLAnchorElement>( + 'footer a[target="_blank"][href*="datascience.ch"]', + ) + if (!a) return null + a.querySelector(".sr-only")?.remove() + return a.href + }) + expect( + stripped, + "no datascience.ch link opens in a new tab — this control cannot " + + "reproduce the defect, so it is not proving the check can fail", + ).not.toBeNull() + + await expect( + expectNewTabLinksAnnounced(page, "control (suffix stripped)"), + "the link is still visible, still correctly labelled on screen and still " + + "reachable — only the flat list of names shows that it silently leaves " + + "the site, which is why this check reads names rather than markup", + ).rejects.toThrow(/without saying so in its accessible name/) + }) + test("the footer is the same footer on both sides of the login", async ({ page, }) => { @@ -320,10 +519,21 @@ test.describe("the footer's links resolve from inside the app", () => { const publicLabels = await labelsOn("/hackathon") const appLabels = await labelsOn("/dashboard") + // This used to compare the public footer against the FOOTER_LINKS constant, + // which made the test a claim about the footer's SIZE — and develop's + // rebuild (`02658384`) grew it from four links to fourteen, so the constant + // was wrong the moment a copy edit landed rather than when anything broke. + // Same disease as `03-dashboard`'s `connectedCount: 3`. + // + // The property was always the EQUALITY: one AppShell, so one footer. That + // is asserted directly now, and the presence of the named links is + // expectFooterOperable's job — which runs on both of these routes already + // and does it through the nav landmarks. expect( - publicLabels, - "the public footer lost a link — FOOTER_LINKS is the contract both sides share", - ).toEqual(FOOTER_LINKS) + publicLabels.length, + "the public footer has no links at all — two empty lists are equal, and " + + "that is the one way this check could agree with a footer that is gone", + ).toBeGreaterThan(4) expect( appLabels, "the signed-in footer differs from the public one; they are supposed to " + diff --git a/.claude/skills/hackathon-e2e/tests/smoke/24-chip-states.spec.ts b/.claude/skills/hackathon-e2e/tests/smoke/24-chip-states.spec.ts new file mode 100644 index 00000000..d28c777d --- /dev/null +++ b/.claude/skills/hackathon-e2e/tests/smoke/24-chip-states.spec.ts @@ -0,0 +1,320 @@ +import { test, expect, type Locator, type Page } from "@playwright/test" + +// The chip's four appearances, measured rather than inferred. +// +// The bug: `.chip:hover` is (0,2,0) and `.chip-active` is (0,1,0), so pointing +// at the tab you are already on ERASED its accent tint and painted it with the +// same neutral `raised` an unselected chip gets. Selected and unselected became +// the same pixels for exactly as long as the pointer was on them — which is the +// moment a reader is most likely to be checking which tab is which. +// +// Why this file asserts COMPUTED STYLE and never a class name. `chip-active` was +// on the element the whole time the bug shipped; every class-based assertion +// that could have been written would have passed. That is the +// "locator that contains the thing it asserts about" family from +// .claude/CLAUDE.md, in its CSS form: the class is the INPUT to the rule, and +// what broke was the rule. Only the resolved colour is the fact. +// +// Carrier: the public landing page's Trending tab row — one `chip chip-active` +// ("Hackathons") and two plain `chip` — because it is anonymous, three chips in +// one row, and no fixture data is involved. The rule itself is global to the +// theme, so any `.chip` anywhere would do. + +type RGB = [number, number, number] + +/** + * The colour a reader actually sees, as sRGB bytes. + * + * Not a regex over the computed string, which was the first attempt and does + * not survive contact with this theme: `.chip-active` is + * `color-mix(in oklab, …)` and Firefox reports that back as + * `oklab(0.8 -0.101689 0.11698 / 0.2)`, while `.chip:hover` resolves to a plain + * `rgb(…)`. Two syntaxes for the same kind of fact, and one of them names a + * colour space this file has no business reimplementing. + * + * So the browser is asked to paint it. The colour goes onto a 1×1 canvas OVER + * the page's own background, which resolves both the colour space and the alpha + * exactly the way the page resolves them — a 20%-accent wash and a 30% one are + * two different pixels, which is the claim, and reading them as `rgba(…,0.2)` + * vs `rgba(…,0.3)` would have been a claim about the stylesheet instead. + * + * `fillStyle` silently KEEPS its previous value when handed something it cannot + * parse, so an unsupported syntax would quietly measure whatever was set last + * and every comparison would come out equal — a vacuous pass of the worst kind. + * The sentinel makes that a thrown error. + */ +async function paintedColour( + page: Page, + value: string, + over: string, +): Promise<RGB> { + const out = await page.evaluate( + ([v, backdrop]) => { + const canvas = document.createElement("canvas") + canvas.width = 1 + canvas.height = 1 + const ctx = canvas.getContext("2d") + if (!ctx) return null + + const SENTINEL = "#ff00ff" + ctx.fillStyle = SENTINEL + ctx.fillStyle = v + if (ctx.fillStyle === SENTINEL) return null + + ctx.fillStyle = backdrop + ctx.fillRect(0, 0, 1, 1) + ctx.fillStyle = v + ctx.fillRect(0, 0, 1, 1) + const d = ctx.getImageData(0, 0, 1, 1).data + + return [d[0], d[1], d[2]] + }, + [value, over] as const, + ) + if (!out) { + throw new Error( + `the browser could not paint '${value}' — nothing was measured`, + ) + } + + return out as RGB +} + +/** Straight-line distance in sRGB between two painted colours. */ +function colourDistance(a: RGB, b: RGB): number { + return Math.hypot(a[0] - b[0], a[1] - b[1], a[2] - b[2]) +} + +/** + * A settled computed value. + * + * `.chip` carries `transition: background-color 120ms ease`, and + * `getComputedStyle` mid-transition returns the INTERMEDIATE colour — so a read + * taken straight after `hover()` is a point on the way to the answer and would + * make every comparison below timing-dependent. Reads until two consecutive + * samples agree rather than sleeping a guessed interval. + */ +async function settledStyle( + el: Locator, + prop: "background-color" | "color", +): Promise<string> { + const read = () => + el.evaluate((node, p) => getComputedStyle(node).getPropertyValue(p), prop) + + let previous = await read() + for (let i = 0; i < 25; i++) { + await el.page().waitForTimeout(40) + const current = await read() + if (current === previous) return current + previous = current + } + throw new Error(`${prop} never settled on this element`) +} + +interface ChipPaint { + background: RGB + /** Kept as the computed string: it is compared for equality, never measured. */ + ink: string +} + +/** Move the pointer off every chip and let the transition finish. */ +async function unhover(page: Page) { + await page.mouse.move(0, 0) +} + +async function paintOf(el: Locator, backdrop: string): Promise<ChipPaint> { + const background = await settledStyle(el, "background-color") + + return { + background: await paintedColour(el.page(), background, backdrop), + ink: await settledStyle(el, "color"), + } +} + +/** + * The whole claim, as one function, so the control below can call the exact + * check the real test calls rather than a lookalike. + * + * Four appearances, and every pair that must differ: + * + * selected at rest ─┬─ vs unselected at rest : selection is visible + * └─ vs selected hovered : hover answers on it too + * unselected at rest ─── vs unselected hovered : hover stays visible + * unselected hovered ─── vs selected hovered : THE BUG — these were equal + * + * Plus the "reads as both" claim, which the four inequalities alone do not + * make: a selected-and-hovered chip painted some unrelated third colour would + * satisfy all of them. It has to stay in the accent family, so its background + * is required to be NEARER to selected-at-rest than to unselected-hovered, and + * its ink has to be the selected ink exactly. + */ +async function expectChipStatesDistinct(page: Page) { + const row = page.locator("#trending") + const selected = row.locator("button.chip.chip-active") + const unselected = row.locator("button.chip:not(.chip-active)").first() + + // Positive control for the whole file: without both kinds of chip on screen + // every comparison below is between two reads of the same element. + await expect( + selected, + "the landing page must render one selected chip", + ).toHaveCount(1) + await expect(unselected, "…and at least one unselected chip").toBeVisible() + + // The backdrop these washes are composited over: the chips sit directly on the + // page in the Trending row, with no card between them, so the body's own + // background is what shows through a `transparent` chip. Read once and used + // for all four, which is what makes the four numbers comparable. + const backdrop = await page.evaluate( + () => getComputedStyle(document.body).backgroundColor, + ) + + await unhover(page) + const selectedRest = await paintOf(selected, backdrop) + const unselectedRest = await paintOf(unselected, backdrop) + + await unselected.hover() + const unselectedHover = await paintOf(unselected, backdrop) + + await selected.hover() + const selectedHover = await paintOf(selected, backdrop) + + await unhover(page) + + const d = (a: ChipPaint, b: ChipPaint) => + colourDistance(a.background, b.background) + const show = (c: ChipPaint) => `rgb(${c.background.join(",")})` + // 8 rather than 0: two colours a reader cannot tell apart are not two states, + // and an exact-inequality check would accept a one-unit rounding difference + // as a distinguishable style. + const VISIBLE = 8 + + expect( + d(selectedRest, unselectedRest), + `the selected chip is painted like an unselected one at rest ` + + `(${show(selectedRest)} vs ${show(unselectedRest)})`, + ).toBeGreaterThan(VISIBLE) + + // `> 0`, not `> VISIBLE`, and deliberately so. HOW FAR the unselected hover + // moves is a design decision that differs by colour mode — light mode's + // `raised` is two lightness points from `canvas` — and this file has no + // business ruling on it. That the hover rule REACHES the chip at all is the + // property; the integers come from getImageData, so equality here is exact + // rather than approximate. + expect( + d(unselectedRest, unselectedHover), + `hover does not change an unselected chip at all ` + + `(${show(unselectedRest)} vs ${show(unselectedHover)})`, + ).toBeGreaterThan(0) + + // The regression itself. Before the fix both of these resolved to + // `--color-raised` with `--color-ink`, i.e. the selection vanished under the + // pointer. + expect( + d(unselectedHover, selectedHover), + `THE BUG: a hovered selected chip is painted exactly like a hovered ` + + `unselected one (${show(selectedHover)} vs ${show(unselectedHover)}) — ` + + `.chip:hover (0,2,0) is beating .chip-active (0,1,0)`, + ).toBeGreaterThan(VISIBLE) + + expect( + d(selectedRest, selectedHover), + `the selected chip does not answer the pointer at all ` + + `(${show(selectedRest)} vs ${show(selectedHover)})`, + ).toBeGreaterThan(VISIBLE) + + // Reads as BOTH: still accent-tinted, not merely "some other colour". + expect( + d(selectedHover, selectedRest), + `selected-and-hovered has left the accent family: it is nearer to the ` + + `unselected hover (${show(unselectedHover)}) than to the selected ` + + `rest state (${show(selectedRest)})`, + ).toBeLessThan(d(selectedHover, unselectedHover)) + + expect( + selectedHover.ink, + `a hovered selected chip must keep the selected ink (${selectedRest.ink}); ` + + `it took the unselected hover ink (${unselectedHover.ink}) instead`, + ).toBe(selectedRest.ink) +} + +test.describe("chip states stay distinguishable", () => { + test("selected, hovered, and selected-and-hovered are three different chips", async ({ + page, + }) => { + await page.goto("/") + await expectChipStatesDistinct(page) + }) + + // ─── Control: the check, shown failing ────────────────────────────────────── + // + // Deleting the rule from the live CSSOM restores the exact stylesheet that + // shipped the bug, so this is the defect itself rather than an imitation of + // it. Done in the page rather than by editing the theme and re-running by + // hand: a source edit proves it once, for whoever was watching, and then + // stops existing. + test("CONTROL: without .chip-active:hover the check fails", async ({ + page, + }) => { + await page.goto("/") + + // Passes as it stands, first — a check that threw unconditionally would + // look exactly like a good one from here. + await expectChipStatesDistinct(page) + + const deleted = await page.evaluate(() => { + // Anything that OWNS rules and can drop one: a stylesheet, or a grouping + // rule. `@layer components { … }` wraps this whole theme, so the rule is + // never at the top level of the sheet and the walk has to recurse — and + // deletion is by index ON THE OWNER, which is why the owner is what gets + // passed down rather than a bare CSSRuleList. + type RuleOwner = { + cssRules: CSSRuleList + deleteRule(index: number): void + } + + const strip = (owner: RuleOwner): number => { + let n = 0 + const rules = owner.cssRules + // Backwards: deleting shifts every later index down by one. + for (let i = rules.length - 1; i >= 0; i--) { + const rule = rules[i] as CSSRule & + Partial<RuleOwner> & { selectorText?: string } + if (rule.cssRules && typeof rule.deleteRule === "function") { + n += strip(rule as unknown as RuleOwner) + } + if (rule.selectorText === ".chip-active:hover") { + owner.deleteRule(i) + n++ + } + } + + return n + } + + let total = 0 + for (const sheet of Array.from(document.styleSheets)) { + try { + total += strip(sheet) + } catch { + // A cross-origin sheet cannot be read; none of ours are. + } + } + + return total + }) + + expect( + deleted, + "the control removed no rule — either the selector was renamed (update " + + "this control with it) or the fix is not in the stylesheet at all, and " + + "the test above is passing for some other reason", + ).toBeGreaterThan(0) + + await expect( + expectChipStatesDistinct(page), + "with the rule gone the selected chip is repainted by .chip:hover, and " + + "this check is what must say so", + ).rejects.toThrow(/THE BUG/) + }) +}) diff --git a/.claude/skills/hackathon-e2e/tests/tunnel/host.ts b/.claude/skills/hackathon-e2e/tests/tunnel/host.ts new file mode 100644 index 00000000..253e3dc5 --- /dev/null +++ b/.claude/skills/hackathon-e2e/tests/tunnel/host.ts @@ -0,0 +1,50 @@ +/** + * The public host these specs are pointed at, and the URL patterns built from + * it. + * + * WHY THIS IS NOT A LITERAL. Both tunnel specs used to wait for + * `/trycloudflare\.com\/realms\/hackagon/`, which is the QUICK tunnel's domain. + * That was fine while a quick tunnel was the only public path there was, and it + * silently became a lie the moment a NAMED tunnel on our own zone became the + * default: every one of those waits would time out on a hostname that is + * working perfectly, and the failure reads as "login is broken through the + * tunnel" — the exact conclusion these specs exist to make trustworthy. + * + * Derive the pattern from TUNNEL_BASE_URL instead. It says the same thing the + * literal said — "the flow must stay on the public host, never dead-end on + * localhost:8180" — for whichever public host is actually in use. + */ + +/** e.g. `hackagon.example.org` or `x-y-z.trycloudflare.com`. */ +export function tunnelHost(base: string | undefined): string { + if (!base) return "" + try { + return new URL(base).host + } catch { + return "" + } +} + +/** Escape a hostname for use inside a RegExp — dots are the whole point. */ +function esc(s: string): string { + return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&") +} + +/** + * Matches any URL on the public host. Used to assert the browser came BACK to + * the app after Keycloak, which is where a wrong `ORIGIN` or a wrong issuer + * dead-ends. + */ +export function onTunnel(base: string | undefined): RegExp { + return new RegExp(esc(tunnelHost(base))) +} + +/** + * Matches Keycloak's login page served from the SAME public host — caddy + * path-multiplexes `/realms/*` to it. This is the step that used to dead-end on + * `localhost:8180` when the issuer rewiring was not in effect, and it is the + * one assertion that distinguishes a login-capable tunnel from a view-only one. + */ +export function onTunnelRealm(base: string | undefined): RegExp { + return new RegExp(`${esc(tunnelHost(base))}/realms/hackagon`) +} diff --git a/.claude/skills/hackathon-e2e/tests/tunnel/login.spec.ts b/.claude/skills/hackathon-e2e/tests/tunnel/login.spec.ts index e9b42f3e..e80c3731 100644 --- a/.claude/skills/hackathon-e2e/tests/tunnel/login.spec.ts +++ b/.claude/skills/hackathon-e2e/tests/tunnel/login.spec.ts @@ -1,19 +1,23 @@ import { test, expect } from "@playwright/test" import { PERSONAS } from "../../personas.js" +import { onTunnel, onTunnelRealm } from "./host.js" /** - * Proves OIDC login works through the Cloudflare quick tunnel. Bring the - * tunnel up with issuer rewiring first, then run with its URL: + * Proves OIDC login works through the Cloudflare tunnel — quick or named, the + * spec does not care which. Bring the tunnel up with issuer rewiring first, + * then run with its URL: * * bash .claude/skills/cloudflare-tunnel/scripts/up.sh --with-auth - * TUNNEL_BASE_URL=https://X.trycloudflare.com pnpm exec playwright test --project=tunnel + * TUNNEL_BASE_URL=https://hackagon.example.org \ + * pnpm exec playwright test --project=tunnel * * The whole flow must stay on the tunnel host: a redirect to localhost:8180 - * means the issuer rewiring is not in effect (view-only tunnel). + * means the issuer rewiring is not in effect (view-only tunnel). The host comes + * from TUNNEL_BASE_URL rather than from a literal domain — see host.ts. */ const base = process.env.TUNNEL_BASE_URL -test.describe("login through the quick tunnel", () => { +test.describe("login through the tunnel", () => { test.skip( !base, "TUNNEL_BASE_URL not set — start the tunnel with --with-auth first", @@ -30,9 +34,7 @@ test.describe("login through the quick tunnel", () => { // Keycloak served from the SAME public host (caddy path-multiplexes // /realms/* to it) — this is the step that used to dead-end on // localhost:8180. - await page.waitForURL(/trycloudflare\.com\/realms\/hackagon/, { - timeout: 45_000, - }) + await page.waitForURL(onTunnelRealm(base), { timeout: 45_000 }) await page.locator("#username").fill(alice.username) if (!(await page.locator("#password").isVisible())) { @@ -45,7 +47,7 @@ test.describe("login through the quick tunnel", () => { // Back on the app through the tunnel, logged in. Identity is a monogram // <span>, not a button — this design draws it as "who you are" rather than // an action — so a role-based locator finds nothing even when login worked. - await page.waitForURL(/trycloudflare\.com/, { timeout: 30_000 }) + await page.waitForURL(onTunnel(base), { timeout: 30_000 }) await expect( page.locator("header").getByText(alice.initial, { exact: true }), ).toBeVisible({ timeout: 20_000 }) diff --git a/.claude/skills/hackathon-e2e/tests/tunnel/upload.spec.ts b/.claude/skills/hackathon-e2e/tests/tunnel/upload.spec.ts index 48d8bbfe..e8f288c5 100644 --- a/.claude/skills/hackathon-e2e/tests/tunnel/upload.spec.ts +++ b/.claude/skills/hackathon-e2e/tests/tunnel/upload.spec.ts @@ -1,5 +1,6 @@ import { test, expect } from "@playwright/test" import { PERSONAS } from "../../personas.js" +import { onTunnel, onTunnelRealm } from "./host.js" /** * Proves a presigned UPLOAD survives the public path, not just localhost. @@ -25,11 +26,16 @@ import { PERSONAS } from "../../personas.js" * spec is the independent check, from the browser, on the deployed path. * * bash .claude/skills/cloudflare-tunnel/scripts/up.sh --with-auth - * TUNNEL_BASE_URL=https://X.trycloudflare.com pnpm exec playwright test --project=tunnel + * TUNNEL_BASE_URL=https://hackagon.example.org \ + * pnpm exec playwright test --project=tunnel + * + * Works against a quick tunnel or a named one — the public host is derived from + * TUNNEL_BASE_URL (host.ts). The Host-rewrite fault this catches is caddy's and + * is identical either way; a named hostname does not make it go away. */ const base = process.env.TUNNEL_BASE_URL -test.describe("presigned upload through the quick tunnel", () => { +test.describe("presigned upload through the tunnel", () => { test.skip( !base, "TUNNEL_BASE_URL not set — start the tunnel with --with-auth first", @@ -45,9 +51,7 @@ test.describe("presigned upload through the quick tunnel", () => { await page.goto("/") await page.waitForLoadState("networkidle") await page.getByRole("button", { name: "Log in" }).click() - await page.waitForURL(/trycloudflare\.com\/realms\/hackagon/, { - timeout: 45_000, - }) + await page.waitForURL(onTunnelRealm(base), { timeout: 45_000 }) await page.locator("#username").fill(alice.username) if (!(await page.locator("#password").isVisible())) { await page.locator("#kc-login").click() @@ -55,7 +59,7 @@ test.describe("presigned upload through the quick tunnel", () => { } await page.locator("#password").fill(alice.password) await page.locator("#kc-login").click() - await page.waitForURL(/trycloudflare\.com/, { timeout: 30_000 }) + await page.waitForURL(onTunnel(base), { timeout: 30_000 }) // Presign + PUT exactly as the app does it, in the page, so the request // travels the same hops with the same headers a person's upload would. diff --git a/.claude/skills/k3d-chart-rig/.gitignore b/.claude/skills/k3d-chart-rig/.gitignore new file mode 100644 index 00000000..f91f7616 --- /dev/null +++ b/.claude/skills/k3d-chart-rig/.gitignore @@ -0,0 +1,6 @@ +# Downloaded, pinned toolchain (k3d, helm, kubectl). Binaries, never tracked. +bin/ +# Cluster kubeconfig, generated passwords, rewritten realm — all per-run. +.state/ +# Anything the scripts write for a human to read. +.artifacts/ diff --git a/.claude/skills/k3d-chart-rig/SKILL.md b/.claude/skills/k3d-chart-rig/SKILL.md new file mode 100644 index 00000000..138e3124 --- /dev/null +++ b/.claude/skills/k3d-chart-rig/SKILL.md @@ -0,0 +1,520 @@ +--- +name: k3d-chart-rig +description: + Install and exercise this repo's Helm chart on a throwaway local Kubernetes + cluster (k3d + ingress-nginx + a test-only object store), then prove the + things a rendered manifest can only assert — that a presigned upload survives + the /objects Host rewrite, that the regex path beats the frontend's /, that + ingress-nginx accepts the ExternalName upstream, and that a real OIDC login + round-trip completes. Use when asked to test, install, debug or change + helm-chart/, or to reproduce a Kubernetes-only failure. +--- + +# A local Kubernetes rig for `helm-chart/` + +The chart renders and lints. Nothing had ever installed it. This makes the +difference observable. + +```bash +bash .claude/skills/k3d-chart-rig/scripts/up.sh # ~4 min cold, ~90 s warm +bash .claude/skills/k3d-chart-rig/scripts/verify.sh # 55 checks, ~4 min +bash .claude/skills/k3d-chart-rig/scripts/down.sh # deletes the cluster +``` + +…and, optionally, the same cluster on a **real public hostname with a real +certificate** (needs the Cloudflare credentials the other tunnels use): + +```bash +bash .claude/skills/k3d-chart-rig/scripts/tunnel.sh up # ~2 min +bash .claude/skills/k3d-chart-rig/scripts/verify.sh # the same 55, over https +bash .claude/skills/k3d-chart-rig/scripts/browser-check.sh # 13 checks a browser must answer +bash .claude/skills/k3d-chart-rig/scripts/tunnel.sh down # back to *.localhost +bash .claude/skills/k3d-chart-rig/scripts/tunnel.sh destroy # …and give the names up +``` + +**Opt-in, loopback-only, and it imports development accounts** — see the warning +at the bottom before running it on a machine anyone else can reach. + +## What it is + +k3d (k3s in Docker) as **sibling containers on the host Docker daemon**. Three +containers: `k3d-hackagon-server-0` (the whole cluster), `-serverlb` (the port +proxy) and a short-lived `-tools`. Not docker-in-docker: no privileged +container, no nested storage driver. + +> The task that commissioned this assumed the devcontainer could drive it, +> because "the devcontainer already speaks to the host socket". **It does not** +> — `.devcontainer/docker-compose.yml` mounts no Docker socket and the image has +> no `docker` CLI. Everything here therefore runs from the **host** shell (Git +> Bash on Windows), which is also where `docker` and the pinned toolchain live. + +``` +scripts/lib.sh names, ports, hostnames, tool wrappers, path translation +scripts/tools.sh downloads pinned k3d / helm / kubectl into bin/ (gitignored) +scripts/up.sh cluster → ingress-nginx → CoreDNS → store → secrets → helm +scripts/install.sh just the `helm upgrade`, for iterating on the chart +scripts/verify.sh the 55 checks — in EITHER mode +scripts/presign.sh SigV4 presigner mirroring internal/storage/sigv4.go +scripts/tunnel.sh the public-https mode: up / down / destroy / status +scripts/browser-check.sh + browser-login.mjs a real browser, the 13 checks + that need one (see "Real HTTPS", below) +scripts/down.sh delete (or --stop, or --purge) +manifests/ ingress-nginx values · the test store · the negative control +helm-chart/values.k3d.yaml the test values (TRACKED, and carries no secret) +.state/values.tunnel.yaml the public-https overlay (GENERATED, gitignored — + it names a hostname, and nothing tracked ever does) +``` + +## Ports it claims on the host + +| Port | What | Why not a dev-stack port | +| -------- | ----------------------------------- | --------------------------------------------------------------- | +| **8090** | ingress-nginx http — the app | dev uses 3000 · 8081 · 8082 · 8180 · 15432 · 9000 · 9001 · 8010 | +| **8443** | ingress-nginx https — Keycloak only | " | +| **6551** | k3s apiserver, bound to `127.0.0.1` | " | + +The public-https mode claims **no further host port**: cloudflared joins the +`k3d-hackagon` docker network and dials the load balancer container directly, so +the tunnel adds a container and nothing that any other rig could collide with. + +Nothing else is published, nothing binds `0.0.0.0` except the two ingress ports +(Docker's default), and the compose project, the dev stack's three tunnels and +`~/.kube/config` are untouched — k3d is called with +`--kubeconfig-update-default=false` and every wrapper points at the rig's own +`.state/kubeconfig.yaml`. + +## What it costs + +| | | +| ---- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| RAM | **~2.7 GB** resident with everything up (`k3d-…-server-0` 2.64 GiB) | +| Disk | **~4.6 GB**: 4.1 GB of images inside the node's containerd, ~420 MB of k3d/k3s images in the host cache, ~46 MB of pinned binaries in `bin/` | +| Time | measured: **68 s** to create the cluster + **123 s** for ingress-nginx, CoreDNS, the store, the secrets and `helm install` — ~3.2 min from nothing, with every in-cluster image pulled fresh. `verify.sh` is ~2 min (two ingress-controller rollouts). | + +The frontend image alone is 1.17 GB (Nix-based). `down.sh` takes the 4.1 GB with +the cluster and prints what is left in the host cache; keeping those saves ~40 s +of the next run, and the in-cluster pulls happen again either way because a new +node starts with an empty containerd. + +## The three decisions worth knowing + +**Traefik is disabled and ingress-nginx installed in its place.** k3d bundles +Traefik, and every annotation the chart writes on the `/objects` Ingress — +`upstream-vhost`, `rewrite-target`, `use-regex`, `proxy-body-size` — is +ingress-nginx's. The Host rewrite is not expressible in a core Ingress object on +Traefik at all. With the bundled controller the one thing this rig exists to +test cannot work, so `up.sh` passes `--disable=traefik`. + +**The controller listens on 8090 in-cluster, not on 80.** The OIDC issuer is ONE +string: the browser follows it to Keycloak and the frontend POD fetches the +discovery document from it, and Auth.js rejects a document whose `issuer` +differs from the configured one. Making the in-cluster port equal the host port +is what lets one URL be true from both sides. CoreDNS is then taught to answer +`app.` and `auth.hackagon.localhost` with the controller's Service — via a +`rewrite` in the `coredns-custom` ConfigMap, **not a `hosts` block**: k3s's +Corefile already has one, a second makes CoreDNS refuse to start ("this plugin +can only be used once per Server Block") and takes cluster DNS down with it. + +**`*.localhost`, not nip.io or sslip.io.** Measured on the machine this was +written on: the local resolver applies DNS-rebinding protection and returns +nothing for `app.127.0.0.1.nip.io`, while the same query to `8.8.8.8` answers +`127.0.0.1`. Whether a wildcard-DNS service works is a property of whoever runs +DNS for the developer. `*.localhost` needs no resolver at all — curl (≥ 7.77) +and every Chromium, plus Firefox ≥ 84, map it to loopback themselves (RFC 6761) +— and nothing is written to any hosts file. + +## The app is on http and Keycloak is on https, on purpose + +Not an oversight, and not laziness in either direction. + +**Keycloak needs TLS.** Its `AUTH_SESSION_ID` / `KC_RESTART` cookies are in the +FEDERATION scope, which is `SameSite=None`, which forces `Secure` — whatever +scheme it is reached over. Measured before the split: over http Keycloak +answered `Set-Cookie: AUTH_SESSION_ID=…;Secure;HttpOnly;SameSite=None`, curl +discarded them per the cookie spec, and the login POST came back 400 "session +expired, it may have been deleted or cookies are disabled". No chart change +could fix that. (A browser would have completed it — `*.localhost` is a +potentially-trustworthy origin, so Chrome and Firefox accept Secure cookies +there over http. curl has no such exception, and a check that only passes in a +browser is a check this rig cannot run.) `up.sh` mints a 90-day self-signed +certificate into the Secret name the chart already defaults to. + +**The app stays on http, and that half is load-bearing.** The SvelteKit node +adapter INFERS the public scheme, and its unconfigured guess is the literal +string `https`. An https-everywhere rig would agree with that guess by accident +and prove nothing. On http, a frontend that guesses wrong advertises https +callback URLs, issues `__Secure-` cookies the browser will not send back, and +login dies — with every page still answering 200. That is exactly what happened +here, and it is what `frontend.protocolHeader` now prevents. + +**That argument has since been checked from the other side, and it held.** +`scripts/tunnel.sh` runs the same cluster behind real edge-terminated TLS: over +https, removing `protocolHeader` breaks nothing at all, because the guess is +right by accident. Which is exactly the state this mode exists not to be in. +**Keep both.** + +## …and a second mode with REAL https, which is the other half + +`scripts/tunnel.sh up` publishes the SAME cluster through a **named Cloudflare +tunnel** on a zone we own. Nothing is simulated: DNS is public, the certificate +is the zone's real one, and a browser verifies it. + +``` +browser ──https──▶ Cloudflare edge ──tunnel──▶ cloudflared ──http──▶ + ingress-nginx :8090 ──▶ frontend · Keycloak · the store +``` + +**TLS terminates at the edge and the origin stays plain http.** That is not a +shortcut, it is the shape a deployment behind any TLS-terminating proxy has, and +it is the only shape in which `frontend.protocolHeader` has an input: +cloudflared is what puts `X-Forwarded-Proto: https` on the request the cluster +receives. + +**Two hostnames, one tunnel.** The chart routes the app and Keycloak by HOST on +two separate Ingresses, so there is no single name that reaches both. + +| | | +| -------- | ------------------------------------------------------------------------ | +| app | `k3d-hackagon.example.org` | +| Keycloak | `k3d-auth-hackagon.example.org` | +| origin | `http://k3d-hackagon-serverlb:8090` on the `k3d-hackagon` docker network | + +⚠ **Both names are ONE label deep and that is a constraint, not a style.** +Cloudflare's free Universal SSL covers the apex and one label and nothing below +it. Measured against the edge before any record was created: SNI +`auth.k3d-hackagon.example.org` gets **TLS alert 40, handshake failure**, while +a one-label sibling gets the zone's certificate. A browser reads that as a +broken site, not as a missing certificate. + +**The port property is REPLACED, not dropped.** In the localhost mode the +controller listens on 8090 in-cluster as well as on the host so that one issuer +string is true from both sides. A public https URL names no port at all, so the +replacement is stronger: the frontend POD resolves the same public hostname +through public DNS and reaches Keycloak the way the browser does — out to +Cloudflare and back down the tunnel, over the same real certificate. There is +one URL and one path to it, so there is nothing left to disagree. Measured from +inside the cluster before this was built: a pod resolves and reaches +Cloudflare-proxied names over IPv4 in ~290 ms. + +That also **removes `NODE_TLS_REJECT_UNAUTHORIZED=0`**, the line values.k3d.yaml +calls the worst in the file. It is there because the frontend has to accept the +self-signed Keycloak certificate; on a real one it does not, so the overlay sets +`frontend.extraEnv: []` and the pod runs with node's trust store intact. + +### What a browser answers and curl cannot + +`browser-check.sh` (13 checks, Firefox, driven inside the devcontainer where the +e2e suite's Playwright already lives). It exists for one reason: **`__Secure-` +is a rule about the USER AGENT.** A browser must refuse to store a `__Secure-` +cookie that did not arrive over a secure connection. curl implements no such +rule — it would keep and replay that cookie over plain http — so a green curl +login is equally consistent with the prefix working and with it being ignored. + +Observed, signing alice in through the public URL: the callback sets +`__Secure-authjs.session-token` (`Secure; HttpOnly; SameSite=Lax`), Firefox +stores it, no unprefixed twin is set beside it, `/auth/session` fetched from the +page returns alice and a Keycloak access token, and it survives a full page +load. On the `*.localhost` mode `cookies.useSecure` is false and none of that is +reachable. + +### The chain, broken and put back + +Three experiments, because a fix you can break and restore is a fix you have +proven. All three are single commands and all three were run. + +| change | advertised origin through the tunnel | sign-in POST | +| -------------------------------------------- | ------------------------------------ | ------------ | +| baseline | `https://k3d-sdsc-hackathons…` | 302 | +| ingress-nginx `use-forwarded-headers: false` | `http://k3d-sdsc-hackathons…` | **403** | +| chart `frontend.protocolHeader: ""` | `https://k3d-sdsc-hackathons…` | 302 | + +The 403's body is SvelteKit's own +`Cross-site POST form submissions are forbidden`: the app computed an http +origin, the browser sent an https `Origin`, and the CSRF check refused them. +Every page still answered 200. + +**And the third row is the honest result: under real https, removing +`protocolHeader` breaks nothing.** adapter-node's unconfigured guess is the +literal string `https`, which is correct here by accident — which is precisely +why the app is on plain http in the default mode, and why that asymmetry is +worth keeping. What is load-bearing over https is the header being TRUE, and +`protocolHeader` is what makes the scheme observed rather than guessed. + +The cheapest proof that it is READ at all needs no restart and no downtime — +**same pod, same Host header, one hop apart**: + +``` +through the tunnel (cloudflared sends X-Forwarded-Proto: https) + "callbackUrl":"https://k3d-hackagon.example.org/auth/callback/keycloak" +loopback to the same ingress, no X-Forwarded-Proto + "callbackUrl":"http://k3d-hackagon.example.org:8090/auth/callback/keycloak" +``` + +With `protocolHeader` removed the second line becomes `https://…:8090` — the +guess, on an origin nothing serves. That is the original bug, reproduced. + +### Teardown, and what it refuses to leave behind + +- `tunnel.sh down` — stops cloudflared, reinstalls the `*.localhost` values, + puts the Keycloak client's redirect URIs back, deletes `.state/mode.env`, and + **prints both hostnames and the give-up command**. +- `tunnel.sh destroy` — the above plus the tunnel, its credentials file and both + CNAMEs. +- `down.sh` **stops the tunnel BEFORE deleting the cluster**, always. The order + matters: in between, the public URL is a 502 from a healthy-looking tunnel, + which is the least informative failure available. It leaves the DNS records + (they are cheap, they make the next `up` a no-op, and deleting records in a + shared zone is not a thing a routine teardown should do quietly) — with the + tunnel stopped they answer Cloudflare's 1033, "tunnel not found", which is + true. `down.sh --purge` destroys them. +- Nothing tracked ever carries the hostname: the overlay is generated into + gitignored `.state/`, and the names live in the same gitignored + `.claude/skills/cloudflare-tunnel/.env` the other three rigs read. + +`tunnel.sh up` is idempotent — the second run reuses the tunnel and reports +`DNS ok` for both records rather than rewriting them. + +### Two traps this mode has, both about DNS and neither about the tunnel + +**A probe run BEFORE the record exists poisons the local resolver for the zone's +negative TTL** — 1800 s on `example.org`. `tunnel.sh status` asks, so the very +first status call on a name you are about to create costs you half an hour of +`NXDOMAIN` on that machine while the record serves perfectly everywhere else. +`lib.sh` answers it by asking Cloudflare over DoH and pinning `--resolve` for +curl; SNI and the certificate check are untouched, so the pin chooses an edge +and nothing more. + +**The devcontainer's network answers AAAA-only with no IPv6 route out** (the +same fault `.claude/CLAUDE.md` records for the dev tunnels): `getent hosts` +returns two v6 addresses, none reachable, and Firefox fails in 3 ms with +`NS_ERROR_UNKNOWN_HOST`. `browser-check.sh` pins `/etc/hosts` inside the +container from a DoH-resolved A record and removes it again on exit. Measured on +the way: `network.dns.disableIPv6` **is not enough on its own** — it stops the +browser preferring v6, and here there is no A record to fall back to, so the +failure is identical. + +## What it found + +Seven things, all in `helm-chart/`, all fixed here, none of which the rendered +manifest showed. The first six were found here; the seventh (below) was found by +reading and could only be _settled_ here. + +1. **`templates/keycloak-ingress.yaml` hard-coded + `ingressClassName: webapprouting.kubernetes.azure.com`**, the cert-manager + issuer and a TLS block. `frontend.ingress.ingressClass` has always existed; + this object ignored it, so on any cluster that is not the AKS app-routing + addon the Ingress was claimed by no controller — a deployment that serves the + product and not its login. `keycloak.ingress.enabled` was decoration too: the + object rendered whatever it said, and values.yaml said `false`. +2. **`backend.config.server.adminkeycloakid` shipped empty and is required at + boot.** `internal/config/config.go` refuses the whole configuration with + "server.adminkeycloakid is required", so `helm install` of the chart's own + defaults succeeds and the backend then sits in CrashLoopBackOff with the + reason only in a pod log. It is `required` at render time now. +3. **The OIDC callback 502'd through the chart's own Ingress.** The session JWT + carries the Keycloak access and refresh tokens, so `Set-Cookie` on + `/auth/callback/keycloak` is chunked and multi-kilobyte; nginx's default 4k + `proxy_buffer_size` refuses it rather than truncating. It presents as well as + a bug can hide: Keycloak had authenticated, the code had been exchanged, and + the frontend logged "JWT Callback: Initial sign-in successful" with the right + user id. Only the browser sees the 502, only on the last redirect. + `frontend.ingress.proxyBufferSize` (16k) is now written as an annotation. + Applies to the AKS addon too — it is ingress-nginx with the same default. +4. **The frontend advertised an origin that did not exist** (see above); + `frontend.protocolHeader` (`x-forwarded-proto`) and a `frontend.extraEnv` + escape hatch were added. +5. **values.yaml named the ExternalName kill switch wrongly.** It said + `disable-service-external-name` "in the controller ConfigMap". Setting that + key changes nothing — no warning, same upstream in the access log. The real + switch is the controller's command-line flag `--disable-svc-external-name`, + and with it the route does answer 503 exactly as the comment predicted. A + cluster believed to have blocked ExternalName that way has not. + +6. **Keycloak's hostname was hard-coded one label deeper than the app's.** + `frontend.ingress.hosts[].host` has always been free-form; + `hackagon.keycloakHost` was `auth.{baseDomain}` with no override, so putting + the app at `k3d-sdsc-hackathons.example.org` forced Keycloak to + `auth.k3d-sdsc-hackathons.example.org`. **A one-label wildcard certificate + covers the first and not the second** — and Cloudflare's free Universal SSL + is exactly such a certificate, answering the deeper name with TLS alert 40. + So a deployment fronted that way publishes the product on a certificate that + does not cover its login: the same shape as finding 1, from a different + cause. `keycloak.ingress.host` now overrides it, defaulting to the old + derivation, so no existing deployment changes. **This is the only chart + change real HTTPS needed** — everything else about the https mode is + configuration. + +7. **A config-only `helm upgrade` was a silent no-op** (2026-08-14). Not found + by this rig — it was read out of the chart and written down in + `docs/deployment.md` as known-broken — but **the rig is what turned it from a + claim into a measurement, and then proved the fix**. With the annotations + reverted: `helm upgrade` returned in 0.9 s with status `deployed`, the + ConfigMap held the new value, and the running pod (same name, same + `metadata.generation`) still served the old one. Both configs and the + frontend Secret are `subPath` mounts, which the kubelet resolves once at + container start, and no template carried a `checksum/*` annotation, so the + pod template never changed and nothing rolled. Three annotations now do. Step + 6 pins it, and was itself run against the reverted chart to watch it fail — + 10 reds, including the original bug reproduced by the check. + +Three more are recorded but deliberately **not** fixed — one is an arguable +design call and two are cosmetic: + +- **The backend hard-exits at boot if Keycloak's JWKS endpoint is not yet + answering.** On a fresh install Keycloak takes ~90 s (image pull plus schema + migration) and the backend crash-loops until then — 3 restarts on the run that + produced these numbers. It self-heals, so nothing is broken; the cost is + diagnostic. An operator watching `helm install` sees a backend in + CrashLoopBackOff and cannot tell this apart from finding 2, which looks + identical and never recovers. Either retry the JWKS fetch at startup, or give + the Deployment an init container that waits on Keycloak. + +- The frontend's `livenessProbe` is `GET /`, which renders the home page, which + calls the backend. A backend outage therefore restarts every frontend pod for + as long as it lasts — and liveness restarts cannot fix an upstream. It also + masked finding 2: the frontend crash-looped alongside the backend and looked + like the same fault. Readiness on `/` is right; liveness probably wants a + route that does not fan out. +- `keycloak.database.external.database` / `.user` in values.yaml are read by the + subchart as `name` / `username`. The two ignored keys happen to carry the same + strings as the subchart's defaults, so it works by coincidence. + `keycloak.persistence` is ignored outright — the subchart has no such value. + +- **`hackagon.frontendHost` — the host the realm ConfigMap rewrites the client's + redirect URIs to — is `app.{baseDomain}` and ignores + `frontend.ingress.hosts[].host` entirely.** Set the app's host to anything + else and the realm is imported allowing redirects to a hostname that does not + exist; Keycloak then answers the login with `Invalid parameter: redirect_uri`, + which names the parameter and not the mistake. Found while wiring the https + mode, and NOT fixed here because this rig cannot observe the failure — `up.sh` + rewrites the realm itself before handing it to the chart, so the chart's own + replaces are already no-ops, and `tunnel.sh` sets the URIs on the running + Keycloak (a realm imports ONCE, so an upgrade could not change them anyway). + Same family as finding 6: one host value free-form, its partner derived. + +## What the checks actually check + +`verify.sh` never asserts "the pods are Running". Every negative assertion has a +positive control, because this repository has a written record of suites staying +green while testing nothing. + +**Claim 1 — a presigned PUT survives the Host rewrite.** Three legs: the signed +URL is accepted through the chart's `/objects` route (200); the SAME signature +is refused with `SignatureDoesNotMatch` through an Ingress identical but for the +missing `upstream-vhost`; and accepted again through the chart's route, so the +refusal is about the annotation and not a stale URL. The control is applied from +`manifests/control-no-vhost-ingress.yaml` and `verify.sh` asserts the two +annotation sets differ by exactly that one key — a control that has drifted is +not a control. The bucket policy is checked both ways too, and the "private +prefix 403s" check is backed by a presigned GET proving the object is there. + +The signing is `scripts/presign.sh`, a mirror of +`components/backend/internal/storage/sigv4.go`. It exists because the published +images (`ghcr.io/…/backend-service:latest`) **predate the object-storage work on +this branch** — the binary contains no `internal/storage` package and no +`CreateUploadUrl`, so there is no deployed handler to ask. That also makes it +the more honest test of the claim, which is about what the ingress does to a +signed request. + +**Claim 2 — the regex path beats `/`.** Both Ingresses claim the same host; `/` +returns the SvelteKit document; `/objects/<bucket>/<key>` returns the object's +bytes; and `/objectsnotaprefix` does NOT reach the store — the `(/|$)` boundary +is what stops the prefix swallowing application routes. + +**Claim 3 — ingress-nginx accepts an ExternalName upstream.** The chart's +Service is ExternalName, pointing at the endpoint host, and is the `/objects` +backend; traffic through it is served rather than 503'd. Then the flag is turned +ON, the route is watched to 503, and turned off again — otherwise "200" is +equally consistent with the kill switch not existing, which is what the first +attempt at this check accidentally demonstrated. + +**Login round-trip.** Discovery names the public issuer; the frontend advertises +the origin it is actually reached on; sign-in redirects to Keycloak; the realm's +username-first flow is driven in two POSTs; Keycloak redirects back with a code; +**the callback returns 302 and not 502**; and `/auth/session` carries alice's +email and a Keycloak access token. + +**A config-only upgrade reaches the running pod** (step 6, added 2026-08-14). +Both halves are asserted, because each is a bug: a config change MUST roll the +pods, and an unchanged one MUST NOT. The second is the one likely to catch a +mistake — a hash over anything non-deterministic would trade a silent no-op for +a rollout on every upgrade, and this chart really does contain such bytes (the +Keycloak and Postgres subcharts mint passwords during rendering). So the render +is done twice and compared, the live Deployment's annotations are compared +against what the chart renders to, and three probe renders prove the hashes +still TRACK their inputs — determinism alone is also what a constant would give +you. Then the behavioural half: an unchanged upgrade must leave +`metadata.generation` untouched (Kubernetes' own answer to "did the pod template +move"), and a changed one must land in the file the RUNNING container has open, +with nobody restarting anything. + +⚠ **Its values come from `helm get values`, never a hard-coded list of `-f` +files.** This script runs in tunnel mode too, and re-installing the localhost +values there would quietly repoint the release mid-run. That the round-trip is +faithful is not assumed — the no-op check is exactly that claim, so a lossy +round-trip fails loudly instead of silently reinstalling something else. + +**Optional blocks absent.** Read out of the LIVE container — not +`helm template`, not the ConfigMap. The frontend image is distroless (no shell, +no tar), so `kubectl exec` and `kubectl cp` are both out; an ephemeral debug +container with `--profile=sysadmin` reads the real mount through `/proc/1/root`. +The positive control runs FIRST, and it earned its place: with the default debug +profile the read returns "Permission denied" even though both containers run as +uid 0, and both absence assertions passed against that error message. + +## ⚠ It imports the development realm + +`up.sh` imports `tools/configs/keycloak/realm-hackagon.json` — the development +export: **alice, bob, charles and hackagon-admin, all with the password +`aliceandbob`**, all with `emailVerified` and no password policy. It is imported +because the login round-trip has to sign somebody in, and inventing a second +realm would test a realm nobody deploys. `--no-realm` skips it. + +### ⚠⚠ …and `tunnel.sh up` PUBLISHES that realm to the internet + +Everything above is written on the assumption that the rig binds loopback only. +**`scripts/tunnel.sh up` is the one thing here that breaks it**, and the +consequence deserves saying plainly: while the tunnel is up, anyone who learns +the hostname can sign in as **alice, bob, charles or hackagon-admin with the +password `aliceandbob`** — the last of those being a global Admin. There is no +authentication in front of the tunnel; a Cloudflare quick-tunnel-style obscure +name is not one either, and these names are guessable by design. + +Treat a tunnelled cluster as a demo you are watching, not as something to leave +running. `tunnel.sh down` is one command and `down.sh` stops the tunnel before +it deletes anything. If it must live longer than a session, put Cloudflare +Access in front of the hostnames or import a realm that is not the development +export (`up.sh --no-realm`, then create the accounts you actually want). + +That is why this rig is opt-in and why it binds loopback only. Nothing it +generates may be copied anywhere: `helm-chart/values.k3d.yaml` is tracked and +contains **no credential of any kind** — the postgres, keycloak-db, OIDC-client +and object-store secrets are minted per cluster into a gitignored +`.state/secrets.env`, and the storage credentials reach the chart as a Secret +`up.sh` creates with `kubectl`, because the chart refuses to read them from +values at all. + +`values.k3d.yaml` does carry one line that must never travel: +`NODE_TLS_REJECT_UNAUTHORIZED=0` on the frontend, so it will accept the +self-signed Keycloak certificate. The honest alternative needs an `extraVolumes` +hook and `NODE_EXTRA_CA_CERTS`; inventing chart surface to make a test pass is +how test-only surface gets into a production chart. + +## Toolchain + +`helm` and `k3d` are not on this Windows host and `scripts/tools.sh` fetches +pinned binaries into the gitignored `bin/` (k3d 25 MB, helm 18 MB, kubectl 3 +MB). Containerised alternatives were considered and rejected: `k3d` in a +container needs the Docker socket bind-mounted AND a shared path for the +kubeconfig, and on Docker Desktop for Windows the socket has to be spelled +`//var/run/docker.sock` to survive MSYS mangling; `alpine/helm` has to reach an +apiserver published on the HOST's loopback, which is not the container's, so it +needs `--network k3d-<cluster>` and a rewritten server URL. If a machine may not +fetch binaries, both are still possible — that is the shape they need. + +**Two Windows traps, both handled in `lib.sh`.** Paths are converted with +`cygpath -m` (`C:/Users/…`), never `cygpath -w`: helm's `--set-file` value goes +through its strvals parser, which treats `\` as an escape, so a Windows path +arrives as `C:UsersKato…` and helm reports the file missing. And every wrapper +sets `MSYS_NO_PATHCONV=1`, because MSYS rewrites arguments that only LOOK like +paths — a JSON patch's `"/spec/template/…"`, `sh -c 'cat /proc/1/root/…'`. diff --git a/.claude/skills/k3d-chart-rig/manifests/control-no-vhost-ingress.yaml b/.claude/skills/k3d-chart-rig/manifests/control-no-vhost-ingress.yaml new file mode 100644 index 00000000..bbd23c44 --- /dev/null +++ b/.claude/skills/k3d-chart-rig/manifests/control-no-vhost-ingress.yaml @@ -0,0 +1,41 @@ +# THE NEGATIVE CONTROL for claim 1. +# +# A copy of the Ingress the chart renders for /objects, differing in EXACTLY ONE +# thing: no `nginx.ingress.kubernetes.io/upstream-vhost`. Same backend, same +# regex path, same rewrite-target, same body size, same class. +# +# Without it, "the presigned PUT returned 200" is only evidence that a PUT can +# reach a store. With it, a 403 SignatureDoesNotMatch here and a 200 through the +# chart's own route are evidence that THE ANNOTATION is what makes uploads work +# — which is the claim, and the one that has already cost this project days +# because unsigned public reads keep working while every upload dies. +# +# On its own hostname so both routes exist at once and the same signed URL can +# be sent down each. verify.sh asserts that the annotation sets differ by that +# one key and nothing else, so this file cannot quietly drift away from what the +# chart renders. +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: rig-objects-control + namespace: NAMESPACE_PLACEHOLDER + labels: + hackagon.rig/purpose: negative-control + annotations: + nginx.ingress.kubernetes.io/rewrite-target: /$2 + nginx.ingress.kubernetes.io/use-regex: "true" + nginx.ingress.kubernetes.io/proxy-body-size: "64m" + nginx.ingress.kubernetes.io/backend-protocol: "HTTP" +spec: + ingressClassName: nginx + rules: + - host: CONTROL_HOST_PLACEHOLDER + http: + paths: + - path: /objects(/|$)(.*) + pathType: ImplementationSpecific + backend: + service: + name: OBJECTS_SERVICE_PLACEHOLDER + port: + number: STORE_PORT_PLACEHOLDER diff --git a/.claude/skills/k3d-chart-rig/manifests/ingress-nginx-values.yaml b/.claude/skills/k3d-chart-rig/manifests/ingress-nginx-values.yaml new file mode 100644 index 00000000..46a2f794 --- /dev/null +++ b/.claude/skills/k3d-chart-rig/manifests/ingress-nginx-values.yaml @@ -0,0 +1,84 @@ +# ingress-nginx for the k3d rig. +# +# k3d ships TRAEFIK, and every annotation the chart writes on its /objects +# Ingress (`nginx.ingress.kubernetes.io/upstream-vhost`, `rewrite-target`, +# `use-regex`, `proxy-body-size`) is ingress-nginx's. On Traefik the Host +# rewrite is not expressible in a core Ingress object at all — it needs a +# Traefik CRD — so with the bundled controller the one thing this rig exists to +# test cannot work. `up.sh` therefore creates the cluster with +# `--disable=traefik` and installs this instead. +# +# THE PORT IS THE INTERESTING PART. The controller Service listens on 8090, not +# 80, so that the port in the public URL is the same port the cluster's own DNS +# answer reaches. Keycloak's issuer is ONE string: the browser fetches +# `http://auth.hackagon.localhost:8090/realms/hackagon` and the frontend pod +# fetches the discovery document from the same string. If the in-cluster +# listener were on :80 the two could not agree, and Auth.js rejects a discovery +# document whose `issuer` differs from the configured one. +# +# k3s's ServiceLB turns a LoadBalancer Service into a DaemonSet with +# hostPort: 8090 on the node, and k3d publishes host 8090 to that same node +# port — so one number runs the whole way through. +controller: + replicaCount: 1 + + ingressClassResource: + name: nginx + enabled: true + default: true + ingressClass: nginx + # An Ingress with no class must NOT be picked up: the point of this rig is to + # observe that the chart's own ingressClassName selects this controller. + watchIngressWithoutClass: false + + service: + type: LoadBalancer + ports: + http: 8090 + https: 8443 + targetPorts: + http: http + https: https + + # Snippets stay off. Nothing in helm-chart/ uses them, and leaving them + # enabled would let a rig-only annotation stand in for a chart-only one. + allowSnippetAnnotations: false + + config: + # THE SCHEME HAS TO SURVIVE A HOP THIS CONTROLLER DID NOT TERMINATE. + # + # ingress-nginx always sends `X-Forwarded-Proto: $pass_access_scheme` + # upstream, and by default `$pass_access_scheme` is `$scheme` — the scheme of + # the connection IT accepted. Behind a tunnel that terminates TLS at + # Cloudflare's edge and speaks plain http to this controller, that is `http`, + # so the frontend would be told `http` on a request the browser made over + # https: it would advertise an http callback URL, drop the `__Secure-` cookie + # prefix, and 403 the sign-in POST whose `Origin` is https. Every page would + # still answer 200. + # + # With this on, the map becomes `default $http_x_forwarded_proto; '' $scheme` + # — so cloudflared's header is believed when present and the connection's own + # scheme is used when it is not. That second half is why this is safe to + # leave on in the plain `*.localhost` mode: nothing sends the header there, + # the fallback is exactly the previous behaviour, and `verify.sh` still + # passes unchanged. + # + # ⚠ It does mean any client that can reach this controller directly can claim + # to be on https. That is the standard trade for running behind a proxy and + # it is why this rig binds loopback only; a deployment pairs it with + # `proxy-real-ip-cidr` scoped to the proxies it actually has. + use-forwarded-headers: "true" + + resources: + requests: + cpu: 50m + memory: 90Mi + + admissionWebhooks: + enabled: true + + # A single-node cluster: no reason to spread or to keep a second copy. + minAvailable: 0 + +defaultBackend: + enabled: false diff --git a/.claude/skills/k3d-chart-rig/manifests/store-init-job.yaml b/.claude/skills/k3d-chart-rig/manifests/store-init-job.yaml new file mode 100644 index 00000000..b6fd6765 --- /dev/null +++ b/.claude/skills/k3d-chart-rig/manifests/store-init-job.yaml @@ -0,0 +1,47 @@ +# Bucket and bucket policy, once, in-cluster. +# +# The chart deliberately creates neither (helm-chart/templates/NOTES.txt says +# so). Without the policy every stored image 403s while uploads succeed, which +# is the same shape of asymmetric failure as the missing Host rewrite — so the +# rig sets it and then PROVES both halves in verify.sh: a public prefix +# readable with no credentials at all, and a private one that is not. +# +# The public prefixes are the ones .devcontainer/rustfs-init.sh publishes, and +# for the same reason: they render on pages that need no login, which is what +# lets their stable path live in a database column. +apiVersion: batch/v1 +kind: Job +metadata: + name: store-init + namespace: hackagon-store +spec: + backoffLimit: 6 + ttlSecondsAfterFinished: 3600 + template: + spec: + restartPolicy: OnFailure + containers: + - name: mc + image: MC_IMAGE_PLACEHOLDER + env: + - name: AK + valueFrom: + { secretKeyRef: { name: store-credentials, key: accessKey } } + - name: SK + valueFrom: + { secretKeyRef: { name: store-credentials, key: secretKey } } + command: ["/bin/sh", "-c"] + args: + - | + set -e + until mc alias set s3 http://store.hackagon-store.svc.cluster.local:9000 "$AK" "$SK" >/dev/null 2>&1; do + echo "waiting for the store"; sleep 2 + done + mc mb --ignore-existing s3/BUCKET_PLACEHOLDER + for p in hackathons users site; do + mc anonymous set download "s3/BUCKET_PLACEHOLDER/$p" + done + # A private object to prove the other half of the policy: teams/* + # must NOT be world-readable. + echo "private-canary" | mc pipe s3/BUCKET_PLACEHOLDER/teams/canary.txt + mc anonymous list s3/BUCKET_PLACEHOLDER diff --git a/.claude/skills/k3d-chart-rig/manifests/store.yaml b/.claude/skills/k3d-chart-rig/manifests/store.yaml new file mode 100644 index 00000000..9f54ede4 --- /dev/null +++ b/.claude/skills/k3d-chart-rig/manifests/store.yaml @@ -0,0 +1,84 @@ +# TEST-ONLY object store for the rig. +# +# The chart deploys no store, on purpose (helm-chart/values.yaml says why: a +# bucket must outlive the release). This is not a chart template and never +# should be — it is the thing the chart POINTS AT, supplied by the rig so the +# /objects route has an upstream to prove itself against. +# +# It lives in its OWN namespace and is addressed by cluster DNS +# (store.hackagon-store.svc.cluster.local:9000). That is deliberate: it means +# `storage.objects.ingress.service.name` stays EMPTY, so the chart renders its +# ExternalName Service — which is the third claim this rig exists to check. +# Naming the Service directly would route the same traffic while skipping the +# object under test. +# +# emptyDir, not a PVC: `down.sh` deletes the cluster, so persistence would be a +# lie. Credentials come from the Secret `store-credentials`, created by up.sh +# from generated values — nothing here carries one. +apiVersion: v1 +kind: Namespace +metadata: + name: hackagon-store +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + name: store + namespace: hackagon-store + labels: { app: store } +spec: + replicas: 1 + selector: + matchLabels: { app: store } + template: + metadata: + labels: { app: store } + spec: + containers: + - name: minio + image: MINIO_IMAGE_PLACEHOLDER + args: + [ + "server", + "/data", + "--address", + ":9000", + "--console-address", + ":9001", + ] + env: + - name: MINIO_ROOT_USER + valueFrom: + { secretKeyRef: { name: store-credentials, key: accessKey } } + - name: MINIO_ROOT_PASSWORD + valueFrom: + { secretKeyRef: { name: store-credentials, key: secretKey } } + # No browser console needed and one less surface to reason about. + - name: MINIO_BROWSER + value: "off" + ports: + - { name: s3, containerPort: 9000 } + volumeMounts: + - { name: data, mountPath: /data } + readinessProbe: + httpGet: { path: /minio/health/ready, port: 9000 } + initialDelaySeconds: 3 + periodSeconds: 3 + resources: + requests: { cpu: 50m, memory: 128Mi } + limits: { memory: 1Gi } + volumes: + - name: data + emptyDir: {} +--- +apiVersion: v1 +kind: Service +metadata: + name: store + namespace: hackagon-store + labels: { app: store } +spec: + type: ClusterIP + selector: { app: store } + ports: + - { name: s3, port: 9000, targetPort: 9000, protocol: TCP } diff --git a/.claude/skills/k3d-chart-rig/scripts/browser-check.sh b/.claude/skills/k3d-chart-rig/scripts/browser-check.sh new file mode 100644 index 00000000..d821f02e --- /dev/null +++ b/.claude/skills/k3d-chart-rig/scripts/browser-check.sh @@ -0,0 +1,92 @@ +#!/usr/bin/env bash +# Sign in through the PUBLIC https hostname with a real browser. +# +# bash scripts/browser-check.sh # alice +# bash scripts/browser-check.sh bob hunter2 +# +# Tunnel mode only — there is nothing here the *.localhost mode can answer, and +# it says so rather than passing vacuously. +# +# WHY A BROWSER AT ALL, when verify.sh already drives a login with curl: the +# property this mode adds is a COOKIE-PREFIX rule, and cookie prefixes live in +# the user agent. `__Secure-` means "a user agent must refuse to store this +# unless it arrived over a secure connection" — curl implements no such rule, so +# a green curl login is equally consistent with the prefix working and with it +# being ignored. See the header of browser-login.mjs. +# +# WHERE IT RUNS. Everything else in this rig runs on the HOST, because that is +# where docker and the pinned k3d/helm/kubectl live. This one runs inside the +# devcontainer, because that is where Playwright and its Firefox already are — +# installing a second 300 MB browser on the host to avoid a docker exec is the +# wrong trade, and the thing under test is a public URL that both can reach. +set -euo pipefail +. "$(dirname "${BASH_SOURCE[0]}")/lib.sh" + +USER_NAME="${1:-alice}" +USER_PASS="${2:-aliceandbob}" +DEV_CONTAINER="${RIG_DEV_CONTAINER:-devcontainer-dev-1}" +# Inside the container the repo is a bind mount at a different path than on the +# host, so the script is addressed by the container's view of it. +IN_CONTAINER_REPO="${RIG_CONTAINER_REPO:-/workspaces/hackagon}" + +[ "$RIG_MODE" = "tunnel" ] || die "not in tunnel mode — run scripts/tunnel.sh up first (this check has no meaning on plain http)" +docker inspect "$DEV_CONTAINER" >/dev/null 2>&1 || + die "container '$DEV_CONTAINER' is not there — it carries Playwright and its Firefox (see devcontainer-up)" + +# --- the address the browser will dial --------------------------------- +# Resolved over DoH on the HOST and handed in, because the container's own +# resolver is the thing that may be unable to answer. A pin is a routing choice, +# not a trust choice: SNI is still the real hostname and Firefox still verifies +# the chain, which is the whole point of this mode. +doh_a() { # <fqdn> -> first IPv4 + curl -sS --max-time 10 "https://1.1.1.1/dns-query?name=$1&type=A" \ + -H "accept: application/dns-json" 2>/dev/null | + tr ',' '\n' | grep -oE '"data":"[0-9.]+"' | head -1 | sed 's/.*:"//;s/"//' +} +APP_IP="$(doh_a "$APP_HOST")" +AUTH_IP="$(doh_a "$AUTH_HOST")" +[ -n "$APP_IP" ] && [ -n "$AUTH_IP" ] || + die "could not resolve $APP_HOST / $AUTH_HOST over DoH — is the tunnel up?" +ok "edge $APP_IP for $APP_HOST · $AUTH_IP for $AUTH_HOST" + +step "signing in as $USER_NAME through $APP_URL" + +# The pin is written and REMOVED by the same shell, including on failure: a +# leftover hosts entry outlives the tunnel it names, and the next thing to look +# that name up would get a stale address with no clue where it came from. +# +# MSYS_NO_PATHCONV=1 is load-bearing on the Git Bash host and its absence is not +# subtle: `/workspaces/hackagon` is an argument that LOOKS like a path, so MSYS +# rewrites it to `C:/Program Files/Git/workspaces/hackagon` before docker sees +# it, and the container reports a directory it has never heard of. Same trap the +# kubectl/helm wrappers in lib.sh carry the flag for. +MSYS_NO_PATHCONV=1 MSYS2_ARG_CONV_EXCL='*' \ + docker exec -i "$DEV_CONTAINER" bash -s -- \ + "$APP_HOST" "$APP_IP" "$AUTH_HOST" "$AUTH_IP" "$APP_URL" \ + "$IN_CONTAINER_REPO" "$USER_NAME" "$USER_PASS" <<'REMOTE' +set -euo pipefail +APP_HOST="$1"; APP_IP="$2"; AUTH_HOST="$3"; AUTH_IP="$4"; APP_URL="$5" +REPO="$6"; U="$7"; P="$8" +MARK="# k3d-chart-rig browser-check" + +unpin() { sed -i "/$MARK\$/d" /etc/hosts 2>/dev/null || true; } +trap unpin EXIT INT TERM +unpin +printf '%s %s %s\n%s %s %s\n' \ + "$APP_IP" "$APP_HOST" "$MARK" "$AUTH_IP" "$AUTH_HOST" "$MARK" >>/etc/hosts + +export PATH="$REPO/.devenv/profile/bin:$PATH" +# Playwright's browsers were installed by the e2e suite as the `vscode` user; +# this exec runs as root, whose cache is empty. Point at the one that exists +# rather than downloading a second copy. +export PLAYWRIGHT_BROWSERS_PATH="${PLAYWRIGHT_BROWSERS_PATH:-/home/vscode/.cache/ms-playwright}" +[ -d "$PLAYWRIGHT_BROWSERS_PATH" ] || { + echo "error: no Playwright browsers at $PLAYWRIGHT_BROWSERS_PATH" >&2 + echo " run the e2e suite once, or set PLAYWRIGHT_BROWSERS_PATH." >&2 + exit 1 +} +export SHOT=/tmp/k3d-tunnel-login.png +cd "$REPO/.claude/skills/hackathon-e2e" +node "$REPO/.claude/skills/k3d-chart-rig/scripts/browser-login.mjs" \ + "$APP_URL" "$AUTH_HOST" "$U" "$P" +REMOTE diff --git a/.claude/skills/k3d-chart-rig/scripts/browser-login.mjs b/.claude/skills/k3d-chart-rig/scripts/browser-login.mjs new file mode 100644 index 00000000..4acc1daa --- /dev/null +++ b/.claude/skills/k3d-chart-rig/scripts/browser-login.mjs @@ -0,0 +1,237 @@ +// A REAL BROWSER signing in through the public https hostname. +// +// node browser-login.mjs <app-url> <auth-host> [username] [password] +// +// Everything else in this rig is curl, and curl cannot answer the question this +// mode exists to ask. `__Secure-` is a COOKIE-PREFIX rule and it lives in the +// browser: a user agent must refuse to store a `__Secure-` cookie that did not +// arrive over a secure connection, and must refuse to send one over an insecure +// one. curl implements no such rule — it would keep and replay that cookie over +// plain http quite happily, so a green curl login says nothing about whether the +// prefix works. The *.localhost mode could not test it either, because it has no +// https at all and `frontend.config.cookies.useSecure` is false there. +// +// So the assertions below are specifically the ones that need a browser: +// +// 1. the whole round-trip completes across TWO public hostnames (the app's and +// Keycloak's), each with its own certificate check by the browser itself; +// 2. the session cookie is NAMED `__Secure-authjs.session-token` and the +// browser STORED it — which it would refuse to do over http; +// 3. a later request carries it back, read out of the app's own /auth/session +// rather than out of the cookie jar: "the browser has a cookie" and "the +// server accepted it" are different claims, and this repo has a written +// record of measuring the first and reporting the second. +// +// Firefox, because it is what the e2e suite already installs in this container +// and because a second engine's cookie rules are not the thing under test. +// +// Playwright is BORROWED from the hackathon-e2e skill rather than installed a +// second time — that skill already carries it and its browsers, and two copies +// of a 300 MB dependency drift. ESM resolves from the SCRIPT's directory, not +// the cwd, so `createRequire` anchored at that package.json is what finds it; +// `PW_FROM` moves the anchor for a machine that keeps it elsewhere. +import { createRequire } from "node:module" +const require = createRequire( + process.env.PW_FROM || + new URL("../../hackathon-e2e/package.json", import.meta.url), +) +const { firefox } = require("@playwright/test") + +const [appUrl, authHost, username = "alice", password = "aliceandbob"] = + process.argv.slice(2) +if (!appUrl || !authHost) { + console.error( + "usage: node browser-login.mjs <app-url> <auth-host> [user] [pass]", + ) + process.exit(2) +} +const appHost = new URL(appUrl).hostname + +let pass = 0 +let fail = 0 +const ok = (m) => { + pass++ + console.log(` ok ${m}`) +} +const bad = (m, detail) => { + fail++ + console.log(` FAIL ${m}`) + if (detail !== undefined) console.log(` ${detail}`) +} +const check = (m, cond, detail) => (cond ? ok(m) : bad(m, detail)) + +// ⚠ THIS PROCESS MAY NEED AN /etc/hosts PIN TO RESOLVE ITS OWN TARGET, and that +// is a property of the network rather than of the tunnel. The one these +// containers run on answers AAAA-ONLY for Cloudflare-proxied names and has no +// IPv6 route out: `getent hosts` returns two v6 addresses, none reachable, and +// Firefox fails in 3 ms with NS_ERROR_UNKNOWN_HOST. `browser-check.sh` writes +// the pin from a DoH-resolved A record and takes it away again — the same fix +// .claude/CLAUDE.md records for the dev tunnels. +// +// Measured on the way: `firefoxUserPrefs: {"network.dns.disableIPv6": true}` is +// NOT enough on its own. It stops the browser PREFERRING v6, and here the +// resolver never offers an A record at all, so turning v6 off leaves nothing to +// fall back to and the failure is identical. The pin has to supply the address. +const browser = await firefox.launch() +// No ignoreHTTPSErrors, and its absence is an assertion: every navigation below +// is verified against Firefox's own trust store. Setting it would turn the one +// thing this mode adds over the *.localhost rig into a no-op. +const ctx = await browser.newContext() +const page = await ctx.newPage() + +try { + // ── 1 · the app, over a certificate the browser accepts ──────────────── + const resp = await page.goto(appUrl, { + waitUntil: "domcontentloaded", + timeout: 60_000, + }) + check( + "the landing page loads over https", + resp?.status() === 200, + `status ${resp?.status()}`, + ) + check( + "…on the public hostname", + new URL(page.url()).protocol === "https:", + page.url(), + ) + + const sec = await page.evaluate(() => window.isSecureContext) + // Not decoration: `*.localhost` over plain http is ALSO a secure context (RFC + // 6761 makes it potentially-trustworthy), so this is true in both modes and + // proves nothing on its own — the certificate check is what `goto` succeeding + // already established. It is asserted because the __Secure- rule below is + // stated in terms of it. + check("the page is a secure context", sec === true, String(sec)) + + // ── 2 · sign in, which crosses to the OTHER public hostname ──────────── + // + // The header's "Log in" is a <button> whose handler only exists after + // hydration, so a click that lands before the bundle has run is SWALLOWED — + // no error, no navigation. That has cost this repo a debugging session + // already (.claude/CLAUDE.md, "the avatar swallowed its first click"), and it + // is worse here: every hop is a public round-trip, so hydration takes longer + // than it does on localhost. Wait for the network to settle, then retry. + const clickLogin = async () => { + const btn = page.getByRole("button", { name: "Log in" }) + const link = page.getByRole("link", { name: /log ?in|sign ?in/i }) + if (await btn.count()) return btn.first().click() + if (await link.count()) return link.first().click() + await page.goto(`${appUrl}/auth/signin`, { waitUntil: "domcontentloaded" }) + const provider = page.getByRole("button", { name: /keycloak/i }) + if (await provider.count()) await provider.first().click() + } + + let reached = false + for (let attempt = 1; attempt <= 3 && !reached; attempt++) { + await page.waitForLoadState("networkidle").catch(() => {}) + await clickLogin() + try { + await page.waitForURL((u) => u.hostname === authHost, { timeout: 30_000 }) + reached = true + } catch { + if (attempt < 3) + await page.goto(appUrl, { waitUntil: "domcontentloaded" }) + } + } + check( + "sign-in reaches Keycloak on its own public hostname", + reached, + page.url(), + ) + if (!reached) throw new Error(`never left ${page.url()}`) + + await page.locator("#username").waitFor({ timeout: 30_000 }) + await page.locator("#username").fill(username) + if (!(await page.locator("#password").isVisible())) { + await page.locator("#kc-login").click() + await page.locator("#password").waitFor({ timeout: 30_000 }) + } + await page.locator("#password").fill(password) + await page.locator("#kc-login").click() + + // Back on the app. THIS is the hop that 502s when the proxy buffer is too + // small — the callback's Set-Cookie block is chunked and multi-kilobyte. + await page.waitForURL((u) => u.hostname === appHost, { timeout: 60_000 }) + check("Keycloak redirects back to the app", true, page.url()) + + // ── 3 · the cookie the browser would have refused over http ──────────── + const cookies = await ctx.cookies() + const names = cookies.map((c) => c.name) + // POSITIVE CONTROL FIRST. An empty jar agrees with every claim below, and + // "no cookie called X" reads identically to "no cookies at all". + check( + "the browser kept cookies for the app host", + cookies.some((c) => c.domain.endsWith(appHost)), + names.join(", ") || "(none)", + ) + + const session = cookies.filter((c) => /authjs\.session-token/.test(c.name)) + check( + "a session cookie exists at all", + session.length > 0, + names.join(", ") || "(none)", + ) + check( + "it is named __Secure-authjs.session-token", + session.every((c) => c.name.startsWith("__Secure-")), + session.map((c) => c.name).join(", "), + ) + check( + "…and carries the Secure and HttpOnly flags", + session.every((c) => c.secure && c.httpOnly), + JSON.stringify( + session.map((c) => ({ n: c.name, s: c.secure, h: c.httpOnly })), + ), + ) + check( + "no unprefixed authjs.session-token was set alongside it", + !names.includes("authjs.session-token"), + names.join(", "), + ) + + // ── 4 · the server accepts it back ───────────────────────────────────── + // Read through the PAGE, so the request carries the real cookie jar and the + // browser's own rules about what it will send where. + const sess = await page.evaluate(async (u) => { + const r = await fetch(`${u}/auth/session`, { credentials: "include" }) + return { status: r.status, body: await r.text() } + }, appUrl) + check( + "/auth/session identifies the signed-in user", + /alice/.test(sess.body), + `${sess.status} ${sess.body.slice(0, 200)}`, + ) + check( + "…and carries a Keycloak access token", + /"accessToken":"ey/.test(sess.body), + sess.body.slice(0, 120), + ) + + // ── 5 · it survives a fresh navigation ───────────────────────────────── + // The cookie being in the jar is not the same as it being sent on the next + // request; a Secure cookie that the browser declines to replay would look + // identical up to here. + await page.goto(`${appUrl}/`, { waitUntil: "domcontentloaded" }) + const after = await page.evaluate(async (u) => { + const r = await fetch(`${u}/auth/session`, { credentials: "include" }) + return await r.text() + }, appUrl) + check( + "the session survives a full page load", + /alice/.test(after), + after.slice(0, 160), + ) + + await page.screenshot({ + path: process.env.SHOT || "/tmp/k3d-tunnel-login.png", + fullPage: false, + }) +} catch (err) { + bad("the run threw", String(err).split("\n")[0]) +} finally { + await browser.close() +} + +console.log(`\n ${pass} passed, ${fail} failed`) +process.exit(fail === 0 ? 0 : 1) diff --git a/.claude/skills/k3d-chart-rig/scripts/down.sh b/.claude/skills/k3d-chart-rig/scripts/down.sh new file mode 100644 index 00000000..633020be --- /dev/null +++ b/.claude/skills/k3d-chart-rig/scripts/down.sh @@ -0,0 +1,114 @@ +#!/usr/bin/env bash +# Delete the cluster. One command, and it takes everything with it. +# +# bash scripts/down.sh # delete the cluster, keep .state/ +# bash scripts/down.sh --purge # …and the credentials, AND give the +# # public hostnames back to Cloudflare +# bash scripts/down.sh --stop # stop the containers, keep the cluster +# +# There is nothing to preserve: the store is an emptyDir, postgres runs without +# a PVC, and every credential is regenerated by the next up.sh. `--purge` exists +# only so a fresh run is provably fresh. +# +# It touches nothing outside its own cluster: the dev stack's compose project, +# its ports (3000/8081/8082/8180/15432/9000/9001/8010) and the three tunnels +# that serve it are untouched, because none of them is a k3d resource. +# +# ── THE ONE THING IT OWNS OUTSIDE THE CLUSTER ──────────────────────────────── +# +# This rig's OWN tunnel (scripts/tunnel.sh) is different: its whole job is to +# point two public hostnames at a cluster that is about to stop existing. +# +# * the cloudflared container is STOPPED, always. Left running it would keep +# answering for hostnames whose origin has been deleted — Cloudflare would +# return 502 from a healthy-looking tunnel, which is the least informative +# failure available. +# * the DNS records and the tunnel are LEFT unless --purge. They are cheap, +# they make the next `tunnel.sh up` a no-op instead of a re-registration, +# and deleting records in a shared zone is not something a routine teardown +# should do quietly. With the tunnel stopped they answer Cloudflare's 1033 +# ("tunnel not found"), which says what is actually true. +# * either way the exact give-up command is PRINTED, every time, naming both +# hostnames. A hostname you have forgotten you own is the failure mode here. +set -euo pipefail +. "$(dirname "${BASH_SOURCE[0]}")/lib.sh" +# shellcheck source=../../lib/cf-named-tunnel.sh +. "$REPO_ROOT/.claude/skills/lib/cf-named-tunnel.sh" + +PURGE=0 +STOP=0 +for arg in "$@"; do + case "$arg" in + --purge) PURGE=1 ;; + --stop) STOP=1 ;; + *) die "unknown flag $arg" ;; + esac +done + +# --- the tunnel, before the cluster it points at ------------------------ +# Before, not after: between deleting the cluster and stopping cloudflared the +# public URL is a 502, and that window is exactly what someone watching the link +# would misread as a broken tunnel. +TUNNEL_NAME="${RIG_TUNNEL_NAME:-hackagon-k3d}" +TUNNEL_HOSTS=() +if cf_configured; then + cf_load >/dev/null 2>&1 || true + # Same fallbacks as tunnel.sh, and derived from the zone for the same + # reason: no tracked file in this repository names a hostname. + TUNNEL_HOSTS=("${K3D_HOSTNAME:-k3d.${CLOUDFLARE_ZONE:-}}" + "${K3D_AUTH_HOSTNAME:-k3d-auth.${CLOUDFLARE_ZONE:-}}") +fi +if cfn_running "$TUNNEL_NAME"; then + step "stopping this rig's tunnel" + cfn_stop "$TUNNEL_NAME" +elif [ -n "$(docker ps -aq -f "name=^$(cfn_container "$TUNNEL_NAME")\$" 2>/dev/null)" ]; then + cfn_stop "$TUNNEL_NAME" +fi +rm -f "$MODE_ENV" + +if ! cluster_exists; then + ok "cluster '$CLUSTER' is already gone" +else + if [ "$STOP" = 1 ]; then + step "stopping cluster '$CLUSTER'" + k3d cluster stop "$CLUSTER" >/dev/null + ok "stopped (k3d cluster start $CLUSTER to resume)" + exit 0 + fi + step "deleting cluster '$CLUSTER'" + k3d cluster delete "$CLUSTER" >/dev/null + ok "cluster, its network and its volumes are gone" +fi + +if [ "$PURGE" = 1 ]; then + rm -f "$KUBECONFIG_FILE" "$GEN_VALUES" "$REALM_FILE" "$SECRETS_ENV" \ + "$TLS_CERT" "$TLS_KEY" "$STATE_DIR/values.tunnel.yaml" + rm -f "$STATE_DIR"/*.html "$STATE_DIR"/*.txt "$STATE_DIR"/*.body 2>/dev/null || true + ok "purged $STATE_DIR" + if [ "${#TUNNEL_HOSTS[@]}" -gt 0 ]; then + step "giving the public hostnames back" + cfn_destroy "$TUNNEL_NAME" "${TUNNEL_HOSTS[@]}" || true + fi +fi + +# --- say what is left, by name ----------------------------------------- +# Printed whether or not a tunnel was ever created here: "there is nothing to +# clean up" and "you were never told" look the same from the outside, and the +# second is how a record survives a laptop. +if [ "$PURGE" != 1 ] && [ "${#TUNNEL_HOSTS[@]}" -gt 0 ]; then + say "" + say " Still registered on Cloudflare (nothing serves them now — 1033):" + say " ${TUNNEL_HOSTS[0]}" + say " ${TUNNEL_HOSTS[1]}" + say " Give them up:" + say " bash $HERE/tunnel.sh destroy" + say " bash $HERE/down.sh --purge # same thing, with the credentials" +fi + +say "" +say " The ~4.1 GB of images inside the node's containerd went with the cluster." +say " What stays is ~420 MB in the HOST cache (k3s, k3d-proxy, k3d-tools):" +say " docker image rm $K3S_IMAGE \\" +say " ghcr.io/k3d-io/k3d-proxy:${K3D_VERSION#v} ghcr.io/k3d-io/k3d-tools:${K3D_VERSION#v}" +say " Leaving them saves ~40 s of the next up.sh; the in-cluster pulls (~2 min)" +say " happen again either way, because a new node has an empty containerd." diff --git a/.claude/skills/k3d-chart-rig/scripts/install.sh b/.claude/skills/k3d-chart-rig/scripts/install.sh new file mode 100644 index 00000000..2746fa4d --- /dev/null +++ b/.claude/skills/k3d-chart-rig/scripts/install.sh @@ -0,0 +1,45 @@ +#!/usr/bin/env bash +# Re-run just the `helm upgrade` against an already-running cluster. +# +# bash scripts/install.sh # upgrade in place +# bash scripts/install.sh --restart # …and roll the app pods afterwards +# +# The point of splitting this out of up.sh: iterating on helm-chart/ costs +# seconds this way, and ~4 minutes if the cluster is rebuilt each time. +set -euo pipefail +. "$(dirname "${BASH_SOURCE[0]}")/lib.sh" + +RESTART=0 +[ "${1:-}" = "--restart" ] && RESTART=1 + +require_cluster +load_secrets +[ -f "$GEN_VALUES" ] || die "no $GEN_VALUES — run scripts/up.sh" + +REALM_ARGS=() +[ -f "$REALM_FILE" ] && REALM_ARGS=(--set-file "realmJson=$(winpath "$REALM_FILE")") + +step "helm upgrade" +helm upgrade --install "$RELEASE" "$(winpath "$CHART_DIR")" \ + --namespace "$NAMESPACE" \ + -f "$(winpath "$CHART_DIR/values.yaml")" \ + -f "$(winpath "$CHART_DIR/values.k3d.yaml")" \ + -f "$(winpath "$GEN_VALUES")" \ + "${REALM_ARGS[@]}" \ + --timeout 15m >&2 + +if [ "$RESTART" = 1 ]; then + step "rolling app pods" + kubectl -n "$NAMESPACE" rollout restart deployment "$RELEASE-backend" "$RELEASE-frontend" >/dev/null +fi + +step "waiting" +for d in backend frontend; do + if kubectl -n "$NAMESPACE" rollout status "deployment/$RELEASE-$d" --timeout=300s >/dev/null 2>&1; then + ok "$RELEASE-$d ready" + else + bad "$RELEASE-$d did NOT become ready" + kubectl -n "$NAMESPACE" get pods + kubectl -n "$NAMESPACE" logs "deployment/$RELEASE-$d" --tail=30 || true + fi +done diff --git a/.claude/skills/k3d-chart-rig/scripts/lib.sh b/.claude/skills/k3d-chart-rig/scripts/lib.sh new file mode 100644 index 00000000..8806f270 --- /dev/null +++ b/.claude/skills/k3d-chart-rig/scripts/lib.sh @@ -0,0 +1,292 @@ +#!/usr/bin/env bash +# Shared settings and helpers for the k3d chart rig. +# +# Sourced by every script here. Nothing in this file starts anything. +set -euo pipefail + +HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +SKILL_DIR="$(cd "$HERE/.." && pwd)" +REPO_ROOT="$(cd "$SKILL_DIR/../../.." && pwd)" +CHART_DIR="$REPO_ROOT/helm-chart" +STATE_DIR="$SKILL_DIR/.state" +BIN_DIR="$SKILL_DIR/bin" +MANIFEST_DIR="$SKILL_DIR/manifests" + +# --- names ------------------------------------------------------------ +# Release name is `hackagon` on purpose: helm-chart/values.yaml hard-codes +# `keycloak.realmImport.existingConfigMap: hackagon-realm`, and the ConfigMap +# the chart actually creates is `<fullname>-realm`. Those two only agree when +# the release is called hackagon. +CLUSTER="${RIG_CLUSTER:-hackagon}" +RELEASE="${RIG_RELEASE:-hackagon}" +NAMESPACE="${RIG_NAMESPACE:-hackagon}" +STORE_NS="${RIG_STORE_NAMESPACE:-hackagon-store}" + +# --- the hostname ------------------------------------------------------ +# `*.localhost`, not nip.io/sslip.io. Both of those were measured failing on +# this machine: the local resolver applies DNS-rebinding protection and refuses +# to hand back an answer inside 127.0.0.0/8, so `app.127.0.0.1.nip.io` does not +# resolve at all while `nslookup … 8.8.8.8` does. That is a property of whoever +# runs DNS for the developer, which is not something a test rig may depend on. +# +# `*.localhost` involves no resolver: curl (>= 7.77) and every Chromium, plus +# Firefox >= 84, map it to loopback themselves per RFC 6761. Nothing is written +# to /etc/hosts or the Windows hosts file. Scripts additionally pass --resolve +# so that an older curl works too. +BASE_DOMAIN="${RIG_BASE_DOMAIN:-hackagon.localhost}" +APP_HOST="app.$BASE_DOMAIN" +AUTH_HOST="auth.$BASE_DOMAIN" + +# --- which mode the cluster is currently installed in ------------------- +# `local` (the default) is everything above: *.localhost, loopback, plain http +# for the app. `tunnel` is the same cluster published through a NAMED Cloudflare +# tunnel on a zone we own — real hostnames, real certificates, TLS terminated at +# the edge, plain http to the origin. See scripts/tunnel.sh. +# +# The mode is read from a file the installer WRITES, not inferred from whether a +# tunnel happens to be running: which URLs the release is configured for is a +# property of the last `helm upgrade`, and a container can be stopped without +# that changing. A stopped tunnel in tunnel mode is a rig whose public URL is +# down — which is what it should report, rather than quietly measuring a +# different URL that would pass. +MODE_ENV="$STATE_DIR/mode.env" +RIG_MODE="${RIG_MODE:-}" +if [ -z "$RIG_MODE" ] && [ -f "$MODE_ENV" ]; then + # shellcheck disable=SC1090 + . "$MODE_ENV" +fi +RIG_MODE="${RIG_MODE:-local}" + +# --- host ports this rig claims --------------------------------------- +# Deliberately clear of everything the dev stack publishes: +# 3000 backend · 8081 frontend (vite) · 8082 frontend (built) · 8180 keycloak +# 15432 postgres · 9000/9001 rustfs · 8010 plausible +HTTP_PORT="${RIG_HTTP_PORT:-8090}" # ingress-nginx, http — the app +HTTPS_PORT="${RIG_HTTPS_PORT:-8443}" # ingress-nginx, https — Keycloak only +API_PORT="${RIG_API_PORT:-6551}" # k3s apiserver + +# THE APP IS ON PLAIN HTTP AND KEYCLOAK IS ON TLS, and the split is deliberate. +# +# Keycloak's AUTH_SESSION_ID / KC_RESTART cookies are in its FEDERATION scope, +# which is `SameSite=None` — and SameSite=None requires Secure, so Keycloak +# marks them Secure whatever scheme it is reached over. Measured on this rig +# before the split: over http it answered +# `Set-Cookie: AUTH_SESSION_ID=…;Secure;HttpOnly;SameSite=None`, curl dropped +# them per the cookie spec, and the login POST came back 400 "session expired, +# it may have been deleted or cookies are disabled". Nothing in the chart could +# have fixed that; it is Keycloak's rule. +# +# (A real browser would have completed it: `*.localhost` is a +# potentially-trustworthy origin, so Chrome and Firefox accept Secure cookies +# there over http. curl has no such exception, and a test that only passes in +# a browser is a test this rig cannot run.) +# +# Leaving the APP on http is not a shortcut either — it is the load-bearing +# half. The SvelteKit node adapter INFERS the public scheme, and its +# unconfigured guess is the literal string "https". An https-everywhere rig +# would agree with that guess by accident and prove nothing. On http, a +# frontend that guesses wrong advertises https callback URLs, issues +# `__Secure-` cookies the browser will not send back, and login dies while +# every page still answers 200. That is the failure `frontend.protocolHeader` +# was added for, and this is where it is observed. +APP_URL="http://$APP_HOST:$HTTP_PORT" +AUTH_URL="https://$AUTH_HOST:$HTTPS_PORT" + +# --- tunnel mode overrides --------------------------------------------- +# The one thing the localhost mode cannot do: a REAL certificate. Cloudflare +# terminates TLS at its edge and cloudflared speaks plain http to the ingress +# controller, so the origin is unencrypted and `X-Forwarded-Proto: https` is the +# only thing that tells the app what the browser actually used. That is +# production's shape, and it is what `frontend.protocolHeader` is for. +# +# THE PORT PROPERTY IS REPLACED, NOT DROPPED. In localhost mode the controller +# listens on 8090 in-cluster as well as on the host so that ONE issuer string is +# true from both sides. A public https URL names no port at all, so the +# replacement is stronger: the frontend POD resolves the same public hostname +# through public DNS and reaches it the same way the browser does — out to +# Cloudflare and back down the tunnel — over the same real certificate. There is +# one URL and one path to it, so there is nothing left to disagree. + +# The loopback URL is kept under its own name in BOTH modes. Two callers need +# it while tunnel mode is on: the Keycloak client's redirect URIs, which stay +# valid for both origins so switching modes cannot lock anyone out, and the +# `localcurl` control below. +LOCAL_APP_HOST="app.$BASE_DOMAIN" +LOCAL_APP_URL="http://$LOCAL_APP_HOST:$HTTP_PORT" + +if [ "$RIG_MODE" = "tunnel" ]; then + APP_HOST="${RIG_APP_HOST:?tunnel mode needs RIG_APP_HOST (scripts/tunnel.sh writes it)}" + AUTH_HOST="${RIG_AUTH_HOST:?tunnel mode needs RIG_AUTH_HOST (scripts/tunnel.sh writes it)}" + APP_URL="https://$APP_HOST" + AUTH_URL="https://$AUTH_HOST" +fi + +# --- when THIS machine cannot look the public name up ------------------ +# Two states produce it and neither is a broken tunnel: the LAN resolver here +# answers AAAA-only for these names on a network with no IPv6 route out (written +# up in .claude/CLAUDE.md), and a resolver that was asked for the name BEFORE the +# record existed caches the NXDOMAIN for minutes afterwards — which is every +# first run, because `tunnel.sh status` asks. +# +# So: ask Cloudflare over DoH and pin the connection to the address it gives. +# This changes WHICH EDGE is dialled and nothing else — SNI is still the real +# hostname and the certificate is still verified, so the thing this mode exists +# to prove is untouched. `-k` would be the shortcut that throws it away. +RIG_RESOLVE_ARGS="${RIG_RESOLVE_ARGS:-}" +rig_pin_edge() { + [ "$RIG_MODE" = "tunnel" ] || return 0 + [ -z "$RIG_RESOLVE_ARGS" ] || return 0 + local h ip + for h in "$APP_HOST" "$AUTH_HOST"; do + ip="$(curl -sS --max-time 10 "https://1.1.1.1/dns-query?name=$h&type=A" \ + -H "accept: application/dns-json" 2>/dev/null | + tr ',' '\n' | grep -oE '"data":"[0-9.]+"' | head -1 | + sed 's/.*:"//;s/"//')" || true + [ -n "$ip" ] && RIG_RESOLVE_ARGS="$RIG_RESOLVE_ARGS --resolve $h:443:$ip" + done + [ -n "$RIG_RESOLVE_ARGS" ] || return 1 +} +# Only when the machine actually needs it: a resolver that works must be left to +# work, or the pin would hide a genuinely dead record. +if [ "$RIG_MODE" = "tunnel" ] && [ -z "$RIG_RESOLVE_ARGS" ]; then + if [ "${RIG_RESOLVE_V4:-0}" = 1 ] || + ! curl -sS -o /dev/null --max-time 8 "https://$APP_HOST/" 2>/dev/null; then + rig_pin_edge || true + fi +fi + +# --- pinned toolchain -------------------------------------------------- +K3D_VERSION="${RIG_K3D_VERSION:-v5.8.3}" +HELM_VERSION="${RIG_HELM_VERSION:-v3.19.0}" +KUBECTL_VERSION="${RIG_KUBECTL_VERSION:-v1.31.5}" +# k3s image: must match the k3d default for the pinned k3d, or say so loudly. +K3S_IMAGE="${RIG_K3S_IMAGE:-rancher/k3s:v1.31.5-k3s1}" +INGRESS_NGINX_CHART_VERSION="${RIG_INGRESS_NGINX_VERSION:-4.13.3}" +MINIO_IMAGE="${RIG_MINIO_IMAGE:-quay.io/minio/minio:RELEASE.2025-04-22T22-12-26Z}" +MC_IMAGE="${RIG_MC_IMAGE:-quay.io/minio/mc:RELEASE.2025-04-16T18-13-26Z}" + +# --- object store ------------------------------------------------------ +# In its OWN namespace, reached by its cluster DNS name. That is what makes the +# chart render its ExternalName Service (claim 3): `storage.endpoint` names a +# host that is not a Service in the release namespace, so +# `storage.objects.ingress.service.name` stays empty and the chart supplies the +# adapter itself. +STORE_HOST="store.$STORE_NS.svc.cluster.local" +STORE_PORT=9000 +STORE_ENDPOINT="http://$STORE_HOST:$STORE_PORT" +STORE_BUCKET="${RIG_BUCKET:-hackagon}" + +# --- generated files --------------------------------------------------- +KUBECONFIG_FILE="$STATE_DIR/kubeconfig.yaml" +GEN_VALUES="$STATE_DIR/values.generated.yaml" +REALM_FILE="$STATE_DIR/realm-rewritten.json" +SECRETS_ENV="$STATE_DIR/secrets.env" +TLS_CERT="$STATE_DIR/auth-tls.crt" +TLS_KEY="$STATE_DIR/auth-tls.key" + +mkdir -p "$STATE_DIR" + +# --- output ------------------------------------------------------------ +if [ -t 1 ]; then + C_OK=$'\033[32m' + C_BAD=$'\033[31m' + C_DIM=$'\033[2m' + C_OFF=$'\033[0m' +else + C_OK='' + C_BAD='' + C_DIM='' + C_OFF='' +fi +say() { printf '%s\n' "$*" >&2; } +step() { printf '\n%s==> %s%s\n' "$C_DIM" "$*" "$C_OFF" >&2; } +ok() { printf '%s ok%s %s\n' "$C_OK" "$C_OFF" "$*" >&2; } +bad() { printf '%s FAIL%s %s\n' "$C_BAD" "$C_OFF" "$*" >&2; } +die() { + bad "$*" + exit 1 +} + +# --- path translation -------------------------------------------------- +# k3d.exe, helm.exe and kubectl.exe are WINDOWS binaries invoked from Git Bash. +# They do not understand /c/Users/... , and MSYS's automatic conversion only +# fires on arguments it recognises as paths — which it does not for +# `--set-file key=/c/...`. Convert explicitly wherever a path is passed. +# +# `cygpath -m` (mixed: `C:/Users/…`), NOT `cygpath -w` (`C:\Users\…`). Helm's +# --set-file value goes through its strvals parser, which treats `\` as an +# escape character: a Windows path arrives as `C:UsersKato…` and helm reports +# the file as missing. Forward slashes are accepted by every Windows binary +# here and survive the parser untouched. +if command -v cygpath >/dev/null 2>&1; then + winpath() { cygpath -m "$1"; } + IS_WINDOWS=1 +else + winpath() { printf '%s' "$1"; } + IS_WINDOWS=0 +fi + +# --- tool wrappers ----------------------------------------------------- +# Every wrapper points at the rig's OWN kubeconfig. The user's ~/.kube/config +# is never read and never written: k3d is called with +# --kubeconfig-update-default=false, so an existing cluster context on this +# machine keeps whatever it was pointing at. +# +# MSYS_NO_PATHCONV=1 throughout: every path these commands need is passed +# through winpath() deliberately, and MSYS's automatic conversion would corrupt +# the ones that only LOOK like paths — a JSON patch's "/spec/template/…", a +# jsonpath, `sh -c 'cat /proc/1/root/…'`. Off is the predictable setting. +k3d() { MSYS_NO_PATHCONV=1 "$BIN_DIR/k3d" "$@"; } +kubectl() { MSYS_NO_PATHCONV=1 KUBECONFIG="$(winpath "$KUBECONFIG_FILE")" "$BIN_DIR/kubectl" "$@"; } +helm() { MSYS_NO_PATHCONV=1 KUBECONFIG="$(winpath "$KUBECONFIG_FILE")" "$BIN_DIR/helm" "$@"; } + +# curl against the rig's ingress. +# --resolve belt and braces: modern curl maps *.localhost itself, older ones +# do not. +# -k the Keycloak certificate is minted by up.sh and signed by +# nothing. Scoped to this function so it can never leak into a +# call that ought to be verifying a real chain. +# +# In TUNNEL mode neither flag is used, and their absence is the assertion: the +# hostname is looked up in public DNS and the certificate is verified against the +# system trust store. `-k` there would throw away the only thing this mode adds. +# (`RIG_RESOLVE_V4=1` pins both names to a DoH-resolved Cloudflare IPv4 for the +# network described in .claude/CLAUDE.md, which answers AAAA-only with no v6 +# route out. It still verifies the chain — it only chooses the edge.) +rigcurl() { + if [ "$RIG_MODE" = "tunnel" ]; then + curl ${RIG_RESOLVE_ARGS:+$RIG_RESOLVE_ARGS} \ + --max-time "${RIG_CURL_TIMEOUT:-30}" "$@" + return + fi + curl --resolve "$APP_HOST:$HTTP_PORT:127.0.0.1" \ + --resolve "$AUTH_HOST:$HTTPS_PORT:127.0.0.1" \ + --resolve "$APP_HOST:$HTTPS_PORT:127.0.0.1" \ + -k --max-time "${RIG_CURL_TIMEOUT:-30}" "$@" +} + +# Reach the ingress controller DIRECTLY on loopback, presenting whatever Host +# the public name is. Same pod, same release, same nginx — the ONLY difference +# from a tunnelled request is that no `X-Forwarded-Proto` arrives. That makes it +# the positive control for the header chain: if the app answers differently to +# these two, the scheme is being READ rather than assumed. +localcurl() { # <host> <path> [curl args…] + local host="$1" path="$2" + shift 2 + curl --resolve "$host:$HTTP_PORT:127.0.0.1" \ + --max-time "${RIG_CURL_TIMEOUT:-30}" "$@" "http://$host:$HTTP_PORT$path" +} + +cluster_exists() { k3d cluster list -o json 2>/dev/null | grep -q "\"name\":\"$CLUSTER\""; } + +require_cluster() { + cluster_exists || die "cluster '$CLUSTER' does not exist — run scripts/up.sh" + [ -f "$KUBECONFIG_FILE" ] || die "no kubeconfig at $KUBECONFIG_FILE — run scripts/up.sh" +} + +# Read a generated secret back out of .state/secrets.env. +load_secrets() { + [ -f "$SECRETS_ENV" ] || die "no $SECRETS_ENV — run scripts/up.sh" + # shellcheck disable=SC1090 + . "$SECRETS_ENV" +} diff --git a/.claude/skills/k3d-chart-rig/scripts/presign.sh b/.claude/skills/k3d-chart-rig/scripts/presign.sh new file mode 100644 index 00000000..713b719f --- /dev/null +++ b/.claude/skills/k3d-chart-rig/scripts/presign.sh @@ -0,0 +1,174 @@ +#!/usr/bin/env bash +# AWS SigV4, query-string ("presigned") flavour — a line-for-line mirror of +# components/backend/internal/storage/sigv4.go and the PresignPut/PresignGet +# wrappers in client.go. +# +# bash presign.sh put <key> <content-type> <size-bytes> [ttl-seconds] +# bash presign.sh get <key> [ttl-seconds] +# bash presign.sh put --direct <key> … # absolute URL at the store +# +# `put` prints the ROOT-RELATIVE browser URL the backend would hand out — +# `<publicPrefix>/<bucket>/<key>?X-Amz-…` — because that is the value the whole +# /objects contract is about. `--direct` prints the absolute URL at the store +# instead, which is what the leg of the test that bypasses the ingress uses. +# +# WHY THIS EXISTS AT ALL, rather than calling StorageService.CreateUploadUrl: +# the published images (ghcr.io/…/backend-service:latest) predate the storage +# work on this branch — their binary contains no `internal/storage` package and +# no CreateUploadUrl — so there is no deployed handler to ask. Signing here is +# also the more honest test of the claim under examination: the claim is about +# what the INGRESS does to a signed request, and this makes the signature the +# known quantity. +# +# Mirrored exactly, because each of these is a way to be silently wrong: +# * signed headers are host + content-type + content-length for PUT, host +# alone for GET (client.go PresignPut/PresignGet); +# * the payload hash is the literal UNSIGNED-PAYLOAD; +# * `/` is left alone in the path and encoded in the query (uriEncode's +# encodeSlash); +# * the signed Host carries the PORT, and under virtual-hosted style the +# bucket prefix — the same rule as `hackagon.storageSignHost` in the chart. +# +# Limitation, stated rather than discovered: uriEncode here is byte-wise over +# ASCII. Keys with non-ASCII characters would need the multi-byte loop that +# sigv4.go has; the rig only ever signs ASCII keys. +set -euo pipefail +. "$(dirname "${BASH_SOURCE[0]}")/lib.sh" + +ALGORITHM="AWS4-HMAC-SHA256" +TERMINATOR="aws4_request" +S3SERVICE="s3" +UNSIGNED_PAYLOAD="UNSIGNED-PAYLOAD" + +# --- configuration, same shape as config.StorageConfig ----------------- +load_secrets +ACCESS_KEY="${STORAGE_ACCESS_KEY:?}" +SECRET_KEY="${STORAGE_SECRET_KEY:?}" +REGION="${RIG_STORAGE_REGION:-us-east-1}" +BUCKET="$STORE_BUCKET" +PUBLIC_PREFIX="${RIG_PUBLIC_PREFIX:-/objects}" +USE_PATH_STYLE="${RIG_USE_PATH_STYLE:-1}" + +if [ "$USE_PATH_STYLE" = 1 ]; then + SIGN_HOST="$STORE_HOST:$STORE_PORT" +else + SIGN_HOST="$BUCKET.$STORE_HOST:$STORE_PORT" +fi +DIRECT_BASE="http://$SIGN_HOST" + +# --- primitives -------------------------------------------------------- +uriencode() { # value encode_slash(0|1) + local s="$1" slash="${2:-0}" out="" i c + local LC_ALL=C + for ((i = 0; i < ${#s}; i++)); do + c="${s:i:1}" + case "$c" in + [a-zA-Z0-9._~-]) out+="$c" ;; + /) if [ "$slash" = 1 ]; then out+="%2F"; else out+="/"; fi ;; + *) out+="$(printf '%%%02X' "'$c")" ;; + esac + done + printf '%s' "$out" +} + +sha256hex() { printf '%s' "$1" | openssl dgst -sha256 -r | awk '{print $1}'; } +tohex() { printf '%s' "$1" | od -An -tx1 | tr -d ' \n'; } +hmachex() { printf '%s' "$2" | openssl dgst -sha256 -mac HMAC -macopt "hexkey:$1" -r | awk '{print $1}'; } + +signing_key() { # datestamp -> hex + local k + k="$(hmachex "$(tohex "AWS4$SECRET_KEY")" "$1")" + k="$(hmachex "$k" "$REGION")" + k="$(hmachex "$k" "$S3SERVICE")" + hmachex "$k" "$TERMINATOR" +} + +canonical_uri() { # key + local key="$1" + if [ "$USE_PATH_STYLE" = 1 ]; then + if [ -z "$key" ]; then + printf '/%s' "$(uriencode "$BUCKET" 0)" + else printf '/%s/%s' "$(uriencode "$BUCKET" 0)" "$(uriencode "$key" 0)"; fi + else + if [ -z "$key" ]; then + printf '/' + else printf '/%s' "$(uriencode "$key" 0)"; fi + fi +} + +# presign METHOD KEY TTL SIGNED_HEADER_LINES... +# SIGNED_HEADER_LINES are "name:value" with the name already lowercased; `host` +# is added here because SigV4 requires it and because it is the one header a +# proxy in front of the store rewrites. +presign() { + local method="$1" key="$2" ttl="$3" + shift 3 + local amz_date datestamp scope uri raw_query + amz_date="$(date -u +%Y%m%dT%H%M%SZ)" + datestamp="$(date -u +%Y%m%d)" + scope="$datestamp/$REGION/$S3SERVICE/$TERMINATOR" + + local lines=("$@" "host:$SIGN_HOST") + # canonicalHeaders: sorted by name, "name:value\n" each, names ';'-joined. + local sorted names="" block="" + sorted="$(printf '%s\n' "${lines[@]}" | LC_ALL=C sort)" + while IFS= read -r line; do + [ -n "$line" ] || continue + names="${names:+$names;}${line%%:*}" + block+="$line"$'\n' + done <<<"$sorted" + + # canonicalQuery: names sorted, both halves encoded with '/' escaped. The + # five parameters below already sort into this order. + raw_query="X-Amz-Algorithm=$(uriencode "$ALGORITHM" 1)" + raw_query+="&X-Amz-Credential=$(uriencode "$ACCESS_KEY/$scope" 1)" + raw_query+="&X-Amz-Date=$amz_date" + raw_query+="&X-Amz-Expires=$ttl" + raw_query+="&X-Amz-SignedHeaders=$(uriencode "$names" 1)" + + uri="$(canonical_uri "$key")" + + local canonical_request string_to_sign signature + canonical_request="$method"$'\n'"$uri"$'\n'"$raw_query"$'\n'"$block"$'\n'"$names"$'\n'"$UNSIGNED_PAYLOAD" + string_to_sign="$ALGORITHM"$'\n'"$amz_date"$'\n'"$scope"$'\n'"$(sha256hex "$canonical_request")" + signature="$(hmachex "$(signing_key "$datestamp")" "$string_to_sign")" + + PRESIGN_URI="$uri" + PRESIGN_QUERY="$raw_query&X-Amz-Signature=$signature" +} + +# --- CLI --------------------------------------------------------------- +main() { + local direct=0 + local verb="${1:-}" + shift || true + if [ "${1:-}" = "--direct" ]; then + direct=1 + shift + fi + + case "$verb" in + put) + local key="$1" ctype="$2" size="$3" ttl="${4:-300}" + presign PUT "$key" "$ttl" "content-type:$ctype" "content-length:$size" + ;; + get) + local key="$1" ttl="${2:-300}" + presign GET "$key" "$ttl" + ;; + *) + say "usage: presign.sh put [--direct] <key> <content-type> <size> [ttl]" + say " presign.sh get [--direct] <key> [ttl]" + exit 2 + ;; + esac + + if [ "$direct" = 1 ]; then + printf '%s%s?%s\n' "$DIRECT_BASE" "$PRESIGN_URI" "$PRESIGN_QUERY" + else + printf '%s%s?%s\n' "$PUBLIC_PREFIX" "$PRESIGN_URI" "$PRESIGN_QUERY" + fi +} + +# Only run the CLI when executed, so verify.sh can source this for presign(). +if [ "${BASH_SOURCE[0]}" = "$0" ]; then main "$@"; fi diff --git a/.claude/skills/k3d-chart-rig/scripts/tools.sh b/.claude/skills/k3d-chart-rig/scripts/tools.sh new file mode 100644 index 00000000..1d2da176 --- /dev/null +++ b/.claude/skills/k3d-chart-rig/scripts/tools.sh @@ -0,0 +1,97 @@ +#!/usr/bin/env bash +# Put a pinned k3d, helm and kubectl in the skill's own gitignored bin/. +# +# bash scripts/tools.sh # install what is missing +# bash scripts/tools.sh --force # re-download everything +# +# Why download rather than run them in containers: +# +# k3d talks to the Docker socket and writes a kubeconfig. In a container +# it needs the socket bind-mounted AND a shared path for the +# kubeconfig, and on Docker Desktop for Windows the socket path has to +# be spelled `//var/run/docker.sock` to survive MSYS mangling. One +# 25 MB static binary removes all of that. +# helm the previous attempt at this used `docker run alpine/helm`, which +# works but has to reach the apiserver: the published API port is on +# the HOST's loopback, which is not the container's, so it would need +# `--network k3d-<cluster>` plus a rewritten server URL. Same +# conclusion. +# kubectl a Windows kubectl may already be on PATH (Docker Desktop ships one). +# It is still installed here, pinned, so the rig does not depend on +# which version happens to be installed. +# +# A container fallback is documented in SKILL.md for a machine where fetching +# binaries is not allowed. +set -euo pipefail +. "$(dirname "${BASH_SOURCE[0]}")/lib.sh" + +FORCE=0 +[ "${1:-}" = "--force" ] && FORCE=1 + +mkdir -p "$BIN_DIR" + +case "$(uname -s)" in +MINGW* | MSYS* | CYGWIN*) + OS=windows + EXT=.exe + ;; +Darwin) + OS=darwin + EXT= + ;; +*) + OS=linux + EXT= + ;; +esac +case "$(uname -m)" in +x86_64 | amd64) ARCH=amd64 ;; +arm64 | aarch64) ARCH=arm64 ;; +*) die "unsupported architecture $(uname -m)" ;; +esac + +fetch() { # name url + local name="$1" url="$2" dest="$BIN_DIR/$1$EXT" + if [ -x "$dest" ] && [ "$FORCE" -eq 0 ]; then + ok "$name already present ($("$dest" version --short 2>/dev/null | head -1 || echo present))" + return + fi + step "fetching $name" + curl -fsSL --retry 3 -o "$dest.part" "$url" || die "download failed: $url" + mv "$dest.part" "$dest" + chmod +x "$dest" + ok "$name -> $dest" +} + +fetch k3d "https://github.com/k3d-io/k3d/releases/download/$K3D_VERSION/k3d-$OS-$ARCH$EXT" + +if [ ! -x "$BIN_DIR/helm$EXT" ] || [ "$FORCE" -eq 1 ]; then + step "fetching helm $HELM_VERSION" + tmp="$STATE_DIR/helm-dl" + rm -rf "$tmp" + mkdir -p "$tmp" + if [ "$OS" = windows ]; then + curl -fsSL --retry 3 -o "$tmp/helm.zip" \ + "https://get.helm.sh/helm-$HELM_VERSION-windows-$ARCH.zip" || die "helm download failed" + (cd "$tmp" && unzip -q helm.zip) + mv "$tmp/windows-$ARCH/helm.exe" "$BIN_DIR/helm.exe" + else + curl -fsSL --retry 3 -o "$tmp/helm.tgz" \ + "https://get.helm.sh/helm-$HELM_VERSION-$OS-$ARCH.tar.gz" || die "helm download failed" + (cd "$tmp" && tar xzf helm.tgz) + mv "$tmp/$OS-$ARCH/helm" "$BIN_DIR/helm" + chmod +x "$BIN_DIR/helm" + fi + rm -rf "$tmp" + ok "helm -> $BIN_DIR/helm$EXT" +else + ok "helm already present" +fi + +fetch kubectl "https://dl.k8s.io/release/$KUBECTL_VERSION/bin/$OS/$ARCH/kubectl$EXT" + +step "versions" +"$BIN_DIR/k3d$EXT" version | sed 's/^/ /' >&2 +"$BIN_DIR/helm$EXT" version --short | sed 's/^/ helm /' >&2 +"$BIN_DIR/kubectl$EXT" version --client=true -o yaml 2>/dev/null | + awk '/gitVersion/{print " kubectl " $2; exit}' >&2 || true diff --git a/.claude/skills/k3d-chart-rig/scripts/tunnel.sh b/.claude/skills/k3d-chart-rig/scripts/tunnel.sh new file mode 100644 index 00000000..6316154f --- /dev/null +++ b/.claude/skills/k3d-chart-rig/scripts/tunnel.sh @@ -0,0 +1,351 @@ +#!/usr/bin/env bash +# Publish the k3d cluster on REAL hostnames with a REAL certificate, through a +# named Cloudflare tunnel — then put it back. +# +# bash scripts/tunnel.sh up # switch the release to https + start the tunnel +# bash scripts/tunnel.sh down # switch back to *.localhost, stop the tunnel +# bash scripts/tunnel.sh destroy # …and give the hostnames up (tunnel + DNS) +# bash scripts/tunnel.sh status # what is running, and what the DNS says +# +# WHY THIS EXISTS. The rig's default mode serves the app over plain http on +# `*.localhost`, on purpose: adapter-node's unconfigured guess for the public +# scheme is the literal string "https", so an https-everywhere rig agrees with a +# wrong guess by accident. That asymmetry found `frontend.protocolHeader`. This +# is the other half — the shape a deployment actually has, and the only one in +# which `__Secure-` cookies, a verified chain and a browser's real HTTPS rules +# are exercised at all. +# +# browser ──https──▶ Cloudflare edge ──tunnel──▶ cloudflared ──http──▶ +# ingress-nginx :8090 ──▶ frontend / Keycloak / the store +# +# TLS terminates at the edge. The origin is plain http and cloudflared is what +# adds `X-Forwarded-Proto: https`, which is exactly the header +# `frontend.protocolHeader` reads. Nothing in the cluster holds a certificate. +# +# WHAT IT DOES NOT TOUCH: the dev stack, the three tunnels that serve it +# (`cf-named-hackagon`, `-plausible`, `-openreplay`), and the rig's own +# `*.localhost` mode — `down` restores that exactly, and `verify.sh` is the +# check that it did. +set -euo pipefail +. "$(dirname "${BASH_SOURCE[0]}")/lib.sh" +# shellcheck source=../../lib/cf-named-tunnel.sh +. "$REPO_ROOT/.claude/skills/lib/cf-named-tunnel.sh" + +# ── names ────────────────────────────────────────────────────────────── +# One tunnel, two hostnames, because the chart routes the app and Keycloak by +# HOST on two separate Ingresses. Path-muxing them onto one name (which is what +# the dev stack's caddy does) would need Keycloak on a relative path and would +# skip the chart's own keycloak Ingress — the object this rig found a bug in. +# +# BOTH NAMES ARE ONE LABEL DEEP, and that is not a style choice. Cloudflare's +# free Universal SSL covers the apex and one label and nothing below it: +# measured against the edge before anything was created, `auth.k3d-sdsc-…` (two +# labels) answers a TLS handshake with alert 40 while `k3d-auth-sdsc-…` (one) +# gets the zone's real certificate. `k3d-` says which cluster, and says it is +# the throwaway one. +TUNNEL_NAME="${RIG_TUNNEL_NAME:-hackagon-k3d}" +TUNNEL_NETWORK="k3d-$CLUSTER" +TUNNEL_ORIGIN="http://k3d-$CLUSTER-serverlb:$HTTP_PORT" +TUNNEL_VALUES="$STATE_DIR/values.tunnel.yaml" + +load_hostnames() { + cf_load || { + say "" + bad "no Cloudflare credentials — this mode needs a zone you own." + cf_explain_unconfigured >&2 + exit 1 + } + : "${CLOUDFLARE_ZONE:?}" + # Read from the same gitignored .env the other three rigs read, so a + # hostname lives in exactly one place and NO TRACKED FILE NAMES ONE. The + # fallbacks are derived from the configured zone rather than written out, + # for that reason: `k3d.<zone>` says which cluster without committing + # anybody's domain to this repository. + TUNNEL_APP_HOST="${K3D_HOSTNAME:-k3d.$CLOUDFLARE_ZONE}" + TUNNEL_AUTH_HOST="${K3D_AUTH_HOSTNAME:-k3d-auth.$CLOUDFLARE_ZONE}" + TUNNEL_APP_URL="https://$TUNNEL_APP_HOST" + TUNNEL_AUTH_URL="https://$TUNNEL_AUTH_HOST" +} + +# ── Keycloak's redirect URIs ─────────────────────────────────────────── +# The realm is imported ONCE, when Keycloak first starts against an empty +# database, so a later `helm upgrade` cannot change the client's redirect URIs — +# the ConfigMap it renders is only ever read by an import that has already +# happened. Switching modes therefore has to talk to the running Keycloak. +# +# Both origins are allowed while the tunnel is up. A single-origin list would +# make the switch a cliff: the first person to sign in on the OTHER URL gets +# "Invalid parameter: redirect_uri" from Keycloak, which names nothing anyone +# can act on. +# +# The script goes to the pod on STDIN, never in argv. Two reasons and both have +# bitten this repo: the values are JSON, so they carry double quotes that a +# `sh -c "…$var…"` on this side would end the string on; and the admin password +# would otherwise be visible in `ps` on this machine for the life of the call +# (cf-api.sh feeds curl its bearer token the same way, for the same reason). +kc_sh() { # remote sh script on stdin + kubectl -n "$NAMESPACE" exec -i "deploy/$RELEASE-keycloak" -c keycloak -- sh -s +} + +# `'` inside a single-quoted shell word, spelled the only way that works. +sq() { printf "'%s'" "$(printf '%s' "$1" | sed "s/'/'\\\\''/g")"; } + +kcadm_login() { # emits the login line for the remote script + local u p + u="$(kubectl -n "$NAMESPACE" get secret "$RELEASE-keycloak-admin" -o jsonpath='{.data.admin-username}' | base64 -d)" + p="$(kubectl -n "$NAMESPACE" get secret "$RELEASE-keycloak-admin" -o jsonpath='{.data.admin-password}' | base64 -d)" + # --config /tmp/…: $HOME here is /opt/keycloak and kcadm's default config + # path under it is not reliably writable by uid 1000. + printf 'set -e\nKC=/opt/keycloak/bin/kcadm.sh\n' + printf '$KC config credentials --config /tmp/kcadm.json --server http://localhost:8080 --realm master --user %s --password %s >/dev/null 2>&1\n' \ + "$(sq "$u")" "$(sq "$p")" +} + +set_redirect_uris() { # <origin…> + local id uris="" origins="" o + id="$({ + kcadm_login + printf '$KC get clients -r hackagon -q clientId=hackagon-frontend --fields id --format csv --noquotes --config /tmp/kcadm.json\n' + } | kc_sh | tr -d '\r' | grep -E '^[0-9a-f-]{36}$' | head -1)" + [ -n "$id" ] || die "could not find the hackagon-frontend client in the realm" + for o in "$@"; do + uris="${uris:+$uris,}\"$o/*\"" + origins="${origins:+$origins,}\"$o\"" + done + { + kcadm_login + printf '$KC update clients/%s -r hackagon -s %s -s %s --config /tmp/kcadm.json\n' \ + "$id" "$(sq "redirectUris=[$uris]")" "$(sq "webOrigins=[$origins]")" + } | kc_sh >/dev/null + ok "Keycloak accepts redirects to: $*" +} + +# ── the controller has to pass the scheme through ────────────────────── +# ingress-nginx overwrites X-Forwarded-Proto with the scheme of the connection +# IT terminated unless `use-forwarded-headers` is on. Without it the frontend is +# told `http` on a request the browser made over https and login dies with every +# page answering 200 — see the comment in manifests/ingress-nginx-values.yaml. +# +# Applied by re-running the same `helm upgrade` up.sh uses, so the values file +# stays the single description of this controller. Skipped when it is already +# set, because that upgrade costs ~40 s. +ensure_forwarded_headers() { + local cur + cur="$(kubectl -n ingress-nginx get configmap ingress-nginx-controller \ + -o jsonpath='{.data.use-forwarded-headers}' 2>/dev/null || true)" + if [ "$cur" = "true" ]; then + ok "ingress-nginx already trusts X-Forwarded-Proto" + return 0 + fi + step "ingress-nginx: trusting X-Forwarded-Proto" + helm upgrade --install ingress-nginx ingress-nginx/ingress-nginx \ + --version "$INGRESS_NGINX_CHART_VERSION" \ + --namespace ingress-nginx --create-namespace \ + -f "$(winpath "$MANIFEST_DIR/ingress-nginx-values.yaml")" \ + --wait --timeout 10m >/dev/null + ok 'use-forwarded-headers=true (absent header still falls back to $scheme)' +} + +# ── the values overlay ───────────────────────────────────────────────── +write_values() { + cat >"$TUNNEL_VALUES" <<YAML +# GENERATED by scripts/tunnel.sh — gitignored, and it carries the public +# hostname, which is why it is not a tracked file. Layered ON TOP of +# helm-chart/values.k3d.yaml, so everything not mentioned here is still the +# rig's normal test configuration. +# +# Read it as the diff between "a laptop on *.localhost" and "the same cluster +# behind a TLS-terminating proxy" — which is the diff between this rig and a +# deployment. + +# The real zone. Only \`hackagon.frontendHost\` still derives from it (the realm +# ConfigMap), and up.sh has already rewritten the realm to the URL in use, so +# every host below is stated outright rather than derived. +baseDomain: "$CLOUDFLARE_ZONE" + +frontend: + config: + cookies: + # TRUE, and this is the property the localhost mode cannot test. Auth.js + # names the session cookie \`__Secure-authjs.session-token\` when it + # believes the connection is secure, and a browser refuses to store a + # \`__Secure-\` cookie that did not arrive over https. On a real + # certificate it does; that is the whole point of this mode. + useSecure: true + oidc: + issuer: "$TUNNEL_AUTH_URL/realms/hackagon" + # EMPTIED ON PURPOSE — values.k3d.yaml sets NODE_TLS_REJECT_UNAUTHORIZED=0 so + # the frontend will accept the self-signed certificate up.sh mints for + # Keycloak. There is no self-signed certificate on this path: the frontend pod + # fetches the discovery document from the same public URL the browser uses, + # over Cloudflare's certificate, verified by node's own trust store. A list + # value replaces rather than merges, so [] really does remove it. + extraEnv: [] + ingress: + hosts: + - host: "$TUNNEL_APP_HOST" + paths: + - path: / + pathType: Prefix + # No TLS block anywhere in this file. Cloudflare holds the certificate; the + # cluster speaks plain http and would only be able to offer a self-signed + # one. An empty list also keeps ingress-nginx's ssl-redirect off, which + # matters: with a TLS block it would 308 the plain-http request the tunnel + # makes, and the browser would bounce between the edge and the origin. + tls: [] + +backend: + config: + oidc: + # issuerurl is COMPARED against the \`iss\` claim, so it is the public URL. + # jwksurl is FETCHED and stays on the in-cluster short path — inherited + # from values.k3d.yaml, unchanged. + issuerurl: "$TUNNEL_AUTH_URL/realms/hackagon" + +keycloak: + hostname: + # No port: this one really is on 443. Keycloak 26 derives the public scheme + # from this URL, and \`proxy.headers: xforwarded\` (values.k3d.yaml) makes it + # believe the X-Forwarded-* headers cloudflared and nginx put on the request. + hostname: "$TUNNEL_AUTH_URL" + ingress: + # The override this mode needed the chart to grow. \`auth.{baseDomain}\` would + # be \`auth.$CLOUDFLARE_ZONE\` — a name that belongs to the zone rather than to + # this throwaway cluster — and any \`k3d-\`-prefixed baseDomain would put + # Keycloak two labels deep, where Universal SSL has no certificate for it. + host: "$TUNNEL_AUTH_HOST" + # Explicit null, not {}: helm deep-merges values files and an empty map + # merges as "no change", so values.yaml's secret name would survive. + tlsSecretName: null +YAML +} + +install() { # <values-overlay…> + local -a extra=() + [ -f "$REALM_FILE" ] && extra=(--set-file "realmJson=$(winpath "$REALM_FILE")") + local -a files=(-f "$(winpath "$CHART_DIR/values.yaml")" + -f "$(winpath "$CHART_DIR/values.k3d.yaml")") + local f + for f in "$@"; do files+=(-f "$(winpath "$f")"); done + files+=(-f "$(winpath "$GEN_VALUES")") + helm upgrade --install "$RELEASE" "$(winpath "$CHART_DIR")" \ + --namespace "$NAMESPACE" "${files[@]}" "${extra[@]}" --timeout 15m >&2 +} + +wait_rollout() { + local d + for d in keycloak frontend backend; do + kubectl -n "$NAMESPACE" rollout status "deployment/$RELEASE-$d" --timeout=300s >/dev/null && + ok "$d rolled out" || bad "$d did NOT roll out" + done +} + +# ===================================================================== +cmd_up() { + require_cluster + load_secrets + load_hostnames + + step "hostnames" + say " app $TUNNEL_APP_URL" + say " keycloak $TUNNEL_AUTH_URL" + say " origin $TUNNEL_ORIGIN on docker network $TUNNEL_NETWORK" + + ensure_forwarded_headers + + step "release → public https" + write_values + install "$TUNNEL_VALUES" + wait_rollout + + step "Keycloak redirect URIs" + set_redirect_uris "$TUNNEL_APP_URL" "$LOCAL_APP_URL" + + step "the tunnel" + cfn_up "$TUNNEL_NAME" "$TUNNEL_APP_HOST" "$TUNNEL_NETWORK" "$TUNNEL_ORIGIN" \ + "$TUNNEL_AUTH_HOST" + + # Written LAST. Every script here reads this file to decide which URLs it is + # talking about, so writing it before the release is actually serving them + # would make a failed switch look like a successful one. + cat >"$MODE_ENV" <<EOF +# Written by scripts/tunnel.sh — the mode the RELEASE is installed in. +RIG_MODE=tunnel +RIG_APP_HOST=$TUNNEL_APP_HOST +RIG_AUTH_HOST=$TUNNEL_AUTH_HOST +EOF + + step "up" + say " app $TUNNEL_APP_URL" + say " keycloak $TUNNEL_AUTH_URL" + say " uploads $TUNNEL_APP_URL/objects/$STORE_BUCKET/…" + say " loopback $LOCAL_APP_URL (still served — no X-Forwarded-Proto, see verify.sh)" + say "" + say " verify: bash $HERE/verify.sh" + say " back: bash $HERE/tunnel.sh down" + say " give up: bash $HERE/tunnel.sh destroy (deletes the DNS records too)" +} + +cmd_down() { + load_hostnames + step "stopping the tunnel" + cfn_stop "$TUNNEL_NAME" + + if cluster_exists && [ -f "$KUBECONFIG_FILE" ]; then + load_secrets + step "release → *.localhost" + install + wait_rollout + step "Keycloak redirect URIs" + set_redirect_uris "$LOCAL_APP_URL" + else + say " (no cluster — nothing to put back)" + fi + rm -f "$MODE_ENV" + + step "what is left on Cloudflare" + say " The tunnel '$TUNNEL_NAME' and these DNS records still EXIST:" + say " $TUNNEL_APP_HOST" + say " $TUNNEL_AUTH_HOST" + say " Nothing serves them now, so they answer Cloudflare's 1033 (\"tunnel not" + say ' found") — which is honest, and is why the container is stopped rather' + say " than left pointing at a cluster that may be gone." + say "" + say " Give the names up: bash $HERE/tunnel.sh destroy" +} + +cmd_destroy() { + load_hostnames + step "destroying the tunnel and its DNS records" + cfn_destroy "$TUNNEL_NAME" "$TUNNEL_APP_HOST" "$TUNNEL_AUTH_HOST" + rm -f "$MODE_ENV" "$TUNNEL_VALUES" + ok "tunnel, credentials file and both CNAMEs are gone" + say "" + say " The RELEASE is still configured for those hostnames if it was up when" + say " this ran. Put it back with: bash $HERE/tunnel.sh down" +} + +cmd_status() { + load_hostnames + say " mode $RIG_MODE (from $MODE_ENV)" + say " app $TUNNEL_APP_URL" + say " keycloak $TUNNEL_AUTH_URL" + if cfn_running "$TUNNEL_NAME"; then + ok "cloudflared $(cfn_container "$TUNNEL_NAME") is running" + else + bad "cloudflared $(cfn_container "$TUNNEL_NAME") is NOT running" + fi + local h + for h in "$TUNNEL_APP_HOST" "$TUNNEL_AUTH_HOST"; do + say " https://$h -> $(cfn_http_code "https://$h/")" + done +} + +case "${1:-}" in +up) cmd_up ;; +down) cmd_down ;; +destroy) cmd_destroy ;; +status) cmd_status ;; +-h | --help | "") sed -n '2,40p' "$0" | sed 's/^# \{0,1\}//' ;; +*) die "unknown command '$1' (up | down | destroy | status)" ;; +esac diff --git a/.claude/skills/k3d-chart-rig/scripts/up.sh b/.claude/skills/k3d-chart-rig/scripts/up.sh new file mode 100644 index 00000000..df4315c5 --- /dev/null +++ b/.claude/skills/k3d-chart-rig/scripts/up.sh @@ -0,0 +1,273 @@ +#!/usr/bin/env bash +# One command from nothing to an installed, reachable Hackagon release on a +# throwaway Kubernetes cluster. +# +# bash scripts/up.sh # cluster + ingress-nginx + store + release +# bash scripts/up.sh --recreate # delete the cluster first +# bash scripts/up.sh --no-realm # skip the Keycloak realm import (see below) +# +# THE REALM IMPORT CARRIES DEV ACCOUNTS. tools/configs/keycloak/realm-hackagon.json +# is the development export: alice, bob, charles and hackagon-admin, all with +# the password `aliceandbob`, all with `emailVerified` and no password policy. +# It is imported here because the login round-trip in verify.sh has to sign +# SOMEBODY in, and inventing a second realm would test a realm nobody deploys. +# It is why this rig is opt-in, why it binds only 127.0.0.1, and why nothing it +# writes may ever be copied into a deployment. `--no-realm` leaves Keycloak with +# an empty realm; the login check then self-reports as skipped. +set -euo pipefail +. "$(dirname "${BASH_SOURCE[0]}")/lib.sh" + +RECREATE=0 +WITH_REALM=1 +for arg in "$@"; do + case "$arg" in + --recreate) RECREATE=1 ;; + --no-realm) WITH_REALM=0 ;; + -h | --help) + sed -n '2,30p' "$0" + exit 0 + ;; + *) die "unknown flag $arg" ;; + esac +done + +# ===================================================================== +step "toolchain" +# ===================================================================== +bash "$HERE/tools.sh" >/dev/null 2>&1 || bash "$HERE/tools.sh" +ok "k3d $K3D_VERSION · helm $HELM_VERSION · kubectl $KUBECTL_VERSION in bin/" + +# ===================================================================== +step "cluster" +# ===================================================================== +if [ "$RECREATE" = 1 ] && cluster_exists; then + say " deleting existing cluster" + k3d cluster delete "$CLUSTER" >/dev/null +fi + +if cluster_exists; then + ok "cluster '$CLUSTER' already exists" + k3d cluster start "$CLUSTER" >/dev/null 2>&1 || true +else + # --disable=traefik is the whole reason this is not `k3d cluster create` with + # defaults. k3d bundles Traefik, and every annotation the chart puts on its + # /objects Ingress is ingress-nginx's; the Host rewrite in particular is not + # expressible in a core Ingress object on Traefik at all. With the bundled + # controller the one thing worth testing here cannot work. + # + # metrics-server is disabled purely to save ~150 MB of RAM; nothing reads it. + k3d cluster create "$CLUSTER" \ + --servers 1 --agents 0 \ + --image "$K3S_IMAGE" \ + --api-port "127.0.0.1:$API_PORT" \ + --port "$HTTP_PORT:$HTTP_PORT@loadbalancer" \ + --port "$HTTPS_PORT:$HTTPS_PORT@loadbalancer" \ + --k3s-arg "--disable=traefik@server:*" \ + --k3s-arg "--disable=metrics-server@server:*" \ + --kubeconfig-update-default=false \ + --kubeconfig-switch-context=false \ + --wait --timeout 300s >&2 + ok "cluster created" +fi + +# The rig's own kubeconfig, never ~/.kube/config: this machine has other +# clusters and other contexts, and a rig that repoints `kubectl` for everything +# else is a rig that breaks someone's day. +k3d kubeconfig get "$CLUSTER" >"$KUBECONFIG_FILE" +ok "kubeconfig -> $KUBECONFIG_FILE" + +# ===================================================================== +step "ingress-nginx" +# ===================================================================== +helm repo add ingress-nginx https://kubernetes.github.io/ingress-nginx >/dev/null 2>&1 || true +helm repo update ingress-nginx >/dev/null 2>&1 || true +helm upgrade --install ingress-nginx ingress-nginx/ingress-nginx \ + --version "$INGRESS_NGINX_CHART_VERSION" \ + --namespace ingress-nginx --create-namespace \ + -f "$(winpath "$MANIFEST_DIR/ingress-nginx-values.yaml")" \ + --wait --timeout 10m >/dev/null +INGRESS_IP="$(kubectl -n ingress-nginx get svc ingress-nginx-controller -o jsonpath='{.spec.clusterIP}')" +ok "ingress-nginx $INGRESS_NGINX_CHART_VERSION on :$HTTP_PORT (ClusterIP $INGRESS_IP)" + +# ===================================================================== +step "in-cluster DNS for the public hostnames" +# ===================================================================== +# The frontend pod fetches the OIDC discovery document from the SAME string the +# browser uses — Auth.js rejects a document whose `issuer` differs from the +# configured one, so there is no second, internal URL to fall back on. Inside +# the cluster `auth.hackagon.localhost` would otherwise resolve to nothing (or, +# with a nip.io-style name, to the pod's own loopback), so CoreDNS is taught to +# answer both names with the ingress controller's ClusterIP. +# +# `coredns-custom` is k3s's supported extension point: its Corefile ends with +# `import /etc/coredns/custom/*.override` INSIDE the `.:53` block, and the +# Deployment mounts the ConfigMap optionally. Editing the Corefile directly +# would work until k3s re-applied its bundled manifest. +# +# `rewrite`, not `hosts`. Two reasons, one of them measured: k3s's Corefile +# already contains `hosts /etc/coredns/NodeHosts`, and a second stanza makes +# CoreDNS refuse to start — "plugin/hosts: this plugin can only be used once +# per Server Block", CrashLoopBackOff, whole cluster's DNS down. `rewrite` is +# explicitly multi-instance. It is also the better answer: it names the ingress +# controller's SERVICE, so reinstalling ingress-nginx (new ClusterIP) does not +# leave a stale address baked into DNS. Plugin order in CoreDNS comes from the +# registry, not from the file, so an import at the end of the block still runs +# `rewrite` before `kubernetes`. +kubectl -n kube-system create configmap coredns-custom \ + --from-literal="hackagon.override=rewrite name $APP_HOST ingress-nginx-controller.ingress-nginx.svc.cluster.local +rewrite name $AUTH_HOST ingress-nginx-controller.ingress-nginx.svc.cluster.local" \ + --dry-run=client -o yaml | kubectl apply -f - >/dev/null +kubectl -n kube-system rollout restart deployment coredns >/dev/null +kubectl -n kube-system rollout status deployment coredns --timeout=120s >/dev/null +ok "$APP_HOST, $AUTH_HOST -> $INGRESS_IP inside the cluster" + +# ===================================================================== +step "generated credentials" +# ===================================================================== +# Never in a tracked file, and never in values.yaml either — `helm get values` +# would print them back out. Hex only: the backend's env loader splits any value +# containing a SPACE into a list, so a credential with one would not arrive +# intact (helm-chart/values.yaml says so about the storage keys). +if [ ! -f "$SECRETS_ENV" ]; then + umask 077 + cat >"$SECRETS_ENV" <<EOF +# Generated by scripts/up.sh — gitignored, throwaway, cluster-local. +POSTGRES_PASSWORD=$(openssl rand -hex 16) +KEYCLOAK_DB_PASSWORD=$(openssl rand -hex 16) +HACKAGON_DB_PASSWORD=$(openssl rand -hex 16) +STORAGE_ACCESS_KEY=rig$(openssl rand -hex 8) +STORAGE_SECRET_KEY=$(openssl rand -hex 24) +FRONTEND_CLIENT_SECRET=$(openssl rand -hex 24) +FRONTEND_AUTH_SECRET=$(openssl rand -hex 32) +EOF + ok "minted $SECRETS_ENV" +else + ok "reusing $SECRETS_ENV" +fi +load_secrets + +# ===================================================================== +step "object store (test-only, not part of the chart)" +# ===================================================================== +kubectl create namespace "$STORE_NS" --dry-run=client -o yaml | kubectl apply -f - >/dev/null +kubectl -n "$STORE_NS" create secret generic store-credentials \ + --from-literal=accessKey="$STORAGE_ACCESS_KEY" \ + --from-literal=secretKey="$STORAGE_SECRET_KEY" \ + --dry-run=client -o yaml | kubectl apply -f - >/dev/null +sed "s|MINIO_IMAGE_PLACEHOLDER|$MINIO_IMAGE|" "$MANIFEST_DIR/store.yaml" | kubectl apply -f - >/dev/null +kubectl -n "$STORE_NS" rollout status deployment store --timeout=300s >/dev/null +kubectl -n "$STORE_NS" delete job store-init --ignore-not-found >/dev/null +sed -e "s|MC_IMAGE_PLACEHOLDER|$MC_IMAGE|" -e "s|BUCKET_PLACEHOLDER|$STORE_BUCKET|g" \ + "$MANIFEST_DIR/store-init-job.yaml" | kubectl apply -f - >/dev/null +kubectl -n "$STORE_NS" wait --for=condition=complete job/store-init --timeout=180s >/dev/null +ok "store at $STORE_ENDPOINT, bucket '$STORE_BUCKET', hackathons|users|site public-read" + +# ===================================================================== +step "release namespace and secrets" +# ===================================================================== +kubectl create namespace "$NAMESPACE" --dry-run=client -o yaml | kubectl apply -f - >/dev/null +# The chart REFUSES to take storage credentials from values (see +# backend-deployment.yaml's `required`), which is the right call — this is the +# Secret it insists on. +kubectl -n "$NAMESPACE" create secret generic hackagon-storage \ + --from-literal=accessKey="$STORAGE_ACCESS_KEY" \ + --from-literal=secretKey="$STORAGE_SECRET_KEY" \ + --dry-run=client -o yaml | kubectl apply -f - >/dev/null +ok "Secret hackagon-storage created from generated values" + +# --- the Keycloak certificate ------------------------------------------ +# Self-signed, 90 days, one hostname. It exists because Keycloak's federation +# cookies are `SameSite=None`, which forces `Secure`, which means no browser and +# no HTTP client will keep them over plain http — so the login round-trip is +# unreachable without TLS somewhere. See lib.sh for the measurement. +# +# The Secret name is the chart's OWN DEFAULT (`{releaseName}-keycloak-tls`), so +# this also exercises the default rather than routing around it. +if [ ! -f "$TLS_CERT" ] || ! openssl x509 -in "$TLS_CERT" -checkend 604800 >/dev/null 2>&1; then + openssl req -x509 -newkey rsa:2048 -sha256 -days 90 -nodes \ + -keyout "$TLS_KEY" -out "$TLS_CERT" \ + -subj "//CN=$AUTH_HOST" \ + -addext "subjectAltName=DNS:$AUTH_HOST" >/dev/null 2>&1 || + die "openssl could not mint the Keycloak certificate" + ok "minted a 90-day self-signed certificate for $AUTH_HOST" +else + ok "reusing the certificate for $AUTH_HOST" +fi +# winpath both: the kubectl wrapper runs with MSYS_NO_PATHCONV=1, so a +# /c/Users/… path reaches kubectl.exe verbatim and it cannot open it. +kubectl -n "$NAMESPACE" create secret tls "$RELEASE-keycloak-tls" \ + --cert="$(winpath "$TLS_CERT")" --key="$(winpath "$TLS_KEY")" \ + --dry-run=client -o yaml | kubectl apply -f - >/dev/null + +cat >"$GEN_VALUES" <<EOF +# Generated by scripts/up.sh. Gitignored: every value below is a credential. +postgresql: + auth: + postgresPassword: "$POSTGRES_PASSWORD" +backend: + config: + database: + postgresPassword: "$HACKAGON_DB_PASSWORD" +keycloak: + database: + external: + password: "$KEYCLOAK_DB_PASSWORD" +frontendSecrets: + clientSecret: "$FRONTEND_CLIENT_SECRET" + authSecret: "$FRONTEND_AUTH_SECRET" +EOF + +# ===================================================================== +step "realm" +# ===================================================================== +REALM_ARGS=() +if [ "$WITH_REALM" = 1 ]; then + SRC_REALM="$REPO_ROOT/tools/configs/keycloak/realm-hackagon.json" + [ -f "$SRC_REALM" ] || die "no realm export at $SRC_REALM" + # Rewritten HERE rather than by the chart. keycloak-realm-configmap.yaml + # rewrites `http://localhost:8081` to `https://app.{baseDomain}` — scheme + # hard-coded, port dropped — which is right for a deployment on :443 and + # wrong for anything else, this rig included. Doing the substitution first + # makes the chart's own replaces no-ops rather than fighting them. + sed "s|http://localhost:8081|$APP_URL|g" "$SRC_REALM" >"$REALM_FILE" + REALM_ARGS=(--set-file "realmJson=$(winpath "$REALM_FILE")") + say " ⚠ importing the DEVELOPMENT realm: alice, bob, charles, hackagon-admin" + say " ⚠ all with the password 'aliceandbob'. Local, opt-in, throwaway only." + ok "realm redirect URIs rewritten to $APP_URL" +else + ok "no realm imported (--no-realm)" +fi + +# ===================================================================== +step "helm install" +# ===================================================================== +helm upgrade --install "$RELEASE" "$(winpath "$CHART_DIR")" \ + --namespace "$NAMESPACE" \ + -f "$(winpath "$CHART_DIR/values.yaml")" \ + -f "$(winpath "$CHART_DIR/values.k3d.yaml")" \ + -f "$(winpath "$GEN_VALUES")" \ + "${REALM_ARGS[@]}" \ + --timeout 15m >&2 +ok "release '$RELEASE' installed in namespace '$NAMESPACE'" + +step "waiting for workloads" +for d in postgresql keycloak backend frontend; do + name="$RELEASE-$d" + kind=deployment + kubectl -n "$NAMESPACE" get statefulset "$name" >/dev/null 2>&1 && kind=statefulset + if kubectl -n "$NAMESPACE" rollout status "$kind/$name" --timeout=420s >/dev/null 2>&1; then + ok "$kind/$name ready" + else + bad "$kind/$name did NOT become ready" + kubectl -n "$NAMESPACE" get pods + fi +done + +step "up" +say " frontend $APP_URL" +say " keycloak $AUTH_URL" +say " uploads $APP_URL/objects/$STORE_BUCKET/…" +say "" +say " kubectl: KUBECONFIG=$KUBECONFIG_FILE $BIN_DIR/kubectl get pods -n $NAMESPACE" +say " verify: bash $HERE/verify.sh" +say " down: bash $HERE/down.sh" diff --git a/.claude/skills/k3d-chart-rig/scripts/verify.sh b/.claude/skills/k3d-chart-rig/scripts/verify.sh new file mode 100644 index 00000000..e227f30c --- /dev/null +++ b/.claude/skills/k3d-chart-rig/scripts/verify.sh @@ -0,0 +1,484 @@ +#!/usr/bin/env bash +# Turn the chart's three arguable claims into observations, and prove login. +# +# bash scripts/verify.sh # everything +# bash scripts/verify.sh --quick # skip the checks that reconfigure the +# # cluster: the two ingress-controller +# # rollouts (~30 s each) and the config- +# # reload upgrade in step 6 (two rollouts). +# # The render-level half of step 6 still runs. +# +# "The pods are Running" is not in here anywhere. Pods can be Running and the +# product unusable — this rig was written after exactly that: every page +# answered 200 while login was dead, and the app's own logs said sign-in had +# succeeded. +# +# Every negative assertion has a positive control. A 403 that would also be a +# 403 if the object never existed, or a "no replay: key" that would also hold if +# the file were empty, proves nothing — and this repository has a written record +# of suites staying green while testing nothing. +set -euo pipefail +. "$(dirname "${BASH_SOURCE[0]}")/lib.sh" +# shellcheck source=presign.sh +. "$HERE/presign.sh" + +QUICK=0 +[ "${1:-}" = "--quick" ] && QUICK=1 + +require_cluster +load_secrets + +PASS=0 +FAIL=0 +check() { # description expected actual + if [ "$2" = "$3" ]; then + ok "$1" + PASS=$((PASS + 1)) + else + bad "$1" + say " expected: $2" + say " actual: $3" + FAIL=$((FAIL + 1)) + fi +} +check_contains() { # description needle haystack + case "$3" in + *"$2"*) + ok "$1" + PASS=$((PASS + 1)) + ;; + *) + bad "$1" + say " expected to contain: $2" + say " got: $(printf '%.200s' "$3")" + FAIL=$((FAIL + 1)) + ;; + esac +} +check_lacks() { # description needle haystack + case "$3" in + *"$2"*) + bad "$1" + say " found: $2" + FAIL=$((FAIL + 1)) + ;; + *) + ok "$1" + PASS=$((PASS + 1)) + ;; + esac +} + +CONTROL_HOST="control.$BASE_DOMAIN" +STAMP="$(date +%s)" + +# Read the config file a RUNNING container has open — not `helm template`, not +# the ConfigMap. Both images are distroless (no shell, no tar), so `kubectl +# exec` and `kubectl cp` are both out; an ephemeral debug container sharing the +# process namespace reads the real mount through /proc/1/root. +# +# `--profile=sysadmin` is needed, not decoration: with the default profile the +# read comes back "cat: can't open '/proc/1/root/…': Permission denied" even +# though both containers run as uid 0. That failure is why EVERY caller asserts +# a positive control on the result — the absence assertions in step 5 all +# passed against that error message once, and would have gone on passing. +# +# The Running field-selector matters after a rollout: a Terminating pod still +# matches the label and can sort first, and reading the OLD pod is exactly the +# mistake step 6 exists to detect. +read_live_config() { # component tag -> the file's text on stdout + local comp="$1" tag="$2" pod dbg out="" + pod="$(kubectl -n "$NAMESPACE" get pod -l "app.kubernetes.io/component=$comp" \ + --field-selector=status.phase=Running \ + -o jsonpath='{.items[0].metadata.name}')" + dbg="rigread$tag" + kubectl -n "$NAMESPACE" debug "$pod" --image=busybox:1.36 --target="$comp" \ + --profile=sysadmin -c "$dbg" -q \ + -- sh -c 'cat /proc/1/root/etc/hackagon/config.yaml' >/dev/null 2>&1 || true + for _ in $(seq 40); do + out="$(kubectl -n "$NAMESPACE" logs "$pod" -c "$dbg" 2>/dev/null || true)" + [ -n "$out" ] && break + sleep 2 + done + printf '%s' "$out" +} + +# ===================================================================== +step "0 · the release is installed and serving" +# ===================================================================== +check "helm reports the release deployed" "deployed" \ + "$(helm -n "$NAMESPACE" status "$RELEASE" -o json 2>/dev/null | grep -o '"status":"[a-z]*"' | head -1 | sed 's/.*:"//;s/"//')" + +for c in frontend backend keycloak; do + ready="$(kubectl -n "$NAMESPACE" get deployment "$RELEASE-$c" -o jsonpath='{.status.readyReplicas}' 2>/dev/null)" + check "$c has a ready replica" "1" "${ready:-0}" +done + +check "GET / is served by the frontend" "200" \ + "$(rigcurl -s -o /dev/null -w '%{http_code}' "$APP_URL/")" + +# ===================================================================== +step "1 · a presigned upload survives the /objects Ingress" +# ===================================================================== +# The claim: the `upstream-vhost` annotation rewrites Host to the name SigV4 was +# computed over, so a presigned PUT sent to the APP's origin authenticates at +# the STORE. Three legs, because the middle one is what makes the first mean +# anything. + +KEY="hackathons/rig/$STAMP.txt" +BODY="$STATE_DIR/upload.txt" +printf 'uploaded through the ingress at %s\n' "$STAMP" >"$BODY" +SIZE="$(wc -c <"$BODY" | tr -d ' ')" + +check "the chart wrote the Host rewrite" "$STORE_HOST:$STORE_PORT" \ + "$(kubectl -n "$NAMESPACE" get ingress "$RELEASE-objects" \ + -o jsonpath='{.metadata.annotations.nginx\.ingress\.kubernetes\.io/upstream-vhost}')" + +URL="$(bash "$HERE/presign.sh" put "$KEY" "text/plain" "$SIZE")" +code="$(rigcurl -s -X PUT --data-binary "@$BODY" -H "Content-Type: text/plain" \ + -o "$STATE_DIR/put.body" -w '%{http_code}' "$APP_URL$URL")" +check "presigned PUT through /objects" "200" "$code" +[ "$code" = "200" ] || say " $(head -c 300 "$STATE_DIR/put.body")" + +got="$(rigcurl -s "$APP_URL/objects/$STORE_BUCKET/$KEY")" +check "the bytes read back are the bytes sent" "$(cat "$BODY")" "$got" +check "the signed Content-Type is what the store kept" "text/plain" \ + "$(rigcurl -s -o /dev/null -w '%{content_type}' "$APP_URL/objects/$STORE_BUCKET/$KEY")" + +# --- the negative control ------------------------------------------- +# An Ingress identical to the chart's but for the one annotation. Applied here +# rather than left lying around, and asserted to differ by exactly that key — +# a control that has drifted is not a control. +sed -e "s|NAMESPACE_PLACEHOLDER|$NAMESPACE|" \ + -e "s|CONTROL_HOST_PLACEHOLDER|$CONTROL_HOST|" \ + -e "s|OBJECTS_SERVICE_PLACEHOLDER|$RELEASE-objects|" \ + -e "s|STORE_PORT_PLACEHOLDER|$STORE_PORT|" \ + "$MANIFEST_DIR/control-no-vhost-ingress.yaml" | kubectl apply -f - >/dev/null + +annots() { # ingress-name -> "key=value" lines, minus the bookkeeping ones + kubectl -n "$NAMESPACE" get ingress "$1" \ + -o go-template='{{range $k,$v := .metadata.annotations}}{{$k}}={{$v}}{{"\n"}}{{end}}' | + tr -d '\r' | grep -vE '^\s*$|^(kubectl\.kubernetes\.io/|meta\.helm\.sh/)' | LC_ALL=C sort +} +diffed="$(diff <(annots "$RELEASE-objects") <(annots rig-objects-control) | grep '^[<>]' | tr -d ' ' || true)" +check "control differs from the chart's Ingress by exactly upstream-vhost" \ + "<nginx.ingress.kubernetes.io/upstream-vhost=$STORE_HOST:$STORE_PORT" "$diffed" + +# Wait for the controller to pick the new Ingress up; a 404 here just means it +# has not synced yet, and racing it would read as a pass. +for _ in $(seq 30); do + ctl="$(rigcurl -s -o /dev/null -w '%{http_code}' --resolve "$CONTROL_HOST:$HTTP_PORT:127.0.0.1" \ + "http://$CONTROL_HOST:$HTTP_PORT/objects/$STORE_BUCKET/$KEY")" + [ "$ctl" = "404" ] || break + sleep 1 +done + +CKEY="hackathons/rig/$STAMP-control.txt" +CURL_PATH="$(bash "$HERE/presign.sh" put "$CKEY" "text/plain" "$SIZE")" +body="$(rigcurl -s -X PUT --data-binary "@$BODY" -H "Content-Type: text/plain" \ + --resolve "$CONTROL_HOST:$HTTP_PORT:127.0.0.1" \ + "http://$CONTROL_HOST:$HTTP_PORT$CURL_PATH")" +check_contains "without the rewrite the same signature is refused" "SignatureDoesNotMatch" "$body" + +# And the same signature IS accepted through the chart's route, so the refusal +# above is about the annotation and not about the URL having gone stale. +check "the same signature is accepted through the chart's route" "200" \ + "$(rigcurl -s -X PUT --data-binary "@$BODY" -H "Content-Type: text/plain" \ + -o /dev/null -w '%{http_code}' "$APP_URL$CURL_PATH")" + +# --- the bucket policy, both halves ---------------------------------- +check "a public prefix is readable with no credentials" "200" \ + "$(rigcurl -s -o /dev/null -w '%{http_code}' "$APP_URL/objects/$STORE_BUCKET/$KEY")" +check "a private prefix is not" "403" \ + "$(rigcurl -s -o /dev/null -w '%{http_code}' "$APP_URL/objects/$STORE_BUCKET/teams/canary.txt")" +# …and it IS there, so the 403 is a refusal and not a miss. +check "the private object exists (presigned GET reaches it)" "200" \ + "$(rigcurl -s -o /dev/null -w '%{http_code}' "$APP_URL$(bash "$HERE/presign.sh" get "teams/canary.txt")")" + +# ===================================================================== +step "2 · the regex path beats the frontend's /" +# ===================================================================== +check "both Ingresses claim the same host" "$APP_HOST $APP_HOST" \ + "$(kubectl -n "$NAMESPACE" get ingress "$RELEASE-frontend" "$RELEASE-objects" \ + -o jsonpath='{.items[*].spec.rules[0].host}')" +check_contains "/ still reaches the SvelteKit app" "<!doctype html" \ + "$(rigcurl -s "$APP_URL/" | head -c 200)" +check "/objects/… reaches the store, not the app" "$(cat "$BODY")" \ + "$(rigcurl -s "$APP_URL/objects/$STORE_BUCKET/$KEY")" +# The regex is `/objects(/|$)(.*)`, so a path that merely STARTS with the word +# must fall through to the frontend. If it did not, the prefix would be +# swallowing application routes. +check_lacks "/objectsnotaprefix is NOT sent to the store" "<Error>" \ + "$(rigcurl -s "$APP_URL/objectsnotaprefix")" + +# ===================================================================== +step "3 · ingress-nginx accepts the ExternalName upstream" +# ===================================================================== +check "the chart created an ExternalName Service" "ExternalName" \ + "$(kubectl -n "$NAMESPACE" get svc "$RELEASE-objects" -o jsonpath='{.spec.type}')" +check "it points at the store's endpoint host" "$STORE_HOST" \ + "$(kubectl -n "$NAMESPACE" get svc "$RELEASE-objects" -o jsonpath='{.spec.externalName}')" +check "it is the /objects backend" "$RELEASE-objects" \ + "$(kubectl -n "$NAMESPACE" get ingress "$RELEASE-objects" \ + -o jsonpath='{.spec.rules[0].http.paths[0].backend.service.name}')" +# Every 200 above already went through it — a rejected ExternalName upstream is +# a 503, not a wrong answer. +check "traffic through it is served, not 503'd" "200" \ + "$(rigcurl -s -o /dev/null -w '%{http_code}' "$APP_URL/objects/$STORE_BUCKET/$KEY")" + +if [ "$QUICK" = 0 ]; then + # The check above is only as good as the alternative being real: a "200" + # would look identical if the kill switch did not exist. So turn it on and + # watch the route die, then turn it off and watch it come back. + # + # ⚠ IT IS A COMMAND-LINE FLAG, `--disable-svc-external-name`, and NOT a + # ConfigMap key. values.yaml used to name it `disable-service-external-name` + # in the controller ConfigMap; that was tried here first and the route kept + # serving, upstream unchanged in the access log, no warning logged — + # an unknown ConfigMap key is silently ignored. A cluster believed to have + # blocked ExternalName that way has not. Corrected in values.yaml. + ARGS_PATH="/spec/template/spec/containers/0/args" + ext_flag_index() { kubectl -n ingress-nginx get deployment ingress-nginx-controller \ + -o go-template="{{range \$i,\$a := (index .spec.template.spec.containers 0).args}}{{if eq \$a \"--disable-svc-external-name=true\"}}{{\$i}}{{end}}{{end}}"; } + kubectl -n ingress-nginx patch deployment ingress-nginx-controller --type=json \ + -p "[{\"op\":\"add\",\"path\":\"$ARGS_PATH/-\",\"value\":\"--disable-svc-external-name=true\"}]" >/dev/null + kubectl -n ingress-nginx rollout status deploy/ingress-nginx-controller --timeout=180s >/dev/null + wait_code() { + local c + for _ in $(seq 40); do + c="$(rigcurl -s -o /dev/null -w '%{http_code}' "$APP_URL/objects/$STORE_BUCKET/$KEY")" + [ "$c" = "$1" ] && break + sleep 1 + done + printf '%s' "$c" + } + check "with --disable-svc-external-name the route 503s (so 200 was not vacuous)" \ + "503" "$(wait_code 503)" + idx="$(ext_flag_index)" + [ -n "$idx" ] && kubectl -n ingress-nginx patch deployment ingress-nginx-controller \ + --type=json -p "[{\"op\":\"remove\",\"path\":\"$ARGS_PATH/$idx\"}]" >/dev/null + kubectl -n ingress-nginx rollout status deploy/ingress-nginx-controller --timeout=180s >/dev/null + check "and it recovers when the flag goes away" "200" "$(wait_code 200)" +fi + +# ===================================================================== +step "4 · a login round-trip through the ingress hostname" +# ===================================================================== +disc="$(rigcurl -s "$AUTH_URL/realms/hackagon/.well-known/openid-configuration")" +check_contains "Keycloak's discovery names the public issuer" \ + "\"issuer\":\"$AUTH_URL/realms/hackagon\"" "$disc" + +# REGRESSION GUARD for the frontend's public origin. adapter-node infers the +# scheme; with nothing configured it assumes "https", and then advertises +# callback URLs on an origin that does not exist, issues `__Secure-` cookies +# over http, and login dies with every page still answering 200. That is what +# frontend.protocolHeader is for. Asserting the ADVERTISED origin rather than +# the env var means the check survives however the chart chooses to fix it. +prov="$(rigcurl -s "$APP_URL/auth/providers")" +check_contains "the frontend advertises the origin it is actually reached on" \ + "\"callbackUrl\":\"$APP_URL/auth/callback/keycloak\"" "$prov" + +J="$STATE_DIR/login-jar.txt" +rm -f "$J" +lc() { rigcurl -s -b "$J" -c "$J" "$@"; } +form_action() { tr '\n' ' ' <"$1" | grep -oE '<form[^>]*action="[^"]*"' | head -1 | + grep -oE 'action="[^"]*"' | sed 's/action="//;s/"$//;s/&/\&/g'; } + +lc -o /dev/null "$APP_URL/auth/signin" +AUTHZ="$(lc -o /dev/null -w '%{redirect_url}' -X POST \ + -H "Origin: $APP_URL" -H "Content-Type: application/x-www-form-urlencoded" \ + --data "csrfToken=&callbackUrl=$APP_URL/" "$APP_URL/auth/signin/keycloak")" +check_contains "sign-in redirects to Keycloak's authorize endpoint" \ + "$AUTH_URL/realms/hackagon/protocol/openid-connect/auth" "$AUTHZ" + +# The realm's browser flow is username-first, so this is two POSTs, not one. +lc -o "$STATE_DIR/login1.html" "$AUTHZ" +lc -o "$STATE_DIR/login2.html" -X POST --data-urlencode "username=alice" \ + "$(form_action "$STATE_DIR/login1.html")" +CB="$(lc -o /dev/null -w '%{redirect_url}' -X POST \ + --data-urlencode "password=aliceandbob" --data "credentialId=" \ + "$(form_action "$STATE_DIR/login2.html")")" +check_contains "Keycloak redirects back to the app with a code" \ + "$APP_URL/auth/callback/keycloak?" "$CB" + +# THE hop. Everything before it can succeed while this returns 502: the +# callback's Set-Cookie block is chunked and multi-kilobyte, and nginx's default +# 4k proxy buffer refuses it AFTER Keycloak has authenticated and the app has +# logged "Initial sign-in successful". See frontend.ingress.proxyBufferSize. +check "the OIDC callback completes (not 502)" "302" \ + "$(lc -o /dev/null -w '%{http_code}' "$CB")" + +sess="$(lc "$APP_URL/auth/session")" +check_contains "the session is established for the user who signed in" \ + '"email":"alice@mail.com"' "$sess" +check_contains "and it carries a Keycloak access token" '"accessToken":"ey' "$sess" + +# ===================================================================== +step "5 · the optional blocks are absent from the RUNNING pod" +# ===================================================================== +# Read out of the live container, not out of `helm template` and not out of the +# ConfigMap: the question is what the process is actually configured with. See +# read_live_config at the top of this file for how, and why the positive +# control below has to come first. +LIVE="$(read_live_config frontend "$STAMP")" + +# POSITIVE CONTROL FIRST. An empty read agrees with every absence assertion +# below, and on the first attempt at this the read WAS empty. +check_contains "the live config was actually read (positive control)" "clientId:" "$LIVE" +check_lacks "no replay: block reaches the running frontend" "replay:" "$LIVE" +check_lacks "no plausible: block reaches the running frontend" "plausible:" "$LIVE" +check_contains "…while the blocks that ARE configured are present" "useSecure:" "$LIVE" + +ENVJSON="$(kubectl -n "$NAMESPACE" get deployment "$RELEASE-frontend" \ + -o go-template='{{range .spec.template.spec.containers}}{{range .env}}{{.name}} {{end}}{{end}}')" +check_lacks "and nothing smuggles them in as environment" "REPLAY" "$ENVJSON" + +# ===================================================================== +step "6 · a config-only upgrade reaches the RUNNING pod" +# ===================================================================== +# THE BUG THIS EXISTS FOR was silent in the worst way: `helm upgrade` reported +# success in 0.9 s, `kubectl get configmap` showed the new value, and every +# running pod went on serving the old one. Two things combined — config.yaml is +# a `subPath` mount, which the kubelet resolves ONCE at container start and +# never refreshes, and no template carried a pod-template annotation, so after +# a config-only upgrade the Deployment was byte-identical and Kubernetes +# correctly did nothing. Rotating the OIDC client secret that way changed +# nothing that was running, with no signal anywhere. +# +# The fix is the checksum annotations in {backend,frontend}-deployment.yaml. +# Both halves are checked here, because each is a bug on its own: a config +# change MUST roll the pods, and an unchanged one MUST NOT — a hash over +# something non-deterministic would trade a silent no-op for a rollout on every +# upgrade, which is worse. +# +# Values come from `helm get values`, never a hard-coded list of -f files: this +# script runs in TUNNEL mode too, and re-installing the localhost values there +# would quietly repoint the release mid-run. That the round-trip is faithful is +# asserted rather than assumed — the no-op check below is exactly that claim. +# +# ⚠ THAT FILE HOLDS EVERY CREDENTIAL THE RELEASE WAS INSTALLED WITH — both DB +# passwords, the OIDC client secret and the Auth.js key. `.state/` is +# gitignored (checked), and the umask matches the one up.sh mints +# `secrets.env` under. +# `rm` first: a umask only applies when the file is CREATED, so rewriting an +# existing world-readable one would keep its mode. +VALS="$STATE_DIR/current-values.yaml" +rm -f "$VALS" +( + umask 077 + helm -n "$NAMESPACE" get values "$RELEASE" -o yaml >"$VALS" +) + +csums() { # component [extra helm args…] -> "checksum/<kind>: <sha256>" lines, sorted + local comp="$1" + shift + helm template "$RELEASE" "$(winpath "$CHART_DIR")" --namespace "$NAMESPACE" \ + -f "$(winpath "$VALS")" --show-only "templates/$comp-deployment.yaml" "$@" 2>/dev/null | + grep -oE 'checksum/[a-z]+: [0-9a-f]{64}' | LC_ALL=C sort +} +live_csums() { # component -> the same shape, off the running Deployment + kubectl -n "$NAMESPACE" get deploy "$RELEASE-$1" \ + -o jsonpath='{.spec.template.metadata.annotations}' | + tr ',' '\n' | grep -oE 'checksum/[a-z]+":"[0-9a-f]{64}' | + sed 's/":"/: /' | LC_ALL=C sort +} + +# --- the annotations are there, and are not empty strings ------------- +# The count assertions are the positive control for every equality below: two +# empty strings compare equal, so "live matches rendered" would hold just as +# well if the annotation had been deleted from both sides. +check "the backend renders exactly one checksum annotation" "1" "$(csums backend | grep -c .)" +check "the frontend renders two (config and secret)" "2" "$(csums frontend | grep -c .)" +check "the running backend carries the checksum it renders to" "$(csums backend)" "$(live_csums backend)" +check "the running frontend carries the checksums it renders to" "$(csums frontend)" "$(live_csums frontend)" + +# --- the hash is deterministic --------------------------------------- +# If anything non-deterministic reached the hashed templates — a timestamp, a +# generated password — every `helm upgrade` would roll every pod forever. The +# Bitnami and Keycloak subcharts DO mint passwords on each render (measured: +# `helm template` twice produces two different keycloak admin-passwords, and a +# different postgres one whenever postgresql.auth.postgresPassword is empty), +# so this is not a hypothetical property of this chart. +# `|| true` on every assignment from csums/grep: they are pipelines under +# `set -o pipefail`, and a missing annotation makes grep exit 1 — which would +# kill this script through errexit at exactly the moment it has something to +# report. A check that cannot survive its own subject being absent is no check. +FE="$(csums frontend || true)" +check "two renders of identical inputs agree (no rollout on every upgrade)" "$FE" "$(csums frontend)" +check "…and so does the backend's" "$(csums backend)" "$(csums backend)" + +# --- …and it still tracks what it is supposed to track ---------------- +# Determinism alone is also what a CONSTANT would give you. These three prove +# the hashes are functions of the right inputs, and that the frontend's two are +# independent of each other — without rotating a real secret on the cluster. +FE_CFG="$(printf '%s' "$FE" | grep 'checksum/config' || true)" +FE_SEC="$(printf '%s' "$FE" | grep 'checksum/secret' || true)" +ALT_CFG="$(csums frontend --set frontend.config.oidc.clientId=rig-checksum-probe || true)" +check_lacks "a changed config value changes checksum/config" "$FE_CFG" "$ALT_CFG" +check_contains "…and leaves checksum/secret alone" "$FE_SEC" "$ALT_CFG" +ALT_SEC="$(csums frontend --set frontendSecrets.authSecret=rig-checksum-probe || true)" +check_lacks "a rotated secret changes checksum/secret" "$FE_SEC" "$ALT_SEC" +check_contains "…and leaves checksum/config alone" "$FE_CFG" "$ALT_SEC" +check_lacks "a changed backend config value changes the backend's checksum" \ + "$(csums backend)" "$(csums backend --set backend.config.logging.level=rigprobe)" + +if [ "$QUICK" = 0 ]; then + # --- the behaviour, on the running cluster ------------------------ + # Everything above is a render. The claim is about a pod. + reupgrade() { # extra helm args… + if ! helm upgrade "$RELEASE" "$(winpath "$CHART_DIR")" --namespace "$NAMESPACE" \ + -f "$(winpath "$VALS")" "$@" --timeout 15m >/dev/null 2>"$STATE_DIR/upgrade.err"; then + bad "helm upgrade failed" + sed 's/^/ /' "$STATE_DIR/upgrade.err" >&2 + FAIL=$((FAIL + 1)) + return 1 + fi + } + gens() { kubectl -n "$NAMESPACE" get deploy "$RELEASE-backend" "$RELEASE-frontend" \ + -o jsonpath='{.items[*].metadata.generation}'; } + + # THE ASSERTION MOST LIKELY TO CATCH A MISTAKE. `metadata.generation` only + # advances when the SPEC changes, so this is Kubernetes' own answer to "did + # the pod template move", not an inference from pod names. + GEN0="$(gens)" + reupgrade || true + check "an upgrade with unchanged values does not touch the pod template" "$GEN0" "$(gens)" + + # Flip a value that is pure logging, computed from what the pod is actually + # running rather than assumed — the tunnel overlay may set it either way. + BEFORE="$(read_live_config frontend "${STAMP}b")" + check_contains "the live config was actually read (positive control)" "forceDevLog:" "$BEFORE" + case "$BEFORE" in *"forceDevLog: true"*) + WAS=true + WANT=false + ;; + *) + WAS=false + WANT=true + ;; + esac + + if reupgrade --set "frontend.config.log.forceDevLog=$WANT"; then + kubectl -n "$NAMESPACE" rollout status "deploy/$RELEASE-frontend" --timeout=300s >/dev/null 2>&1 || true + AFTER="$(read_live_config frontend "${STAMP}c")" + check_contains "the live config was read after the upgrade too (positive control)" \ + "forceDevLog:" "$AFTER" + # NOBODY RESTARTED ANYTHING. No `kubectl rollout restart`, no pod + # deletion — the annotation changed, so the Deployment rolled itself. + check_contains "a config-only upgrade reaches the running pod" \ + "forceDevLog: $WANT" "$AFTER" + check_lacks "…and the old value is gone from it" "forceDevLog: $WAS" "$AFTER" + fi + + # Put it back, and leave the release where this script found it. + reupgrade || true + kubectl -n "$NAMESPACE" rollout status "deploy/$RELEASE-frontend" --timeout=300s >/dev/null 2>&1 || true + check "the restored release renders the checksums it started with" "$FE" "$(csums frontend)" + check "…and the running pod carries them again" "$FE" "$(live_csums frontend)" +fi + +# ===================================================================== +step "result" +# ===================================================================== +say " $PASS passed, $FAIL failed" +[ "$FAIL" -eq 0 ] || exit 1 diff --git a/.claude/skills/lib/cf-api.sh b/.claude/skills/lib/cf-api.sh new file mode 100644 index 00000000..e33d8b4f --- /dev/null +++ b/.claude/skills/lib/cf-api.sh @@ -0,0 +1,364 @@ +# shellcheck shell=bash +# Cloudflare API access for NAMED tunnels — credentials, zone lookup, tunnel +# records, DNS records. Source it (it only defines functions and constants): +# +# HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# source "$HERE/../../lib/cf-api.sh" +# +# WHY NAMED TUNNELS EXIST HERE AT ALL. A quick tunnel hands out a fresh +# *.trycloudflare.com hostname on every start, and that one fact is the root of +# most of the churn in this repo's tunnel tooling: auth-wire.sh re-points BOTH +# OIDC issuers at each new URL, run.sh unwires and re-wires around every suite, +# the adapter-node build reads its issuer once at boot and goes stale, and a +# dead hostname once sat committed in HEAD for several commits. A stable +# hostname removes the CAUSE rather than the symptoms: wire it once and it stays +# correct across restarts, reboots and rebuilds. +# +# Quick tunnels are NOT replaced. They are the zero-config path for anyone +# without a Cloudflare account, and every rig still falls back to one. +# +# ── THE CREDENTIAL ─────────────────────────────────────────────────────────── +# +# Read from a gitignored .env (see cf_env_file below). This file REFUSES to +# read or write it unless `git check-ignore` says git cannot take it — the same +# discipline .secrets.env has in openreplay-stack and plausible-stack, and for +# the same reason: a rule that is present-but-wrong looks exactly like a rule +# that works, so ask git rather than reading .gitignore. +# +# THE TOKEN IS NEVER ECHOED. Not in a log line, not in an error, not in a file +# this code writes, and not in argv — the Authorization header goes to curl +# through `--config -` (stdin), so it never appears in `ps` output on a shared +# machine. Progress output names the HOSTNAME, never the credential. +# +# ⚠ Cloudflare API tokens scope to a ZONE, not to a hostname. A token that can +# edit DNS in example.org can edit ANY record in that zone, including ones +# this tooling never created. There is no narrower grant; see SKILL.md. +# +# ── DEPENDENCIES ───────────────────────────────────────────────────────────── +# +# curl and docker. jq is used when present and otherwise run from the official +# `ghcr.io/jqlang/jq` image — docker is already a hard requirement of every +# script that sources this, and the Git Bash host these scripts run on has no +# jq. Override with HACKAGON_JQ=/path/to/jq. + +CF_API="https://api.cloudflare.com/client/v4" + +# Where the credentials live. ONE location, deliberately: copying a token to a +# second path is how one of them goes stale and how a rotation misses a copy. +# The cloudflare-tunnel skill owns Cloudflare credentials, so the file lives +# beside it; HACKAGON_CF_ENV moves it (move the file, do not duplicate it). +cf_env_file() { + if [ -n "${HACKAGON_CF_ENV:-}" ]; then + echo "$HACKAGON_CF_ENV" + return 0 + fi + local skills root + skills="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" + root="$(cd "$skills/../.." && pwd)" + if [ -f "$skills/cloudflare-tunnel/.env" ]; then + echo "$skills/cloudflare-tunnel/.env" + elif [ -f "$root/.env" ] && grep -q '^CLOUDFLARE_API_TOKEN=' "$root/.env" 2>/dev/null; then + # Repo root is where people put a .env by reflex. Accepted, but only when + # it really is the Cloudflare one — this repo has other .env files. + echo "$root/.env" + else + echo "$skills/cloudflare-tunnel/.env" + fi +} + +# Machine-local state: per-tunnel credentials files and cloudflared configs. +# `.claude/**/.state/` is gitignored repo-wide. +cf_state_dir() { + local skills + skills="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" + echo "${HACKAGON_CF_STATE:-$skills/cloudflare-tunnel/.state}" +} + +# A secret is about to be read from (or written to) this path. Ask git whether +# it could ever be committed, and refuse if it could. Not "does .gitignore +# mention it" — `git check-ignore` is the only answer that accounts for +# negations, precedence and a nested .gitignore. +cf_guard_gitignored() { # <path> + local f="${1:?}" dir + dir="$(dirname "$f")" + command -v git >/dev/null 2>&1 || return 0 + git -C "$dir" rev-parse --is-inside-work-tree >/dev/null 2>&1 || return 0 + git -C "$dir" check-ignore -q "$f" && return 0 + echo "error: $f is NOT gitignored — refusing to touch a Cloudflare token there." >&2 + echo " git could commit it. Add it to .gitignore first." >&2 + return 1 +} + +# Load CLOUDFLARE_* / *_HOSTNAME from the env file. Explicit environment wins, +# so `CLOUDFLARE_ZONE=other bash up.sh` overrides on purpose. +# +# The `\r` strip is load-bearing on Windows and cost an hour in plausible-stack: +# a CR that rides INSIDE a value (not at the end of the line, where every tool +# would treat it as a line ending) survives every obvious inspection and makes +# the far end reject a credential that looks byte-perfect everywhere you check. +cf_load() { + local f + f="$(cf_env_file)" + [ -f "$f" ] || return 1 + cf_guard_gitignored "$f" || return 1 + local line k v + while IFS= read -r line || [ -n "$line" ]; do + line="${line%$'\r'}" + case "$line" in '' | \#*) continue ;; esac + k="${line%%=*}" + case "$k" in *[!A-Za-z0-9_]* | '') continue ;; esac + v="${line#*=}" + v="${v%$'\r'}" + [ -n "${!k:-}" ] || export "$k=$v" + done <"$f" + [ -n "${CLOUDFLARE_API_TOKEN:-}" ] && [ -n "${CLOUDFLARE_ZONE:-}" ] +} + +# "Is named mode available at all?" — the question up.sh asks before choosing a +# mode. Silent: callers decide what to print. +cf_configured() { cf_load >/dev/null 2>&1; } + +# Explain the absence in the terms someone can act on. Never prints a value. +cf_explain_unconfigured() { + local f + f="$(cf_env_file)" + if [ ! -f "$f" ]; then + echo " no Cloudflare credentials at $f" + echo " (copy .env.example beside it and fill it in — see SKILL.md," + echo " 'Named tunnels', for the exact token to mint)" + else + echo " $f exists but has no CLOUDFLARE_API_TOKEN + CLOUDFLARE_ZONE pair" + fi +} + +# ── jq, wherever it is ─────────────────────────────────────────────────────── +# The Git Bash host has no jq; the dev container does; the docker image is the +# floor. Resolved once per process. +CF_JQ="" +cf_jq_init() { + [ -z "$CF_JQ" ] || return 0 + if [ -n "${HACKAGON_JQ:-}" ]; then + CF_JQ="native:$HACKAGON_JQ" + elif command -v jq >/dev/null 2>&1; then + CF_JQ="native:jq" + else + CF_JQ="docker" + fi +} + +# Filter stdin. Always -r: every caller here wants a bare string or an id. +jqr() { # <filter> + cf_jq_init + case "$CF_JQ" in + native:*) "${CF_JQ#native:}" -r "$1" ;; + docker) + # -i, not argv: the JSON may carry a tunnel secret, and stdin to a local + # container keeps it off every process list on the box. + docker run --rm -i "${HACKAGON_JQ_IMAGE:-ghcr.io/jqlang/jq:1.7.1}" -r "$1" + ;; + esac +} + +# ── the API ────────────────────────────────────────────────────────────────── +# +# The bearer token goes through `curl --config -` on STDIN. Putting it in argv +# (`-H "Authorization: Bearer $T"`) publishes it to every process listing on the +# machine for the life of the request; this does not. The request body is not a +# secret and stays on the command line. +cf_api() { # <METHOD> <path> [json-body] -> response JSON on stdout + local method="$1" path="$2" body="${3:-}" + local -a args=(-sS -X "$method" --max-time 30 + -H "Content-Type: application/json" "$CF_API$path") + [ -n "$body" ] && args+=(-d "$body") + printf 'header = "Authorization: Bearer %s"\n' "$CLOUDFLARE_API_TOKEN" | + curl --config - "${args[@]}" +} + +# Cloudflare answers 200 with `"success": false` for most failures, so an exit +# code is not the check. Reads the response on stdin, re-emits it when it is +# good, and names the failure when it is not. +# +# The error text comes back from Cloudflare and never contains the token, but it +# is piped rather than interpolated into a message anyway — one fewer place a +# future edit could widen. +cf_ok() { # <what-was-attempted> (response on stdin, response on stdout) + local what="$1" resp + resp="$(cat)" + if [ "$(printf '%s' "$resp" | jqr '.success // false')" = "true" ]; then + printf '%s' "$resp" + return 0 + fi + { + echo "error: Cloudflare refused: $what" + printf '%s' "$resp" | jqr ' + if (.errors | length) > 0 + then (.errors[] | " [\(.code)] \(.message)") + else " (no error detail in the response)" end' 2>/dev/null || + echo " (unparseable response)" + } >&2 + return 1 +} + +# Is the token alive, and what can it do? Run before anything else: an expired +# or mis-scoped token otherwise surfaces as a confusing failure three calls +# later, on whichever permission happens to be checked first. +cf_verify_token() { + local resp status + resp="$(cf_api GET /user/tokens/verify)" || return 1 + status="$(printf '%s' "$resp" | jqr '.result.status // "unknown"')" + if [ "$(printf '%s' "$resp" | jqr '.success // false')" != "true" ]; then + echo "error: the Cloudflare API token was rejected (status: $status)." >&2 + echo " Mint a new one — SKILL.md, 'Named tunnels' → 'The token'." >&2 + return 1 + fi + [ "$status" = "active" ] || { + echo "error: the token verifies but its status is '$status', not 'active'." >&2 + return 1 + } + return 0 +} + +# ── zone ───────────────────────────────────────────────────────────────────── +# One call answers both ids. Deriving the ACCOUNT id from the zone is why this +# tooling needs no `Account → Account Settings → Read` permission: the zone +# record carries it. +CF_ZONE_ID="" +CF_ACCOUNT_ID="" +cf_resolve_zone() { + [ -z "$CF_ZONE_ID" ] || return 0 + local resp count st + resp="$(cf_api GET "/zones?name=$CLOUDFLARE_ZONE")" || return 1 + printf '%s' "$resp" | cf_ok "looking up the zone $CLOUDFLARE_ZONE" >/dev/null || return 1 + count="$(printf '%s' "$resp" | jqr '.result | length')" + if [ "$count" = "0" ]; then + echo "error: the token cannot see a zone named '$CLOUDFLARE_ZONE'." >&2 + echo " Either the name is wrong, or the token was not granted" >&2 + echo " Zone → DNS → Edit on THAT zone (zone resources are opt-in)." >&2 + return 1 + fi + CF_ZONE_ID="$(printf '%s' "$resp" | jqr '.result[0].id')" + CF_ACCOUNT_ID="${CLOUDFLARE_ACCOUNT_ID:-$(printf '%s' "$resp" | jqr '.result[0].account.id')}" + st="$(printf '%s' "$resp" | jqr '.result[0].status')" + if [ "$st" != "active" ]; then + # Worth failing on: a pending zone accepts DNS writes and resolves nothing, + # so every later step succeeds and the hostname stays dead. + echo "error: zone $CLOUDFLARE_ZONE is '$st', not 'active' — Cloudflare is not" >&2 + echo " authoritative for it yet, so a record written now resolves nowhere." >&2 + return 1 + fi + return 0 +} + +# ── tunnels ────────────────────────────────────────────────────────────────── +cf_tunnel_id() { # <name> -> id on stdout, empty when absent + local resp + resp="$(cf_api GET "/accounts/$CF_ACCOUNT_ID/cfd_tunnel?name=$1&is_deleted=false")" || return 1 + printf '%s' "$resp" | cf_ok "listing tunnels" >/dev/null || return 1 + printf '%s' "$resp" | jqr '.result[0].id // empty' +} + +cf_tunnel_create() { # <name> <base64-secret> -> id on stdout + local resp + # config_src=local: the ingress rules live in a file on this machine (and are + # therefore reviewable here) rather than in the Cloudflare dashboard. + resp="$(cf_api POST "/accounts/$CF_ACCOUNT_ID/cfd_tunnel" \ + "{\"name\":\"$1\",\"tunnel_secret\":\"$2\",\"config_src\":\"local\"}")" || return 1 + printf '%s' "$resp" | cf_ok "creating the tunnel '$1'" >/dev/null || return 1 + printf '%s' "$resp" | jqr '.result.id' +} + +cf_tunnel_delete() { # <id> + cf_api DELETE "/accounts/$CF_ACCOUNT_ID/cfd_tunnel/$1" | + cf_ok "deleting tunnel $1" >/dev/null +} + +# The run token is base64 of {"a":account,"t":tunnel,"s":secret} — the same three +# fields a credentials file holds. That equivalence is what makes an existing +# tunnel recoverable: Cloudflare never returns the secret from the create call +# again, so without this a lost credentials file would mean deleting and +# recreating the tunnel (and re-pointing DNS) to get back to a working state. +cf_tunnel_token() { # <id> -> base64 token on stdout + local resp + resp="$(cf_api GET "/accounts/$CF_ACCOUNT_ID/cfd_tunnel/$1/token")" || return 1 + printf '%s' "$resp" | cf_ok "fetching the run token for tunnel $1" >/dev/null || return 1 + printf '%s' "$resp" | jqr '.result' +} + +# ── DNS ────────────────────────────────────────────────────────────────────── +# A proxied CNAME to <tunnel-id>.cfargotunnel.com is what makes a hostname +# resolve to the tunnel. `proxied: true` is mandatory, not a preference — an +# unproxied cfargotunnel.com name does not resolve publicly at all. +CF_DNS_COMMENT="hackagon dev tunnel — managed by .claude/skills/lib/cf-named-tunnel.sh" + +cf_dns_find() { # <fqdn> -> "<id> <type> <content>" on stdout, empty when absent + local resp + resp="$(cf_api GET "/zones/$CF_ZONE_ID/dns_records?name=$1")" || return 1 + printf '%s' "$resp" | cf_ok "listing DNS records for $1" >/dev/null || return 1 + printf '%s' "$resp" | jqr '.result[0] | if . == null then empty + else "\(.id) \(.type) \(.content)" end' +} + +# Point <fqdn> at <tunnel-id>. Idempotent, and it REFUSES to overwrite a record +# it did not create. +# +# That refusal is the whole safety story of this function. The token is +# zone-wide: nothing in Cloudflare stops this code from replacing the zone's +# apex, a mail record, or a colleague's staging host if a hostname were +# mistyped in .env. So anything that is not already a cfargotunnel.com CNAME is +# left alone and reported, and CF_FORCE_DNS=1 is the deliberate override. +cf_dns_point() { # <fqdn> <tunnel-id> + local fqdn="$1" tid="$2" target="$2.cfargotunnel.com" existing rid rtype rcontent + local body + body="$(printf '{"type":"CNAME","name":"%s","content":"%s","proxied":true,"ttl":1,"comment":"%s"}' \ + "$fqdn" "$target" "$CF_DNS_COMMENT")" + + existing="$(cf_dns_find "$fqdn")" || return 1 + if [ -z "$existing" ]; then + cf_api POST "/zones/$CF_ZONE_ID/dns_records" "$body" | + cf_ok "creating the CNAME $fqdn → $target" >/dev/null || return 1 + echo " DNS created $fqdn → $target (proxied)" + return 0 + fi + + read -r rid rtype rcontent <<<"$existing" + if [ "$rtype" = "CNAME" ] && [ "$rcontent" = "$target" ]; then + echo " DNS ok $fqdn → $target" + return 0 + fi + case "$rtype:$rcontent" in + CNAME:*.cfargotunnel.com) ;; # ours, pointing at a different tunnel — repoint + *) + if [ "${CF_FORCE_DNS:-0}" != "1" ]; then + echo "error: $fqdn already has a $rtype record that this tooling did not create." >&2 + echo " Refusing to replace it. The API token is ZONE-wide, so a typo" >&2 + echo " here could take out an unrelated hostname." >&2 + echo " Override deliberately with CF_FORCE_DNS=1 if it really is stale." >&2 + return 1 + fi + echo " DNS ⚠ replacing a $rtype record (CF_FORCE_DNS=1)" + ;; + esac + cf_api PATCH "/zones/$CF_ZONE_ID/dns_records/$rid" "$body" | + cf_ok "repointing $fqdn → $target" >/dev/null || return 1 + echo " DNS updated $fqdn → $target (proxied)" +} + +cf_dns_delete() { # <fqdn> — only when it is one of ours + local existing rid rtype rcontent + existing="$(cf_dns_find "$1")" || return 1 + [ -n "$existing" ] || { + echo " DNS absent $1" + return 0 + } + read -r rid rtype rcontent <<<"$existing" + case "$rtype:$rcontent" in + CNAME:*.cfargotunnel.com) + cf_api DELETE "/zones/$CF_ZONE_ID/dns_records/$rid" | + cf_ok "deleting the CNAME $1" >/dev/null || return 1 + echo " DNS deleted $1" + ;; + *) + echo " DNS left alone: $1 is a $rtype this tooling did not create" >&2 + ;; + esac +} diff --git a/.claude/skills/lib/cf-named-tunnel.sh b/.claude/skills/lib/cf-named-tunnel.sh new file mode 100644 index 00000000..4e24b459 --- /dev/null +++ b/.claude/skills/lib/cf-named-tunnel.sh @@ -0,0 +1,518 @@ +#!/usr/bin/env bash +# Run a Cloudflare NAMED tunnel — a tunnel with a PERSISTENT hostname on a zone +# you own, instead of a quick tunnel's throwaway *.trycloudflare.com. +# +# cf-named-tunnel.sh up <name> <fqdn> <network> <service-url> [fqdn…] +# cf-named-tunnel.sh ensure <name> <fqdn> [fqdn…] # Cloudflare side only +# cf-named-tunnel.sh stop <name> +# cf-named-tunnel.sh status [name] +# cf-named-tunnel.sh url <name> # from the RUNNING container +# cf-named-tunnel.sh running <name> # exit 0 when it is +# cf-named-tunnel.sh destroy <name> <fqdn> [fqdn…] # tunnel + DNS + container +# cf-named-tunnel.sh check # credentials + zone only +# +# e.g. cf-named-tunnel.sh up hackagon hackagon.example.org \ +# hackagon-dev http://caddy:80 +# +# ONE TUNNEL PER RIG, not one tunnel with three ingress rules. The three rigs +# live in three different compose projects on three different docker networks +# (hackagon-dev, plausible_default, openreplay_openreplay-net); a single +# cloudflared would have to be attached to all three and restarted whenever any +# rig came up or down. Per-rig tunnels are independent, they mirror the +# quick-tunnel-per-rig design that is already here, and a rig that is down +# simply has no tunnel rather than breaking the others'. +# +# ── SEVERAL HOSTNAMES, ONE RIG ─────────────────────────────────────────────── +# +# The rule above is per RIG, not per hostname, and one rig can legitimately need +# more than one public name: the k3d chart rig serves the app and Keycloak from +# two host-based Ingresses on the same ingress controller, because the chart +# routes them by Host. Those extra names take TRAILING ARGUMENTS and share the +# ONE service URL — which is the whole point, since the thing behind the tunnel +# is a single proxy that dispatches on Host itself. A second tunnel would be a +# second container, a second thing to start and stop in step, and a second way +# to leave half a rig public. +# +# Every hostname must be inside the same zone (the token can write nowhere +# else), they all get their own proxied CNAME to the same tunnel, and `destroy` +# takes the same list so no record outlives the tunnel it points at. +# +# ⚠ Cloudflare's free Universal SSL covers the apex and ONE label +# (`example.org`, `a.example.org`) and nothing deeper. `a.b.example.org` gets no +# certificate at the edge and fails the TLS handshake outright — measured, alert +# 40, before anything was created. Pick sibling names, not nested ones, unless +# the zone has Advanced Certificate Manager. +# +# ── WHAT RUNS, AND WITH WHICH CREDENTIAL ───────────────────────────────────── +# +# The container runs cloudflared with a LOCALLY-MANAGED config: a per-tunnel +# credentials file plus an ingress file, both under .state/named/<name>/ and +# both gitignored, mounted read-only at /etc/cloudflared. +# +# This is deliberate and it is the security point. The API token is a SETUP +# credential — it creates the tunnel and writes the DNS record, once. The +# credentials file is the RUN-TIME credential, and it can do exactly one thing: +# serve traffic for that one tunnel. It cannot touch DNS, cannot see the zone, +# and cannot create anything. A machine that only needs to RUN a tunnel should +# hold the credentials file and no token at all — copy .state/named/<name>/ to +# it and never put the .env there. +# +# The alternative (`cloudflared tunnel run --token …`, ingress managed in the +# Cloudflare dashboard) needs no files but puts the ingress rules somewhere this +# repo cannot review or diff, and the run token is a full tunnel credential +# either way. Local config keeps the routing table on disk, next to the +# Caddyfile it hands off to. +set -euo pipefail +CFN_HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# shellcheck source=./cf-api.sh +source "$CFN_HERE/cf-api.sh" + +# Paths handed to docker.exe. On Git Bash/MSYS the automatic POSIX→Windows +# translation has to be off for docker's own /container/paths to survive, which +# means a /c/Users/... host path would reach docker.exe verbatim and resolve as +# C:\c\Users\... — hand it a Windows-style path instead. Same fix as +# openreplay-stack/scripts/lib.sh and plausible-stack/scripts/lib.sh. +CURL_DISCARD="/dev/null" +case "$(uname -s)" in +MINGW* | MSYS*) + export MSYS_NO_PATHCONV=1 MSYS2_ARG_CONV_EXCL="*" + # …and not /dev/null either: curl.exe would try to create a file at that + # literal path and print `client returned ERROR on write` in the middle of + # a check that then passes anyway. + CURL_DISCARD="NUL" + ;; +esac + +CFN_IMAGE="${HACKAGON_CLOUDFLARED_IMAGE:-cloudflare/cloudflared:latest}" + +cfn_container() { echo "cf-named-${1:?}"; } +cfn_dir() { echo "$(cf_state_dir)/named/${1:?}"; } +cfn_dir_docker() { # host path in the form docker.exe accepts + local d + d="$(cfn_dir "$1")" + case "$(uname -s)" in MINGW* | MSYS*) cygpath -m "$d" ;; *) echo "$d" ;; esac +} + +# ── Cloudflare side ────────────────────────────────────────────────────────── +# Create-or-reuse the tunnel, make sure we hold its credentials, point DNS at +# it. Idempotent: run it as often as you like. +cfn_ensure() { # <name> <fqdn> [fqdn…] + local name="$1" fqdn="$2" dir tid secret token host + shift 2 + local extras=("$@") + dir="$(cfn_dir "$name")" + + cf_load || { + echo "error: no usable Cloudflare credentials." >&2 + cf_explain_unconfigured >&2 + return 1 + } + cf_verify_token || return 1 + cf_resolve_zone || return 1 + + # Every fqdn must be inside the zone the token can edit. Checked here because + # the API's own error for this is a bare "record name is invalid". + for host in "$fqdn" "${extras[@]}"; do + case "$host" in + *".$CLOUDFLARE_ZONE" | "$CLOUDFLARE_ZONE") ;; + *) + echo "error: '$host' is not inside the zone '$CLOUDFLARE_ZONE'." >&2 + echo " The token is scoped to that zone and can write nowhere else." >&2 + return 1 + ;; + esac + done + + mkdir -p "$dir" + cf_guard_gitignored "$dir/credentials.json" || return 1 + + tid="$(cf_tunnel_id "$name")" || return 1 + if [ -z "$tid" ]; then + echo "==> Creating the named tunnel '$name'…" + # tr -d '\r\n', not '\n': Git Bash's openssl prints CRLF, and a CR inside a + # base64 secret is not a line ending, it is a byte in the credential. + secret="$(openssl rand -base64 32 | tr -d '\r\n')" + tid="$(cf_tunnel_create "$name" "$secret")" || return 1 + umask 077 + printf '{"AccountTag":"%s","TunnelSecret":"%s","TunnelID":"%s"}\n' \ + "$CF_ACCOUNT_ID" "$secret" "$tid" >"$dir/credentials.json" + echo " tunnel created $name ($tid)" + else + echo " tunnel reusing $name ($tid)" + if [ ! -s "$dir/credentials.json" ]; then + # Cloudflare will not hand the creation secret back, but the RUN TOKEN is + # base64 of the same three fields — so an existing tunnel whose + # credentials file was lost is recoverable without deleting it and + # re-pointing DNS. + echo " creds missing — rebuilding from the tunnel's run token" + token="$(cf_tunnel_token "$tid")" || return 1 + umask 077 + printf '%s' "$token" | base64 -d 2>/dev/null | + jqr '{AccountTag: .a, TunnelSecret: .s, TunnelID: .t}' >"$dir/credentials.json.tmp" || { + echo "error: could not decode the run token into a credentials file." >&2 + rm -f "$dir/credentials.json.tmp" + return 1 + } + # jqr is -r, which prints the object as JSON anyway; verify before commit. + grep -q '"TunnelID"' "$dir/credentials.json.tmp" || { + echo "error: the rebuilt credentials file has no TunnelID." >&2 + rm -f "$dir/credentials.json.tmp" + return 1 + } + mv "$dir/credentials.json.tmp" "$dir/credentials.json" + fi + fi + + for host in "$fqdn" "${extras[@]}"; do + cf_dns_point "$host" "$tid" || return 1 + done + echo "$tid" >"$dir/tunnel-id" + printf '%s\n' "$fqdn" "${extras[@]}" >"$dir/hostname" +} + +# ── the ingress file ───────────────────────────────────────────────────────── +# One rule per hostname, all pointing at the one service, then an explicit 404. +# The catch-all matters: without a final rule cloudflared refuses to start, and +# with a permissive one the tunnel would answer for hostnames it was never given. +# +# All hostnames share the service because the origin is a proxy that dispatches +# on Host itself (caddy here, ingress-nginx for the k3d rig). cloudflared passes +# the requested Host through unchanged, so the far end sees the name the browser +# asked for and routes on it. +cfn_write_config() { # <name> <fqdn> <service-url> [fqdn…] + local name="$1" fqdn="$2" service="$3" dir tid host + shift 3 + dir="$(cfn_dir "$name")" + tid="$(cat "$dir/tunnel-id")" + { + cat <<YAML +# GENERATED by .claude/skills/lib/cf-named-tunnel.sh — do not edit by hand. +# Mounted read-only at /etc/cloudflared inside the cloudflared container. +tunnel: $tid +credentials-file: /etc/cloudflared/credentials.json + +ingress: +YAML + for host in "$fqdn" ${1+"$@"}; do + cat <<YAML + - hostname: $host + service: $service + originRequest: + # The origin is a plain-HTTP proxy on its rig's docker network; TLS + # terminates at Cloudflare's edge, and cloudflared is what puts + # \`X-Forwarded-Proto: https\` on the request the origin receives. Nothing + # here speaks https, so no verification setting applies — this timeout is + # the only knob that has bitten us: a cold vite SSR can take ~19s and the + # default 30s is uncomfortably close. + connectTimeout: 30s +YAML + done + cat <<YAML + # Anything else reaching this tunnel is not ours. Say so rather than serving it. + - service: http_status:404 +YAML + } >"$dir/config.yml" +} + +# ── the container ──────────────────────────────────────────────────────────── +cfn_running() { # <name> + [ -n "$(docker ps -q -f "name=^$(cfn_container "$1")\$" 2>/dev/null)" ] +} + +# The hostname is read back off the RUNNING container's label, not off a state +# file. Same rule as the quick tunnels reading cloudflared's log rather than a +# cached URL: a state file describes what someone intended, a label describes +# what is actually serving. +cfn_url() { # <name> + local host + host="$(docker inspect "$(cfn_container "$1")" \ + --format '{{index .Config.Labels "hackagon.tunnel.hostname"}}' 2>/dev/null || true)" + [ -n "$host" ] || return 1 + echo "https://$host" +} + +cfn_stop() { # <name> + local c + c="$(cfn_container "$1")" + if [ -n "$(docker ps -aq -f "name=^$c\$" 2>/dev/null)" ]; then + docker rm -f "$c" >/dev/null + echo " stopped $c" + else + echo " no container $c" + fi +} + +cfn_run() { # <name> <fqdn> <network> <service-url> [fqdn…] + local name="$1" fqdn="$2" network="$3" service="$4" c dir_d all + shift 4 + c="$(cfn_container "$name")" + dir_d="$(cfn_dir_docker "$name")" + # Every hostname this container serves, on ONE label. `cfn_url` still reads + # `hostname` (the primary) so nothing that asks "what is this rig's URL" + # changes; this one exists so `status` and a teardown can name them all. + all="$(printf '%s,' "$fqdn" ${1+"$@"})" + all="${all%,}" + + cfn_write_config "$name" "$fqdn" "$service" ${1+"$@"} + + docker network inspect "$network" >/dev/null 2>&1 || { + echo "error: docker network '$network' does not exist — is that rig up?" >&2 + return 1 + } + + docker rm -f "$c" >/dev/null 2>&1 || true + docker run -d --name "$c" \ + --restart unless-stopped \ + --network "$network" \ + --label "hackagon.tunnel.name=$name" \ + --label "hackagon.tunnel.hostname=$fqdn" \ + --label "hackagon.tunnel.hostnames=$all" \ + --label "hackagon.tunnel.service=$service" \ + -v "$dir_d:/etc/cloudflared:ro" \ + "$CFN_IMAGE" \ + tunnel --no-autoupdate --config /etc/cloudflared/config.yml run >/dev/null + + # Two waits, because they fail for different reasons and only one of them is + # ours to fix. "Registered tunnel connection" is cloudflared reaching the edge + # — if that never appears the credentials or the network are wrong. The HTTPS + # probe is the hostname resolving and the origin answering, which additionally + # needs the DNS record and a live upstream. + printf " edge " + local i + for i in $(seq 1 40); do + if docker logs "$c" 2>&1 | grep -q "Registered tunnel connection"; then + echo "connected" + break + fi + if [ "$i" = 40 ]; then + echo "FAILED" + echo "error: cloudflared never registered a connection. Last lines:" >&2 + docker logs --tail 20 "$c" 2>&1 | sed 's/^/ /' >&2 + return 1 + fi + sleep 2 + done + + printf " https " + local code=000 + for i in $(seq 1 30); do + # `|| true`, NOT `|| echo 000`: curl writes %{http_code} — which is `000` + # on a connection failure — to stdout itself, so the fallback appended a + # SECOND 000 and the guard below saw the string "000000", which is not + # "000", so the loop broke on its first attempt and printed a code nobody + # could read. The probe reported success-shaped output having waited for + # nothing. + code="$(cfn_probe "$fqdn")" + # Anything but a connection failure means the name resolved AND Cloudflare + # reached the tunnel. A 502 here is the origin's problem, not the tunnel's, + # and saying so is more useful than waiting for it to become a 200. + [ "$code" != "000" ] && break + sleep 2 + done + + if [ "$code" != "000" ]; then + echo "$code" + return 0 + fi + + # ── it did not answer. WHICH of the two failures is it? ──────────────────── + # + # "The tunnel is broken" and "this machine cannot look the name up" produce + # the same silence and want opposite responses, so ask Cloudflare's own + # resolver over DoH and retry against the address it gives. If THAT works, the + # tunnel is fine and the local resolver is the story — which is a real state + # here, not a hypothetical: the network this was built on answers AAAA-only + # for these names and has no IPv6 route out, so every lookup succeeds and + # every connection fails in 9ms. + local ip + ip="$(curl -sS --max-time 10 "https://1.1.1.1/dns-query?name=$fqdn&type=A" \ + -H "accept: application/dns-json" 2>/dev/null | + jqr '[.Answer[]? | select(.type == 1) | .data][0] // empty' 2>/dev/null || true)" + if [ -n "$ip" ]; then + code="$(cfn_probe "$fqdn" "$ip")" + if [ "$code" != "000" ]; then + echo "$code (only via a pinned IPv4 edge)" + echo "warn: https://$fqdn WORKS — the tunnel and the DNS record are fine —" >&2 + echo " but THIS machine's resolver cannot reach it. Most often it is" >&2 + echo " answering AAAA-only on a network with no IPv6 route out." >&2 + echo " Anything running here needs the name pinned to $ip;" >&2 + echo " auth-wire.sh does that inside the dev container automatically." >&2 + return 0 + fi + fi + + echo "no answer" + echo "warn: https://$fqdn did not answer, and neither did $ip via a pinned" >&2 + echo " IPv4 edge. A freshly created record can take a minute; after that," >&2 + echo " check the origin: docker logs $c" >&2 +} + +# Fetch <url> and answer with its HTTP code, falling back to a DoH-resolved +# IPv4 edge when this machine's own resolver cannot get there. +# +# For the pre-flight checks in the rigs' wire-frontend.sh scripts, which refuse +# to write a config pointing at something that does not answer — a good rule +# that becomes a wrong ANSWER on a host whose resolver returns AAAA-only for a +# name that is serving perfectly. The check should fail when the TUNNEL is +# broken, not when the caller's DNS is. +# +# Prints the code; prints nothing else, so it can be used in a `$( )`. +cfn_http_code() { # <url> + local url="$1" host code ip + host="${url#*://}" + host="${host%%/*}" + host="${host%%:*}" + code="$(cfn_url_probe "$url")" + [ "$code" != "000" ] && { + printf '%s' "$code" + return 0 + } + ip="$(curl -sS --max-time 10 "https://1.1.1.1/dns-query?name=$host&type=A" \ + -H "accept: application/dns-json" 2>/dev/null | + jqr '[.Answer[]? | select(.type == 1) | .data][0] // empty' 2>/dev/null || true)" + [ -n "$ip" ] || { + printf '000' + return 0 + } + cfn_url_probe "$url" "$host" "$ip" +} + +cfn_url_probe() { # <url> [host ipv4] + local out + if [ -n "${3:-}" ]; then + out="$(curl -sS -o "$CURL_DISCARD" -w '%{http_code}' --max-time 20 \ + --resolve "$2:443:$3" "$1" 2>/dev/null || true)" + else + out="$(curl -sS -o "$CURL_DISCARD" -w '%{http_code}' --max-time 20 "$1" 2>/dev/null || true)" + fi + out="${out//[^0-9]/}" + [ -n "$out" ] || out=000 + printf '%s' "${out: -3}" +} + +# One probe attempt. <fqdn> [ipv4] — with an address it pins the connection to +# that edge instead of trusting the local resolver. +cfn_probe() { # -> a 3-digit code, 000 when nothing answered + local out + if [ -n "${2:-}" ]; then + out="$(curl -sS -o "$CURL_DISCARD" -w '%{http_code}' --max-time 10 \ + --resolve "$1:443:$2" "https://$1/" 2>/dev/null || true)" + else + out="$(curl -sS -o "$CURL_DISCARD" -w '%{http_code}' --max-time 10 "https://$1/" 2>/dev/null || true)" + fi + out="${out//[^0-9]/}" + [ -n "$out" ] || out=000 + # curl prints its own 000 on failure; keep the LAST three digits so a stray + # concatenation cannot read as success. + printf '%s' "${out: -3}" +} + +cfn_up() { # <name> <fqdn> <network> <service-url> [fqdn…] + local name="$1" fqdn="$2" network="$3" service="$4" + shift 4 + cfn_ensure "$name" "$fqdn" ${1+"$@"} || return 1 + cfn_run "$name" "$fqdn" "$network" "$service" ${1+"$@"} +} + +cfn_destroy() { # <name> <fqdn> [fqdn…] + local name="$1" host + shift + cfn_stop "$name" + cf_load || return 1 + cf_resolve_zone || return 1 + # Every hostname handed in, plus anything the state dir remembers — a record + # this tooling created and then forgot about is exactly the stranded record + # `destroy` exists to prevent. + local -a hosts=("$@") + if [ -s "$(cfn_dir "$name")/hostname" ]; then + while IFS= read -r host; do + [ -n "$host" ] || continue + case " ${hosts[*]-} " in *" $host "*) continue ;; esac + hosts+=("$host") + done <"$(cfn_dir "$name")/hostname" + fi + for host in ${hosts[0]+"${hosts[@]}"}; do + cf_dns_delete "$host" || true + done + local tid + tid="$(cf_tunnel_id "$name")" || return 1 + if [ -n "$tid" ]; then + cf_tunnel_delete "$tid" && echo " tunnel deleted $name ($tid)" + else + echo " tunnel absent $name" + fi + rm -rf "$(cfn_dir "$name")" +} + +cfn_status() { + local c host hosts svc + local found=0 + for c in $(docker ps --format '{{.Names}}' | grep -E '^cf-named-' || true); do + host="$(docker inspect "$c" --format '{{index .Config.Labels "hackagon.tunnel.hostname"}}' 2>/dev/null || true)" + hosts="$(docker inspect "$c" --format '{{index .Config.Labels "hackagon.tunnel.hostnames"}}' 2>/dev/null || true)" + svc="$(docker inspect "$c" --format '{{index .Config.Labels "hackagon.tunnel.service"}}' 2>/dev/null || true)" + printf '%-28s https://%-45s → %s\n' "$c" "$host" "$svc" + case "$hosts" in + "" | "$host") ;; + *) printf '%-28s also %s\n' "" "${hosts#"$host",}" ;; + esac + found=1 + done + [ "$found" -eq 1 ] || echo "no named tunnels running" +} + +# ── CLI ────────────────────────────────────────────────────────────────────── +# Sourced (BASH_SOURCE differs from $0) it defines functions and stops here. +if [ "${BASH_SOURCE[0]}" = "$0" ]; then + case "${1:-}" in + up) + shift + cfn_up "$@" + ;; + ensure) + shift + cfn_ensure "$@" + ;; + stop) + shift + cfn_stop "$@" + ;; + status) + cfn_status + ;; + url) + shift + cfn_url "$@" + ;; + running) + shift + cfn_running "$@" + ;; + destroy) + shift + cfn_destroy "$@" + ;; + check) + cf_load || { + cf_explain_unconfigured + exit 1 + } + cf_verify_token || exit 1 + cf_resolve_zone || exit 1 + echo "credentials ok — zone $CLOUDFLARE_ZONE is active and writable" + echo " app ${HACKAGON_HOSTNAME:-(unset)}" + echo " plausible ${PLAUSIBLE_HOSTNAME:-(unset)}" + echo " openreplay ${OPENREPLAY_HOSTNAME:-(unset)}" + echo " k3d app ${K3D_HOSTNAME:-(unset)}" + echo " k3d auth ${K3D_AUTH_HOSTNAME:-(unset)}" + ;; + -h | --help | "") + sed -n '2,20p' "${BASH_SOURCE[0]}" | sed 's/^# \{0,1\}//' + exit 0 + ;; + *) + echo "unknown command: $1 (see --help)" >&2 + exit 2 + ;; + esac +fi diff --git a/.claude/skills/lib/config-overlay.sh b/.claude/skills/lib/config-overlay.sh index 4e366355..7476a86a 100644 --- a/.claude/skills/lib/config-overlay.sh +++ b/.claude/skills/lib/config-overlay.sh @@ -25,8 +25,9 @@ # # It now has MORE THAN ONE WRITER, and they are unaware of each other: # -# oidc: .claude/skills/cloudflare-tunnel/scripts/auth-wire.sh -# replay: .claude/skills/openreplay-stack/scripts/wire-frontend.sh +# oidc: .claude/skills/cloudflare-tunnel/scripts/auth-wire.sh +# replay: .claude/skills/openreplay-stack/scripts/wire-frontend.sh +# plausible: .claude/skills/plausible-stack/scripts/wire-frontend.sh # # Each owns exactly one top-level key. If either treated "unwire me" as # "delete the overlay", it would silently take the other's wiring with it — @@ -57,8 +58,9 @@ HEADER='# GENERATED — machine-local config overlay. Do not edit, do not commit # independently. Do not delete this file to "turn something off": that takes # the other tools'"'"' wiring with it. # -# oidc: .claude/skills/cloudflare-tunnel/scripts/auth-wire.sh [--restore] -# replay: .claude/skills/openreplay-stack/scripts/wire-frontend.sh [--restore]' +# oidc: .claude/skills/cloudflare-tunnel/scripts/auth-wire.sh [--restore] +# replay: .claude/skills/openreplay-stack/scripts/wire-frontend.sh [--restore] +# plausible: .claude/skills/plausible-stack/scripts/wire-frontend.sh [--restore]' usage() { sed -n '2,7p' "${BASH_SOURCE[0]}" | sed 's/^# \{0,1\}//' >&2 diff --git a/.claude/skills/lib/frontend-build.sh b/.claude/skills/lib/frontend-build.sh new file mode 100644 index 00000000..291988ed --- /dev/null +++ b/.claude/skills/lib/frontend-build.sh @@ -0,0 +1,185 @@ +#!/usr/bin/env bash +# THE ONE WRITER of components/frontend/build/service. +# +# frontend-build.sh build # build, unconditionally +# frontend-build.sh if-stale # build only if src/ moved under the last build +# frontend-build.sh stale # exit 0 when a build is needed (no build) +# +# WHY THIS EXISTS. +# +# `pnpm build` in components/frontend is `vite build -m production`, and +# svelte.config.js sends adapter-node's output to +# `${QUITSH_BUILD_DIR:-build}/service`. Nothing in that chain is atomic and +# nothing in it is exclusive, so two builds running at once write the same tree +# — and vite also shares `.svelte-kit/output` between them. Observed +# 2026-08-13, three agents driving the harness concurrently: +# +# Unexpected end of JSON input (a half-written manifest read back) +# Cannot find module '…/build/service/server/index.js' (at server boot) +# +# There were TWO independent callers before this file existed, both writing +# `build/service` with no coordination whatsoever: +# +# hackathon-e2e/scripts/prod-frontend.sh serves that tree on :8081 +# cloudflare-tunnel/scripts/prod-serve.sh serves the SAME tree on :8082 +# +# so the two servers do not merely race to build it, they race to build it out +# from under each other while serving it. +# +# The fix is both halves, because they close different holes: +# +# 1. an exclusive LOCK, so two builds cannot interleave, and so the second +# caller waits and then discovers the first caller's fresh output instead +# of redoing it (staleness is re-checked INSIDE the lock — checking it +# outside is how both callers decide to build); +# +# 2. an atomic SWAP, so `build/service` only ever contains a COMPLETE tree. +# A build is minutes long on the 9p mount and gets interrupted (Ctrl-C, a +# container recreate, a suite timeout); without this, whatever it had +# written so far stays there looking like a build, and the next server to +# boot dies on a missing or truncated file. The lock alone cannot help +# with that — the writer is gone, not concurrent. +# +# Callers should not have to know any of this, which is why staleness lives here +# too rather than being reimplemented per caller. +set -euo pipefail +trap 'echo "frontend-build.sh: aborted at line $LINENO (status $?)" >&2' ERR + +HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +ROOT_DIR="$(cd "$HERE/../../.." && pwd)" +FRONTEND_DIR="$ROOT_DIR/components/frontend" +OUT_PARENT="$FRONTEND_DIR/build" +OUT="$OUT_PARENT/service" +ENTRY="$OUT/index.js" +RUN_DIR="$ROOT_DIR/.output/run" +LOCK="$RUN_DIR/frontend-build.lock" +BUILD_LOG="$RUN_DIR/frontend-build.log" +# Long, because the thing being waited for is a full production build of this app +# on a 9p bind mount. Measured 2026-08-13: ~2 min warm. A caller that gives up +# early is a caller that builds concurrently, which is the bug. +LOCK_WAIT="${FRONTEND_BUILD_LOCK_WAIT:-900}" + +mkdir -p "$RUN_DIR" + +# True when src/ (or the build's own inputs) moved since the last build. Same +# rule prod-frontend.sh used to carry inline; a stale build is worse than no +# build, because a suite then reports green against yesterday's frontend. +stale() { + [ -f "$ENTRY" ] || return 0 + local newer + newer="$(cd "$FRONTEND_DIR" && + find src static package.json pnpm-lock.yaml svelte.config.js vite.config.ts \ + -newer "$ENTRY" -print -quit 2>/dev/null || true)" + [ -n "$newer" ] +} + +# Build into a private directory, then move it into place. +# +# QUITSH_BUILD_DIR is read by svelte.config.js (`out = $QUITSH_BUILD_DIR/service`), +# so the temp tree needs no config change — and it stays inside `build/`, which +# is gitignored, so an interrupted build cannot dirty the worktree either. That +# matters more here than it looks: a dirty worktree is what makes every +# `nix develop` in this repo re-fetch and re-hash the tree under a global lock. +build_locked() { + local tmp="$OUT_PARENT/.build-$$" + # Sweep any temp trees a KILLED build left behind. Safe here and only here: we + # hold the lock, so no live build owns one. Without this they accumulate — the + # whole point of building elsewhere is that an interrupted build leaves its mess + # somewhere nothing reads, but somewhere is still on disk. + rm -rf "$OUT_PARENT"/.build-* "$OUT_PARENT"/.service-old-* + mkdir -p "$tmp" + + echo "==> Building the frontend (exclusive; log: $BUILD_LOG)..." + if ! (cd "$FRONTEND_DIR" && QUITSH_BUILD_DIR="$tmp" pnpm build) >"$BUILD_LOG" 2>&1; then + echo "error: pnpm build failed — see $BUILD_LOG" >&2 + tail -30 "$BUILD_LOG" >&2 + rm -rf "$tmp" + return 1 + fi + # Check the tree BEFORE swapping it in, and check the file that actually went + # missing. "vite exited 0" and "the server can boot" are different claims — the + # reported failure was `Cannot find module …/build/service/server/index.js`, at + # boot, from a build that had reported success. Nothing incomplete gets to + # replace a working tree. + local f + for f in index.js handler.js server/index.js; do + if [ ! -f "$tmp/service/$f" ]; then + echo "error: build reported success but $tmp/service/$f is missing —" >&2 + echo " refusing to swap it over the working build. See $BUILD_LOG." >&2 + rm -rf "$tmp" + return 1 + fi + done + + # Two renames on one filesystem, old tree out of the way first, so the window + # in which `build/service` does not exist is a single rename long. Callers + # start their server after this function returns, so nothing reads it during + # the swap. + # + # RETRIED, because `build/` is on the 9p bind mount and a directory rename + # there intermittently answers EPERM: + # + # mv: cannot move '…/build/service' to '…/build/.service-old-352884': + # Permission denied + # + # Observed 2026-08-13 mid-run and NOT reproducible a minute later with the same + # processes running and no open descriptors anywhere under the tree — so it is + # the filesystem, not a lock we could take or a handle we could close. An + # abort here is safe (the working tree is untouched) but it fails a build for a + # reason that clears itself, which is its own kind of flake. + local old="$OUT_PARENT/.service-old-$$" i + rm -rf "$old" + for i in 1 2 3 4 5; do + [ -d "$OUT" ] || break + mv "$OUT" "$old" 2>/dev/null && break + sleep 2 + done + if [ -d "$OUT" ]; then + echo "error: could not move $OUT aside after 5 attempts (9p EPERM — see above)." >&2 + echo " The existing build is untouched; the new one is in $tmp." >&2 + return 1 + fi + # If THIS one fails the tree would be missing entirely, which is the exact + # state this whole file exists to prevent — put the old one back. + if ! mv "$tmp/service" "$OUT"; then + if [ -d "$old" ]; then mv "$old" "$OUT"; fi + echo "error: could not move the new build into place; restored the previous one." >&2 + return 1 + fi + rm -rf "$old" "$tmp" + echo " built" +} + +case "${1:-if-stale}" in +stale) + # No lock: a read-only question, and the answer is only ever used to decide + # whether to CALL this script, which locks properly. `exit`, not a bare + # `stale`, so "not stale" is an ANSWER rather than an aborted script — the + # ERR trap above would otherwise print a scary line for the normal case. + if stale; then exit 0; else exit 1; fi + ;; +build) + flock -w "$LOCK_WAIT" 9 || { + echo "error: another frontend build held the lock for ${LOCK_WAIT}s" >&2 + exit 1 + } + build_locked + ;; +if-stale) + flock -w "$LOCK_WAIT" 9 || { + echo "error: another frontend build held the lock for ${LOCK_WAIT}s" >&2 + exit 1 + } + # INSIDE the lock. Whoever waited here was very likely waiting for exactly + # the build it wanted; re-asking is what turns N concurrent builds into one. + if stale; then + build_locked + else + echo "==> The frontend build is current — nothing to do." + fi + ;; +*) + echo "usage: frontend-build.sh [build|if-stale|stale]" >&2 + exit 2 + ;; +esac 9>"$LOCK" diff --git a/.claude/skills/openreplay-stack/SKILL.md b/.claude/skills/openreplay-stack/SKILL.md index fa927ba9..c547c8f3 100644 --- a/.claude/skills/openreplay-stack/SKILL.md +++ b/.claude/skills/openreplay-stack/SKILL.md @@ -33,6 +33,26 @@ First run pulls ~25 images and runs DB migrations — expect a long wait. There no signing up by hand: **`up.sh` creates the admin account itself** from `.secrets.env` (see below) and prints the project key when done. +## Named hostname (`--named`) + +`up.sh --named` puts this rig on a **persistent hostname you own** instead of a +quick tunnel, using the same Cloudflare credentials as the app's tunnel — +`OPENREPLAY_HOSTNAME` in the gitignored `.claude/skills/cloudflare-tunnel/.env`. +See that skill's SKILL.md for the token and its real (zone-wide) scope. Named is +the default when configured; `--quick` forces the old behaviour, which still +needs no account at all. + +**This is the rig a stable hostname helps most.** `COMMON_DOMAIN_NAME` is baked +into ~25 containers at boot, so every new quick-tunnel URL means rewriting +`common.env` and recreating the whole stack — and the app's `replay.ingestPoint` +with it. A tracker still holding the old ingest host posts sessions into +nothing, and an empty OpenReplay UI looks exactly like the correct default (the +tracker is consent-gated), so that failure is silent in the worst way. With a +named hostname, `wire-frontend.sh` is run once and stays true. + +Only one tunnel runs at a time: `--named` stops the quick tunnel and vice versa, +because `COMMON_DOMAIN_NAME` names exactly one of them. + ## Layout ``` diff --git a/.claude/skills/openreplay-stack/scripts/down.sh b/.claude/skills/openreplay-stack/scripts/down.sh index 963ad046..6f84fae5 100644 --- a/.claude/skills/openreplay-stack/scripts/down.sh +++ b/.claude/skills/openreplay-stack/scripts/down.sh @@ -22,19 +22,29 @@ require_vendor # which reads as a docker bug rather than a leftover container. export COMPOSE_PROFILES=migration +# The named tunnel's CONTAINER goes with the stack; its hostname and DNS record +# stay, so the next up.sh reuses both and nothing needs re-wiring. Give them up +# explicitly with: +# bash .claude/skills/lib/cf-named-tunnel.sh destroy hackagon-openreplay <host> +stop_named() { if cfn_running "$NAMED_TUNNEL"; then cfn_stop "$NAMED_TUNNEL"; fi; } + case "${1:-}" in --tunnel) - compose stop tunnel && rm -f "$STATE/tunnel-url" + compose stop tunnel || true + stop_named + rm -f "$STATE/tunnel-url" echo "tunnel stopped — the public URL is gone; the stack is still running." ;; --volumes) echo "==> stopping and DELETING ALL VOLUMES (recorded sessions included)…" + stop_named compose down --volumes --remove-orphans rm -f "$STATE/tunnel-url" "$STATE/env.prepared" echo "done. Next up.sh re-randomizes secrets, re-runs migrations, and" echo "re-creates the admin account from .secrets.env (kept on purpose)." ;; *) + stop_named compose down --remove-orphans rm -f "$STATE/tunnel-url" echo "stopped (data volumes kept)." diff --git a/.claude/skills/openreplay-stack/scripts/lib.sh b/.claude/skills/openreplay-stack/scripts/lib.sh index 332ca9d5..4fefaaf1 100644 --- a/.claude/skills/openreplay-stack/scripts/lib.sh +++ b/.claude/skills/openreplay-stack/scripts/lib.sh @@ -74,8 +74,41 @@ require_vendor() { } } -# Read the quick-tunnel URL out of cloudflared's log (it only ever prints it there). +# ── named vs quick tunnel ──────────────────────────────────────────────────── +# Same two modes as the app's own tunnel (see .claude/skills/lib/cf-api.sh for +# why named exists). Named when Cloudflare credentials and OPENREPLAY_HOSTNAME +# are configured; otherwise the quick tunnel, unchanged. +# +# The gain is largest here. COMMON_DOMAIN_NAME is baked into ~25 containers at +# boot, so a new hostname means rewriting common.env and recreating the whole +# stack — and every browser still carrying the old ingest URL posts sessions +# into a host that no longer exists, which looks exactly like "nobody visited". +# A stable hostname means the app's `replay.ingestPoint` wiring stays true. +# shellcheck source=../../lib/cf-named-tunnel.sh +source "$SKILL_DIR/../lib/cf-named-tunnel.sh" +NAMED_TUNNEL="${OPENREPLAY_TUNNEL_NAME:-hackagon-openreplay}" + +named_configured() { + cf_configured && [ -n "${OPENREPLAY_HOSTNAME:-}" ] +} + +# The URL this rig is reachable on, asked of whatever is RUNNING — a named +# tunnel's container label, or the quick tunnel's log, which is the only place +# cloudflared ever prints it. Never the state file: a restarted tunnel has a new +# URL and the same state file, and a stale URL fails silently. tunnel_url() { + cfn_url "$NAMED_TUNNEL" 2>/dev/null && return 0 docker logs "$(compose ps -q tunnel 2>/dev/null)" 2>&1 | grep -oE 'https://[a-z0-9-]+\.trycloudflare\.com' | tail -1 } + +# The docker network a service is on, read off the live container. Upstream +# defines `openreplay-net` explicitly and compose prefixes it with the project +# name, so guessing it wrong is easy — and it fails as a DNS lookup for `caddy` +# INSIDE cloudflared, which Cloudflare renders as a plain 502 while every +# container reports healthy. That cost an afternoon once already. +rig_network() { # <service> + docker inspect "$(compose ps -q "$1")" \ + --format '{{range $k,$v := .NetworkSettings.Networks}}{{$k}} {{end}}' 2>/dev/null | + awk '{print $1}' +} diff --git a/.claude/skills/openreplay-stack/scripts/up.sh b/.claude/skills/openreplay-stack/scripts/up.sh index 08e212e6..979a11d6 100644 --- a/.claude/skills/openreplay-stack/scripts/up.sh +++ b/.claude/skills/openreplay-stack/scripts/up.sh @@ -17,10 +17,13 @@ source "$HERE/lib.sh" DRY=0 SKIP_DOCTOR=0 +MODE="" while [ $# -gt 0 ]; do case "$1" in --dry-run) DRY=1 ;; --skip-doctor) SKIP_DOCTOR=1 ;; + --named) MODE=named ;; + --quick) MODE=quick ;; -h | --help) sed -n '2,14p' "${BASH_SOURCE[0]}" | sed 's/^# \{0,1\}//' exit 0 @@ -83,21 +86,47 @@ if [ "$DRY" -eq 1 ]; then exit 0 fi -# ── phase 1: tunnel only, to learn the public URL ────────────────────────── -echo "==> starting the quick tunnel…" -compose up -d --no-deps tunnel -url="" -for _ in $(seq 1 30); do - url="$(tunnel_url || true)" - [ -n "$url" ] && break - sleep 2 -done -[ -z "$url" ] && { - echo "error: no tunnel URL after 60s — check: docker logs openreplay-tunnel" >&2 - exit 1 -} +# ── phase 1: learn the public URL ────────────────────────────────────────── +# NAMED MODE SKIPS THIS PHASE. The hostname is known before anything starts, so +# COMMON_DOMAIN_NAME is written once and never revisited — which is worth more +# here than anywhere else, because that value is baked into ~25 containers and +# changing it recreates the whole stack. +if [ "$MODE" = "named" ] && ! named_configured; then + echo "error: --named needs Cloudflare credentials and OPENREPLAY_HOSTNAME." >&2 + cf_explain_unconfigured >&2 + exit 2 +fi +[ -n "$MODE" ] || { named_configured && MODE=named || MODE=quick; } + +if [ "$MODE" = "named" ]; then + url="https://$OPENREPLAY_HOSTNAME" + echo "==> Mode: NAMED — $url (persistent)" + # One COMMON_DOMAIN_NAME, one hostname. A second tunnel onto the same caddy + # would serve a UI whose API calls all address the other host. + if [ -n "$(compose ps -q tunnel 2>/dev/null)" ]; then + echo " stopping the quick tunnel (named mode owns COMMON_DOMAIN_NAME)" + compose rm -sf tunnel >/dev/null 2>&1 || true + fi +else + echo "==> Mode: QUICK — starting the quick tunnel to learn its hostname…" + if cfn_running "$NAMED_TUNNEL"; then + echo " stopping the named tunnel (one COMMON_DOMAIN_NAME, one hostname)" + cfn_stop "$NAMED_TUNNEL" + fi + compose up -d --no-deps tunnel + url="" + for _ in $(seq 1 30); do + url="$(tunnel_url || true)" + [ -n "$url" ] && break + sleep 2 + done + [ -z "$url" ] && { + echo "error: no tunnel URL after 60s — check: docker logs openreplay-tunnel" >&2 + exit 1 + } + echo " $url" +fi host="${url#https://}" -echo " $url" echo "$url" >"$STATE/tunnel-url" # ── prepare env, part B: the domain (changes on every tunnel restart) ────── @@ -113,7 +142,25 @@ sync_dotenv # ── phase 3: everything (migration profile creates schemas on first run) ─── echo "==> starting OpenReplay (first run pulls ~25 images — this takes a while)…" -COMPOSE_PROFILES=migration compose up -d +if [ "$MODE" = "named" ]; then + # Every service EXCEPT the quick tunnel, named explicitly — a bare `up -d` + # would start it and mint a hostname nothing uses. + # shellcheck disable=SC2046 + COMPOSE_PROFILES=migration compose up -d \ + $(COMPOSE_PROFILES=migration compose config --services | grep -v '^tunnel$' | tr '\n' ' ') +else + COMPOSE_PROFILES=migration compose up -d +fi + +# ── phase 3b: the named tunnel, once caddy exists to point it at ─────────── +if [ "$MODE" = "named" ]; then + cfn_up "$NAMED_TUNNEL" "$OPENREPLAY_HOSTNAME" "$(rig_network caddy)" \ + "http://caddy:80" || { + echo "error: the named tunnel did not come up — OpenReplay is local-only," >&2 + echo " which means the tracker cannot reach /ingest from a browser." >&2 + exit 1 + } +fi # ── phase 4: the admin account, from .secrets.env ────────────────────────── # The stack has no seeded account and the first signup becomes the admin; an @@ -133,5 +180,12 @@ echo " Tracker config for the SvelteKit app:" echo " ingestPoint: \"$url/ingest\"" echo " projectKey: printed above, or: bash $HERE/wire-frontend.sh --print" echo "" -echo " ⚠ This URL dies with the tunnel. Re-running up.sh mints a new one and" -echo " rewrites the config — fine for debugging, not for anything lasting." +if [ "$MODE" = "named" ]; then + echo " Mode: NAMED — this hostname persists. COMMON_DOMAIN_NAME and any" + echo " wiring done against it stay correct across restarts, so the ~25" + echo " containers do not have to be recreated for a new URL." +else + echo " ⚠ This URL dies with the tunnel. Re-running up.sh mints a new one and" + echo " rewrites the config — fine for debugging, not for anything lasting." + echo " A named hostname removes that churn: see SKILL.md, 'Named tunnels'." +fi diff --git a/.claude/skills/plausible-stack/.gitignore b/.claude/skills/plausible-stack/.gitignore new file mode 100644 index 00000000..6ef46c93 --- /dev/null +++ b/.claude/skills/plausible-stack/.gitignore @@ -0,0 +1,9 @@ +# Upstream's compose is fetched, not vendored into git (pinned + SHA-recorded +# by scripts/fetch-upstream.sh, so a fetch is reproducible). +vendor/ +.state/ + +# Admin credentials + SECRET_KEY_BASE, generated by scripts/up.sh on first run. +# This entry is what protects them on a branch where .claude/ is TRACKED — +# secrets.sh refuses to write the file at all if git would not ignore it. +.secrets.env diff --git a/.claude/skills/plausible-stack/SKILL.md b/.claude/skills/plausible-stack/SKILL.md new file mode 100644 index 00000000..7fe8b4b1 --- /dev/null +++ b/.claude/skills/plausible-stack/SKILL.md @@ -0,0 +1,313 @@ +--- +name: plausible-stack +description: + Spin up a self-hosted Plausible Analytics (Community Edition) instance with + docker compose, behind a Cloudflare quick tunnel so both the dashboard and the + tracking script have a public origin. Vendors the upstream compose, prepares + secrets and the owner account non-interactively, wires the SvelteKit app at it + and back, and proves a page view lands end to end. Use when asked to + run/try/evaluate Plausible, add privacy-friendly analytics, or count page + views for the hackathon platform. +--- + +# Plausible CE behind a quick tunnel + +A **development rig**, not a deployment. Three containers plus a tunnel: +Plausible, its own Postgres, its own ClickHouse. Same shape as +`openreplay-stack`, one tenth the weight, and it answers a different question — +"is this page used at all", which the RPC journal structurally cannot see and +session replay is far too heavy to answer. + +## Commands + +```bash +bash .claude/skills/plausible-stack/scripts/doctor.sh # preflight +bash .claude/skills/plausible-stack/scripts/up.sh # fetch, secrets, tunnel, start, owner account, lock down +bash .claude/skills/plausible-stack/scripts/up.sh --dry-run # everything except `compose up` +bash .claude/skills/plausible-stack/scripts/url.sh --all # current public URL +bash .claude/skills/plausible-stack/scripts/wire-frontend.sh # point the app at it +bash .claude/skills/plausible-stack/scripts/verify.sh # prove it end to end +bash .claude/skills/plausible-stack/scripts/wire-frontend.sh --restore +bash .claude/skills/plausible-stack/scripts/down.sh # stop, keep the statistics +bash .claude/skills/plausible-stack/scripts/down.sh --volumes # stop and delete everything +``` + +`up.sh` needs no input and asks for none: it mints the secrets, registers the +owner, creates the site and a Stats API key, then **closes registration behind +itself** and checks that `/register` refuses. + +## Named hostname (`--named`) + +`up.sh --named` puts this rig on a **persistent hostname you own** instead of a +quick tunnel, using the same Cloudflare credentials as the app's tunnel — +`PLAUSIBLE_HOSTNAME` in the gitignored `.claude/skills/cloudflare-tunnel/.env`. +See that skill's SKILL.md for the token and its real (zone-wide) scope. Named is +the default when configured; `--quick` forces the old behaviour, which still +needs no account at all. + +It matters more here than it looks. **`BASE_URL` is read once at boot** and used +for link generation and the LiveView origin check, so a new hostname means +rewriting `vendor/.env` and recreating the container — and any browser still +holding the old `scriptUrl` posts into a host that no longer exists, which is +indistinguishable from "nobody visited". With a stable hostname the wiring +written by `wire-frontend.sh` stays true across restarts, and `up.sh --named` +skips the "start a tunnel just to find out what it is called" phase entirely. + +Only one tunnel runs at a time: `--named` stops the quick tunnel and vice versa, +because `BASE_URL` names exactly one of them and a dashboard reached on the +other renders and then never loads any numbers. + +## Layout + +``` +compose.tunnel.yaml overlay: adds cloudflared, publishes :8010 on loopback +scripts/ doctor · fetch-upstream · secrets · up · url · down + signup (owner + site + Stats API key, via `bin/plausible rpc`) + wire-frontend (point the app at this rig, and back) + verify (9 checks, ending at Plausible's own API) + pageview.mjs (drives a real Firefox; used by verify) +vendor/ upstream compose.yml + clickhouse/ (fetched, gitignored) +vendor/UPSTREAM.txt repo, ref, exact commit and the image tag +vendor/.env what compose interpolates (generated) +.state/ tunnel URL (gitignored) +.secrets.env owner credentials, SECRET_KEY_BASE, API key (gitignored) +``` + +`vendor/` is fetched at a pinned tag (`PLAUSIBLE_REF`, default `v3.2.1`) rather +than committed: **the compose file IS the version** — the image tag lives inside +it — so the ref pinned here and the release running are one decision. + +## Why the tunnel starts first + +Plausible reads `BASE_URL` **at boot** and uses it for link generation and for +the LiveView origin check, so a dashboard booted against the wrong hostname +serves HTML and then fails to connect its own websocket: a page that renders and +never loads any numbers. `up.sh` therefore starts `tunnel` alone (`--no-deps`), +reads the URL out of cloudflared's log, writes it, and only then starts the app. +Same ordering trap as openreplay-stack's `COMMON_DOMAIN_NAME`, and the same +consequence: **every `up.sh` mints a new URL**, so this is a debugging tool. A +lasting instance wants a named tunnel and a fixed `BASE_URL`. + +Two differences from that rig, both easy to get backwards: + +| | openreplay-stack | here | +| -------------------- | ----------------------------------------------------- | ------------------------------------------------------------------------ | +| tunnel's `networks:` | must name `openreplay-net` — upstream defines one | must name NOTHING — upstream defines none, so everything is on `default` | +| public hostname | bare host in `COMMON_DOMAIN_NAME` + `COMMON_PROTOCOL` | full URL in `BASE_URL`, scheme included | + +Getting either wrong produces the same symptom: a 502 on the public URL with +every container healthy. + +## The owner account, and why signup is an `rpc` + +Plausible CE seeds no account; whoever registers first owns the instance, and +with no mailer configured there is **no recovery path**. So the credentials are +generated into `.secrets.env` and the account is created from them — +`openreplay-stack` learned this by having to wipe its volumes once. + +The HTTP route _looks_ scriptable and is not. `GET /register` serves a form with +`user[name]`, `user[email]`, `user[password]`, `user[password_confirmation]` and +a CSRF token; **`POST /register` is 404.** The form is a LiveView +(`phx-submit="register"`) — the account is created by a handler on the +websocket, and the form's own `action="/login"` only signs the new user in +afterwards. `signup.sh` therefore runs Elixir inside the release with +`bin/plausible rpc`, which is what that handler would have done. + +The cost, written down rather than discovered: that code names internal +functions (`Plausible.Auth.User.new/1`, `Plausible.Sites.create/2`, +`Plausible.Auth.create_stats_api_key/4`), so it is pinned to the version in +`vendor/UPSTREAM.txt` in a way an HTTP call would not be. Every step matches on +its expected result, so a moved API fails loudly instead of half-working. + +`.secrets.env` sits OUTSIDE `.state/` on purpose: it must survive `down.sh` and +even `--volumes`, so that a wipe is the RECOVERY path (the next `up.sh` +re-creates the same account) and not a second lockout. `secrets.sh` refuses to +write it at all if `git check-ignore` does not cover it. + +**Registration is closed after the account exists.** A quick-tunnel URL is +unguessable, not private, and an open `/register` on it is an invitation. The +check that matters is the one after the restart: "the variable is set" and "the +route refuses" are different claims, and only the second is verified. + +## Wiring the app + +```bash +bash .claude/skills/plausible-stack/scripts/wire-frontend.sh # ON +bash .claude/skills/plausible-stack/scripts/wire-frontend.sh --print # show, change nothing +bash .claude/skills/plausible-stack/scripts/wire-frontend.sh --restore # OFF +``` + +It writes **`config.local.yaml`, never the tracked `config.yaml`** — the value +is a `*.trycloudflare.com` hostname that dies in a few hours, and this repo has +already had one of those committed and left dead in HEAD for several commits. +`internal/config/config_test.go` asserts both tracked configs still say +`localhost`; `verify.sh` re-checks it too. + +**This is the THIRD writer of that overlay** (`oidc` ← cloudflare-tunnel, +`replay` ← openreplay-stack, `plausible` ← here), and none of them knows about +the others, so `--restore` removes the **block** and never the file. +`.claude/skills/lib/config-overlay.sh` does the per-key surgery and deletes the +file only when the last key leaves it. An `rm` here would silently break login +(a tunnel with no issuer keeps serving every page) or stop session replay (an +empty OpenReplay UI looks exactly like the correct default). + +⚠ **Anything that READS this config must read the MERGED view.** Wiring writes +the overlay, so a reader that looked only at `config.yaml` finds `enabled` +absent on a perfectly wired machine, self-skips, and reports success having +checked nothing — precisely how the replay suite once passed while testing +nothing. `verify.sh` reads both. + +### The script variant is two decisions + +`…/js/script.local.manual.js`, and both parts are load-bearing: + +- **`local`** — the stock script refuses to send from a local address. Read out + of the served file, not the docs: + `/localhost$|^127(\.[0-9]+){0,2}\.[0-9]+$|^\[::1?\]$/.test(location.hostname)`. + The dev app is `http://localhost:8081`, so without this variant every page + view is dropped **by the browser** — everything looks wired and nothing + arrives. +- **`manual`** — the stock script otherwise sends a pageview by itself, using + `location.href`. That URL is the one thing this integration must never send. + Manual mode means every page view is one the app decided to send, with a URL + it built. + +`verify.sh` asserts both properties of the fetched script, because a wiring that +names the wrong variant is invisible until somebody asks why the dashboard is +empty. + +### Unwire before running an e2e suite + +Nothing breaks if you do not — the tracker's POSTs are fire-and-forget and no +suite asserts about them — but a journey run is 465 actions of page loads, so +the numbers become mostly Playwright. `run.sh` does NOT borrow this block away +(the same decision as for `replay`: a suite should see what a visitor sees), so +it is a manual `wire-frontend.sh --restore` before, and a re-wire after. + +## Privacy: the decisions, not the defaults + +Full statement in **`docs/frontend/analytics.md`**. The short version: + +- **The URL is never sent.** `u` is SvelteKit's ROUTE ID — + `/my/hackathon/[id]/teams` — so it cannot contain an id, because it never + touched one. `/invite/<token>` (that token is a working credential) is + reported as `/invite/[token]`. Query strings are dropped, so `utm_*` is not + recorded either. The trade is accepted and stated: no per-hackathon + breakdowns, ever. +- **Cookieless, and that is the property consent turns on.** Nothing is stored + in or read from the browser. +- **Plausible still sees the IP and the user agent**, and hashes them with a + daily-rotated salt to count unique visitors. Only the hash is stored — + verified against `system.columns`, which shows no IP or user-agent column in + `events_v2`/`sessions_v2`, rather than against a marketing page. No + geolocation database is shipped, so country/city stay empty. +- **It sits OUTSIDE the session-replay consent gate**, deliberately: that banner + asks about session RECORDING, and consent is scoped to what was asked. A + second banner would ask about something with no artefact to permit or + withdraw. `DNT`/GPC still suppress it, before the script is fetched. +- **Nothing is correlated.** No user id, session id or replay id is ever sent; + these counts cannot be joined to the RPC journal or to a replay. + +## Cost, measured + +Idle, on this machine, with the app stack and the openreplay rig also running: + +| container | RSS | image | +| ---------------------------------- | ------------ | -------- | +| `plausible` | 431 MB | 265 MB | +| `plausible_events_db` (ClickHouse) | 188 MB | 718 MB | +| `plausible_db` (Postgres 16) | 110 MB | 396 MB | +| `tunnel` | 18 MB | — | +| **total** | **≈ 750 MB** | ≈ 1.4 GB | + +Volumes after the first few page views: ~80 MB, nearly all of it Postgres's +initial database. + +**It coexists with the openreplay rig comfortably** — that one wants 8 GB _of +its own_, this one runs in under one, and the two were up together throughout +the verification below. Upstream recommends 2 GB RAM; the measured idle +footprint is well under that, and ClickHouse is configured small by upstream's +own `clickhouse/low-resources.xml`. + +Ports: **one**, `127.0.0.1:8010` (override `PLAUSIBLE_PORT`). Not upstream's +8000 — the most contested port on a developer machine, and a collision fails +`compose up` after the databases have started, which reads as a stack failure +rather than a clash. Loopback only; the tunnel is the entrypoint. + +## Verification status + +**Verified on 2026-08-14** against CE v3.2.1 (commit `ec6c4da`), Windows host, +Docker Desktop. `verify.sh` — all nine checks: + +1. every compose service has a running container +2. the dashboard answers **through the tunnel**, with a real login round-trip + (CSRF → `POST /login` → 302 → `/sites` lists the site) +3. `/register` refuses signup on that public URL +4. the app is wired, read from the **merged** config, and the tracked + `config.yaml` still says `localhost` +5. the tracker script is fetchable and is the `local` + `manual` variant +6. **a real Firefox** visits `/`, clicks through to `/hackathon/<uuid>` and + opens `/invite/<token>`; the captured POST bodies carry `/`, + `/hackathon/[id]`, `/invite/[token]` and **no UUID, no token, no internal + referrer** +7. **Plausible's own Stats API** returns those three pages for today +8. `events_v2`/`sessions_v2` have no IP or user-agent column +9. `--restore` removes `plausible` and leaves `oidc` and `replay` intact + (simulated on a copy; also done for real once, with both surviving) + +Step 6 does not self-skip. Without `--no-browser`, an unavailable browser is a +**failure**: a proof that quietly drops its own hardest step is how this repo +has been lied to before. Unwired, the same script records **zero** requests — +which is the other half of "absent config ⇒ absent script". + +The **wipe-and-recover** claim is verified too, not just written down: +`down.sh --volumes` (databases gone, frontend unwired, `oidc`/`replay` intact) → +`up.sh` → a new tunnel URL, the SAME owner account re-created from +`.secrets.env`, the site re-created, registration closed again → re-wire → +`verify.sh` green. All nine checks above were re-run after that cycle. + +**That round-trip found a bug that only a real wipe could show.** The Stats API +key survives in `.secrets.env` by design, but its ROW was in the wiped database +— so `signup.sh`'s "is the key set" test was true and useless, and the first +thing to notice would have been a 401 in `verify.sh` step 7, pointing nowhere +near the cause. It probes the key against the Stats API now and mints a new one +when the answer is not 200. A file that outlives the thing it describes is not a +cache; it is a claim that needs checking. + +Playwright and Firefox are borrowed from the sibling `hackathon-e2e` skill (the +only place they are installed), through a `createRequire` anchored at that +package — ESM resolves bare specifiers relative to the FILE, and `cd`-ing does +not help. + +### Two traps this cost, both worth keeping + +**A carriage return inside a secret.** Git Bash's `openssl` prints CRLF, so +`openssl rand -base64 32 | tr -d '\n'` leaves a `\r` at the END OF THE VALUE — +no longer a line ending, just a byte in a secret. Plausible then refuses to boot +with `TOTP_VAULT_KEY must be Base64 encoded 32 bytes` about a key that decodes +to exactly 32 bytes in every tool you check it with. It survives every obvious +check: `cat -A` on a file `sed` has since rewritten shows nothing (MSYS strips +CRs on the way through), `docker compose config` shows it clean because that +reads `.env` rather than the process environment, and +`docker inspect --format '{{range .Config.Env}}'` renders the CR as the line +break it looks like. Only `{{json .Config.Env}}` shows it. Stripped in three +places now — at generation, on read, and on write into `.env` — because a +`.secrets.env` already on disk is not fixed by fixing the generator. + +**`curl -o /dev/null` on a Windows host.** `lib.sh` exports `MSYS_NO_PATHCONV=1` +(docker needs its own `/container/paths` left alone), so `/dev/null` reaches +`curl.exe` verbatim, which tries to create a file at that literal path and +prints `curl: (23) client returned ERROR on write` — in the middle of a check +that then PASSES, because `-w '%{http_code}'` already produced the number. Use +`$CURL_DISCARD`. The same shape bites `curl … | grep … | head`: head closes the +pipe at the first match while curl is still writing. Capture, then match. + +**A restart that stops a server and does not start it.** The first +`wire-frontend.sh` ran its restarts as `cmd && cmd` with output on `/dev/null`, +and left the app tunnel's only upstream (`:8082`) stopped while printing +"analytics is ON". The public URL kept answering — caddy falls back to `:8081` — +right up until that was down too. Every restart step now captures its output, +prints `ok`/`FAILED`, and names the port that is down. **Three** servers can be +serving this app (process-compose's vite, `prod-frontend.sh` on :8081, +`prod-serve.sh` on :8082 for the tunnel), and the built ones read their config +once at boot, so all three are bounced. diff --git a/.claude/skills/plausible-stack/compose.tunnel.yaml b/.claude/skills/plausible-stack/compose.tunnel.yaml new file mode 100644 index 00000000..59822510 --- /dev/null +++ b/.claude/skills/plausible-stack/compose.tunnel.yaml @@ -0,0 +1,38 @@ +# Overlay merged on top of the vendored upstream compose: +# docker compose -f vendor/compose.yml -f compose.tunnel.yaml … +# +# Upstream ships no ingress at all (its README tells you to publish ports or +# put a reverse proxy in front), so this adds the two things a debugging rig +# needs: a Cloudflare quick tunnel, and a loopback port. +services: + tunnel: + image: cloudflare/cloudflared:latest + container_name: plausible-tunnel + restart: unless-stopped + # --no-autoupdate: the container is disposable; an in-place update would + # restart the process and silently mint a NEW public URL, which every + # wired browser would keep posting past. + command: tunnel --no-autoupdate --url http://plausible:8000 + depends_on: + - plausible + # ⚠ NO `networks:` KEY HERE, AND THAT IS DELIBERATE — the opposite of the + # openreplay overlay, for the opposite reason. Upstream's compose defines + # no networks, so every service lands on compose's implicit `default` and a + # service that named one would be the odd one out and fail to RESOLVE + # `plausible`. (OpenReplay's upstream DOES define `openreplay-net`, so its + # tunnel had to join it explicitly — that omission cost an afternoon of + # 502s with every container healthy.) The rule is the same either way: + # match whatever upstream does, and check with `compose config` that the + # tunnel and its target share a network. + + plausible: + ports: + # LOOPBACK ONLY. The tunnel is the entrypoint — the browser must reach + # the ingest endpoint from outside — but a local port is what lets a + # diagnostic tell "Plausible is broken" apart from "Cloudflare is + # broken", which are indistinguishable from the public URL. 127.0.0.1 so + # publishing it does not put a dashboard on the LAN. + # + # Container-side 8000 is upstream's HTTP_PORT default; up.sh pins + # HTTP_PORT=8000 in .env rather than relying on it. + - "127.0.0.1:${PLAUSIBLE_PORT:-8010}:8000" diff --git a/.claude/skills/plausible-stack/scripts/doctor.sh b/.claude/skills/plausible-stack/scripts/doctor.sh new file mode 100644 index 00000000..c0d901c7 --- /dev/null +++ b/.claude/skills/plausible-stack/scripts/doctor.sh @@ -0,0 +1,112 @@ +#!/usr/bin/env bash +# Preflight. Cheaper than discovering the same facts three images into a pull. +# +# Plausible's own floor is modest (upstream README: 2 GB RAM for ClickHouse + +# Plausible), which is a third of what the openreplay rig wants — the two +# CAN coexist on this machine, and the measured numbers are in SKILL.md. +set -uo pipefail +HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +source "$HERE/lib.sh" +fail=0 +warn=0 +ok() { printf ' [x] %s\n' "$1"; } +bad() { + printf ' ✕ %s\n' "$1" + fail=1 +} +soft() { + printf ' ! %s\n' "$1" + warn=1 +} + +echo "── docker" +if docker info >/dev/null 2>&1; then ok "daemon reachable"; else bad "daemon not reachable"; fi +docker compose version >/dev/null 2>&1 && ok "compose v2" || bad "docker compose v2 required" + +echo "── architecture" +arch=$(uname -m) +case "$arch" in +x86_64 | amd64 | aarch64 | arm64) ok "$arch (ClickHouse needs SSE4.2 or NEON)" ;; +*) bad "$arch — ClickHouse requires SSE 4.2 (x86) or NEON (arm)" ;; +esac + +echo "── resources (as seen by the docker host)" +mem=$(docker info --format '{{.MemTotal}}' 2>/dev/null || echo 0) +memgb=$((mem / 1024 / 1024 / 1024)) +if [ "$memgb" -ge 4 ]; then + ok "${memgb} GB RAM" +elif [ "$memgb" -gt 0 ]; then + soft "${memgb} GB RAM — 2 GB is upstream's floor, and that is for Plausible ALONE" +else soft "could not read MemTotal"; fi + +cpus=$(docker info --format '{{.NCPU}}' 2>/dev/null || echo 0) +[ "$cpus" -ge 2 ] && ok "${cpus} vCPU" || soft "${cpus} vCPU" + +avail=$(df -Pk "$SKILL_DIR" 2>/dev/null | awk 'NR==2{print int($4/1024/1024)}') +[ -n "$avail" ] && { [ "$avail" -ge 10 ] && ok "${avail} GB free" || soft "${avail} GB free — 10 GB recommended"; } + +echo "── ports" +# The only host port this stack takes. Something else on it does not merely +# collide: `compose up` fails after the databases have started, which reads as +# a stack failure rather than a port clash. +# `ss` on Linux, `netstat` on a Windows host — and when NEITHER is available, +# say so rather than printing "free". A probe that cannot run and a probe that +# found nothing look identical from the outside, and only one of them is +# information. +listeners() { + if command -v ss >/dev/null 2>&1; then + ss -ltn 2>/dev/null + elif command -v netstat >/dev/null 2>&1; then + netstat -ano 2>/dev/null | grep -i listen + else + return 1 + fi +} +if ports="$(listeners)"; then + if printf '%s\n' "$ports" | grep -qE "[:.]$LOCAL_PORT[[:space:]]"; then + # Ours holding it is fine; anything else is not. + if docker ps --format '{{.Names}} {{.Ports}}' | grep -q ":$LOCAL_PORT->"; then + ok "port $LOCAL_PORT held by a container (this stack, presumably)" + else + bad "port $LOCAL_PORT in use by something else — set PLAUSIBLE_PORT" + fi + else + ok "port $LOCAL_PORT free" + fi +else + soft "no ss/netstat here — could not check whether port $LOCAL_PORT is free" +fi + +echo "── neighbours" +if docker ps --format '{{.Names}}' | grep -qx 'clickhouse'; then + soft "the openreplay rig is running — it has its OWN ClickHouse; this one is separate (~1 GB more)" +fi + +# ── a stack that is up: is ALL of it up? ─────────────────────────────────── +# `docker compose ps` shows what IS there and says nothing about what is not. +# A service whose container was removed reads exactly like a service that was +# never meant to run — that is how openreplay's `sink` went missing while every +# client-side check stayed green. +if [ -f "$VENDOR/compose.yml" ] && [ -n "$(compose ps -q 2>/dev/null)" ]; then + echo "── running stack" + missing="" + running="$(compose ps --format '{{.Service}}' 2>/dev/null | sort -u)" + while IFS= read -r svc; do + [ -n "$svc" ] || continue + printf '%s\n' "$running" | grep -qx "$svc" || missing="$missing $svc" + done <<EOF +$(compose config --services 2>/dev/null | sort -u) +EOF + if [ -n "$missing" ]; then + bad "not running:$missing — start them with: bash $HERE/up.sh" + else + ok "every compose service has a running container" + fi +fi + +echo "" +[ "$fail" -eq 0 ] && echo "Preflight passed$([ "$warn" -eq 1 ] && echo " (with warnings)")." || + { + echo "Preflight FAILED — fix the ✕ items first." + exit 1 + } diff --git a/.claude/skills/plausible-stack/scripts/down.sh b/.claude/skills/plausible-stack/scripts/down.sh new file mode 100644 index 00000000..3ced33b3 --- /dev/null +++ b/.claude/skills/plausible-stack/scripts/down.sh @@ -0,0 +1,44 @@ +#!/usr/bin/env bash +# Stop the rig. +# +# down.sh stop the containers, keep the collected statistics +# down.sh --volumes …and delete the databases (Postgres AND ClickHouse) +# +# It also UNWIRES THE FRONTEND, and that is not tidiness. A wired app points at +# a tunnel hostname that stops existing the moment this script runs; leaving the +# block behind means every browser loading the app tries to fetch a tracker +# script from a dead host on every page. Nothing breaks visibly — which is +# exactly why it would stay that way. +# +# `.secrets.env` is never touched: after `--volumes` the next up.sh re-creates +# the same owner account from it, which is what makes a wipe recoverable. +set -euo pipefail +HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +source "$HERE/lib.sh" +require_docker + +VOLUMES=0 +[ "${1:-}" = "--volumes" ] && VOLUMES=1 + +bash "$HERE/wire-frontend.sh" --restore || true + +# The named tunnel's container, if this rig is running one. Its HOSTNAME and DNS +# record are kept: that is the whole point of a named tunnel, and the next up.sh +# reuses both. Give them up explicitly with +# bash .claude/skills/lib/cf-named-tunnel.sh destroy hackagon-plausible <host> +if cfn_running "$NAMED_TUNNEL"; then cfn_stop "$NAMED_TUNNEL"; fi + +if [ "$VOLUMES" -eq 1 ]; then + echo "==> stopping and deleting volumes" + compose down -v --remove-orphans + # The tunnel URL and the "which URL was this wired at" marker describe a + # world that no longer exists. + rm -f "$STATE/tunnel-url" +else + echo "==> stopping (statistics kept)" + compose down --remove-orphans +fi + +echo " done. Bring it back with: bash $HERE/up.sh" +[ "$VOLUMES" -eq 1 ] && echo " (a new tunnel URL will be minted, and the owner account re-created from .secrets.env)" +exit 0 diff --git a/.claude/skills/plausible-stack/scripts/fetch-upstream.sh b/.claude/skills/plausible-stack/scripts/fetch-upstream.sh new file mode 100644 index 00000000..6b7b952c --- /dev/null +++ b/.claude/skills/plausible-stack/scripts/fetch-upstream.sh @@ -0,0 +1,75 @@ +#!/usr/bin/env bash +# Fetch upstream's compose.yml + clickhouse/ into vendor/ and record the exact +# commit in vendor/UPSTREAM.txt, so a fetch is reproducible. +# +# Upstream's README is a manual quick-start (edit .env by hand, then +# `docker compose up -d`, then create the first user in a browser). up.sh does +# the same preparation non-interactively and keeps control — same relationship +# openreplay-stack has with upstream's install.sh. +# +# The COMPOSE FILE IS THE VERSION. `ghcr.io/plausible/community-edition:v3.2.1` +# is written inside it, so the ref pinned here and the release running are one +# decision rather than two that can drift. +# +# Usage: fetch-upstream.sh [--force] env: PLAUSIBLE_REF=v3.2.1 +set -euo pipefail +HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +source "$HERE/lib.sh" + +[ "${1:-}" = "--force" ] && rm -rf "$VENDOR" +if [ -f "$VENDOR/compose.yml" ]; then + echo "already fetched: $(head -1 "$VENDOR/UPSTREAM.txt" 2>/dev/null)" + echo "(use --force to refetch)" + exit 0 +fi + +command -v git >/dev/null || { + echo "error: git not found" >&2 + exit 1 +} +tmp="$(mktemp -d)" +trap 'rm -rf "$tmp"' EXIT + +echo "==> cloning $UPSTREAM_REPO @ $UPSTREAM_REF…" +# Everything relative, inside $tmp. lib.sh exports MSYS_NO_PATHCONV=1 for +# docker's sake, which stops Git Bash translating POSIX paths for Windows +# binaries — so git.exe would put an absolute /tmp/... clone somewhere this +# shell cannot see. Relative paths sidestep it on every platform. +# +# `-c core.autocrlf=false -c core.eol=lf`: these files are read by LINUX +# containers, not by the host. ClickHouse's XML survives CRLF, but a compose +# file with CR line endings puts a trailing CR inside every unquoted scalar — +# which is how `BASE_URL` acquires an invisible character and Plausible starts +# generating links to a hostname nothing resolves. Same class of failure that +# cost openreplay-stack an afternoon of S3 signature mismatches. +( + cd "$tmp" + git -c core.autocrlf=false -c core.eol=lf \ + clone --depth 1 --branch "$UPSTREAM_REF" --single-branch \ + "$UPSTREAM_REPO" ce 2>&1 | tail -1 + cd ce + git rev-parse HEAD >../SHA +) +sha="$(cat "$tmp/SHA")" +[ -f "$tmp/ce/compose.yml" ] || { + echo "error: no compose.yml at $UPSTREAM_REF" >&2 + exit 1 +} +[ -d "$tmp/ce/clickhouse" ] || { + echo "error: no clickhouse/ config dir at $UPSTREAM_REF — compose bind-mounts it" >&2 + exit 1 +} + +mkdir -p "$VENDOR" +cp "$tmp/ce/compose.yml" "$VENDOR/compose.yml" +cp -r "$tmp/ce/clickhouse" "$VENDOR/clickhouse" +cp "$tmp/ce/README.md" "$VENDOR/README.md" 2>/dev/null || true +{ + echo "$UPSTREAM_REPO @ $UPSTREAM_REF" + echo "commit $sha" + echo "fetched $(date -u +%Y-%m-%dT%H:%M:%SZ)" + echo "image $(grep -oE 'ghcr.io/plausible/community-edition:[^ ]*' "$VENDOR/compose.yml" | head -1)" +} >"$VENDOR/UPSTREAM.txt" + +echo "==> vendored $(find "$VENDOR" -type f | wc -l) files at ${sha:0:12}" +sed 's/^/ /' "$VENDOR/UPSTREAM.txt" diff --git a/.claude/skills/plausible-stack/scripts/lib.sh b/.claude/skills/plausible-stack/scripts/lib.sh new file mode 100644 index 00000000..54be9b82 --- /dev/null +++ b/.claude/skills/plausible-stack/scripts/lib.sh @@ -0,0 +1,223 @@ +# shellcheck shell=bash +# Shared helpers. Source after setting HERE. + +SKILL_DIR="$(dirname "$HERE")" +VENDOR="$SKILL_DIR/vendor" +STATE="$SKILL_DIR/.state" +PROJECT="${PLAUSIBLE_PROJECT:-plausible}" +# Pin upstream. The CE compose is a versioned artefact: the image tag inside it +# is the release, so the ref and the running version are one decision. +UPSTREAM_REPO="${PLAUSIBLE_REPO:-https://github.com/plausible/community-edition.git}" +UPSTREAM_REF="${PLAUSIBLE_REF:-v3.2.1}" + +# The port `plausible` is published on, on the LOOPBACK interface only. The +# tunnel is the entrypoint (the browser has to reach the ingest endpoint from +# outside), but a local port is what lets doctor/verify tell "the app is broken" +# apart from "the tunnel is broken" — two failures that look identical from the +# public URL. +# +# 8010, not upstream's 8000: 8000 is the single most contested port on a +# developer machine (it was already taken by an unrelated python process on the +# machine this was built on) and a collision here fails `compose up` AFTER the +# databases have started, which reads as a stack failure rather than a clash. +# The container still listens on 8000 inside; only the host side moves. +LOCAL_PORT="${PLAUSIBLE_PORT:-8010}" + +mkdir -p "$STATE" + +# Admin credentials + SECRET_KEY_BASE — generated by scripts/secrets.sh on +# first run, gitignored (skill .gitignore; also ignored repo-wide). +# +# OUTSIDE .state on purpose, exactly as in openreplay-stack: the password must +# survive down.sh, and surviving `--volumes` is what makes a wipe a RECOVERY +# path rather than a second loss — the next up.sh re-creates the same account +# from this file. +SECRETS_FILE="$SKILL_DIR/.secrets.env" + +# Fill unset PLAUSIBLE_* variables from the secrets file. Explicit environment +# wins — an exported PLAUSIBLE_PASSWORD is someone overriding on purpose. +# Plain KEY=value lines, no quoting: the value is everything after the first +# '=', so spaces survive without shell-quoting rules getting involved. +# +# ⚠ THE `\r` STRIP IS LOad-BEARING ON WINDOWS, and it cost an hour. Git Bash's +# openssl (/mingw64/bin/openssl) prints CRLF, so a naively captured +# `openssl rand -base64 32` ends in a carriage return. That CR then rides +# inside the VALUE — not at the end of the line, where every tool would treat +# it as a line ending — and Plausible dies at boot with +# `TOTP_VAULT_KEY must be Base64 encoded 32 bytes`, an error about a key that +# decodes to exactly 32 bytes everywhere you check it. +# +# It survives every obvious check: `cat -A` on a file sed has since rewritten +# shows nothing (MSYS sed strips CRs on the way through), `docker compose +# config` shows it clean because that reads .env rather than the process +# environment, and `docker inspect --format '{{range .Config.Env}}'` renders +# the CR as the line break it looks like. Only `{{json .Config.Env}}` shows it. +# Stripped here as well as at the point of generation, because a .secrets.env +# already on disk is not fixed by fixing the generator. +load_secrets() { + [ -f "$SECRETS_FILE" ] || return 0 + local line k v + while IFS= read -r line || [ -n "$line" ]; do + line="${line%$'\r'}" + case "$line" in '' | \#*) continue ;; esac + k="${line%%=*}" + case "$k" in *[!A-Za-z0-9_]* | '') continue ;; esac + v="${line#*=}" + v="${v%$'\r'}" + [ -n "${!k:-}" ] || export "$k=$v" + done <"$SECRETS_FILE" +} + +# Paths handed to docker.exe. On Git Bash/MSYS, MSYS_NO_PATHCONV stops the +# automatic POSIX→Windows translation (needed so docker's own /container/paths +# survive), which means a `/c/Users/...` argument reaches docker.exe verbatim +# and it resolves `C:\c\Users\...`. Hand it a Windows-style path instead; +# MSYS leaves those alone. Same fix as openreplay-stack/scripts/lib.sh. +COMPOSE_VENDOR="$VENDOR/compose.yml" +COMPOSE_OVERLAY="$SKILL_DIR/compose.tunnel.yaml" +COMPOSE_DIR="$VENDOR" +case "$(uname -s)" in +MINGW* | MSYS*) + export MSYS_NO_PATHCONV=1 MSYS2_ARG_CONV_EXCL="*" + COMPOSE_VENDOR="$(cygpath -m "$COMPOSE_VENDOR")" + COMPOSE_OVERLAY="$(cygpath -m "$COMPOSE_OVERLAY")" + COMPOSE_DIR="$(cygpath -m "$COMPOSE_DIR")" + ;; +esac + +# `--project-directory vendor` matters twice: upstream's compose bind-mounts +# ./clickhouse/*.xml relative to it, and compose reads `.env` from it — which +# is the file up.sh writes BASE_URL and the secrets into. +# Where curl should throw a body away. +# +# NOT `/dev/null` on a Windows host: this file exports MSYS_NO_PATHCONV=1 for +# docker's sake, so `/dev/null` reaches curl.exe verbatim, curl tries to create +# a file at that literal path, and fails with +# `curl: (23) client returned ERROR on write of 72 bytes` — printed in the +# middle of a check that then passes anyway, because `-w '%{http_code}'` has +# already produced the number the caller wanted. A scary line attached to a +# successful step is worse than either a failure or silence. +CURL_DISCARD="/dev/null" +case "$(uname -s)" in +MINGW* | MSYS*) CURL_DISCARD="NUL" ;; +esac + +compose() { + docker compose -p "$PROJECT" \ + -f "$COMPOSE_VENDOR" -f "$COMPOSE_OVERLAY" \ + --project-directory "$COMPOSE_DIR" "$@" +} + +require_docker() { + command -v docker >/dev/null 2>&1 || { + echo "error: docker not found" >&2 + exit 1 + } + docker info >/dev/null 2>&1 || { + echo "error: docker daemon not reachable" >&2 + exit 1 + } +} + +require_vendor() { + [ -f "$VENDOR/compose.yml" ] || { + echo "error: upstream not fetched — run scripts/fetch-upstream.sh" >&2 + exit 1 + } +} + +ENV_FILE="$VENDOR/.env" + +# Idempotent `KEY=value` in vendor/.env. Compose reads that file for ${VAR} +# interpolation AND for the bare `- TOTP_VAULT_KEY` style pass-throughs in +# upstream's compose, so it is the one place configuration lives. +env_set() { # <key> <value> + mkdir -p "$VENDOR" + touch "$ENV_FILE" + # Same CR strip as load_secrets, for the same reason and one layer further + # out: whatever the value came from, a CR must not reach a container. + set -- "$1" "${2%$'\r'}" + if grep -q "^$1=" "$ENV_FILE"; then + # `|` as the sed delimiter: values here are URLs, and base64 secrets can + # contain `/`. + sed -i "s|^$1=.*|$1=$2|" "$ENV_FILE" + else + printf '%s=%s\n' "$1" "$2" >>"$ENV_FILE" + fi +} + +# Write one KEY=value into .secrets.env, replacing any existing line. +# +# It exists for the API key, and the reason is the recovery story: after +# `down.sh --volumes` the databases are gone but this FILE survives on purpose, +# so a stored key names a row that no longer exists. Appending a second line +# would leave load_secrets reading whichever came first — so this replaces. +secrets_set() { # <key> <value> + local k="$1" v="${2%$'\r'}" + if command -v git >/dev/null 2>&1 && + git -C "$SKILL_DIR" rev-parse --is-inside-work-tree >/dev/null 2>&1 && + ! git -C "$SKILL_DIR" check-ignore -q "$SECRETS_FILE"; then + echo "error: $SECRETS_FILE is not gitignored — refusing to write a secret there." >&2 + return 1 + fi + umask 077 + touch "$SECRETS_FILE" + if grep -q "^$k=" "$SECRETS_FILE"; then + sed -i "s|^$k=.*|$k=$v|" "$SECRETS_FILE" + else + printf '%s=%s\n' "$k" "$v" >>"$SECRETS_FILE" + fi + export "$k=$v" +} + +env_get() { # <key> -> stdout (empty when absent) + [ -f "$ENV_FILE" ] || return 0 + sed -n "s|^$1=||p" "$ENV_FILE" | tail -1 +} + +# ── named vs quick tunnel ──────────────────────────────────────────────────── +# Same two modes as the app's own tunnel (see .claude/skills/lib/cf-api.sh for +# why named exists). Named is chosen when Cloudflare credentials and +# PLAUSIBLE_HOSTNAME are configured; otherwise the quick tunnel, unchanged. +# +# It matters MORE here than for the app. Plausible reads BASE_URL once at boot +# and uses it for link generation and the LiveView origin check, so every new +# quick-tunnel hostname is a config rewrite plus a container recreate — and any +# browser still holding the old tracker URL posts into a host that no longer +# exists, which looks exactly like "nobody visited". +# shellcheck source=../../lib/cf-named-tunnel.sh +source "$SKILL_DIR/../lib/cf-named-tunnel.sh" +NAMED_TUNNEL="${PLAUSIBLE_TUNNEL_NAME:-hackagon-plausible}" + +named_configured() { + cf_configured && [ -n "${PLAUSIBLE_HOSTNAME:-}" ] +} + +# The URL this rig is reachable on, asked of whatever is RUNNING. A named +# tunnel answers from its container label; a quick tunnel from cloudflared's +# log, which is the only place it ever prints it. Neither reads the state file: +# a tunnel that was restarted has a new URL and the same state file, and a stale +# URL fails silently — the tracker keeps posting into nothing. +tunnel_url() { + cfn_url "$NAMED_TUNNEL" 2>/dev/null && return 0 + docker logs "$(compose ps -q tunnel 2>/dev/null)" 2>&1 | + grep -oE 'https://[a-z0-9-]+\.trycloudflare\.com' | tail -1 +} + +# The docker network the rig's containers are on, read off a live container +# rather than assumed: compose derives it from the project name, which is +# overridable. A wrong guess fails as a DNS lookup for `plausible` inside +# cloudflared, which Cloudflare renders as a plain 502 while every container +# reports healthy — an afternoon lost to that once already, in the openreplay +# overlay. +rig_network() { # <service> + docker inspect "$(compose ps -q "$1")" \ + --format '{{range $k,$v := .NetworkSettings.Networks}}{{$k}} {{end}}' 2>/dev/null | + awk '{print $1}' +} + +# The URL to talk to Plausible on from THIS machine. Prefer the loopback port: +# it is the same server, one hop shorter, and it keeps a diagnostic from +# depending on Cloudflare being up. Callers that specifically mean to test the +# public path use tunnel_url. +local_url() { echo "http://127.0.0.1:$LOCAL_PORT"; } diff --git a/.claude/skills/plausible-stack/scripts/pageview.mjs b/.claude/skills/plausible-stack/scripts/pageview.mjs new file mode 100644 index 00000000..166847c3 --- /dev/null +++ b/.claude/skills/plausible-stack/scripts/pageview.mjs @@ -0,0 +1,136 @@ +/* + * Drive a real browser through the wired app and record what the tracker put + * on the wire. + * + * This is the CLIENT half of the proof. It exists because "the app renders a + * script tag" and "a page view was counted" are different claims, and because + * every privacy property this integration has is a property of the BYTES — + * an assertion that a config flag is set would have passed while the tracker + * shipped `/invite/<token>`. + * + * It deliberately does NOT judge whether Plausible stored anything: that is + * the far end, and verify.sh asks Plausible itself. Session replay was green + * for three days here while every recorded session was unplayable, because + * every spec measured what left the browser and nothing asked the server. + * + * Usage (inside the dev container, from the hackathon-e2e skill dir so the + * playwright dependency resolves): + * node …/plausible-stack/scripts/pageview.mjs <appUrl> <plausibleOrigin> <hackathonId> <out.json> + */ +import { createRequire } from "node:module" +import { fileURLToPath } from "node:url" +import path from "node:path" +import { writeFileSync } from "node:fs" + +// Playwright and its Firefox come from the SIBLING skill, which is the only +// place in this repo that installs them. Borrowed through `createRequire` +// anchored at that package rather than imported directly, because ESM resolves +// bare specifiers relative to the FILE, and this file lives in a directory with +// no node_modules — `cd`-ing there first does not help. +const HERE = path.dirname(fileURLToPath(import.meta.url)) +const require = createRequire( + path.join(HERE, "..", "..", "hackathon-e2e", "package.json"), +) +const { firefox } = require("@playwright/test") + +const [appUrl, plausibleOrigin, hackathonId, out] = process.argv.slice(2) +if (!appUrl || !plausibleOrigin || !out) { + console.error( + "usage: pageview.mjs <appUrl> <plausibleOrigin> <hackathonId> <out.json>", + ) + process.exit(2) +} + +// A token that could not possibly be real, but has the SHAPE of one — the +// point is to prove the shape never reaches the wire, so it must be +// recognisable in a grep of the captured bodies. +const FAKE_TOKEN = "plausible-proof-token-4f2a9c7e" + +const events = [] +const browser = await firefox.launch() +const page = await browser.newPage() + +page.on("request", (req) => { + if (req.method() !== "POST") return + if (!req.url().startsWith(`${plausibleOrigin}/api/event`)) return + let body = req.postData() + try { + body = JSON.parse(body) + } catch { + /* keep the raw string — a body we cannot parse is still evidence */ + } + events.push({ url: req.url(), body }) +}) + +async function visit(path, { click } = {}) { + if (click) { + // A CLIENT-SIDE navigation, which is the case `afterNavigate` handles and + // the only case where document.referrer is non-empty and points at one of + // our own paths. + const link = page.locator(`a[href="${path}"]`).first() + if ((await link.count()) > 0) { + await link.click() + await page.waitForURL(`**${path}`, { timeout: 15000 }).catch(() => {}) + await page.waitForTimeout(2500) + return "clicked" + } + } + await page.goto(`${appUrl}${path}`, { waitUntil: "load", timeout: 30000 }) + // The first pageview is fired from an idle callback after first paint. + await page.waitForTimeout(2500) + return "goto" +} + +const visited = [] +visited.push(["/", await visit("/")]) +if (hackathonId) { + visited.push([ + `/hackathon/${hackathonId}`, + await visit(`/hackathon/${hackathonId}`, { click: true }), + ]) +} +visited.push([`/invite/${FAKE_TOKEN}`, await visit(`/invite/${FAKE_TOKEN}`)]) + +await browser.close() + +const raw = JSON.stringify(events) +const sent = events.map((e) => (e.body && e.body.u) || null) + +// ── the client-side claims, judged here because the bodies are here ──────── +const problems = [] +if (events.length === 0) + problems.push("no /api/event request left the browser at all") + +// The positive control comes FIRST: an "id absent" assertion agrees with +// everything when nothing was ever sent, and this repo has shipped exactly +// that mistake more than once. +const expectPage = hackathonId ? "/hackathon/[id]" : "/" +if (!sent.some((u) => u && u.endsWith(expectPage))) + problems.push( + `no pageview carried the route template ${expectPage} (got ${JSON.stringify(sent)})`, + ) + +if (hackathonId && raw.includes(hackathonId)) + problems.push(`the hackathon id ${hackathonId} appeared on the wire`) +if (raw.includes(FAKE_TOKEN)) + problems.push(`the invite token appeared on the wire`) +if (/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/i.test(raw)) + problems.push("a UUID appeared on the wire") + +// Referrers: our own origin must never be sent with a path attached. +for (const e of events) { + const r = e.body && e.body.r + if (r && r.startsWith(appUrl) && r !== appUrl && r !== `${appUrl}/`) + problems.push(`an internal referrer with a path was sent: ${r}`) +} + +writeFileSync(out, JSON.stringify({ visited, events, problems }, null, 2)) + +console.log(`visited: ${visited.map(([p, how]) => `${p} (${how})`).join(", ")}`) +console.log(`events: ${events.length}`) +for (const u of sent) console.log(` u = ${u}`) +if (problems.length) { + for (const p of problems) console.log(`PROBLEM: ${p}`) + process.exit(1) +} +console.log("client-side: OK") diff --git a/.claude/skills/plausible-stack/scripts/secrets.sh b/.claude/skills/plausible-stack/scripts/secrets.sh new file mode 100644 index 00000000..eef012be --- /dev/null +++ b/.claude/skills/plausible-stack/scripts/secrets.sh @@ -0,0 +1,94 @@ +#!/usr/bin/env bash +# Mint everything this rig needs to be brought up without a single prompt, into +# a gitignored .secrets.env. Idempotent: values already present are kept. +# +# SECRET_KEY_BASE signs the dashboard's sessions (≥64 bytes, upstream) +# TOTP_VAULT_KEY encrypts TOTP secrets at rest (32 bytes, base64) +# PLAUSIBLE_EMAIL the admin account up.sh registers +# PLAUSIBLE_PASSWORD …and its password +# PLAUSIBLE_SITE the site (data-domain) events are attributed to +# +# WHY A FILE, NOT ENVIRONMENT VARIABLES. Plausible CE seeds no account: whoever +# registers first is the owner, and there is no recovery path without a working +# mailer. A password that exists only in one shell's environment is a dashboard +# that becomes unreachable the moment that shell closes — openreplay-stack +# learned this by having to wipe its volumes. Same convention as this repo's +# other dev secrets (.devcontainer/post-create.sh mints secrets.yaml the same +# way with openssl). +# +# The two Elixir secrets live here rather than in vendor/.env for one reason: +# vendor/ is deleted by `fetch-upstream.sh --force`, and rotating +# SECRET_KEY_BASE under a live database logs every session out and makes +# TOTP secrets undecryptable. up.sh copies them into vendor/.env on every run. +# +# Usage: secrets.sh [--print] +set -euo pipefail +HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +source "$HERE/lib.sh" + +load_secrets + +# A password is about to be written to disk — make sure git can never take it. +# On this branch .claude/ is TRACKED, so only the skill's own .gitignore +# protects this file, and a rule that is present-but-wrong looks exactly like a +# rule that works. Ask git, do not read the file. +guard_gitignored() { + command -v git >/dev/null 2>&1 || return 0 + git -C "$SKILL_DIR" rev-parse --is-inside-work-tree >/dev/null 2>&1 || return 0 + git -C "$SKILL_DIR" check-ignore -q "$SECRETS_FILE" && return 0 + echo "error: $SECRETS_FILE would be TRACKED by git — refusing to write secrets there." >&2 + echo " Add '.secrets.env' to $SKILL_DIR/.gitignore first." >&2 + exit 1 +} + +fresh=0 +if [ -z "${SECRET_KEY_BASE:-}" ] || [ -z "${PLAUSIBLE_PASSWORD:-}" ]; then + fresh=1 + guard_gitignored + + # `tr -d '\r\n'`, NOT `tr -d '\n'`. Git Bash's openssl prints CRLF, so the + # obvious version leaves a carriage return at the END OF THE VALUE — which + # is not a line ending any more, it is a byte inside a secret. Plausible + # then refuses to boot with "TOTP_VAULT_KEY must be Base64 encoded 32 bytes" + # about a key that decodes to exactly 32 bytes in every tool you check it + # with. See the long note in lib.sh:load_secrets for why nothing shows it. + + # ≥64 bytes. `openssl rand -base64 48` is upstream's own suggestion. + SECRET_KEY_BASE="${SECRET_KEY_BASE:-$(openssl rand -base64 48 | tr -d '\r\n')}" + # Exactly 32 bytes, base64 — Plausible decodes this one and rejects a wrong + # length at boot rather than at first use. + TOTP_VAULT_KEY="${TOTP_VAULT_KEY:-$(openssl rand -base64 32 | tr -d '\r\n')}" + PLAUSIBLE_EMAIL="${PLAUSIBLE_EMAIL:-admin@hackagon.test}" + PLAUSIBLE_PASSWORD="${PLAUSIBLE_PASSWORD:-$(openssl rand -hex 16 | tr -d '\r\n')}" + PLAUSIBLE_NAME="${PLAUSIBLE_NAME:-Hackagon Analytics Admin}" + # RFC 2606 reserves .test for exactly this. It is a LABEL, not a hostname: + # Plausible attributes an event to a site by matching the tracker's + # `data-domain` string, and never resolves or contacts it. Using a domain we + # do not own (hackagon.dev) would work identically and be somebody else's + # namespace. + PLAUSIBLE_SITE="${PLAUSIBLE_SITE:-hackagon.test}" + + umask 077 + cat >"$SECRETS_FILE" <<EOF +# Plausible CE dev rig — generated by scripts/secrets.sh, gitignored. +# KEY=value, no quotes; everything after the first '=' is the value. +# +# Survives down.sh. After down.sh --volumes (databases wiped, registration +# reopens) the next up.sh re-creates the same account from these values, which +# is what makes a wipe a recovery path instead of a second lockout. +SECRET_KEY_BASE=$SECRET_KEY_BASE +TOTP_VAULT_KEY=$TOTP_VAULT_KEY +PLAUSIBLE_EMAIL=$PLAUSIBLE_EMAIL +PLAUSIBLE_PASSWORD=$PLAUSIBLE_PASSWORD +PLAUSIBLE_NAME=$PLAUSIBLE_NAME +PLAUSIBLE_SITE=$PLAUSIBLE_SITE +EOF +fi + +if [ "$fresh" -eq 1 ] || [ "${1:-}" = "--print" ]; then + echo "==> Plausible admin credentials ($SECRETS_FILE)" + echo " email ${PLAUSIBLE_EMAIL:-}" + echo " password ${PLAUSIBLE_PASSWORD:-}" + echo " site ${PLAUSIBLE_SITE:-}" + [ "$fresh" -eq 1 ] && echo " ⚠ dev-only credentials, shown once — afterwards read the file." +fi diff --git a/.claude/skills/plausible-stack/scripts/signup.sh b/.claude/skills/plausible-stack/scripts/signup.sh new file mode 100644 index 00000000..dc14e9ae --- /dev/null +++ b/.claude/skills/plausible-stack/scripts/signup.sh @@ -0,0 +1,192 @@ +#!/usr/bin/env bash +# Create the Plausible owner account, the site, and a Stats API key — without +# a prompt, a mailbox or a browser. Idempotent: safe to re-run any time. +# +# WHY THIS IS AN `rpc` AND NOT AN HTTP POST, which is worth writing down +# because the HTTP route LOOKS like it exists: +# +# GET /register serves a form with user[name], user[email], user[password], +# user[password_confirmation] and a _csrf_token — everything +# a scripted signup would need. +# POST /register is 404. There is no such route. +# +# The form is a LiveView (`phx-submit="register"`): the account is created by a +# handler on the WEBSOCKET, and the form's native `action="/login"` only runs +# afterwards, to log the new user in. So the visible form cannot be driven with +# curl at all, and openreplay-stack's "POST the signup endpoint" shape has no +# equivalent here. `bin/plausible rpc` runs Elixir inside the release, which is +# the same thing the LiveView handler would have done. +# +# The consequence for anyone reading this later: the code below names INTERNAL +# functions (Plausible.Auth.User.new/1, Plausible.Sites.create/2), so it is +# pinned to a Plausible version in a way an HTTP call would not be. It is +# checked against the ref in vendor/UPSTREAM.txt (v3.2.1) and it FAILS LOUDLY +# rather than half-working: every step matches on its expected result. +# +# Usage: signup.sh [--print] +set -euo pipefail +HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +source "$HERE/lib.sh" + +bash "$HERE/secrets.sh" >/dev/null +load_secrets +: "${PLAUSIBLE_EMAIL:?}" "${PLAUSIBLE_PASSWORD:?}" "${PLAUSIBLE_SITE:?}" +NAME="${PLAUSIBLE_NAME:-Hackagon Analytics Admin}" + +base="$(local_url)" + +if [ "${1:-}" = "--print" ]; then + echo "email $PLAUSIBLE_EMAIL" + echo "password $PLAUSIBLE_PASSWORD" + echo "site $PLAUSIBLE_SITE" + echo "apiKey ${PLAUSIBLE_API_KEY:-<none yet>}" + exit 0 +fi + +cid="$(compose ps -q plausible 2>/dev/null || true)" +[ -n "$cid" ] || { + echo "error: the plausible container is not running — scripts/up.sh first" >&2 + exit 1 +} + +rpc() { docker exec -i "$cid" bin/plausible rpc "$1"; } + +# ── the owner, the site ──────────────────────────────────────────────────── +# `email_verified: true` is set explicitly. ENABLE_EMAIL_VERIFICATION=false +# already means nobody is asked to verify, but the COLUMN still exists and a +# future flip of that variable would otherwise lock this account out of an +# instance with no mailer configured — i.e. permanently. +echo "==> ensuring the owner account and site exist" +out="$(rpc ' +email = "'"$PLAUSIBLE_EMAIL"'" +pass = "'"$PLAUSIBLE_PASSWORD"'" +name = "'"$NAME"'" +domain = "'"$PLAUSIBLE_SITE"'" + +user = + case Plausible.Auth.find_user_by(email: email) do + nil -> + u = + %{name: name, email: email, password: pass, password_confirmation: pass} + |> Plausible.Auth.User.new() + |> Ecto.Changeset.put_change(:email_verified, true) + |> Plausible.Repo.insert!() + + IO.puts("user=created") + u + + u -> + IO.puts("user=existing") + u + end + +case Plausible.Sites.get_by_domain(domain) do + nil -> + {:ok, %{site: _}} = Plausible.Sites.create(user, %{"domain" => domain, "timezone" => "UTC"}) + IO.puts("site=created") + + _ -> + IO.puts("site=existing") +end +')" +printf '%s\n' "$out" | sed 's/^/ /' +case "$out" in +*user=*) ;; +*) + echo "error: rpc did not report a user — Plausible internals may have moved" >&2 + exit 1 + ;; +esac + +# ── a Stats API key, so a machine can read the numbers back ──────────────── +# The dashboard is for humans; verify.sh needs to ASK PLAUSIBLE what it stored, +# and the Stats API is the only answer that goes through Plausible's own query +# layer rather than around it into ClickHouse. Created here rather than clicked +# in the UI for the same reason as everything else in this file. +# +# ⚠ A STORED KEY IS NOT A WORKING KEY, and that is the whole point of the probe +# below. `.secrets.env` deliberately survives `down.sh --volumes` so the owner +# account can be re-created after a wipe — but the API key row was IN the wiped +# database, so the file goes on naming a key that no longer exists. "Is it set" +# would be true and useless; the wipe would look recovered and verify.sh would +# then fail on a 401 several steps later, pointing nowhere near here. +if [ -n "${PLAUSIBLE_API_KEY:-}" ]; then + probe="$(curl -s -o "$CURL_DISCARD" -w '%{http_code}' -m 20 \ + -H "Authorization: Bearer $PLAUSIBLE_API_KEY" \ + "$base/api/v1/stats/aggregate?site_id=$PLAUSIBLE_SITE&period=day&metrics=visitors" || true)" + if [ "$probe" != "200" ]; then + echo "==> the stored Stats API key no longer works (HTTP $probe) — minting a new one" + PLAUSIBLE_API_KEY="" + fi +fi + +if [ -z "${PLAUSIBLE_API_KEY:-}" ]; then + echo "==> creating a Stats API key" + key="$(openssl rand -hex 24 | tr -d '\r\n')" + keyout="$(rpc ' +user = Plausible.Auth.find_user_by(email: "'"$PLAUSIBLE_EMAIL"'") +{:ok, team} = Plausible.Teams.get_by_owner(user) + +case Plausible.Auth.create_stats_api_key(user, team, "hackagon-verify", "'"$key"'") do + {:ok, _} -> IO.puts("apikey=created") + {:error, e} -> IO.puts("apikey=error " <> inspect(e)) +end +')" + case "$keyout" in + *apikey=created*) + # secrets_set REPLACES the line and re-checks `git check-ignore` first — + # the generator ran once, long ago, and a .gitignore can be edited since. + secrets_set PLAUSIBLE_API_KEY "$key" || exit 1 + echo " stored in $SECRETS_FILE" + ;; + *) + echo " ⚠ could not create a Stats API key: $keyout" >&2 + ;; + esac +fi + +# ── prove the account actually logs IN ───────────────────────────────────── +# A row in Postgres is not a working dashboard. This is the round trip a person +# will make: fetch the form for its CSRF token, post the credentials, follow +# the session to /sites, and find the site listed there. openreplay-stack +# learned to do this too — an account nobody had ever logged into once cost a +# full volume wipe. +echo "==> verifying login at $base" +jar="$(mktemp)" +trap 'rm -f "$jar"' EXIT +# Captured first, then matched: `curl | grep | head` dies with +# `curl: (23) client returned ERROR on write` on a Windows host, because head +# closes the pipe at the first match while curl is still writing. +login_html="$(curl -fsS -c "$jar" "$base/login")" +csrf="$(printf '%s' "$login_html" | + grep -oE 'name="_csrf_token"[^>]*value="[^"]+"' | sed 's/.*value="//;s/"//' | head -1)" +[ -n "$csrf" ] || { + echo "error: no CSRF token on $base/login" >&2 + exit 1 +} +code="$(curl -fsS -b "$jar" -c "$jar" -o "$CURL_DISCARD" -w '%{http_code}' -X POST "$base/login" \ + --data-urlencode "_csrf_token=$csrf" \ + --data-urlencode "email=$PLAUSIBLE_EMAIL" \ + --data-urlencode "password=$PLAUSIBLE_PASSWORD" || true)" +[ "$code" = "302" ] || { + echo "error: login answered $code (expected a 302 to /sites)" >&2 + exit 1 +} +# Captured, not piped into `grep -q`: grep exits at the first match, and curl +# on a Windows host then dies writing into a closed pipe +# (`curl: (23) client returned ERROR on write`) — a scary line in the middle of +# a successful verification. +sites_html="$(curl -fsS -b "$jar" "$base/sites")" +case "$sites_html" in +*"$PLAUSIBLE_SITE"*) ;; +*) + echo "error: logged in, but $PLAUSIBLE_SITE is not listed on /sites" >&2 + exit 1 + ;; +esac +echo " login OK, $PLAUSIBLE_SITE listed" + +echo "" +echo " owner $PLAUSIBLE_EMAIL (password: $SECRETS_FILE)" +echo " site $PLAUSIBLE_SITE" +echo " apiKey ${PLAUSIBLE_API_KEY:-<none>} (Stats API, read-only)" diff --git a/.claude/skills/plausible-stack/scripts/up.sh b/.claude/skills/plausible-stack/scripts/up.sh new file mode 100644 index 00000000..7588b7e7 --- /dev/null +++ b/.claude/skills/plausible-stack/scripts/up.sh @@ -0,0 +1,247 @@ +#!/usr/bin/env bash +# Bring Plausible Community Edition up behind a Cloudflare quick tunnel. +# +# doctor → fetch upstream → mint secrets → start the tunnel (learn the URL) +# → point BASE_URL at it → start the databases and the app +# → register the admin + the site → close registration again +# +# THE TUNNEL STARTS FIRST, and that ordering is not stylistic. Plausible reads +# BASE_URL at boot and uses it for link generation and for the LiveView +# origin/CSWSH check, so a dashboard booted against the wrong hostname serves +# HTML and then fails to connect its own websocket — which presents as a page +# that renders and never loads any numbers. Same trap as openreplay-stack's +# COMMON_DOMAIN_NAME, and the same consequence: a quick tunnel is a DEBUGGING +# tool here, because every fresh URL means rewriting the config again. +# +# Usage: up.sh [--skip-doctor] [--skip-signup] [--keep-registration-open] [--dry-run] +set -euo pipefail +HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +source "$HERE/lib.sh" + +DRY=0 +SKIP_DOCTOR=0 +SKIP_SIGNUP=0 +KEEP_OPEN=0 +MODE="" +while [ $# -gt 0 ]; do + case "$1" in + --dry-run) DRY=1 ;; + --skip-doctor) SKIP_DOCTOR=1 ;; + --skip-signup) SKIP_SIGNUP=1 ;; + --keep-registration-open) KEEP_OPEN=1 ;; + --named) MODE=named ;; + --quick) MODE=quick ;; + -h | --help) + sed -n '2,17p' "${BASH_SOURCE[0]}" | sed 's/^# \{0,1\}//' + exit 0 + ;; + *) + echo "unknown argument: $1" >&2 + exit 2 + ;; + esac + shift +done + +require_docker +[ "$SKIP_DOCTOR" -eq 1 ] || bash "$HERE/doctor.sh" +bash "$HERE/fetch-upstream.sh" +require_vendor +bash "$HERE/secrets.sh" +load_secrets + +# ── vendor/.env — everything upstream's compose interpolates or passes through ─ +# Rewritten on every run, from .secrets.env, because vendor/ is disposable +# (`fetch-upstream.sh --force` deletes it) while the secrets are not: rotating +# SECRET_KEY_BASE under a live database logs every session out. +env_set SECRET_KEY_BASE "$SECRET_KEY_BASE" +env_set TOTP_VAULT_KEY "$TOTP_VAULT_KEY" +env_set HTTP_PORT 8000 +# No mailer is configured, so email verification would create accounts that can +# never be used. Upstream's default is already false; pinned because "the +# default is what I want" is a claim that expires. +env_set ENABLE_EMAIL_VERIFICATION false +# Placeholder so compose can interpolate ${BASE_URL} for the tunnel-only start +# below. Only the `plausible` service reads it, and that service is not running +# yet — the real value is written before it is. +[ -n "$(env_get BASE_URL)" ] || env_set BASE_URL "http://localhost:$LOCAL_PORT" + +if [ "$DRY" -eq 1 ]; then + echo "" + echo "[dry-run] would start:" + compose config --services | sed 's/^/ /' + exit 0 +fi + +# ── phase 1: learn the public URL ────────────────────────────────────────── +# NAMED MODE SKIPS THIS PHASE ENTIRELY, and that is the point of it. The +# hostname is known before anything starts, so BASE_URL can be written once and +# never revisited; the "start a tunnel just to find out what it is called" dance +# is a quick-tunnel tax. +if [ "$MODE" = "named" ] && ! named_configured; then + echo "error: --named needs Cloudflare credentials and PLAUSIBLE_HOSTNAME." >&2 + cf_explain_unconfigured >&2 + exit 2 +fi +[ -n "$MODE" ] || { named_configured && MODE=named || MODE=quick; } + +if [ "$MODE" = "named" ]; then + url="https://$PLAUSIBLE_HOSTNAME" + echo "==> Mode: NAMED — $url (persistent)" + # Two tunnels onto one Plausible is not redundancy: BASE_URL names ONE of + # them, and the other would serve a dashboard whose websocket fails its own + # origin check — a page that renders and never loads any numbers. + if [ -n "$(compose ps -q tunnel 2>/dev/null)" ]; then + echo " stopping the quick tunnel (named mode owns BASE_URL)" + compose rm -sf tunnel >/dev/null 2>&1 || true + fi +else + # --no-deps: the overlay declares `tunnel → plausible`, and the point of this + # phase is that plausible must NOT start yet. cloudflared happily serves 502s + # until its origin exists; it re-resolves the name per connection. + echo "==> Mode: QUICK — starting the quick tunnel to learn its hostname…" + if cfn_running "$NAMED_TUNNEL"; then + echo " stopping the named tunnel (one BASE_URL, one hostname)" + cfn_stop "$NAMED_TUNNEL" + fi + compose up -d --no-deps tunnel + url="" + for _ in $(seq 1 30); do + url="$(tunnel_url || true)" + [ -n "$url" ] && break + sleep 2 + done + [ -z "$url" ] && { + echo "error: no tunnel URL after 60s — check: docker logs plausible-tunnel" >&2 + exit 1 + } + echo " $url" +fi +echo "$url" >"$STATE/tunnel-url" + +# ── phase 2: point the app at that URL, then start it ────────────────────── +# BASE_URL carries the SCHEME here (unlike OpenReplay's bare-hostname +# COMMON_DOMAIN_NAME + COMMON_PROTOCOL pair). https, because that is what the +# public URL is: Cloudflare terminates TLS at its edge and forwards plain http +# to the container, which is why HTTP_PORT is what the app listens on and no +# certificate is involved anywhere in here. +echo "==> pointing Plausible at $url" +env_set BASE_URL "$url" + +# Registration has to be OPEN for the admin to be created at all — CE seeds no +# account and the first person to register owns the instance. It is closed +# again at the end of this script, which matters here more than it does on a +# laptop: this dashboard is on a PUBLIC URL for as long as the tunnel lives. +[ "$SKIP_SIGNUP" -eq 1 ] || env_set DISABLE_REGISTRATION false + +echo "==> starting Plausible (first run pulls 3 images and migrates — a few minutes)…" +if [ "$MODE" = "named" ]; then + # Every service EXCEPT the quick tunnel, named explicitly. A bare `up -d` + # would start `tunnel` as well and mint a hostname nothing uses. + # shellcheck disable=SC2046 + compose up -d $(compose config --services | grep -v '^tunnel$' | tr '\n' ' ') +else + compose up -d +fi + +# ── phase 3: wait for the app, not for the container ─────────────────────── +# `compose up -d` returns when the containers were CREATED. Plausible then +# creates its database and runs migrations; /api/health is the first thing that +# is true only once it can actually serve. +echo "==> waiting for /api/health…" +health="" +deadline=$(($(date +%s) + 600)) +while :; do + health="$(curl -fsS -m 5 "$(local_url)/api/health" 2>/dev/null || true)" + case "$health" in *'"ok"'* | *'"clickhouse"'*) break ;; esac + + # A crash loop is not slowness, and waiting 600s to say so is the single + # least useful thing this script could do. `restart: always` on a container + # that dies during config evaluation looks exactly like a slow boot from the + # outside — the port simply never answers. Ask the container instead, and + # print the line that names the cause. + state="$(docker inspect -f '{{.State.Status}} {{.RestartCount}}' \ + "$(compose ps -q plausible 2>/dev/null)" 2>/dev/null || true)" + case "$state" in + restarting\ [3-9]* | restarting\ [1-9][0-9]* | exited*) + echo "error: the plausible container is not staying up ($state)" >&2 + compose logs --tail 200 plausible 2>/dev/null | + grep -E '\*\* \(|ERROR!' | head -5 | sed 's/^/ /' >&2 + echo " full log: docker compose -p $PROJECT logs plausible" >&2 + exit 1 + ;; + esac + + if [ "$(date +%s)" -ge "$deadline" ]; then + echo "error: $(local_url)/api/health did not answer within 600s" >&2 + echo " logs: docker compose -p $PROJECT logs plausible" >&2 + exit 1 + fi + sleep 5 +done +echo " $health" + +# ── phase 3b: the named tunnel, once there is an origin to point it at ───── +# AFTER the app is healthy, unlike the quick tunnel which has to run first to +# reveal its own hostname. cloudflared re-resolves per connection so the order +# is not strictly required — but starting it here means the first request +# through the public hostname finds a working dashboard rather than a 502, and +# the readiness probe inside cfn_run is then a real end-to-end check. +if [ "$MODE" = "named" ]; then + cfn_up "$NAMED_TUNNEL" "$PLAUSIBLE_HOSTNAME" "$(rig_network plausible)" \ + "http://plausible:8000" || { + echo "error: the named tunnel did not come up — the dashboard is local-only." >&2 + exit 1 + } +fi + +# ── phase 4: the admin account and the site ──────────────────────────────── +if [ "$SKIP_SIGNUP" -eq 0 ]; then + bash "$HERE/signup.sh" + + # ── phase 5: close registration behind us ──────────────────────────────── + # A quick tunnel URL is unguessable but public, and an open /register on it + # is an invitation. Closing it is one env var and a container recreate; the + # verification below is the part that matters, because "the variable is set" + # and "the route refuses" are different claims. + if [ "$KEEP_OPEN" -eq 0 ]; then + echo "==> closing registration" + env_set DISABLE_REGISTRATION true + # An env change is only picked up when the container is RE-CREATED — a + # restart re-runs the same process with the same baked environment. `up -d` + # notices the difference and recreates just this service. + compose up -d plausible >/dev/null + for _ in $(seq 1 60); do + curl -fsS -m 5 "$(local_url)/api/health" >/dev/null 2>&1 && break + sleep 2 + done + if curl -fsS -m 10 "$(local_url)/register" 2>/dev/null | grep -qi 'password_confirmation'; then + echo " ⚠ /register still serves a signup form — anyone with the tunnel URL can register." >&2 + else + echo " /register no longer offers signup" + fi + fi +fi + +load_secrets +echo "" +echo "── Plausible Community Edition ────────────────────────────" +echo " Public $url" +echo " Local $(local_url) (loopback only)" +echo " Login $url/login ${PLAUSIBLE_EMAIL:-} / see .secrets.env" +echo " Site ${PLAUSIBLE_SITE:-} (the tracker's data-domain must match this exactly)" +echo " Logs docker compose -p $PROJECT logs -f plausible" +echo " Stop bash $HERE/down.sh" +echo "" +echo " Wire the app at it: bash $HERE/wire-frontend.sh" +echo " Prove it works: bash $HERE/verify.sh" +echo "" +if [ "$MODE" = "named" ]; then + echo " Mode: NAMED — this hostname persists. BASE_URL and any wiring done" + echo " against it stay correct across restarts; re-running up.sh rewrites" + echo " nothing and the frontend does not need re-pointing." +else + echo " ⚠ This URL dies with the tunnel. Re-running up.sh mints a new one and" + echo " rewrites BASE_URL — fine for debugging, not for anything lasting." + echo " A named hostname removes that churn: see SKILL.md, 'Named tunnels'." +fi diff --git a/.claude/skills/plausible-stack/scripts/url.sh b/.claude/skills/plausible-stack/scripts/url.sh new file mode 100644 index 00000000..a50d442d --- /dev/null +++ b/.claude/skills/plausible-stack/scripts/url.sh @@ -0,0 +1,20 @@ +#!/usr/bin/env bash +# The rig's current URLs. Reads cloudflared's log rather than the state file: +# a tunnel that was restarted has a new public URL and the same state file, and +# a stale URL fails SILENTLY — the tracker keeps posting into nothing. +set -euo pipefail +HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +source "$HERE/lib.sh" + +url="$(tunnel_url || true)" +if [ -z "$url" ]; then + echo "no tunnel running — bash $HERE/up.sh" >&2 + exit 1 +fi + +echo "$url" +if [ "${1:-}" = "--all" ]; then + echo "local $(local_url)" + echo "cached $(cat "$STATE/tunnel-url" 2>/dev/null || echo '-')" + echo "BASE_URL $(env_get BASE_URL)" +fi diff --git a/.claude/skills/plausible-stack/scripts/verify.sh b/.claude/skills/plausible-stack/scripts/verify.sh new file mode 100644 index 00000000..d389fca4 --- /dev/null +++ b/.claude/skills/plausible-stack/scripts/verify.sh @@ -0,0 +1,253 @@ +#!/usr/bin/env bash +# Prove this rig works — end to end, from a real browser to Plausible's own +# query API and back. +# +# The claims, in the order they are checked, and why each is a SEPARATE claim: +# +# 1. every compose service has a running container +# "the service exists" and "the service works" are different; a +# container that was removed reads exactly like one that never ran. +# 2. the dashboard answers THROUGH THE TUNNEL, with a real login +# a 200 on / proves a proxy is up. Logging in proves Postgres, the +# session store and the app are all working from the outside. +# 3. registration is closed through the tunnel +# the URL is unguessable, not private. +# 4. the app is wired, read from the MERGED config +# a reader that looked only at the tracked config.yaml finds the key +# absent on a perfectly wired machine, skips, and reports success. +# 5. the tracker script is fetchable and is the right VARIANT +# the stock script silently refuses to send from localhost. +# 6. a real browser sends page views, carrying no id and no token +# the privacy properties are properties of the bytes. +# 7. Plausible has them: its own Stats API returns the route templates +# "the server accepted it" is not "the server can use it" — three days +# of green replay specs were bought with that confusion. +# 8. nothing it stored contains an id, and no column could hold an IP +# the positive control for 6 lives in 7, and this is the negative. +# 9. --restore removes ONE key from the shared overlay +# checked on a COPY, so it costs no restart. +# +# Usage: verify.sh [--no-browser] +# --no-browser skip step 6 EXPLICITLY. Without the flag, an unavailable +# browser is a FAILURE, not a skip: a proof that quietly +# removes its own hardest step is how this repo has been +# lied to before. +set -uo pipefail +HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +source "$HERE/lib.sh" +ROOT_DIR="$(cd "$HERE/../../../.." && pwd)" +CONFIG_DIR="$ROOT_DIR/components/frontend/data/test/config" +OVERLAY="$ROOT_DIR/.claude/skills/lib/config-overlay.sh" +APP_URL="${E2E_BASE_URL:-http://localhost:8081}" + +BROWSER=1 +[ "${1:-}" = "--no-browser" ] && BROWSER=0 + +fail=0 +ok() { printf ' [x] %s\n' "$1"; } +bad() { + printf ' ✕ %s\n' "$1" + fail=1 +} + +require_docker +load_secrets + +# ── 1. the stack ─────────────────────────────────────────────────────────── +echo "── containers" +missing="" +running="$(compose ps --format '{{.Service}}' 2>/dev/null | sort -u)" +while IFS= read -r svc; do + [ -n "$svc" ] || continue + printf '%s\n' "$running" | grep -qx "$svc" || missing="$missing $svc" +done <<EOF +$(compose config --services 2>/dev/null | sort -u) +EOF +[ -z "$missing" ] && ok "every compose service is running" || bad "not running:$missing" + +url="$(tunnel_url || true)" +[ -n "$url" ] || { + echo " ✕ no tunnel — scripts/up.sh first" >&2 + exit 1 +} + +# ── 2. the dashboard, through the tunnel, with a real login ──────────────── +echo "── dashboard at $url" +jar="$(mktemp)" +trap 'rm -f "$jar"' EXIT +login_html="$(curl -fsS -m 30 -c "$jar" "$url/login" 2>/dev/null || true)" +case "$login_html" in +*'name="_csrf_token"'*) ok "GET /login serves the form" ;; +*) bad "GET /login did not serve a login form" ;; +esac +csrf="$(printf '%s' "$login_html" | + grep -oE 'name="_csrf_token"[^>]*value="[^"]+"' | sed 's/.*value="//;s/"//' | head -1)" +code="$(curl -fsS -m 30 -b "$jar" -c "$jar" -o "$CURL_DISCARD" -w '%{http_code}' -X POST "$url/login" \ + --data-urlencode "_csrf_token=$csrf" \ + --data-urlencode "email=${PLAUSIBLE_EMAIL:-}" \ + --data-urlencode "password=${PLAUSIBLE_PASSWORD:-}" 2>/dev/null || true)" +[ "$code" = "302" ] && ok "POST /login → 302 (session issued)" || bad "POST /login answered $code" +sites="$(curl -fsS -m 30 -b "$jar" "$url/sites" 2>/dev/null || true)" +case "$sites" in +*"${PLAUSIBLE_SITE:-__none__}"*) ok "/sites lists ${PLAUSIBLE_SITE:-}" ;; +*) bad "/sites does not list ${PLAUSIBLE_SITE:-} — the dashboard is not usable" ;; +esac + +# ── 3. registration closed ───────────────────────────────────────────────── +reg="$(curl -fsS -m 30 "$url/register" 2>/dev/null || true)" +case "$reg" in +*password_confirmation*) bad "/register still offers signup on a PUBLIC url" ;; +*) ok "/register does not offer signup" ;; +esac + +# ── 4. the app's wiring, from the MERGED view ────────────────────────────── +# config.yaml < config.local.yaml, which is what +# components/frontend/src/lib/server/settings.ts does. Reading only the tracked +# file is the trap: wiring never writes there, so the key is always absent and +# a reader that stopped there would report "not wired" on a wired machine — the +# exact shape that made the replay suite pass while testing nothing. +echo "── app wiring (merged config)" +base_block="$(bash "$OVERLAY" get "$CONFIG_DIR/config.yaml" plausible 2>/dev/null || true)" +over_block="$(bash "$OVERLAY" get "$CONFIG_DIR/config.local.yaml" plausible 2>/dev/null || true)" +if [ -n "$base_block" ] && [ -n "$over_block" ]; then + # The only case where "prefer the overlay's block" differs from a deep merge. + # Single quotes: backticks inside a double-quoted string are a command + # substitution, so this line would have tried to RUN `plausible`. + echo ' ! both config.yaml and config.local.yaml define "plausible" — the' + echo " loader deep-merges them; this check reads the overlay's block only." +fi +block="${over_block:-$base_block}" +script_url="$(printf '%s\n' "$block" | sed -n 's/^[[:space:]]*scriptUrl:[[:space:]]*//p')" +domain="$(printf '%s\n' "$block" | sed -n 's/^[[:space:]]*domain:[[:space:]]*//p')" +case "$block" in +*"enabled: true"*) ok "plausible.enabled: true" ;; +*) bad "the app is not wired — scripts/wire-frontend.sh" ;; +esac +[ -n "$script_url" ] && ok "scriptUrl $script_url" || bad "no scriptUrl in the merged config" +[ "$domain" = "${PLAUSIBLE_SITE:-}" ] && + ok "domain $domain matches the registered site" || + bad "domain '$domain' ≠ registered site '${PLAUSIBLE_SITE:-}' — events would be dropped" + +# The tracked file must NEVER carry a tunnel hostname. There is a Go spec for +# the same property on the OIDC issuer (internal/config/config_test.go); +# this is the one for ours, and it is cheap enough to run every time. +if grep -q 'trycloudflare' "$CONFIG_DIR/config.yaml" 2>/dev/null; then + bad "$CONFIG_DIR/config.yaml contains a tunnel hostname — it must stay on localhost" +else + ok "the TRACKED config.yaml has no tunnel hostname" +fi + +# ── 5. the tracker script, and the right variant ─────────────────────────── +echo "── tracker script" +js="$(curl -fsS -m 30 "$script_url" 2>/dev/null || true)" +[ -n "$js" ] && ok "fetchable through the tunnel" || bad "$script_url is not fetchable" +case "$js" in +*"localhost\$|^127"*) + bad "this is the NON-local variant: it silently refuses to send from localhost" + ;; +*) ok "no localhost guard (the .local variant)" ;; +esac +# `manual`: the script must not count anything by itself. The auto-tracking +# variants call the sender on load and on history changes; the manual one only +# installs the queue. Checked by the absence of the pushState patch. +case "$js" in +*pushState*) bad "this variant tracks navigations ITSELF — it would send location.href" ;; +*) ok "manual variant (every pageview is one the app decided to send)" ;; +esac + +# ── 6. a real browser ────────────────────────────────────────────────────── +if [ "$BROWSER" -eq 1 ]; then + echo "── browser" + hackathon_id="$(curl -fsS -m 20 "$APP_URL/" 2>/dev/null | + grep -oE '[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}' | head -1)" + # Firefox and playwright live in the sibling e2e skill, inside the dev + # container. No container ⇒ FAIL, never skip. + cname="$(docker ps --format '{{.Names}}' | grep -E '^devcontainer-dev-1$' | head -1)" + if [ -z "$cname" ]; then + bad "the dev container is not running, so no browser can be driven (--no-browser to accept that)" + else + out="$(docker exec -u vscode "$cname" \ + /workspaces/hackagon/.devenv/profile/bin/node \ + /workspaces/hackagon/.claude/skills/plausible-stack/scripts/pageview.mjs \ + "$APP_URL" "$url" "$hackathon_id" /tmp/plausible-pageview.json 2>&1)" + rc=$? + printf '%s\n' "$out" | sed 's/^/ /' + [ "$rc" -eq 0 ] && ok "a real browser sent page views, with no id and no token on the wire" || + bad "the browser proof failed (above)" + fi +fi + +# ── 7. what Plausible actually stored ────────────────────────────────────── +echo "── read-back (Plausible's own Stats API)" +if [ -z "${PLAUSIBLE_API_KEY:-}" ]; then + bad "no PLAUSIBLE_API_KEY in $SECRETS_FILE — run scripts/signup.sh" +else + q="$(curl -fsS -m 30 -X POST "$url/api/v2/query" \ + -H "Authorization: Bearer $PLAUSIBLE_API_KEY" -H 'Content-Type: application/json' \ + -d "{\"site_id\":\"$domain\",\"metrics\":[\"visitors\",\"pageviews\"],\"date_range\":\"day\",\"dimensions\":[\"event:page\"]}" 2>/dev/null || true)" + # Only the `results` array. The response ECHOES the query back under "query", + # dimensions and all, so a grep over the whole body reports `event:page` as a + # page that was visited — a fake row in a list whose whole job is to be read + # for what is and is not in it. + pages="$(printf '%s' "${q%%\"meta\"*}" | grep -oE '"dimensions":\["[^"]*"\]' | sed 's/.*\["//;s/"\]//')" + if [ -z "$pages" ]; then + bad "Plausible has no page views for $domain today — the browser's 202s went nowhere" + else + ok "pages stored today: $(printf '%s' "$pages" | tr '\n' ' ')" + # The positive control: the deep route must be one of them. Without it, + # "no id was stored" is satisfied by an empty database. + case "$pages" in + */hackathon/\[id\]*) ok "the deep route landed as its TEMPLATE, /hackathon/[id]" ;; + *) [ "$BROWSER" -eq 1 ] && bad "no /hackathon/[id] row — the deep-route pageview did not land" ;; + esac + # …and the negative: nothing stored may look like an id. + if printf '%s' "$pages" | grep -qE '[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}'; then + bad "a UUID is stored in Plausible's page list" + else + ok "no UUID in anything Plausible stored" + fi + fi +fi + +# ── 8. the schema cannot hold an IP ──────────────────────────────────────── +# The privacy claim in docs/frontend/analytics.md, asked of the database rather +# than of the vendor. +ch="$(compose exec -T plausible_events_db clickhouse-client -q \ + "SELECT count() FROM system.columns WHERE database='plausible_events_db' AND table IN ('events_v2','sessions_v2') AND (name ILIKE '%ip%' OR name ILIKE '%user_agent%')" 2>/dev/null | tr -d '\r\n ')" +if [ "$ch" = "0" ]; then + ok "events_v2/sessions_v2 have no IP or user-agent column" +elif [ -n "$ch" ]; then + bad "$ch column(s) in events_v2/sessions_v2 look like they hold an IP or user agent" +else + echo " ! could not query ClickHouse for the column check" +fi + +# ── 9. --restore takes exactly one key ───────────────────────────────────── +# On a COPY: the real thing would restart three servers, and this is a claim +# about config-overlay.sh, not about the running app. +echo "── unwiring (simulated on a copy)" +live="$CONFIG_DIR/config.local.yaml" +if [ -f "$live" ]; then + tmp="$(mktemp -d)/config.local.yaml" + cp "$live" "$tmp" + before="$(bash "$OVERLAY" keys "$tmp" | sort | tr '\n' ' ')" + bash "$OVERLAY" remove "$tmp" plausible >/dev/null + after="$(bash "$OVERLAY" keys "$tmp" 2>/dev/null | sort | tr '\n' ' ')" + expect="$(bash "$OVERLAY" keys "$live" | grep -vx plausible | sort | tr '\n' ' ')" + if [ "$after" = "$expect" ]; then + ok "removes 'plausible' and keeps [ ${after:-—}] (was [ $before])" + else + bad "removal changed the wrong keys: [$after] ≠ [$expect]" + fi + rm -rf "$(dirname "$tmp")" +else + echo " ! no config.local.yaml — nothing to unwire" +fi + +echo "" +if [ "$fail" -eq 0 ]; then + echo "VERIFIED$([ "$BROWSER" -eq 0 ] && echo " (browser step skipped ON PURPOSE — the end-to-end claim is NOT proven)")" +else + echo "FAILED — see the ✕ lines." + exit 1 +fi diff --git a/.claude/skills/plausible-stack/scripts/wire-frontend.sh b/.claude/skills/plausible-stack/scripts/wire-frontend.sh new file mode 100644 index 00000000..2b5878b0 --- /dev/null +++ b/.claude/skills/plausible-stack/scripts/wire-frontend.sh @@ -0,0 +1,227 @@ +#!/usr/bin/env bash +# Point the SvelteKit app at this Plausible, or unpoint it. +# +# wire-frontend.sh wire: read the live tunnel URL, write the +# `plausible:` block, restart the frontend +# wire-frontend.sh --restore turn measurement back OFF (the default state) +# wire-frontend.sh --print show what it would write, change nothing +# +# Measurement is OFF unless this script (or a human) has written +# `plausible.enabled: true`. An absent block parses to `{enabled:false}`, and +# the component then renders no script tag at all — no request, no console +# noise. Nothing counts because somebody forgot a flag. +# +# NOTHING TRACKED IS EDITED. The block goes into config.local.yaml, the +# gitignored overlay the loader deep-merges over config.yaml +# (components/frontend/src/lib/server/settings.ts). The value written here is a +# `*.trycloudflare.com` hostname that dies in a few hours: this repo has +# already had one of those committed and left dead in HEAD for several commits, +# and `internal/config/config_test.go` now asserts both tracked configs still +# say localhost. +# +# THIS SCRIPT OWNS EXACTLY ONE KEY IN THAT FILE: `plausible`. It is the THIRD +# writer — cloudflare-tunnel's auth-wire.sh owns `oidc`, openreplay-stack's +# wire-frontend.sh owns `replay` — and none of them knows about the others. So +# --restore removes the BLOCK, never the file: an `rm` here would silently +# unwire login (a tunnel with no issuer keeps serving every page and breaks +# only signing in) or stop session replay recording (an empty OpenReplay UI +# looks exactly like the correct default). .claude/skills/lib/config-overlay.sh +# does the per-key surgery for all three. +set -euo pipefail +HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +source "$HERE/lib.sh" +ROOT_DIR="$(cd "$HERE/../../../.." && pwd)" +FRONTEND_LOCAL="$ROOT_DIR/components/frontend/data/test/config/config.local.yaml" +OVERLAY="$ROOT_DIR/.claude/skills/lib/config-overlay.sh" + +MODE="wire" +case "${1:-}" in +--restore) MODE="restore" ;; +--print) MODE="print" ;; +"") ;; +*) + echo "unknown argument: $1" >&2 + exit 2 + ;; +esac + +# The frontend reads its config ONCE at boot, so a rewrite is inert until it +# restarts — and getting that restart right is the whole trick, exactly as it +# is for openreplay-stack's wiring. THREE servers can be serving this app: +# +# process-compose `frontend` vite dev on :8081 +# prod-frontend.sh the adapter-node build on :8081 (the e2e +# harness puts it there and leaves it there) +# prod-serve.sh the adapter-node build on :8082, which is what +# the app's own Cloudflare tunnel proxies +# +# Restarting only the first succeeds, prints "Process frontend restarted", and +# changes nothing at all. +in_shell() { # run a command in the dev shell, wherever this script started + local pc="$ROOT_DIR/.claude/skills/devcontainer-up/scripts/exec.sh" + if command -v process-compose >/dev/null 2>&1; then + (cd "$ROOT_DIR" && bash -c "$1") + elif [ -f "$pc" ]; then + (cd "$ROOT_DIR" && MSYS_NO_PATHCONV=1 bash "$pc" just nix::develop default bash -c "$1") + else + return 1 + fi +} + +# ⚠ EVERY STEP HERE REPORTS. The first version of this function ran the +# restarts with `>/dev/null 2>&1` on both ends of an `&&` chain, which is how a +# stopped :8082 — the app tunnel's ONLY upstream — sat down for ten minutes +# with this script printing "analytics is ON" and nothing else. A restart that +# stops a server and fails to start it again is the one outcome that must never +# be quiet, because the public URL keeps answering (caddy falls back) right up +# until the fallback is down too. +# +# Each step is therefore: run, capture, and on failure print the tail and name +# the port that is now down. Slow, honest, and it costs one nix-shell entry per +# step (trap 4 in .claude/CLAUDE.md) — which is why they are three steps and +# not one. +step() { # <label> <command…> + local label="$1" + shift + local out + if out="$(in_shell "$*" 2>&1)"; then + echo " $label: ok" + return 0 + fi + echo " $label: FAILED" >&2 + printf '%s\n' "$out" | tail -6 | sed 's/^/ /' >&2 + return 1 +} + +restart_frontend() { + local ok=1 + + # vite, when process-compose is the thing serving :8081. + in_shell 'just deploy::proc-comp process restart frontend' >/dev/null 2>&1 && ok=0 + + # The built server on :8081, when the e2e harness put one there. `ensure` + # rebuilds if the source is newer than the build — which it is the first time + # this feature is wired, because it also adds a component. That build takes + # minutes, and its failure used to be invisible. + if in_shell 'test -s .output/run/e2e-prod-frontend.pid' >/dev/null 2>&1; then + if step ":8081 built server" \ + 'bash .claude/skills/hackathon-e2e/scripts/prod-frontend.sh stop && + bash .claude/skills/hackathon-e2e/scripts/prod-frontend.sh ensure'; then + ok=0 + else + echo " ⚠ :8081 IS DOWN — restart it: prod-frontend.sh ensure" >&2 + fi + fi + + # The built server on :8082 — the app tunnel's upstream. Restarted with ITS + # OWN origin, read back from the running process: an adapter-node server is + # launched with a FIXED ORIGIN, and handing it the wrong one 403s every form + # POST through the public URL while every page still renders. + local origin + origin="$(in_shell 'bash .claude/skills/cloudflare-tunnel/scripts/prod-serve.sh origin' 2>/dev/null | + tr -d '\r' | grep -oE 'https?://[^ ]+' | tail -1 || true)" + if [ -n "$origin" ]; then + step ":8082 tunnel upstream ($origin)" \ + "bash .claude/skills/cloudflare-tunnel/scripts/prod-serve.sh stop && + bash .claude/skills/cloudflare-tunnel/scripts/prod-serve.sh ensure '$origin'" || + echo " ⚠ THE APP TUNNEL HAS NO UPSTREAM — restart it: prod-serve.sh ensure $origin" >&2 + fi + + [ "$ok" -eq 0 ] && return 0 + echo "note: restart the frontend for this to take effect:" >&2 + echo " just deploy::proc-comp process restart frontend" >&2 + echo " (or, if the e2e built server is serving :8081)" >&2 + echo " bash .claude/skills/hackathon-e2e/scripts/prod-frontend.sh stop && … ensure" >&2 +} + +# Restart only when the overlay actually CHANGED — config-overlay.sh answers +# `changed` or `unchanged` for exactly this. A needless bounce of :8081 mid-suite +# fails one unrelated test and nothing in the output points back here. +# +# ⚠ Do not run this while an e2e suite is in flight, even so. +apply() { # <changed|unchanged> <message> + if [ "$1" = "changed" ]; then + echo "$2" + restart_frontend + else + echo "$2 (already; nothing changed, frontend left alone)" + fi +} + +if [ "$MODE" = "restore" ]; then + # `remove`, never `rm`: `oidc` and `replay` may share this file. + apply "$(bash "$OVERLAY" remove "$FRONTEND_LOCAL" plausible)" \ + "==> analytics is OFF" + exit 0 +fi + +require_docker +url="$(tunnel_url || true)" +[ -n "$url" ] || { + echo "error: no Plausible tunnel running — scripts/up.sh first" >&2 + exit 1 +} +load_secrets +domain="${PLAUSIBLE_SITE:-}" +[ -n "$domain" ] || { + echo "error: no PLAUSIBLE_SITE in $SECRETS_FILE — run scripts/secrets.sh" >&2 + exit 1 +} + +# THE SCRIPT VARIANT IS TWO DECISIONS, and both are load-bearing. +# +# `local` — the stock script REFUSES to send from a local address. Read out of +# the served file, not the docs: +# /localhost$|^127(\.[0-9]+){0,2}\.[0-9]+$|^\[::1?\]$/.test(location.hostname) +# The dev app is http://localhost:8081, so without this variant +# every page view is silently dropped by the browser — the most +# expensive shape of failure there is, because everything looks +# wired and nothing arrives. +# `manual` — the script otherwise sends a pageview by ITSELF, using +# `location.href`. That URL is the one thing this integration must +# never send (invite tokens, hackathon ids). Manual mode means every +# pageview is one PlausibleAnalytics.svelte decided to send, with a +# URL it built out of the route template. +# +# The file name's variant order does not matter (the instance serves every +# permutation); `local.manual` is written for readability. +script_url="$url/js/script.local.manual.js" + +block=$( + cat <<YAML +plausible: + enabled: true + scriptUrl: $script_url + domain: $domain +YAML +) + +if [ "$MODE" = "print" ]; then + echo "$block" + exit 0 +fi + +# Fail before writing a config that points at a script that is not there — a +# 404 on the tracker is invisible in the app (a failed <script> logs one line +# and the page is fine) and looks identical to "nobody visited". +# +# cfn_http_code, not a bare curl: with a NAMED hostname the check must fail on a +# broken TUNNEL and not on a broken RESOLVER, and those are distinguishable — +# it retries against the address Cloudflare's own DoH endpoint gives. The +# machine this was built on answers AAAA-only for these names with no IPv6 route +# out, so a bare curl reports 000 for a dashboard that is serving 200. +code="$(cfn_http_code "$script_url")" +[ "$code" = "200" ] || { + echo "error: $script_url answered $code — is the tunnel healthy? (scripts/url.sh)" >&2 + exit 1 +} + +apply "$(printf '%s\n' "$block" | bash "$OVERLAY" set "$FRONTEND_LOCAL" plausible)" \ + "==> analytics is ON" +echo " script $script_url" +echo " domain $domain" +echo " dashboard $url/$domain" +echo " ⚠ page views from every visitor to the app are now counted." +echo " No cookie is set and no URL is sent — the ROUTE TEMPLATE is" +echo " (see components/frontend/src/lib/utils/analyticsRoute.ts), and" +echo " docs/frontend/analytics.md states what Plausible does with the IP." diff --git a/.devcontainer/Dockerfile b/.devcontainer/Dockerfile index 474ac496..9829be1f 100644 --- a/.devcontainer/Dockerfile +++ b/.devcontainer/Dockerfile @@ -8,6 +8,10 @@ FROM ${BASE_IMAGE} # "libgtk-3.so.0: cannot open shared object file". Baking them into the image # makes the browser survive recreation. # Kept in sync with `playwright install --with-deps firefox` on Ubuntu 24.04. +# To re-derive this list after a Playwright bump: run `playwright install +# --with-deps firefox` in a container, then diff `apt-mark showmanual` against +# the same command run in a fresh container of this image. Anything the diff +# names is living in the writable layer and dies at the next recreate. ARG INSTALL_BROWSER_DEPS=1 RUN if [ "${INSTALL_BROWSER_DEPS}" = "1" ]; then \ apt-get update \ @@ -32,9 +36,47 @@ RUN if [ "${INSTALL_BROWSER_DEPS}" = "1" ]; then \ libnss3 \ libnspr4 \ fonts-liberation \ + libcairo2 \ + libdbus-1-3 \ + libfontconfig1 \ + libfreetype6 \ + libgdk-pixbuf-2.0-0 \ + libglib2.0-0t64 \ + libpango-1.0-0 \ + libx11-6 \ + libxcb1 \ + libxext6 \ + libxrender1 \ + libavcodec60 \ + xvfb \ + xfonts-cyrillic \ + xfonts-scalable \ + fonts-freefont-ttf \ + fonts-ipafont-gothic \ + fonts-noto-color-emoji \ + fonts-tlwg-loma-otf \ + fonts-unifont \ + fonts-wqy-zenhei \ && rm -rf /var/lib/apt/lists/*; \ fi +# git-lfs. Three files in this repo are stored in LFS +# (components/frontend/static/favicon.png, static/og-default.jpg and +# tools/configs/keycloak/themes/hackagon/login/resources/img/favicon.ico). +# The workspace is bind-mounted from the host, which HAS git-lfs, so those +# files hold their real bytes in the worktree while HEAD holds the pointer. +# Without the lfs filters installed here, git inside the container compares +# pointer against content and reports all three modified — the worktree is +# dirty with zero edits, `git status` stops being a usable signal, and every +# `nix develop` evaluates a flake it must mark dirty. +# +# `--system` writes /etc/gitconfig on purpose: /home/vscode is a named volume, +# so anything this image writes to the user's home is masked at runtime. +RUN apt-get update \ + && DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends git-lfs \ + && rm -rf /var/lib/apt/lists/* \ + && git lfs install --system --skip-repo + # Space-separated list of extra apt packages to bake into the image # (set HACKAGON_DEV_EXTRA_APT_PACKAGES in .devcontainer/.env). ARG EXTRA_APT_PACKAGES="" diff --git a/.devcontainer/README.md b/.devcontainer/README.md index 895b3474..1fcaaf9d 100644 --- a/.devcontainer/README.md +++ b/.devcontainer/README.md @@ -18,14 +18,14 @@ container runtime in the picture at all. Everything below is **optional**, and here is what each piece buys you and what you lose without it: -| Piece | Buys you | Without it | -| ---------------------------------------- | ------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| This devcontainer (Docker) | A Linux box with Nix on a Windows/macOS host; pinned browser libs for e2e | Nothing, if you are on Linux. On Windows you need _some_ Linux (WSL2 works) because Nix does not run natively there. | -| `rustfs` object store (Docker) | File uploads: event logos, page media, submission attachments | The app still **boots and serves** (verified: backend `health.HealthService/Check` OK, frontend `/` 200). Uploads fail at use time and `/objects/*` answers **500**. Nothing warns you at boot. | -| Cloudflare tunnel (`tunnel`+`caddy`) | A public `*.trycloudflare.com` URL with working OIDC login | Localhost only. No effect on anything else. | -| OpenReplay rig (`.claude/skills`) | Session replay for debugging | Nothing — session replay is **off unless** a `replay:` block exists in the frontend `config.yaml` (`src/lib/schemas/config-schema.ts`). | -| `services` compose profile | Postgres + Keycloak as real containers instead of devenv processes | Nothing; the devenv copies are the default and the two sets fight over ports, which is why the profile is opt-in. | -| `.claude/` skills (e2e, tunnel, docs, …) | The Playwright suites, the recipe spec, the tunnel and docs tooling | Nothing in the app's build, test or run path. Grep confirms: outside `.claude/` the only references to it are explanatory comments. | +| Piece | Buys you | Without it | +| ---------------------------------------- | ------------------------------------------------------------------------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| This devcontainer (Docker) | A Linux box with Nix on a Windows/macOS host; pinned browser libs for e2e | Nothing, if you are on Linux. On Windows you need _some_ Linux (WSL2 works) because Nix does not run natively there. | +| `rustfs` object store (Docker) | File uploads: event logos, page media, submission attachments | The app still **boots and serves** (verified: backend `health.HealthService/Check` OK, frontend `/` 200). Uploads fail at use time and `/objects/*` answers **500**. Nothing warns you at boot. | +| Cloudflare tunnel (`tunnel`+`caddy`) | A public URL with working OIDC login — a throwaway `*.trycloudflare.com` one, or a persistent hostname on a zone you own | Localhost only. No effect on anything else. | +| Debug rigs: OpenReplay, Plausible | Session replay; cookieless audience measurement. Each is its own compose project with its own tunnel | Nothing — both are **off unless** a `replay:` / `plausible:` block reaches the frontend config (`src/lib/schemas/config-schema.ts`); the rigs write theirs into the gitignored `config.local.yaml` overlay. Idle cost when you do run them: ~750 MB for Plausible, 8 GB of its own for OpenReplay. They coexist. | +| `services` compose profile | Postgres + Keycloak as real containers instead of devenv processes | Nothing; the devenv copies are the default and the two sets fight over ports, which is why the profile is opt-in. | +| `.claude/` skills (e2e, tunnel, docs, …) | The Playwright suites, the recipe spec, the mutation manifest and quality report, the tunnel and docs tooling | Nothing in the app's build, test or run path. Grep confirms: outside `.claude/` the only references to it are explanatory comments. | ### Minimal path from a clean machine to a running app @@ -58,11 +58,28 @@ just develop just deploy::proc-comp process restart backend # casbin reload seeding is not optional: casbin loads its policy once at startup and the seed writes roles straight into Postgres. -⚠ **The bootstrap leaves the tree dirty.** On a clean clone, -`GOWORK=off go mod tidy` prunes ~26 lines from the committed -`components/backend/go.sum`, so your first `git status` is not clean. The build -is unaffected. `just ci::codegen-check` runs the same command followed by -`git diff --exit-code`, so this needs resolving rather than ignoring. +Neither script prepares the object store — nothing in the app's own build path +knows it exists. If you are using the container, run +`bash .devcontainer/rustfs-init.sh` once (idempotent) or let +`.claude/skills/devcontainer-up/scripts/up.sh` do it for you; without it the +store is up but empty, which shows as three broken `<img>` frames on the +hackathon list and 404s under `/objects`. + +⚠ **The bootstrap leaves the tree dirty.** `GOWORK=off go mod tidy` prunes +exactly 26 lines from the committed `components/backend/go.sum`, so your first +`git status` is not clean. The build is unaffected. `just ci::codegen-check` +runs the same command followed by `git diff --exit-code`, so this needs +resolving rather than ignoring. (Still true: re-run in this container on +2026-08-14 at `a5003590`, same 26 deletions.) + +Nothing _else_ should be dirty. `git-lfs` is in the image as of 2026-08-13, so +the three LFS-tracked binaries (`components/frontend/static/favicon.png`, +`static/og-default.jpg`, the Keycloak theme's `favicon.ico`) no longer read as +permanently modified. They used to: the workspace is bind-mounted from a Windows +host that HAS git-lfs, so the worktree held real bytes while HEAD held a +129-byte pointer, and a container with no `filter.lfs` config compared the two +and reported ` M` forever. That was a filter that was never installed, not an +edit — and while it lasted, `git status` was not a signal anything could read. **Budget the first run.** It downloads and partly _compiles_ the toolchain — devenv's own Rust binaries build from source, because the flake declares its @@ -72,6 +89,29 @@ bootstrap ≈ 12 minutes to a ~10 GB Nix store, then `deploy::up` ≈ 5 more min before process-compose reports the stack started (Keycloak's `kc.sh build` runs in that window), and the frontend needs a few minutes more before Vite listens. +**After that, entering the shell is cheap — and an earlier claim that it was not +was wrong.** Every `just develop …` (and therefore every service in the stack, +whose start commands are `just develop just run` / `just develop just serve`) +re-enters `nix develop`. Measured in this container on 2026-08-14 at `a5003590`, +`just nix::develop default true`: + +| | measured | +| --------------------------------------- | --------------------------------------------- | +| steady state, tree clean | **4.6–5.0 s** | +| steady state, one tracked file modified | 4.7–5.0 s — **no difference** | +| first entry after a tree edit | 4.7–10.6 s (one 36 s outlier, not reproduced) | +| against a _fixed_ devenv-root file | 3.2–4.5 s | + +That last row is where the avoidable cost is: `tools/just/devenv.sh` rewrites +`.devenv/state/pwd` on **every** invocation, so the `devenv-root` flake input +gets a new `lastModified` and Nix's eval cache misses every single run — about +1.7 s of the 4.8 s, self-inflicted. Clean-versus-dirty is not the variable; the +"44 s floor on a permanently dirty worktree" written down previously was almost +certainly measured while `frontend` was crash-looping through one full +`nix develop` per round (see "When the stack starves itself" below), and +installing git-lfs — which does make the tree genuinely clean — moved the number +not at all. Keep git-lfs for the truthful `git status`, not for speed. + **On the native path.** These commands are what the container runs — the container adds nothing but Nix — but the run behind this document was performed in the devcontainer on a Windows host, where native Nix is not an option. The @@ -170,16 +210,40 @@ the e2e suite ran in the second without touching the first. The cost is a second Nix store (~10 GB): the `nix-store` volume is per project, so the new checkout re-downloads the toolchain. +There is a **fourth** collision axis if both checkouts use named Cloudflare +tunnels and share one Cloudflare account: the tunnel names default to +`hackagon`, `hackagon-plausible`, `hackagon-openreplay`, so the second checkout +would reuse the first's tunnel and repoint its DNS. Set `HACKAGON_TUNNEL_NAME` +(and the Plausible/OpenReplay equivalents) in +`.claude/skills/cloudflare-tunnel/.env` — a different file from the +`.devcontainer/.env` above. + ## Ports -| Port | Service | Where it binds | -| ---- | -------------- | ------------------------------------------ | -| 3000 | backend (gRPC) | inside `dev`, all interfaces | -| 8081 | frontend | inside `dev`, `[::1]` (vite) | -| 8180 | keycloak | inside `dev`, 0.0.0.0 | -| 5432 | postgres | inside `dev`, 127.0.0.1 | -| 9000 | rustfs S3 API | own container — see the object store | -| 9001 | rustfs console | own container — subpath `/rustfs/console/` | +| Port | Service | Where it binds | +| ---- | --------------------------- | ------------------------------------------------------ | +| 3000 | backend (gRPC) | inside `dev`, all interfaces | +| 8081 | frontend | inside `dev`, `[::1]` — vite, **or** the built server | +| 8082 | frontend (production build) | inside `dev`, **not published** — caddy's first choice | +| 8180 | keycloak | inside `dev`, 0.0.0.0 | +| 5432 | postgres | inside `dev`, 127.0.0.1 | +| 9000 | rustfs S3 API | own container — see the object store | +| 9001 | rustfs console | own container — subpath `/rustfs/console/` | + +**Two things can hold :8081.** `just up` starts `vite dev` there; the e2e +harness stops vite and parks the adapter-node production build on the same port +(`hackathon-e2e/scripts/prod-frontend.sh`), because after a codegen regeneration +vite's first SSR takes longer than any readiness probe will wait. :8081 is the +port to prefer for anything driving a browser — the realm export's +`hackagon-frontend` client allows exactly one redirect URI, +`http://localhost:8081/*`, so a login on :8082 dies with +`Invalid parameter: redirect_uri`. :8082 exists for the tunnel +(`cloudflare-tunnel/scripts/prod-serve.sh`), whose `Caddyfile.tunnel` tries +`dev:8082` first and falls back to `dev:8081`, so a public URL and a suite run +stop fighting over one port. It binds `HOST=::` (dual-stack) because caddy +reaches it as `dev:8082` on the container's eth0 while local checks use `::1` — +and it is deliberately not published, so nothing on the host can reach it and +mistake it for the app. The `rustfs` ports need no bridging: it is its own container, published straight to the host, and reached from `dev` as `rustfs:9000`. Note that 9000 is **also** @@ -199,14 +263,15 @@ docker compose -f .devcontainer/docker-compose.yml exec -u vscode dev \ ### Access: public imagery, private everything else -`rustfs-init.sh` applies a bucket policy on every run. Two prefixes are readable -with **no credentials at all**; nothing else is. +`rustfs-init.sh` applies a bucket policy on every run. Three prefixes are +readable with **no credentials at all**; nothing else is. -| Prefix | Read access | Holds | -| --------------- | ----------- | ------------------------------- | -| `hackathons/*` | public | event covers, gallery photos | -| `users/*` | public | profile pictures | -| everything else | private | submission attachments, exports | +| Prefix | Read access | Holds | +| --------------- | ----------- | ----------------------------------------------- | +| `hackathons/*` | public | event covers, gallery photos | +| `users/*` | public | profile pictures | +| `site/*` | public | media pasted into platform pages (`SITE_MEDIA`) | +| everything else | private | submission attachments, exports | Public-read is a decision, not an accident (see `docs/storage.md`): these images already render on pages that need no login, so a public prefix gives a stable @@ -219,6 +284,15 @@ casbin has approved the read. each prefix, reads them unsigned, and fails unless it sees 200 and 403 respectively. Getting this backwards is silent — signed callers keep working while the private half is world-readable — so it is tested rather than assumed. +Run here on 2026-08-14: +`hackathons/* 200, users/* 200, site/* 200, teams/* 403`. + +**The list has to track `storage_service.go`.** A kind the backend marks public +but this policy has no prefix for uploads perfectly and then answers 403 to +every read, because the handler returns a `publicUrl` it has no way to know is +unreadable. That is exactly what happened when `SITE_MEDIA` landed, which is why +`check_public_policy` now probes _every_ public prefix rather than a +representative one. ### Objects are served from the app's own origin @@ -257,11 +331,14 @@ leaving three broken image frames. Content-Type is set from the file extension curl otherwise stores `application/x-www-form-urlencoded`, the bytes upload fine, and the browser then refuses to render them. -## Public URL (Cloudflare quick tunnel, optional) +## Public URL (Cloudflare tunnel, optional) -An opt-in `tunnel` service (compose profile `tunnel`) exposes the running -frontend on a random `*.trycloudflare.com` URL — no Cloudflare account needed. -The bridge script must be running so the tunnel container can reach Vite: +Two modes, and the tooling picks between them. + +**Quick tunnel — the zero-setup default.** An opt-in `tunnel` service (compose +profile `tunnel`) exposes the running frontend on a random `*.trycloudflare.com` +URL — no Cloudflare account needed, and a new hostname on every start. The +bridge script must be running so the tunnel container can reach Vite: ```bash docker compose -f .devcontainer/docker-compose.yml exec -u vscode dev \ @@ -271,16 +348,74 @@ docker compose -f .devcontainer/docker-compose.yml logs tunnel | grep -o 'https: ``` The tunnel targets `caddy`, which path-splits the one public hostname: -`/realms/*` + `/resources/*` reach Keycloak, everything else the frontend -(`Caddyfile.tunnel`). Anonymous browsing works out of the box; **login through -the tunnel** additionally needs the OIDC issuers rewired to the (ephemeral) -public URL — scripted as +`/realms/*` + `/resources/*` reach Keycloak, `/objects/*` the object store, +everything else the frontend (`Caddyfile.tunnel`). Anonymous browsing works out +of the box; **login through the tunnel** additionally needs the OIDC issuers +rewired to the public URL — scripted as `bash .claude/skills/cloudflare-tunnel/scripts/up.sh --with-auth`, undone by the matching `down.sh`. Keycloak trusts forwarded headers for this (`proxy-headers=xforwarded` in toolchain.nix); the admin console is not routed through the tunnel. Stop with -`docker compose -f .devcontainer/docker-compose.yml --profile tunnel down tunnel caddy` -(quick-tunnel URLs are ephemeral and change on every start). +`docker compose -f .devcontainer/docker-compose.yml --profile tunnel down tunnel caddy`. + +**Named tunnel — a hostname that stops changing.** Most of the re-wiring above +exists only because a quick-tunnel hostname is thrown away on every restart. A +named tunnel is a persistent hostname on a zone you own, and there is one per +rig, driven by `.claude/skills/lib/cf-named-tunnel.sh` from a **gitignored** +`.claude/skills/cloudflare-tunnel/.env` (copy `.env.example` beside it): + +| rig | hostname variable | tunnel | origin | +| ---------- | --------------------- | --------------------- | ----------------------- | +| the app | `HACKAGON_HOSTNAME` | `hackagon` | `http://caddy:80` | +| Plausible | `PLAUSIBLE_HOSTNAME` | `hackagon-plausible` | `http://plausible:8000` | +| OpenReplay | `OPENREPLAY_HOSTNAME` | `hackagon-openreplay` | `http://caddy:80` | + +```bash +bash .claude/skills/lib/cf-named-tunnel.sh check # credentials + zone only +bash .claude/skills/lib/cf-named-tunnel.sh status # which named tunnels run +bash .claude/skills/cloudflare-tunnel/scripts/up.sh --with-auth # auto-selects +bash .claude/skills/cloudflare-tunnel/scripts/up.sh --with-auth --quick # force ephemeral +``` + +`up.sh` chooses named when those credentials exist and quick otherwise, **prints +which mode it is in**, and stops the other mode's tunnel — the OIDC issuer names +exactly one hostname, so a second public URL would serve every page and fail +every login, which is the failure nobody notices until somebody signs in. Caddy +needed no change: `Caddyfile.tunnel` binds `:80` for any Host, so the path mux +applies identically. Nothing tracked ever carries the hostname; the issuer goes +into the gitignored `config.local.yaml` overlay, and a spec in +`components/backend/internal/config/config_test.go` asserts both tracked configs +still say `localhost`. + +⚠ **A Cloudflare API token scopes to a ZONE, not to a hostname.** There is no +per-subdomain grant. The narrowest token that can do this job can edit **any DNS +record in the whole zone** — do not describe it as limited to the three +subdomains above, and use a zone you are willing to hand to a dev script. The +tooling supplies the guard Cloudflare cannot: `cf_dns_point` refuses to replace +a record that is not already a `*.cfargotunnel.com` CNAME (`CF_FORCE_DNS=1` +overrides). + +**The token is a SETUP credential.** Once the tunnels exist, `cloudflared` runs +from a per-tunnel credentials file under `.state/named/<name>/` that can serve +that one tunnel and nothing else: it cannot touch DNS, cannot enumerate the zone +and cannot create anything. A machine that only _runs_ a tunnel should hold that +directory and no token at all. + +⚠ **A named hostname can look dead from the Windows host and be perfectly +healthy.** On this LAN the resolver answers **AAAA-only** for these names on a +network with no IPv6 route out, so every lookup succeeds and every connection +fails in milliseconds — while the same URL works from inside the dev container. +The tooling detects this rather than reporting a broken tunnel: +`cf-named-tunnel.sh` retries against a DoH-resolved IPv4 edge and, when that +answers, says "the tunnel is fine, this machine's resolver is not"; +`auth-wire.sh` pins the name in `/etc/hosts` inside the container, and tests +reachability rather than asking `getent hosts`, which says yes about a name +nothing can reach. The manual check: + +```bash +curl -sS -o /dev/null -w '%{http_code}\n' \ + --resolve app.example.org:443:<a-cloudflare-ipv4> https://app.example.org/ +``` ## Object store (service `rustfs`) @@ -500,6 +635,91 @@ container's hostname, so its first boot can hang spamming stale member ages out. The frontend also takes a few minutes on first boot (pnpm install + svelte-kit sync before vite listens). +## When the stack starves itself + +Fixed 2026-08-13, and worth recognising because for several days it read as +product bugs in four different places rather than as an infrastructure fault. + +Every process in the stack starts with `just develop just …`, so **entering the +Nix shell is inside every service's startup**, while process-compose's readiness +clock is already running. That is fine at ~5 s a go. It stops being fine when +something enters that shell in a loop. + +What happened: `vite dev` binds `[::1]:8081`, and so does the adapter-node build +the e2e harness parks there. Whenever a previous run had left that server up — +the common case, since nothing stopped it between runs — vite could not bind, +exited 1 with `Error: Port 8081 is already in use`, and an **uncapped** +`restart: on_failure` sent it round again roughly every 55 seconds. Found live +at **54 restarts in 50 minutes**, each one a full `nix develop`. + +Two lessons generalise past this instance: + +- ⚠ **A readiness probe on a PORT cannot say which PROCESS holds it.** + `process list` reported `frontend Running Ready` the entire time, because the + probe is `curl http://localhost:8081` and the _other_ server was answering it. + The `RESTARTS` column said 54 throughout and nothing read it. +- ⚠ **A SIGTERM that lands after the Go signal handler is up exits 0**, and + `restart: on_failure` does not consider 0 a failure — so a backend killed by + its own readiness budget stays down and is recorded as `Completed`, + `exit_code=0`, which reads like a clean stop. The log line sequence is + `grpc server listening` → `received shutdown signal` → nothing, forever. + +The fixes are in `tools/nix/hackagon/lib/toolchain.nix` and the harness, and +none of them is a rule anyone has to remember: + +- frontend: `max_restarts = 3`, so a port conflict costs three shell entries + rather than one an hour. +- backend: `restart = "always"` **plus `max_restarts = 3`**. `always` alone + converts a permanent outage into an unbounded loop (measured with the budget + scaled down to force it: 149 restarts in 151 seconds); the cap is what makes + `always` safe. `failure_threshold` went 50 → 150 (~37 min) because a cold + restart of that service — Nix shell, build quitsh, build the Go service, boot + — was measured at 486 s on a quiet lock. A generous budget costs nothing when + healthy, since probing stops at the first success, and the thing that should + decide "the backend did not come up" is + `hackathon-e2e/scripts/wait-ready.sh`'s own 300 s-per-service timeout, which + names the service. +- `prod-frontend.sh ensure` calls `stop_vite` **unconditionally**. Its fast path + ("the built frontend already serves :8081 — leaving it alone") used to return + without touching process-compose, and that was the whole of how the loop + survived. "Leaving it alone" is about _our_ server, never about vite. +- `wait-ready.sh` reads the restart counters back and warns, with the exit code, + when any service is at ≥3. + +## One writer for the frontend build + +**Never run a bare `pnpm build` in `components/frontend`.** Two independent +callers build _and serve_ the same `build/service` tree — +`hackathon-e2e/scripts/prod-frontend.sh` on :8081 and +`cloudflare-tunnel/scripts/prod-serve.sh` on :8082 — so they do not merely race +to build it, they race to replace it while the other is serving it. Symptoms +(three agents hit this in one day): `Unexpected end of JSON input`, then a +missing `build/service/server/index.js` at boot. + +Both go through `.claude/skills/lib/frontend-build.sh`: + +```bash +bash .claude/skills/lib/frontend-build.sh if-stale # build only if src/ moved +bash .claude/skills/lib/frontend-build.sh build # unconditional +bash .claude/skills/lib/frontend-build.sh stale # exit 0 when a build is due +``` + +It closes two different holes. An exclusive `flock` stops two builds +interleaving, and re-checks staleness **inside** the lock, so the second caller +waits and then finds the first one's fresh output — checking staleness outside +the lock is how both callers decide to build. And it builds into a temp dir and +swaps atomically, so `build/service` only ever holds a complete tree; the lock +cannot help there, because an interrupted build's writer is gone rather than +concurrent, and what it had written so far stays behind looking like a build. + +⚠ **A directory rename on the 9p bind mount intermittently answers `EPERM`** +(`mv: cannot move '…/build/service' to '…/build/.service-old-352884': Permission denied`), +with no open descriptors involved — the same rename succeeded a minute later +with the same servers running. The swap therefore retries and rolls the old tree +back if the second rename fails, so `build/service` is never left missing. +Anything else here that renames a directory on this mount needs the same +treatment. + ## Adding sidecar services The app itself (backend, frontend, Keycloak, Postgres) runs in-container via @@ -527,3 +747,70 @@ Two rules, both learned the hard way (the `rustfs` service follows them): Pin the image tag (never `latest`) and put persistent state in a named volume, not the workspace bind mount. + +### Before you recreate `dev`: find what is only in the writable layer + +Anything apt-installed at runtime dies with the container, and it dies silently +— the first symptom is a suite failing on a missing shared library some days +later. That is how Firefox's system libraries were lost once, and a check before +the 2026-08-13 recreate found **21 more Playwright packages** in the same +position (xvfb, libavcodec60, six font packages, and the usual X/cairo/pango +set). All of them are baked into `Dockerfile` now. + +The recovery procedure is recorded in `Dockerfile`'s own comment above the +browser-deps block, and it is the thing to re-run after any Playwright bump or +before any deliberate recreate: install into a container, then **diff +`apt-mark showmanual` against the same list in a fresh container of this +image**. Anything the diff names is living in the writable layer. Add it to the +Dockerfile before recreating, not after. + +When you do recreate, `up -d --no-deps dev` keeps compose from touching `caddy` +and the tunnel alongside it. Afterwards, restart the stack +(`hackathon-e2e/scripts/up.sh` + `wait-ready.sh`) before anything else. + +## Two artefacts you would not guess were there + +Neither is needed to build or run the app — nothing outside `.claude/` +references them — but both answer questions people ask about this repo, so they +are worth knowing about before someone re-derives them by hand. + +**Mutation testing** (`.claude/skills/hackathon-e2e/mutations/`) turns "would +this test go red?" into something that runs. `manifest.jsonl` is a list of +deliberate, reversible breakages, each paired with the exact set of tests that +must notice; the runner applies one, runs them, and asserts exactly that set +failed. `NO REDS` **fails** the run — it means nothing in the suite holds the +property. Only an exact match passes. + +```bash +bash .claude/skills/devcontainer-up/scripts/mutate.sh check # anchors still match source +bash .claude/skills/devcontainer-up/scripts/mutate.sh list +bash .claude/skills/devcontainer-up/scripts/mutate.sh run # fast tier: go + vitest +bash .claude/skills/devcontainer-up/scripts/mutate.sh restore # after a run was killed +``` + +The fast tier drives `go test` and `vitest` straight from source and needs the +container but **no running stack**. `check` is cheap enough for every commit and +is the one to run after touching backend or frontend source: it verifies each +mutation's anchor still matches its file, and an anchor that has drifted is the +same disease as a test that has stopped asserting. Run here on 2026-08-14: +`all 38 mutations still anchor`. + +**The quality report** (`.claude/skills/hackathon-e2e/quality-report.html`) is a +single self-contained page: what is tested, how well, what is not, what is +known-broken. Nothing in it is hand-typed — every figure is read from a file at +build time and then read back out of the finished HTML and re-derived by a +second code path before the build is allowed to succeed. Rebuild it after +changing the recipe, the manifest or a run report: + +```bash +bash .claude/skills/devcontainer-up/scripts/exec.sh \ + just develop node .claude/skills/hackathon-e2e/scripts/build-quality-report.mjs +``` + +(`node` is not on the login shell's `PATH` — it comes from the dev shell, or +from `.devenv/profile/bin` if you would rather not enter one.) The build prints +every claim it re-derived and refuses to write a page it could not verify; a run +on 2026-08-14 reported `205 figures re-derived … and matched`. Its animated +sibling, `recipe-player.html`, is rebuilt separately — same shell, same +directory, with `node .claude/skills/hackathon-e2e/scripts/splice-player.mjs` — +and that rebuild is required rather than cosmetic after any recipe edit. diff --git a/.gitignore b/.gitignore index 720c1b89..6f220234 100644 --- a/.gitignore +++ b/.gitignore @@ -44,8 +44,14 @@ result # All .env files .env .env.yaml +# Helm dev values with passwords +helm-chart/values.dev.yaml ## ============================================================================ +# Helm subchart archives, fetched by `helm dependency build` from Chart.lock. +# Not a secret, just generated — the lock file is what belongs in a commit. +helm-chart/charts/ + # Generated files components/backend/internal/proto components/backend/ent/* diff --git a/components/backend/internal/middleware/auth_test.go b/components/backend/internal/middleware/auth_test.go index f403f820..dd0d7127 100644 --- a/components/backend/internal/middleware/auth_test.go +++ b/components/backend/internal/middleware/auth_test.go @@ -10,7 +10,9 @@ import ( "github.com/golang-jwt/jwt/v5" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" + "google.golang.org/grpc/codes" "google.golang.org/grpc/metadata" + "google.golang.org/grpc/status" "github.com/swissdatasciencecenter/hackagon/components/backend/internal/config" "github.com/swissdatasciencecenter/hackagon/components/backend/internal/middleware" @@ -180,6 +182,44 @@ var _ = Describe("Auth Middleware", func() { }) }) + // RequireSubject admits the anonymous subject on purpose — casbin evaluates + // it as an unprivileged caller, which is what makes public reads work + // without a token. RequireUser is the variant for endpoints that treat the + // subject as an IDENTITY, and the whole of it is one rejection. + // + // Nothing else can stand in for it: a handler that lost this check answers + // whatever its next step answers — NotFound for an id that does not exist, + // which lets an unauthenticated caller probe which ids do. + Describe("RequireUser Function", func() { + It("refuses the anonymous subject", func() { + ctx := middleware.CtxWithClaims(middleware.AnonSubject) + + sub, claims, err := middleware.RequireUser(ctx) + Expect(err).To(HaveOccurred()) + Expect(status.Code(err)).To(Equal(codes.Unauthenticated)) + Expect(sub).To(BeEmpty(), "an admitted subject is the bug this guards") + Expect(claims).To(BeNil()) + }) + + // The control: the same call on a real subject has to get through, or + // the rejection above would agree with a function that refused everyone. + It("passes a real subject through with its claims", func() { + ctx := middleware.CtxWithClaims("keycloak-alice") + + sub, claims, err := middleware.RequireUser(ctx) + Expect(err).NotTo(HaveOccurred()) + Expect(sub).To(Equal("keycloak-alice")) + Expect(claims["sub"]).To(Equal("keycloak-alice")) + }) + + It("refuses a context with no claims at all", func() { + sub, _, err := middleware.RequireUser(context.Background()) + Expect(err).To(HaveOccurred()) + Expect(status.Code(err)).To(Equal(codes.Unauthenticated)) + Expect(sub).To(BeEmpty()) + }) + }) + Describe("GetSubject Function", func() { It("returns subject when available", func() { ctx := middleware.CtxWithClaims("test-user-subject") diff --git a/components/backend/internal/service/config_service_test.go b/components/backend/internal/service/config_service_test.go new file mode 100644 index 00000000..652bede0 --- /dev/null +++ b/components/backend/internal/service/config_service_test.go @@ -0,0 +1,192 @@ +//go:build test && unittest + +package service_test + +import ( + "context" + "time" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + "google.golang.org/grpc" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/metadata" + "google.golang.org/grpc/status" + "google.golang.org/protobuf/types/known/timestamppb" + + "github.com/google/uuid" + + ent "github.com/swissdatasciencecenter/hackagon/components/backend/ent" + enthackathon "github.com/swissdatasciencecenter/hackagon/components/backend/ent/hackathon" + enthackathonwindows "github.com/swissdatasciencecenter/hackagon/components/backend/ent/hackathonwindows" + entparticipant "github.com/swissdatasciencecenter/hackagon/components/backend/ent/participant" + hackathonSvc "github.com/swissdatasciencecenter/hackagon/components/backend/internal/proto/hackathon" + "github.com/swissdatasciencecenter/hackagon/components/backend/internal/proto/hackathon/entities" + cfgMsgs "github.com/swissdatasciencecenter/hackagon/components/backend/internal/proto/hackathon/messages/config_svc" + msgs "github.com/swissdatasciencecenter/hackagon/components/backend/internal/proto/hackathon/messages/hackathon_svc" + "github.com/swissdatasciencecenter/hackagon/components/backend/internal/testutils" +) + +// Window enforcement — requireWindowOpen in config_service.go, seen through the +// RPC that consults it. +// +// Driven through Join rather than by calling the unexported function, because +// the property is "the acting RPC refuses", and the two halves of a window plus +// its override are the same closure for every kind: registration exercises +// `closedAfter` (shared with proposals, preferences and submissions) and the +// opens-before branch that only registration has. +// +// NO CLOCK CONTROL AND NO SLEEP. Every instant here is written relative to +// time.Now() at the moment the window is stored — a deadline an hour in the past +// is closed however slowly the suite runs, and an override thirty minutes out +// cannot expire mid-spec. A test that sleeps to cross a boundary is a test that +// flakes on a loaded machine. +var _ = Describe("Window enforcement", func() { + var ( + dbClient *ent.Client + conn *grpc.ClientConn + hackClient hackathonSvc.HackathonServiceClient + cfgClient hackathonSvc.ConfigServiceClient + adminCtx context.Context + joinerCtx context.Context + joiner *ent.User + hackathonID string + ) + + authed := func(keycloakID string) context.Context { + return metadata.NewOutgoingContext( + context.Background(), + metadata.Pairs( + "authorization", + "Bearer "+testutils.CreateTestJWTToken(keycloakID), + ), + ) + } + + BeforeEach(func() { + dbClient, conn, _ = testutils.CreateTestServer() + hackClient = hackathonSvc.NewHackathonServiceClient(conn) + cfgClient = hackathonSvc.NewConfigServiceClient(conn) + adminCtx = authed(testutils.TestAdminKeycloakID) + + now := time.Now() + created, err := hackClient.Create(adminCtx, &msgs.CreateRequest{ + Name: "Windowed Hackathon", + Visibility: entities.Visibility_VISIBILITY_PUBLIC, + StartsAt: timestamppb.New(now.Add(24 * time.Hour)), + EndsAt: timestamppb.New(now.Add(48 * time.Hour)), + }) + Expect(err).NotTo(HaveOccurred()) + hackathonID = created.GetHackathonId() + + joiner, err = dbClient.User.Create(). + SetKeycloakID("window-joiner"). + SetUsername("window-joiner"). + Save(context.Background()) + Expect(err).NotTo(HaveOccurred()) + joinerCtx = authed("window-joiner") + }) + + join := func() error { + _, err := hackClient.Join(joinerCtx, &msgs.JoinRequest{HackathonId: hackathonID}) + + return err + } + + // onRoster reads the END STATE back. "The call was refused" and "no row was + // written" are different claims, and the second is the one a participant + // would notice. + onRoster := func() int { + n, err := dbClient.Participant.Query().Where( + entparticipant.HackathonIDEQ(uuid.MustParse(hackathonID)), + entparticipant.UserIDEQ(joiner.ID), + ).Count(context.Background()) + Expect(err).NotTo(HaveOccurred()) + + return n + } + + setWindows := func(req *cfgMsgs.SetWindowsRequest) { + GinkgoHelper() + req.HackathonId = hackathonID + _, err := cfgClient.SetWindows(adminCtx, req) + Expect(err).NotTo(HaveOccurred()) + } + + // The control. Every refusal below is only evidence about the WINDOW if the + // same join lands when no window is configured — otherwise the whole + // Describe would agree just as loudly with a Join that refused everybody. + It("lets a join through when no window is configured", func() { + Expect(join()).To(Succeed()) + Expect(onRoster()).To(Equal(1)) + }) + + It("refuses a join once the registration window has closed", func() { + setWindows(&cfgMsgs.SetWindowsRequest{ + RegistrationCloses: timestamppb.New(time.Now().Add(-1 * time.Hour)), + }) + + err := join() + Expect(err).To(HaveOccurred()) + Expect(status.Convert(err).Code()).To(Equal(codes.FailedPrecondition)) + Expect(status.Convert(err).Message()).To(ContainSubstring("registration is closed")) + Expect(onRoster()).To(Equal(0), "a refused join must write no participant row") + }) + + It("refuses a join before the registration window opens", func() { + setWindows(&cfgMsgs.SetWindowsRequest{ + RegistrationOpens: timestamppb.New(time.Now().Add(1 * time.Hour)), + }) + + err := join() + Expect(err).To(HaveOccurred()) + Expect(status.Convert(err).Code()).To(Equal(codes.FailedPrecondition)) + Expect(status.Convert(err).Message()).To(ContainSubstring("not open yet")) + Expect(onRoster()).To(Equal(0), "a refused join must write no participant row") + }) + + It("reopens a closed window for the length of a now-anchored override", func() { + setWindows(&cfgMsgs.SetWindowsRequest{ + RegistrationCloses: timestamppb.New(time.Now().Add(-1 * time.Hour)), + }) + + // Anchored at NOW, not at the configured close — which is what makes a + // late signup a support decision ("let them in for the next 30 minutes") + // rather than an argument about how long ago the deadline was. + _, err := cfgClient.OverrideWindow(adminCtx, &cfgMsgs.OverrideWindowRequest{ + HackathonId: hackathonID, + Window: "registration", + ExtendMinutes: 30, + Reason: "walk-in at the desk", + }) + Expect(err).NotTo(HaveOccurred()) + + Expect(join()).To(Succeed()) + Expect(onRoster()).To(Equal(1)) + }) + + It("stops honouring an override once it has expired", func() { + setWindows(&cfgMsgs.SetWindowsRequest{ + RegistrationCloses: timestamppb.New(time.Now().Add(-1 * time.Hour)), + }) + + // An override that has RUN OUT, written straight to the row. The RPC + // cannot produce this state — protovalidate holds extend_minutes to + // 1..1440, so an override is always granted into the future — and the + // only other way to reach it is to wait for one to expire, which is a + // sleep, and a test that sleeps to cross a boundary flakes. This is what + // the row of a 30-minute grace window looks like an hour later. + _, err := dbClient.HackathonWindows.Update(). + Where(enthackathonwindows.HasHackathonWith( + enthackathon.IDEQ(uuid.MustParse(hackathonID)), + )). + SetRegistrationOverrideUntil(time.Now().Add(-30 * time.Minute)). + Save(context.Background()) + Expect(err).NotTo(HaveOccurred()) + + err = join() + Expect(err).To(HaveOccurred()) + Expect(status.Convert(err).Code()).To(Equal(codes.FailedPrecondition)) + Expect(onRoster()).To(Equal(0)) + }) +}) diff --git a/components/backend/internal/service/hackathon_join_test.go b/components/backend/internal/service/hackathon_join_test.go new file mode 100644 index 00000000..72aa34a8 --- /dev/null +++ b/components/backend/internal/service/hackathon_join_test.go @@ -0,0 +1,203 @@ +//go:build test && unittest + +package service_test + +import ( + "context" + "time" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + "google.golang.org/grpc" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/metadata" + "google.golang.org/grpc/status" + "google.golang.org/protobuf/types/known/timestamppb" + + "github.com/google/uuid" + + ent "github.com/swissdatasciencecenter/hackagon/components/backend/ent" + entparticipant "github.com/swissdatasciencecenter/hackagon/components/backend/ent/participant" + "github.com/swissdatasciencecenter/hackagon/components/backend/internal/middleware" + hackathonSvc "github.com/swissdatasciencecenter/hackagon/components/backend/internal/proto/hackathon" + "github.com/swissdatasciencecenter/hackagon/components/backend/internal/proto/hackathon/entities" + msgs "github.com/swissdatasciencecenter/hackagon/components/backend/internal/proto/hackathon/messages/hackathon_svc" + "github.com/swissdatasciencecenter/hackagon/components/backend/internal/testutils" +) + +// The three guards Join applies before it writes a row, and the role it writes +// afterwards. +// +// None of these is visible in the response: Join answers the same shape for a +// member and for an organizer, so the ROLE it granted has to be read back out +// of casbin, and a refusal has to be read back out of the roster. +var _ = Describe("HackathonService Join guards", func() { + var ( + dbClient *ent.Client + conn *grpc.ClientConn + enf *middleware.Enforcer + client hackathonSvc.HackathonServiceClient + adminCtx context.Context + joinerCtx context.Context + joiner *ent.User + ) + + authed := func(keycloakID string) context.Context { + return metadata.NewOutgoingContext( + context.Background(), + metadata.Pairs( + "authorization", + "Bearer "+testutils.CreateTestJWTToken(keycloakID), + ), + ) + } + + // create makes a hackathon with the given visibility and lifespan, offset + // from now so nothing here depends on the wall clock beyond "past" and + // "future". + create := func(name string, vis entities.Visibility, startsIn, endsIn time.Duration) string { + GinkgoHelper() + now := time.Now() + resp, err := client.Create(adminCtx, &msgs.CreateRequest{ + Name: name, + Visibility: vis, + StartsAt: timestamppb.New(now.Add(startsIn)), + EndsAt: timestamppb.New(now.Add(endsIn)), + }) + Expect(err).NotTo(HaveOccurred()) + + return resp.GetHackathonId() + } + + onRoster := func(hackathonID string) int { + GinkgoHelper() + n, err := dbClient.Participant.Query().Where( + entparticipant.HackathonIDEQ(uuid.MustParse(hackathonID)), + entparticipant.UserIDEQ(joiner.ID), + ).Count(context.Background()) + Expect(err).NotTo(HaveOccurred()) + + return n + } + + BeforeEach(func() { + var err error + dbClient, conn, enf = testutils.CreateTestServer() + client = hackathonSvc.NewHackathonServiceClient(conn) + adminCtx = authed(testutils.TestAdminKeycloakID) + + joiner, err = dbClient.User.Create(). + SetKeycloakID("join-guard-user"). + SetUsername("join-guard-user"). + Save(context.Background()) + Expect(err).NotTo(HaveOccurred()) + joinerCtx = authed("join-guard-user") + }) + + Describe("a private hackathon", func() { + var hackathonID string + + BeforeEach(func() { + hackathonID = create( + "Invite Only", entities.Visibility_VISIBILITY_PRIVATE, + 24*time.Hour, 48*time.Hour, + ) + }) + + It("refuses a join that carries no invitation", func() { + // Privacy was discovery-only before this check: knowing the UUID was + // enough to join. The id is exactly what this caller has. + _, err := client.Join(joinerCtx, &msgs.JoinRequest{HackathonId: hackathonID}) + Expect(err).To(HaveOccurred()) + Expect(status.Convert(err).Code()).To(Equal(codes.PermissionDenied)) + Expect(status.Convert(err).Message()).To(ContainSubstring("invitation link")) + Expect(onRoster(hackathonID)).To(Equal(0)) + }) + + It("refuses a join that carries an invitation to somewhere else", func() { + other := create( + "Some Other Event", entities.Visibility_VISIBILITY_PRIVATE, + 24*time.Hour, 48*time.Hour, + ) + inv, err := client.CreateInvite(adminCtx, &msgs.CreateInviteRequest{ + HackathonId: other, + }) + Expect(err).NotTo(HaveOccurred()) + + token := inv.GetInvite().GetToken() + _, err = client.Join(joinerCtx, &msgs.JoinRequest{ + HackathonId: hackathonID, + InviteToken: &token, + }) + Expect(err).To(HaveOccurred()) + Expect(status.Convert(err).Code()).To(Equal(codes.PermissionDenied)) + Expect(onRoster(hackathonID)).To(Equal(0)) + }) + + // The control for both refusals above. Without it they would both pass + // against a Join that refused every private event outright, which is a + // different product. + It("admits the same join once it carries a live invitation", func() { + inv, err := client.CreateInvite(adminCtx, &msgs.CreateInviteRequest{ + HackathonId: hackathonID, + }) + Expect(err).NotTo(HaveOccurred()) + + token := inv.GetInvite().GetToken() + _, err = client.Join(joinerCtx, &msgs.JoinRequest{ + HackathonId: hackathonID, + InviteToken: &token, + }) + Expect(err).NotTo(HaveOccurred()) + Expect(onRoster(hackathonID)).To(Equal(1)) + }) + }) + + It("refuses a join once the event has finished", func() { + finished := create( + "Last Year's Hackathon", entities.Visibility_VISIBILITY_PUBLIC, + -48*time.Hour, -24*time.Hour, + ) + + _, err := client.Join(joinerCtx, &msgs.JoinRequest{HackathonId: finished}) + Expect(err).To(HaveOccurred()) + Expect(status.Convert(err).Code()).To(Equal(codes.FailedPrecondition)) + Expect(status.Convert(err).Message()).To(ContainSubstring("already finished")) + Expect(onRoster(finished)).To(Equal(0)) + }) + + It("still admits a join into an event that has not ended", func() { + running := create( + "Happening Now", entities.Visibility_VISIBILITY_PUBLIC, + -1*time.Hour, 24*time.Hour, + ) + + _, err := client.Join(joinerCtx, &msgs.JoinRequest{HackathonId: running}) + Expect(err).NotTo(HaveOccurred()) + Expect(onRoster(running)).To(Equal(1)) + }) + + It("grants the joiner Member and nothing more", func() { + open := create( + "Open Event", entities.Visibility_VISIBILITY_PUBLIC, + 24*time.Hour, 48*time.Hour, + ) + + _, err := client.Join(joinerCtx, &msgs.JoinRequest{HackathonId: open}) + Expect(err).NotTo(HaveOccurred()) + + // Member is granted to everyone on the roster, waitlisted included — + // that is what lets them propose and see the event they signed up for. + // The role is read from casbin because the response cannot show it. + role, err := enf.GetHackathonRole(joiner.KeycloakID, open) + Expect(err).NotTo(HaveOccurred()) + Expect(role).To(Equal(entities.HackathonRole_HACKATHON_ROLE_MEMBER)) + + holders, err := enf.HackathonOwners(open) + Expect(err).NotTo(HaveOccurred()) + Expect(holders).NotTo( + ContainElement(joiner.KeycloakID), + "signing up must not make somebody an organizer of the event", + ) + }) +}) diff --git a/components/backend/internal/service/hackathon_owner_test.go b/components/backend/internal/service/hackathon_owner_test.go new file mode 100644 index 00000000..1c41c567 --- /dev/null +++ b/components/backend/internal/service/hackathon_owner_test.go @@ -0,0 +1,211 @@ +//go:build test && unittest + +package service_test + +import ( + "context" + "time" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + "google.golang.org/grpc" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/metadata" + "google.golang.org/grpc/status" + "google.golang.org/protobuf/types/known/timestamppb" + + "github.com/google/uuid" + + ent "github.com/swissdatasciencecenter/hackagon/components/backend/ent" + entuser "github.com/swissdatasciencecenter/hackagon/components/backend/ent/user" + "github.com/swissdatasciencecenter/hackagon/components/backend/internal/middleware" + hackathonSvc "github.com/swissdatasciencecenter/hackagon/components/backend/internal/proto/hackathon" + "github.com/swissdatasciencecenter/hackagon/components/backend/internal/proto/hackathon/entities" + msgs "github.com/swissdatasciencecenter/hackagon/components/backend/internal/proto/hackathon/messages/hackathon_svc" + "github.com/swissdatasciencecenter/hackagon/components/backend/internal/testutils" +) + +// AddOwner / RemoveOwner. +// +// OWNERSHIP IS A CASBIN FACT ON THIS BRANCH — there is no owners column to +// count — so every assertion here goes through the enforcer the server itself +// holds (testutils.CreateTestServer hands back that instance, not a copy). +// Asserting on the gRPC response alone would be the container-holds-the-thing +// trap: RemoveOwner answers with an empty message, so "it returned OK" says +// nothing about who ends up holding what. +// +// Nothing here opens an ent transaction: casbin writes on its own connection, +// and a transaction held across one deadlocks. +var _ = Describe("HackathonService owner roles", func() { + var ( + dbClient *ent.Client + conn *grpc.ClientConn + enf *middleware.Enforcer + client hackathonSvc.HackathonServiceClient + adminCtx context.Context + admin string + hackathonID string + bob *ent.User + ) + + authed := func(keycloakID string) context.Context { + return metadata.NewOutgoingContext( + context.Background(), + metadata.Pairs( + "authorization", + "Bearer "+testutils.CreateTestJWTToken(keycloakID), + ), + ) + } + + owners := func() []string { + GinkgoHelper() + list, err := enf.HackathonOwners(hackathonID) + Expect(err).NotTo(HaveOccurred()) + + return list + } + + roleOf := func(keycloakID string) entities.HackathonRole { + GinkgoHelper() + role, err := enf.GetHackathonRole(keycloakID, hackathonID) + Expect(err).NotTo(HaveOccurred()) + + return role + } + + // The owner RPCs address people by platform UUID while casbin holds Keycloak + // ids, and mixing the two silently addresses nobody. + platformID := func(keycloakID string) string { + GinkgoHelper() + u, err := dbClient.User.Query(). + Where(entuser.KeycloakIDEQ(keycloakID)). + Only(context.Background()) + Expect(err).NotTo(HaveOccurred()) + + return u.ID.String() + } + + BeforeEach(func() { + dbClient, conn, enf = testutils.CreateTestServer() + client = hackathonSvc.NewHackathonServiceClient(conn) + admin = testutils.TestAdminKeycloakID + adminCtx = authed(admin) + + now := time.Now() + created, err := client.Create(adminCtx, &msgs.CreateRequest{ + Name: "Owned Hackathon", + Visibility: entities.Visibility_VISIBILITY_PUBLIC, + StartsAt: timestamppb.New(now.Add(24 * time.Hour)), + EndsAt: timestamppb.New(now.Add(48 * time.Hour)), + }) + Expect(err).NotTo(HaveOccurred()) + hackathonID = created.GetHackathonId() + + // bob is a CONFIRMED participant and nothing more. The row is written + // directly rather than through Join on purpose: Join grants Member, and + // a bob who already held Member could not show that RemoveOwner is what + // gives it back to him. + bob, err = dbClient.User.Create(). + SetKeycloakID("owner-test-bob"). + SetUsername("owner-test-bob"). + Save(context.Background()) + Expect(err).NotTo(HaveOccurred()) + _, err = dbClient.Participant.Create(). + SetHackathonID(uuid.MustParse(hackathonID)). + SetUserID(bob.ID). + SetIsWaiting(false). + Save(context.Background()) + Expect(err).NotTo(HaveOccurred()) + }) + + // Creating the event makes its creator the sole owner. Stated here because + // every spec below is a statement about moving away from that. + It("makes the creator the only owner", func() { + Expect(owners()).To(ConsistOf(admin)) + Expect(roleOf(admin)).To(Equal(entities.HackathonRole_HACKATHON_ROLE_OWNER)) + }) + + Describe("RemoveOwner", func() { + It("refuses to demote the last organizer", func() { + // A SECOND global admin does the asking, so the refusal cannot come + // from the self-demotion rule: this is the last-organizer guard on + // its own, with a caller who is allowed to do everything else. + _, err := enf.AddGlobalRole("owner-test-superadmin", middleware.Admin) + Expect(err).NotTo(HaveOccurred()) + + _, err = client.RemoveOwner( + authed("owner-test-superadmin"), + &msgs.RemoveOwnerRequest{HackathonId: hackathonID, UserId: platformID(admin)}, + ) + Expect(err).To(HaveOccurred()) + Expect(status.Convert(err).Code()).To(Equal(codes.FailedPrecondition)) + Expect(status.Convert(err).Message()).To(ContainSubstring("last organizer")) + + Expect(owners()).To( + ConsistOf(admin), + "an event left with zero owners can be edited by nobody short of a global admin", + ) + }) + + It( + "refuses an organizer demoting themselves, even with a co-organizer to fall back on", + func() { + // The co-organizer matters: with one owner this would be refused by + // the last-organizer guard, which runs first, and the spec would + // pass with the self-demotion rule deleted. + _, err := client.AddOwner( + adminCtx, + &msgs.AddOwnerRequest{HackathonId: hackathonID, UserId: bob.ID.String()}, + ) + Expect(err).NotTo(HaveOccurred()) + Expect(owners()).To(ConsistOf(admin, bob.KeycloakID)) + + _, err = client.RemoveOwner( + adminCtx, + &msgs.RemoveOwnerRequest{HackathonId: hackathonID, UserId: platformID(admin)}, + ) + Expect(err).To(HaveOccurred()) + Expect(status.Convert(err).Code()).To(Equal(codes.PermissionDenied)) + Expect( + status.Convert(err).Message(), + ).To(ContainSubstring("your own organizer role")) + + Expect(owners()).To(ContainElement(admin)) + }, + ) + + It("gives a demoted co-organizer Member back", func() { + _, err := client.AddOwner( + adminCtx, + &msgs.AddOwnerRequest{HackathonId: hackathonID, UserId: bob.ID.String()}, + ) + Expect(err).NotTo(HaveOccurred()) + Expect(roleOf(bob.KeycloakID)). + To(Equal(entities.HackathonRole_HACKATHON_ROLE_OWNER)) + + _, err = client.RemoveOwner( + adminCtx, + &msgs.RemoveOwnerRequest{HackathonId: hackathonID, UserId: bob.ID.String()}, + ) + Expect(err).NotTo(HaveOccurred()) + + Expect(owners()).To(ConsistOf(admin)) + // Not merely "no longer an owner": without the Member grant the role + // resolves to UNSPECIFIED, which renders as a corrupted record + // rather than a demotion. + Expect(roleOf(bob.KeycloakID)). + To(Equal(entities.HackathonRole_HACKATHON_ROLE_MEMBER)) + }) + + It("refuses to demote a participant who is not an organizer", func() { + _, err := client.RemoveOwner( + adminCtx, + &msgs.RemoveOwnerRequest{HackathonId: hackathonID, UserId: bob.ID.String()}, + ) + Expect(err).To(HaveOccurred()) + Expect(status.Convert(err).Code()).To(Equal(codes.NotFound)) + Expect(owners()).To(ConsistOf(admin)) + }) + }) +}) diff --git a/components/backend/internal/service/hackathon_service.go b/components/backend/internal/service/hackathon_service.go index 4f541d86..808b3eed 100644 --- a/components/backend/internal/service/hackathon_service.go +++ b/components/backend/internal/service/hackathon_service.go @@ -1215,6 +1215,26 @@ func (s *HackathonService) SetCapabilities( wanted[entCapability] = t.GetEnabled() } + // The hackathon itself, not its capabilities: the batch below CREATES a row + // for anything ungoverned, and a create against an id that names nothing is + // a foreign-key error rather than an answer. Asked once, here, so a caller + // naming a hackathon that does not exist still gets NotFound about the + // HACKATHON — which is the true statement — instead of NotFound about a + // capability, which used to be the same reply and said the wrong thing. + // + // After RequirePermission on purpose: a stranger must not learn which + // hackathon ids exist from the difference between PermissionDenied and + // NotFound. + exists, err := s.dbClient.Hackathon.Query().Where(enthackathon.IDEQ(id)).Exist(ctx) + if err != nil { + slog.Error("query hackathon", "err", err) + + return nil, status.Error(codes.Internal, "couldn't query database") + } + if !exists { + return nil, status.Errorf(codes.NotFound, "hackathon %s not found", id) + } + rows, err := s.dbClient.Capability.Query(). Where(entcapability.HasHackathonWith(enthackathon.IDEQ(id))). All(ctx) @@ -1228,11 +1248,6 @@ func (s *HackathonService) SetCapabilities( for _, row := range rows { present[row.Capability] = row } - for c := range wanted { - if _, ok := present[c]; !ok { - return nil, status.Errorf(codes.NotFound, "hackathon %s has no %s capability", id, c) - } - } txn, err := s.dbClient.Tx(ctx) if err != nil { @@ -1247,7 +1262,59 @@ func (s *HackathonService) SetCapabilities( } for c, enabled := range wanted { - row := present[c] + row, governed := present[c] + + // An ungoverned capability is CREATED rather than refused. + // + // This used to answer NotFound for the whole batch, and the organiser's + // panel posts all six switches every save — so one absent row made the + // entire capability screen unusable, with a 404 as the only explanation + // and no RPC anywhere that could create the missing row. The panel had + // grown a paragraph of copy warning about it, which is a product + // explaining its own data gap to the person least able to close it. + // + // Of the three possible answers, creating is the only one that is both + // safe and true to what the request says. SKIPPING is the dangerous one: + // an ungoverned capability is ALLOWED (`capability.State` reports + // UNGOVERNED and `Allowed` returns true for it), so quietly dropping a + // row the caller asked to set to `false` would report a save that + // changed nothing while participants kept the permission — a silent + // no-op on a gate. REFUSING with the capability named is honest but + // still leaves the panel dead, because there is no way to act on the + // name. And the schema already calls a full set the invariant — "one row + // per capability per hackathon, pre-created on hackathon creation" — so + // a missing row is a gap (an older event, a partial restore, a + // capability added to the enum after the event was made), never a + // decision anyone took. `SetCapabilities` takes a whole list rather than + // a delta, which means "these are the values afterwards"; creating what + // is missing is what makes that sentence true. + if !governed { + if _, err := txn.Capability.Create(). + SetCapability(c). + SetEnabled(enabled). + SetHackathonID(id). + SetModifier(user). + Save(ctx); err != nil { + rollback(err) + // The unique index is `(capability, hackathon)`, so this is a + // concurrent writer that governed it first — a real outcome, and + // a different one from a broken request. Retrying takes the + // UpdateOne branch. + if ent.IsConstraintError(err) { + return nil, status.Errorf( + codes.Aborted, + "another change to %s landed first; retry", + c, + ) + } + slog.Error("create capability", "err", err) + + return nil, status.Error(codes.Internal, "couldn't update capabilities") + } + + continue + } + // Already correct: skipping the write keeps modified_at and the modifier // meaningful, so "who last changed this" stays a real answer. if row.Enabled == enabled { @@ -1319,14 +1386,17 @@ func (s *HackathonService) capabilityStatuses( return nil } - clock := newCapabilityClock(order, hack.CurrentPhaseID) - now := time.Now() - out := make([]*ents.CapabilityStatus, 0, len(rows)) - for _, row := range rows { - out = append(out, capabilityStatusFromEnt(row, clock, now)) - } - - return out + // `capabilityStatusesFromEnt`, the same mapper Get uses, rather than one + // status per stored row: it fills the vocabulary, reporting UNGOVERNED for a + // capability with no row. Built from the rows alone this reply was SHORT + // wherever Get's was six long — one handler giving two answers to "what are + // this hackathon's capabilities", and the shorter one is the reply a client + // gets immediately after saving. + return capabilityStatusesFromEnt( + rows, + newCapabilityClock(order, hack.CurrentPhaseID), + time.Now(), + ) } // AdvancePhase declares which phase a hackathon is now in, and switches its diff --git a/components/backend/internal/service/hackathon_service_test.go b/components/backend/internal/service/hackathon_service_test.go index 7f9f36f9..44ab5784 100644 --- a/components/backend/internal/service/hackathon_service_test.go +++ b/components/backend/internal/service/hackathon_service_test.go @@ -1426,6 +1426,189 @@ var _ = Describe("HackathonService", func() { Expect(status.Code(err)).To(BeElementOf(codes.NotFound, codes.PermissionDenied)) }) + // SetCapabilities used to answer NotFound if ANY capability in the batch + // had no stored row, and the organiser's panel posts all six switches on + // every save — so one absent row made the whole capability screen + // unusable, with a 404 as its only explanation. + Describe("SetCapabilities with an ungoverned capability", func() { + // The six the panel sends, every save. Written out rather than + // derived so a capability added to the enum shows up here as a + // compile-time gap rather than as a batch that quietly got smaller. + wholeForm := func(vote bool) []*entities.CapabilityToggle { + return []*entities.CapabilityToggle{ + {Capability: entities.Capability_CAPABILITY_REGISTER, Enabled: false}, + { + Capability: entities.Capability_CAPABILITY_PROPOSE_PROJECTS, + Enabled: true, + }, + { + Capability: entities.Capability_CAPABILITY_SET_TEAM_PREFERENCES, + Enabled: true, + }, + { + Capability: entities.Capability_CAPABILITY_CREATE_PROJECT_SUBMISSIONS, + Enabled: true, + }, + {Capability: entities.Capability_CAPABILITY_VOTE, Enabled: vote}, + { + Capability: entities.Capability_CAPABILITY_VIEW_RESULTS, + Enabled: true, + }, + } + } + + // stateOf reads one capability's state out of Get, which reports the + // whole vocabulary — including UNGOVERNED for a capability with no + // row, which is the state under test. + stateOf := func(c entities.Capability) entities.CapabilityState { + resp, err := client.Get(adminCtx, &msgs.GetRequest{HackathonId: hackathonID}) + Expect(err).NotTo(HaveOccurred()) + for _, s := range resp.GetHackathon().GetCapabilities() { + if s.GetCapability() == c { + return s.GetState() + } + } + Fail("capability missing from Get response") + + return entities.CapabilityState_CAPABILITY_STATE_UNSPECIFIED + } + + // Deleting the row is the ONLY way to reach this state, and that is + // the whole reason the bug survived: Create seeds all six and no RPC + // removes one, so nothing driven through the API is ever ungoverned. + // The gap is real off the API (an event older than a capability the + // enum gained later, a partial restore) and has to be made here. + BeforeEach(func() { + n, err := dbClient.Capability.Delete(). + Where( + entcapability.HasHackathonWith( + enthackathon.IDEQ(uuid.MustParse(hackathonID)), + ), + entcapability.CapabilityEQ(entcapability.CapabilityVote), + ). + Exec(context.Background()) + Expect(err).NotTo(HaveOccurred()) + Expect( + n, + ).To(Equal(1), "nothing was ungoverned — the specs below would prove nothing") + + // The positive control. Without it, every claim under this + // Describe would also hold against a governed row, i.e. against + // the setup silently failing. + Expect(stateOf(entities.Capability_CAPABILITY_VOTE)).To( + Equal(entities.CapabilityState_CAPABILITY_STATE_UNGOVERNED), + ) + }) + + It("creates the missing row instead of refusing the whole batch", func() { + _, err := client.SetCapabilities(adminCtx, &msgs.SetCapabilitiesRequest{ + HackathonId: hackathonID, + Capabilities: wholeForm(true), + }) + Expect(err).NotTo(HaveOccurred()) + + Expect(stateOf(entities.Capability_CAPABILITY_VOTE)).To( + Equal(entities.CapabilityState_CAPABILITY_STATE_OPEN), + ) + }) + + It("lands the rest of the batch, which the refusal used to lose", func() { + // The cost of the old behaviour was never the one row: it was the + // other five. `register: false` is the switch that decides whether + // anyone can still sign up, and it never reached the database. + _, err := client.SetCapabilities(adminCtx, &msgs.SetCapabilitiesRequest{ + HackathonId: hackathonID, + Capabilities: wholeForm(true), + }) + Expect(err).NotTo(HaveOccurred()) + + Expect(stateOf(entities.Capability_CAPABILITY_REGISTER)).To( + Equal(entities.CapabilityState_CAPABILITY_STATE_CLOSED), + ) + }) + + It("governs a capability asked to be OFF rather than leaving it allowed", func() { + // Why creating beats skipping, stated as the outcome rather than + // as a preference. UNGOVERNED is ALLOWED (`capability.State`'s + // Allowed returns true for it), so a handler that skipped the + // missing row would report a successful save while participants + // kept the permission the organiser had just switched off. + _, err := client.SetCapabilities(adminCtx, &msgs.SetCapabilitiesRequest{ + HackathonId: hackathonID, + Capabilities: wholeForm(false), + }) + Expect(err).NotTo(HaveOccurred()) + + Expect(stateOf(entities.Capability_CAPABILITY_VOTE)).To( + Equal(entities.CapabilityState_CAPABILITY_STATE_CLOSED), + "an ungoverned capability set to false must end up governed and "+ + "closed — UNGOVERNED here would mean the save was a no-op on a gate", + ) + }) + + It("attributes the row it creates to whoever saved the form", func() { + // A created row is a row like any other: "who last changed this" + // has to keep working across the gap, or the first save after a + // restore is the one edit with no author. + _, err := client.SetCapabilities(adminCtx, &msgs.SetCapabilitiesRequest{ + HackathonId: hackathonID, + Capabilities: wholeForm(true), + }) + Expect(err).NotTo(HaveOccurred()) + + row, err := dbClient.Capability.Query(). + Where( + entcapability.HasHackathonWith( + enthackathon.IDEQ(uuid.MustParse(hackathonID)), + ), + entcapability.CapabilityEQ(entcapability.CapabilityVote), + ). + WithModifier(). + Only(context.Background()) + Expect(err).NotTo(HaveOccurred()) + Expect(row.Edges.Modifier).NotTo(BeNil()) + Expect(row.Edges.Modifier.KeycloakID).To(Equal(testAdmin)) + }) + + It("still refuses a hackathon that does not exist, and says so", func() { + // The other half of removing the NotFound: an id that names + // nothing must not be answered by creating rows for it. It keeps + // NotFound — but now about the HACKATHON, which is the true + // statement; the old reply named a capability and was misleading + // about which thing was missing. + missing := uuid.New() + + _, err := client.SetCapabilities(adminCtx, &msgs.SetCapabilitiesRequest{ + HackathonId: missing.String(), + Capabilities: []*entities.CapabilityToggle{ + {Capability: entities.Capability_CAPABILITY_VOTE, Enabled: true}, + }, + }) + Expect(status.Code(err)).To(Equal(codes.NotFound)) + Expect(err.Error()).To(ContainSubstring("hackathon")) + + n, qErr := dbClient.Capability.Query(). + Where(entcapability.HasHackathonWith(enthackathon.IDEQ(missing))). + Count(context.Background()) + Expect(qErr).NotTo(HaveOccurred()) + Expect(n).To(Equal(0), "a refused call must not have written rows") + }) + + It("reports the created row in its own response, not only on the next Get", func() { + // `capabilityStatuses` says it "reports every capability the way + // Get does" and used to build from the stored rows alone, so a + // capability with no row was simply absent from this reply while + // Get reported it as UNGOVERNED. Two answers to the same question + // from one handler. + resp, err := client.SetCapabilities(adminCtx, &msgs.SetCapabilitiesRequest{ + HackathonId: hackathonID, + Capabilities: wholeForm(true), + }) + Expect(err).NotTo(HaveOccurred()) + Expect(resp.GetCapabilities()).To(HaveLen(6)) + }) + }) + Describe("phase schedule", func() { var adminUserID uuid.UUID diff --git a/components/backend/internal/service/require_user_test.go b/components/backend/internal/service/require_user_test.go new file mode 100644 index 00000000..4b04ae55 --- /dev/null +++ b/components/backend/internal/service/require_user_test.go @@ -0,0 +1,66 @@ +//go:build test && unittest + +package service_test + +import ( + "context" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + "google.golang.org/grpc" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" + + "github.com/google/uuid" + + hackathonSvc "github.com/swissdatasciencecenter/hackagon/components/backend/internal/proto/hackathon" + msgs "github.com/swissdatasciencecenter/hackagon/components/backend/internal/proto/hackathon/messages/team_svc" + "github.com/swissdatasciencecenter/hackagon/components/backend/internal/testutils" +) + +// The anonymous subject at the handler boundary. +// +// The auth interceptor injects `sub: "anonymous"` when there is no bearer +// token, so a call with no credentials reaches the handler like any other. +// TeamService's mutation handlers moved onto middleware.RequireUser for one +// reason: without it they parsed the request and looked the row up first, and +// answered NotFound — which tells an unauthenticated caller which submission +// and team ids exist. +// +// The ids below are therefore RANDOM AND ABSENT ON PURPOSE. "Unauthenticated +// rather than NotFound" is the whole claim, and it can only be made about a +// request whose id would genuinely miss. +var _ = Describe("TeamService with no credentials", func() { + var ( + conn *grpc.ClientConn + client hackathonSvc.TeamServiceClient + ) + + BeforeEach(func() { + _, conn, _ = testutils.CreateTestServer() + client = hackathonSvc.NewTeamServiceClient(conn) + }) + + // No metadata at all: this is what a caller with no session sends. + anon := func() context.Context { return context.Background() } + + It("tells an anonymous caller to sign in rather than whether a submission exists", func() { + _, err := client.EditSubmission(anon(), &msgs.EditSubmissionRequest{ + SubmissionId: uuid.NewString(), + }) + Expect(err).To(HaveOccurred()) + Expect(status.Convert(err).Code()).To( + Equal(codes.Unauthenticated), + "NotFound here is an oracle for which submission ids exist", + ) + }) + + It("tells an anonymous caller to sign in rather than whether a team exists", func() { + _, err := client.CreateSubmission(anon(), &msgs.CreateSubmissionRequest{ + TeamId: uuid.NewString(), + ProjectId: uuid.NewString(), + }) + Expect(err).To(HaveOccurred()) + Expect(status.Convert(err).Code()).To(Equal(codes.Unauthenticated)) + }) +}) diff --git a/components/backend/internal/service/storage_upload_internal_test.go b/components/backend/internal/service/storage_upload_internal_test.go new file mode 100644 index 00000000..7732a257 --- /dev/null +++ b/components/backend/internal/service/storage_upload_internal_test.go @@ -0,0 +1,87 @@ +//go:build test && unittest + +package service + +import ( + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" + + storageEnts "github.com/swissdatasciencecenter/hackagon/components/backend/internal/proto/storage/entities" +) + +// checkContentType is the whole of the upload allowlist, and it is a pure +// function, so it is tested as one — CreateUploadUrl's own specs cover +// authorization and would need an object store configured to get this far. +// +// The refusals assert the MESSAGE, not merely InvalidArgument. There are two +// different refusals in this function — "that type is not accepted" and "that +// filename does not match the type you declared" — and a rejected type whose +// filename carries the matching extension falls into the second one the moment +// the allowlist stops refusing. Two InvalidArguments for opposite reasons look +// identical from a status code alone, so a code-only assertion here would stay +// green with the allowlist gone. +var _ = Describe("checkContentType", func() { + imageRule := uploadRules[storageEnts.UploadKind_UPLOAD_KIND_HACKATHON_LOGO] + attachmentRule := uploadRules[storageEnts.UploadKind_UPLOAD_KIND_SUBMISSION_ATTACHMENT] + + refused := func(rule uploadRule, contentType, filename string) string { + GinkgoHelper() + ext, err := checkContentType(rule, contentType, filename) + Expect(err).To(HaveOccurred()) + Expect(ext).To(BeEmpty()) + Expect(status.Convert(err).Code()).To(Equal(codes.InvalidArgument)) + + return status.Convert(err).Message() + } + + // The one the allowlist exists for. Objects are served from the app's own + // origin at /objects, so a stored SVG is script running as the application: + // an XSS with a stable URL, uploadable by anyone who may upload a picture. + It("refuses image/svg+xml wherever an image is accepted", func() { + Expect(refused(imageRule, "image/svg+xml", "logo.svg")). + To(ContainSubstring("is not accepted for this kind of upload")) + Expect(refused(attachmentRule, "image/svg+xml", "diagram.svg")). + To(ContainSubstring("is not accepted for this kind of upload")) + }) + + It("refuses a document where only images are accepted", func() { + Expect(refused(imageRule, "application/pdf", "poster.pdf")). + To(ContainSubstring("is not accepted for this kind of upload")) + }) + + It("refuses a type nobody declared at all", func() { + Expect(refused(attachmentRule, "application/x-msdownload", "setup.exe")). + To(ContainSubstring("is not accepted for this kind of upload")) + Expect(refused(imageRule, "", "mystery")). + To(ContainSubstring("is not accepted for this kind of upload")) + }) + + // The control. Without it every refusal above would pass just as loudly + // against a function that accepted nothing. + It("accepts the image types the product does, and names the canonical extension", func() { + Expect(checkContentType(imageRule, "image/png", "cover.png")).To(Equal("png")) + Expect(checkContentType(imageRule, "image/webp", "cover.webp")).To(Equal("webp")) + Expect(checkContentType(imageRule, "image/gif", "cover.gif")).To(Equal("gif")) + // jpg is canonical; jpeg is merely accepted from the filename. + Expect(checkContentType(imageRule, "image/jpeg", "cover.jpeg")).To(Equal("jpg")) + Expect(checkContentType(attachmentRule, "application/pdf", "slides.pdf")).To(Equal("pdf")) + }) + + It("compares the media type, not the whole header", func() { + Expect(checkContentType(imageRule, "image/png; charset=binary", "cover.png")). + To(Equal("png")) + Expect(checkContentType(imageRule, " IMAGE/PNG ", "cover.PNG")).To(Equal("png")) + }) + + It("refuses a filename whose extension contradicts the declared type", func() { + Expect(refused(imageRule, "image/png", "clip.mov")). + To(ContainSubstring("does not look like")) + }) + + It("refuses a filename carrying a path", func() { + Expect(refused(imageRule, "image/png", "../../etc/cover.png")). + To(ContainSubstring("must not contain a path")) + }) +}) diff --git a/components/frontend/src/lib/components/hackathon/CapabilitiesPanel.svelte b/components/frontend/src/lib/components/hackathon/CapabilitiesPanel.svelte index f87b49e2..ac5c78fa 100644 --- a/components/frontend/src/lib/components/hackathon/CapabilitiesPanel.svelte +++ b/components/frontend/src/lib/components/hackathon/CapabilitiesPanel.svelte @@ -63,10 +63,13 @@ : `${unmetNames.slice(0, -1).join(', ')} and ${unmetNames.at(-1)}` ); - // Capabilities with no stored row. `SetCapabilities` answers NotFound for - // any of them and refuses the WHOLE batch, and the save posts all six — so - // one ungoverned row makes the form unusable, which the organiser should - // read here rather than deduce from a 404. + // Capabilities with no stored row. This used to be a warning that the save + // would be REFUSED: `SetCapabilities` answered NotFound for any ungoverned + // capability and threw away the whole batch, and this form posts all six — + // so one absent row made the panel unusable and the copy existed to explain + // a 404. The handler creates the row now, so what is left to say is the part + // that was always the real news: nothing governs this yet, which means the + // server is ALLOWING it. const ungoverned = $derived(rows.filter((c) => capabilityIsUngoverned(c.state))); const ungovernedSentence = $derived( ungoverned.map((c) => capabilitySubject(c.capability) ?? 'A capability').join(', ') @@ -174,14 +177,19 @@ </form> {#if ungoverned.length > 0} - <!-- Not a permission check — the backend decides, and it decides by - refusing. This says so before the organiser spends a save on it. --> + <!-- The state itself, not an error about it. An unticked box and an + ungoverned capability look the same in this form and mean + opposite things — nobody has switched it on, versus nobody has + switched it on and everyone may do it anyway — so the sentence + has to say which, and say what saving will do about it. --> <p class="m-0 text-xs text-warning-ink" role="status"> {ungovernedSentence} {ungoverned.length === 1 ? 'has' : 'have'} no stored setting on this hackathon, so the server allows - {ungoverned.length === 1 ? 'it' : 'them'} and will refuse to save this form until the - {ungoverned.length === 1 ? 'row exists' : 'rows exist'}. + {ungoverned.length === 1 ? 'it' : 'them'} right now, whatever the box above says. + Saving this form creates the + {ungoverned.length === 1 ? 'setting' : 'settings'} and puts + {ungoverned.length === 1 ? 'it' : 'them'} under these switches. </p> {/if} diff --git a/components/frontend/src/lib/components/hackathon/CapabilitiesPanel.test.ts b/components/frontend/src/lib/components/hackathon/CapabilitiesPanel.test.ts index 63ee0a33..c2af487f 100644 --- a/components/frontend/src/lib/components/hackathon/CapabilitiesPanel.test.ts +++ b/components/frontend/src/lib/components/hackathon/CapabilitiesPanel.test.ts @@ -155,15 +155,24 @@ describe("the four capability states", () => { }) describe("capabilities with no stored row", () => { - it("warns that the server allows them and will refuse the save", () => { - // `SetCapabilities` answers NotFound for a capability with no row and - // refuses the WHOLE batch, and the form posts all six. An organiser should - // read that here rather than deduce it from a 404. + it("says the server allows them, and what saving will do about it", () => { + // An unticked box and an ungoverned capability are the same pixel and the + // opposite fact: nobody switched it on, versus nobody switched it on and + // everyone may do it anyway. This sentence is the only thing that tells + // them apart, so it has to state the permission — and, since + // `SetCapabilities` creates the missing row now rather than refusing the + // batch over it, what pressing Save will do. It used to say the save would + // be REFUSED, which stopped being true when that was fixed. mount(FOUR_STATES) const warning = screen.getByText(/no stored setting on this hackathon/) expect(warning).toHaveTextContent(capabilitySubject(VOTE) as string) - expect(warning).toHaveTextContent(/will refuse to save/) + expect(warning).toHaveTextContent(/the server allows it right now/) + expect(warning).toHaveTextContent(/Saving this form creates the setting/) + expect( + warning.textContent, + "the panel must not still be telling organisers the save will be refused", + ).not.toMatch(/refuse/i) }) it("says nothing when every capability has a row", () => { diff --git a/components/frontend/src/lib/components/layout/AppFooter.svelte b/components/frontend/src/lib/components/layout/AppFooter.svelte index 77b7f190..0225cd1d 100644 --- a/components/frontend/src/lib/components/layout/AppFooter.svelte +++ b/components/frontend/src/lib/components/layout/AppFooter.svelte @@ -17,7 +17,8 @@ // The SDSC org site, off-site from here — read out of datascience.ch's own // markup rather than guessed. Distinct from the Platform column's own About, - // which is about Hackagon, not about SDSC. + // which is about Hackagon, not about SDSC: see the note on that link for why + // the two must not share a NAME, only a column heading. const SDSC_LINKS = [ { label: 'About', href: 'https://datascience.ch/about' }, { label: 'Events', href: 'https://datascience.ch/events' }, @@ -74,16 +75,47 @@ <!-- eslint-disable-next-line svelte/no-navigation-without-resolve -- static route, matches NavBar's own convention --> <a href="/hackathon" class={LINK}>Hackathons</a> <a href={resolve('/(app)/dashboard')} class={LINK}>Dashboard</a> + <!-- "About Hackagon", not "About". The SDSC column beside this + one links datascience.ch/about, and two links whose + accessible name is exactly "About" cannot be told apart in a + screen reader's link list — which is a flat list of NAMES, + with the column heading that distinguishes them on screen + thrown away. Fixed in the visible text rather than with an + aria-label on one of them: an aria-label REPLACES the label + a sighted person reads, so "click About" would stop working + for voice control and the visible text would no longer be + contained in the accessible name (WCAG 2.5.3). + The rule that keeps it fixed: each link names its + destination the way that destination names ITSELF. "About + Hackagon" is this SitePage's own <h1> (cmd/seed/main.go, and + the journey's act0 authors it under the same title); + "About" is datascience.ch's own label for theirs. Two pages, + two titles, so the collision cannot come back through a copy + edit on one side. --> <!-- eslint-disable-next-line svelte/no-navigation-without-resolve -- SitePage slug, not a typed route --> - <a href="/about" class={LINK}>About</a> + <a href="/about" class={LINK}>About Hackagon</a> </nav> <nav class="flex flex-col gap-3" aria-label="Swiss Data Science Center"> <h2 class="meta">SDSC</h2> {#each SDSC_LINKS as link (link.href)} + <!-- The sr-only suffix APPENDS to the accessible name, so the + visible word stays contained in it and voice control + ("click Events") keeps working — the opposite trade to an + aria-label, which would replace the label a sighted person + reads (WCAG 2.5.3, and the reason the About collision was + fixed in visible text instead). + target="_blank" is the ONLY signal today that these leave + the site, and nothing announces it: the nav landmark is + named "Swiss Data Science Center", but a screen reader's + link list is a flat list of NAMES and landmark context is + exactly what it discards — the same reason "About" and + "About" collided one column apart. --> <!-- eslint-disable-next-line svelte/no-navigation-without-resolve -- absolute off-site URL; resolve() is for app routes --> <a href={link.href} class={LINK} target="_blank" rel="noopener noreferrer"> - {link.label} + {link.label}<span class="sr-only"> + (datascience.ch, opens in a new tab)</span + > </a> {/each} </nav> @@ -94,15 +126,22 @@ asset-level swap, not a hand-rolled colour mode. --> <div class="flex flex-wrap items-center gap-x-8 gap-y-4 pb-10"> <span class="meta">A joint venture of</span> + <!-- These two already NAME their destination, through the image alt + that is their whole accessible name — so they take the bare "opens + in a new tab" and not the host suffix the SDSC column carries. + Saying "ethz.ch" after "ETH Zurich" is the doubling-up that makes + a link list harder to read, not easier. --> <a href="https://ethz.ch" target="_blank" rel="noopener noreferrer" class={LOGO_LINK}> <img src="/images/logos/eth-zurich.svg" alt="ETH Zurich" class="h-4 invert dark:invert-0" /> + <span class="sr-only"> (opens in a new tab)</span> </a> <a href="https://epfl.ch" target="_blank" rel="noopener noreferrer" class={LOGO_LINK}> <img src="/images/logos/epfl.svg" alt="EPFL" class="h-4 invert dark:invert-0" /> + <span class="sr-only"> (opens in a new tab)</span> </a> </div> </div> @@ -135,9 +174,15 @@ <div class="flex items-center gap-1"> {#each SOCIALS as social (social.href)} {@const Icon = social.icon} + <!-- Icon-only, so aria-label is the ENTIRE accessible name + and an sr-only child would be ignored — it is extended + rather than added to. There is no visible text label + here for it to contradict, which is why replacing the + name is safe on these three and wrong on the five + above. `title` stays the sighted tooltip. --> <a href={social.href} - aria-label={social.label} + aria-label="{social.label} (opens in a new tab)" title={social.label} target="_blank" rel="noopener noreferrer" diff --git a/components/frontend/src/lib/components/observability/PlausibleAnalytics.svelte b/components/frontend/src/lib/components/observability/PlausibleAnalytics.svelte new file mode 100644 index 00000000..30636c18 --- /dev/null +++ b/components/frontend/src/lib/components/observability/PlausibleAnalytics.svelte @@ -0,0 +1,162 @@ +<script lang="ts"> + /* + * Plausible — how many people used which SCREEN. + * + * The other half of the observability kit answers a different question and + * is much heavier: session replay records a browser to catch the bug class + * that produces no RPC (SessionReplay.svelte). This one answers "is the + * proposals page used at all", which nothing else here can — the RPC + * journal sees calls, not visits, and a page whose whole job is to be read + * makes no calls at all. + * + * OFF unless wired. `config === null` renders nothing, imports nothing and + * requests nothing: no script tag, no network call, no console line. That + * is the default state of a deployment that has never heard of this. + * + * NOT BEHIND THE CONSENT BANNER, deliberately — the reasoning is in + * `+layout.server.ts` (the banner asks about session recording; consent is + * scoped to what was asked; and this stores nothing in the browser to + * consent to). Do-Not-Track and Global Privacy Control ARE honoured, here, + * before the script is fetched. + * + * Three properties, the same three the replay component keeps: + * + * 1. It must not break SSR — everything happens in `onMount`. + * 2. A dead endpoint must not take the app down — the script tag's + * failure is the browser's problem and nothing awaits it. + * 3. It must not slow the boot — `defer`, and the first pageview is sent + * from an idle callback. + */ + import { onMount } from 'svelte'; + import { afterNavigate } from '$app/navigation'; + import { page } from '$app/state'; + import { analyticsUrl, analyticsReferrer } from '$lib/utils/analyticsRoute'; + + export type PlausibleConfig = { + scriptUrl: string; + domain: string; + }; + + let { config }: { config: PlausibleConfig | null } = $props(); + + /** + * Do Not Track / Global Privacy Control. Checked HERE, not passed as an + * option, for the same two reasons as in SessionReplay.svelte: the script + * is then never FETCHED (a request for an analytics script is itself a + * small signal), and GPC — the signal with actual legal weight in several + * jurisdictions — is not something Plausible's script looks at at all. + */ + function tracking_refused(): boolean { + if (typeof navigator === 'undefined') return false; + const nav = navigator as Navigator & { + globalPrivacyControl?: boolean; + msDoNotTrack?: string; + }; + const win = window as Window & { doNotTrack?: string }; + + return ( + nav.doNotTrack === '1' || + nav.msDoNotTrack === '1' || + win.doNotTrack === '1' || + nav.globalPrivacyControl === true + ); + } + + type PlausibleFn = ((event: string, options?: { u?: string }) => void) & { + q?: unknown[]; + }; + + /** + * Send one pageview, with the two things the script would otherwise read + * off the page itself replaced. + * + * `u` — passed explicitly, which is what `manual` mode is for. The script's + * default is `location.href`; this app's URLs carry invite TOKENS and + * hackathon ids, so what goes instead is the route TEMPLATE + * (`/my/hackathon/[id]/teams`). See $lib/utils/analyticsRoute for why a + * template beats a scrubbed path. + * + * `document.referrer` — read INSIDE the script (`h.r = document.referrer || + * null`), with no option to override, so it is shadowed for the duration + * of the call and revealed again in `finally`. The same trick, for the same + * reason, as the `document.URL` shadow in SessionReplay.svelte, and safe + * for the same reason: the script builds its payload synchronously inside + * the call, so the window is one function invocation wide. + * + * Engagement events (the tracker's `visibilitychange`/`blur` handlers) are + * not a hole: they reuse the `u` of the last pageview, which is this + * sanitized string, and the handlers are only attached once a pageview has + * been sent. + */ + function send_pageview(): void { + const fn = (window as unknown as { plausible?: PlausibleFn }).plausible; + if (!fn) return; + + const url = analyticsUrl(window.location.origin, page.route.id); + const referrer = analyticsReferrer(document.referrer, window.location.origin); + + let shadowed = false; + try { + Object.defineProperty(document, 'referrer', { + configurable: true, + get: () => referrer + }); + shadowed = true; + } catch { + // Non-configurable in some future engine: the pageview still goes, + // and what leaks is a referrer, not the current page. Asserted in + // the rig's verification rather than assumed here. + } + + try { + fn('pageview', { u: url }); + } finally { + if (shadowed) delete (document as unknown as Record<string, unknown>)['referrer']; + } + } + + onMount(() => { + if (!config) return; + if (tracking_refused()) return; + + // The queue stub the tracker itself looks for (`window.plausible.q`), + // so a pageview fired before the script finishes loading is replayed + // rather than lost. Manual mode sends nothing on its own, so without + // this the first view of a fast page would simply not be counted. + const w = window as unknown as { plausible?: PlausibleFn }; + if (!w.plausible) { + const stub = function (...args: unknown[]) { + (stub.q = stub.q || []).push(args); + } as PlausibleFn & { q?: unknown[] }; + w.plausible = stub; + } + + const el = document.createElement('script'); + el.defer = true; + el.src = config.scriptUrl; + el.setAttribute('data-domain', config.domain); + document.head.appendChild(el); + + // After first paint, never during it. + const idle = + typeof requestIdleCallback === 'function' + ? requestIdleCallback(() => send_pageview()) + : setTimeout(() => send_pageview(), 0); + + return () => { + if (typeof requestIdleCallback === 'function') cancelIdleCallback(idle as number); + else clearTimeout(idle as ReturnType<typeof setTimeout>); + }; + }); + + // Client-side navigations. `manual` mode means the script counts nothing by + // itself — which is the point: every pageview this app reports is one this + // component decided to send, with a URL it built. + afterNavigate((nav) => { + if (!config || tracking_refused()) return; + // The first `afterNavigate` fires for the initial load too (type + // "enter"), which onMount has already counted. + if (nav.type === 'enter') return; + send_pageview(); + }); +</script> diff --git a/components/frontend/src/lib/schemas/config-schema.ts b/components/frontend/src/lib/schemas/config-schema.ts index 5cea6ddf..66f6e2fa 100644 --- a/components/frontend/src/lib/schemas/config-schema.ts +++ b/components/frontend/src/lib/schemas/config-schema.ts @@ -39,6 +39,51 @@ const replaySchema = z }) .default({}) +// Audience measurement (Plausible). OFF unless a `plausible:` block says +// otherwise, exactly like `replay` above and for the same reason: a deployment +// that has never heard of this feature must not start counting because +// somebody forgot a flag. Absent block ⇒ no script tag, no request, nothing in +// the console. +// +// WHAT ENABLING IT COLLECTS, per page view: the ROUTE TEMPLATE (never the URL — +// see $lib/utils/analyticsRoute), the referrer's origin, screen width, and +// whatever Plausible derives server-side from the request (browser, OS, +// country if a geolocation database is configured — this dev rig ships none). +// No cookie, no localStorage, no identifier of any kind is stored in the +// browser. +// +// WHAT PLAUSIBLE ITSELF DOES WITH THE REQUEST: to count a visitor twice in one +// day without an identifier, it computes a hash of (daily-rotated salt, IP +// address, user agent, site domain) and stores ONLY that hash — verified +// against the schema, not the marketing page: `plausible_events_db.events_v2` +// has a `user_id UInt64` and no column of any kind holding an IP or a user +// agent. The salt rotates every day, so the same person is a different number +// tomorrow. That is still processing of an IP address in transit, and +// docs/frontend/analytics.md says so out loud rather than calling it +// "anonymous". +// +// It is deliberately NOT correlated with the RPC journal or with session +// replay: nothing joins these numbers to a user id, and no replay session id +// is ever sent here. +const plausibleSchema = z + .object({ + enabled: z.boolean().default(false), + // The tracker script, served by the Plausible instance itself. The script + // derives its own ingest endpoint from this URL's origin, so one setting + // points at both. Wiring writes the `…local.manual.js` variant on purpose + // (see PlausibleAnalytics.svelte). + scriptUrl: z.string().url("plausible.scriptUrl must be a URL").optional(), + // The site to attribute events to — must equal the domain registered in + // Plausible EXACTLY, and it is a label rather than a hostname: nothing + // resolves it. + domain: z.string().min(1).optional(), + }) + .refine((p) => !p.enabled || (!!p.scriptUrl && !!p.domain), { + message: + "plausible.enabled requires plausible.scriptUrl and plausible.domain", + }) + .default({}) + // Secrets File Schema for Validation export const secretsFileSchema = z.object({ oidc: z.object({ @@ -82,6 +127,7 @@ export const settingsFileSchema = z.object({ }), cookies: z.object({ useSecure: z.boolean() }), replay: replaySchema, + plausible: plausibleSchema, }) // App Config Schema @@ -121,4 +167,5 @@ export const AppConfigSchema = z.object({ }), cookies: z.object({ useSecure: z.boolean() }), replay: replaySchema, + plausible: plausibleSchema, }) diff --git a/components/frontend/src/lib/server/grpc/client.ts b/components/frontend/src/lib/server/grpc/client.ts index 37605888..327c70aa 100644 --- a/components/frontend/src/lib/server/grpc/client.ts +++ b/components/frontend/src/lib/server/grpc/client.ts @@ -26,7 +26,30 @@ import type { VoteServiceClient } from "./generated/vote/vote_service" import type { SitePageServiceClient } from "./generated/site/site_page_service" import type { StorageServiceClient } from "./generated/storage/storage_service" -const channel = createChannel("localhost:3000") +// ONE channel for the whole process, created at module load and never replaced. +// grpc-js reconnects by itself, so a backend restart does heal — but on ITS +// schedule, and that schedule is tuned for a remote service rather than for a +// supervisor-managed backend on loopback that gets stopped and started several +// times in a single test run. The defaults are a 1s initial backoff, x1.6 per +// failure, capped at 120s (`GRPC_INITIAL_RECONNECT_BACKOFF_MS` / +// `GRPC_MAX_RECONNECT_BACKOFF_MS`), and while the channel is in TRANSIENT_FAILURE +// waiting out that interval every RPC fails IMMEDIATELY. +// +// So a backend that was down for a couple of minutes leaves this server serving +// errors for up to two more minutes after the backend is demonstrably healthy — +// and because the public list loads turn any error into an empty array, what +// reaches the browser is a confident "no hackathons yet" over a fully populated +// database. That cost hours on 2026-08-13: `grpcurl` returned 8 events while the +// browse page rendered none, which is indistinguishable from "the seed data is +// gone" unless you already suspect the channel. +// +// The window is what is wrong here, not the reconnect. Cap it: on loopback a +// failed connect costs nothing, so retrying every 2s means a restarted backend +// is picked up in about as long as it takes to bind its port. +const channel = createChannel("localhost:3000", undefined, { + "grpc.initial_reconnect_backoff_ms": 200, + "grpc.max_reconnect_backoff_ms": 2000, +}) // Unauthenticated health client for the startup check in hooks.server.ts export const healthClient = createClientFactory().create( diff --git a/components/frontend/src/lib/server/hackathon/stateActions.test.ts b/components/frontend/src/lib/server/hackathon/stateActions.test.ts new file mode 100644 index 00000000..12ffa0b2 --- /dev/null +++ b/components/frontend/src/lib/server/hackathon/stateActions.test.ts @@ -0,0 +1,213 @@ +/** + * `applyPhaseCapabilities` — the action behind the hub's "Enable it" button. + * + * The bug this file exists for: the hub decided WHICH phase is live with + * `currentAndNextPhase`, which falls back to the calendar when no phase has been + * declared, and then offered a button whose action looked the phase up by + * `current_phase_id` alone. Declaring a phase is an explicit act nobody is + * required to perform, so in the ordinary state of a running hackathon the + * warning rendered and its one action answered 400, every time. + * + * The tests are written against the RESOLUTION, not against the lookup: what + * matters is that the button switches on what the live phase names, whichever + * way "live" was decided. A test that asserted "the action calls + * `currentAndNextPhase`" would pass against an implementation that resolved the + * phase correctly and then wrote the wrong capabilities. + */ + +import { describe, expect, it, vi } from "vitest" +import type { RequestEvent } from "@sveltejs/kit" +import { applyPhaseCapabilities } from "./stateActions" + +const REGISTER = 1 +const PROPOSE = 2 +const TEAM_PREFS = 3 +const VOTE = 5 + +const OPEN = 2 +const CLOSED = 3 + +const HOUR = 3_600_000 +const now = Date.now() + +/** A phase whose dates are running right now. */ +const live = { + id: "phase-live", + name: "Sprint Evening", + startsAt: new Date(now - HOUR), + endsAt: new Date(now + HOUR), +} +/** One that finished yesterday, so the calendar cannot pick it. */ +const past = { + id: "phase-past", + name: "Warm-up", + startsAt: new Date(now - 48 * HOUR), + endsAt: new Date(now - 24 * HOUR), +} + +interface Row { + capability: number + state: number + openInPhaseId?: string +} + +/** + * A hackathon the action can read, plus the spy that records what it wrote. + * + * `get` is answered from the world rather than from a fixed literal, because the + * action re-reads state on purpose (a stale page must not be able to switch + * something back on) — so the read has to be the same object the write is judged + * against. + */ +function world(opts: { currentPhaseId?: string; capabilities: Row[] }) { + const setCapabilities = vi.fn(async () => ({})) + const grpc = { + hackathon: { + get: async () => ({ + hackathon: { + currentPhaseId: opts.currentPhaseId ?? "", + phases: [past, live], + capabilities: opts.capabilities, + }, + }), + setCapabilities, + }, + } + + return { + setCapabilities, + event: { + locals: { grpc }, + request: new Request("http://localhost/", { method: "POST" }), + } as unknown as RequestEvent, + } +} + +/** The `{capability, enabled}` list the action posted, as a plain map. */ +function written(spy: ReturnType<typeof vi.fn>): Record<number, boolean> { + expect(spy, "the action wrote nothing at all").toHaveBeenCalledTimes(1) + const req = spy.mock.calls[0]?.[0] as + | { capabilities: { capability: number; enabled: boolean }[] } + | undefined + expect(req, "the write carried no payload to read back").toBeDefined() + + return Object.fromEntries( + (req?.capabilities ?? []).map((c) => [c.capability, c.enabled]), + ) +} + +/** + * The live phase names team preferences, which are off; registration is off and + * named by no phase; voting is on and named by no phase. That is the shape the + * hub's warning appears in, and the shape every claim below is about. + */ +const gap: Row[] = [ + { capability: REGISTER, state: CLOSED }, + { capability: PROPOSE, state: CLOSED }, + { capability: TEAM_PREFS, state: CLOSED, openInPhaseId: live.id }, + { capability: VOTE, state: OPEN }, +] + +describe("applyPhaseCapabilities", () => { + it("applies the live phase's plan when no phase has been declared", async () => { + // The regression. `currentPhaseId` is empty — the calendar is what says + // `phase-live` is running — and this used to be an unconditional 400. + const { event, setCapabilities } = world({ capabilities: gap }) + + const result = await applyPhaseCapabilities(event, "h1") + + expect( + (result as { status?: number }).status, + "a by-dates phase is still a current phase, and this must not refuse it", + ).toBeUndefined() + expect(written(setCapabilities)[TEAM_PREFS]).toBe(true) + }) + + it("applies the declared phase's plan, ignoring what the dates say", async () => { + // A declaration outranks the calendar everywhere else in the product + // (`resolvePhaseStatus`), so the fallback must not have become the rule: + // `phase-past` is declared and names nothing, `phase-live` is running by its + // dates and names team preferences. Nothing may come on. + const { event, setCapabilities } = world({ + currentPhaseId: past.id, + capabilities: gap, + }) + + await applyPhaseCapabilities(event, "h1") + + expect( + written(setCapabilities)[TEAM_PREFS], + "the declared phase names nothing, so nothing the calendar's phase names " + + "may be switched on", + ).toBe(false) + }) + + it("only ever switches things on", async () => { + // The additive rule, asserted on the two capabilities no phase names: + // registration must stay closed and voting must stay open. Applying a + // phase's plan is a catch-up, never a reset. + const { event, setCapabilities } = world({ capabilities: gap }) + + await applyPhaseCapabilities(event, "h1") + const sent = written(setCapabilities) + + expect(sent[VOTE], "voting was on and no phase names it").toBe(true) + expect(sent[REGISTER], "registration was off and no phase names it").toBe( + false, + ) + }) + + it("refuses when neither a declaration nor the calendar names a phase", async () => { + // The one state that genuinely has no plan to read. It keeps a 400 — but it + // is now reachable only when BOTH meanings of "current" come back empty, + // which is what the message says. + const setCapabilities = vi.fn(async () => ({})) + const event = { + locals: { + grpc: { + hackathon: { + get: async () => ({ + hackathon: { + currentPhaseId: "", + phases: [past], + capabilities: gap, + }, + }), + setCapabilities, + }, + }, + }, + request: new Request("http://localhost/", { method: "POST" }), + } as unknown as RequestEvent + + const result = (await applyPhaseCapabilities(event, "h1")) as { + status: number + data: { message: string } + } + + expect(result.status).toBe(400) + expect(result.data.message).toMatch(/no phase's dates cover today/i) + expect( + setCapabilities, + "refusing must not have written anything on the way", + ).not.toHaveBeenCalled() + }) + + it("refuses a declaration that names a phase this hackathon does not have", async () => { + // `currentAndNextPhase` deliberately does NOT fall back to the dates for a + // dangling pointer — the organiser has decided, and quietly applying some + // other phase's plan would be worse than applying none. Pinned here because + // the fallback added above is exactly the change that could erode it. + const { event, setCapabilities } = world({ + currentPhaseId: "deleted-phase", + capabilities: gap, + }) + + const result = (await applyPhaseCapabilities(event, "h1")) as { + status: number + } + + expect(result.status).toBe(400) + expect(setCapabilities).not.toHaveBeenCalled() + }) +}) diff --git a/components/frontend/src/lib/server/hackathon/stateActions.ts b/components/frontend/src/lib/server/hackathon/stateActions.ts index da543a31..edbc2ee9 100644 --- a/components/frontend/src/lib/server/hackathon/stateActions.ts +++ b/components/frontend/src/lib/server/hackathon/stateActions.ts @@ -19,7 +19,10 @@ import { enabledCapabilities, phaseCapabilities, } from "$lib/server/hackathon/phaseForm" -import { withPhaseCapabilitiesEnabled } from "$lib/utils/phase" +import { + currentAndNextPhase, + withPhaseCapabilitiesEnabled, +} from "$lib/utils/phase" import { fail } from "@sveltejs/kit" import { ClientError, Status } from "nice-grpc-common" @@ -30,12 +33,12 @@ export function toCapabilityFailure(e: unknown) { message: "You don't have permission to change this hackathon", }) } - // TODO(backend: project-preferences-capability): on `SetCapabilities` a - // NotFound means the hackathon has no HackathonState row — and by then the - // casbin policies have already been written, because they are added inside the - // capability loop before the state re-read fails (`hackathon_service.go:655` - // then `:681`). So a failure reported here may have granted permissions anyway. - // No hackathon reachable from the app is in that state, so this is a guard. + // On `SetCapabilities` a NotFound now means one thing only: the HACKATHON does + // not exist. It used to also mean "one capability in the batch has no stored + // row", which refused all six and reported the wrong missing thing; that case + // creates the row instead (`hackathon_service.go`, SetCapabilities). So a 404 + // reaching a viewer here is a hackathon that was deleted under them, and + // `e.details` names it. if (e instanceof ClientError && e.code === Status.NOT_FOUND) { return fail(404, { message: e.details }) } @@ -138,6 +141,33 @@ export async function saveCapabilities( * Switch on whatever the current phase expects and is off. Additive only — see * `withPhaseCapabilitiesEnabled`; nothing is ever switched off here, so this * cannot close registration as a side effect of moving through phases. + * + * "Current" is resolved by `currentAndNextPhase`, which is the SAME answer the + * hub used when it drew the warning this button sits under — an organiser's + * declaration if there is one, otherwise whichever phase's own dates are + * running. It used to be `phases.find(p => p.id === currentPhaseId)`, a second, + * narrower definition of the word, and the two disagreed in the state most + * hackathons are actually in: declaring a phase is an explicit act nobody has to + * perform, so with no marker set the panel named the live phase off the calendar + * and offered `Enable it`, and this action answered + * `400 "…no current phase to take settings from"` every single time. The + * control was offered exactly where it could not work. + * + * Resolving by dates here rather than hiding the warning, because the warning is + * TRUE in that state — the phase the calendar says is running does name a + * capability participants cannot use — and hiding a true, actionable warning + * behind a marker nobody is required to set would report the gap in fewer + * situations than it exists in. Nothing about the phase POINTER moves: this + * still writes only capability switches, and declaring a phase stays the + * separate, deliberate act it is. `SetCapabilities` is additive here, so the + * worst a by-dates resolution can do is switch on something the calendar says + * should already be on. + * + * The 400 survives for the case that is genuinely impossible — no declaration + * AND no phase whose dates cover today — where there is no phase to read a plan + * from under either meaning of the word. Not reachable from the panel (with no + * current phase there are no phase capabilities, so `unmet` is empty and the + * warning does not render), so it stays a guard on a direct POST. */ export async function applyPhaseCapabilities( event: RequestEvent, @@ -147,10 +177,15 @@ export async function applyPhaseCapabilities( try { const state = await readState(grpc, hackathonId) - const current = state.phases.find((p) => p.id === state.currentPhaseId) + const { current } = currentAndNextPhase( + state.phases, + state.currentPhaseId || undefined, + ) if (!current) { return fail(400, { - message: "This hackathon has no current phase to take settings from", + message: + "No phase is current here — none is declared and no phase's dates " + + "cover today — so there are no settings to take", }) } diff --git a/components/frontend/src/lib/utils/analyticsRoute.test.ts b/components/frontend/src/lib/utils/analyticsRoute.test.ts new file mode 100644 index 00000000..14955bf8 --- /dev/null +++ b/components/frontend/src/lib/utils/analyticsRoute.test.ts @@ -0,0 +1,86 @@ +import { describe, it, expect } from "vitest" +import { + analyticsPath, + analyticsUrl, + analyticsReferrer, +} from "./analyticsRoute" + +/* + * These are absence-assertions ("no id reaches the analytics store"), and this + * repo has been bitten three times by an absence-assertion that agreed with + * everything (see .claude/CLAUDE.md, "Ways a test reported green while proving + * nothing"). So every "the token is gone" case here is paired with a POSITIVE + * CONTROL: the same call must still produce the route it claims to produce. A + * function that returned "" for everything would pass the first half of each + * pair and fail the second. + */ + +describe("analyticsPath", () => { + it("keeps the route template and drops the layout group", () => { + expect(analyticsPath("/(app)/my/hackathon/[id]/teams")).toBe( + "/my/hackathon/[id]/teams", + ) + expect(analyticsPath("/(public)/hackathon/[id]")).toBe("/hackathon/[id]") + }) + + it("reports the root as /", () => { + expect(analyticsPath("/(public)")).toBe("/") + expect(analyticsPath("/")).toBe("/") + }) + + it("drops a param matcher but keeps the param", () => { + expect(analyticsPath("/(public)/[slug=sitepage]")).toBe("/[slug]") + }) + + it("reports an unmatched route as such, never as its URL", () => { + expect(analyticsPath(null)).toBe("/[unmatched]") + expect(analyticsPath(undefined)).toBe("/[unmatched]") + expect(analyticsPath("")).toBe("/[unmatched]") + }) + + it("cannot carry an invite token, because it never sees one", () => { + // The route id is all this function is ever given — an invite URL and the + // route it matched are different strings, and only the second one exists + // here. Positive control on the same call: the ROUTE is still reported. + const out = analyticsPath("/(public)/invite/[token]") + expect(out).toBe("/invite/[token]") + expect(out).not.toMatch(/[0-9a-f]{8}-[0-9a-f]{4}/i) + }) +}) + +describe("analyticsUrl", () => { + it("is this origin plus the template", () => { + expect(analyticsUrl("http://localhost:8081", "/(app)/dashboard")).toBe( + "http://localhost:8081/dashboard", + ) + }) + + it("does not double the slash when the origin has a trailing one", () => { + expect(analyticsUrl("http://localhost:8081/", "/(app)/dashboard")).toBe( + "http://localhost:8081/dashboard", + ) + }) +}) + +describe("analyticsReferrer", () => { + const origin = "http://localhost:8081" + + it("drops an internal referrer entirely", () => { + // Positive control lives in the next test: if this function returned "" + // unconditionally, "keeps an external origin" would fail. + expect(analyticsReferrer(`${origin}/invite/abc-123-secret`, origin)).toBe( + "", + ) + }) + + it("keeps an external origin and nothing else of it", () => { + expect( + analyticsReferrer("https://github.com/some/repo?q=private", origin), + ).toBe("https://github.com") + }) + + it("is empty for an absent or unparseable referrer", () => { + expect(analyticsReferrer("", origin)).toBe("") + expect(analyticsReferrer("not a url", origin)).toBe("") + }) +}) diff --git a/components/frontend/src/lib/utils/analyticsRoute.ts b/components/frontend/src/lib/utils/analyticsRoute.ts new file mode 100644 index 00000000..96039103 --- /dev/null +++ b/components/frontend/src/lib/utils/analyticsRoute.ts @@ -0,0 +1,102 @@ +/* + * What the analytics tracker is allowed to say about "which page". + * + * THE URL IS NEVER SENT. Plausible's script defaults to `location.href`, and + * this app's URLs carry things that must not end up in an analytics database: + * + * /invite/<token> the token IS the credential — hooks.server.ts + * makes that route public precisely because the + * URL authenticates the visitor + * /my/hackathon/<uuid>/… the id of an event that may be private + * /register/<uuid> which event a person is signing up to + * ?… query strings, which nothing here needs + * + * This is the same rule session replay had to be argued into + * (`SessionReplay.svelte`: `resourceBaseHref`, `urlSanitizer`, and the + * `document.URL` shadow). It is settled the same way and one step earlier: + * rather than sanitizing a URL, we never build one from the address bar. + * + * WHAT IS SENT INSTEAD IS THE ROUTE ID — SvelteKit's own template for the + * matched route, e.g. `/my/hackathon/[id]/teams`. That is structurally + * incapable of carrying an id, because the string never comes from the + * location: it comes from the route tree at build time. A regex scrubber over + * the real path would have been the obvious alternative and is strictly worse — + * it has to ENUMERATE what secrets look like, so a route added later leaks + * until somebody remembers to extend the pattern, which is exactly how + * per-field masking opt-in fails in the replay tracker. + * + * The cost, stated plainly: Plausible can tell you that the teams screen was + * viewed 40 times and can never tell you for which hackathon. Per-event + * breakdowns would mean putting event ids in an aggregate store, which is the + * thing being avoided. Product questions of the form "which SCREENS do people + * use" are answered; "what happened at event X" is not, and that is what the + * backend's own data is for. + */ + +/** + * SvelteKit route id → the path reported to analytics. + * + * `page.route.id` looks like `/(app)/my/hackathon/[id]/teams`. Two + * normalisations, both cosmetic and both about not leaking the SHAPE of the + * codebase into a dashboard people read: + * + * - layout GROUPS (`(app)`, `(public)`) are removed — they are a source-tree + * concern and are not part of any URL a visitor ever sees; + * - param MATCHERS are dropped (`[slug=sitepage]` → `[slug]`), so renaming a + * matcher does not fork one page into two rows in the report. + * + * `null` — which is what SvelteKit reports for a 404 or an error page, i.e. + * for a request that matched no route at all — becomes `/[unmatched]`. NOT the + * real path: an unmatched URL is the single most likely place for a mistyped + * or hand-edited token to appear, and it is a row worth having in the report + * (a spike of them means something links somewhere that does not exist). + */ +export function analyticsPath(routeId: string | null | undefined): string { + if (!routeId) return "/[unmatched]" + + const path = routeId + .split("/") + .filter((seg) => !(seg.startsWith("(") && seg.endsWith(")"))) + .map((seg) => seg.replace(/^\[([^\]=]+)=[^\]]*\]$/, "[$1]")) + .join("/") + + return path === "" ? "/" : path +} + +/** Absolute URL for the tracker's `u`: this origin, plus the route template. */ +export function analyticsUrl( + origin: string, + routeId: string | null | undefined, +): string { + return `${origin.replace(/\/$/, "")}${analyticsPath(routeId)}` +} + +/** + * What the tracker may say about where a visitor came FROM. + * + * Plausible's script reads `document.referrer` itself — there is no option for + * it — so the value is shadowed around the call (see PlausibleAnalytics.svelte) + * and this decides what to shadow it with: + * + * same origin → "" . An internal referrer is one of OUR paths, so it + * carries exactly the ids the rest of this file exists to + * keep out. Plausible discards same-site referrers when + * computing sources anyway, so nothing is lost but the leak. + * other origin → its ORIGIN, no path, no query. That is the whole of the + * acquisition-source signal ("came from GitHub") and none of + * the private part ("came from this Slack search URL"). + * unparseable → "". + * + * Campaign parameters (utm_*) are therefore never recorded, because they live + * in a query string this never sends. Deliberate: a hackathon platform's + * traffic is links from partners and chat, not ad campaigns. + */ +export function analyticsReferrer(referrer: string, origin: string): string { + if (!referrer) return "" + try { + const url = new URL(referrer) + return url.origin === origin ? "" : url.origin + } catch { + return "" + } +} diff --git a/components/frontend/src/routes/(app)/account/+page.svelte b/components/frontend/src/routes/(app)/account/+page.svelte index 3bab2e1d..9ca6bb70 100644 --- a/components/frontend/src/routes/(app)/account/+page.svelte +++ b/components/frontend/src/routes/(app)/account/+page.svelte @@ -152,15 +152,37 @@ consent you gave it — belongs to that event's registration and is edited there. Consents especially: agreeing to one event's code of conduct is not a standing agreement with the platform, and - showing it here would imply it was. --> - <p class="m-0 border-t border-line pt-3 text-meta text-ink-3"> + showing it here would imply it was. + + The route it names is the one that EXISTS. This sentence used to + send people to "Your registration answers → View or edit" — a + block on the event overview that c596683c removed, so for anyone + following it the instruction dead-ended on a page that no longer + had the control. 76037844 moved the way in to the participants + roster, where your own row's View opens your editable form + (`?userId=` opens someone else's, for organisers). + + The `<strong>`s are the contract, not decoration: + smoke/07-account-menu reads them out of this paragraph and walks + the trail they name, so re-wording it to a control that does not + exist turns that spec red rather than shipping a second dead + instruction. Name a control here only if you can click it. + + No link, and that is a limit rather than an oversight: this page + has no event in hand (`load` returns your profile and nothing + else), so any href here would have to guess WHICH event's + answers you meant. --> + <p + class="m-0 border-t border-line pt-3 text-meta text-ink-3" + data-testid="event-answers-note" + > This is your profile across the whole platform. Answers you gave to a <em>particular</em> event — its own extra questions, and the consents you agreed to, such as its code of conduct or event photography — live with that - event's registration, not here: open the event and use - <strong>“Your registration answers → View or edit”</strong>. Changing this - profile does not change what an event already recorded, and organisers see - the answers you gave them at the time. + event's registration, not here: open the event, go to + <strong>Participants</strong>, and use <strong>View</strong> on your own row. + Changing this profile does not change what an event already recorded, and + organisers see the answers you gave them at the time. </p> <dl diff --git a/components/frontend/src/routes/(public)/+page.server.ts b/components/frontend/src/routes/(public)/+page.server.ts index a604a891..0df7c29e 100644 --- a/components/frontend/src/routes/(public)/+page.server.ts +++ b/components/frontend/src/routes/(public)/+page.server.ts @@ -15,13 +15,35 @@ export const load: PageServerLoad = async (event) => { // This awaited bare, so any unreachable backend turned the platform's front // page into a 500 — measured at 98 of 356 samples during one e2e run, which // wipes and reboots Postgres and the backend by design. The rest of the page - // is static marketing copy that needs no backend at all, and "no events to - // show" is a truthful, calm thing to render; a stack trace is not. + // is static marketing copy that needs no backend at all, and a stack trace is + // not something to render at a visitor. // - // The awards block below already degrades this way. Now the list does too. - const result = await publicHackathonClient - .list({ visibilityFilter: Visibility.VISIBILITY_PUBLIC }) - .catch(() => ({ hackathons: [] })) + // But "no events to show" is NOT the truthful thing to render, which is what + // the comment here used to claim. It is truthful only when there are no + // events; when the list could not be FETCHED it is a different fact, and + // flattening the two cost hours on 2026-08-13 — this page and /hackathon both + // showed nothing while the database held eight public editions and `grpcurl` + // returned them (the gRPC channel was waiting out its reconnect backoff; see + // lib/server/grpc/client.ts). In a container where every test run wipes and + // reseeds the database, "empty" and "unreachable" looking identical is the + // most expensive confusion available. So the failure is carried, not hidden. + let hackathons: Awaited< + ReturnType<typeof publicHackathonClient.list> + >["hackathons"] = [] + let listUnavailable = false + try { + const listed = await publicHackathonClient.list({ + visibilityFilter: Visibility.VISIBILITY_PUBLIC, + }) + hackathons = listed.hackathons + } catch (e) { + event.locals.logger.error( + { err: e }, + "Public hackathon list unavailable on the landing page — rendering the outage state, not an empty platform.", + ) + listUnavailable = true + } + const result = { hackathons } // Winners, from the events that actually finished and recorded them. // @@ -70,6 +92,7 @@ export const load: PageServerLoad = async (event) => { return { session: event.locals.session, hackathons: result.hackathons, + listUnavailable, awards, } } diff --git a/components/frontend/src/routes/(public)/+page.svelte b/components/frontend/src/routes/(public)/+page.svelte index 8c8cd80c..88769cc1 100644 --- a/components/frontend/src/routes/(public)/+page.svelte +++ b/components/frontend/src/routes/(public)/+page.svelte @@ -162,7 +162,12 @@ </div> <div class="mt-0 divide-y divide-line"> - {#if data.hackathons.length === 0} + {#if data.listUnavailable} + <!-- "Unreachable" is not "empty" — see +page.server.ts. --> + <p class="py-6 text-sm text-ink-3" data-testid="listUnavailable"> + We could not reach the service to load events. Please try again in a moment. + </p> + {:else if data.hackathons.length === 0} <p class="py-6 text-sm text-ink-3">No hackathons available yet.</p> {:else} {#each data.hackathons as h, i (h.id)} diff --git a/components/frontend/src/routes/(public)/hackathon/+page.server.ts b/components/frontend/src/routes/(public)/hackathon/+page.server.ts index 8296eeff..59211a4d 100644 --- a/components/frontend/src/routes/(public)/hackathon/+page.server.ts +++ b/components/frontend/src/routes/(public)/hackathon/+page.server.ts @@ -9,14 +9,43 @@ import { Visibility } from "$lib/server/grpc/generated/hackathon/entities/visibi // and private events are filtered out server-side rather than hidden in the UI. export const load: PageServerLoad = async (event) => { // Degrades rather than 500s when the backend is unreachable — same reasoning - // as the landing page. An empty list renders "no hackathons yet", which is a - // calm and truthful thing for a visitor to read during an outage. - const result = await publicHackathonClient - .list({ visibilityFilter: Visibility.VISIBILITY_PUBLIC }) - .catch(() => ({ hackathons: [] })) + // as the landing page. + // + // But it must SAY SO. This used to be `.catch(() => ({ hackathons: [] }))` + // with a comment calling an empty list "a calm and truthful thing for a + // visitor to read during an outage", and it is only the first of those: an + // empty list is not truthful when the truth is "I could not ask". Measured + // 2026-08-13: this page rendered ZERO events while `grpcurl` returned eight + // from the same database, for 51 seconds AFTER the backend was healthy again + // (the gRPC channel was still waiting out its reconnect backoff, whose + // default cap is 120s — see lib/server/grpc/client.ts). "The seed data is + // gone" and "I cannot reach the backend" are indistinguishable from the + // browser unless the page distinguishes them, and hours went into the wrong + // one. + // + // So the failure is carried to the component instead of being flattened into + // the success shape. The page still renders, still has no stack trace on it, + // and no longer claims an empty platform. + let hackathons: Awaited< + ReturnType<typeof publicHackathonClient.list> + >["hackathons"] = [] + let listUnavailable = false + try { + const result = await publicHackathonClient.list({ + visibilityFilter: Visibility.VISIBILITY_PUBLIC, + }) + hackathons = result.hackathons + } catch (e) { + event.locals.logger.error( + { err: e }, + "Public hackathon list unavailable — rendering the outage state, not an empty platform.", + ) + listUnavailable = true + } return { session: event.locals.session, - hackathons: result.hackathons, + hackathons, + listUnavailable, } } diff --git a/components/frontend/src/routes/(public)/hackathon/+page.svelte b/components/frontend/src/routes/(public)/hackathon/+page.svelte index f764dae9..cb86a8ff 100644 --- a/components/frontend/src/routes/(public)/hackathon/+page.svelte +++ b/components/frontend/src/routes/(public)/hackathon/+page.svelte @@ -84,7 +84,15 @@ people invited to them. </p> - {#if data.hackathons.length === 0} + {#if data.listUnavailable} + <!-- NOT "no hackathons yet". The list could not be fetched, which is a + different fact, and saying the wrong one of the two sent people + looking for a wiped database for hours. See +page.server.ts. --> + <p class="mt-8 text-ink-3" data-testid="listUnavailable"> + The hackathon list is temporarily unavailable — we could not reach the service. + This is not an empty platform; please try again in a moment. + </p> + {:else if data.hackathons.length === 0} <p class="mt-8 text-ink-3">No hackathons have been published yet.</p> {:else} <div class="mt-6"> diff --git a/components/frontend/src/routes/+layout.server.ts b/components/frontend/src/routes/+layout.server.ts index c349fc4d..c0fd406e 100644 --- a/components/frontend/src/routes/+layout.server.ts +++ b/components/frontend/src/routes/+layout.server.ts @@ -74,9 +74,42 @@ export const load: LayoutServerLoad = async (event) => { ) const consent = parseReplayConsent(event.cookies.get(REPLAY_CONSENT_COOKIE)) + // Audience measurement, if a deployment switched it on. ONE switch, not two — + // and that difference from `replay` directly above is the deliberate part. + // + // WHY THIS IS NOT BEHIND THE REPLAY CONSENT BANNER. The banner asks one + // question, in its own words: may we RECORD YOUR SESSION. Consent is scoped + // to what was asked, so reusing that answer to authorise a second, different + // collection would be helping ourselves to permission nobody gave — the same + // reason a registration consent could not be reused for replay + // ($lib/utils/replayConsent). A second banner was the other option and is + // worse for everyone: two asks on a first visit, for one question that has a + // real answer and one that does not need asking. + // + // What makes "does not need asking" true here is a property, not a vendor + // claim: nothing is stored in or read from the visitor's browser — no + // cookie, no localStorage, no fingerprint — which is the thing consent is + // required for under ePrivacy, and it is why Plausible was picked over the + // alternatives. What remains is an IP address processed in transit to + // compute a daily-salted hash, which is a legitimate-interest processing + // decision the deployment makes and STATES (docs/frontend/analytics.md), + // not a box to trick someone into ticking. + // + // The visitor's own signal is still honoured: DNT/GPC suppresses the script + // entirely, client-side, before it is fetched (PlausibleAnalytics.svelte). + // And there is nothing here to withdraw later, because there is nothing + // stored to withdraw — which is the whole difference between the two + // features on this page. + const plausible = event.locals.config?.plausible + const plausibleConfig = + plausible?.enabled && plausible.scriptUrl && plausible.domain + ? { scriptUrl: plausible.scriptUrl, domain: plausible.domain } + : null + return { session: event.locals.session, publicOrigin: `${proto}://${host}`, + plausible: plausibleConfig, replay: { configured, consent, diff --git a/components/frontend/src/routes/+layout.svelte b/components/frontend/src/routes/+layout.svelte index f2410b11..207c38d8 100644 --- a/components/frontend/src/routes/+layout.svelte +++ b/components/frontend/src/routes/+layout.svelte @@ -14,6 +14,11 @@ // The ask. Same reasoning about where it is mounted: the tracker would run // on public pages, so the question has to be answerable there. import ReplayConsentBanner from '$lib/components/observability/ReplayConsentBanner.svelte'; + // Audience measurement. Same root-layout mounting and the same "absent + // config ⇒ absent script" rule as session replay; unlike replay it is not + // behind the consent banner, and +layout.server.ts is where that decision + // is argued. + import PlausibleAnalytics from '$lib/components/observability/PlausibleAnalytics.svelte'; import type { LayoutData } from './$types'; const { children, data }: { children: import('svelte').Snippet; data: LayoutData } = @@ -21,6 +26,7 @@ </script> <SessionReplay config={data.replay.config} /> +<PlausibleAnalytics config={data.plausible} /> {@render children()} diff --git a/components/frontend/src/themes/hackagon.css b/components/frontend/src/themes/hackagon.css index c81035bf..c68b84f4 100644 --- a/components/frontend/src/themes/hackagon.css +++ b/components/frontend/src/themes/hackagon.css @@ -593,4 +593,27 @@ background-color: color-mix(in oklab, var(--color-accent) 20%, transparent); color: var(--color-accent-ink); } + /* The selected chip, hovered. Without this the pointer ERASED the selection: + * `.chip:hover` is (0,2,0) and `.chip-active` is (0,1,0), so the tab the + * viewer is on lost its accent tint for exactly as long as they pointed at + * it — the one moment they are most likely to be reading which tab is which. + * + * A rule of its own rather than raising `.chip-active`, for two reasons. + * `.chip-active` is also worn by a `btn btn-icon btn-quiet` (the markdown + * editor's table toggle), so `.chip.chip-active` would have left that one + * untinted; and `.btn-quiet:hover` is (0,2,0) too, so the same erasure was + * happening there. This selector is (0,2,0) and covers both. + * + * ⚠ It TIES with `.chip:hover` and `.btn-quiet:hover` and wins on source + * order, which is the same way `.chip-active` already beats `.chip`. Keep it + * last: moved above either of those it stops applying and nothing but the + * computed-style check in `24-chip-states.spec.ts` would notice. + * + * 20% → 30% is the step `.btn-accent`/`.btn-accent:hover` already uses, so + * selected-and-hovered reads as both: still accent (not the neutral + * `raised` an unselected chip gets), and visibly lifted from selected-at-rest. */ + .chip-active:hover { + background-color: color-mix(in oklab, var(--color-accent) 30%, transparent); + color: var(--color-accent-ink); + } } diff --git a/docs/README.md b/docs/README.md index b6eab0f6..83e3d388 100644 --- a/docs/README.md +++ b/docs/README.md @@ -7,27 +7,28 @@ per-hackathon RBAC. The whole stack runs locally under a Nix dev shell driven by ## Guides -| Document | Answers | -| ---------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------- | -| [architecture.md](architecture.md) | How do the pieces fit together — repo layout, runtime topology, request flow, codegen? | -| [getting-started.md](getting-started.md) | How do I get a working environment and run/seed/inspect the stack? | -| [architecture-model.md](architecture-model.md) | The architecture as C4 (context / containers / components), channels, and the endpoint catalogue — generated from the model. | -| [backend/services.md](backend/services.md) | Which gRPC services and RPCs exist, and what does each handler do? | -| [backend/data-model.md](backend/data-model.md) | What tables/entities exist and how do they relate? | -| [backend/rbac.md](backend/rbac.md) | How are permissions modelled and enforced (casbin roles, domains, policy rows)? | -| [backend/rpc-journal.md](backend/rpc-journal.md) | The off-by-default RPC journal: what it records, what it never reads, and how it seeds recipe actions. | -| [frontend/routes-and-auth.md](frontend/routes-and-auth.md) | Which routes exist, which are public, and how does the session/auth guard work? | -| [frontend/grpc-clients.md](frontend/grpc-clients.md) | How does the SvelteKit server talk to the backend, and how are gRPC errors translated to HTTP? | -| [frontend/session-replay.md](frontend/session-replay.md) | Session replay: what is recorded, when, on whose say-so, how consent is withdrawn, and how long recordings live. | -| [user-flows.md](user-flows.md) | What does the platform look like to a visitor, participant, organizer and admin — screen by screen, desktop and phone? | -| [lifecycle.md](lifecycle.md) | What is the end-to-end hackathon lifecycle, from publication through voting and prizes? | -| [testing.md](testing.md) | What test suites exist (Go, Vitest, Playwright e2e) and how do I run them? | -| [backend/schema.dbml](backend/schema.dbml) | The data model as DBML — paste into [dbdiagram.io](https://dbdiagram.io/d) for an interactive diagram. | -| [TODO.md](TODO.md) | Known bugs, open decisions, and the cleanup checklist (from the 2026-08-04 code audit). | -| [requirements.md](requirements.md) | Requirements summary (backend/frontend split, per-act themes) generated from the executable spec. | -| [roadmap.md](roadmap.md) | What is MVP vs Core, the designed user flow scored step by step, and what is explicitly unscheduled. | -| [glossary.md](glossary.md) | The domain vocabulary, code-grounded — including the collision-prone terms (capability, member, phase…). | -| [infrastructure.md](infrastructure.md) | What runs today vs what production needs; a paperdraw.dev build sheet with real load profiles. | +| Document | Answers | +| ---------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------- | +| [architecture.md](architecture.md) | How do the pieces fit together — repo layout, runtime topology, request flow, codegen? | +| [getting-started.md](getting-started.md) | How do I get a working environment and run/seed/inspect the stack? | +| [architecture-model.md](architecture-model.md) | The architecture as C4 (context / containers / components), channels, and the endpoint catalogue — generated from the model. | +| [backend/services.md](backend/services.md) | Which gRPC services and RPCs exist, and what does each handler do? | +| [backend/data-model.md](backend/data-model.md) | What tables/entities exist and how do they relate? | +| [backend/rbac.md](backend/rbac.md) | How are permissions modelled and enforced (casbin roles, domains, policy rows)? | +| [backend/rpc-journal.md](backend/rpc-journal.md) | The off-by-default RPC journal: what it records, what it never reads, and how it seeds recipe actions. | +| [frontend/routes-and-auth.md](frontend/routes-and-auth.md) | Which routes exist, which are public, and how does the session/auth guard work? | +| [frontend/grpc-clients.md](frontend/grpc-clients.md) | How does the SvelteKit server talk to the backend, and how are gRPC errors translated to HTTP? | +| [frontend/session-replay.md](frontend/session-replay.md) | Session replay: what is recorded, when, on whose say-so, how consent is withdrawn, and how long recordings live. | +| [user-flows.md](user-flows.md) | What does the platform look like to a visitor, participant, organizer and admin — screen by screen, desktop and phone? | +| [lifecycle.md](lifecycle.md) | What is the end-to-end hackathon lifecycle, from publication through voting and prizes? | +| [testing.md](testing.md) | What test suites exist (Go, Vitest, Playwright e2e) and how do I run them? | +| [backend/schema.dbml](backend/schema.dbml) | The data model as DBML — paste into [dbdiagram.io](https://dbdiagram.io/d) for an interactive diagram. | +| [TODO.md](TODO.md) | Known bugs, open decisions, and the cleanup checklist (from the 2026-08-04 code audit). | +| [requirements.md](requirements.md) | Requirements summary (backend/frontend split, per-act themes) generated from the executable spec. | +| [roadmap.md](roadmap.md) | What is MVP vs Core, the designed user flow scored step by step, and what is explicitly unscheduled. | +| [glossary.md](glossary.md) | The domain vocabulary, code-grounded — including the collision-prone terms (capability, member, phase…). | +| [infrastructure.md](infrastructure.md) | What runs today vs what production needs; a paperdraw.dev build sheet with real load profiles. | +| [deployment.md](deployment.md) | The Helm chart: what an operator must supply, what will bite them, and the k3d rig that proved it (two modes, what each can prove). | ## Generated references diff --git a/docs/deployment.md b/docs/deployment.md new file mode 100644 index 00000000..480911e0 --- /dev/null +++ b/docs/deployment.md @@ -0,0 +1,628 @@ +# Deploying on Kubernetes — the Helm chart, and the rig that proved it + +`helm-chart/` installs the whole platform: frontend, backend, Keycloak and +Postgres, with two Ingresses and a third for uploads. It renders, it lints, and +until 2026-08-14 **nothing had ever installed it** — which is how five bugs +survived in a chart that looked fine in every diff. + +This page has two halves and they are for different people. + +| If you are… | Read | +| --------------------------------- | -------------------------------------------------------------------------------------------------------------- | +| **deploying this for real** | [Part 1](#part-1--deploying-it) — what you supply, what will bite you, what the chart deliberately does not do | +| **testing or changing the chart** | [Part 2](#part-2--testing-the-chart-locally) — the k3d rig, its two modes, and what each can and cannot prove | + +Every hostname below is `example.org`. Nothing tracked in this repository names +a real zone, and that is a rule rather than a habit: a tunnel hostname once sat +committed in a tracked config and outlived the tunnel it named. + +--- + +## Part 1 — deploying it + +### What the chart deploys, and what it does not + +Deployed: `frontend` (SvelteKit, adapter-node) and `backend` (Go, gRPC) as +Deployments with a Service each; Keycloak and Postgres as subchart dependencies +(`repo.helmforge.dev` 3.x and Bitnami 18.x, vendored in `helm-chart/charts/`); +an Ingress for the app, one for Keycloak, and one for `/objects`. + +**Not deployed, on purpose:** + +- **The object store.** A bucket is the one piece of state here that must + outlive the release, and a StatefulSet inside an application chart invites + `helm uninstall` to take the uploads with it. You point the chart at S3, Ceph + RGW, MinIO — whatever the platform already runs and already backs up. +- **The bucket policy.** See + [the object store](#the-object-store-is-yours-and-so-is-its-policy); a store + without it answers 403 to every image. +- **Certificates.** The chart writes + `cert-manager.io/cluster-issuer: letsencrypt-production` as a default + annotation and names TLS secrets; it installs no issuer and mints nothing. +- **Anything below the pod.** There is no ServiceAccount, PodSecurityContext, + NetworkPolicy, PodDisruptionBudget, HPA, `nodeSelector`, `tolerations`, + `affinity` or `imagePullSecrets` in any template — verified by grep across + `helm-chart/templates/`. The images are public on GHCR, so the last one has + not been needed yet. +- **A backend Ingress.** gRPC is cluster-internal; the SvelteKit server is the + only client. That is deliberate — see + [frontend/grpc-clients.md](frontend/grpc-clients.md). + +### What you must supply + +The chart refuses to render without these. The list is not from reading +`values.yaml` — it is what `helm template` demanded, one failure at a time, +until it rendered (25 objects, `helm lint` clean): + +| Value | Why it is required | +| ------------------------------------------ | -------------------------------------------------------------------------------- | +| `backend.config.server.adminkeycloakid` | the Keycloak `sub` of the platform admin; the backend refuses to boot without it | +| `backend.config.database.postgresPassword` | the app's Postgres user | +| `frontendSecrets.clientSecret` | the OIDC client secret for `hackagon-frontend` | +| `frontendSecrets.authSecret` | Auth.js session signing key | +| `keycloak.hostname.hostname` | full URL with scheme; Keycloak 26 in production mode | +| `keycloak.database.external.host` | e.g. `hackagon-postgresql` | +| `keycloak.database.external.password` | Keycloak's own Postgres user | +| `storage.endpoint` | when `storage.enabled` (the default) | +| `storage.bucket` | " | +| `storage.existingSecret` | " — the chart **refuses** to read storage credentials from values | + +`baseDomain` has a default (`example.com`) that is wrong everywhere, and +`postgresql.auth.postgresPassword` is _not_ render-required — left empty, the +Bitnami subchart looks the existing Secret up and, failing that, +`randAlphaNum 24`s one. That lookup is what makes `helm upgrade` against a live +cluster safe, and it is exactly what a rendered pipeline does not have: +`helm template` piped to `kubectl apply`, or any GitOps flow that renders +without cluster access, mints a _new_ password on every render while the +database keeps the old one. Set it explicitly. + +A minimal file, then: + +```bash +helm lint ./helm-chart -f my-values.yaml +helm template hackagon ./helm-chart -f my-values.yaml | less +``` + +Both were run while writing this. Read the render before installing: every +template in this chart carries its reasoning in comments, and the rendered +output is where you find out that `frontend.ingress.ingressClass` still defaults +to `webapprouting.kubernetes.azure.com` — the AKS app-routing addon. + +Storage credentials come from a Secret you create out of band: + +```bash +kubectl -n hackagon create secret generic hackagon-storage \ + --from-literal=accessKey=… --from-literal=secretKey=… +``` + +⚠ **The backend's env loader splits any value containing a SPACE into a list.** +An access key with a space in it will not arrive intact. + +Then: + +```bash +helm upgrade --install hackagon ./helm-chart \ + --namespace hackagon --create-namespace \ + -f my-values.yaml --timeout 15m +``` + +**The release must be called `hackagon`.** `values.yaml` sets +`keycloak.realmImport.existingConfigMap: hackagon-realm`, while the ConfigMap +the chart creates is `<fullname>-realm`. Rendered as release `hackapp`, the +chart creates `hackapp-hackagon-realm` and Keycloak mounts `hackagon-realm` — a +ConfigMap that does not exist. `hackagon-keycloak-init` (the Postgres initdb +script) is hard-coded on both sides, so it survives a rename but collides +between two releases in one namespace. + +### The object store is yours, and so is its policy + +Uploads go **from the browser straight to the store** over a presigned URL; the +bytes never pass through the app. The design is [storage.md](storage.md) — read +it before wiring a bucket, not after. + +What the chart needs from you: a bucket, an endpoint, credentials in a Secret, +and **a per-prefix policy**: + +| Prefix | Access | Holds | +| --------------- | ----------- | -------------------------------------------- | +| `hackathons/*` | public-read | event logos, gallery photos | +| `users/*` | public-read | avatars | +| `site/*` | public-read | platform-page imagery | +| everything else | **private** | `teams/*` submissions above all, and exports | + +**A store without that policy answers 403 to every image.** The upload succeeds, +the backend hands back a `publicUrl` it has no way to know is unreadable, and +the row in the database is correct — so the failure looks like a broken +frontend. It has happened once already, when `SITE_MEDIA` was added to the +upload rules and not to the policy. + +`.devcontainer/rustfs-init.sh` is the reference: it carries the exact policy +document (`put_public_policy`) and a `--selftest` that proves both halves — +public prefixes readable with no credentials, private ones refused, with a +presigned GET as the positive control so the 403 is a refusal and not a miss. It +resolves its endpoint and credentials from `RUSTFS_ENDPOINT`, +`HACKAGON_RUSTFS_ACCESS_KEY`, `HACKAGON_RUSTFS_SECRET_KEY` and +`HACKAGON_RUSTFS_BUCKET`, so it can be pointed at a real bucket — _not run +against one while writing this_, only against the k3d rig's store. + +`storage.enabled: false` is honest rather than absent: the chart writes an +**empty** endpoint into the backend config so the storage RPCs answer +`Unavailable`. Omitting the key would leave the backend on its development +default (`http://rustfs:9000`, with the committed dev keys). + +### The `/objects` Host rewrite — the failure nobody diagnoses + +SigV4 signs the `Host` header. The backend signs the **store's** hostname, +because that is the only name it knows. A reverse proxy passes the **incoming** +host through by default, the store recomputes a different signature, and every +presigned PUT answers `403 SignatureDoesNotMatch`. + +**Public reads are unsigned and keep working.** That asymmetry is why it hid for +days at the tunnel edge: every page renders, every image loads, and only people +uploading find out. + +The chart writes the fix for you, on a **separate** Ingress +(`<release>-objects`) because `upstream-vhost` and `rewrite-target` are +per-Ingress annotations and applying them to the app's own rules would rewrite +every application URL: + +``` +nginx.ingress.kubernetes.io/upstream-vhost: <store host[:port]> +nginx.ingress.kubernetes.io/rewrite-target: /$2 +nginx.ingress.kubernetes.io/use-regex: "true" +nginx.ingress.kubernetes.io/proxy-body-size: 64m +nginx.ingress.kubernetes.io/backend-protocol: HTTP|HTTPS +``` + +**The observed negative control**, run against the k3d rig on 2026-08-14 — same +store, same presigned URL, one annotation apart: + +| Route | Result | +| ------------------------------------------------- | ------------------------------ | +| the chart's `/objects` Ingress | `200` | +| an Ingress identical **but for `upstream-vhost`** | `SignatureDoesNotMatch` | +| the chart's route again, **same signature** | `200` | +| public prefix, no credentials | `200` | +| `teams/` (private) | `403` | +| `/objectsnotaprefix` | `303` — the app, not the store | + +The third row is what makes the second mean anything: without it, the refusal is +equally consistent with the URL having gone stale. The last row is the `(/|$)` +boundary in the path regex doing its job — the prefix must not swallow +application routes. + +**This is ingress-nginx only.** The AKS app-routing addon is ingress-nginx, so +it works there. Traefik cannot express a Host rewrite in a core Ingress object +at all — it needs `passHostHeader: false` plus a `Headers` middleware CRD. On +Traefik, set `storage.objects.ingress.enabled: false` and let the frontend proxy +`/objects` instead: the app owns that path as a fallback +(`src/routes/objects/[...path]/+server.ts`) and re-issues the request to +`STORAGE_ENDPOINT`, so `fetch` sets Host from that URL and the signature still +matches. It works — it just puts every uploaded byte through the app server, +which is what presigned URLs exist to avoid. + +`64m` is not arbitrary: the largest rule the backend signs is a 50 MiB +submission attachment (`internal/service/storage_service.go`), and nginx +defaults to `1m`, which would 413 almost every image. + +Left empty, `storage.objects.ingress.service.name` makes the chart render an +**ExternalName** Service for the endpoint's hostname — that is what makes an +out-of-cluster store routable from an Ingress. ingress-nginx can be told to +refuse those, and the switch is the controller's **command-line flag** +`--disable-svc-external-name`, not a ConfigMap key. On a cluster that sets it, +name an in-cluster Service here or disable the route. + +### The scheme has to be observed, not guessed + +adapter-node does not know what scheme the browser used. With neither `ORIGIN` +nor `PROTOCOL_HEADER` set, **its inference is the literal string `https`**, and +everything is built from that guess: the URLs `/auth/providers` advertises, the +target of the sign-in form, SvelteKit's CSRF comparison, and whether Auth.js +puts the `__Secure-` prefix on its session cookie. + +`frontend.protocolHeader` (default `x-forwarded-proto`) makes it observed. +Measured on the rig on 2026-08-14, app reached over plain http: + +```console +$ curl -s http://app.hackagon.localhost:8090/auth/providers +{"keycloak":{…,"callbackUrl":"http://app.hackagon.localhost:8090/auth/callback/keycloak"}} + +$ kubectl -n hackagon get deploy hackagon-frontend -o jsonpath=\ +'{.spec.template.spec.containers[0].env[?(@.name=="PROTOCOL_HEADER")].value}' +x-forwarded-proto +``` + +Without it, that line reads `https://…` on an origin nothing serves: the sign-in +form POSTs nowhere, the CSRF cookie is issued `Secure` over http and never +stored, and **every page still returns 200**. + +⚠ **The header has to be TRUE, which is a property of your ingress, not of this +chart.** ingress-nginx sends `X-Forwarded-Proto: $pass_access_scheme`, which +defaults to the scheme of the connection _it_ accepted. If TLS terminates in +front of the controller (a cloud LB, a CDN, a tunnel), the controller sees http +and will happily tell the app so. The controller then needs +`use-forwarded-headers: "true"` — and, as the rig's own +`manifests/ingress-nginx-values.yaml` warns in the same breath, that means any +client able to reach the controller directly can claim to be on https, so pair +it with `proxy-real-ip-cidr` scoped to the proxies you actually have. + +Keep `frontend.config.cookies.useSecure` in step with the real scheme. + +### A config-only upgrade used to change nothing at all + +Fixed 2026-08-14. It is written up rather than deleted because **the symptom was +that there was no symptom**: `helm upgrade` reported success, +`kubectl get configmap` showed the new value, and every running pod went on +serving the old one. Rotating the OIDC client secret that way changed nothing +that was running. + +Two things combined, and either alone would have been survivable: + +1. **`subPath` mounts never receive updates.** Three of them — the backend's + `config.yaml`, the frontend's `config.yaml` and its `secrets.yaml`. The + kubelet refreshes a plain ConfigMap volume; a `subPath` mount is resolved + once, at container start. That is Kubernetes behaviour, not a chart bug. +2. **No template carried a `checksum/config` annotation.** So after a + config-only upgrade the Deployment's pod template was byte-identical, and + Kubernetes correctly did nothing. + +Measured on the k3d rig before the fix, changing one frontend config value: +`helm upgrade` returned in **0.9 s** with status `deployed`, the ConfigMap held +the new value, and the frontend pod — **same pod name, same +`metadata.generation`** — still served the old one. It was never restarted, +because nothing asked it to be. + +The fix is the standard idiom: a pod-template annotation whose value is a hash +of the rendered config, so changing the config changes the pod template and the +Deployment rolls itself. + +```yaml +checksum/config: + { + { + include (print $.Template.BasePath "/backend-configmap.yaml") . | + sha256sum, + }, + } +``` + +Three of them: `checksum/config` on the backend, and `checksum/config` + +`checksum/secret` on the frontend. After the fix, the same experiment leaves the +running pod holding the new value with nobody restarting anything. + +⚠ **Hashing rendered output is only safe while the render is deterministic.** +One non-deterministic byte in a hashed template and every upgrade rolls every +pod forever — a worse bug wearing this one's clothes. This chart has a real +source of such bytes: the Bitnami and Keycloak subcharts mint passwords during +rendering, and `helm template` run twice produces two different Keycloak +`admin-password`s (and two different `postgres-password`s whenever +`postgresql.auth.postgresPassword` is left empty). **None of it reaches the +hashed templates** — verified by rendering twice, in that exact state, and +finding all three checksums byte-identical while the subchart Secrets differed. +The structural reason is that the DB password these ConfigMaps write comes from +`backend.config.database.postgresPassword`, which is `required`, so it can only +come from values. `verify.sh` now asserts it on every run. + +**What it does not cover, and what the annotation costs:** + +- **Editing a ConfigMap or Secret directly** — `kubectl edit`, or any controller + writing to it — still does not reach a running pod. The `subPath` mount is + unchanged; only a pod-template change rolls it, and Helm is what produces one. + Outside Helm, `kubectl rollout restart` remains the answer. +- **Keycloak and Postgres are the subcharts' business.** The Keycloak subchart + already carries its own `checksum/secrets`; nothing here changed for either. +- **`checksum/secret` puts a hash of secret material into the pod spec**, which + anyone with `get deployment` can read while the Secret itself needs + `get secret`. A sha256 is not the secret, but it _is_ an oracle: someone who + guesses `frontendSecrets.clientSecret` can confirm the guess without asking + Keycloak. Kept anyway, because every alternative is worse — any value that + changes when the secret changes is the same oracle, and a value that does not + change is the original bug. What bounds it is entropy: keep `clientSecret` and + `authSecret` long and random, which they must be regardless. + +### `keycloakHost` and the certificate that does not cover it + +`hackagon.keycloakHost` derives `auth.{baseDomain}`, and +`frontend.ingress.hosts[].host` has always been free-form. So the moment the app +is _not_ at `app.{baseDomain}`, the two part company — and if `baseDomain` is +the app's own one-label name, Keycloak lands **one label deeper**. + +That matters because **Cloudflare's free Universal SSL covers the apex plus one +label and nothing below it**. Measured against the edge before any record was +created (`e0d2f6d2`): a two-label name answers the TLS handshake with alert 40, +`handshake_failure`; a one-label sibling gets the zone's real certificate. A +browser reads that as a broken site, not as a missing certificate — so the +deployment publishes the product on a certificate that does not cover its login. + +`keycloak.ingress.host` overrides it (with the same `{baseDomain}` / +`{releaseName}` substitution as every other host value), defaulting to the old +derivation so no existing deployment changes. + +⚠ **The Keycloak hostname appears in five values and none derives from +another.** Change it and change all of them: + +```yaml +keycloak: + hostname: { hostname: "https://auth-app.example.org" } # subchart, full URL + ingress: { host: "auth-app.example.org" } # the Ingress rule + TLS +frontend: + config: { oidc: { issuer: "https://auth-app.example.org/realms/hackagon" } } +backend: + config: + oidc: + issuerurl: "https://auth-app.example.org/realms/hackagon" + jwksurl: "https://auth-app.example.org/realms/hackagon/protocol/openid-connect/certs" +``` + +`issuerurl` is a **string compared** against the `iss` claim, so it must be the +public URL. `jwksurl` is a URL the backend **fetches**, so it may take the short +in-cluster path — the rig does exactly that. + +### Five bugs a real install found + +Each is fixed in the chart. They are here because **the symptom is how you +recognise the next one**, and none of them showed up in a rendered manifest. + +1. **The product serves; login does not.** `keycloak-ingress.yaml` hard-coded + `ingressClassName: webapprouting.kubernetes.azure.com`. On any cluster that + is not the AKS app-routing addon, no controller claimed that Ingress: the app + answered and `auth.<baseDomain>` answered nothing at all. + `keycloak.ingress.enabled` was decoration too — the object rendered whatever + it said, and `values.yaml` said `false`. + +2. **Install succeeds, backend crash-loops, reason only in a pod log.** + `backend.config.server.adminkeycloakid` shipped empty and + `internal/config/config.go` refuses the whole configuration without it. The + chart is `required` at render time now — ten seconds instead of ten minutes. + +3. **The OIDC callback 502s, after login has already succeeded.** The session + JWT carries Keycloak's access and refresh tokens, so `Set-Cookie` on + `/auth/callback/keycloak` is chunked and multi-kilobyte, and nginx's 4k + `proxy_buffer_size` refuses it rather than truncating. Keycloak had + authenticated, the code had been exchanged, and the frontend logged + `JWT Callback: Initial sign-in successful` with the right user id. Only the + browser sees the 502, and only on the last redirect. + `frontend.ingress.proxyBufferSize: "16k"` is written as an annotation now. + **This hits the AKS addon too** — same controller, same default. + +4. **The frontend advertised an origin that does not exist.** adapter-node's + guess; see [above](#the-scheme-has-to-be-observed-not-guessed). + +5. **A kill switch documented in the wrong place.** `values.yaml` named the + ExternalName switch as `disable-service-external-name` "in the controller + ConfigMap". Setting that key changes nothing — no warning, same upstream in + the access log. The real switch is the flag `--disable-svc-external-name`. A + cluster believed to have blocked ExternalName that way has not. + +### Known broken, recorded rather than fixed + +- **`hackagon.frontendHost` ignores `frontend.ingress.hosts` entirely.** It is + `app.{baseDomain}`, full stop, and it is what the realm ConfigMap rewrites the + OIDC client's redirect URIs to. Rendered with the app at + `hackathons.example.org`, the realm still says `https://app.example.org/*` — a + hostname that does not exist — and Keycloak answers the login with + `Invalid parameter: redirect_uri`, which names the parameter and not the + mistake. **If your app is not at `app.{baseDomain}`, fix the redirect URIs on + the running Keycloak; a realm imports once, so an upgrade cannot change them + anyway.** + +- **`NOTES.txt` prints the wrong URL on every install.** + `Frontend: https://{{ .Values.baseDomain }}` — not `app.{baseDomain}`, not + `frontend.ingress.hosts` — and the Keycloak line ignores + `keycloak.ingress.host`. Verified with `helm install --dry-run`. Same family + as the item above. + +- **The backend hard-exits on any dependency that is not up yet.** Postgres + refusing connections (`create schema`) and Keycloak's JWKS endpoint not + answering (`create server`) both reach `logx.Fatal` in `cmd/service/main.go`. + On a fresh install Keycloak takes ~90 s (image pull plus schema migration) and + the backend crash-loops until then. It self-heals — the rig's own cluster + shows `RESTARTS 3`, the last termination being + `dial tcp …:5432: connect: connection refused`, exit 1 — so nothing is broken. + **The cost is diagnostic: this is indistinguishable from bug 2, which looks + identical and never recovers.** Either retry at startup or give the Deployment + an init container that waits. + +- **`livenessProbe` on the frontend is `GET /`, which renders the landing + page.** Both of that page's backend calls are caught today + (`(public)/+page.server.ts` carries `listUnavailable` precisely so an outage + and an empty platform stop looking alike), so a backend outage no longer fails + the probe — but it still costs up to five gRPC calls per pod every 15 s to + answer "is this process alive", and liveness and readiness use the same path, + so neither distinguishes wedged from degraded. Liveness wants a route that + does not fan out. + +- **`postgresPassword` renders into a ConfigMap in plaintext.** Two of them: + `<release>-backend-config` (the app DB password, inside `config.yaml`) and + `hackagon-keycloak-init` (both DB passwords, inside the initdb SQL). A + ConfigMap is readable by anything with `get` on the namespace. The storage + credentials are handled correctly — Secret only, and the chart refuses to read + them from values at all — so the pattern exists; the DB passwords have not + been moved to it. + +- **`keycloak.database.external.database` / `.user` are ignored** by the + subchart, which reads `name` / `username`. They happen to carry the same + strings as the subchart's defaults, so it works by coincidence. + `keycloak.persistence` is ignored outright — the subchart has no such value. + +- **`--set-file realmJson=./tools/configs/keycloak/realm-hackagon.json` imports + the development accounts.** That export carries alice, bob, charles and + hackagon-admin with real password credential hashes, `emailVerified` true and + no password policy. It is the right file for a test cluster and the wrong file + for anything reachable. Omit `realmJson` and configure the realm yourself, or + export one that has no users in it. + +- **Three helpers in `_helpers.tpl` are defined and never used**: + `hackagon.serviceAccountName`, `hackagon.randAlphaNum`, + `hackagon.getPassword`. The first would nil-pointer if anything called it — + there is no `serviceAccount` key in `values.yaml`. + +### What has never been tested on a cluster + +Written down in `b98fbdda` rather than left to be discovered: real TLS and +cert-manager, virtual-hosted-style storage (`usePathStyle: false`), storage +disabled, the frontend-proxy `/objects` fallback, more than one replica of +anything, PVC-backed Postgres, and Traefik. Real edge-terminated TLS has since +been covered by the rig's tunnel mode (below); the rest has not. + +--- + +## Part 2 — testing the chart locally + +`.claude/skills/k3d-chart-rig/` installs the chart on a throwaway k3d cluster +and then makes the arguable claims observable. Its `SKILL.md` is the reference — +what follows is why it is shaped the way it is. + +```bash +bash .claude/skills/k3d-chart-rig/scripts/up.sh # ~4 min cold, ~90 s warm +bash .claude/skills/k3d-chart-rig/scripts/verify.sh # 55 checks, ~4 min +bash .claude/skills/k3d-chart-rig/scripts/install.sh --restart # iterate on the chart +bash .claude/skills/k3d-chart-rig/scripts/down.sh # delete the cluster +``` + +Three decisions carry the rig. **Traefik is disabled and ingress-nginx installed +in its place** — with the bundled controller the one thing this exists to test +cannot work. **The controller listens on 8090 in-cluster as well as on the +host**, because the OIDC issuer is one string that both the browser and the +frontend pod must reach, and Auth.js rejects a discovery document whose `issuer` +differs from the configured one. And **`*.localhost`, not nip.io** — whether a +wildcard-DNS service resolves is a property of whoever runs DNS for the +developer, and the resolver on the machine this was written on applies +DNS-rebinding protection and refuses. + +It runs from the **host**, not the devcontainer: `.devcontainer/` mounts no +Docker socket and the image has no `docker` CLI. `verify.sh` never asserts "the +pods are Running" — that was true throughout the run where login was dead. + +### Mode 1 — `*.localhost`, app on http, Keycloak on https + +The asymmetry is the point, in both directions. + +**Keycloak needs TLS.** Its `AUTH_SESSION_ID` / `KC_RESTART` cookies are in the +federation scope, which is `SameSite=None`, which forces `Secure` — whatever +scheme it is reached over. Over plain http, curl discards them per the cookie +spec and the login POST comes back 400 "session expired". No chart change could +fix that; `up.sh` mints a 90-day self-signed certificate into the Secret name +the chart already defaults to. + +**The app stays on http, and that half is load-bearing.** An https-everywhere +rig agrees with adapter-node's `https` guess by accident and proves nothing. On +http, a frontend that guesses wrong advertises https callbacks, issues +`__Secure-` cookies the browser will not send back, and login dies with every +page still answering 200. That is bug 4, and this mode is where it was found. + +**What it cannot prove:** anything about a real certificate chain, and anything +about `__Secure-` — because that prefix is a rule about the **user agent**, and +curl implements no such rule. A green curl login is equally consistent with the +prefix working and with it being ignored. + +### Mode 2 — the same cluster on a real public hostname + +```bash +bash .claude/skills/k3d-chart-rig/scripts/tunnel.sh up +bash .claude/skills/k3d-chart-rig/scripts/verify.sh # the same 55, over https +bash .claude/skills/k3d-chart-rig/scripts/browser-check.sh # 13 checks a browser must answer +bash .claude/skills/k3d-chart-rig/scripts/tunnel.sh down +bash .claude/skills/k3d-chart-rig/scripts/tunnel.sh destroy +``` + +> ### ⚠ This publishes the development realm to the internet +> +> While the tunnel is up, **anyone who learns the hostname can sign in as alice, +> bob, charles or hackagon-admin with the password `aliceandbob`** — the last of +> those being a global Admin. There is no authentication in front of the tunnel, +> and an obscure hostname is not one; these names are guessable by design. +> +> Treat a tunnelled cluster as a demo you are watching, not as something to +> leave running. `tunnel.sh down` is one command, and `down.sh` stops the tunnel +> **before** deleting the cluster — in between, the public URL is a 502 from a +> healthy-looking tunnel, which is the least informative failure available. If +> it must outlive a session, put Cloudflare Access in front of it or run +> `up.sh --no-realm` and create the accounts you actually want. + +TLS terminates at the Cloudflare edge and the origin stays plain http. That is +not a shortcut — it is the shape a deployment behind any TLS-terminating proxy +has, and **the only shape in which `frontend.protocolHeader` has an input**: +cloudflared is what puts `X-Forwarded-Proto: https` on the request the cluster +receives. + +Both hostnames are one label deep because they have to be — see +[`keycloakHost`](#keycloakhost-and-the-certificate-that-does-not-cover-it). This +mode is also what removed `NODE_TLS_REJECT_UNAUTHORIZED=0`, the line +`values.k3d.yaml` calls the worst in the file: with a real certificate the +frontend does not need it, so the overlay sets `frontend.extraEnv: []` and the +pod runs with node's trust store intact. + +**What only a browser can answer** (`browser-check.sh`, 13 checks, Firefox): +signing alice in through the public URL sets `__Secure-authjs.session-token` +(`Secure; HttpOnly; SameSite=Lax`), Firefox stores it, **no unprefixed twin is +set beside it**, `/auth/session` returns alice and a Keycloak access token, and +it survives a full page load. + +### Why both modes exist — the result that argues for it + +Three experiments through the tunnel, from `e0d2f6d2`: + +| change | advertised origin | sign-in POST | +| -------------------------------------------- | ----------------- | ------------ | +| baseline | `https://…` | 302 | +| ingress-nginx `use-forwarded-headers: false` | `http://…` | **403** | +| chart `frontend.protocolHeader: ""` | `https://…` | 302 | + +The 403 is SvelteKit's own `Cross-site POST form submissions are forbidden`: the +app computed an http origin, the browser sent an https `Origin`, and the CSRF +check refused them. Every page still answered 200. + +**The third row is the honest result — under real https, removing +`protocolHeader` breaks nothing**, because adapter-node's guess is right by +accident there. Which is precisely why the default mode keeps the app on plain +http. Neither mode subsumes the other: one can catch a scheme bug and cannot +test a cookie prefix; the other is the reverse. **Keep both.** + +--- + +## How this page was checked + +Numbers and behaviours here come from one of three places, and it is worth +knowing which. + +**Run on 2026-08-14 against a live k3d cluster and `helm template`:** the +required-values list (added one failure at a time until it rendered); +`helm lint`; the rendered annotations, hosts, ExternalName Service and +`NOTES.txt`; the release-name coupling (rendered as `hackapp`); the whole +[`/objects` control table](#the-objects-host-rewrite--the-failure-nobody-diagnoses); +`/auth/providers` advertising `http://` with `PROTOCOL_HEADER` set; the +backend's restart reason read off the live pod; and the unset-`postgresPassword` +claim, by rendering the same input twice and reading two different passwords out +of the two Secrets. + +**Read from source, not run:** the liveness probe's fan-out, the five places the +Keycloak hostname is written, the unused helpers, and the plaintext passwords in +the two ConfigMaps. + +**Run on 2026-08-14, against the same live cluster, for the config-reload +section:** the bug reproduced with the fix reverted (0.9 s upgrade, ConfigMap +updated, running pod unchanged, generation frozen), the fix observed from inside +the container afterwards, three consecutive identical upgrades leaving every +generation untouched, and the two-render determinism check with the subchart +Secrets as its control. The `verify.sh` checks that pin all of it were then +themselves reverted-and-run, to see them fail. + +**Attributed, not re-measured:** the 4k `proxy_buffer_size` 502, the +ExternalName flag experiment, the `use-forwarded-headers` / `protocolHeader` +table, the `__Secure-` browser results, Cloudflare's TLS alert 40 below one +label, and the 13 browser checks — all from `b98fbdda`, `e0d2f6d2` and the rig's +`SKILL.md`. (`verify.sh` was 37 checks in those commits and is 55 now; the 18 +added on 2026-08-14 are the config-reload step above, and they were run.) + +## See also + +- [storage.md](storage.md) — why uploads work this way, and the prefix policy in + full. +- [infrastructure.md](infrastructure.md) — what runs today, and the four gaps + between it and production (I1, the casbin policy that never reloads, is the + blocker for `replicaCount > 1`). +- [`.claude/skills/k3d-chart-rig/SKILL.md`](../.claude/skills/k3d-chart-rig/SKILL.md) + — the rig in detail: costs, ports, teardown, and the two DNS traps. +- [getting-started.md](getting-started.md) — the local dev stack, which is not + this. diff --git a/docs/frontend/analytics.md b/docs/frontend/analytics.md new file mode 100644 index 00000000..a4e015eb --- /dev/null +++ b/docs/frontend/analytics.md @@ -0,0 +1,154 @@ +# Audience measurement + +Self-hosted [Plausible](https://plausible.io) Community Edition, counting page +views per SCREEN. It answers one question nothing else here can: **is this page +used at all.** The RPC journal (`docs/backend/rpc-journal.md`) records calls, +and a page whose whole job is to be read — the About page, a hackathon's news +tab, the prize table — makes no calls. Session replay +(`docs/frontend/session-replay.md`) records one browser at a time and is a +debugging instrument, not a counter. + +**OFF unless a deployment says otherwise.** No `plausible:` block in +`config.yaml` means no script tag, no request, and nothing in the console. The +dev rig that runs one is `.claude/skills/plausible-stack/` — see its SKILL.md. + +## What leaves the browser + +One POST per page view (plus an `engagement` event when the tab is hidden or +blurred, carrying the same page and a scroll depth), to the Plausible instance: + +| field | value | | +| ----- | ------------------------------------------ | ------------------------------------- | +| `u` | `https://<origin>/my/hackathon/[id]/teams` | the ROUTE TEMPLATE, never the URL | +| `d` | `hackagon.test` | which site to count it against | +| `r` | `https://github.com` or empty | the referrer's ORIGIN, never its path | +| `w` | `1280` | viewport width | +| `n` | `pageview` / `engagement` | | + +No cookie. No `localStorage`. No identifier of any kind is created in, stored +in, or read from the visitor's browser — which is the property that makes this +different from almost every other analytics product, and the reason it was +chosen. + +### The URL is never sent, and that is structural + +`components/frontend/src/lib/utils/analyticsRoute.ts` builds `u` from +SvelteKit's **route id** — `/(app)/my/hackathon/[id]/teams` — not from the +address bar. The string therefore cannot contain an id, because it never touched +one: + +- `/invite/<token>` is reported as `/invite/[token]`. That token IS a credential + (`hooks.server.ts` makes the route public precisely because the URL + authenticates the visitor), and an analytics database is not a place for + working keys. +- `/hackathon/<uuid>` is reported as `/hackathon/[id]`. +- Query strings are dropped entirely, so `utm_*` campaign parameters are never + recorded either. + +A regex scrubber over the real path was the obvious alternative and is strictly +worse: it has to enumerate what a secret looks like, so a route added next month +leaks until somebody remembers to extend the pattern. This is the same failure +mode as per-field masking opt-in, which is why session replay is default-deny. + +**The cost is real and is accepted:** Plausible can say the teams screen was +opened 40 times and can never say for which hackathon. Per-event numbers would +mean putting event ids into an aggregate store; the backend's own data answers +"what happened at event X". + +The referrer is handled the same way: an internal referrer is dropped (it is one +of our own paths, so it carries exactly the ids above — and Plausible discards +same-site referrers when computing sources anyway), and an external one is +reduced to its origin. + +## What Plausible does with the request + +Say this plainly, because "cookieless" is often read as "collects nothing": + +**Plausible sees the visitor's IP address and user agent, and hashes them.** To +count one person twice in a day without an identifier in the browser, the server +computes + +``` +user_id = hash(daily_salt, ip_address, user_agent, site_domain) +``` + +and stores **only the hash**. The salt lives in Postgres (`public.salts`) and +rotates every day, so the same person is a different number tomorrow and the +hash cannot be re-derived from a captured IP later. + +This is verified against the schema, not the vendor's page: in +`plausible_events_db`, `events_v2` and `sessions_v2` have a `user_id UInt64` and +**no column of any kind that could hold an IP or a user agent** — `verify.sh` +asks `system.columns` on every run. + +The IP is still **processed in transit**, and that is a legitimate-interest +processing decision a deployment makes, not something this document can wave +away. What it is not is storage on, or access to, the visitor's device — the +thing ePrivacy requires consent for. + +Geolocation: this rig ships **no** MaxMind/GeoNames database +(`IP_GEOLOCATION_DB` unset), so `country_code`, `region` and `city` stay empty. +A deployment that adds one starts deriving coarse location from that IP; that +would be a new decision, and it belongs in this file. + +## Consent: outside the replay banner, deliberately + +The session-replay banner asks one question, in its own words: **may we record +your session.** Consent is scoped to what was asked, so reusing that answer to +authorise a second, different collection would be helping ourselves to a +permission nobody gave. (The same argument, in the other direction, is why +replay consent could not reuse the registration consents — +`$lib/utils/replayConsent`.) + +The alternatives were weighed rather than defaulted: + +- **Behind the existing banner.** Wrong question, and it would also make the + numbers a function of who clicked yes — a "usage" figure that mostly measures + banner behaviour is worse than no figure. +- **A second banner.** Two asks on a first visit, one of which has nothing to + ask about: there is no artefact in the browser to permit or to withdraw. +- **Outside the gate, stated in this document.** Chosen. It is honest only + because of the properties above — nothing stored in the browser, no URL, no + identity — and it stops being the right answer the moment any of them change. + +What the visitor's own signal still does: **`DNT: 1` or Global Privacy Control +suppresses the script entirely**, checked before it is fetched, so the request +for the script is not made either. GPC is included because it is the signal with +legal weight in several jurisdictions and Plausible's script does not look at +it. + +## Not correlated with anything + +`tracker.setUserID()` has no equivalent here and no user id, session id or +replay id is ever sent. Nothing joins these counts to a person's rows, to their +line in the RPC journal, or to a replay session — the same rule the journal and +the replay tracker already keep between themselves. Joining them is an owner's +decision to make explicitly, never a default to drift into. + +## Turning it on + +```bash +bash .claude/skills/plausible-stack/scripts/up.sh # the instance, behind a quick tunnel +bash .claude/skills/plausible-stack/scripts/wire-frontend.sh # point the app at it +bash .claude/skills/plausible-stack/scripts/verify.sh # prove a page view lands +bash .claude/skills/plausible-stack/scripts/wire-frontend.sh --restore +``` + +Wiring writes `config.local.yaml` — the gitignored overlay — never the tracked +`config.yaml`, which must keep saying `localhost`. + +The tracker script must be the **`local.manual`** variant: `local` because the +stock script silently refuses to send from `localhost`, and `manual` because the +stock script otherwise sends `location.href` by itself, which is the one thing +this integration exists to prevent. + +## None of this is claimed, it is asserted + +| Claim | What checks it | +| ------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------ | +| no id or token on the wire | `plausible-stack/scripts/pageview.mjs` — a real Firefox, greping the captured POST bodies for the UUID and the token it just visited | +| the route template IS sent (the positive control) | same script: an absence-assertion with nothing to assert about passes for the wrong reason | +| Plausible stored the template, not an id | `verify.sh` step 7 — Plausible's own Stats API, not a peek into ClickHouse | +| no column can hold an IP | `verify.sh` step 8 — `system.columns` | +| absent config ⇒ absent script | run `pageview.mjs` unwired: zero requests leave the browser | +| the route→path mapping | `src/lib/utils/analyticsRoute.test.ts`, every absence case paired with a positive control | diff --git a/docs/infrastructure.md b/docs/infrastructure.md index 609ca5b8..dbb45199 100644 --- a/docs/infrastructure.md +++ b/docs/infrastructure.md @@ -92,6 +92,9 @@ far more informative than uniform traffic: ## See also +- [deployment.md](deployment.md) — the Helm chart that puts this topology on a + cluster, what an operator must supply, and what installing it for the first + time found. - [architecture.md](architecture.md) — the request flow and codegen pipelines behind this topology. - [getting-started.md](getting-started.md) — how to run the dev stack shown diff --git a/helm-chart/.helmignore b/helm-chart/.helmignore new file mode 100644 index 00000000..0e8a0eb3 --- /dev/null +++ b/helm-chart/.helmignore @@ -0,0 +1,23 @@ +# Patterns to ignore when building packages. +# This supports shell glob matching, relative path matching, and +# negation (prefixed with !). Only one pattern per line. +.DS_Store +# Common VCS dirs +.git/ +.gitignore +.bzr/ +.bzrignore +.hg/ +.hgignore +.svn/ +# Common backup files +*.swp +*.bak +*.tmp +*.orig +*~ +# Various IDEs +.project +.idea/ +*.tmproj +.vscode/ diff --git a/helm-chart/Chart.lock b/helm-chart/Chart.lock new file mode 100644 index 00000000..f04a776c --- /dev/null +++ b/helm-chart/Chart.lock @@ -0,0 +1,9 @@ +dependencies: +- name: postgresql + repository: https://charts.bitnami.com/bitnami + version: 18.8.6 +- name: keycloak + repository: https://repo.helmforge.dev + version: 3.0.7 +digest: sha256:15cdc5bf9f1d24c4f3beb12f85675fa57c050e4f1662d36d50e35e430eeb301a +generated: "2026-08-07T09:27:51.820259829+02:00" diff --git a/helm-chart/Chart.yaml b/helm-chart/Chart.yaml new file mode 100644 index 00000000..619bcb6e --- /dev/null +++ b/helm-chart/Chart.yaml @@ -0,0 +1,18 @@ +apiVersion: v2 +name: hackagon +description: A Helm chart for Hackagon on Kubernetes + +type: application + +version: 0.1.0 +appVersion: "1.0.0" + +dependencies: + - name: postgresql + version: 18.x + repository: https://charts.bitnami.com/bitnami + condition: postgresql.enabled + - name: keycloak + version: 3.x + repository: https://repo.helmforge.dev + condition: keycloak.enabled diff --git a/helm-chart/templates/NOTES.txt b/helm-chart/templates/NOTES.txt new file mode 100644 index 00000000..8319f7d4 --- /dev/null +++ b/helm-chart/templates/NOTES.txt @@ -0,0 +1,50 @@ +Hackagon has been deployed! + +Frontend: https://{{ .Values.baseDomain }} +Keycloak: https://auth.{{ .Values.baseDomain }} + +To get the generated frontend OIDC secrets, run: + + kubectl get secret {{ include "hackagon.fullname" . }}-frontend-secrets \ + -n {{ .Release.Namespace }} -o jsonpath='{.data.secrets\.yaml}' | base64 -d + +Passwords are set in your values files. Retrieve them with: + + # Platform (hackagon) user password + kubectl get secret {{ include "hackagon.fullname" . }}-postgresql \ + -n {{ .Release.Namespace }} -o jsonpath='{.data.postgres-password}' | base64 -d + +{{ if .Values.storage.enabled }} +Object storage is configured against {{ include "hackagon.storageEndpointURL" . }} +(bucket {{ .Values.storage.bucket }}), with credentials from the Secret +"{{ .Values.storage.existingSecret }}". + +This chart does NOT create the bucket or its policy. Uploads will succeed and +every image will then 403 until these prefixes are public-read: + + hackathons/* users/* site/* + +and everything else — teams/* above all — is left private. See +.devcontainer/rustfs-init.sh for the exact policy document and a self-test. +{{- if .Values.storage.objects.ingress.enabled }} + +{{ include "hackagon.storagePublicPrefix" . }}/* is served by its own Ingress, +which rewrites Host to {{ include "hackagon.storageSignHost" . }}. That rewrite +is what makes presigned uploads work; without it they answer 403 +SignatureDoesNotMatch while public reads keep working. +{{- else }} + +{{ include "hackagon.storagePublicPrefix" . }}/* is served by the frontend's own +fallback proxy — every uploaded byte passes through the app server. Enable +storage.objects.ingress to route it at the edge instead. +{{- end }} +{{- else }} + +Object storage is DISABLED: uploads (avatars, event logos, page media, prize +art, the gallery) will answer Unavailable. Set storage.enabled with an +endpoint, a bucket and a credentials Secret to turn them on. +{{- end }} + +Check the status of your release: + + helm status {{ .Release.Name }} -n {{ .Release.Namespace }} \ No newline at end of file diff --git a/helm-chart/templates/_helpers.tpl b/helm-chart/templates/_helpers.tpl new file mode 100644 index 00000000..8c876441 --- /dev/null +++ b/helm-chart/templates/_helpers.tpl @@ -0,0 +1,251 @@ +{{/* +Expand the name of the chart. +*/}} +{{- define "hackagon.name" -}} +{{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix "-" }} +{{- end }} + +{{/* +Create a default fully qualified app name. +We truncate at 63 chars because some Kubernetes name fields are limited to this (by the DNS naming spec). +If release name contains chart name it will be used as a full name. +*/}} +{{- define "hackagon.fullname" -}} +{{- if .Values.fullnameOverride }} +{{- .Values.fullnameOverride | trunc 63 | trimSuffix "-" }} +{{- else }} +{{- $name := default .Chart.Name .Values.nameOverride }} +{{- if contains $name .Release.Name }} +{{- .Release.Name | trunc 63 | trimSuffix "-" }} +{{- else }} +{{- printf "%s-%s" .Release.Name $name | trunc 63 | trimSuffix "-" }} +{{- end }} +{{- end }} +{{- end }} + +{{/* +Create chart name and version as used by the chart label. +*/}} +{{- define "hackagon.chart" -}} +{{- printf "%s-%s" .Chart.Name .Chart.Version | replace "+" "_" | trunc 63 | trimSuffix "-" }} +{{- end }} + +{{/* +Common labels +*/}} +{{- define "hackagon.labels" -}} +helm.sh/chart: {{ include "hackagon.chart" . }} +{{ include "hackagon.selectorLabels" . }} +{{- if .Chart.AppVersion }} +app.kubernetes.io/version: {{ .Chart.AppVersion | quote }} +{{- end }} +app.kubernetes.io/managed-by: {{ .Release.Service }} +{{- end }} + +{{/* +Selector labels +*/}} +{{- define "hackagon.selectorLabels" -}} +app.kubernetes.io/name: {{ include "hackagon.name" . }} +app.kubernetes.io/instance: {{ .Release.Name }} +{{- end }} + +{{/* +Create the name of the service account to use +*/}} +{{- define "hackagon.serviceAccountName" -}} +{{- if .Values.serviceAccount.create }} +{{- default (include "hackagon.fullname" .) .Values.serviceAccount.name }} +{{- else }} +{{- default "default" .Values.serviceAccount.name }} +{{- end }} +{{- end }} + +{{/* +Base domain with substitution +*/}} +{{- define "hackagon.baseDomain" -}} +{{- .Values.baseDomain | replace "{baseDomain}" .Values.baseDomain }} +{{- end }} + +{{/* +Frontend host with substitution +*/}} +{{- define "hackagon.frontendHost" -}} +{{- printf "app.%s" (include "hackagon.baseDomain" .) | replace "{baseDomain}" .Values.baseDomain }} +{{- end }} + +{{/* +Keycloak host with substitution. + +`auth.{baseDomain}` unless `keycloak.ingress.host` names one, and the override +exists because the derived name is a level DEEPER than the app's whenever the +app is not itself at `app.{baseDomain}` — `frontend.ingress.hosts[].host` has +always been free-form, this was not. + +That extra label is not cosmetic. A one-label wildcard certificate covers +`app.example.org` and not `auth.app.example.org`; Cloudflare's free Universal +SSL is exactly such a certificate (apex + one label, nothing deeper) and answers +a handshake for anything below it with TLS alert 40 — measured against the edge, +not deduced. A deployment fronted that way could therefore +publish the product on a certificate that does not cover its login, which is the +same shape as the hard-coded ingressClass bug this file's Keycloak Ingress +already carries a note about: the app works, the sign-in does not. + +Same `{baseDomain}` / `{releaseName}` substitution as every other host value, so +one string can stay portable across environments. +*/}} +{{- define "hackagon.keycloakHost" -}} +{{- if .Values.keycloak.ingress.host -}} +{{- .Values.keycloak.ingress.host | replace "{baseDomain}" .Values.baseDomain | replace "{releaseName}" .Release.Name }} +{{- else -}} +{{- printf "auth.%s" (include "hackagon.baseDomain" .) | replace "{baseDomain}" .Values.baseDomain }} +{{- end }} +{{- end }} + +{{/* +Frontend service name +*/}} +{{- define "hackagon.frontendServiceName" -}} +{{- printf "%s-frontend" (include "hackagon.fullname" .) }} +{{- end }} + +{{/* +Backend service name +*/}} +{{- define "hackagon.backendServiceName" -}} +{{- printf "%s-backend" (include "hackagon.fullname" .) }} +{{- end }} + +{{/* +Generate a random alphanumeric string of given length +Usage: include "hackagon.randAlphaNum" (dict "length" 32) +*/}} +{{- define "hackagon.randAlphaNum" -}} +{{- randAlphaNum .length | lower }} +{{- end }} + +{{/* +Keycloak service name (bitnami chart names it <release>-keycloak) +*/}} +{{- define "hackagon.keycloakServiceName" -}} +{{- printf "%s-keycloak" .Release.Name }} +{{- end }} + +{{/* +PostgreSQL service name (bitnami chart names it <release>-postgresql) +*/}} +{{- define "hackagon.postgresqlServiceName" -}} +{{- printf "%s-postgresql" .Release.Name }} +{{- end }} + +{{/* +Get password: use provided value or generate one +*/}} +{{- define "hackagon.getPassword" -}} +{{- if .value }} +{{- .value | b64enc }} +{{- else }} +{{- include "hackagon.randAlphaNum" .length | b64enc }} +{{- end }} +{{- end }} + +{{/* +========================================================================= +Object store (StorageService) +========================================================================= +Uploads never pass through the app: the backend signs a URL and the BROWSER +talks to the store. Everything below exists to keep the one value the browser +uses (`/objects/…`) resolving to the store with the Host the signature was +computed over. See values.yaml `storage:` for the whole story. +*/}} + +{{/* +The store endpoint, parsed. Fails the render when storage is enabled and no +endpoint was given, rather than deploying a backend that silently falls back to +its DEVELOPMENT default (http://rustfs:9000 with the committed dev keys). +*/}} +{{- define "hackagon.storageEndpointURL" -}} +{{- required "storage.endpoint is required when storage.enabled (e.g. https://s3.eu-central-1.amazonaws.com)" .Values.storage.endpoint | trimSuffix "/" }} +{{- end }} + +{{/* +The Host header SigV4 signs, and therefore the Host every proxy in front of the +store must send upstream. + +This mirrors `signHost` in components/backend/internal/storage/client.go +EXACTLY, port included: path-style signs the endpoint's host, virtual-hosted +style signs <bucket>.<host>. A mismatch here answers 403 SignatureDoesNotMatch +on every presigned PUT while unsigned public reads keep working — which is why +it can go unnoticed for days. +*/}} +{{- define "hackagon.storageSignHost" -}} +{{- $u := urlParse (include "hackagon.storageEndpointURL" .) -}} +{{- if .Values.storage.usePathStyle -}} +{{- $u.host }} +{{- else -}} +{{- printf "%s.%s" (required "storage.bucket is required when storage.enabled" .Values.storage.bucket) $u.host }} +{{- end }} +{{- end }} + +{{/* +The store's DNS name, without the port — what an ExternalName Service resolves. +*/}} +{{- define "hackagon.storageHostname" -}} +{{- $u := urlParse (include "hackagon.storageEndpointURL" .) -}} +{{- $u.hostname }} +{{- end }} + +{{/* +The store's TCP port: taken from the endpoint when it names one, otherwise the +default for its scheme. +*/}} +{{- define "hackagon.storagePort" -}} +{{- $u := urlParse (include "hackagon.storageEndpointURL" .) -}} +{{- $parts := splitList ":" $u.host -}} +{{- if gt (len $parts) 1 -}} +{{- last $parts }} +{{- else if eq $u.scheme "https" -}} +443 +{{- else -}} +80 +{{- end }} +{{- end }} + +{{/* +HTTP or HTTPS to the store, in the spelling ingress-nginx wants. +*/}} +{{- define "hackagon.storageBackendProtocol" -}} +{{- $u := urlParse (include "hackagon.storageEndpointURL" .) -}} +{{- if eq $u.scheme "https" -}}HTTPS{{- else -}}HTTP{{- end }} +{{- end }} + +{{/* +Service the /objects Ingress routes to: the one named in values, or the +ExternalName Service this chart creates for the endpoint. +*/}} +{{- define "hackagon.storageObjectsServiceName" -}} +{{- if .Values.storage.objects.ingress.service.name -}} +{{- .Values.storage.objects.ingress.service.name }} +{{- else -}} +{{- printf "%s-objects" (include "hackagon.fullname" .) }} +{{- end }} +{{- end }} + +{{- define "hackagon.storageObjectsServicePort" -}} +{{- if .Values.storage.objects.ingress.service.name -}} +{{- required "storage.objects.ingress.service.port is required when service.name is set" .Values.storage.objects.ingress.service.port }} +{{- else -}} +{{- include "hackagon.storagePort" . }} +{{- end }} +{{- end }} + +{{/* +The path uploads are served under, without a trailing slash. Both the Ingress +rule and the backend's `publicprefix` come from here, so they cannot drift: the +backend hands the browser `<publicPrefix>/<bucket>/<key>` and something has to +be listening on exactly that path. +*/}} +{{- define "hackagon.storagePublicPrefix" -}} +{{- .Values.storage.publicPrefix | default "/objects" | trimSuffix "/" }} +{{- end }} \ No newline at end of file diff --git a/helm-chart/templates/backend-configmap.yaml b/helm-chart/templates/backend-configmap.yaml new file mode 100644 index 00000000..4161f899 --- /dev/null +++ b/helm-chart/templates/backend-configmap.yaml @@ -0,0 +1,68 @@ +apiVersion: v1 +kind: ConfigMap +metadata: + name: {{ include "hackagon.fullname" . }}-backend-config + namespace: {{ .Release.Namespace }} + labels: + {{- include "hackagon.labels" . | nindent 4 }} +data: + config.yaml: | + server: + port: {{ .Values.backend.config.server.port | quote }} + adminemail: {{ .Values.backend.config.server.adminemail | replace "{baseDomain}" .Values.baseDomain | quote }} + {{- /* + Required at RENDER time because it is required at BOOT time: + internal/config/config.go refuses the whole configuration with + "server.adminkeycloakid is required" when it is empty. Shipped empty in + values.yaml, `helm install` succeeded and the backend then sat in + CrashLoopBackOff with the reason only in a pod log — observed on a fresh + install of the chart's own defaults. Failing here costs the operator ten + seconds instead of ten minutes. + */}} + adminkeycloakid: {{ .Values.backend.config.server.adminkeycloakid | required "backend.config.server.adminkeycloakid is required: the Keycloak `sub` (not the DB UUID) of the platform administrator. The backend refuses to start without it." | quote }} + database: + driver: {{ .Values.backend.config.database.driver | quote }} + host: {{ include "hackagon.postgresqlServiceName" . | quote }} + port: {{ .Values.backend.config.database.port }} + dbname: {{ .Values.backend.config.database.dbname | quote }} + user: {{ .Values.backend.config.database.user | quote }} + password: {{ .Values.backend.config.database.postgresPassword | required "postgresql.auth.postgresPassword is required" | quote }} + oidc: + jwksurl: {{ .Values.backend.config.oidc.jwksurl | replace "{baseDomain}" .Values.baseDomain | quote }} + issuerurl: {{ .Values.backend.config.oidc.issuerurl | replace "{baseDomain}" .Values.baseDomain | quote }} + algorithm: {{ .Values.backend.config.oidc.algorithm | quote }} + logging: + level: {{ .Values.backend.config.logging.level | quote }} + {{- /* + Object store. Keys are lowercase and unseparated to match the yaml tags on + config.StorageConfig and the spelling in + components/backend/data/test/config/config.yaml — the same file this one + replaces in a deployment. + + accesskey/secretkey are DELIBERATELY absent: a ConfigMap is readable by + anything with `get` on the namespace. They arrive as HACKAGON_STORAGE_* + env vars from a Secret, and env is the backend's last config layer, so it + wins over this file. + */}} + storage: + {{- if .Values.storage.enabled }} + endpoint: {{ include "hackagon.storageEndpointURL" . | quote }} + region: {{ .Values.storage.region | quote }} + bucket: {{ required "storage.bucket is required when storage.enabled" .Values.storage.bucket | quote }} + usepathstyle: {{ .Values.storage.usePathStyle }} + publicprefix: {{ include "hackagon.storagePublicPrefix" . | quote }} + {{- else }} + {{- /* + Explicitly empty, not omitted. The backend treats an empty endpoint as + "no store" and answers Unavailable; omitting the key would leave it on + its development default (http://rustfs:9000 with committed dev keys), + which fails later and less clearly. + */}} + endpoint: "" + {{- end }} + {{- if .Values.backend.config.audit.enabled }} + audit: + enabled: true + path: {{ .Values.backend.config.audit.path | quote }} + buffer: {{ .Values.backend.config.audit.buffer }} + {{- end }} diff --git a/helm-chart/templates/backend-deployment.yaml b/helm-chart/templates/backend-deployment.yaml new file mode 100644 index 00000000..dee94936 --- /dev/null +++ b/helm-chart/templates/backend-deployment.yaml @@ -0,0 +1,110 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: {{ include "hackagon.fullname" . }}-backend + namespace: {{ .Release.Namespace }} + labels: + {{- include "hackagon.labels" . | nindent 4 }} + app.kubernetes.io/component: backend +spec: + replicas: {{ .Values.backend.replicaCount }} + selector: + matchLabels: + {{- include "hackagon.selectorLabels" . | nindent 6 }} + app.kubernetes.io/component: backend + template: + metadata: + annotations: + {{- /* + THE CONFIG RELOAD. Without this line a `helm upgrade` that changes only + `backend.config` updates the ConfigMap, reports success, and changes + nothing that is running — because TWO things are true at once: + + * config.yaml is a `subPath` mount (below), and a subPath mount is + resolved ONCE at container start. The kubelet refreshes a plain + ConfigMap volume; it never refreshes this one. + * with no annotation here the pod template is byte-identical after + the upgrade, so Kubernetes correctly does nothing. + + Observed on the k3d rig before this landed: `helm upgrade` in 0.9 s, + status `deployed`, `kubectl get configmap` showing the new value, and + the running pod — same name, same `metadata.generation` — still serving + the old one. Rotating a credential that way changes nothing, with no + signal anywhere that it did not. + + The hash is over the RENDERED ConfigMap, so it covers every input that + can reach the file — values, the `{baseDomain}` substitutions, the + storage helpers — without this line having to enumerate them. + + ⚠ That only works while the render is DETERMINISTIC: one + non-deterministic byte in there and every upgrade rolls the pods + forever, which is a worse bug wearing this one's clothes. Checked, and + worth re-checking if backend-configmap.yaml grows an input: nothing in + it calls `randAlphaNum`, `uuidv4`, `now` or `lookup`, and the one + non-deterministic thing in this chart — the Bitnami subchart minting a + postgres password when `postgresql.auth.postgresPassword` is empty — + cannot reach it, because the DB password written here is `required` and + so can only come from values. Measured: two renders of identical inputs + produce an identical checksum while the subchart's own Secret differs + between them. + */}} + checksum/config: {{ include (print $.Template.BasePath "/backend-configmap.yaml") . | sha256sum }} + {{- with .Values.backend.annotations }} + {{- toYaml . | nindent 8 }} + {{- end }} + labels: + {{- include "hackagon.selectorLabels" . | nindent 8 }} + app.kubernetes.io/component: backend + spec: + containers: + - name: backend + image: "{{ .Values.backend.image.repository }}:{{ .Values.backend.image.tag }}" + imagePullPolicy: {{ .Values.backend.image.pullPolicy }} + args: + - "--config-dir=/etc/hackagon/" + {{- if .Values.storage.enabled }} + {{- /* + Object-store credentials, from a Secret and never from the + ConfigMap. HACKAGON_* env is the backend's LAST config layer, so + these win over anything in config.yaml — and the key names have no + separator on purpose: the loader lowercases and turns `_` into `.`, + so HACKAGON_STORAGE_ACCESS_KEY would become `storage.access.key` and + be quietly ignored. + */}} + env: + - name: HACKAGON_STORAGE_ACCESSKEY + valueFrom: + secretKeyRef: + name: {{ required "storage.existingSecret is required when storage.enabled — credentials must come from a Secret, not from values.yaml" .Values.storage.existingSecret }} + key: {{ .Values.storage.secretKeys.accessKey }} + - name: HACKAGON_STORAGE_SECRETKEY + valueFrom: + secretKeyRef: + name: {{ .Values.storage.existingSecret }} + key: {{ .Values.storage.secretKeys.secretKey }} + {{- end }} + ports: + - name: grpc + containerPort: 3000 + protocol: TCP + resources: + {{- toYaml .Values.backend.resources | nindent 12 }} + volumeMounts: + - name: config + mountPath: /etc/hackagon/config.yaml + subPath: config.yaml + readOnly: true + livenessProbe: + grpc: + port: 3000 + initialDelaySeconds: 10 + periodSeconds: 15 + readinessProbe: + grpc: + port: 3000 + initialDelaySeconds: 5 + periodSeconds: 10 + volumes: + - name: config + configMap: + name: {{ include "hackagon.fullname" . }}-backend-config diff --git a/helm-chart/templates/backend-service.yaml b/helm-chart/templates/backend-service.yaml new file mode 100644 index 00000000..01fd7c7d --- /dev/null +++ b/helm-chart/templates/backend-service.yaml @@ -0,0 +1,18 @@ +apiVersion: v1 +kind: Service +metadata: + name: {{ include "hackagon.fullname" . }}-backend + namespace: {{ .Release.Namespace }} + labels: + {{- include "hackagon.labels" . | nindent 4 }} + app.kubernetes.io/component: backend +spec: + type: {{ .Values.backend.service.type }} + ports: + - port: {{ .Values.backend.service.port }} + targetPort: grpc + protocol: TCP + name: grpc + selector: + {{- include "hackagon.selectorLabels" . | nindent 4 }} + app.kubernetes.io/component: backend \ No newline at end of file diff --git a/helm-chart/templates/frontend-configmap.yaml b/helm-chart/templates/frontend-configmap.yaml new file mode 100644 index 00000000..46b9afa3 --- /dev/null +++ b/helm-chart/templates/frontend-configmap.yaml @@ -0,0 +1,43 @@ +apiVersion: v1 +kind: ConfigMap +metadata: + name: {{ include "hackagon.fullname" . }}-frontend-config + namespace: {{ .Release.Namespace }} + labels: + {{- include "hackagon.labels" . | nindent 4 }} +data: + config.yaml: | + log: + forceDevLog: {{ .Values.frontend.config.log.forceDevLog }} + backend: + hostname: {{ .Values.frontend.config.backend.hostname | default (include "hackagon.backendServiceName" .) | quote }} + port: {{ .Values.frontend.config.backend.port }} + cookies: + useSecure: {{ .Values.frontend.config.cookies.useSecure }} + oidc: + clientId: {{ .Values.frontend.config.oidc.clientId | quote }} + issuer: {{ .Values.frontend.config.oidc.issuer | replace "{baseDomain}" .Values.baseDomain | quote }} + audience: {{ .Values.frontend.config.oidc.audience | quote }} + {{- /* + replay and plausible are OPTIONAL blocks. Nothing is rendered when they are + off — not `enabled: false`, not an empty map — because the frontend's zod + schema parses an absent block as disabled, and a half-written block + (enabled with no endpoint) fails validation at boot rather than starting. + */}} + {{- with .Values.frontend.config.replay }} + {{- if .enabled }} + replay: + enabled: true + ingestPoint: {{ required "frontend.config.replay.ingestPoint is required when replay is enabled" .ingestPoint | quote }} + projectKey: {{ required "frontend.config.replay.projectKey is required when replay is enabled" .projectKey | quote }} + allowInsecureOrigin: {{ .allowInsecureOrigin | default false }} + {{- end }} + {{- end }} + {{- with .Values.frontend.config.plausible }} + {{- if .enabled }} + plausible: + enabled: true + scriptUrl: {{ required "frontend.config.plausible.scriptUrl is required when plausible is enabled" .scriptUrl | quote }} + domain: {{ required "frontend.config.plausible.domain is required when plausible is enabled" .domain | quote }} + {{- end }} + {{- end }} \ No newline at end of file diff --git a/helm-chart/templates/frontend-deployment.yaml b/helm-chart/templates/frontend-deployment.yaml new file mode 100644 index 00000000..79bcd078 --- /dev/null +++ b/helm-chart/templates/frontend-deployment.yaml @@ -0,0 +1,158 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: {{ include "hackagon.fullname" . }}-frontend + namespace: {{ .Release.Namespace }} + labels: + {{- include "hackagon.labels" . | nindent 4 }} + app.kubernetes.io/component: frontend +spec: + replicas: {{ .Values.frontend.replicaCount }} + selector: + matchLabels: + {{- include "hackagon.selectorLabels" . | nindent 6 }} + app.kubernetes.io/component: frontend + template: + metadata: + annotations: + {{- /* + THE CONFIG RELOAD. Both files this pod reads are `subPath` mounts + (below), and a subPath mount is resolved ONCE at container start — the + kubelet refreshes a plain ConfigMap volume and never refreshes this + one. With no annotation here the pod template is byte-identical after a + config-only upgrade, so nothing rolls and the running pod keeps serving + the old file while `kubectl get configmap` shows the new one. The whole + story is in backend-deployment.yaml's copy of this comment. + + Two hashes because there are two mounts, and either can change without + the other. + */}} + checksum/config: {{ include (print $.Template.BasePath "/frontend-configmap.yaml") . | sha256sum }} + {{- /* + ⚠ THIS PUTS A HASH OF SECRET MATERIAL IN THE POD SPEC, which anyone + with `get deployment` on the namespace can read while the Secret itself + needs `get secret`. A sha256 is not the secret, but it IS an oracle: a + reader who guesses `frontendSecrets.clientSecret` can confirm the guess + here without asking Keycloak. + + Kept anyway, because every alternative is worse. Any value that changes + when the secret changes is the same oracle, and a value that does not + change is the bug — an OIDC client secret rotated by `helm upgrade` and + never picked up by a running pod, which is exactly the failure this + chart shipped with. What bounds it is entropy: `clientSecret` and + `authSecret` are meant to be long random strings (the k3d rig mints 24 + and 32 bytes of hex). Do not put a guessable one in `frontendSecrets` — + that was never safe, and this makes the guess cheap to check. + */}} + checksum/secret: {{ include (print $.Template.BasePath "/frontend-secrets.yaml") . | sha256sum }} + {{- with .Values.frontend.annotations }} + {{- toYaml . | nindent 8 }} + {{- end }} + labels: + {{- include "hackagon.selectorLabels" . | nindent 8 }} + app.kubernetes.io/component: frontend + spec: + containers: + - name: frontend + image: "{{ .Values.frontend.image.repository }}:{{ .Values.frontend.image.tag }}" + imagePullPolicy: {{ .Values.frontend.image.pullPolicy }} + args: + - "--config-dir=/etc/hackagon" + env: + - name: PORT + value: "3000" + {{- /* + THE PUBLIC ORIGIN. adapter-node does not know what scheme the + browser used; it INFERS one, and with neither ORIGIN nor + PROTOCOL_HEADER set its inference is the literal string "https". + Everything downstream is built from that guess: SvelteKit's CSRF + check compares the request's `Origin` header against it, and + Auth.js picks its cookie NAMES from it (`__Secure-…` when it + believes the connection is secure). + + Observed on a chart install reached over plain http: /auth/providers + advertised `https://app…:8090/auth/callback/keycloak`, the sign-in + form POSTed to an https URL nothing served, and the CSRF cookie was + never stored because it was issued `Secure` over an http + connection. Login was dead, and every page still returned 200 — + which is exactly why "the pods are Running" is not the test. + + ingress-nginx always sets X-Forwarded-Proto, so reading it makes the + scheme OBSERVED instead of assumed, and correct in both directions. + + ⚠ This is not the trap recorded in .claude/CLAUDE.md about + X-Forwarded-Proto and the tunnel. That one is about the VITE dev + server, which ignores PROTOCOL_HEADER — the header changed what + Auth.js believed while `event.url` stayed http, and the two + disagreed about the cookie name. adapter-node applies this header to + `event.url` itself, so here the two cannot disagree. Keep + `frontend.config.cookies.useSecure` in step with the real scheme + anyway. + + HOST_HEADER is deliberately left at its default (`host`): ingress + controllers forward the requested host including its port, and a + missing X-Forwarded-Host would leave the origin undefined. + */}} + {{- with .Values.frontend.protocolHeader }} + - name: PROTOCOL_HEADER + value: {{ . | quote }} + {{- end }} + {{- if .Values.storage.enabled }} + {{- /* + The app owns `/objects/*` as a FALLBACK route + (src/routes/objects/[...path]/+server.ts) for when nothing in front + of it serves that path. It re-issues the request to STORAGE_ENDPOINT, + so `fetch` sets Host from that URL and the SigV4 signature still + matches — no rewrite needed on this path. + + Set even when the /objects Ingress is on: the Ingress is the fast + path, this is what keeps the app correct if that route is removed. + No credentials here — the URL the browser presents is already + signed. + */}} + - name: STORAGE_ENDPOINT + value: {{ include "hackagon.storageEndpointURL" . | quote }} + {{- end }} + {{- /* + Escape hatch. adapter-node reads several more environment variables + (ORIGIN, ADDRESS_HEADER, XFF_DEPTH, BODY_SIZE_LIMIT) and node itself + reads NODE_EXTRA_CA_CERTS; a topology this chart did not anticipate + needs somewhere to say so without a template change. + */}} + {{- with .Values.frontend.extraEnv }} + {{- toYaml . | nindent 12 }} + {{- end }} + ports: + - name: http + containerPort: 3000 + protocol: TCP + resources: + {{- toYaml .Values.frontend.resources | nindent 12 }} + volumeMounts: + - name: config + mountPath: /etc/hackagon/config.yaml + subPath: config.yaml + readOnly: true + - name: secrets + mountPath: /etc/hackagon/secrets.yaml + subPath: secrets.yaml + readOnly: true + livenessProbe: + httpGet: + path: / + port: http + initialDelaySeconds: 10 + periodSeconds: 15 + readinessProbe: + httpGet: + path: / + port: http + initialDelaySeconds: 5 + periodSeconds: 10 + volumes: + - name: config + configMap: + name: {{ include "hackagon.fullname" . }}-frontend-config + - name: secrets + secret: + secretName: {{ include "hackagon.fullname" . }}-frontend-secrets \ No newline at end of file diff --git a/helm-chart/templates/frontend-ingress.yaml b/helm-chart/templates/frontend-ingress.yaml new file mode 100644 index 00000000..5f63b890 --- /dev/null +++ b/helm-chart/templates/frontend-ingress.yaml @@ -0,0 +1,68 @@ +{{- if .Values.frontend.ingress.enabled -}} +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: {{ include "hackagon.fullname" . }}-frontend + namespace: {{ .Release.Namespace }} + labels: + {{- include "hackagon.labels" . | nindent 4 }} + app.kubernetes.io/component: frontend + annotations: + {{- /* + ⚠ LOAD-BEARING, and the failure it prevents is the last hop of login. + + Auth.js keeps the session in a JWT cookie, and this app puts the Keycloak + access token, refresh token and expiry inside it (src/auth.ts). The + resulting Set-Cookie block is several kilobytes and Auth.js chunks it across + `authjs.session-token.0`, `.1`, … — so the RESPONSE HEADERS of + /auth/callback/<provider> are far larger than a normal response's. + + nginx buffers a whole upstream header block in a single `proxy_buffer_size`, + which ingress-nginx defaults to 4k. Over that it does not truncate, it + fails: "upstream sent too big header while reading response header from + upstream", 502 to the browser. + + OBSERVED on a k3d install of this chart, and worth noting for how it + presents: Keycloak had authenticated, the code had been exchanged, and the + frontend logged "JWT Callback: Initial sign-in successful" with the right + user id — the app's own logs say login worked. Only the browser sees the + 502, and only on the last redirect. Every other page is 200. + + Applies to the AKS app-routing addon too: it is ingress-nginx, with the same + default. Blank the value to omit the annotation on a controller that does + not understand it. + */}} + {{- with .Values.frontend.ingress.proxyBufferSize }} + nginx.ingress.kubernetes.io/proxy-buffer-size: {{ . | quote }} + {{- end }} + {{- with .Values.frontend.ingress.annotations }} + {{- toYaml . | nindent 4 }} + {{- end }} +spec: + ingressClassName: {{ .Values.frontend.ingress.ingressClass }} + rules: + {{- range .Values.frontend.ingress.hosts }} + - host: {{ .host | replace "{baseDomain}" $.Values.baseDomain | replace "{releaseName}" $.Release.Name }} + http: + paths: + {{- range .paths }} + - path: {{ .path }} + pathType: {{ .pathType }} + backend: + service: + name: {{ include "hackagon.frontendServiceName" $ }} + port: + number: {{ $.Values.frontend.service.port }} + {{- end }} + {{- end }} + {{- with .Values.frontend.ingress.tls }} + tls: + {{- range . }} + - hosts: + {{- range .hosts }} + - {{ . | replace "{baseDomain}" $.Values.baseDomain | replace "{releaseName}" $.Release.Name }} + {{- end }} + secretName: {{ .secretName | replace "{releaseName}" $.Release.Name }} + {{- end }} + {{- end }} +{{- end }} \ No newline at end of file diff --git a/helm-chart/templates/frontend-secrets.yaml b/helm-chart/templates/frontend-secrets.yaml new file mode 100644 index 00000000..ea1636e0 --- /dev/null +++ b/helm-chart/templates/frontend-secrets.yaml @@ -0,0 +1,13 @@ +apiVersion: v1 +kind: Secret +metadata: + name: {{ include "hackagon.fullname" . }}-frontend-secrets + namespace: {{ .Release.Namespace }} + labels: + {{- include "hackagon.labels" . | nindent 4 }} +type: Opaque +stringData: + secrets.yaml: | + oidc: + clientSecret: {{ .Values.frontendSecrets.clientSecret | required "frontendSecrets.clientSecret is required" | quote }} + authSecret: {{ .Values.frontendSecrets.authSecret | required "frontendSecrets.authSecret is required" | quote }} \ No newline at end of file diff --git a/helm-chart/templates/frontend-service.yaml b/helm-chart/templates/frontend-service.yaml new file mode 100644 index 00000000..cc385948 --- /dev/null +++ b/helm-chart/templates/frontend-service.yaml @@ -0,0 +1,18 @@ +apiVersion: v1 +kind: Service +metadata: + name: {{ include "hackagon.fullname" . }}-frontend + namespace: {{ .Release.Namespace }} + labels: + {{- include "hackagon.labels" . | nindent 4 }} + app.kubernetes.io/component: frontend +spec: + type: {{ .Values.frontend.service.type }} + ports: + - port: {{ .Values.frontend.service.port }} + targetPort: 3000 + protocol: TCP + name: http + selector: + {{- include "hackagon.selectorLabels" . | nindent 4 }} + app.kubernetes.io/component: frontend \ No newline at end of file diff --git a/helm-chart/templates/keycloak-ingress.yaml b/helm-chart/templates/keycloak-ingress.yaml new file mode 100644 index 00000000..11b6af70 --- /dev/null +++ b/helm-chart/templates/keycloak-ingress.yaml @@ -0,0 +1,59 @@ +{{- if and .Values.keycloak.enabled .Values.keycloak.ingress.enabled -}} +{{- $keycloakHost := include "hackagon.keycloakHost" . }} +{{- /* +Keycloak's public route, rendered by THIS chart rather than by the subchart: +the subchart's own ingress.public/ingress.admin stay disabled so there is one +place the hostname is decided, and it is the same `{baseDomain}` the frontend +and the OIDC issuer are built from. + +Everything below used to be hard-coded — the ingress class +(webapprouting.kubernetes.azure.com), the cert-manager issuer and a TLS block — +so the object rendered identically on every cluster and was CLAIMED BY NO +CONTROLLER on any cluster that is not the AKS app-routing addon. The frontend +Ingress has had `frontend.ingress.ingressClass` since the beginning; this one +ignoring it meant a deployment could serve the app and not its login. Observed, +not deduced: on a k3d cluster running ingress-nginx the frontend answered and +auth.<baseDomain> answered nothing at all. + +`keycloak.ingress.enabled` also did nothing — the object was rendered whatever +it said, and values.yaml said `false`. Its default is now `true`, which is what +the chart has always DONE, so the flag finally means what it reads as without +changing any existing deployment. +*/}} +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: {{ include "hackagon.fullname" . }}-keycloak + namespace: {{ .Release.Namespace }} + labels: + {{- include "hackagon.labels" . | nindent 4 }} + app.kubernetes.io/component: keycloak + {{- with .Values.keycloak.ingress.annotations }} + annotations: + {{- toYaml . | nindent 4 }} + {{- end }} +spec: + {{- /* + Empty means "whatever serves the frontend". Keycloak is reached from the same + browser on the same cluster; two classes here would be a mistake far more + often than a choice. + */}} + ingressClassName: {{ .Values.keycloak.ingress.ingressClass | default .Values.frontend.ingress.ingressClass }} + rules: + - host: {{ $keycloakHost }} + http: + paths: + - path: / + pathType: Prefix + backend: + service: + name: {{ include "hackagon.keycloakServiceName" . }} + port: + number: 8080 + {{- with .Values.keycloak.ingress.tlsSecretName }} + tls: + - hosts: + - {{ $keycloakHost }} + secretName: {{ . | replace "{releaseName}" $.Release.Name }} + {{- end }} +{{- end }} diff --git a/helm-chart/templates/keycloak-init-configmap.yaml b/helm-chart/templates/keycloak-init-configmap.yaml new file mode 100644 index 00000000..78f2f04c --- /dev/null +++ b/helm-chart/templates/keycloak-init-configmap.yaml @@ -0,0 +1,15 @@ +apiVersion: v1 +kind: ConfigMap +metadata: + name: hackagon-keycloak-init + namespace: {{ .Release.Namespace }} + labels: + {{- include "hackagon.labels" . | nindent 4 }} +data: + 01-create-keycloak-db.sql: | + CREATE USER keycloak WITH PASSWORD '{{ .Values.keycloak.database.external.password | required "keycloak.database.external.password is required" }}'; + CREATE DATABASE keycloak OWNER keycloak; + GRANT ALL PRIVILEGES ON DATABASE keycloak TO keycloak; + CREATE USER hackagon WITH PASSWORD '{{ .Values.backend.config.database.postgresPassword | required "backend.config.database.postgresPassword" }}'; + CREATE DATABASE hackagon OWNER hackagon; + GRANT ALL PRIVILEGES ON DATABASE hackagon TO hackagon; diff --git a/helm-chart/templates/keycloak-realm-configmap.yaml b/helm-chart/templates/keycloak-realm-configmap.yaml new file mode 100644 index 00000000..16870450 --- /dev/null +++ b/helm-chart/templates/keycloak-realm-configmap.yaml @@ -0,0 +1,27 @@ +{{- $host := .Values.keycloak.database.external.host | required "keycloak.database.external.host is required (e.g. \"hackagon-postgresql\")" -}} +{{- $keycloakPassword := .Values.keycloak.database.external.password | required "keycloak.database.external.password is required" -}} +apiVersion: v1 +kind: ConfigMap +metadata: + name: {{ include "hackagon.fullname" . }}-realm + namespace: {{ .Release.Namespace }} + labels: + {{- include "hackagon.labels" . | nindent 4 }} +data: + hackagon.json: | + {{- /* + The dev export (tools/configs/keycloak/realm-hackagon.json) hard-codes + http://localhost:8081 in the frontend client's redirectUris, webOrigins and + post.logout.redirect.uris. Every one of those has to become the deployed + origin, WITH ITS SCHEME: Keycloak accepts an absolute URI or a root-relative + path, and a bare `app.example.org/*` is neither — it is taken as a relative + path and the login redirect is then rejected as an invalid redirect_uri. + https, because the frontend Ingress terminates TLS and the OIDC issuer in + values.yaml is https for the same reason. + */}} + {{- $frontendUrl := printf "https://%s" (include "hackagon.frontendHost" .) }} + {{- $raw := .Values.realmJson | default "" }} + {{- $processed := $raw | replace "http://localhost:8081/*##http://localhost:8081/auth/callback/keycloak" (printf "%s/*##%s/auth/callback/keycloak" $frontendUrl $frontendUrl) }} + {{- $processed = $processed | replace "http://localhost:8081/*" (printf "%s/*" $frontendUrl) }} + {{- $processed = $processed | replace "http://localhost:8081" $frontendUrl }} + {{- $processed | nindent 4 }} \ No newline at end of file diff --git a/helm-chart/templates/storage-ingress.yaml b/helm-chart/templates/storage-ingress.yaml new file mode 100644 index 00000000..85e0a1fa --- /dev/null +++ b/helm-chart/templates/storage-ingress.yaml @@ -0,0 +1,101 @@ +{{- if and .Values.storage.enabled .Values.storage.objects.ingress.enabled -}} +{{- if not .Values.frontend.ingress.enabled -}} +{{- fail "storage.objects.ingress.enabled needs frontend.ingress.enabled: /objects is served on the frontend's own hosts, so there is nothing to attach it to" -}} +{{- end -}} +{{- $prefix := include "hackagon.storagePublicPrefix" . -}} +{{- $signHost := include "hackagon.storageSignHost" . -}} +{{- /* +Uploaded files, on the APP's own origin. + +The database stores a root-relative path (<publicPrefix>/<bucket>/<key>), so one +stored value resolves from every hostname the app is reached at. Something has +to serve that path; this is it, and it routes straight to the store so the bytes +never touch the app server. + +A SEPARATE Ingress from the frontend's, and it has to be: `rewrite-target` and +`upstream-vhost` are per-Ingress annotations, and applying them to the +frontend's own rules would rewrite every application URL and lie to SvelteKit +about its host. + +Same host as the frontend — same-origin is the entire point of this path — so +the hosts and the TLS block are taken from `frontend.ingress` rather than +duplicated in values. + +⚠ TWO annotations here are load-bearing, and the failure they prevent is silent: + + upstream-vhost SigV4 signs the Host header, and the backend signs the + STORE's hostname because that is the only name it knows. + ingress-nginx forwards the INCOMING host by default, the + store recomputes a different signature, and every presigned + PUT answers 403 SignatureDoesNotMatch — while unsigned + public reads keep working, so nothing looks broken except + uploads. `header_up Host` in .devcontainer/Caddyfile.tunnel + and `changeOrigin` in vite.config.ts are the same fix at the + other two edges. It carries the PORT when the endpoint names + one, and the bucket prefix under virtual-hosted style, + because that is what `signHost` in internal/storage/client.go + does. + + rewrite-target The store knows nothing about `/objects`. The signature is + computed over the un-prefixed path, so the prefix must be + stripped before the request leaves — the `(/|$)(.*)` capture + plus `/$2` is the ingress-nginx spelling of caddy's + `handle_path` and vite's `rewrite`. +*/}} +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: {{ include "hackagon.fullname" . }}-objects + namespace: {{ .Release.Namespace }} + labels: + {{- include "hackagon.labels" . | nindent 4 }} + app.kubernetes.io/component: storage + annotations: + nginx.ingress.kubernetes.io/upstream-vhost: {{ $signHost | quote }} + nginx.ingress.kubernetes.io/rewrite-target: /$2 + nginx.ingress.kubernetes.io/use-regex: "true" + nginx.ingress.kubernetes.io/proxy-body-size: {{ .Values.storage.objects.ingress.maxBodySize | quote }} + nginx.ingress.kubernetes.io/backend-protocol: {{ include "hackagon.storageBackendProtocol" . | quote }} + {{- if eq (include "hackagon.storageBackendProtocol" .) "HTTPS" }} + # SNI as well as Host: a store behind a shared TLS endpoint answers with the + # wrong certificate otherwise. + nginx.ingress.kubernetes.io/proxy-ssl-server-name: "on" + nginx.ingress.kubernetes.io/proxy-ssl-name: {{ include "hackagon.storageHostname" . | quote }} + {{- end }} + {{- with .Values.storage.objects.ingress.annotations }} + {{- toYaml . | nindent 4 }} + {{- end }} +spec: + ingressClassName: {{ .Values.frontend.ingress.ingressClass }} + rules: + {{- range .Values.frontend.ingress.hosts }} + - host: {{ .host | replace "{baseDomain}" $.Values.baseDomain | replace "{releaseName}" $.Release.Name }} + http: + paths: + # ImplementationSpecific, not Prefix: a Prefix path is matched + # literally and the capture groups the rewrite needs would be part of + # the path instead of a regex. + - path: {{ printf "%s(/|$)(.*)" $prefix }} + pathType: ImplementationSpecific + backend: + service: + name: {{ include "hackagon.storageObjectsServiceName" $ }} + port: + number: {{ include "hackagon.storageObjectsServicePort" $ }} + {{- end }} + {{- /* + The same host and the same TLS secret as the frontend Ingress, referenced + rather than re-issued: the cert-manager annotation lives on that Ingress + only, so exactly one Certificate is ever requested for this hostname. + */}} + {{- with .Values.frontend.ingress.tls }} + tls: + {{- range . }} + - hosts: + {{- range .hosts }} + - {{ . | replace "{baseDomain}" $.Values.baseDomain | replace "{releaseName}" $.Release.Name }} + {{- end }} + secretName: {{ .secretName | replace "{releaseName}" $.Release.Name }} + {{- end }} + {{- end }} +{{- end }} diff --git a/helm-chart/templates/storage-service.yaml b/helm-chart/templates/storage-service.yaml new file mode 100644 index 00000000..5d716ea5 --- /dev/null +++ b/helm-chart/templates/storage-service.yaml @@ -0,0 +1,34 @@ +{{- if and .Values.storage.enabled .Values.storage.objects.ingress.enabled .Values.frontend.ingress.enabled }} +{{- if not .Values.storage.objects.ingress.service.name }} +{{- /* +An Ingress can only send traffic to a Service, and the object store this chart +expects is OUTSIDE the cluster (see values.yaml: the chart deploys no store on +purpose). An ExternalName Service is the adapter that makes it addressable. + +The name resolved here is the store's hostname WITHOUT its port — kube-dns +returns a CNAME and knows nothing about ports — while the port travels on the +Service. Both come from `storage.endpoint`, so neither can drift from what the +backend signs. + +When the store DOES run in this cluster, set +`storage.objects.ingress.service.name` to its Service and this object is not +rendered at all. +*/}} +apiVersion: v1 +kind: Service +metadata: + name: {{ include "hackagon.storageObjectsServiceName" . }} + namespace: {{ .Release.Namespace }} + labels: + {{- include "hackagon.labels" . | nindent 4 }} + app.kubernetes.io/component: storage +spec: + type: ExternalName + externalName: {{ include "hackagon.storageHostname" . | quote }} + ports: + - port: {{ include "hackagon.storagePort" . }} + targetPort: {{ include "hackagon.storagePort" . }} + protocol: TCP + name: s3 +{{- end }} +{{- end }} diff --git a/helm-chart/values.k3d.yaml b/helm-chart/values.k3d.yaml new file mode 100644 index 00000000..90cebc7a --- /dev/null +++ b/helm-chart/values.k3d.yaml @@ -0,0 +1,197 @@ +# ============================================================ +# TEST VALUES — a laptop, a throwaway cluster, plain HTTP. +# NOT a deployment template. Do not copy this into one. +# ============================================================ +# +# Used by .claude/skills/k3d-chart-rig, which creates a k3d cluster, installs +# ingress-nginx over the disabled bundled Traefik, runs an object store the +# chart deliberately does not deploy, and then installs this chart on top so the +# three things the rendered manifest can only ASSERT can be OBSERVED instead: +# +# 1. a presigned PUT survives the /objects Ingress's Host rewrite; +# 2. the regex path on that separate Ingress beats the frontend's `/`; +# 3. ingress-nginx really does accept an ExternalName upstream. +# +# Everything here that differs from values.yaml differs because this rig has no +# TLS, no DNS and no cert-manager. Read each one as "what a deployment must NOT +# do": +# +# * `http://` everywhere, and `cookies.useSecure: false` with it. A session +# cookie without Secure is a session cookie an attacker on the network can +# read. It is set false here only because there is no certificate to make +# the page https in the first place, and Auth.js would then set a +# `__Secure-` cookie the browser refuses to send back. +# * no TLS blocks and no cert-manager annotations: nothing issues certificates +# on this cluster. +# * the object store is in-cluster and reached by its cluster DNS name. A +# deployment points at S3/Ceph/MinIO run as its own release with its own +# backups — see the `storage:` comment in values.yaml. +# +# NO CREDENTIAL APPEARS IN THIS FILE. The postgres, keycloak-db, OIDC-client and +# object-store secrets are minted per cluster by scripts/up.sh into a gitignored +# .state/, and the storage credentials arrive as a Secret the script creates. +# +# ⚠ The rig also imports tools/configs/keycloak/realm-hackagon.json, which +# carries the DEVELOPMENT ACCOUNTS (alice/bob/charles/hackagon-admin, password +# `aliceandbob`). That import is done by the script, not by this file, and it is +# why the whole rig is opt-in and binds loopback only. + +# `*.localhost` rather than nip.io/sslip.io: those need a resolver willing to +# answer with a loopback address, and a resolver with DNS-rebinding protection — +# which is the default on plenty of home routers, and was the case on the +# machine this was written on — refuses. `*.localhost` is resolved by curl and +# by browsers themselves (RFC 6761), so nothing is written to any hosts file. +baseDomain: "hackagon.localhost" + +frontend: + image: + # The rig pulls the published images. They are built from the mainline and + # PREDATE the object-storage work on this branch, which is why the rig signs + # its own upload URLs instead of asking StorageService for one — see + # scripts/presign.sh. + tag: "latest" + config: + log: + forceDevLog: true + cookies: + # See the banner. http-only rig; a deployment leaves this true. + useSecure: false + oidc: + # One string, used by two very different callers: the BROWSER follows it + # to Keycloak, and the frontend POD fetches the discovery document from + # it. That is why the ingress controller listens on the same ports + # in-cluster as it does on the host — so this URL is true from both sides, + # and why CoreDNS is taught to answer `auth.hackagon.localhost`. + issuer: "https://auth.hackagon.localhost:8443/realms/hackagon" + # ⚠⚠ TEST-ONLY, AND THE WORST LINE IN THIS FILE. It turns off certificate + # verification for EVERY outbound TLS connection this Node process makes. + # It is here because the Keycloak certificate is self-signed by scripts/up.sh + # and the frontend fetches the OIDC discovery document and exchanges the + # authorization code over it. The alternative — mounting a CA bundle and + # setting NODE_EXTRA_CA_CERTS — needs an extraVolumes hook the chart does not + # have, and inventing one to make a test pass is how test-only surface gets + # into a production chart. + # + # A deployment sets neither this nor anything like it. If a deployment ever + # needs a private CA, add extraVolumes and use NODE_EXTRA_CA_CERTS. + extraEnv: + - name: NODE_TLS_REJECT_UNAUTHORIZED + value: "0" + # replay and plausible are left at their values.yaml defaults (absent / + # disabled) ON PURPOSE: the rig asserts that no `replay:` or `plausible:` + # key reaches the running pod's config.yaml. + ingress: + enabled: true + ingressClass: "nginx" + # `null`, not `{}`. Helm DEEP-MERGES values files, and an empty map merges + # as "no change" — values.yaml's cert-manager annotation would survive it + # and stay on the object. Only an explicit null removes a key. + annotations: null + hosts: + - host: "app.{baseDomain}" + paths: + - path: / + pathType: Prefix + # No TLS: nothing issues certificates here. The /objects Ingress copies + # this block, so leaving it empty keeps both Ingresses plain http. + tls: [] + +backend: + image: + tag: "latest" + config: + server: + # The `id` of `hackagon-admin` in tools/configs/keycloak/realm-hackagon.json. + # A realm import preserves user ids, so this is stable across re-creations + # of the cluster. The backend REFUSES TO START without this value — the + # chart now fails the render rather than letting it reach a pod log. + adminkeycloakid: "1183370a-46a2-4dad-b8fd-dd927d083e14" + oidc: + # THESE TWO ARE DIFFERENT ON PURPOSE, and the chart separating them is + # what makes it possible. `issuerurl` is a STRING COMPARED against the + # `iss` claim, so it must be the public URL a browser was sent to. + # `jwksurl` is a URL the backend FETCHES, so it may take the short path: + # the in-cluster Keycloak Service over plain http. The keys are the same + # keys either way, and Go's TLS stack would refuse the rig's self-signed + # certificate with no env hook on this deployment to relax it. + jwksurl: "http://hackagon-keycloak:8080/realms/hackagon/protocol/openid-connect/certs" + issuerurl: "https://auth.hackagon.localhost:8443/realms/hackagon" + logging: + level: debug + +storage: + enabled: true + # An in-cluster store in ANOTHER namespace, addressed by cluster DNS. The + # namespace matters: it is what keeps `objects.ingress.service.name` empty, so + # the chart renders the ExternalName Service — the object claim 3 is about. + # Naming the Service directly would carry the same traffic while skipping the + # thing under test. + endpoint: "http://store.hackagon-store.svc.cluster.local:9000" + bucket: "hackagon" + region: "us-east-1" + usePathStyle: true + publicPrefix: "/objects" + # Created by scripts/up.sh with `kubectl create secret`, from values minted + # into a gitignored file. The chart refuses to read these from values at all. + existingSecret: "hackagon-storage" + objects: + ingress: + enabled: true + maxBodySize: "64m" + service: + name: "" + port: null + +keycloak: + enabled: true + mode: production + hostname: + # Scheme included, and the PORT included. Keycloak 26 takes a full URL here + # and derives the public scheme from it; without the port every link it + # generates would point at :443 and the browser would land nowhere. + hostname: "https://auth.hackagon.localhost:8443" + admin: + username: hackagon-admin + database: + external: + vendor: postgres + host: "hackagon-postgresql" + port: 5432 + # NOTE: the subchart reads `name` and `username`, not `database`/`user`. + # values.yaml sets the latter pair, which the subchart ignores; the + # defaults happen to be the same two strings, so it works by coincidence. + database: keycloak + user: keycloak + proxy: + # Keycloak is behind ingress-nginx, which sets X-Forwarded-*. Without this + # Keycloak builds its own URLs from the request it physically received. + headers: xforwarded + ingress: + enabled: true + # Was hard-coded to webapprouting.kubernetes.azure.com until this rig + # observed Keycloak answering nothing while the frontend answered fine. + ingressClass: "nginx" + annotations: null + # LEFT AT THE CHART DEFAULT (`{releaseName}-keycloak-tls`), which is the + # point: scripts/up.sh creates a Secret of exactly that name holding a + # self-signed certificate, so the chart's own naming is exercised rather + # than routed around. TLS is required here and only here — Keycloak's + # federation cookies are SameSite=None, hence Secure, hence unusable over + # plain http. The app itself deliberately stays on http; see lib.sh. + +postgresql: + enabled: true + auth: + username: postgres + database: postgres + primary: + persistence: + # A throwaway cluster: `down.sh` deletes the node, so a PVC would only be + # a slower way to lose the same data. + enabled: false + resources: + requests: + cpu: 50m + memory: 128Mi + limits: + memory: 512Mi diff --git a/helm-chart/values.yaml b/helm-chart/values.yaml new file mode 100644 index 00000000..e1ffa3cb --- /dev/null +++ b/helm-chart/values.yaml @@ -0,0 +1,418 @@ +# -- Base domain for all external services +baseDomain: "example.com" + +# -- Keycloak realm JSON (pass via --set-file: --set-file realmJson=@./tools/configs/keycloak/realm-hackagon.json) +# realmJson: "" + +# ============================================================ +# Frontend +# ============================================================ +frontend: + image: + repository: ghcr.io/swissdatasciencecenter/hackagon/temporary/frontend-service + tag: "latest" + pullPolicy: IfNotPresent + + replicaCount: 1 + annotations: {} + resources: + requests: + cpu: 100m + memory: 128Mi + limits: + cpu: 500m + memory: 512Mi + + # -- Header the SvelteKit node adapter reads the public SCHEME from. + # + # Without it adapter-node ASSUMES "https", and everything built from the + # public origin inherits the assumption: the URLs /auth/providers advertises, + # the target of the sign-in form, SvelteKit's CSRF comparison, and the + # `__Secure-` prefix Auth.js puts on its cookies. On a deployment reached over + # plain http that produces a site where every page returns 200 and login is + # dead — see the comment in templates/frontend-deployment.yaml. + # + # ingress-nginx sets X-Forwarded-Proto unconditionally. Empty disables the + # lookup and restores the assumption. + protocolHeader: "x-forwarded-proto" + + # -- Extra environment for the frontend container, as a list of core v1 + # EnvVar. For the knobs this chart does not model: ORIGIN, BODY_SIZE_LIMIT, + # XFF_DEPTH (adapter-node), NODE_EXTRA_CA_CERTS (node). + extraEnv: [] + + # -- Frontend config.yaml content (non-sensitive settings) + config: + log: + forceDevLog: false + backend: + # -- Empty means the backend Service this chart creates. Set a hostname + # only to point the frontend somewhere else. + hostname: "" + port: 3000 + cookies: + useSecure: true + oidc: + clientId: hackagon-frontend + issuer: "https://auth.{baseDomain}/realms/hackagon" + audience: hackagon-backend + + # -- Session replay (OpenReplay). OFF, and absent from the rendered + # config.yaml, unless enabled — the frontend's schema + # (src/lib/schemas/config-schema.ts) parses an absent block as + # `{enabled: false}`, so a deployment that has never heard of this feature + # cannot start recording because somebody forgot a flag. + # + # Enabling it streams the DOM, its mutations, clicks, scrolls and + # navigations of EVERY visitor to `ingestPoint`. Read the schema comment + # and docs/frontend/session-replay.md before turning it on. + # + # There is deliberately no default endpoint here. A dev tunnel hostname + # once sat committed in a tracked config and outlived the tunnel; + # internal/config/config_test.go asserts the tracked configs still say + # localhost, and this file must not reintroduce the problem from the + # deployment side. + replay: + enabled: false + # ingestPoint: "https://replay.example.org/ingest" + # projectKey: "" + # -- The tracker refuses a page served over plain http. Only ever set + # this on a deployment that is genuinely http (it should not be one). + allowInsecureOrigin: false + + # -- Audience measurement (Plausible). Same discipline as `replay`: no + # block rendered unless enabled, no default host. Sets no cookie and + # stores no identifier in the browser; see docs/frontend/analytics.md for + # what it does send. + plausible: + enabled: false + # scriptUrl: "https://plausible.example.org/js/script.local.manual.js" + # domain: "app.example.org" + + service: + type: ClusterIP + port: 8080 + + ingress: + enabled: true + ingressClass: "webapprouting.kubernetes.azure.com" + + # -- Upstream header buffer for the frontend's routes. The OIDC callback + # answers with a chunked, multi-kilobyte Set-Cookie block (the session JWT + # carries the Keycloak access and refresh tokens), and nginx's 4k default + # answers 502 "upstream sent too big header" rather than truncating — + # AFTER the login has actually succeeded, so only the browser ever sees it. + # Observed on a real install; see templates/frontend-ingress.yaml. Blank it + # for a controller that does not understand the annotation. + proxyBufferSize: "16k" + + annotations: + cert-manager.io/cluster-issuer: letsencrypt-production + hosts: + - host: "app.{baseDomain}" + paths: + - path: / + pathType: Prefix + tls: + - secretName: "{releaseName}-frontend-tls" + hosts: + - "app.{baseDomain}" + +# ============================================================ +# Backend +# ============================================================ +backend: + image: + repository: ghcr.io/swissdatasciencecenter/hackagon/temporary/backend-service + tag: "latest" + pullPolicy: IfNotPresent + + replicaCount: 1 + annotations: {} + resources: + requests: + cpu: 100m + memory: 128Mi + limits: + cpu: 1000m + memory: 512Mi + + # -- Backend config.yaml content + config: + server: + port: "3000" + adminemail: "admin@{baseDomain}" + # -- REQUIRED. The Keycloak `sub` of the platform administrator — the + # subject claim in the JWT, not the DB UUID (the backend has no user row + # to look up before the first request). The backend refuses to start + # without it, so the chart refuses to render without it. + adminkeycloakid: "" + database: + driver: postgres + host: "" # Auto-generated from release name in template + port: 5432 + dbname: hackagon + user: hackagon + postgresPassword: "" + oidc: + jwksurl: "https://auth.{baseDomain}/realms/hackagon/protocol/openid-connect/certs" + issuerurl: "https://auth.{baseDomain}/realms/hackagon" + algorithm: RS256 + logging: + level: info + + # -- RPC journal (components/backend/internal/audit). One JSON line per + # gRPC call, appended to a file INSIDE the pod: no volume is mounted for + # it, so it dies with the pod and is a debugging aid, not an audit trail + # you can rely on. Off by default; read config.AuditConfig for exactly + # what enabling it records (caller username, method, allowlisted request + # fields, status, response ids). + # + # No block is written to config.yaml unless enabled, so the backend's own + # default (off) applies. + audit: + enabled: false + path: ".output/audit/rpc-journal.jsonl" + buffer: 4096 + + service: + type: ClusterIP + port: 3000 + +# ============================================================ +# Object storage (StorageService) +# ============================================================ +# Avatars, event logos, page media, prize art and the gallery are uploaded +# from the BROWSER straight to an S3-compatible store over a presigned URL — +# the bytes never pass through the app. Without this block configured every +# upload in the product fails, and every image already stored 404s. +# +# THE CHART DEPLOYS NO STORE. Deliberately: a bucket is the one piece of state +# here that must outlive the release, and a StatefulSet in an application chart +# invites `helm uninstall` to take the uploads with it. Development uses the +# RustFS container in .devcontainer/; a cluster points at S3, MinIO, Ceph RGW +# or whatever else the platform already runs and backs up. If you want a store +# in-cluster, install one as its own release and give its endpoint here. +# +# THE CHART DOES NOT CREATE THE BUCKET POLICY EITHER, and a store without it +# serves 403 to every image. The prefixes `hackathons/*`, `users/*` and +# `site/*` must be public-read (they render on pages that need no login, which +# is what lets their stable path live in a database column); everything else — +# `teams/*` above all — must stay private and is read through short-lived +# presigned GETs. .devcontainer/rustfs-init.sh has the exact policy document +# and a --selftest that proves both halves. docs/storage.md explains why. +storage: + # -- Master switch. When false the chart writes an EMPTY endpoint into the + # backend config on purpose: the storage RPCs then answer Unavailable, which + # is an honest "off". Writing nothing at all would leave the backend on its + # development defaults — http://rustfs:9000 with the committed dev keys. + enabled: true + + # -- S3 base URL, scheme included. Required when enabled. + endpoint: "" + # -- Bucket the platform uploads into. Required when enabled. + bucket: "" + region: "us-east-1" + + # -- Keep the bucket in the PATH (endpoint/bucket/key) rather than in the + # hostname (bucket.endpoint/key). Path-style works everywhere; virtual-hosted + # style needs wildcard DNS and a certificate that covers it. Both are + # supported here — the signed Host follows this flag, see + # `hackagon.storageSignHost`. + usePathStyle: true + + # -- The path the app serves uploads under, on its OWN origin. The database + # stores `<publicPrefix>/<bucket>/<key>`, root-relative, so one stored value + # resolves from every hostname the app is reached at. Changing this after + # anything has been uploaded breaks the stored paths. + publicPrefix: "/objects" + + # -- Credentials. A Secret, always — never values.yaml, which ends up in + # `helm get values`, in CI logs and in whatever repository this file lives + # in. Create it out of band, e.g. + # + # kubectl create secret generic hackagon-storage \ + # --from-literal=accessKey=… --from-literal=secretKey=… + # + # The two keys are read into HACKAGON_STORAGE_ACCESSKEY / + # HACKAGON_STORAGE_SECRETKEY on the backend pod. Required when enabled. + # + # ⚠ The backend's env loader splits any value containing a SPACE into a + # list. An access key with a space in it will not arrive intact. + existingSecret: "" + secretKeys: + accessKey: accessKey + secretKey: secretKey + + # -- How `<publicPrefix>` is served to browsers. + objects: + ingress: + # -- Route it at the edge, straight to the store. Turn this OFF to have + # the frontend proxy it instead: the app owns `/objects/*` as a fallback + # route and will forward to STORAGE_ENDPOINT itself. That works, but it + # puts every uploaded byte through the app server, which is exactly what + # presigned URLs exist to avoid. + enabled: true + + # -- ⚠ THE HOST REWRITE. This chart writes it for you; it is described + # here because it is the failure nobody diagnoses. + # + # SigV4 signs the Host header, and the backend signs the STORE's + # hostname — the only name it knows. A reverse proxy passes the INCOMING + # host through by default (app.example.org here), the store recomputes a + # different signature, and every presigned PUT answers + # 403 SignatureDoesNotMatch. Public reads are unsigned and keep working, + # so the deployment looks healthy and only uploads are dead. + # + # Expressed for INGRESS-NGINX (`nginx.ingress.kubernetes.io/ + # upstream-vhost`), because the chart's default ingressClass — + # webapprouting.kubernetes.azure.com, the AKS app-routing addon — is + # ingress-nginx. On Traefik the same thing is `passHostHeader: false` on + # the ServicesLoadBalancer plus a `Headers` middleware setting + # `customRequestHeaders.Host`; neither is expressible in a core Ingress + # object, so it would need a Traefik-specific CRD. If you run Traefik, + # set `enabled: false` here and let the frontend proxy serve /objects. + # + # The equivalents in this repo, for reference: `changeOrigin` in + # components/frontend/vite.config.ts and `header_up Host + # {upstream_hostport}` in .devcontainer/Caddyfile.tunnel. + # + # Rendered on its own Ingress object, not the frontend's: these + # annotations are per-Ingress, and rewrite-target on the frontend's rules + # would mangle every application URL. + annotations: {} + + # -- Largest upload the store accepts through this route. The biggest + # rule the backend signs is a 50 MiB submission attachment + # (internal/service/storage_service.go); nginx defaults to 1m and would + # refuse almost every image with a 413. + maxBodySize: "64m" + + # -- The Service the /objects path routes to. Left empty, the chart + # creates an ExternalName Service for `endpoint`'s hostname — which is + # what makes an out-of-cluster store routable from an Ingress at all. + # Name an existing Service instead when the store runs in this cluster. + # + # ⚠ ingress-nginx can be configured to refuse ExternalName backends. The + # switch is the controller's COMMAND-LINE FLAG + # `--disable-svc-external-name` (off by default) — not a ConfigMap key, as + # this comment used to say; setting `disable-service-external-name` in the + # controller ConfigMap is silently ignored, so a cluster believed to be + # locked down might not be. Verified both ways on a real controller + # (.claude/skills/k3d-chart-rig): with the flag this route answers 503 and + # the upstream disappears; with only the ConfigMap key it keeps serving. + # On a cluster that sets the flag: either name an in-cluster Service here, + # or set `enabled: false` and let the frontend proxy /objects. + service: + name: "" + port: null + +# ============================================================ +# Keycloak (helmforge) +# ============================================================ +keycloak: + enabled: true + replicaCount: 1 + + # -- Production mode requires hostname and database + mode: production + + # -- Hostname for Keycloak (public admin UI) + hostname: + hostname: "" # e.g. "https://auth.{baseDomain}" + + # -- Admin credentials (password from realm JSON, but username needed) + admin: + username: hackagon-admin + + # -- External database (reuse the same postgres instance) + database: + external: + vendor: postgres + host: "" # Required: set to <release>-postgresql (e.g. "hackagon-postgresql") + port: 5432 + database: keycloak + user: keycloak + password: "" + + # -- Realm import from ConfigMap + realmImport: + enabled: true + existingConfigMap: hackagon-realm + + # -- Persistence (for Keycloak data, not DB) + persistence: + enabled: true + size: 10Gi + + # -- Resources + resources: + requests: + cpu: 250m + memory: 256Mi + limits: + cpu: 1000m + memory: 1Gi + + # -- Proxy configuration (behind reverse proxy) + proxy: + headers: xforwarded + + # -- Ingress for Keycloak, rendered by the PARENT chart (the subchart's own + # ingress.public/ingress.admin stay off, so the hostname is decided once). + # + # This used to be `enabled: false` while the parent rendered the Ingress + # unconditionally — the flag was decoration. `true` is what the chart has + # always DONE, so no existing deployment changes. + ingress: + enabled: true + # -- Empty means "the same class that serves the frontend" + # (frontend.ingress.ingressClass). This was hard-coded to the AKS + # app-routing class, which left Keycloak unreachable on every other + # cluster while the app itself came up — a deployment serving the product + # and not its login. + ingressClass: "" + # -- Keycloak's public hostname. Empty derives `auth.{baseDomain}`, which is + # what every existing deployment gets. Set it when the app is NOT at + # `app.{baseDomain}` — otherwise Keycloak lands one label deeper than the + # product, and a one-label wildcard (Cloudflare's free Universal SSL is + # exactly that: apex plus one label, handshake failure below it) covers the + # app and not its login. `{baseDomain}` and `{releaseName}` substitute here + # the same way they do in `frontend.ingress.hosts`. + host: "" + annotations: + cert-manager.io/cluster-issuer: letsencrypt-production + # -- TLS secret for the Keycloak host. Empty renders no `tls:` block at + # all, which is what a cluster with no certificate issuer needs. + tlsSecretName: "{releaseName}-keycloak-tls" + +# ============================================================ +# PostgreSQL (bitnami) — two databases, two users +# ============================================================ +postgresql: + enabled: true + auth: + username: postgres + database: postgres + postgresPassword: "" + + primary: + persistence: + enabled: true + size: 10Gi + resources: + requests: + cpu: 100m + memory: 128Mi + limits: + cpu: 500m + memory: 512Mi + initdb: + scriptsConfigMap: hackagon-keycloak-init + +# ============================================================ +# Frontend OIDC secrets +# ============================================================ +frontendSecrets: + clientSecret: "" + authSecret: "" diff --git a/tools/nix/hackagon/lib/toolchain.nix b/tools/nix/hackagon/lib/toolchain.nix index 3b8d2cd4..1a6cbd52 100644 --- a/tools/nix/hackagon/lib/toolchain.nix +++ b/tools/nix/hackagon/lib/toolchain.nix @@ -131,6 +131,18 @@ let keycloak.process-compose.log_location = createProcCompLog "keycloak"; backend = lib.mkIf withBackend { + # NOTE: this enters the Nix dev shell, and that is the single + # most expensive fact about this process. Entering it costs + # ~5s unopposed (re-measured 2026-08-14; the 44s once written + # here was sampled during the frontend crash loop below, not a + # floor), and it takes a REPO-WIDE lock while it re-fetches + # the tree + # ("waiting for another Nix process to finish fetching input + # 'git+file:///workspaces/hackagon'…"), so every other + # `just develop` anywhere on the machine queues behind it. + # The readiness budget below has to cover that wait, because + # the probe clock starts when THIS command is launched, not + # when the server it eventually starts binds a port. exec = "just develop just run"; process-compose = { log_location = createProcCompLog "backend"; @@ -141,7 +153,30 @@ let }; }; availability = { - restart = "on_failure"; + # `always`, not `on_failure`, because of how a + # readiness-probe kill actually looks. When the budget + # below runs out process-compose SIGTERMs the process; the + # Go server handles that and exits 0 — and `on_failure` + # does not consider 0 a failure, so the backend stayed + # DOWN and the supervisor recorded it as `Completed`, + # exit_code=0, i.e. as having finished successfully. + # Reproduced 2026-08-13: `grpc server listening` followed + # by `received shutdown signal`, then nothing, forever. + # Every downstream symptom (connection refused mid-run, a + # browse page listing nothing) came from that. + restart = "always"; + # LOAD-BEARING, not belt-and-braces. The same kill can + # also land BEFORE the Go signal handler exists, in which + # case the wrapper dies with 143 — which `on_failure` + # does consider a failure. Measured 2026-08-13 with the + # budget scaled down to force it: 149 restarts in 151 + # seconds, i.e. one full `nix develop` PER SECOND, which + # starves every other service's startup and is exactly the + # runaway this file is being changed to prevent. An + # uncapped restart policy on a process that enters the Nix + # shell is a self-amplifying outage; cap it and a bad + # start costs 3 attempts and then says so. + max_restarts = 3; }; readiness_probe = { exec = { @@ -150,7 +185,24 @@ let initial_delay_seconds = 10; timeout_seconds = 5; success_threshold = 1; - failure_threshold = 50; + # Was 50. Measured on this container 2026-08-13: probes + # land ~15s apart (process-compose's default period), so + # 50 was a ~12.7 min budget — and a COLD restart of this + # service (enter the Nix shell, build quitsh, build the Go + # service, boot) took 486s on a QUIET lock. That is 64% of + # the budget spent before one competitor for the fetch + # lock is added, and each competitor measured +36s. The + # margin was ~4 minutes on a machine where the frontend + # could take the lock every 55 seconds. + # + # 150 makes it ~37 min. This costs nothing when the + # service is healthy — probing stops at the first success — + # and the thing that should decide "the backend did not + # come up" is the harness's own timeout + # (hackathon-e2e/scripts/wait-ready.sh, 300s, which says + # WHICH service and prints why), not a supervisor whose + # only move is to kill a server that was merely slow. + failure_threshold = 150; }; }; }; @@ -167,6 +219,33 @@ let }; availability = { restart = "on_failure"; + # THE RUNAWAY THIS FILE EXISTS TO PREVENT, found live + # 2026-08-13 with 54 restarts in 50 minutes. + # + # `vite dev` binds [::1]:8081. So does the adapter-node + # build the e2e harness serves in its place + # (hackathon-e2e/scripts/prod-frontend.sh — see the comment + # on stop_vite for why it has to). Whenever a previous run + # has left that server up, vite cannot bind, exits 1 with + # "Error: Port 8081 is already in use", and an uncapped + # `on_failure` sends it round again — every ~55s, forever, + # and each round is a full `just develop`, i.e. one + # acquisition of the repo-wide git+file:// fetch lock. + # That is what starved the backend's own startup. + # + # It was also INVISIBLE: `process list` said + # `frontend Running Ready` throughout, because the + # readiness probe below is a plain GET of :8081 and the + # OTHER server was answering it. A probe that measures a + # PORT cannot tell you which PROCESS holds it. + # + # 3 is enough for a genuine transient (a port freed a + # moment later) and small enough that a permanent conflict + # costs three shell entries instead of one an hour. + # `wait-ready.sh` now reads these counters back and says so + # out loud, because the number was there all along and + # nothing was looking at it. + max_restarts = 3; }; readiness_probe = { exec = { diff --git a/tools/nix/hackagon/pkgs/treefmt.nix b/tools/nix/hackagon/pkgs/treefmt.nix index 3dbfeae7..7eb8383b 100644 --- a/tools/nix/hackagon/pkgs/treefmt.nix +++ b/tools/nix/hackagon/pkgs/treefmt.nix @@ -13,10 +13,13 @@ _: { "components/backend/internal/proto/**" "components/backend/ent/**" "components/frontend/src/lib/server/grpc/generated/**" - # Self-contained agent tooling (skills + their vendored deps). It carries - # its own conventions — 2-space shell, hand-wrapped markdown, a pnpm-owned - # lockfile — and reformatting it here only makes the two styles fight. - ".claude/**" + # Generated by scripts/splice-player.mjs and scripts/build-quality-report.mjs + # respectively. Both re-emit the whole file from recipe.jsonl / the run + # journals, so a hand-format is overwritten by the next build — and the + # player's splice matches `<script id="recipe-data" ...>` as an exact + # string, which prettier is free to re-wrap. Regenerate, don't format. + ".claude/skills/hackathon-e2e/recipe-player.html" + ".claude/skills/hackathon-e2e/quality-report.html" ]; }; @@ -36,11 +39,18 @@ _: { "*/api/openapi*" # this are symlinks, which prettier cannot deal with ".golangci.yaml" # this is a symlink, which prettier cannot deal with ".yamllint.yaml" # this is a symlink, which prettier cannot deal with - # pnpm writes this file and owns its style. Formatting it means prettier - # and pnpm each rewrite it back, so every real dependency change would - # reappear as a format failure. Same reason the codegen dirs are excluded - # above: a generated file belongs to its generator. - "components/frontend/pnpm-lock.yaml" + # pnpm writes these files and owns their style. Formatting them means + # prettier and pnpm each rewrite them back, so every real dependency change + # would reappear as a format failure. Same reason the codegen dirs are + # excluded above: a generated file belongs to its generator. + "**/pnpm-lock.yaml" + # Helm templates are Go templates that happen to end in .yaml — a + # directive can stand where a key belongs (`name: {{ include … }}`, and + # whole blocks under `{{- if }}`), so NO yaml parser accepts them. prettier + # exits 2 on backend-configmap.yaml at line 4 and takes the whole formatter + # run with it, which is what made CI red the moment the chart landed. + # `helm lint` and `helm template` are what check these files. + "helm-chart/templates/**" ]; programs.ruff-format.enable = true; @@ -58,6 +68,14 @@ _: { "-e" "SC1091" ]; + # shellcheck is a LINTER, not a formatter — it rewrites nothing, it only + # fails the run. The agent tooling carries 54 findings (2 SC1087 errors, 37 + # SC2034 "appears unused" on variables that ARE used by the scripts that + # source these libs, and 15 style/info), and clearing them means editing + # logic, which is not a thing a formatting pass may do. Formatting is + # un-excluded above so the tree stops drifting; the lint backlog is its own + # piece of work and is tracked separately rather than blocking every PR. + excludes = [ ".claude/**" ]; }; # Nix.