diff --git a/.claude/CLAUDE.md b/.claude/CLAUDE.md
new file mode 100644
index 00000000..7ad6f0cc
--- /dev/null
+++ b/.claude/CLAUDE.md
@@ -0,0 +1,1482 @@
+# .claude — Hackagon e2e tooling
+
+Self-contained Claude Code skills for testing the full Hackagon hackathon
+lifecycle. Everything lives under `skills/`; nothing outside this folder is
+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. |
+| `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` — **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 →
+event days (no-show, same-day walk-in, deadline overrides) → voting
+(single-choice, ranked, points) → prizes (admin final voice) → post-event
+(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 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
+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 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`,
+which re-splices the JSONL between the ``, even inside
+ a JSON string — and `act0.about.xss` pastes a script tag on purpose. The
+ splice escapes it as `<\/script>`, which JSON parses back to the same
+ character. Re-splicing the player after a recipe edit is required, not
+ cosmetic; the escape is part of the splice.
+- **A zero that could be vacuous.** The replay privacy proofs count BYTES ON THE
+ WIRE, and a zero-hit grep reads identically whether the string was masked or
+ nothing was ever captured — on the first run nothing was. `masking.spec.ts`
+ therefore runs an **unmasked control first**, and `consent.spec.ts` asserts
+ the positive too (the project key IS in the HTML once granted).
+- **A measurement that stops at the wire.** The same replay suite was green for
+ three days while **every recorded session was unplayable** — the player spun
+ forever and no recording file was ever written. It could not have noticed:
+ every spec in that folder measures what leaves the BROWSER, and all three
+ faults were on OpenReplay's side of an ingest endpoint that answered `200` to
+ every batch. Its `sink` container was not running (a compose service with no
+ container looks nothing like an unhealthy one); its object store answered
+ `NoSuchBucket` to every request, PUT included, for a bucket on its own disk,
+ until it was restarted; and `ender` rejected one batch per session, which
+ `Iterate` discards WHOLE. `tests/openreplay/playable.spec.ts` now asks the far
+ end — it records a session, reads the id off the tracker's own start response,
+ and waits for the mob file to come back through the player's two hops.
+ Corollary worth keeping: **"the server accepted it" is not "the server can use
+ it"**, and a suite that only ever asks the client cannot tell those apart.
+- **An action the loader silently drops — closed.** `loadRecipe()` used to
+ filter every line with a `comment` key, which is how act banners are removed,
+ without checking for an `id` first — `act8.flow.bob` carried a trailing
+ `comment` and had never executed. The loader now keeps every line with an
+ `id`, throws on a line that is neither banner nor action, cross-checks the
+ count against a textual scan of the raw file, and rejects duplicate ids.
+- **An `or` whose third outcome nobody enumerated — OPEN.** `09-browse-and-join`
+ asserts "either the registration form opens, or the event asks nothing and the
+ dashboard just updates". The join being **REFUSED** satisfies the else-branch
+ too, and that is what happens today: charles's target is `h2`, whose
+ `register` capability is disabled, so the spec drives a Join that always fails
+ and reports green (confirmed in the DB — charles still has exactly one
+ participant row after many runs). Its else-branch must assert the
+ `joinNotice`/badge, or assert the refusal explicitly. A disjunction is only a
+ test if every branch is a SUCCESS; one that also accepts the failure is a
+ tautology. Same file, lines 57-61, plus `05-new-user-funnel:31`: both click
+ "the first Join button on the dashboard", which is the fixture's event only
+ because `just db::seed` ran before the SDSC seeding (`List` orders
+ `created_at ASC`).
+- **A fixture constant that assumed the fixture was the whole database.**
+ `03-dashboard` hard-coded `connectedCount: 3`; the instance also carries the 6
+ SDSC editions, and `HackathonService.Create` enrolls its creator, so
+ `hackagon-admin` is in 9. The two "lists other public hackathons" tests were
+ worse: their whole assertion was the empty-state sentence, i.e. a claim about
+ the fixture's SIZE rather than about the page. Replaced with self-consistent
+ properties — the count the page states equals the rows it renders (a mismatch
+ is a real bug class the constant could never see), and the offered set is
+ checked in both directions against membership with `joined > 0` as the
+ control. Populated instances are a supported state; a spec that only passes on
+ an empty one is coupled to the seeder, not to the product.
+- **A helper that no-ops when its subject is absent.** The mobile sweeps
+ iterated `["header", "footer", BANNER]` and called
+ `expectNoOverlap`/`expectNoClippedText` on each — and both helpers `return`
+ early when `document.querySelector(scope)` is null. The `(app)` route group
+ shipped with NO footer on 37 of 42 routes (the `5551b8d` split gave it its own
+ copy of the shell, minus `AppFooter`), so two checks per route measured an
+ element that did not exist and passed. Worse than a missing test, because the
+ route list _named_ the thing: coverage looked complete. A guard clause for
+ "not applicable here" and an assertion are the same shape from the outside —
+ so a sweep over a fixed list of chrome must assert PRESENCE separately from
+ geometry, and the geometry helper's early return must be reserved for scopes
+ that are legitimately optional. This is the same family as the vacuous zero
+ and the unprobed gate: absence agreeing with everything.
+- **A field that moved out from under a check.** `usersLackNames` read `u.name`;
+ the User proto has `username` and `displayName` and no `name`, so every user
+ mapped to undefined and "the deleted profiles are gone" passed no matter who
+ was still in the list. It reads `displayName` now and THROWS when no user in
+ the list has one — absence-assertions need a positive control or they agree
+ with everything.
+
+One hole no option closes: the tracker masks TEXT NODES and input values but
+sends ATTRIBUTE values verbatim. `title={userName}` was shipping the signed-in
+person's name in clear next to the same name arriving as asterisks. **Personal
+data goes in text nodes, never in an attribute.**
+
+**Fixing a bug that a recipe action _pins_ will turn the suite red on purpose**
+— that is the mechanism working. Re-specify the action, do not delete it:
+`act2.flow.alice.users` asserted the `/manage/users` 500 until it was fixed, and
+its own `todo` said to flip it to 403.
+
+**Cast differs between suites.** In the smoke fixture alice OWNS h1; in the
+journey `hackagon-admin` creates the hackathon and alice joins it and votes in
+act 7 (organizers may not vote). A recipe action written with the smoke cast in
+mind gets `PermissionDenied` from the right code for the wrong reason.
+
+**Test-side locator lessons.** The dashboard's membership badge is a SIBLING of
+the row link, so rows are reached as the link's grandparent (`helpers/ui.ts`) —
+the badge has moved three times and the class lists changed with it, but "the
+thing the link is mounted in" did not. `clickLink` falls back to the accessible
+name: an icon-only link has no text content to filter on. Content assertions
+scope to ``, because a hidden-but-present account menu matched a page-wide
+`getByText` first. `getByRole("button", {name})` is substring AND
+case-insensitive, so `clickButton: "A"` also matched "Toggle light/d**a**rk
+mode" — `clickButton` prefers an exact match now. And `login: true` only works
+with `fresh: true`: with a persona's saved session Keycloak SSOs straight
+through and the helper waits forever for a `#username` field that never renders.
+
+## Mutation testing — making "can this test go red?" a thing that runs
+
+The section above is the expensive one, and every entry in it was found BY HAND,
+once. `.claude/skills/hackathon-e2e/mutations/` turns that hunt into a check: a
+**manifest** of deliberate, reversible breakages, each paired with the exact set
+of tests that MUST notice, and a **runner** that applies one, runs the tests,
+and asserts exactly that set failed.
+
+```bash
+bash .claude/skills/devcontainer-up/scripts/mutate.sh run # from the host
+bash .claude/skills/hackathon-e2e/scripts/mutate.sh list # the manifest
+bash .claude/skills/hackathon-e2e/scripts/mutate.sh check # anchors still match source
+bash .claude/skills/hackathon-e2e/scripts/mutate.sh run owner # one id, or a prefix
+bash .claude/skills/hackathon-e2e/scripts/mutate.sh restore # after a run was killed
+```
+
+**`NO REDS` is the result this exists for, and it FAILS the run.** Not a
+curiosity to note and move past: it means nothing in the suite holds that
+property, which is the same fact the sidebar-fold assertion turned out to be
+stating and the same fact `usersLackNames` stated for months. `MISMATCH` fails
+too and names the extras — an over-broad mutation, or coupling nobody knew
+about. Only `EXACT` passes.
+
+### Adding one
+
+Append a line to `mutations/manifest.jsonl`:
+
+```json
+{
+ "id": "cap.allowed.flatten",
+ "property": "UNGOVERNED PERMITS. Flattening …",
+ "arena": "go",
+ "tier": "fast",
+ "file": "components/backend/internal/capability/capability.go",
+ "find": "\treturn s == StateOpen || s == StateUngoverned",
+ "replace": "\treturn s == StateOpen",
+ "expectReds": [
+ "capability::Capability > Allowed > allows an ungoverned capability"
+ ],
+ "crossRef": ["act5.cap.ungoverned"]
+}
+```
+
+`find` must match its file **exactly once** — a fragment of real source, tabs
+and all. A manifest whose anchor has drifted is the same disease as a test that
+has stopped asserting, so `apply()` throws rather than skipping, and `check`
+exists to be cheap enough to run on every commit. Two edits in one mutation go
+in `edits: [{file,find,replace}, …]`; that shape exists because
+`markdown.script-survives` has to weaken BOTH `ALLOWED_TAGS` and `FORBID_TAGS` —
+defence in depth, and a half-applied mutation would report the property as
+tested when only the other half held.
+
+Author `expectReds` with `--record`, which prints the observed reds instead of
+judging them, then **read them before you freeze them**: recording is how a
+manifest agrees with whatever the code happens to do. An entry with an empty
+`expectReds` is rejected at load time unless it also says `"gap": true` with a
+`gapReason` — because `expectReds: []` is the one value that would make every
+mutation pass, and that is precisely the vacuous shape this tool is for. A gap
+that later starts producing reds is reported as `GAP CLOSED`, so promoting it is
+prompted rather than remembered.
+
+### Arenas, and why the journey is the last resort
+
+An arena is where the evidence is. Cost is why there is more than one.
+
+| arena | what it runs | cost | identity |
+| --------- | ----------------------------------------------- | ------------------------------------------------ | --------------------------------------------------------------- |
+| `go` | `go test -tags "test unittest"`, six packages | **~9 s** | `pkg::Describe > Context > It` (Ginkgo), `pkg::TestXxx` (plain) |
+| `vitest` | frontend units, narrowed to `arenaConfig.files` | ~10 s narrowed, 60 s full | `file::full test name` |
+| `journey` | `run.sh journey --until-act N` | minutes, + a backend restart or frontend rebuild | the recipe action id |
+| `smoke` | `run.sh smoke` | ~1.4 min against the built frontend | `file::title` |
+
+The **fast tier (`go` + `vitest`) needs no running stack at all** — it drives
+the compilers straight from source — which is what makes 30 mutations a
+five-minute check rather than an afternoon, and means it still works while the
+stack is down, being rebuilt, or in use by somebody else.
+
+Two things make that possible and neither is incidental. **The runner never
+enters `nix develop`**: that shell is a repo-wide mutex, ~5 s unopposed and
+serializing under contention (container trap 4), so 38 entries through it would
+cost more than every test they run. `.devenv/profile/bin` already holds `go`,
+`node` and `pnpm` and costs nothing to put on `PATH`. And **the journey cannot
+be `--grep`ped**: it is serial with chained `vars`, so the only lever is
+`--until-act N`, and a backend mutation additionally needs the running server
+rebuilt against it. That is minutes per entry against seconds, so the manifest
+routes a property to the journey only when nothing cheaper can witness it — and
+records the journey action in `crossRef` when a cheap arena is the primary
+witness, so the two are not confused for each other.
+
+### Restoration is verified, not assumed
+
+A mutation left in the tree that then gets committed is the worst outcome this
+tool can produce, so it has three independent recoveries. The runner refuses to
+start when any file it may write is already dirty; it writes the original bytes
+to `.state/backup/` and fsyncs them **before** editing, journals the edit, and
+restores on exit, on signal, and on `restore`. `scripts/mutate.sh` restores from
+the same journal in its own `trap EXIT` and then checks `git status` itself —
+because a trap cannot survive a SIGKILL or a container recreate, and the journal
+on disk is what makes a later `restore` possible at all.
+
+**That check earned its keep on the first multi-edit mutation, by finding a bug
+in the runner itself.** `markdown.script-survives` edits ONE file TWICE, so it
+journals two backups — and the second holds the file as it stood after edit 1,
+i.e. already mutated. Replaying the journal forwards restored the original and
+then overwrote it with the half-mutated copy: every backup on disk intact, the
+journal reading as fully unwound, and `markdown.ts` left broken. Restore runs
+**newest first** now, so each entry undoes exactly the edit that produced it.
+Nothing but a post-restore `git status` could have caught that — the tool
+believed it had cleaned up.
+
+⚠ **The cleanliness check is SCOPED to the files the manifest names**, plus
+`components/`, and that is deliberate. It was originally scoped for two reasons
+and one of them has since been fixed: a repo-wide "git status is empty" check
+could never pass while three git-lfs pointer files read as permanently modified,
+and `git-lfs` in the image closed that (trap 4). The reason that remains is a
+check on **other people**: the first run of this tool aborted ten mutations
+because a second agent added a file elsewhere under `.claude/` while it worked.
+A safety check that cries wolf on somebody else's work is a safety check that
+gets deleted.
+
+### What the first manifest found (2026-08-13, 38 mutations)
+
+**26 caught, 12 with NO REDS** (eleven of the twelve are closed as of 2026-08-14
+— see the section after this one; what they were is kept because the CLUSTERING
+is the finding). Every one of the twelve is a backend property, and eleven of
+them cluster into three surfaces that the 6-second Go suite did not touch at
+all:
+
+- **`requireWindowOpen` — all of it.** Deadlines never closing, the now-anchored
+ override ignored, registration opening early: three mutations, zero reds. No
+ Go spec exercises a window in any package.
+- **`RemoveOwner` — all of it.** The last-organizer guard, the
+ cannot-demote-yourself rule, and demotion leaving Member behind: three
+ mutations, zero reds, and a fourth (dropping `ownerMu`) that no unit test
+ could see anyway. Ownership is a casbin fact here with no column to assert
+ against, which is likely why the specs were never written.
+- **`Join`'s guards.** The invite requirement on a private event, the
+ already-finished refusal, and the ROLE a join grants. `join.grants-member`
+ hands every joiner OWNER instead of Member and not one Go spec notices.
+
+Plus `RequireUser` admitting the anonymous subject (the change eight TeamService
+handlers were made for), and `checkContentType`'s allowlist — the rule keeping
+`image/svg+xml` out of an origin we serve.
+
+**These are gaps in the FAST tier, not proof the product is unguarded**: each
+one is pinned by journey actions, listed in the entry's `crossRef`. But that
+means the only thing standing behind window enforcement and the last-organizer
+invariant is a suite that costs minutes, needs the whole stack, and cannot be
+run on a branch. **Those reds are also DEDUCED** — from each action's declared
+`expect.error` — not observed, because no journey mutation has been run yet.
+
+The frontend half came out the other way round: all 8 vitest mutations produced
+reds, including a cross-file one — flattening `capabilityAllows` turns
+`joinOffer`'s ungoverned case red as well, which is the two gates agreeing, in
+the test suite, that UNGOVERNED permits.
+
+### Eleven of the twelve closed (2026-08-14): 37 exact, 1 gap
+
+28 Go specs later the manifest reads **37 EXACT, 1 GAP, 0 NO REDS**, and the
+whole fast tier still runs in the same ~9.3 s — `internal/service` 312 → 337
+specs (+0.06 s), `internal/middleware` 43 → 46. New files:
+`config_service_test.go` (windows through Join), `hackathon_owner_test.go`,
+`hackathon_join_test.go`, `require_user_test.go`,
+`storage_upload_internal_test.go`, plus a `RequireUser` block in
+`middleware/auth_test.go`.
+
+Four things the work turned up that are worth more than the coverage:
+
+- **Ownership has no column, so every owner assertion goes through the
+ enforcer** `CreateTestServer` hands back — the same instance the server holds,
+ not a copy. `RemoveOwner` answers with an EMPTY message, so "it returned OK"
+ says nothing about who ends up holding what; `HackathonOwners` and
+ `GetHackathonRole` are the facts. And `owner.demote-restores-member` is only
+ visible on someone who did NOT already hold Member, so the spec writes its
+ participant row directly rather than joining — a bob who joined normally would
+ read as Member whether or not the demotion restored it.
+- **The order of RemoveOwner's two refusals is load-bearing in the tests too.**
+ The last-organizer guard runs before the self-demotion one, so a sole owner
+ demoting themselves is refused by the FIRST — which is why the self-demotion
+ spec promotes a co-organizer first (its recipe twin, `act5.owner.self`, says
+ the same in its `todo`), and why the last-organizer spec has a SECOND global
+ admin do the asking instead.
+- **No clock control anywhere, and no sleep.** `requireWindowOpen` takes `now`
+ as an argument but the handlers pass `time.Now()`, so the windows are written
+ relative to now (−1 h closed, +1 h not yet open) and the assertions hold
+ however slowly the suite runs.
+- **`OverrideWindow` cannot express an expired override** — protovalidate holds
+ `extend_minutes` to 1..1440, which the first draft of that spec discovered by
+ failing. The expired state is written to the row directly; the alternative is
+ waiting for one to run out, and a test that sleeps to cross a boundary flakes.
+
+The one that stays open is **`owner.mutex-dropped`**, and deliberately: the
+three RemoveOwner specs above are all SERIAL, so dropping `ownerMu` changes
+nothing any of them can see. A test that went red under it without two calls
+genuinely in flight would be pinning a coincidence. Its witness remains
+`act5.race.owner.remove` in the journey.
+
+### The baseline is not green, and that is handled rather than hidden
+
+"Exactly the expected set failed" means nothing against a suite that is not
+green to start with. `internal/service`'s **"Capacity > never oversells the last
+place under simultaneous joins" fails intermittently** under in-memory SQLite
+(roughly one run in five; the error is `Internal: couldn't join hackathon`).
+Left alone it lands in an arbitrary mutation's extras column and reads as
+coupling. So reds are diffed against a baseline taken on the clean tree, and an
+unexpected red is re-checked against a FRESH clean run before it is called
+coupling.
+
+**That re-check is not sufficient on its own, and the first verification run
+proved it**: `cap.gate.removed` came back MISMATCH naming exactly that spec,
+while the fresh sample happened to pass — an intermittent failure that does not
+reproduce in one extra sample is indistinguishable from coupling. A tool that
+randomly fails one entry in five is a tool people stop reading. So the flake is
+also **declared**, in `KNOWN_FLAKY` at the top of the arena section, with the
+reason it is there.
+
+Both paths report; neither drops. Every ignored red is printed **with its
+reason**, because an ignored red is a claim, and a list of tests whose failures
+don't count is precisely the shape that could hide a real one. Adding a line to
+`KNOWN_FLAKY` is a claim about the SUITE that wants justifying — never a way to
+quieten a mutation that is genuinely over-broad.
+
+⚠ **A flaky test can be genuine evidence and still be the wrong thing to judge
+by — closed 2026-08-16.** That capacity spec hammers concurrent joins against a
+cap, which is precisely what `capacity.oversell-by-one` breaks, so under THAT
+mutation its failure is the evidence and the first freeze had stripped it as
+noise. The fix then was to list it in the entry's own `expectReds`, where the
+excuse cannot reach it (the filter only ever looks at reds that are NOT
+expected). That bought a correct reading in one direction and a coin flip in the
+other: **it flakes in BOTH directions, and no filter can excuse the second
+one.** Observed 2026-08-14, `capacity.oversell-by-one` came back MISMATCH with
+that same spec under "expected but stayed GREEN" — its witness had PASSED under
+the mutation. Same root cause (a join that errors out under SQLite contention
+seats one fewer, so the oversell never materialises), opposite symptom, and an
+expected red that does not arrive is exactly what a MISMATCH is for.
+
+So the entry is judged by something that cannot flake.
+`Capacity > seats exactly the capacity, counting the roster after every join`
+makes the same END-STATE claim the race spec makes — the confirmed roster equals
+the cap, read back from the DB — by filling an event to exactly its capacity one
+join at a time and asserting the next arrival queues with the count unmoved.
+Three capacities, because a rule that is off by one is off by one at every cap,
+and the seated joins at 2 and 3 are the positive control against an event that
+simply queues everybody. It fails under the mutation in 0.012 s with no
+goroutines anywhere. Five consecutive `mutate.sh run capacity.oversell-by-one`
+came back EXACT.
+
+**The rule that survived: a verdict may only rest on witnesses that cannot
+flake.** The race spec is KEPT as a test — it pins `capacityMu`, which once
+genuinely oversold — and it is out of `expectReds`, so its red now lands in the
+extras column, is excused by `KNOWN_FLAKY`, and is PRINTED with its reason on
+every run of that entry. The evidence is still on screen; it is no longer on the
+ballot. ⚠ The flake itself is still open and still reds CI (see
+`just check::test -c backend` above) — this changes what the MUTATION is judged
+by, not the underlying test-side race.
+
+## Container traps (Windows/macOS hosts) — read before touching compose
+
+These cost hours; all of them are handled in `.devcontainer/` (or, for 2b, in
+the e2e harness), but the failure modes recur whenever the setup changes.
+
+**1. Never let `node_modules` live on the bind mount.** The workspace mount is
+`9p` on Windows; the volumes are `ext4`. Measured in this container:
+
+| | bind mount | named volume |
+| ----------------------------------------- | ---------- | ------------ |
+| `require("isomorphic-dompurify")` (jsdom) | 52,821 ms | 331 ms |
+| `pnpm install` | 34 s | 5 s |
+| `vitest run` (23 tests) | 104 s | 1.06 s |
+
+52 s exceeded vite's 60 s SSR module-transport timeout, so **every route
+returned 500** with a `fetchModule` timeout on `/src/app.css` — a total outage
+that looks nothing like "slow disk". Check which side you are on:
+`findmnt -no TARGET,FSTYPE | grep node_modules` must say `ext4`. ⚠ Still on the
+bind mount: `.claude/skills/hackathon-e2e/node_modules` (only
+`components/frontend/node_modules` is volumed).
+
+**2. Changing `dev`'s compose config recreates the container — which kills the
+stack inside it** (process-compose, and therefore Postgres/Keycloak/backend/
+frontend) **and wipes anything apt-installed at runtime.** That is how the e2e
+suite lost Firefox's system libraries
+(`libgtk-3.so.0: cannot open shared object file`); they are baked into the
+Dockerfile now. After any recreate: restart the stack (`scripts/up.sh` +
+`wait-ready.sh`) before anything else.
+
+**2b. After a proto/ent regeneration, vite's first SSR can take longer than
+anyone will wait — and process-compose kills it while it tries.** Regenerating
+wipes and rewrites ~260 files under `src/lib/server/grpc/generated/`, which
+invalidates that much of vite's transform cache. `src/` is on the 9p mount, so
+each cold transform is seconds (measured: 28 s for `src/app.css` alone). The
+first request to `/` then hangs, and the readiness probe (`curl`, 5 s timeout,
+kills after 100 failures) terminates the process mid-warm-up — the log says
+`readiness check fail - signal: killed`, which reads like a crash and is not
+one.
+
+How to tell this apart from a real hang, in one command:
+
+```bash
+PID=$(pgrep -f "vite.js dev"); cat /proc/$PID/task/$PID/stat | awk '{print $14, $15}'
+```
+
+Low utime/stime with the process alive means it is I/O-bound on 9p, not
+deadlocked. `ss -tanp | grep $PID` showing NO outbound socket means it has not
+reached the backend yet, so nothing downstream is to blame.
+
+**The fix is not to wait — serve the built output instead.** It has no transform
+step and boots in seconds:
+
+```bash
+cd components/frontend && pnpm build
+PORT=8081 HOST=::1 ORIGIN=http://localhost:8081 AUTH_URL=http://localhost:8081 \
+ node build/service/index.js --config-dir ./data/test/config --data-dir ./data/test
+```
+
+Smoke drops from 3.0 m to 1.4 m against it. Three traps in that one command:
+`HOST=::` collides with the socat bridge already on `:8081` (EADDRINUSE);
+`HOST=127.0.0.1` binds a port `localhost` does not resolve to, because
+`localhost` is **`::1`** in this container; and `AUTH_URL` must be set alongside
+`ORIGIN` or login completes and does nothing. Stop process-compose's `frontend`
+first, and check for a leftover vite still holding `[::1]:8081`.
+
+`E2E_BASE_URL` retargets the whole harness (`lib.sh` derives `FRONTEND_URL` from
+it), but prefer serving on **:8081**: the realm export's `hackagon-frontend`
+client — realm `hackagon`, not `hackagon-dev`, which is the bucket and network
+name — allows exactly one redirect URI, `http://localhost:8081/*`, so :8082 dies
+at login with `Invalid parameter: redirect_uri`.
+
+**3. Do not gate sidecars on `dev`'s health.** `dev` is healthy only once
+someone runs `just up`, which compose does not manage, so
+`depends_on: condition: service_healthy` on `dev` deadlocks: it blocks on a
+stack compose cannot start, and the config change that added the healthcheck is
+what killed that stack. `caddy` uses a plain `depends_on`; readiness is checked
+by `cloudflare-tunnel/scripts/up.sh`, which fails fast when nothing serves on
+:8081. `tunnel → caddy` keeps `service_healthy` — that race is real (cloudflared
+resolves its target once) and compose owns both sides.
+
+Optional `services` profile runs Postgres and Keycloak as their own containers
+(`service-bridge.sh` maps them onto localhost inside `dev` so checked-in configs
+keep working). Opt-in: `just up` still starts devenv's copies and they would
+fight over ports. Note `postgres:18+` wants a single mount at
+`/var/lib/postgresql`, not `/var/lib/postgresql/data`.
+
+**4. `nix develop` is a GLOBAL MUTEX on this repo, and every service in the
+stack goes through it** (fixed 2026-08-13). This is the one that poisoned
+several days of test results, and it never once looked like an infrastructure
+problem — it looked like product bugs, at four different places in four runs.
+
+Every `nix develop` takes a repo-wide lock while it fetches and hashes the tree
+(`waiting for another Nix process to finish fetching input 'git+file:///workspaces/hackagon'…`),
+and the stack's own processes are `just develop just run` /
+`just develop just serve`, so entering that shell is inside every service's
+startup — while process-compose's readiness clock is already running. **The
+probe budget is spent waiting for Nix, not on the server.** (The probes
+themselves are fine; they are `${pkgs.grpcurl}/bin/grpcurl` and
+`${pkgs.curl}/bin/curl` by store path and enter no shell.)
+
+⚠ **THE 44 s FLOOR THIS ONCE CLAIMED WAS WRONG — corrected 2026-08-13,
+re-measured 2026-08-14.** `just nix::develop default true` in this container is
+**~4.6–5.0 s steady state**, and _clean versus dirty is not the variable_: one
+modified tracked file measured 4.7–5.0 s, identical to a clean tree. The first
+entry after a tree edit runs 4.7–10.6 s. The 44 s was almost certainly sampled
+while `frontend` was crash-looping through one full `nix develop` per round —
+i.e. it measured the contention, not the floor, and capping `max_restarts`
+removed it.
+
+The cost that IS real is self-inflicted and small: `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 run. Against a
+fixed root file the same call drops to 3.2–4.5 s — about 1.7 s of every entry.
+
+**`git-lfs` is in the image now** (`.devcontainer/Dockerfile`, 2026-08-13), so
+the three LFS-tracked binaries (`components/frontend/static/favicon.png`,
+`static/og-default.jpg`, `tools/configs/keycloak/.../img/favicon.ico`) no longer
+read as permanently modified — the worktree held their real bytes, smudged by
+the Windows host, while HEAD held a pointer, and a container with no
+`filter.lfs.smudge` compared the two and reported ` M` forever. It was A/B'd
+directly: the tree goes genuinely clean, Nix stops printing
+`warning: Git tree … is dirty`, and **the time does not move.** Keep it for the
+truthful `git status` — several tools read it — not for speed.
+
+What that budget actually was: probes land ~15 s apart (process-compose's
+default period), so the backend's `failure_threshold: 50` was ~12.7 min —
+against a **COLD backend restart measured at 486 s on a quiet lock.** 64% of the
+budget spent before one competitor is added, each competitor costing ~+36 s.
+
+When the budget runs out process-compose **kills the service**, and both of its
+two possible endings are bad. Reproduced on the real `just develop just run`
+with the budget scaled down:
+
+| how the SIGTERM lands | exit | `restart: on_failure` does | result |
+| --------------------------- | ------- | ---------------------------- | -------------------------------------------------------- |
+| the Go signal handler is up | **0** | nothing — 0 is not a failure | **down forever**, recorded as `Completed` |
+| it lands before the handler | **143** | restarts, uncapped | **149 restarts in 151 s** = one `nix develop` per second |
+
+The first is what the logs showed in the wild: `grpc server listening`, then
+`received shutdown signal`, then `exit_code=0` — which reads like a clean stop
+and is a kill. Downstream it was mid-run `NS_ERROR_CONNECTION_REFUSED`, a
+`reset.sh` that printed "State wiped" while data survived, and a stack needing
+manual restarts.
+
+**What generated the contention was a crash loop nobody could see.** Found live:
+process-compose's `frontend` at **54 restarts in 50 minutes**, exit 1,
+`Error: Port 8081 is already in use` — because the harness's own adapter-node
+server holds `[::1]:8081` (that is its job, trap 2b) and nothing had put vite
+down. `prod-frontend.sh ensure`'s fast path ("the built frontend already serves
+:8081 — leaving it alone") returned without touching process-compose, so the
+loop ran forever, one full `nix develop` per round.
+
+⚠ **And `process list` said `frontend Running Ready` throughout.** Its
+readiness probe is `curl http://localhost:8081`, which the OTHER server was
+answering. **A probe on a PORT cannot tell you which PROCESS holds it** — this
+is the infrastructure member of the silent-green family above, and the same trap
+bit the reproduction rig itself (a leftover scratch backend on :3001 made a run
+report Ready in 10 s having tested nothing). The `RESTARTS` column said 54 the
+whole time and nothing read it.
+
+Four changes, no compose change and no rule to remember:
+
+- `prod-frontend.sh`'s `ensure` calls `stop_vite` **unconditionally** — it is
+ the built server that gets left alone, never vite.
+- `toolchain.nix` frontend: `max_restarts = 3`, so a port conflict costs three
+ shell entries rather than one an hour.
+- `toolchain.nix` backend: `restart = "always"` **plus `max_restarts = 3`**
+ (`always` alone converts a permanent outage into an unbounded loop — that is
+ the 149-restarts row), and `failure_threshold` 50 → 150 (~37 min). A generous
+ budget costs nothing when healthy, because probing stops at the first success,
+ and **the thing that should decide "the backend did not come up" is
+ `wait-ready.sh`'s own 300 s timeout, which names the service** — not a
+ supervisor whose only move is to kill a server that was merely slow.
+- `wait-ready.sh` now **reads the restart counters back** and warns, with the
+ exit code, when any service is ≥3. The number was always there.
+
+**5. Two concurrent `pnpm build`s corrupt `components/frontend/build/service`**
+(fixed 2026-08-13; hit by three agents in one day). `pnpm build` is
+`vite build -m production`, `svelte.config.js` sends adapter-node's output to
+`${QUITSH_BUILD_DIR:-build}/service`, and there were **two independent callers
+that both build AND SERVE that one tree** — `hackathon-e2e/prod-frontend.sh` on
+:8081 and `cloudflare-tunnel/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:
+`Unexpected end of JSON input`, then a missing `build/service/server/index.js`
+at boot.
+
+Both callers now go through **`.claude/skills/lib/frontend-build.sh`**, which
+does two things for two different holes: an **exclusive `flock`**, so two builds
+cannot interleave and the second caller waits and then finds the first one's
+fresh output (staleness is re-checked INSIDE the lock — checking it outside is
+how both callers decide to build); and a **build into a temp dir + atomic
+swap**, so `build/service` only ever contains a complete tree. The lock cannot
+help with the second: an interrupted build's writer is gone, not concurrent, and
+what it had written so far stays there looking like a build. `if-stale` is the
+entry point for the harness, `build` for an unconditional rebuild.
+
+Two things measured while building that, both worth keeping:
+
+- **Two concurrent bare builds did NOT reliably corrupt anything** — one attempt
+ with a 5 s stagger left an intact tree, because adapter-node's copy phase is
+ short and the two missed each other. That is consistent with it taking three
+ people in one day to hit; it is a narrow window, not a certainty. The
+ _interrupted_ build reproduces every time, which is why the atomic swap is the
+ half with a deterministic proof: killed at the instant
+ `build/service/index.js` was gone, the tree was left with no entry point;
+ through the helper that window **never opens at all**, and a build killed 40 s
+ in leaves `build/service` with the same inode it had before.
+- ⚠ **A directory rename on the 9p bind mount intermittently answers EPERM**
+ (`mv: cannot move '…/build/service' to '…/build/.service-old-352884': Permission denied`),
+ and it is NOT open descriptors — the same rename succeeded a minute later with
+ the same servers running and nothing open under the tree. 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.
+- ⚠ **But one cause of that EPERM IS reproducible — a server still SERVING the
+ tree** (2026-08-16). `frontend-build.sh build` with the :8081 adapter-node
+ server up burned all five attempts and gave up; `prod-frontend.sh stop`, then
+ the very next attempt, succeeded — with the :8082 server still running. So
+ when the retries all fail, the question is who is serving it, not the
+ filesystem. The order is **stop, build, start**, which is what
+ `prod-frontend.sh start` already does; calling the helper directly against a
+ live server is the case that hits the wall.
+
+⚠ **Swapping the tree in does NOT reach a server that booted against the old
+one, and the bytes will still be right** (2026-08-16). adapter-node serves
+`build/service/client` through `sirv`, which builds its manifest — sizes and
+ETags included — ONCE at boot. After a swap it streams the file that is on disk
+NOW while advertising the length and ETag of the file that was there when it
+started. Measured on the tunnel's :8082 server after rebuilding the social card:
+`Content-Length: 85099`, `ETag: W/"85099-…"`, and 58,130 correct bytes on the
+wire, every request, `curl: (18) transfer closed with 26969 bytes remaining`.
+
+**This is a silent-green trap aimed straight at how you would check.** Hash what
+arrived and it MATCHES the file on disk — the body is genuinely the new card —
+so a `sha256sum` of the download says the deploy worked while every real client
+sees a truncated image and every cache honours an ETag that is a lie. The card
+exists for link-preview crawlers, which is exactly the audience that would have
+got the broken one. **Compare the HEADERS, and read curl's exit code, not just
+its output.** Any rebuild has to restart every server on that tree —
+`prod-frontend.sh start` for :8081 and `prod-serve.sh start --no-build`
+for :8082 — because there are two of them and only one of them is ever the one
+you were thinking about.
+
+**6. An empty list is not an answer — say "I could not ask"** (fixed
+2026-08-13). The built :8081 server keeps ONE module-scope gRPC channel
+(`lib/server/grpc/client.ts`) for its whole life. grpc-js does reconnect, but on
+a backoff that grows to a **120 s cap**, and every RPC issued while it waits
+fails immediately — so a backend that was down for a few minutes leaves the app
+serving errors for up to two more minutes AFTER the backend is demonstrably
+healthy. The browse page then rendered **0 events while `grpcurl` returned 8
+from the same database.**
+
+That alone was survivable; what cost the hours was the page's own load doing
+`.catch(() => ({ hackathons: [] }))`, with a comment calling an empty list "a
+calm and truthful thing for a visitor to read during an outage". Calm, yes;
+truthful, no — **"the database is empty" and "I cannot reach the backend" became
+the same page**, and in a container where every run wipes and reseeds the
+database, that is the most expensive confusion available.
+
+Both halves fixed: the channel caps its reconnect backoff at 2 s (a failed
+connect on loopback costs nothing), and the load carries `listUnavailable` so
+the page says which of the two it is. The regression test is
+**`hackathon-e2e/scripts/check-reconnect.sh`** — restart the backend under a
+running :8081, assert the browse page lists its events again, _and_ assert that
+while the backend is down the page says unavailable rather than empty. Without
+that second assertion half the script passes against a page that is lying.
+
+## Named tunnels — the churn above has a root cause, and this removes it
+
+Everything in the next section exists to survive a hostname that changes on
+every restart. **A named tunnel is a hostname on a zone you own**, and all three
+rigs support one now (`.claude/skills/lib/cf-named-tunnel.sh`, driven by a
+gitignored `.claude/skills/cloudflare-tunnel/.env`):
+
+| rig | hostname var | 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` |
+
+`up.sh` picks 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 ONE hostname, so a second public URL would serve every page and fail every
+login, which is the failure that only surfaces when somebody signs in. **Quick
+tunnels are untouched and remain the zero-setup path**; `--quick` forces them
+and needs no account.
+
+**The re-wiring dance is gone in named mode, and it is the far end that says
+so.** A stable hostname makes the second `up.sh --with-auth` write a
+byte-identical overlay, so `config-overlay.sh` answers `unchanged` — but an
+unchanged FILE is not a correct PROCESS (that stale-process trap has cost three
+debugging sessions). `auth-wire.sh` therefore mints a token from the issuer it
+just wired and asks the running backend whether it accepts it; only then does it
+skip the restart. "Could not ask" restarts, because a skip has to be earned.
+
+**Nothing tracked carries the hostname**, exactly as before: the issuer lives in
+`config.local.yaml` through `config-overlay.sh`, and `config_test.go` still
+asserts both tracked configs say `localhost`. Caddy needed no change at all —
+`Caddyfile.tunnel` binds `:80` for any Host, so the path mux and the `/objects`
+Host rewrite apply identically.
+
+⚠ **A Cloudflare API token scopes to a ZONE, not to a hostname.** There is no
+per-subdomain grant and no combination of settings that produces one: the
+narrowest token for this job can edit **any DNS record in the whole zone**. Do
+not describe it as limited to the three subdomains. 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). Minting
+steps, permissions, rotation and leak response are in the skill's SKILL.md.
+
+**The token is a SETUP credential.** After the tunnels exist, cloudflared runs
+from a per-tunnel credentials file that cannot touch DNS, cannot enumerate the
+zone and cannot create anything. A machine that only RUNS a tunnel should hold
+`.state/named//` and no token.
+
+**One local trap worth knowing, because it looks like a broken tunnel.** The LAN
+resolver here answers **AAAA-only** for these names on a network with no IPv6
+route out: every lookup succeeds, every connection fails in milliseconds.
+`auth-wire.sh`'s `/etc/hosts` pin used to be gated on `getent hosts`, which says
+YES about a name nothing can reach — it tests REACHABILITY now, and the
+readiness probe retries against a DoH-resolved IPv4 edge and, when that works,
+says "the tunnel is fine, this machine's resolver is not" instead of reporting a
+failure. `curl --resolve :443:` is the manual check.
+
+## The tunnel's auth wiring (why login kept breaking)
+
+`run.sh` unwires the tunnel before a suite — every persona logs in over
+localhost, and tokens carrying the tunnel issuer fail every auth setup. It now
+**re-wires on EXIT**, so a test run no longer silently logs out the public URL.
+The failure was invisible in the worst way: the tunnel kept serving pages, so
+only someone actually signing in found out.
+
+**Wiring writes `config.local.yaml`, never the tracked `config.yaml`.** Both
+loaders read an optional, gitignored overlay beside the base file — backend
+`defaults < config.yaml < config.local.yaml < HACKAGON_* env`, frontend
+`config.yaml < config.local.yaml`, deep-merged and validated by the same schema
+— so `auth-wire.sh` writes one key and `--restore` is an `rm`. It used to `sed`
+the two tracked files and keep `.pretunnel` backups: while wired the working
+tree differed from HEAD, and a `git add -A` committed a hostname that dies with
+the tunnel. That happened — a dead issuer sat committed for several commits, and
+a fresh clone pointed at a tunnel that no longer existed. The guard against a
+repeat is a spec in `internal/config/config_test.go` asserting BOTH tracked
+configs still say `localhost`; `run.sh` reads the wired URL out of the overlay,
+so the overlay's absence is now the "no tunnel" signal.
+
+**That overlay has THREE writers now, and none of them may `rm` it.**
+`auth-wire.sh` owns `oidc`; `openreplay-stack/scripts/wire-frontend.sh` owns
+`replay` (moved there for the same reason — a wired dev machine used to carry a
+`*.trycloudflare.com` ingest hostname in the tracked `config.yaml`);
+`plausible-stack/scripts/wire-frontend.sh` owns `plausible`. All go through
+`.claude/skills/lib/config-overlay.sh`, which adds and removes ONE top-level key
+and deletes the file only when the last key leaves it. A whole-file `rm` is
+invisible in both directions: dropping `replay` stops recording, and an empty
+OpenReplay UI already looks like the correct default; dropping `oidc` leaves the
+tunnel serving every page and breaks only login. The second is not hypothetical
+— `run.sh` calls `auth-wire.sh --restore` on the way into every suite run, so an
+`rm` there would unwire replay before the openreplay suite could read it.
+**Anything that READS the replay config must read the merged view**
+(`tests/openreplay/capture.ts` does): a reader looking only at `config.yaml`
+finds `enabled` absent on a well-wired machine, every spec in that folder
+self-skips, and the suite reports green having tested nothing.
+
+**The consent banner used to be a lid on the bottom of every page — fixed
+2026-08-10, and the workaround it forced is gone with it.** It was
+`fixed bottom-0 z-[60]`, which takes NO space in the document, so it sat on
+whatever was at the bottom of the viewport and swallowed clicks aimed at it with
+nowhere to scroll them to: `act0.about.publish` clicks the CMS `visible`
+checkbox, Playwright retried for the full 60 s against
+`
intercepts pointer events`,
+and the journey died on its 10th action with 338 not run. **Smoke passed the
+same wiring** — whether a page trips over a banner drawn on top of it depends
+entirely on where that page's controls sit.
+
+It is `sticky bottom-0` now: last in the document, so it pins to the viewport
+while there is page below it and settles into its own space at the end. That
+makes the reserved space exactly its own height at every width and however many
+lines the sentence wraps to — no hard-coded spacer, no measuring script (it
+still works with JS off). `helpers/reflow.ts:expectConsentBannerClearsContent`
+pins BOTH halves at 8 widths across every route: fully on screen at the top of
+the page, and covering nothing operable with the document scrolled to its end.
+Against the `fixed` version it failed at all six chrome widths, naming the
+footer's Privacy/Terms/About/GitHub links.
+
+**The new assertion immediately found a SECOND instance, on 21 routes.**
+Reserving space in the document does nothing for chrome anchored to the
+VIEWPORT: `HackathonSidebar` is `sticky top-14 h-[calc(100vh-3.5rem)]`, so it
+reaches the bottom of the screen at every scroll position and its last four
+entries — Manage Pages, Prizes, Deadlines, Manage Forms — sat under the banner
+with **no scroll position that freed them**. Scrolling frees page content; it
+can never free a viewport-pinned column. That one cannot be solved in CSS alone
+(no element can ask another how tall it is), so the banner publishes its
+measured height as `--consent-banner-h` and the sidebar takes
+`pb-[var(--consent-banner-h,0px)]` — padding, not a shorter box, so the nav's
+own `overflow-y-auto` scrollport is what shrinks. Measured, never hard-coded:
+`bind:offsetHeight` is a ResizeObserver, so a resize or a copy edit moves it
+(`offsetHeight`, because `clientHeight` omits the 1px `border-t` — and a 1px
+overlap sits inside the check's rounding tolerance, i.e. it would be wrong in
+the one way nothing would report). With no JS the fallback is `0px` — the ask
+still works and the document still scrolls clear; only that inset needs a
+script.
+
+`run.sh` therefore **no longer borrows the `replay` block away** for other
+suites — do not reintroduce that. The tracker is consent-gated (the server
+withholds the ingest endpoint and the project key until a browser clicks "Allow
+recording", which no suite but `openreplay` does), so a wired block changes
+exactly one thing for the others: the ask is on screen, exactly as it is for
+every first-time visitor. What run.sh does now is the OPPOSITE for the `mobile`
+suite — it ADDS a no-ingest `replay` block when none is wired, because that
+sweep asserts about the banner and an assertion whose subject is absent verifies
+nothing.
+
+**The tunnel has its own upstream port.** `--prod` used to park the adapter-node
+build on **:8081**, vite's port, so `run.sh` had to evict it for the duration of
+a suite and put it back on exit — and during each handover nothing was
+listening, so caddy answered the public link with **502 for ~40s on every test
+run**. The built server lives on **:8082** now and `Caddyfile.tunnel` tries
+`dev:8082` then falls back to `dev:8081` (`lb_policy first` + passive health
+check), so prod and vite coexist, plain non-prod tunnels still work, and
+`run.sh` has no prod-mode guard at all — only the auth re-wire trap. Reload that
+config with
+`docker compose … exec caddy caddy reload --config /etc/caddy/Caddyfile`;
+`up -d` would recreate `dev` and kill the stack. The remaining trade: the built
+server reads `config.yaml` once at boot, so during a run the public URL keeps
+SERVING but new logins through it fail until the exit re-wire restarts it.
+
+**That fallback is only correct when :8081 is vite, and caddy cannot tell**
+(fixed 2026-08-10). vite derives the request origin from the Host header, so it
+answers a tunnel hostname correctly. The adapter-node build does not — it is
+launched with a FIXED `ORIGIN`, and the harness always uses
+`http://localhost:8081` (`prod-frontend.sh`, which **`wait-ready.sh` starts on
+every run**, so every machine that has ever run a suite or `start.sh` is in this
+state). Caddy served it happily under the tunnel hostname; SvelteKit then 403s
+every form POST whose `Origin` is not its `ORIGIN`, so the public URL rendered
+every page and **"Log in" did nothing**, and `start.sh --tunnel`'s login proof
+timed out with nothing in any log naming the cause. That server had also read
+its OIDC issuer once at boot, before the tunnel was wired, so it was stale twice
+over.
+
+`cloudflare-tunnel/scripts/up.sh` now calls **`prod-serve.sh ensure `** on
+every hackagon-stack tunnel (not just `--prod`): it starts a correct-origin
+:8082 only when a fixed-origin server holds :8081, leaves a vite fallback alone
+— it is the reason the fallback exists — and **exits non-zero rather than hand
+over a link it knows is broken**. `prod-serve.sh status` names :8081's `ORIGIN`
+for the same reason. The alternatives were worse: unsetting `ORIGIN` on the
+harness's server makes adapter-node default the protocol to `https`, which fails
+the same CSRF check from the other side on `http://localhost:8081` (caddy
+deliberately does not forward `X-Forwarded-Proto` to the frontend, so there is
+no header to read), leaves `AUTH_URL` on localhost, and does nothing about the
+stale issuer; and dropping the fallback from `Caddyfile.tunnel` would break the
+plain vite tunnel, which is a supported mode.
+
+`devcontainer-up/scripts/start.sh` is the one-command path — container → stack →
+(optionally) tunnel with auth — and it finishes by driving a real login
+round-trip, because serving HTML proves nothing about OIDC.
+
+**A container keeps its boot-time config forever, and the file on disk lies
+about what is running.** `caddy` reads `Caddyfile.tunnel` once, when its
+container starts. `docker compose up -d caddy` does not re-read it for a running
+container, and recreating it is not available here (trap 2 — it can take `dev`
+and the whole stack with it). So a correct, committed Caddyfile can sit next to
+a running config that does not match it, for as long as that container lives.
+
+That shipped a real outage. The `/objects` route's
+`header_up Host {upstream_hostport}` — REQUIRED, because SigV4 signs the Host
+and the store recomputes the signature over whatever arrives — was present and
+correct in the file and **absent from the running config**. Every presigned
+UPLOAD through the public URL answered `403 SignatureDoesNotMatch`; nothing else
+did, because public reads are unsigned. The symptom reaching a person was
+"Storage rejected the upload (403)" on an app whose every page and image worked.
+
+`cloudflare-tunnel/scripts/up.sh` now calls `ensure_caddy_config`: reload, then
+**ask the admin API what is live** (`localhost:2019/config/`) and warn when the
+`/objects` route has no Host rewrite. Checking the file would have proven
+nothing — the file was already right. (`MSYS_NO_PATHCONV=1` on that reload: from
+Git Bash, `/etc/caddy/Caddyfile` is rewritten to
+`C:/Program Files/Git/etc/caddy/Caddyfile` before docker sees it.)
+
+`tests/tunnel/upload.spec.ts` is the independent check, and its existence is the
+lesson: **`smoke/16-image-upload` passed throughout, because it uploads over
+localhost.** A suite that only ever exercises the local path cannot see a fault
+that lives in a proxy only the public path traverses. The spec was verified by
+stripping the rewrite from the running config and watching it fail with the
+user's exact 403.
+
+Two hypotheses were wrong on the way, both worth not re-running: signed
+`content-length` being re-chunked away by a proxy (it survives caddy AND
+Cloudflare — measured), and the browser converting to WebP after presigning
+(`uploadImage` converts first). A 403 from the store is a signed header being
+rewritten in flight, and it has always been the Host.
+
+⚠ **Do not forward `X-Forwarded-Proto: https` to the frontend** in
+`Caddyfile.tunnel` (Keycloak's route needs it; the frontend's must not have it).
+Auth.js picks its cookie NAMES from the scheme it believes it is on, and the
+vite dev server does not apply the header to `event.url` — so it wrote the
+session as `authjs.session-token`, then looked for
+`__Secure-authjs.session-token` and found nothing. Login dead-ended back on the
+public page with tokens successfully issued. The frontend infers the public
+scheme itself in `src/routes/+layout.server.ts` for the absolute URLs link
+previews need.
+
+## Quick start
+
+```bash
+bash .claude/skills/devcontainer-up/scripts/up.sh # container up + ready
+bash .claude/skills/devcontainer-up/scripts/e2e.sh smoke
+bash .claude/skills/devcontainer-up/scripts/e2e.sh journey
+bash .claude/skills/hackathon-e2e/scripts/run.sh journey --until-act 5 # freeze mid-story
+```
+
+Session-replay privacy proof (needs the openreplay rig up and the app wired at
+it — the suite self-skips otherwise, so running it without the rig costs nothing
+and claims nothing):
+
+```bash
+bash .claude/skills/openreplay-stack/scripts/up.sh # creates the admin account itself (.secrets.env)
+bash .claude/skills/openreplay-stack/scripts/wire-frontend.sh
+bash .claude/skills/hackathon-e2e/scripts/run.sh openreplay
+bash .claude/skills/openreplay-stack/scripts/wire-frontend.sh --restore
+```
+
+Audience measurement (page views per SCREEN, cookieless, no URL ever sent — the
+privacy decisions are in `docs/frontend/analytics.md`):
+
+```bash
+bash .claude/skills/plausible-stack/scripts/up.sh # instance + its own tunnel, no prompts
+bash .claude/skills/plausible-stack/scripts/wire-frontend.sh
+bash .claude/skills/plausible-stack/scripts/verify.sh # real browser → Plausible's own Stats API
+bash .claude/skills/plausible-stack/scripts/wire-frontend.sh --restore
+```
+
+Public URL with working login (see the cloudflare-tunnel skill):
+
+```bash
+bash .claude/skills/cloudflare-tunnel/scripts/up.sh --with-auth # stack must be up first
+bash .claude/skills/cloudflare-tunnel/scripts/up.sh --with-auth --quick # force an ephemeral URL
+bash .claude/skills/cloudflare-tunnel/scripts/down.sh # also un-wires OIDC
+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
+```
+
+Quick-tunnel URLs are ephemeral, so `--with-auth` re-points the frontend and
+backend issuers at each new URL. **While wired, localhost logins fail** (their
+tokens carry the wrong issuer) — that is expected, `down.sh` restores it. Suite
+runs restore it too, so re-run `--with-auth` after any smoke/journey run. With a
+NAMED hostname that re-run is a no-op: same hostname, same overlay, no restart.
+
+`tests/tunnel/*.spec.ts` derive the public host from `TUNNEL_BASE_URL`
+(`tests/tunnel/host.ts`) rather than matching `trycloudflare.com`. The literal
+was correct while quick tunnels were the only public path and became a lie the
+day a named hostname worked — every wait would have timed out against a URL that
+was serving perfectly, reading as "login is broken through the tunnel".
+
+Dev credentials: all cast members use password `aliceandbob`; Keycloak admin is
+`admin`/`admin`. The extras crowd (`cast.json`) is provisioned by
+`scripts/roster.sh`.
+
+Not included in this archive: `node_modules/` (pnpm install), `.state/` (storage
+states / capabilities — regenerated per run), `.artifacts/` (reports).
diff --git a/.claude/settings.json b/.claude/settings.json
new file mode 100644
index 00000000..9b406c60
--- /dev/null
+++ b/.claude/settings.json
@@ -0,0 +1,7 @@
+{
+ "attribution": {
+ "commit": "",
+ "pr": ""
+ },
+ "includeCoAuthoredBy": false
+}
diff --git a/.claude/skills/cloudflare-tunnel/.env.example b/.claude/skills/cloudflare-tunnel/.env.example
new file mode 100644
index 00000000..827de0e9
--- /dev/null
+++ b/.claude/skills/cloudflare-tunnel/.env.example
@@ -0,0 +1,64 @@
+# Cloudflare credentials for NAMED tunnels — persistent hostnames on a zone you
+# own, instead of a quick tunnel's throwaway *.trycloudflare.com.
+#
+# Copy to `.env` BESIDE THIS FILE and fill it in. That path is gitignored by the
+# repo-wide `.env` rule, and every script that touches it asks
+# `git check-ignore` first and REFUSES to read or write a token that git could
+# take. Verify yourself before you paste anything:
+#
+# git check-ignore -v .claude/skills/cloudflare-tunnel/.env
+#
+# Nothing here is required. With no `.env`, every rig falls back to a quick
+# tunnel exactly as before — named mode is an addition, not a replacement.
+#
+# ⚠ THE TOKEN SCOPES TO A ZONE, NOT TO A HOSTNAME. There is no "only these three
+# subdomains" grant in Cloudflare. A token that can edit DNS in your zone can
+# edit ANY record in it. Use a zone you are willing to hand to a dev script.
+# SKILL.md → "Named tunnels" has the exact minting steps and what each
+# permission is for.
+
+# API token. Minted at
+# Cloudflare dashboard → My Profile → API Tokens → Create Token → Custom token
+# with exactly two permissions:
+# Zone → DNS → Edit (Zone Resources: Include → your zone)
+# Account → Cloudflare Tunnel → Edit (Account Resources: your account)
+CLOUDFLARE_API_TOKEN=
+
+# The zone the hostnames below live in. The account id is read OUT of the zone
+# record, which is why no account-read permission is needed.
+CLOUDFLARE_ZONE=example.org
+
+# Optional: only if the token can see more than one account and the zone lookup
+# picks the wrong one.
+#CLOUDFLARE_ACCOUNT_ID=
+
+# One hostname per rig; each gets its own tunnel. Leave a line blank or absent
+# and that rig stays on quick tunnels.
+#
+# The app hostname serves the frontend AND Keycloak through caddy's path mux
+# (/realms/*, /resources/* → Keycloak, /objects/* → the object store, everything
+# else → SvelteKit), which is what makes the OIDC redirect work from outside.
+HACKAGON_HOSTNAME=hackagon.example.org
+PLAUSIBLE_HOSTNAME=plausible-hackagon.example.org
+OPENREPLAY_HOSTNAME=openreplay-hackagon.example.org
+
+# The k3d chart rig (.claude/skills/k3d-chart-rig/scripts/tunnel.sh) takes TWO
+# names on ONE tunnel: the helm chart routes the app and Keycloak by HOST, on
+# two separate Ingresses, so there is no single name that reaches both.
+#
+# ⚠ BOTH ARE ONE LABEL DEEP, DELIBERATELY. Cloudflare's free Universal SSL
+# covers the apex and one label — `example.org` and `a.example.org` — and
+# NOTHING below that. `auth.k3d-hackagon.example.org` gets no certificate at the
+# edge and answers a TLS handshake with alert 40, which reads to a browser as a
+# broken site rather than as a missing certificate. Keep them siblings unless
+# the zone has Advanced Certificate Manager.
+K3D_HOSTNAME=k3d-hackagon.example.org
+K3D_AUTH_HOSTNAME=k3d-auth-hackagon.example.org
+
+# Optional: tunnel names as they appear in the Cloudflare dashboard. Defaults
+# below. Change them if two checkouts share one Cloudflare account, or the
+# second one will reuse the first's tunnel and repoint its DNS.
+#HACKAGON_TUNNEL_NAME=hackagon
+#PLAUSIBLE_TUNNEL_NAME=hackagon-plausible
+#OPENREPLAY_TUNNEL_NAME=hackagon-openreplay
+#RIG_TUNNEL_NAME=hackagon-k3d
diff --git a/.claude/skills/cloudflare-tunnel/SKILL.md b/.claude/skills/cloudflare-tunnel/SKILL.md
new file mode 100644
index 00000000..4586e1fa
--- /dev/null
+++ b/.claude/skills/cloudflare-tunnel/SKILL.md
@@ -0,0 +1,364 @@
+---
+name: cloudflare-tunnel
+description:
+ Expose the locally running stack on a public URL through Cloudflare — either a
+ NAMED tunnel on a hostname you own (persistent, survives restarts) or a free
+ ephemeral *.trycloudflare.com quick tunnel needing no account at all. One
+ hostname serves frontend AND Keycloak (caddy path-mux), so --with-auth gives
+ fully working OIDC login/registration through the tunnel. Use when asked to
+ expose the app (or any local port) publicly, share a demo link, get a public
+ URL, set up a stable dev hostname, or demo login from a phone. Wraps the
+ devcontainer's caddy+tunnel services, a named-tunnel mode driven by a
+ gitignored Cloudflare API token, and a generic any-port mode.
+---
+
+# Cloudflare tunnel
+
+Two modes, and `up.sh` picks between them.
+
+A **quick tunnel** is `cloudflared tunnel --url `: cloudflared opens an
+outbound connection to Cloudflare and receives a random public
+`https://.trycloudflare.com` URL that pipes to the target. No account, no
+DNS, no config — but **ephemeral** (new URL every start, gone when the process
+stops) and **public** (anyone with the link can reach the service while it
+runs).
+
+A **named tunnel** is the same pipe with a hostname you own: a tunnel record in
+a Cloudflare account, a proxied `CNAME` to `.cfargotunnel.com`, and a
+credentials file cloudflared runs from. It needs an account, a zone and a
+one-time API token — and in exchange the hostname is **the same tomorrow**.
+
+**Named mode is chosen automatically when credentials are present** (`.env`
+beside this file — see "Named tunnels" below); otherwise quick, which keeps this
+the zero-setup path it has always been. `--named` / `--quick` force it, and
+`up.sh` prints which mode it is in.
+
+## Why a stable hostname is worth the setup
+
+Most of the tunnel tooling's complexity exists to survive a hostname that
+changes on every restart:
+
+- `auth-wire.sh` re-points BOTH OIDC issuers at each new URL and restarts the
+ frontend and the backend to load them (a cold backend restart is minutes).
+- `hackathon-e2e/scripts/run.sh` unwires before every suite and re-wires on
+ exit, because the personas log in over localhost.
+- The adapter-node build reads its issuer and its `ORIGIN` once at boot, so a
+ new hostname leaves it stale in two ways at once.
+- A dead `*.trycloudflare.com` hostname once sat committed in HEAD for several
+ commits, and a fresh clone pointed at a tunnel that no longer existed.
+
+With a named hostname the second and every later `up.sh --with-auth` writes a
+**byte-identical** overlay, so `config-overlay.sh` answers `unchanged` and
+**nothing is rewritten and nothing is restarted** — `auth-wire.sh` confirms that
+by minting a token from the wired issuer and asking the running backend whether
+it accepts it, so the skip is granted by the far end rather than by the file.
+The suite's unwire/re-wire is still there (localhost logins need the localhost
+issuer), but it is now idempotent instead of a fresh hostname every time.
+
+## Commands
+
+```bash
+bash .claude/skills/cloudflare-tunnel/scripts/up.sh # tunnel the hackagon stack (view-only)
+bash .claude/skills/cloudflare-tunnel/scripts/up.sh --with-auth # …with working OIDC login
+bash .claude/skills/cloudflare-tunnel/scripts/up.sh --with-auth --prod # …serving the production build
+bash .claude/skills/cloudflare-tunnel/scripts/up.sh --named # force a persistent hostname
+bash .claude/skills/cloudflare-tunnel/scripts/up.sh --quick # force an ephemeral one
+bash .claude/skills/cloudflare-tunnel/scripts/up.sh --port 3000 # tunnel any local port (generic mode)
+bash .claude/skills/cloudflare-tunnel/scripts/url.sh # print the current public URL(s)
+bash .claude/skills/cloudflare-tunnel/scripts/down.sh # stop all tunnels (+ undo auth rewiring + prod mode)
+```
+
+Named-tunnel plumbing, shared by all three rigs:
+
+```bash
+bash .claude/skills/lib/cf-named-tunnel.sh check # credentials + zone, nothing else
+bash .claude/skills/lib/cf-named-tunnel.sh status # which named tunnels are running
+bash .claude/skills/lib/cf-named-tunnel.sh destroy hackagon # give the hostname up
+```
+
+Prod mode on its own (the stack and tunnel must already be up):
+
+```bash
+bash .claude/skills/cloudflare-tunnel/scripts/prod-serve.sh start https://X.trycloudflare.com
+bash .claude/skills/cloudflare-tunnel/scripts/prod-serve.sh status # both ports + what the tunnel serves
+bash .claude/skills/cloudflare-tunnel/scripts/prod-serve.sh stop # tunnel falls back to vite
+```
+
+`up.sh` waits for the URL to appear in the tunnel logs and prints it — that IS
+the piping: grep `https://[a-z0-9-]*\.trycloudflare\.com` from `cloudflared`'s
+stderr. Everything runs in Docker; nothing is installed on the host.
+
+## Named tunnels
+
+### What it builds
+
+One tunnel **per rig**, not one tunnel with three ingress rules: the three rigs
+live in three compose projects on three docker networks, so a single cloudflared
+would have to be attached to all of them and restarted whenever any one came up
+or down. Per-rig tunnels are independent, and a rig that is down simply has no
+tunnel.
+
+| rig | hostname variable | tunnel name | origin |
+| ---------- | --------------------- | --------------------- | ----------------------------------------------------------- |
+| the app | `HACKAGON_HOSTNAME` | `hackagon` | `http://caddy:80` (the same path-mux the quick tunnel uses) |
+| Plausible | `PLAUSIBLE_HOSTNAME` | `hackagon-plausible` | `http://plausible:8000` |
+| OpenReplay | `OPENREPLAY_HOSTNAME` | `hackagon-openreplay` | `http://caddy:80` (its own caddy) |
+
+Nothing about caddy changes. `Caddyfile.tunnel` binds `:80` for **any** Host, so
+the path mux, the `/objects` route and its `header_up Host {upstream_hostport}`
+(without which every presigned upload 403s while reads keep working) apply
+identically on a named hostname. `tests/tunnel/upload.spec.ts` proves that from
+a browser on whichever hostname it is pointed at.
+
+### The token
+
+Cloudflare dashboard → **My Profile → API Tokens → Create Token → Create Custom
+Token**:
+
+1. **Permissions** — exactly two rows:
+ - `Zone` · `DNS` · **Edit**
+ - `Account` · `Cloudflare Tunnel` · **Edit**
+2. **Zone Resources** — `Include` · `Specific zone` · _your zone_.
+3. **Account Resources** — `Include` · _your account_.
+4. Leave Client IP Address Filtering and TTL as you like; a TTL is a good idea
+ for a setup credential.
+5. Create, copy the token **once** (Cloudflare never shows it again), and paste
+ it into `.claude/skills/cloudflare-tunnel/.env` (copy `.env.example`).
+
+What each permission is for, and what it cannot do:
+
+- **`Zone → DNS → Edit`** creates and updates the `CNAME` that makes the
+ hostname resolve to the tunnel. It cannot read or change anything outside DNS
+ — no zone settings, no WAF, no page rules, no cache purge.
+- **`Account → Cloudflare Tunnel → Edit`** creates the tunnel record and reads
+ back its run token. It is account-scoped because tunnels are account objects,
+ not zone objects. It cannot touch DNS, and it cannot see any other kind of
+ account resource.
+- No account-read permission is needed: the account id is read out of the zone
+ record the DNS permission already returns.
+
+### ⚠ What "scoped" honestly means here
+
+**A Cloudflare API token scopes to a ZONE, not to a hostname.** There is no
+"only `hackagon.example.org`" grant, and no combination of settings produces
+one. The narrowest possible token for this job can edit **any DNS record in that
+entire zone** — the apex, mail records, a colleague's staging host. Do not
+describe it, to yourself or anyone else, as restricted to the three subdomains:
+it is not.
+
+Two consequences worth acting on:
+
+- **Use a zone you are willing to hand to a dev script.** A zone that also
+ serves production mail or a live site is the wrong one.
+- The tooling adds the guard Cloudflare cannot. `cf_dns_point` **refuses to
+ replace a record it did not create** — anything that is not already a
+ `*.cfargotunnel.com` CNAME is left alone and reported, and `CF_FORCE_DNS=1` is
+ the deliberate override. That is what stands between a typo in `.env` and an
+ unrelated hostname going down.
+
+### The token is a SETUP credential — do not leave it on a runner
+
+Once the tunnels exist, **nothing at run time needs the token**. `cfn_ensure`
+writes a per-tunnel credentials file to
+`.claude/skills/cloudflare-tunnel/.state/named//credentials.json`
+(gitignored, `AccountTag` + `TunnelSecret` + `TunnelID`), and cloudflared runs
+from that plus a local ingress file. That credential can do exactly one thing:
+serve traffic for that one tunnel. **It cannot touch DNS, cannot enumerate the
+zone, and cannot create anything.**
+
+So split the two roles:
+
+- The machine that **sets up** hostnames holds the `.env`.
+- A machine that only **runs** a tunnel gets `.state/named//` copied to it
+ and **no token at all**.
+
+If a credentials file is ever lost, the tunnel is still recoverable without
+deleting it and re-pointing DNS: the tunnel's run token is base64 of the same
+three fields, and `cfn_ensure` rebuilds the file from it.
+
+### Rotation, and what to do if it leaks
+
+**Rotate the token now if it was ever transmitted in plain text** — pasted into
+a chat, an issue, a terminal that is being recorded, or a shared clipboard. That
+includes the initial hand-off: a secret that travelled in the clear should be
+treated as spent the moment it has done its job.
+
+- **Roll it**: dashboard → **My Profile → API Tokens** → the token's row → `…` →
+ **Roll**. This issues a new value and **revokes the old one immediately** —
+ there is no grace period, so anything still using the old value stops working
+ at once. Paste the new value into `.env`; nothing else changes, because the
+ tunnels and DNS records already exist and the run-time path does not use the
+ token at all.
+- **Delete it** instead if you no longer need setup access: same menu, `Delete`.
+ Existing tunnels keep running — they authenticate with their own credentials
+ files.
+- **If it leaked**: roll or delete it FIRST, then look at the zone's DNS records
+ (dashboard → the zone → DNS) for anything you did not create, and at **Zero
+ Trust → Networks → Tunnels** for tunnels you did not create. A leaked token's
+ blast radius is "any DNS record in that zone, plus any tunnel in that account"
+ — plan the review around that, not around the three hostnames this tooling
+ uses.
+- The credentials files are secrets too, of a narrower kind. Revoke one by
+ deleting its tunnel (`cf-named-tunnel.sh destroy `), which also
+ removes the DNS record.
+
+### What is stored where
+
+| path | holds | protection |
+| ----------------------------------------------------------------------- | ------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------- |
+| `.claude/skills/cloudflare-tunnel/.env` | API token, zone, the three hostnames | gitignored by the repo-wide `.env` rule; every script `git check-ignore`s it before reading or writing and refuses otherwise |
+| `.claude/skills/cloudflare-tunnel/.state/named//credentials.json` | per-tunnel run credential | gitignored by `.claude/**/.state/`; written with `umask 077`; checked the same way |
+| `…/.state/named//config.yml` | the ingress rules, generated | gitignored; no secret in it |
+| `components/*/data/test/config.local.yaml` | the wired issuer | gitignored overlay, one key per writer (`config-overlay.sh`) |
+
+**Nothing tracked ever carries the hostname.** `internal/config/config_test.go`
+asserts both tracked `config.yaml` files still say `localhost`, and that holds
+for a named hostname exactly as it did for a quick one — a stable hostname is
+still this machine's deployment choice, not the repo's.
+
+### Known local-network trap: AAAA-only answers
+
+On the machine this was built on, the LAN resolver returns Cloudflare's **IPv6**
+edge for these names and no `A` record, on a network with no IPv6 route out.
+Every lookup succeeds and every connection fails in milliseconds — so a check
+that asks "does it resolve" says yes about a hostname nothing here can reach.
+
+Both places that mattered ask the right question now: `auth-wire.sh` pins a
+DoH-resolved **IPv4** edge into the dev container's `/etc/hosts` when the host
+is _unreachable_ (it used to check only that it resolved), and the named-tunnel
+readiness probe retries against a DoH-resolved address and, if that works, says
+so — "the tunnel is fine, this machine's resolver is not". If a browser on your
+laptop cannot reach a hostname that `--resolve` reaches, this is why.
+
+## How each mode pipes
+
+- **Named (hackagon stack)**: starts the compose `caddy` service only, then a
+ `cf-named-hackagon` cloudflared container of its own on caddy's network, with
+ a locally-managed ingress file. It does NOT go through the compose `tunnel`
+ service — and it stops that service if it is running, because the OIDC issuer
+ can only name ONE hostname and a second public URL would serve every page
+ while silently failing every login. No file under `.devcontainer/` is edited:
+ the container is a plain `docker run`, the same shape the generic `--port`
+ mode uses, which also means it cannot trigger a recreate of `dev` (container
+ trap 2).
+- **Quick (hackagon stack)**: starts the compose `caddy` + `tunnel` services
+ (profile `tunnel` in `.devcontainer/docker-compose.yml`). The tunnel targets
+ caddy, which path-splits the single public hostname: `/realms/*` +
+ `/resources/*` go to Keycloak (`dev:8180`), everything else to the frontend —
+ see `.devcontainer/Caddyfile.tunnel`. The frontend route has TWO upstreams
+ tried in order: `dev:8082` (the production build, when `--prod` is running)
+ then `dev:8081` (`vite dev`). Vite binds loopback inside the dev container, so
+ `up.sh` first (re)runs `.devcontainer/host-bridge.sh` — the socat bridge that
+ republishes the loopback ports on the container's network interface. Vite's
+ `server.allowedHosts` already allowlists `.trycloudflare.com`.
+- **`--with-auth`**: after the URL appears, `auth-wire.sh` runs inside the dev
+ container and (1) verifies Keycloak reports the https tunnel issuer (needs
+ `proxy-headers=xforwarded`, baked into toolchain.nix — one full stack restart
+ after first pulling that change), (2) allowlists the tunnel origin on the
+ `hackagon-frontend` realm client via the admin API (the committed realm file
+ stays untouched), (3) writes frontend `oidc.issuer` and backend
+ `oidc.issuerurl` into each component's **`config.local.yaml`** and restarts
+ both processes. `down.sh` (or `auth-wire.sh --restore`) undoes all of it.
+ While wired, log in **through the tunnel URL** — localhost logins carry the
+ wrong issuer and fail backend validation.
+- **Wiring never touches a tracked file.** `config.local.yaml` sits beside
+ `config.yaml` in the same config dir, is gitignored, optional and partial;
+ both loaders read it **after `config.yaml` and before the environment**
+ (`components/backend/internal/config/config.go`,
+ `components/frontend/src/lib/server/settings.ts`), merging key by key so an
+ overlay naming only the issuer leaves `jwksurl`, `clientId` and `audience`
+ alone. `--restore` removes the **`oidc` block**, and the file only when that
+ was the last key in it — it used to be a plain `rm`, which was right while
+ there was one writer and became a silent bug when session replay
+ (`openreplay-stack/scripts/wire-frontend.sh`) started owning `replay` in the
+ same overlay: `hackathon-e2e/scripts/run.sh` calls `--restore` on the way into
+ EVERY suite run, so an `rm` there would delete the replay wiring and the
+ openreplay suite would self-skip and report green having tested nothing.
+ `.claude/skills/lib/config-overlay.sh` does the per-key surgery for both
+ writers. This replaced a `sed` over the two tracked `config.yaml` files with
+ `.pretunnel` backups: while wired, the working tree differed from HEAD, and a
+ `git add -A` committed a hostname that dies with the tunnel — which happened,
+ and a fresh clone then pointed at a tunnel that no longer existed.
+ `config_test.go` in the backend's config package asserts both tracked configs
+ still say `localhost`, so the old shape cannot come back quietly.
+- **`--prod`**: runs the adapter-node **production build** on its OWN port,
+ `:8082`, next to `vite dev` on `:8081`. The dev server ships unbundled modules
+ — measured on the landing page: **150 requests / 7.7 MB** versus **54 requests
+ / 2.9 MB** built (code alone: 136 requests / 5.1 MB → 42 / 0.26 MB; the rest
+ is unoptimised JPEGs, identical either way). `prod-serve.sh` builds and
+ launches `build/service/index.js` detached (pid + log under `.output/run/`);
+ it stops nothing. Runs LAST in `up.sh`, after `auth-wire.sh`: config is read
+ once into a module singleton at boot, so the issuer must already be on disk.
+ Undo with `prod-serve.sh stop` or `down.sh` — caddy then falls back to vite on
+ its own.
+- **Generic (`--port N`)**: runs a one-off `cloudflare/cloudflared` container
+ named `cf-quicktunnel-` targeting `http://host.docker.internal:` — works
+ for anything listening on the host, independent of the devcontainer.
+
+## Caveats
+
+- **Login needs `--with-auth`**: the plain mode leaves OIDC pointed at
+ `localhost:8180`, so only anonymous/public surfaces work through the tunnel.
+ `--with-auth` rewires the issuers for the tunnel's lifetime; the Keycloak
+ admin console stays localhost-only either way (caddy does not route `/admin`).
+- **Fresh hostnames lose the DNS race**: the first lookup often lands before
+ Cloudflare's record propagates and resolvers negative-cache the NXDOMAIN.
+ `auth-wire.sh` pins the hostname inside the dev container via DoH to 1.1.1.1
+ (`/etc/hosts`, removed on restore); remote devices (a phone) may just need a
+ minute before the URL resolves. This applies to a **newly created** named
+ hostname too — once, rather than on every restart, which is the whole point.
+- **A named hostname that resolves to nothing serving is a Cloudflare 1033 error
+ page**, not a connection failure. `down.sh` stops the container and keeps the
+ hostname, so that is what the link shows while the stack is down — which is
+ more honest than a dead name, and is why `down.sh` does not delete the DNS
+ record.
+- **The stack must already be serving.** `up.sh` checks `:8081` inside the dev
+ container and fails fast if not — caddy proxies to the app and cloudflared
+ resolves its target once at startup, so a tunnel started against a dead stack
+ silently points at nothing. This check lives here, not as a compose
+ `depends_on: service_healthy` on `dev`: that container's health depends on
+ `just up`, which compose does not manage, and the config change needed to add
+ the healthcheck recreates `dev` — killing the very stack it waits for.
+- **If the tunnel serves nothing but caddy and cloudflared look fine**, recreate
+ the tunnel container: cloudflared resolved `caddy`'s address at startup and
+ caches it, so a caddy recreated underneath it leaves the tunnel pointing at a
+ stale IP.
+- **A suite run un-wires auth — but no longer takes the link down.**
+ `scripts/run.sh` restores the localhost issuers for the duration of a run and
+ re-wires on exit. In `--prod` mode the public URL keeps SERVING throughout
+ (the built server on `:8082` is outside process-compose and holds its config
+ in memory, so rewriting the overlay does not reach it); only _new logins_
+ through the tunnel fail until the run ends and the re-wire restarts it. That
+ trade is deliberate — **pages must never 502**. Prod mode used to share
+ `:8081` with vite, so `run.sh` had to hand the port back and forth around
+ every run and the public link answered **502 Bad Gateway for ~40s per suite**.
+ Without `--prod` the tunnel rides on vite, which the suite does restart, so
+ expect gaps there.
+- **Prod mode needs `ORIGIN` _and_ `AUTH_URL` set to the public URL**, and
+ `prod-serve.sh` sets both. `ORIGIN` alone gives a tunnel where login
+ completes, tokens are issued — and the visitor lands back signed out. Auth.js
+ decides its cookie NAMES (`__Secure-authjs.session-token` vs
+ `authjs.session-token`) twice per request cycle and from different inputs: the
+ `/auth/*` routes see `event.request`, built from `ORIGIN` (https), while
+ `event.locals.auth()` — the session read behind every page and the route guard
+ — asks `createActionURL()`, which trusts the `X-Forwarded-Proto` header that
+ caddy deliberately does not set to https for the frontend. So the callback
+ writes the `__Secure-` cookie and every later request looks for the plain one.
+ `AUTH_URL` short-circuits the header sniffing. Without `ORIGIN`, separately,
+ every form POST 403s as cross-site.
+- **Prod mode is a snapshot.** Source edits do nothing until
+ `prod-serve.sh start ` rebuilds — there is no hot reload. Config is a
+ snapshot too: `config.yaml` and its overlay are read once at boot, which is
+ why `auth-wire.sh` restarts the built server after writing the overlay (and
+ why localhost keeps its hot reload on `:8081` regardless). `status` reports
+ both ports and which one the tunnel is currently served from.
+- The URL is public while up — don't leave tunnels running unattended, and never
+ tunnel anything with real data. **This is more true of a named hostname, not
+ less**: it is guessable, it is in your zone's DNS, and it comes back at the
+ same address every time. A quick tunnel's obscurity was never security, but a
+ named one does not even have that.
+- Quick tunnels are rate-limited, best-effort infrastructure for demos. Named
+ tunnels are the same technology Cloudflare runs in production, but nothing
+ here makes this stack production-ready — the dev passwords are still
+ `aliceandbob`, Keycloak is still `admin`/`admin`, and the object store still
+ ships committed credentials.
diff --git a/.claude/skills/cloudflare-tunnel/scripts/auth-wire.sh b/.claude/skills/cloudflare-tunnel/scripts/auth-wire.sh
new file mode 100644
index 00000000..62173138
--- /dev/null
+++ b/.claude/skills/cloudflare-tunnel/scripts/auth-wire.sh
@@ -0,0 +1,433 @@
+#!/usr/bin/env bash
+# Rewire OIDC so login works through the tunnel's public hostname — quick
+# (*.trycloudflare.com) or NAMED (your own zone). Runs INSIDE the dev container
+# (up.sh execs it there). The hostname is just an argument; nothing here knows
+# or cares which kind it is.
+#
+# auth-wire.sh wire issuers to that hostname
+# auth-wire.sh --restore undo (back to localhost)
+#
+# With a NAMED hostname the second and every later wire is a no-op: the overlay
+# it would write is byte-identical, so nothing is rewritten and — once the
+# running backend confirms it accepts tokens from that issuer — nothing is
+# restarted either. That is the whole reason to prefer a named tunnel: the
+# per-restart re-wiring churn a quick tunnel forces simply does not arise.
+#
+# What wiring does:
+# 1. sanity-check Keycloak answers on the tunnel host with an https issuer
+# (requires proxy-headers=xforwarded — baked into toolchain.nix; needs a
+# one-time full stack restart after pulling that change)
+# 2. allowlist the tunnel origin on the hackagon-frontend realm client
+# (runtime admin-API patch — the committed realm file stays untouched)
+# 3. write frontend `oidc.issuer` and backend `oidc.issuerurl` into each
+# component's config.local.yaml, restart both processes — plus the
+# adapter-node build on :8082 when prod mode is up, since it reads its
+# config once into a module singleton at boot
+# While wired, logins via plain http://localhost:8081 will FAIL backend
+# validation (tokens carry the tunnel issuer) — use the tunnel URL, then
+# --restore when done.
+#
+# NOTHING TRACKED IS EDITED. config.local.yaml sits beside config.yaml, is
+# gitignored, and both loaders read it AFTER config.yaml and BEFORE the
+# environment (components/backend/internal/config/config.go,
+# components/frontend/src/lib/server/settings.ts).
+# This used to be a `sed` over the two TRACKED config.yaml files with
+# `.pretunnel` backups beside them: while wired, the working tree differed from
+# HEAD, and a `git add -A` committed an issuer that dies with the tunnel. That
+# happened — a dead hostname sat committed for several commits, and a fresh
+# clone pointed at a tunnel that no longer existed.
+#
+# THIS SCRIPT OWNS EXACTLY ONE KEY IN THAT FILE: `oidc`. It is not the only
+# writer any more — openreplay-stack/scripts/wire-frontend.sh owns `replay` in
+# the same overlay — so --restore removes the `oidc` BLOCK rather than the
+# file. It used to `rm` it, which was correct while there was one writer and is
+# now a silent way to stop session replay recording: `hackathon-e2e/scripts/
+# run.sh` calls `--restore` on the way into every suite run, so an `rm` there
+# would delete the replay wiring before the openreplay suite could read it, and
+# the suite would self-skip and report green having tested nothing.
+# .claude/skills/lib/config-overlay.sh does the per-key surgery.
+set -euo pipefail
+HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
+ROOT_DIR="$(cd "$HERE/../../../.." && pwd)"
+
+FRONTEND_LOCAL="$ROOT_DIR/components/frontend/data/test/config/config.local.yaml"
+BACKEND_LOCAL="$ROOT_DIR/components/backend/data/test/config/config.local.yaml"
+OVERLAY="$ROOT_DIR/.claude/skills/lib/config-overlay.sh"
+PROD_SERVE="$HERE/prod-serve.sh"
+# The e2e harness's own :8081 server. Same staleness as :8082 — it reads the
+# issuer once at boot — but it belongs to the other skill, so it is optional
+# here: a machine without the e2e skill still restores correctly.
+E2E_PROD_FRONTEND="$ROOT_DIR/.claude/skills/hackathon-e2e/scripts/prod-frontend.sh"
+KC="http://localhost:8180"
+REALM="hackagon"
+CLIENT="hackagon-frontend"
+
+# Toolchain (just, process-compose, grpcurl, jq) — re-exec in the Nix dev
+# shell when invoked from a plain shell (same trick as the e2e skill).
+if ! command -v process-compose >/dev/null 2>&1 || ! command -v jq >/dev/null 2>&1; then
+ if [ -n "${HACKAGON_TUNNEL_NIX_WRAPPED:-}" ]; then
+ echo "error: toolchain not found even inside the Nix dev shell" >&2
+ exit 1
+ fi
+ export HACKAGON_TUNNEL_NIX_WRAPPED=1
+ cd "$ROOT_DIR"
+ exec just nix::develop default bash "$HERE/$(basename "${BASH_SOURCE[0]}")" "$@"
+fi
+
+wait_for() { #
+ local name="$1" timeout="$2" start
+ shift 2
+ start=$(date +%s)
+ printf " waiting for %-16s " "$name"
+ until "$@" >/dev/null 2>&1; do
+ if [ $(($(date +%s) - start)) -ge "$timeout" ]; then
+ echo "FAILED (timeout after ${timeout}s)"
+ return 1
+ fi
+ printf "."
+ sleep 2
+ done
+ echo "ok"
+}
+
+# Every curl in this script carries --max-time: an unbounded request that
+# never returns reads as a hang, not a failure (a single untimed attempt once
+# blocked a wait loop for 15+ minutes with no output). 10s is generous for the
+# local Keycloak admin API and still fails fast when it is wedged.
+admin_token() {
+ curl -s --max-time 10 -X POST "$KC/realms/master/protocol/openid-connect/token" \
+ -d client_id=admin-cli -d username=admin -d password=admin \
+ -d grant_type=password | jq -r ".access_token"
+}
+
+client_id() { #
+ curl -s --max-time 10 -H "Authorization: Bearer $1" \
+ "$KC/admin/realms/$REALM/clients?clientId=$CLIENT" | jq -r ".[0].id"
+}
+
+# Replace the client's redirectUris/webOrigins wholesale (GET the full
+# representation first — Keycloak's PUT nulls absent fields).
+patch_client() { #
+ local token cid rep
+ token=$(admin_token)
+ cid=$(client_id "$token")
+ [ -n "$cid" ] && [ "$cid" != "null" ] || {
+ echo "error: realm client '$CLIENT' not found" >&2
+ return 1
+ }
+ rep=$(curl -s --max-time 10 -H "Authorization: Bearer $token" "$KC/admin/realms/$REALM/clients/$cid")
+ echo "$rep" | jq ".redirectUris = $1 | .webOrigins = $2" |
+ curl -s -f --max-time 10 -X PUT -H "Authorization: Bearer $token" \
+ -H "Content-Type: application/json" -d @- \
+ "$KC/admin/realms/$REALM/clients/$cid"
+}
+
+restart_and_wait() {
+ echo "==> Restarting frontend + backend to load the new issuer..."
+ (cd "$ROOT_DIR" && just deploy::proc-comp process restart frontend >/dev/null)
+ (cd "$ROOT_DIR" && just deploy::proc-comp process restart backend >/dev/null)
+ # Per-attempt bounds are load-bearing: wait_for checks its deadline BETWEEN
+ # attempts, so one blocking probe (a cold vite holding :8081 has sat on a
+ # single untimed curl for 15+ minutes) defeats the timeout entirely.
+ wait_for "backend" 120 grpcurl -plaintext -max-time 10 localhost:3000 health.HealthService/Check
+ # vite binds [::1] only; `localhost` hits 127.0.0.1 first in this container.
+ wait_for "frontend" 120 curl -fsS --max-time 10 "http://[::1]:8081/"
+}
+
+# The two restarts above are process-compose's. The adapter-node build that the
+# TUNNEL is actually served from lives on :8082 outside process-compose, and it
+# read the issuer out of config.yaml once at boot — so a sed alone leaves the
+# public URL on the OLD issuer and every login through it fails. Call AFTER the
+# sed, never before.
+#
+# WIRE MODE ONLY, deliberately. --restore does NOT bounce it: restore is what a
+# suite run does on its way IN, and a restart there is a hole in the public
+# link at the exact moment `reset.sh` has vite down too. Leaving the built
+# server on the in-memory tunnel issuer means the URL keeps SERVING for the
+# whole run and only NEW logins fail — pages must never 502, and Keycloak has
+# dropped the tunnel redirect URIs by then anyway.
+restart_prod_server() { #
+ local origin="$1"
+ [ -f "$PROD_SERVE" ] || return 0
+ # `origin` doubles as "is prod mode live?" — it exits 1 when it is not.
+ bash "$PROD_SERVE" origin >/dev/null 2>&1 || return 0
+
+ echo "==> Restarting the built server on :8082 (ORIGIN=$origin)..."
+ # --no-build: only config on disk changed, the bundle is byte-identical.
+ bash "$PROD_SERVE" start "$origin" --no-build >/dev/null ||
+ echo "warn: could not restart the built server; the public URL is still on" \
+ "the old issuer — run prod-serve.sh start $origin --no-build" >&2
+}
+
+# Does the RUNNING backend accept a token minted by ?
+#
+# Answers three things, not two, and the third is why this is not a boolean:
+# 0 yes — the running backend validates tokens from that issuer
+# 1 no — it is running with a different issuer than the one asked about
+# 2 could not ask — Keycloak did not mint a token at all
+#
+# Callers want opposite defaults for that third case, so it is theirs to decide:
+# --restore must not bounce the backend because Keycloak happens to be down,
+# while the wire path must not SKIP a restart on the strength of a question it
+# could not put.
+backend_accepts_issuer() { #
+ local base="${1%/}" token
+ token="$(curl -s --max-time 15 \
+ -X POST "$base/realms/hackagon/protocol/openid-connect/token" \
+ -H "Content-Type: application/x-www-form-urlencoded" \
+ -d client_id=hackagon-backend -d username=alice -d password=aliceandbob \
+ -d grant_type=password -d scope="openid profile" 2>/dev/null |
+ jq -r '.access_token // empty' 2>/dev/null)"
+ [ -n "$token" ] || return 2
+ grpcurl -plaintext -H "authorization: Bearer $token" -max-time 10 \
+ localhost:3000 user.UserService/WhoAmI >/dev/null 2>&1
+}
+
+# Does the RUNNING backend accept a token minted by the LOCALHOST issuer?
+#
+# The overlay says what the configuration intends; this says what the process is
+# actually doing, and the two disagree whenever a process outlived the config it
+# booted with. That is not a corner case: `run.sh` calls `--restore` on the way
+# into every suite, so a backend started while a tunnel was wired reaches the
+# tests still validating against the tunnel issuer — and the old code skipped
+# the restart precisely BECAUSE the overlay was already clean, i.e. it was most
+# likely to do nothing exactly when the repair was needed.
+#
+# Symptom when it happens: all four auth setups time out and every spec after
+# them is "did not run", which reads like a broken product and is a stale
+# process. It has cost three separate debugging sessions.
+#
+# Asking the far end, not reading the file, is the same lesson the replay suite
+# learned: "the server accepted it" and "the server can use it" are different
+# claims, and only one of them can be read off a config file.
+backend_accepts_localhost() {
+ local rc=0
+ backend_accepts_issuer "http://localhost:8180" || rc=$?
+ # No token means Keycloak is down or unreachable, which is a different
+ # problem: answer "fine" so this never restarts the backend for a reason that
+ # has nothing to do with the issuer.
+ if [ "$rc" = 2 ]; then
+ echo "note: could not mint a localhost token (is Keycloak up?) — skipping" \
+ "the backend issuer check" >&2
+ return 0
+ fi
+ return "$rc"
+}
+
+# Restart the e2e harness's :8081 built server, which reads its OIDC issuer
+# ONCE at boot and is what every suite actually logs in through.
+#
+# `prod-frontend.sh ensure` deliberately leaves a running server alone, so a
+# stale one survives any restart that does not stop it first — and
+# `restart_and_wait` above restarts process-compose's services, which :8081 is
+# not one of.
+#
+# This runs on BOTH restore paths, and the split is what made the first version
+# of this fix wrong: repairing only the overlay-absent branch left the COMMON
+# case — a wired tunnel being unwound at the start of every suite run — still
+# handing the tests a server on the tunnel issuer. Symptom either way is four
+# auth setups timing out on `page.waitForURL`.
+bounce_e2e_frontend() {
+ [ -x "$E2E_PROD_FRONTEND" ] || return 0
+ bash "$E2E_PROD_FRONTEND" stop >/dev/null 2>&1 || true
+ bash "$E2E_PROD_FRONTEND" ensure >/dev/null 2>&1 ||
+ echo "warn: could not restart the :8081 built server; logins there may still" \
+ "fail — run prod-frontend.sh stop && prod-frontend.sh ensure" >&2
+}
+
+if [ "${1:-}" = "--restore" ]; then
+ # Drop any tunnel-hostname pin left in /etc/hosts (see wire mode below).
+ sudo sed -i "/# hackagon-tunnel/d" /etc/hosts 2>/dev/null || true
+ # Restore DROPS THE `oidc` KEY — not the file. With it gone both loaders fall
+ # back to the tracked config.yaml on their next boot; nothing to diff, nothing
+ # to reconcile, and no way for a stale backup to overwrite an edit somebody
+ # made to config.yaml meanwhile. The helper deletes the file itself once
+ # `oidc` was the last key in it, so an unwired machine still looks exactly
+ # like a fresh clone — but a machine with session replay wired keeps its
+ # `replay` block, which is the whole reason this is not an `rm`.
+ restored=0
+ for f in "$FRONTEND_LOCAL" "$BACKEND_LOCAL"; do
+ if [ "$(bash "$OVERLAY" remove "$f" oidc)" = "changed" ]; then
+ restored=1
+ fi
+ done
+ if [ "$restored" = 1 ]; then
+ patch_client '["http://localhost:8081/*"]' '["http://localhost:8081"]' ||
+ echo "warn: could not reset realm client (is Keycloak up?)" >&2
+ restart_and_wait
+ # No restart_prod_server here — see the comment on it. The e2e harness's
+ # :8081 server IS bounced: unlike :8082 it is what the suites log in
+ # through, and it booted with the issuer we just removed.
+ bounce_e2e_frontend
+ echo "OIDC rewired back to localhost."
+ echo "NOTE: a built server on :8082 keeps the tunnel issuer it booted with;"
+ echo "stop it (prod-serve.sh stop) if you need it on localhost too."
+ else
+ # Note the wording: config.local.yaml may well still EXIST holding somebody
+ # else's key (session replay writes `replay` into it). What matters here is
+ # only that no `oidc` block was there to remove.
+ #
+ # And that says nothing about the PROCESSES. Ask one before claiming it.
+ if backend_accepts_localhost; then
+ echo "Nothing to restore (no oidc overlay, and the backend takes localhost tokens)."
+ else
+ echo "No oidc overlay, but the backend REJECTS a localhost token — it is"
+ echo "still running with an issuer it booted with. Repairing:"
+ # The realm client's redirect URIs are patched by wire mode and are just
+ # as capable of outliving the overlay, so reset them on this path too.
+ patch_client '["http://localhost:8081/*"]' '["http://localhost:8081"]' ||
+ echo "warn: could not reset realm client (is Keycloak up?)" >&2
+ restart_and_wait
+ bounce_e2e_frontend
+ if backend_accepts_localhost; then
+ echo "OIDC repaired: the backend now takes localhost tokens."
+ else
+ echo "warn: the backend STILL rejects a localhost token after a restart." >&2
+ echo " Check components/backend/data/test/config/ for a stray issuer." >&2
+ fi
+ fi
+ fi
+ exit 0
+fi
+
+URL="${1:?usage: auth-wire.sh | --restore}"
+URL="${URL%/}"
+case "$URL" in https://*) ;; *)
+ echo "error: expected an https:// tunnel URL, got '$URL'" >&2
+ exit 1
+ ;;
+esac
+URL_HTTP="http://${URL#https://}"
+HOST="${URL#https://}"
+
+# Fresh hostnames routinely lose the DNS race: the first lookup lands before
+# propagation and the local resolver negative-caches NXDOMAIN, which would break
+# both this script's checks and the frontend's server-side token exchange.
+# Resolve via DNS-over-HTTPS straight at Cloudflare and pin the hostname in
+# /etc/hosts (real edge IP + real TLS cert — traffic still flows through the
+# tunnel). --restore removes the pin.
+#
+# ⚠ THE TEST IS REACHABILITY, NOT RESOLVABILITY, and the difference is not
+# academic. This used to ask `getent hosts`, which answers YES for a name that
+# resolves to an address nothing here can reach — and that is a real state: the
+# resolver on the machine this was developed against returns Cloudflare's IPv6
+# edge and no A record at all, on a network with no IPv6 route out. Every lookup
+# succeeded, every connection failed instantly, and the pin that exists for
+# exactly this never fired because the question it asked had the wrong answer.
+# A DoH A-record pin fixes both cases, because it forces IPv4.
+host_reachable() {
+ # No -f: any HTTP status means the name resolved AND the edge answered. Only
+ # a resolve (6) or connect (7) failure is what this is looking for.
+ curl -sS -o /dev/null --max-time 8 "https://$HOST/" >/dev/null 2>&1
+}
+if ! host_reachable; then
+ echo "==> '$HOST' is not reachable from here yet — pinning an IPv4 edge via /etc/hosts..."
+ ip=$(curl -s --max-time 10 "https://1.1.1.1/dns-query?name=$HOST&type=A" \
+ -H "accept: application/dns-json" |
+ jq -r '[.Answer[]? | select(.type == 1) | .data][0] // empty')
+ if [ -n "$ip" ]; then
+ sudo sed -i "/# hackagon-tunnel/d" /etc/hosts 2>/dev/null || true
+ echo "$ip $HOST # hackagon-tunnel" | sudo tee -a /etc/hosts >/dev/null
+ else
+ echo "warn: DoH could not resolve $HOST yet; relying on DNS to propagate" >&2
+ fi
+fi
+
+echo "==> Checking Keycloak answers on the tunnel host (issuer must be https)..."
+issuer=""
+for _ in $( # trycloudflare DNS can take ~30s to propagate
+ seq 1 45
+); do
+ issuer=$(curl -fsS --max-time 5 "$URL/realms/$REALM/.well-known/openid-configuration" 2>/dev/null | jq -r ".issuer" || true)
+ [ "$issuer" = "$URL/realms/$REALM" ] && break
+ sleep 2
+done
+if [ "$issuer" != "$URL/realms/$REALM" ]; then
+ echo "error: tunnel well-known reports issuer '$issuer'," >&2
+ echo " expected '$URL/realms/$REALM'." >&2
+ echo "If the issuer is http:// or localhost-based, Keycloak is running without" >&2
+ echo "proxy-headers=xforwarded (added in toolchain.nix) — restart the stack once:" >&2
+ echo " just down && just up" >&2
+ exit 1
+fi
+
+echo "==> Allowlisting the tunnel origin on the '$CLIENT' realm client..."
+# Keep localhost so direct logins still pass Keycloak's redirect check; the
+# http:// variant covers SvelteKit deriving an http origin behind the proxy.
+patch_client \
+ "[\"http://localhost:8081/*\", \"$URL/*\", \"$URL_HTTP/*\"]" \
+ '["+"]'
+
+echo "==> Pointing frontend/backend issuers at the tunnel (config.local.yaml)..."
+# Each overlay carries ONE key. Both loaders merge it into config.yaml key by
+# key, so everything else — the frontend's clientId/audience, the backend's
+# jwksurl and algorithm — comes from the tracked file unchanged.
+#
+# jwksurl deliberately stays on localhost: the signing keys are
+# host-independent and the internal fetch avoids a hairpin through Cloudflare
+# on every token validation.
+#
+# Written through config-overlay.sh, which replaces the `oidc` BLOCK and leaves
+# every other top-level key alone — `replay`, when session replay is wired into
+# the same overlay, is somebody else's and must survive a re-wire.
+CHANGED=0
+write_overlay() { #
+ local answer
+ answer="$(
+ bash "$OVERLAY" set "$1" oidc < Issuer already wired to $URL and the running backend accepts its"
+ echo " tokens — nothing to write, nothing to restart."
+ else
+ echo "==> Issuer overlay was already correct, but the running backend does" \
+ "not accept a token from it (it outlived its config). Restarting:"
+ fi
+fi
+if [ "$NEEDS_RESTART" = 1 ]; then
+ restart_and_wait
+ # Only alongside a real restart: this one rebuilds nothing but does take the
+ # public URL's upstream down and back up, and doing that to load a config it
+ # already holds is a hole in the link for no gain.
+ restart_prod_server "$URL"
+fi
+
+echo
+echo "Login-capable tunnel ready: $URL"
+echo "NOTE: while wired, log in via the tunnel URL (localhost logins carry the"
+echo "wrong issuer). Undo with: auth-wire.sh --restore (or the skill's down.sh)."
diff --git a/.claude/skills/cloudflare-tunnel/scripts/down.sh b/.claude/skills/cloudflare-tunnel/scripts/down.sh
new file mode 100644
index 00000000..b54808a3
--- /dev/null
+++ b/.claude/skills/cloudflare-tunnel/scripts/down.sh
@@ -0,0 +1,55 @@
+#!/usr/bin/env bash
+# Stop all tunnels to this stack — the compose quick tunnel, the named tunnel's
+# container, and any generic port tunnels — and undo the OIDC rewiring.
+#
+# THE NAMED TUNNEL'S HOSTNAME AND DNS RECORD SURVIVE THIS, on purpose. Stopping
+# the container is "take the link down for now"; the whole value of a named
+# tunnel is that the same hostname comes back on the next up.sh, with the issuer
+# wiring still correct. To give the hostname up for good — delete the tunnel and
+# its DNS record from Cloudflare — that is a separate, explicit act:
+#
+# bash .claude/skills/lib/cf-named-tunnel.sh destroy hackagon
+set -euo pipefail
+HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
+ROOT_DIR="$(cd "$HERE/../../../.." && pwd)"
+COMPOSE_FILE="$ROOT_DIR/.devcontainer/docker-compose.yml"
+case "$(uname -s)" in
+MINGW* | MSYS*)
+ export MSYS_NO_PATHCONV=1
+ export MSYS2_ARG_CONV_EXCL="*"
+ COMPOSE_FILE="$(cygpath -m "$COMPOSE_FILE")"
+ ;;
+esac
+# Stop the built server on :8082 first. `auth-wire.sh --restore` deliberately
+# leaves a running one alone (it would be a hole in the public link mid-suite),
+# so if this did not kill it the box would keep a server pinned to a tunnel
+# issuer for a tunnel that no longer exists. No-op when nothing is running.
+# (It never owned :8081, so `vite dev` needs no handover — that used to be this
+# step's real job, and the reason a suite run blacked out the public link.)
+docker compose -f "$COMPOSE_FILE" exec -T -u vscode -e USER=vscode dev \
+ bash -lc 'cd /workspaces/hackagon && bash .claude/skills/cloudflare-tunnel/scripts/prod-serve.sh stop' ||
+ echo "warn: prod-serve stop skipped (dev container not running?)" >&2
+
+# If a --with-auth run rewired the OIDC issuers, put them back too (no-op
+# when there is no config.local.yaml to delete; skipped if the dev container is
+# down, in which case the next `just up` still needs a manual --restore).
+docker compose -f "$COMPOSE_FILE" exec -T -u vscode -e USER=vscode dev \
+ bash -lc 'cd /workspaces/hackagon && bash .claude/skills/cloudflare-tunnel/scripts/auth-wire.sh --restore' ||
+ echo "warn: auth restore skipped (dev container not running?)" >&2
+
+docker compose -f "$COMPOSE_FILE" --profile tunnel rm -sf tunnel caddy 2>/dev/null || true
+
+# THIS STACK's named tunnel only. `cf-named-*` also covers the plausible and
+# openreplay rigs, which have their own hostnames and their own down.sh — taking
+# an analytics dashboard offline as a side effect of stopping the app's link is
+# exactly the kind of over-broad cleanup that gets discovered days later.
+docker rm -f "cf-named-${HACKAGON_TUNNEL_NAME:-hackagon}" >/dev/null 2>&1 &&
+ echo "stopped cf-named-${HACKAGON_TUNNEL_NAME:-hackagon}" || true
+
+for name in $(docker ps --format '{{.Names}}' | grep -E '^cf-quicktunnel-' || true); do
+ docker rm -f "$name" >/dev/null
+ echo "stopped $name"
+done
+echo "tunnels down"
+echo "(a named tunnel's hostname and DNS record are kept — the next up.sh reuses"
+echo " them. Give them up with: lib/cf-named-tunnel.sh destroy )"
diff --git a/.claude/skills/cloudflare-tunnel/scripts/prod-serve.sh b/.claude/skills/cloudflare-tunnel/scripts/prod-serve.sh
new file mode 100644
index 00000000..501a093a
--- /dev/null
+++ b/.claude/skills/cloudflare-tunnel/scripts/prod-serve.sh
@@ -0,0 +1,526 @@
+#!/usr/bin/env bash
+# Serve the ADAPTER-NODE PRODUCTION BUILD on :8082 — the tunnel's own upstream,
+# ALONGSIDE process-compose's `vite dev` on :8081, which it never touches.
+# Runs INSIDE the dev container (up.sh execs it there; re-execs itself into the
+# Nix dev shell when the toolchain is not on PATH).
+#
+# prod-serve.sh start build, serve it on :8082
+# prod-serve.sh start --no-build reuse the existing build/
+# prod-serve.sh ensure make the tunnel's upstream answer
+# as — the minimum that takes
+# prod-serve.sh stop stop the built server
+# prod-serve.sh status what is on :8082 and on :8081
+# prod-serve.sh origin print ORIGIN, exit 1 if not prod
+#
+# Why: `vite dev` ships unbundled ES modules. Measured on the landing page
+# (Playwright, cold cache): 150 requests / 7.7 MB dev vs 54 / 2.9 MB built —
+# and of that, CODE is 136 requests / 5.1 MB vs 42 / 0.26 MB (the remainder is
+# unoptimised JPEGs, identical either way). Fine on localhost, painful through
+# a Cloudflare quick tunnel.
+#
+# Why a DEDICATED port: this used to take :8081 over from vite, which meant
+# hackathon-e2e/scripts/run.sh had to hand the port back for the duration of a
+# suite and restore prod mode afterwards. Nothing listened on :8081 during
+# either switch, so caddy answered the PUBLIC link with 502 Bad Gateway for
+# ~40s on every single test run. Caddy now tries dev:8082 first and falls back
+# to dev:8081 (.devcontainer/Caddyfile.tunnel), so the two servers coexist and
+# a suite run is invisible from outside.
+#
+# This is the same server `just run-service` starts (components/frontend/
+# justfile), with three deliberate differences, all load-bearing:
+#
+# ORIGIN The recipe hardcodes http://localhost:8081. SvelteKit compares
+# every form POST's Origin header against it and answers 403
+# "cross-site form submission forbidden" when they differ — so with
+# the localhost value EVERY action through the tunnel breaks (login
+# kick-off, join, submit, vote). ORIGIN must be the URL the visitor
+# actually typed, hence the required argument.
+# AUTH_URL Must equal ORIGIN, or login completes and then does nothing.
+# Auth.js derives its cookie NAMES from the scheme it believes it
+# is on (`__Secure-authjs.session-token` vs `authjs.session-token`)
+# and it works that out in TWO different ways: the /auth/* routes
+# get `event.request`, whose URL adapter-node builds from ORIGIN
+# (https); `event.locals.auth()` — the session read every page and
+# the route guard depend on — calls createActionURL(), which uses
+# the X-Forwarded-Proto HEADER, and caddy deliberately does not
+# send https on the frontend route (see Caddyfile.tunnel). So the
+# callback wrote `__Secure-authjs.session-token` and every later
+# request looked for the unprefixed name, found nothing, and
+# bounced the freshly-logged-in visitor back to `/?returnTo=…`
+# with real tokens in hand. AUTH_URL short-circuits the header
+# sniffing (@auth/core `createActionURL`, read from
+# $env/dynamic/private) so both halves agree on https.
+# HOST adapter-node binds 0.0.0.0 (IPv4 only) by default; caddy proxies
+# `dev:8082`, which resolves to the dev container's eth0 IP, while
+# local checks use ::1/127.0.0.1. HOST=:: is a dual-stack wildcard
+# that covers all of them. (:8082 is deliberately NOT published in
+# docker-compose.yml — caddy reaches it over the compose network,
+# and editing that file would recreate `dev` and kill the stack.)
+#
+# Everything else must match the dev recipe: config is NOT env-based, it comes
+# from YAML found via --config-dir/--data-dir resolved against process.cwd(),
+# so the server has to run with cwd = components/frontend.
+set -euo pipefail
+HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
+ROOT_DIR="$(cd "$HERE/../../../.." && pwd)"
+
+FRONTEND_DIR="$ROOT_DIR/components/frontend"
+SERVER_ENTRY="build/service/index.js"
+# The tunnel's own upstream. Must match the first `reverse_proxy` upstream in
+# .devcontainer/Caddyfile.tunnel; :8081 stays with process-compose's vite.
+PROD_PORT=8082
+DEV_PORT=8081
+CONFIG_DIR="./data/test/config"
+DATA_DIR="./data/test"
+# Same run/log location process-compose writes into (.output is gitignored).
+RUN_DIR="$ROOT_DIR/.output/run"
+PID_FILE="$RUN_DIR/frontend-prod.pid"
+LOG_FILE="$RUN_DIR/frontend-prod-log"
+# ORIGIN is baked in at launch and cannot be read back off the process, but
+# anything that has to bounce this server (hackathon-e2e/scripts/run.sh) needs
+# to put the same value back. Park it next to the pid.
+ORIGIN_FILE="$RUN_DIR/frontend-prod.origin"
+
+# Toolchain (just, process-compose, node, pnpm) — re-exec in the Nix dev shell
+# when invoked from a plain shell (same trick as auth-wire.sh).
+if ! command -v process-compose >/dev/null 2>&1 || ! command -v node >/dev/null 2>&1; then
+ if [ -n "${HACKAGON_TUNNEL_NIX_WRAPPED:-}" ]; then
+ echo "error: toolchain not found even inside the Nix dev shell" >&2
+ exit 1
+ fi
+ export HACKAGON_TUNNEL_NIX_WRAPPED=1
+ cd "$ROOT_DIR"
+ exec just nix::develop default bash "$HERE/$(basename "${BASH_SOURCE[0]}")" "$@"
+fi
+
+wait_for() { #
+ local name="$1" timeout="$2" start
+ shift 2
+ start=$(date +%s)
+ printf " waiting for %-16s " "$name"
+ until "$@" >/dev/null 2>&1; do
+ if [ $(($(date +%s) - start)) -ge "$timeout" ]; then
+ echo "FAILED (timeout after ${timeout}s)"
+ return 1
+ fi
+ printf "."
+ sleep 2
+ done
+ echo "ok"
+}
+
+# THE SAME ENTRYPOINT RUNS ON BOTH PORTS, so the only honest way to tell the
+# tunnel's server from the e2e harness's is the PORT it was launched with —
+# `pgrep -f build/service/index.js` matches both. hackathon-e2e/scripts/
+# prod-frontend.sh scopes its own scan to `PORT=8081` for exactly this reason,
+# and this file did not: with no :8082 server up, `prod_pid` returned the
+# HARNESS's :8081 pid, so `stop` — which `down.sh` calls — killed the local
+# stack's frontend while reporting that it had stopped a tunnel upstream, and
+# `ensure`/`start` would have "restarted" it onto another port.
+servers_on_port() { # — pids of our built server launched with that PORT
+ local pid
+ for pid in $({ pgrep -f "$SERVER_ENTRY" 2>/dev/null || true; }); do
+ if tr '\0' '\n' <"/proc/$pid/environ" 2>/dev/null | grep -qx "PORT=$1"; then
+ echo "$pid"
+ fi
+ done
+ return 0
+}
+
+# A PID alone is not proof: PIDs get recycled, and the file survives a crash.
+# Only treat it as ours when the live process really is the built server ON OUR
+# PORT.
+is_prod_server() { #
+ local pid="${1:-}"
+ [ -n "$pid" ] || return 1
+ kill -0 "$pid" 2>/dev/null || return 1
+ tr '\0' ' ' <"/proc/$pid/cmdline" 2>/dev/null | grep -q "$SERVER_ENTRY" || return 1
+ tr '\0' '\n' <"/proc/$pid/environ" 2>/dev/null | grep -qx "PORT=$PROD_PORT"
+}
+
+prod_pid() {
+ local pid=""
+ if [ -f "$PID_FILE" ]; then
+ pid="$(cat "$PID_FILE" 2>/dev/null || true)"
+ fi
+ if is_prod_server "$pid"; then
+ echo "$pid"
+ return 0
+ fi
+ # Fall back to a scan: the pid file can be stale (or absent after a manual
+ # launch), but a second copy of the server holding the port would be
+ # invisible. First line taken in the shell rather than with `| head -1`:
+ # under `set -o pipefail` head's early exit SIGPIPEs the producer, the
+ # pipeline reports 141, and the caller's `$(...)` assignment inherits it —
+ # which `set -e` turns into an abort.
+ local pids
+ pids="$(servers_on_port "$PROD_PORT")" || true
+ printf '%s' "${pids%%$'\n'*}"
+}
+
+prod_html() { curl -fsS --max-time 5 "http://localhost:$PROD_PORT/" 2>/dev/null; }
+
+# vite binds [::1] only, so ask for that directly rather than making curl walk
+# a refused 127.0.0.1 first. The long budget is not paranoia: a cold `vite dev`
+# SSRs the landing page in ~19s here, and a 5s probe reports "nothing is
+# serving" for a dev server that is merely thinking.
+dev_html() { curl -fsS --max-time 45 "http://[::1]:$DEV_PORT/" 2>/dev/null; }
+
+# How to tell the two servers apart from their markup. The dev page pulls its
+# client entry straight off disk through vite's `/@fs/` prefix (unbundled — the
+# 150 requests this whole mode exists to avoid); the built page references
+# hashed `/_app/immutable/` bundles. Note there is NO literal "@vite/client" in
+# the document: SvelteKit's entry module imports it, the HTML does not.
+DEV_MARKER="/@fs/"
+PROD_MARKER="/_app/immutable/"
+
+# ── who holds :8081, and with which ORIGIN ──────────────────────────────────
+#
+# This exists because "something serves :8081" is not the question caddy's
+# fallback actually raises. TWO different servers live on that port at
+# different times and they answer the tunnel differently:
+#
+# vite dev derives the request origin from the Host header, so it is
+# correct on localhost AND on a tunnel hostname. A fine
+# fallback, and the reason the fallback exists at all.
+# the build was launched with a FIXED ORIGIN, and the e2e harness
+# (hackathon-e2e/scripts/prod-frontend.sh) always uses
+# http://localhost:8081 — wait-ready.sh starts one on every
+# single run. SvelteKit compares every form POST's Origin
+# header against that value and answers 403 "cross-site form
+# submission forbidden" when they differ. Reached through the
+# tunnel it therefore SERVES EVERY PAGE and breaks every
+# action, login first — "Log in" does nothing at all. It also
+# read its OIDC config once at boot, so on a machine where the
+# harness has run it is holding a pre-tunnel issuer too.
+#
+# So a fallback to :8081 is right for one of them and silently wrong for the
+# other, and caddy cannot tell them apart. `ensure` below can.
+dev_port_pid() { # the adapter-node build on :$DEV_PORT, if that is what is there
+ local pids
+ pids="$(servers_on_port "$DEV_PORT")" || true
+ [ -n "$pids" ] || return 1
+ printf '%s' "${pids%%$'\n'*}"
+}
+
+# ORIGIN is baked in at launch and not exposed by the app, so read it back out
+# of the process that was launched with it.
+pid_origin() { #
+ tr '\0' '\n' <"/proc/${1:-0}/environ" 2>/dev/null |
+ sed -n 's/^ORIGIN=//p' | head -1
+}
+
+# ── start ───────────────────────────────────────────────────────────────────
+cmd_start() {
+ local origin="${1:-}" build=1
+ shift || true
+ while [ $# -gt 0 ]; do
+ case "$1" in
+ --no-build) build=0 ;;
+ *)
+ echo "unknown argument: $1" >&2
+ exit 2
+ ;;
+ esac
+ shift
+ done
+ case "$origin" in
+ http://* | https://*) ;;
+ *)
+ echo "usage: prod-serve.sh start [--no-build]" >&2
+ echo " the URL becomes ORIGIN — SvelteKit 403s every form POST whose" >&2
+ echo " Origin header does not match it." >&2
+ exit 2
+ ;;
+ esac
+ origin="${origin%/}"
+
+ local existing
+ existing="$(prod_pid)"
+ if [ -n "$existing" ]; then
+ echo "==> A built server is already on :$PROD_PORT (pid $existing) — restarting it."
+ cmd_stop
+ fi
+
+ if [ "$build" -eq 1 ]; then
+ # Through the shared serializer, NOT a bare `pnpm build`. This script and
+ # hackathon-e2e/scripts/prod-frontend.sh both build AND SERVE the same
+ # `build/service` tree (this one on :8082, that one on :8081), so they do
+ # not merely race to build it — they race to replace it while the other is
+ # serving it. Two concurrent builds into that one directory corrupted it
+ # three times in one day: `Unexpected end of JSON input`, then a missing
+ # build/service/server/index.js at boot. The helper takes an exclusive lock
+ # and swaps a complete tree into place.
+ bash "$ROOT_DIR/.claude/skills/lib/frontend-build.sh" build
+ fi
+ if [ ! -f "$FRONTEND_DIR/$SERVER_ENTRY" ]; then
+ echo "error: $SERVER_ENTRY missing — run without --no-build." >&2
+ exit 1
+ fi
+
+ # NOTHING is stopped here. process-compose keeps `vite dev` on :$DEV_PORT for
+ # localhost work and for the e2e suites; this server owns :$PROD_PORT and the
+ # two never meet. The socat bridge (.devcontainer/host-bridge.sh) is likewise
+ # left alone: it binds the eth0 IP on :$DEV_PORT only, so the EADDRINUSE that
+ # HOST=:: used to hit when both wanted :8081 cannot happen on a free port.
+
+ mkdir -p "$RUN_DIR"
+ : >"$LOG_FILE"
+ echo "$origin" >"$ORIGIN_FILE"
+
+ echo "==> Starting the built server (ORIGIN=$origin)..."
+ # Args go on the command line, unmodified: src/lib/server/args.ts hands
+ # process.argv to command-line-args BY IDENTITY, and the library only strips
+ # the node+script pair for that exact array — rebuilding argv in a wrapper
+ # makes it swallow --config-dir instead.
+ (
+ cd "$FRONTEND_DIR"
+ PORT="$PROD_PORT" HOST="::" ORIGIN="$origin" AUTH_URL="$origin" \
+ setsid node "$SERVER_ENTRY" --config-dir "$CONFIG_DIR" --data-dir "$DATA_DIR" \
+ >>"$LOG_FILE" 2>&1 &
+ echo $! >"$PID_FILE"
+ )
+
+ # --max-time bounds each attempt: wait_for only checks its deadline BETWEEN
+ # attempts, so a probe that never returns would defeat the 90s budget.
+ if ! wait_for "built frontend" 90 curl -fsS -o /dev/null --max-time 10 "http://localhost:$PROD_PORT/"; then
+ echo "error: the built server never answered on :$PROD_PORT." >&2
+ echo "── $LOG_FILE (tail) ─────────────────────────────" >&2
+ tail -40 "$LOG_FILE" >&2
+ rm -f "$PID_FILE" "$ORIGIN_FILE"
+ exit 1
+ fi
+ # setsid normally execs in place, but re-resolve anyway so `stop` never
+ # chases a PID that belonged to the launcher.
+ prod_pid >"$PID_FILE.tmp" && mv "$PID_FILE.tmp" "$PID_FILE"
+
+ echo
+ echo "Serving the PRODUCTION BUILD on :$PROD_PORT (pid $(cat "$PID_FILE"))."
+ echo " ORIGIN: $origin"
+ echo " log: $LOG_FILE"
+ echo " tunnel: caddy prefers :$PROD_PORT, falls back to vite on :$DEV_PORT"
+ echo " stop: prod-serve.sh stop (vite on :$DEV_PORT is untouched either way)"
+}
+
+# ── ensure ──────────────────────────────────────────────────────────────────
+# "Make the tunnel's upstream answer as ", doing the least that takes.
+#
+# THE BUG THIS CLOSES. `Caddyfile.tunnel` proxies `dev:8082 dev:8081` with
+# `lb_policy first`, so :8081 is a fallback — and a fallback that is only
+# sometimes correct. On any machine where the e2e harness has run (which is
+# every machine that has run `devcontainer-up/scripts/start.sh`, since
+# wait-ready.sh starts one unconditionally) :8081 holds the adapter-node build
+# with ORIGIN=http://localhost:8081. Caddy served it happily under the tunnel
+# hostname; SvelteKit then 403'd the login form POST, so the public URL rendered
+# every page and "Log in" did nothing. `start.sh --tunnel` ends by PROVING a
+# login round-trip, and that proof timed out with nothing in any log to say why.
+#
+# The alternatives, and why they are worse:
+#
+# Make the :8081 server ORIGIN-agnostic. adapter-node does support it —
+# `origin || get_origin(headers)` — but unsetting ORIGIN makes the protocol
+# default to https for a localhost request (caddy deliberately does not
+# forward X-Forwarded-Proto to the frontend, and there is no header to read),
+# so every form POST on http://localhost:8081 would fail the same CSRF check
+# from the other side. AUTH_URL would still be localhost, which is the
+# documented cause of "login completes and then does nothing". And it would
+# not help anyway: that server also read its OIDC issuer once at boot, before
+# the tunnel was wired.
+#
+# Make caddy refuse instead of falling back. Dropping `dev:8081` would break
+# the plain (non-`--prod`) tunnel, where the fallback is `vite dev` and is
+# perfectly correct — vite derives the origin from the Host header. Caddy
+# cannot tell the two servers apart, so it cannot refuse only the wrong one.
+# This function can, and it does the refusing HERE, before a public URL is
+# handed over: it either fixes the upstream or exits non-zero.
+cmd_ensure() {
+ local want="${1:-}"
+ case "$want" in
+ http://* | https://*) ;;
+ *)
+ echo "usage: prod-serve.sh ensure " >&2
+ exit 2
+ ;;
+ esac
+ want="${want%/}"
+
+ local pid current
+ pid="$(prod_pid)"
+ if [ -n "$pid" ]; then
+ current="$(cat "$ORIGIN_FILE" 2>/dev/null || true)"
+ [ -n "$current" ] || current="$(pid_origin "$pid")"
+ if [ "$current" = "$want" ] && prod_html >/dev/null 2>&1; then
+ echo "==> :$PROD_PORT already serves ORIGIN=$want — the tunnel's upstream is correct."
+ return 0
+ fi
+ echo "==> :$PROD_PORT serves ORIGIN=${current:-unknown}, not $want — restarting it."
+ start_with_current_bundle "$want"
+ return 0
+ fi
+
+ # Nothing on :8082. Whether that is fine depends entirely on WHO is on :8081.
+ local dev_pid dev_origin dev_body
+ if dev_pid="$(dev_port_pid)"; then
+ dev_origin="$(pid_origin "$dev_pid")"
+ echo "==> :$DEV_PORT holds the adapter-node BUILD (pid $dev_pid, ORIGIN=${dev_origin:-unset})."
+ echo " caddy would fall back to it, and SvelteKit answers 403 to every form"
+ echo " POST whose Origin is not its ORIGIN — through $want that means login"
+ echo " silently does nothing. Starting a correct-origin server on :$PROD_PORT."
+ start_with_current_bundle "$want"
+ return 0
+ fi
+
+ dev_body="$(dev_html || true)"
+ if [ -n "$dev_body" ] && printf '%s' "$dev_body" | grep -q -- "$DEV_MARKER"; then
+ echo "==> :$DEV_PORT is \`vite dev\`, which takes its origin from the request Host"
+ echo " and is therefore correct on $want as it stands. Nothing to start."
+ return 0
+ fi
+ if [ -n "$dev_body" ]; then
+ # Serving, but not vite and not a process we can read an ORIGIN off (another
+ # namespace, or started by hand). Assume the worst: a fixed origin we cannot
+ # verify is exactly the silent failure this function exists to prevent.
+ echo "==> :$DEV_PORT is serving something whose ORIGIN cannot be read — treating"
+ echo " it as a fixed origin and taking the tunnel to :$PROD_PORT instead."
+ start_with_current_bundle "$want"
+ return 0
+ fi
+
+ echo "error: nothing is serving on :$PROD_PORT or :$DEV_PORT — the tunnel has no" >&2
+ echo " upstream and would answer 502. Start the stack first (just up, or" >&2
+ echo " hackathon-e2e/scripts/up.sh)." >&2
+ return 1
+}
+
+# The bundle is a snapshot of src/, so it has to be rebuilt when src/ moved
+# under it — but rebuilding a current one costs ~40s of a tunnel handover for
+# nothing.
+#
+# The test lives in the shared builder now, so this script and
+# hackathon-e2e/scripts/prod-frontend.sh cannot drift apart on what "stale"
+# means — they build and serve the SAME build/service tree, and two callers
+# disagreeing about whether it needs rebuilding is one of them rebuilding it
+# under the other.
+bundle_is_stale() {
+ bash "$ROOT_DIR/.claude/skills/lib/frontend-build.sh" stale
+}
+
+start_with_current_bundle() { #
+ if bundle_is_stale; then
+ cmd_start "$1"
+ else
+ cmd_start "$1" --no-build
+ fi
+}
+
+# ── stop ────────────────────────────────────────────────────────────────────
+# Only stops the built server. It never owned :$DEV_PORT, so there is nothing to
+# hand back — caddy notices :$PROD_PORT refusing connections and falls through
+# to vite on its own.
+cmd_stop() {
+ local pid
+ pid="$(prod_pid)"
+ if [ -n "$pid" ]; then
+ echo "==> Stopping the built server (pid $pid)..."
+ kill "$pid" 2>/dev/null || true
+ for _ in $(seq 1 20); do
+ is_prod_server "$pid" || break
+ sleep 0.5
+ done
+ is_prod_server "$pid" && kill -9 "$pid" 2>/dev/null || true
+ else
+ echo "==> No built server running."
+ fi
+ rm -f "$PID_FILE" "$ORIGIN_FILE"
+}
+
+# ── status ──────────────────────────────────────────────────────────────────
+# Both ports, because with a fallback upstream "is prod up?" and "what does the
+# public link serve?" are no longer the same question.
+cmd_status() {
+ local pid prod dev
+ pid="$(prod_pid)"
+ prod="$(prod_html || true)"
+ dev="$(dev_html || true)"
+
+ if [ -n "$pid" ] && [ -n "$prod" ]; then
+ echo ":$PROD_PORT PRODUCTION BUILD (adapter-node, pid $pid)"
+ echo " origin: $(cat "$ORIGIN_FILE" 2>/dev/null || echo '(unknown)')"
+ echo " log: $LOG_FILE"
+ elif [ -n "$pid" ]; then
+ echo ":$PROD_PORT built server is RUNNING (pid $pid) but not answering"
+ else
+ echo ":$PROD_PORT not running"
+ fi
+
+ if [ -n "$dev" ] && printf '%s' "$dev" | grep -q -- "$DEV_MARKER"; then
+ echo ":$DEV_PORT DEV SERVER (vite, via process-compose) — origin from the Host header"
+ elif [ -n "$dev" ]; then
+ # Name the ORIGIN, because that is the difference that decides whether
+ # caddy's fallback is harmless or silently breaks every form POST.
+ local dev_pid dev_origin
+ if dev_pid="$(dev_port_pid)"; then
+ dev_origin="$(pid_origin "$dev_pid")"
+ echo ":$DEV_PORT adapter-node BUILD (pid $dev_pid) — FIXED origin ${dev_origin:-unset}"
+ echo " usable as the tunnel's fallback ONLY for that exact origin"
+ else
+ echo ":$DEV_PORT something is serving, but no '$DEV_MARKER' in the markup"
+ fi
+ else
+ echo ":$DEV_PORT not serving"
+ fi
+
+ if [ -n "$prod" ]; then
+ echo "tunnel serves the PRODUCTION BUILD (caddy prefers :$PROD_PORT)"
+ { printf '%s' "$prod" | grep -o "$PROD_MARKER[^\"]*" | head -1 |
+ sed 's/^/ asset: /'; } || true
+ elif [ -n "$dev" ]; then
+ echo "tunnel falls back to the DEV SERVER on :$DEV_PORT"
+ { printf '%s' "$dev" | grep -o "$DEV_MARKER[^\"]*" | head -1 |
+ sed 's/^/ asset: /'; } || true
+ else
+ echo "tunnel has NO upstream — it will answer 502"
+ return 1
+ fi
+}
+
+# ── origin ──────────────────────────────────────────────────────────────────
+# Machine-readable "is prod mode live, and with which ORIGIN?" — one place that
+# knows, so callers do not re-implement the pid/cmdline check.
+cmd_origin() {
+ local pid origin
+ pid="$(prod_pid)"
+ [ -n "$pid" ] || return 1
+ origin="$(cat "$ORIGIN_FILE" 2>/dev/null || true)"
+ [ -n "$origin" ] || return 1
+ echo "$origin"
+}
+
+case "${1:-}" in
+start)
+ shift
+ cmd_start "$@"
+ ;;
+ensure)
+ shift
+ cmd_ensure "$@"
+ ;;
+stop)
+ shift
+ cmd_stop "$@"
+ ;;
+status)
+ cmd_status
+ ;;
+origin)
+ cmd_origin
+ ;;
+-h | --help | "")
+ sed -n '2,13p' "${BASH_SOURCE[0]}" | sed 's/^# \{0,1\}//'
+ exit 0
+ ;;
+*)
+ echo "unknown command: $1 (see --help)" >&2
+ exit 2
+ ;;
+esac
diff --git a/.claude/skills/cloudflare-tunnel/scripts/serve-public.sh b/.claude/skills/cloudflare-tunnel/scripts/serve-public.sh
new file mode 100644
index 00000000..b947caa7
--- /dev/null
+++ b/.claude/skills/cloudflare-tunnel/scripts/serve-public.sh
@@ -0,0 +1,168 @@
+#!/usr/bin/env bash
+# ONE command for a public URL serving the whole application, with working
+# logins. Idempotent, and it verifies rather than announces.
+#
+# Why this exists as its own script: getting here reliably means five things
+# being true at once, and every one of them has broken on its own at least
+# once during development —
+#
+# 1. postgres, keycloak and the backend running (a suite run leaves the
+# backend down often enough that "it worked yesterday" is not evidence);
+# 2. a BUILT frontend on :8081 (vite is unusable after a codegen wipe — see
+# container trap 2b in .claude/CLAUDE.md);
+# 3. the tunnel container up with a quick-tunnel hostname;
+# 4. that hostname wired into BOTH OIDC issuers, or every login fails with
+# "invalid issuer" while every page still serves — the failure that is
+# invisible until somebody actually signs in;
+# 5. a server whose ORIGIN matches the hostname it is reached on, or
+# SvelteKit rejects the login POST and the button silently does nothing.
+#
+# Each step is checked, repaired if it can be, and reported. The script ends by
+# driving a REAL login round-trip: serving HTML proves nothing about OIDC.
+#
+# Usage: serve-public.sh [--seed] [--with-plausible]
+# --seed also load the SDSC archive (six real past editions)
+# --with-plausible bring the Plausible rig up on its OWN tunnel and wire the
+# app at it. Off by default: it costs ~750 MB idle, and an
+# analytics dashboard full of Playwright traffic is worse
+# than an empty one — unwire before any suite run.
+set -euo pipefail
+HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
+SKILLS="$(cd "$HERE/../.." && pwd)"
+ROOT_DIR="$(cd "$SKILLS/.." && pwd)"
+E2E="$SKILLS/hackathon-e2e"
+
+SEED=0
+PLAUSIBLE=0
+# Set only when the rig comes up; the Ready block tests it with ${VAR:+…}.
+PLAUSIBLE_URL=""
+for arg in "$@"; do
+ case "$arg" in
+ --seed) SEED=1 ;;
+ --with-plausible) PLAUSIBLE=1 ;;
+ *)
+ echo "unknown argument: $arg (see the usage comment)" >&2
+ exit 2
+ ;;
+ esac
+done
+
+step() {
+ echo
+ echo "── $* ─────────────────────────────────────────"
+}
+ok() { echo " ok $*"; }
+warn() { echo " warn $*" >&2; }
+
+in_dev() { bash "$SKILLS/devcontainer-up/scripts/exec.sh" "$@"; }
+nix() { in_dev just nix::develop default bash -c "$1"; }
+
+# ── 1. the stack ─────────────────────────────────────────────────────────────
+step "Stack"
+nix 'bash .claude/skills/hackathon-e2e/scripts/up.sh' >/dev/null 2>&1 || true
+
+for svc in postgres keycloak; do
+ case "$svc" in
+ postgres) probe='pg_isready -h 127.0.0.1 -p 5432 -U postgres' ;;
+ keycloak) probe='curl -fsS -o /dev/null --max-time 10 http://localhost:8180/realms/hackagon/.well-known/openid-configuration' ;;
+ esac
+ if nix "$probe" >/dev/null 2>&1; then ok "$svc"; else
+ warn "$svc not ready — restarting"
+ nix "just deploy::proc-comp process restart $svc" >/dev/null 2>&1 || true
+ fi
+done
+
+# The backend is the one that is routinely down: `just deploy::down` and the
+# suites both stop it, and nothing brings it back on its own.
+if nix 'grpcurl -plaintext localhost:3000 health.HealthService/Check' >/dev/null 2>&1; then
+ ok "backend"
+else
+ warn "backend not answering — restarting (it rebuilds, ~1 min)"
+ nix 'just deploy::proc-comp process restart backend' >/dev/null 2>&1 || true
+ for _ in $(seq 1 40); do
+ nix 'grpcurl -plaintext localhost:3000 health.HealthService/Check' >/dev/null 2>&1 && break
+ sleep 3
+ done
+ nix 'grpcurl -plaintext localhost:3000 health.HealthService/Check' >/dev/null 2>&1 &&
+ ok "backend" || {
+ echo "error: backend will not start — see 'just deploy::proc-comp process logs backend'" >&2
+ exit 1
+ }
+fi
+
+# ── 2. the built frontend ────────────────────────────────────────────────────
+# prod-frontend.sh already encodes the three traps in starting this by hand
+# (HOST=:: collides with the socat bridge; 127.0.0.1 is not what localhost
+# resolves to in this container; AUTH_URL must accompany ORIGIN).
+step "Frontend"
+nix 'bash .claude/skills/hackathon-e2e/scripts/prod-frontend.sh ensure' 2>&1 | sed 's/^/ /' || {
+ echo "error: no frontend on :8081" >&2
+ exit 1
+}
+
+# ── 3+4. tunnel, wired ───────────────────────────────────────────────────────
+step "Tunnel"
+bash "$HERE/up.sh" --with-auth --prod 2>&1 | tail -5 | sed 's/^/ /'
+URL="$(bash "$HERE/url.sh" 2>/dev/null | awk '{print $NF}' | grep -E '^https://' | tail -1)"
+[ -n "$URL" ] || {
+ echo "error: no public URL" >&2
+ exit 1
+}
+
+if [ "$SEED" -eq 1 ]; then
+ step "Archive"
+ nix "E2E_KEYCLOAK_URL=$URL bash .claude/skills/seed-past-hackathons/scripts/seed.sh" 2>&1 |
+ grep -cE '\[\+\] hackathon|\[=\]' | sed 's/^/ editions present: /'
+ nix "E2E_KEYCLOAK_URL=$URL bash .claude/skills/seed-past-hackathons/scripts/prizes.sh" >/dev/null 2>&1 || true
+fi
+
+# ── 4b. analytics, opt-in ────────────────────────────────────────────────────
+# Its own tunnel, not this one: the tracking script and the dashboard need a
+# public origin of their own, and sharing this hostname would put the app and a
+# third-party dashboard behind one link.
+if [ "$PLAUSIBLE" -eq 1 ]; then
+ step "Plausible"
+ if bash "$SKILLS/plausible-stack/scripts/up.sh" >/dev/null 2>&1; then
+ nix 'bash .claude/skills/plausible-stack/scripts/wire-frontend.sh' >/dev/null 2>&1 &&
+ ok "wired — the frontend loads the tracker" ||
+ warn "rig is up but wiring failed; run plausible-stack/scripts/wire-frontend.sh"
+ PLAUSIBLE_URL="$(bash "$SKILLS/plausible-stack/scripts/url.sh" 2>/dev/null | grep -oE 'https://[a-z0-9-]+\.trycloudflare\.com' | tail -1 || true)"
+ else
+ warn "could not start Plausible — continuing without it"
+ fi
+fi
+
+# ── 5. prove a login ─────────────────────────────────────────────────────────
+# The whole point. Every step above can be green while signing in is broken,
+# and that combination has happened repeatedly: the issuer, the ORIGIN and a
+# stale server each produce it.
+step "Proving a real login through $URL"
+if in_dev env TUNNEL_BASE_URL="$URL" PLAYWRIGHT_SKIP_VALIDATE_HOST_REQUIREMENTS=true \
+ just nix::develop default bash -c \
+ 'cd .claude/skills/hackathon-e2e && pnpm exec playwright test --project=tunnel --grep "logs in"' \
+ >/dev/null 2>&1; then
+ ok "alice signed in and reached her dashboard"
+else
+ echo "error: pages serve but LOGIN FAILED — the one failure that hides." >&2
+ echo " Check: is the issuer wired? (config.local.yaml should hold an oidc block)" >&2
+ echo " Check: is a server with ORIGIN=$URL on :8082? (prod-serve.sh status)" >&2
+ exit 1
+fi
+
+cat < tunnel the stack (view-only: login stays local)
+# up.sh --with-auth -> same, plus rewire OIDC so login works through the tunnel
+# up.sh --prod -> ALSO run the production BUILD on :8082 and let the
+# tunnel prefer it (54 requests/page instead of 150;
+# combine with --with-auth). `vite dev` keeps :8081.
+# Undo with down.sh or prod-serve.sh stop.
+# up.sh --named -> force a NAMED tunnel on your own hostname
+# up.sh --quick -> force an ephemeral *.trycloudflare.com quick tunnel
+# up.sh --port -> tunnel any local port via host.docker.internal
+#
+# TWO MODES, and the default picks between them:
+#
+# NAMED a persistent hostname on a zone you own (HACKAGON_HOSTNAME in the
+# gitignored .env — see SKILL.md, "Named tunnels"). Chosen
+# automatically when those credentials are present. The hostname
+# survives restarts, so the issuer wiring below stays correct instead
+# of having to be redone every time.
+# QUICK cloudflared's free ephemeral *.trycloudflare.com URL. No account, no
+# DNS, nothing to configure — and a new hostname on every start. Used
+# whenever named mode is not configured, which keeps this the
+# zero-setup path it has always been.
+#
+# The two are mutually exclusive per run: bringing one up stops the other, because
+# the OIDC issuer can only name ONE hostname and the other would keep serving
+# every page while silently failing every login.
+#
+# Every hackagon-stack run also ENSURES the tunnel's upstream can serve the
+# public hostname: caddy prefers :8082 and falls back to vite on :8081, but the
+# e2e harness parks an adapter-node build there with ORIGIN=http://localhost:8081
+# — which serves pages through the tunnel and 403s every form POST, so login
+# silently does nothing. prod-serve.sh ensure starts a correct-origin :8082 in
+# that case and refuses rather than hand over a broken link.
+set -euo pipefail
+HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
+ROOT_DIR="$(cd "$HERE/../../../.." && pwd)"
+COMPOSE_FILE="$ROOT_DIR/.devcontainer/docker-compose.yml"
+# shellcheck source=../../lib/cf-named-tunnel.sh
+source "$ROOT_DIR/.claude/skills/lib/cf-named-tunnel.sh"
+CFN_NAME="${HACKAGON_TUNNEL_NAME:-hackagon}"
+
+case "$(uname -s)" in
+MINGW* | MSYS*)
+ export MSYS_NO_PATHCONV=1
+ export MSYS2_ARG_CONV_EXCL="*"
+ COMPOSE_FILE="$(cygpath -m "$COMPOSE_FILE")"
+ ;;
+esac
+
+PORT=""
+WITH_AUTH=""
+PROD=""
+MODE=""
+while [ $# -gt 0 ]; do
+ case "$1" in
+ --port)
+ shift
+ PORT="${1:?--port needs a port number}"
+ ;;
+ --with-auth) WITH_AUTH=1 ;;
+ --prod) PROD=1 ;;
+ --named) MODE=named ;;
+ --quick) MODE=quick ;;
+ -h | --help)
+ sed -n '2,32p' "${BASH_SOURCE[0]}" | sed 's/^# \{0,1\}//'
+ exit 0
+ ;;
+ *)
+ echo "unknown argument: $1 (see --help)" >&2
+ exit 2
+ ;;
+ esac
+ shift
+done
+if [ -n "$PORT" ] && [ -n "$PROD" ]; then
+ echo "error: --prod only applies to the hackagon stack, not --port mode." >&2
+ exit 2
+fi
+if [ -n "$PORT" ] && [ -n "$MODE" ]; then
+ echo "error: --named/--quick only apply to the hackagon stack, not --port mode." >&2
+ exit 2
+fi
+
+# Which mode, and SAY SO. Auto-selection reads the gitignored .env; --named and
+# --quick override it. An explicit --named with nothing configured is an error
+# rather than a silent downgrade to an ephemeral hostname: somebody who asked
+# for a stable URL and got a throwaway one finds out at the worst moment.
+resolve_mode() {
+ if [ "$MODE" = "named" ]; then
+ cf_configured && [ -n "${HACKAGON_HOSTNAME:-}" ] || {
+ echo "error: --named needs Cloudflare credentials and HACKAGON_HOSTNAME." >&2
+ cf_explain_unconfigured >&2
+ exit 2
+ }
+ return
+ fi
+ [ -n "$MODE" ] && return
+ if cf_configured && [ -n "${HACKAGON_HOSTNAME:-}" ]; then MODE=named; else MODE=quick; fi
+}
+
+# Make the RUNNING caddy match Caddyfile.tunnel, and prove one route did.
+#
+# caddy loads its config once, at container start. `docker compose up -d caddy`
+# does not re-read the file for an already-running container, and recreating it
+# is not an option on this compose project — `up -d` on anything that shares
+# `dev`'s config can recreate `dev`, which kills the whole stack inside it
+# (container trap 2). So a Caddyfile edit sits on disk, doing nothing, for as
+# long as the container happens to live: days, across many tunnels.
+#
+# That is not a hypothetical. The `/objects` route's `header_up Host` rewrite —
+# REQUIRED, because SigV4 signs the Host and the store recomputes the signature
+# over whatever arrives — was committed and correct while the running config had
+# no `headers` block at all. Every presigned UPLOAD through the public URL
+# answered 403 SignatureDoesNotMatch, and nothing else did: public reads are
+# unsigned, so every page and every image kept working. The report was "Storage
+# rejected the upload (403)" from someone using the app normally.
+#
+# Reload, then ASK CADDY what it is serving. Checking the file proves nothing
+# here — the file was already right. Verifying the reload took is the only part
+# of this that could have caught the bug.
+ensure_caddy_config() {
+ # MSYS_NO_PATHCONV: on a Git Bash host, /etc/caddy/Caddyfile is rewritten to
+ # C:/Program Files/Git/etc/caddy/Caddyfile before docker ever sees it, and the
+ # reload fails with a path nobody typed. Ignored everywhere else.
+ MSYS_NO_PATHCONV=1 docker compose -f "$COMPOSE_FILE" exec -T caddy \
+ caddy reload --config /etc/caddy/Caddyfile >/dev/null 2>&1 || {
+ echo "warn: could not reload caddy's config; it is serving whatever it booted with" >&2
+ return 0
+ }
+ # The Host rewrite on the /objects route, read back out of the live config.
+ if docker compose -f "$COMPOSE_FILE" exec -T caddy \
+ sh -c 'wget -qO- http://localhost:2019/config/ 2>/dev/null || curl -sS http://localhost:2019/config/' 2>/dev/null |
+ tr -d ' \n' | grep -q '"strip_path_prefix":"/objects"'; then
+ if ! docker compose -f "$COMPOSE_FILE" exec -T caddy \
+ sh -c 'wget -qO- http://localhost:2019/config/ 2>/dev/null || curl -sS http://localhost:2019/config/' 2>/dev/null |
+ tr -d ' \n' | grep -q 'upstream.hostport'; then
+ echo "warn: caddy's /objects route has no Host rewrite — presigned UPLOADS" >&2
+ echo " through the public URL will 403 while reads keep working." >&2
+ fi
+ fi
+}
+
+wait_for_url() { # container-name
+ local name="$1" url=""
+ for _ in $(seq 1 30); do
+ url=$(docker logs "$name" 2>&1 | grep -oE "https://[a-z0-9-]+\.trycloudflare\.com" | tail -1 || true)
+ if [ -n "$url" ]; then
+ echo "$url"
+ return 0
+ fi
+ sleep 2
+ done
+ echo "error: no trycloudflare URL appeared in '$name' logs after 60s" >&2
+ return 1
+}
+
+if [ -z "$PORT" ]; then
+ # The app must already be serving: caddy proxies to it and cloudflared
+ # resolves its target once at startup. This check lives here rather than as
+ # a compose `depends_on: service_healthy` because the stack inside `dev` is
+ # started by hand (`just up`), not by compose — see the comment on caddy in
+ # docker-compose.yml.
+ echo "==> Checking the app is up inside the dev container..."
+ if ! docker compose -f "$COMPOSE_FILE" exec -T -u vscode dev bash -c \
+ 'curl -fsS -o /dev/null --max-time 5 "http://[::1]:8081/" ||
+ curl -fsS -o /dev/null --max-time 5 "http://127.0.0.1:8081/"'; then
+ echo "error: nothing is serving on :8081 inside the dev container." >&2
+ echo "Start the stack first: just up (or scripts/up.sh in hackathon-e2e)" >&2
+ exit 1
+ fi
+
+ # Vite binds loopback inside the dev container: republish it on the
+ # container interface first so caddy (the tunnel's target, which
+ # path-splits the hostname between frontend and Keycloak) can reach it.
+ docker compose -f "$COMPOSE_FILE" exec -T -u vscode -e USER=vscode dev \
+ bash -lc 'cd /workspaces/hackagon && bash .devcontainer/host-bridge.sh'
+
+ resolve_mode
+ if [ "$MODE" = "named" ]; then
+ echo "==> Mode: NAMED — https://$HACKAGON_HOSTNAME (persistent)"
+ # caddy only. `up -d tunnel` would start the QUICK tunnel through
+ # depends_on, which is the other mode; naming the service explicitly is
+ # what keeps the two from both running.
+ docker compose -f "$COMPOSE_FILE" --profile tunnel up -d caddy
+ ensure_caddy_config
+ # Stop the quick tunnel if a previous run left one up. Two tunnels onto the
+ # same caddy is not a redundancy — the OIDC issuer names ONE hostname, so
+ # the other would serve every page and fail every login, which is the
+ # failure mode that only surfaces when somebody tries to sign in.
+ if [ -n "$(docker compose -f "$COMPOSE_FILE" --profile tunnel ps -q tunnel 2>/dev/null)" ]; then
+ echo " stopping the quick tunnel (named mode owns the issuer)"
+ docker compose -f "$COMPOSE_FILE" --profile tunnel rm -sf tunnel >/dev/null 2>&1 || true
+ fi
+ # caddy's network, read off the container rather than assumed: the compose
+ # network name is overridable (HACKAGON_DEV_NETWORK) and a wrong guess
+ # fails as a DNS lookup for `caddy` inside cloudflared, which Cloudflare
+ # renders as a plain 502 while every container reports healthy.
+ caddy_net=$(docker inspect "$(docker compose -f "$COMPOSE_FILE" --profile tunnel ps -q caddy)" \
+ --format '{{range $k,$v := .NetworkSettings.Networks}}{{$k}} {{end}}' | awk '{print $1}')
+ cfn_up "$CFN_NAME" "$HACKAGON_HOSTNAME" "$caddy_net" "http://caddy:80"
+ url="https://$HACKAGON_HOSTNAME"
+ else
+ echo "==> Mode: QUICK — an ephemeral *.trycloudflare.com hostname"
+ if cfn_running "$CFN_NAME"; then
+ echo " stopping the named tunnel (one issuer, one hostname)"
+ cfn_stop "$CFN_NAME"
+ fi
+ docker compose -f "$COMPOSE_FILE" --profile tunnel up -d tunnel
+ # After caddy exists (compose starts it via depends_on), before anyone is
+ # handed the link: a running container keeps its boot-time config forever.
+ ensure_caddy_config
+ name=$(docker compose -f "$COMPOSE_FILE" --profile tunnel ps -q tunnel)
+ url=$(wait_for_url "$name")
+ fi
+
+ if [ -n "$WITH_AUTH" ]; then
+ # Rewire issuers + realm allowlist so OIDC login works via the tunnel.
+ docker compose -f "$COMPOSE_FILE" exec -T -u vscode -e USER=vscode dev \
+ bash -lc "cd /workspaces/hackagon && bash .claude/skills/cloudflare-tunnel/scripts/auth-wire.sh '$url'"
+ fi
+
+ # LAST, and only now: the built server reads config.yaml once into a module
+ # singleton at boot, so the issuer overlay above has to be on disk before it
+ # starts. (Nothing is duplicated by ordering it this way — auth-wire.sh
+ # restarts the built server only when one is ALREADY running, which on this
+ # path it is not.)
+ #
+ # ORIGIN is the tunnel URL, not localhost: SvelteKit rejects any form POST
+ # whose Origin header does not match ORIGIN, so a localhost value would 403
+ # every action a visitor takes through the public link — login first.
+ #
+ # `ensure` runs on EVERY hackagon-stack tunnel, not just `--prod`, because
+ # caddy's fallback to :8081 is only correct when `vite dev` is what is there.
+ # Whenever the adapter-node build holds that port — which is what the e2e
+ # harness leaves behind, and what `hackathon-e2e/scripts/wait-ready.sh` sets
+ # up on every single run — its ORIGIN is http://localhost:8081 and the public
+ # URL serves every page while every form POST 403s. That is silent: the link
+ # looks perfect until somebody tries to sign in, which is exactly what
+ # `devcontainer-up/scripts/start.sh --tunnel` then failed to prove, with
+ # nothing in any log naming the cause. `ensure` starts a correct-origin server
+ # on :8082 only in that case, leaves a vite fallback alone, and exits non-zero
+ # rather than handing over a URL it knows is broken. `--prod` still forces the
+ # built server (and a build) for the request-count win.
+ if [ -n "$PROD" ]; then
+ UPSTREAM_CMD="bash .claude/skills/cloudflare-tunnel/scripts/prod-serve.sh start '$url'"
+ else
+ UPSTREAM_CMD="bash .claude/skills/cloudflare-tunnel/scripts/prod-serve.sh ensure '$url'"
+ fi
+ if ! docker compose -f "$COMPOSE_FILE" exec -T -u vscode -e USER=vscode dev \
+ bash -lc "cd /workspaces/hackagon && $UPSTREAM_CMD &&
+ echo && echo '==> Tunnel upstream:' &&
+ bash .claude/skills/cloudflare-tunnel/scripts/prod-serve.sh status"; then
+ echo "error: the tunnel is up but its upstream cannot serve $url correctly." >&2
+ echo " Fix that before using the link — see the lines above." >&2
+ exit 1
+ fi
+
+ echo
+ if [ -n "$WITH_AUTH" ]; then
+ echo "Public URL (login-capable): $url"
+ else
+ echo "Public URL (frontend, view-only): $url"
+ fi
+ if [ "$MODE" = "named" ]; then
+ echo "Mode: NAMED — this hostname persists across restarts,"
+ echo " so the OIDC wiring stays valid and does not have"
+ echo " to be redone on the next up.sh."
+ else
+ echo "Mode: QUICK — this hostname dies with the tunnel."
+ fi
+ if [ -n "$PROD" ]; then
+ echo "Back to the dev server: scripts/prod-serve.sh stop (down.sh does it too)"
+ else
+ echo "Which server answers it: printed above by prod-serve.sh status"
+ echo "Fewer requests per page: re-run with --prod (bundled build, 54 vs 150)"
+ fi
+else
+ name="cf-quicktunnel-$PORT"
+ docker rm -f "$name" >/dev/null 2>&1 || true
+ docker run -d --name "$name" --restart unless-stopped \
+ cloudflare/cloudflared:latest \
+ tunnel --no-autoupdate --url "http://host.docker.internal:$PORT" >/dev/null
+ echo "Public URL (port $PORT): $(wait_for_url "$name")"
+fi
diff --git a/.claude/skills/cloudflare-tunnel/scripts/url.sh b/.claude/skills/cloudflare-tunnel/scripts/url.sh
new file mode 100644
index 00000000..100fe347
--- /dev/null
+++ b/.claude/skills/cloudflare-tunnel/scripts/url.sh
@@ -0,0 +1,34 @@
+#!/usr/bin/env bash
+# Print the public URL(s) of running tunnels — named and quick.
+#
+# Read off the RUNNING containers, never off a state file: a quick tunnel that
+# restarted has a new URL and the same state file, and a named tunnel that is
+# not running has a hostname that resolves to a 1033 error page. Both failures
+# are silent from a cached value.
+set -euo pipefail
+found=0
+
+# Named tunnels carry their hostname as a container label (see
+# lib/cf-named-tunnel.sh) — the label is written at `docker run` time from the
+# same value the DNS record was pointed at.
+for name in $(docker ps --format '{{.Names}}' | grep -E '^cf-named-' || true); do
+ host=$(docker inspect "$name" \
+ --format '{{index .Config.Labels "hackagon.tunnel.hostname"}}' 2>/dev/null || true)
+ if [ -n "$host" ]; then
+ echo "$name: https://$host"
+ found=1
+ fi
+done
+
+for name in $(docker ps --format '{{.Names}}' | grep -E '^cf-quicktunnel-|tunnel' || true); do
+ url=$(docker logs "$name" 2>&1 | grep -oE "https://[a-z0-9-]+\.trycloudflare\.com" | tail -1 || true)
+ if [ -n "$url" ]; then
+ echo "$name: $url"
+ found=1
+ fi
+done
+
+[ "$found" -eq 1 ] || {
+ echo "no running tunnels found" >&2
+ exit 1
+}
diff --git a/.claude/skills/dbml-diagrams/SKILL.md b/.claude/skills/dbml-diagrams/SKILL.md
new file mode 100644
index 00000000..622546c5
--- /dev/null
+++ b/.claude/skills/dbml-diagrams/SKILL.md
@@ -0,0 +1,67 @@
+---
+name: dbml-diagrams
+description:
+ Build and validate DBML database diagrams (dbdiagram.io) from the ent schema.
+ Use when asked for a database/ER diagram, to update docs/backend/schema.dbml
+ after a schema change, or when dbdiagram.io reports parse errors like "An Enum
+ must have only a field and optionally a setting list". ALWAYS validate with
+ the official parser before sharing — never ship unvalidated DBML.
+---
+
+# DBML diagrams for the Hackagon schema
+
+The canonical diagram file is `docs/backend/schema.dbml`. Source of truth is the
+ent schema (`components/backend/db/schema/*.go`), rendered human-readably in
+`components/backend/Schema.md` — regenerate that first after schema edits
+(`just codegen::db-schema`), then update the DBML from it, then **validate**.
+
+The DBML was brought level with the ent schema on 2026-08-08 — all 23 physical
+tables are present and every name in it is now the one ent generates. Re-check
+it against `ent/migrate/schema.go` (see step 2) after any schema edit.
+
+## The workflow
+
+1. `just codegen::db-schema` — refresh `Schema.md` from the ent sources.
+2. Edit `docs/backend/schema.dbml` to match (mapping rules below). **Read the
+ physical names off `components/backend/ent/migrate/schema.go`, not off
+ `Schema.md`** — that file lists edges by their Go name (`hackathon`,
+ `modifier`) and only names the columns inside its index lists, so guessing a
+ FK column from an edge name is how the diagram drifted last time. Its
+ `
Columns` blocks give every column, its nullability and its FK target;
+ `PrimaryKey:` shows which join tables have a composite key and no `id` at
+ all.
+3. **Validate**: `bash .claude/skills/dbml-diagrams/scripts/validate.sh` (wraps
+ the official `@dbml/cli` parser — the same one dbdiagram.io uses).
+4. Only then share / commit / paste into https://dbdiagram.io/d.
+
+## Mapping ent → DBML
+
+| ent concept | DBML |
+| --------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
+| Entity `FooBar` | `Table foo_bars` (snake_case plural; already-plural names unchanged) |
+| M2O edge with inverse `bars` on parent `Foo` | FK column named `foo_bars` → `[ref: > foos.id]` (so `pages.hackathon_pages`, `votes.user_votes`) |
+| O2O edge to hackathon | Same convention, NOT `hackathon_id`: `hackathon_settings`, `hackathon_windows`, `hackathon_forms`, and — because the edge is called `prize_table` — `hackathon_prize_table`, each `[unique, ref: - hackathons.id]` |
+| Explicit join entity (Participant, TeamParticipant) | Its own Table with real `*_id` field columns and a COMPOSITE `[pk]` index — ent gives these no `id` column |
+| Implicit M2M edge | A join table `_` with composite `[pk]` index (`user_preferred_projects`, `user_jury_categories`) |
+| `Optional().Nillable()` field | Column without `not null` |
+| enum field | A DBML `Enum` block + column typed with it |
+| Composite unique index | `indexes { (col_a, col_b) [unique] }` |
+
+## Syntax gotchas (each one has bitten us)
+
+- **Enums: ONE value per line.** `Enum v { public private }` fails with "An Enum
+ must have only a field and optionally a setting list" — every value on its own
+ line inside the block.
+- Reserved/odd column names need double quotes: `"order" int`.
+- Inline refs: `>` many-to-one, `<` one-to-many, `-` one-to-one.
+- Notes use single quotes; avoid apostrophes inside them (or escape by
+ rephrasing) and never nest single quotes.
+- Composite PK only via `indexes { (a, b) [pk] }`, not on columns.
+- Table `Note:` goes inside the table body on its own line.
+
+## Validating
+
+`scripts/validate.sh [file]` (default `docs/backend/schema.dbml`) runs
+`dbml2sql` — if it emits SQL, the file parses; any error is exactly what
+dbdiagram.io would show. It uses pnpm/npx where available and otherwise routes
+through the devcontainer's Nix shell automatically.
diff --git a/.claude/skills/dbml-diagrams/scripts/validate.sh b/.claude/skills/dbml-diagrams/scripts/validate.sh
new file mode 100644
index 00000000..78852d3f
--- /dev/null
+++ b/.claude/skills/dbml-diagrams/scripts/validate.sh
@@ -0,0 +1,28 @@
+#!/usr/bin/env bash
+# Validate a DBML file with the official parser (@dbml/cli, the engine behind
+# dbdiagram.io). Emits "OK" on success; on failure prints the same errors
+# dbdiagram.io would show (line:column).
+#
+# Usage: validate.sh [file.dbml] (default: docs/backend/schema.dbml)
+set -euo pipefail
+HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
+ROOT_DIR="$(cd "$HERE/../../../.." && pwd)"
+FILE="${1:-docs/backend/schema.dbml}"
+
+cd "$ROOT_DIR"
+if [ ! -f "$FILE" ]; then
+ echo "error: no such file: $FILE" >&2
+ exit 2
+fi
+
+if command -v pnpm >/dev/null 2>&1; then
+ pnpm --package=@dbml/cli dlx dbml2sql "$FILE" >/dev/null
+elif command -v npx >/dev/null 2>&1; then
+ npx -y -p @dbml/cli dbml2sql "$FILE" >/dev/null
+else
+ # No node toolchain on this shell — run inside the devcontainer's Nix shell.
+ bash "$HERE/../../devcontainer-up/scripts/exec.sh" \
+ just develop pnpm --package=@dbml/cli dlx dbml2sql "$FILE" >/dev/null
+fi
+
+echo "OK: $FILE parses — safe to paste into dbdiagram.io"
diff --git a/.claude/skills/devcontainer-up/SKILL.md b/.claude/skills/devcontainer-up/SKILL.md
new file mode 100644
index 00000000..e89bfb5a
--- /dev/null
+++ b/.claude/skills/devcontainer-up/SKILL.md
@@ -0,0 +1,181 @@
+---
+name: devcontainer-up
+description:
+ Spin up the Hackagon devcontainer (docker compose) and get it fully ready —
+ Nix installed, toolchain warmed, post-create bootstrap done — then run
+ commands, the e2e test suites, or the mutation manifest inside it. Use when
+ asked to start/stop the devcontainer or dev environment, run something inside
+ the container, or run the hackathon e2e tests (which run in this container by
+ default).
+---
+
+# Devcontainer up & ready
+
+Wraps `.devcontainer/docker-compose.yml` (service `dev`, user `vscode`,
+workspace `/workspaces/hackagon`) so one command produces a container where
+`just` / Nix / the whole toolchain work — from any host shell, including Git
+Bash on Windows (MSYS path mangling is handled).
+
+## Commands
+
+```bash
+bash .claude/skills/devcontainer-up/scripts/start.sh # nothing → running stack (see below)
+bash .claude/skills/devcontainer-up/scripts/start.sh --tunnel --seed # …public, with login, seeded
+bash .claude/skills/devcontainer-up/scripts/start.sh --replay # …with session replay, proved
+bash .claude/skills/devcontainer-up/scripts/up.sh # start + make ready (idempotent)
+bash .claude/skills/devcontainer-up/scripts/e2e.sh smoke # hackathon-e2e inside the container
+bash .claude/skills/devcontainer-up/scripts/e2e.sh journey # full lifecycle recipe
+bash .claude/skills/devcontainer-up/scripts/mutate.sh run # mutation manifest, fast tier
+bash .claude/skills/devcontainer-up/scripts/mutate.sh check # anchors still match source
+bash .claude/skills/devcontainer-up/scripts/exec.sh just start # any repo command inside
+bash .claude/skills/devcontainer-up/scripts/exec.sh # interactive shell inside
+bash .claude/skills/devcontainer-up/scripts/down.sh # stop (volumes kept)
+bash .claude/skills/devcontainer-up/scripts/down.sh --volumes # full cold reset
+```
+
+`start.sh` is **the one-command path**: container → stack → optionally the seed
+fixture → optionally session replay → optionally a Cloudflare tunnel with OIDC
+wired. It exists because the chain has four steps across three skills and the
+one people forget is the last — a tunnel that serves pages but was never
+auth-wired looks completely fine until somebody tries to sign in. It finishes by
+driving a real login round-trip, because serving HTML proves nothing about OIDC.
+
+The tunnel step is `cloudflare-tunnel/scripts/up.sh --with-auth`, which picks
+**named** mode (a persistent hostname on a zone you own) when
+`.claude/skills/cloudflare-tunnel/.env` supplies credentials, and a quick
+`*.trycloudflare.com` tunnel otherwise. Quick tunnels stay the zero-setup
+default; with a named hostname the issuer wiring survives restarts, so the
+re-wire-after-every-suite-run dance disappears. `start.sh`'s own step banner
+still says "quick tunnel" — the mode `up.sh` prints is the one that is true.
+
+`mutate.sh` forwards to `hackathon-e2e/scripts/mutate.sh` inside the container.
+Its fast tier (`go` + `vitest`) drives the compilers straight from source and
+needs **no running stack**, only the container — so it works while the stack is
+down, being rebuilt, or in use by somebody else.
+
+**`--replay`** is the same idea for session replay: bring up the OpenReplay rig
+(`openreplay-stack/scripts/up.sh`, which creates or reuses its admin account
+from the gitignored `.secrets.env`), point the app at it (`wire-frontend.sh` —
+project key read from OpenReplay's own API, written into the gitignored
+`config.local.yaml`), and then **prove a session records** by running the
+consent spec's first test: it clicks the real "Allow recording" banner and
+counts the bytes the tracker posts to `/ingest`. Opt-in, always: the rig is 23
+more containers and wants 8 GB on top of the dev stack.
+
+Three things that check are built to catch, none of which a `docker ps` would:
+
+- a **stale `ingestPoint`** — every quick tunnel restart mints a new hostname,
+ and the tracker fails silently against the old one (a named
+ `OPENREPLAY_HOSTNAME` removes this failure, because the hostname stops
+ changing);
+- a **skip reported as a pass** — every spec under `tests/openreplay` self-skips
+ when it cannot see `replay.enabled`, and a skipped Playwright run exits 0, so
+ `start.sh` fails on the word `skipped` as well as on a failure;
+- a **zero-byte capture** — the spec writes what it captured, and `start.sh`
+ reads the file size back rather than trusting the exit code.
+
+`--replay` runs BEFORE the tunnel step deliberately: the proof drives Playwright
+over `localhost:8081` and its `setup` dependency logs every persona in, and a
+wired tunnel repoints both OIDC issuers at the public hostname, so localhost
+logins fail while it is up. Both end up wired — they own different keys in the
+same `config.local.yaml` and neither can remove the other's.
+
+There is a third rig with the same shape, not driven by `start.sh`:
+`plausible-stack` (audience measurement, its own Postgres and ClickHouse, its
+own tunnel, its own `.secrets.env`, and `plausible` as its key in the same
+overlay). It costs ~750 MB idle where OpenReplay wants 8 GB of its own, and the
+two coexist. Bring it up with `plausible-stack/scripts/up.sh` +
+`wire-frontend.sh`, or get it alongside the public URL with
+`cloudflare-tunnel/scripts/serve-public.sh --with-plausible`. **Unwire it before
+a suite run** — a dashboard full of Playwright traffic is worse than an empty
+one.
+
+## What `up.sh` does
+
+1. **Start the compose stack.** If the `devcontainer` CLI is installed it is
+ preferred (`devcontainer up`) — it applies the Nix _feature_ and runs
+ post-create exactly like VS Code would. Otherwise it falls back to plain
+ `docker compose up -d dev` and self-bootstraps: single-user Nix install (the
+ feature is only applied by devcontainer tooling), flakes enabled, then the
+ repo's own idempotent `.devcontainer/post-create.sh` (just/direnv bootstrap,
+ dev secrets, codegen). Either path also starts `rustfs` explicitly — naming a
+ service makes compose start only what is named, and `up -d dev` silently left
+ the object store down.
+2. **Prepare the object store** (`.devcontainer/rustfs-init.sh`, idempotent):
+ bucket, public-read policy, seeded event covers. Nothing in the app's build
+ path knows the store exists, so without this it is up and empty — broken
+ `` frames and 404s under `/objects`.
+3. **Warm the dev shell** (`just develop true`): the first run downloads the
+ toolchain into the `nix-store` volume (slow once, cached afterwards).
+
+`e2e.sh` is the **default entry point for the e2e tests**: it ensures the
+container is ready, then forwards to
+`.claude/skills/hackathon-e2e/scripts/run.sh` inside it. Ports 3000/8081/8180/
+5432 are published to the host, so you can also watch the frontend at
+http://localhost:8081 while tests run.
+
+## Notes
+
+- Everything (Keycloak, Postgres, backend, frontend) runs _inside_ the `dev`
+ container via process-compose. `rustfs` — the S3-compatible object store the
+ storage service presigns against — is a sibling container and always on.
+ Optional sidecars: `caddy`+`tunnel` (profile `tunnel`, see the
+ cloudflare-tunnel skill) and `postgres`+`keycloak` as real containers (profile
+ `services`, opt-in — `just up` still starts devenv's own copies and they would
+ collide). The OpenReplay and Plausible rigs are _not_ in this compose file:
+ each owns its own compose project, its own tunnel and its own gitignored
+ `.secrets.env`, so bringing one up cannot recreate `dev`.
+- **Editing `.devcontainer/docker-compose.yml` recreates the `dev` container on
+ the next `compose up`**, which kills process-compose inside it _and_ discards
+ anything apt-installed at runtime. Restart the stack afterwards, and expect a
+ cold first request. This is why Firefox's system libraries are baked into the
+ Dockerfile rather than left to `--with-deps` — a rule that has had to be
+ applied twice: an `apt-mark showmanual` diff before the 2026-08-13 recreate
+ found **21 more Playwright packages** (xvfb, libavcodec60, six font packages,
+ the X/cairo/pango set) living only in the writable layer. They are in the
+ image now, and the Dockerfile records how to re-derive the list after a
+ Playwright bump. That comment is the recovery procedure after any recreate;
+ read it before reaching for `--with-deps` again.
+- **`git-lfs` is in the image** (2026-08-13). Three files in this repo are LFS
+ pointers in HEAD and hold their real bytes in the worktree, smudged by the
+ Windows host; without the filters installed, git inside the container compared
+ pointer against content and reported all three modified forever. `git status`
+ inside `dev` is a usable signal again, which the mutation runner's cleanliness
+ check depends on. It did **not** buy back a performance floor — see the
+ README's note on what entering the Nix shell actually costs.
+- Postgres data lives in the `devenv-state` volume, the Nix store in
+ `nix-store`; `down.sh` keeps both. `down.sh --volumes` deletes them — next
+ start re-downloads the toolchain (minutes).
+- **`node_modules`, `.svelte-kit` and `.pnpm-store` are named volumes**, not
+ bind-mounted: small-file IO across the host mount is ~100× slower and caused a
+ total outage (see the container traps in `.claude/CLAUDE.md`). They start
+ empty on a fresh volume — `post-create.sh` chowns the root-owned mountpoints
+ and `bootstrap.sh` repopulates them. Consequence: `node_modules` is not
+ visible from Windows, so run editors/tooling inside the container.
+- Playwright's Firefox **binary** is downloaded by the e2e suite itself
+ (`run.sh` tries `playwright install --with-deps firefox` and falls back to a
+ plain `playwright install firefox`); its **system libraries** come from the
+ image, per the bullet above. `--with-deps` works because `vscode` has
+ passwordless sudo, but nothing should depend on it succeeding.
+- **Never run a bare `pnpm build` in `components/frontend`.** Two callers build
+ _and serve_ that one `build/service` tree — `hackathon-e2e/prod-frontend.sh`
+ on :8081 and `cloudflare-tunnel/prod-serve.sh` on :8082 — so both go through
+ `.claude/skills/lib/frontend-build.sh` (`build` | `if-stale` | `stale`): an
+ exclusive `flock` so two builds cannot interleave, and a build into a temp dir
+ swapped in atomically so `build/service` never contains a half-written tree.
+ Note the 9p quirk it works around: a directory rename on the bind mount
+ intermittently answers `EPERM`, so the swap retries and rolls the old tree
+ back rather than leaving nothing in place.
+- **`process list` saying `Running Ready` does not mean the service you think is
+ running.** The frontend's readiness probe is a plain GET of :8081, which the
+ harness's own adapter-node server answers just as happily as vite — a probe on
+ a PORT cannot say which PROCESS holds it. That hid a vite crash loop (54
+ restarts in 50 minutes, `Port 8081 is already in use`) whose only visible
+ symptom was every other service starting slowly. Both stack processes are
+ capped at `max_restarts = 3` in `tools/nix/hackagon/lib/toolchain.nix` now,
+ and `hackathon-e2e/scripts/wait-ready.sh` reads the `RESTARTS` column back and
+ warns at ≥3. **Read that column** — the number was always there.
+- Config knobs (ports, base image, project name) come from `.devcontainer/.env`
+ — see `.devcontainer/.env.example`. Cloudflare credentials for named tunnels
+ are separate and live in `.claude/skills/cloudflare-tunnel/.env` (gitignored;
+ `.env.example` beside it).
diff --git a/.claude/skills/devcontainer-up/scripts/down.sh b/.claude/skills/devcontainer-up/scripts/down.sh
new file mode 100644
index 00000000..a17c3bde
--- /dev/null
+++ b/.claude/skills/devcontainer-up/scripts/down.sh
@@ -0,0 +1,18 @@
+#!/usr/bin/env bash
+# Stop the devcontainer compose stack.
+# down.sh # stop containers, keep volumes (nix store, devenv state)
+# down.sh --volumes # ALSO delete the volumes — full cold-start next time
+# # (re-downloads the entire Nix toolchain; ~minutes)
+set -euo pipefail
+HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
+source "$HERE/lib.sh"
+
+require_docker
+if [ "${1:-}" = "--volumes" ]; then
+ echo "==> Stopping and REMOVING VOLUMES (nix store, devenv/postgres state)..."
+ compose down --volumes
+else
+ echo "==> Stopping the devcontainer stack (volumes preserved)..."
+ compose down
+fi
+echo "==> Done."
diff --git a/.claude/skills/devcontainer-up/scripts/e2e.sh b/.claude/skills/devcontainer-up/scripts/e2e.sh
new file mode 100644
index 00000000..d5ff43b2
--- /dev/null
+++ b/.claude/skills/devcontainer-up/scripts/e2e.sh
@@ -0,0 +1,20 @@
+#!/usr/bin/env bash
+# Run the hackathon-e2e suite INSIDE the devcontainer — the default way to run
+# it. Ensures the container is up and ready first, then forwards all
+# arguments to .claude/skills/hackathon-e2e/scripts/run.sh:
+#
+# e2e.sh # smoke suite
+# e2e.sh journey # full lifecycle recipe
+# e2e.sh all --grep act5 # any run.sh arguments pass through
+set -euo pipefail
+HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
+source "$HERE/lib.sh"
+
+require_docker
+if ! container_running; then
+ bash "$HERE/up.sh"
+fi
+
+args=""
+if [ $# -gt 0 ]; then args=$(printf "%q " "$@"); fi
+in_container "bash .claude/skills/hackathon-e2e/scripts/run.sh $args"
diff --git a/.claude/skills/devcontainer-up/scripts/exec.sh b/.claude/skills/devcontainer-up/scripts/exec.sh
new file mode 100644
index 00000000..c8d94b4a
--- /dev/null
+++ b/.claude/skills/devcontainer-up/scripts/exec.sh
@@ -0,0 +1,24 @@
+#!/usr/bin/env bash
+# Run a command inside the running devcontainer (as vscode, in the workspace,
+# login shell so the Nix profile is loaded). No arguments -> interactive bash.
+#
+# exec.sh just start
+# exec.sh bash .claude/skills/hackathon-e2e/scripts/probe.sh
+# exec.sh # interactive shell
+set -euo pipefail
+HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
+source "$HERE/lib.sh"
+
+require_docker
+if ! container_running; then
+ echo "error: devcontainer is not running — start it with scripts/up.sh" >&2
+ exit 1
+fi
+
+if [ $# -eq 0 ]; then
+ compose exec -u "$CONTAINER_USER" -w "$WORKDIR" "$SERVICE" bash -l
+else
+ # %q-quote each argument so spaces/quotes survive the bash -lc round-trip.
+ cmd=$(printf "%q " "$@")
+ in_container "$cmd"
+fi
diff --git a/.claude/skills/devcontainer-up/scripts/lib.sh b/.claude/skills/devcontainer-up/scripts/lib.sh
new file mode 100644
index 00000000..53766717
--- /dev/null
+++ b/.claude/skills/devcontainer-up/scripts/lib.sh
@@ -0,0 +1,57 @@
+# shellcheck shell=bash
+# Shared helpers for the devcontainer-up scripts. Source after setting HERE:
+# HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
+# source "$HERE/lib.sh"
+
+SKILL_DIR="$(dirname "$HERE")"
+ROOT_DIR="$(cd "$SKILL_DIR/../../.." && pwd)"
+COMPOSE_FILE="$ROOT_DIR/.devcontainer/docker-compose.yml"
+SERVICE="dev"
+# The S3-compatible object store (see .devcontainer/README.md). A sibling of
+# `dev`, deliberately not a dependency of it: `dev` is only healthy once someone
+# runs `just up`, so gating anything on its health deadlocks.
+STORAGE_SERVICE="rustfs"
+CONTAINER_USER="vscode"
+WORKDIR="/workspaces/hackagon"
+
+# Git Bash / MSYS on Windows rewrites absolute POSIX paths in arguments
+# (/workspaces/... -> C:/Program Files/Git/workspaces/...). Disable that for
+# every docker invocation.
+case "$(uname -s)" in
+MINGW* | MSYS*)
+ export MSYS_NO_PATHCONV=1
+ export MSYS2_ARG_CONV_EXCL="*"
+ # With conversion disabled, docker.exe would receive the POSIX-style
+ # /c/... compose path verbatim and resolve it as C:\c\... — hand it a
+ # Windows-style (C:/...) path instead; MSYS leaves those untouched.
+ COMPOSE_FILE="$(cygpath -m "$COMPOSE_FILE")"
+ ;;
+esac
+
+require_docker() {
+ if ! docker info >/dev/null 2>&1; then
+ echo "error: docker is not available (is Docker Desktop / the daemon running?)" >&2
+ exit 1
+ fi
+}
+
+compose() {
+ docker compose -f "$COMPOSE_FILE" "$@"
+}
+
+# Run a command inside the dev container as the vscode user, with a login
+# shell so the Nix profile and direnv hooks from post-create.sh are loaded.
+# TTY is attached only when we actually have one (CI/scripting safe).
+in_container() {
+ local tty_flag="-T"
+ if [ -t 0 ] && [ -t 1 ]; then tty_flag=""; fi
+ # -e USER: docker exec does not set it, and Nix's profile script silently
+ # no-ops when USER is unset — leaving the whole toolchain off PATH.
+ # shellcheck disable=SC2086
+ compose exec $tty_flag -u "$CONTAINER_USER" -e USER="$CONTAINER_USER" \
+ -w "$WORKDIR" "$SERVICE" bash -lc "$*"
+}
+
+container_running() {
+ [ -n "$(compose ps -q --status running "$SERVICE" 2>/dev/null)" ]
+}
diff --git a/.claude/skills/devcontainer-up/scripts/mutate.sh b/.claude/skills/devcontainer-up/scripts/mutate.sh
new file mode 100644
index 00000000..71bed9b0
--- /dev/null
+++ b/.claude/skills/devcontainer-up/scripts/mutate.sh
@@ -0,0 +1,26 @@
+#!/usr/bin/env bash
+# Run the mutation-testing manifest INSIDE the devcontainer — the default way
+# to run it, and a sibling of e2e.sh. All arguments forward verbatim to
+# .claude/skills/hackathon-e2e/scripts/mutate.sh:
+#
+# mutate.sh list # the manifest
+# mutate.sh check # every anchor still matches its source
+# mutate.sh run # the fast tier (go + vitest, no stack)
+# mutate.sh run owner.last-guard # one mutation
+# mutate.sh restore # after a run that was killed outright
+#
+# The fast tier needs NO running stack: it drives `go test` and `vitest`
+# straight from source. That is deliberate — it means mutation testing stays
+# available while the stack is down, being rebuilt, or in use by someone else.
+set -euo pipefail
+HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
+source "$HERE/lib.sh"
+
+require_docker
+if ! container_running; then
+ bash "$HERE/up.sh"
+fi
+
+args=""
+if [ $# -gt 0 ]; then args=$(printf "%q " "$@"); fi
+in_container "bash .claude/skills/hackathon-e2e/scripts/mutate.sh $args"
diff --git a/.claude/skills/devcontainer-up/scripts/start.sh b/.claude/skills/devcontainer-up/scripts/start.sh
new file mode 100644
index 00000000..0fce75c1
--- /dev/null
+++ b/.claude/skills/devcontainer-up/scripts/start.sh
@@ -0,0 +1,207 @@
+#!/usr/bin/env bash
+# One command from nothing to a working public URL.
+#
+# start.sh container + stack, reachable on localhost:8081
+# start.sh --tunnel ... plus a Cloudflare quick tunnel with LOGIN
+# start.sh --replay ... plus OpenReplay session replay, wired + PROVED
+# start.sh --tunnel --seed ... and the dev fixture loaded
+#
+# Exists because the chain has four steps in three skills, and the one people
+# forget is the last: a tunnel that serves pages but was never auth-wired looks
+# completely fine until somebody tries to sign in. This wires it and then
+# PROVES a login round-trip before telling you it is ready. `--replay` gets the
+# same treatment: it finishes by watching bytes reach the ingest endpoint,
+# because "session replay is ready" from a script that never saw a byte is the
+# exact claim this repo keeps catching.
+set -euo pipefail
+HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
+ROOT_DIR="$(cd "$HERE/../../../.." && pwd)"
+SKILLS="$ROOT_DIR/.claude/skills"
+
+WITH_TUNNEL=0
+WITH_SEED=0
+WITH_REPLAY=0
+for arg in "$@"; do
+ case "$arg" in
+ --tunnel) WITH_TUNNEL=1 ;;
+ --seed) WITH_SEED=1 ;;
+ --replay) WITH_REPLAY=1 ;;
+ -h | --help)
+ sed -n '2,10p' "${BASH_SOURCE[0]}" | sed 's/^# \{0,1\}//'
+ exit 0
+ ;;
+ *)
+ echo "unknown option: $arg (try --help)" >&2
+ exit 2
+ ;;
+ esac
+done
+
+step() { printf '\n\033[1m==> %s\033[0m\n' "$1"; }
+
+step "Dev container"
+bash "$SKILLS/devcontainer-up/scripts/up.sh"
+
+step "Stack (postgres, keycloak, backend, frontend)"
+bash "$SKILLS/devcontainer-up/scripts/exec.sh" bash .claude/skills/hackathon-e2e/scripts/up.sh
+bash "$SKILLS/devcontainer-up/scripts/exec.sh" bash .claude/skills/hackathon-e2e/scripts/wait-ready.sh
+
+if [ "$WITH_SEED" -eq 1 ]; then
+ step "Seed fixture"
+ bash "$SKILLS/devcontainer-up/scripts/exec.sh" bash .claude/skills/hackathon-e2e/scripts/seed.sh
+fi
+
+# ── session replay ─────────────────────────────────────────────────────────
+#
+# OPT-IN, and it stays that way: this brings up 23 more containers and wants
+# 8 GB of RAM on top of the dev stack (openreplay-stack/scripts/doctor.sh
+# checks). Nobody who typed `start.sh` gets that by surprise.
+#
+# BEFORE the tunnel, deliberately. The proof below drives Playwright against
+# http://localhost:8081 and its `setup` dependency logs every persona in
+# through Keycloak; a wired tunnel repoints both OIDC issuers at the public
+# hostname, so doing this afterwards would test the tunnel's auth wiring
+# instead of the ingest path and fail for a reason that has nothing to do with
+# replay. Both end up wired: they own different keys in the same
+# config.local.yaml and neither can remove the other's.
+REPLAY_URL=""
+REPLAY_BYTES=""
+if [ "$WITH_REPLAY" -eq 1 ]; then
+ step "OpenReplay (23 services — first run pulls ~25 images)"
+ # up.sh is idempotent and creates/reuses the admin account itself from the
+ # gitignored .secrets.env; an account made by hand with an unrecorded
+ # password has already cost one full volume wipe.
+ bash "$SKILLS/openreplay-stack/scripts/up.sh"
+
+ step "Pointing the app at it"
+ # Writes the `replay` block into components/frontend/data/test/config/
+ # config.local.yaml — the gitignored overlay, never the tracked config.yaml —
+ # and bounces both possible :8081 servers, since each reads its config once
+ # at boot.
+ bash "$SKILLS/openreplay-stack/scripts/wire-frontend.sh"
+ REPLAY_URL="$(bash "$SKILLS/openreplay-stack/scripts/url.sh" 2>/dev/null || true)"
+
+ step "Proving a session is actually recorded"
+ # The check that matters, and the reason this is not just three `up` calls in
+ # a row. Everything up to here is configuration: a stack that answers, a
+ # block written into a file, a frontend that restarted. None of it says a
+ # single byte can travel from a browser to the ingest endpoint — a stale
+ # ingestPoint, a dead quick tunnel or an unstarted tracker all leave every
+ # one of those steps looking successful and the OpenReplay UI empty.
+ #
+ # So: run the consent spec's first test, which clears cookies, loads a page,
+ # clicks the REAL "Allow recording" banner and counts the bytes the tracker
+ # posts. Reused, not reinvented — tests/openreplay/capture.ts is the same
+ # machinery masking.spec.ts greps for sentinels.
+ CAPTURE="$ROOT_DIR/.claude/skills/hackathon-e2e/.artifacts/openreplay/consented.bin"
+ rm -f "$CAPTURE"
+ PROOF_LOG="$(mktemp)"
+ PROOF='cd .claude/skills/hackathon-e2e
+[ -d node_modules ] || pnpm install
+pnpm exec playwright install firefox >/dev/null 2>&1 || true
+pnpm exec playwright test --project=openreplay --grep "records nothing until the banner is answered"'
+
+ if ! bash "$SKILLS/devcontainer-up/scripts/exec.sh" \
+ env PLAYWRIGHT_SKIP_VALIDATE_HOST_REQUIREMENTS=true \
+ just nix::develop default bash -c "$PROOF" >"$PROOF_LOG" 2>&1; then
+ echo
+ echo "error: the session-replay proof FAILED — recording does not work." >&2
+ tail -40 "$PROOF_LOG" >&2
+ exit 1
+ fi
+
+ # A SKIP IS A FAILURE HERE. Every spec under tests/openreplay self-skips when
+ # it cannot see `replay.enabled: true`, and a skipped Playwright run exits 0 —
+ # so without this check a broken wire-up would print "ready" and mean
+ # "nothing ran". That is not hypothetical: the config moved from config.yaml
+ # to the config.local.yaml overlay, and until capture.ts learned to read the
+ # merged view, this suite skipped itself on a correctly wired machine.
+ # Matched against Playwright's own summary line (" 1 skipped"), not the word
+ # anywhere in the log: `pnpm install` above prints "skipped" of its own accord
+ # and would fail this check for nothing.
+ if grep -qE "^[[:space:]]*[0-9]+ skipped" "$PROOF_LOG"; then
+ echo
+ echo "error: the session-replay proof SELF-SKIPPED, so nothing was verified." >&2
+ echo " tests/openreplay could not see replay.enabled — check the merged" >&2
+ echo " config: components/frontend/data/test/config/config.local.yaml" >&2
+ tail -20 "$PROOF_LOG" >&2
+ exit 1
+ fi
+
+ # ...and the bytes themselves, on disk. The spec writes what it captured; an
+ # empty file would mean it passed for some reason other than a recording.
+ REPLAY_BYTES="$(wc -c <"$CAPTURE" 2>/dev/null || echo 0)"
+ if [ "${REPLAY_BYTES:-0}" -le 0 ]; then
+ echo
+ echo "error: the proof reported success but captured 0 bytes ($CAPTURE)." >&2
+ exit 1
+ fi
+ rm -f "$PROOF_LOG"
+ echo " $REPLAY_BYTES bytes reached the ingest endpoint (see $CAPTURE)"
+fi
+
+# ── the public URL ─────────────────────────────────────────────────────────
+URL=""
+if [ "$WITH_TUNNEL" -eq 1 ]; then
+ step "Cloudflare quick tunnel with login"
+ bash "$SKILLS/cloudflare-tunnel/scripts/up.sh" --with-auth
+
+ URL="$(bash "$SKILLS/cloudflare-tunnel/scripts/url.sh" | awk '{print $NF}' | tail -1)"
+ if [ -z "$URL" ]; then
+ echo "error: tunnel is up but no public URL was found" >&2
+ exit 1
+ fi
+
+ step "Proving a login round-trip through $URL"
+ # The check that matters. Serving HTML proves nothing about OIDC: the failure
+ # mode this guards against is a tunnel whose issuers still point at localhost,
+ # where every page loads and only signing in is broken.
+ #
+ # It caught a second failure of exactly that shape, one layer down. The stack
+ # step above ends in `wait-ready.sh`, which serves the frontend from the
+ # adapter-node BUILD on :8081 with ORIGIN=http://localhost:8081 — and caddy
+ # falls back to :8081 when nothing holds :8082, so the public URL was served
+ # by a server that 403s every form POST arriving with the tunnel's Origin.
+ # Pages rendered; "Log in" did nothing; this proof timed out with nothing in
+ # any log naming the cause. `cloudflare-tunnel/scripts/up.sh` now calls
+ # `prod-serve.sh ensure ` to put a correct-origin server on :8082 in that
+ # case, so the proof below tests OIDC rather than the upstream's ORIGIN.
+ if ! bash "$SKILLS/devcontainer-up/scripts/exec.sh" \
+ env TUNNEL_BASE_URL="$URL" PLAYWRIGHT_SKIP_VALIDATE_HOST_REQUIREMENTS=true \
+ just nix::develop default bash -c \
+ 'cd .claude/skills/hackathon-e2e && pnpm exec playwright test --project=tunnel --grep "logs in"' \
+ >/dev/null 2>&1; then
+ echo
+ echo "warn: the tunnel serves pages but the login round-trip FAILED." >&2
+ echo " Re-wire with: cloudflare-tunnel/scripts/auth-wire.sh $URL" >&2
+ exit 1
+ fi
+fi
+
+step "Ready"
+if [ -n "$URL" ]; then
+ echo " App $URL"
+ echo " sign in as alice / aliceandbob — login verified through the tunnel"
+ echo " (while the tunnel is wired, localhost logins carry the wrong issuer)"
+else
+ echo " App http://localhost:8081"
+ echo " sign in as alice / aliceandbob"
+fi
+
+if [ "$WITH_REPLAY" -eq 1 ]; then
+ email="$(sed -n 's/^OPENREPLAY_EMAIL=//p' "$SKILLS/openreplay-stack/.secrets.env" 2>/dev/null | head -1)"
+ echo
+ echo " OpenReplay ${REPLAY_URL:-}"
+ echo " sign in as ${email:-} — the password is in"
+ echo " .claude/skills/openreplay-stack/.secrets.env (gitignored)"
+ echo " $REPLAY_BYTES bytes were recorded just now, so ingest works."
+ echo
+ echo " ⚠ RECORDING IS CONSENT-GATED. Every visitor is ASKED, and nothing is"
+ echo ' recorded until they click "Allow recording" — so an OpenReplay UI'
+ echo " with no sessions in it is the CORRECT default, not a broken ingest."
+ echo " Withdraw at /account. What is recorded is masked by default-deny;"
+ echo " see docs/frontend/session-replay.md."
+ echo
+ echo " Turn it off openreplay-stack/scripts/wire-frontend.sh --restore"
+ echo " openreplay-stack/scripts/down.sh (keeps recordings)"
+fi
diff --git a/.claude/skills/devcontainer-up/scripts/up.sh b/.claude/skills/devcontainer-up/scripts/up.sh
new file mode 100644
index 00000000..f7c57d99
--- /dev/null
+++ b/.claude/skills/devcontainer-up/scripts/up.sh
@@ -0,0 +1,58 @@
+#!/usr/bin/env bash
+# Bring the Hackagon devcontainer up AND ready:
+# 1. Start the compose stack (.devcontainer/docker-compose.yml, service `dev`).
+# Prefers the `devcontainer` CLI when installed (it applies the Nix
+# feature + post-create automatically); falls back to plain compose and
+# bootstraps Nix + post-create.sh itself.
+# 2. Warm the Nix dev shell (first run downloads the whole toolchain —
+# this is the slow step; the nix-store volume caches it for next time).
+#
+# After this, everything runs inside the container, e.g.:
+# scripts/e2e.sh smoke # hackathon-e2e suite in the container
+# scripts/exec.sh just start # any repo command
+set -euo pipefail
+HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
+source "$HERE/lib.sh"
+
+require_docker
+
+if command -v devcontainer >/dev/null 2>&1; then
+ echo "==> Starting via the devcontainer CLI (Nix feature + post-create handled)..."
+ devcontainer up --workspace-folder "$ROOT_DIR"
+ # The CLI starts what devcontainer.json names; the object store is a sibling
+ # service it has no opinion about.
+ compose up -d "$STORAGE_SERVICE"
+else
+ echo "==> devcontainer CLI not found — using plain docker compose..."
+ # Both, explicitly: naming a service makes compose start only what is named,
+ # so `up -d dev` silently left the object store down even though it has no
+ # profile and a bare `up` would have started it.
+ compose up -d --build "$SERVICE" "$STORAGE_SERVICE"
+
+ if ! in_container 'command -v nix' >/dev/null 2>&1; then
+ echo "==> Installing Nix (single-user) inside the container..."
+ in_container 'curl -fsSL https://nixos.org/nix/install -o /tmp/install-nix && sh /tmp/install-nix --no-daemon'
+ in_container 'mkdir -p ~/.config/nix && printf "experimental-features = nix-command flakes\nsandbox = false\n" > ~/.config/nix/nix.conf'
+ fi
+
+ echo "==> Running post-create bootstrap (idempotent)..."
+ in_container 'bash .devcontainer/post-create.sh'
+fi
+
+echo "==> Preparing the object store (bucket, access policy, seeded event images)..."
+# Idempotent: creates what is missing and overwrites the seed images with
+# identical bytes. Without this the store is up but empty, which shows as three
+# broken frames on a fresh clone and as 404s under /objects.
+in_container 'bash .devcontainer/rustfs-init.sh'
+
+echo "==> Warming the Nix dev shell (first run downloads the toolchain — grab a coffee)..."
+in_container 'just develop true'
+
+echo ""
+echo "── Devcontainer ready ──────────────────────────────────────"
+echo " Run the e2e suites: bash $SKILL_DIR/scripts/e2e.sh smoke"
+echo " bash $SKILL_DIR/scripts/e2e.sh journey"
+echo " Run any command: bash $SKILL_DIR/scripts/exec.sh just start"
+echo " Shell inside: bash $SKILL_DIR/scripts/exec.sh bash"
+echo " Object store: http://localhost:9000 (bucket hackagon-dev)"
+echo " Stop: bash $SKILL_DIR/scripts/down.sh"
diff --git a/.claude/skills/docs-bundle/.gitignore b/.claude/skills/docs-bundle/.gitignore
new file mode 100644
index 00000000..72aae85f
--- /dev/null
+++ b/.claude/skills/docs-bundle/.gitignore
@@ -0,0 +1,2 @@
+node_modules/
+out/
diff --git a/.claude/skills/docs-bundle/SKILL.md b/.claude/skills/docs-bundle/SKILL.md
new file mode 100644
index 00000000..b4702435
--- /dev/null
+++ b/.claude/skills/docs-bundle/SKILL.md
@@ -0,0 +1,76 @@
+---
+name: docs-bundle
+description:
+ Build the whole docs/ folder into ONE self-contained static HTML file — images
+ re-encoded to webp and inlined as data URIs, mermaid diagrams pre-rendered to
+ inline SVG, cross-document links rewritten to anchors. No network, no CDN, no
+ JS needed to read it. Use when asked for a single shareable documentation
+ file, an offline/emailable doc bundle, a docs PDF, or to export the
+ documentation.
+---
+
+# Single-file documentation bundle
+
+One HTML file containing every markdown document under `docs/`. Nothing loads at
+view time — hand it to anyone, open it from a USB stick or an email attachment,
+print it to PDF.
+
+## Build
+
+```bash
+# from a host without node:
+bash .claude/skills/devcontainer-up/scripts/exec.sh just develop \
+ bash -c "cd .claude/skills/docs-bundle && pnpm install && node scripts/build.mjs"
+
+# inside the dev shell:
+cd .claude/skills/docs-bundle && pnpm install && node scripts/build.mjs
+```
+
+Output: `out/hackagon-docs.html`. `docs/` currently holds **23 markdown
+documents, 26 screenshots and 7 mermaid diagrams**; the last build on disk came
+to 1.8 MB. Only 17 documents are named in `ORDER` — the rest are appended
+alphabetically and reported, so a new file is never silently dropped, but it
+does land at the end until someone places it.
+
+| Flag | Default | Effect |
+| --------------- | ------------------------ | ------------------------------------------------------ |
+| `--out FILE` | `out/hackagon-docs.html` | write somewhere else |
+| `--quality N` | `78` | webp quality |
+| `--max-width N` | `1400` | downscale wider images |
+| `--no-mermaid` | off | skip diagram rendering (faster; blocks stay as source) |
+
+## What it does
+
+| Step | Detail |
+| --------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
+| Order | An explicit `ORDER` list in `build.mjs` (README first, TODO last). Anything in `docs/` not listed is **appended alphabetically and reported**, so a new file is never silently dropped. |
+| Images | Every `` — HTML tags _and_ markdown `![]()` — resolved relative to its own document, re-encoded to webp with sharp, inlined as a data URI. SVGs pass through untouched (re-encoding would rasterize them). Missing files warn and keep their original `src`. |
+| Diagrams | ` ```mermaid ` blocks are pulled out **before** markdown parsing, rendered in a headless browser, and reinserted as inline SVG. If rendering is unavailable the block degrades to a labelled source listing rather than vanishing. |
+| Diagram theming | Mermaid is themed with the **same palette as the hand-laid C4 SVGs** (`dev/scripts/render-diagrams.mjs`), so both diagram families read as one system. Mermaid bakes colours at render time, so each block is rendered **twice** (light + dark) and CSS shows the matching one; print forces light. The C4 SVGs need no such trick — they carry their own `prefers-color-scheme` block. Changing the palette means editing it in **both** places. |
+| Links | `foo.md` and `backend/rbac.md#x` become in-page anchors. Links to files that are not part of the bundle are left alone. |
+| Navigation | Sticky sidebar: every document plus its `##` headings. |
+| Provenance | Branch, short commit and build date in the sidebar and on the cover — a shared file always says which state of the repo it describes. |
+
+Light and dark are both styled (`prefers-color-scheme`), and there is a print
+stylesheet: the sidebar disappears, each document starts on a new page, and
+code/tables/figures avoid page breaks — so _Print → Save as PDF_ gives a decent
+handout.
+
+## Requirements
+
+`pnpm install` in this folder pulls `marked`, `sharp`, `mermaid` and
+`playwright`. The Playwright **browser binary is shared** with the hackathon-e2e
+skill (per-user cache), so this does not download another one — and if no
+browser is available the build still succeeds, just without rendered diagrams.
+
+## Gotchas worth knowing
+
+- **Semicolons break mermaid.** `;` terminates a statement, so
+ `A->>B: did x; then y` is a parse error — the diagram silently fails to
+ render, on GitHub too. One such error existed in `architecture.md` and was
+ fixed when this skill was built; the builder reports any block it cannot
+ render instead of dropping it.
+- Images are inlined **once and cached**, so the same screenshot used in two
+ documents costs one copy.
+- The output is regenerated wholesale; it is gitignored (`out/`) because it is a
+ build artifact, not a source of truth.
diff --git a/.claude/skills/docs-bundle/package.json b/.claude/skills/docs-bundle/package.json
new file mode 100644
index 00000000..8d8643eb
--- /dev/null
+++ b/.claude/skills/docs-bundle/package.json
@@ -0,0 +1,15 @@
+{
+ "name": "hackagon-docs-bundle",
+ "private": true,
+ "version": "0.1.0",
+ "type": "module",
+ "scripts": {
+ "build": "node scripts/build.mjs"
+ },
+ "dependencies": {
+ "marked": "^18.0.9",
+ "mermaid": "^11.4.1",
+ "playwright": "^1.50.1",
+ "sharp": "^0.35.3"
+ }
+}
diff --git a/.claude/skills/docs-bundle/pnpm-lock.yaml b/.claude/skills/docs-bundle/pnpm-lock.yaml
new file mode 100644
index 00000000..7e0a0ddd
--- /dev/null
+++ b/.claude/skills/docs-bundle/pnpm-lock.yaml
@@ -0,0 +1,1199 @@
+lockfileVersion: '9.0'
+
+settings:
+ autoInstallPeers: true
+ excludeLinksFromLockfile: false
+
+importers:
+
+ .:
+ dependencies:
+ marked:
+ specifier: ^18.0.9
+ version: 18.0.9
+ mermaid:
+ specifier: ^11.4.1
+ version: 11.16.1
+ playwright:
+ specifier: ^1.50.1
+ version: 1.62.1
+ sharp:
+ specifier: ^0.35.3
+ version: 0.35.3
+
+packages:
+
+ '@antfu/install-pkg@1.1.0':
+ resolution: {integrity: sha512-MGQsmw10ZyI+EJo45CdSER4zEb+p31LpDAFp2Z3gkSd1yqVZGi0Ebx++YTEMonJy4oChEMLsxZ64j8FH6sSqtQ==}
+
+ '@braintree/sanitize-url@7.1.2':
+ resolution: {integrity: sha512-jigsZK+sMF/cuiB7sERuo9V7N9jx+dhmHHnQyDSVdpZwVutaBu7WvNYqMDLSgFgfB30n452TP3vjDAvFC973mA==}
+
+ '@chevrotain/types@11.1.2':
+ resolution: {integrity: sha512-U+HFai5+zmJCkK86QsaJtoITlboZHBqrVketcO2ROv865xfCMSFpELQoz1GkX5GzME8pTa+3kbKrZHQtI0gdbw==}
+
+ '@emnapi/runtime@1.11.3':
+ resolution: {integrity: sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA==}
+
+ '@iconify/types@2.0.0':
+ resolution: {integrity: sha512-+wluvCrRhXrhyOmRDJ3q8mux9JkKy5SJ/v8ol2tu4FVjyYvtEzkc/3pK15ET6RKg4b4w4BmTk1+gsCUhf21Ykg==}
+
+ '@iconify/utils@3.1.4':
+ resolution: {integrity: sha512-b1S7B1k9ohZ+iNTi2ATxbRYG9fTrJmUT0rc46bvVnNxqNRGW7dyo/vRREwyniI5IRN2RSJHDcm+s3BjWrSAjHw==}
+
+ '@img/colour@1.1.0':
+ resolution: {integrity: sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==}
+ engines: {node: '>=18'}
+
+ '@img/sharp-darwin-arm64@0.35.3':
+ resolution: {integrity: sha512-RMnFX7YQsMoh7lWfcM4NEHHymBX/rLuKNPVM84XE9ONPcaSCDgE7CHIHpSgPcO2xcRthgBy1HfNO319mwhIAkg==}
+ engines: {node: '>=20.9.0'}
+ cpu: [arm64]
+ os: [darwin]
+
+ '@img/sharp-darwin-x64@0.35.3':
+ resolution: {integrity: sha512-Xo+5uFBtLN0BKqieTxiFzFPQAUlBbbH5iBKyRX/z1JrbnYsHTfKJnUfL8+p2TPXr1pXqao4eeL4Rl144uDpK9w==}
+ engines: {node: '>=20.9.0'}
+ cpu: [x64]
+ os: [darwin]
+
+ '@img/sharp-freebsd-wasm32@0.35.3':
+ resolution: {integrity: sha512-lUxcqWIj2wMQ9BrwNjngcr1gWUr5xgaGThBRqPPalIC2n67Cqj1uPh8NnA/ZhAg8hUbKl+kVHKwgUIwe6ZYPrg==}
+ engines: {node: '>=20.9.0'}
+ os: [freebsd]
+
+ '@img/sharp-libvips-darwin-arm64@1.3.2':
+ resolution: {integrity: sha512-9J6ypZFpQBj4YnePGoq/S38w6nz+vqg5WZLrLGY4YuSemdMq47GMLBPO42MzwdGwpg/agZ7xzZcFHa48xlywfg==}
+ cpu: [arm64]
+ os: [darwin]
+
+ '@img/sharp-libvips-darwin-x64@1.3.2':
+ resolution: {integrity: sha512-m2pW1n6cns9VaubNwsZ+c3CRYjxNQWgJ5gPlnL1nbBcpkBvFm6SCFN5o0psFHI8w9n11NKhFkeEDns98tiqbEw==}
+ cpu: [x64]
+ os: [darwin]
+
+ '@img/sharp-libvips-linux-arm64@1.3.2':
+ resolution: {integrity: sha512-dqVSFynCox4C/J8kT16V7SIFAns0IjgLwkvYT7p8LQVmJ5OS5b6tI9IGflxTeuBS//zXeFIUbwt5dwxyZ17cnA==}
+ cpu: [arm64]
+ os: [linux]
+
+ '@img/sharp-libvips-linux-arm@1.3.2':
+ resolution: {integrity: sha512-1eMLzy92I4J6rmi4mAT8yC3HxOtniyGELlzGbNMLLeqe052ahFQ0h6LFq+lh5DsDIdYViIDst08abvSbcEdLXQ==}
+ cpu: [arm]
+ os: [linux]
+
+ '@img/sharp-libvips-linux-ppc64@1.3.2':
+ resolution: {integrity: sha512-3z0NHDxD6n5I9gc05U1eW1AyRm+Gznzq3naMrthPNqE6oYykcogW0l/jfpJdjYnuNl8R7yI9pNbE1XiUeyq0Aw==}
+ cpu: [ppc64]
+ os: [linux]
+
+ '@img/sharp-libvips-linux-riscv64@1.3.2':
+ resolution: {integrity: sha512-bsb4rI+NldGOsXuej2r8OdSS8+zXDVaCWxyWrcv6kneTOlgAHtZABRzBBCwdsPiD90J4myNJuHpg6kA20ImW/w==}
+ cpu: [riscv64]
+ os: [linux]
+
+ '@img/sharp-libvips-linux-s390x@1.3.2':
+ resolution: {integrity: sha512-/ABshyj8gCpyIrNXnHn4LorDJ0HHm1VhXPBlxZ8zAtfVPAaSafXPGn+sUSIRiwaSBy0mmFjSjiXI5mkcwdChKQ==}
+ cpu: [s390x]
+ os: [linux]
+
+ '@img/sharp-libvips-linux-x64@1.3.2':
+ resolution: {integrity: sha512-ITPEtgffGJ0S6G9dRyw/366tJQqFRcHWPHhC+Stpg3Z8AEMrDrTr2lhdz4f/Y/HMbRh//7Z5mBzEpVdi62Oc3w==}
+ cpu: [x64]
+ os: [linux]
+
+ '@img/sharp-libvips-linuxmusl-arm64@1.3.2':
+ resolution: {integrity: sha512-zE9EdiUzUmg5mDT5a1rk5fYJ6GWPloTwWBYDS14naqHsL+EaMpDj1AWnpLgh3u0YCORv2Tt50wrcrpYqkP97Kw==}
+ cpu: [arm64]
+ os: [linux]
+
+ '@img/sharp-libvips-linuxmusl-x64@1.3.2':
+ resolution: {integrity: sha512-m0lrLiUt+lBYnCFr8qV/65yMR4E/c7/wf78I5eKTdkEakFAlZ9QlzEM3QIhhAwVeUhLAHLcCq7a7Vszq/oFNZQ==}
+ cpu: [x64]
+ os: [linux]
+
+ '@img/sharp-linux-arm64@0.35.3':
+ resolution: {integrity: sha512-QgKDspHPnrU+GQ55XPhGwyhC8acLVOOSyAvo1oVfFmrIXLkDNmGWzAfDZ4xK8oSA1qBQrALcHX0G5UZni/SuFQ==}
+ engines: {node: '>=20.9.0'}
+ cpu: [arm64]
+ os: [linux]
+
+ '@img/sharp-linux-arm@0.35.3':
+ resolution: {integrity: sha512-affVWCTLooy8TSxbDx2qkzuDeaWLNVBA+P//FNBirHsXpP2fuBhk5AuboYUnrDnzoXes8GFjpTx0SBFOCRg+FA==}
+ engines: {node: '>=20.9.0'}
+ cpu: [arm]
+ os: [linux]
+
+ '@img/sharp-linux-ppc64@0.35.3':
+ resolution: {integrity: sha512-sMd8rDxmpLOwv/7N44klFjOD5DUO7FLdjiXDI0hoxYaf7Ar262dQIEkosE98bps+5HPLtp/EvNqeqQtOycP/IA==}
+ engines: {node: '>=20.9.0'}
+ cpu: [ppc64]
+ os: [linux]
+
+ '@img/sharp-linux-riscv64@0.35.3':
+ resolution: {integrity: sha512-0Eob78yjlYPfL5vMNWAW55l3R9Y6BQS/gOfe0ZcP9mEz9ohhKSt4im1hayiknXgf8AWrFqMvJcKIdmLmEe7yeQ==}
+ engines: {node: '>=20.9.0'}
+ cpu: [riscv64]
+ os: [linux]
+
+ '@img/sharp-linux-s390x@0.35.3':
+ resolution: {integrity: sha512-KgAxQ0DxpNOq1rG2t5cgTgShJFGSuU7XO45cqC+1NVOuZnP6tlgZRuSYOfNupGkHID0o3cJOsw4DVeJpMovcGw==}
+ engines: {node: '>=20.9.0'}
+ cpu: [s390x]
+ os: [linux]
+
+ '@img/sharp-linux-x64@0.35.3':
+ resolution: {integrity: sha512-8pqvxubL2PGdhlPy6GLqzDYMUjyRmKAwKHYKixpdJYBUK7PJ0C029XdsnpFIdgRZG68fZiGdHVWcKPvtiPB4cA==}
+ engines: {node: '>=20.9.0'}
+ cpu: [x64]
+ os: [linux]
+
+ '@img/sharp-linuxmusl-arm64@0.35.3':
+ resolution: {integrity: sha512-Vz0iQjzzcSX3HCbfwFfCSG/9SCIqyO0mH2sXyiHaAYfBk0cRsCWXRyQYX0ovCK/PAQBbTzQ0dsPQHh5MAFL59w==}
+ engines: {node: '>=20.9.0'}
+ cpu: [arm64]
+ os: [linux]
+
+ '@img/sharp-linuxmusl-x64@0.35.3':
+ resolution: {integrity: sha512-6O1NPKcDVj9QEdg7Hx549EX8U0rp6yXQERqru6yRN7fGBn32UvIRJUlWnk+8xDCiG76hXVBbX82NZ/ZKr0euIg==}
+ engines: {node: '>=20.9.0'}
+ cpu: [x64]
+ os: [linux]
+
+ '@img/sharp-wasm32@0.35.3':
+ resolution: {integrity: sha512-cZ0XkcYGpHZkqW6iCkqTcmUC0CD9DhD5d/qeZlZkfRBn6GnHniZXLUo5+9xw8Iv76YE6LQFN9YNBlKREcCG76w==}
+ engines: {node: '>=20.9.0'}
+
+ '@img/sharp-webcontainers-wasm32@0.35.3':
+ resolution: {integrity: sha512-2rnq7bX3NzeR2T4YWgz8qiG4h3TSdMe+vN1iQXpJleSJ3SM5zQ8Fy2SyyXAWlbxpEZ2Y+Z4u1BePgJEYbSy80Q==}
+ engines: {node: '>=20.9.0'}
+ cpu: [wasm32]
+
+ '@img/sharp-win32-arm64@0.35.3':
+ resolution: {integrity: sha512-4bPwFdMbeC4JQ8L8LOyWp6nsHcboP5fxkp6iPOXz2Vg49R42TuMs2whkJ5OAP4/Ul035qOzy0AecOF9VOscn4w==}
+ engines: {node: '>=20.9.0'}
+ cpu: [arm64]
+ os: [win32]
+
+ '@img/sharp-win32-ia32@0.35.3':
+ resolution: {integrity: sha512-r53mXsBN6lFUDiST764SvgwUdHAqM4rPAiDzAmf4fLoB6X/rkfyTrLCg6+g17wJJiCmB3JYgHuUldCWUIRFSXw==}
+ engines: {node: ^20.9.0}
+ cpu: [ia32]
+ os: [win32]
+
+ '@img/sharp-win32-x64@0.35.3':
+ resolution: {integrity: sha512-D4y1vNeZrIIJCN+uHaWVtH86B+aCrdMYYjicy9pXHvbGZeGYLLSd3wdVuC37FxVXlU1ARsk84eKWfWMXGYEqvA==}
+ engines: {node: '>=20.9.0'}
+ cpu: [x64]
+ os: [win32]
+
+ '@mermaid-js/parser@1.2.0':
+ resolution: {integrity: sha512-oYPyv8A4As1yH5Bx+04iQEQxXuIQDe0GKCNSRgao6z8AM9jixXIfP0vsppRLvGf+nKIOb9/LdpWA4YuJiVvESA==}
+
+ '@types/d3-array@3.2.2':
+ resolution: {integrity: sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==}
+
+ '@types/d3-axis@3.0.6':
+ resolution: {integrity: sha512-pYeijfZuBd87T0hGn0FO1vQ/cgLk6E1ALJjfkC0oJ8cbwkZl3TpgS8bVBLZN+2jjGgg38epgxb2zmoGtSfvgMw==}
+
+ '@types/d3-brush@3.0.6':
+ resolution: {integrity: sha512-nH60IZNNxEcrh6L1ZSMNA28rj27ut/2ZmI3r96Zd+1jrZD++zD3LsMIjWlvg4AYrHn/Pqz4CF3veCxGjtbqt7A==}
+
+ '@types/d3-chord@3.0.6':
+ resolution: {integrity: sha512-LFYWWd8nwfwEmTZG9PfQxd17HbNPksHBiJHaKuY1XeqscXacsS2tyoo6OdRsjf+NQYeB6XrNL3a25E3gH69lcg==}
+
+ '@types/d3-color@3.1.3':
+ resolution: {integrity: sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==}
+
+ '@types/d3-contour@3.0.6':
+ resolution: {integrity: sha512-BjzLgXGnCWjUSYGfH1cpdo41/hgdWETu4YxpezoztawmqsvCeep+8QGfiY6YbDvfgHz/DkjeIkkZVJavB4a3rg==}
+
+ '@types/d3-delaunay@6.0.4':
+ resolution: {integrity: sha512-ZMaSKu4THYCU6sV64Lhg6qjf1orxBthaC161plr5KuPHo3CNm8DTHiLw/5Eq2b6TsNP0W0iJrUOFscY6Q450Hw==}
+
+ '@types/d3-dispatch@3.0.7':
+ resolution: {integrity: sha512-5o9OIAdKkhN1QItV2oqaE5KMIiXAvDWBDPrD85e58Qlz1c1kI/J0NcqbEG88CoTwJrYe7ntUCVfeUl2UJKbWgA==}
+
+ '@types/d3-drag@3.0.7':
+ resolution: {integrity: sha512-HE3jVKlzU9AaMazNufooRJ5ZpWmLIoc90A37WU2JMmeq28w1FQqCZswHZ3xR+SuxYftzHq6WU6KJHvqxKzTxxQ==}
+
+ '@types/d3-dsv@3.0.7':
+ resolution: {integrity: sha512-n6QBF9/+XASqcKK6waudgL0pf/S5XHPPI8APyMLLUHd8NqouBGLsU8MgtO7NINGtPBtk9Kko/W4ea0oAspwh9g==}
+
+ '@types/d3-ease@3.0.2':
+ resolution: {integrity: sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==}
+
+ '@types/d3-fetch@3.0.7':
+ resolution: {integrity: sha512-fTAfNmxSb9SOWNB9IoG5c8Hg6R+AzUHDRlsXsDZsNp6sxAEOP0tkP3gKkNSO/qmHPoBFTxNrjDprVHDQDvo5aA==}
+
+ '@types/d3-force@3.0.10':
+ resolution: {integrity: sha512-ZYeSaCF3p73RdOKcjj+swRlZfnYpK1EbaDiYICEEp5Q6sUiqFaFQ9qgoshp5CzIyyb/yD09kD9o2zEltCexlgw==}
+
+ '@types/d3-format@3.0.4':
+ resolution: {integrity: sha512-fALi2aI6shfg7vM5KiR1wNJnZ7r6UuggVqtDA+xiEdPZQwy/trcQaHnwShLuLdta2rTymCNpxYTiMZX/e09F4g==}
+
+ '@types/d3-geo@3.1.1':
+ resolution: {integrity: sha512-65Emv9fQiQQqphLlRkuQ5ypPsOmWPhtBGCMv61JDPEPMvsx+gzhGf74yw1a78xFKPj6zw4AgQICJoQv0vK9M2w==}
+
+ '@types/d3-hierarchy@3.1.7':
+ resolution: {integrity: sha512-tJFtNoYBtRtkNysX1Xq4sxtjK8YgoWUNpIiUee0/jHGRwqvzYxkq0hGVbbOGSz+JgFxxRu4K8nb3YpG3CMARtg==}
+
+ '@types/d3-interpolate@3.0.4':
+ resolution: {integrity: sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==}
+
+ '@types/d3-path@3.1.1':
+ resolution: {integrity: sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==}
+
+ '@types/d3-polygon@3.0.2':
+ resolution: {integrity: sha512-ZuWOtMaHCkN9xoeEMr1ubW2nGWsp4nIql+OPQRstu4ypeZ+zk3YKqQT0CXVe/PYqrKpZAi+J9mTs05TKwjXSRA==}
+
+ '@types/d3-quadtree@3.0.6':
+ resolution: {integrity: sha512-oUzyO1/Zm6rsxKRHA1vH0NEDG58HrT5icx/azi9MF1TWdtttWl0UIUsjEQBBh+SIkrpd21ZjEv7ptxWys1ncsg==}
+
+ '@types/d3-random@3.0.4':
+ resolution: {integrity: sha512-UHYId5WTCx4L4YNel7NU00XUXXgvgpgZOvp10PuvsQENjMDXhh2RyFc0KBjO7B45ne4Ha1yVH7ii0vnzKkuzWA==}
+
+ '@types/d3-scale-chromatic@3.1.0':
+ resolution: {integrity: sha512-iWMJgwkK7yTRmWqRB5plb1kadXyQ5Sj8V/zYlFGMUBbIPKQScw+Dku9cAAMgJG+z5GYDoMjWGLVOvjghDEFnKQ==}
+
+ '@types/d3-scale@4.0.9':
+ resolution: {integrity: sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==}
+
+ '@types/d3-selection@3.0.11':
+ resolution: {integrity: sha512-bhAXu23DJWsrI45xafYpkQ4NtcKMwWnAC/vKrd2l+nxMFuvOT3XMYTIj2opv8vq8AO5Yh7Qac/nSeP/3zjTK0w==}
+
+ '@types/d3-shape@3.1.8':
+ resolution: {integrity: sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w==}
+
+ '@types/d3-time-format@4.0.3':
+ resolution: {integrity: sha512-5xg9rC+wWL8kdDj153qZcsJ0FWiFt0J5RB6LYUNZjwSnesfblqrI/bJ1wBdJ8OQfncgbJG5+2F+qfqnqyzYxyg==}
+
+ '@types/d3-time@3.0.4':
+ resolution: {integrity: sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==}
+
+ '@types/d3-timer@3.0.2':
+ resolution: {integrity: sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==}
+
+ '@types/d3-transition@3.0.9':
+ resolution: {integrity: sha512-uZS5shfxzO3rGlu0cC3bjmMFKsXv+SmZZcgp0KD22ts4uGXp5EVYGzu/0YdwZeKmddhcAccYtREJKkPfXkZuCg==}
+
+ '@types/d3-zoom@3.0.8':
+ resolution: {integrity: sha512-iqMC4/YlFCSlO8+2Ii1GGGliCAY4XdeG748w5vQUbevlbDu0zSjH/+jojorQVBK/se0j6DUFNPBGSqD3YWYnDw==}
+
+ '@types/d3@7.4.3':
+ resolution: {integrity: sha512-lZXZ9ckh5R8uiFVt8ogUNf+pIrK4EsWrx2Np75WvF/eTpJ0FMHNhjXk8CKEx/+gpHbNQyJWehbFaTvqmHWB3ww==}
+
+ '@types/geojson@7946.0.16':
+ resolution: {integrity: sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==}
+
+ '@types/trusted-types@2.0.7':
+ resolution: {integrity: sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==}
+
+ '@upsetjs/venn.js@2.0.0':
+ resolution: {integrity: sha512-WbBhLrooyePuQ1VZxrJjtLvTc4NVfpOyKx0sKqioq9bX1C1m7Jgykkn8gLrtwumBioXIqam8DLxp88Adbue6Hw==}
+
+ commander@7.2.0:
+ resolution: {integrity: sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw==}
+ engines: {node: '>= 10'}
+
+ commander@8.3.0:
+ resolution: {integrity: sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww==}
+ engines: {node: '>= 12'}
+
+ cose-base@1.0.3:
+ resolution: {integrity: sha512-s9whTXInMSgAp/NVXVNuVxVKzGH2qck3aQlVHxDCdAEPgtMKwc4Wq6/QKhgdEdgbLSi9rBTAcPoRa6JpiG4ksg==}
+
+ cose-base@2.2.0:
+ resolution: {integrity: sha512-AzlgcsCbUMymkADOJtQm3wO9S3ltPfYOFD5033keQn9NJzIbtnZj+UdBJe7DYml/8TdbtHJW3j58SOnKhWY/5g==}
+
+ cytoscape-cose-bilkent@4.1.0:
+ resolution: {integrity: sha512-wgQlVIUJF13Quxiv5e1gstZ08rnZj2XaLHGoFMYXz7SkNfCDOOteKBE6SYRfA9WxxI/iBc3ajfDoc6hb/MRAHQ==}
+ peerDependencies:
+ cytoscape: ^3.2.0
+
+ cytoscape-fcose@2.2.0:
+ resolution: {integrity: sha512-ki1/VuRIHFCzxWNrsshHYPs6L7TvLu3DL+TyIGEsRcvVERmxokbf5Gdk7mFxZnTdiGtnA4cfSmjZJMviqSuZrQ==}
+ peerDependencies:
+ cytoscape: ^3.2.0
+
+ cytoscape@3.34.0:
+ resolution: {integrity: sha512-62rNSrioXw93uliKFBwjukeQyeWwH2PqDrTac31r2P6464u3AUvTk0xS4LVvT251g7IgkFunrI48ZEZGjywSOg==}
+ engines: {node: '>=0.10'}
+
+ d3-array@2.12.1:
+ resolution: {integrity: sha512-B0ErZK/66mHtEsR1TkPEEkwdy+WDesimkM5gpZr5Dsg54BiTA5RXtYW5qTLIAcekaS9xfZrzBLF/OAkB3Qn1YQ==}
+
+ d3-array@3.2.4:
+ resolution: {integrity: sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==}
+ engines: {node: '>=12'}
+
+ d3-axis@3.0.0:
+ resolution: {integrity: sha512-IH5tgjV4jE/GhHkRV0HiVYPDtvfjHQlQfJHs0usq7M30XcSBvOotpmH1IgkcXsO/5gEQZD43B//fc7SRT5S+xw==}
+ engines: {node: '>=12'}
+
+ d3-brush@3.0.0:
+ resolution: {integrity: sha512-ALnjWlVYkXsVIGlOsuWH1+3udkYFI48Ljihfnh8FZPF2QS9o+PzGLBslO0PjzVoHLZ2KCVgAM8NVkXPJB2aNnQ==}
+ engines: {node: '>=12'}
+
+ d3-chord@3.0.1:
+ resolution: {integrity: sha512-VE5S6TNa+j8msksl7HwjxMHDM2yNK3XCkusIlpX5kwauBfXuyLAtNg9jCp/iHH61tgI4sb6R/EIMWCqEIdjT/g==}
+ engines: {node: '>=12'}
+
+ d3-color@3.1.0:
+ resolution: {integrity: sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==}
+ engines: {node: '>=12'}
+
+ d3-contour@4.0.2:
+ resolution: {integrity: sha512-4EzFTRIikzs47RGmdxbeUvLWtGedDUNkTcmzoeyg4sP/dvCexO47AaQL7VKy/gul85TOxw+IBgA8US2xwbToNA==}
+ engines: {node: '>=12'}
+
+ d3-delaunay@6.0.4:
+ resolution: {integrity: sha512-mdjtIZ1XLAM8bm/hx3WwjfHt6Sggek7qH043O8KEjDXN40xi3vx/6pYSVTwLjEgiXQTbvaouWKynLBiUZ6SK6A==}
+ engines: {node: '>=12'}
+
+ d3-dispatch@3.0.1:
+ resolution: {integrity: sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg==}
+ engines: {node: '>=12'}
+
+ d3-drag@3.0.0:
+ resolution: {integrity: sha512-pWbUJLdETVA8lQNJecMxoXfH6x+mO2UQo8rSmZ+QqxcbyA3hfeprFgIT//HW2nlHChWeIIMwS2Fq+gEARkhTkg==}
+ engines: {node: '>=12'}
+
+ d3-dsv@3.0.1:
+ resolution: {integrity: sha512-UG6OvdI5afDIFP9w4G0mNq50dSOsXHJaRE8arAS5o9ApWnIElp8GZw1Dun8vP8OyHOZ/QJUKUJwxiiCCnUwm+Q==}
+ engines: {node: '>=12'}
+ hasBin: true
+
+ d3-ease@3.0.1:
+ resolution: {integrity: sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==}
+ engines: {node: '>=12'}
+
+ d3-fetch@3.0.1:
+ resolution: {integrity: sha512-kpkQIM20n3oLVBKGg6oHrUchHM3xODkTzjMoj7aWQFq5QEM+R6E4WkzT5+tojDY7yjez8KgCBRoj4aEr99Fdqw==}
+ engines: {node: '>=12'}
+
+ d3-force@3.0.0:
+ resolution: {integrity: sha512-zxV/SsA+U4yte8051P4ECydjD/S+qeYtnaIyAs9tgHCqfguma/aAQDjo85A9Z6EKhBirHRJHXIgJUlffT4wdLg==}
+ engines: {node: '>=12'}
+
+ d3-format@3.1.2:
+ resolution: {integrity: sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==}
+ engines: {node: '>=12'}
+
+ d3-geo@3.1.1:
+ resolution: {integrity: sha512-637ln3gXKXOwhalDzinUgY83KzNWZRKbYubaG+fGVuc/dxO64RRljtCTnf5ecMyE1RIdtqpkVcq0IbtU2S8j2Q==}
+ engines: {node: '>=12'}
+
+ d3-hierarchy@3.1.2:
+ resolution: {integrity: sha512-FX/9frcub54beBdugHjDCdikxThEqjnR93Qt7PvQTOHxyiNCAlvMrHhclk3cD5VeAaq9fxmfRp+CnWw9rEMBuA==}
+ engines: {node: '>=12'}
+
+ d3-interpolate@3.0.1:
+ resolution: {integrity: sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==}
+ engines: {node: '>=12'}
+
+ d3-path@1.0.9:
+ resolution: {integrity: sha512-VLaYcn81dtHVTjEHd8B+pbe9yHWpXKZUC87PzoFmsFrJqgFwDe/qxfp5MlfsfM1V5E/iVt0MmEbWQ7FVIXh/bg==}
+
+ d3-path@3.1.0:
+ resolution: {integrity: sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==}
+ engines: {node: '>=12'}
+
+ d3-polygon@3.0.1:
+ resolution: {integrity: sha512-3vbA7vXYwfe1SYhED++fPUQlWSYTTGmFmQiany/gdbiWgU/iEyQzyymwL9SkJjFFuCS4902BSzewVGsHHmHtXg==}
+ engines: {node: '>=12'}
+
+ d3-quadtree@3.0.1:
+ resolution: {integrity: sha512-04xDrxQTDTCFwP5H6hRhsRcb9xxv2RzkcsygFzmkSIOJy3PeRJP7sNk3VRIbKXcog561P9oU0/rVH6vDROAgUw==}
+ engines: {node: '>=12'}
+
+ d3-random@3.0.1:
+ resolution: {integrity: sha512-FXMe9GfxTxqd5D6jFsQ+DJ8BJS4E/fT5mqqdjovykEB2oFbTMDVdg1MGFxfQW+FBOGoB++k8swBrgwSHT1cUXQ==}
+ engines: {node: '>=12'}
+
+ d3-sankey@0.12.3:
+ resolution: {integrity: sha512-nQhsBRmM19Ax5xEIPLMY9ZmJ/cDvd1BG3UVvt5h3WRxKg5zGRbvnteTyWAbzeSvlh3tW7ZEmq4VwR5mB3tutmQ==}
+
+ d3-scale-chromatic@3.1.0:
+ resolution: {integrity: sha512-A3s5PWiZ9YCXFye1o246KoscMWqf8BsD9eRiJ3He7C9OBaxKhAd5TFCdEx/7VbKtxxTsu//1mMJFrEt572cEyQ==}
+ engines: {node: '>=12'}
+
+ d3-scale@4.0.2:
+ resolution: {integrity: sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==}
+ engines: {node: '>=12'}
+
+ d3-selection@3.0.0:
+ resolution: {integrity: sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==}
+ engines: {node: '>=12'}
+
+ d3-shape@1.3.7:
+ resolution: {integrity: sha512-EUkvKjqPFUAZyOlhY5gzCxCeI0Aep04LwIRpsZ/mLFelJiUfnK56jo5JMDSE7yyP2kLSb6LtF+S5chMk7uqPqw==}
+
+ d3-shape@3.2.0:
+ resolution: {integrity: sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==}
+ engines: {node: '>=12'}
+
+ d3-time-format@4.1.0:
+ resolution: {integrity: sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==}
+ engines: {node: '>=12'}
+
+ d3-time@3.1.0:
+ resolution: {integrity: sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==}
+ engines: {node: '>=12'}
+
+ d3-timer@3.0.1:
+ resolution: {integrity: sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==}
+ engines: {node: '>=12'}
+
+ d3-transition@3.0.1:
+ resolution: {integrity: sha512-ApKvfjsSR6tg06xrL434C0WydLr7JewBB3V+/39RMHsaXTOG0zmt/OAXeng5M5LBm0ojmxJrpomQVZ1aPvBL4w==}
+ engines: {node: '>=12'}
+ peerDependencies:
+ d3-selection: 2 - 3
+
+ d3-zoom@3.0.0:
+ resolution: {integrity: sha512-b8AmV3kfQaqWAuacbPuNbL6vahnOJflOhexLzMMNLga62+/nh0JzvJ0aO/5a5MVgUFGS7Hu1P9P03o3fJkDCyw==}
+ engines: {node: '>=12'}
+
+ d3@7.9.0:
+ resolution: {integrity: sha512-e1U46jVP+w7Iut8Jt8ri1YsPOvFpg46k+K8TpCb0P+zjCkjkPnV7WzfDJzMHy1LnA+wj5pLT1wjO901gLXeEhA==}
+ engines: {node: '>=12'}
+
+ dagre-d3-es@7.0.14:
+ resolution: {integrity: sha512-P4rFMVq9ESWqmOgK+dlXvOtLwYg0i7u0HBGJER0LZDJT2VHIPAMZ/riPxqJceWMStH5+E61QxFra9kIS3AqdMg==}
+
+ dayjs@1.11.21:
+ resolution: {integrity: sha512-98IT+HOahAisibz/yjKbzuOBwYcjJ7BCLPzARyHiyEBmRz4fatF+KPJszEHXsGYjUG234aH/cOjW1wwTbKUZlA==}
+
+ delaunator@5.1.0:
+ resolution: {integrity: sha512-AGrQ4QSgssa1NGmWmLPqN5NY2KajF5MqxetNEO+o0n3ZwZZeTmt7bBnvzHWrmkZFxGgr4HdyFgelzgi06otLuQ==}
+
+ detect-libc@2.1.2:
+ resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==}
+ engines: {node: '>=8'}
+
+ dompurify@3.4.13:
+ resolution: {integrity: sha512-2vmYIoqjze2d+kakP8S/nS5shfsl587kzwEjcGlTdiksUVgFHnFCsLYDVj/JNqJVOQZGSYBTmuycv0PodwmnMQ==}
+
+ es-toolkit@1.50.0:
+ resolution: {integrity: sha512-OyZKhUVvEep9ITEiwHn8GKnMRQIVqoSIX7WnRbkWgJkllCujilqP2rD0u979tkl8wqyc8ICwlc1UBVv/Sl1G6w==}
+
+ fsevents@2.3.2:
+ resolution: {integrity: sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==}
+ engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
+ os: [darwin]
+
+ hachure-fill@0.5.2:
+ resolution: {integrity: sha512-3GKBOn+m2LX9iq+JC1064cSFprJY4jL1jCXTcpnfER5HYE2l/4EfWSGzkPa/ZDBmYI0ZOEj5VHV/eKnPGkHuOg==}
+
+ iconv-lite@0.6.3:
+ resolution: {integrity: sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==}
+ engines: {node: '>=0.10.0'}
+
+ import-meta-resolve@4.2.0:
+ resolution: {integrity: sha512-Iqv2fzaTQN28s/FwZAoFq0ZSs/7hMAHJVX+w8PZl3cY19Pxk6jFFalxQoIfW2826i/fDLXv8IiEZRIT0lDuWcg==}
+
+ internmap@1.0.1:
+ resolution: {integrity: sha512-lDB5YccMydFBtasVtxnZ3MRBHuaoE8GKsppq+EchKL2U4nK/DmEpPHNH8MZe5HkMtpSiTSOZwfN0tzYjO/lJEw==}
+
+ internmap@2.0.3:
+ resolution: {integrity: sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==}
+ engines: {node: '>=12'}
+
+ katex@0.16.47:
+ resolution: {integrity: sha512-Eeo8Ys1doU1z+x8AZsPpQu+p/QcZBI5PeOo7QGQdy2x2m0MU/hYagBbGOmXwr5KVbEfVuWv9LpnQWeehogurjg==}
+ hasBin: true
+
+ khroma@2.1.0:
+ resolution: {integrity: sha512-Ls993zuzfayK269Svk9hzpeGUKob/sIgZzyHYdjQoAdQetRKpOLj+k/QQQ/6Qi0Yz65mlROrfd+Ev+1+7dz9Kw==}
+
+ layout-base@1.0.2:
+ resolution: {integrity: sha512-8h2oVEZNktL4BH2JCOI90iD1yXwL6iNW7KcCKT2QZgQJR2vbqDsldCTPRU9NifTCqHZci57XvQQ15YTu+sTYPg==}
+
+ layout-base@2.0.1:
+ resolution: {integrity: sha512-dp3s92+uNI1hWIpPGH3jK2kxE2lMjdXdr+DH8ynZHpd6PUlH6x6cbuXnoMmiNumznqaNO31xu9e79F0uuZ0JFg==}
+
+ lodash-es@4.18.1:
+ resolution: {integrity: sha512-J8xewKD/Gk22OZbhpOVSwcs60zhd95ESDwezOFuA3/099925PdHJ7OFHNTGtajL3AlZkykD32HykiMo+BIBI8A==}
+
+ marked@16.4.2:
+ resolution: {integrity: sha512-TI3V8YYWvkVf3KJe1dRkpnjs68JUPyEa5vjKrp1XEEJUAOaQc+Qj+L1qWbPd0SJuAdQkFU0h73sXXqwDYxsiDA==}
+ engines: {node: '>= 20'}
+ hasBin: true
+
+ marked@18.0.9:
+ resolution: {integrity: sha512-/Sa4qiiHZxf0/FQdBBowr9q4r10krCwMvpK48FUBdXdUXScDxiQGR9zCPrFgRVR5LU3iySOiIjy09ZQvADir1w==}
+ engines: {node: '>= 20'}
+ hasBin: true
+
+ mermaid@11.16.1:
+ resolution: {integrity: sha512-TQsq6u22fAn3rek5VOubrhKPo1g5hwC3FXUN9hiyupTckcYiGuuKGkNQrKYwGJkXUxZdojwRG46gsSCFZMDp4g==}
+
+ package-manager-detector@1.8.0:
+ resolution: {integrity: sha512-yQA4H19AmPEoMUeavPMDIe1higySl/gH/yaQrkT/s07Qp+7pp2hYz30N3z2l5BkjVkF9Ow6o0wjJamm2y7Sn0A==}
+
+ path-data-parser@0.1.0:
+ resolution: {integrity: sha512-NOnmBpt5Y2RWbuv0LMzsayp3lVylAHLPUTut412ZA3l+C4uw4ZVkQbjShYCQ8TCpUMdPapr4YjUqLYD6v68j+w==}
+
+ playwright-core@1.62.1:
+ resolution: {integrity: sha512-wPYSwEBJY9GHraISXqyqtx0na0LpO3XEX7jNDhntbex7tzUS7kLnZsOlFruFJB4Hi/rhDMjXGqHewDZ68nYZVw==}
+ engines: {node: '>=20'}
+ hasBin: true
+
+ playwright@1.62.1:
+ resolution: {integrity: sha512-0M+L3LAD8/nm554LOla9Ayx0j0tmFZ0FBcoQ7F1VuVHpM/XpiC8RcDzBQB8W5+hA8L22THxELzeF+2WcUzvcLg==}
+ engines: {node: '>=20'}
+ hasBin: true
+
+ points-on-curve@0.2.0:
+ resolution: {integrity: sha512-0mYKnYYe9ZcqMCWhUjItv/oHjvgEsfKvnUTg8sAtnHr3GVy7rGkXCb6d5cSyqrWqL4k81b9CPg3urd+T7aop3A==}
+
+ points-on-path@0.2.1:
+ resolution: {integrity: sha512-25ClnWWuw7JbWZcgqY/gJ4FQWadKxGWk+3kR/7kD0tCaDtPPMj7oHu2ToLaVhfpnHrZzYby2w6tUA0eOIuUg8g==}
+
+ robust-predicates@3.0.3:
+ resolution: {integrity: sha512-NS3levdsRIUOmiJ8FZWCP7LG3QpJyrs/TE0Zpf1yvZu8cAJJ6QMW92H1c7kWpdIHo8RvmLxN/o2JXTKHp74lUA==}
+
+ roughjs@4.6.6:
+ resolution: {integrity: sha512-ZUz/69+SYpFN/g/lUlo2FXcIjRkSu3nDarreVdGGndHEBJ6cXPdKguS8JGxwj5HA5xIbVKSmLgr5b3AWxtRfvQ==}
+
+ rw@1.3.3:
+ resolution: {integrity: sha512-PdhdWy89SiZogBLaw42zdeqtRJ//zFd2PgQavcICDUgJT5oW10QCRKbJ6bg4r0/UY2M6BWd5tkxuGFRvCkgfHQ==}
+
+ safer-buffer@2.1.2:
+ resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==}
+
+ semver@7.8.5:
+ resolution: {integrity: sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==}
+ engines: {node: '>=10'}
+ hasBin: true
+
+ sharp@0.35.3:
+ resolution: {integrity: sha512-ej0zVHuZGHCiABXcNxeYhpRnPNPAcvbG8RMdBAhDAxLKkCRVSpK3Iyu7qbqw3JMzoj0REeM6f3tJLtVwl0023Q==}
+ engines: {node: '>=20.9.0'}
+ peerDependencies:
+ '@types/node': '*'
+ peerDependenciesMeta:
+ '@types/node':
+ optional: true
+
+ stylis@4.4.0:
+ resolution: {integrity: sha512-5Z9ZpRzfuH6l/UAvCPAPUo3665Nk2wLaZU3x+TLHKVzIz33+sbJqbtrYoC3KD4/uVOr2Zp+L0LySezP9OHV9yA==}
+
+ tinyexec@1.3.0:
+ resolution: {integrity: sha512-QKAl9m8gWWGHV8jZcPeym6j+XULi6tOf1mT83WYJ4Lk2ytW/uwAWkrP0uFsdoYMdueVJ0qs26wZ+23xeB4ibNQ==}
+ engines: {node: '>=18'}
+
+ ts-dedent@2.3.0:
+ resolution: {integrity: sha512-JfJeIHke7y2egdGGgRAvpCwYFUsHlM2gPcrVOxFkznt/4uzQ7HFmvE63iFHVLBJNDuyDOQgijDK/tXH/f6Msjg==}
+ engines: {node: '>=6.10'}
+
+ tslib@2.8.1:
+ resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==}
+
+ uuid@14.0.1:
+ resolution: {integrity: sha512-6ZxzVpzDXDa3bJWaHilVayA+BH/1zmxCJoVgvmqJnid/gPoKHxUrS/aC/T6LGQtNHT+XHG9fXPJB4d+IrU30Ew==}
+ hasBin: true
+
+snapshots:
+
+ '@antfu/install-pkg@1.1.0':
+ dependencies:
+ package-manager-detector: 1.8.0
+ tinyexec: 1.3.0
+
+ '@braintree/sanitize-url@7.1.2': {}
+
+ '@chevrotain/types@11.1.2': {}
+
+ '@emnapi/runtime@1.11.3':
+ dependencies:
+ tslib: 2.8.1
+ optional: true
+
+ '@iconify/types@2.0.0': {}
+
+ '@iconify/utils@3.1.4':
+ dependencies:
+ '@antfu/install-pkg': 1.1.0
+ '@iconify/types': 2.0.0
+ import-meta-resolve: 4.2.0
+
+ '@img/colour@1.1.0': {}
+
+ '@img/sharp-darwin-arm64@0.35.3':
+ optionalDependencies:
+ '@img/sharp-libvips-darwin-arm64': 1.3.2
+ optional: true
+
+ '@img/sharp-darwin-x64@0.35.3':
+ optionalDependencies:
+ '@img/sharp-libvips-darwin-x64': 1.3.2
+ optional: true
+
+ '@img/sharp-freebsd-wasm32@0.35.3':
+ dependencies:
+ '@img/sharp-wasm32': 0.35.3
+ optional: true
+
+ '@img/sharp-libvips-darwin-arm64@1.3.2':
+ optional: true
+
+ '@img/sharp-libvips-darwin-x64@1.3.2':
+ optional: true
+
+ '@img/sharp-libvips-linux-arm64@1.3.2':
+ optional: true
+
+ '@img/sharp-libvips-linux-arm@1.3.2':
+ optional: true
+
+ '@img/sharp-libvips-linux-ppc64@1.3.2':
+ optional: true
+
+ '@img/sharp-libvips-linux-riscv64@1.3.2':
+ optional: true
+
+ '@img/sharp-libvips-linux-s390x@1.3.2':
+ optional: true
+
+ '@img/sharp-libvips-linux-x64@1.3.2':
+ optional: true
+
+ '@img/sharp-libvips-linuxmusl-arm64@1.3.2':
+ optional: true
+
+ '@img/sharp-libvips-linuxmusl-x64@1.3.2':
+ optional: true
+
+ '@img/sharp-linux-arm64@0.35.3':
+ optionalDependencies:
+ '@img/sharp-libvips-linux-arm64': 1.3.2
+ optional: true
+
+ '@img/sharp-linux-arm@0.35.3':
+ optionalDependencies:
+ '@img/sharp-libvips-linux-arm': 1.3.2
+ optional: true
+
+ '@img/sharp-linux-ppc64@0.35.3':
+ optionalDependencies:
+ '@img/sharp-libvips-linux-ppc64': 1.3.2
+ optional: true
+
+ '@img/sharp-linux-riscv64@0.35.3':
+ optionalDependencies:
+ '@img/sharp-libvips-linux-riscv64': 1.3.2
+ optional: true
+
+ '@img/sharp-linux-s390x@0.35.3':
+ optionalDependencies:
+ '@img/sharp-libvips-linux-s390x': 1.3.2
+ optional: true
+
+ '@img/sharp-linux-x64@0.35.3':
+ optionalDependencies:
+ '@img/sharp-libvips-linux-x64': 1.3.2
+ optional: true
+
+ '@img/sharp-linuxmusl-arm64@0.35.3':
+ optionalDependencies:
+ '@img/sharp-libvips-linuxmusl-arm64': 1.3.2
+ optional: true
+
+ '@img/sharp-linuxmusl-x64@0.35.3':
+ optionalDependencies:
+ '@img/sharp-libvips-linuxmusl-x64': 1.3.2
+ optional: true
+
+ '@img/sharp-wasm32@0.35.3':
+ dependencies:
+ '@emnapi/runtime': 1.11.3
+ optional: true
+
+ '@img/sharp-webcontainers-wasm32@0.35.3':
+ dependencies:
+ '@img/sharp-wasm32': 0.35.3
+ optional: true
+
+ '@img/sharp-win32-arm64@0.35.3':
+ optional: true
+
+ '@img/sharp-win32-ia32@0.35.3':
+ optional: true
+
+ '@img/sharp-win32-x64@0.35.3':
+ optional: true
+
+ '@mermaid-js/parser@1.2.0':
+ dependencies:
+ '@chevrotain/types': 11.1.2
+
+ '@types/d3-array@3.2.2': {}
+
+ '@types/d3-axis@3.0.6':
+ dependencies:
+ '@types/d3-selection': 3.0.11
+
+ '@types/d3-brush@3.0.6':
+ dependencies:
+ '@types/d3-selection': 3.0.11
+
+ '@types/d3-chord@3.0.6': {}
+
+ '@types/d3-color@3.1.3': {}
+
+ '@types/d3-contour@3.0.6':
+ dependencies:
+ '@types/d3-array': 3.2.2
+ '@types/geojson': 7946.0.16
+
+ '@types/d3-delaunay@6.0.4': {}
+
+ '@types/d3-dispatch@3.0.7': {}
+
+ '@types/d3-drag@3.0.7':
+ dependencies:
+ '@types/d3-selection': 3.0.11
+
+ '@types/d3-dsv@3.0.7': {}
+
+ '@types/d3-ease@3.0.2': {}
+
+ '@types/d3-fetch@3.0.7':
+ dependencies:
+ '@types/d3-dsv': 3.0.7
+
+ '@types/d3-force@3.0.10': {}
+
+ '@types/d3-format@3.0.4': {}
+
+ '@types/d3-geo@3.1.1':
+ dependencies:
+ '@types/geojson': 7946.0.16
+
+ '@types/d3-hierarchy@3.1.7': {}
+
+ '@types/d3-interpolate@3.0.4':
+ dependencies:
+ '@types/d3-color': 3.1.3
+
+ '@types/d3-path@3.1.1': {}
+
+ '@types/d3-polygon@3.0.2': {}
+
+ '@types/d3-quadtree@3.0.6': {}
+
+ '@types/d3-random@3.0.4': {}
+
+ '@types/d3-scale-chromatic@3.1.0': {}
+
+ '@types/d3-scale@4.0.9':
+ dependencies:
+ '@types/d3-time': 3.0.4
+
+ '@types/d3-selection@3.0.11': {}
+
+ '@types/d3-shape@3.1.8':
+ dependencies:
+ '@types/d3-path': 3.1.1
+
+ '@types/d3-time-format@4.0.3': {}
+
+ '@types/d3-time@3.0.4': {}
+
+ '@types/d3-timer@3.0.2': {}
+
+ '@types/d3-transition@3.0.9':
+ dependencies:
+ '@types/d3-selection': 3.0.11
+
+ '@types/d3-zoom@3.0.8':
+ dependencies:
+ '@types/d3-interpolate': 3.0.4
+ '@types/d3-selection': 3.0.11
+
+ '@types/d3@7.4.3':
+ dependencies:
+ '@types/d3-array': 3.2.2
+ '@types/d3-axis': 3.0.6
+ '@types/d3-brush': 3.0.6
+ '@types/d3-chord': 3.0.6
+ '@types/d3-color': 3.1.3
+ '@types/d3-contour': 3.0.6
+ '@types/d3-delaunay': 6.0.4
+ '@types/d3-dispatch': 3.0.7
+ '@types/d3-drag': 3.0.7
+ '@types/d3-dsv': 3.0.7
+ '@types/d3-ease': 3.0.2
+ '@types/d3-fetch': 3.0.7
+ '@types/d3-force': 3.0.10
+ '@types/d3-format': 3.0.4
+ '@types/d3-geo': 3.1.1
+ '@types/d3-hierarchy': 3.1.7
+ '@types/d3-interpolate': 3.0.4
+ '@types/d3-path': 3.1.1
+ '@types/d3-polygon': 3.0.2
+ '@types/d3-quadtree': 3.0.6
+ '@types/d3-random': 3.0.4
+ '@types/d3-scale': 4.0.9
+ '@types/d3-scale-chromatic': 3.1.0
+ '@types/d3-selection': 3.0.11
+ '@types/d3-shape': 3.1.8
+ '@types/d3-time': 3.0.4
+ '@types/d3-time-format': 4.0.3
+ '@types/d3-timer': 3.0.2
+ '@types/d3-transition': 3.0.9
+ '@types/d3-zoom': 3.0.8
+
+ '@types/geojson@7946.0.16': {}
+
+ '@types/trusted-types@2.0.7':
+ optional: true
+
+ '@upsetjs/venn.js@2.0.0':
+ optionalDependencies:
+ d3-selection: 3.0.0
+ d3-transition: 3.0.1(d3-selection@3.0.0)
+
+ commander@7.2.0: {}
+
+ commander@8.3.0: {}
+
+ cose-base@1.0.3:
+ dependencies:
+ layout-base: 1.0.2
+
+ cose-base@2.2.0:
+ dependencies:
+ layout-base: 2.0.1
+
+ cytoscape-cose-bilkent@4.1.0(cytoscape@3.34.0):
+ dependencies:
+ cose-base: 1.0.3
+ cytoscape: 3.34.0
+
+ cytoscape-fcose@2.2.0(cytoscape@3.34.0):
+ dependencies:
+ cose-base: 2.2.0
+ cytoscape: 3.34.0
+
+ cytoscape@3.34.0: {}
+
+ d3-array@2.12.1:
+ dependencies:
+ internmap: 1.0.1
+
+ d3-array@3.2.4:
+ dependencies:
+ internmap: 2.0.3
+
+ d3-axis@3.0.0: {}
+
+ d3-brush@3.0.0:
+ dependencies:
+ d3-dispatch: 3.0.1
+ d3-drag: 3.0.0
+ d3-interpolate: 3.0.1
+ d3-selection: 3.0.0
+ d3-transition: 3.0.1(d3-selection@3.0.0)
+
+ d3-chord@3.0.1:
+ dependencies:
+ d3-path: 3.1.0
+
+ d3-color@3.1.0: {}
+
+ d3-contour@4.0.2:
+ dependencies:
+ d3-array: 3.2.4
+
+ d3-delaunay@6.0.4:
+ dependencies:
+ delaunator: 5.1.0
+
+ d3-dispatch@3.0.1: {}
+
+ d3-drag@3.0.0:
+ dependencies:
+ d3-dispatch: 3.0.1
+ d3-selection: 3.0.0
+
+ d3-dsv@3.0.1:
+ dependencies:
+ commander: 7.2.0
+ iconv-lite: 0.6.3
+ rw: 1.3.3
+
+ d3-ease@3.0.1: {}
+
+ d3-fetch@3.0.1:
+ dependencies:
+ d3-dsv: 3.0.1
+
+ d3-force@3.0.0:
+ dependencies:
+ d3-dispatch: 3.0.1
+ d3-quadtree: 3.0.1
+ d3-timer: 3.0.1
+
+ d3-format@3.1.2: {}
+
+ d3-geo@3.1.1:
+ dependencies:
+ d3-array: 3.2.4
+
+ d3-hierarchy@3.1.2: {}
+
+ d3-interpolate@3.0.1:
+ dependencies:
+ d3-color: 3.1.0
+
+ d3-path@1.0.9: {}
+
+ d3-path@3.1.0: {}
+
+ d3-polygon@3.0.1: {}
+
+ d3-quadtree@3.0.1: {}
+
+ d3-random@3.0.1: {}
+
+ d3-sankey@0.12.3:
+ dependencies:
+ d3-array: 2.12.1
+ d3-shape: 1.3.7
+
+ d3-scale-chromatic@3.1.0:
+ dependencies:
+ d3-color: 3.1.0
+ d3-interpolate: 3.0.1
+
+ d3-scale@4.0.2:
+ dependencies:
+ d3-array: 3.2.4
+ d3-format: 3.1.2
+ d3-interpolate: 3.0.1
+ d3-time: 3.1.0
+ d3-time-format: 4.1.0
+
+ d3-selection@3.0.0: {}
+
+ d3-shape@1.3.7:
+ dependencies:
+ d3-path: 1.0.9
+
+ d3-shape@3.2.0:
+ dependencies:
+ d3-path: 3.1.0
+
+ d3-time-format@4.1.0:
+ dependencies:
+ d3-time: 3.1.0
+
+ d3-time@3.1.0:
+ dependencies:
+ d3-array: 3.2.4
+
+ d3-timer@3.0.1: {}
+
+ d3-transition@3.0.1(d3-selection@3.0.0):
+ dependencies:
+ d3-color: 3.1.0
+ d3-dispatch: 3.0.1
+ d3-ease: 3.0.1
+ d3-interpolate: 3.0.1
+ d3-selection: 3.0.0
+ d3-timer: 3.0.1
+
+ d3-zoom@3.0.0:
+ dependencies:
+ d3-dispatch: 3.0.1
+ d3-drag: 3.0.0
+ d3-interpolate: 3.0.1
+ d3-selection: 3.0.0
+ d3-transition: 3.0.1(d3-selection@3.0.0)
+
+ d3@7.9.0:
+ dependencies:
+ d3-array: 3.2.4
+ d3-axis: 3.0.0
+ d3-brush: 3.0.0
+ d3-chord: 3.0.1
+ d3-color: 3.1.0
+ d3-contour: 4.0.2
+ d3-delaunay: 6.0.4
+ d3-dispatch: 3.0.1
+ d3-drag: 3.0.0
+ d3-dsv: 3.0.1
+ d3-ease: 3.0.1
+ d3-fetch: 3.0.1
+ d3-force: 3.0.0
+ d3-format: 3.1.2
+ d3-geo: 3.1.1
+ d3-hierarchy: 3.1.2
+ d3-interpolate: 3.0.1
+ d3-path: 3.1.0
+ d3-polygon: 3.0.1
+ d3-quadtree: 3.0.1
+ d3-random: 3.0.1
+ d3-scale: 4.0.2
+ d3-scale-chromatic: 3.1.0
+ d3-selection: 3.0.0
+ d3-shape: 3.2.0
+ d3-time: 3.1.0
+ d3-time-format: 4.1.0
+ d3-timer: 3.0.1
+ d3-transition: 3.0.1(d3-selection@3.0.0)
+ d3-zoom: 3.0.0
+
+ dagre-d3-es@7.0.14:
+ dependencies:
+ d3: 7.9.0
+ lodash-es: 4.18.1
+
+ dayjs@1.11.21: {}
+
+ delaunator@5.1.0:
+ dependencies:
+ robust-predicates: 3.0.3
+
+ detect-libc@2.1.2: {}
+
+ dompurify@3.4.13:
+ optionalDependencies:
+ '@types/trusted-types': 2.0.7
+
+ es-toolkit@1.50.0: {}
+
+ fsevents@2.3.2:
+ optional: true
+
+ hachure-fill@0.5.2: {}
+
+ iconv-lite@0.6.3:
+ dependencies:
+ safer-buffer: 2.1.2
+
+ import-meta-resolve@4.2.0: {}
+
+ internmap@1.0.1: {}
+
+ internmap@2.0.3: {}
+
+ katex@0.16.47:
+ dependencies:
+ commander: 8.3.0
+
+ khroma@2.1.0: {}
+
+ layout-base@1.0.2: {}
+
+ layout-base@2.0.1: {}
+
+ lodash-es@4.18.1: {}
+
+ marked@16.4.2: {}
+
+ marked@18.0.9: {}
+
+ mermaid@11.16.1:
+ dependencies:
+ '@braintree/sanitize-url': 7.1.2
+ '@iconify/utils': 3.1.4
+ '@mermaid-js/parser': 1.2.0
+ '@types/d3': 7.4.3
+ '@upsetjs/venn.js': 2.0.0
+ cytoscape: 3.34.0
+ cytoscape-cose-bilkent: 4.1.0(cytoscape@3.34.0)
+ cytoscape-fcose: 2.2.0(cytoscape@3.34.0)
+ d3: 7.9.0
+ d3-sankey: 0.12.3
+ dagre-d3-es: 7.0.14
+ dayjs: 1.11.21
+ dompurify: 3.4.13
+ es-toolkit: 1.50.0
+ katex: 0.16.47
+ khroma: 2.1.0
+ marked: 16.4.2
+ roughjs: 4.6.6
+ stylis: 4.4.0
+ ts-dedent: 2.3.0
+ uuid: 14.0.1
+
+ package-manager-detector@1.8.0: {}
+
+ path-data-parser@0.1.0: {}
+
+ playwright-core@1.62.1: {}
+
+ playwright@1.62.1:
+ dependencies:
+ playwright-core: 1.62.1
+ optionalDependencies:
+ fsevents: 2.3.2
+
+ points-on-curve@0.2.0: {}
+
+ points-on-path@0.2.1:
+ dependencies:
+ path-data-parser: 0.1.0
+ points-on-curve: 0.2.0
+
+ robust-predicates@3.0.3: {}
+
+ roughjs@4.6.6:
+ dependencies:
+ hachure-fill: 0.5.2
+ path-data-parser: 0.1.0
+ points-on-curve: 0.2.0
+ points-on-path: 0.2.1
+
+ rw@1.3.3: {}
+
+ safer-buffer@2.1.2: {}
+
+ semver@7.8.5: {}
+
+ sharp@0.35.3:
+ dependencies:
+ '@img/colour': 1.1.0
+ detect-libc: 2.1.2
+ semver: 7.8.5
+ optionalDependencies:
+ '@img/sharp-darwin-arm64': 0.35.3
+ '@img/sharp-darwin-x64': 0.35.3
+ '@img/sharp-freebsd-wasm32': 0.35.3
+ '@img/sharp-libvips-darwin-arm64': 1.3.2
+ '@img/sharp-libvips-darwin-x64': 1.3.2
+ '@img/sharp-libvips-linux-arm': 1.3.2
+ '@img/sharp-libvips-linux-arm64': 1.3.2
+ '@img/sharp-libvips-linux-ppc64': 1.3.2
+ '@img/sharp-libvips-linux-riscv64': 1.3.2
+ '@img/sharp-libvips-linux-s390x': 1.3.2
+ '@img/sharp-libvips-linux-x64': 1.3.2
+ '@img/sharp-libvips-linuxmusl-arm64': 1.3.2
+ '@img/sharp-libvips-linuxmusl-x64': 1.3.2
+ '@img/sharp-linux-arm': 0.35.3
+ '@img/sharp-linux-arm64': 0.35.3
+ '@img/sharp-linux-ppc64': 0.35.3
+ '@img/sharp-linux-riscv64': 0.35.3
+ '@img/sharp-linux-s390x': 0.35.3
+ '@img/sharp-linux-x64': 0.35.3
+ '@img/sharp-linuxmusl-arm64': 0.35.3
+ '@img/sharp-linuxmusl-x64': 0.35.3
+ '@img/sharp-webcontainers-wasm32': 0.35.3
+ '@img/sharp-win32-arm64': 0.35.3
+ '@img/sharp-win32-ia32': 0.35.3
+ '@img/sharp-win32-x64': 0.35.3
+
+ stylis@4.4.0: {}
+
+ tinyexec@1.3.0: {}
+
+ ts-dedent@2.3.0: {}
+
+ tslib@2.8.1:
+ optional: true
+
+ uuid@14.0.1: {}
diff --git a/.claude/skills/docs-bundle/scripts/build.mjs b/.claude/skills/docs-bundle/scripts/build.mjs
new file mode 100644
index 00000000..85eff287
--- /dev/null
+++ b/.claude/skills/docs-bundle/scripts/build.mjs
@@ -0,0 +1,460 @@
+#!/usr/bin/env node
+// Bundle every markdown file under docs/ into ONE self-contained HTML:
+// images re-encoded to webp and inlined as data URIs, mermaid diagrams
+// pre-rendered to inline SVG, cross-document links rewritten to anchors.
+//
+// Nothing is loaded at view time — no CDN, no fonts, no JS required to read
+// it. Hand the file to anyone; it works from a USB stick or an email
+// attachment, and prints to PDF cleanly.
+//
+// Usage: node scripts/build.mjs [--out FILE] [--no-mermaid] [--quality N]
+
+import fs from "node:fs/promises"
+import { existsSync } from "node:fs"
+import path from "node:path"
+import { fileURLToPath } from "node:url"
+import { execSync } from "node:child_process"
+import { marked } from "marked"
+import sharp from "sharp"
+
+const SKILL = path.dirname(path.dirname(fileURLToPath(import.meta.url)))
+const ROOT = path.resolve(SKILL, "..", "..", "..")
+const DOCS = path.join(ROOT, "docs")
+
+const args = process.argv.slice(2)
+const opt = (flag, def) => {
+ const i = args.indexOf(flag)
+ return i === -1 ? def : args[i + 1]
+}
+const OUT = path.resolve(
+ opt("--out", path.join(SKILL, "out", "hackagon-docs.html")),
+)
+const QUALITY = Number(opt("--quality", 78))
+const MAX_WIDTH = Number(opt("--max-width", 1400))
+const NO_MERMAID = args.includes("--no-mermaid")
+
+// Reading order. Anything in docs/ not listed here is appended alphabetically,
+// so a new doc is never silently dropped.
+const ORDER = [
+ "README.md",
+ "architecture.md",
+ "architecture-model.md",
+ "getting-started.md",
+ "user-flows.md",
+ "lifecycle.md",
+ "backend/services.md",
+ "backend/data-model.md",
+ "backend/rbac.md",
+ "frontend/routes-and-auth.md",
+ "frontend/grpc-clients.md",
+ "testing.md",
+ "requirements.md",
+ "roadmap.md",
+ "infrastructure.md",
+ "glossary.md",
+ "TODO.md",
+]
+
+const slug = (s) =>
+ s
+ .toLowerCase()
+ .replace(/[^\w\s-]/g, "")
+ .trim()
+ .replace(/\s+/g, "-")
+ .slice(0, 60)
+const docId = (rel) =>
+ "doc-" + rel.replace(/\.md$/, "").replace(/[/\\]/g, "-").toLowerCase()
+const esc = (s) =>
+ s.replace(/&/g, "&").replace(//g, ">")
+
+async function listDocs() {
+ const found = []
+ const walk = async (dir) => {
+ for (const e of await fs.readdir(dir, { withFileTypes: true })) {
+ const p = path.join(dir, e.name)
+ if (e.isDirectory()) await walk(p)
+ else if (e.name.endsWith(".md"))
+ found.push(path.relative(DOCS, p).replace(/\\/g, "/"))
+ }
+ }
+ await walk(DOCS)
+ const ordered = ORDER.filter((f) => found.includes(f))
+ const rest = found.filter((f) => !ORDER.includes(f)).sort()
+ if (rest.length)
+ console.log(` (not in ORDER, appended: ${rest.join(", ")})`)
+ return [...ordered, ...rest]
+}
+
+// ── images → webp data URIs ─────────────────────────────────────────────────
+const imgCache = new Map()
+let imgBytesIn = 0,
+ imgBytesOut = 0,
+ imgCount = 0,
+ imgMissing = 0
+
+async function inlineImage(src, fromDir) {
+ if (/^(https?:|data:)/.test(src)) return src // leave remote/inline alone
+ const abs = path.resolve(fromDir, src.split("#")[0].split("?")[0])
+ if (imgCache.has(abs)) return imgCache.get(abs)
+ if (!existsSync(abs)) {
+ console.warn(` ! missing image: ${path.relative(ROOT, abs)}`)
+ imgMissing++
+ return src
+ }
+ const input = await fs.readFile(abs)
+ imgBytesIn += input.length
+ let out, mime
+ if (/\.svg$/i.test(abs)) {
+ out = input // vector already; re-encoding would rasterize it
+ mime = "image/svg+xml"
+ } else {
+ out = await sharp(input)
+ .resize({ width: MAX_WIDTH, withoutEnlargement: true })
+ .webp({ quality: QUALITY })
+ .toBuffer()
+ mime = "image/webp"
+ }
+ imgBytesOut += out.length
+ imgCount++
+ const uri = `data:${mime};base64,${out.toString("base64")}`
+ imgCache.set(abs, uri)
+ return uri
+}
+
+async function inlineAllImages(html, fromDir) {
+ const srcs = [...html.matchAll(/]*?\ssrc=["']([^"']+)["']/gi)].map(
+ (m) => m[1],
+ )
+ for (const src of new Set(srcs)) {
+ const uri = await inlineImage(src, fromDir)
+ if (uri !== src)
+ html = html
+ .split(`src="${src}"`)
+ .join(`src="${uri}"`)
+ .split(`src='${src}'`)
+ .join(`src="${uri}"`)
+ }
+ return html
+}
+
+// ── mermaid theming ─────────────────────────────────────────────────────────
+// Same palette as the hand-laid C4 SVGs (dev/scripts/render-diagrams.mjs), so
+// the two diagram families read as one system. Mermaid renders at BUILD time
+// with fixed colours, so each block is rendered twice — light and dark — and
+// the page shows whichever matches the reader. Four diagram types are in use
+// (flowchart, sequence, ER, state); the variables below span all of them.
+const FONT = 'system-ui,-apple-system,"Segoe UI",sans-serif'
+const THEMES = {
+ light: {
+ background: "#faf9f6",
+ primaryColor: "#dbe7f4",
+ primaryTextColor: "#17171a",
+ primaryBorderColor: "#8fb2d6",
+ secondaryColor: "#f3ece0",
+ secondaryBorderColor: "#d3bf9e",
+ tertiaryColor: "#d9ece3",
+ tertiaryBorderColor: "#84b9a2",
+ lineColor: "#7b7972",
+ textColor: "#17171a",
+ mainBkg: "#dbe7f4",
+ nodeBorder: "#8fb2d6",
+ clusterBkg: "#f2f1ec",
+ clusterBorder: "#b6b4aa",
+ titleColor: "#17171a",
+ edgeLabelBackground: "#faf9f6",
+ labelBoxBkgColor: "#dbe7f4",
+ labelBoxBorderColor: "#8fb2d6",
+ actorBkg: "#2c4a63",
+ actorBorder: "#22394c",
+ actorTextColor: "#ffffff",
+ signalColor: "#4c4b48",
+ signalTextColor: "#17171a",
+ loopTextColor: "#17171a",
+ noteBkgColor: "#f3ece0",
+ noteBorderColor: "#d3bf9e",
+ noteTextColor: "#17171a",
+ activationBkgColor: "#dbe7f4",
+ activationBorderColor: "#8fb2d6",
+ sequenceNumberColor: "#ffffff",
+ attributeBackgroundColorOdd: "#faf9f6",
+ attributeBackgroundColorEven: "#f2f1ec",
+ fontFamily: FONT,
+ fontSize: "14px",
+ },
+ dark: {
+ darkMode: true,
+ background: "#121213",
+ primaryColor: "#1e2f42",
+ primaryTextColor: "#f2f1ec",
+ primaryBorderColor: "#3f6187",
+ secondaryColor: "#2c2519",
+ secondaryBorderColor: "#5a4a2c",
+ tertiaryColor: "#17322a",
+ tertiaryBorderColor: "#3d6b58",
+ lineColor: "#8d8c85",
+ textColor: "#f2f1ec",
+ mainBkg: "#1e2f42",
+ nodeBorder: "#3f6187",
+ clusterBkg: "#1b1c1a",
+ clusterBorder: "#4a4b47",
+ titleColor: "#f2f1ec",
+ edgeLabelBackground: "#121213",
+ labelBoxBkgColor: "#1e2f42",
+ labelBoxBorderColor: "#3f6187",
+ actorBkg: "#40607d",
+ actorBorder: "#5a7c9c",
+ actorTextColor: "#ffffff",
+ signalColor: "#c6c5be",
+ signalTextColor: "#f2f1ec",
+ loopTextColor: "#f2f1ec",
+ noteBkgColor: "#2c2519",
+ noteBorderColor: "#5a4a2c",
+ noteTextColor: "#f2f1ec",
+ activationBkgColor: "#1e2f42",
+ activationBorderColor: "#3f6187",
+ sequenceNumberColor: "#ffffff",
+ attributeBackgroundColorOdd: "#121213",
+ attributeBackgroundColorEven: "#1b1c1a",
+ fontFamily: FONT,
+ fontSize: "14px",
+ },
+}
+
+// ── mermaid → inline SVG (rendered once in a headless browser) ──────────────
+async function renderMermaid(blocks) {
+ if (!blocks.length || NO_MERMAID) return new Map()
+ let chromium, firefox
+ try {
+ ;({ chromium, firefox } = await import("playwright"))
+ } catch {
+ console.warn(" ! playwright not installed — mermaid blocks stay as code")
+ return new Map()
+ }
+ const mermaidJs = path.join(
+ SKILL,
+ "node_modules",
+ "mermaid",
+ "dist",
+ "mermaid.min.js",
+ )
+ if (!existsSync(mermaidJs)) {
+ console.warn(" ! mermaid package not found — blocks stay as code")
+ return new Map()
+ }
+ let browser
+ for (const launcher of [chromium, firefox]) {
+ try {
+ browser = await launcher.launch()
+ break
+ } catch {
+ /* try the next */
+ }
+ }
+ if (!browser) {
+ console.warn(
+ " ! no browser available to render mermaid — blocks stay as code",
+ )
+ return new Map()
+ }
+ const page = await browser.newPage()
+ await page.setContent("")
+ await page.addScriptTag({ path: mermaidJs })
+
+ const out = new Map()
+ for (const mode of ["light", "dark"]) {
+ await page.evaluate(
+ (vars) =>
+ window.mermaid.initialize({
+ startOnLoad: false,
+ theme: "base",
+ themeVariables: vars,
+ flowchart: { curve: "basis", padding: 14 },
+ sequence: { actorMargin: 46, boxMargin: 8, mirrorActors: false },
+ }),
+ THEMES[mode],
+ )
+ for (const [i, code] of blocks.entries()) {
+ try {
+ const svg = await page.evaluate(
+ async ([id, def]) => (await window.mermaid.render(id, def)).svg,
+ [`m${mode}${i}`, code], // distinct ids: both variants live in one page
+ )
+ const entry = out.get(code) ?? {}
+ entry[mode] = svg
+ out.set(code, entry)
+ } catch (e) {
+ if (mode === "light")
+ console.warn(
+ ` ! mermaid block ${i + 1} failed to render: ${String(e).split("\n")[0]}`,
+ )
+ }
+ }
+ }
+ await browser.close()
+ return out
+}
+
+// ── build ───────────────────────────────────────────────────────────────────
+console.log("── collecting docs")
+const files = await listDocs()
+console.log(` ${files.length} markdown files`)
+
+// pass 1: read, pull mermaid out so marked cannot mangle it
+const docs = []
+const mermaidBlocks = []
+for (const rel of files) {
+ let md = await fs.readFile(path.join(DOCS, rel), "utf8")
+ const mine = []
+ md = md.replace(/```mermaid\n([\s\S]*?)```/g, (_, code) => {
+ const token = `%%MERMAID_${mermaidBlocks.length}%%`
+ mermaidBlocks.push(code.trim())
+ mine.push(token)
+ return token
+ })
+ const title = md.match(/^#\s+(.+)$/m)?.[1]?.trim() ?? rel
+ docs.push({ rel, md, title, id: docId(rel) })
+}
+console.log(` ${mermaidBlocks.length} mermaid diagrams`)
+
+console.log("── rendering mermaid")
+const svgs = await renderMermaid(mermaidBlocks)
+console.log(` ${svgs.size}/${mermaidBlocks.length} rendered to inline SVG`)
+
+console.log("── rendering markdown + inlining images")
+marked.setOptions({ gfm: true, breaks: false })
+
+const linkTargets = new Map(docs.map((d) => [d.rel, d.id]))
+for (const doc of docs) {
+ let html = marked.parse(doc.md)
+
+ // heading ids + per-doc outline (h2 only — enough to navigate, not noise)
+ doc.outline = []
+ html = html.replace(/([\s\S]*?)<\/h\1>/g, (m, level, inner) => {
+ const text = inner.replace(/<[^>]+>/g, "").trim()
+ const id = `${doc.id}-${slug(text)}`
+ if (level === "2") doc.outline.push({ id, text })
+ return `${inner}#`
+ })
+
+ // Tables keep normal table layout and get their OWN scroll container —
+ // `display:block` on
is what made wide tables look cropped.
+ html = html
+ .replace(/
/g, '
')
+ .replace(/<\/table>/g, "
")
+
+ // cross-document links → in-page anchors
+ html = html.replace(/href="([^"]+\.md)(#[^"]*)?"/g, (m, target, frag) => {
+ const key = path.posix.normalize(
+ path.posix.join(path.posix.dirname(doc.rel), target),
+ )
+ const id = linkTargets.get(key) ?? linkTargets.get(target)
+ return id ? `href="#${id}"` : m
+ })
+
+ html = await inlineAllImages(html, path.join(DOCS, path.dirname(doc.rel)))
+
+ // mermaid placeholders → SVG (or a labelled code block if rendering failed)
+ html = html.replace(/%%MERMAID_(\d+)%%/g, (m, i) => {
+ const code = mermaidBlocks[Number(i)]
+ const v = svgs.get(code)
+ if (!v?.light)
+ return `