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 `
${esc(code)}
` + // Both themes ship; CSS picks one. Print gets the light variant. + return ( + `
` + + `${v.light}` + + (v.dark ? `${v.dark}` : "") + + `
` + ) + }) + + doc.html = html +} + +// ── assemble ──────────────────────────────────────────────────────────────── +let commit = "unknown" +try { + commit = execSync("git rev-parse --short HEAD", { cwd: ROOT }) + .toString() + .trim() +} catch {} +let branch = "" +try { + branch = execSync("git rev-parse --abbrev-ref HEAD", { cwd: ROOT }) + .toString() + .trim() +} catch {} + +const nav = docs + .map( + (d) => + `
  • ${esc(d.title)}` + + (d.outline.length + ? `` + : "") + + `
  • `, + ) + .join("\n") + +const body = docs + .map( + (d) => `
    +

    docs/${d.rel}

    + ${d.html} +
    `, + ) + .join("\n") + +// ── search index: one entry per h2 section, plain text ────────────────────── +const stripTags = (h) => + h + .replace(/<(script|style)[\s\S]*?<\/\1>/g, " ") + .replace(//g, " ") // diagrams carry no useful prose + .replace(/<[^>]+>/g, " ") + .replace(/&(nbsp|amp|lt|gt|quot|#39);/g, " ") + .replace(/\s+/g, " ") + .trim() + +const index = [] +for (const doc of docs) { + // split on the h2 boundaries we just tagged, so a hit lands on a section + const parts = doc.html.split(/(?=

    ]*>([\s\S]*?)<\/h2>/) + const text = stripTags(part) + if (!text) continue + index.push({ + d: doc.title, + i: m ? m[1] : doc.id, + h: m ? stripTags(m[2]).replace(/#$/, "").trim() : "", + t: text.slice(0, 4000), + }) + } +} + +const template = await fs.readFile( + path.join(SKILL, "scripts", "template.html"), + "utf8", +) +const html = template + .replace("{{NAV}}", nav) + .replace("{{BODY}}", body) + .replace("{{SEARCH_INDEX}}", JSON.stringify(index)) + .replace(/{{COMMIT}}/g, commit) + .replace(/{{BRANCH}}/g, branch) + .replace(/{{DATE}}/g, new Date().toISOString().slice(0, 10)) + .replace(/{{DOCCOUNT}}/g, String(docs.length)) + +await fs.mkdir(path.dirname(OUT), { recursive: true }) +await fs.writeFile(OUT, html) + +const kb = (n) => `${(n / 1024).toFixed(0)} KB` +console.log("") +console.log( + ` images ${imgCount} inlined, ${kb(imgBytesIn)} → ${kb(imgBytesOut)} webp` + + (imgMissing ? ` (${imgMissing} missing)` : ""), +) +console.log(` diagrams ${svgs.size} inline SVG`) +console.log( + ` output ${path.relative(ROOT, OUT)} ${kb((await fs.stat(OUT)).size)}`, +) diff --git a/.claude/skills/docs-bundle/scripts/template.html b/.claude/skills/docs-bundle/scripts/template.html new file mode 100644 index 00000000..097e0b6b --- /dev/null +++ b/.claude/skills/docs-bundle/scripts/template.html @@ -0,0 +1,891 @@ + + + + + + Hackagon — documentation + + + +
    + + +
    +
    +
    SDSC · Hackathon platform
    +

    Hackagon documentation

    +

    + Everything in docs/ as one file — architecture, data + model, RBAC, the frontend, the lifecycle, testing and the open work. + Images and diagrams are embedded; nothing is fetched when you open + it. +

    +
    + {{DOCCOUNT}} documents + {{BRANCH}} @ {{COMMIT}} + built {{DATE}} + offline · printable +
    +
    + {{BODY}} +
    +
    + + + + + + + diff --git a/.claude/skills/hackathon-e2e/.gitignore b/.claude/skills/hackathon-e2e/.gitignore new file mode 100644 index 00000000..4da3b954 --- /dev/null +++ b/.claude/skills/hackathon-e2e/.gitignore @@ -0,0 +1,11 @@ +node_modules/ +.state/ +.artifacts/ +test-results/ +playwright-report/ +# build-quality-report.mjs and embed-run-report.mjs stage their output beside +# itself and rename it into place, so these only exist while a build is in +# flight — or after one was killed mid-write, which is exactly when they must +# not read as a tracked change. +.quality-report.html.tmp-* +.recipe-player.html.tmp-* diff --git a/.claude/skills/hackathon-e2e/SKILL.md b/.claude/skills/hackathon-e2e/SKILL.md new file mode 100644 index 00000000..2969e7ad --- /dev/null +++ b/.claude/skills/hackathon-e2e/SKILL.md @@ -0,0 +1,465 @@ +--- +name: hackathon-e2e +description: Deterministic end-to-end testing of the full hackathon lifecycle. Boots the whole stack from scratch (Keycloak, Postgres, backend, frontend), then runs Playwright (Firefox) suites with a 15-person cast — admin, organizer, a 13-strong registration wave, capacity cut-off, waitlist, dropout, day-1 no-show, and a same-day walk-in — plus generated file-upload fixtures and a 309-action recipe (recipe.jsonl) with priority/outcome/gate triage. Runs inside the devcontainer by default (see the devcontainer-up skill). Also owns mutation testing (`mutations/`, `scripts/mutate.sh`): breaks the product on purpose and checks the suites notice, so a passing test is one that has been seen to go red. Use when asked to run e2e/browser tests, verify the hackathon lifecycle (publication → registration → teams → event → voting → post-event), smoke-test the platform, check which lifecycle RPCs the backend implements, or ask whether a test would actually catch a given bug. +--- + +# Hackathon lifecycle e2e testing + +Everything lives in this directory (`.claude/skills/hackathon-e2e/`) — scripts, +cast, Playwright config, and tests. No source file outside the skill is edited +by a run. It does write outside it: the stack's state is wiped +(`just clean::state`), the frontend is built (`components/frontend/build/`, logs +and pidfile under `.output/run/`), and the `docs` project writes `docs/flows/` +when `DOCS_SHOTS=1`. + +## How to run + +**Default: inside the devcontainer** (sibling skill `devcontainer-up`): + +```bash +bash .claude/skills/devcontainer-up/scripts/up.sh # once: container ready +bash .claude/skills/devcontainer-up/scripts/e2e.sh smoke # seeded-fixture suite +bash .claude/skills/devcontainer-up/scripts/e2e.sh journey # full lifecycle recipe +``` + +Direct (any Linux/WSL shell with the repo checked out — scripts re-exec inside +the Nix dev shell automatically): + +```bash +bash .claude/skills/hackathon-e2e/scripts/run.sh [smoke|journey|all|mobile|openreplay] \ + [--headed] [--grep

    ] [--no-reset] [--until-act ] +``` + +**Mobile battery**: `run.sh mobile` runs every surface (public home + event +page, dashboard, all member tabs, manage/users) at a 390×844 phone viewport — +asserting no horizontal overflow and no broken images — and drops a full-page +screenshot per page into `.artifacts/mobile/` for visual review. Fresh runs seed +the fixture; `--no-reset` runs it over whatever world is live (e.g. a journey +frozen at some act). + +It also owns the **layout** contracts (`helpers/reflow.ts`, shared by +`chrome-reflow.spec.ts` at 6 widths and `full-sweep.spec.ts` at 2 widths × every +route): nothing overflows sideways, nothing overlaps, no text is clipped, and +**the session-replay consent banner is on screen without scrolling and covers no +control once the document is at its end**. That last one needs the banner to +EXIST, so `run.sh mobile` adds a no-ingest `replay` block to the config overlay +when no rig has wired one, and removes it on exit — an assertion whose subject +is absent verifies nothing. + +**Freeze the world at a phase**: `run.sh journey --until-act 4` plays the story +up to (and including) act 4 and leaves the stack in exactly that state — browse +it at http://localhost:8081, or publicly via the sibling `cloudflare-tunnel` +skill (`up.sh` for anonymous viewing, `up.sh --with-auth` for logged-in browsing +through the tunnel) to inspect any page mid-lifecycle. Acts: 0 platform setup, 1 +publication, 2 registration, 3 proposals, 4 teams, 5 roster cut, 6 event days, 7 +voting, 8 post-event. (`--until-act` compares against each action's `act`, so +act 0 always plays.) + +**Tunnel login proof**: `tests/tunnel/login.spec.ts` (project `tunnel`) drives a +real login through the public tunnel URL — Keycloak on the same hostname, then +an authenticated dashboard load. Needs a login-capable tunnel up first; the spec +self-skips without `TUNNEL_BASE_URL`: + +```bash +bash .claude/skills/cloudflare-tunnel/scripts/up.sh --with-auth +TUNNEL_BASE_URL=https://.trycloudflare.com pnpm exec playwright test --project=tunnel +``` + +**Session-replay privacy proof**: `run.sh openreplay` seeds the same fixture as +smoke and runs `tests/openreplay/` — 7 tests that count BYTES ON THE WIRE rather +than reading a flag back. It needs a live OpenReplay (sibling skill +`openreplay-stack`) and `replay.enabled: true` in the frontend config; it +self-skips otherwise, and it is deliberately not part of `all`. + +Each run is **deterministic by default**: stop stack → wipe Postgres+Keycloak +(`just clean::state`) → boot via process-compose → wait for readiness → seed +(smoke/openreplay) / provision the extras roster (journey) → probe backend +capabilities → Playwright on Firefox. The stack is left running afterwards +(`just down` to stop). `pnpm install` and the Firefox download happen +automatically on first run. + +**The harness serves the frontend itself.** `wait-ready.sh` unconditionally +stops process-compose's `vite dev` and starts the adapter-node build on `:8081` +(`scripts/prod-frontend.sh`). Regenerating protos rewrites ~260 files under +`src/lib/server/grpc/generated/`, which invalidates that much of vite's +transform cache; `src/` is on the 9p bind mount, so the first SSR request took +**five minutes** (measured 2026-08-08) and process-compose's readiness probe +killed the process mid-warm-up — the log says +`readiness check fail - signal: killed`, which reads like a crash and is not +one. The built output has no transform step: smoke drops from 3.0 m to 1.4 m. + +"Unconditionally" is the load-bearing word. The earlier guard — _leave it alone +if anything answers within 5 s_ — handed the run to a cold vite whenever it +happened to reply in time, and left vite holding `[::1]:8081` against the built +server whenever it did not, so the identical command passed for one suite and +failed for the next. `:8081` and not `:8082` because Keycloak's `hackagon-dev` +client only allows redirect URIs on 8081; `:8082` belongs to the +cloudflare-tunnel skill's own built server, which is why everything here is +scoped to servers launched with `PORT=8081` — a blanket +`pkill -f build/service/index.js` also killed the tunnel's upstream, and nothing +ever restarted it. + +## The cast (15 people) + +Four **principals** (checked-in dev realm, browser sessions + API): + +| Persona | Role in the story | +| ---------------- | ------------------------------------------------------------------------- | +| `hackagon-admin` | Global admin/organizer — publishes, approves, removes, edits dates | +| `alice` | Organizer-to-be; approved participant in the journey | +| `bob` | The spotlight participant — every UI outcome is asserted through his eyes | +| `charles` | The unlucky one — registers, never gets off the waitlist | + +Eleven **extras** (`cast.json` — Dana Moser, Erik Lindqvist, Fatima Khoury, +Giulia Ricci, Hiro Tanaka, Ines Duarte, Jonas Weber, Katya Volkova, Liam +O'Brien, Mei Chen, and **Noor Haddad, the same-day walk-in** who skips the act-2 +wave and registers at the door in act 6), provisioned idempotently into Keycloak +by `scripts/roster.sh` (admin REST API). They self-register through the same +`UserService.Register` RPC the frontend uses and act via the API with real +tokens, so RBAC is exercised for all of them. The capacity screenplay lives in +`personas.ts` (`JOURNEY_CAST`): **13 registrations, capacity 8, one dropout, one +backfill, one no-show, one walk-in — final roster 13 (9 confirmed, 4 +waitlisted), 8 of 9 confirmed in teams.** + +## The suites + +Playwright projects, all Firefox, all serial (`workers: 1`, `retries: 0`). +`setup` is a dependency of every suite except `tunnel`; it logs each principal +in through the real Keycloak flow and saves a storage state. + +| Project | Database | Size (last green run) | +| ------------ | ------------------------------ | ---------------------------------------------------------------------------------- | +| `smoke` | seed fixture (`just db::seed`) | 76 tests across 16 spec files — **80 passed** with setup (2026-08-08) | +| `journey` | **empty**, never seeded | 308 recipe actions — **312 passed / 0 failed / 0 skipped** with setup (2026-08-08) | +| `mobile` | seed fixture (fresh runs) | 14 tests at 390×844 (2026-08-05) | +| `openreplay` | seed fixture | 7 tests — **11 passed** with setup (2026-08-08); self-skips without a live rig | +| `tunnel` | whatever is live | 5 tests; self-skip without `TUNNEL_BASE_URL` | +| `docs` | seed fixture | 1 test; self-skips without `DOCS_SHOTS=1` (writes `docs/flows/`) | + +**smoke** — snapshot mode. Verifies what each principal can see and do: public +vs private listing for anonymous visitors, login, dashboard contents + +membership badges, the full persona × hackathon member-view access matrix +(200/403/404), list views, the CMS pages, global-role and co-organizer grants, +nav centring, and a media upload that is read back. Plus the **new-user funnel** +(`05-new-user-funnel.spec.ts`): Keycloak self-registration → auto-login → +backend auto-registration → the dashboard Join button → Waitlisted badge. Its +actor is `SELF_REGISTRANT` in `personas.ts` — deliberately outside `PERSONAS` +and never provisioned by the realm import or `roster.sh`, because walking +through the signup form is the point. + +**journey** — the **full lifecycle as a data-driven screenplay**: +`recipe.jsonl`, one JSON action per line, executed strictly in order by +`tests/journey/recipe.spec.ts` via the engine in `helpers/recipe.ts`. The recipe +covers the complete hackathon **including voting** — **309 action lines** across +acts 0–8 — interleaving the participant story with a realistic mess of life: + +- **Filled-in forms that conform to the admin's schema**: 9 registration-form + responses use exactly the keys defined in act1.config.regform (affiliation/ + skills/diet/avatar-link + conduct/photos consents — Giulia declines photo + consent, which must flow to act-8 publication), plus two validation negatives + (missing required consent, unknown field) and Noor's paper form digitized by + the admin (`onBehalfOf`). Submission payloads carry the subform keys + (repo/demo/slides/summary); slides is `file-or-url`, and the recipe exercises + the link form. A blob store exists now (`StorageService`), but the + registration form still asks people for a LINK to their picture — there is no + avatar upload field. + +- **Everyone confirmed gets a team seat** (act 4): Matterhorn = bob, alice, + hiro, ines; Bernina = dana, erik, giulia, fatima — and team composition + cascades with the roster: fatima's dropout clears her Bernina seat, backfill + jonas takes it. +- **Day-1 check-in reality** (act 6): hiro is a NO-SHOW ("see you there!" and + never appears) — his Matterhorn seat is cleared but he stays a confirmed + participant; **walk-in** Noor Haddad (cast.json's 11th extra, not in the act-2 + wave) creates an account at the door, admin overrides the closed registration + window, approves her on the spot and slots her into the no-show's seat. + Roster: 13 on the list, 9 confirmed, 4 waitlisted. + +- **Per-event configuration (act 1, `ConfigService`)**: custom registration + form + consents, submission form, voting policy, email templates, branding, + and time windows — pinned as ONE configuration-engine design decision. +- **Time-window enforcement + manual override**: early/late registration, + post-deadline preferences and submissions all bounce (`FailedPrecondition`, + array-gated on ConfigService.SetWindows + the acting RPC); the admin extends + the submission window by 30 minutes (`OverrideWindow`) and the grace-period + submission is accepted. Note: window fields must time-travel together with the + event dates. +- **Ballots that are not just a tick** (act 7): five categories — three + single-choice, one RANKED, one POINTS with a 10-point budget. The negatives + are the point: a ranked ballot that skips rank 2 or names one project twice is + refused, a single-choice ballot cast into the ranked category is refused, and + a points ballot over budget is refused. `SuggestResults` computes the Borda + and points tallies. +- **Co-organizers** (act 5): the admin promotes Alice with `AddOwner`; a mere + member and a waitlisted person are both denied; an organizer cannot demote + themselves and the LAST organizer cannot be demoted at all. +- **The `HackathonState` façade** (act 5): the organizer flips capabilities + through main's boolean contract, a member cannot, and the switch goes back. +- **Media, uploaded for real** (act 8): the organizer presigns a gallery photo + upload, a member is denied one, and an SVG is refused outright. +- **Prize governance (`PrizeService`)**: prize table defined at publication; + after the vote the **admin has the final voice** — results are advisory until + `Finalize`; prizes stay admin-editable afterwards (sponsor credit edit) and + members are denied. +- **Admin "meanwhile" actions throughout** (138 of the 309 are the admin's): + identity checks, watching registrations arrive mid-wave, user-management + chains, a maintenance unlist/relist cycle, audit snapshots, live + announcements, logo refresh, post-event cleanup. +- **Edit cycles**: name typo published → noticed → fixed; reschedule; venue + change; description announcements; proposal/preference/team/submission edits. +- **Deletions & churn**: withdrawn proposal, created-then-deleted placeholder + team, participant removal (dropout), obsolete page cleanup, draft-event + deletion, and two never-approved registrants deleting their platform profiles + post-event (`UserService.DeleteAccount`). +- **Finale**: winners announced, photos page, and a final wrap-up blog entry + published by the admin. +- **Abandoned/incomplete actions**: a login form filled halfway and left, a + wrong-password recovery chain, a participant search typed and abandoned, an + unfinalized scratch submission. +- **Malformed/ghost negatives**: bad UUIDs → InvalidArgument, ghosts → NotFound, + double-approve idempotency, and permission negatives for every privileged + mutation. Anonymous callers get `Unauthenticated`, not `PermissionDenied` — a + status code is an answer, and "who are you" and "not you" are different + answers. + +| Act | Timeline | Actions | What it covers | +| --- | -------------------------- | ------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| 0 | before any event | 15 | platform setup: the admin drafts the About site page, the draft stays invisible, an organizer is denied (site pages need the _global_ Admin role), publish makes it world-readable, duplicate/invalid slugs rejected, a pasted ` + + diff --git a/.claude/skills/hackathon-e2e/recipe-player.html b/.claude/skills/hackathon-e2e/recipe-player.html new file mode 100644 index 00000000..3bae2e58 --- /dev/null +++ b/.claude/skills/hackathon-e2e/recipe-player.html @@ -0,0 +1,4036 @@ +Hackathon Lifecycle Player + + +

    +
    +
    +

    Hackathon Lifecycle Player

    + replaying recipe.jsonl — every action of one full hackathon, T-4 months → T+1 week +
    +
    + + + + + + ACT — press play + + +
    +
    + +
    +
    + + +
    + + Press ▶ to replay the full hackathon: publication, a 13-person registration wave, approvals and dropouts, event days, voting, and the archive. + + +
    +
    + + +
    + +
    + + + + + + 0 / 0 +
    +
    +
    +
    + +
    +

    +
    + + + + + + + + + + + +
    + + + + + + + diff --git a/.claude/skills/hackathon-e2e/recipe.jsonl b/.claude/skills/hackathon-e2e/recipe.jsonl new file mode 100644 index 00000000..3ea7b881 --- /dev/null +++ b/.claude/skills/hackathon-e2e/recipe.jsonl @@ -0,0 +1,477 @@ +{"comment": "════════ HACKAGON FULL-LIFECYCLE RECIPE ════════ One action per line, executed strictly in order by tests/journey/recipe.spec.ts (helpers/recipe.ts). Lines with a 'todo' are placeholders: they SKIP while their 'method' probes as unimplemented and start running the moment the backend lands it — keep the action, never delete it. Placeholders whose params reference protos that do not exist yet carry the guessed field names in-line; align them when the proto lands. Template tokens: {{hackathonId}} {{var:NAME}} {{userId:USERNAME}} {{now+Nd}}/{{now-Nd}} {{logoDataUri}}."} +{"comment": "── ACT 0 — platform setup: the site itself, before any hackathon exists ────────"} +{"id": "act0.about.absent", "priority": "P1", "implement": true, "outcome": "The About page 404s on a fresh platform - nothing has been published yet.", "act": 0, "t": "T-4mo", "title": "the platform starts blank: the footer About link leads nowhere yet", "actor": "anonymous", "action": "ui.flow", "steps": [{"goto": "/about", "status": 404}]} +{"id": "act0.about.create", "priority": "P1", "implement": true, "outcome": "The admin reaches the platform CMS by CLICKING from the dashboard and creates the About draft through the form; it lists with a Draft badge.", "act": 0, "t": "T-4mo", "title": "PLATFORM: admin writes the About page through the CMS (draft first)", "actor": "hackagon-admin", "action": "ui.flow", "steps": [{"goto": "/dashboard"}, {"clickLink": "Pages"}, {"expectUrl": "/manage/pages$"}, {"clickButton": "New page"}, {"fill": {"selector": "input[name='slug']", "value": "about"}}, {"fill": {"selector": "input[name='title']", "value": "About Hackagon"}}, {"fill": {"selector": "input[name='order']", "value": "1"}}, {"fill": {"selector": "textarea[name='content']", "value": "## What this is\n\nHackagon is the hackathon platform built by the **Swiss Data Science Center**.\n"}}, {"clickButton": "Create page"}, {"expectSelector": ".card:has(h2:text-is('About Hackagon')) .badge-warning"}], "todo": "/manage/pages was once linked from nowhere at all; page.goto would never have said so. Create through the browser, starting from a landing point."} +{"id": "act0.about.draft.hidden", "priority": "P1", "implement": true, "outcome": "Still 404 for visitors: an unpublished page is indistinguishable from a missing one.", "act": 0, "t": "T-4mo", "title": "the draft stays invisible to the public", "actor": "anonymous", "action": "ui.flow", "steps": [{"goto": "/about", "status": 404}]} +{"id": "act0.about.rogue", "priority": "P1", "implement": true, "outcome": "PermissionDenied - only platform admins write site pages.", "act": 0, "t": "T-4mo", "title": "DENIED: alice (an organizer, not a platform admin) tries to edit the About page", "actor": "alice", "action": "rpc", "method": "site.SitePageService/Edit", "params": {"slug": "about", "title": "Alice was here"}, "expect": {"error": "PermissionDenied"}} +{"id": "act0.about.anon", "priority": "P1", "implement": true, "outcome": "Unauthenticated - anonymous callers cannot write site pages.", "act": 0, "t": "T-4mo", "title": "DENIED: an anonymous caller tries to create a site page", "actor": "anonymous", "action": "rpc", "method": "site.SitePageService/Create", "params": {"slug": "pirate", "title": "Pirate page", "content": "nope"}, "expect": {"error": "Unauthenticated"}} +{"id": "act0.about.publish", "priority": "P1", "implement": true, "outcome": "The admin publishes the draft from the CMS card; the badge flips from Draft to Published.", "act": 0, "t": "T-4mo", "title": "PLATFORM: admin publishes the About page from the CMS", "actor": "hackagon-admin", "action": "ui.flow", "steps": [{"goto": "/manage/pages"}, {"clickButton": "Edit"}, {"clickSelector": "form[action='?/edit'] input[name='visible']"}, {"clickButton": "Save changes"}, {"expectSelector": ".card:has(h2:text-is('About Hackagon')) .badge-success"}], "todo": "The badge is the element that states the fact - page-wide 'Published' text also lives in the status filter's options, which would match before AND after."} +{"id": "act0.about.live", "priority": "P1", "implement": true, "outcome": "The About page is now readable by anyone, headline included.", "act": 0, "t": "T-4mo", "title": "anyone can now read About from the footer link", "actor": "anonymous", "action": "ui.flow", "steps": [{"goto": "/about"}, {"expectText": "About Hackagon"}, {"expectText": "Swiss Data Science Center"}]} +{"id": "act0.about.xss", "priority": "P1", "implement": true, "outcome": "Succeeds; the markdown is stored verbatim - sanitizing is the renderer's job, not the database's.", "act": 0, "t": "T-4mo", "title": "SECURITY: admin pastes markdown containing a script tag and an onerror handler", "actor": "hackagon-admin", "action": "rpc", "method": "site.SitePageService/Edit", "params": {"slug": "about", "content": "## About\n\nHackagon is built by the Swiss Data Science Center.\n\n\n\n\n"}, "expect": {"ok": true}} +{"id": "act0.about.sanitized", "priority": "P1", "implement": true, "outcome": "The page renders its text, and neither the script tag nor the onerror handler executes.", "act": 0, "t": "T-4mo", "title": "SECURITY: the script never runs - the markdown pipeline sanitizes it", "actor": "anonymous", "action": "ui.assert", "assert": "sitePageSanitized", "params": {"slug": "about", "textContains": "Swiss Data Science Center"}} +{"id": "act0.privacy.create", "priority": "P1", "implement": true, "outcome": "Succeeds; the Privacy page is published.", "act": 0, "t": "T-4mo", "title": "PLATFORM: admin publishes the Privacy page", "actor": "hackagon-admin", "action": "rpc", "method": "site.SitePageService/Create", "params": {"slug": "privacy", "title": "Privacy", "content": "## What we store\\n\\nAccount details from the login provider, and what you do on the platform.\\n", "visible": true, "order": 2}, "expect": {"ok": true}} +{"id": "act0.terms.create", "priority": "P1", "implement": true, "outcome": "Succeeds; the Terms page is published.", "act": 0, "t": "T-4mo", "title": "PLATFORM: admin publishes the Terms page", "actor": "hackagon-admin", "action": "rpc", "method": "site.SitePageService/Create", "params": {"slug": "terms", "title": "Terms of use", "content": "## Taking part\\n\\nFollow the rules and the code of conduct of each event.\\n", "visible": true, "order": 3}, "expect": {"ok": true}} +{"id": "act0.slug.dupe", "priority": "P1", "implement": true, "outcome": "AlreadyExists - slugs are unique because they are URLs.", "act": 0, "t": "T-4mo", "title": "DENIED: admin re-uses an existing slug", "actor": "hackagon-admin", "action": "rpc", "method": "site.SitePageService/Create", "params": {"slug": "about", "title": "About (again)", "content": "duplicate"}, "expect": {"error": "AlreadyExists"}} +{"id": "act0.slug.invalid", "priority": "P1", "implement": true, "outcome": "InvalidArgument - slugs must be lowercase kebab-case, they go straight into a URL.", "act": 0, "t": "T-4mo", "title": "DENIED: admin tries a slug with spaces and capitals", "actor": "hackagon-admin", "action": "rpc", "method": "site.SitePageService/Create", "params": {"slug": "Code Of Conduct", "title": "Code of conduct", "content": "be nice"}, "expect": {"error": "InvalidArgument"}} +{"id": "act0.footer.links", "priority": "P1", "implement": true, "outcome": "All three footer links resolve to real published pages.", "act": 0, "t": "T-4mo", "title": "the footer links (About, Privacy, Terms) all lead somewhere real", "actor": "anonymous", "action": "ui.flow", "steps": [{"goto": "/privacy"}, {"expectText": "What we store"}, {"goto": "/terms"}, {"expectText": "Taking part"}]} +{"id": "act0.ghost", "priority": "P1", "implement": true, "outcome": "NotFound - a slug nobody published does not resolve.", "act": 0, "t": "T-4mo", "title": "a slug that was never created stays a 404", "actor": "anonymous", "action": "rpc", "method": "site.SitePageService/Get", "params": {"slug": "does-not-exist"}, "expect": {"error": "NotFound"}} +{"comment": "── ACT 1 — T-4 months: PUBLICATION & ANNOUNCEMENT ──────────────────"} +{"id": "act1.guard", "priority": "P1", "implement": true, "outcome": "The public site shows no trace of the journey event (fresh database).", "act": 1, "t": "T-4mo", "title": "the world starts empty (from-scratch guard)", "action": "ui.assert", "assert": "worldEmpty", "params": {"name": "SDSC Open Research Data Hackathon 2027"}} +{"id": "act1.publish", "priority": "P1", "implement": true, "outcome": "Succeeds. Returns hackathonId for later steps.", "act": 1, "t": "T-4mo", "title": "admin publishes the hackathon (page goes live, theme/dates/capacity announced)", "actor": "hackagon-admin", "action": "rpc", "method": "hackathon.HackathonService/Create", "params": {"name": "SDSC Open Research Data Hackathon 2027", "description": "Two days of building open, reproducible research-data tooling with the Swiss scientific community — hosted by SDSC at EPFL, Lausanne. Tracks: Data Science and Research Data Infrastructure. Participation is free; registration is mandatory. Max capacity: 8 participants (pilot edition). Waitlisted registrations are confirmed by the organizers as spots open up. Call for project proposals opens today.", "visibility": "VISIBILITY_PUBLIC", "logo": "{{logoDataUri}}", "startsAt": "{{now+120d}}", "endsAt": "{{now+122d}}"}, "save": {"hackathonId": "hackathonId"}, "expect": {"ok": true}} +{"id": "act1.logo.presign", "priority": "P1", "implement": true, "outcome": "Succeeds - a presigned PUT and a server-chosen key come back.", "act": 1, "t": "T-4mo", "title": "STORAGE: organizer asks for an upload URL for the event logo", "actor": "hackagon-admin", "action": "rpc", "method": "storage.StorageService/CreateUploadUrl", "params": {"kind": "UPLOAD_KIND_HACKATHON_LOGO", "ownerId": "{{hackathonId}}", "filename": "logo.webp", "contentType": "image/webp", "sizeBytes": 98028}, "expect": {"ok": true}, "todo": "Nothing in the request names a path - the key is the server's to choose, so the worst a hostile caller can do is ask for a kind it may not write."} +{"id": "act1.logo.rogue", "priority": "P1", "implement": true, "outcome": "Rejected with PermissionDenied - uploading the event's logo needs hackathon Write.", "act": 1, "t": "T-4mo", "title": "STORAGE: a nobody cannot get an upload URL for someone else's event", "actor": "bob", "action": "rpc", "method": "storage.StorageService/CreateUploadUrl", "params": {"kind": "UPLOAD_KIND_HACKATHON_LOGO", "ownerId": "{{hackathonId}}", "filename": "logo.webp", "contentType": "image/webp", "sizeBytes": 1024}, "expect": {"error": "PermissionDenied"}} +{"id": "act1.logo.anon", "priority": "P1", "implement": true, "outcome": "Rejected with Unauthenticated.", "act": 1, "t": "T-4mo", "title": "STORAGE: an anonymous caller gets no upload URL", "actor": "anonymous", "action": "rpc", "method": "storage.StorageService/CreateUploadUrl", "params": {"kind": "UPLOAD_KIND_HACKATHON_LOGO", "ownerId": "{{hackathonId}}", "filename": "logo.webp", "contentType": "image/webp", "sizeBytes": 1024}, "expect": {"error": "Unauthenticated"}} +{"id": "act1.logo.svg", "priority": "P1", "implement": true, "outcome": "Rejected with InvalidArgument - SVG is excluded deliberately.", "act": 1, "t": "T-4mo", "title": "SECURITY: an SVG logo is refused (it would be script on our own origin)", "actor": "hackagon-admin", "action": "rpc", "method": "storage.StorageService/CreateUploadUrl", "params": {"kind": "UPLOAD_KIND_HACKATHON_LOGO", "ownerId": "{{hackathonId}}", "filename": "logo.svg", "contentType": "image/svg+xml", "sizeBytes": 2048}, "expect": {"error": "InvalidArgument"}, "todo": "/objects is served from the app's own origin, so a stored SVG runs as the application."} +{"id": "act1.logo.toobig", "priority": "P1", "implement": true, "outcome": "Rejected with InvalidArgument BEFORE any byte is transferred.", "act": 1, "t": "T-4mo", "title": "STORAGE: an oversized logo is refused at presign time, not after the upload", "actor": "hackagon-admin", "action": "rpc", "method": "storage.StorageService/CreateUploadUrl", "params": {"kind": "UPLOAD_KIND_HACKATHON_LOGO", "ownerId": "{{hackathonId}}", "filename": "huge.webp", "contentType": "image/webp", "sizeBytes": 52428800}, "expect": {"error": "InvalidArgument"}, "todo": "The presign is the only place a 4 GB upload can be refused before it is transferred rather than after."} +{"id": "act1.roundtrip", "priority": "P1", "implement": true, "outcome": "Succeeds; the stored logo (and name/description) round-trips byte-for-byte.", "act": 1, "t": "T-4mo", "title": "the announcement round-trips intact, including the generated PNG logo", "actor": "hackagon-admin", "action": "rpc", "method": "hackathon.HackathonService/Get", "params": {"hackathonId": "{{hackathonId}}"}, "expect": {"ok": true, "check": "logoRoundTrip", "checkArgs": {"nameContains": "Open Research Data", "descriptionContains": "Max capacity"}}} +{"id": "act1.config.regform", "priority": "P2", "implement": true, "outcome": "Succeeds.", "act": 1, "t": "T-4mo", "title": "CONFIG: admin defines the custom registration form (fields + consents)", "actor": "hackagon-admin", "action": "rpc", "method": "hackathon.ConfigService/SetRegistrationForm", "params": {"hackathonId": "{{hackathonId}}", "fields": [{"key": "affiliation", "label": "Affiliation", "type": "text", "required": true}, {"key": "skills", "label": "Skills", "type": "tags", "required": false}, {"key": "diet", "label": "Dietary requirements", "type": "text", "required": false}, {"key": "avatar", "label": "Profile picture (link)", "type": "url", "required": false}], "consents": [{"key": "conduct", "label": "I accept the Code of Conduct", "required": true}, {"key": "photos", "label": "I consent to event photography", "required": false}]}, "expect": {"ok": true}} +{"id": "act1.config.subform", "priority": "P2", "implement": true, "outcome": "Succeeds.", "act": 1, "t": "T-4mo", "title": "CONFIG: admin defines the submission form (repo required, demo, slides, size limits)", "actor": "hackagon-admin", "action": "rpc", "method": "hackathon.ConfigService/SetSubmissionForm", "params": {"hackathonId": "{{hackathonId}}", "fields": [{"key": "repo", "label": "Repository URL", "type": "url", "required": true}, {"key": "demo", "label": "Live demo URL", "type": "url", "required": false}, {"key": "slides", "label": "Slides (PDF) — upload or link", "type": "file-or-url", "maxMb": 20}, {"key": "summary", "label": "One-paragraph summary", "type": "text", "required": true}]}, "expect": {"ok": true}} +{"id": "act1.config.subform.url", "priority": "P2", "implement": true, "outcome": "Succeeds - the repo field is declared a url, not free text.", "act": 1, "t": "T-4mo", "title": "CONFIG: the submission form declares its link fields as URLs", "actor": "hackagon-admin", "action": "rpc", "method": "hackathon.ConfigService/SetSubmissionForm", "params": {"hackathonId": "{{hackathonId}}", "fields": [{"key": "repo", "label": "Repository", "type": "url", "required": true}, {"key": "demo", "label": "Live demo", "type": "url", "required": false}, {"key": "summary", "label": "One-paragraph summary", "type": "textarea", "required": true}]}, "expect": {"ok": true}, "todo": "The type was honoured for textarea and nothing else, so a url field rendered as a plain text box - no validation and no keyboard hint on a phone."} +{"id": "act1.config.voting", "priority": "P2", "implement": true, "outcome": "Succeeds.", "act": 1, "t": "T-4mo", "title": "CONFIG: admin sets the voting mechanism and tie-breaking", "actor": "hackagon-admin", "action": "rpc", "method": "hackathon.ConfigService/SetVotingPolicy", "params": {"hackathonId": "{{hackathonId}}", "mechanism": "points", "scale": {"min": 1, "max": 5}, "oneBallotPer": "member-category-submission", "ownTeamVoting": true, "organizerVoting": false, "tieBreak": ["highest-impact-category", "earliest-final-submission"]}, "expect": {"ok": true}} +{"id": "act1.config.emails", "priority": "P3", "implement": true, "outcome": "Succeeds.", "act": 1, "t": "T-4mo", "title": "CONFIG: admin sets the email templates (confirmation, assignment, deadlines, results)", "actor": "hackagon-admin", "action": "rpc", "method": "hackathon.ConfigService/SetEmailTemplates", "params": {"hackathonId": "{{hackathonId}}", "templates": {"registrationConfirmed": "You are on the list for {event} — you will hear from us when a spot opens.", "teamAssigned": "Welcome to {team}! Your project: {project}.", "deadlineReminder": "{window} closes in 48h.", "results": "The winners are out — see the results page."}}, "expect": {"ok": true}} +{"id": "act1.race.emails", "priority": "P2", "implement": true, "outcome": "Both concurrent SetEmailTemplates calls succeed - whole-record replace means last-writer-wins, silently.", "act": 1, "t": "T-4mo", "title": "RACE: two organizer sessions save the email templates at the same moment", "action": "rpc.race", "calls": [{"actor": "hackagon-admin", "method": "hackathon.ConfigService/SetEmailTemplates", "params": {"hackathonId": "{{hackathonId}}", "templates": {"registrationConfirmed": "Writer A: you are registered.", "teamAssigned": "Writer A: welcome to {team}.", "deadlineReminder": "Writer A: {window} closes soon.", "results": "Writer A: results are out."}}}, {"actor": "hackagon-admin", "method": "hackathon.ConfigService/SetEmailTemplates", "params": {"hackathonId": "{{hackathonId}}", "templates": {"registrationConfirmed": "Writer B: your spot is confirmed.", "teamAssigned": "Writer B: meet {team}.", "deadlineReminder": "Writer B: 48h left for {window}.", "results": "Writer B: winners announced."}}}], "race": {"ok": 2}, "todo": "Set* RPCs replace whole records, so a concurrent edit silently discards the other organizer's change. This pins that semantics - a future merge or conflict answer would (rightly) turn it red and force a decision."} +{"id": "act1.race.emails.check", "priority": "P2", "implement": true, "outcome": "The stored templates equal exactly ONE writer's payload - never a field-mix of both.", "act": 1, "t": "T-4mo", "title": "RACE: the surviving template set is one writer's, whole", "actor": "hackagon-admin", "action": "rpc", "method": "hackathon.ConfigService/GetEmailTemplates", "params": {"hackathonId": "{{hackathonId}}"}, "expect": {"ok": true, "check": "templatesOneOf", "checkArgs": {"candidates": [{"registrationConfirmed": "Writer A: you are registered.", "teamAssigned": "Writer A: welcome to {team}.", "deadlineReminder": "Writer A: {window} closes soon.", "results": "Writer A: results are out."}, {"registrationConfirmed": "Writer B: your spot is confirmed.", "teamAssigned": "Writer B: meet {team}.", "deadlineReminder": "Writer B: 48h left for {window}.", "results": "Writer B: winners announced."}]}}} +{"id": "act1.race.emails.restore", "priority": "P2", "implement": true, "outcome": "Succeeds - the canonical templates from act1.config.emails are back on file.", "act": 1, "t": "T-4mo", "title": "RACE: the organizer restores the intended templates", "actor": "hackagon-admin", "action": "rpc", "method": "hackathon.ConfigService/SetEmailTemplates", "params": {"hackathonId": "{{hackathonId}}", "templates": {"registrationConfirmed": "You are on the list for {event} — you will hear from us when a spot opens.", "teamAssigned": "Welcome to {team}! Your project: {project}.", "deadlineReminder": "{window} closes in 48h.", "results": "The winners are out — see the results page."}}, "expect": {"ok": true}} +{"id": "act1.config.branding", "priority": "P3", "implement": true, "outcome": "Succeeds.", "act": 1, "t": "T-4mo", "title": "CONFIG: admin sets the event branding (colors + visuals; logo already set at creation)", "actor": "hackagon-admin", "action": "rpc", "method": "hackathon.ConfigService/SetBranding", "params": {"hackathonId": "{{hackathonId}}", "primaryColor": "#0A7ACC", "accentColor": "#F5B83D", "bannerText": "Open Research Data Hackathon 2027"}, "expect": {"ok": true}} +{"id": "act1.config.windows", "priority": "P2", "implement": true, "outcome": "Succeeds.", "act": 1, "t": "T-4mo", "title": "CONFIG: admin sets the time windows (registration, proposals, preferences, submissions)", "actor": "hackagon-admin", "action": "rpc", "method": "hackathon.ConfigService/SetWindows", "params": {"hackathonId": "{{hackathonId}}", "registrationOpens": "{{now+7d}}", "registrationCloses": "{{now+113d}}", "proposalsClose": "{{now+60d}}", "preferencesClose": "{{now+80d}}", "submissionsClose": "{{now+123d}}", "latePolicy": "reject-without-override"}, "expect": {"ok": true}} +{"id": "act1.window.early", "priority": "P2", "implement": true, "outcome": "Rejected with FailedPrecondition - no state change.", "act": 1, "t": "T-4mo", "title": "ENFORCEMENT: bob tries to register before the registration window opens — bounced", "actor": "bob", "action": "rpc", "method": "hackathon.HackathonService/Join", "gate": "hackathon.ConfigService/SetWindows", "params": {"hackathonId": "{{hackathonId}}"}, "expect": {"error": "FailedPrecondition"}} +{"id": "act1.prizes", "priority": "P3", "implement": true, "outcome": "The prize table is defined through the Prizes form and saves; the page confirms with 'Saved.'", "act": 1, "t": "T-4mo", "title": "PRIZES: admin defines the prize table through the Prizes page (the admin has the final voice on prizes)", "actor": "hackagon-admin", "action": "ui.flow", "steps": [{"goto": "/my/hackathon/{{hackathonId}}/prizes"}, {"fill": {"selector": "input[name='rank'] >> nth=0", "value": "1"}}, {"fill": {"selector": "input[name='title'] >> nth=0", "value": "1st — CHF 5000 + SDSC mentoring"}}, {"clickButton": "Add prize"}, {"fill": {"selector": "input[name='rank'] >> nth=1", "value": "2"}}, {"fill": {"selector": "input[name='title'] >> nth=1", "value": "2nd — CHF 2000"}}, {"clickButton": "Add prize"}, {"fill": {"selector": "input[name='rank'] >> nth=2", "value": "0"}}, {"fill": {"selector": "input[name='title'] >> nth=2", "value": "Community Choice (discretionary, admin-awarded)"}}, {"clickButton": "Save prizes"}, {"expectText": "Saved."}], "todo": "Set replaces the whole table, which is why PrizeService.Get exists: a form that cannot prefill is destructive. This flow pins that the form is wired at all - act8.prizes.edit later edits what was saved here."} +{"id": "act1.admin.whoami", "priority": "P1", "implement": true, "outcome": "Succeeds.", "act": 1, "t": "T-4mo", "title": "MEANWHILE admin verifies their platform identity", "actor": "hackagon-admin", "action": "rpc", "method": "user.UserService/WhoAmI", "params": {}, "expect": {"ok": true}} +{"id": "act1.admin.users", "priority": "P1", "implement": true, "outcome": "Succeeds; the platform user list has at least 4 accounts.", "act": 1, "t": "T-4mo", "title": "MEANWHILE admin reviews the platform user list (principals registered)", "actor": "hackagon-admin", "action": "rpc", "method": "user.UserService/List", "params": {}, "expect": {"ok": true, "check": "usersCount", "checkArgs": {"atLeast": 4}}} +{"id": "act1.public", "priority": "P1", "implement": true, "outcome": "The public home lists 'SDSC Open Research Data Hackathon 2027' with the 'Upcoming' badge.", "act": 1, "t": "T-4mo", "title": "anonymous visitors see the event listed as Upcoming", "action": "ui.assert", "assert": "homeStatus", "params": {"name": "SDSC Open Research Data Hackathon 2027", "status": "Upcoming"}} +{"id": "act1.ui.cover", "priority": "P1", "implement": true, "outcome": "The home row renders the event's cover with real pixels (naturalWidth > 0), not a glyph fallback.", "act": 1, "t": "T-4mo", "title": "the announcement's artwork actually renders on the public home row", "action": "ui.assert", "assert": "homeRowCover", "params": {"name": "SDSC Open Research Data Hackathon 2027"}, "todo": "List rows once accepted a cover prop and never mounted it, and every suite stayed green because all assertions were text. Pixels, not markup."} +{"id": "act1.typo", "priority": "P1", "implement": true, "outcome": "Succeeds.", "act": 1, "t": "T-4mo", "title": "admin publishes a typo in the name…", "actor": "hackagon-admin", "action": "rpc", "method": "hackathon.HackathonService/Edit", "params": {"hackathonId": "{{hackathonId}}", "name": "SDSC Open Reserach Data Hackathon 2027"}, "expect": {"ok": true}} +{"id": "act1.typo.check", "priority": "P1", "implement": true, "outcome": "Succeeds; name is exactly 'SDSC Open Reserach Data Hackathon 2027'.", "act": 1, "t": "T-4mo", "title": "…the typo is live…", "actor": "hackagon-admin", "action": "rpc", "method": "hackathon.HackathonService/Get", "params": {"hackathonId": "{{hackathonId}}"}, "expect": {"ok": true, "check": "hackathonField", "checkArgs": {"nameEquals": "SDSC Open Reserach Data Hackathon 2027"}}} +{"id": "act1.typo.fix", "priority": "P1", "implement": true, "outcome": "Succeeds.", "act": 1, "t": "T-4mo", "title": "…admin notices and fixes the name", "actor": "hackagon-admin", "action": "rpc", "method": "hackathon.HackathonService/Edit", "params": {"hackathonId": "{{hackathonId}}", "name": "SDSC Open Research Data Hackathon 2027"}, "expect": {"ok": true}} +{"id": "act1.typo.fixed", "priority": "P1", "implement": true, "outcome": "Succeeds; name is exactly 'SDSC Open Research Data Hackathon 2027'.", "act": 1, "t": "T-4mo", "title": "the corrected name is live", "actor": "hackagon-admin", "action": "rpc", "method": "hackathon.HackathonService/Get", "params": {"hackathonId": "{{hackathonId}}"}, "expect": {"ok": true, "check": "hackathonField", "checkArgs": {"nameEquals": "SDSC Open Research Data Hackathon 2027"}}} +{"id": "act1.reschedule", "priority": "P1", "implement": true, "outcome": "Succeeds.", "act": 1, "t": "T-4mo", "title": "admin reschedules the event by two days (venue availability)", "actor": "hackagon-admin", "action": "rpc", "method": "hackathon.HackathonService/Edit", "params": {"hackathonId": "{{hackathonId}}", "startsAt": "{{now+122d}}", "endsAt": "{{now+124d}}"}, "expect": {"ok": true}} +{"id": "act1.venue", "priority": "P1", "implement": true, "outcome": "Succeeds.", "act": 1, "t": "T-4mo", "title": "admin updates the venue in the announcement", "actor": "hackagon-admin", "action": "rpc", "method": "hackathon.HackathonService/Edit", "params": {"hackathonId": "{{hackathonId}}", "description": "Two days of building open, reproducible research-data tooling with the Swiss scientific community — hosted by SDSC at the SwissTech Convention Center, EPFL, Lausanne. Tracks: Data Science and Research Data Infrastructure. Participation is free; registration is mandatory. Max capacity: 8 participants (pilot edition). Waitlisted registrations are confirmed by the organizers as spots open up. Call for project proposals opens today."}, "expect": {"ok": true}} +{"id": "act1.venue.check", "priority": "P1", "implement": true, "outcome": "Succeeds; description contains 'SwissTech'.", "act": 1, "t": "T-4mo", "title": "the venue change is live", "actor": "hackagon-admin", "action": "rpc", "method": "hackathon.HackathonService/Get", "params": {"hackathonId": "{{hackathonId}}"}, "expect": {"ok": true, "check": "hackathonField", "checkArgs": {"descriptionContains": "SwissTech"}}} +{"id": "act1.edit.rogue", "priority": "P1", "implement": true, "outcome": "Rejected with PermissionDenied - no state change.", "act": 1, "t": "T-4mo", "title": "a regular user cannot edit the event", "actor": "bob", "action": "rpc", "method": "hackathon.HackathonService/Edit", "params": {"hackathonId": "{{hackathonId}}", "name": "Bob's Hackathon Now"}, "expect": {"error": "PermissionDenied"}} +{"id": "act1.draft.create", "priority": "P1", "implement": true, "outcome": "Succeeds. Returns draftId for later steps.", "act": 1, "t": "T-4mo", "title": "MEANWHILE admin drafts a second, private event for next winter", "actor": "hackagon-admin", "action": "rpc", "method": "hackathon.HackathonService/Create", "params": {"name": "SDSC Winter School Sprint (draft)", "description": "Internal draft — do not announce yet.", "visibility": "VISIBILITY_PRIVATE", "startsAt": "{{now+300d}}", "endsAt": "{{now+302d}}"}, "save": {"draftId": "hackathonId"}, "expect": {"ok": true}} +{"id": "act1.draft.hidden", "priority": "P1", "implement": true, "outcome": "'SDSC Winter School Sprint (draft)' is invisible on the public home.", "act": 1, "t": "T-4mo", "title": "the private draft is invisible to the public", "action": "ui.assert", "assert": "homeAbsent", "params": {"name": "SDSC Winter School Sprint (draft)"}} +{"id": "act1.draft.api", "priority": "P1", "implement": true, "outcome": "Succeeds; 'SDSC Winter School Sprint (draft)' is absent from the list.", "act": 1, "t": "T-4mo", "title": "an anonymous crawler asking for private events gets nothing", "actor": "anonymous", "action": "rpc", "method": "hackathon.HackathonService/List", "params": {"visibilityFilter": "VISIBILITY_PRIVATE"}, "expect": {"ok": true, "check": "listLacksName", "checkArgs": {"name": "SDSC Winter School Sprint (draft)"}}} +{"id": "act1.draft.hub.noreview", "priority": "P1", "implement": true, "outcome": "Nobody has registered for the unannounced draft, so the hub offers NO review prompt at all - it is a prompt, never a permanent readout showing zero. The nine tiles are the control that the page rendered.", "act": 1, "t": "T-4mo", "title": "MANAGE HUB: an event nobody has joined shows no approval prompt", "actor": "hackagon-admin", "action": "ui.assert", "assert": "manageHub", "params": {"hackathonId": "{{var:draftId}}", "tiles": [{"label": "Manage Pages", "href": "/pages"}, {"label": "Prizes", "href": "/prizes"}], "tileCount": 9, "mayEdit": true, "nowBadge": null, "next": null, "phaseAction": null, "clearMarker": false}} +{"id": "act1.joinable", "priority": "P1", "implement": true, "outcome": "The dashboard lists 'SDSC Open Research Data Hackathon 2027' under Other hackathons with a Join action.", "act": 1, "t": "T-4mo", "title": "future participants see it as joinable on their dashboard", "actor": "bob", "action": "ui.assert", "assert": "dashboardOthersShows", "params": {"name": "SDSC Open Research Data Hackathon 2027"}} +{"id": "act1.rogue", "priority": "P1", "implement": true, "outcome": "Rejected with PermissionDenied - no state change.", "act": 1, "t": "T-4mo", "title": "a regular user cannot publish a hackathon", "actor": "bob", "action": "rpc", "method": "hackathon.HackathonService/Create", "params": {"name": "Bob's Rogue Hackathon", "visibility": "VISIBILITY_PUBLIC"}, "expect": {"error": "PermissionDenied"}} +{"id": "act1.flow.anon", "priority": "P1", "implement": true, "outcome": "The 7-step browsing chain completes, ending showing the 'SDSC Hackathon Platform' heading.", "act": 1, "t": "T-4mo", "title": "anonymous browse chain: home → hackathon detail → back home", "action": "ui.flow", "steps": [{"goto": "/"}, {"expectHeading": "SDSC Hackathon Platform"}, {"expectText": "SDSC Open Research Data Hackathon 2027"}, {"clickLink": "SDSC Open Research Data Hackathon 2027"}, {"expectUrl": "/hackathon/"}, {"back": true}, {"expectHeading": "SDSC Hackathon Platform"}]} +{"id": "act1.flow.bob", "priority": "P1", "implement": true, "outcome": "The chain completes: a signed-in non-member sees the events public page instead of a 403 dead end.", "act": 1, "t": "T-4mo", "title": "signed-in non-member chain: fresh login -> dashboard -> click event -> public event page", "actor": "bob", "action": "ui.flow", "fresh": true, "steps": [{"login": true}, {"expectUrl": "/dashboard$"}, {"clickLink": "SDSC Open Research Data Hackathon 2027"}, {"expectText": "SDSC Open Research Data Hackathon 2027"}]} +{"id": "act1.flow.abandon", "priority": "P1", "implement": true, "outcome": "The 7-step browsing chain completes, ending showing 'Log in'.", "act": 1, "t": "T-4mo", "title": "ABANDONED FORM: a visitor starts logging in, types a username, then walks away", "action": "ui.flow", "steps": [{"goto": "/"}, {"clickButton": "Log in"}, {"expectUrl": "8180"}, {"fill": {"selector": "#username", "value": "maybe-later"}}, {"back": true}, {"expectUrl": "localhost:8081"}, {"expectText": "Log in"}]} +{"id": "act1.signin.deeplink", "priority": "P1", "implement": true, "outcome": "The guard parks the refused URL on /signin?returnTo=... and the page says what happened, where it is going and how to get on with it - instead of dropping the visitor on the landing page with nothing.", "act": 1, "t": "T-4mo", "title": "DEEP LINK: an anonymous visitor opens an organizer URL and is told why it did not open", "action": "ui.assert", "assert": "signinInterstitial", "params": {"open": "/my/hackathon/{{hackathonId}}/manage", "expectParked": "/my/hackathon/{{hackathonId}}/manage", "says": ["not signed in", "/my/hackathon/{{hackathonId}}/manage", "login page", "button below"], "lacks": ["Taking you"]}, "todo": "Asserted with JAVASCRIPT OFF - with script on, this page forwards itself after 2s, so every assertion here would race the navigation it exists to describe. The no-script form is also the floor this page is built on."} +{"id": "act1.signin.offsite", "priority": "P1", "implement": true, "outcome": "The off-site returnTo is dropped: the page claims no destination, promises the dashboard and never echoes evil.example back, so the login flow cannot be used as an open redirect.", "act": 1, "t": "T-4mo", "title": "SECURITY: a crafted returnTo pointing off-site is refused, not echoed", "action": "ui.assert", "assert": "signinInterstitial", "params": {"open": "/signin?returnTo=https://evil.example/steal", "says": ["not signed in yet", "land on your dashboard"], "lacks": ["evil.example"]}} +{"id": "act1.signin.roundtrip", "priority": "P1", "implement": true, "outcome": "The parked destination survives the whole OIDC round trip: the login lands back on the organizer page that was refused, not on the dashboard.", "act": 1, "t": "T-4mo", "title": "DEEP LINK: signing in from the bounce comes back where it started", "actor": "hackagon-admin", "action": "ui.flow", "fresh": true, "steps": [{"goto": "/my/hackathon/{{hackathonId}}/manage"}, {"fill": {"selector": "#username", "value": "hackagon-admin"}}, {"clickSelector": "#kc-login"}, {"fill": {"selector": "#password", "value": "aliceandbob"}}, {"clickSelector": "#kc-login"}, {"expectUrl": "/my/hackathon/[0-9a-f-]+/manage$"}, {"expectHeading": "Manage Hackathon"}], "todo": "No login: step on purpose - the interstitial forwards ITSELF, which is the path under test; the #username fill waits through the hop."} +{"id": "act1.flow.wrongpw", "priority": "P1", "implement": true, "outcome": "The 11-step browsing chain completes, ending at a URL matching '/dashboard$'.", "act": 1, "t": "T-4mo", "title": "RECOVERY CHAIN: charles fumbles his password, sees the Keycloak error, retries and gets in", "actor": "charles", "action": "ui.flow", "fresh": true, "steps": [{"goto": "/"}, {"clickButton": "Log in"}, {"expectUrl": "8180"}, {"fill": {"selector": "#username", "value": "charles"}}, {"clickSelector": "#kc-login"}, {"fill": {"selector": "#password", "value": "wrong-password"}}, {"clickSelector": "#kc-login"}, {"expectText": "Invalid"}, {"fill": {"selector": "#password", "value": "aliceandbob"}}, {"clickSelector": "#kc-login"}, {"expectUrl": "/dashboard$"}]} +{"id": "act1.flow.joinstub", "priority": "P1", "implement": true, "outcome": "Join is real now but registration has not opened: the click yields the friendly window-closed banner and charles stays a non-member.", "act": 1, "t": "T-4mo", "title": "EARLY BIRD: charles clicks the real dashboard Join button before registration opens - polite window-closed error", "actor": "charles", "action": "ui.flow", "steps": [{"goto": "/dashboard"}, {"expectText": "SDSC Open Research Data Hackathon 2027"}, {"clickButton": "Join"}, {"expectText": "Registration is not open"}]} +{"id": "act1.page.welcome", "priority": "P1", "implement": true, "outcome": "Succeeds. Returns pageWelcome for later steps. [Skips until the gated capability lands.]", "act": 1, "t": "T-4mo", "title": "organizer publishes the Welcome page", "actor": "hackagon-admin", "action": "rpc", "method": "hackathon.PageService/Create", "params": {"hackathonId": "{{hackathonId}}", "title": "Welcome", "content": "Welcome to the SDSC Open Research Data Hackathon 2027! Venue: EPFL, Lausanne. Doors open 08:30.", "visible": true}, "save": {"pageWelcome": "pageId"}, "expect": {"ok": true}, "todo": "TODO: runs automatically once PageService.Create lands — verify field names (title/content/visible) against the final proto."} +{"id": "act1.page.conduct", "priority": "P1", "implement": true, "outcome": "Succeeds. [Skips until the gated capability lands.]", "act": 1, "t": "T-4mo", "title": "organizer publishes the Code of Conduct page", "actor": "hackagon-admin", "action": "rpc", "method": "hackathon.PageService/Create", "params": {"hackathonId": "{{hackathonId}}", "title": "Code of Conduct", "content": "Be excellent to each other. Harassment-free event; report issues to the organizers on site or via conduct@sdsc.example.", "visible": true}, "expect": {"ok": true}, "todo": "TODO: runs once PageService.Create lands."} +{"id": "act1.editor.toolbar", "priority": "P2", "implement": true, "outcome": "Bold wraps then unwraps, Heading 2 becomes Heading 1 without stacking hashes, and Inline code and Link rewrite the FIELD - the textarea stays the single source of truth, so hand-editing can never disagree with the buttons.", "act": 1, "t": "T-4mo", "title": "EDITOR: the markdown toolbar writes markdown, not rich text", "actor": "hackagon-admin", "action": "ui.assert", "assert": "markdownToolbar", "params": {"path": "pages/new", "start": "Doors open at 08:30\nCoffee is in Hall B", "presses": [{"select": "Doors open at 08:30", "button": "Bold", "expect": "**Doors open at 08:30**\nCoffee is in Hall B"}, {"select": "Doors open at 08:30", "button": "Bold", "expect": "Doors open at 08:30\nCoffee is in Hall B"}, {"select": "Coffee is in Hall B", "button": "Heading 2", "expect": "Doors open at 08:30\n## Coffee is in Hall B"}, {"select": "Coffee is in Hall B", "button": "Heading 1", "expect": "Doors open at 08:30\n# Coffee is in Hall B"}, {"select": "08:30", "button": "Inline code", "expect": "Doors open at `08:30`\n# Coffee is in Hall B"}, {"select": "Hall B", "button": "Link", "expect": "Doors open at `08:30`\n# Coffee is in [Hall B](url)"}]}, "todo": "Pressing Bold twice is the round trip: wrap, then unwrap when the markers are just outside the selection. Heading 1 over a Heading 2 must RE-LEVEL, not stack."} +{"id": "act1.editor.table.tsv", "priority": "P2", "implement": true, "outcome": "A spreadsheet selection (tab-separated, which is what the clipboard actually carries) is recognised as 3 columns by 2 rows and inserted as a padded GFM table.", "act": 1, "t": "T-4mo", "title": "EDITOR: pasting a spreadsheet selection becomes a markdown table", "actor": "hackagon-admin", "action": "ui.assert", "assert": "markdownTable", "params": {"path": "pages/new", "paste": "Session\tRoom\tTime\nKeynote\tHall B\t09:00\nLunch\tHall A\t12:30", "shape": "3 columns × 2 rows, separated by tab.", "contains": ["| Session | Room | Time |", "| ------- | ------ | ----- |", "| Keynote | Hall B | 09:00 |", "| Lunch | Hall A | 12:30 |"]}, "todo": "A tab in pasted text is a cell boundary - you cannot type one into a spreadsheet cell - so it wins every tie and is never reported as ambiguous."} +{"id": "act1.editor.table.csv", "priority": "P2", "implement": true, "outcome": "A quoted cell holding the delimiter stays ONE cell: 'CSV, TSV and friends' does not become two columns.", "act": 1, "t": "T-4mo", "title": "EDITOR: a quoted cell containing a comma survives a comma-separated paste", "actor": "hackagon-admin", "action": "ui.assert", "assert": "markdownTable", "params": {"path": "pages/new", "paste": "Topic,Notes\nData,\"CSV, TSV and friends\"\nCode,\"Rust, Go\"", "shape": "2 columns × 2 rows, separated by comma.", "contains": ["| Data | CSV, TSV and friends |", "| Code | Rust, Go |"], "lacks": ["| CSV | TSV and friends |"]}} +{"id": "act1.editor.table.semicolon", "priority": "P2", "implement": true, "outcome": "European CSV: semicolons separate the fields and the commas inside the numbers stay put, because agreement decides the delimiter rather than frequency.", "act": 1, "t": "T-4mo", "title": "EDITOR: a semicolon-separated paste with decimal commas is read as two columns", "actor": "hackagon-admin", "action": "ui.assert", "assert": "markdownTable", "params": {"path": "pages/new", "paste": "Metric;Value\nUptime;99,5\nLatency;12,3", "shape": "2 columns × 2 rows, separated by semicolon.", "contains": ["| Uptime | 99,5 |", "| Latency | 12,3 |"], "lacks": ["| Uptime;99 | 5 |"]}} +{"id": "act1.editor.table.ambiguous", "priority": "P2", "implement": true, "outcome": "Two separators explain the text equally well, so the panel SAYS so instead of picking silently - and the comma wins on order, not on merit.", "act": 1, "t": "T-4mo", "title": "EDITOR: an ambiguous paste is reported as ambiguous, not guessed at", "actor": "hackagon-admin", "action": "ui.assert", "assert": "markdownTable", "params": {"path": "pages/new", "paste": "Name,Role;Team\nAlice,Organizer;Core", "shape": "2 columns × 1 row, separated by comma. More than one separator fits — pick one if this looks wrong.", "contains": ["| Alice | Organizer;Core |"]}} +{"id": "act1.editor.table.override", "priority": "P2", "implement": true, "outcome": "The same paste with the separator chosen by hand splits the other way and is no longer called ambiguous - an explicit pick is not a guess.", "act": 1, "t": "T-4mo", "title": "EDITOR: choosing the separator overrules the sniffer", "actor": "hackagon-admin", "action": "ui.assert", "assert": "markdownTable", "params": {"path": "pages/new", "paste": "Name,Role;Team\nAlice,Organizer;Core", "separator": ";", "shape": "2 columns × 1 row, separated by semicolon.", "contains": ["| Alice,Organizer | Core |"], "lacks": ["More than one separator fits"]}} +{"id": "act1.editor.table.pipe", "priority": "P2", "implement": true, "outcome": "A cell containing a literal pipe is escaped as a\\|b, so the table does not silently shift every column after it.", "act": 1, "t": "T-4mo", "title": "EDITOR: a cell containing a pipe does not break the table it lands in", "actor": "hackagon-admin", "action": "ui.assert", "assert": "markdownTable", "params": {"path": "pages/new", "paste": "Field\tPattern\nAlternation\ta|b", "shape": "2 columns × 1 row, separated by tab.", "contains": ["| Alternation | a\\|b |"], "lacks": ["| Alternation | a | b |"]}, "todo": "The backslash has to be escaped FIRST for the same reason: a cell reading a\\|b would otherwise come out as a\\\\|b, which GFM reads as an escaped backslash followed by a live pipe."} +{"id": "act1.editor.table.ragged", "priority": "P2", "implement": true, "outcome": "The column count is the WIDEST row, so a short row shows up as a column with gaps rather than losing the field beside it.", "act": 1, "t": "T-4mo", "title": "EDITOR: ragged rows are padded, never truncated", "actor": "hackagon-admin", "action": "ui.assert", "assert": "markdownTable", "params": {"path": "pages/new", "paste": "Room\tCapacity\tFloor\nHall B\t120\nHall A\t80\t2", "shape": "3 columns × 2 rows, separated by tab.", "contains": ["| Room | Capacity | Floor |", "| Hall B | 120 | |", "| Hall A | 80 | 2 |"]}} +{"id": "act1.editor.table.noheader", "priority": "P2", "implement": true, "outcome": "With 'First row is a header' off, the first pasted row stays a BODY row and the header comes out blank - two rows in, two rows of data out.", "act": 1, "t": "T-4mo", "title": "EDITOR: a paste with no header row keeps its first line as data", "actor": "hackagon-admin", "action": "ui.assert", "assert": "markdownTable", "params": {"path": "pages/new", "paste": "Alice\tOrganizer\nBob\tParticipant", "firstRowIsHeader": false, "shape": "2 columns × 2 rows, separated by tab.", "contains": ["| Alice | Organizer |", "| Bob | Participant |"]}} +{"id": "act1.editor.table.cancel", "priority": "P2", "implement": true, "outcome": "Pasting rows and then thinking better of it leaves the document exactly as it was - the panel is a conversion, not a commitment.", "act": 1, "t": "T-4mo", "title": "EDITOR: a pasted table abandoned with Cancel writes nothing", "actor": "hackagon-admin", "action": "ui.assert", "assert": "markdownTable", "params": {"path": "pages/new", "paste": "Slot\tRoom\n09:00\tHall B", "shape": "2 columns × 1 row, separated by tab.", "cancel": true}} +{"id": "act1.editor.typo", "priority": "P2", "implement": true, "outcome": "The page saves from the editor and appears in the organizer's list - with a typo in its title and another in its body, which is how a real page arrives.", "act": 1, "t": "T-4mo", "title": "EDITOR: the organizer writes the venue page, typos and all", "actor": "hackagon-admin", "action": "ui.flow", "steps": [{"goto": "/my/hackathon/{{hackathonId}}/pages/new"}, {"fill": {"selector": "input[name=title]", "value": "Venue & Travle — EPFL"}}, {"fill": {"selector": "textarea#page-content", "value": "**Doors open at 08:30.** The venue is the SwissTech Convention Center, EPFL — Lausanne. Coffee, thé and croissants in Hall B from 08:00.\n\n| Session | Room | Time |\n| ------- | ------ | ----- |\n| Keynote | Hall B | 09:00 |\n"}}, {"clickButton": "Add page"}, {"expectUrl": "/pages$"}, {"expectText": "Venue & Travle — EPFL"}]} +{"id": "act1.editor.fix", "priority": "P1", "implement": true, "outcome": "The edit form opens PREFILLED - title and body - the title is corrected from the row's own Edit link, and the body is never retyped. A Set that replaces the whole record makes any form that cannot prefill destructive.", "act": 1, "t": "T-4mo", "title": "TYPO: the organizer spots 'Travle' and fixes the title from the page list", "actor": "hackagon-admin", "action": "ui.assert", "assert": "pageEditFix", "params": {"from": "Venue & Travle — EPFL", "to": "Venue & Travel — EPFL", "bodyContains": ["**Doors open at 08:30.**", "| Keynote | Hall B | 09:00 |", "thé"]}} +{"id": "act1.editor.readback", "priority": "P1", "implement": true, "outcome": "Succeeds; the title is corrected AND the body is still there, accents, bold markers and table pipes intact - which is only true if the edit form prefilled the content it did not ask about.", "act": 1, "t": "T-4mo", "title": "EDITOR: correcting the title left the page's body untouched", "actor": "hackagon-admin", "action": "rpc", "method": "hackathon.PageService/List", "params": {"hackathonId": "{{hackathonId}}"}, "expect": {"ok": true, "check": "pageContent", "checkArgs": {"title": "Venue & Travel — EPFL", "contains": ["**Doors open at 08:30.**", "| Session | Room | Time |", "| Keynote | Hall B | 09:00 |", "thé"]}}} +{"id": "act1.track.ds", "priority": "P1", "implement": true, "outcome": "Succeeds. Returns trackDS for later steps. [Skips until the gated capability lands.]", "act": 1, "t": "T-4mo", "title": "organizer creates the Data Science track", "actor": "hackagon-admin", "action": "rpc", "method": "hackathon.TrackService/Create", "params": {"hackathonId": "{{hackathonId}}", "name": "Data Science", "description": "ML, statistics and analytics on open research data."}, "save": {"trackDS": "trackId"}, "expect": {"ok": true}, "todo": "TODO: TrackService.Create has no proto yet (priority item 5) — action kept as placeholder; align fields when the proto lands."} +{"id": "act1.track.rdi", "priority": "P1", "implement": true, "outcome": "Succeeds. Returns trackRDI for later steps. [Skips until the gated capability lands.]", "act": 1, "t": "T-4mo", "title": "organizer creates the Research Data Infrastructure track", "actor": "hackagon-admin", "action": "rpc", "method": "hackathon.TrackService/Create", "params": {"hackathonId": "{{hackathonId}}", "name": "Research Data Infrastructure", "description": "FAIR pipelines, metadata, repositories and reproducibility tooling."}, "save": {"trackRDI": "trackId"}, "expect": {"ok": true}, "todo": "TODO: TrackService.Create has no proto yet — placeholder."} +{"comment": "── ACT 2 — T-3 months: REGISTRATION OPENS (13 sign-ups vs capacity 8) ──"} +{"id": "act2.window.open", "priority": "P2", "implement": true, "outcome": "Succeeds - registration is open; the wave can sign up.", "act": 2, "t": "T-3mo", "title": "T-3 months: the announcement goes out - admin opens registration", "actor": "hackagon-admin", "action": "rpc", "method": "hackathon.ConfigService/SetWindows", "params": {"hackathonId": "{{hackathonId}}", "registrationOpens": "{{now-1d}}"}, "expect": {"ok": true}} +{"id": "act2.join.alice", "priority": "P1", "implement": true, "outcome": "Succeeds.", "act": 2, "t": "T-3mo", "title": "alice registers (waitlisted)", "actor": "alice", "action": "rpc", "method": "hackathon.HackathonService/Join", "params": {"hackathonId": "{{hackathonId}}"}, "expect": {"ok": true}} +{"id": "act2.join.bob", "priority": "P1", "implement": true, "outcome": "Succeeds.", "act": 2, "t": "T-3mo", "title": "bob registers (waitlisted)", "actor": "bob", "action": "rpc", "method": "hackathon.HackathonService/Join", "params": {"hackathonId": "{{hackathonId}}"}, "expect": {"ok": true}} +{"id": "act2.join.charles", "priority": "P1", "implement": true, "outcome": "charles joins through the real dashboard Join button, is taken straight to the organizer's registration form, answers it, and lands on the waitlist.", "act": 2, "t": "T-3mo", "title": "charles registers via the dashboard Join button, filling the form on the way (waitlisted)", "actor": "charles", "action": "ui.flow", "steps": [{"goto": "/dashboard"}, {"clickButton": "Join"}, {"expectUrl": "/register/"}, {"expectHeading": "Registration"}, {"fill": {"selector": "input[name=\"field:affiliation\"]", "value": "Univ. of Zurich"}}, {"clickSelector": "input[name=\"consent:conduct\"]"}, {"clickButton": "Submit registration"}, {"expectText": "your answers are in"}, {"goto": "/dashboard"}, {"expectText": "Waitlisted"}]} +{"id": "act2.join.dana", "priority": "P1", "implement": true, "outcome": "Succeeds.", "act": 2, "t": "T-3mo", "title": "Dana Moser (ETH) registers", "actor": "dana.moser", "action": "rpc", "method": "hackathon.HackathonService/Join", "params": {"hackathonId": "{{hackathonId}}"}, "expect": {"ok": true}} +{"id": "act2.join.erik", "priority": "P1", "implement": true, "outcome": "Succeeds.", "act": 2, "t": "T-3mo", "title": "Erik Lindqvist (EPFL) registers", "actor": "erik.lindqvist", "action": "rpc", "method": "hackathon.HackathonService/Join", "params": {"hackathonId": "{{hackathonId}}"}, "expect": {"ok": true}} +{"id": "act2.join.fatima", "priority": "P1", "implement": true, "outcome": "Succeeds.", "act": 2, "t": "T-3mo", "title": "Fatima Khoury (SDSC) registers", "actor": "fatima.khoury", "action": "rpc", "method": "hackathon.HackathonService/Join", "params": {"hackathonId": "{{hackathonId}}"}, "expect": {"ok": true}} +{"id": "act2.join.giulia", "priority": "P1", "implement": true, "outcome": "Succeeds.", "act": 2, "t": "T-3mo", "title": "Giulia Ricci (Bern) registers", "actor": "giulia.ricci", "action": "rpc", "method": "hackathon.HackathonService/Join", "params": {"hackathonId": "{{hackathonId}}"}, "expect": {"ok": true}} +{"id": "act2.midway", "priority": "P1", "implement": true, "outcome": "Succeeds; roster shows 7 on the list, 0 approved, 7 waitlisted.", "act": 2, "t": "T-3mo", "title": "MEANWHILE admin watches registrations come in: 7 so far, all waitlisted (roster includes the organizer)", "actor": "hackagon-admin", "action": "rpc", "method": "hackathon.HackathonService/Get", "params": {"hackathonId": "{{hackathonId}}"}, "expect": {"ok": true, "check": "roster", "checkArgs": {"total": 8, "approved": 1, "waiting": 7}}} +{"id": "act2.pause", "priority": "P1", "implement": true, "outcome": "Succeeds.", "act": 2, "t": "T-3mo", "title": "MEANWHILE admin briefly unlists the event for maintenance (visibility → private)", "actor": "hackagon-admin", "action": "rpc", "method": "hackathon.HackathonService/Edit", "params": {"hackathonId": "{{hackathonId}}", "visibility": "VISIBILITY_PRIVATE"}, "expect": {"ok": true}} +{"id": "act2.pause.ui", "priority": "P1", "implement": true, "outcome": "'SDSC Open Research Data Hackathon 2027' is invisible on the public home.", "act": 2, "t": "T-3mo", "title": "while unlisted, anonymous visitors no longer see the event", "action": "ui.assert", "assert": "homeAbsent", "params": {"name": "SDSC Open Research Data Hackathon 2027"}} +{"id": "act2.pause.api", "priority": "P1", "implement": true, "outcome": "Succeeds; 'SDSC Open Research Data Hackathon 2027' is absent from the list.", "act": 2, "t": "T-3mo", "title": "while unlisted, the public list API omits it too", "actor": "anonymous", "action": "rpc", "method": "hackathon.HackathonService/List", "params": {"visibilityFilter": "VISIBILITY_PUBLIC"}, "expect": {"ok": true, "check": "listLacksName", "checkArgs": {"name": "SDSC Open Research Data Hackathon 2027"}}} +{"id": "act2.resume", "priority": "P1", "implement": true, "outcome": "Succeeds.", "act": 2, "t": "T-3mo", "title": "admin relists the event (visibility → public)", "actor": "hackagon-admin", "action": "rpc", "method": "hackathon.HackathonService/Edit", "params": {"hackathonId": "{{hackathonId}}", "visibility": "VISIBILITY_PUBLIC"}, "expect": {"ok": true}} +{"id": "act2.resume.ui", "priority": "P1", "implement": true, "outcome": "The public home lists 'SDSC Open Research Data Hackathon 2027' with the 'Upcoming' badge.", "act": 2, "t": "T-3mo", "title": "back online: the event is publicly listed again", "action": "ui.assert", "assert": "homeStatus", "params": {"name": "SDSC Open Research Data Hackathon 2027", "status": "Upcoming"}} +{"id": "act2.join.hiro", "priority": "P1", "implement": true, "outcome": "Succeeds.", "act": 2, "t": "T-3mo", "title": "Hiro Tanaka (ETH) registers", "actor": "hiro.tanaka", "action": "rpc", "method": "hackathon.HackathonService/Join", "params": {"hackathonId": "{{hackathonId}}"}, "expect": {"ok": true}} +{"id": "act2.join.ines", "priority": "P1", "implement": true, "outcome": "Succeeds.", "act": 2, "t": "T-3mo", "title": "Ines Duarte (EPFL) registers", "actor": "ines.duarte", "action": "rpc", "method": "hackathon.HackathonService/Join", "params": {"hackathonId": "{{hackathonId}}"}, "expect": {"ok": true}} +{"id": "act2.join.jonas", "priority": "P1", "implement": true, "outcome": "Succeeds.", "act": 2, "t": "T-3mo", "title": "Jonas Weber (UZH) registers", "actor": "jonas.weber", "action": "rpc", "method": "hackathon.HackathonService/Join", "params": {"hackathonId": "{{hackathonId}}"}, "expect": {"ok": true}} +{"id": "act2.join.katya", "priority": "P1", "implement": true, "outcome": "Succeeds.", "act": 2, "t": "T-3mo", "title": "Katya Volkova (SDSC) registers", "actor": "katya.volkova", "action": "rpc", "method": "hackathon.HackathonService/Join", "params": {"hackathonId": "{{hackathonId}}"}, "expect": {"ok": true}} +{"id": "act2.join.liam", "priority": "P1", "implement": true, "outcome": "Succeeds.", "act": 2, "t": "T-3mo", "title": "Liam O'Brien (Bern) registers", "actor": "liam.obrien", "action": "rpc", "method": "hackathon.HackathonService/Join", "params": {"hackathonId": "{{hackathonId}}"}, "expect": {"ok": true}} +{"id": "act2.join.mei", "priority": "P1", "implement": true, "outcome": "Succeeds.", "act": 2, "t": "T-3mo", "title": "Mei Chen (ETH) registers", "actor": "mei.chen", "action": "rpc", "method": "hackathon.HackathonService/Join", "params": {"hackathonId": "{{hackathonId}}"}, "expect": {"ok": true}} +{"id": "act2.form.alice", "priority": "P2", "implement": true, "outcome": "Succeeds.", "act": 2, "t": "T-3mo", "title": "FORMS: alice fills the registration form (schema defined by the admin in act 1)", "actor": "alice", "action": "rpc", "method": "hackathon.HackathonService/SubmitRegistrationForm", "gate": ["hackathon.ConfigService/SetRegistrationForm", "hackathon.HackathonService/SubmitRegistrationForm"], "params": {"hackathonId": "{{hackathonId}}", "responses": {"affiliation": "SDSC", "skills": ["go", "grpc", "facilitation"], "diet": "none", "avatar": "https://pics.example.org/alice-wonderland.jpg"}, "consents": {"conduct": true, "photos": true}}, "expect": {"ok": true}} +{"id": "act2.form.bob", "priority": "P2", "implement": true, "outcome": "Succeeds.", "act": 2, "t": "T-3mo", "title": "FORMS: bob fills the form (vegetarian)", "actor": "bob", "action": "rpc", "method": "hackathon.HackathonService/SubmitRegistrationForm", "gate": ["hackathon.ConfigService/SetRegistrationForm", "hackathon.HackathonService/SubmitRegistrationForm"], "params": {"hackathonId": "{{hackathonId}}", "responses": {"affiliation": "SDSC", "skills": ["svelte", "typescript", "data-viz"], "diet": "vegetarian", "avatar": "https://pics.example.org/bob-henderson.jpg"}, "consents": {"conduct": true, "photos": true}}, "expect": {"ok": true}} +{"id": "act2.form.charles", "priority": "P2", "implement": true, "outcome": "Succeeds.", "act": 2, "t": "T-3mo", "title": "FORMS: charles fills the form (ever hopeful)", "actor": "charles", "action": "rpc", "method": "hackathon.HackathonService/SubmitRegistrationForm", "gate": ["hackathon.ConfigService/SetRegistrationForm", "hackathon.HackathonService/SubmitRegistrationForm"], "params": {"hackathonId": "{{hackathonId}}", "responses": {"affiliation": "Univ. of Zurich", "skills": ["r", "statistics"], "diet": "none"}, "consents": {"conduct": true, "photos": true}}, "expect": {"ok": true}} +{"id": "act2.form.dana", "priority": "P2", "implement": true, "outcome": "Succeeds.", "act": 2, "t": "T-3mo", "title": "FORMS: Dana fills the form (skips the optional diet field)", "actor": "dana.moser", "action": "rpc", "method": "hackathon.HackathonService/SubmitRegistrationForm", "gate": ["hackathon.ConfigService/SetRegistrationForm", "hackathon.HackathonService/SubmitRegistrationForm"], "params": {"hackathonId": "{{hackathonId}}", "responses": {"affiliation": "ETH Zurich", "skills": ["python", "ml", "nlp"], "avatar": "https://pics.example.org/dana-moser.jpg"}, "consents": {"conduct": true, "photos": true}}, "expect": {"ok": true}} +{"id": "act2.form.erik", "priority": "P2", "implement": true, "outcome": "Succeeds.", "act": 2, "t": "T-3mo", "title": "FORMS: Erik fills the form", "actor": "erik.lindqvist", "action": "rpc", "method": "hackathon.HackathonService/SubmitRegistrationForm", "gate": ["hackathon.ConfigService/SetRegistrationForm", "hackathon.HackathonService/SubmitRegistrationForm"], "params": {"hackathonId": "{{hackathonId}}", "responses": {"affiliation": "EPFL", "skills": ["rust", "systems"], "diet": "none"}, "consents": {"conduct": true, "photos": true}}, "expect": {"ok": true}} +{"id": "act2.form.giulia", "priority": "P2", "implement": true, "outcome": "Succeeds.", "act": 2, "t": "T-3mo", "title": "FORMS: Giulia declines photo consent — the optional consent must be honored", "actor": "giulia.ricci", "action": "rpc", "method": "hackathon.HackathonService/SubmitRegistrationForm", "gate": ["hackathon.ConfigService/SetRegistrationForm", "hackathon.HackathonService/SubmitRegistrationForm"], "params": {"hackathonId": "{{hackathonId}}", "responses": {"affiliation": "Univ. of Bern", "skills": ["bioinformatics", "genomics"], "diet": "halal"}, "consents": {"conduct": true, "photos": false}}, "expect": {"ok": true}} +{"id": "act2.form.hiro", "priority": "P2", "implement": true, "outcome": "Succeeds.", "act": 2, "t": "T-3mo", "title": "FORMS: Hiro fills the form (he will still no-show)", "actor": "hiro.tanaka", "action": "rpc", "method": "hackathon.HackathonService/SubmitRegistrationForm", "gate": ["hackathon.ConfigService/SetRegistrationForm", "hackathon.HackathonService/SubmitRegistrationForm"], "params": {"hackathonId": "{{hackathonId}}", "responses": {"affiliation": "ETH Zurich", "skills": ["computer-vision", "pytorch"], "diet": "none"}, "consents": {"conduct": true, "photos": true}}, "expect": {"ok": true}} +{"id": "act2.form.katya", "priority": "P2", "implement": true, "outcome": "Succeeds.", "act": 2, "t": "T-3mo", "title": "FORMS: waitlisted Katya fills the form too (forms are independent of approval)", "actor": "katya.volkova", "action": "rpc", "method": "hackathon.HackathonService/SubmitRegistrationForm", "gate": ["hackathon.ConfigService/SetRegistrationForm", "hackathon.HackathonService/SubmitRegistrationForm"], "params": {"hackathonId": "{{hackathonId}}", "responses": {"affiliation": "SDSC", "skills": ["data-eng", "spark"], "diet": "vegan"}, "consents": {"conduct": true, "photos": true}}, "expect": {"ok": true}} +{"id": "act2.form.mei", "priority": "P2", "implement": true, "outcome": "Succeeds.", "act": 2, "t": "T-3mo", "title": "FORMS: Mei fills the form", "actor": "mei.chen", "action": "rpc", "method": "hackathon.HackathonService/SubmitRegistrationForm", "gate": ["hackathon.ConfigService/SetRegistrationForm", "hackathon.HackathonService/SubmitRegistrationForm"], "params": {"hackathonId": "{{hackathonId}}", "responses": {"affiliation": "ETH Zurich", "skills": ["javascript", "react"], "diet": "vegetarian"}, "consents": {"conduct": true, "photos": true}}, "expect": {"ok": true}} +{"id": "act2.form.missing", "priority": "P2", "implement": true, "outcome": "Rejected with InvalidArgument - no state change.", "act": 2, "t": "T-3mo", "title": "VALIDATION: Liam omits the required Code-of-Conduct consent — rejected", "actor": "liam.obrien", "action": "rpc", "method": "hackathon.HackathonService/SubmitRegistrationForm", "gate": ["hackathon.ConfigService/SetRegistrationForm", "hackathon.HackathonService/SubmitRegistrationForm"], "params": {"hackathonId": "{{hackathonId}}", "responses": {"affiliation": "Univ. of Bern", "skills": ["devops"]}, "consents": {"photos": true}}, "expect": {"error": "InvalidArgument"}} +{"id": "act2.form.unknown", "priority": "P2", "implement": true, "outcome": "Rejected with InvalidArgument - no state change.", "act": 2, "t": "T-3mo", "title": "VALIDATION: Jonas submits a field the admin never defined (tshirtSize) — rejected", "actor": "jonas.weber", "action": "rpc", "method": "hackathon.HackathonService/SubmitRegistrationForm", "gate": ["hackathon.ConfigService/SetRegistrationForm", "hackathon.HackathonService/SubmitRegistrationForm"], "params": {"hackathonId": "{{hackathonId}}", "responses": {"affiliation": "Univ. of Zurich", "skills": ["nlp"], "tshirtSize": "XL"}, "consents": {"conduct": true}}, "expect": {"error": "InvalidArgument"}} +{"id": "act2.form.alice.readback", "priority": "P2", "implement": true, "outcome": "Returns the answers alice filed, so the form opens filled in instead of blank.", "act": 2, "t": "T-3mo", "title": "FORMS: alice reads her own answers back", "actor": "alice", "action": "rpc", "method": "hackathon.HackathonService/GetRegistrationResponse", "gate": ["hackathon.HackathonService/GetRegistrationResponse"], "params": {"hackathonId": "{{hackathonId}}"}, "expect": {"ok": true, "check": "formAnswers", "checkArgs": {"responses": {"diet": "none", "affiliation": "SDSC"}, "consents": {"conduct": true, "photos": true}}}} +{"id": "act2.form.alice.correct", "priority": "P2", "implement": true, "outcome": "Succeeds - answers are editable, not write-once. Used to fail with AlreadyExists.", "act": 2, "t": "T-3mo", "title": "FORMS: alice turns vegetarian and corrects her answers", "actor": "alice", "action": "rpc", "method": "hackathon.HackathonService/SubmitRegistrationForm", "gate": ["hackathon.HackathonService/SubmitRegistrationForm"], "params": {"hackathonId": "{{hackathonId}}", "responses": {"affiliation": "SDSC", "skills": ["go", "grpc", "facilitation"], "diet": "vegetarian", "avatar": "https://pics.example.org/alice-wonderland.jpg"}, "consents": {"conduct": true, "photos": true}}, "expect": {"ok": true}} +{"id": "act2.form.alice.recheck", "priority": "P2", "implement": true, "outcome": "The correction REPLACED the original - one row per person, not an append-only log.", "act": 2, "t": "T-3mo", "title": "FORMS: the corrected answer is the one on file", "actor": "alice", "action": "rpc", "method": "hackathon.HackathonService/GetRegistrationResponse", "gate": ["hackathon.HackathonService/GetRegistrationResponse"], "params": {"hackathonId": "{{hackathonId}}"}, "expect": {"ok": true, "check": "formAnswers", "checkArgs": {"responses": {"diet": "vegetarian"}}}} +{"id": "act2.form.bob.snoop", "priority": "P1", "implement": true, "outcome": "Rejected with PermissionDenied - a form response is personal data, not roster info.", "act": 2, "t": "T-3mo", "title": "PRIVACY: bob tries to read alice's registration answers", "actor": "bob", "action": "rpc", "method": "hackathon.HackathonService/GetRegistrationResponse", "gate": ["hackathon.HackathonService/GetRegistrationResponse"], "params": {"hackathonId": "{{hackathonId}}", "userId": "{{userId:alice}}"}, "expect": {"error": "PermissionDenied"}} +{"id": "act2.form.admin.read", "priority": "P2", "implement": true, "outcome": "Succeeds - organizers need the answers for catering and check-in.", "act": 2, "t": "T-3mo", "title": "FORMS: the organizer reads alice's answers (catering headcount)", "actor": "hackagon-admin", "action": "rpc", "method": "hackathon.HackathonService/GetRegistrationResponse", "gate": ["hackathon.HackathonService/GetRegistrationResponse"], "params": {"hackathonId": "{{hackathonId}}", "userId": "{{userId:alice}}"}, "expect": {"ok": true, "check": "formAnswers", "checkArgs": {"responses": {"diet": "vegetarian"}}}} +{"id": "act2.idempotent", "priority": "P1", "implement": true, "outcome": "Succeeds.", "act": 2, "t": "T-3mo", "title": "registering twice is idempotent", "actor": "bob", "action": "rpc", "method": "hackathon.HackathonService/Join", "params": {"hackathonId": "{{hackathonId}}"}, "expect": {"ok": true}} +{"id": "act2.anonymous", "priority": "P1", "implement": true, "outcome": "Rejected with Unauthenticated - no state change.", "act": 2, "t": "T-3mo", "title": "anonymous visitors cannot register", "actor": "anonymous", "action": "rpc", "method": "hackathon.HackathonService/Join", "params": {"hackathonId": "{{hackathonId}}"}, "expect": {"error": "Unauthenticated"}} +{"id": "act2.anonymous.register", "priority": "P1", "implement": true, "outcome": "Rejected with Unauthenticated. It used to SUCCEED and create a profile with keycloak_id \"anonymous\", which then appeared in the user admin as a person and could have been granted roles.", "act": 2, "t": "T-3mo", "title": "PRIVACY: an anonymous caller cannot register a profile", "actor": "anonymous", "action": "rpc", "method": "user.UserService/Register", "params": {}, "expect": {"error": "Unauthenticated"}} +{"id": "act2.roster", "priority": "P1", "implement": true, "outcome": "Succeeds; roster shows 13 on the list, 0 approved, 13 waitlisted.", "act": 2, "t": "T-3mo", "title": "authoritative roster: 13 registrations, all waitlisted (roster includes the organizer)", "actor": "hackagon-admin", "action": "rpc", "method": "hackathon.HackathonService/Get", "params": {"hackathonId": "{{hackathonId}}"}, "expect": {"ok": true, "check": "roster", "checkArgs": {"total": 14, "approved": 1, "waiting": 13}}} +{"id": "act2.users.grown", "priority": "P1", "implement": true, "outcome": "Succeeds; the platform user list has at least 14 accounts.", "act": 2, "t": "T-3mo", "title": "MEANWHILE admin sees the platform grew to 14 accounts (extras self-registered)", "actor": "hackagon-admin", "action": "rpc", "method": "user.UserService/List", "params": {}, "expect": {"ok": true, "check": "usersCount", "checkArgs": {"atLeast": 14}}} +{"id": "act2.flow.admin.users", "priority": "P1", "implement": true, "outcome": "The 5-step browsing chain completes, ending showing 'Mei Chen'.", "act": 2, "t": "T-3mo", "title": "MEANWHILE admin chain: dashboard → user management → sees the new registrants", "actor": "hackagon-admin", "action": "ui.flow", "steps": [{"goto": "/dashboard"}, {"goto": "/manage/users"}, {"expectHeading": "Users"}, {"expectText": "Dana Moser"}, {"expectText": "Mei Chen"}]} +{"id": "act2.users.rogue", "priority": "P1", "implement": true, "outcome": "Rejected with PermissionDenied - no state change.", "act": 2, "t": "T-3mo", "title": "a regular user cannot list platform users", "actor": "bob", "action": "rpc", "method": "user.UserService/List", "params": {}, "expect": {"error": "PermissionDenied"}} +{"id": "act2.flow.alice.users", "priority": "P1", "implement": true, "outcome": "The 1-step browsing chain completes, ending with HTTP 403 - the permission denial is translated, not leaked as a 500.", "act": 2, "t": "T-3mo", "title": "a non-admin opening user management is politely refused (403)", "actor": "alice", "action": "ui.flow", "steps": [{"goto": "/manage/users", "status": 403}]} +{"id": "act2.join.badid", "priority": "P1", "implement": true, "outcome": "Rejected with InvalidArgument - no state change.", "act": 2, "t": "T-3mo", "title": "a broken client sends a malformed join request", "actor": "bob", "action": "rpc", "method": "hackathon.HackathonService/Join", "params": {"hackathonId": "not-a-uuid"}, "expect": {"error": "InvalidArgument"}} +{"id": "act2.join.ghost", "priority": "P1", "implement": true, "outcome": "Rejected with NotFound - no state change.", "act": 2, "t": "T-3mo", "title": "joining a non-existent hackathon fails cleanly", "actor": "bob", "action": "rpc", "method": "hackathon.HackathonService/Join", "params": {"hackathonId": "00000000-0000-0000-0000-000000000000"}, "expect": {"error": "NotFound"}} +{"id": "act2.whoami.bob", "priority": "P1", "implement": true, "outcome": "Succeeds.", "act": 2, "t": "T-3mo", "title": "bob's platform account is live (WhoAmI)", "actor": "bob", "action": "rpc", "method": "user.UserService/WhoAmI", "params": {}, "expect": {"ok": true}} +{"id": "act2.ui.waitlisted", "priority": "P1", "implement": true, "outcome": "The dashboard shows 'SDSC Open Research Data Hackathon 2027' with the 'Waitlisted' membership badge.", "act": 2, "t": "T-3mo", "title": "bob's dashboard shows the event as Waitlisted", "actor": "bob", "action": "ui.assert", "assert": "dashboardBadge", "params": {"name": "SDSC Open Research Data Hackathon 2027", "badge": "Waitlisted"}} +{"id": "act2.ui.locked", "priority": "P1", "implement": true, "outcome": "Opening the member view returns HTTP 403.", "act": 2, "t": "T-3mo", "title": "waitlisted users cannot open the member view", "actor": "bob", "action": "ui.assert", "assert": "memberViewStatus", "params": {"status": 403}} +{"id": "act2.flow.bob", "priority": "P1", "implement": true, "outcome": "The 9-step browsing chain completes: the refusal is not a dead end, and its way out lands on the public page of the SAME event - the one that offers Join.", "act": 2, "t": "T-3mo", "title": "waitlisted chain: fresh login → dashboard (Waitlisted) → click my event → 403 → back to the event's public page", "actor": "bob", "action": "ui.flow", "fresh": true, "steps": [{"login": true}, {"expectUrl": "/dashboard$"}, {"expectText": "Waitlisted"}, {"clickLink": "SDSC Open Research Data Hackathon 2027"}, {"expectText": "403"}, {"expectText": "not a confirmed member"}, {"clickLink": "Back to this hackathon"}, {"expectUrl": "/hackathon/[0-9a-f-]+$"}, {"expectText": "SDSC Open Research Data Hackathon 2027"}], "todo": "Re-specified 2026-08-14 for develop's +error.svelte. The refusal page used to offer one always-Home button; it now reads the hackathon id off the URL and offers 'Back to this hackathon', landing on the PUBLIC event page - which for a waitlisted person is the page that offers Join, i.e. the one place the refusal should send them. The claim is unchanged and is why the action exists: a 403 must not be a dead end. Strengthened while re-writing - it now asserts WHICH event it landed on, because 'some link was clicked and the URL changed' would pass against a link back to anywhere."} +{"id": "act2.flow.anxious", "priority": "P1", "implement": true, "outcome": "The 4-step browsing chain completes, ending showing 'Waitlisted'.", "act": 2, "t": "T-3mo", "title": "charles anxiously re-checks his waitlist status (dashboard → reload → still Waitlisted)", "actor": "charles", "action": "ui.flow", "steps": [{"goto": "/dashboard"}, {"expectText": "Waitlisted"}, {"goto": "/dashboard"}, {"expectText": "Waitlisted"}]} +{"comment": "── ACT 2b — T-3 months: THE CAPACITY PILOT (a capped side sprint) ──"} +{"id": "act2.cap.create", "priority": "P1", "implement": true, "outcome": "Succeeds. Returns capHackId for the capacity plot.", "act": 2, "t": "T-3mo", "title": "admin opens a capped side sprint - capacity will be enforced here, not prose", "actor": "hackagon-admin", "action": "rpc", "method": "hackathon.HackathonService/Create", "params": {"name": "SDSC Capacity Pilot Sprint", "description": "A small evening sprint piloting REAL capacity enforcement: 3 seats, first-come-first-served, waiting list for the overflow.", "visibility": "VISIBILITY_PUBLIC"}, "save": {"capHackId": "hackathonId"}, "expect": {"ok": true}} +{"id": "act2.cap.set", "priority": "P1", "implement": true, "outcome": "Succeeds; the hackathon echoes max_participants=3 back.", "act": 2, "t": "T-3mo", "title": "admin sets the capacity to 3 on the edit path (a FIELD now, not prose in the description)", "actor": "hackagon-admin", "action": "rpc", "method": "hackathon.HackathonService/Edit", "params": {"hackathonId": "{{var:capHackId}}", "maxParticipants": 3}, "expect": {"ok": true, "check": "capacityField", "checkArgs": {"value": 3}}} +{"id": "act2.cap.join.room", "priority": "P1", "implement": true, "outcome": "Succeeds with waitlisted=false - below capacity, a capped event confirms outright instead of waitlisting for approval.", "act": 2, "t": "T-3mo", "title": "Dana joins below capacity and is in INSTANTLY (2 of 3 seats taken, counting the organizer)", "actor": "dana.moser", "action": "rpc", "method": "hackathon.HackathonService/Join", "params": {"hackathonId": "{{var:capHackId}}"}, "expect": {"ok": true, "check": "joinOutcome", "checkArgs": {"waitlisted": false, "position": 0}}} +{"id": "act2.cap.race", "priority": "P1", "implement": true, "outcome": "All four concurrent joins SUCCEED - landing on the waiting list is not an error - and exactly one of them takes the last seat. The roster read below is the oversell detector.", "act": 2, "t": "T-3mo", "title": "RACE: four people hit Join the moment the link drops - ONE seat left", "action": "rpc.race", "calls": [{"actor": "erik.lindqvist", "method": "hackathon.HackathonService/Join", "params": {"hackathonId": "{{var:capHackId}}"}}, {"actor": "fatima.khoury", "method": "hackathon.HackathonService/Join", "params": {"hackathonId": "{{var:capHackId}}"}}, {"actor": "giulia.ricci", "method": "hackathon.HackathonService/Join", "params": {"hackathonId": "{{var:capHackId}}"}}, {"actor": "hiro.tanaka", "method": "hackathon.HackathonService/Join", "params": {"hackathonId": "{{var:capHackId}}"}}], "race": {"ok": 4}, "todo": "Join's seat check is check-then-act (count confirmed, then insert), serialized by HackathonService.capacityMu - without the lock, simultaneous joins for the last seat all counted it free (and on SQLite broke outright with 'database table is locked'). All four calls succeed BY DESIGN: the losers are queued, not refused, so race.ok alone cannot catch an oversell - act2.cap.roster below is the real assertion."} +{"id": "act2.cap.roster", "priority": "P1", "implement": true, "outcome": "Succeeds; roster shows 6 on the list, exactly 3 confirmed (capacity, never oversold), 3 queued.", "act": 2, "t": "T-3mo", "title": "END STATE of the race: confirmed EQUALS capacity - the last seat sold once", "actor": "hackagon-admin", "action": "rpc", "method": "hackathon.HackathonService/Get", "params": {"hackathonId": "{{var:capHackId}}"}, "expect": {"ok": true, "check": "roster", "checkArgs": {"total": 6, "approved": 3, "waiting": 3}}} +{"id": "act2.cap.join.full", "priority": "P1", "implement": true, "outcome": "Succeeds with waitlisted=true and queue position 4 - joining a full event is NOT an error, and the response says exactly where Mei stands.", "act": 2, "t": "T-3mo", "title": "Mei joins the FULL sprint and is told she is number 4 in the queue", "actor": "mei.chen", "action": "rpc", "method": "hackathon.HackathonService/Join", "params": {"hackathonId": "{{var:capHackId}}"}, "expect": {"ok": true, "check": "joinOutcome", "checkArgs": {"waitlisted": true, "position": 4}}} +{"id": "act2.cap.remove", "priority": "P1", "implement": true, "outcome": "Succeeds - Dana's confirmed place frees up (2 of 3 seats taken again).", "act": 2, "t": "T-3mo", "title": "Dana's plans change - the organizer removes her and a seat FREES", "actor": "hackagon-admin", "action": "rpc", "method": "hackathon.HackathonService/RemoveParticipant", "params": {"hackathonId": "{{var:capHackId}}", "userId": "{{userId:dana.moser}}"}, "expect": {"ok": true}} +{"id": "act2.cap.nojump", "priority": "P1", "implement": true, "outcome": "Succeeds with waitlisted=true and queue position 5 - a free seat with four people already waiting belongs to the QUEUE, not to whoever clicks Join next.", "act": 2, "t": "T-3mo", "title": "charles joins while a seat is free but four people wait - he may NOT jump the queue", "actor": "charles", "action": "rpc", "method": "hackathon.HackathonService/Join", "params": {"hackathonId": "{{var:capHackId}}"}, "expect": {"ok": true, "check": "joinOutcome", "checkArgs": {"waitlisted": true, "position": 5}}} +{"id": "act2.cap.ui.queued", "priority": "P1", "implement": true, "outcome": "The dashboard shows 'SDSC Capacity Pilot Sprint' with the 'Waitlisted' membership badge - a participant can tell they are queued, not in.", "act": 2, "t": "T-3mo", "title": "charles's dashboard says where he stands on the pilot sprint: Waitlisted", "actor": "charles", "action": "ui.assert", "assert": "dashboardBadge", "params": {"name": "SDSC Capacity Pilot Sprint", "badge": "Waitlisted"}} +{"id": "act2.cap.noautopromote", "priority": "P1", "implement": true, "outcome": "Succeeds; roster shows 7 on the list, still only 2 confirmed, 5 queued - the freed seat was handed to NOBODY automatically.", "act": 2, "t": "T-3mo", "title": "the freed seat stays free: nobody is auto-promoted off the waiting list", "actor": "hackagon-admin", "action": "rpc", "method": "hackathon.HackathonService/Get", "params": {"hackathonId": "{{var:capHackId}}"}, "expect": {"ok": true, "check": "roster", "checkArgs": {"total": 7, "approved": 2, "waiting": 5}}, "todo": "Auto-promotion is a deliberate NON-feature: no notification exists to tell the promoted person, and queue-order-versus-organizer's-pick belongs to whoever can see the room (see capacity.go). If promotion ever becomes automatic this turns red and forces the fairness discussion."} +{"id": "act2.cap.approve.fill", "priority": "P1", "implement": true, "outcome": "Succeeds - the organizer hands the freed seat to Mei BY HAND (3 of 3 confirmed; queue order advises, it does not bind).", "act": 2, "t": "T-3mo", "title": "the organizer gives the freed seat to Mei - promotion is a human decision", "actor": "hackagon-admin", "action": "rpc", "method": "hackathon.HackathonService/ApproveParticipant", "params": {"hackathonId": "{{var:capHackId}}", "userId": "{{userId:mei.chen}}"}, "expect": {"ok": true}} +{"id": "act2.cap.approve.over", "priority": "P1", "implement": true, "outcome": "Succeeds - approving PAST capacity works (4 confirmed of 3). The cap is the organizer's estimate of the room, not the platform's law.", "act": 2, "t": "T-3mo", "title": "the room fits one more: the organizer approves charles PAST capacity", "actor": "hackagon-admin", "action": "rpc", "method": "hackathon.HackathonService/ApproveParticipant", "params": {"hackathonId": "{{var:capHackId}}", "userId": "{{userId:charles}}"}, "expect": {"ok": true}} +{"id": "act2.cap.roster.final", "priority": "P1", "implement": true, "outcome": "Succeeds; roster shows 7 on the list, 4 confirmed - one OVER the capacity of 3, deliberately - and 3 still queued.", "act": 2, "t": "T-3mo", "title": "the books after the overshoot: 4 confirmed of capacity 3, on purpose", "actor": "hackagon-admin", "action": "rpc", "method": "hackathon.HackathonService/Get", "params": {"hackathonId": "{{var:capHackId}}"}, "expect": {"ok": true, "check": "roster", "checkArgs": {"total": 7, "approved": 4, "waiting": 3}}} +{"id": "act2.cap.ui.gauge", "priority": "P1", "implement": true, "outcome": "The participants page states 'Over capacity: 4 confirmed of 3 places.' - the overshoot is visible, so approving past the cap is a decision, never an accident.", "act": 2, "t": "T-3mo", "title": "the organizer SEES the overshoot on the participants page", "actor": "hackagon-admin", "action": "ui.assert", "assert": "capacityGauge", "params": {"hackathonId": "{{var:capHackId}}", "textContains": ["Over capacity", "4 confirmed of 3 places"]}} +{"id": "act2.cap.ui.in", "priority": "P1", "implement": true, "outcome": "The dashboard shows 'SDSC Capacity Pilot Sprint' with the 'Member' badge - the same row that said Waitlisted now says he is in.", "act": 2, "t": "T-3mo", "title": "charles's dashboard flips from Waitlisted to Member on the pilot sprint", "actor": "charles", "action": "ui.assert", "assert": "dashboardBadge", "params": {"name": "SDSC Capacity Pilot Sprint", "badge": "Member"}} +{"comment": "── ACT 3 — T-2 months: PROJECT PROPOSALS DUE ───────────────────────"} +{"id": "act3.propose.fair", "priority": "P1", "implement": true, "outcome": "Succeeds. Returns projectFair for later steps.", "act": 3, "t": "T-2mo", "title": "bob proposes 'FAIR Pipeline Builder' on the Data Science track", "actor": "bob", "action": "rpc", "method": "hackathon.ProjectService/Propose", "params": {"hackathonId": "{{hackathonId}}", "trackId": "{{var:trackDS}}", "description": "Automated pipeline that converts raw research data into FAIR-compliant open datasets with provenance tracking.", "title": "FAIR Pipeline Builder"}, "save": {"projectFair": "projectId"}, "expect": {"ok": true}} +{"id": "act3.propose.litdata", "priority": "P1", "implement": true, "outcome": "Succeeds. Returns projectLitdata for later steps.", "act": 3, "t": "T-2mo", "title": "Dana proposes 'LitData Extractor' on the RDI track", "actor": "dana.moser", "action": "rpc", "method": "hackathon.ProjectService/Propose", "params": {"hackathonId": "{{hackathonId}}", "trackId": "{{var:trackRDI}}", "description": "Automatic extraction of tabular data from published literature into open repositories.", "title": "LitData Extractor"}, "save": {"projectLitdata": "projectId"}, "expect": {"ok": true}} +{"id": "act3.propose.genomelens", "priority": "P1", "implement": true, "outcome": "Succeeds. Returns projectGenomelens for later steps.", "act": 3, "t": "T-2mo", "title": "Erik proposes 'GenomeLens' on the Data Science track", "actor": "erik.lindqvist", "action": "rpc", "method": "hackathon.ProjectService/Propose", "params": {"hackathonId": "{{hackathonId}}", "trackId": "{{var:trackDS}}", "description": "Interactive visualization of genomic variants powered by open reference data.", "title": "GenomeLens"}, "save": {"projectGenomelens": "projectId"}, "expect": {"ok": true}} +{"id": "act3.approve.fair", "priority": "P1", "implement": true, "outcome": "Succeeds.", "act": 3, "t": "T-2mo", "title": "organizer reviews and approves 'FAIR Pipeline Builder'", "actor": "hackagon-admin", "action": "rpc", "method": "hackathon.ProjectService/Approve", "params": {"projectId": "{{var:projectFair}}"}, "expect": {"ok": true}} +{"id": "act3.approve.litdata", "priority": "P1", "implement": true, "outcome": "The organizer clicks Approve on the LitData card and the awaiting-review count drops from 2 to 1 ('GenomeLens' stays proposed).", "act": 3, "t": "T-2mo", "title": "organizer approves 'LitData Extractor' by clicking Approve on the projects page", "actor": "hackagon-admin", "action": "ui.flow", "steps": [{"goto": "/my/hackathon/{{hackathonId}}/projects"}, {"expectText": "2 awaiting review"}, {"clickSelector": "form[action='?/approve']:has(input[value='{{var:projectLitdata}}']) button"}, {"expectText": "1 awaiting review"}], "todo": "The click must change the COUNT, not merely fire: a control wired to an RPC that always refuses looks identical to a working one in any test that only checks a request was made."} +{"id": "act3.rogue", "priority": "P1", "implement": true, "outcome": "Rejected with PermissionDenied - no state change.", "act": 3, "t": "T-2mo", "title": "a non-registrant cannot approve proposals", "actor": "bob", "action": "rpc", "method": "hackathon.ProjectService/Approve", "params": {"projectId": "{{var:projectGenomelens}}"}, "expect": {"error": "PermissionDenied"}} +{"id": "act3.propose.sensor", "priority": "P1", "implement": true, "outcome": "Succeeds. Returns projectSensor for later steps.", "act": 3, "t": "T-2mo", "title": "WITHDRAWN LATER: Hiro proposes 'Sensor Mesh Atlas'…", "actor": "hiro.tanaka", "action": "rpc", "method": "hackathon.ProjectService/Propose", "params": {"hackathonId": "{{hackathonId}}", "trackId": "{{var:trackDS}}", "description": "Open atlas of environmental sensor meshes across Switzerland.", "title": "Sensor Mesh Atlas"}, "save": {"projectSensor": "projectId"}, "expect": {"ok": true}} +{"id": "act3.withdraw", "priority": "P1", "implement": true, "outcome": "Succeeds.", "act": 3, "t": "T-2mo", "title": "…then changes his mind and withdraws it (deletes his own proposal)", "actor": "hiro.tanaka", "action": "rpc", "method": "hackathon.ProjectService/Delete", "params": {"projectId": "{{var:projectSensor}}"}, "expect": {"ok": true}} +{"id": "act3.edit.fair", "priority": "P1", "implement": true, "outcome": "Succeeds.", "act": 3, "t": "T-2mo", "title": "bob edits his proposal description before the deadline", "actor": "bob", "action": "rpc", "method": "hackathon.ProjectService/Edit", "params": {"projectId": "{{var:projectFair}}", "description": "Automated pipeline converting raw research data into FAIR-compliant open datasets — now with provenance tracking AND schema inference."}, "expect": {"ok": true}} +{"id": "act3.propose.anonymous", "priority": "P1", "implement": true, "outcome": "Rejected with Unauthenticated - no state change.", "act": 3, "t": "T-2mo", "title": "anonymous visitors cannot propose projects", "actor": "anonymous", "action": "rpc", "method": "hackathon.ProjectService/Propose", "params": {"hackathonId": "{{hackathonId}}", "trackId": "{{var:trackDS}}", "title": "drive-by proposal"}, "expect": {"error": "Unauthenticated"}} +{"id": "act3.approve.ghost", "priority": "P1", "implement": true, "outcome": "Rejected with NotFound - no state change.", "act": 3, "t": "T-2mo", "title": "approving a non-existent proposal fails cleanly", "actor": "hackagon-admin", "action": "rpc", "method": "hackathon.ProjectService/Approve", "params": {"projectId": "00000000-0000-0000-0000-000000000000"}, "expect": {"error": "NotFound"}} +{"id": "act3.propose.waitlisted", "priority": "P1", "implement": true, "outcome": "Succeeds. Returns projectMetadata for later steps.", "act": 3, "t": "T-2mo", "title": "waitlisted Katya proposes 'Metadata Commons' (policy: waitlisted may propose?)", "actor": "katya.volkova", "action": "rpc", "method": "hackathon.ProjectService/Propose", "params": {"hackathonId": "{{hackathonId}}", "trackId": "{{var:trackRDI}}", "description": "Shared metadata registry for Swiss research datasets.", "title": "Metadata Commons"}, "save": {"projectMetadata": "projectId"}, "expect": {"ok": true}} +{"id": "act3.ui.proposals", "priority": "P2", "implement": true, "outcome": "The proposals page shows approved and pending proposals with their status.", "act": 3, "t": "T-2mo", "title": "approved proposals are published on the proposals page (organizer view - registrants are waitlisted until act 5)", "actor": "hackagon-admin", "action": "ui.assert", "assert": "proposalsPage", "params": {"approved": ["FAIR Pipeline Builder", "LitData Extractor"], "proposed": ["GenomeLens"]}} +{"comment": "── ACT 4 — T-1.5 months: TEAMS ARRANGEMENT + T-1 month: WEBINARS ────"} +{"id": "act4.pref.bob", "priority": "P1", "implement": true, "outcome": "Succeeds.", "act": 4, "t": "T-1.5mo", "title": "bob marks his preferred project", "actor": "bob", "action": "rpc", "method": "hackathon.ProjectService/SetPreference", "params": {"projectId": "{{var:projectFair}}"}, "expect": {"ok": true}} +{"id": "act4.pref.dana", "priority": "P1", "implement": true, "outcome": "Succeeds.", "act": 4, "t": "T-1.5mo", "title": "Dana ranks her project preferences", "actor": "dana.moser", "action": "rpc", "method": "hackathon.ProjectService/SetPreference", "params": {"projectId": "{{var:projectLitdata}}"}, "expect": {"ok": true}} +{"id": "act4.export", "priority": "P1", "implement": true, "outcome": "Succeeds. [Skips until the gated capability lands.]", "act": 4, "t": "T-1.5mo", "title": "organizer exports preferences for team matching", "actor": "hackagon-admin", "action": "rpc", "method": "hackathon.ProjectService/ExportPreferences", "params": {"hackathonId": "{{hackathonId}}"}, "expect": {"ok": true}, "todo": "TODO: placeholder until ExportPreferences exists."} +{"id": "act4.team.matterhorn", "priority": "P1", "implement": true, "outcome": "Succeeds. Returns teamMatterhorn for later steps.", "act": 4, "t": "T-1.5mo", "title": "organizer creates Team Matterhorn on 'FAIR Pipeline Builder'", "actor": "hackagon-admin", "action": "rpc", "method": "hackathon.TeamService/Create", "params": {"name": "Team Matterhorn", "projectId": "{{var:projectFair}}"}, "save": {"teamMatterhorn": "teamId"}, "expect": {"ok": true}} +{"id": "act4.team.bernina", "priority": "P1", "implement": true, "outcome": "Succeeds. Returns teamBernina for later steps.", "act": 4, "t": "T-1.5mo", "title": "organizer creates Team Bernina on 'LitData Extractor'", "actor": "hackagon-admin", "action": "rpc", "method": "hackathon.TeamService/Create", "params": {"name": "Team Bernina", "projectId": "{{var:projectLitdata}}"}, "save": {"teamBernina": "teamId"}, "expect": {"ok": true}} +{"id": "act4.team.anon", "priority": "P1", "implement": true, "outcome": "Rejected with Unauthenticated - no team is created. It used to answer Internal, \"user not found\": TeamService admitted the anonymous subject the auth interceptor injects, looked up a User row for keycloak_id \"anonymous\", missed, and reported the miss as a server fault. Internal means \"we broke\": it tells a client to retry something that can never work, and it buries real faults among routine unauthenticated traffic.", "act": 4, "t": "T-1.5mo", "title": "DENIED: an anonymous caller cannot create a team", "actor": "anonymous", "action": "rpc", "method": "hackathon.TeamService/Create", "params": {"name": "Team Drive-By", "projectId": "{{var:projectFair}}"}, "expect": {"error": "Unauthenticated"}} +{"id": "act4.assign.bob", "priority": "P1", "implement": true, "outcome": "Succeeds.", "act": 4, "t": "T-1.5mo", "title": "bob is assigned to Team Matterhorn (initial teams communicated)", "actor": "hackagon-admin", "action": "rpc", "method": "hackathon.TeamService/AssignUser", "params": {"teamId": "{{var:teamMatterhorn}}", "userId": "{{userId:bob}}"}, "expect": {"ok": true}} +{"id": "act4.assign.alice", "priority": "P1", "implement": true, "outcome": "Succeeds.", "act": 4, "t": "T-1.5mo", "title": "alice is assigned to Team Matterhorn", "actor": "hackagon-admin", "action": "rpc", "method": "hackathon.TeamService/AssignUser", "params": {"teamId": "{{var:teamMatterhorn}}", "userId": "{{userId:alice}}"}, "expect": {"ok": true}} +{"id": "act4.assign.dana", "priority": "P1", "implement": true, "outcome": "Succeeds.", "act": 4, "t": "T-1.5mo", "title": "Dana is assigned to Team Bernina", "actor": "hackagon-admin", "action": "rpc", "method": "hackathon.TeamService/AssignUser", "params": {"teamId": "{{var:teamBernina}}", "userId": "{{userId:dana.moser}}"}, "expect": {"ok": true}} +{"id": "act4.assign.erik", "priority": "P1", "implement": true, "outcome": "Succeeds.", "act": 4, "t": "T-1.5mo", "title": "Erik is assigned to Team Bernina", "actor": "hackagon-admin", "action": "rpc", "method": "hackathon.TeamService/AssignUser", "params": {"teamId": "{{var:teamBernina}}", "userId": "{{userId:erik.lindqvist}}"}, "expect": {"ok": true}} +{"id": "act4.assign.anon", "priority": "P1", "implement": true, "outcome": "Rejected with Unauthenticated - Charles does not join Team Bernina.", "act": 4, "t": "T-1.5mo", "title": "DENIED: an anonymous caller cannot put someone on a team", "actor": "anonymous", "action": "rpc", "method": "hackathon.TeamService/AssignUser", "params": {"teamId": "{{var:teamBernina}}", "userId": "{{userId:charles}}"}, "expect": {"error": "Unauthenticated"}} +{"id": "act4.removeuser.anon", "priority": "P1", "implement": true, "outcome": "Rejected with Unauthenticated - Erik keeps his seat. Refused before the team is even looked up, so an anonymous caller cannot probe which team ids exist either.", "act": 4, "t": "T-1.5mo", "title": "DENIED: an anonymous caller cannot take someone off a team", "actor": "anonymous", "action": "rpc", "method": "hackathon.TeamService/RemoveUser", "params": {"teamId": "{{var:teamBernina}}", "userId": "{{userId:erik.lindqvist}}"}, "expect": {"error": "Unauthenticated"}} +{"id": "act4.pref.erik", "priority": "P1", "implement": true, "outcome": "Succeeds.", "act": 4, "t": "T-1.5mo", "title": "Erik marks his preferred project", "actor": "erik.lindqvist", "action": "rpc", "method": "hackathon.ProjectService/SetPreference", "params": {"projectId": "{{var:projectLitdata}}"}, "expect": {"ok": true}} +{"id": "act4.pref.update", "priority": "P1", "implement": true, "outcome": "Succeeds.", "act": 4, "t": "T-1.5mo", "title": "EDIT: bob changes his mind and adds another preference: his preferences", "actor": "bob", "action": "rpc", "method": "hackathon.ProjectService/SetPreference", "params": {"projectId": "{{var:projectLitdata}}"}, "expect": {"ok": true}} +{"id": "act4.window.prefclose", "priority": "P2", "implement": true, "outcome": "Succeeds - preferences are closed from here on.", "act": 4, "t": "T-1mo", "title": "the preference deadline passes - admin closes preferences", "actor": "hackagon-admin", "action": "rpc", "method": "hackathon.ConfigService/SetWindows", "params": {"hackathonId": "{{hackathonId}}", "preferencesClose": "{{now-1d}}"}, "expect": {"ok": true}} +{"id": "act4.window.preflate", "priority": "P2", "implement": true, "outcome": "Rejected with FailedPrecondition - no state change.", "act": 4, "t": "T-1.5mo", "title": "ENFORCEMENT: Katya submits preferences after the preference deadline — bounced", "actor": "katya.volkova", "action": "rpc", "method": "hackathon.ProjectService/SetPreference", "gate": ["hackathon.ConfigService/SetWindows", "hackathon.ProjectService/SetPreference"], "params": {"projectId": "{{var:projectFair}}"}, "expect": {"error": "FailedPrecondition"}} +{"id": "act4.team.placeholder", "priority": "P1", "implement": true, "outcome": "Succeeds. Returns teamPlaceholder for later steps.", "act": 4, "t": "T-1.5mo", "title": "CREATED THEN DELETED: admin drafts 'Team Placeholder' while sketching the split…", "actor": "hackagon-admin", "action": "rpc", "method": "hackathon.TeamService/Create", "params": {"name": "Team Placeholder", "projectId": "{{var:projectGenomelens}}"}, "save": {"teamPlaceholder": "teamId"}, "expect": {"ok": true}} +{"id": "act4.team.placeholder.delete", "priority": "P1", "implement": true, "outcome": "Succeeds.", "act": 4, "t": "T-1.5mo", "title": "…and deletes it again", "actor": "hackagon-admin", "action": "rpc", "method": "hackathon.TeamService/Delete", "params": {"id": "{{var:teamPlaceholder}}"}, "expect": {"ok": true}} +{"id": "act4.rebalance.add", "priority": "P1", "implement": true, "outcome": "Succeeds.", "act": 4, "t": "T-1.5mo", "title": "REBALANCING: Giulia is first assigned to Team Matterhorn…", "actor": "hackagon-admin", "action": "rpc", "method": "hackathon.TeamService/AssignUser", "params": {"teamId": "{{var:teamMatterhorn}}", "userId": "{{userId:giulia.ricci}}"}, "expect": {"ok": true}} +{"id": "act4.rebalance.remove", "priority": "P1", "implement": true, "outcome": "The organizer unassigns Giulia on the team board; her chip's unassign control disappears with her seat.", "act": 4, "t": "T-1.5mo", "title": "…then removed via the team board to balance team sizes…", "actor": "hackagon-admin", "action": "ui.flow", "steps": [{"goto": "/my/hackathon/{{hackathonId}}/teams/manage"}, {"clickButton": "Unassign Giulia Ricci"}, {"expectGoneSelector": "button[aria-label='Unassign Giulia Ricci']"}], "todo": "Unassign posts ?/move with an EMPTY toTeamId - the same empty-id-into-UUID-parse shape that broke 'Clear current phase'. The vanished control is the result-changed assertion; act4.rebalance.final then re-seats her over rpc."} +{"id": "act4.rebalance.final", "priority": "P1", "implement": true, "outcome": "Succeeds.", "act": 4, "t": "T-1.5mo", "title": "…and lands on Team Bernina", "actor": "hackagon-admin", "action": "rpc", "method": "hackathon.TeamService/AssignUser", "params": {"teamId": "{{var:teamBernina}}", "userId": "{{userId:giulia.ricci}}"}, "expect": {"ok": true}} +{"id": "act4.assign.hiro", "priority": "P1", "implement": true, "outcome": "Succeeds.", "act": 4, "t": "T-1.5mo", "title": "Hiro is assigned to Team Matterhorn (everyone confirmed gets a seat)", "actor": "hackagon-admin", "action": "rpc", "method": "hackathon.TeamService/AssignUser", "params": {"teamId": "{{var:teamMatterhorn}}", "userId": "{{userId:hiro.tanaka}}"}, "expect": {"ok": true}} +{"id": "act4.assign.ines", "priority": "P1", "implement": true, "outcome": "Succeeds.", "act": 4, "t": "T-1.5mo", "title": "Ines is assigned to Team Matterhorn", "actor": "hackagon-admin", "action": "rpc", "method": "hackathon.TeamService/AssignUser", "params": {"teamId": "{{var:teamMatterhorn}}", "userId": "{{userId:ines.duarte}}"}, "expect": {"ok": true}} +{"id": "act4.assign.fatima", "priority": "P1", "implement": true, "outcome": "Succeeds.", "act": 4, "t": "T-1.5mo", "title": "Fatima is assigned to Team Bernina (she will drop out at T-1wk)", "actor": "hackagon-admin", "action": "rpc", "method": "hackathon.TeamService/AssignUser", "params": {"teamId": "{{var:teamBernina}}", "userId": "{{userId:fatima.khoury}}"}, "expect": {"ok": true}} +{"id": "act4.team.edit", "priority": "P1", "implement": true, "outcome": "Succeeds.", "act": 4, "t": "T-1.5mo", "title": "EDIT: admin polishes Team Bernina's description", "actor": "hackagon-admin", "action": "rpc", "method": "hackathon.TeamService/Edit", "params": {"description": "Cross-institution team: EPFL + ETH, focused on literature data extraction.", "id": "{{var:teamBernina}}"}, "expect": {"ok": true}} +{"id": "act4.team.edit.anon", "priority": "P1", "implement": true, "outcome": "Rejected with Unauthenticated - the description the organizer wrote stands.", "act": 4, "t": "T-1.5mo", "title": "DENIED: an anonymous caller cannot rewrite a team's description", "actor": "anonymous", "action": "rpc", "method": "hackathon.TeamService/Edit", "params": {"id": "{{var:teamBernina}}", "description": "drive-by edit"}, "expect": {"error": "Unauthenticated"}} +{"id": "act4.team.delete.anon", "priority": "P1", "implement": true, "outcome": "Rejected with Unauthenticated - Team Bernina survives, which the rest of the story proves: it submits, is voted on and takes a prize.", "act": 4, "t": "T-1.5mo", "title": "DENIED: an anonymous caller cannot delete a team", "actor": "anonymous", "action": "rpc", "method": "hackathon.TeamService/Delete", "params": {"id": "{{var:teamBernina}}"}, "expect": {"error": "Unauthenticated"}} +{"id": "act4.ui.teams", "priority": "P2", "implement": true, "outcome": "The teams page lists each team with exactly its expected members.", "act": 4, "t": "T-1.5mo", "title": "teams and their members are visible on the teams page (organizer view - bob is still waitlisted until act 5)", "actor": "hackagon-admin", "action": "ui.assert", "assert": "teamsPage", "params": {"teams": {"Team Matterhorn": ["Bob Henderson", "Alice Wonderland", "Hiro Tanaka", "Ines Duarte"], "Team Bernina": ["Dana Moser", "Erik Lindqvist", "Giulia Ricci", "Fatima Khoury"]}}} +{"id": "act4.webinars", "priority": "P1", "implement": true, "outcome": "Succeeds. Returns pageWebinars for later steps. [Skips until the gated capability lands.]", "act": 4, "t": "T-1mo", "title": "pre-event webinar page published (2 sessions, recordings linked)", "actor": "hackagon-admin", "action": "rpc", "method": "hackathon.PageService/Create", "params": {"hackathonId": "{{hackathonId}}", "title": "Pre-event webinars", "content": "Session 1 (Data pipelines, 1.5h) and Session 2 (Repro tooling, 1.5h). Recordings: https://media.example.org/hackagon-2027/webinar-1 and /webinar-2.", "visible": true}, "save": {"pageWebinars": "pageId"}, "expect": {"ok": true}, "todo": "TODO: runs once PageService.Create lands."} +{"comment": "── ACT 5 — T-1 week: REGISTRATION CLOSES (approve 8, dropout, backfill) ──"} +{"id": "act5.approve.alice", "priority": "P1", "implement": true, "outcome": "Succeeds.", "act": 5, "t": "T-1wk", "title": "alice is approved off the waitlist", "actor": "hackagon-admin", "action": "rpc", "method": "hackathon.HackathonService/ApproveParticipant", "params": {"hackathonId": "{{hackathonId}}", "userId": "{{userId:alice}}"}, "expect": {"ok": true}} +{"id": "act5.approve.bob", "priority": "P1", "implement": true, "outcome": "Succeeds.", "act": 5, "t": "T-1wk", "title": "bob is approved", "actor": "hackagon-admin", "action": "rpc", "method": "hackathon.HackathonService/ApproveParticipant", "params": {"hackathonId": "{{hackathonId}}", "userId": "{{userId:bob}}"}, "expect": {"ok": true}} +{"id": "act5.approve.dana", "priority": "P1", "implement": true, "outcome": "Succeeds.", "act": 5, "t": "T-1wk", "title": "Dana is approved", "actor": "hackagon-admin", "action": "rpc", "method": "hackathon.HackathonService/ApproveParticipant", "params": {"hackathonId": "{{hackathonId}}", "userId": "{{userId:dana.moser}}"}, "expect": {"ok": true}} +{"id": "act5.approve.erik", "priority": "P1", "implement": true, "outcome": "Succeeds.", "act": 5, "t": "T-1wk", "title": "Erik is approved", "actor": "hackagon-admin", "action": "rpc", "method": "hackathon.HackathonService/ApproveParticipant", "params": {"hackathonId": "{{hackathonId}}", "userId": "{{userId:erik.lindqvist}}"}, "expect": {"ok": true}} +{"id": "act5.approve.fatima", "priority": "P1", "implement": true, "outcome": "Succeeds.", "act": 5, "t": "T-1wk", "title": "Fatima is approved", "actor": "hackagon-admin", "action": "rpc", "method": "hackathon.HackathonService/ApproveParticipant", "params": {"hackathonId": "{{hackathonId}}", "userId": "{{userId:fatima.khoury}}"}, "expect": {"ok": true}} +{"id": "act5.approve.giulia", "priority": "P1", "implement": true, "outcome": "Succeeds.", "act": 5, "t": "T-1wk", "title": "Giulia is approved", "actor": "hackagon-admin", "action": "rpc", "method": "hackathon.HackathonService/ApproveParticipant", "params": {"hackathonId": "{{hackathonId}}", "userId": "{{userId:giulia.ricci}}"}, "expect": {"ok": true}} +{"id": "act5.approve.hiro", "priority": "P1", "implement": true, "outcome": "Succeeds.", "act": 5, "t": "T-1wk", "title": "Hiro is approved", "actor": "hackagon-admin", "action": "rpc", "method": "hackathon.HackathonService/ApproveParticipant", "params": {"hackathonId": "{{hackathonId}}", "userId": "{{userId:hiro.tanaka}}"}, "expect": {"ok": true}} +{"id": "act5.approve.ines", "priority": "P1", "implement": true, "outcome": "Succeeds.", "act": 5, "t": "T-1wk", "title": "Ines is approved — capacity (8) reached", "actor": "hackagon-admin", "action": "rpc", "method": "hackathon.HackathonService/ApproveParticipant", "params": {"hackathonId": "{{hackathonId}}", "userId": "{{userId:ines.duarte}}"}, "expect": {"ok": true}} +{"id": "act5.approve.double", "priority": "P1", "implement": true, "outcome": "Succeeds.", "act": 5, "t": "T-1wk", "title": "a double-click on approve is harmless (idempotent re-approval of bob)", "actor": "hackagon-admin", "action": "rpc", "method": "hackathon.HackathonService/ApproveParticipant", "params": {"hackathonId": "{{hackathonId}}", "userId": "{{userId:bob}}"}, "expect": {"ok": true}} +{"id": "act5.roster.full", "priority": "P1", "implement": true, "outcome": "Succeeds; roster shows 13 on the list, 8 approved, 5 waitlisted.", "act": 5, "t": "T-1wk", "title": "roster: 8 approved, 5 waitlisted (roster includes the organizer)", "actor": "hackagon-admin", "action": "rpc", "method": "hackathon.HackathonService/Get", "params": {"hackathonId": "{{hackathonId}}"}, "expect": {"ok": true, "check": "roster", "checkArgs": {"total": 14, "approved": 9, "waiting": 5}}} +{"id": "act5.ui.member", "priority": "P1", "implement": true, "outcome": "The dashboard shows 'SDSC Open Research Data Hackathon 2027' with the 'Member' membership badge.", "act": 5, "t": "T-1wk", "title": "bob's badge flips to Member", "actor": "bob", "action": "ui.assert", "assert": "dashboardBadge", "params": {"name": "SDSC Open Research Data Hackathon 2027", "badge": "Member"}} +{"id": "act5.ui.open", "priority": "P1", "implement": true, "outcome": "Opening the member view returns HTTP 200.", "act": 5, "t": "T-1wk", "title": "the member view opens for approved members", "actor": "bob", "action": "ui.assert", "assert": "memberViewStatus", "params": {"status": 200}} +{"id": "act5.ui.about", "priority": "P1", "implement": true, "outcome": "The member overview About section shows 'Max capacity'.", "act": 5, "t": "T-1wk", "title": "the About section shows the real announcement", "actor": "bob", "action": "ui.assert", "assert": "aboutVisible", "params": {"textContains": "Max capacity"}} +{"id": "act5.flow.bob", "priority": "P1", "implement": true, "outcome": "The 14-step browsing chain completes, ending showing 'About'. The landing page stays reachable while signed in; the dashboard is reached explicitly.", "act": 5, "t": "T-1wk", "title": "member tour chain: home → dashboard → overview → Participants → Timeline → Overview", "actor": "bob", "action": "ui.flow", "steps": [{"goto": "/"}, {"expectUrl": "trycloudflare|localhost:8081/$"}, {"goto": "/dashboard"}, {"clickLink": "SDSC Open Research Data Hackathon 2027"}, {"expectUrl": "/overview$"}, {"expectText": "SDSC Open Research Data Hackathon 2027"}, {"expectText": "Member"}, {"clickLink": "Participants"}, {"expectUrl": "/participants$"}, {"expectHeading": "Participants"}, {"clickLink": "Timeline"}, {"expectUrl": "/timeline$"}, {"clickLink": "Overview"}, {"expectUrl": "/overview$"}, {"expectText": "About"}]} +{"id": "act5.flow.admin", "priority": "P1", "implement": true, "outcome": "The 5-step browsing chain completes, ending showing 'SDSC Open Research Data Hackathon 2027'. The landing page stays reachable while signed in; the dashboard is reached explicitly.", "act": 5, "t": "T-1wk", "title": "admin chain: dashboard → click event (not a participant) → straight into the member view via the admin escape hatch", "actor": "hackagon-admin", "action": "ui.flow", "steps": [{"goto": "/"}, {"expectUrl": "trycloudflare|localhost:8081/$"}, {"goto": "/dashboard"}, {"clickLink": "SDSC Open Research Data Hackathon 2027"}, {"expectUrl": "/overview$"}, {"expectText": "SDSC Open Research Data Hackathon 2027"}]} +{"id": "act5.flow.alice", "priority": "P1", "implement": true, "outcome": "The 8-step browsing chain completes, ending at a URL matching '/webinars$'. The landing page stays reachable while signed in; the dashboard is reached explicitly.", "act": 5, "t": "T-1wk", "title": "alice's member tour: home → dashboard → overview → Teams → Webinars", "actor": "alice", "action": "ui.flow", "steps": [{"goto": "/"}, {"expectUrl": "trycloudflare|localhost:8081/$"}, {"goto": "/dashboard"}, {"clickLink": "SDSC Open Research Data Hackathon 2027"}, {"expectUrl": "/overview$"}, {"clickLink": "Teams"}, {"expectUrl": "/teams$"}, {"clickLink": "Webinars"}, {"expectUrl": "/webinars$"}]} +{"id": "act5.flow.search", "priority": "P1", "implement": true, "outcome": "The 7-step browsing chain completes, ending showing 'No participants match your search.'.", "act": 5, "t": "T-1wk", "title": "ABANDONED FORM: bob types a participant search, gets no matches, leaves without clearing it", "actor": "bob", "action": "ui.flow", "steps": [{"goto": "/dashboard"}, {"clickLink": "SDSC Open Research Data Hackathon 2027"}, {"clickLink": "Participants"}, {"expectUrl": "/participants$"}, {"fill": {"selector": "input[type=search]", "value": "quantum blockchain"}}, {"expectText": "No participants match your search."}, {"goto": "/dashboard"}]} +{"id": "act5.pref.reopen", "priority": "P2", "implement": true, "outcome": "Succeeds - the preference window is open again for the late-approved cohort.", "act": 5, "t": "T-1wk", "title": "FORMS: approvals landed after preferences closed, so the organizer reopens the window for a day", "actor": "hackagon-admin", "action": "rpc", "method": "hackathon.ConfigService/SetWindows", "params": {"hackathonId": "{{hackathonId}}", "preferencesClose": "{{now+1d}}"}, "expect": {"ok": true}} +{"id": "act5.flow.prefer", "priority": "P2", "implement": true, "outcome": "Alice clicks Prefer on her project and the 'Preferred' badge appears - the control does what it says.", "act": 5, "t": "T-1wk", "title": "alice stars 'FAIR Pipeline Builder' through the projects page", "actor": "alice", "action": "ui.flow", "steps": [{"goto": "/my/hackathon/{{hackathonId}}/projects"}, {"clickSelector": "form[action='?/prefer']:has(input[value='{{var:projectFair}}']) button"}, {"expectText": "Preferred"}], "todo": "The un-prefer sibling of this control shipped calling an organizer-only RPC without the argument it requires, so it ALWAYS failed. A browser click plus a result assertion is the only test shape that notices that class of bug."} +{"id": "act5.pref.close", "priority": "P2", "implement": true, "outcome": "Succeeds - preferences are closed again, so act4.window.preflate's pin (late preferences bounce) holds from here on.", "act": 5, "t": "T-1wk", "title": "FORMS: the reopened preference window is closed again", "actor": "hackagon-admin", "action": "rpc", "method": "hackathon.ConfigService/SetWindows", "params": {"hackathonId": "{{hackathonId}}", "preferencesClose": "{{now-1d}}"}, "expect": {"ok": true}} +{"id": "act5.dropout.before", "priority": "P1", "implement": true, "outcome": "Succeeds.", "act": 5, "t": "T-1wk", "title": "Fatima (confirmed) can access the event before dropping out", "actor": "fatima.khoury", "action": "rpc", "method": "hackathon.HackathonService/Get", "params": {"hackathonId": "{{hackathonId}}"}, "expect": {"ok": true}} +{"id": "act5.dropout.remove", "priority": "P1", "implement": true, "outcome": "Succeeds.", "act": 5, "t": "T-1wk", "title": "Fatima cancels a week before the event and is removed", "actor": "hackagon-admin", "action": "rpc", "method": "hackathon.HackathonService/RemoveParticipant", "params": {"hackathonId": "{{hackathonId}}", "userId": "{{userId:fatima.khoury}}"}, "expect": {"ok": true}} +{"id": "act5.dropout.after", "priority": "P1", "implement": true, "outcome": "Rejected with PermissionDenied - no state change.", "act": 5, "t": "T-1wk", "title": "Fatima loses access immediately (row deleted, role revoked)", "actor": "fatima.khoury", "action": "rpc", "method": "hackathon.HackathonService/Get", "params": {"hackathonId": "{{hackathonId}}"}, "expect": {"error": "PermissionDenied"}} +{"id": "act5.dropout.team", "priority": "P1", "implement": true, "outcome": "Succeeds.", "act": 5, "t": "T-1wk", "title": "…and her seat on Team Bernina is cleared", "actor": "hackagon-admin", "action": "rpc", "method": "hackathon.TeamService/RemoveUser", "params": {"teamId": "{{var:teamBernina}}", "userId": "{{userId:fatima.khoury}}"}, "expect": {"ok": true}} +{"id": "act5.backfill", "priority": "P1", "implement": true, "outcome": "Both concurrent Approve calls succeed and Jonas is approved exactly once - the waitlist-to-member transition is double-click-safe at network speed.", "act": 5, "t": "T-1wk", "title": "RACE: Jonas moves up from the waitlist - the organizer's double-click fires the approval twice at once", "action": "rpc.race", "calls": [{"actor": "hackagon-admin", "method": "hackathon.HackathonService/ApproveParticipant", "params": {"hackathonId": "{{hackathonId}}", "userId": "{{userId:jonas.weber}}"}}, {"actor": "hackagon-admin", "method": "hackathon.HackathonService/ApproveParticipant", "params": {"hackathonId": "{{hackathonId}}", "userId": "{{userId:jonas.weber}}"}}], "race": {"ok": 2}, "todo": "act5.roster.final below is the end-state read: 8 approved, not 9 - a double-approve that inserted a second participant row would break its counts."} +{"id": "act5.backfill.access", "priority": "P1", "implement": true, "outcome": "Succeeds.", "act": 5, "t": "T-1wk", "title": "Jonas has member access now", "actor": "jonas.weber", "action": "rpc", "method": "hackathon.HackathonService/Get", "params": {"hackathonId": "{{hackathonId}}"}, "expect": {"ok": true}} +{"id": "act5.backfill.team", "priority": "P1", "implement": true, "outcome": "Succeeds.", "act": 5, "t": "T-1wk", "title": "Jonas takes Fatima's seat on Team Bernina", "actor": "hackagon-admin", "action": "rpc", "method": "hackathon.TeamService/AssignUser", "params": {"teamId": "{{var:teamBernina}}", "userId": "{{userId:jonas.weber}}"}, "expect": {"ok": true}} +{"id": "act5.roster.final", "priority": "P1", "implement": true, "outcome": "Succeeds; roster shows 12 on the list, 8 approved, 4 waitlisted.", "act": 5, "t": "T-1wk", "title": "final list confirmed: 8 approved, 4 waitlisted, 12 total (roster includes the organizer)", "actor": "hackagon-admin", "action": "rpc", "method": "hackathon.HackathonService/Get", "params": {"hackathonId": "{{hackathonId}}"}, "expect": {"ok": true, "check": "roster", "checkArgs": {"total": 13, "approved": 9, "waiting": 4}}} +{"comment": "── ACT 5 (cont.) — the organizer's own screens: the manage hub, the flat Manage nav, the capability switches, bulk team composition ──"} +{"id": "act5.hub.tiles", "priority": "P1", "implement": true, "outcome": "The hub tiles all nine organizer destinations and never itself, states 'Review 4 waiting' beside the approval queue, offers Edit details - and with no phases on the schedule the Now/Next box says so and offers no action at all.", "act": 5, "t": "T-1wk", "title": "MANAGE HUB: the organizer's landing page tiles every screen it leads to", "actor": "hackagon-admin", "action": "ui.assert", "assert": "manageHub", "params": {"tiles": [{"label": "Manage Tracks", "href": "/tracks"}, {"label": "Manage Teams", "href": "/teams/manage"}, {"label": "New Phase", "href": "/timeline/new"}, {"label": "Manage Pages", "href": "/pages"}, {"label": "Prizes", "href": "/prizes"}, {"label": "Deadlines", "href": "/windows"}, {"label": "Manage Forms", "href": "/forms"}, {"label": "Notifications", "href": "/email"}, {"label": "Invitation Links", "href": "/invites"}], "tileCount": 9, "reviewWaiting": 4, "mayEdit": true, "nowBadge": null, "next": null, "phaseAction": null, "clearMarker": false}, "todo": "The tiles are asserted INSIDE the tile grid: the sidebar renders the same nine labels on this very page, so a page-wide locator is satisfied by the nav and would pass with no tiles rendered at all."} +{"id": "act5.hub.rogue", "priority": "P1", "implement": true, "outcome": "403 - the hub is gated on the same owner-or-admin rule that decides whether the sidebar offers it, so the nav can never offer a link the page then refuses.", "act": 5, "t": "T-1wk", "title": "a participant opening the manage hub is politely refused", "actor": "bob", "action": "ui.flow", "steps": [{"goto": "/my/hackathon/{{hackathonId}}/manage", "status": 403}]} +{"id": "act5.hub.review.click", "priority": "P1", "implement": true, "outcome": "Clicking the prompt lands on the participants screen where the four waiting people can actually be approved.", "act": 5, "t": "T-1wk", "title": "MANAGE HUB: the approval prompt leads to the queue it counts", "actor": "hackagon-admin", "action": "ui.flow", "steps": [{"goto": "/my/hackathon/{{hackathonId}}/manage"}, {"clickLink": "Review 4 waiting"}, {"expectUrl": "/participants$"}, {"expectHeading": "All Participants"}, {"expectText": "Charles Whitfield"}]} +{"id": "act5.hub.edit.click", "priority": "P1", "implement": true, "outcome": "Edit details opens the hackathon's own record - the one organizer form that is not among the tiles, because it changes the event rather than something inside it.", "act": 5, "t": "T-1wk", "title": "MANAGE HUB: Edit details opens the event's own record", "actor": "hackagon-admin", "action": "ui.flow", "steps": [{"goto": "/my/hackathon/{{hackathonId}}/manage"}, {"clickLink": "Edit details"}, {"expectUrl": "/manage/edit$"}, {"expectHeading": "Edit Hackathon"}, {"expectText": "SDSC Open Research Data Hackathon 2027"}]} +{"id": "act5.flow.reach.manage", "priority": "P1", "implement": true, "outcome": "The organizer gets from the dashboard to a deadline form entirely by CLICKING: event row, then the hub on the Manage rail, then the tile - and the back button returns to the hub.", "act": 5, "t": "T-1wk", "title": "REACHABILITY: dashboard → event → Manage Hackathon → hub → Deadlines, by clicking", "actor": "hackagon-admin", "action": "ui.flow", "steps": [{"goto": "/dashboard"}, {"clickLink": "SDSC Open Research Data Hackathon 2027"}, {"expectUrl": "/my/hackathon/[0-9a-f-]+/overview$"}, {"clickLink": "Manage Hackathon"}, {"expectUrl": "/manage$"}, {"expectHeading": "Manage Hackathon"}, {"clickSelector": "xpath=//main//section[.//span[normalize-space()='Manage']]//a[normalize-space()='Deadlines']"}, {"expectUrl": "/windows$"}, {"expectHeading": "Deadlines"}, {"back": true}, {"expectUrl": "/manage$"}], "todo": "Re-specified 2026-08-14 for develop's 942b60a7: the Manage rail is flat, so there is no disclosure to open on the way through and the chain is one click shorter. The claim is unchanged and is the reason the action exists - a goto proves the route works, only a click proves you can GET there (.claude/CLAUDE.md, pass 1)."} +{"id": "act5.flow.hub.back", "priority": "P2", "implement": true, "outcome": "Opening Edit details and pressing Back returns to the hub with the event untouched - the form is a detour, not a commitment.", "act": 5, "t": "T-1wk", "title": "SECOND THOUGHTS: the organizer opens Edit details and backs out", "actor": "hackagon-admin", "action": "ui.flow", "steps": [{"goto": "/my/hackathon/{{hackathonId}}/manage"}, {"clickLink": "Edit details"}, {"expectHeading": "Edit Hackathon"}, {"back": true}, {"expectUrl": "/manage$"}, {"expectHeading": "Manage Hackathon"}]} +{"id": "act5.hub.title.check", "priority": "P2", "implement": true, "outcome": "Succeeds; the event's name is exactly as act 1 left it - backing out of a form must not have written anything.", "act": 5, "t": "T-1wk", "title": "END STATE: backing out of the edit form changed nothing", "actor": "hackagon-admin", "action": "rpc", "method": "hackathon.HackathonService/Get", "params": {"hackathonId": "{{hackathonId}}"}, "expect": {"ok": true, "check": "hackathonField", "checkArgs": {"nameEquals": "SDSC Open Research Data Hackathon 2027"}}} +{"id": "act5.nav.fold", "priority": "P1", "implement": true, "outcome": "The Manage section is flat: every organizer entry sits on the rail directly, on a participant page and inside the section alike, with no disclosure to open and nothing about the rail that changes when you walk into Manage.", "act": 5, "t": "T-1wk", "title": "SIDEBAR: the organizer entries are drawn flat, with no fold to open", "actor": "hackagon-admin", "action": "ui.assert", "assert": "sidebarManageFold", "params": {"entry": "Manage Pages", "insidePath": "tracks"}, "todo": "Re-specified 2026-08-14 for develop's 942b60a7, which removed the fold this action used to pin (it force-opened on entering /manage/*, so the disclosure was already open wherever an organizer used it). The claim carrying the weight is the EQUALITY of the rail outside and inside Manage: a per-page presence check passes against a fold too, once it is open, and this one opened itself on exactly those pages. If a disclosure is ever reintroduced, re-specify this action again rather than delete it."} +{"id": "act5.pilot.join.offered", "priority": "P1", "implement": true, "outcome": "The pilot sprint is open, so alice's dashboard offers Join and says nothing about being closed. This is the control for the refusal asserted below.", "act": 5, "t": "T-1wk", "title": "CONTROL: while registration is open the dashboard offers Join on the pilot sprint", "actor": "alice", "action": "ui.assert", "assert": "dashboardJoinOffered", "params": {"name": "SDSC Capacity Pilot Sprint"}} +{"id": "act5.pilot.phase.wrap", "priority": "P2", "implement": true, "outcome": "Succeeds. Returns pilotWrapPhase - a phase entirely in the future, so nothing is running on the pilot yet.", "act": 5, "t": "T-1wk", "title": "PILOT: the side sprint gets a wrap-up phase, next week", "actor": "hackagon-admin", "action": "rpc", "method": "hackathon.PhaseService/Create", "params": {"hackathonId": "{{var:capHackId}}", "name": "Pilot Wrap-up", "startsAt": "{{now+2d}}", "endsAt": "{{now+3d}}", "description": "Retrospective and hand-over of the capacity pilot."}, "save": {"pilotWrapPhase": "phaseId"}, "expect": {"ok": true}} +{"id": "act5.pilot.hub.start", "priority": "P1", "implement": true, "outcome": "Nothing is running and nothing is declared, so the one action offered is 'Start Pilot Wrap-up' - the case a single 'Advance to X' label used to collapse into a sentence naming a phase that appears nowhere on the page.", "act": 5, "t": "T-1wk", "title": "MANAGE HUB (case 3): with no phase live, the action is to START the first one", "actor": "hackagon-admin", "action": "ui.assert", "assert": "manageHub", "params": {"hackathonId": "{{var:capHackId}}", "tiles": [{"label": "Manage Tracks", "href": "/tracks"}, {"label": "Deadlines", "href": "/windows"}], "tileCount": 9, "reviewWaiting": 3, "mayEdit": true, "nowBadge": null, "next": "Pilot Wrap-up", "phaseAction": "Start Pilot Wrap-up", "clearMarker": false}} +{"id": "act5.pilot.phase.day", "priority": "P2", "implement": true, "outcome": "Succeeds. Returns pilotDayPhase - its dates cover today, so the hub now has a live phase nobody declared.", "act": 5, "t": "T-1wk", "title": "PILOT: the sprint evening itself goes on the schedule, covering today", "actor": "hackagon-admin", "action": "rpc", "method": "hackathon.PhaseService/Create", "params": {"hackathonId": "{{var:capHackId}}", "name": "Pilot Sprint Evening", "startsAt": "{{now-1d}}", "endsAt": "{{now+1d}}", "description": "The evening sprint: three seats, first come first served."}, "save": {"pilotDayPhase": "phaseId"}, "expect": {"ok": true}} +{"id": "act5.pilot.hub.declare", "priority": "P1", "implement": true, "outcome": "The dates say Pilot Sprint Evening is running and nobody declared it, so the Now box is badged 'By dates' and the action offered is to DECLARE that phase - advancing from a marker that does not exist would skip the live phase.", "act": 5, "t": "T-1wk", "title": "MANAGE HUB (case 2): a phase running by the calendar is offered for declaration", "actor": "hackagon-admin", "action": "ui.assert", "assert": "manageHub", "params": {"hackathonId": "{{var:capHackId}}", "reviewWaiting": 3, "now": "Pilot Sprint Evening", "nowBadge": "By dates", "next": "Pilot Wrap-up", "phaseAction": "Declare Pilot Sprint Evening current", "clearMarker": false}} +{"id": "act5.pilot.phase.advance", "priority": "P1", "implement": true, "outcome": "Declare, then Advance, then Clear: each click is judged on the box it moved - the badge flips to Declared, the marker lands on the wrap-up with nothing after it, and clearing hands the Now box back to the dates.", "act": 5, "t": "T-1wk", "title": "MANAGE HUB (case 1): declare, advance, then clear the marker", "actor": "hackagon-admin", "action": "ui.assert", "assert": "managePhaseAdvance", "params": {"hackathonId": "{{var:capHackId}}", "steps": [{"click": "Declare Pilot Sprint Evening current", "now": "Pilot Sprint Evening", "nowBadge": "Declared", "next": "Pilot Wrap-up", "action": "Advance to Pilot Wrap-up"}, {"click": "Advance to Pilot Wrap-up", "now": "Pilot Wrap-up", "nowBadge": "Declared", "next": null, "action": null}, {"click": "Clear the marker", "now": "Pilot Sprint Evening", "nowBadge": "By dates", "action": "Declare Pilot Sprint Evening current"}]}} +{"id": "act5.pilot.phase.readback", "priority": "P1", "implement": true, "outcome": "Succeeds; the pilot has no declared current phase - 'the button posted' and 'the marker is where I left it' are different claims.", "act": 5, "t": "T-1wk", "title": "END STATE of the marker clicks: the declaration really was cleared", "actor": "hackagon-admin", "action": "rpc", "method": "hackathon.HackathonService/Get", "params": {"hackathonId": "{{var:capHackId}}"}, "expect": {"ok": true, "check": "currentPhase", "checkArgs": {"declared": false}}} +{"id": "act5.pilot.cap.close", "priority": "P1", "implement": true, "outcome": "Succeeds; registration and voting are closed on the pilot while the rest stay open, so the panel below has more than one state to tell apart.", "act": 5, "t": "T-1wk", "title": "PILOT: the organizer closes registration and voting on the side sprint", "actor": "hackagon-admin", "action": "rpc", "method": "hackathon.HackathonService/SetCapabilities", "params": {"hackathonId": "{{var:capHackId}}", "capabilities": [{"capability": "CAPABILITY_REGISTER", "enabled": false}, {"capability": "CAPABILITY_VOTE", "enabled": false}, {"capability": "CAPABILITY_PROPOSE_PROJECTS", "enabled": true}, {"capability": "CAPABILITY_SET_TEAM_PREFERENCES", "enabled": true}, {"capability": "CAPABILITY_CREATE_PROJECT_SUBMISSIONS", "enabled": true}, {"capability": "CAPABILITY_VIEW_RESULTS", "enabled": true}]}, "expect": {"ok": true}} +{"id": "act5.pilot.cap.schedule", "priority": "P1", "implement": true, "outcome": "Succeeds; results are switched off AND linked to a phase that starts next week, which is the only way a capability can read 'Opens 14 Aug' rather than merely 'Closed'.", "act": 5, "t": "T-1wk", "title": "PILOT: results are scheduled for the wrap-up phase rather than closed outright", "actor": "hackagon-admin", "action": "rpc", "method": "hackathon.HackathonService/EditCapability", "params": {"hackathonId": "{{var:capHackId}}", "capability": "CAPABILITY_VIEW_RESULTS", "enabled": false, "openInPhaseId": "{{var:pilotWrapPhase}}"}, "expect": {"ok": true}, "todo": "Schedule links are display only - setting one never opens or closes anything, only `enabled` does. COMING is what the pair means: off now, and a date to count down to."} +{"id": "act5.pilot.cap.states", "priority": "P1", "implement": true, "outcome": "Three different words for three different situations: Closed for registration and voting, Open for proposing, and a DATE for results. The panel must show at least three distinct states - one word for everything is the bug it exists for.", "act": 5, "t": "T-1wk", "title": "CAPABILITIES: the panel tells Closed, Open and 'Opens later' apart", "actor": "hackagon-admin", "action": "ui.assert", "assert": "capabilityPanel", "params": {"hackathonId": "{{var:capHackId}}", "states": {"Register": "Closed", "Vote": "Closed", "Propose projects": "Open", "Set team preferences": "Open"}, "statePatterns": {"View results": "^Opens \\d"}, "checked": {"Register": false, "Vote": false, "Propose projects": true, "View results": false}, "distinctAtLeast": 3, "absentStates": ["Not governed"], "ungovernedWarning": false}, "todo": "'Not governed' is absent because HackathonService.Create seeds a row for all six capabilities, so nothing reachable through the app is ungoverned - the state is unreachable here by construction, and the three states present are the positive control for asserting its absence."} +{"id": "act5.pilot.cap.readback", "priority": "P1", "implement": true, "outcome": "Succeeds; the API resolves the same four-way answer the panel drew - CLOSED for registration and voting, OPEN for proposing, COMING for results.", "act": 5, "t": "T-1wk", "title": "END STATE: the server resolves the states the panel showed", "actor": "hackagon-admin", "action": "rpc", "method": "hackathon.HackathonService/Get", "params": {"hackathonId": "{{var:capHackId}}"}, "expect": {"ok": true, "check": "capabilityStates", "checkArgs": {"states": {"CAPABILITY_REGISTER": "CAPABILITY_STATE_CLOSED", "CAPABILITY_VOTE": "CAPABILITY_STATE_CLOSED", "CAPABILITY_PROPOSE_PROJECTS": "CAPABILITY_STATE_OPEN", "CAPABILITY_VIEW_RESULTS": "CAPABILITY_STATE_COMING"}}}} +{"id": "act5.pilot.join.closed", "priority": "P1", "implement": true, "outcome": "The row SAYS 'Registration closed' and offers no button: withholding the control silently reads as a rendering fault, and a Join that can only fail is worse than none.", "act": 5, "t": "T-1wk", "title": "JOIN GATE: with registration closed the dashboard says so instead of offering Join", "actor": "alice", "action": "ui.assert", "assert": "dashboardJoinClosed", "params": {"name": "SDSC Capacity Pilot Sprint"}} +{"id": "act5.pilot.join.rpc", "priority": "P1", "implement": true, "outcome": "Rejected with FailedPrecondition - the backend refuses for the same reason the button was withheld, which is what makes the withholding honest rather than a guess.", "act": 5, "t": "T-1wk", "title": "JOIN GATE: the backend refuses the join the dashboard declined to offer", "actor": "alice", "action": "rpc", "method": "hackathon.HackathonService/Join", "params": {"hackathonId": "{{var:capHackId}}"}, "expect": {"error": "FailedPrecondition"}} +{"id": "act5.pilot.cap.save", "priority": "P1", "implement": true, "outcome": "The form saves the whole set from the checkboxes: proposing closes, voting opens, and the panel confirms it in words before the badges are re-read.", "act": 5, "t": "T-1wk", "title": "CAPABILITIES: ticking two boxes and pressing Save changes the switches", "actor": "hackagon-admin", "action": "ui.assert", "assert": "capabilitySave", "params": {"hackathonId": "{{var:capHackId}}", "uncheck": ["Propose projects"], "check": ["Vote"], "after": {"Propose projects": "Closed", "Vote": "Open"}}, "todo": "Unchecked boxes submit NOTHING, so the six are rebuilt from the enum order server-side. A save that dropped that would look identical on screen until the badges are read back."} +{"id": "act5.pilot.cap.save.readback", "priority": "P1", "implement": true, "outcome": "Succeeds; proposing is CLOSED, voting OPEN, registration still CLOSED and results still COMING - a whole-set save must not flatten the scheduled one into a plain closed switch.", "act": 5, "t": "T-1wk", "title": "END STATE: the saved form kept the scheduled capability scheduled", "actor": "hackagon-admin", "action": "rpc", "method": "hackathon.HackathonService/Get", "params": {"hackathonId": "{{var:capHackId}}"}, "expect": {"ok": true, "check": "capabilityStates", "checkArgs": {"states": {"CAPABILITY_PROPOSE_PROJECTS": "CAPABILITY_STATE_CLOSED", "CAPABILITY_VOTE": "CAPABILITY_STATE_OPEN", "CAPABILITY_REGISTER": "CAPABILITY_STATE_CLOSED", "CAPABILITY_VIEW_RESULTS": "CAPABILITY_STATE_COMING"}}}} +{"id": "act5.pilot.cap.plan", "priority": "P1", "implement": true, "outcome": "Succeeds; the live phase now NAMES a capability that is switched off, which is the plan-vs-reality gap the hub warns about.", "act": 5, "t": "T-1wk", "title": "PILOT: team preferences are planned for the phase that is running, and switched off", "actor": "hackagon-admin", "action": "rpc", "method": "hackathon.HackathonService/EditCapability", "params": {"hackathonId": "{{var:capHackId}}", "capability": "CAPABILITY_SET_TEAM_PREFERENCES", "enabled": false, "openInPhaseId": "{{var:pilotDayPhase}}"}, "expect": {"ok": true}} +{"id": "act5.pilot.cap.unmet.bydates", "priority": "P1", "implement": true, "outcome": "The warning is offered for the phase the DATES say is live, with no marker set anywhere - and its one click works: team preferences come on, registration and proposing are untouched, and the warning goes away because it has nothing left to say.", "act": 5, "t": "T-1wk", "title": "CAPABILITIES: Enable-it works for a phase resolved by dates, with nothing declared", "actor": "hackagon-admin", "action": "ui.assert", "assert": "capabilityEnableUnmet", "params": {"hackathonId": "{{var:capHackId}}", "nowBadge": "By dates", "phase": "Pilot Sprint Evening", "names": ["set team preferences"], "button": "Enable it", "after": {"Set team preferences": "Open"}, "untouched": {"Register": "Closed", "Propose projects": "Closed"}}, "todo": "RE-SPECIFIED (was 'BUG PINNED: Enable-it is offered for a phase resolved by dates, and refuses'). It used to assert the 400 'no current phase to take settings from': the panel resolved the live phase with currentAndNextPhase, which falls back to the dates, while applyPhaseCapabilities looked it up by current_phase_id alone - so the control was offered exactly where it could not work. The action resolves it the same way now. `nowBadge` is the positive control: without it this passes against a DECLARED phase, which is the case act5.pilot.cap.unmet already covers."} +{"id": "act5.pilot.cap.bydates.reset", "priority": "P1", "implement": true, "outcome": "Succeeds; team preferences go back off, so the two actions below are once again about what ADVANCING does rather than about what the click above already did.", "act": 5, "t": "T-1wk", "title": "PILOT: the organizer undoes the by-dates enable before declaring anything", "actor": "hackagon-admin", "action": "rpc", "method": "hackathon.HackathonService/EditCapability", "params": {"hackathonId": "{{var:capHackId}}", "capability": "CAPABILITY_SET_TEAM_PREFERENCES", "enabled": false, "openInPhaseId": "{{var:pilotDayPhase}}"}, "expect": {"ok": true}, "todo": "Added when act5.pilot.cap.unmet.bydates was re-specified from a refusal into a working click. Without it act5.pilot.phase.declare.applied would assert OPEN against a capability that was already open - green whatever AdvancePhase did with it."} +{"id": "act5.pilot.cap.bydates.reset.readback", "priority": "P1", "implement": true, "outcome": "Succeeds; team preferences read CLOSED. This is the precondition the next two actions are judged against, so it is asserted rather than assumed.", "act": 5, "t": "T-1wk", "title": "END STATE: the by-dates enable really was undone", "actor": "hackagon-admin", "action": "rpc", "method": "hackathon.HackathonService/Get", "params": {"hackathonId": "{{var:capHackId}}"}, "expect": {"ok": true, "check": "capabilityStates", "checkArgs": {"states": {"CAPABILITY_SET_TEAM_PREFERENCES": "CAPABILITY_STATE_CLOSED"}}}} +{"id": "act5.pilot.phase.declare", "priority": "P1", "implement": true, "outcome": "Succeeds; the marker lands on the running phase AND applies what that phase opens - team preferences come on, which is the transaction advancing is supposed to carry.", "act": 5, "t": "T-1wk", "title": "PILOT: the organizer declares the running phase current, which applies its plan", "actor": "hackagon-admin", "action": "rpc", "method": "hackathon.HackathonService/AdvancePhase", "params": {"hackathonId": "{{var:capHackId}}", "phaseId": "{{var:pilotDayPhase}}"}, "expect": {"ok": true}} +{"id": "act5.pilot.phase.declare.applied", "priority": "P1", "implement": true, "outcome": "Succeeds; team preferences are OPEN because the phase the marker landed on names them - advancing applies the phase's plan in the same transaction that moves the marker, which is what makes the panel's copy true.", "act": 5, "t": "T-1wk", "title": "END STATE: advancing applied what the phase opens", "actor": "hackagon-admin", "action": "rpc", "method": "hackathon.HackathonService/Get", "params": {"hackathonId": "{{var:capHackId}}"}, "expect": {"ok": true, "check": "capabilityStates", "checkArgs": {"states": {"CAPABILITY_SET_TEAM_PREFERENCES": "CAPABILITY_STATE_OPEN"}}}} +{"id": "act5.pilot.cap.plan.again", "priority": "P1", "implement": true, "outcome": "Succeeds; the organizer switches the phase's own capability back off by hand, which is how a real gap between plan and reality appears - the timeline moved, then somebody changed their mind.", "act": 5, "t": "T-1wk", "title": "PILOT: …and then switches team preferences off again by hand", "actor": "hackagon-admin", "action": "rpc", "method": "hackathon.HackathonService/EditCapability", "params": {"hackathonId": "{{var:capHackId}}", "capability": "CAPABILITY_SET_TEAM_PREFERENCES", "enabled": false, "openInPhaseId": "{{var:pilotDayPhase}}"}, "expect": {"ok": true}, "todo": "That it really went off is read back by the action below: the plan-vs-reality warning can only appear while this capability is switched off AND named by the live phase."} +{"id": "act5.pilot.cap.unmet", "priority": "P1", "implement": true, "outcome": "The warning names the phase and the capability, the one click switches ONLY that on, and registration - which no phase names - is still closed afterwards. Nothing closes this gap automatically, which is the design.", "act": 5, "t": "T-1wk", "title": "CAPABILITIES: the hub names what the live phase expects and cannot do, and offers the one click", "actor": "hackagon-admin", "action": "ui.assert", "assert": "capabilityEnableUnmet", "params": {"hackathonId": "{{var:capHackId}}", "phase": "Pilot Sprint Evening", "names": ["set team preferences"], "button": "Enable it", "after": {"Set team preferences": "Open"}, "untouched": {"Register": "Closed", "Propose projects": "Closed"}}} +{"id": "act5.pilot.cap.unmet.readback", "priority": "P1", "implement": true, "outcome": "Succeeds; only the unmet capability moved. Applying a phase's plan is additive by design - it must never close registration as a side effect of catching up with the timeline.", "act": 5, "t": "T-1wk", "title": "END STATE: enabling the phase's plan switched nothing else", "actor": "hackagon-admin", "action": "rpc", "method": "hackathon.HackathonService/Get", "params": {"hackathonId": "{{var:capHackId}}"}, "expect": {"ok": true, "check": "capabilityStates", "checkArgs": {"states": {"CAPABILITY_SET_TEAM_PREFERENCES": "CAPABILITY_STATE_OPEN", "CAPABILITY_REGISTER": "CAPABILITY_STATE_CLOSED", "CAPABILITY_PROPOSE_PROJECTS": "CAPABILITY_STATE_CLOSED"}}}} +{"id": "act5.pilot.cap.refresh", "priority": "P1", "implement": true, "outcome": "The switches survive the refresh that follows every save, and the 'Saved.' line does not - a confirmation that outlives its POST is a page claiming something it cannot know.", "act": 5, "t": "T-1wk", "title": "AFTER A SAVE: the organizer refreshes the page to be sure", "actor": "hackagon-admin", "action": "ui.assert", "assert": "capabilitySave", "params": {"hackathonId": "{{var:capHackId}}", "uncheck": ["Set team preferences"], "check": ["Propose projects"], "after": {"Set team preferences": "Closed", "Propose projects": "Open", "Register": "Closed"}, "reload": true}} +{"id": "act5.pilot.cap.refresh.readback", "priority": "P1", "implement": true, "outcome": "Succeeds; proposing is OPEN again, team preferences are CLOSED and registration is still CLOSED - which is the state act 8's join gate is asserted against.", "act": 5, "t": "T-1wk", "title": "END STATE after the refresh: the stored switches match the screen", "actor": "hackagon-admin", "action": "rpc", "method": "hackathon.HackathonService/Get", "params": {"hackathonId": "{{var:capHackId}}"}, "expect": {"ok": true, "check": "capabilityStates", "checkArgs": {"states": {"CAPABILITY_PROPOSE_PROJECTS": "CAPABILITY_STATE_OPEN", "CAPABILITY_SET_TEAM_PREFERENCES": "CAPABILITY_STATE_CLOSED", "CAPABILITY_REGISTER": "CAPABILITY_STATE_CLOSED"}}}} +{"id": "act5.cap.dupe", "priority": "P2", "implement": true, "outcome": "Succeeds - a batch naming the same capability twice is resolved into a map, so the LAST value wins rather than the call being refused.", "act": 5, "t": "T-1wk", "title": "PARAMS: one batch names Vote twice, with opposite values", "actor": "hackagon-admin", "action": "rpc", "method": "hackathon.HackathonService/SetCapabilities", "params": {"hackathonId": "{{var:capHackId}}", "capabilities": [{"capability": "CAPABILITY_VOTE", "enabled": true}, {"capability": "CAPABILITY_VOTE", "enabled": false}]}, "expect": {"ok": true}} +{"id": "act5.cap.dupe.check", "priority": "P2", "implement": true, "outcome": "Succeeds; voting is CLOSED - the second entry won. 'The call returned OK' says nothing about which of two conflicting values landed.", "act": 5, "t": "T-1wk", "title": "END STATE of the duplicated batch: last writer wins", "actor": "hackagon-admin", "action": "rpc", "method": "hackathon.HackathonService/Get", "params": {"hackathonId": "{{var:capHackId}}"}, "expect": {"ok": true, "check": "capabilityStates", "checkArgs": {"states": {"CAPABILITY_VOTE": "CAPABILITY_STATE_CLOSED"}}}} +{"id": "act5.cap.empty", "priority": "P2", "implement": true, "outcome": "Succeeds and changes nothing - an empty batch is a no-op, not an error, so a form that submits no ticks cannot fail on its way to closing everything.", "act": 5, "t": "T-1wk", "title": "PARAMS: an empty capability batch", "actor": "hackagon-admin", "action": "rpc", "method": "hackathon.HackathonService/SetCapabilities", "params": {"hackathonId": "{{var:capHackId}}", "capabilities": []}, "expect": {"ok": true, "check": "capabilityStates", "checkArgs": {"states": {"CAPABILITY_VOTE": "CAPABILITY_STATE_CLOSED", "CAPABILITY_REGISTER": "CAPABILITY_STATE_CLOSED"}}}} +{"id": "act5.cap.unknown", "priority": "P2", "implement": true, "outcome": "Rejected with InvalidArgument, and nothing in the batch is written: every capability is resolved BEFORE the first row is touched, so a typo cannot apply the prefix before it.", "act": 5, "t": "T-1wk", "title": "PARAMS: a batch naming a capability the enum does not have", "actor": "hackagon-admin", "action": "rpc", "method": "hackathon.HackathonService/SetCapabilities", "params": {"hackathonId": "{{var:capHackId}}", "capabilities": [{"capability": "CAPABILITY_UNSPECIFIED", "enabled": true}]}, "expect": {"error": "InvalidArgument"}} +{"id": "act5.cap.badid", "priority": "P2", "implement": true, "outcome": "Rejected with InvalidArgument - a malformed hackathon id fails before any permission is even considered.", "act": 5, "t": "T-1wk", "title": "PARAMS: a malformed hackathon id on the capability batch", "actor": "hackagon-admin", "action": "rpc", "method": "hackathon.HackathonService/SetCapabilities", "params": {"hackathonId": "not-a-uuid", "capabilities": [{"capability": "CAPABILITY_VOTE", "enabled": true}]}, "expect": {"error": "InvalidArgument"}} +{"id": "act5.cap.ungoverned", "priority": "P2", "implement": true, "outcome": "Rejected with NotFound, and the message names the HACKATHON. A batch against an id that exists is never refused over a missing capability row any more - the row is created - so the only NotFound left here is 'no such hackathon', and nothing may be written for an id that names nothing.", "act": 5, "t": "T-1wk", "title": "PARAMS: a capability batch against a hackathon that does not exist", "actor": "hackagon-admin", "action": "rpc", "method": "hackathon.HackathonService/SetCapabilities", "params": {"hackathonId": "00000000-0000-0000-0000-000000000000", "capabilities": [{"capability": "CAPABILITY_VOTE", "enabled": true}]}, "expect": {"error": "NotFound", "errorMatches": "hackathon .* not found"}, "todo": "RE-SPECIFIED (was 'setting a capability that has no stored row refuses the whole batch'). SetCapabilities used to answer NotFound if ANY capability in the batch was ungoverned, and the organiser's panel posts all six - so one missing row made the whole capability screen unusable. It creates the row now. This id still refuses, but for the other reason, and `errorMatches` is what keeps the two apart: the same code from the same request meant a missing capability before and a missing hackathon now. The upsert itself is unreachable from the API (Create seeds all six and no RPC deletes one), so it is pinned in Go instead - hackathon_service_test.go, 'SetCapabilities with an ungoverned capability', which deletes a row to get there."} +{"id": "act5.cap.rogue", "priority": "P1", "implement": true, "outcome": "Rejected with PermissionDenied - the switches are hackathon Write, and a participant does not hold it.", "act": 5, "t": "T-1wk", "title": "a participant cannot change what participants may do", "actor": "bob", "action": "rpc", "method": "hackathon.HackathonService/SetCapabilities", "params": {"hackathonId": "{{var:capHackId}}", "capabilities": [{"capability": "CAPABILITY_REGISTER", "enabled": true}]}, "expect": {"error": "PermissionDenied"}} +{"id": "act5.cap.anon", "priority": "P1", "implement": true, "outcome": "Rejected with Unauthenticated - 'who are you' and 'not you' are different answers, and an anonymous caller gets the first one.", "act": 5, "t": "T-1wk", "title": "an anonymous caller cannot change the capability switches", "actor": "anonymous", "action": "rpc", "method": "hackathon.HackathonService/SetCapabilities", "params": {"hackathonId": "{{var:capHackId}}", "capabilities": [{"capability": "CAPABILITY_REGISTER", "enabled": true}]}, "expect": {"error": "Unauthenticated"}} +{"id": "act5.import.template", "priority": "P1", "implement": true, "outcome": "Both formats download as files, carry the columns the importer reads and this event's REAL roster - and the importer accepts the very file the page just handed out, with nothing left to apply.", "act": 5, "t": "T-1wk", "title": "TEAM IMPORT: the template downloads, and its own importer accepts it", "actor": "hackagon-admin", "action": "ui.assert", "assert": "teamImportTemplate", "params": {"mustContain": ["bob@mail.org", "hiro.tanaka@example.org", "FAIR Pipeline Builder", "Team Matterhorn"], "roundTrip": true}, "todo": "Two halves shipped separately drift apart in one commit, and the failure surfaces only when a real organizer downloads one - so the template is not merely parsed here, it is fed straight back in through the UI."} +{"id": "act5.import.badmail", "priority": "P1", "implement": true, "outcome": "The unknown address is named by email and the good row is still resolved and shown - then NOTHING is offered to apply, and the roster afterwards proves the good half did not sneak through.", "act": 5, "t": "T-1wk", "title": "TEAM IMPORT: one unknown email blocks the whole file, including its good rows", "actor": "hackagon-admin", "action": "ui.assert", "assert": "teamImportPreview", "params": {"file": {"name": "mixed.csv", "body": "user_email,project,team\r\nhiro.tanaka@example.org,LitData Extractor,Team Bernina\r\nnobody@example.org,LitData Extractor,Team Bernina\r\n"}, "outcomes": ["no participant of this hackathon has the email \"nobody@example.org\"", "moves from \"Team Matterhorn\" to \"Team Bernina\""], "rowsAtLeast": 2, "alertContains": ["1 of 2 rows in mixed.csv cannot be applied", "Nothing has been changed"], "noApply": true, "badges": {"Hiro Tanaka": "Team Matterhorn"}}} +{"id": "act5.import.badproject", "priority": "P1", "implement": true, "outcome": "A different answer to a different question: the person resolved and the project did not, said in those words rather than as 'row 1 failed'.", "act": 5, "t": "T-1wk", "title": "TEAM IMPORT: a project this event does not have is named", "actor": "hackagon-admin", "action": "ui.assert", "assert": "teamImportPreview", "params": {"file": {"name": "badproject.csv", "body": "user_email,project,team\r\nhiro.tanaka@example.org,Quantum Blockchain,Team Q\r\n"}, "outcomes": ["no project of this hackathon is titled \"Quantum Blockchain\""], "noApply": true, "badges": {"Hiro Tanaka": "Team Matterhorn"}}} +{"id": "act5.import.dupe", "priority": "P1", "implement": true, "outcome": "Two rows for one person have no defensible resolution - everyone belongs to at most one team - so the row says how many times the address appears instead of silently letting the last line win.", "act": 5, "t": "T-1wk", "title": "TEAM IMPORT: the same person on two rows is refused, not resolved", "actor": "hackagon-admin", "action": "ui.assert", "assert": "teamImportPreview", "params": {"file": {"name": "twice.csv", "body": "user_email,project,team\r\nhiro.tanaka@example.org,LitData Extractor,Team Bernina\r\nhiro.tanaka@example.org,FAIR Pipeline Builder,Team Matterhorn\r\n"}, "outcomeCounts": {"appears on 2 rows": 2}, "rowsAtLeast": 2, "noApply": true, "badges": {"Hiro Tanaka": "Team Matterhorn"}}, "todo": "BOTH rows must be refused, not just the second: 'last one wins' would silently pick a team for someone. The count is the claim, so it is asserted as a count."} +{"id": "act5.import.waitlisted", "priority": "P1", "implement": true, "outcome": "A waitlisted person is not staffable: the row says to approve them first, which is a different problem from an unknown address and gets a different sentence.", "act": 5, "t": "T-1wk", "title": "TEAM IMPORT: someone still on the waiting list cannot be put on a team", "actor": "hackagon-admin", "action": "ui.assert", "assert": "teamImportPreview", "params": {"file": {"name": "waiting.csv", "body": "user_email,project,team\r\ncharles@mail.net,LitData Extractor,Team Bernina\r\n"}, "outcomes": ["is on the waiting list"], "noApply": true}} +{"id": "act5.import.halfrow", "priority": "P2", "implement": true, "outcome": "A team with no project and a project with no team are two different mistakes, and each row says which column is missing rather than reporting a generic parse failure.", "act": 5, "t": "T-1wk", "title": "TEAM IMPORT: a half-filled row names the column it is missing", "actor": "hackagon-admin", "action": "ui.assert", "assert": "teamImportPreview", "params": {"file": {"name": "halves.csv", "body": "user_email,project,team\r\nhiro.tanaka@example.org,,Team Orphan\r\nines.duarte@example.org,LitData Extractor,\r\n"}, "outcomes": ["needs a project", "needs a team name"], "rowsAtLeast": 2, "noApply": true}} +{"id": "act5.import.semicolon", "priority": "P2", "implement": true, "outcome": "A semicolon-separated export (what Excel writes under a comma-decimal locale) is read as three columns, so a European spreadsheet is not silently one column of nonsense.", "act": 5, "t": "T-1wk", "title": "TEAM IMPORT: a semicolon-separated file is read as columns", "actor": "hackagon-admin", "action": "ui.assert", "assert": "teamImportPreview", "params": {"file": {"name": "european.csv", "body": "user_email;project;team\r\nhiro.tanaka@example.org;LitData Extractor;Team Bernina\r\n"}, "outcomes": ["moves from \"Team Matterhorn\" to \"Team Bernina\""], "statusContains": ["european.csv: 1 row", "1 moved onto an existing team", "Nothing has been changed yet"], "badges": {"Hiro Tanaka": "Team Matterhorn"}}} +{"id": "act5.import.newteam", "priority": "P2", "implement": true, "outcome": "A team the event does not have yet is planned as a NEW team - shown, counted and named, and still nothing is written until Apply.", "act": 5, "t": "T-1wk", "title": "TEAM IMPORT: a team the event does not have is previewed as a creation", "actor": "hackagon-admin", "action": "ui.assert", "assert": "teamImportPreview", "params": {"file": {"name": "newteam.csv", "body": "user_email,project,team\r\nhiro.tanaka@example.org,LitData Extractor,Team Import Squad\r\n"}, "outcomes": ["into a new team \"Team Import Squad\" under \"LitData Extractor\""], "statusContains": ["1 moved onto a new team"], "badges": {"Hiro Tanaka": "Team Matterhorn"}}} +{"id": "act5.import.malformed", "priority": "P1", "implement": true, "outcome": "The header is wrong, so the file is refused as a FILE - it names the columns it needs instead of reporting every row as unresolvable.", "act": 5, "t": "T-1wk", "title": "TEAM IMPORT: a spreadsheet saved with the wrong headers is refused, by name", "actor": "hackagon-admin", "action": "ui.assert", "assert": "teamImportPreview", "params": {"file": {"name": "wrong-headers.csv", "body": "Email address;Idea;Group\r\nhiro.tanaka@example.org;LitData Extractor;Team Bernina\r\n"}, "alertContains": ["wrong-headers.csv", "user_email"], "noApply": true, "badges": {"Hiro Tanaka": "Team Matterhorn"}}} +{"id": "act5.import.bom", "priority": "P2", "implement": true, "outcome": "The byte-order mark Excel writes is stripped rather than becoming part of the first column name, which would otherwise lose the email column of every file it saves.", "act": 5, "t": "T-1wk", "title": "TEAM IMPORT: the same file re-saved by Excel, byte-order mark and all", "actor": "hackagon-admin", "action": "ui.assert", "assert": "teamImportPreview", "params": {"file": {"name": "excel.csv", "body": "user_email,project,team\r\nhiro.tanaka@example.org,FAIR Pipeline Builder,Team Matterhorn\r\n"}, "outcomes": ["already on \"Team Matterhorn\""], "noAlert": true, "nothingToApply": true, "badges": {"Hiro Tanaka": "Team Matterhorn"}}} +{"id": "act5.import.accents", "priority": "P2", "implement": true, "outcome": "A team named in French with an em dash survives the round trip through the file and is quoted back verbatim in the plan - a name is not ASCII, and a preview that mangles one cannot be trusted about anything else.", "act": 5, "t": "T-1wk", "title": "TEAM IMPORT: a team named 'Équipe Léman — Rive Sud' is previewed intact", "actor": "hackagon-admin", "action": "ui.assert", "assert": "teamImportPreview", "params": {"file": {"name": "leman.csv", "body": "user_email,project,team\r\nines.duarte@example.org,LitData Extractor,\"Équipe Léman — Rive Sud\"\r\n"}, "outcomes": ["into a new team \"Équipe Léman — Rive Sud\" under \"LitData Extractor\""], "badges": {"Ines Duarte": "Team Matterhorn"}}} +{"id": "act5.import.apply", "priority": "P1", "implement": true, "outcome": "A JSON file with a rows wrapper is applied: Hiro leaves Matterhorn and joins Bernina, the banner counts what the server MANAGED rather than what it planned, and the people panel badge says where he ended up.", "act": 5, "t": "T-1wk", "title": "TEAM IMPORT: applying a JSON file moves someone between teams", "actor": "hackagon-admin", "action": "ui.assert", "assert": "teamImportPreview", "params": {"file": {"name": "move.json", "mimeType": "application/json", "body": "{\"rows\":[{\"user_email\":\"hiro.tanaka@example.org\",\"project\":\"LitData Extractor\",\"team\":\"Team Bernina\"}]}"}, "outcomes": ["moves from \"Team Matterhorn\" to \"Team Bernina\""], "apply": "Apply 1 change", "appliedContains": ["Applied 1 of 1 changes from move.json"], "badges": {"Hiro Tanaka": "Team Bernina"}}} +{"id": "act5.import.revert", "priority": "P1", "implement": true, "outcome": "The same file shape in the other direction puts him back, which is what makes a downloaded roster editable rather than one-way.", "act": 5, "t": "T-1wk", "title": "TEAM IMPORT: a second file moves him back, from CSV this time", "actor": "hackagon-admin", "action": "ui.assert", "assert": "teamImportPreview", "params": {"file": {"name": "revert.csv", "body": "user_email,project,team\r\nhiro.tanaka@example.org,FAIR Pipeline Builder,Team Matterhorn\r\n"}, "outcomes": ["moves from \"Team Bernina\" to \"Team Matterhorn\""], "apply": "Apply 1 change", "appliedContains": ["Applied 1 of 1 changes from revert.csv"], "badges": {"Hiro Tanaka": "Team Matterhorn"}}} +{"id": "act5.import.again", "priority": "P1", "implement": true, "outcome": "The organizer uploads the file a second time - the reflex after a save that did not obviously land. Every row is now 'No change' and there is nothing to apply, so a double submit cannot double-move anyone.", "act": 5, "t": "T-1wk", "title": "DOUBLE SUBMIT: the same import file, uploaded again", "actor": "hackagon-admin", "action": "ui.assert", "assert": "teamImportPreview", "params": {"file": {"name": "revert.csv", "body": "user_email,project,team\r\nhiro.tanaka@example.org,FAIR Pipeline Builder,Team Matterhorn\r\n"}, "outcomes": ["already on \"Team Matterhorn\""], "nothingToApply": true, "noAlert": true, "badges": {"Hiro Tanaka": "Team Matterhorn"}}} +{"id": "act5.import.readback", "priority": "P1", "implement": true, "outcome": "Succeeds; both teams hold exactly the people the story left them with - 'Applied 1 of 1' and 'the roster is what it was' are different claims, and only the second one is what act 6 depends on.", "act": 5, "t": "T-1wk", "title": "END STATE of the import round trip: the roster is back where it started", "actor": "hackagon-admin", "action": "rpc", "method": "hackathon.TeamService/List", "params": {"hackathonId": "{{hackathonId}}"}, "expect": {"ok": true, "check": "teamComposition", "checkArgs": {"teams": {"Team Matterhorn": ["Bob Henderson", "Alice Wonderland", "Hiro Tanaka", "Ines Duarte"], "Team Bernina": ["Dana Moser", "Erik Lindqvist", "Giulia Ricci", "Jonas Weber"]}}}} +{"id": "act5.approve.ghost", "priority": "P1", "implement": true, "outcome": "Rejected with NotFound - no state change.", "act": 5, "t": "T-1wk", "title": "admin mistakenly re-approves the dropout — she is gone (NotFound)", "actor": "hackagon-admin", "action": "rpc", "method": "hackathon.HackathonService/ApproveParticipant", "params": {"hackathonId": "{{hackathonId}}", "userId": "{{userId:fatima.khoury}}"}, "expect": {"error": "NotFound"}} +{"id": "act5.remove.ghost", "priority": "P1", "implement": true, "outcome": "Rejected with NotFound - no state change.", "act": 5, "t": "T-1wk", "title": "removing her twice also fails cleanly", "actor": "hackagon-admin", "action": "rpc", "method": "hackathon.HackathonService/RemoveParticipant", "params": {"hackathonId": "{{hackathonId}}", "userId": "{{userId:fatima.khoury}}"}, "expect": {"error": "NotFound"}} +{"id": "act5.approve.badid", "priority": "P1", "implement": true, "outcome": "Rejected with InvalidArgument - no state change.", "act": 5, "t": "T-1wk", "title": "a malformed approve request is rejected", "actor": "hackagon-admin", "action": "rpc", "method": "hackathon.HackathonService/ApproveParticipant", "params": {"hackathonId": "{{hackathonId}}", "userId": "not-a-uuid"}, "expect": {"error": "InvalidArgument"}} +{"id": "act5.window.regclose", "priority": "P2", "implement": true, "outcome": "Succeeds - the registration window is now closed.", "act": 5, "t": "T-1wk", "title": "T-1 week: registration closes as announced", "actor": "hackagon-admin", "action": "rpc", "method": "hackathon.ConfigService/SetWindows", "params": {"hackathonId": "{{hackathonId}}", "registrationCloses": "{{now-1d}}"}, "expect": {"ok": true}} +{"id": "act5.window.regclosed", "priority": "P2", "implement": true, "outcome": "Rejected with FailedPrecondition - no state change.", "act": 5, "t": "T-1wk", "title": "ENFORCEMENT: the registration window is closed — a late signup bounces (no override given)", "actor": "hackagon-admin", "action": "rpc", "method": "hackathon.HackathonService/Join", "gate": "hackathon.ConfigService/SetWindows", "params": {"hackathonId": "{{hackathonId}}"}, "expect": {"error": "FailedPrecondition"}} +{"id": "act5.ui.charles", "priority": "P1", "implement": true, "outcome": "The dashboard shows 'SDSC Open Research Data Hackathon 2027' with the 'Waitlisted' membership badge.", "act": 5, "t": "T-1wk", "title": "charles stays waitlisted", "actor": "charles", "action": "ui.assert", "assert": "dashboardBadge", "params": {"name": "SDSC Open Research Data Hackathon 2027", "badge": "Waitlisted"}} +{"id": "act5.ui.charles.locked", "priority": "P1", "implement": true, "outcome": "Opening the member view returns HTTP 403.", "act": 5, "t": "T-1wk", "title": "charles is still locked out of the member view", "actor": "charles", "action": "ui.assert", "assert": "memberViewStatus", "params": {"status": 403}} +{"id": "act5.rogue.approve", "priority": "P1", "implement": true, "outcome": "Rejected with PermissionDenied - no state change.", "act": 5, "t": "T-1wk", "title": "a mere member cannot approve participants", "actor": "bob", "action": "rpc", "method": "hackathon.HackathonService/ApproveParticipant", "params": {"hackathonId": "{{hackathonId}}", "userId": "{{userId:charles}}"}, "expect": {"error": "PermissionDenied"}} +{"id": "act5.rogue.remove", "priority": "P1", "implement": true, "outcome": "Rejected with PermissionDenied - no state change.", "act": 5, "t": "T-1wk", "title": "a mere member cannot remove participants", "actor": "bob", "action": "rpc", "method": "hackathon.HackathonService/RemoveParticipant", "params": {"hackathonId": "{{hackathonId}}", "userId": "{{userId:charles}}"}, "expect": {"error": "PermissionDenied"}} +{"id": "act5.owner.rogue", "priority": "P1", "implement": true, "outcome": "Rejected with PermissionDenied - a member cannot hand out ownership.", "act": 5, "t": "T-1wk", "title": "a mere member cannot appoint a co-organizer", "actor": "bob", "action": "rpc", "method": "hackathon.HackathonService/AddOwner", "params": {"hackathonId": "{{hackathonId}}", "userId": "{{userId:charles}}"}, "expect": {"error": "PermissionDenied"}} +{"id": "act5.owner.waitlisted", "priority": "P1", "implement": true, "outcome": "Rejected with FailedPrecondition - approve them first.", "act": 5, "t": "T-1wk", "title": "a waitlisted person cannot be made an organizer", "actor": "hackagon-admin", "action": "rpc", "method": "hackathon.HackathonService/AddOwner", "params": {"hackathonId": "{{hackathonId}}", "userId": "{{userId:charles}}"}, "expect": {"error": "FailedPrecondition"}, "todo": "Ownership is a casbin role while the member list is built from the participants table, so granting it to someone outside that table makes an owner absent from the roster."} +{"id": "act5.owner.promote", "priority": "P1", "implement": true, "outcome": "Succeeds; Alice is a co-organizer.", "act": 5, "t": "T-1wk", "title": "the admin recruits Alice as co-organizer", "actor": "hackagon-admin", "action": "rpc", "method": "hackathon.HackathonService/AddOwner", "params": {"hackathonId": "{{hackathonId}}", "userId": "{{userId:alice}}"}, "expect": {"ok": true}} +{"id": "act5.owner.alice.hub", "priority": "P1", "implement": true, "outcome": "The co-organizer gets the same hub the admin does - the section is role-gated, not person-gated - including the approval prompt and the Edit details link.", "act": 5, "t": "T-1wk", "title": "CO-ORGANIZER: Alice can now manage the event she joined as a participant", "actor": "alice", "action": "ui.assert", "assert": "manageHub", "params": {"tiles": [{"label": "Manage Teams", "href": "/teams/manage"}, {"label": "Manage Forms", "href": "/forms"}], "tileCount": 9, "reviewWaiting": 4, "mayEdit": true, "nowBadge": null, "next": null, "phaseAction": null}} +{"id": "act5.owner.alice.media", "priority": "P1", "implement": true, "outcome": "Succeeds - an organizer may list their own event's uploads, because they may write them; the rule is one rule, not two kept in agreement.", "act": 5, "t": "T-1wk", "title": "CO-ORGANIZER: Alice may list her own event's media", "actor": "alice", "action": "rpc", "method": "storage.StorageService/ListObjects", "params": {"scope": "OBJECT_SCOPE_HACKATHON_MEDIA", "ownerId": "{{hackathonId}}"}, "expect": {"ok": true}} +{"id": "act5.owner.alice.other.media", "priority": "P1", "implement": true, "outcome": "Rejected with PermissionDenied - being an organizer somewhere is not being an organizer everywhere, and the listing scope stops exactly where the write permission does.", "act": 5, "t": "T-1wk", "title": "CO-ORGANIZER: Alice may NOT list another event's media", "actor": "alice", "action": "rpc", "method": "storage.StorageService/ListObjects", "params": {"scope": "OBJECT_SCOPE_HACKATHON_MEDIA", "ownerId": "{{var:capHackId}}"}, "expect": {"error": "PermissionDenied"}} +{"id": "act5.owner.alice.other.cap", "priority": "P1", "implement": true, "outcome": "Rejected with PermissionDenied - the switches are scoped to one hackathon's domain, so a co-organizer cannot close registration on somebody else's event.", "act": 5, "t": "T-1wk", "title": "CO-ORGANIZER: Alice cannot touch the pilot sprint's switches", "actor": "alice", "action": "rpc", "method": "hackathon.HackathonService/SetCapabilities", "params": {"hackathonId": "{{var:capHackId}}", "capabilities": [{"capability": "CAPABILITY_REGISTER", "enabled": true}]}, "expect": {"error": "PermissionDenied"}} +{"id": "act5.owner.alice.other.hub", "priority": "P1", "implement": true, "outcome": "403 - and the same refusal on the screen: an organizer typing another event's manage URL is told no by the layer that decides, not shown a hub that half-works.", "act": 5, "t": "T-1wk", "title": "CO-ORGANIZER: the pilot sprint's manage hub is not Alice's to open", "actor": "alice", "action": "ui.flow", "steps": [{"goto": "/my/hackathon/{{var:capHackId}}/manage", "status": 403}]} +{"id": "act5.owner.self", "priority": "P1", "implement": true, "outcome": "Rejected with PermissionDenied - even with a co-organizer to fall back on.", "act": 5, "t": "T-1wk", "title": "an organizer cannot demote themselves", "actor": "hackagon-admin", "action": "rpc", "method": "hackathon.HackathonService/RemoveOwner", "params": {"hackathonId": "{{hackathonId}}", "userId": "{{userId:hackagon-admin}}"}, "expect": {"error": "PermissionDenied"}, "todo": "Ordered after act5.owner.promote on purpose: with one owner this would be refused by the last-organizer guard and would pass even if the self guard were deleted."} +{"id": "act5.owner.demote", "priority": "P1", "implement": true, "outcome": "Succeeds; Alice is an ordinary member again, not a participant with no role.", "act": 5, "t": "T-1wk", "title": "the admin stands Alice back down", "actor": "hackagon-admin", "action": "rpc", "method": "hackathon.HackathonService/RemoveOwner", "params": {"hackathonId": "{{hackathonId}}", "userId": "{{userId:alice}}"}, "expect": {"ok": true}, "todo": "Restores the cast: Alice votes in act 7, and organizers may not vote."} +{"id": "act5.owner.last", "priority": "P1", "implement": true, "outcome": "Rejected with FailedPrecondition - the event would be left unowned.", "act": 5, "t": "T-1wk", "title": "the last organizer cannot be demoted", "actor": "hackagon-admin", "action": "rpc", "method": "hackathon.HackathonService/RemoveOwner", "params": {"hackathonId": "{{hackathonId}}", "userId": "{{userId:hackagon-admin}}"}, "expect": {"error": "FailedPrecondition"}} +{"id": "act5.race.owner.doubleadd", "priority": "P1", "implement": true, "outcome": "Both concurrent AddOwner calls succeed - promotion is idempotent even at the same instant.", "act": 5, "t": "T-1wk", "title": "RACE: the admin double-clicks 'Make organizer' on Alice - both grants fire at once", "action": "rpc.race", "calls": [{"actor": "hackagon-admin", "method": "hackathon.HackathonService/AddOwner", "params": {"hackathonId": "{{hackathonId}}", "userId": "{{userId:alice}}"}}, {"actor": "hackagon-admin", "method": "hackathon.HackathonService/AddOwner", "params": {"hackathonId": "{{hackathonId}}", "userId": "{{userId:alice}}"}}], "race": {"ok": 2}, "todo": "The interesting failure is a DUPLICATE casbin grouping row slipping between casbin's own check and insert - act5.race.owner.restore2 would then leave Alice still an owner and act5.race.owner.final turns red."} +{"id": "act5.race.owner.doubleadd.verify", "priority": "P1", "implement": true, "outcome": "Alice is an Owner on the roster - once.", "act": 5, "t": "T-1wk", "title": "RACE: the double-granted role reads back as one ownership", "actor": "hackagon-admin", "action": "rpc", "method": "hackathon.HackathonService/Get", "params": {"hackathonId": "{{hackathonId}}"}, "expect": {"ok": true, "check": "memberRoles", "checkArgs": {"roles": {"alice": "HACKATHON_ROLE_OWNER"}}}} +{"id": "act5.race.owner.remove", "priority": "P1", "implement": true, "outcome": "Exactly ONE of the two mutual demotions lands; the loser is refused. Before writeBallot's sibling fix this left the event with ZERO owners - both callers counted two, both passed the last-organizer guard.", "act": 5, "t": "T-1wk", "title": "RACE: the two organizers demote EACH OTHER at the same moment", "action": "rpc.race", "calls": [{"actor": "hackagon-admin", "method": "hackathon.HackathonService/RemoveOwner", "params": {"hackathonId": "{{hackathonId}}", "userId": "{{userId:alice}}"}}, {"actor": "alice", "method": "hackathon.HackathonService/RemoveOwner", "params": {"hackathonId": "{{hackathonId}}", "userId": "{{userId:hackagon-admin}}"}}], "race": {"ok": 1, "failCodesOneOf": [["FailedPrecondition"], ["PermissionDenied"]]}, "todo": "The loser's code depends on timing: FailedPrecondition when their guard re-reads one remaining owner, PermissionDenied when their own demotion landed before their permission check ran. Both are refusals; zero-owner is the bug."} +{"id": "act5.race.owner.invariant", "priority": "P1", "implement": true, "outcome": "The event still has exactly ONE owner - whoever won. Never zero: that is the invariant the last-organizer guard exists for.", "act": 5, "t": "T-1wk", "title": "RACE: the event is not left unowned", "actor": "hackagon-admin", "action": "rpc", "method": "hackathon.HackathonService/Get", "params": {"hackathonId": "{{hackathonId}}"}, "expect": {"ok": true, "check": "ownerCount", "checkArgs": {"count": 1}}} +{"id": "act5.race.owner.restore", "priority": "P1", "implement": true, "outcome": "Succeeds either way - a no-op re-grant if the admin survived as owner, a re-promotion if Alice's demotion of the admin won.", "act": 5, "t": "T-1wk", "title": "RACE: the admin makes sure they are an organizer again", "actor": "hackagon-admin", "action": "rpc", "method": "hackathon.HackathonService/AddOwner", "params": {"hackathonId": "{{hackathonId}}", "userId": "{{userId:hackagon-admin}}"}, "expect": {"ok": true}} +{"id": "act5.race.owner.restore2", "priority": "P1", "implement": true, "outcome": "Alice is stood down if the race left her an owner; NotFound if the admin's demotion of her already won. Either way she is a plain Member after this.", "act": 5, "t": "T-1wk", "title": "RACE: alice is stood back down, whichever way the race went", "actor": "hackagon-admin", "action": "rpc", "method": "hackathon.HackathonService/RemoveOwner", "params": {"hackathonId": "{{hackathonId}}", "userId": "{{userId:alice}}"}, "expect": {"okOr": ["NotFound"]}} +{"id": "act5.race.owner.final", "priority": "P1", "implement": true, "outcome": "The cast is restored: admin is the Owner, Alice an ordinary Member - she votes in act 7, and organizers may not vote.", "act": 5, "t": "T-1wk", "title": "RACE: the roster reads back exactly as the story needs it", "actor": "hackagon-admin", "action": "rpc", "method": "hackathon.HackathonService/Get", "params": {"hackathonId": "{{hackathonId}}"}, "expect": {"ok": true, "check": "memberRoles", "checkArgs": {"roles": {"hackagon-admin": "HACKATHON_ROLE_OWNER", "alice": "HACKATHON_ROLE_MEMBER"}}}} +{"id": "act5.forms.roster", "priority": "P1", "implement": true, "outcome": "Succeeds - the organizer reads the whole cohort's answers in one call.", "act": 5, "t": "T-1wk", "title": "FORMS: the organizer reads every registration answer at once", "actor": "hackagon-admin", "action": "rpc", "method": "hackathon.HackathonService/ListRegistrationResponses", "params": {"hackathonId": "{{hackathonId}}"}, "expect": {"ok": true}, "todo": "The per-user RPC would be a round-trip per participant; the team board needs the cohort to show skills and answers beside the drop targets."} +{"id": "act5.forms.rogue", "priority": "P1", "implement": true, "outcome": "Rejected with PermissionDenied - answers are not readable by a fellow member.", "act": 5, "t": "T-1wk", "title": "FORMS: a member cannot read everyone's registration answers", "actor": "bob", "action": "rpc", "method": "hackathon.HackathonService/ListRegistrationResponses", "params": {"hackathonId": "{{hackathonId}}"}, "expect": {"error": "PermissionDenied"}, "todo": "Same rule GetRegistrationResponse enforces for one other person, applied to the whole cohort. act2.form.bob.snoop pins the single-user half."} +{"id": "act5.forms.anon", "priority": "P1", "implement": true, "outcome": "Rejected with Unauthenticated.", "act": 5, "t": "T-1wk", "title": "FORMS: anonymous cannot read registration answers", "actor": "anonymous", "action": "rpc", "method": "hackathon.HackathonService/ListRegistrationResponses", "params": {"hackathonId": "{{hackathonId}}"}, "expect": {"error": "Unauthenticated"}} +{"id": "act5.state.facade", "priority": "P2", "implement": true, "outcome": "Succeeds - main's boolean payload drives our four-state capability rows.", "act": 5, "t": "T-1wk", "title": "FACADE: organizer switches capabilities through main's boolean contract", "actor": "hackagon-admin", "action": "rpc", "method": "hackathon.HackathonService/SetCapabilities", "params": {"hackathonId": "{{hackathonId}}", "capabilities": [{"capability": "CAPABILITY_PROPOSE_PROJECTS", "enabled": false}]}, "expect": {"ok": true}, "todo": "The facade carries NO enforcement - requireCapability remains the only gate. true maps to OPEN, false to CLOSED, and reads project back through resolved state."} +{"id": "act5.state.rogue", "priority": "P2", "implement": true, "outcome": "Rejected with PermissionDenied - the facade is not a way around authorisation.", "act": 5, "t": "T-1wk", "title": "FACADE: a member cannot flip capabilities through it", "actor": "bob", "action": "rpc", "method": "hackathon.HackathonService/SetCapabilities", "params": {"hackathonId": "{{hackathonId}}", "capabilities": [{"capability": "CAPABILITY_PROPOSE_PROJECTS", "enabled": true}]}, "expect": {"error": "PermissionDenied"}} +{"id": "act5.state.restore", "priority": "P2", "implement": true, "outcome": "Succeeds - proposing is back on for the rest of the story.", "act": 5, "t": "T-1wk", "title": "FACADE: organizer switches it back", "actor": "hackagon-admin", "action": "rpc", "method": "hackathon.HackathonService/SetCapabilities", "params": {"hackathonId": "{{hackathonId}}", "capabilities": [{"capability": "CAPABILITY_PROPOSE_PROJECTS", "enabled": true}]}, "expect": {"ok": true}} +{"id": "act5.phase.alias", "priority": "P2", "implement": true, "outcome": "Succeeds - main's SetCurrentPhase name reaches our AdvancePhase.", "act": 5, "t": "T-1wk", "title": "FACADE: clearing the current phase through main's RPC name", "actor": "hackagon-admin", "action": "rpc", "method": "hackathon.HackathonService/SetCurrentPhase", "params": {"hackathonId": "{{hackathonId}}", "phaseId": ""}, "expect": {"ok": true}, "todo": "Empty phase_id means clear. ent's SetNillableCurrentPhaseID(nil) is a silent no-op, which is why AdvancePhase uses ClearCurrentPhase."} +{"id": "act5.audit", "priority": "P1", "implement": true, "outcome": "Succeeds; roster shows 12 on the list, 8 approved, 4 waitlisted.", "act": 5, "t": "T-1wk", "title": "MEANWHILE admin takes a final pre-event audit snapshot (full tree) (roster includes the organizer)", "actor": "hackagon-admin", "action": "rpc", "method": "hackathon.HackathonService/Get", "params": {"hackathonId": "{{hackathonId}}"}, "expect": {"ok": true, "check": "roster", "checkArgs": {"total": 13, "approved": 9, "waiting": 4}}} +{"comment": "── ACT 6 — T=0 / T+1: HACKATHON DAYS (time travel: move the event, not the clock) ──"} +{"id": "act6.begin", "priority": "P1", "implement": true, "outcome": "Succeeds.", "act": 6, "t": "T0", "title": "the event begins: dates shifted onto today", "actor": "hackagon-admin", "action": "rpc", "method": "hackathon.HackathonService/Edit", "params": {"hackathonId": "{{hackathonId}}", "startsAt": "{{now-1d}}", "endsAt": "{{now+1d}}"}, "expect": {"ok": true}} +{"id": "act6.ui.active", "priority": "P1", "implement": true, "outcome": "The public home lists 'SDSC Open Research Data Hackathon 2027' with the 'Active' badge.", "act": 6, "t": "T0", "title": "the public site announces the event as Active", "action": "ui.assert", "assert": "homeStatus", "params": {"name": "SDSC Open Research Data Hackathon 2027", "status": "Active"}} +{"id": "act6.flow.anon", "priority": "P1", "implement": true, "outcome": "The 4-step browsing chain completes, ending at a URL matching '/hackathon/'.", "act": 6, "t": "T0", "title": "anonymous event-day chain: home (Active badge) → hackathon detail", "action": "ui.flow", "steps": [{"goto": "/"}, {"expectText": "Active"}, {"clickLink": "SDSC Open Research Data Hackathon 2027"}, {"expectUrl": "/hackathon/"}]} +{"id": "act6.list.active", "priority": "P1", "implement": true, "outcome": "Succeeds; the list contains 'SDSC Open Research Data Hackathon 2027'.", "act": 6, "t": "T0", "title": "the public list API filtered by ACTIVE returns the running event", "actor": "anonymous", "action": "rpc", "method": "hackathon.HackathonService/List", "params": {"statusFilter": ["HACKATHON_STATUS_ACTIVE"]}, "expect": {"ok": true, "check": "listHasName", "checkArgs": {"name": "SDSC Open Research Data Hackathon 2027"}}} +{"id": "act6.noshow", "priority": "P1", "implement": true, "outcome": "Succeeds.", "act": 6, "t": "T0", "title": "NO-SHOW at check-in: Hiro wrote 'see you there!' and never appeared — admin clears his Team Matterhorn seat", "actor": "hackagon-admin", "action": "rpc", "method": "hackathon.TeamService/RemoveUser", "params": {"teamId": "{{var:teamMatterhorn}}", "userId": "{{userId:hiro.tanaka}}"}, "expect": {"ok": true}} +{"id": "act6.noshow.access", "priority": "P1", "implement": true, "outcome": "Succeeds.", "act": 6, "t": "T0", "title": "a no-show stays a confirmed participant (off the team, not out of the event)", "actor": "hiro.tanaka", "action": "rpc", "method": "hackathon.HackathonService/Get", "params": {"hackathonId": "{{hackathonId}}"}, "expect": {"ok": true}} +{"id": "act6.walkin.signup", "priority": "P1", "implement": true, "outcome": "Succeeds.", "act": 6, "t": "T0", "title": "WALK-IN: Noor Haddad hears about the event that morning and creates an account at the door", "actor": "noor.haddad", "action": "rpc", "method": "user.UserService/Register", "params": {}, "expect": {"ok": true}} +{"id": "act6.walkin.override", "priority": "P2", "implement": true, "outcome": "Succeeds.", "act": 6, "t": "T0", "title": "admin reopens registration for on-site walk-ins (manual override)", "actor": "hackagon-admin", "action": "rpc", "method": "hackathon.ConfigService/OverrideWindow", "params": {"hackathonId": "{{hackathonId}}", "window": "registration", "extendMinutes": 120, "reason": "on-site walk-ins at check-in"}, "expect": {"ok": true}} +{"id": "act6.walkin.join", "priority": "P1", "implement": true, "outcome": "Succeeds.", "act": 6, "t": "T0", "title": "Noor registers on the spot (waitlisted for a moment)", "actor": "noor.haddad", "action": "rpc", "method": "hackathon.HackathonService/Join", "params": {"hackathonId": "{{hackathonId}}"}, "expect": {"ok": true}} +{"id": "act6.walkin.approve", "priority": "P1", "implement": true, "outcome": "The organizer clicks Approve on Noor's row and the control disappears with the approval; act6.walkin.access then proves member access server-side.", "act": 6, "t": "T0", "title": "admin approves the walk-in on the spot - from the participants table, like a person at the check-in desk", "actor": "hackagon-admin", "action": "ui.flow", "steps": [{"goto": "/my/hackathon/{{hackathonId}}/participants"}, {"clickSelector": "form[action='?/approve']:has(input[value='{{userId:noor.haddad}}']) button"}, {"expectGoneSelector": "form[action='?/approve']:has(input[value='{{userId:noor.haddad}}'])"}], "todo": "The waitlist queue is the organizer's daily surface and nothing had ever CLICKED its Approve."} +{"id": "act6.walkin.form", "priority": "P2", "implement": true, "outcome": "Succeeds.", "act": 6, "t": "T0", "title": "FORMS: admin digitizes Noor's paper registration form from the check-in desk", "actor": "hackagon-admin", "action": "rpc", "method": "hackathon.HackathonService/SubmitRegistrationForm", "gate": ["hackathon.ConfigService/SetRegistrationForm", "hackathon.HackathonService/SubmitRegistrationForm"], "params": {"hackathonId": "{{hackathonId}}", "onBehalfOf": "{{userId:noor.haddad}}", "responses": {"affiliation": "EPFL", "skills": ["design", "frontend"]}, "consents": {"conduct": true, "photos": true}}, "expect": {"ok": true}} +{"id": "act6.walkin.access", "priority": "P1", "implement": true, "outcome": "Succeeds.", "act": 6, "t": "T0", "title": "Noor has member access minutes after walking in", "actor": "noor.haddad", "action": "rpc", "method": "hackathon.HackathonService/Get", "params": {"hackathonId": "{{hackathonId}}"}, "expect": {"ok": true}} +{"id": "act6.walkin.team", "priority": "P1", "implement": true, "outcome": "Succeeds.", "act": 6, "t": "T0", "title": "admin assigns Noor to Team Matterhorn — the no-show's seat is filled", "actor": "hackagon-admin", "action": "rpc", "method": "hackathon.TeamService/AssignUser", "params": {"teamId": "{{var:teamMatterhorn}}", "userId": "{{userId:noor.haddad}}"}, "expect": {"ok": true}} +{"id": "act6.ui.teams", "priority": "P2", "implement": true, "outcome": "The teams page lists each team with exactly its expected members.", "act": 6, "t": "T0", "title": "the teams page reflects the day-1 reality (no-show out, walk-in in)", "actor": "bob", "action": "ui.assert", "assert": "teamsPage", "params": {"teams": {"Team Matterhorn": ["Bob Henderson", "Alice Wonderland", "Ines Duarte", "Noor Haddad"], "Team Bernina": ["Dana Moser", "Erik Lindqvist", "Giulia Ricci", "Jonas Weber"]}}} +{"id": "act6.roster.walkin", "priority": "P1", "implement": true, "outcome": "Succeeds; roster shows 13 on the list, 9 approved, 4 waitlisted.", "act": 6, "t": "T0", "title": "roster after check-in: 13 on the list, 9 confirmed, 4 waitlisted (roster includes the organizer)", "actor": "hackagon-admin", "action": "rpc", "method": "hackathon.HackathonService/Get", "params": {"hackathonId": "{{hackathonId}}"}, "expect": {"ok": true, "check": "roster", "checkArgs": {"total": 14, "approved": 10, "waiting": 4}}} +{"id": "act6.announce", "priority": "P1", "implement": true, "outcome": "Succeeds.", "act": 6, "t": "T0", "title": "MEANWHILE admin pins a live announcement into the event description", "actor": "hackagon-admin", "action": "rpc", "method": "hackathon.HackathonService/Edit", "params": {"hackathonId": "{{hackathonId}}", "description": "UPDATE (Day 1): Lunch at 12:30 in Hall B. Keynote recording will be shared tonight. — Two days of building open, reproducible research-data tooling with the Swiss scientific community at the SwissTech Convention Center, EPFL, Lausanne. Max capacity: 8 participants (pilot edition)."}, "expect": {"ok": true}} +{"id": "act6.announce.ui", "priority": "P1", "implement": true, "outcome": "The member overview About section shows 'Lunch at 12:30'.", "act": 6, "t": "T0", "title": "members see the live announcement on their overview", "actor": "bob", "action": "ui.assert", "assert": "aboutVisible", "params": {"textContains": "Lunch at 12:30"}} +{"id": "act6.phase.ideation", "priority": "P1", "implement": true, "outcome": "Succeeds.", "act": 6, "t": "T0", "title": "Ideation phase on the schedule", "actor": "hackagon-admin", "action": "rpc", "method": "hackathon.PhaseService/Create", "params": {"hackathonId": "{{hackathonId}}", "name": "Ideation", "startsAt": "{{now-1d}}", "endsAt": "{{now-0d}}", "description": "Frame the problem, form ideas, pitch them to the room."}, "expect": {"ok": true}} +{"id": "act6.phase.hacking", "priority": "P1", "implement": true, "outcome": "Succeeds.", "act": 6, "t": "T0", "title": "Hacking phase on the schedule", "actor": "hackagon-admin", "action": "rpc", "method": "hackathon.PhaseService/Create", "params": {"hackathonId": "{{hackathonId}}", "name": "Hacking", "startsAt": "{{now-0d}}", "endsAt": "{{now+1d}}", "description": "Heads-down build time across both event days."}, "expect": {"ok": true}} +{"id": "act6.phase.judging", "priority": "P1", "implement": true, "outcome": "Succeeds.", "act": 6, "t": "T+1", "title": "Judging phase on the schedule", "actor": "hackagon-admin", "action": "rpc", "method": "hackathon.PhaseService/Create", "params": {"hackathonId": "{{hackathonId}}", "name": "Judging", "startsAt": "{{now+1d}}", "endsAt": "{{now+1d}}", "description": "Demos, jury deliberation and community voting."}, "expect": {"ok": true}} +{"id": "act6.phase.rogue", "priority": "P1", "implement": true, "outcome": "Rejected with PermissionDenied - no state change.", "act": 6, "t": "T0", "title": "a participant cannot edit the schedule", "actor": "bob", "action": "rpc", "method": "hackathon.PhaseService/Create", "params": {"hackathonId": "{{hackathonId}}", "name": "Nap Time", "startsAt": "{{now-0d}}", "endsAt": "{{now+1d}}", "description": "Rogue phase that must be denied."}, "expect": {"error": "PermissionDenied"}} +{"id": "act6.ui.timeline", "priority": "P2", "implement": true, "outcome": "The timeline shows the phases in order: Ideation, Hacking, Judging.", "act": 6, "t": "T0", "title": "the phases render in order on the member timeline", "actor": "bob", "action": "ui.assert", "assert": "timelinePhases", "params": {"phases": ["Ideation", "Hacking", "Judging"]}} +{"id": "act6.phase.current", "priority": "P2", "implement": true, "outcome": "'Make current' marks Hacking as the Current phase, and 'Clear current phase' - the control that once submitted no id into a UUID parse - returns it to In progress.", "act": 6, "t": "T0", "title": "organizer declares the Hacking phase current from the timeline, then clears it", "actor": "hackagon-admin", "action": "ui.flow", "steps": [{"goto": "/my/hackathon/{{hackathonId}}/timeline"}, {"clickSelector": "li:has(h4:text-is('Hacking')) form[action='?/setCurrent'] button"}, {"expectText": "Current phase"}, {"clickButton": "Clear current phase"}, {"expectText": "In progress"}], "todo": "Both clicks assert the state that CHANGED. The clear leaves no current phase, exactly as before this action - nothing downstream shifts."} +{"id": "act6.hub.now", "priority": "P1", "implement": true, "outcome": "With the marker cleared, the hub reads the live phase off the CALENDAR - Hacking, badged 'By dates' - names Judging as next, and offers to declare the live one rather than to advance past it.", "act": 6, "t": "T0", "title": "MANAGE HUB: on day one the box reads Hacking from the dates, not from a marker", "actor": "hackagon-admin", "action": "ui.assert", "assert": "manageHub", "params": {"tiles": [{"label": "New Phase", "href": "/timeline/new"}, {"label": "Manage Pages", "href": "/pages"}], "tileCount": 9, "reviewWaiting": 4, "mayEdit": true, "now": "Hacking", "nowBadge": "By dates", "next": "Judging", "phaseAction": "Declare Hacking current", "clearMarker": false}} +{"id": "act6.hub.advance", "priority": "P1", "implement": true, "outcome": "Declare Hacking, advance to Judging, then clear: the Now box, its badge and the next action are re-read after every click, and the story is left exactly where it started.", "act": 6, "t": "T0", "title": "MANAGE HUB: the organizer walks the phase marker from the hub and puts it back", "actor": "hackagon-admin", "action": "ui.assert", "assert": "managePhaseAdvance", "params": {"steps": [{"click": "Declare Hacking current", "now": "Hacking", "nowBadge": "Declared", "next": "Judging", "action": "Advance to Judging"}, {"click": "Advance to Judging", "now": "Judging", "nowBadge": "Declared", "next": null, "action": null}, {"click": "Clear the marker", "now": "Hacking", "nowBadge": "By dates", "action": "Declare Hacking current"}]}, "todo": "Advancing DOES apply the capabilities a phase names as its opening ones - none of this event's do, which is why walking the marker here changes nothing else."} +{"id": "act6.hub.marker.readback", "priority": "P1", "implement": true, "outcome": "Succeeds; no phase is declared current, so the rest of the story runs from the dates exactly as it did before the hub was touched.", "act": 6, "t": "T0", "title": "END STATE of the marker walk: the declaration is cleared again", "actor": "hackagon-admin", "action": "rpc", "method": "hackathon.HackathonService/Get", "params": {"hackathonId": "{{hackathonId}}"}, "expect": {"ok": true, "check": "currentPhase", "checkArgs": {"declared": false}}} +{"id": "act6.pages.ids", "priority": "P1", "implement": true, "outcome": "Succeeds; the four pages come back in their stored order, and their ids are captured for the reorder calls below.", "act": 6, "t": "T0", "title": "PAGES: the event's four pages, in the order they were created", "actor": "hackagon-admin", "action": "rpc", "method": "hackathon.PageService/List", "params": {"hackathonId": "{{hackathonId}}"}, "save": {"pageOne": "pages.0.id", "pageTwo": "pages.1.id", "pageThree": "pages.2.id", "pageFour": "pages.3.id"}, "expect": {"ok": true, "check": "pageOrder", "checkArgs": {"titles": ["Welcome", "Code of Conduct", "Venue & Travel — EPFL", "Pre-event webinars"]}}} +{"id": "act6.pages.movedown", "priority": "P2", "implement": true, "outcome": "The down arrow swaps two pages and the swap survives a reload - one MoveDown rather than a whole-sequence write, and the only reorder path that works with no JavaScript at all.", "act": 6, "t": "T0", "title": "PAGES: the down arrow moves one page one place", "actor": "hackagon-admin", "action": "ui.assert", "assert": "pagesReorder", "params": {"title": "Welcome", "button": "Move Welcome down", "before": ["Welcome", "Code of Conduct", "Venue & Travel — EPFL", "Pre-event webinars"], "after": ["Code of Conduct", "Welcome", "Venue & Travel — EPFL", "Pre-event webinars"]}} +{"id": "act6.pages.moveup", "priority": "P2", "implement": true, "outcome": "The up arrow puts it back, so the pair is reversible and the story continues from where it was.", "act": 6, "t": "T0", "title": "PAGES: the up arrow puts it back", "actor": "hackagon-admin", "action": "ui.assert", "assert": "pagesReorder", "params": {"title": "Welcome", "button": "Move Welcome up", "before": ["Code of Conduct", "Welcome", "Venue & Travel — EPFL", "Pre-event webinars"], "after": ["Welcome", "Code of Conduct", "Venue & Travel — EPFL", "Pre-event webinars"]}} +{"id": "act6.pages.reorder", "priority": "P1", "implement": true, "outcome": "The webinars page moves two places up from the keyboard, the live region SAYS what happened, the rows settle into the new order and a RELOAD still shows it - which is what distinguishes a reorder from a local array shuffle.", "act": 6, "t": "T0", "title": "PAGES: reordering the page list posts one SetOrder for the whole sequence", "actor": "hackagon-admin", "action": "ui.assert", "assert": "pagesReorder", "params": {"title": "Pre-event webinars", "direction": "up", "by": 2, "toPosition": 2, "before": ["Welcome", "Code of Conduct", "Venue & Travel — EPFL", "Pre-event webinars"], "after": ["Welcome", "Pre-event webinars", "Code of Conduct", "Venue & Travel — EPFL"]}, "todo": "Driven from the KEYBOARD, which posts the same single SetOrder the drag does: dragging is pointer-only, and one write for the whole sequence is what stops a five-place move half-landing."} +{"id": "act6.pages.reorder.readback", "priority": "P1", "implement": true, "outcome": "Succeeds; the API returns the pages in the new order, so the reorder is a stored fact rather than a rendering.", "act": 6, "t": "T0", "title": "END STATE of the reorder: the server holds the new order", "actor": "hackagon-admin", "action": "rpc", "method": "hackathon.PageService/List", "params": {"hackathonId": "{{hackathonId}}"}, "expect": {"ok": true, "check": "pageOrder", "checkArgs": {"titles": ["Welcome", "Pre-event webinars", "Code of Conduct", "Venue & Travel — EPFL"]}}} +{"id": "act6.pages.excerpt", "priority": "P2", "implement": true, "outcome": "The row quotes the page's opening line as TEXT: the bold markers are flattened away, which is also why a row can never be an XSS surface - it interpolates a string rather than rendering an author's markup.", "act": 6, "t": "T0", "title": "PAGES: each row quotes its page, flattened and sanitized", "actor": "hackagon-admin", "action": "ui.assert", "assert": "pageExcerpt", "params": {"title": "Venue & Travel — EPFL", "contains": ["Doors open at 08:30.", "SwissTech Convention Center"], "lacks": ["**", "

    "]}} +{"id": "act6.pages.setorder.partial", "priority": "P1", "implement": true, "outcome": "Rejected with InvalidArgument - SetOrder renumbers the whole list in one transaction, so a partial list has no meaning and is refused rather than half-applied.", "act": 6, "t": "T0", "title": "PARAMS: SetOrder with three of the four pages", "actor": "hackagon-admin", "action": "rpc", "method": "hackathon.PageService/SetOrder", "params": {"hackathonId": "{{hackathonId}}", "pageIds": ["{{var:pageOne}}", "{{var:pageTwo}}", "{{var:pageThree}}"]}, "expect": {"error": "InvalidArgument"}} +{"id": "act6.pages.setorder.dupe", "priority": "P1", "implement": true, "outcome": "Rejected with InvalidArgument - the count matches but one page is named twice and another not at all, which is exactly the shape a length check alone would wave through.", "act": 6, "t": "T0", "title": "PARAMS: SetOrder with the right COUNT but one page listed twice", "actor": "hackagon-admin", "action": "rpc", "method": "hackathon.PageService/SetOrder", "params": {"hackathonId": "{{hackathonId}}", "pageIds": ["{{var:pageOne}}", "{{var:pageOne}}", "{{var:pageTwo}}", "{{var:pageThree}}"]}, "expect": {"error": "InvalidArgument"}} +{"id": "act6.pages.setorder.empty", "priority": "P2", "implement": true, "outcome": "Rejected with InvalidArgument - an empty list is refused before any permission is considered.", "act": 6, "t": "T0", "title": "PARAMS: SetOrder with no pages at all", "actor": "hackagon-admin", "action": "rpc", "method": "hackathon.PageService/SetOrder", "params": {"hackathonId": "{{hackathonId}}", "pageIds": []}, "expect": {"error": "InvalidArgument"}} +{"id": "act6.pages.setorder.ghost", "priority": "P2", "implement": true, "outcome": "Rejected with InvalidArgument - a page id that belongs to no page of this hackathon cannot take a position in its order.", "act": 6, "t": "T0", "title": "PARAMS: SetOrder naming a page that does not exist", "actor": "hackagon-admin", "action": "rpc", "method": "hackathon.PageService/SetOrder", "params": {"hackathonId": "{{hackathonId}}", "pageIds": ["{{var:pageOne}}", "{{var:pageTwo}}", "{{var:pageThree}}", "00000000-0000-0000-0000-000000000000"]}, "expect": {"error": "InvalidArgument"}} +{"id": "act6.pages.setorder.rogue", "priority": "P1", "implement": true, "outcome": "Rejected with PermissionDenied - reordering the event's content is page Write, which a participant does not hold.", "act": 6, "t": "T0", "title": "a participant cannot reorder the event's pages", "actor": "bob", "action": "rpc", "method": "hackathon.PageService/SetOrder", "params": {"hackathonId": "{{hackathonId}}", "pageIds": ["{{var:pageOne}}", "{{var:pageTwo}}", "{{var:pageThree}}", "{{var:pageFour}}"]}, "expect": {"error": "PermissionDenied"}} +{"id": "act6.pages.setorder.anon", "priority": "P1", "implement": true, "outcome": "Rejected with Unauthenticated - an anonymous caller is told who they are not, never what they may not do.", "act": 6, "t": "T0", "title": "an anonymous caller cannot reorder the event's pages", "actor": "anonymous", "action": "rpc", "method": "hackathon.PageService/SetOrder", "params": {"hackathonId": "{{hackathonId}}", "pageIds": ["{{var:pageOne}}", "{{var:pageTwo}}", "{{var:pageThree}}", "{{var:pageFour}}"]}, "expect": {"error": "Unauthenticated"}} +{"id": "act6.pages.setorder.restore", "priority": "P1", "implement": true, "outcome": "Succeeds; the original order is restored in one write, from the API this time rather than from the list screen.", "act": 6, "t": "T0", "title": "PAGES: the organizer puts the original order back", "actor": "hackagon-admin", "action": "rpc", "method": "hackathon.PageService/SetOrder", "params": {"hackathonId": "{{hackathonId}}", "pageIds": ["{{var:pageOne}}", "{{var:pageTwo}}", "{{var:pageThree}}", "{{var:pageFour}}"]}, "expect": {"ok": true}} +{"id": "act6.pages.order.final", "priority": "P1", "implement": true, "outcome": "Succeeds; the pages are back in their original order, so nothing downstream sees a reshuffled sidebar.", "act": 6, "t": "T0", "title": "END STATE: the page order is as the story left it", "actor": "hackagon-admin", "action": "rpc", "method": "hackathon.PageService/List", "params": {"hackathonId": "{{hackathonId}}"}, "expect": {"ok": true, "check": "pageOrder", "checkArgs": {"titles": ["Welcome", "Code of Conduct", "Venue & Travel — EPFL", "Pre-event webinars"]}}} +{"id": "act6.flow.day1end", "priority": "P1", "implement": true, "outcome": "The 4-step browsing chain completes, ending showing 'Log in'. Signing out is an entry INSIDE the account menu now, not the avatar's own click.", "act": 6, "t": "T0", "title": "end of day 1: bob signs out from the venue machine", "actor": "bob", "action": "ui.flow", "steps": [{"goto": "/dashboard"}, {"clickButton": "Log out"}, {"expectText": "Log in"}]} +{"id": "act6.flow.day2", "priority": "P1", "implement": true, "outcome": "The 4-step browsing chain completes, ending at a URL matching '/overview$'.", "act": 6, "t": "T+1", "title": "day 2: bob logs back in and heads straight to his event", "actor": "bob", "action": "ui.flow", "fresh": true, "steps": [{"login": true}, {"expectUrl": "/dashboard$"}, {"clickLink": "SDSC Open Research Data Hackathon 2027"}, {"expectUrl": "/overview$"}]} +{"id": "act6.files", "priority": "P1", "implement": true, "outcome": "Five deterministic files (PNG/SVG/PDF/CSV/README) are written to .state/uploads/team-matterhorn/ and verified byte-stable.", "act": 6, "t": "T+1", "title": "submission upload fixtures generated deterministically (PNG/SVG/PDF/CSV/README)", "action": "files.generate", "params": {"slug": "team-matterhorn", "seed": 2027, "team": "Team Matterhorn", "project": "FAIR Pipeline Builder"}} +{"id": "act6.submit.draft", "priority": "P1", "implement": true, "outcome": "Team Matterhorn's draft goes in through the submissions page - the form whose backing RPC once had NO caller at all - and the card shows Version 1.", "act": 6, "t": "T+1", "title": "Team Matterhorn creates their draft through the submissions page, file bundle referenced", "actor": "bob", "action": "ui.flow", "steps": [{"goto": "/my/hackathon/{{hackathonId}}/submissions"}, {"clickButton": "Submit your work"}, {"fill": {"selector": "textarea[name='result']", "value": "FAIR Pipeline Builder — draft. Attachments: logo.png, poster.svg, final-report.pdf, data-sample.csv, README.md from .state/uploads/team-matterhorn/"}}, {"fill": {"selector": "input[name='field:repo']", "value": "https://github.com/sdsc/fair-pipeline-builder"}}, {"fill": {"selector": "input[name='field:demo']", "value": "https://demo.sdsc.dev/fair-pipeline"}}, {"fill": {"selector": "textarea[name='field:summary']", "value": "FAIR data pipeline builder."}}, {"clickButton": "Submit"}, {"expectText": "Version 1"}], "todo": "CreateSubmission/EditSubmission/FinalizeSubmission had no frontend caller when the design migration landed - a team could not turn work in and every rpc-level test stayed green."} +{"id": "act6.submit.draft.id", "priority": "P1", "implement": true, "outcome": "Succeeds. Returns submissionMatterhorn for later steps - the id of the single submission the UI just created.", "act": 6, "t": "T+1", "title": "the draft's id is read back for the rest of the story", "actor": "bob", "action": "rpc", "method": "hackathon.TeamService/ListSubmissions", "params": {"teamId": "{{var:teamMatterhorn}}"}, "save": {"submissionMatterhorn": "submissions.0.id"}, "expect": {"ok": true}} +{"id": "act6.submit.final", "priority": "P1", "implement": true, "outcome": "Team Matterhorn finalizes from the submissions page - two clicks, confirm included - and the finalize control disappears with the act.", "act": 6, "t": "T+1", "title": "Team Matterhorn finalizes before the deadline, through the page", "actor": "bob", "action": "ui.flow", "steps": [{"goto": "/my/hackathon/{{hackathonId}}/submissions"}, {"clickButton": "Finalise…"}, {"clickButton": "Yes, finalise"}, {"expectGoneSelector": "form[action='?/finalize']"}, {"expectText": "Final"}]} +{"id": "act6.submit.bernina", "priority": "P1", "implement": true, "outcome": "Succeeds. Returns submissionBernina for later steps.", "act": 6, "t": "T+1", "title": "Team Bernina submits final", "actor": "dana.moser", "action": "rpc", "method": "hackathon.TeamService/CreateSubmission", "params": {"teamId": "{{var:teamBernina}}", "projectId": "{{var:projectLitdata}}", "result": "LitData Extractor — final submission.", "form": {"repo": "https://github.com/sdsc/fair-pipeline-builder", "demo": "https://demo.sdsc.dev/fair-pipeline", "summary": "FAIR data pipeline builder."}}, "save": {"submissionBernina": "id"}, "expect": {"ok": true}} +{"id": "act6.submit.bernina.edit", "priority": "P1", "implement": true, "outcome": "Succeeds - the draft submission content is updated.", "act": 6, "t": "T+1", "title": "EDIT: Team Bernina revises their draft before finalizing", "actor": "dana.moser", "action": "rpc", "method": "hackathon.TeamService/EditSubmission", "params": {"submissionId": "{{var:submissionBernina}}", "result": "LitData Extractor — final: added evaluation on 1,200 open-access papers."}, "expect": {"ok": true}} +{"id": "act6.submit.bernina.final", "priority": "P1", "implement": true, "outcome": "Succeeds.", "act": 6, "t": "T+1", "title": "Team Bernina finalizes before the deadline", "actor": "dana.moser", "action": "rpc", "method": "hackathon.TeamService/FinalizeSubmission", "params": {"submissionId": "{{var:submissionBernina}}"}, "expect": {"ok": true}} +{"id": "act6.submit.abandoned", "priority": "P1", "implement": true, "outcome": "Succeeds. Returns submissionBerninaScratch for later steps.", "act": 6, "t": "T+1", "title": "ABANDONED WORK: Bernina starts a second draft that is never finalized", "actor": "erik.lindqvist", "action": "rpc", "method": "hackathon.TeamService/CreateSubmission", "params": {"teamId": "{{var:teamBernina}}", "projectId": "{{var:projectLitdata}}", "result": "Scratch draft — alternate demo idea (never submitted).", "form": {"repo": "https://github.com/sdsc/fair-pipeline-builder", "demo": "https://demo.sdsc.dev/fair-pipeline", "summary": "FAIR data pipeline builder."}}, "save": {"submissionBerninaScratch": "id"}, "expect": {"ok": true}} +{"id": "act6.logo.refresh", "priority": "P1", "implement": true, "outcome": "Succeeds.", "act": 6, "t": "T+1", "title": "MEANWHILE admin swaps in the final event artwork", "actor": "hackagon-admin", "action": "rpc", "method": "hackathon.HackathonService/Edit", "params": {"hackathonId": "{{hackathonId}}", "logo": "{{logoDataUri:2028}}"}, "expect": {"ok": true}} +{"id": "act6.logo.check", "priority": "P1", "implement": true, "outcome": "Succeeds; the stored logo (and name/description) round-trips byte-for-byte.", "act": 6, "t": "T+1", "title": "the new artwork round-trips", "actor": "hackagon-admin", "action": "rpc", "method": "hackathon.HackathonService/Get", "params": {"hackathonId": "{{hackathonId}}"}, "expect": {"ok": true, "check": "logoRoundTrip", "checkArgs": {"seed": 2028}}} +{"id": "act6.submit.rogue", "priority": "P1", "implement": true, "outcome": "Rejected with PermissionDenied - no state change.", "act": 6, "t": "T+1", "title": "a non-team-member cannot submit for the team", "actor": "charles", "action": "rpc", "method": "hackathon.TeamService/CreateSubmission", "params": {"teamId": "{{var:teamMatterhorn}}", "projectId": "{{var:projectFair}}", "result": "hijack attempt"}, "expect": {"error": "PermissionDenied"}} +{"id": "act6.submit.anon", "priority": "P1", "implement": true, "outcome": "Rejected with Unauthenticated - no submission is created. It used to answer Internal, \"user not found\": TeamService admitted the anonymous subject the auth interceptor injects, looked up a User row for keycloak_id \"anonymous\", missed, and reported the miss as a server fault.", "act": 6, "t": "T+1", "title": "DENIED: an anonymous caller cannot turn work in for a team", "actor": "anonymous", "action": "rpc", "method": "hackathon.TeamService/CreateSubmission", "params": {"teamId": "{{var:teamMatterhorn}}", "projectId": "{{var:projectFair}}", "result": "drive-by submission"}, "expect": {"error": "Unauthenticated"}} +{"id": "act6.submit.edit.anon", "priority": "P1", "implement": true, "outcome": "Rejected with Unauthenticated. The draft is unfinalized and inside the window, so authentication is the only thing refusing it - and it is checked first, before the frozen check and before the deadline.", "act": 6, "t": "T+1", "title": "DENIED: an anonymous caller cannot edit somebody's draft", "actor": "anonymous", "action": "rpc", "method": "hackathon.TeamService/EditSubmission", "params": {"submissionId": "{{var:submissionBerninaScratch}}", "result": "drive-by edit"}, "expect": {"error": "Unauthenticated"}} +{"id": "act6.submit.final.anon", "priority": "P1", "implement": true, "outcome": "Rejected with Unauthenticated - Bernina's scratch draft stays abandoned, which is what the later acts count on.", "act": 6, "t": "T+1", "title": "DENIED: an anonymous caller cannot finalize somebody's draft", "actor": "anonymous", "action": "rpc", "method": "hackathon.TeamService/FinalizeSubmission", "params": {"submissionId": "{{var:submissionBerninaScratch}}"}, "expect": {"error": "Unauthenticated"}} +{"id": "act6.submit.invalid", "priority": "P2", "implement": true, "outcome": "Rejected with InvalidArgument - no state change.", "act": 6, "t": "T+1", "title": "VALIDATION: a submission missing the admin-required repo field is rejected", "actor": "bob", "action": "rpc", "method": "hackathon.TeamService/CreateSubmission", "params": {"teamId": "{{var:teamMatterhorn}}", "projectId": "{{var:projectFair}}", "result": "oops - forgot the repo", "form": {"summary": "A submission with no repository link."}}, "expect": {"error": "InvalidArgument"}} +{"id": "act6.window.subclose", "priority": "P2", "implement": true, "outcome": "Succeeds - submissions are closed pending any organizer override.", "act": 6, "t": "T+1", "title": "the submission deadline passes", "actor": "hackagon-admin", "action": "rpc", "method": "hackathon.ConfigService/SetWindows", "params": {"hackathonId": "{{hackathonId}}", "submissionsClose": "{{now-1d}}"}, "expect": {"ok": true}} +{"id": "act6.window.sublate", "priority": "P2", "implement": true, "outcome": "Rejected with FailedPrecondition - no state change.", "act": 6, "t": "T+1", "title": "ENFORCEMENT: Bernina tries one more submission after the deadline — bounced", "actor": "dana.moser", "action": "rpc", "method": "hackathon.TeamService/CreateSubmission", "gate": ["hackathon.ConfigService/SetWindows", "hackathon.TeamService/CreateSubmission"], "params": {"teamId": "{{var:teamBernina}}", "projectId": "{{var:projectLitdata}}", "result": "Missed the deadline: supplementary slides."}, "expect": {"error": "FailedPrecondition"}} +{"id": "act6.window.override", "priority": "P2", "implement": true, "outcome": "Succeeds.", "act": 6, "t": "T+1", "title": "MANUAL OVERRIDE: admin extends the submission window by 30 minutes (AV issues during demos)", "actor": "hackagon-admin", "action": "rpc", "method": "hackathon.ConfigService/OverrideWindow", "params": {"hackathonId": "{{hackathonId}}", "window": "submissions", "extendMinutes": 30, "reason": "AV issues during the demo session"}, "expect": {"ok": true}} +{"id": "act6.submit.grace", "priority": "P2", "implement": true, "outcome": "Succeeds. Returns submissionBerninaExtra for later steps.", "act": 6, "t": "T+1", "title": "within the grace window, Bernina's supplementary submission is accepted", "actor": "dana.moser", "action": "rpc", "method": "hackathon.TeamService/CreateSubmission", "gate": ["hackathon.ConfigService/SetWindows", "hackathon.TeamService/CreateSubmission"], "params": {"teamId": "{{var:teamBernina}}", "projectId": "{{var:projectLitdata}}", "result": "Supplementary slides, submitted within the admin-granted grace window.", "form": {"repo": "https://github.com/sdsc/fair-pipeline-builder", "demo": "https://demo.sdsc.dev/fair-pipeline", "summary": "FAIR data pipeline builder."}}, "save": {"submissionBerninaExtra": "id"}, "expect": {"ok": true}} +{"id": "act6.ui.submissions", "priority": "P2", "implement": true, "outcome": "The submissions page lists the finalized submissions.", "act": 6, "t": "T+1", "title": "submissions render on the submissions page", "actor": "bob", "action": "ui.assert", "assert": "submissionsPage", "params": {"final": ["FAIR Pipeline Builder", "LitData Extractor"]}} +{"comment": "── ACT 7 — T+1 evening: VOTING & AWARDS ─── VoteService has NO proto and NO DB tables yet (priority item 8) — every action below is a placeholder with guessed shapes; keep them, align fields when VoteService lands. ──"} +{"id": "act7.cat.impact", "priority": "P2", "implement": true, "outcome": "Succeeds - the category exists and is listed for the hackathon.", "act": 7, "t": "T+1", "title": "organizer defines vote category: Impact", "actor": "hackagon-admin", "action": "rpc", "method": "vote.VoteService/CreateVoteCategory", "params": {"hackathonId": "{{hackathonId}}", "name": "Impact", "description": "Scientific and societal impact", "votingMethod": "VOTING_METHOD_SINGLE_CHOICE", "voterType": "VOTER_TYPE_ALL_PARTICIPANTS"}, "save": {"catImpact": "voteCategory.id"}, "expect": {"ok": true}} +{"id": "act7.cat.tech", "priority": "P2", "implement": true, "outcome": "Succeeds - the category exists and is listed for the hackathon.", "act": 7, "t": "T+1", "title": "organizer defines vote category: Technical Excellence", "actor": "hackagon-admin", "action": "rpc", "method": "vote.VoteService/CreateVoteCategory", "params": {"hackathonId": "{{hackathonId}}", "name": "Technical Excellence", "description": "Engineering quality and reproducibility", "votingMethod": "VOTING_METHOD_SINGLE_CHOICE", "voterType": "VOTER_TYPE_ALL_PARTICIPANTS"}, "save": {"catTech": "voteCategory.id"}, "expect": {"ok": true}} +{"id": "act7.cat.demo", "priority": "P2", "implement": true, "outcome": "Succeeds - the category exists and is listed for the hackathon.", "act": 7, "t": "T+1", "title": "organizer defines vote category: Best Demo", "actor": "hackagon-admin", "action": "rpc", "method": "vote.VoteService/CreateVoteCategory", "params": {"hackathonId": "{{hackathonId}}", "name": "Best Demo", "description": "Presentation and live demo", "votingMethod": "VOTING_METHOD_SINGLE_CHOICE", "voterType": "VOTER_TYPE_ALL_PARTICIPANTS"}, "save": {"catDemo": "voteCategory.id"}, "expect": {"ok": true}} +{"id": "act7.cat.ranked", "priority": "P2", "implement": true, "outcome": "Succeeds - a ranked category exists.", "act": 7, "t": "T+1", "title": "organizer defines a RANKED vote category: Overall", "actor": "hackagon-admin", "action": "rpc", "method": "vote.VoteService/CreateVoteCategory", "params": {"hackathonId": "{{hackathonId}}", "name": "Overall", "description": "Rank the projects best-first", "votingMethod": "VOTING_METHOD_RANKED", "voterType": "VOTER_TYPE_ALL_PARTICIPANTS"}, "save": {"catRanked": "voteCategory.id"}, "expect": {"ok": true}, "todo": "The method was selectable in the organizer's form long before a ballot could be cast in it; this pins that it now can."} +{"id": "act7.cat.points", "priority": "P2", "implement": true, "outcome": "Succeeds - a points category with a 10-point budget exists.", "act": 7, "t": "T+1", "title": "organizer defines a POINTS vote category: Craft (10 points to spend)", "actor": "hackagon-admin", "action": "rpc", "method": "vote.VoteService/CreateVoteCategory", "params": {"hackathonId": "{{hackathonId}}", "name": "Craft", "description": "Spend up to 10 points across the projects", "votingMethod": "VOTING_METHOD_POINTS", "voterType": "VOTER_TYPE_ALL_PARTICIPANTS", "maxPoints": 10}, "save": {"catPoints": "voteCategory.id"}, "expect": {"ok": true}} +{"id": "act7.voting.open", "priority": "P2", "implement": true, "outcome": "The Open voting button actually opens the vote: the page flips to 'Voting is open — ballots are being accepted.'", "act": 7, "t": "T+1", "title": "admin opens the voting window by clicking Open voting (the button that once could only fail)", "actor": "hackagon-admin", "action": "ui.flow", "steps": [{"goto": "/my/hackathon/{{hackathonId}}/voting"}, {"expectText": "Voting is not open"}, {"clickButton": "Open voting"}, {"expectText": "Voting is open — ballots are being accepted."}], "todo": "EditSettings had no caller for a while (votingEnabled was openable only over grpcurl), and later the button existed but always failed on seeded data. Click it and assert the STATE, not the request."} +{"id": "act7.monitor.open", "priority": "P2", "implement": true, "outcome": "Succeeds - admin-only raw ballot export while votes come in.", "act": 7, "t": "T+1", "title": "MEANWHILE admin watches the live leaderboard while votes come in", "actor": "hackagon-admin", "action": "rpc", "method": "vote.VoteService/ExportVotes", "params": {"categoryId": "{{var:catImpact}}", "format": "EXPORT_FORMAT_JSON"}, "expect": {"ok": true}} +{"id": "act7.cast.alice", "priority": "P2", "implement": true, "outcome": "Succeeds - the single-choice ballot is recorded.", "act": 7, "t": "T+1", "title": "alice votes for Bernina/Impact 5 (own-team votes: decide policy)", "actor": "alice", "action": "rpc", "method": "vote.VoteService/SubmitVote", "params": {"singleChoice": {"categoryId": "{{var:catImpact}}", "submissionId": "{{var:submissionBernina}}"}}, "expect": {"ok": true}} +{"id": "act7.cast.bob", "priority": "P2", "implement": true, "outcome": "Bob picks Bernina in the Technical Excellence card and casts; the card flips to the one-ballot-final state.", "act": 7, "t": "T+1", "title": "bob votes for Bernina/Technical - through the ballot card, like a person in the room", "actor": "bob", "action": "ui.flow", "steps": [{"goto": "/my/hackathon/{{hackathonId}}/voting"}, {"clickSelector": "form:has(input[name='categoryId'][value='{{var:catTech}}']) input[type='radio'][value='{{var:submissionBernina}}']"}, {"clickSelector": "form:has(input[name='categoryId'][value='{{var:catTech}}']) button"}, {"expectText": "One ballot per category — this one is final."}], "todo": "The voter's own surface: nothing had ever cast a ballot through the BallotCard, so a radio wired to the wrong field name would have kept every rpc-level vote test green."} +{"id": "act7.cast.dana", "priority": "P2", "implement": true, "outcome": "Succeeds - the single-choice ballot is recorded.", "act": 7, "t": "T+1", "title": "Dana votes for Matterhorn/Impact", "actor": "dana.moser", "action": "rpc", "method": "vote.VoteService/SubmitVote", "params": {"singleChoice": {"categoryId": "{{var:catImpact}}", "submissionId": "{{var:submissionMatterhorn}}"}}, "expect": {"ok": true}} +{"id": "act7.cast.erik", "priority": "P2", "implement": true, "outcome": "Succeeds - the single-choice ballot is recorded.", "act": 7, "t": "T+1", "title": "Erik votes for Matterhorn/Technical", "actor": "erik.lindqvist", "action": "rpc", "method": "vote.VoteService/SubmitVote", "params": {"singleChoice": {"categoryId": "{{var:catTech}}", "submissionId": "{{var:submissionMatterhorn}}"}}, "expect": {"ok": true}} +{"id": "act7.cast.giulia", "priority": "P2", "implement": true, "outcome": "Succeeds - the single-choice ballot is recorded.", "act": 7, "t": "T+1", "title": "Giulia votes for Matterhorn/Demo", "actor": "giulia.ricci", "action": "rpc", "method": "vote.VoteService/SubmitVote", "params": {"singleChoice": {"categoryId": "{{var:catDemo}}", "submissionId": "{{var:submissionMatterhorn}}"}}, "expect": {"ok": true}} +{"id": "act7.cast.hiro", "priority": "P2", "implement": true, "outcome": "Succeeds - the single-choice ballot is recorded.", "act": 7, "t": "T+1", "title": "Hiro votes for Bernina/Demo", "actor": "hiro.tanaka", "action": "rpc", "method": "vote.VoteService/SubmitVote", "params": {"singleChoice": {"categoryId": "{{var:catDemo}}", "submissionId": "{{var:submissionBernina}}"}}, "expect": {"ok": true}} +{"id": "act7.cast.ines", "priority": "P2", "implement": true, "outcome": "Succeeds - the single-choice ballot is recorded.", "act": 7, "t": "T+1", "title": "Ines votes for Matterhorn/Impact", "actor": "ines.duarte", "action": "rpc", "method": "vote.VoteService/SubmitVote", "params": {"singleChoice": {"categoryId": "{{var:catImpact}}", "submissionId": "{{var:submissionMatterhorn}}"}}, "expect": {"ok": true}} +{"id": "act7.cast.jonas", "priority": "P2", "implement": true, "outcome": "Succeeds - the single-choice ballot is recorded.", "act": 7, "t": "T+1", "title": "Jonas votes for Matterhorn/Technical", "actor": "jonas.weber", "action": "rpc", "method": "vote.VoteService/SubmitVote", "params": {"singleChoice": {"categoryId": "{{var:catTech}}", "submissionId": "{{var:submissionMatterhorn}}"}}, "expect": {"ok": true}} +{"id": "act7.cast.noor", "priority": "P2", "implement": true, "outcome": "Succeeds - the single-choice ballot is recorded.", "act": 7, "t": "T+1", "title": "walk-in Noor votes for too: Bernina/Impact", "actor": "noor.haddad", "action": "rpc", "method": "vote.VoteService/SubmitVote", "params": {"singleChoice": {"categoryId": "{{var:catImpact}}", "submissionId": "{{var:submissionBernina}}"}}, "expect": {"ok": true}} +{"id": "act7.cast.alice2", "priority": "P2", "implement": true, "outcome": "Succeeds - the single-choice ballot is recorded.", "act": 7, "t": "T+1", "title": "alice also votes for Bernina/Demo", "actor": "alice", "action": "rpc", "method": "vote.VoteService/SubmitVote", "params": {"singleChoice": {"categoryId": "{{var:catDemo}}", "submissionId": "{{var:submissionBernina}}"}}, "expect": {"ok": true}} +{"id": "act7.cast.bob2", "priority": "P2", "implement": true, "outcome": "Succeeds - the single-choice ballot is recorded.", "act": 7, "t": "T+1", "title": "bob also votes for Matterhorn/Demo 3 (harsh on his own demo — decide own-team policy)", "actor": "bob", "action": "rpc", "method": "vote.VoteService/SubmitVote", "params": {"singleChoice": {"categoryId": "{{var:catDemo}}", "submissionId": "{{var:submissionMatterhorn}}"}}, "expect": {"ok": true}} +{"id": "act7.cast.ines2", "priority": "P2", "implement": true, "outcome": "Succeeds - the single-choice ballot is recorded.", "act": 7, "t": "T+1", "title": "Ines also votes for Bernina/Technical", "actor": "ines.duarte", "action": "rpc", "method": "vote.VoteService/SubmitVote", "params": {"singleChoice": {"categoryId": "{{var:catTech}}", "submissionId": "{{var:submissionBernina}}"}}, "expect": {"ok": true}} +{"id": "act7.cast.giulia2", "priority": "P2", "implement": true, "outcome": "Succeeds - the single-choice ballot is recorded.", "act": 7, "t": "T+1", "title": "Giulia also votes for Bernina/Technical", "actor": "giulia.ricci", "action": "rpc", "method": "vote.VoteService/SubmitVote", "params": {"singleChoice": {"categoryId": "{{var:catTech}}", "submissionId": "{{var:submissionBernina}}"}}, "expect": {"ok": true}} +{"id": "act7.race.cat", "priority": "P2", "implement": true, "outcome": "Succeeds - a scratch single-choice category exists for the race; nobody has voted in it, so the real tallies stay untouched.", "act": 7, "t": "T+1", "title": "RACE: organizer defines a scratch category (Sprint Spirit) for the double-submit probe", "actor": "hackagon-admin", "action": "rpc", "method": "vote.VoteService/CreateVoteCategory", "params": {"hackathonId": "{{hackathonId}}", "name": "Sprint Spirit", "description": "Scratch category - the double-ballot race is probed here so the story's tallies stay clean.", "votingMethod": "VOTING_METHOD_SINGLE_CHOICE", "voterType": "VOTER_TYPE_ALL_PARTICIPANTS"}, "save": {"catRace": "voteCategory.id"}, "expect": {"ok": true}} +{"id": "act7.race.doublevote", "priority": "P1", "implement": true, "outcome": "Exactly ONE of four simultaneous ballots lands; the other three answer AlreadyExists.", "act": 7, "t": "T+1", "title": "RACE: jonas's flaky wifi retries his vote - four submits in flight at once, two per finalist", "action": "rpc.race", "calls": [{"actor": "jonas.weber", "method": "vote.VoteService/SubmitVote", "params": {"singleChoice": {"categoryId": "{{var:catRace}}", "submissionId": "{{var:submissionMatterhorn}}"}}}, {"actor": "jonas.weber", "method": "vote.VoteService/SubmitVote", "params": {"singleChoice": {"categoryId": "{{var:catRace}}", "submissionId": "{{var:submissionBernina}}"}}}, {"actor": "jonas.weber", "method": "vote.VoteService/SubmitVote", "params": {"singleChoice": {"categoryId": "{{var:catRace}}", "submissionId": "{{var:submissionMatterhorn}}"}}}, {"actor": "jonas.weber", "method": "vote.VoteService/SubmitVote", "params": {"singleChoice": {"categoryId": "{{var:catRace}}", "submissionId": "{{var:submissionBernina}}"}}}], "race": {"ok": 1, "failCodesOneOf": [["AlreadyExists", "AlreadyExists", "AlreadyExists"]]}, "todo": "The unique index moved to (category, voter, submission) for ranked ballots, so one-ballot-per-category became a handler pre-check - and the pre-check raced: 7 of 12 hammer rounds double-voted before writeBallot was serialized. Different submissions on purpose: identical ones the index still catches. Do not weaken this to make it pass."} +{"id": "act7.race.check", "priority": "P1", "implement": true, "outcome": "Exactly one ballot row exists in the category - the invariant, read back from the votes themselves and not from the RPC verdicts.", "act": 7, "t": "T+1", "title": "RACE: the category holds ONE ballot, whoever won", "actor": "hackagon-admin", "action": "rpc", "method": "vote.VoteService/ExportVotes", "params": {"categoryId": "{{var:catRace}}", "format": "EXPORT_FORMAT_JSON"}, "expect": {"ok": true, "check": "exportBallotCount", "checkArgs": {"count": 1, "oneVoter": true}}} +{"id": "act7.ranked.gap", "priority": "P2", "implement": true, "outcome": "Rejected with InvalidArgument - ranks must be a contiguous 1..N.", "act": 7, "t": "T+1", "title": "a ranked ballot skipping rank 2 is refused", "actor": "bob", "action": "rpc", "method": "vote.VoteService/SubmitVote", "params": {"ranked": {"categoryId": "{{var:catRanked}}", "submissions": [{"submissionId": "{{var:submissionBernina}}", "rank": 1}, {"submissionId": "{{var:submissionMatterhorn}}", "rank": 3}]}}, "expect": {"error": "InvalidArgument"}, "todo": "Ranks are carried explicitly rather than implied by list order, so a gap is a mistake the server can name instead of silently normalising."} +{"id": "act7.ranked.dupe", "priority": "P2", "implement": true, "outcome": "Rejected with InvalidArgument - the same submission twice in one ballot.", "act": 7, "t": "T+1", "title": "a ranked ballot naming one project twice is refused", "actor": "bob", "action": "rpc", "method": "vote.VoteService/SubmitVote", "params": {"ranked": {"categoryId": "{{var:catRanked}}", "submissions": [{"submissionId": "{{var:submissionBernina}}", "rank": 1}, {"submissionId": "{{var:submissionBernina}}", "rank": 2}]}}, "expect": {"error": "InvalidArgument"}} +{"id": "act7.ranked.bob", "priority": "P2", "implement": true, "outcome": "Succeeds - a ranked ballot is several Vote rows for one voter, which the old unique index made impossible.", "act": 7, "t": "T+1", "title": "bob ranks the two finished projects", "actor": "bob", "action": "rpc", "method": "vote.VoteService/SubmitVote", "params": {"ranked": {"categoryId": "{{var:catRanked}}", "submissions": [{"submissionId": "{{var:submissionBernina}}", "rank": 1}, {"submissionId": "{{var:submissionMatterhorn}}", "rank": 2}]}}, "expect": {"ok": true}} +{"id": "act7.ranked.wrongmethod", "priority": "P2", "implement": true, "outcome": "Rejected with InvalidArgument - the ballot variant must match the category's method.", "act": 7, "t": "T+1", "title": "a single-choice ballot cast into the ranked category is refused", "actor": "ines.duarte", "action": "rpc", "method": "vote.VoteService/SubmitVote", "params": {"singleChoice": {"categoryId": "{{var:catRanked}}", "submissionId": "{{var:submissionBernina}}"}}, "expect": {"error": "InvalidArgument"}} +{"id": "act7.points.over", "priority": "P2", "implement": true, "outcome": "Rejected with InvalidArgument - 8+5 exceeds the 10-point budget.", "act": 7, "t": "T+1", "title": "a points ballot spending more than the budget is refused", "actor": "bob", "action": "rpc", "method": "vote.VoteService/SubmitVote", "params": {"points": {"categoryId": "{{var:catPoints}}", "submissions": [{"submissionId": "{{var:submissionBernina}}", "points": 8}, {"submissionId": "{{var:submissionMatterhorn}}", "points": 5}]}}, "expect": {"error": "InvalidArgument"}} +{"id": "act7.points.bob", "priority": "P2", "implement": true, "outcome": "Succeeds - 7+3 is exactly the budget.", "act": 7, "t": "T+1", "title": "bob spends his 10 points across the two projects", "actor": "bob", "action": "rpc", "method": "vote.VoteService/SubmitVote", "params": {"points": {"categoryId": "{{var:catPoints}}", "submissions": [{"submissionId": "{{var:submissionBernina}}", "points": 7}, {"submissionId": "{{var:submissionMatterhorn}}", "points": 3}]}}, "expect": {"ok": true}} +{"id": "act7.points.ines", "priority": "P2", "implement": true, "outcome": "Succeeds - a second voter's points land alongside bob's.", "act": 7, "t": "T+1", "title": "ines spends hers the other way round", "actor": "ines.duarte", "action": "rpc", "method": "vote.VoteService/SubmitVote", "params": {"points": {"categoryId": "{{var:catPoints}}", "submissions": [{"submissionId": "{{var:submissionBernina}}", "points": 2}, {"submissionId": "{{var:submissionMatterhorn}}", "points": 8}]}}, "expect": {"ok": true}} +{"id": "act7.cast.admin", "priority": "P2", "implement": true, "outcome": "Rejected with PermissionDenied - only confirmed participants vote.", "act": 7, "t": "T+1", "title": "the organizer does not vote (policy: organizers are neutral)", "actor": "hackagon-admin", "action": "rpc", "method": "vote.VoteService/SubmitVote", "params": {"singleChoice": {"categoryId": "{{var:catImpact}}", "submissionId": "{{var:submissionMatterhorn}}"}}, "expect": {"error": "PermissionDenied"}} +{"id": "act7.cast.waitlisted", "priority": "P2", "implement": true, "outcome": "Rejected with PermissionDenied - only confirmed participants vote.", "act": 7, "t": "T+1", "title": "waitlisted charles cannot vote", "actor": "charles", "action": "rpc", "method": "vote.VoteService/SubmitVote", "params": {"singleChoice": {"categoryId": "{{var:catImpact}}", "submissionId": "{{var:submissionMatterhorn}}"}}, "expect": {"error": "PermissionDenied"}} +{"id": "act7.cast.double", "priority": "P2", "implement": true, "outcome": "Rejected with AlreadyExists - one ballot per voter per category.", "act": 7, "t": "T+1", "title": "double-voting the same submission+category is rejected", "actor": "dana.moser", "action": "rpc", "method": "vote.VoteService/SubmitVote", "params": {"singleChoice": {"categoryId": "{{var:catImpact}}", "submissionId": "{{var:submissionMatterhorn}}"}}, "expect": {"error": "AlreadyExists"}} +{"id": "act7.close", "priority": "P2", "implement": true, "outcome": "Succeeds - voting_enabled flips to false; late ballots bounce.", "act": 7, "t": "T+1", "title": "admin closes voting (voting_enabled toggle - there is no Close RPC)", "actor": "hackagon-admin", "action": "rpc", "method": "hackathon.HackathonService/EditSettings", "params": {"hackathonId": "{{hackathonId}}", "votingEnabled": false}, "expect": {"ok": true}} +{"id": "act7.cast.late", "priority": "P2", "implement": true, "outcome": "Rejected with FailedPrecondition - voting is closed.", "act": 7, "t": "T+1", "title": "votes for after closing are rejected", "actor": "bob", "action": "rpc", "method": "vote.VoteService/SubmitVote", "params": {"singleChoice": {"categoryId": "{{var:catImpact}}", "submissionId": "{{var:submissionBernina}}"}}, "expect": {"error": "FailedPrecondition"}} +{"id": "act7.result.impact", "priority": "P2", "implement": true, "outcome": "Succeeds - Matterhorn is placed first in Impact (results are advisory until the admin says so).", "act": 7, "t": "T+1", "title": "admin records the Impact winner from the tally (admin has the final voice)", "actor": "hackagon-admin", "action": "rpc", "method": "vote.VoteService/CreateVoteResult", "params": {"categoryId": "{{var:catImpact}}", "submissionId": "{{var:submissionMatterhorn}}", "position": 1, "title": "Winner - Impact"}, "expect": {"ok": true}} +{"id": "act7.result.ranked", "priority": "P2", "implement": true, "outcome": "Succeeds - Borda count over the ranked ballots.", "act": 7, "t": "T+1", "title": "organizer computes the ranked tally (Borda)", "actor": "hackagon-admin", "action": "rpc", "method": "vote.VoteService/SuggestResults", "params": {"categoryId": "{{var:catRanked}}"}, "expect": {"ok": true}} +{"id": "act7.result.points", "priority": "P2", "implement": true, "outcome": "Succeeds - Matterhorn 11 to Bernina 9, so the points winner differs from the ranked one.", "act": 7, "t": "T+1", "title": "organizer computes the points tally", "actor": "hackagon-admin", "action": "rpc", "method": "vote.VoteService/SuggestResults", "params": {"categoryId": "{{var:catPoints}}"}, "expect": {"ok": true}} +{"id": "act7.results", "priority": "P2", "implement": true, "outcome": "Succeeds - the Impact results list Matterhorn in first place.", "act": 7, "t": "T+1", "title": "results: Team Matterhorn wins (aggregated leaderboard)", "actor": "hackagon-admin", "action": "rpc", "method": "vote.VoteService/ListVoteResults", "params": {"categoryId": "{{var:catImpact}}"}, "expect": {"ok": true}} +{"id": "act7.prizes.finalize", "priority": "P3", "implement": true, "outcome": "Succeeds.", "act": 7, "t": "T+1", "title": "FINAL VOICE: admin reviews the results and finalizes the awards (votes are advisory)", "actor": "hackagon-admin", "action": "rpc", "method": "hackathon.PrizeService/Finalize", "params": {"hackathonId": "{{hackathonId}}", "awards": [{"rank": 1, "submissionId": "{{var:submissionMatterhorn}}"}, {"rank": 2, "submissionId": "{{var:submissionBernina}}"}, {"special": "Community Choice", "submissionId": "{{var:submissionBernina}}"}]}, "expect": {"ok": true}} +{"comment": "── ACT 8 — T+1 week: POST-EVENT ────────────────────────────────────"} +{"id": "act8.end", "priority": "P1", "implement": true, "outcome": "Succeeds.", "act": 8, "t": "T+1wk", "title": "the event moves into the past: status flips to Finished", "actor": "hackagon-admin", "action": "rpc", "method": "hackathon.HackathonService/Edit", "params": {"hackathonId": "{{hackathonId}}", "startsAt": "{{now-9d}}", "endsAt": "{{now-7d}}"}, "expect": {"ok": true}} +{"id": "act8.ui.finished", "priority": "P1", "implement": true, "outcome": "The public home lists 'SDSC Open Research Data Hackathon 2027' with the 'Finished' badge.", "act": 8, "t": "T+1wk", "title": "the public site shows the event as Finished", "action": "ui.assert", "assert": "homeStatus", "params": {"name": "SDSC Open Research Data Hackathon 2027", "status": "Finished"}} +{"id": "act8.latejoin", "priority": "P1", "implement": true, "outcome": "Rejected with FailedPrecondition - no state change.", "act": 8, "t": "T+1wk", "title": "late registrations are rejected once the event is over", "actor": "hackagon-admin", "action": "rpc", "method": "hackathon.HackathonService/Join", "params": {"hackathonId": "{{hackathonId}}"}, "expect": {"error": "FailedPrecondition"}} +{"id": "act8.flow.anon", "priority": "P1", "implement": true, "outcome": "The 6-step browsing chain completes, ending showing the 'SDSC Hackathon Platform' heading.", "act": 8, "t": "T+1wk", "title": "anonymous archive chain: home (Finished badge) → detail → back", "action": "ui.flow", "steps": [{"goto": "/"}, {"expectText": "Finished"}, {"clickLink": "SDSC Open Research Data Hackathon 2027"}, {"expectUrl": "/hackathon/"}, {"back": true}, {"expectHeading": "SDSC Hackathon Platform"}]} +{"id": "act8.audit", "priority": "P1", "implement": true, "outcome": "Succeeds; roster shows 13 on the list, 9 approved, 4 waitlisted.", "act": 8, "t": "T+1wk", "title": "MEANWHILE admin takes the post-event archive snapshot (walk-in included) (roster includes the organizer)", "actor": "hackagon-admin", "action": "rpc", "method": "hackathon.HackathonService/Get", "params": {"hackathonId": "{{hackathonId}}"}, "expect": {"ok": true, "check": "roster", "checkArgs": {"total": 14, "approved": 10, "waiting": 4}}} +{"id": "act8.thanks", "priority": "P1", "implement": true, "outcome": "Succeeds.", "act": 8, "t": "T+1wk", "title": "admin updates the description with thanks and the winners", "actor": "hackagon-admin", "action": "rpc", "method": "hackathon.HackathonService/Edit", "params": {"hackathonId": "{{hackathonId}}", "description": "THANK YOU for an amazing edition! Winners: 1st Team Matterhorn (FAIR Pipeline Builder), 2nd Team Bernina (LitData Extractor). Photos and submissions are available to participants. — SDSC Open Research Data Hackathon 2027, SwissTech Convention Center, EPFL."}, "expect": {"ok": true}} +{"id": "act8.thanks.ui", "priority": "P1", "implement": true, "outcome": "The member overview About section shows 'Team Matterhorn'.", "act": 8, "t": "T+1wk", "title": "members see the thank-you note and winners on their overview", "actor": "bob", "action": "ui.assert", "assert": "aboutVisible", "params": {"textContains": "Team Matterhorn"}} +{"id": "act8.retention.alice", "priority": "P1", "implement": true, "outcome": "Opening the member view returns HTTP 200.", "act": 8, "t": "T+1wk", "title": "alice also keeps access to the archived event", "actor": "alice", "action": "ui.assert", "assert": "memberViewStatus", "params": {"status": 200}} +{"id": "act8.prizes.edit", "priority": "P3", "implement": true, "outcome": "Succeeds.", "act": 8, "t": "T+1wk", "title": "PRIZES: admin edits the awarded prize text (adds the sponsor credit)", "actor": "hackagon-admin", "action": "rpc", "method": "hackathon.PrizeService/Edit", "params": {"hackathonId": "{{hackathonId}}", "rank": 1, "title": "1st — CHF 5'000 + SDSC mentoring (sponsored by the Innovation Unit)"}, "expect": {"ok": true}} +{"id": "act8.prizes.rogue", "priority": "P3", "implement": true, "outcome": "Rejected with PermissionDenied - no state change.", "act": 8, "t": "T+1wk", "title": "a member cannot touch the prize table", "actor": "bob", "action": "rpc", "method": "hackathon.PrizeService/Edit", "params": {"hackathonId": "{{hackathonId}}", "rank": 1, "title": "1st — a lifetime supply of pizza"}, "expect": {"error": "PermissionDenied"}} +{"id": "act8.retention", "priority": "P1", "implement": true, "outcome": "Opening the member view returns HTTP 200.", "act": 8, "t": "T+1wk", "title": "confirmed members keep access to the event history", "actor": "bob", "action": "ui.assert", "assert": "memberViewStatus", "params": {"status": 200}} +{"id": "act8.flow.charles", "priority": "P1", "implement": true, "outcome": "The 8-step browsing chain completes: the refusal is not a dead end, and its way out lands on the public page of the SAME event - the one that offers Join.", "act": 8, "t": "T+1wk", "title": "post-event waitlisted chain: fresh login → dashboard (still Waitlisted) → click event → still 403 → back to the event's public page", "actor": "charles", "action": "ui.flow", "fresh": true, "steps": [{"login": true}, {"expectUrl": "/dashboard$"}, {"expectText": "Waitlisted"}, {"clickLink": "SDSC Open Research Data Hackathon 2027"}, {"expectText": "403"}, {"clickLink": "Back to this hackathon"}, {"expectUrl": "/hackathon/[0-9a-f-]+$"}, {"expectText": "SDSC Open Research Data Hackathon 2027"}], "todo": "Re-specified 2026-08-14 for develop's +error.svelte. The refusal page used to offer one always-Home button; it now reads the hackathon id off the URL and offers 'Back to this hackathon', landing on the PUBLIC event page - which for a waitlisted person is the page that offers Join, i.e. the one place the refusal should send them. The claim is unchanged and is why the action exists: a 403 must not be a dead end. Strengthened while re-writing - it now asserts WHICH event it landed on, because 'some link was clicked and the URL changed' would pass against a link back to anywhere."} +{"id": "act8.photos", "priority": "P1", "implement": true, "outcome": "Succeeds. [Skips until the gated capability lands.]", "act": 8, "t": "T+1wk", "title": "photos published + winners announced on the website", "actor": "hackagon-admin", "action": "rpc", "method": "hackathon.PageService/Create", "params": {"hackathonId": "{{hackathonId}}", "title": "Photos & Winners", "content": "Winners: 1st Team Matterhorn (FAIR Pipeline Builder), 2nd Team Bernina (LitData Extractor). Photo material: generated posters from helpers/files.ts by default, or CC files fetched by scripts/fetch-cc-assets.sh — keep .state/uploads/cc/ATTRIBUTION.md content on the page.", "visible": true}, "expect": {"ok": true}, "todo": "TODO: runs once PageService.Create lands; image embedding needs the upload channel from act6.submit.draft."} +{"id": "act8.media.presign", "priority": "P1", "implement": true, "outcome": "Succeeds - a presigned PUT for a gallery photo.", "act": 8, "t": "T+1wk", "title": "MEDIA: the organizer gets an upload URL for a gallery photo", "actor": "hackagon-admin", "action": "rpc", "method": "storage.StorageService/CreateUploadUrl", "params": {"kind": "UPLOAD_KIND_HACKATHON_MEDIA", "ownerId": "{{hackathonId}}", "filename": "day-two.webp", "contentType": "image/webp", "sizeBytes": 98028}, "expect": {"ok": true}, "todo": "The page editor's Insert image control calls this. Uploads are re-encoded to WebP in the browser first, so the declared type is what the signature is built for."} +{"id": "act8.media.rogue", "priority": "P1", "implement": true, "outcome": "Rejected with PermissionDenied - gallery media needs hackathon Write.", "act": 8, "t": "T+1wk", "title": "MEDIA: a member cannot upload gallery photos", "actor": "bob", "action": "rpc", "method": "storage.StorageService/CreateUploadUrl", "params": {"kind": "UPLOAD_KIND_HACKATHON_MEDIA", "ownerId": "{{hackathonId}}", "filename": "day-two.webp", "contentType": "image/webp", "sizeBytes": 1024}, "expect": {"error": "PermissionDenied"}} +{"id": "act8.media.svg", "priority": "P1", "implement": true, "outcome": "Rejected with InvalidArgument - SVG is excluded on purpose.", "act": 8, "t": "T+1wk", "title": "SECURITY: an SVG gallery photo is refused", "actor": "hackagon-admin", "action": "rpc", "method": "storage.StorageService/CreateUploadUrl", "params": {"kind": "UPLOAD_KIND_HACKATHON_MEDIA", "ownerId": "{{hackathonId}}", "filename": "diagram.svg", "contentType": "image/svg+xml", "sizeBytes": 2048}, "expect": {"error": "InvalidArgument"}, "todo": "/objects is the app's own origin, so a stored SVG is script running as the application."} +{"id": "act8.media.upload", "priority": "P1", "implement": true, "outcome": "A real gallery upload round-trips: presign, PUT the bytes, GET them back - every hop over the same origin the suite runs against.", "act": 8, "t": "T+1wk", "title": "MEDIA: the uploaded photo actually serves from /objects (presign → PUT → GET)", "actor": "hackagon-admin", "action": "ui.assert", "assert": "mediaUploadRoundTrip", "params": {"seed": 2029, "filename": "day-two-real.png"}, "todo": "The presign RPC succeeded for months while /objects 404'd on the adapter-node build - the upload went nowhere, no uploaded image loaded, and every suite stayed green. This is the hop that turns red."} +{"id": "act8.media.upload2", "priority": "P2", "implement": true, "outcome": "A second photo round-trips the same way, so the listing below has more than one object and its paging is a real claim rather than an empty one.", "act": 8, "t": "T+1wk", "title": "MEDIA: a second gallery photo is uploaded and serves", "actor": "hackagon-admin", "action": "ui.assert", "assert": "mediaUploadRoundTrip", "params": {"seed": 2031, "filename": "gallery-2.png"}} +{"id": "act8.objects.hackathon", "priority": "P1", "implement": true, "outcome": "Succeeds; both uploads come back and every key sits under this event's own prefix - a listing that leaked another event's keys would be the whole point of the scope failing.", "act": 8, "t": "T+1wk", "title": "STORAGE: the organizer lists what this event has uploaded", "actor": "hackagon-admin", "action": "rpc", "method": "storage.StorageService/ListObjects", "params": {"scope": "OBJECT_SCOPE_HACKATHON_MEDIA", "ownerId": "{{hackathonId}}"}, "expect": {"ok": true, "check": "objectsListed", "checkArgs": {"atLeast": 2, "keyPrefix": "hackathons/{{hackathonId}}/"}}} +{"id": "act8.objects.noowner", "priority": "P1", "implement": true, "outcome": "Rejected with InvalidArgument - the hackathon scope is the one that names an owner, and 'required unless the scope is X' is a rule the handler states rather than a CEL expression restating the scope table.", "act": 8, "t": "T+1wk", "title": "PARAMS: the hackathon scope with no owner id", "actor": "hackagon-admin", "action": "rpc", "method": "storage.StorageService/ListObjects", "params": {"scope": "OBJECT_SCOPE_HACKATHON_MEDIA"}, "expect": {"error": "InvalidArgument"}} +{"id": "act8.objects.badowner", "priority": "P2", "implement": true, "outcome": "Rejected with InvalidArgument by the request's own validation, before any prefix is derived from it.", "act": 8, "t": "T+1wk", "title": "PARAMS: a malformed owner id on a listing", "actor": "hackagon-admin", "action": "rpc", "method": "storage.StorageService/ListObjects", "params": {"scope": "OBJECT_SCOPE_HACKATHON_MEDIA", "ownerId": "not-a-uuid"}, "expect": {"error": "InvalidArgument"}} +{"id": "act8.objects.rogue", "priority": "P1", "implement": true, "outcome": "Rejected with PermissionDenied - you may LIST a prefix exactly when you may WRITE to it, and a member may not upload the event's media.", "act": 8, "t": "T+1wk", "title": "STORAGE: a member cannot list the event's media", "actor": "bob", "action": "rpc", "method": "storage.StorageService/ListObjects", "params": {"scope": "OBJECT_SCOPE_HACKATHON_MEDIA", "ownerId": "{{hackathonId}}"}, "expect": {"error": "PermissionDenied"}} +{"id": "act8.objects.anon", "priority": "P1", "implement": true, "outcome": "Rejected with Unauthenticated - the keys alone would say what an event has been sent, so listing is never anonymous even where reading one object is.", "act": 8, "t": "T+1wk", "title": "STORAGE: an anonymous caller cannot enumerate the store", "actor": "anonymous", "action": "rpc", "method": "storage.StorageService/ListObjects", "params": {"scope": "OBJECT_SCOPE_HACKATHON_MEDIA", "ownerId": "{{hackathonId}}"}, "expect": {"error": "Unauthenticated"}} +{"id": "act8.objects.site.admin", "priority": "P1", "implement": true, "outcome": "Succeeds - the platform's own imagery prefix belongs to no event, so it takes the only role that spans the platform.", "act": 8, "t": "T+1wk", "title": "STORAGE: an admin lists the platform pages' imagery", "actor": "hackagon-admin", "action": "rpc", "method": "storage.StorageService/ListObjects", "params": {"scope": "OBJECT_SCOPE_SITE_MEDIA"}, "expect": {"ok": true}} +{"id": "act8.objects.site.rogue", "priority": "P1", "implement": true, "outcome": "Rejected with PermissionDenied - an event's organizer may write their own event's prefix and nothing else, which is exactly what they may list.", "act": 8, "t": "T+1wk", "title": "STORAGE: an event organizer cannot list the platform's imagery", "actor": "alice", "action": "rpc", "method": "storage.StorageService/ListObjects", "params": {"scope": "OBJECT_SCOPE_SITE_MEDIA"}, "expect": {"error": "PermissionDenied"}} +{"id": "act8.objects.all.admin", "priority": "P1", "implement": true, "outcome": "Succeeds; every listable prefix in one answer, which is what the platform media library is built on.", "act": 8, "t": "T+1wk", "title": "STORAGE: an admin lists every listable prefix at once", "actor": "hackagon-admin", "action": "rpc", "method": "storage.StorageService/ListObjects", "params": {"scope": "OBJECT_SCOPE_ALL_MEDIA"}, "expect": {"ok": true, "check": "objectsListed", "checkArgs": {"atLeast": 2}}} +{"id": "act8.objects.all.rogue", "priority": "P1", "implement": true, "outcome": "Rejected with PermissionDenied - the scope spans events the caller may have no part in, so it takes the global Admin role and nothing less.", "act": 8, "t": "T+1wk", "title": "STORAGE: a member cannot list every event's media", "actor": "bob", "action": "rpc", "method": "storage.StorageService/ListObjects", "params": {"scope": "OBJECT_SCOPE_ALL_MEDIA"}, "expect": {"error": "PermissionDenied"}} +{"id": "act8.objects.scope.zero", "priority": "P2", "implement": true, "outcome": "Rejected with InvalidArgument - the scope is the whole placement input, so an unset one has no prefix to fall back to and none is invented.", "act": 8, "t": "T+1wk", "title": "PARAMS: a listing with no scope", "actor": "hackagon-admin", "action": "rpc", "method": "storage.StorageService/ListObjects", "params": {"scope": "OBJECT_SCOPE_UNSPECIFIED"}, "expect": {"error": "InvalidArgument"}} +{"id": "act8.objects.page.one", "priority": "P1", "implement": true, "outcome": "Succeeds; exactly one object and a cursor to carry on with - the answer is bounded whatever is asked for, because a bucket grows without limit.", "act": 8, "t": "T+1wk", "title": "PARAMS: a page size of one returns one object and a cursor", "actor": "hackagon-admin", "action": "rpc", "method": "storage.StorageService/ListObjects", "params": {"scope": "OBJECT_SCOPE_ALL_MEDIA", "pageSize": 1}, "save": {"objFirstKey": "objects.0.key", "objToken": "nextPageToken"}, "expect": {"ok": true, "check": "objectsListed", "checkArgs": {"count": 1, "hasNextToken": true}}} +{"id": "act8.objects.page.two", "priority": "P1", "implement": true, "outcome": "Succeeds; the second page holds a DIFFERENT object - 'a token came back' and 'the cursor advanced' are different claims, and only the second one makes paging real.", "act": 8, "t": "T+1wk", "title": "PARAMS: the cursor from the first page really advances", "actor": "hackagon-admin", "action": "rpc", "method": "storage.StorageService/ListObjects", "params": {"scope": "OBJECT_SCOPE_ALL_MEDIA", "pageSize": 1, "pageToken": "{{var:objToken}}"}, "expect": {"ok": true, "check": "objectsListed", "checkArgs": {"count": 1, "keyNot": "{{var:objFirstKey}}"}}} +{"id": "act8.objects.pagesize.max", "priority": "P2", "implement": true, "outcome": "Succeeds - a page size above the server's ceiling is CLAMPED rather than refused, because the number is a rendering preference and not a permission.", "act": 8, "t": "T+1wk", "title": "PARAMS: a page size of 1000 is clamped, not rejected", "actor": "hackagon-admin", "action": "rpc", "method": "storage.StorageService/ListObjects", "params": {"scope": "OBJECT_SCOPE_ALL_MEDIA", "pageSize": 1000}, "expect": {"ok": true, "check": "objectsListed", "checkArgs": {"atLeast": 2}}} +{"id": "act8.objects.pagesize.over", "priority": "P2", "implement": true, "outcome": "Rejected with InvalidArgument - past 1000 the request itself is malformed, which is a different answer from 'more than I will serve'.", "act": 8, "t": "T+1wk", "title": "PARAMS: a page size of 5000", "actor": "hackagon-admin", "action": "rpc", "method": "storage.StorageService/ListObjects", "params": {"scope": "OBJECT_SCOPE_ALL_MEDIA", "pageSize": 5000}, "expect": {"error": "InvalidArgument"}} +{"id": "act8.objects.badtoken", "priority": "P2", "implement": true, "outcome": "Rejected with InvalidArgument - the cursor is opaque but bounded, so an oversized one is refused rather than parsed.", "act": 8, "t": "T+1wk", "title": "PARAMS: an oversized page token", "actor": "hackagon-admin", "action": "rpc", "method": "storage.StorageService/ListObjects", "params": {"scope": "OBJECT_SCOPE_ALL_MEDIA", "pageToken": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"}, "expect": {"error": "InvalidArgument"}} +{"id": "act8.flow.reach.gallery", "priority": "P1", "implement": true, "outcome": "The media library is reached by clicking the platform tile on the dashboard - the route it took two audits to notice nothing linked to.", "act": 8, "t": "T+1wk", "title": "REACHABILITY: dashboard → Manage platform → Media, by clicking", "actor": "hackagon-admin", "action": "ui.flow", "steps": [{"goto": "/dashboard"}, {"expectText": "Manage platform"}, {"clickLink": "Media"}, {"expectUrl": "/manage/gallery$"}, {"expectHeading": "Media library"}], "todo": "Two mechanical audits found what neither review could: routes with NO inbound link, /manage/pages and /account among them. A tile nobody can click is a page nobody has."} +{"id": "act8.gallery.admin", "priority": "P1", "implement": true, "outcome": "The library lists the event's uploads and each tile states WHAT it is and WHICH event it came from, on their own elements - the card also carries those words in its alt text and its link, so asserting on the card would prove nothing.", "act": 8, "t": "T+1wk", "title": "GALLERY: the platform media library shows the event's photos", "actor": "hackagon-admin", "action": "ui.assert", "assert": "galleryShows", "params": {"origins": ["Event image"], "eventName": "SDSC Open Research Data Hackathon 2027"}} +{"id": "act8.gallery.rogue", "priority": "P1", "implement": true, "outcome": "403 - the URL is guessable and the backend is what refuses it; the page only translates that verdict.", "act": 8, "t": "T+1wk", "title": "GALLERY: an event organizer typing the library's URL is refused", "actor": "alice", "action": "ui.flow", "steps": [{"goto": "/manage/gallery", "status": 403}]} +{"id": "act8.picker.halves", "priority": "P1", "implement": true, "outcome": "The picker opens on Upload with a labelled file input, switches to a gallery of what this event already holds, and each half is ABSENT while the other shows - a picker stacking both would have looked right from either one alone.", "act": 8, "t": "T+1wk", "title": "MEDIA PICKER: upload and choose-from-gallery are two halves, not a stack", "actor": "hackagon-admin", "action": "ui.assert", "assert": "imagePicker", "params": {"path": "pages/new"}, "todo": "The browse half only renders when the mount passes a browseEndpoint - a tab that could only ever be empty is worse than one tab - so this also pins that the page editor passes one."} +{"id": "act8.picker.reject", "priority": "P1", "implement": true, "outcome": "A PDF chosen where a picture is wanted is refused with words, and nothing is written into the page - the field must not end up holding a link to something that will never render.", "act": 8, "t": "T+1wk", "title": "WRONG FILE: someone picks a PDF in the image picker", "actor": "hackagon-admin", "action": "ui.assert", "assert": "imagePicker", "params": {"path": "pages/new", "halves": false, "reject": {"name": "programme.pdf", "mimeType": "application/pdf", "body": "%PDF-1.4 not really a pdf, and certainly not an image"}}, "todo": "The refusal comes from the presign - size and content type are conditions ON the signature - so the file is turned away before a byte moves."} +{"id": "act8.picker.cancel", "priority": "P2", "implement": true, "outcome": "The picker opened by mistake closes on Cancel and leaves the draft untouched.", "act": 8, "t": "T+1wk", "title": "SECOND THOUGHTS: the image picker is closed without picking anything", "actor": "hackagon-admin", "action": "ui.assert", "assert": "imagePicker", "params": {"path": "pages/new", "halves": false, "cancel": true}} +{"id": "act8.pilot.finished", "priority": "P1", "implement": true, "outcome": "Succeeds; the pilot sprint moves into the past, so its status flips to Finished and the reason its Join is withheld changes underneath the same sentence.", "act": 8, "t": "T+1wk", "title": "JOIN GATE: the pilot sprint also ends", "actor": "hackagon-admin", "action": "rpc", "method": "hackathon.HackathonService/Edit", "params": {"hackathonId": "{{var:capHackId}}", "startsAt": "{{now-3d}}", "endsAt": "{{now-2d}}"}, "expect": {"ok": true}} +{"id": "act8.pilot.join.finished", "priority": "P1", "implement": true, "outcome": "Still 'Registration closed' and still no button - a finished event is the first refusal Join makes, checked before any capability, because 'already finished' is the more useful answer.", "act": 8, "t": "T+1wk", "title": "JOIN GATE: a finished event offers no Join either", "actor": "alice", "action": "ui.assert", "assert": "dashboardJoinClosed", "params": {"name": "SDSC Capacity Pilot Sprint"}} +{"id": "act8.pilot.join.finished.rpc", "priority": "P1", "implement": true, "outcome": "Rejected with FailedPrecondition - the same refusal the row was reporting, from the layer that owns the decision.", "act": 8, "t": "T+1wk", "title": "JOIN GATE: the backend refuses a join into a finished event", "actor": "alice", "action": "rpc", "method": "hackathon.HackathonService/Join", "params": {"hackathonId": "{{var:capHackId}}"}, "expect": {"error": "FailedPrecondition"}} +{"id": "act8.flow.bob", "priority": "P1", "implement": true, "outcome": "The 8-step browsing chain completes, ending at a URL matching '/photos$'.", "act": 8, "t": "T+1wk", "title": "member history chain: dashboard (Finished badge) → overview → Submissions → Photos", "actor": "bob", "action": "ui.flow", "steps": [{"goto": "/dashboard"}, {"expectText": "Finished"}, {"clickLink": "SDSC Open Research Data Hackathon 2027"}, {"expectUrl": "/overview$"}, {"clickLink": "Submissions"}, {"expectUrl": "/submissions$"}, {"clickLink": "Photos"}, {"expectUrl": "/photos$"}], "comment": "Runs AFTER act8.photos on purpose: the Photos tab is derived from the event's own pages — no gallery page, no tab — so the chain that ends on it needs the gallery published first."} +{"id": "act8.ui.winners", "priority": "P2", "implement": true, "outcome": "The public winners page names 'Team Matterhorn' as the winner.", "act": 8, "t": "T+1wk", "title": "the winners page renders for anonymous visitors", "action": "ui.assert", "assert": "publicWinnersPage", "params": {"winner": "Team Matterhorn"}} +{"id": "act8.blog", "priority": "P1", "implement": true, "outcome": "Succeeds. Returns pageBlog for later steps. [Skips until the gated capability lands.]", "act": 8, "t": "T+1wk", "title": "FINAL BLOG: admin publishes the wrap-up post — winner, numbers, thank-yous", "actor": "hackagon-admin", "action": "rpc", "method": "hackathon.PageService/Create", "params": {"hackathonId": "{{hackathonId}}", "title": "Wrap-up: ORD Hackathon 2027", "content": "Final blog entry. 13 registrations, 8 confirmed participants, 2 teams, 14 ballots. Winner: Team Matterhorn with FAIR Pipeline Builder; runner-up Team Bernina with LitData Extractor. Webinar recordings, photos and the full leaderboard are linked below. See you at the Winter School!", "visible": true}, "save": {"pageBlog": "pageId"}, "expect": {"ok": true}, "todo": "TODO: runs once PageService.Create lands — the public wrap-up/blog entry announcing the winner."} +{"id": "act8.ui.blog", "priority": "P2", "implement": true, "outcome": "The public wrap-up post is readable and names 'Team Matterhorn'.", "act": 8, "t": "T+1wk", "title": "the wrap-up post is readable by everyone", "action": "ui.assert", "assert": "publicBlogEntry", "params": {"titleContains": "Wrap-up", "winner": "Team Matterhorn"}} +{"id": "act8.profile.rename", "priority": "P2", "implement": true, "outcome": "Succeeds - the display name is the platform's own field, not Keycloak's.", "act": 8, "t": "T+3w", "title": "PROFILE: alice sets the name shown on everything she made", "actor": "alice", "action": "rpc", "method": "user.UserService/EditProfile", "gate": ["user.UserService/EditProfile"], "params": {"displayName": "Alice Wonderland (SDSC)"}, "expect": {"ok": true, "check": "profileName", "checkArgs": {"equals": "Alice Wonderland (SDSC)"}}} +{"id": "act8.profile.sticks", "priority": "P1", "implement": true, "outcome": "WhoAmI returns the edited name. It used to re-sync display_name from the token on EVERY request, so any edit was reverted by the next page load.", "act": 8, "t": "T+3w", "title": "PROFILE: the new name survives the next request", "actor": "alice", "action": "rpc", "method": "user.UserService/WhoAmI", "params": {}, "expect": {"ok": true, "check": "profileName", "checkArgs": {"equals": "Alice Wonderland (SDSC)"}}} +{"id": "act8.profile.blank", "priority": "P2", "implement": true, "outcome": "Rejected with InvalidArgument - a blank name renders as an empty byline everywhere.", "act": 8, "t": "T+3w", "title": "VALIDATION: alice cannot blank out her display name", "actor": "alice", "action": "rpc", "method": "user.UserService/EditProfile", "gate": ["user.UserService/EditProfile"], "params": {"displayName": " "}, "expect": {"error": "InvalidArgument"}} +{"id": "act8.menu.alice", "priority": "P1", "implement": true, "outcome": "The account menu opens on the FIRST click and reaches /account - the only route to it.", "act": 8, "t": "T+3w", "title": "NAVIGATION: alice reaches her account from the top bar", "actor": "alice", "action": "ui.flow", "steps": [{"login": true}, {"expectUrl": "/dashboard$"}, {"clickLink": "Your account"}, {"expectUrl": "/account$"}, {"expectHeading": "Your account"}], "fresh": true} +{"id": "act8.menu.admin", "priority": "P2", "implement": true, "outcome": "Admins reach the platform CMS from the menu; the PLATFORM section is role-gated.", "act": 8, "t": "T+3w", "title": "NAVIGATION: the admin reaches /manage/pages from the dashboard", "actor": "hackagon-admin", "action": "ui.flow", "steps": [{"goto": "/dashboard"}, {"clickLink": "Pages"}, {"expectUrl": "/manage/pages$"}]} +{"id": "act8.form.ui.edit", "priority": "P2", "implement": true, "outcome": "A participant can FIND their registration answers from the event and change them - now via the participants roster, where his own row's View opens his editable form.", "act": 8, "t": "T+3w", "title": "FORMS: bob reaches his registration answers through the UI", "actor": "bob", "action": "ui.flow", "steps": [{"goto": "/my/hackathon/{{hackathonId}}/overview"}, {"clickLink": "Participants"}, {"expectUrl": "/participants$"}, {"clickSelector": "xpath=//main//a[starts-with(normalize-space(@aria-label),'View') and not(contains(@href,'userId'))]"}, {"expectUrl": "/register/"}, {"expectText": "You've already filled this in"}], "todo": "Re-specified 2026-08-14. develop's c596683c redesigned the overview and removed the 'Your registration answers -> View or edit' block; its 76037844 moved the entry point to the participants roster, where View opens /register/{id} for your own row and /register/{id}?userId= for someone else's (organizers only). The PRODUCT rule is unchanged and is the one this action exists for - SubmitRegistrationForm is an upsert precisely so the first typo is not permanent, which needs a way in from the UI - so the locator moved and the claim did not. The account page used to send people to that same removed block; fixed 2026-08-14 - it names Participants -> View now, and smoke/07-account-menu follows that sentence out of the page's own markup rather than reading it."} +{"id": "act8.account.liam", "priority": "P3", "implement": true, "outcome": "Succeeds.", "act": 8, "t": "T+1wk", "title": "CHURN: Liam (never got off the waitlist) deletes his profile and leaves the platform", "actor": "liam.obrien", "action": "rpc", "method": "user.UserService/DeleteAccount", "params": {}, "expect": {"ok": true}} +{"id": "act8.account.mei", "priority": "P3", "implement": true, "outcome": "Succeeds.", "act": 8, "t": "T+1wk", "title": "CHURN: Mei deletes her profile too", "actor": "mei.chen", "action": "rpc", "method": "user.UserService/DeleteAccount", "params": {}, "expect": {"ok": true}} +{"id": "act8.account.check", "priority": "P3", "implement": true, "outcome": "Succeeds; the deleted profiles no longer appear in the user list.", "act": 8, "t": "T+1wk", "title": "the departed profiles are gone from the platform user list", "actor": "hackagon-admin", "action": "rpc", "method": "user.UserService/List", "params": {}, "expect": {"ok": true, "check": "usersLackNames", "checkArgs": {"names": ["Liam O'Brien", "Mei Chen"]}}} +{"id": "act8.page.cleanup", "priority": "P1", "implement": true, "outcome": "Succeeds.", "act": 8, "t": "T+1wk", "title": "CLEANUP: admin deletes the outdated webinar page", "actor": "hackagon-admin", "action": "rpc", "method": "hackathon.PageService/Delete", "params": {"pageId": "{{var:pageWebinars}}"}, "expect": {"ok": true}} +{"id": "act8.draft.delete", "priority": "P2", "implement": true, "outcome": "Succeeds. [Skips until the gated capability lands.]", "act": 8, "t": "T+1wk", "title": "CLEANUP: admin deletes the never-announced winter draft event", "actor": "hackagon-admin", "action": "rpc", "method": "hackathon.HackathonService/Delete", "params": {"hackathonId": "{{var:draftId}}"}, "expect": {"ok": true}, "todo": "TODO: runs once HackathonService.Delete lands — pin cascade semantics (participants/pages/teams of a deleted hackathon) when it does."} diff --git a/.claude/skills/hackathon-e2e/scripts/build-quality-report.mjs b/.claude/skills/hackathon-e2e/scripts/build-quality-report.mjs new file mode 100644 index 00000000..106b584b --- /dev/null +++ b/.claude/skills/hackathon-e2e/scripts/build-quality-report.mjs @@ -0,0 +1,2178 @@ +#!/usr/bin/env node +/** + * Build `quality-report.html` — a status report you could hand someone: what is + * tested, how well, what is not, and what is known-broken. + * + * This is NOT recipe-player.html. The player is an animated REPLAY of the + * recipe; this is the report about it. The report links to the player. + * + * ── The one rule ─────────────────────────────────────────────────────────── + * Nothing in the output is hand-typed. Every number is read from a file on + * disk at build time, rendered with the source it came from, and then READ + * BACK OUT of the finished HTML and re-derived by a second, independent code + * path before the build is allowed to succeed. A stale hand-written count is + * the failure mode this exists to make impossible — that is why `claim()` + * stamps `data-claim`/`data-value` on every figure, and why `rederive()` + * counts with textual scans rather than reusing the parsed objects. + * + * ── The check GATES the write; it does not follow it ──────────────────────── + * The document is assembled in memory, every read-back check runs against that + * string, and only a clean pass reaches the disk — through a temp file in the + * same directory and a rename, so an interrupted run cannot leave half a file + * and a failing run leaves the previous report byte-for-byte intact. + * + * This used to be the other way round: `writeFileSync` first, `read(OUT)` and + * re-derive after. **A validator that runs after the write certifies nothing** + * — the bad artefact is on disk either way, its exit code is the only thing + * standing between it and a commit, and one run whose CLAUDE.md row disagreed + * with results.json left an `undefined`-filled report that had to be reverted + * by hand. Reading the string rather than the file loses nothing: the string + * IS the finished document, and the bytes that land are compared against it + * after the rename, so "what was checked" and "what is on disk" stay one thing. + * + * Sources (all repo-relative, all read fresh): + * A .claude/skills/hackathon-e2e/recipe.jsonl + * B .claude/skills/hackathon-e2e/.artifacts/results.json + * C .claude/skills/hackathon-e2e/mutations/manifest.jsonl + * D .claude/skills/hackathon-e2e/mutations/.state/verify.json + * E docs/testing.md + * F .claude/CLAUDE.md + * G api/proto/ ** /*_service.proto + * H components/frontend/src + * I the named spec / Go files under components/ and tests/ + * + * ── Traps this script is built around ────────────────────────────────────── + * 1. An inline + + +` + +/* ══════════ read back, re-derive, and refuse to WRITE on a disagreement ═══ */ +/* Nothing has touched the disk at this point, and nothing will until every + check below has passed. See the header note: the write is the last thing + this script does, not the first. */ + +/** + * The second code path. Deliberately textual where the first was structural: + * if both were `JSON.parse` + the same reduce, agreeing would prove nothing. + */ +function rederive() { + const out = {} + const raw = read(P.recipe) + const lines = raw.split(/\r?\n/).filter((l) => l.trim()) + const count = (re) => lines.filter((l) => re.test(l)).length + + out["recipe.actions"] = count(/"id"\s*:/) + out["recipe.actionsLede"] = out["recipe.actions"] + for (const k of KINDS) + out[`recipe.kind.${k.key}`] = count( + new RegExp(`"action"\\s*:\\s*"${k.key.replace(".", "\\.")}"`), + ) + for (const p of ["P1", "P2", "P3"]) + out[`recipe.${p}`] = count(new RegExp(`"priority"\\s*:\\s*"${p}"`)) + out["recipe.gates"] = count(/"gate"\s*:/) + out["recipe.todos"] = count(/"todo"\s*:/) + out["recipe.grpc"] = out["recipe.kind.rpc"] + out["recipe.kind.rpc.race"] + out["recipe.browser"] = + out["recipe.kind.ui.flow"] + out["recipe.kind.ui.assert"] + out["recipe.browserAgain"] = out["recipe.browser"] + out["recipe.files"] = out["recipe.kind.files.generate"] + let denials = 0 + for (const code of errCount.keys()) { + const c = count(new RegExp(`"error"\\s*:\\s*"${code}"`)) + out[`recipe.denial.${code}`] = c + denials += c + } + out["recipe.denials"] = denials + out["recipe.denialsTable"] = denials + out["recipe.actors"] = new Set( + lines + .map((l) => (l.match(/"actor"\s*:\s*"([^"]+)"/) || [])[1]) + .filter(Boolean), + ).size + const methods = new Set() + for (const m of raw.matchAll(/"method"\s*:\s*"([^"]+)"/g)) methods.add(m[1]) + out["recipe.methods"] = methods.size + out["recipe.services"] = new Set( + [...methods].map((m) => m.split("/")[0]), + ).size + + // per-section counts: split the raw text on ACT banners and count id lines. + // Both JSONL files here are written with a space after the colon, so every + // regex below allows for it — a `"key":"value"` pattern silently matches + // nothing against `"key": "value"`, which is a zero that looks like an answer. + const chunks = raw.split(/\n(?=\{"comment":\s*"─+ ACT )/) + const secCounts = [] + for (const ch of chunks) { + if (!/^\{"comment":\s*"─+ ACT /.test(ch)) continue + secCounts.push(ch.split(/\r?\n/).filter((l) => /"id"\s*:/.test(l)).length) + } + const secKinds = [] + for (const ch of chunks) { + if (!/^\{"comment":\s*"─+ ACT /.test(ch)) continue + const m = {} + for (const k of KINDS) + m[k.key] = ch + .split(/\r?\n/) + .filter((l) => + new RegExp(`"action"\\s*:\\s*"${k.key.replace(".", "\\.")}"`).test(l), + ).length + secKinds.push(m) + } + sections.forEach((s, i) => { + const key = s.label.toLowerCase().replace(/[^a-z0-9]+/g, "") + out[`recipe.section.${key}`] = secCounts[i] + for (const k of KINDS) + if (secKinds[i][k.key]) + out[`recipe.section.${key}.${k.key}`] = secKinds[i][k.key] + }) + + // B — results.json, read by regex off the raw text rather than the object tree + const rr = read(P.results) + const stats = JSON.parse( + rr.slice(rr.lastIndexOf('"stats":') + 8).replace(/}\s*$/, ""), + ) + out["journey.expected"] = stats.expected + out["journey.unexpected"] = stats.unexpected + out["journey.unexpectedHero"] = stats.unexpected + out["journey.skipped"] = stats.skipped + out["journey.skippedHero"] = stats.skipped + out["journey.flaky"] = stats.flaky + out["journey.minutes"] = (stats.duration / 60000).toFixed(1) + out["journey.when"] = stats.startTime.slice(0, 10) + out["journey.day"] = out["journey.when"] + out["journey.setupSpecs"] = ( + rr.match(/"projectName":\s*"setup"/g) || [] + ).length + out["journey.recipeSpecs"] = ( + rr.match(/"projectName":\s*"journey"/g) || [] + ).length + out["journey.recipeSpecsHero"] = out["journey.recipeSpecs"] + out["xcheck.runIds"] = (rr.match(/"title":\s*"\[[^\]"]+\]/g) || []).length + out["xcheck.recipeIds"] = out["recipe.actions"] + out["xcheck.same"] = out["recipe.actions"] + out["open.bootstrapCommit"] = git( + "log", + "-1", + "--format=%h", + "--", + rel(P.storageBoot), + ) + + // C — manifest, counted line by line + const ml = read(P.manifest) + .split(/\r?\n/) + .filter((l) => /"id"\s*:/.test(l)) + out["mut.total"] = ml.length + out["mut.totalTile"] = ml.length + out["mut.totalTile2"] = ml.length + out["mut.gaps"] = ml.filter((l) => /"gap"\s*:\s*true/.test(l)).length + out["mut.gaps2"] = out["mut.gaps"] + out["mut.gaps3"] = out["mut.gaps"] + out["mut.gapsHeading"] = out["mut.gaps"] + out["mut.caught"] = ml.length - out["mut.gaps"] + out["mut.go"] = ml.filter((l) => /"arena"\s*:\s*"go"/.test(l)).length + out["mut.vitest"] = ml.filter((l) => /"arena"\s*:\s*"vitest"/.test(l)).length + out["mut.vitest2"] = out["mut.vitest"] + out["mut.gaps.crossRef"] = ml.filter( + (l) => /"gap"\s*:\s*true/.test(l) && /"crossRef"\s*:\s*\[/.test(l), + ).length + out["mut.gaps.noWitness"] = ml.filter( + (l) => /"gap"\s*:\s*true/.test(l) && /no journey witness/i.test(l), + ).length + out["mut.gaps.proseOnly"] = + out["mut.gaps"] - out["mut.gaps.crossRef"] - out["mut.gaps.noWitness"] + out["mut.manifestDay"] = mtimeDay(P.manifest) + for (const l of ml) { + const id = (l.match(/"id"\s*:\s*"([^"]+)"/) || [])[1] + const cr = l.match(/"crossRef"\s*:\s*\[([^\]]*)\]/) + if (id && cr) + out[`mut.gap.${id}.refs`] = (cr[1].match(/"/g) || []).length / 2 + } + for (const c of clusterList) { + const rows = ml.filter((l) => + new RegExp(`"id"\\s*:\\s*"${c.key}\\.`).test(l), + ) + const g = rows.filter((l) => /"gap"\s*:\s*true/.test(l)).length + out[`mut.cluster.${c.key}.total`] = rows.length + out[`mut.cluster.${c.key}.gaps`] = g + out[`mut.cluster.${c.key}.caught`] = rows.length - g + } + const major = clusterList.filter( + (c) => out[`mut.cluster.${c.key}.gaps`] >= MAJOR_GAP, + ) + for (const c of major) { + const t = out[`mut.cluster.${c.key}.total`], + g = out[`mut.cluster.${c.key}.gaps`] + if (t === g) out[`mut.major.${c.key}.all`] = t + else { + out[`mut.major.${c.key}.gaps`] = g + out[`mut.major.${c.key}.total`] = t + } + } + out["mut.majorClusters"] = major.length + out["mut.majorClustersHeading"] = major.length + out["mut.totalTile3"] = ml.length + out["mut.maxClusterGaps"] = clusterList.reduce( + (a, c) => Math.max(a, out[`mut.cluster.${c.key}.gaps`]), + 0, + ) + out["mut.clusterGapShare"] = major.reduce( + (a, c) => a + out[`mut.cluster.${c.key}.gaps`], + 0, + ) + out["mut.clusterGapShareHeading"] = out["mut.clusterGapShare"] + out["mut.gaps4"] = out["mut.gaps"] + + // D — verify.json, counted by verdict string + const vr = read(P.verify) + for (const [v] of [["EXACT"], ["MISMATCH"], ["GAP"], ["GAP CLOSED"]]) { + const re = new RegExp(`"verdict"\\s*:\\s*"${v}"`, "g") + out[`mut.verdict.${v.replace(/\s/g, "")}`] = (vr.match(re) || []).length + } + // "GAP" also matches inside "GAP CLOSED"? No — the quote terminates it. Verify: + out["mut.verdict.GAP"] = (vr.match(/"verdict"\s*:\s*"GAP"/g) || []).length + out["mut.verdict.GAP2"] = out["mut.verdict.GAP"] + out["mut.gaps5"] = ml.filter((l) => /"gap"\s*:\s*true/.test(l)).length + out["mut.verifyRows"] = (vr.match(/"verdict"\s*:/g) || []).length + out["src.verifyRows"] = out["mut.verifyRows"] + out["src.actionLines"] = out["recipe.actions"] + out["src.bannerLines"] = + read(P.recipe) + .split(/\r?\n/) + .filter((l) => l.trim()).length - out["recipe.actions"] + out["src.actSections"] = ( + read(P.recipe).match(/^\{"comment":\s*"─+ ACT /gm) || [] + ).length + const at = (vr.match(/"at"\s*:\s*"([^"]+)"/) || [])[1] + out["mut.verifyDay"] = at.slice(0, 10) + out["mut.verifyDay2"] = out["mut.verifyDay"] + out["mut.verifyDate"] = out["mut.verifyDay"] + out["mut.mismatch.superseded"] = supersededMismatch.length + out["mut.mismatch.rows"] = ( + vr.match(/"verdict"\s*:\s*"MISMATCH"/g) || [] + ).length + out["mut.mismatch.rows2"] = out["mut.mismatch.rows"] + out["mut.mismatch.rows3"] = out["mut.mismatch.rows"] + out["mut.manifestTime"] = + fs.statSync(P.manifest).mtime.toISOString().slice(0, 19).replace("T", " ") + + "Z" + out["mut.verifyTime"] = at.slice(0, 19).replace("T", " ") + "Z" + + // E/G/H — proto + frontend, recounted with different expressions + let decls = 0 + for (const f of protoFiles) + decls += (read(f).match(/^[ \t]*rpc[ \t]+[A-Za-z0-9_]+/gm) || []).length + out["proto.declarations"] = decls + out["proto.declarationsAudit"] = decls + out["proto.declarationsAudit2"] = decls + out["proto.names"] = new Set( + protoFiles.flatMap((f) => + [...read(f).matchAll(/^[ \t]*rpc[ \t]+([A-Za-z0-9_]+)/gm)].map( + (m) => m[1], + ), + ), + ).size + out["api.uncalledNow"] = [...methodNames].filter( + (m) => !new RegExp(`\\.${lcFirst(m)}\\s*\\(`).test(frontendCorpus), + ).length + const doc = read(P.testingDoc) + out["api.uncalledDoc"] = (doc.split(/^## API-to-UI coverage$/m)[1] || "") + .split(/\r?\n/) + .filter((l) => /^\|\s*`[A-Za-z]+\.[A-Za-z]+`/.test(l)).length + const cm = doc.match(/\*\*(\w+) RPC declarations have no frontend caller\*\*/) + if (cm) { + const words = { Seven: 7, Eight: 8, Nine: 9, Six: 6, Five: 5 } + if (words[cm[1]] && words[cm[1]] !== out["api.uncalledDoc"]) + throw new Error( + `docs/testing.md says ${cm[1]} uncalled RPCs but its table lists ${out["api.uncalledDoc"]}`, + ) + } + + // F — the suite table, re-parsed with a stricter row regex + const rows = [ + ...claudeMd.matchAll( + /^\|\s*([^|]+?)\s*\|\s*([^|]+?)\s*\|\s*(\d{4}-\d{2}-\d{2})\s*\|$/gm, + ), + ] + const find = (k) => + rows.find((r) => r[1].toLowerCase().replace(/`/g, "").startsWith(k)) + const num = (s, re) => Number((String(s).match(re) || [])[1]) + const sm = find("smoke"), + mo = find("mobile"), + or = find("openreplay"), + fe = find("frontend"), + be = find("backend"), + jo = find("journey") + out["smoke.passed"] = num(sm[2], /(\d+) passed/) + out["smoke.failed"] = num(sm[2], /(\d+) failed/) + out["smoke.notrun"] = num(sm[2], /(\d+) did not run/) + out["smoke.when"] = sm[3] + out["open.smokeFailedRepeat"] = out["smoke.failed"] + out["open.smokeNotRunRepeat"] = out["smoke.notrun"] + out["open.smokeSum"] = + `${out["smoke.passed"]} + ${out["smoke.failed"]} + ${out["smoke.notrun"]}` + out["open.smokeTotal"] = + out["smoke.passed"] + out["smoke.failed"] + out["smoke.notrun"] + out["repro.smokeTotal"] = out["open.smokeTotal"] + out["repro.actions"] = out["recipe.actions"] + out["mobile.passed"] = num(mo[2], /(\d+) passed/) + out["mobile.when"] = mo[3] + out["openreplay.passed"] = num(or[2], /(\d+) passed/) + out["openreplay.skipped"] = num(or[2], /(\d+) skipped/) + out["openreplay.when"] = or[3] + out["feunits.passed"] = num(fe[2], /(\d+) passed/) + out["feunits.files"] = num(fe[1], /\((\d+) files\)/) + out["feunits.when"] = fe[3] + out["backend.serviceSpecs"] = num(be[2], /service (\d+)\//) + out["backend.serviceTotal"] = num(be[2], /service \d+\/(\d+)/) + out["backend.capability"] = num(be[2], /capability (\d+)/) + out["backend.middleware"] = num(be[2], /middleware (\d+)/) + out["backend.when"] = be[3] + if (num(jo[2], /(\d+) passed/) !== out["journey.expected"]) + throw new Error( + `.claude/CLAUDE.md claims journey ${num(jo[2], /(\d+) passed/)} but results.json recorded ${ + out["journey.expected"] + } — one of them is stale`, + ) + const cu = claudeMd + .replace(/\s+/g, " ") + .match( + /coverage: \**(\d+) of (\d+) RPC declarations have a frontend caller/, + ) + if (!cu) throw new Error("the API-to-UI sentence moved in .claude/CLAUDE.md") + out["claudemd.uncalled"] = Number(cu[2]) - Number(cu[1]) + out["claudemd.total"] = Number(cu[2]) + + // I — the two code sites, located by a different anchor + const ds = read(P.dragSpec).split(/\r?\n/) + out["open.dragLine"] = + ds.findIndex((l) => /dragging a row saves the whole new order/.test(l)) + 1 + out["open.endYLine"] = ds.findIndex((l) => /\bendY\b\s*=/.test(l)) + 1 + return out +} + +/** The finished document, checked as a string. It is not read from `OUT` — + * `OUT` still holds the PREVIOUS report and must keep holding it if any check + * below fails. `writeChecked()` compares the bytes it lands against this. */ +const back = html + +// One script block, exactly — a second close tag means data truncated the page. +const opens = (back.match(//g) || []).length +if (opens !== 1 || closes !== 1) + throw new Error( + `expected exactly one script block, found ${opens} open / ${closes} close`, + ) + +// No figure may have been escaped INTO the page as text. This is the shape a +// stamped number takes after esc() has run over it, and it is invisible to the +// data-claim scan (the escaped stamp simply leaves the checked set) — it showed +// up first as raw `` printed inside a tile. +const leaked = back.match(/<span class="num"/g) +if (leaked) + throw new Error( + `${leaked.length} stamped figure(s) were HTML-escaped into visible text — a field holding ` + + `n()'s markup was passed through esc()`, + ) + +const stamped = new Map() +for (const m of back.matchAll(/data-claim="([^"]+)"\s+data-value="([^"]*)"/g)) + stamped.set( + m[1], + m[2] + .replace(/&/g, "&") + .replace(/'/g, "'") + .replace(/"/g, '"'), + ) + +const truth = rederive() +const problems = [] +for (const [id, shown] of stamped) { + if (!(id in truth)) { + problems.push( + `${id}: rendered ${shown} but the re-derivation has no value for it`, + ) + continue + } + if (String(truth[id]) !== String(shown)) + problems.push(`${id}: rendered ${shown}, re-derived ${truth[id]}`) +} +/* Every registered claim must be re-derivable, stamped or not. A figure that + only lives inside a chip is still a figure, and "the checker had nothing to + say about it" is the shape every silent-green bug in this repo has had. */ +for (const [id, v] of CLAIMS) { + if (!(id in truth)) { + problems.push( + `${id}: built ${v} but rederive() covers it nowhere — add it or drop the claim`, + ) + continue + } + if (!stamped.has(id) && String(truth[id]) !== String(v)) + problems.push(`${id} (unstamped): built ${v}, re-derived ${truth[id]}`) +} + +if (!idsIdentical) + problems.push( + `the run report and recipe.jsonl are not the same list of ids (` + + `${missingFromRun.length} never ran, ${extraInRun.length} unknown)`, + ) + +/* ── print what was embedded, the way splice-player.mjs does ─────────────── */ +const line = (k, v) => console.log(" " + String(k).padEnd(34) + String(v)) +console.log( + `built quality-report.html — ${(back.length / 1024).toFixed(1)} KiB, ${branch} @ ${headSha}`, +) +line( + "recipe actions", + `${actions.length} in ${actCount.size} acts, ${sections.length} banner sections`, +) +line( + " by kind", + KINDS.map((k) => `${k.label} ${kindCount.get(k.key) || 0}`).join(", "), +) +line(" by priority", [...prioCount].map(([k, v]) => `${k} ${v}`).join(", ")) +line( + " driver", + `${grpcActions} gRPC, ${uiActions} browser, ${fileActions} fixture`, +) +line(" gates / todos", `${gateCount} / ${todoCount}`) +line( + " expected refusals", + `${denialTotal} across ${errCount.size} status codes`, +) +line( + " reaches", + `${recipeCovered.length} of ${declarations.length} declared RPCs, ${recipeServices.size} services`, +) +line( + "journey run", + `${results.stats.expected} passed, ${results.stats.unexpected} failed, ${results.stats.skipped} skipped (${runDay})`, +) +line( + " spec ids == recipe ids", + idsIdentical ? `yes, all ${runIds.length}, same order` : "NO", +) +line( + "mutations", + `${manifest.length} entries — ${caught.length} with a witness, ${gaps.length} gaps`, +) +line( + " verdicts", + [...verdictCount].map(([k, v]) => `${k} ${v}`).join(", ") + ` (${verifyDay})`, +) +line( + " gap clusters", + majorGapClusters.map((c) => `${c.key} ${c.gaps}/${c.total}`).join(", ") || + "none", +) +line( + "API-to-UI", + `${uncalledNow.length} of ${methodNames.size} method names uncalled; ${declarations.length} declarations`, +) +line("suite table rows", suiteRows.map((r) => r.suite.split(" ")[0]).join(", ")) +line("figures stamped", `${stamped.size} (${CLAIMS.size} claims registered)`) + +if (problems.length) { + console.error(`\n✗ ${problems.length} figure(s) disagree with their source:`) + for (const p of problems) console.error(" " + p) + console.error( + `\n✗ NOTHING WAS WRITTEN. ${rel(OUT)} still holds the previous report, ` + + `byte for byte — fix the source (or the figure) and run this again.`, + ) + process.exit(1) +} + +/** + * Land the checked bytes, or land nothing at all. + * + * The temp file goes in the SAME directory: a rename across filesystems is a + * copy, and a copy is precisely the interruptible write this exists to avoid. + * fsync before the rename, so the rename cannot publish a name pointing at + * contents still sitting in a buffer. Then read the destination back and + * require it to equal the string every check above ran against — otherwise + * "verified" and "on disk" are two different documents and only one of them + * was ever inspected. + * + * The rename retries on EPERM: renames on this repo's 9p bind mount + * intermittently refuse with nothing holding the file (CLAUDE.md, container + * trap 5) and succeed a moment later. Every failure path removes the temp, so + * a refused build leaves the directory exactly as it found it. + */ +function writeChecked(dest, text) { + const tmp = path.join( + path.dirname(dest), + `.${path.basename(dest)}.tmp-${process.pid}`, + ) + const sleep = (ms) => + Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms) + try { + const fd = fs.openSync(tmp, "w") + try { + fs.writeFileSync(fd, text) + fs.fsyncSync(fd) + } finally { + fs.closeSync(fd) + } + for (let attempt = 1; ; attempt++) { + try { + fs.renameSync(tmp, dest) + break + } catch (e) { + if (attempt >= 3 || e.code !== "EPERM") throw e + sleep(250) + } + } + } catch (e) { + try { + fs.unlinkSync(tmp) + } catch {} + throw e + } + const landed = read(dest) + if (landed !== text) + throw new Error( + `${rel(dest)} does not hold the bytes that were checked ` + + `(${landed.length} chars on disk vs ${text.length} verified) — do not trust it`, + ) +} + +writeChecked(OUT, html) + +console.log( + `\n✓ ${CLAIMS.size} figures re-derived from their sources by a second code path and matched ` + + `(${stamped.size} of them also stamped in the HTML as data-claim/data-value)`, +) +console.log(`✓ checked first, then written: ${rel(OUT)}`) diff --git a/.claude/skills/hackathon-e2e/scripts/check-reconnect.sh b/.claude/skills/hackathon-e2e/scripts/check-reconnect.sh new file mode 100644 index 00000000..af237aa2 --- /dev/null +++ b/.claude/skills/hackathon-e2e/scripts/check-reconnect.sh @@ -0,0 +1,145 @@ +#!/usr/bin/env bash +# THE regression test for "the frontend cannot see the seed data". +# +# Restart the backend under a running :8081, then assert the browse page still +# lists its events. +# +# Why this is a test of its own rather than a Playwright spec: the subject is a +# process-level fact (one module-scope gRPC channel in the built server, outliving +# a backend restart), it needs to STOP a service the rest of the suite depends on, +# and the assertion is a count in HTML — no browser adds anything. Running it +# inside smoke would perturb 140 tests to check one. +# +# WHAT WENT WRONG, so the shape of the check is not mysterious. On 2026-08-13 the +# browse page rendered ZERO events while grpcurl returned eight from the same +# database. Two independent causes, both fixed, both regressions worth catching: +# +# 1. lib/server/grpc/client.ts creates ONE channel at module load. grpc-js +# reconnects on its own but on a backoff that doubles to a 120s cap, and +# every RPC issued while it waits fails immediately. Measured with a 7-minute +# outage: the page was still wrong 51 SECONDS after the backend was +# demonstrably healthy, and the lag grows with the outage toward that cap. +# The channel now caps the backoff at 2s; the same measurement is 0s. +# 2. The page's load turned any error into `hackathons: []`, which renders "No +# hackathons have been published yet." — so "the database is empty" and "I +# cannot reach the backend" were the same page. It now carries +# `listUnavailable` and says which. +# +# The check asserts BOTH: the count comes back, and while the backend is down the +# page says unavailable rather than empty. The second half is the one that would +# have saved the hours — without it, half of this script's assertions pass on a +# page that is lying. +# +# Usage: check-reconnect.sh (needs a stack up, seeded, and :8081 serving) +set -euo pipefail +trap 'echo "check-reconnect.sh: aborted at line $LINENO (status $?)" >&2' ERR +HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +source "$HERE/lib.sh" +ensure_toolchain "${BASH_SOURCE[0]}" "$@" + +PC_SOCKET_FILE="$ROOT_DIR/tools/deploy/process-compose/.socket-path-test-services" +SOCK="$(cat "$PC_SOCKET_FILE" 2>/dev/null || true)" +BROWSE="$FRONTEND_URL/hackathon" +FAILED=0 + +pc() { process-compose --unix-socket "$SOCK" "$@" >/dev/null 2>&1; } + +# One per listed event. +page_html() { curl -fsS --max-time 15 "$BROWSE" 2>/dev/null || true; } +page_count() { + page_html | grep -oE 'href="/hackathon/[0-9a-f-]{36}"' | sort -u | wc -l | tr -d ' ' +} +# jq, not a grep for `"id"`. Every hackathon in this response also carries +# nested ids (capability modifiers, creator, …), so a naive count reported 16 for +# 8 events — and an indentation-anchored grep reported 0, which this script's own +# guard would have read as "nothing to test" and exited cleanly having tested +# nothing. Ask the structure. +rpc_count() { + grpcurl -plaintext -d '{"visibility_filter":1}' "$GRPC_ADDR" \ + hackathon.HackathonService/List 2>/dev/null | + jq -r '.hackathons | length' 2>/dev/null || echo 0 +} + +fail() { + echo " ✗ $1" >&2 + FAILED=1 +} + +if [ -z "$SOCK" ] || [ ! -S "$SOCK" ]; then + echo "error: no process-compose socket — start the stack first (scripts/up.sh)." >&2 + exit 1 +fi + +echo "==> Baseline" +BEFORE_RPC="$(rpc_count)" +BEFORE_PAGE="$(page_count)" +echo " gRPC lists $BEFORE_RPC public events; the browse page renders $BEFORE_PAGE" +if [ "$BEFORE_RPC" -eq 0 ]; then + echo "error: no public hackathons to check against — seed the instance first." >&2 + echo " (An assertion whose subject is absent verifies nothing.)" >&2 + exit 1 +fi +[ "$BEFORE_PAGE" -eq "$BEFORE_RPC" ] || + fail "before any restart the page already disagrees with gRPC ($BEFORE_PAGE vs $BEFORE_RPC)" + +echo "==> Stopping the backend" +pc process stop backend +for _ in $(seq 1 15); do + [ "$(rpc_count)" -eq 0 ] && break + sleep 1 +done + +echo "==> While the backend is down the page must say UNAVAILABLE, not EMPTY" +DOWN_HTML="$(page_html)" +if echo "$DOWN_HTML" | grep -q 'data-testid="listUnavailable"'; then + echo " ✓ the page reports the outage" +else + if echo "$DOWN_HTML" | grep -q "No hackathons have been published yet"; then + fail "the page claims an EMPTY PLATFORM while the backend is down — this is the bug" + elif [ -z "$DOWN_HTML" ]; then + fail "the page did not render at all while the backend was down (it used to degrade)" + else + fail "the page neither reported the outage nor rendered the empty state" + fi +fi + +echo "==> Starting the backend" +pc process start backend +S=$(date +%s) +while :; do + [ "$(rpc_count)" -gt 0 ] && break + if [ $(($(date +%s) - S)) -gt 600 ]; then + echo "error: the backend did not come back within 600s — not a channel problem." >&2 + exit 1 + fi + sleep 5 +done +HEALTHY_AT=$(date +%s) +echo " gRPC answers again after $((HEALTHY_AT - S))s" + +# THE assertion. The lag allowed here is a channel reconnect, not a boot: the +# capped backoff is 2s, so 60s is ~30x headroom and still an order of magnitude +# below the 120s default that caused the incident. If this ever needs raising, +# the channel options in client.ts regressed — raise those, not this. +echo "==> The page must agree with gRPC again" +LAG=-1 +for _ in $(seq 1 20); do + if [ "$(page_count)" -eq "$(rpc_count)" ] && [ "$(page_count)" -gt 0 ]; then + LAG=$(($(date +%s) - HEALTHY_AT)) + break + fi + sleep 3 +done +if [ "$LAG" -lt 0 ]; then + fail "the page still disagrees with gRPC 60s after the backend was healthy (page=$(page_count) grpc=$(rpc_count)) — the channel did not reconnect" +else + echo " ✓ the page recovered ${LAG}s after the backend was healthy" +fi + +if [ "$FAILED" -ne 0 ]; then + echo "" + echo "FAILED — a backend restart is visible to users of :8081." >&2 + exit 1 +fi +echo "" +echo "PASSED — a backend restart heals itself, and an outage never reads as an empty platform." diff --git a/.claude/skills/hackathon-e2e/scripts/embed-run-report.mjs b/.claude/skills/hackathon-e2e/scripts/embed-run-report.mjs new file mode 100644 index 00000000..9cb64ffd --- /dev/null +++ b/.claude/skills/hackathon-e2e/scripts/embed-run-report.mjs @@ -0,0 +1,260 @@ +#!/usr/bin/env node +// Bake a real journey run into recipe-player.html, so its `run outcome` colour +// mode answers "did it pass" on open instead of "nobody has told me". +// +// bash scripts/run.sh journey # writes .artifacts/results.json +// node scripts/embed-run-report.mjs # splices it in, reduced +// node scripts/embed-run-report.mjs +// +// Note what is NOT needed here: `--reporter=json > report.json`. The json +// reporter is already in playwright.config.ts (it writes +// .artifacts/results.json on every run), and redirecting stdout in this +// container captures the Nix/devenv/quitsh banner ahead of the JSON, so the +// file does not parse. run.sh does forward a --reporter flag if you pass one, +// but the file on disk is the thing to read. +// +// REDUCED, on purpose. The player joins on exactly three things — the action +// id, the outcome bucket and how long it took — while a full report carries +// stdout, attachments, stack frames and error snippets. Those snippets are also +// the hazard: an inline script block ends at the first LITERAL close tag even +// inside a JSON string, so every ` /"id"\s*:/.test(l)) + .map((l) => JSON.parse(l).id), +) + +// recipe.spec.ts titles every test `[] `, which is the join key +const BUCKET = { + passed: "passed", + expected: "passed", + failed: "failed", + unexpected: "failed", + timedOut: "failed", + interrupted: "failed", + skipped: "skipped", + flaky: "flaky", +} +const status = {} +const totals = { passed: 0, failed: 0, skipped: 0, flaky: 0, other: 0 } +let specs = 0 +let unmatched = 0 +let maxEnd = 0 +;(function walk(node) { + if (!node || typeof node !== "object") return + for (const sp of node.specs || []) { + specs++ + const m = /^\[([^\]]+)\]/.exec(String(sp.title || "")) + if (!m) { + unmatched++ + continue + } + const test = (sp.tests || [])[0] || {} + const results = test.results || [] + const last = results[results.length - 1] || {} + const s = String(last.status || test.status || "unknown") + const dur = Math.round(+last.duration || 0) + if (!ids.has(m[1])) { + unmatched++ + continue + } + status[m[1]] = [s, dur] + const b = BUCKET[s] || "other" + totals[b]++ + const start = Date.parse(last.startTime || "") + if (!isNaN(start)) maxEnd = Math.max(maxEnd, start + dur) + } + for (const s of node.suites || []) walk(s) +})(report) + +const matched = Object.keys(status).length +if (!matched) + throw new Error( + `no spec title in ${reportPath} starts with a recipe action id`, + ) + +const startedAt = Date.parse(report.stats?.startTime || "") || 0 +const payload = { + kind: "hackagon-journey-run", + suite: "journey", + // the run's own clock, not this script's — a snapshot must be dated by when + // it was PRODUCED, or its date says nothing about the code it describes + generatedAt: new Date(startedAt || Date.now()).toISOString(), + recipeActions: ids.size, + specs, + durationMs: Math.round( + report.stats?.duration || (maxEnd && startedAt ? maxEnd - startedAt : 0), + ), + totals, + status, +} + +const json = JSON.stringify(payload) +const escaped = json.split("</").join("<\\/") + +const html = fs.readFileSync(playerPath, "utf8") +const open = '<script id="run-report" type="application/json">' +const start = html.indexOf(open) +if (start < 0) + throw new Error("run-report open marker not found in recipe-player.html") +const bodyStart = start + open.length +const close = "</" + "script>" +const end = html.indexOf(close, bodyStart) +if (end < 0) throw new Error("run-report close marker not found") + +// The finished document — in memory. Nothing has touched the disk yet. +const next = html.slice(0, bodyStart) + "\n" + escaped + "\n" + html.slice(end) + +// Read the block back OUT of that string and prove it parses in place, and that +// the document still has the three close tags it is supposed to have. Reading +// the string rather than the file loses nothing: the string IS the finished +// player, and the bytes that land are compared against it after the rename, so +// "what was checked" and "what is on disk" stay one thing. +const problems = [] +const s2 = next.indexOf(open) + open.length +const e2 = next.indexOf(close, s2) +let round = null +try { + round = JSON.parse(next.slice(s2, e2).trim().split("<\\/").join("</")) +} catch (e) { + problems.push( + `the embedded run-report block does not parse back: ${e.message}. ` + + `A block that ends early is a block whose escape did not hold.`, + ) +} +const tags = next.split(close).length - 1 +if (round && Object.keys(round.status).length !== matched) + problems.push( + `embedded ${Object.keys(round.status).length} entries, expected ${matched}`, + ) +// THREE, not one: recipe data, run report, program. The quality report's twin +// of this check asserts one close tag — same treatment, different invariant. +if (tags !== 3) + problems.push(`expected 3 literal close tags in the player, found ${tags}`) + +if (problems.length) { + console.error( + `\n✗ ${problems.length} problem(s) with the spliced player:\n` + + problems.map((p) => " " + p).join("\n"), + ) + console.error( + `\n✗ NOTHING WAS WRITTEN. ${path.basename(playerPath)} still holds the ` + + `previous run report, byte for byte — fix the input (or this script) and ` + + `run it again.`, + ) + process.exit(1) +} + +/** + * Land the checked bytes, or land nothing at all. + * + * Lifted from build-quality-report.mjs deliberately, down to the retry: the + * temp file goes in the SAME directory, because a rename across filesystems is + * a copy and a copy is precisely the interruptible write this exists to avoid; + * fsync precedes the rename so the rename cannot publish a name pointing at + * contents still sitting in a buffer; and the destination is read back and + * required to equal the string every check above ran against, or "verified" and + * "on disk" are two different documents and only one of them was inspected. + * + * The rename retries on EPERM: renames on this repo's 9p bind mount + * intermittently refuse with nothing holding the file (CLAUDE.md, container + * trap 5) and succeed a moment later. Every failure path removes the temp, so a + * refused run leaves the directory exactly as it found it. + */ +function writeChecked(dest, text) { + const tmp = path.join( + path.dirname(dest), + `.${path.basename(dest)}.tmp-${process.pid}`, + ) + const sleep = (ms) => + Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms) + try { + const fd = fs.openSync(tmp, "w") + try { + fs.writeFileSync(fd, text) + fs.fsyncSync(fd) + } finally { + fs.closeSync(fd) + } + for (let attempt = 1; ; attempt++) { + try { + fs.renameSync(tmp, dest) + break + } catch (e) { + if (attempt >= 3 || e.code !== "EPERM") throw e + sleep(250) + } + } + } catch (e) { + try { + fs.unlinkSync(tmp) + } catch {} + throw e + } + const landed = fs.readFileSync(dest, "utf8") + if (landed !== text) + throw new Error( + `${path.basename(dest)} does not hold the bytes that were checked ` + + `(${landed.length} chars on disk vs ${text.length} verified) — do not trust it`, + ) +} + +writeChecked(playerPath, next) + +const pct = (100 * escaped.length) / raw.length +console.log( + `embedded ${matched} of ${ids.size} actions from ${path.basename(reportPath)} — ` + + `${totals.passed} passed / ${totals.failed} failed / ${totals.skipped} skipped` + + (totals.flaky ? ` / ${totals.flaky} flaky` : "") + + (unmatched ? ` (${unmatched} specs not recipe actions)` : ""), +) +console.log( + `reduced ${(raw.length / 1024).toFixed(1)} KiB → ${(escaped.length / 1024).toFixed(1)} KiB ` + + `(${pct.toFixed(1)}% of the report) · run of ${payload.generatedAt} · ` + + `${Math.round(payload.durationMs / 1000)}s · ${tags} literal close tags`, +) +console.log(`✓ checked first, then written: ${path.basename(playerPath)}`) diff --git a/.claude/skills/hackathon-e2e/scripts/fetch-cc-assets.sh b/.claude/skills/hackathon-e2e/scripts/fetch-cc-assets.sh new file mode 100644 index 00000000..846cfd97 --- /dev/null +++ b/.claude/skills/hackathon-e2e/scripts/fetch-cc-assets.sh @@ -0,0 +1,60 @@ +#!/usr/bin/env bash +# OPTIONAL: fetch a couple of well-known Creative-Commons/public-domain files +# from Wikimedia Commons for realistic photo material (e.g. the future +# "photos published" act). The DEFAULT upload fixtures are the generated, +# fully offline files from helpers/files.ts — this script is garnish, never a +# test dependency, and is NOT called by run.sh. +# +# Determinism: remote files can change (re-uploads happen on Commons), so the +# first fetch records sha256 checksums in a lockfile; later fetches verify +# against it and fail loudly on drift (trust-on-first-use). +# +# LICENSING: verify and keep the attribution — ATTRIBUTION.md links each +# file's Commons page, which is authoritative for author and license. +set -euo pipefail +HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +source "$HERE/lib.sh" + +DEST="$STATE_DIR/uploads/cc" +LOCK="$DEST/checksums.sha256" +mkdir -p "$DEST" + +# name|Special:FilePath URL (stable redirect to the current original) +ASSETS=( + "example.jpg|https://commons.wikimedia.org/wiki/Special:FilePath/Example.jpg" + "png-transparency-demo.png|https://commons.wikimedia.org/wiki/Special:FilePath/PNG_transparency_demonstration_1.png" +) + +echo "==> Fetching Creative-Commons sample assets from Wikimedia Commons..." +for entry in "${ASSETS[@]}"; do + name="${entry%%|*}" + url="${entry#*|}" + out="$DEST/$name" + if [ -f "$out" ]; then + echo " [=] $name (already downloaded)" + else + echo " [v] $name" + curl -fsSL -A "hackagon-e2e/1.0 (dev test fixtures)" -o "$out" "$url" + fi +done + +cat >"$DEST/ATTRIBUTION.md" <<'EOF' +# Attribution — Wikimedia Commons sample assets + +Downloaded by `scripts/fetch-cc-assets.sh` for local test fixtures only. +The Commons file pages below are authoritative for author and license — +verify them before using these files anywhere user-facing, and keep the +attribution with the files: + +- `example.jpg` — https://commons.wikimedia.org/wiki/File:Example.jpg +- `png-transparency-demo.png` — https://commons.wikimedia.org/wiki/File:PNG_transparency_demonstration_1.png +EOF + +if [ -f "$LOCK" ]; then + echo "==> Verifying checksums against the lockfile..." + (cd "$DEST" && sha256sum -c "$(basename "$LOCK")") +else + echo "==> First fetch — recording checksums (trust-on-first-use)..." + (cd "$DEST" && sha256sum ./*.jpg ./*.png >"$(basename "$LOCK")") +fi +echo "==> CC assets ready in $DEST (see ATTRIBUTION.md)." diff --git a/.claude/skills/hackathon-e2e/scripts/journal-to-recipe.mjs b/.claude/skills/hackathon-e2e/scripts/journal-to-recipe.mjs new file mode 100644 index 00000000..9b8d2a66 --- /dev/null +++ b/.claude/skills/hackathon-e2e/scripts/journal-to-recipe.mjs @@ -0,0 +1,314 @@ +#!/usr/bin/env node +// Turn a captured RPC journal (components/backend/internal/audit) into DRAFT +// recipe actions. +// +// What it does: +// - copies actor / method / params / expect straight across; the journal +// was deliberately written in the recipe's own field names; +// - substitutes ids for recipe templates. An id first seen in a call's +// `produced` map becomes a variable, and every later occurrence of that +// UUID — anywhere in any params tree — is rewritten to the token. The +// defining call gets the matching `save`; +// - leaves id / title / outcome / priority / act / t as EMPTY placeholders. +// +// What it deliberately does NOT do: write prose. A generated `outcome` that +// reads plausible but was never thought about is worse than a blank one — the +// recipe's whole value is the human judgement about what SHOULD happen, and a +// draft that looks finished is a draft nobody re-reads. +// +// Usage: +// node journal-to-recipe.mjs <journal.jsonl> [options] +// --out <file> write drafts here (default: stdout) +// --dedupe collapse runs of identical (actor, method, params) +// --keep-health keep health.HealthService/Check lines (dropped by +// default: the readiness probe, not an action) +// --keep-reads keep Get/List/WhoAmI calls (dropped by default: the +// frontend issues them on every page load) +// --from-seq <n> ignore everything before sequence n. The e2e harness +// opens with scripts/probe.sh, which calls every gated +// method once with '{}' to see what exists — traffic the +// journal cannot tell from somebody doing it on purpose. + +import fs from "node:fs" + +const UUID = + /^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$/ +// Same shape, unanchored, for "does this blob contain a uuid anywhere". +const UUID_ANYWHERE = + /[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}/ + +// Journal lines that are traffic rather than intent. Dropping these is the one +// editorial judgement this script makes, and both are reversible by flag. +const HEALTH = "health.HealthService/Check" +const READ_METHOD = /\/(Get|List|WhoAmI|Preview|Export|Suggest)[A-Za-z]*$/ + +// ─── argv ──────────────────────────────────────────────────────────────────── + +const argv = process.argv.slice(2) +const opts = { + dedupe: false, + keepHealth: false, + keepReads: false, + out: null, + fromSeq: 0, +} +let journalPath = null +for (let i = 0; i < argv.length; i++) { + const a = argv[i] + if (a === "--dedupe") opts.dedupe = true + else if (a === "--keep-health") opts.keepHealth = true + else if (a === "--keep-reads") opts.keepReads = true + else if (a === "--out") opts.out = argv[++i] + else if (a === "--from-seq") opts.fromSeq = Number(argv[++i]) + else if (a === "-h" || a === "--help") { + console.log( + fs + .readFileSync(new URL(import.meta.url), "utf8") + .split("\n") + .slice(1, 31) + .join("\n"), + ) + process.exit(0) + } else if (a.startsWith("-")) { + console.error(`unknown option: ${a}`) + process.exit(2) + } else journalPath = a +} +if (!journalPath) { + console.error( + "usage: journal-to-recipe.mjs <journal.jsonl> [--out f] [--dedupe] [--keep-health] [--keep-reads]", + ) + process.exit(2) +} + +// ─── read ──────────────────────────────────────────────────────────────────── + +const raw = fs + .readFileSync(journalPath, "utf8") + .split("\n") + .map((l) => l.trim()) + .filter(Boolean) + +const entries = [] +let malformed = 0 +for (const line of raw) { + try { + entries.push(JSON.parse(line)) + } catch { + malformed++ + } +} +entries.sort((a, b) => (a.seq ?? 0) - (b.seq ?? 0)) + +const captured = entries.length +let dropped = { health: 0, reads: 0, dupes: 0, early: 0 } + +let kept = entries.filter((e) => { + if (opts.fromSeq && (e.seq ?? 0) < opts.fromSeq) + return (dropped.early++, false) + if (!opts.keepHealth && e.method === HEALTH) return (dropped.health++, false) + if (!opts.keepReads && READ_METHOD.test("/" + (e.method ?? ""))) + return (dropped.reads++, false) + return true +}) + +if (opts.dedupe) { + const out = [] + let prev = null + for (const e of kept) { + const sig = `${e.actor}|${e.method}|${JSON.stringify(e.params)}|${JSON.stringify(e.expect)}` + if (sig === prev) { + dropped.dupes++ + continue + } + prev = sig + out.push(e) + } + kept = out +} + +// ─── binding: UUID -> template token ───────────────────────────────────────── +// +// One pass in journal order. A UUID is BOUND the first time a response reports +// it; from then on every params occurrence is rewritten. Reads are used for +// binding even when they are filtered out of the output — WhoAmI is where a +// person's DB id becomes knowable, and dropping it from the draft must not +// cost us {{userId:alice}}. + +const binding = new Map() // uuid -> template token, e.g. "{{hackathonId}}" +const definedBy = new Map() // uuid -> { entry, path, varName } +const usedVars = new Set() + +// A person's DB uuid is named after WHO it belongs to, which is knowable +// exactly here: WhoAmI/Register answer for their own caller. +const SELF_ID = /\/(WhoAmI|Register)$/ + +function varNameFor(entry, path) { + const method = entry.method ?? "" + const short = method.split("/").pop() ?? "" + const leaf = path.split(".").pop() ?? "id" + if ( + SELF_ID.test("/" + method) && + entry.actor && + entry.actor !== "anonymous" + ) { + return { token: `{{userId:${entry.actor}}}`, save: null } + } + // {{hackathonId}} is the recipe's one bare token and it names THE event the + // story is about. The journey also creates a second, private hackathon, and + // binding both to the same token silently rewrote every later reference to + // the draft into a reference to the main event — a draft that looks right + // and is wrong. Only the first Create claims it; the rest fall through. + if ( + leaf === "hackathonId" && + short === "Create" && + !usedVars.has("hackathonId") + ) { + usedVars.add("hackathonId") + return { token: "{{hackathonId}}", save: "hackathonId" } + } + const base = + leaf === "id" + ? lowerFirst( + entry.method + .split(".") + .pop() + .split("/")[0] + .replace(/Service$/, ""), + ) + : leaf + let name = `${base}${short === "Create" || short === "Propose" ? "" : capitalize(short)}` + let n = 1 + let candidate = name + while (usedVars.has(candidate)) candidate = `${name}${++n}` + usedVars.add(candidate) + return { token: `{{var:${candidate}}}`, save: candidate } +} + +const lowerFirst = (s) => (s ? s[0].toLowerCase() + s.slice(1) : s) +const capitalize = (s) => (s ? s[0].toUpperCase() + s.slice(1) : s) + +for (const e of entries) { + for (const [path, uuid] of Object.entries(e.produced ?? {})) { + if (binding.has(uuid)) continue + const { token, save } = varNameFor(e, path) + binding.set(uuid, token) + definedBy.set(uuid, { entry: e, path, save }) + } +} + +// ─── rewrite params ────────────────────────────────────────────────────────── + +const untemplated = new Map() // uuid -> count of params occurrences with no binding + +function templateValue(v) { + if (typeof v === "string") { + if (UUID.test(v)) { + const bound = binding.get(v) + if (bound) return bound + untemplated.set(v, (untemplated.get(v) ?? 0) + 1) + return v + } + return v + } + if (Array.isArray(v)) return v.map(templateValue) + if (v && typeof v === "object") { + const out = {} + for (const [k, val] of Object.entries(v)) out[k] = templateValue(val) + return out + } + return v +} + +// `save` is emitted only for variables something downstream actually uses — +// a Create whose id is never referenced again needs no variable. +const referenced = new Set() +for (const e of kept) { + JSON.stringify(e.params ?? {}, (k, v) => { + if (typeof v === "string" && UUID.test(v) && binding.has(v)) + referenced.add(v) + return v + }) +} + +const drafts = [] +let templatedCalls = 0 +let manualCalls = 0 + +for (const e of kept) { + const before = untemplated.size + const params = templateValue(e.params ?? {}) + const grew = untemplated.size > before + + const saves = {} + for (const [path, uuid] of Object.entries(e.produced ?? {})) { + const def = definedBy.get(uuid) + if (!def || def.entry !== e || !def.save) continue + if (!referenced.has(uuid)) continue + saves[def.save] = path + } + + // Only calls that carried an id at all can be "templated" or "manual" — + // a Create with no id in its request is neither. + if (UUID_ANYWHERE.test(JSON.stringify(e.params ?? {}))) { + if (grew || UUID_ANYWHERE.test(JSON.stringify(params))) manualCalls++ + else templatedCalls++ + } + + const draft = { + id: "", + priority: "", + implement: true, + outcome: "", + act: null, + t: "", + title: "", + actor: e.actor, + action: "rpc", + method: e.method, + params, + expect: e.expect, + } + if (Object.keys(saves).length > 0) draft.save = saves + draft._journalSeq = e.seq + drafts.push(draft) +} + +// ─── output ────────────────────────────────────────────────────────────────── + +const body = drafts.map((d) => JSON.stringify(d)).join("\n") + "\n" +if (opts.out) fs.writeFileSync(opts.out, body) +else process.stdout.write(body) + +// ─── summary ───────────────────────────────────────────────────────────────── +// The last line is the one that matters: ids never seen created are exactly +// the actions a human has to fix by hand. + +const log = (s) => process.stderr.write(s + "\n") +log( + `captured ${captured} journal lines` + + (malformed ? ` (${malformed} malformed, skipped)` : "") + + ` -> ${drafts.length} draft actions` + + ` [dropped: ${dropped.health} health, ${dropped.reads} reads` + + (opts.dedupe ? `, ${dropped.dupes} repeats` : "") + + (opts.fromSeq ? `, ${dropped.early} before seq ${opts.fromSeq}` : "") + + `]`, +) +log( + `bound ${binding.size} ids from responses; ${templatedCalls} calls fully templated, ` + + `${manualCalls} carry a literal id`, +) +if (untemplated.size === 0) { + log( + "UNTEMPLATED: none — every id in every draft came from a call in this journal.", + ) +} else { + const sample = [...untemplated.entries()] + .sort((a, b) => b[1] - a[1]) + .slice(0, 5) + .map(([u, n]) => `${u}(x${n})`) + log( + `UNTEMPLATED: ${untemplated.size} id(s) never seen created — fix by hand: ${sample.join(" ")}` + + (untemplated.size > 5 ? ` +${untemplated.size - 5} more` : ""), + ) +} diff --git a/.claude/skills/hackathon-e2e/scripts/journal-to-recipe.sh b/.claude/skills/hackathon-e2e/scripts/journal-to-recipe.sh new file mode 100644 index 00000000..b869304f --- /dev/null +++ b/.claude/skills/hackathon-e2e/scripts/journal-to-recipe.sh @@ -0,0 +1,31 @@ +#!/usr/bin/env bash +# Convert a captured RPC journal into DRAFT recipe actions. +# +# The journal is written by the backend's audit interceptor +# (components/backend/internal/audit), which is OFF unless +# `audit.enabled: true` — see docs/backend/rpc-journal.md. +# +# Usage: +# journal-to-recipe.sh [journal.jsonl] [--out f] [--dedupe] [--keep-health] [--keep-reads] +# +# With no path it reads the default journal location, +# components/backend/.output/audit/rpc-journal.jsonl. +set -euo pipefail +HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +source "$HERE/lib.sh" +ensure_toolchain "${BASH_SOURCE[0]}" "$@" + +DEFAULT_JOURNAL="$ROOT_DIR/components/backend/.output/audit/rpc-journal.jsonl" + +args=("$@") +if [ ${#args[@]} -eq 0 ] || [[ ${args[0]} == -* ]]; then + args=("$DEFAULT_JOURNAL" "${args[@]+"${args[@]}"}") +fi + +if [ ! -f "${args[0]}" ]; then + echo "error: no journal at ${args[0]}" >&2 + echo " enable it first: audit.enabled: true in components/backend/data/test/config/config.yaml" >&2 + exit 1 +fi + +exec node "$HERE/journal-to-recipe.mjs" "${args[@]}" diff --git a/.claude/skills/hackathon-e2e/scripts/lib.sh b/.claude/skills/hackathon-e2e/scripts/lib.sh new file mode 100644 index 00000000..121ff379 --- /dev/null +++ b/.claude/skills/hackathon-e2e/scripts/lib.sh @@ -0,0 +1,73 @@ +# shellcheck shell=bash +# Shared helpers for the hackathon-e2e 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)" +STATE_DIR="$SKILL_DIR/.state" +GRPC_ADDR="${E2E_GRPC_ADDR:-localhost:3000}" +KEYCLOAK_URL="${E2E_KEYCLOAK_URL:-http://localhost:8180}" +FRONTEND_URL="${E2E_BASE_URL:-http://localhost:8081}" + +# Everything here needs the repo toolchain (just, process-compose, grpcurl, +# pnpm, psql, jq — all provided by the Nix dev shell). When invoked from a +# plain shell, re-exec the calling script inside `just nix::develop default`. +# Usage: ensure_toolchain "${BASH_SOURCE[0]}" "$@" +ensure_toolchain() { + local script="$1" + shift + if command -v process-compose >/dev/null 2>&1 && command -v grpcurl >/dev/null 2>&1; then + return 0 + fi + if [ -n "${HACKAGON_E2E_NIX_WRAPPED:-}" ]; then + echo "error: toolchain not found even inside the Nix dev shell" >&2 + exit 1 + fi + echo "==> Toolchain not on PATH — re-executing inside the Nix dev shell..." + export HACKAGON_E2E_NIX_WRAPPED=1 + script="$(cd "$(dirname "$script")" && pwd)/$(basename "$script")" + cd "$ROOT_DIR" + exec just nix::develop default bash "$script" "$@" +} + +# wait_for <name> <timeout_seconds> <cmd...> — poll until cmd succeeds. +# The deadline is only checked BETWEEN attempts, so every attempt is bounded +# with coreutils `timeout` — otherwise one blocking probe defeats the deadline +# entirely (an untimed curl against a cold vite holding :8081 once blocked a +# single attempt for 15+ minutes, printing not one dot). Callers may still +# pass tighter bounds of their own (e.g. curl --max-time 10); the 15s cap only +# backstops the ones that forget. Attempts must be external commands, not +# shell functions — `timeout` cannot run a function, and no caller passes one. +wait_for() { + local name="$1" timeout="$2" + shift 2 + local start + local -a bound=() + command -v timeout >/dev/null 2>&1 && bound=(timeout 15) + start=$(date +%s) + printf " waiting for %-12s " "$name" + until "${bound[@]}" "$@" >/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" +} + +# Access token for a dev-realm user via the password grant (same flow as +# `just rpc::as`). +keycloak_token() { + local user="$1" password="$2" + curl -s -X POST \ + "$KEYCLOAK_URL/realms/hackagon/protocol/openid-connect/token" \ + -H "Content-Type: application/x-www-form-urlencoded" \ + -d client_id="hackagon-backend" \ + -d username="$user" \ + -d password="$password" \ + -d grant_type="password" \ + -d scope="openid profile" | jq -r ".access_token" +} diff --git a/.claude/skills/hackathon-e2e/scripts/mutate.sh b/.claude/skills/hackathon-e2e/scripts/mutate.sh new file mode 100644 index 00000000..c5a66506 --- /dev/null +++ b/.claude/skills/hackathon-e2e/scripts/mutate.sh @@ -0,0 +1,76 @@ +#!/usr/bin/env bash +# Mutation testing: break the product on purpose and check the suite notices. +# +# mutate.sh list # the manifest, one line per mutation +# mutate.sh run # every FAST mutation (go + vitest) +# mutate.sh run cap.ungoverned-flat # one, by id +# mutate.sh run cap # every id under the `cap.` prefix +# mutate.sh run --tier all # includes the e2e tier (minutes each) +# mutate.sh run --arena go +# mutate.sh run --record # print the reds instead of judging them +# mutate.sh restore # undo a run that was killed outright +# +# A mutation that produces NO REDS fails the run. That is the whole point: it +# means nothing in the suite is holding that property. +# +# Two things this script is responsible for that the runner cannot be: +# +# 1. A SECOND, INDEPENDENT restore path. The runner journals every edit before +# making it and restores on exit and on signal; this trap restores from the +# same journal even if node dies in a way that runs none of its handlers. +# A mutation left in the tree that then gets committed is the worst outcome +# this tool can produce, so it gets two locks rather than one. +# 2. Keeping `nix develop` out of the loop. Every other script here calls +# ensure_toolchain, which re-enters the dev shell — a repo-wide mutex, +# ~5s unopposed and serializing under contention (container trap 4). +# devenv's profile has go, node and pnpm already and costs nothing, so the +# fast tier spends its seconds on tests rather than on shell entries. +set -euo pipefail +HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +SKILL_DIR="$(dirname "$HERE")" +ROOT_DIR="$(cd "$SKILL_DIR/../../.." && pwd)" +RUNNER="$SKILL_DIR/mutations/run.mjs" +JOURNAL="$SKILL_DIR/mutations/.state/journal.json" + +DEVENV_BIN="$ROOT_DIR/.devenv/profile/bin" +if [ -d "$DEVENV_BIN" ]; then + export PATH="$DEVENV_BIN:$PATH" +fi + +if ! command -v node >/dev/null 2>&1; then + echo "error: node is not on PATH and $DEVENV_BIN does not exist." >&2 + echo " Run this inside the dev container (devcontainer-up/scripts/mutate.sh)," >&2 + echo " or enter the Nix dev shell first." >&2 + exit 1 +fi + +# The belt to the runner's braces. `[ -s ]` rather than `[ -f ]`: the runner +# writes an empty array when it has nothing outstanding, and restoring from an +# empty journal would print a scary message about a run that ended cleanly. +on_exit() { + if [ -s "$JOURNAL" ] && ! grep -q '^\[\]$' "$JOURNAL" 2>/dev/null; then + echo + echo "!! mutations were still applied when this script exited — restoring" >&2 + node "$RUNNER" restore || true + fi + # Verified, not assumed. Scoped to components/, which is the only tree the + # runner may touch; the repo-wide check can never pass in this container + # (three git-lfs pointer files read as permanently modified — trap 4). + local left + left="$(git -C "$ROOT_DIR" status --porcelain -- components/ || true)" + if [ -n "$left" ]; then + echo >&2 + echo "!! components/ IS NOT CLEAN after the run:" >&2 + echo "$left" >&2 + echo "!! Do not commit. Inspect, then: git -C '$ROOT_DIR' checkout -- components/" >&2 + exit 1 + fi +} +trap on_exit EXIT + +# NOT `exec`. `exec` replaces this shell with node, and a process that no longer +# exists runs no EXIT trap — the second restore path above would have been +# decoration. Run it as a child, keep its status, and let the trap fire. +STATUS=0 +node "$RUNNER" "$@" || STATUS=$? +exit "$STATUS" diff --git a/.claude/skills/hackathon-e2e/scripts/probe.sh b/.claude/skills/hackathon-e2e/scripts/probe.sh new file mode 100644 index 00000000..cb665c6f --- /dev/null +++ b/.claude/skills/hackathon-e2e/scripts/probe.sh @@ -0,0 +1,139 @@ +#!/usr/bin/env bash +# Capability probe: which lifecycle RPCs does the running backend actually +# implement? Writes .state/capabilities.json, which the journey acts use to +# self-skip. This is what lets the lifecycle recipe grow automatically as +# write-path handlers land — no test-code change needed for an act to wake up. +# +# SAFETY: probes are UNAUTHENTICATED on purpose. Implemented mutation handlers +# follow the enforce-first pattern (RequireSubject / casbin check before any +# DB write), so an anonymous '{}' call is rejected with Unauthenticated / +# PermissionDenied / InvalidArgument without side effects — any of which +# proves the method is implemented. Missing methods return Unimplemented (or a +# reflection error for unregistered services). +set -euo pipefail +HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +source "$HERE/lib.sh" +ensure_toolchain "${BASH_SOURCE[0]}" "$@" + +# The lifecycle methods the journey acts gate on. Methods may reference +# services or RPCs that do not exist yet even as protos — those simply probe +# as unimplemented. +METHODS=( + hackathon.ConfigService/SetEmailTemplates + hackathon.ConfigService/GetEmailTemplates + hackathon.ConfigService/SetBranding + user.UserService/DeleteAccount + user.UserService/EditProfile + hackathon.HackathonService/GetRegistrationResponse + hackathon.HackathonService/ListRegistrationResponses + hackathon.HackathonService/CreateInvite + hackathon.HackathonService/PreviewInvite + site.SitePageService/Get + site.SitePageService/List + site.SitePageService/Create + site.SitePageService/Edit + site.SitePageService/Delete + hackathon.HackathonService/Get + hackathon.HackathonService/List + user.UserService/WhoAmI + user.UserService/List + user.UserService/Register + hackathon.HackathonService/Create + hackathon.HackathonService/Edit + hackathon.HackathonService/Delete + hackathon.HackathonService/Join + hackathon.HackathonService/ApproveParticipant + hackathon.HackathonService/RemoveParticipant + hackathon.HackathonService/AddOwner + hackathon.HackathonService/RemoveOwner + hackathon.HackathonService/SetCapabilities + hackathon.HackathonService/SetCurrentPhase + hackathon.HackathonService/EditCapability + hackathon.HackathonService/AdvancePhase + vote.VoteService/SuggestResults + storage.StorageService/CreateUploadUrl + storage.StorageService/CreateDownloadUrl + storage.StorageService/ListObjects + hackathon.PageService/Create + hackathon.PageService/Delete + hackathon.PageService/List + hackathon.PageService/Get + hackathon.PageService/Edit + hackathon.PageService/SetOrder + hackathon.PhaseService/Create + hackathon.TrackService/Create + hackathon.ProjectService/Propose + hackathon.ProjectService/Approve + hackathon.ProjectService/Edit + hackathon.ProjectService/Delete + hackathon.ProjectService/SetPreference + hackathon.ProjectService/ExportPreferences + hackathon.TeamService/List + hackathon.TeamService/Create + hackathon.TeamService/Edit + hackathon.TeamService/Delete + hackathon.TeamService/AssignUser + hackathon.TeamService/RemoveUser + hackathon.TeamService/CreateSubmission + hackathon.TeamService/EditSubmission + hackathon.TeamService/FinalizeSubmission + hackathon.TeamService/ListSubmissions + vote.VoteService/CreateVoteCategory + vote.VoteService/SubmitVote + vote.VoteService/ListVoteResults + vote.VoteService/CreateVoteResult + vote.VoteService/ExportVotes + hackathon.HackathonService/EditSettings + hackathon.HackathonService/SubmitRegistrationForm + hackathon.ConfigService/SetRegistrationForm + hackathon.ConfigService/SetSubmissionForm + hackathon.ConfigService/SetVotingPolicy + hackathon.ConfigService/SetWindows + hackathon.ConfigService/OverrideWindow + hackathon.PrizeService/Set + hackathon.PrizeService/Finalize + hackathon.PrizeService/Edit +) + +if ! grpcurl -plaintext "$GRPC_ADDR" list >/dev/null 2>&1; then + echo "error: backend not reachable at $GRPC_ADDR (is the stack up?)" >&2 + exit 1 +fi + +mkdir -p "$STATE_DIR" +OUT="$STATE_DIR/capabilities.json" + +echo "==> Probing backend capabilities at $GRPC_ADDR..." +{ + printf '{\n' + printf ' "generatedAt": "%s",\n' "$(date -u +%Y-%m-%dT%H:%M:%SZ)" + printf ' "grpcAddr": "%s",\n' "$GRPC_ADDR" + printf ' "methods": {\n' + first=1 + for m in "${METHODS[@]}"; do + out=$(grpcurl -plaintext -d '{}' "$GRPC_ADDR" "$m" 2>&1 || true) + impl=true + case "$out" in + *"Code: Unimplemented"* | *"code = Unimplemented"*) impl=false ;; + *"does not expose service"* | *"does not include a method"* | \ + *"Failed to resolve symbol"* | *"unknown service"* | *"no such service"*) + impl=false + ;; + *"Failed to dial"* | *"connection refused"*) + echo "error: lost connection to backend while probing $m" >&2 + exit 1 + ;; + esac + [ $first -eq 1 ] || printf ',\n' + first=0 + printf ' "%s": %s' "$m" "$impl" + if [ "$impl" = true ]; then + echo " [x] $m" >&2 + else + echo " [ ] $m" >&2 + fi + done + printf '\n }\n}\n' +} >"$OUT" + +echo "==> Capabilities written to $OUT" diff --git a/.claude/skills/hackathon-e2e/scripts/prod-frontend.sh b/.claude/skills/hackathon-e2e/scripts/prod-frontend.sh new file mode 100644 index 00000000..792c5265 --- /dev/null +++ b/.claude/skills/hackathon-e2e/scripts/prod-frontend.sh @@ -0,0 +1,284 @@ +#!/usr/bin/env bash +# Serve the ADAPTER-NODE BUILD on :8081 for a suite run, IN PLACE OF vite. +# +# Why this exists: regenerating protos wipes ~260 files under +# src/lib/server/grpc/generated/, which invalidates that much of vite's +# transform cache. `src/` is on the 9p bind mount, so the first SSR request +# then takes tens of minutes while process-compose's readiness probe kills the +# process mid-warm-up — the log says "readiness check fail - signal: killed", +# which reads like a crash and is not one. Measured 2026-08-08 on a freshly +# booted vite here: `curl /` returned 0 bytes after FIVE MINUTES. The built +# output has no transform step and boots in seconds (smoke: 3.0m -> 1.4m). +# +# "IN PLACE OF" is the load-bearing word. process-compose's `frontend` process +# IS `vite dev`, and it binds exactly the address this script wants: +# [::1]:8081. `just deploy::down` does not free that port either — it only +# kills :8180 and :3000 — so a vite can also outlive its supervisor. Without +# stop_vite() below, node dies in its first second with +# +# Error: listen EADDRINUSE: address already in use ::1:8081 +# +# and the caller then spends 300s waiting for a "frontend that did not come +# up", pointing at the built server when the culprit is vite. Worse, it was a +# RACE — vite binds ~35s after process-compose starts it, so whoever got there +# first won, and the identical command passed for one suite and failed for the +# next. Stopping vite explicitly is what makes a run deterministic. +# +# The other three traps, each hit repeatedly before being encoded here: +# +# HOST=:: collides with the socat bridge already on :8081 (EADDRINUSE). +# HOST=127.0.0.1 binds an address `localhost` does not resolve to — inside +# this container localhost is ::1. +# AUTH_URL must accompany ORIGIN, or login completes and then does nothing. +# +# :8081 rather than :8082 because Keycloak's hackagon-frontend client only allows +# redirect URIs on 8081; moving the app dies at login with +# "Invalid parameter: redirect_uri". :8082 belongs to the cloudflare-tunnel +# skill's own built server, which is why everything here is scoped to servers +# launched with PORT=8081 — a blanket `pkill -f build/service/index.js` also +# killed the tunnel's upstream, and nothing ever restarted it. +# +# Usage: prod-frontend.sh start [origin] (default origin http://localhost:8081) +# prod-frontend.sh stop +# prod-frontend.sh ensure [origin] start unless OUR server already serves +set -euo pipefail +# `set -e` aborts silently, and this script's failures have now been +# mis-attributed twice — once to the built server when vite held the port, once +# to "the frontend did not come up" when the server was up and a helper had +# merely returned non-zero. Say which line gave up. +trap 'echo "prod-frontend.sh: aborted at line $LINENO (status $?)" >&2' ERR +HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +source "$HERE/lib.sh" +ensure_toolchain "${BASH_SOURCE[0]}" "$@" + +FRONTEND_DIR="$ROOT_DIR/components/frontend" +ENTRY="build/service/index.js" +PIDFILE="$ROOT_DIR/.output/run/e2e-prod-frontend.pid" +LOG="$ROOT_DIR/.output/run/e2e-prod-frontend.log" +# The build log belongs to the build, which is shared — see FRONTEND_BUILD below. +# Written by `just deploy::up` (tools/deploy/process-compose/justfile); holds +# the path of the process-compose control socket. +PC_SOCKET_FILE="$ROOT_DIR/tools/deploy/process-compose/.socket-path-test-services" +PORT=8081 +STORE="${HACKAGON_STORE_ENDPOINT:-http://rustfs:9000}" + +serving() { curl -fsS -o /dev/null --max-time 5 "http://localhost:$PORT/" 2>/dev/null; } + +# Node is the authority on whether it can bind, so ask it the same question the +# server is about to ask. Deliberately NOT `ss`: iproute2 is not on PATH inside +# the Nix dev shell, so a check built on it silently reported "free" every time +# and the wait below was a no-op — which is how the EADDRINUSE race survived a +# fix aimed straight at it. Binding ::1 specifically also ignores the socat +# bridge, which holds 172.26.0.7:8081 (IPv4) for the whole life of the +# container and must not read as a conflict. +port_held() { + node -e ' +const net = require("net"); +const s = net.createServer(); +s.once("error", (e) => process.exit(e.code === "EADDRINUSE" ? 0 : 1)); +s.once("listening", () => s.close(() => process.exit(1))); +s.listen(Number(process.argv[1]), "::1"); +' "$PORT" 2>/dev/null +} + +# Our servers only: same entrypoint as the tunnel's :8082 server, told apart by +# the PORT it was launched with. +our_servers() { + local pid + for pid in $({ pgrep -f "$ENTRY" 2>/dev/null || true; }); do + if tr '\0' '\n' <"/proc/$pid/environ" 2>/dev/null | grep -qx "PORT=$PORT"; then + echo "$pid" + fi + done + return 0 +} + +ours_is_up() { + local pid + pid="$(cat "$PIDFILE" 2>/dev/null || true)" + [ -n "$pid" ] || return 1 + kill -0 "$pid" 2>/dev/null || return 1 + tr '\0' ' ' <"/proc/$pid/cmdline" 2>/dev/null | grep -q "$ENTRY" +} + +# setsid forks, so $! can be the launcher rather than the server. Re-resolve, or +# `stop` chases a PID that has already exited and leaves the real one holding +# the port. +resolve_pid() { + # Deliberately no `| head -1`. With `set -o pipefail`, head exiting after the + # first line SIGPIPEs our_servers, the pipeline reports 141, the assignment + # inherits it and `set -e` exits the script — one line after the server came + # up healthy, so the caller sees a dead harness and a perfectly good server. + # Take the first line in the shell instead, and never fail: a pid we could not + # resolve is a worse `stop`, not a reason to abort a working start. + local pids + pids="$(our_servers)" || true + pids="${pids%%$'\n'*}" + [ -n "$pids" ] && echo "$pids" >"$PIDFILE" + return 0 +} + +# Put process-compose's `frontend` (vite) DOWN and keep it down. +# +# This is not tidiness — an un-stopped vite next to our server on :8081 is the +# single most expensive failure mode this container has. vite cannot bind, exits +# 1, and `availability.restart` sends it round again; each round is a full +# `just develop` = `nix develop`, which takes the repo-wide fetch lock on +# `git+file:///workspaces/hackagon`. Entering that shell is ~5 s unopposed +# (re-measured 2026-08-14) and serializes everything else that wants the lock, +# and a stack found in this state had 54 restarts in 50 minutes — a lock +# acquisition every ~55 s, forever. Everything else that enters the shell then queues behind it: the +# backend's own start command is `just develop just run`, and its readiness +# budget is spent WAITING FOR NIX rather than on the server. When the budget +# runs out process-compose SIGTERMs it, the Go server shuts down gracefully, +# exit code 0 — which `restart: on_failure` does not consider a failure, so the +# backend stays down and everything downstream reads as connection refused. +# +# It is invisible from `process list`, which reported `frontend Running Ready` +# throughout: the readiness probe is `curl http://localhost:8081` and OUR server +# was answering it. The probe measures the PORT, not the PROCESS. +stop_vite() { + local sock + sock="$(cat "$PC_SOCKET_FILE" 2>/dev/null || true)" + if [ -n "$sock" ] && [ -S "$sock" ]; then + process-compose --unix-socket "$sock" process stop frontend >/dev/null 2>&1 || true + fi + # A deliberate stop is not a failure, so `restart: on_failure` leaves it down. + # The pkill is for the orphan case: `just deploy::down` pkills + # process-compose without freeing :8081, and the socket file is gone by then. + pkill -f "vite.js dev" 2>/dev/null || true +} + +stop() { + if [ -f "$PIDFILE" ]; then + kill "$(cat "$PIDFILE")" 2>/dev/null || true + rm -f "$PIDFILE" + fi + # Anything else of ours holding the port — a run killed mid-flight leaves one. + local pid + for pid in $(our_servers); do kill "$pid" 2>/dev/null || true; done + stop_vite + + # Wait for the socket to actually be released. `kill` returns immediately and + # node takes a moment to close its listener, so starting straight afterwards + # raced and died with EADDRINUSE — which the caller then reported as "the + # frontend did not come up", 300 seconds later and pointing at the wrong + # thing entirely. + for _ in $(seq 1 25); do + port_held || return 0 + sleep 1 + done + # Still held after 25s: escalate, then give it a last moment. + for pid in $(our_servers); do kill -9 "$pid" 2>/dev/null || true; done + pkill -9 -f "vite.js dev" 2>/dev/null || true + sleep 2 +} + +# vite served source; the build is a snapshot, so it has to be rebuilt when the +# source moved under it. Skipping this is how a suite silently tests yesterday's +# frontend and reports green. +# +# Both the question and the build now live in .claude/skills/lib/frontend-build.sh, +# because this script is not the only caller: cloudflare-tunnel/prod-serve.sh +# builds and serves the SAME build/service tree on :8082. Two concurrent +# `pnpm build`s into one output directory is not a theoretical race — it +# corrupted that tree three times in one day (`Unexpected end of JSON input`, +# then a missing build/service/server/index.js at boot). The helper holds an +# exclusive lock and swaps a COMPLETE tree into place; nothing here needs to +# know that, which is the point. +FRONTEND_BUILD="$ROOT_DIR/.claude/skills/lib/frontend-build.sh" +needs_build() { + bash "$FRONTEND_BUILD" stale +} + +launch() { + local origin="$1" + ( + cd "$FRONTEND_DIR" + PORT="$PORT" HOST="::1" ORIGIN="$origin" AUTH_URL="$origin" \ + STORAGE_ENDPOINT="$STORE" \ + setsid nohup node "$ENTRY" \ + --config-dir ./data/test/config --data-dir ./data/test \ + >"$LOG" 2>&1 & + echo $! >"$PIDFILE" + ) +} + +wait_serving() { + for _ in $(seq 1 30); do + serving && return 0 + # The server exits in its first second on EADDRINUSE. Sitting out the full + # 60s for a process that is already dead is what buried the real error. + grep -q "EADDRINUSE" "$LOG" 2>/dev/null && return 1 + sleep 2 + done + return 1 +} + +start() { + local origin="${1:-http://localhost:$PORT}" attempt + mkdir -p "$(dirname "$PIDFILE")" + stop + + # `if-stale` re-asks the question INSIDE the lock, so two harnesses starting at + # once produce one build and the loser serves it rather than rebuilding over + # the winner. Do not hoist the staleness check back out here. + if ! bash "$FRONTEND_BUILD" if-stale; then + echo "error: the frontend build failed — see $ROOT_DIR/.output/run/frontend-build.log" >&2 + return 1 + fi + + for attempt in 1 2 3; do + echo "==> Serving the built frontend on :$PORT (origin $origin)..." + launch "$origin" + if wait_serving; then + resolve_pid + echo " ready" + return 0 + fi + grep -q "EADDRINUSE" "$LOG" 2>/dev/null || break + echo " :$PORT is still held by something else — freeing it (attempt $attempt/3)" >&2 + stop + done + + echo "error: the built frontend did not come up on :$PORT — see $LOG" >&2 + echo "── $LOG (tail) ─────────────────────────────" >&2 + tail -30 "$LOG" >&2 + return 1 +} + +case "${1:-ensure}" in +start) + shift + start "${1:-}" + ;; +stop) + stop + echo "stopped" + ;; +ensure) + shift + # "Something answers :8081" is not enough: a cold vite that happens to reply + # inside the probe window is still unusable for a suite, and a build that + # predates the last source edit is worse than useless. Only OUR server, up + # and current, is left alone. + if ours_is_up && serving && ! needs_build; then + echo "==> The built frontend already serves :$PORT — leaving it alone." + # "Leaving it alone" is about OUR server, never about vite. This branch + # used to return without touching process-compose at all, and that is the + # whole of how the crash loop documented above survived: `just deploy::up` + # starts vite on every boot, our server already holds :8081 whenever a + # previous run left one up (the common case — nothing stops it between + # runs), vite therefore exits 1 and is restarted forever, and this fast + # path was the one place that would have stopped it. `stop_vite` is + # idempotent and costs one socket call, so it is unconditional now. + stop_vite + else + start "${1:-}" + fi + ;; +*) + echo "usage: prod-frontend.sh [start|stop|ensure] [origin]" >&2 + exit 1 + ;; +esac diff --git a/.claude/skills/hackathon-e2e/scripts/reset.sh b/.claude/skills/hackathon-e2e/scripts/reset.sh new file mode 100644 index 00000000..52aad694 --- /dev/null +++ b/.claude/skills/hackathon-e2e/scripts/reset.sh @@ -0,0 +1,26 @@ +#!/usr/bin/env bash +# Reset-to-zero: stop the stack and wipe all Postgres + Keycloak state, so the +# next boot is deterministic (realm re-imported from the checked-in JSON, +# empty database, casbin admin re-bootstrapped from config). +set -euo pipefail +HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +source "$HERE/lib.sh" +ensure_toolchain "${BASH_SOURCE[0]}" "$@" + +echo "==> Stopping services..." +(cd "$ROOT_DIR" && just deploy::down) || true + +# The built frontend is NOT managed by process-compose, so `deploy::down` leaves +# it running — and it read its OIDC config once at boot, against the Keycloak +# realm this reset is about to wipe and re-import. Leaving it up meant +# wait-ready found something serving, left it alone, and the whole suite then +# failed in auth.setup with a login form that never rendered. +bash "$HERE/prod-frontend.sh" stop >/dev/null 2>&1 || true + +echo "==> Wiping Postgres + Keycloak state..." +(cd "$ROOT_DIR" && just clean::state) + +# Cross-act journey state is only meaningful for the DB it was created on. +rm -f "$STATE_DIR/journey.json" + +echo "==> Reset complete." diff --git a/.claude/skills/hackathon-e2e/scripts/roster.sh b/.claude/skills/hackathon-e2e/scripts/roster.sh new file mode 100644 index 00000000..1645960d --- /dev/null +++ b/.claude/skills/hackathon-e2e/scripts/roster.sh @@ -0,0 +1,63 @@ +#!/usr/bin/env bash +# Provision the extras crowd (cast.json) into the dev Keycloak realm via the +# admin REST API. Idempotent: existing users are left untouched, so re-runs +# are no-ops and the checked-in realm export stays the source of truth for the +# four principals. Requires Keycloak up (run after wait-ready.sh). +set -euo pipefail +HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +source "$HERE/lib.sh" +ensure_toolchain "${BASH_SOURCE[0]}" "$@" + +CAST="$SKILL_DIR/cast.json" +REALM="hackagon" + +# Dev master-realm credentials (see tools/configs/keycloak/README.md). +ADMIN_TOKEN=$(curl -s -X POST \ + "$KEYCLOAK_URL/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") +if [ -z "$ADMIN_TOKEN" ] || [ "$ADMIN_TOKEN" = "null" ]; then + echo "error: could not get a Keycloak master admin token (admin/admin)" >&2 + exit 1 +fi + +PASSWORD=$(jq -r '.password' "$CAST") +COUNT=$(jq -r '.extras | length' "$CAST") + +echo "==> Ensuring $COUNT extra participants exist in realm '$REALM'..." +created=0 +for i in $(seq 0 $((COUNT - 1))); do + username=$(jq -r ".extras[$i].username" "$CAST") + + existing=$(curl -s -H "Authorization: Bearer $ADMIN_TOKEN" \ + "$KEYCLOAK_URL/admin/realms/$REALM/users?username=$username&exact=true" | jq 'length') + if [ "$existing" -gt 0 ]; then + echo " [=] $username (exists)" + continue + fi + + payload=$(jq -c --arg pw "$PASSWORD" ".extras[$i] | { + username: .username, + firstName: .firstName, + lastName: .lastName, + email: .email, + enabled: true, + emailVerified: true, + credentials: [{type: \"password\", value: \$pw, temporary: false}] + }" "$CAST") + + http_code=$(curl -s -o /dev/null -w "%{http_code}" -X POST \ + -H "Authorization: Bearer $ADMIN_TOKEN" \ + -H "Content-Type: application/json" \ + -d "$payload" \ + "$KEYCLOAK_URL/admin/realms/$REALM/users") + if [ "$http_code" != "201" ] && [ "$http_code" != "409" ]; then + echo "error: creating $username failed (HTTP $http_code)" >&2 + exit 1 + fi + echo " [+] $username (created)" + created=$((created + 1)) +done +echo "==> Roster ready ($created created, $((COUNT - created)) already present)." diff --git a/.claude/skills/hackathon-e2e/scripts/run.sh b/.claude/skills/hackathon-e2e/scripts/run.sh new file mode 100644 index 00000000..0f80705d --- /dev/null +++ b/.claude/skills/hackathon-e2e/scripts/run.sh @@ -0,0 +1,289 @@ +#!/usr/bin/env bash +# One-command deterministic e2e run: +# reset -> boot stack -> wait ready -> (seed) -> probe capabilities -> Playwright (Firefox) +# +# Usage: run.sh [smoke|journey|all|mobile|openreplay] [options] +# smoke (default) seed-fixture suite: what each persona can see and do +# journey full lifecycle recipe on an EMPTY database (acts 1-8) +# all smoke, then a fresh reset, then journey +# openreplay session-replay privacy proof on the seed fixture. Self-skips +# unless replay.enabled is true in the frontend config — wire it +# with openreplay-stack/scripts/wire-frontend.sh first. NOT part +# of `all`: it needs a live OpenReplay, which nothing else does. +# +# Options: +# --no-reset reuse the running stack + data (fast iteration; smoke only — +# the journey always needs a fresh database) +# --headed run Firefox headed +# --grep <p> filter tests by title +# --until-act <n> journey only: play the story up to act <n> and leave the +# stack frozen in that state for inspection (1..8) +# --reporter=<r> passed straight to Playwright, as is anything after `--` +# +# On reporters: you almost certainly do not need one. playwright.config.ts +# already runs the json reporter and writes .artifacts/results.json on EVERY +# run, which is what scripts/embed-run-report.mjs reads. Do not do +# `run.sh journey --reporter=json > report.json`: everything in this container +# prints Nix/devenv/quitsh banners to stdout ahead of the test output, so the +# redirected file does not parse. If you want Playwright to write a second copy +# itself, set PLAYWRIGHT_JSON_OUTPUT_NAME and pass --reporter=json. +set -euo pipefail +HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +source "$HERE/lib.sh" +ensure_toolchain "${BASH_SOURCE[0]}" "$@" + +SUITE="smoke" +RESET=1 +HEADED=0 +GREP="" +# extra flags forwarded verbatim to `playwright test`. --reporter used to hit +# the catch-all below and exit 2, so the documented "run.sh journey +# --reporter=json" failed before it started. +PW_EXTRA=() + +while [ $# -gt 0 ]; do + case "$1" in + smoke | journey | all | mobile | openreplay) SUITE="$1" ;; + --no-reset) RESET=0 ;; + --headed) HEADED=1 ;; + --grep) + shift + GREP="${1:?--grep needs a pattern}" + ;; + --until-act) + shift + export JOURNEY_UNTIL_ACT="${1:?--until-act needs an act number (1..8)}" + ;; + --reporter=*) PW_EXTRA+=("$1") ;; + --reporter) + shift + PW_EXTRA+=("--reporter=${1:?--reporter needs a value}") + ;; + --) + shift + PW_EXTRA+=("$@") + break + ;; + -h | --help) + sed -n '2,30p' "${BASH_SOURCE[0]}" | sed 's/^# \{0,1\}//' + exit 0 + ;; + *) + echo "unknown argument: $1 (see --help)" >&2 + exit 2 + ;; + esac + shift +done + +if [ "$SUITE" = "all" ]; then + # Two independent, fully deterministic runs: seeded smoke, then a clean + # journey. Each does its own reset. + args=() + [ "$HEADED" -eq 1 ] && args+=(--headed) + [ -n "$GREP" ] && args+=(--grep "$GREP") + args+=("${PW_EXTRA[@]+"${PW_EXTRA[@]}"}") + bash "${BASH_SOURCE[0]}" smoke "${args[@]+"${args[@]}"}" + bash "${BASH_SOURCE[0]}" journey "${args[@]+"${args[@]}"}" + exit 0 +fi + +if [ "$SUITE" = "journey" ] && [ "$RESET" -eq 0 ]; then + echo "note: the journey suite requires a fresh database — ignoring --no-reset." + RESET=1 +fi + +echo "════════════════════════════════════════════════════════════" +echo " hackagon-e2e: $SUITE suite $([ "$RESET" -eq 1 ] && echo '(from scratch)' || echo '(reusing state)')" +echo "════════════════════════════════════════════════════════════" + +# A live Cloudflare tunnel wired with --with-auth repoints the frontend and +# backend OIDC issuers at its public URL. Every persona here logs in over +# localhost, so those tokens would carry the wrong issuer and EVERY auth.setup +# test fails with a confusing "invalid issuer". Restoring is idempotent and a +# no-op when no tunnel is wired, so just always do it — remembering to is not +# a workable contract. +AUTH_WIRE="$ROOT_DIR/.claude/skills/cloudflare-tunnel/scripts/auth-wire.sh" +WIRED_URL="" + +# NOTE: there is deliberately no prod-mode handling here any more. `up.sh +# --prod` used to park the adapter-node BUILD on :8081 in place of vite, so +# this script had to evict it for the run and restore it on exit — and during +# each of those two handovers nothing was listening on :8081, which is what +# caddy proxies for the tunnel. Every single suite run therefore answered the +# PUBLIC link with ~40s of 502 Bad Gateway. The built server has its own port +# (:8082) now and process-compose keeps :8081, so a run and the public link no +# longer contend at all. Do not reintroduce a guard here. + +if [ -f "$AUTH_WIRE" ]; then + # Remember whether a tunnel was wired BEFORE unwiring, and put it back when + # the run ends. Restoring localhost is required for the suite, but leaving it + # there silently breaks the public link every single time someone runs the + # tests — which is exactly what kept happening: the URL still served pages, + # so it looked fine until somebody tried to log in. + # Read it out of the OVERLAY, not config.yaml: wiring writes the tunnel + # issuer to the gitignored config.local.yaml precisely so the tracked file + # never carries a hostname that dies with the tunnel. The file's absence is + # the "no tunnel wired" signal — sed on a missing file is silenced below. + FRONTEND_LOCAL="$ROOT_DIR/components/frontend/data/test/config/config.local.yaml" + # Test -f FIRST. `2>/dev/null` hides sed's complaint but not its exit code, + # and under `set -euo pipefail` a missing file made this assignment kill the + # script before it printed a single word — which is every FRESH CLONE, since + # config.local.yaml only exists once auth-wire.sh has written one. It went + # unnoticed because every machine that had ever wired a tunnel had the file. + WIRED_URL="" + if [ -f "$FRONTEND_LOCAL" ]; then + WIRED_URL="$(sed -n 's|^[[:space:]]*issuer:[[:space:]]*\(https://[^/]*\)/realms/.*|\1|p' "$FRONTEND_LOCAL" | head -1)" + fi + + bash "$AUTH_WIRE" --restore || + echo "warn: could not restore OIDC issuers; logins may fail if a tunnel is wired" >&2 + + if [ -n "$WIRED_URL" ]; then + echo "note: tunnel auth was wired to $WIRED_URL — it will be re-wired when this run finishes" + fi +fi + +# Session replay is NO LONGER BORROWED AWAY. Do not reintroduce that. +# +# It used to be: this script read the `replay` block out of the overlay for +# every suite but `openreplay` and wrote it back on exit, because the consent +# banner was `fixed bottom-0 z-[60]` with nothing reserving its space — it sat +# on top of whatever was at the bottom of the page and swallowed clicks aimed +# at it. `act0.about.publish` clicks the CMS `visible` checkbox, which landed +# exactly there; Playwright retried for the full 60s against +# +# <div role="region" aria-label="Session recording" …> intercepts pointer events +# +# and the journey died on its 10th action with 338 not run. +# +# The banner reserves its own space in the document now (it is `sticky +# bottom-0`, so the last band of the page belongs to it and content scrolls +# clear), and `helpers/reflow.ts:expectConsentBannerClearsContent` asserts that +# at 8 widths across every route. With that fixed there is nothing to borrow: +# 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` ever does — so a wired `replay` block changes exactly ONE thing +# for the other suites: the ask is on screen, exactly as it is for every +# first-time visitor. Running against the chrome real people see is the point. +# +# The mobile suite needs the OPPOSITE guarantee. Its sweep asserts ABOUT the +# banner, and an assertion whose subject is absent verifies nothing — the +# failure mode this repo keeps finding. So when no rig has wired replay, that +# suite gets a block of its own: enough for the server to render the ask, +# pointing at a port where nothing listens, removed again on exit. Consent is +# never granted, so not one byte is ever addressed to it. +OVERLAY="$ROOT_DIR/.claude/skills/lib/config-overlay.sh" +FRONTEND_LOCAL="$ROOT_DIR/components/frontend/data/test/config/config.local.yaml" +REPLAY_STUBBED=0 +if [ "$SUITE" = "mobile" ] && [ -f "$OVERLAY" ] && + ! bash "$OVERLAY" has "$FRONTEND_LOCAL" replay; then + bash "$OVERLAY" set "$FRONTEND_LOCAL" replay >/dev/null <<'STUB' +replay: + enabled: true + # NOTHING LISTENS HERE, and nothing ever will. The tracker is only handed an + # ingest endpoint and a project key once a browser has consented, and a + # layout sweep never does — this block exists so the SERVER renders the + # consent banner, which tests/mobile treats as part of the chrome. + # Written by hackathon-e2e/scripts/run.sh, removed when the run ends. + ingestPoint: http://127.0.0.1:9/ingest + projectKey: mobile-sweep-never-ingests + allowInsecureOrigin: true +STUB + REPLAY_STUBBED=1 + echo 'note: no session replay wired — added a no-ingest `replay` block so the consent banner renders for this run' + # The frontend reads its config once at boot, so the block is inert until it + # restarts. Stop the built server here; wait-ready.sh starts a fresh one + # below. (reset.sh does this too, but a --no-reset run would otherwise sweep + # a server that never saw the block — and every banner assertion would fail + # for want of a banner.) + bash "$HERE/prod-frontend.sh" stop >/dev/null 2>&1 || true +fi + +# EXIT, not a success path — a failed or interrupted run must not leave the +# public link logged-out either. auth-wire.sh also bounces the built server on +# :8082 when one is up, because it read the issuer out of config.yaml once at +# boot; that is why re-wiring restores logins through the tunnel and not just +# on localhost. +on_exit() { + if [ "$REPLAY_STUBBED" -eq 1 ]; then + echo + echo '==> Removing the no-ingest `replay` block this run added' + # `remove`, never `rm`: the overlay is shared, and this script has no + # business deleting a key it did not write (see config-overlay.sh). + bash "$OVERLAY" remove "$FRONTEND_LOCAL" replay >/dev/null + # The frontend read the block once at boot, so it keeps rendering the ask + # until it restarts. Bounce the built one — no docker needed, which matters + # because this script runs INSIDE the dev container. + bash "$HERE/prod-frontend.sh" stop >/dev/null 2>&1 && + bash "$HERE/prod-frontend.sh" ensure >/dev/null 2>&1 || + echo "warn: the replay block is gone but the frontend was not restarted;" \ + "run prod-frontend.sh stop && … ensure" >&2 + fi + if [ -n "$WIRED_URL" ]; then + echo + echo "==> Re-wiring tunnel auth to $WIRED_URL" + bash "$AUTH_WIRE" "$WIRED_URL" >/dev/null 2>&1 || + echo "warn: re-wiring failed; run auth-wire.sh $WIRED_URL by hand" >&2 + fi +} +if [ -n "$WIRED_URL" ] || [ "$REPLAY_STUBBED" -eq 1 ]; then + trap on_exit EXIT +fi + +if [ "$RESET" -eq 1 ]; then + bash "$HERE/reset.sh" +fi + +bash "$HERE/up.sh" +bash "$HERE/wait-ready.sh" + +# The openreplay suite asserts against the seed fixture (it gives h1 a +# registration form and types into it), so it seeds exactly like smoke. +if [ "$SUITE" = "smoke" ] || [ "$SUITE" = "openreplay" ]; then + bash "$HERE/seed.sh" +elif [ "$SUITE" = "mobile" ]; then + # Fresh mobile runs use the seeded fixture; --no-reset runs the battery + # over whatever world is live (e.g. a journey frozen at some act) without + # polluting it with the fixture. + if [ "$RESET" -eq 1 ]; then bash "$HERE/seed.sh"; fi +else + # The journey's extras crowd (cast.json) must exist in Keycloak. + bash "$HERE/roster.sh" +fi + +bash "$HERE/probe.sh" + +cd "$SKILL_DIR" + +if [ ! -d node_modules ]; then + echo "==> Installing test dependencies (pnpm)..." + pnpm install +fi + +# Idempotent: returns quickly when browser + libs are already present. +# --with-deps first: a plain install "succeeds" without the system libraries +# and Firefox then fails at LAUNCH time, which the fallback cannot catch. +echo "==> Ensuring Playwright Firefox is installed..." +pnpm exec playwright install --with-deps firefox 2>/dev/null || + pnpm exec playwright install firefox + +PW_ARGS=(test --project="$SUITE") +[ "$HEADED" -eq 1 ] && PW_ARGS+=(--headed) +[ -n "$GREP" ] && PW_ARGS+=(--grep "$GREP") +PW_ARGS+=("${PW_EXTRA[@]+"${PW_EXTRA[@]}"}") + +# Inside the Nix dev shell, ldd is Nix's glibc ldd whose linker does not +# search /usr/lib — Playwright's host validation then reports every system +# library as missing even though Firefox launches fine. Skip the check. +export PLAYWRIGHT_SKIP_VALIDATE_HOST_REQUIREMENTS=true + +echo "==> Running Playwright ($SUITE, Firefox)..." +STATUS=0 +pnpm exec playwright "${PW_ARGS[@]}" || STATUS=$? + +echo "" +echo "── Done ────────────────────────────────────────────────────" +echo " HTML report: pnpm --dir '$SKILL_DIR' run report" +echo " Stack is left running — stop it with: just down" +exit "$STATUS" diff --git a/.claude/skills/hackathon-e2e/scripts/seed.sh b/.claude/skills/hackathon-e2e/scripts/seed.sh new file mode 100644 index 00000000..cfc523b4 --- /dev/null +++ b/.claude/skills/hackathon-e2e/scripts/seed.sh @@ -0,0 +1,20 @@ +#!/usr/bin/env bash +# Populate the DB with the deterministic dev fixture (idempotent — a re-run +# against an already-seeded DB is a no-op thanks to the sentinel hackathon). +# Used by the SMOKE suite only; the journey suite needs an empty database. +set -euo pipefail +HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +source "$HERE/lib.sh" +ensure_toolchain "${BASH_SOURCE[0]}" "$@" + +wait_for "postgres" 60 pg_isready -h 127.0.0.1 -p 5432 -U postgres + +echo "==> Seeding the database..." +(cd "$ROOT_DIR" && just db::seed) + +# The backend's casbin enforcer loads the policy table at startup and never +# reloads: roles the seed writes straight to Postgres are invisible until the +# backend restarts (badges render wrong, private hackathons vanish). +echo "==> Restarting the backend to reload seeded casbin roles..." +(cd "$ROOT_DIR" && just deploy::proc-comp process restart backend >/dev/null) +wait_for "backend" 120 grpcurl -plaintext localhost:3000 health.HealthService/Check diff --git a/.claude/skills/hackathon-e2e/scripts/splice-player.mjs b/.claude/skills/hackathon-e2e/scripts/splice-player.mjs new file mode 100644 index 00000000..e54db5b8 --- /dev/null +++ b/.claude/skills/hackathon-e2e/scripts/splice-player.mjs @@ -0,0 +1,61 @@ +#!/usr/bin/env node +// Re-splice recipe.jsonl into recipe-player.html between the +// <script id="recipe-data"> markers. Required after ANY recipe edit — the +// player shows what is embedded, not the file on disk. +// +// The escape is part of the splice, not cosmetics: an inline <script> block +// ends at the FIRST literal close tag, even inside a JSON string — and +// act0.about.xss pastes a script tag on purpose, which once truncated the +// embedded data to 10 of 274 actions. `</` becomes `<\/`, which JSON parses +// back to the identical characters. +import fs from "node:fs" +import path from "node:path" +import { fileURLToPath } from "node:url" + +const skillDir = path.dirname(path.dirname(fileURLToPath(import.meta.url))) +const playerPath = path.join(skillDir, "recipe-player.html") +const recipePath = path.join(skillDir, "recipe.jsonl") + +const html = fs.readFileSync(playerPath, "utf8") +const open = '<script id="recipe-data" type="application/jsonl">' +const start = html.indexOf(open) +if (start < 0) throw new Error("open marker not found in recipe-player.html") +const bodyStart = start + open.length +const close = "</" + "script>" +const end = html.indexOf(close, bodyStart) +if (end < 0) throw new Error("close marker not found in recipe-player.html") + +const recipe = fs + .readFileSync(recipePath, "utf8") + .trimEnd() + .split("</") + .join("<\\/") + +fs.writeFileSync( + playerPath, + html.slice(0, bodyStart) + "\n" + recipe + "\n" + html.slice(end), +) + +// Read back and count — a splice nobody verified is how the player once +// showed 10 actions of 274 while every suite stayed green. +const back = fs.readFileSync(playerPath, "utf8") +const s2 = back.indexOf(open) + open.length +const e2 = back.indexOf(close, s2) +const lines = back.slice(s2, e2).trim().split("\n").filter(Boolean) +let actions = 0 +for (const l of lines) { + const o = JSON.parse(l) // throws on a truncated or mangled line + if (o.id) actions++ +} +const sourceActions = fs + .readFileSync(recipePath, "utf8") + .split("\n") + .filter((l) => /"id"\s*:/.test(l)).length +if (actions !== sourceActions) { + throw new Error( + `player embeds ${actions} actions but recipe.jsonl has ${sourceActions}`, + ) +} +console.log( + `player embeds ${lines.length} lines, ${actions} actions — matches recipe.jsonl`, +) diff --git a/.claude/skills/hackathon-e2e/scripts/timeshift.sh b/.claude/skills/hackathon-e2e/scripts/timeshift.sh new file mode 100644 index 00000000..56e783cc --- /dev/null +++ b/.claude/skills/hackathon-e2e/scripts/timeshift.sh @@ -0,0 +1,76 @@ +#!/usr/bin/env bash +# Time travel for manual testing: shift a hackathon's start/end dates by N +# days (negative = into the past) via the Edit RPC as hackagon-admin. This is +# the deterministic alternative to faking the system clock: HackathonStatus is +# computed server-side from starts_at/ends_at, so moving the event moves it +# through its lifecycle (Upcoming -> Active -> Finished). +# +# Usage: timeshift.sh <hackathon-uuid> <days> +# e.g. timeshift.sh 3f2a... -120 # pull the event 4 months into the past +# +# Phases carry their own dates, so shifting only the hackathon window would +# leave the timeline pointing at the old calendar. PhaseService.Edit exists now +# (seed-past-hackathons uses it), so this moves every dated phase by the same N +# days — which keeps each phase where it was RELATIVE to the event. Phases with +# no dates are left alone: the proto requires starts_at and ends_at together, +# so there is nothing to shift. +set -euo pipefail +HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +source "$HERE/lib.sh" +ensure_toolchain "${BASH_SOURCE[0]}" "$@" + +if [ $# -ne 2 ]; then + echo "usage: $(basename "$0") <hackathon-uuid> <days (may be negative)>" >&2 + exit 2 +fi +HACKATHON_ID="$1" +DAYS="$2" + +TOKEN=$(keycloak_token "hackagon-admin" "aliceandbob") +if [ -z "$TOKEN" ] || [ "$TOKEN" = "null" ]; then + echo "error: could not get an admin token from Keycloak" >&2 + exit 1 +fi + +CURRENT=$(grpcurl -plaintext -H "authorization: Bearer $TOKEN" \ + -d "{\"hackathonId\":\"$HACKATHON_ID\"}" "$GRPC_ADDR" \ + hackathon.HackathonService/Get) +STARTS=$(echo "$CURRENT" | jq -r '.hackathon.startsAt') +ENDS=$(echo "$CURRENT" | jq -r '.hackathon.endsAt') + +NEW_STARTS=$(date -u -d "$STARTS $DAYS days" +%Y-%m-%dT%H:%M:%SZ) +NEW_ENDS=$(date -u -d "$ENDS $DAYS days" +%Y-%m-%dT%H:%M:%SZ) + +echo "==> Shifting hackathon $HACKATHON_ID by $DAYS day(s):" +echo " starts_at: $STARTS -> $NEW_STARTS" +echo " ends_at: $ENDS -> $NEW_ENDS" + +grpcurl -plaintext -H "authorization: Bearer $TOKEN" \ + -d "{\"hackathonId\":\"$HACKATHON_ID\",\"startsAt\":\"$NEW_STARTS\",\"endsAt\":\"$NEW_ENDS\"}" \ + "$GRPC_ADDR" hackathon.HackathonService/Edit >/dev/null + +PHASES=$(grpcurl -plaintext -H "authorization: Bearer $TOKEN" \ + -d "{\"hackathonId\":\"$HACKATHON_ID\"}" "$GRPC_ADDR" \ + hackathon.PhaseService/List) +PHASE_COUNT=$(echo "$PHASES" | jq '.phases | length') + +for i in $(seq 0 $((PHASE_COUNT - 1))); do + [ "$PHASE_COUNT" -eq 0 ] && break + PHASE=$(echo "$PHASES" | jq -c ".phases[$i]") + PID=$(echo "$PHASE" | jq -r '.id') + P_STARTS=$(echo "$PHASE" | jq -r '.startsAt // empty') + P_ENDS=$(echo "$PHASE" | jq -r '.endsAt // empty') + P_NAME=$(echo "$PHASE" | jq -r '.name') + if [ -z "$P_STARTS" ] || [ -z "$P_ENDS" ]; then + echo " phase '$P_NAME': no dates, left as is" + continue + fi + P_NEW_STARTS=$(date -u -d "$P_STARTS $DAYS days" +%Y-%m-%dT%H:%M:%SZ) + P_NEW_ENDS=$(date -u -d "$P_ENDS $DAYS days" +%Y-%m-%dT%H:%M:%SZ) + echo " phase '$P_NAME': $P_STARTS -> $P_NEW_STARTS" + grpcurl -plaintext -H "authorization: Bearer $TOKEN" \ + -d "{\"phaseId\":\"$PID\",\"startsAt\":\"$P_NEW_STARTS\",\"endsAt\":\"$P_NEW_ENDS\"}" \ + "$GRPC_ADDR" hackathon.PhaseService/Edit >/dev/null +done + +echo "==> Done." diff --git a/.claude/skills/hackathon-e2e/scripts/up.sh b/.claude/skills/hackathon-e2e/scripts/up.sh new file mode 100644 index 00000000..2431ee03 --- /dev/null +++ b/.claude/skills/hackathon-e2e/scripts/up.sh @@ -0,0 +1,10 @@ +#!/usr/bin/env bash +# Start the full stack (Keycloak + Postgres + backend + frontend) detached via +# process-compose. Safe to call when already running (no-op). +set -euo pipefail +HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +source "$HERE/lib.sh" +ensure_toolchain "${BASH_SOURCE[0]}" "$@" + +echo "==> Starting services (process-compose, detached)..." +(cd "$ROOT_DIR" && just deploy::up) diff --git a/.claude/skills/hackathon-e2e/scripts/wait-ready.sh b/.claude/skills/hackathon-e2e/scripts/wait-ready.sh new file mode 100644 index 00000000..4bd93950 --- /dev/null +++ b/.claude/skills/hackathon-e2e/scripts/wait-ready.sh @@ -0,0 +1,98 @@ +#!/usr/bin/env bash +# Block until every service the tests rely on is answering. Fails loudly with +# the service name on timeout. +set -euo pipefail +HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +source "$HERE/lib.sh" +ensure_toolchain "${BASH_SOURCE[0]}" "$@" + +TIMEOUT="${E2E_READY_TIMEOUT:-300}" + +# A crash-looping service is not a "ready" stack, and process-compose will not +# tell you unasked. +# +# On 2026-08-13 this stack ran for 50 minutes with its `frontend` process at 54 +# restarts — vite exiting 1 on "Port 8081 is already in use" roughly once a +# minute, each round a full `just develop`, i.e. one acquisition of the repo-wide +# `git+file:///workspaces/hackagon` fetch lock. That starved every other startup +# on the machine; the backend's own start command is `just develop just run` and +# its readiness budget is spent WAITING FOR NIX. When the budget ran out +# process-compose SIGTERMed a perfectly healthy backend, which exited 0, which +# `restart: on_failure` did not treat as a failure — so it stayed down and the +# suite reported connection-refused failures at four different points in four +# runs. +# +# Nothing reported any of it. `process list` said `frontend Running Ready` +# because the readiness probe is a GET of :8081 and the OTHER server was +# answering — a probe on a PORT cannot say which PROCESS holds it. The RESTARTS +# column said 54 the whole time and no one read it. This reads it. +# +# A warning rather than a hard failure: a restart or two during boot is normal +# (`restart: on_failure` retrying a service whose dependency was a second late), +# and refusing to run the suite over that would be its own kind of flake. What is +# never normal is a number that keeps climbing, so print the count AND the exit +# code, which together name the cause. +report_restarts() { + local sock pc line name restarts exitcode noisy=0 + sock="$(cat "$ROOT_DIR/tools/deploy/process-compose/.socket-path-test-services" 2>/dev/null || true)" + [ -n "$sock" ] && [ -S "$sock" ] || return 0 + command -v process-compose >/dev/null 2>&1 || return 0 + pc="$(process-compose --unix-socket "$sock" process list -o wide 2>/dev/null || true)" + [ -n "$pc" ] || return 0 + while read -r line; do + name="$(echo "$line" | awk '{print $2}')" + # RESTARTS and EXITCODE are the LAST TWO columns, counted from the end on + # purpose: HEALTH is "Not Ready" — TWO whitespace-separated words — for every + # service that is starting up, so fixed field numbers ($6/$7) read the health + # text as the restart count on exactly the rows worth reading. Counting from + # NF is stable across both widths, and "Disabled" rows (`- -`) too. + restarts="$(echo "$line" | awk '{print $(NF - 1)}')" + exitcode="$(echo "$line" | awk '{print $NF}')" + case "$restarts" in '' | *[!0-9]*) continue ;; esac + if [ "$restarts" -ge 3 ]; then + echo " ⚠ $name has restarted $restarts times (last exit code $exitcode)." >&2 + noisy=1 + fi + done <<EOF +$(echo "$pc" | awk 'NR>1 && NF>=7') +EOF + if [ "$noisy" -eq 1 ]; then + echo " ⚠ A service that keeps restarting is a service whose every attempt" >&2 + echo " re-enters the Nix dev shell and takes the repo-wide fetch lock," >&2 + echo " which starves the startup of everything else. Check its log under" >&2 + echo " .output/run/process-compose/ before trusting this run's results." >&2 + fi +} + +echo "==> Waiting for the stack to be ready (timeout ${TIMEOUT}s per service)..." +wait_for "postgres" "$TIMEOUT" pg_isready -h 127.0.0.1 -p 5432 -U postgres +wait_for "keycloak" "$TIMEOUT" curl -fsS \ + "$KEYCLOAK_URL/realms/hackagon/.well-known/openid-configuration" +wait_for "backend" "$TIMEOUT" grpcurl -plaintext "$GRPC_ADDR" list +# vite is unusable here — see prod-frontend.sh for why, and for the traps in +# starting the built server by hand. This is UNCONDITIONAL: the guard used to be +# "leave it alone if anything answers within 5s", which handed the run to a cold +# `vite dev` whenever it happened to reply in time, and left it holding +# [::1]:8081 against the built server whenever it did not. +# +# No `|| true` either. Swallowing the failure meant the real error (EADDRINUSE, +# printed the moment it happened) was followed by 300s of polling and a closing +# message blaming the frontend for not starting. +bash "$HERE/prod-frontend.sh" ensure "$FRONTEND_URL" + +# --max-time is load-bearing, not belt-and-braces. wait_for checks its deadline +# BETWEEN attempts, so a probe that never returns defeats the timeout entirely: +# with a cold `vite dev` holding :8081 this curl blocked for 15+ minutes on a +# single attempt, printing not one dot, and the run looked hung rather than +# failed. Bound every attempt so the deadline can actually be reached. +if ! wait_for "frontend" "$TIMEOUT" curl -fsS --max-time 10 "$FRONTEND_URL"; then + echo "" + echo " The frontend did not come up on $FRONTEND_URL." >&2 + echo " If it is not part of the process-compose stack, start it manually:" >&2 + echo " cd components/frontend && just serve" >&2 + exit 1 +fi +# After everything answers, not before: a restart count taken mid-boot is mostly +# noise, and the question this answers is "is what I am about to test stable". +report_restarts +echo "==> Stack is ready." diff --git a/.claude/skills/hackathon-e2e/tests/auth.setup.ts b/.claude/skills/hackathon-e2e/tests/auth.setup.ts new file mode 100644 index 00000000..f04a09ba --- /dev/null +++ b/.claude/skills/hackathon-e2e/tests/auth.setup.ts @@ -0,0 +1,29 @@ +import { test as setup, expect } from "@playwright/test" +import { ALL_PERSONAS } from "../personas.js" +import { loginViaKeycloak } from "../helpers/login.js" +import { ensureStateDir, storageStatePath } from "../helpers/state.js" + +// Runs before both suites (project dependency). For every persona: +// 1. Log in through the real Keycloak flow. +// 2. Visit /dashboard — on a fresh database this is what auto-registers the +// user in the backend (hooks.server.ts: WhoAmI -> NOT_FOUND -> Register), +// which the journey suite depends on. +// 3. Save the browser storage state for reuse by the actual tests. + +setup.beforeAll(() => ensureStateDir()) + +for (const persona of ALL_PERSONAS) { + setup( + `authenticate ${persona.key} (${persona.username})`, + async ({ page }) => { + await loginViaKeycloak(page, persona) + + await page.goto("/dashboard") + await expect( + page.getByRole("heading", { name: /Welcome back/ }), + ).toBeVisible() + + await page.context().storageState({ path: storageStatePath(persona.key) }) + }, + ) +} diff --git a/.claude/skills/hackathon-e2e/tests/docs/flows.spec.ts b/.claude/skills/hackathon-e2e/tests/docs/flows.spec.ts new file mode 100644 index 00000000..248cf743 --- /dev/null +++ b/.claude/skills/hackathon-e2e/tests/docs/flows.spec.ts @@ -0,0 +1,209 @@ +import { test, expect, type Page } from "@playwright/test" +import fs from "node:fs" +import path from "node:path" +import sharp from "sharp" +import { PERSONAS } from "../../personas.js" +import { storageStatePath, SKILL_DIR } from "../../helpers/state.js" + +/** + * Documentation screenshots for docs/user-flows.md. + * + * Runs against the SEED fixture (`just db::seed`), which is built for exactly + * this: an upcoming, an ongoing and a past hackathon, public and private, with + * teams, draft/final submissions and a waitlisted person. Re-seeding keeps the + * ongoing event ongoing, so the shots stay honest whenever they are retaken. + * + * Each step is captured twice — desktop 1440x900 and phone 390x844 — and + * written as WebP directly into the docs folder. Viewport-sized rather than + * full-page: this is meant to show what a person actually sees. + * + * DOCS_SHOTS=1 pnpm exec playwright test --project=docs + */ +const enabled = !!process.env.DOCS_SHOTS + +const OUT_DIR = path.resolve(SKILL_DIR, "../../../docs/flows") +const DESKTOP = { width: 1440, height: 900 } +const PHONE = { width: 390, height: 844 } + +async function shoot(page: Page, name: string, viewport: "desktop" | "phone") { + // Fonts and lazily-loaded images settle after load; without this the hero + // and logos are half-painted in about one shot in five. + await page.waitForLoadState("networkidle").catch(() => {}) + await page.waitForTimeout(350) + + const png = await page.screenshot() + fs.mkdirSync(OUT_DIR, { recursive: true }) + await sharp(png) + .webp({ quality: 82 }) + .toFile(path.join(OUT_DIR, `${name}-${viewport}.webp`)) +} + +/** One documented flow: a named sequence of steps, each ending in a shot. */ +interface Flow { + slug: string + persona: keyof typeof PERSONAS | "anonymous" + steps: { name: string; run: (page: Page) => Promise<void> }[] +} + +const H_ONGOING = "Climate Tech Hackathon 2026" +const H_UPCOMING = "AI Innovation Challenge 2026" + +/** Open the account menu (native <details>, so no hydration wait needed). */ +async function openAccountMenu(page: Page) { + await page.locator('header summary[aria-haspopup="menu"]').click() + await expect(page.getByRole("menu")).toBeVisible() +} + +const FLOWS: Flow[] = [ + { + slug: "visitor", + persona: "anonymous", + steps: [ + { + name: "1-landing", + run: async (p) => { + await p.goto("/") + }, + }, + { + name: "2-event", + run: async (p) => { + await p.goto("/") + await p + .locator('a[href^="/hackathon/"]') + .filter({ hasText: H_UPCOMING }) + .first() + .click() + await expect(p).toHaveURL(/\/hackathon\//) + }, + }, + { + name: "3-about", + run: async (p) => { + await p.goto("/about") + }, + }, + ], + }, + { + slug: "participant", + persona: "bob", + steps: [ + { + name: "1-dashboard", + run: async (p) => { + await p.goto("/dashboard") + }, + }, + { + name: "2-overview", + run: async (p) => { + await p.goto("/dashboard") + await p.locator("a").filter({ hasText: H_ONGOING }).first().click() + await expect(p).toHaveURL(/\/my\/hackathon\//) + }, + }, + { + name: "3-teams", + run: async (p) => { + await p.goto("/dashboard") + await p.locator("a").filter({ hasText: H_ONGOING }).first().click() + await p.getByRole("link", { name: "Teams" }).first().click() + }, + }, + { + name: "4-submissions", + run: async (p) => { + await p.goto("/dashboard") + await p.locator("a").filter({ hasText: H_ONGOING }).first().click() + await p.getByRole("link", { name: "Submissions" }).first().click() + }, + }, + ], + }, + { + slug: "account", + persona: "alice", + steps: [ + { + name: "1-menu", + run: async (p) => { + await p.goto("/dashboard") + await openAccountMenu(p) + }, + }, + { + name: "2-account", + run: async (p) => { + await p.goto("/account") + }, + }, + ], + }, + { + slug: "organizer", + persona: "admin", + steps: [ + { + name: "1-manage", + run: async (p) => { + await p.goto("/dashboard") + await p.locator("a").filter({ hasText: H_ONGOING }).first().click() + await p.getByRole("link", { name: "Manage" }).first().click() + }, + }, + { + name: "2-participants", + run: async (p) => { + await p.goto("/dashboard") + await p.locator("a").filter({ hasText: H_ONGOING }).first().click() + await p.getByRole("link", { name: "Participants" }).first().click() + }, + }, + ], + }, + { + slug: "admin", + persona: "admin", + steps: [ + { + name: "1-pages", + run: async (p) => { + await p.goto("/manage/pages") + }, + }, + { + name: "2-users", + run: async (p) => { + await p.goto("/manage/users") + }, + }, + ], + }, +] + +for (const flow of FLOWS) { + test.describe(`docs: ${flow.slug}`, () => { + test.skip(!enabled, "set DOCS_SHOTS=1 to regenerate documentation images") + if (flow.persona !== "anonymous") { + test.use({ storageState: storageStatePath(flow.persona) }) + } + + for (const step of flow.steps) { + for (const [label, viewport] of [ + ["desktop", DESKTOP], + ["phone", PHONE], + ] as const) { + test(`${flow.slug} ${step.name} ${label}`, async ({ page }) => { + await page.setViewportSize(viewport) + // Pin the theme: without it the shots inherit whatever the runner's + // default happens to be, and a re-take could silently flip the whole + // set from dark to light. + await page.emulateMedia({ colorScheme: "dark" }) + await step.run(page) + await shoot(page, `${flow.slug}-${step.name}`, label) + }) + } + } + }) +} diff --git a/.claude/skills/hackathon-e2e/tests/journey/recipe.spec.ts b/.claude/skills/hackathon-e2e/tests/journey/recipe.spec.ts new file mode 100644 index 00000000..3535ca42 --- /dev/null +++ b/.claude/skills/hackathon-e2e/tests/journey/recipe.spec.ts @@ -0,0 +1,29 @@ +import { test } from "@playwright/test" +import { loadRecipe, runAction } from "../../helpers/recipe.js" + +// The full hackathon lifecycle — publication → registration → proposals → +// teams → event days → voting → post-event — as a data-driven screenplay. +// The actions live in recipe.jsonl (one JSON action per line); this file just +// executes them strictly in order. Placeholder actions (those with a "todo") +// skip while their backend RPC probes as unimplemented and start running the +// day it lands; steps depending on a skipped step cascade-skip cleanly. +// +// To extend the recipe, edit recipe.jsonl — not this file. + +test.describe.configure({ mode: "serial" }) + +const actions = loadRecipe() + +// JOURNEY_UNTIL_ACT=N plays the story only up to (and including) act N and +// leaves the stack in exactly that state — freeze the world mid-lifecycle to +// inspect it in a browser (see run.sh --until-act). +const untilAct = process.env.JOURNEY_UNTIL_ACT + ? Number(process.env.JOURNEY_UNTIL_ACT) + : Infinity + +for (const action of actions) { + test(`[${action.id}] ${action.title}`, async ({ browser }) => { + test.skip(action.act > untilAct, `beyond requested act ${untilAct}`) + await runAction(test, action, browser) + }) +} diff --git a/.claude/skills/hackathon-e2e/tests/mobile/chrome-reflow.spec.ts b/.claude/skills/hackathon-e2e/tests/mobile/chrome-reflow.spec.ts new file mode 100644 index 00000000..2ae15cf8 --- /dev/null +++ b/.claude/skills/hackathon-e2e/tests/mobile/chrome-reflow.spec.ts @@ -0,0 +1,92 @@ +import { test, expect } from "@playwright/test" +import { + expectFitsViewport, + expectNoOverlap, + expectNoClippedText, + expectConsentBannerClearsContent, + expectFooterOperable, + CONSENT_BANNER, +} from "../../helpers/reflow.js" + +// The page chrome — header, footer, consent banner — must REFLOW at every +// width the app supports, never shrink-and-truncate. This exists because both +// ends broke on a real phone at once (2026-08-09): the header's 1fr_auto_1fr +// grid kept both outer columns equal below md, where the nav between them is +// hidden — the right column (theme switch + Log in + hamburger) held its +// content and the equal-width left column squeezed the wordmark to "H…"; and +// the footer was a fixed-height no-wrap flex row whose three groups need +// ~590px, so at phone widths logos, links and the tagline piled onto each +// other. Neither is a single-viewport bug, so this spec sweeps a range of +// widths on the public pages that mount the full chrome. +// +// The assertions live in helpers/reflow.ts, shared with the every-route sweep +// (full-sweep.spec.ts). This file keeps the DETAIL role: six widths on the +// three high-traffic public pages, with the chrome held to the STRICT checks +// (no ellipsis pardon, no geometric-containment pardon — the chrome layers +// nothing on purpose, so any intersection or truncation there is a defect). + +const WIDTHS = [320, 360, 390, 768, 1024, 1440] +const PAGES = ["/", "/about", "/hackathon"] + +// The width below which the header hides the wordmark text on purpose. +// Mirrors max-[389px]:hidden in NavBar.svelte (the signed-out bar leaves the +// full word ~22px short at 360, so the drop happens below the 390 class of +// phones rather than letting an ellipsis eat it). +const WORDMARK_MIN = 390 + +const BANNER = CONSENT_BANNER + +for (const width of WIDTHS) { + test.describe(`${width}px`, () => { + test.use({ viewport: { width, height: width < 768 ? 844 : 900 } }) + + for (const path of PAGES) { + test(`chrome reflows on ${path}`, async ({ page }) => { + await page.goto(path) + await page.waitForLoadState("networkidle").catch(() => {}) + const name = `${width}px ${path}` + + // The scopes must exist, or every check below passes vacuously. + await expect(page.locator("header")).toBeVisible() + await expect(page.locator("footer")).toBeVisible() + + // Geometry first: its failures name the offending elements, which is + // a far better diagnosis than a bare "wordmark not visible". + await expectFitsViewport(page, name) + for (const scope of ["header", "footer", BANNER]) { + await expectNoOverlap(page, scope, name) + await expectNoClippedText(page, scope, name) + } + + // The footer's four links, hit-tested at the bottom of the document. + // Width-swept for the same reason as the banner below: the footer wraps + // from one spaced row into three centred ones as the viewport narrows, + // so which link sits where — and what can end up drawn over it — is a + // different answer at every width here. + await expectFooterOperable(page, name) + + // The banner's own contract: on screen at the top, over nothing + // operable at the end. Width-swept deliberately — it wraps to two and + // three lines as the viewport narrows, so the space it has to reserve + // is a different number at every width here and may never be a + // hard-coded one. + await expectConsentBannerClearsContent(page, name) + + // The wordmark: logo always; text whole wherever it is shown, and + // shown at every width except the deliberate sub-360 drop. (A span + // squeezed to zero width — the broken grid did exactly that — fails + // toBeVisible, so "present but eaten" cannot pass.) + const brand = page.locator("header a").first() + await expect(brand.locator("img:visible").first()).toBeVisible() + const wordmark = brand.locator("span").first() + if (width >= WORDMARK_MIN) { + await expect( + wordmark, + "the wordmark text may only be dropped below " + + `${WORDMARK_MIN}px — at ${width}px it must render`, + ).toBeVisible() + } + }) + } + }) +} diff --git a/.claude/skills/hackathon-e2e/tests/mobile/full-sweep.spec.ts b/.claude/skills/hackathon-e2e/tests/mobile/full-sweep.spec.ts new file mode 100644 index 00000000..88063670 --- /dev/null +++ b/.claude/skills/hackathon-e2e/tests/mobile/full-sweep.spec.ts @@ -0,0 +1,730 @@ +import { test, expect, type Page } from "@playwright/test" +import fs from "node:fs" +import path from "node:path" +import { SEED_HACKATHONS, type PersonaKey } from "../../personas.js" +import { SKILL_DIR, storageStatePath } from "../../helpers/state.js" +import { rpcAnonymous, rpcAs } from "../../helpers/api.js" +import { + expectFitsViewport, + expectNoOverlap, + expectNoClippedText, + expectConsentBannerClearsContent, + expectFooterOperable, + CONSENT_BANNER, +} from "../../helpers/reflow.js" + +// EVERY +page.svelte route, reflow-checked at a phone width and a desktop +// width. chrome-reflow.spec.ts keeps the six-width DETAIL sweep on the three +// high-traffic public pages; this file trades width resolution for route +// coverage: 2 widths × every route. 360 because every reflow failure found so +// far reproduced at ≤390 and 360 is the narrow end of the mainstream phone +// class (320 stays covered by the chrome sweep); 1440 because desktop-only +// regressions (nav centring, wide grids) are invisible at phone widths and a +// second width is nearly free. Six widths on 42 routes would be ~250 visits — +// too slow for every run, and the intermediate widths have never caught a bug +// the two extremes missed. +// +// COVERAGE IS ASSERTED, NOT ASSUMED. The route list is enumerated from the +// frontend's own route tree (src/routes/**/+page.svelte) at runtime, and the +// final test fails if any enumerated route has neither a sweep entry nor an +// explicit UNCOVERED declaration with a reason. A sweep that quietly visits +// 20 of 42 and reports green is the exact failure mode this repo keeps +// finding (four prior instances in .claude/CLAUDE.md) — a new route turns +// this suite red until someone says how to reach it. +// +// Auth and data: the sweep runs against the SEEDED fixture (scripts/run.sh +// mobile seeds it) and picks per route the persona who sees the page's +// RICHEST variant — alice owns h1, so organizer tools render their controls; +// bob is a plain member, so the ballot (not the category editor) renders on +// his voting visit; a page that 403s proves nothing about its layout, and a +// non-2xx/3xx answer FAILS the visit rather than skipping it. State the +// fixture lacks (registration form, invite link, vote categories, prizes) is +// created over gRPC in beforeAll — real ids from the running instance, never +// fabricated UUIDs. + +// ─── Widths ────────────────────────────────────────────────────────────────── + +const VIEWPORTS = [ + { width: 360, height: 844 }, + { width: 1440, height: 900 }, +] + +const BANNER = CONSENT_BANNER +const SHOTS = ".artifacts/sweep" +fs.mkdirSync(SHOTS, { recursive: true }) + +// ─── Route enumeration (the source of truth for coverage) ──────────────────── + +const ROOT_DIR = path.resolve(SKILL_DIR, "..", "..", "..") +const ROUTES_DIR = path.join( + ROOT_DIR, + "components", + "frontend", + "src", + "routes", +) + +/** Every route pattern that owns a +page.svelte, layout groups stripped: + * `(app)/my/hackathon/[id]/teams/+page.svelte` → `/my/hackathon/[id]/teams`. */ +function enumerateRoutes(dir: string, segs: string[] = []): string[] { + const out: string[] = [] + const entries = fs.readdirSync(dir, { withFileTypes: true }) + if (entries.some((e) => e.isFile() && e.name === "+page.svelte")) { + out.push("/" + segs.join("/")) + } + for (const e of entries) { + if (!e.isDirectory()) continue + const next = + e.name.startsWith("(") && e.name.endsWith(")") ? segs : [...segs, e.name] + out.push(...enumerateRoutes(path.join(dir, e.name), next)) + } + return out +} + +// ─── Fixture ids, resolved from the running instance ───────────────────────── + +interface SweepIds { + /** h1 — "AI Innovation Challenge 2026", public, owned by alice. */ + h1: string + /** An approved project in h1 ("AutoML Pipeline Builder", alice's). */ + approvedProject: string + /** A PROPOSED project authored by bob ("Federated Learning Framework") — + * the proposals list and its edit page are the author's own view. */ + bobProposal: string + phase: string + track: string + page: string + inviteToken: string +} + +let ids: SweepIds | null = null + +/** ids, or a loud explanation of which setup step never ran. */ +function id(key: keyof SweepIds): string { + if (!ids) { + throw new Error( + "sweep ids were never resolved — beforeAll failed; its error is the real one", + ) + } + return ids[key] +} + +function must<T>(v: T | undefined | null, what: string): T { + if (v === undefined || v === null) { + throw new Error( + `${what} — the full sweep needs the seeded fixture; run scripts/run.sh mobile (it seeds) or scripts/seed.sh first`, + ) + } + return v +} + +test.beforeAll(async () => { + test.setTimeout(120_000) + + // The seeded public hackathon, discovered by name the way a user would. + const listed = rpcAnonymous("hackathon.HackathonService/List", { + visibilityFilter: 1, + }) + if (!listed.ok) throw new Error(`HackathonService.List failed: ${listed.raw}`) + const h1 = must( + ( + listed.data.hackathons as { id: string; name: string }[] | undefined + )?.find((h) => h.name === SEED_HACKATHONS.h1.name), + `seed hackathon "${SEED_HACKATHONS.h1.name}" not found`, + ) + + // Real ids for every dynamic segment, from the entities the seed creates. + const [projects, phases, tracks, pages] = await Promise.all([ + rpcAs("alice", "hackathon.ProjectService/List", { hackathonId: h1.id }), + rpcAs("alice", "hackathon.PhaseService/List", { hackathonId: h1.id }), + rpcAs("alice", "hackathon.TrackService/List", { hackathonId: h1.id }), + rpcAs("alice", "hackathon.PageService/List", { hackathonId: h1.id }), + ]) + const projectRows = (projects.data?.projects ?? []) as { + id: string + title: string + }[] + const approved = must( + projectRows.find((p) => p.title === "AutoML Pipeline Builder"), + "seed project 'AutoML Pipeline Builder' not found in h1", + ) + const proposal = must( + projectRows.find((p) => p.title === "Federated Learning Framework"), + "seed proposal 'Federated Learning Framework' not found in h1", + ) + const phase = must( + (phases.data?.phases as { id: string }[] | undefined)?.[0], + "h1 has no phases", + ) + const track = must( + (tracks.data?.tracks as { id: string }[] | undefined)?.[0], + "h1 has no tracks", + ) + const pageRow = must( + (pages.data?.pages as { id: string }[] | undefined)?.[0], + "h1 has no pages", + ) + + // ── State the fixture lacks, created for real (idempotently) ── + + // An invite link, so /invite/[token] renders the preview instead of 404. + let invites = await rpcAs("alice", "hackathon.HackathonService/ListInvites", { + hackathonId: h1.id, + }) + let token = ( + invites.data?.invites as { token: string; revokedAt?: string }[] | undefined + )?.find((i) => !i.revokedAt)?.token + if (!token) { + const created = await rpcAs( + "alice", + "hackathon.HackathonService/CreateInvite", + { hackathonId: h1.id, note: "layout sweep" }, + ) + if (!created.ok) throw new Error(`CreateInvite failed: ${created.raw}`) + invites = await rpcAs("alice", "hackathon.HackathonService/ListInvites", { + hackathonId: h1.id, + }) + token = ( + invites.data?.invites as { token: string; revokedAt?: string }[] + ).find((i) => !i.revokedAt)?.token + } + + // A registration form, so /register/[id] renders fields instead of 404. + // Labels chosen long on purpose: label wrapping at 360 is part of what the + // sweep is here to check. + const form = await rpcAs( + "alice", + "hackathon.ConfigService/SetRegistrationForm", + { + hackathonId: h1.id, + fields: [ + { + key: "affiliation", + label: "Affiliation / organisation", + type: "text", + required: true, + }, + { + key: "experience", + label: + "Tell us about your experience with machine learning and data engineering", + type: "text", + required: false, + }, + { + key: "tags", + label: "Topics you want to work on (comma-separated)", + type: "tags", + required: false, + }, + { + key: "portfolio", + label: "Portfolio or repository URL", + type: "url", + required: false, + }, + ], + consents: [ + { + key: "conduct", + label: + "I accept the Code of Conduct and the event's photography policy", + required: true, + }, + { + key: "newsletter", + label: "Keep me posted about future SDSC hackathons", + required: false, + }, + ], + }, + ) + if (!form.ok) throw new Error(`SetRegistrationForm failed: ${form.raw}`) + + // Vote categories in all three methods, so the voting page renders every + // ballot shape (bob) and the full management surface (alice). + const cats = await rpcAs("alice", "vote.VoteService/ListVoteCategories", { + hackathonId: h1.id, + }) + const existing = new Set( + ((cats.data?.voteCategories ?? []) as { name: string }[]).map( + (c) => c.name, + ), + ) + const wanted: { name: string; votingMethod: number; maxPoints?: number }[] = [ + { name: "Best Overall Project", votingMethod: 1 }, + { name: "Crowd Favourite (ranked)", votingMethod: 2 }, + { name: "Technical Excellence (points)", votingMethod: 3, maxPoints: 10 }, + ] + for (const w of wanted) { + if (existing.has(w.name)) continue + const res = await rpcAs("alice", "vote.VoteService/CreateVoteCategory", { + hackathonId: h1.id, + name: w.name, + description: "Created by the layout sweep so the ballot renders.", + votingMethod: w.votingMethod, + voterType: 1, + ...(w.maxPoints ? { maxPoints: w.maxPoints } : {}), + }) + if (!res.ok) + throw new Error(`CreateVoteCategory ${w.name} failed: ${res.raw}`) + } + + // Voting open, so bob's visit renders live ballot forms, not a closed notice. + const settings = await rpcAs( + "alice", + "hackathon.HackathonService/EditSettings", + { + hackathonId: h1.id, + votingEnabled: true, + }, + ) + if (!settings.ok) throw new Error(`EditSettings failed: ${settings.raw}`) + + // A prize table, so /prizes prefills rows (the many-field form case). + const prizes = await rpcAs("alice", "hackathon.PrizeService/Get", { + hackathonId: h1.id, + }) + if (((prizes.data?.prizes ?? []) as unknown[]).length === 0) { + const set = await rpcAs("alice", "hackathon.PrizeService/Set", { + hackathonId: h1.id, + prizes: [ + { rank: 1, title: "Grand Prize — 5000 CHF and an SDSC mentorship" }, + { rank: 2, title: "Runner-up — 2000 CHF" }, + { rank: 3, title: "Third place — 1000 CHF" }, + { rank: 0, title: "Community Choice" }, + ], + }) + if (!set.ok) throw new Error(`PrizeService.Set failed: ${set.raw}`) + } + + ids = { + h1: h1.id, + approvedProject: approved.id, + bobProposal: proposal.id, + phase: phase.id, + track: track.id, + page: pageRow.id, + inviteToken: must(token, "no usable invite token after CreateInvite"), + } +}) + +// ─── The visit table ───────────────────────────────────────────────────────── +// pattern = the enumerated route (coverage is keyed on it); path() = a real +// URL for it. Personas: "anon" (no session), or a PersonaKey. In the smoke +// fixture ALICE OWNS H1 (journey differs — .claude/CLAUDE.md, "cast differs +// between suites"), bob is a confirmed member, admin holds the global role. + +type Who = PersonaKey | "anon" + +interface Visit { + pattern: string + persona: Who + path: () => string + /** Why this persona / this id — shows up in failure output. */ + note?: string +} + +const VISITS: Visit[] = [ + // Public, anonymous. + { pattern: "/", persona: "anon", path: () => "/" }, + { pattern: "/hackathon", persona: "anon", path: () => "/hackathon" }, + { + pattern: "/hackathon/[id]", + persona: "anon", + path: () => `/hackathon/${id("h1")}`, + }, + { pattern: "/[slug=sitepage]", persona: "anon", path: () => "/about" }, + { + pattern: "/invite/[token]", + persona: "anon", + path: () => `/invite/${id("inviteToken")}`, + note: "token minted in beforeAll", + }, + + // Signed-in platform pages. + { pattern: "/dashboard", persona: "bob", path: () => "/dashboard" }, + { pattern: "/account", persona: "bob", path: () => "/account" }, + { + pattern: "/hackathons/create", + persona: "admin", + path: () => "/hackathons/create", + note: "needs global Admin or HackathonOrganizer", + }, + { + pattern: "/manage/pages", + persona: "admin", + path: () => "/manage/pages", + note: "global Admin only", + }, + { + pattern: "/manage/users", + persona: "admin", + path: () => "/manage/users", + note: "global Admin only", + }, + { + pattern: "/manage/gallery", + persona: "admin", + path: () => "/manage/gallery", + note: "global Admin only — ALL_MEDIA scope", + }, + { + pattern: "/register/[id]", + persona: "bob", + path: () => `/register/${id("h1")}`, + note: "form set in beforeAll", + }, + + // Member surfaces on h1 where the member view is the richer/only one. + { + pattern: "/my/hackathon/[id]/projects/proposals", + persona: "bob", + path: () => `/my/hackathon/${id("h1")}/projects/proposals`, + note: "own pending proposals — bob has two, alice none", + }, + { + pattern: "/my/hackathon/[id]/projects/proposals/propose", + persona: "bob", + path: () => `/my/hackathon/${id("h1")}/projects/proposals/propose`, + }, + { + pattern: "/my/hackathon/[id]/projects/proposals/[projectId]/edit", + persona: "bob", + path: () => + `/my/hackathon/${id("h1")}/projects/proposals/${id("bobProposal")}/edit`, + note: "author edits own proposal", + }, + + // Organizer/owner surfaces on h1 — alice sees every control. + { + pattern: "/my/hackathon/[id]/overview", + persona: "alice", + path: () => `/my/hackathon/${id("h1")}/overview`, + }, + { + pattern: "/my/hackathon/[id]/teams", + persona: "alice", + path: () => `/my/hackathon/${id("h1")}/teams`, + }, + { + pattern: "/my/hackathon/[id]/teams/manage", + persona: "alice", + path: () => `/my/hackathon/${id("h1")}/teams/manage`, + note: "assignment board + people panel", + }, + { + pattern: "/my/hackathon/[id]/projects", + persona: "alice", + path: () => `/my/hackathon/${id("h1")}/projects`, + }, + { + pattern: "/my/hackathon/[id]/projects/[projectId]", + persona: "alice", + path: () => `/my/hackathon/${id("h1")}/projects/${id("approvedProject")}`, + }, + { + pattern: "/my/hackathon/[id]/projects/[projectId]/edit", + persona: "alice", + path: () => + `/my/hackathon/${id("h1")}/projects/${id("approvedProject")}/edit`, + }, + { + pattern: "/my/hackathon/[id]/timeline", + persona: "alice", + path: () => `/my/hackathon/${id("h1")}/timeline`, + }, + { + pattern: "/my/hackathon/[id]/timeline/new", + persona: "alice", + path: () => `/my/hackathon/${id("h1")}/timeline/new`, + }, + { + pattern: "/my/hackathon/[id]/timeline/[phaseId]/edit", + persona: "alice", + path: () => `/my/hackathon/${id("h1")}/timeline/${id("phase")}/edit`, + }, + { + pattern: "/my/hackathon/[id]/tracks", + persona: "alice", + path: () => `/my/hackathon/${id("h1")}/tracks`, + }, + { + pattern: "/my/hackathon/[id]/tracks/new", + persona: "alice", + path: () => `/my/hackathon/${id("h1")}/tracks/new`, + }, + { + pattern: "/my/hackathon/[id]/tracks/[trackId]/edit", + persona: "alice", + path: () => `/my/hackathon/${id("h1")}/tracks/${id("track")}/edit`, + }, + { + pattern: "/my/hackathon/[id]/pages", + persona: "alice", + path: () => `/my/hackathon/${id("h1")}/pages`, + }, + { + pattern: "/my/hackathon/[id]/pages/new", + persona: "alice", + path: () => `/my/hackathon/${id("h1")}/pages/new`, + }, + { + pattern: "/my/hackathon/[id]/pages/[pageId]", + persona: "alice", + path: () => `/my/hackathon/${id("h1")}/pages/${id("page")}`, + }, + { + pattern: "/my/hackathon/[id]/pages/[pageId]/edit", + persona: "alice", + path: () => `/my/hackathon/${id("h1")}/pages/${id("page")}/edit`, + }, + { + pattern: "/my/hackathon/[id]/participants", + persona: "alice", + path: () => `/my/hackathon/${id("h1")}/participants`, + note: "owner sees approve/remove/role controls", + }, + { + pattern: "/my/hackathon/[id]/submissions", + persona: "alice", + path: () => `/my/hackathon/${id("h1")}/submissions`, + }, + { + pattern: "/my/hackathon/[id]/voting", + persona: "alice", + path: () => `/my/hackathon/${id("h1")}/voting`, + note: "organizer view: category editor + tallies", + }, + { + pattern: "/my/hackathon/[id]/prizes", + persona: "alice", + path: () => `/my/hackathon/${id("h1")}/prizes`, + note: "prize table set in beforeAll", + }, + { + pattern: "/my/hackathon/[id]/windows", + persona: "alice", + path: () => `/my/hackathon/${id("h1")}/windows`, + }, + { + pattern: "/my/hackathon/[id]/forms", + persona: "alice", + path: () => `/my/hackathon/${id("h1")}/forms`, + }, + { + pattern: "/my/hackathon/[id]/email", + persona: "alice", + path: () => `/my/hackathon/${id("h1")}/email`, + }, + { + pattern: "/my/hackathon/[id]/invites", + persona: "alice", + path: () => `/my/hackathon/${id("h1")}/invites`, + }, + { + pattern: "/my/hackathon/[id]/photos", + persona: "alice", + path: () => `/my/hackathon/${id("h1")}/photos`, + }, + { + pattern: "/my/hackathon/[id]/webinars", + persona: "alice", + path: () => `/my/hackathon/${id("h1")}/webinars`, + }, + { + pattern: "/my/hackathon/[id]/manage", + persona: "alice", + path: () => `/my/hackathon/${id("h1")}/manage`, + }, + { + pattern: "/my/hackathon/[id]/manage/edit", + persona: "alice", + path: () => `/my/hackathon/${id("h1")}/manage/edit`, + note: "nested under manage — its only entry point is the hub's Edit details", + }, + + // Second visit where the member variant is a genuinely different surface: + // bob gets the BALLOTS (three methods, forms live because voting is open), + // where alice — organizers may not vote — gets the editor asserted above. + { + pattern: "/my/hackathon/[id]/voting", + persona: "bob", + path: () => `/my/hackathon/${id("h1")}/voting`, + note: "member view: live ballots in all three methods", + }, +] + +/** + * Routes we deliberately do NOT visit, each with the reason. The coverage + * test fails on any enumerated route that is in neither list, so adding a + * route to the app forces a decision here. + */ +const UNCOVERED: { pattern: string; reason: string }[] = [ + { + pattern: "/signin", + reason: + "the sign-in interstitial forwards itself to Keycloak about two seconds " + + "after it renders — that IS the feature — so it cannot hold still for a " + + "full-page screenshot, a dozen geometry probes and a scroll to the end of " + + "the document. Every one of those checks would race the navigation and " + + "fail (or, worse, pass against a half-unloaded page). Its render is " + + "asserted with JavaScript disabled, where it does hold still, by " + + "tests/smoke/23-login-destination.spec.ts — including a 360px visit with " + + "header, main and footer present and no sideways overflow.", + }, +] + +// ─── The sweep ─────────────────────────────────────────────────────────────── + +async function sweep(page: Page, name: string, screenshot?: string) { + await page.waitForLoadState("networkidle").catch(() => {}) + // Evidence first, so it exists even when a check below fails. + if (screenshot) { + await page + .screenshot({ + path: path.join(SHOTS, `${screenshot}.png`), + fullPage: true, + }) + .catch(() => {}) + } + + // Without a visible <main>, every content check below passes vacuously. + await expect( + page.locator("main"), + `${name}: no <main> rendered`, + ).toBeVisible() + + // Same argument for the footer, and it is not hypothetical: expectNoOverlap + // and expectNoClippedText below RETURN EARLY when their scope is missing, so + // the two "footer" iterations were measuring nothing at all on the 37 routes + // that had no footer — the `(app)` group's layout never mounted AppFooter + // after the route split. Presence is asserted here, before anything claims to + // have inspected it; expectFooterOperable further down adds the hit test. + await expect( + page.locator("footer"), + `${name}: no <footer> rendered — the footer is the only inbound link to ` + + `/privacy, /terms and /about, and the chrome checks below silently skip ` + + `a scope that is absent`, + ).toBeVisible() + + await expectFitsViewport(page, name) + + // The chrome, held to the strict contract (nothing may overlap or truncate). + for (const scope of ["header", "footer", BANNER]) { + await expectNoOverlap(page, scope, name) + await expectNoClippedText(page, scope, name) + } + + // The page's own content. Two explicit relaxations, documented in + // helpers/reflow.ts: full geometric containment is deliberate layering + // (badges on covers, icons in inputs), and an ellipsis on an arbitrarily + // long user string — or text inside an overflow-x:auto scroller — is a + // design decision, not eaten text. Partial intersection and bare clipping + // still fail. + await expectNoOverlap(page, "main", name, { skipGeometricContainment: true }) + await expectNoClippedText(page, "main", name, { + allowEllipsis: true, + allowInsideHorizontalScroller: true, + }) + + // The footer's four links, hit-tested at the bottom of the document — the + // only place they ever are. "Present" and "clickable" came apart twice: the + // consent banner covered these exact links at every width while it was + // `fixed`, and a viewport-anchored sidebar can cover them at any scroll + // position. Scrolls and restores, like the check below it. + await expectFooterOperable(page, name) + + // LAST, because it scrolls (and puts the page back). The checks above are + // taken at the top of the page; this one is the only claim that depends on + // where the document ends. + // + // Route coverage is the point of running it here rather than only in + // chrome-reflow: whether a page trips over a banner drawn on top of it + // depends entirely on where that page's controls sit, which is why one suite + // passed the same wiring the journey died on. 42 routes × 2 widths asks the + // question of every surface in the app. + await expectConsentBannerClearsContent(page, name) +} + +const PERSONA_ORDER: Who[] = ["anon", "bob", "alice", "admin"] + +for (const vp of VIEWPORTS) { + test.describe(`${vp.width}px`, () => { + test.use({ viewport: vp }) + + for (const persona of PERSONA_ORDER) { + const visits = VISITS.filter((v) => v.persona === persona) + if (visits.length === 0) continue + + test.describe(persona, () => { + test.use({ + storageState: + persona === "anon" + ? { cookies: [], origins: [] } + : storageStatePath(persona), + }) + + for (const v of visits) { + test(v.pattern, async ({ page }) => { + const target = v.path() + const name = `${vp.width}px ${v.pattern} as ${persona}${v.note ? ` (${v.note})` : ""}` + + const resp = await page.goto(target) + expect( + resp?.status() ?? 0, + `${name}: ${target} answered ${resp?.status()} — an error page proves nothing about this route's layout; fix the reason it errors or declare the route UNCOVERED with that reason`, + ).toBeLessThan(400) + + await sweep( + page, + name, + vp.width === 360 + ? `${v.pattern.replace(/\W+/g, "-").replace(/^-|-$/g, "") || "home"}--${persona}` + : undefined, + ) + }) + } + }) + } + }) +} + +// ─── Coverage: the sweep visits what the app actually has ──────────────────── + +test("every route is swept or explicitly declared uncovered", () => { + const routes = enumerateRoutes(ROUTES_DIR).sort() + expect( + routes.length, + `route enumeration found ${routes.length} routes under ${ROUTES_DIR} — an empty result means the sweep is pointed at the wrong tree`, + ).toBeGreaterThan(0) + + const visited = new Set(VISITS.map((v) => v.pattern)) + const declared = new Map(UNCOVERED.map((u) => [u.pattern, u.reason])) + + const unaccounted = routes.filter((r) => !visited.has(r) && !declared.has(r)) + expect( + unaccounted, + "routes the sweep neither visits nor declares uncovered — add a Visit " + + "(with a real id source) or an UNCOVERED entry with the reason:\n " + + unaccounted.join("\n "), + ).toEqual([]) + + // Stale entries are how a table rots into asserting nothing. + const routeSet = new Set(routes) + const staleVisits = [...visited].filter((p) => !routeSet.has(p)) + expect( + staleVisits, + `sweep entries for routes that no longer exist: ${staleVisits.join(", ")}`, + ).toEqual([]) + const staleDeclared = [...declared.keys()].filter((p) => !routeSet.has(p)) + expect( + staleDeclared, + `UNCOVERED entries for routes that no longer exist: ${staleDeclared.join(", ")}`, + ).toEqual([]) + const both = [...declared.keys()].filter((p) => visited.has(p)) + expect( + both, + `routes both visited and declared uncovered — delete the stale declaration: ${both.join(", ")}`, + ).toEqual([]) +}) diff --git a/.claude/skills/hackathon-e2e/tests/mobile/responsive.spec.ts b/.claude/skills/hackathon-e2e/tests/mobile/responsive.spec.ts new file mode 100644 index 00000000..2cdfa588 --- /dev/null +++ b/.claude/skills/hackathon-e2e/tests/mobile/responsive.spec.ts @@ -0,0 +1,137 @@ +import { test, expect, type Page } from "@playwright/test" +import fs from "node:fs" +import path from "node:path" +import { PERSONAS } from "../../personas.js" +import { storageStatePath } from "../../helpers/state.js" + +// Smartphone battery (390×844): data-agnostic responsive checks over every +// surface, so it runs against the seeded smoke fixture AND any frozen +// journey state. Per page: no horizontal overflow, the header renders, and a +// full-page screenshot lands in .artifacts/mobile/ for visual review. + +const SHOTS = ".artifacts/mobile" +fs.mkdirSync(SHOTS, { recursive: true }) + +/** Horizontal overflow is the cardinal mobile sin: assert none (1px slack), + * and name the widest offending elements so the failure is self-diagnosing. */ +async function expectFitsViewport(page: Page, name: string) { + const { overflow, offenders } = await page.evaluate(() => { + const limit = document.documentElement.clientWidth + 1 + const offenders = Array.from(document.querySelectorAll("*")) + .map((el) => ({ el, right: el.getBoundingClientRect().right })) + .filter(({ right }) => right > limit) + .sort((a, b) => b.right - a.right) + .slice(0, 5) + .map(({ el, right }) => { + const cls = String(el.className).split(" ").slice(0, 4).join(".") + return `${el.tagName.toLowerCase()}${cls ? "." + cls : ""} right=${Math.round(right)}` + }) + return { + overflow: + document.documentElement.scrollWidth - + document.documentElement.clientWidth, + offenders, + } + }) + expect( + overflow, + `${name} overflows horizontally by ${overflow}px; widest: ${offenders.join(" | ")}`, + ).toBeLessThanOrEqual(1) +} + +/** Every <img> must actually load: naturalWidth 0 means a broken asset. */ +async function expectImagesRender(page: Page, name: string) { + const broken = await page.evaluate(() => + Array.from(document.querySelectorAll("img")) + .filter((img) => img.complete && img.naturalWidth === 0) + .map((img) => img.getAttribute("src") ?? "(no src)"), + ) + expect( + broken, + `${name} has broken images: ${broken.join(", ")}`, + ).toHaveLength(0) +} + +async function snap(page: Page, name: string) { + await page.waitForLoadState("networkidle").catch(() => {}) + // Screenshot FIRST so the visual evidence exists even when checks fail. + await page.screenshot({ + path: path.join(SHOTS, `${name}.png`), + fullPage: true, + }) + await expectFitsViewport(page, name) + await expectImagesRender(page, name) +} + +test.describe("anonymous surfaces", () => { + test("public home", async ({ page }) => { + await page.goto("/") + await expect(page.locator("header")).toBeVisible() + await snap(page, "01-public-home") + }) + + test("public event page", async ({ page }) => { + await page.goto("/") + const link = page.locator('a[href*="/hackathon/"]').first() + test.skip((await link.count()) === 0, "no public hackathon listed") + await link.click() + await page.waitForURL(/\/hackathon\//) + await snap(page, "02-public-event") + }) +}) + +test.describe("member surfaces (bob)", () => { + test.use({ storageState: storageStatePath(PERSONAS.bob.key) }) + + test("dashboard", async ({ page }) => { + await page.goto("/dashboard") + await expect( + page.getByRole("heading", { name: /Welcome back/ }), + ).toBeVisible() + await snap(page, "03-dashboard") + }) + + // The member spine, as `memberNav` lists it. "proposals" moved under + // "projects" — it is one person's own pending ideas, where "projects" is the + // event's approved list, and the two are separate pages now. Voting joins the + // list because it is a participant surface: the ballot is theirs to cast. + for (const tab of [ + "overview", + "teams", + "projects", + "projects/proposals", + "timeline", + "submissions", + "participants", + "voting", + ]) { + test(`member ${tab}`, async ({ page }) => { + await page.goto("/dashboard") + // Data-agnostic: enter the first hackathon bob is a member of. + const row = page + .locator("section") + .filter({ has: page.getByRole("heading", { name: "Your hackathons" }) }) + .locator('a[href*="/my/hackathon/"]') + .first() + test.skip((await row.count()) === 0, "bob is in no hackathon here") + await row.click() + await page.waitForURL(/\/my\/hackathon\/[^/]+\//) + const base = page + .url() + .replace(/\/my\/hackathon\/([^/]+)\/.*$/, "/my/hackathon/$1") + const resp = await page.goto(`${base}/${tab}`) + test.skip((resp?.status() ?? 500) >= 400, `${tab} not reachable for bob`) + await snap(page, `04-member-${tab.replace("/", "-")}`) + }) + } +}) + +test.describe("admin surfaces", () => { + test.use({ storageState: storageStatePath(PERSONAS.admin.key) }) + + test("manage users", async ({ page }) => { + await page.goto("/manage/users") + await expect(page.getByRole("heading", { name: "Users" })).toBeVisible() + await snap(page, "05-manage-users") + }) +}) diff --git a/.claude/skills/hackathon-e2e/tests/openreplay/capture.ts b/.claude/skills/hackathon-e2e/tests/openreplay/capture.ts new file mode 100644 index 00000000..694a48a5 --- /dev/null +++ b/.claude/skills/hackathon-e2e/tests/openreplay/capture.ts @@ -0,0 +1,293 @@ +import fs from "node:fs" +import path from "node:path" +import zlib from "node:zlib" +import type { BrowserContext, Page, Request } from "@playwright/test" +import { SKILL_DIR } from "../../helpers/state.js" + +/* + * Shared plumbing for the session-replay specs: read the replay config, + * intercept the tracker's ingest traffic, and grep the raw bytes. + * + * Not a `.spec.ts`, so the `openreplay` project's testMatch never picks it up + * as a suite of its own. + */ + +export const ARTIFACTS = path.join(SKILL_DIR, ".artifacts", "openreplay") +export const FRONTEND = path.join( + SKILL_DIR, + "..", + "..", + "..", + "components", + "frontend", +) + +/** Must match REPLAY_CONSENT_COOKIE in $lib/utils/replayConsent. */ +export const CONSENT_COOKIE = "hackagon_replay_consent" + +export type ReplayConfig = { ingestPoint: string; projectKey: string } + +const CONFIG_DIR = path.join(FRONTEND, "data", "test", "config") + +function read(file: string): string | null { + try { + return fs.readFileSync(file, "utf8") + } catch { + return null + } +} + +/** + * The scalar entries of one top-level YAML block, or null when there is no + * such block. + * + * Deliberately not a YAML parser: this package has no yaml dependency, the + * block is flat, and it is written by one script + * (openreplay-stack/scripts/wire-frontend.sh) whose shape is known. The block + * ends where the next column-0 key begins, which is exactly the rule + * `.claude/skills/lib/config-overlay.sh` writes to. + */ +function blockScalars( + text: string | null, + key: string, +): Record<string, string> | null { + if (text === null) return null + const lines = text.split(/\r?\n/) + const start = lines.findIndex((l) => l.startsWith(`${key}:`)) + if (start === -1) return null + + const out: Record<string, string> = {} + for (let i = start + 1; i < lines.length; i++) { + if (/^[^\s#]/.test(lines[i])) break + const m = lines[i].match( + /^\s+([A-Za-z_][A-Za-z0-9_]*):\s*"?([^"#]*?)"?\s*$/, + ) + if (m) out[m[1]] = m[2] + } + + return out +} + +/** + * The frontend's replay block, or null when replay is off. + * + * Read out of the config files rather than out of the page, because every spec + * in this folder self-skips when the rig is not wired and a skip decision has + * to be available before a browser exists. + * + * READS THE MERGED VIEW, config.yaml overlaid with config.local.yaml, because + * that is what the server reads (`mergeConfig` in + * components/frontend/src/lib/server/settings.ts). Wiring writes the gitignored + * OVERLAY — the tracked config.yaml must never carry a `*.trycloudflare.com` + * ingest hostname that dies in a few hours — so a reader that only looked at + * config.yaml would find `enabled` absent on a perfectly well-wired machine, + * every spec here would `test.skip`, and the suite would report green having + * verified nothing about masking, consent or Do Not Track. A skip is the one + * outcome this folder must never reach by accident. + */ +export function replayConfig(): ReplayConfig | null { + const base = blockScalars( + read(path.join(CONFIG_DIR, "config.yaml")), + "replay", + ) + const local = blockScalars( + read(path.join(CONFIG_DIR, "config.local.yaml")), + "replay", + ) + if (base === null && local === null) return null + + // Same precedence as the loader: the overlay wins key by key, so an overlay + // saying `enabled: false` turns off a base that says true. + const replay = { ...(base ?? {}), ...(local ?? {}) } + if (replay.enabled !== "true") return null + const { ingestPoint, projectKey } = replay + + return ingestPoint && projectKey ? { ingestPoint, projectKey } : null +} + +/** + * Collect every byte the page posts to the ingest endpoint. + * + * `postDataBuffer()` and not `postData()`: the tracker's batches are a binary + * message stream, and reading them as a string would re-encode invalid UTF-8 + * and could destroy the very bytes we are searching for. + */ +export function captureIngest(page: Page): { chunks: Buffer[] } { + const chunks: Buffer[] = [] + const onRequest = (req: Request) => { + if (!req.url().includes("/ingest")) return + const body = req.postDataBuffer() + if (body) chunks.push(body) + } + page.on("request", onRequest) + + return { chunks } +} + +/** + * The session ids OpenReplay minted for this page, in the order it minted + * them. + * + * Read out of the `/v1/web/start` RESPONSE, which is the only place the id + * appears on the browser's side of the wire — the tracker keeps it in a + * sessionStorage key whose name has changed between versions, and + * `tracker.getSessionID()` needs a handle on the instance, which the app + * deliberately does not expose. Needed by the playability check, because + * "is there a recording" is a question about one specific id and the sessions + * list of a shared instance is full of other people's. + */ +export function captureSessionIds(page: Page): { ids: string[] } { + const ids: string[] = [] + page.on("response", (res) => { + if (!res.url().includes("/v1/web/start")) return + void res + .json() + .then((body: { sessionID?: string }) => { + if (body.sessionID && !ids.includes(body.sessionID)) + ids.push(body.sessionID) + }) + .catch(() => {}) + }) + + return { ids } +} + +export type IngestPost = { url: string; dataType: string; body: Buffer } + +/** + * Every ingest POST with its URL and `DataType` header kept alongside the + * bytes. + * + * `captureIngest` concatenates, which is right for a grep and useless for a + * question about batch STRUCTURE: the tracker sends the first DOM snapshot as + * one `visual` batch that is two batches glued together, and tells the server + * where the seam is with a `?split=` query parameter. Whether that parameter + * is present is a property of the request, not of the bytes, and the + * concatenated buffer has thrown it away. + */ +export function captureIngestPosts(page: Page): { posts: IngestPost[] } { + const posts: IngestPost[] = [] + page.on("request", (req) => { + if (!req.url().includes("/ingest")) return + const body = req.postDataBuffer() + if (!body) return + posts.push({ + url: req.url(), + dataType: req.headers()["datatype"] ?? "", + body, + }) + }) + + return { posts } +} + +/** + * Offsets of every BatchMetadata (message type 81) in one batch. + * + * The wire format is a varint type followed, for every type EXCEPT 81, by a + * 3-byte little-endian body size (`MessageHasSize(t) { return t != 81 }` in + * backend/pkg/messages/iterator.go). So the whole batch can be walked without + * decoding any bodies, and a type 81 found anywhere but offset 0 is the + * "batch meta not at the start of batch" the backend rejects the entire batch + * for. + */ +export function batchMetaOffsets(batch: Buffer): number[] { + const found: number[] = [] + let i = 0 + while (i < batch.length) { + const start = i + let type = 0 + let shift = 1 + for (;;) { + if (i >= batch.length) return found + const b = batch[i++] + type += (b & 0x7f) * shift + if ((b & 0x80) === 0) break + shift *= 128 + } + if (type === 81) { + found.push(start) + // The header is uint,uint,uint,int,string — walk it rather than guess. + for (let field = 0; field < 4; field++) { + while (i < batch.length && (batch[i++] & 0x80) !== 0) { + /* varint continuation */ + } + } + let len = 0 + let s = 1 + for (;;) { + if (i >= batch.length) return found + const b = batch[i++] + len += (b & 0x7f) * s + if ((b & 0x80) === 0) break + s *= 128 + } + i += len + continue + } + if (i + 3 > batch.length) return found + const size = batch[i] | (batch[i + 1] << 8) | (batch[i + 2] << 16) + i += 3 + size + } + + return found +} + +export function writeCapture(name: string, chunks: Buffer[]): string { + fs.mkdirSync(ARTIFACTS, { recursive: true }) + const file = path.join(ARTIFACTS, `${name}.bin`) + fs.writeFileSync(file, Buffer.concat(chunks)) + + return file +} + +/** + * Case-sensitive raw-byte search — the sentinels are pure ASCII. + * + * Every chunk is searched raw AND, when it turns out to be gzip, inflated + * first: the tracker compresses a batch once it exceeds ~24 kB, and a grep + * that only reads the raw bytes would report "not found" for a sentinel that + * was transmitted perfectly well, just deflated. Exactly the false green this + * folder is built to avoid, in the direction that matters. + */ +export function contains(chunks: Buffer[], needle: string): boolean { + const target = Buffer.from(needle, "utf8") + if (Buffer.concat(chunks).includes(target)) return true + + return chunks.some((c) => { + try { + return zlib.gunzipSync(c).includes(target) + } catch { + return false + } + }) +} + +export function bytes(chunks: Buffer[]): number { + return Buffer.concat(chunks).length +} + +/** + * Pre-set the consent cookie for specs that are about something else. + * + * The consent spec itself never uses this — it clicks the real banner, because + * a test that fakes the mechanism it is verifying proves nothing about it. + * Here it stands in for "this visitor already said yes", which is a + * precondition for the masking spec rather than its subject. + */ +export async function grantConsent( + context: BrowserContext, + baseURL: string, +): Promise<void> { + const url = new URL(baseURL) + await context.addCookies([ + { + name: CONSENT_COOKIE, + value: "granted", + domain: url.hostname, + path: "/", + httpOnly: true, + sameSite: "Lax", + }, + ]) +} diff --git a/.claude/skills/hackathon-e2e/tests/openreplay/consent.spec.ts b/.claude/skills/hackathon-e2e/tests/openreplay/consent.spec.ts new file mode 100644 index 00000000..aeb67bf2 --- /dev/null +++ b/.claude/skills/hackathon-e2e/tests/openreplay/consent.spec.ts @@ -0,0 +1,200 @@ +import { test, expect } from "@playwright/test" +import { storageStatePath } from "../../helpers/state.js" +import { + CONSENT_COOKIE, + bytes, + captureIngest, + contains, + replayConfig, + writeCapture, +} from "./capture.js" + +/* + * Nobody is recorded who did not say yes. + * + * `masking.spec.ts` proves that what IS recorded is masked. This file proves + * the prior question: that a recording happens at all only on the visitor's + * say-so, and that withdrawing stops it. `dnt.spec.ts` covers the browser that + * has said yes but is sending Do Not Track (its own file, because the launch + * preference it needs is a worker-scoped fixture). + * + * THE EVIDENCE IS BYTES ON THE WIRE, NOT AN ASSERTION ABOUT A FLAG. Every test + * here counts the bytes the page posted to the ingest endpoint. "The component + * checked a variable" is a statement about our code; "nothing left the + * browser" is a statement about the visitor, and only the second one is worth + * making. + * + * The shape of the file guards against the failure mode this repo keeps + * finding: a zero-byte capture reads identically whether recording was + * suppressed or the measurement was broken. So the first test does BOTH halves + * — no consent (expect zero), then consent through the real banner (expect + * non-zero) — in one browser context against one interception. That non-zero + * half is what licenses every zero elsewhere in this file. + * + * Needs `replay.enabled: true` in the frontend config pointed at a live + * OpenReplay (`.claude/skills/openreplay-stack`); self-skips otherwise. + */ + +const REPLAY = replayConfig() + +/** How long to wait before concluding that nothing is being recorded. */ +const SILENCE_MS = 8_000 + +/** The consent cookie, as a browser would hold it. */ +const grantedCookie = { + name: CONSENT_COOKIE, + value: "granted", + domain: "localhost", + path: "/", + httpOnly: true, + sameSite: "Lax" as const, +} + +test.describe("session replay consent", () => { + test.skip( + !REPLAY, + "replay.enabled is not true in the frontend config — bring up .claude/skills/openreplay-stack and wire it first", + ) + test.describe.configure({ mode: "serial" }) + + test("records nothing until the banner is answered, and records once it is", async ({ + page, + context, + }) => { + test.setTimeout(120_000) + + // A visitor who has never been here: no cookie, so no decision exists. + // This is the very first page load, which is where "default to not + // recording" has to hold — not "start, then stop if they decline". + await context.clearCookies() + const cap = captureIngest(page) + + await page.goto("/") + await expect( + page.getByRole("region", { name: "Session recording" }), + ).toBeVisible() + + // Move around like a person would. The tracker boots from an idle callback + // after first paint, so this is well past the point at which it would have + // started had it been given anything to start with. + await page.goto("/hackathon") + await page.mouse.move(200, 200) + await page.mouse.click(200, 200) + await page.waitForTimeout(SILENCE_MS) + + expect( + bytes(cap.chunks), + `${bytes(cap.chunks)} bytes reached the ingest endpoint before anyone consented ` + + `(see ${writeCapture("no-consent", cap.chunks)})`, + ).toBe(0) + + // The server never even told the browser where to send anything. Checked + // separately from the byte count because it is the STRUCTURAL claim: not + // "the tracker chose not to start" but "there was nothing to start with". + // Its counterpart — that this key IS in the HTML once consent exists — is + // asserted in `dnt.spec.ts`, so this cannot pass by the key simply never + // appearing in a page at all. + expect( + (await page.content()).includes(REPLAY!.projectKey), + "the page was served the tracker's project key despite no consent", + ).toBe(false) + + // Now say yes, through the actual banner. It is a plain form POST, so this + // also pins that the decision does not depend on hydration having run. + await page.getByRole("button", { name: "Allow recording" }).click() + await expect( + page.getByRole("region", { name: "Session recording" }), + ).toBeHidden() + + // ...and the recording starts. Without this half the zero above would be + // unfalsifiable: it would read the same if the interception were broken, + // or the stack were down, or the page had never loaded. + await expect + .poll(() => bytes(cap.chunks), { + message: + "consent was given and still nothing was recorded — is the OpenReplay stack up and the ingestPoint current?", + timeout: 30_000, + }) + .toBeGreaterThan(0) + + writeCapture("consented", cap.chunks) + }) + + test("the decision is not writable from page scripts", async ({ + page, + context, + }) => { + await context.clearCookies() + await page.goto("/") + await page.getByRole("button", { name: "Allow recording" }).click() + + // httpOnly: a script on the page — ours, or one that got there — must not + // be able to read the decision, and must not be able to grant itself + // permission to record by writing it. + expect( + await page.evaluate(() => document.cookie), + "the replay consent cookie is reachable from JavaScript — it must be httpOnly", + ).not.toContain(CONSENT_COOKIE) + + const cap = captureIngest(page) + await page.goto("/hackathon") + await page.waitForTimeout(3_000) + expect( + contains(cap.chunks, CONSENT_COOKIE), + "the consent cookie was transmitted to the replay service", + ).toBe(false) + }) + + test.describe("withdrawing", () => { + test.use({ storageState: storageStatePath("bob") }) + + test("stops the recording, from /account", async ({ page, context }) => { + test.setTimeout(120_000) + + // Start from consented: this test is about withdrawal, and granting is + // already proved end to end above. + await context.addCookies([grantedCookie]) + + const cap = captureIngest(page) + await page.goto("/account") + + await expect + .poll(() => bytes(cap.chunks), { + message: + "consented, but nothing was recorded — there is nothing to withdraw", + timeout: 30_000, + }) + .toBeGreaterThan(0) + + await expect(page.getByTestId("replay-consent-state")).toContainText( + "Recording is on", + ) + + await page + .getByRole("button", { name: "Stop recording this browser" }) + .click() + await expect(page.getByTestId("replay-consent-state")).toContainText( + "Recording is off", + ) + + // Everything from here on must be silence. The baseline is taken AFTER + // the page has settled: the withdrawal navigates, and the tracker is + // allowed to finish flushing the batch it was holding when the click + // landed. Stopping may finish; starting again may not. + await page.waitForTimeout(2_000) + const settled = bytes(cap.chunks) + + await page.goto("/dashboard") + await page.mouse.move(300, 300) + await page.mouse.click(300, 300) + await page.goto("/account") + await page.waitForTimeout(SILENCE_MS) + + expect( + bytes(cap.chunks) - settled, + `${bytes(cap.chunks) - settled} bytes were recorded AFTER consent was withdrawn ` + + `(see ${writeCapture("withdrawn", cap.chunks)})`, + ).toBe(0) + }) + }) +}) diff --git a/.claude/skills/hackathon-e2e/tests/openreplay/dnt.spec.ts b/.claude/skills/hackathon-e2e/tests/openreplay/dnt.spec.ts new file mode 100644 index 00000000..806d71e2 --- /dev/null +++ b/.claude/skills/hackathon-e2e/tests/openreplay/dnt.spec.ts @@ -0,0 +1,110 @@ +import { test, expect } from "@playwright/test" +import { storageStatePath } from "../../helpers/state.js" +import { + CONSENT_COOKIE, + bytes, + captureIngest, + replayConfig, + writeCapture, +} from "./capture.js" + +/* + * A browser asking not to be tracked is not recorded, even when it has said + * yes. + * + * Do Not Track here is a REAL Firefox preference, not a stubbed property: + * `privacy.donottrackheader.enabled` is set at launch, so `navigator.doNotTrack` + * is "1" for the same reason it would be on a visitor's machine. Stubbing it + * with an init script would test the stub. + * + * Its own FILE and not a describe block inside `consent.spec.ts`: `launchOptions` + * is a WORKER-scoped fixture, and Playwright refuses `test.use` on one inside a + * describe group ("it forces a new worker"). File level is where it is allowed. + * + * CONSENT IS GRANTED HERE ON PURPOSE. With the ask satisfied, DNT is the only + * thing left that can suppress recording, so a zero-byte capture can only mean + * it worked — and the run asserts, positively, that the page really was handed + * the tracker's configuration, so "nothing was recorded" cannot quietly mean + * "nothing was ever offered". + */ + +const REPLAY = replayConfig() + +test.use({ + storageState: storageStatePath("bob"), + launchOptions: { + firefoxUserPrefs: { "privacy.donottrackheader.enabled": true }, + }, +}) + +test.describe("Do Not Track", () => { + test.skip( + !REPLAY, + "replay.enabled is not true in the frontend config — bring up .claude/skills/openreplay-stack and wire it first", + ) + + test("suppresses recording even with consent granted", async ({ + page, + context, + }) => { + test.setTimeout(120_000) + + await context.addCookies([ + { + name: CONSENT_COOKIE, + value: "granted", + domain: "localhost", + path: "/", + httpOnly: true, + sameSite: "Lax", + }, + ]) + + const trackerRequests: string[] = [] + page.on("request", (r) => { + if (/openreplay/i.test(r.url())) trackerRequests.push(r.url()) + }) + const cap = captureIngest(page) + + await page.goto("/dashboard") + + // The pref is what this test rests on, so check it took rather than + // trusting the launch option — a renamed pref would otherwise quietly turn + // this into a test that DNT-off browsers do not record either. + expect( + await page.evaluate(() => navigator.doNotTrack), + "the browser is not actually sending Do Not Track — the launch pref did not take", + ).toBe("1") + + // The consent gate is OPEN: the page really was given the tracker's + // config. Without this the zero below would also be satisfied by the server + // simply not having sent anything, which is a different mechanism — and it + // is what makes the "key absent" assertion in consent.spec.ts mean + // something, since it proves the key is present in the HTML when it should + // be. + expect( + (await page.content()).includes(REPLAY!.projectKey), + "the page was not given the tracker config, so this run proves nothing about DNT", + ).toBe(true) + + await page.goto("/account") + await page.mouse.move(150, 150) + await page.mouse.click(150, 150) + await page.waitForTimeout(8_000) + + expect( + bytes(cap.chunks), + `${bytes(cap.chunks)} bytes were recorded from a browser sending Do Not Track ` + + `(see ${writeCapture("dnt", cap.chunks)})`, + ).toBe(0) + + // ...and the SDK was never even fetched. `respectDoNotTrack` alone would + // have downloaded the chunk and then declined; SessionReplay.svelte checks + // first, so a request that is itself a signal about the visitor is never + // made. + expect( + trackerRequests, + "something was fetched from OpenReplay for a Do Not Track visitor", + ).toEqual([]) + }) +}) diff --git a/.claude/skills/hackathon-e2e/tests/openreplay/masking.spec.ts b/.claude/skills/hackathon-e2e/tests/openreplay/masking.spec.ts new file mode 100644 index 00000000..e7507a0a --- /dev/null +++ b/.claude/skills/hackathon-e2e/tests/openreplay/masking.spec.ts @@ -0,0 +1,329 @@ +import fs from "node:fs" +import path from "node:path" +import { test, expect } from "@playwright/test" +import { storageStatePath } from "../../helpers/state.js" +import { rpcAs, rpcAnonymous } from "../../helpers/api.js" +import { SEED_HACKATHONS } from "../../personas.js" +import { + FRONTEND, + bytes, + captureIngest, + contains, + grantConsent, + replayConfig, + writeCapture, +} from "./capture.js" + +/* + * Does OpenReplay capture what people type into the registration form? + * + * The tracker records the DOM, and its shipped default records input values + * and page text VERBATIM — masking is opt-in per field. On this app that + * default would capture dietary requirements, accessibility needs and + * affiliations, which is exactly what the backend RPC journal's allowlist + * exists to keep off disk. `SessionReplay.svelte` therefore configures + * default-deny masking; this spec is the proof that the configuration works, + * because "we set the option" is not evidence. + * + * THE TRAP THIS SPEC IS BUILT AROUND. A grep for a sentinel returning zero + * hits proves nothing on its own: it reads identically whether the string was + * masked, or nothing was ever captured, or the payload is compressed, or the + * interception missed the tracker's requests entirely. So the run that + * matters is preceded by a CONTROL run over the same interception, on the + * same origin, against the same ingest endpoint, with masking deliberately + * OFF. If the control's sentinel is found and the real one is not, the + * difference is the masking and nothing else. + * + * The control does NOT weaken the app: it loads the tracker itself, into a + * throwaway page fulfilled by `page.route` on the app's origin, and the + * production component is never given an "unmask" switch to forget about. + * + * CONSENT IS A PRECONDITION HERE, NOT THE SUBJECT. Recording now requires the + * visitor's say-so (`consent.spec.ts` is where that is proved), so every test + * below that needs a running tracker grants it first. Without that these tests + * would pass for the worst possible reason — nothing recorded, so nothing + * leaked — which is the exact false green the control exists to rule out. + * + * Needs `replay.enabled: true` in the frontend config pointed at a live + * OpenReplay (`.claude/skills/openreplay-stack`); it self-skips otherwise, so + * it never runs as part of smoke or journey. + */ + +/** Typed into the real registration form. Must NEVER reach the wire. */ +const SENTINEL = "ZZQX-SENTINEL-7731" +/** Typed into the unmasked control page. MUST reach the wire. */ +const CONTROL_SENTINEL = "ZZQX-CONTROL-4409" + +const REPLAY = replayConfig() + +test.describe("OpenReplay masking", () => { + test.skip( + !REPLAY, + "replay.enabled is not true in the frontend config — bring up .claude/skills/openreplay-stack and wire it first", + ) + test.describe.configure({ mode: "serial" }) + + test("CONTROL: an unmasked tracker does put typed text on the wire", async ({ + page, + }) => { + test.setTimeout(120_000) + + // The tracker's own ESM bundle, injected into the page. Same library, same + // version, same ingest — only the options differ. + const trackerSrc = fs.readFileSync( + path.join(FRONTEND, "node_modules/@openreplay/tracker/dist/lib/index.js"), + "utf8", + ) + + // A throwaway page on the APP'S OWN ORIGIN. Origin matters: the tracker + // keeps its session in localStorage, so a data: or about:blank page would + // fail for a reason that has nothing to do with masking. + await page.route("**/__replay_control__", (route) => + route.fulfill({ + status: 200, + contentType: "text/html", + body: `<!doctype html><html><body> + <input id="free-text" name="field:diet" /> + </body></html>`, + }), + ) + + const cap = captureIngest(page) + await page.goto("/__replay_control__") + + await page.evaluate( + async ([src, key, ingest]) => { + const blob = new Blob([src], { type: "text/javascript" }) + const mod = await import(/* @vite-ignore */ URL.createObjectURL(blob)) + const t = new mod.default({ + projectKey: key, + ingestPoint: ingest, + __DISABLE_SECURE_MODE: true, + // Masking OFF, on purpose and only here. + privateMode: false, + defaultInputMode: 0, // InputMode.Plain + obscureInputNumbers: false, + obscureInputEmails: false, + obscureInputDates: false, + obscureTextNumbers: false, + obscureTextEmails: false, + }) + await t.start() + ;(window as unknown as { __t: unknown }).__t = t + }, + [trackerSrc, REPLAY!.projectKey, REPLAY!.ingestPoint] as const, + ) + + await page.locator("#free-text").fill(CONTROL_SENTINEL) + await page.locator("#free-text").blur() + // Batches commit on a ~30ms ticker; give the flush room, then force it. + await page.waitForTimeout(3_000) + await page.evaluate(() => + ( + window as unknown as { __t: { forceFlushBatch: () => void } } + ).__t.forceFlushBatch(), + ) + await page.waitForTimeout(3_000) + + const file = writeCapture("control", cap.chunks) + console.log(`control capture: ${bytes(cap.chunks)} bytes -> ${file}`) + + // Without this the whole spec is theatre: it proves the interception sees + // the tracker's traffic AND that a plain ASCII string survives readable in + // it, so a later zero-hit grep means masking and not measurement failure. + expect( + bytes(cap.chunks), + "the control run captured no ingest traffic at all", + ).toBeGreaterThan(0) + expect( + contains(cap.chunks, CONTROL_SENTINEL), + "an UNMASKED tracker did not transmit the typed text — the capture method is broken, not the masking", + ).toBe(true) + + // The control also demonstrates the URL leak the production component + // closes: with default options the tracker stamps the FULL page URL onto + // every URL-based DOM message as its base href. Asserting it here is what + // makes the "no path" assertion in the masked run below mean "we closed + // it" rather than "this tracker never sent paths anyway". + expect( + contains(cap.chunks, "/__replay_control__"), + "the unmasked control did not transmit the page path — the URL assertions below would prove nothing", + ).toBe(true) + }) + + test.describe("the real registration form", () => { + test.use({ storageState: storageStatePath("bob") }) + + test("does not transmit what was typed into it", async ({ + page, + context, + baseURL, + }) => { + test.setTimeout(120_000) + + // The seed fixture defines no registration form, so give h1 one. `diet` + // is the field the redaction policy is written for. + const listed = rpcAnonymous("hackathon.HackathonService/List", { + visibilityFilter: 1, + }) + expect(listed.ok, listed.raw).toBe(true) + const h1 = ( + listed.data.hackathons as { id: string; name: string }[] + ).find((h) => h.name === SEED_HACKATHONS.h1.name) + expect( + h1, + "seed hackathon h1 not found — did `just db::seed` run?", + ).toBeTruthy() + + const set = await rpcAs( + "alice", + "hackathon.ConfigService/SetRegistrationForm", + { + hackathonId: h1!.id, + fields: [ + { + key: "diet", + label: "Dietary requirements", + type: "text", + required: false, + }, + ], + consents: [ + { + key: "conduct", + label: "I accept the Code of Conduct", + required: true, + }, + ], + }, + ) + expect(set.ok, set.raw).toBe(true) + + // Recording is opt-in now. Grant it, or the tracker never starts and + // every assertion below passes because nothing was captured. + await grantConsent(context, baseURL ?? "http://localhost:8081") + + const cap = captureIngest(page) + + await page.goto(`/register/${h1!.id}`) + await expect( + page.getByRole("heading", { name: /Registration|Your registration/ }), + ).toBeVisible() + + // WAIT FOR RECORDING BEFORE TYPING. The tracker boots from an idle + // callback, so typing straight after load races it — and the first + // version of this test lost that race every time: it typed, submitted, + // and the tracker's first DOM snapshot was of the "thanks, your answers + // are in" screen. The form, the input and the sentinel were never in the + // captured DOM at all, so "sentinel absent" was true for the one reason + // that proves nothing. Nothing in the output said so. + await expect + .poll(() => cap.chunks.length, { + message: + "the tracker never sent anything — is replay.enabled on, was consent granted, and did the frontend restart since?", + timeout: 30_000, + }) + .toBeGreaterThan(0) + + await page.locator('input[name="field:diet"]').fill(SENTINEL) + await page.locator('input[name="consent:conduct"]').check() + await page.locator('input[name="field:diet"]').blur() + // Let the typing reach the wire before anything navigates away. + await page.waitForTimeout(5_000) + + const file = writeCapture("masked", cap.chunks) + console.log(`masked capture: ${bytes(cap.chunks)} bytes -> ${file}`) + + expect( + bytes(cap.chunks), + "no ingest traffic captured — the tracker never started", + ).toBeGreaterThan(0) + + // Positive control WITHIN this run. Attribute values other than href, + // alt and placeholder are transmitted verbatim (they are structure, not + // prose), so the input's own `name` proves THE ELEMENT WE TYPED INTO is + // in these very bytes. That is what makes the next assertion mean + // "the value was masked" rather than "the field was never recorded". + expect( + contains(cap.chunks, "field:diet"), + "the input we typed into is not in the capture — this run recorded some other DOM", + ).toBe(true) + + // The assertion the whole file exists for. + expect( + contains(cap.chunks, SENTINEL), + `the sentinel typed into the registration form WAS transmitted to OpenReplay (see ${file})`, + ).toBe(false) + + // Found by reading a capture rather than by reasoning about the options: + // the tracker stars TEXT nodes but sends ATTRIBUTE values as they are, + // so `title={userName}` on the NavBar monogram shipped the signed-in + // person's full name in clear while the same name one element away + // arrived as asterisks. The attribute is gone; this keeps it gone, and + // will catch the next `title=`/`aria-label=` that carries personal data. + expect( + contains(cap.chunks, "Bob Henderson"), + "the signed-in user's display name was transmitted — something is putting personal data in an ATTRIBUTE, which no masking option covers", + ).toBe(false) + + // NO URL EVER CARRIES A PATH. Also found by reading a capture: privateMode + // wipes the page LOCATION, but the tracker stamps `document.baseURI` — the + // full current URL — onto every URL-based DOM message so the replayer can + // resolve relative assets, and those are not sanitized. The bytes held + // `http://localhost:8081/register/<uuid>` dozens of times. + // + // Route ids would have been arguable. `/invite/<token>` is not: that + // token IS the credential, so a recording of somebody opening their + // invitation would contain a working key to a private event. + // `resourceBaseHref` pins the base to the origin; this is the proof. + expect( + contains(cap.chunks, `/register/${h1!.id}`), + `the page path (with the hackathon id) was transmitted to OpenReplay (see ${file})`, + ).toBe(false) + expect( + contains(cap.chunks, "/register/"), + `a page path was transmitted to OpenReplay (see ${file})`, + ).toBe(false) + }) + }) + + // A debugging tool that can take the product down is worse than no debugging + // tool. The realistic failure is not "OpenReplay is off" — that path is the + // default and every other suite exercises it — but "OpenReplay is CONFIGURED + // and unreachable": the quick tunnel died, the stack was stopped, an ad + // blocker ate the request. Aborting the ingest route reproduces exactly that + // without touching the config, which is why this is a test rather than + // something somebody remembers to try by hand. + test.describe("a dead ingest endpoint", () => { + test.use({ storageState: storageStatePath("bob") }) + + test("does not take the app down with it", async ({ + page, + context, + baseURL, + }) => { + const failures: string[] = [] + page.on("pageerror", (e) => failures.push(String(e))) + + // Consent granted, or the tracker would never try to reach the endpoint + // this test kills, and "an unreachable ingest does not break the page" + // would be true about a page that never contacted it. + await grantConsent(context, baseURL ?? "http://localhost:8081") + await page.route("**/ingest/**", (route) => + route.abort("connectionrefused"), + ) + + await page.goto("/dashboard") + await expect(page.getByRole("heading").first()).toBeVisible() + + // ...and still navigable afterwards, not merely first-paint intact. + await page.goto("/account") + await expect(page.getByRole("heading").first()).toBeVisible() + + expect( + failures, + "an unreachable ingest endpoint raised an uncaught error in the page", + ).toEqual([]) + }) + }) +}) diff --git a/.claude/skills/hackathon-e2e/tests/openreplay/playable.spec.ts b/.claude/skills/hackathon-e2e/tests/openreplay/playable.spec.ts new file mode 100644 index 00000000..0b469d4f --- /dev/null +++ b/.claude/skills/hackathon-e2e/tests/openreplay/playable.spec.ts @@ -0,0 +1,329 @@ +import { test, expect } from "@playwright/test" +import { storageStatePath } from "../../helpers/state.js" +import { rpcAs, rpcAnonymous } from "../../helpers/api.js" +import { SEED_HACKATHONS } from "../../personas.js" +import { + batchMetaOffsets, + bytes, + captureIngest, + captureIngestPosts, + captureSessionIds, + contains, + grantConsent, + replayConfig, + writeCapture, +} from "./capture.js" +import { adminSession, mobPlaintext, waitForMob } from "./replayApi.js" + +/* + * A recording has to be WATCHABLE. Nothing here asserted that, and a totally + * broken pipeline reported green because of it. + * + * Every other spec in this folder measures bytes leaving the browser. That is + * the right measurement for a privacy claim — "nothing left" is a statement + * about the visitor, "the component checked a flag" is a statement about our + * code — and it says nothing whatsoever about whether the far end could use + * what arrived. It could not. Bytes reached the ingest endpoint, it answered + * 200, and every session was unplayable: no recording file was ever written, + * and the player spun forever on a session that was right there in the list. + * Three separate faults produced that, and not one of them could turn a spec + * in this folder red, because none of them are on the browser's side of the + * wire. + * + * So this file asks the far end. It records a real session, reads the session + * id off the tracker's own start response, and waits for the RECORDING to + * exist and come back through the same two hops the player uses. + * + * The three faults, named because each is invisible from here in a different + * way and this is now the assertion that catches all of them: + * + * - The batches were rejected. `Iterate` (backend/pkg/messages/iterator.go) + * RETURNS on the first parse error, so a batch its reader will not accept + * is discarded WHOLE — including the one carrying the first DOM snapshot. + * The symptom is a `broken batch(es)` line in `docker logs ender` and + * nothing at all on our side. + * - The `sink` container, which writes the raw session file every later stage + * reads, was simply not running. `docker compose ps` looks healthy when a + * service is absent rather than unhealthy. + * - The object store answered `NoSuchBucket` to every request, PUT included, + * for a bucket that was right there on its disk. Uploads failed silently; + * restarting it fixed it. + * + * The first test asserts the batch SHAPE on the way out, because a red "no + * recording after four minutes" says the pipeline is broken and not where. + * The second records one session, proves it became a recording, and proves the + * invite token is in neither the wire nor the stored file — the two properties + * `SessionReplay.svelte` has to keep at the same time, asserted on the same + * session, because keeping either one alone is easy. + * + * Needs `replay.enabled: true` in the frontend config pointed at a live + * OpenReplay (`.claude/skills/openreplay-stack`) AND that stack's admin + * credentials in its gitignored `.secrets.env` — reading a stored recording is + * an authenticated operation. Self-skips without the config, like every spec + * here; does NOT self-skip without the credentials, because a missing password + * file is a broken rig, not a reason to stop asserting. + */ + +const REPLAY = replayConfig() + +test.describe("a recorded session is playable", () => { + test.skip( + !REPLAY, + "replay.enabled is not true in the frontend config — bring up .claude/skills/openreplay-stack and wire it first", + ) + test.describe.configure({ mode: "serial" }) + + test.describe("the batches on the way out", () => { + test.use({ storageState: storageStatePath("bob") }) + + test("each begin with their own batch header", async ({ + page, + context, + baseURL, + }) => { + test.setTimeout(120_000) + await grantConsent(context, baseURL ?? "http://localhost:8081") + + const cap = captureIngest(page) + const detailed = captureIngestPosts(page) + + await page.goto("/dashboard") + await expect(page.getByRole("heading").first()).toBeVisible() + await expect + .poll(() => cap.chunks.length, { + message: + "the tracker never sent anything — is replay.enabled on, was consent granted, and did the frontend restart since?", + timeout: 30_000, + }) + .toBeGreaterThan(0) + + // Move around so the DOM mutates and the asset stream has content: the + // defect only appears in a batch carrying BOTH the player stream and the + // asset stream, which is the batch holding the first DOM snapshot of any + // page with stylesheets. + await page.mouse.wheel(0, 400) + await page.waitForTimeout(6_000) + + const file = writeCapture("playable-shape", cap.chunks) + expect( + bytes(cap.chunks), + "no ingest traffic captured — the tracker never started", + ).toBeGreaterThan(0) + + // Positive control. If nothing captured is a batch header at all then + // the walker cannot read this traffic, and "no misplaced header" would + // be true of any buffer whatsoever — an empty one included. + const wellFormed = detailed.posts.filter( + (p) => batchMetaOffsets(p.body)[0] === 0, + ) + expect( + wellFormed.length, + `no captured batch begins with a BatchMetadata — the batch walker cannot read this traffic (see ${file})`, + ).toBeGreaterThan(0) + + // The assertion. A `visual` batch is the player batch and the asset + // batch glued together and legitimately holds two headers — but only + // because the request tells the ingest endpoint where the seam is, and + // it splits the body there before queueing the halves. Without that + // parameter the backend parses one buffer, meets the second header, + // answers "batch meta not at the start of batch", and drops the batch + // that carried the DOM. + const misplaced = detailed.posts + .map((p) => ({ ...p, metas: batchMetaOffsets(p.body) })) + .filter((p) => p.metas.some((o) => o !== 0)) + .filter((p) => { + const split = new URL(p.url).searchParams.get("split") + + return !( + split !== null && + p.metas.length === 2 && + p.metas[1] === Number(split) + ) + }) + .map( + (p) => + `${p.dataType || "?"} batch, headers at [${p.metas.join(", ")}], split=${ + new URL(p.url).searchParams.get("split") ?? "(absent)" + }, ${p.body.length} bytes`, + ) + + expect( + misplaced, + `a batch carries a BatchMetadata that is neither at its start nor at a declared split — OpenReplay's reader answers "batch meta not at the start of batch" and DISCARDS the whole batch, so the session never becomes playable (see ${file})`, + ).toEqual([]) + }) + }) + + test.describe("an invitation opened by a stranger", () => { + // No storage state: the leak this guards was a FRESH load of an invite + // link, by someone who has not signed in and may not even have an account. + // That page load is a first batch, and the first batch is the one carrying + // the header this is all about. + test.use({ storageState: { cookies: [], origins: [] } }) + + test("becomes a playable recording that does not contain the token", async ({ + page, + context, + baseURL, + }) => { + // ender closes a session on inactivity and storage only uploads after + // that, so the wait is minutes rather than seconds. + test.setTimeout(420_000) + + const admin = await adminSession(REPLAY!.ingestPoint, REPLAY!.projectKey) + expect( + admin, + `could not log in to OpenReplay at ${REPLAY!.ingestPoint} — is the stack up, and does .claude/skills/openreplay-stack/.secrets.env hold its admin credentials? (openreplay-stack/scripts/signup.sh mints them)`, + ).not.toBeNull() + + const listed = rpcAnonymous("hackathon.HackathonService/List", { + visibilityFilter: 1, + }) + expect(listed.ok, listed.raw).toBe(true) + const h1 = ( + listed.data.hackathons as { id: string; name: string }[] + ).find((h) => h.name === SEED_HACKATHONS.h1.name) + expect( + h1, + "seed hackathon h1 not found — did `just db::seed` run?", + ).toBeTruthy() + + const created = await rpcAs( + "alice", + "hackathon.HackathonService/CreateInvite", + { + hackathonId: h1!.id, + note: "session replay leak check", + }, + ) + expect(created.ok, created.raw).toBe(true) + const token = (created.data.invite as { token: string }).token + expect(token, "CreateInvite returned no token").toBeTruthy() + + await grantConsent(context, baseURL ?? "http://localhost:8081") + const cap = captureIngest(page) + const posts = captureIngestPosts(page) + const sessions = captureSessionIds(page) + + await page.goto(`/invite/${token}`) + await expect(page.getByRole("heading").first()).toBeVisible() + + // WAIT FOR A BATCH, NOT FOR TRAFFIC. `/v1/web/start` is under /ingest + // too, so "something was posted" is satisfied ~100ms before the DOM + // snapshot exists — and the first version of this test then navigated + // away inside that window. The snapshot went out during unload as a + // keepalive fetch, which the page's `request` event never reported: the + // capture held two start bodies and the NEXT page's DOM, so the invite + // page was never in the bytes being searched. A token-absent grep over + // a recording of somewhere else is the exact false green this folder + // exists to prevent. + await expect + .poll( + () => posts.posts.filter((p) => p.url.includes("/v1/web/i")).length, + { + message: + "the tracker sent no batch on the invite page — is replay.enabled on, was consent granted, and did the frontend restart since?", + timeout: 30_000, + }, + ) + .toBeGreaterThan(0) + + // Stay here. This page load IS the subject: a fresh open of an invite + // link is a first batch, and the first batch carries the header whose + // last field is the URL. Moving to a second page would only add a + // second session to be confused about. + await page.mouse.move(200, 200) + await page.mouse.wheel(0, 300) + await page.waitForTimeout(8_000) + + const file = writeCapture("invite", cap.chunks) + console.log(`invite capture: ${bytes(cap.chunks)} bytes -> ${file}`) + + // ── property 1, on the wire ────────────────────────────────────────── + // Positive control first: the invite page's own form posts to this + // route's named action, and attribute values are transmitted verbatim + // (masking covers text nodes and input values; only href is blanked and + // only alt/placeholder are starred). Finding it proves this capture + // holds the invite page's DOM, which is what makes the token's absence + // mean "it was kept out" rather than "that page was never recorded". + expect( + contains(cap.chunks, "?/join"), + `the invite page's own form is not in the capture — this run recorded some other DOM, so it proves nothing about the token (see ${file})`, + ).toBe(true) + expect( + contains(cap.chunks, token), + `THE INVITE TOKEN WAS TRANSMITTED to OpenReplay. That token is the credential — the invite route is public precisely because the URL authenticates the visitor — so this recording is a working key to a private event (see ${file})`, + ).toBe(false) + + await expect + .poll(() => sessions.ids.length, { + message: + "no session id came back from /v1/web/start — the ingest endpoint never accepted a session", + timeout: 30_000, + }) + .toBeGreaterThan(0) + const sessionId = sessions.ids[0] + console.log(`recorded session ${sessionId}`) + + // Closing the page ends the session promptly. It is an OPTIMISATION, + // never a requirement — ender closes an idle session on its own timer — + // so it must not be able to block this test, and left plain it did: + // twice, the run sat here until Playwright's own 7-minute timeout, which + // reports "slow test" and not "no recording appeared". The tracker sends + // its last batch from a pagehide handler as a keepalive fetch, and + // `page.close()` waits for that round trip to a Cloudflare quick tunnel. + // Race it and carry on either way. + await Promise.race([ + page.close().catch(() => {}), + new Promise((r) => setTimeout(r, 15_000)), + ]) + + // ── property 2: it is actually a recording ─────────────────────────── + const mob = await waitForMob(admin!, sessionId) + console.log( + `first-mob for ${sessionId}: ${ + mob.ok + ? `${mob.bytes} bytes, zstd=${mob.compressed}` + : `${mob.status} ${mob.detail}` + }`, + ) + + expect( + mob.ok, + `session ${sessionId} took ${bytes(cap.chunks)} bytes of recording and produced NO mob file (${ + mob.ok ? "" : `${mob.status} ${mob.detail}` + }) — it is in OpenReplay's session list and the player will spin on it forever. Check, in this order, each of which has produced exactly this failure: \`bash .claude/skills/openreplay-stack/scripts/doctor.sh\` (a compose service with no container — sink writes the raw file every later stage reads, and its absence looks like a healthy stack), \`docker exec minio ls /data/mobs\` together with a signed request to the store (it once answered NoSuchBucket for a bucket on its own disk until it was restarted), and \`docker logs ender\` (a "broken batch" line means the backend discarded a batch whole)`, + ).toBe(true) + if (!mob.ok) return + + // A zero-byte object is a file that exists and is not a recording: the + // same spinner with a better-looking `ls`. + expect( + mob.bytes, + `the mob file for ${sessionId} is empty`, + ).toBeGreaterThan(0) + expect( + mob.compressed, + `the mob file for ${sessionId} is not a zstd frame — storage did not write it`, + ).toBe(true) + + // ── property 1 again, in the stored artefact ───────────────────────── + // A leak that moved off the wire and into the file is not a leak that + // was fixed, and only this half can see that: everything up to here + // watched the browser. + const plain = mobPlaintext(mob.body) + expect( + plain.length, + `the mob file for ${sessionId} did not decompress`, + ).toBeGreaterThan(mob.bytes) + expect( + plain.includes(Buffer.from("?/join", "utf8")), + "the stored recording does not contain the invite page's form — it is a recording of something else, so the token search below proves nothing", + ).toBe(true) + expect( + plain.includes(Buffer.from(token, "utf8")), + `THE INVITE TOKEN IS IN THE STORED RECORDING for session ${sessionId} — anyone with access to the replay UI holds a working key to a private event`, + ).toBe(false) + }) + }) +}) diff --git a/.claude/skills/hackathon-e2e/tests/openreplay/replayApi.ts b/.claude/skills/hackathon-e2e/tests/openreplay/replayApi.ts new file mode 100644 index 00000000..b2f1877a --- /dev/null +++ b/.claude/skills/hackathon-e2e/tests/openreplay/replayApi.ts @@ -0,0 +1,248 @@ +import fs from "node:fs" +import path from "node:path" +import zlib from "node:zlib" +import { SKILL_DIR } from "../../helpers/state.js" + +/* + * The other side of the ingest endpoint: OpenReplay's own API, asked whether + * the session it accepted bytes for actually became a RECORDING. + * + * Every other spec in this folder measures bytes leaving the browser, which is + * the right measurement for a privacy claim and says nothing at all about + * whether the far end could use them. It cannot: a batch OpenReplay's parser + * rejects is counted, acknowledged with 200, and then discarded whole + * (`Iterate` in backend/pkg/messages/iterator.go returns on the first parse + * error), so the session appears in the sessions list with no recording behind + * it and the player spins forever. That is exactly the state this rig shipped + * in, green, for three days. + * + * Reading the stored artefact needs admin credentials, so this module is the + * one place that holds them. They are minted by + * openreplay-stack/scripts/signup.sh into a gitignored `.secrets.env` — never + * hard-coded, never in a fixture. + */ + +const SECRETS = path.join(SKILL_DIR, "..", "openreplay-stack", ".secrets.env") + +export type ReplayAdmin = { baseUrl: string; jwt: string; projectId: number } + +/** KEY=value, no quoting — everything after the first '=' is the value. */ +function readSecrets(): Record<string, string> { + const out: Record<string, string> = {} + let text: string + try { + text = fs.readFileSync(SECRETS, "utf8") + } catch { + return out + } + for (const line of text.split(/\r?\n/)) { + if (!line || line.startsWith("#")) continue + const eq = line.indexOf("=") + if (eq < 1) continue + out[line.slice(0, eq)] = line.slice(eq + 1) + } + + return out +} + +/** + * The OpenReplay origin, derived from the ingest endpoint the app was wired + * with rather than read from the skill's own `.state/tunnel-url`. + * + * They can disagree — the quick tunnel mints a new hostname on every restart + * and only `wire-frontend.sh` updates the app — and if they do, the wrong one + * is the one that did not receive the bytes. Asking the host the recording was + * SENT to is the only choice that cannot check the wrong instance and report + * that nothing arrived. + */ +export function replayOrigin(ingestPoint: string): string { + return new URL(ingestPoint).origin +} + +/** + * Log in as the OpenReplay admin and resolve the project that owns + * `projectKey`. + * + * Returns null when the credentials file is missing or login fails, so a + * caller can say WHICH half of the rig is not set up instead of failing on an + * empty token. + */ +export async function adminSession( + ingestPoint: string, + projectKey: string, +): Promise<ReplayAdmin | null> { + const secrets = readSecrets() + const email = secrets.OPENREPLAY_EMAIL + const password = secrets.OPENREPLAY_PASSWORD + if (!email || !password) return null + + const baseUrl = replayOrigin(ingestPoint) + const login = await fetch(`${baseUrl}/api/login`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ email, password }), + }) + if (!login.ok) return null + const jwt = ((await login.json()) as { jwt?: string }).jwt + if (!jwt) return null + + const projects = await fetch(`${baseUrl}/api/projects`, { + headers: { Authorization: `Bearer ${jwt}` }, + }) + if (!projects.ok) return null + const listed = (await projects.json()) as { + data?: { projectId: number; projectKey: string }[] + } + const project = (listed.data ?? []).find((p) => p.projectKey === projectKey) + if (!project) return null + + return { baseUrl, jwt, projectId: project.projectId } +} + +/** zstd frame magic. `storage` compresses every mob file it uploads. */ +const ZSTD_MAGIC = Buffer.from([0x28, 0xb5, 0x2f, 0xfd]) + +export type MobFetch = + | { ok: true; bytes: number; compressed: boolean; body: Buffer } + | { ok: false; status: number; detail: string } + +/** + * The stored recording as the player would read it. + * + * `storage` uploads mob files as a zstd frame. Node 22 can undo that in the + * standard library, which is what makes "the invite token is not in the + * STORED file" an assertion about the artefact rather than about the wire — + * a leak that moved from one to the other is not a leak that was fixed. + * Returns the raw bytes unchanged when they are not a zstd frame, so a grep + * over the result is never silently searching a compressed buffer. + */ +export function mobPlaintext(body: Buffer): Buffer { + if (!body.subarray(0, 4).equals(ZSTD_MAGIC)) return body + // Added in Node 22.15; the container runs 22.22. Typed by hand because the + // repo's @types/node predates it. + const unzstd = ( + zlib as unknown as { + zstdDecompressSync?: (b: Buffer) => Buffer + } + ).zstdDecompressSync + if (typeof unzstd !== "function") return body + + return unzstd(body) +} + +/** + * Fetch the session's first DOM mob file — the recording itself. + * + * Two hops, because that is what the player does: ask the API for a presigned + * URL (`first-mob` hands one out whether or not the object exists, so this hop + * proves nothing on its own), then GET it. The object store answers 404 + * NoSuchKey until `storage` has uploaded, which is what makes this a poll + * rather than a check. + */ +export async function fetchFirstMob( + admin: ReplayAdmin, + sessionId: string, +): Promise<MobFetch> { + // EVERY fetch here is bounded. The rig is reached over a Cloudflare quick + // tunnel and node's fetch has no default timeout, so one stalled request + // inside the poll below would hang past its own deadline and surface as a + // Playwright test timeout — which says "this test is slow", not "the + // recording never appeared". The distinction is the whole point of the poll. + const at = (ms = 15_000) => AbortSignal.timeout(ms) + + let res: Response + try { + res = await fetch( + `${admin.baseUrl}/v2/api/${admin.projectId}/sessions/${sessionId}/first-mob`, + { headers: { Authorization: `Bearer ${admin.jwt}` }, signal: at() }, + ) + } catch (e) { + return { ok: false, status: 0, detail: `first-mob: ${String(e)}` } + } + if (!res.ok) { + return { + ok: false, + status: res.status, + detail: `first-mob: ${await res.text()}`, + } + } + const payload = (await res.json()) as { data?: { domURL?: string[] } } + const url = payload.data?.domURL?.[0] + if (!url) { + return { ok: false, status: 0, detail: "first-mob returned no domURL" } + } + + let mob: Response + try { + mob = await fetch(url, { signal: at(30_000) }) + } catch (e) { + return { ok: false, status: 0, detail: `mob fetch: ${String(e)}` } + } + if (!mob.ok) { + return { + ok: false, + status: mob.status, + detail: (await mob.text()).slice(0, 200), + } + } + const body = Buffer.from(await mob.arrayBuffer()) + + return { + ok: true, + bytes: body.length, + compressed: body.subarray(0, 4).equals(ZSTD_MAGIC), + body, + } +} + +/** + * Wait for the recording to land. + * + * `ender` closes a session on inactivity and only then does `storage` + * compress and upload it, so nothing exists for a minute or two after the last + * batch. The wait is generous on purpose: a too-short poll turns "the pipeline + * is broken" and "the pipeline is slow" into the same red, and this assertion + * exists precisely to tell a real absence from a timing artefact. + */ +export async function waitForMob( + admin: ReplayAdmin, + sessionId: string, + timeoutMs = 240_000, +): Promise<MobFetch> { + let last: MobFetch = { ok: false, status: 0, detail: "never polled" } + + const poll = async (): Promise<MobFetch> => { + const deadline = Date.now() + timeoutMs + while (Date.now() < deadline) { + last = await fetchFirstMob(admin, sessionId) + if (last.ok) return last + await new Promise((r) => setTimeout(r, 5_000)) + } + + return last + } + + // A HARD CAP, not a second belt. The loop above bounds itself and the fetches + // inside it are bounded too, and it still outlived both — twice — leaving the + // run to die on Playwright's test timeout instead. That reports "this test is + // slow"; the assertion this feeds reports "the session never became a + // recording, here is where to look". A verdict that turns into a timeout is + // the failure telling you nothing, which is the thing this whole file exists + // to stop happening. + return Promise.race([ + poll(), + new Promise<MobFetch>((resolve) => + setTimeout( + () => + resolve({ + ok: false, + status: 0, + detail: `no mob after ${Math.round(timeoutMs / 1000)}s (poll did not return; last seen: ${ + last.ok ? "ok" : `${last.status} ${last.detail}` + })`, + }), + timeoutMs + 30_000, + ), + ), + ]) +} diff --git a/.claude/skills/hackathon-e2e/tests/smoke/01-anonymous.spec.ts b/.claude/skills/hackathon-e2e/tests/smoke/01-anonymous.spec.ts new file mode 100644 index 00000000..c8eab096 --- /dev/null +++ b/.claude/skills/hackathon-e2e/tests/smoke/01-anonymous.spec.ts @@ -0,0 +1,65 @@ +import { test, expect } from "@playwright/test" +import { SEED_HACKATHONS } from "../../personas.js" + +// Anonymous visitor: public reads work, private data is invisible, protected +// routes bounce to login. Backend authority: HackathonService.List filters +// private hackathons via casbin (the "anonymous" subject only passes wildcard +// rules), the frontend only renders what it gets. + +test.describe("anonymous visitor", () => { + test("home page renders the public hackathon list", async ({ page }) => { + await page.goto("/") + + await expect( + page.getByRole("heading", { name: "SDSC Hackathon Platform" }), + ).toBeVisible() + + for (const key of ["h1", "h2"] as const) { + const h = SEED_HACKATHONS[key] + const row = page + .locator('a[href^="/hackathon/"]') + .filter({ hasText: h.name }) + .first() + await expect(row, `${h.name} should be listed`).toBeVisible() + await expect( + row.getByText(h.statusBadge), + `${h.name} should show the server-computed "${h.statusBadge}" status`, + ).toBeVisible() + } + }) + + test("private hackathons are not listed", async ({ page }) => { + await page.goto("/") + await expect(page.getByText(SEED_HACKATHONS.h3.name)).toHaveCount(0) + }) + + // Re-specified: the guard used to drop anonymous visitors on the LANDING page + // (`/?returnTo=…`), which explained nothing and — because the "Log in" button + // computed its own destination from the pathname — threw the parked link away. + // It bounces to the /signin interstitial now, which says what happened and + // hands that same value to Auth.js. See 23-login-destination.spec.ts for the + // round trip; this asserts the 303 itself. + test("protected routes bounce to the sign-in interstitial, carrying the destination", async ({ + page, + }) => { + // The REDIRECT, not the page it lands on. The interstitial sends itself to + // the identity provider about two seconds after it renders — that is the + // feature — so a browser parked on it is a moving target and any assertion + // made there races the navigation. `maxRedirects: 0` asks the guard directly. + for (const target of ["/dashboard", "/my/hackathon/some-id/overview"]) { + const resp = await page.request.get(target, { maxRedirects: 0 }) + expect( + resp.status(), + `${target} should bounce an anonymous visitor`, + ).toBe(303) + expect( + resp.headers()["location"], + `${target} must be parked on the interstitial, not discarded`, + ).toBe(`/signin?returnTo=${encodeURIComponent(target)}`) + } + + // Still anonymous: the NavBar offers "Log in". + await page.goto("/") + await expect(page.getByRole("button", { name: "Log in" })).toBeVisible() + }) +}) diff --git a/.claude/skills/hackathon-e2e/tests/smoke/02-login.spec.ts b/.claude/skills/hackathon-e2e/tests/smoke/02-login.spec.ts new file mode 100644 index 00000000..240859a9 --- /dev/null +++ b/.claude/skills/hackathon-e2e/tests/smoke/02-login.spec.ts @@ -0,0 +1,117 @@ +import { test, expect } from "@playwright/test" +import { ALL_PERSONAS } from "../../personas.js" +import { storageStatePath } from "../../helpers/state.js" + +// Every persona has a working session (established in auth.setup.ts), and the +// public shell stays reachable while signed in. +// +// Sign-out moved: it is a top-bar button in this design, not an item inside an +// avatar menu, and it is covered by 07-account-menu.spec.ts along with the rest +// of the "about you" chrome. + +for (const persona of ALL_PERSONAS) { + test.describe(`${persona.key} session`, () => { + test.use({ storageState: storageStatePath(persona.key) }) + + test(`is signed in and can reach the dashboard`, async ({ page }) => { + // "/" is the PUBLIC landing page and stays reachable while signed in. It + // used to bounce members to the dashboard, which made the platform's own + // front page unreachable once you had an account. + await page.goto("/") + await expect(page).toHaveURL(/(localhost:8081|trycloudflare\.com)\/$/) + + // Identity is a monogram, not a button — see helpers/login.ts. + await expect( + page.locator("header").getByText(persona.initial, { exact: true }), + ).toBeVisible() + + await page.goto("/dashboard") + await expect( + page.getByRole("heading", { name: /Welcome back/ }), + ).toBeVisible() + }) + + // ⚠ Re-specified 2026-08-14 for develop's `143a9612`. The header nav is TWO + // entries now — Dashboard and "All Hackathons" — and About has been dropped + // from it: the page stays, the entry does not. "Hackathons" was renamed + // because the wordmark to its left already reads Hackathons, so the bare + // noun appeared twice in one row, once as the platform and once as a page + // inside it. + // + // The PROPERTY these two tests were written for is untouched and is still + // what they assert: one meaning per entry, and the same set of entries on + // both sides of the login. Only the membership of that set moved. + const NAV_ENTRIES = [ + ["Dashboard", /\/dashboard$/], + ["All Hackathons", /\/hackathon$/], + ] as const + + test(`the public shell links to the hackathon list and the dashboard`, async ({ + page, + }) => { + await page.goto("/") + // The footer is a <nav> too — scope to the header. + const nav = page.locator("header").getByRole("navigation").first() + + // There is no separate "Home" entry: the logo is the platform instance + // and doubles as home, which is why it was dropped rather than added. + // + // Your own events are a SEPARATE entry from everyone's, not the same word + // pointing somewhere else once you sign in: "Hackathons" named a list and + // reached the dashboard, so the browse page was unreachable from the + // chrome for exactly the people who had an account. The label states the + // scope now, which is the third fix to the same confusion. + for (const [label, href] of NAV_ENTRIES) { + await expect( + nav.getByRole("link", { name: label, exact: true }), + `the header nav has no "${label}" entry`, + ).toHaveAttribute("href", href) + } + + // About left the header and did NOT leave the product: it is a SitePage + // and the footer is its way in. Asserted positively, because "no About in + // the header" is satisfied just as well by an About that was deleted, and + // an absence with no positive control agrees with everything + // (.claude/CLAUDE.md, "Ways a test reported green while proving nothing"). + // + // The absence half is matched by PREFIX, the presence half by the exact + // name. The link is called "About Hackagon" now — it names its own + // destination, so it cannot be confused with the SDSC column's + // datascience.ch/about — and an exact `"About"` here would have gone on + // reporting zero header entries whatever the header did with the new + // spelling. Exactly the shape of a check that agrees with everything. + await expect( + nav.getByRole("link", { name: /^About/ }), + "About is deliberately not a header entry any more", + ).toHaveCount(0) + await expect( + page + .locator("footer") + .getByRole("navigation", { name: "Platform" }) + .getByRole("link", { name: "About Hackagon", exact: true }), + "…but it must still be reachable, and the footer is now the only way in", + ).toHaveAttribute("href", "/about") + + // The logo goes home for everyone, signed in or not. + await expect( + page.locator("header").locator('a[href="/"]').first(), + ).toBeVisible() + }) + + test(`the nav keeps its shape inside the app`, async ({ page }) => { + await page.goto("/dashboard") + const nav = page.locator("header").getByRole("navigation").first() + + // The original point, unchanged: About used to be hidden inside the app + // shell as a "marketing link", so the nav had three entries on the way in + // and two once you arrived — it changed shape under you. It is two + // entries on both sides now, and this is what says they stay the same + // two. + for (const [label, href] of NAV_ENTRIES) { + await expect( + nav.getByRole("link", { name: label, exact: true }), + ).toHaveAttribute("href", href) + } + }) + }) +} diff --git a/.claude/skills/hackathon-e2e/tests/smoke/03-dashboard.spec.ts b/.claude/skills/hackathon-e2e/tests/smoke/03-dashboard.spec.ts new file mode 100644 index 00000000..f25d7447 --- /dev/null +++ b/.claude/skills/hackathon-e2e/tests/smoke/03-dashboard.spec.ts @@ -0,0 +1,299 @@ +import { test, expect, type Page, type Locator } from "@playwright/test" +import { + ALL_PERSONAS, + SEED_EXPECTATIONS, + SEED_HACKATHONS, + type SeedHackathonKey, +} from "../../personas.js" +import { storageStatePath } from "../../helpers/state.js" + +// Dashboard content per persona, against the seed fixture. This encodes the +// seed's involvement matrix (cmd/seed/README.md): who is connected to what, +// with which membership badge (Owner / Member / Waitlisted from casbin + +// participant.is_waiting). +// +// THE FIXTURE IS NOT THE WHOLE DATABASE, and that is a supported state — a +// populated instance (what the public demo URL serves) also carries the six +// real SDSC editions from skills/seed-past-hackathons, created by +// hackagon-admin. Every assertion here is therefore phrased as a property that +// survives events the fixture never named: what the fixture puts on the page +// must be on it, what the fixture forbids must not be, and any NUMBER the page +// states is checked against the page's own rows rather than against a constant +// a second seeder silently changes. Three tests in this file failed for a day +// because they held that constant instead. + +function section(page: Page, heading: string) { + return page + .locator("section") + .filter({ has: page.getByRole("heading", { name: heading }) }) +} + +// Each hackathon row is one HackathonRow link and the hackathon id is in its +// href, so the id — not the name — is the row's identity: exact, where a name +// match is a substring match across a list nobody enumerated. +// +// Both patterns are anchored and each admits EVERY link its section is supposed +// to hold, which is what lets `renderedIds` throw on anything else. A row that +// rendered the wrong destination then fails loudly instead of dropping out of +// an href-filtered count — and it is the same "my rows point at the member +// view / other rows point at the public page" claim, applied to every row on +// the page rather than to the handful the fixture happens to name. +// +// `/overview` alone now. The rows used to carry an edit pencil as well, so this +// admitted `/edit` too; editing a hackathon moved to its own Manage Hackathon +// page and that link is gone. Kept narrow rather than left permissive: an +// allowlist that admits a route the app no longer has agrees with a row pointing +// at a 404. +const MY_LINK = /^\/my\/hackathon\/([0-9a-f-]+)\/overview$/ +const OTHER_LINK = /^\/hackathon\/([0-9a-f-]+)$/ + +/** The hackathon ids a dashboard section actually rendered, one per row. */ +async function renderedIds(scope: Locator, allowed: RegExp): Promise<string[]> { + const hrefs = await scope + .locator("a") + .evaluateAll((els) => els.map((e) => e.getAttribute("href") ?? "")) + + const ids = new Set<string>() + for (const href of hrefs) { + const m = allowed.exec(href) + if (!m) + throw new Error( + `unexpected link in a dashboard hackathon list: "${href}" ` + + `does not match ${allowed} — a row is pointing somewhere it should not`, + ) + ids.add(m[1]!) + } + return [...ids] +} + +/** + * The number the page STATES it is connected to, read out of its own sentence. + * + * Also checks the sentence agrees with itself about plural, which the old + * hard-coded regex proved as a side effect and which nothing else would. + */ +async function statedConnectedCount(page: Page): Promise<number> { + const line = page.locator("main").getByText(/connected to \d+ hackathons?\b/) + await expect( + line, + "the dashboard should say how many hackathons you are connected to", + ).toBeVisible() + + // Normalized, because the sentence is four text nodes in the template with + // the indentation of the file between them. + const text = ((await line.textContent()) ?? "").replace(/\s+/g, " ").trim() + const m = /connected to (\d+) hackathon(s?)\b/.exec(text) + if (!m) throw new Error(`could not read a count out of "${text}"`) + + const n = Number(m[1]) + expect(m[2], `"${text}" should agree with itself about plural`).toBe( + n === 1 ? "" : "s", + ) + return n +} + +// A row is a link (the hackathon itself) plus whatever the list wraps around +// it — a membership badge here, a Join form in the other section. Those live +// OUTSIDE the link, because they are not part of navigating to the hackathon, +// so anchoring on the link alone finds neither. +// +// Reached as the link's grandparent rather than by class list: `div.flex-1` +// and `div.items-center` are layout decisions and have already been rewritten +// once. The relationship "the thing the row is mounted in" survives that. +function row(scope: Locator, name: string): Locator { + return scope + .locator("a") + .filter({ hasText: name }) + .first() + .locator("xpath=../..") +} + +for (const persona of ALL_PERSONAS) { + const expected = SEED_EXPECTATIONS[persona.key] + + test.describe(`${persona.key} dashboard`, () => { + test.use({ storageState: storageStatePath(persona.key) }) + + test("states a connected-hackathons count that matches its own list", async ({ + page, + }) => { + await page.goto("/dashboard") + const mine = section(page, "Your hackathons") + await expect(mine).toBeVisible() + + const stated = await statedConnectedCount(page) + const rendered = await renderedIds(mine, MY_LINK) + + // Positive control for the equality below: "0 stated, 0 rendered" would + // satisfy it on a page that rendered no list at all, and every persona in + // the fixture is in at least one hackathon whatever else the instance + // holds. This is a floor under a page that must not be empty, not a + // substitute for the count assertion. + expect( + rendered.length, + `${persona.key} is in ${expected.dashboard.mine.length} fixture hackathon(s), ` + + `so "Your hackathons" cannot be shorter than that`, + ).toBeGreaterThanOrEqual(expected.dashboard.mine.length) + + // The assertion: the sentence and the list are one fact stated twice + // (both are `myHackathons` server-side), so a mismatch is a real bug — a + // count that disagrees with the rows printed underneath it. A hard-coded + // number could never catch that, and stops being true the moment anything + // else populates the instance. + expect( + stated, + `the page says "connected to ${stated}" above ${rendered.length} row(s) under "Your hackathons"`, + ).toBe(rendered.length) + }) + + test("lists my hackathons with the right membership badge", async ({ + page, + }) => { + await page.goto("/dashboard") + const mine = section(page, "Your hackathons") + + for (const { hackathon, badge } of expected.dashboard.mine) { + const name = SEED_HACKATHONS[hackathon].name + const r = row(mine, name) + await expect( + r, + `${name} should be under "Your hackathons"`, + ).toBeVisible() + await expect( + r.getByText(badge, { exact: true }), + `${name} should carry the "${badge}" membership badge`, + ).toBeVisible() + } + + // Nothing the persona is NOT connected to may appear here. + const mineKeys = expected.dashboard.mine.map((m) => m.hackathon) + for (const key of ["h1", "h2", "h3"] as const) { + if (mineKeys.includes(key)) continue + await expect(mine.getByText(SEED_HACKATHONS[key].name)).toHaveCount(0) + } + }) + + test("my hackathons link to the member view, not the public page", async ({ + page, + }) => { + await page.goto("/dashboard") + const mine = section(page, "Your hackathons") + + for (const { hackathon } of expected.dashboard.mine) { + const name = SEED_HACKATHONS[hackathon].name + // Being in an event is the whole difference between the two views; + // sending a member to the public page loses every reason they joined. + await expect( + mine.locator("a").filter({ hasText: name }).first(), + ).toHaveAttribute("href", /\/my\/hackathon\/[^/]+\/overview$/) + } + }) + + test("offers the public hackathons the persona has not joined, and only those", async ({ + page, + }) => { + await page.goto("/dashboard") + const others = section(page, "Other hackathons") + const mine = section(page, "Your hackathons") + await expect(others).toBeVisible() + + // The fixture side, both directions. Present: the public events this + // persona is not in. Absent: everything they ARE in, and the private one + // — offering someone a Join for an event they are already in, or for an + // event they cannot see, are the two ways this section goes wrong with a + // list of the right length. + for (const key of expected.dashboard.others) { + const name = SEED_HACKATHONS[key].name + await expect( + others.getByText(name), + `${name} should be offered to ${persona.key} to join`, + ).toBeVisible() + // Scoped to its OWN row — a page-wide "first Join button" passes even + // when the wrong row has it. + await expect( + row(others, name).getByRole("button", { name: "Join" }), + ).toBeVisible() + } + for (const key of ["h1", "h2", "h3"] as SeedHackathonKey[]) { + if (expected.dashboard.others.includes(key)) continue + const name = SEED_HACKATHONS[key].name + await expect( + others.getByText(name), + `${name} must not be offered to ${persona.key} to join`, + ).toHaveCount(0) + } + + // The whole-section side, over every row the instance produced rather than + // only the ones the fixture named. For the personas who are in everything + // the fixture has, the loops above assert nothing but absences — and the + // one line this test used to have for them ("No other hackathons + // available.") is a claim about the fixture's size, not about the page. + // + // `renderedIds` already made the strongest per-row claim on the way past: + // every link in here is `/hackathon/<id>`, the public page, for all rows. + const offered = await renderedIds(others, OTHER_LINK) + const joined = await renderedIds(mine, MY_LINK) + + if (offered.length === 0) { + // The empty state is asserted where it is TRUE — when the section + // rendered nothing — rather than where the fixture predicts it. + await expect( + others.getByText("No other hackathons available."), + ).toBeVisible() + return + } + await expect( + others.getByText("No other hackathons available."), + "a section with rows in it must not also claim to be empty", + ).toHaveCount(0) + + // Control for the disjointness below: with an empty "Your hackathons" it + // would hold no matter what this section offered. + expect( + joined.length, + `${persona.key} should be in at least one hackathon`, + ).toBeGreaterThan(0) + expect( + offered.filter((id) => joined.includes(id)), + "a hackathon you are already in must never be offered to join", + ).toEqual([]) + + // Every row must ANSWER: a Join button, or the reason there is none. + // + // Not "every row has a Join button" any more. The dashboard gates that + // button on the `capabilities` and `status` List returns, so a finished + // event — of which a populated instance has plenty — correctly has none. + // But "no button" and "the control failed to render" look identical from + // outside, which is why the closed case has to say something rather than + // simply be missing. + for (const id of offered) { + // The CONTROL, not the row that contains it: a row whose hackathon is + // named "…Join…" would satisfy a text match on the container. + const rowEl = others + .locator(`a[href="/hackathon/${id}"]`) + .locator("xpath=../..") + const join = rowEl.getByRole("button", { name: "Join" }) + const closed = rowEl.getByText("Registration closed") + + const hasJoin = (await join.count()) > 0 + + await expect( + hasJoin ? join : closed, + `the row for ${id} offers neither a Join button nor a reason it cannot ` + + `be joined — a row that silently loses its control is indistinguishable ` + + `from one that failed to render`, + ).toBeVisible() + } + + // No blanket "at least one row is joinable" control here, deliberately. + // The positive control is the fixture loop above: for a persona who has + // an unjoined fixture event, that event MUST carry a Join button, which + // is what stops the loop below agreeing with a section nobody can join + // anything from. For a persona who has already joined everything + // joinable — alice and bob — every remaining row is a finished archive + // edition, and "all closed" is then the correct answer rather than a + // fault. Asserting otherwise made this test demand a fixture state that + // contradicts `expected.dashboard.others` being empty. + }) + }) +} diff --git a/.claude/skills/hackathon-e2e/tests/smoke/04-access-control.spec.ts b/.claude/skills/hackathon-e2e/tests/smoke/04-access-control.spec.ts new file mode 100644 index 00000000..1b596858 --- /dev/null +++ b/.claude/skills/hackathon-e2e/tests/smoke/04-access-control.spec.ts @@ -0,0 +1,92 @@ +import { test, expect } from "@playwright/test" +import { + ALL_PERSONAS, + SEED_EXPECTATIONS, + SEED_HACKATHONS, + type SeedHackathonKey, +} from "../../personas.js" +import { anonymousContext, contextFor } from "../../helpers/login.js" +import { publicHackathonId, myHackathonId } from "../../helpers/discover.js" + +// The backend is authoritative for access: /my/hackathon/<id> calls +// HackathonService.Get, which runs a casbin Read check. The frontend only +// translates PERMISSION_DENIED -> 403 and NOT_FOUND -> 404. This spec pins +// the full persona x hackathon access matrix from personas.ts. + +const ids: Partial<Record<SeedHackathonKey, string>> = {} + +test.beforeAll(async ({ browser }) => { + // Public ids from the anonymous home page; the private H3 id via alice, who + // is connected to it. + const anon = await anonymousContext(browser) + const page = await anon.newPage() + ids.h1 = await publicHackathonId(page, SEED_HACKATHONS.h1.name) + ids.h2 = await publicHackathonId(page, SEED_HACKATHONS.h2.name) + await anon.close() + + const alice = await contextFor(browser, "alice") + const alicePage = await alice.newPage() + ids.h3 = await myHackathonId(alicePage, SEED_HACKATHONS.h3.name) + await alice.close() +}) + +for (const persona of ALL_PERSONAS) { + const matrix = SEED_EXPECTATIONS[persona.key].memberView + + test(`${persona.key}: member-view access matrix`, async ({ browser }) => { + const ctx = await contextFor(browser, persona.key) + const page = await ctx.newPage() + + for (const key of ["h1", "h2", "h3"] as const) { + const name = SEED_HACKATHONS[key].name + const resp = await page.goto(`/my/hackathon/${ids[key]}/overview`) + const status = resp?.status() + + if (matrix[key] === "ok") { + expect( + status, + `${persona.key} must reach the ${name} member view`, + ).toBe(200) + } else { + expect( + status, + `${persona.key} must get 403 on the ${name} member view`, + ).toBe(403) + } + } + await ctx.close() + }) +} + +test("unknown hackathon id yields 404 (as admin, who passes the casbin check)", async ({ + browser, +}) => { + // Non-admins without a role on the unknown domain fail the casbin check + // first and get 403 — only admin reaches the existence check. + const ctx = await contextFor(browser, "admin") + const page = await ctx.newPage() + const resp = await page.goto( + "/my/hackathon/00000000-0000-0000-0000-000000000000/overview", + ) + expect(resp?.status()).toBe(404) + await ctx.close() +}) + +test("anonymous visitors are redirected away from the member view", async ({ + browser, +}) => { + const ctx = await anonymousContext(browser) + const page = await ctx.newPage() + const target = `/my/hackathon/${ids.h1}/overview` + // Asserted on the 303, not on the page it lands on: the /signin interstitial + // forwards itself to the identity provider a couple of seconds after it + // renders, so a browser sitting there is mid-navigation by the time an + // assertion runs. What matters here is that the member view refuses an + // anonymous caller AND that the link they wanted survives the refusal. + const resp = await page.request.get(target, { maxRedirects: 0 }) + expect(resp.status()).toBe(303) + expect(resp.headers()["location"]).toBe( + `/signin?returnTo=${encodeURIComponent(target)}`, + ) + await ctx.close() +}) diff --git a/.claude/skills/hackathon-e2e/tests/smoke/05-new-user-funnel.spec.ts b/.claude/skills/hackathon-e2e/tests/smoke/05-new-user-funnel.spec.ts new file mode 100644 index 00000000..925b4bf4 --- /dev/null +++ b/.claude/skills/hackathon-e2e/tests/smoke/05-new-user-funnel.spec.ts @@ -0,0 +1,40 @@ +import { test, expect } from "@playwright/test" +import { SELF_REGISTRANT } from "../../personas.js" +import { anonymousContext, registerViaKeycloak } from "../../helpers/login.js" + +// The full new-user funnel through the REAL UI — the one path no other spec +// covers: Keycloak self-registration (every cast member elsewhere is +// provisioned via the admin API), auto-login back into the app, backend +// auto-registration on first dashboard load (WhoAmI -> Register), and joining +// a hackathon with the dashboard Join button (HackathonService.Join — the F2 +// stub alert until it was wired). +// +// Wanda is a fixed persona (personas.ts SELF_REGISTRANT), so the run stays +// deterministic; the standard smoke reset wipes her between runs, and the +// register helper falls back to login on a --no-reset rerun. + +test.describe("new user funnel: register → login → join", () => { + test("self-registers via Keycloak and joins a hackathon onto the waitlist", async ({ + browser, + }) => { + const ctx = await anonymousContext(browser) + const page = await ctx.newPage() + await registerViaKeycloak(page, SELF_REGISTRANT) + + // First dashboard visit auto-registers the platform user. + await page.goto("/dashboard") + await page.waitForLoadState("networkidle") + + // A brand-new user has no memberships: everything is under "Other + // hackathons". Join the first one through the real button. + await expect(page.getByText("Other hackathons")).toBeVisible() + await page.getByRole("button", { name: "Join" }).first().click() + + // Join puts new registrants on the waitlist (is_waiting until an + // organizer approves): the hackathon moves into "My hackathons" with the + // Waitlisted badge — the new user's only membership. + await expect(page.getByText("Waitlisted")).toBeVisible({ timeout: 20_000 }) + + await ctx.close() + }) +}) diff --git a/.claude/skills/hackathon-e2e/tests/smoke/06-cms-pages.spec.ts b/.claude/skills/hackathon-e2e/tests/smoke/06-cms-pages.spec.ts new file mode 100644 index 00000000..ad7144e1 --- /dev/null +++ b/.claude/skills/hackathon-e2e/tests/smoke/06-cms-pages.spec.ts @@ -0,0 +1,147 @@ +import { test, expect } from "@playwright/test" +import { PERSONAS } from "../../personas.js" +import { anonymousContext, contextFor } from "../../helpers/login.js" +import { storageStatePath } from "../../helpers/state.js" + +// The platform CMS driven through its real admin UI (/manage/pages), not the +// API — act 0 in the journey recipe already covers SitePageService directly. +// This walks the whole authoring lifecycle a human performs: create a draft, +// confirm the public cannot see it, publish, edit the content, then delete. +// +// The page is created and removed inside this spec, so the shared seeded +// database is left exactly as it was found. + +const SLUG = "code-of-conduct" +const TITLE = "Code of conduct" +// Markdown, so the assertions also prove the sanitizing render pipeline runs +// on SitePage content: `##` must become a heading and `**` bold. +const CONTENT = [ + "## Be excellent to each other", + "", + "Harassment is **not tolerated** at any Hackagon event.", +].join("\n") +const EDITED_MARKER = "Report concerns to the organizers" + +test.describe.configure({ mode: "serial" }) + +test.describe("platform CMS: /manage/pages", () => { + test.use({ storageState: storageStatePath(PERSONAS.admin.key) }) + + test("admin creates a draft, publishes, edits and deletes a page", async ({ + page, + browser, + }) => { + // --- create, as a draft ------------------------------------------------- + await page.goto("/manage/pages") + await page.waitForLoadState("networkidle") + await expect( + page.getByRole("heading", { name: "Platform pages" }), + ).toBeVisible() + + // A previous failed run may have left the page behind; slugs are unique, + // so Create would fail with AlreadyExists. Clear it first. + const stale = page.locator(".card", { hasText: TITLE }) + if ((await stale.count()) > 0) { + await stale + .first() + .locator('form[action="?/delete"]') + .getByRole("button") + .click() + await expect(page.locator(".card", { hasText: TITLE })).toHaveCount(0) + } + + await page.getByRole("button", { name: "New page" }).click() + // Scope to the create form: every existing row carries hidden `slug` + // inputs in its edit/delete forms, so a bare input[name="slug"] is + // ambiguous once any page exists. + const createForm = page.locator('form[action="?/create"]') + await createForm.locator('input[name="slug"]').fill(SLUG) + await createForm.locator('input[name="title"]').fill(TITLE) + await createForm.locator('textarea[name="content"]').fill(CONTENT) + // "Published" deliberately left unchecked — drafts must stay private. + await createForm.getByRole("button", { name: "Create page" }).click() + + const row = page.locator(".card", { hasText: TITLE }) + await expect(row).toBeVisible() + // Assert the STATUS BADGE, not any text: the edit form carries a + // "Published" checkbox label with the same words, so a bare getByText + // passes before the save has even happened and races the next step. + const badge = row.locator("span.badge") + await expect(badge).toHaveText("Draft") + + // --- a draft is invisible to the public --------------------------------- + // The backend reports NotFound rather than PermissionDenied, so a draft is + // indistinguishable from a page that was never created. + const anon = await anonymousContext(browser) + const anonPage = await anon.newPage() + let resp = await anonPage.goto(`/${SLUG}`) + expect(resp?.status(), "an unpublished page must not be readable").toBe(404) + + // --- publish ------------------------------------------------------------ + await row.getByRole("button", { name: "Edit" }).click() + const editForm = row.locator('form[action="?/edit"]') + await editForm.locator('input[name="visible"]').check() + await editForm.getByRole("button", { name: "Save changes" }).click() + await expect(editForm).toHaveCount(0) + await expect(badge).toHaveText("Published") + + resp = await anonPage.goto(`/${SLUG}`) + expect(resp?.status(), "a published page must be publicly readable").toBe( + 200, + ) + await expect(anonPage.getByRole("heading", { name: TITLE })).toBeVisible() + // Markdown was parsed, not dumped as source. + await expect( + anonPage.getByRole("heading", { name: "Be excellent to each other" }), + ).toBeVisible() + await expect(anonPage.locator(".markdown-content strong")).toHaveText( + "not tolerated", + ) + + // --- edit the content --------------------------------------------------- + await page.reload() + await page.waitForLoadState("networkidle") + const liveRow = page.locator(".card", { hasText: TITLE }) + await liveRow.getByRole("button", { name: "Edit" }).click() + const liveEdit = liveRow.locator('form[action="?/edit"]') + await liveEdit + .locator('textarea[name="content"]') + .fill(`${CONTENT}\n\n${EDITED_MARKER}.`) + await liveEdit.getByRole("button", { name: "Save changes" }).click() + // The editor unmounts once the action resolves (use:enhance sets + // editing = null), so this is the completion signal. Without it the next + // navigation races the save and reads the pre-edit content. + await expect(liveEdit).toHaveCount(0) + + await anonPage.goto(`/${SLUG}`) + await expect(anonPage.getByText(EDITED_MARKER)).toBeVisible() + + // --- delete, and the public URL goes with it ---------------------------- + await page.reload() + await page.waitForLoadState("networkidle") + await page + .locator(".card", { hasText: TITLE }) + .locator('form[action="?/delete"]') + .getByRole("button", { name: "Delete" }) + .click() + await expect(page.locator(".card", { hasText: TITLE })).toHaveCount(0) + + resp = await anonPage.goto(`/${SLUG}`) + expect(resp?.status(), "a deleted page must stop resolving").toBe(404) + + await anon.close() + }) + + test("a non-admin cannot reach the CMS", async ({ browser }) => { + // alice is a hackathon organizer, not a platform admin: site pages are + // global, so her per-hackathon Owner role grants nothing here. + const ctx = await contextFor(browser, PERSONAS.alice.key) + const alicePage = await ctx.newPage() + const resp = await alicePage.goto("/manage/pages") + expect( + resp?.status(), + "organizers must not administer platform pages", + ).toBe(403) + await ctx.close() + }) +}) diff --git a/.claude/skills/hackathon-e2e/tests/smoke/07-account-menu.spec.ts b/.claude/skills/hackathon-e2e/tests/smoke/07-account-menu.spec.ts new file mode 100644 index 00000000..9c1648ba --- /dev/null +++ b/.claude/skills/hackathon-e2e/tests/smoke/07-account-menu.spec.ts @@ -0,0 +1,264 @@ +import { test, expect, type Page } from "@playwright/test" +import { ALL_PERSONAS, PERSONAS, SEED_HACKATHONS } from "../../personas.js" +import { storageStatePath } from "../../helpers/state.js" +import { rpcAnonymous } from "../../helpers/api.js" + +// Reaching your own account, and signing out. +// +// This replaces the avatar-dropdown spec. That menu does not exist in this +// design: identity is a monogram <span> ("identity, not an action to be drawn +// toward"), and the things you can do about yourself — account, sign out — are +// controls in the top bar rather than items behind a disclosure. +// +// What is still worth pinning is the property the old spec existed for: every +// destination that concerns YOU is reachable from the chrome. /account backs +// EditProfile and account deletion and nothing else links to it, so an +// unreachable link means a dead feature — which is exactly what happened when +// the account link was first added to AppSidebar, a component no route mounts. + +function header(page: Page) { + return page.locator("header") +} + +let h1Id = "" + +test.beforeAll(() => { + // Discovered the way a visitor would rather than hard-coded: seed UUIDs are + // regenerated on every reset. + const listed = rpcAnonymous("hackathon.HackathonService/List", { + visibilityFilter: 1, + }) + if (!listed.ok) throw new Error(`HackathonService.List failed: ${listed.raw}`) + const found = ( + listed.data.hackathons as { id: string; name: string }[] | undefined + )?.find((h) => h.name === SEED_HACKATHONS.h1.name) + if (!found) { + throw new Error( + `seed hackathon "${SEED_HACKATHONS.h1.name}" not found — this spec needs ` + + `the seed fixture (scripts/run.sh smoke seeds it)`, + ) + } + h1Id = found.id +}) + +/** + * Walk the trail the account page describes, using the names IT gives. + * + * The labels are an argument rather than constants on purpose: the caller reads + * them out of the page's own copy, so this asserts the sentence a person is + * asked to follow, not a sentence a test author remembered. Feeding it labels + * that name nothing is how the control below proves it can fail. + */ +async function expectTrailExists( + page: Page, + hackathonId: string, + destination: string, + control: string, +) { + await page.goto(`/my/hackathon/${hackathonId}/overview`) + await page.waitForLoadState("networkidle").catch(() => {}) + + const entry = page + .getByRole("link", { name: destination, exact: true }) + .filter({ visible: true }) + .first() + await expect( + entry, + `the account page sends people to "${destination}" inside an event, and ` + + `this event offers no such destination`, + ).toBeVisible({ timeout: 10_000 }) + + // Clicked, never `goto`-ed: goto proves a route exists, a click proves you + // can GET there by doing what you were told (.claude/CLAUDE.md, pass 1). + await entry.click() + await expect(page).toHaveURL(/\/participants$/) + + // Asked of the SERVER, not inferred from what rendered: SvelteKit's error + // page is a rendered page too, and a heading assertion cannot tell a 200 from + // a 404 that happens to look tidy. + const landed = await page.request.get(page.url()) + expect( + landed.status(), + `"${destination}" resolved to ${page.url()}, which did not answer 200`, + ).toBe(200) + + // Your OWN row. The same control on someone else's row carries `?userId=` + // and opens their answers read-only for an organiser — alice is one here, so + // the page is full of those and picking `.first()` would prove the wrong + // thing. The name is matched loosely because the row control is + // `<a aria-label="View <name> profile">View</a>`: "View" is what a person + // reads and what the copy can honestly name. + const hrefs = await page + .locator("main") + .getByRole("link", { name: control }) + .evaluateAll((els) => els.map((e) => e.getAttribute("href") ?? "")) + const mine = hrefs.filter( + (h) => h.includes("/register/") && !h.includes("userId="), + ) + expect( + mine, + `the account page tells people to use "${control}" on their own row of ` + + `${page.url()}; no such control links to their own registration there ` + + `(found: ${hrefs.join(", ") || "no links with that name at all"})`, + ).toHaveLength(1) + expect(mine[0]).toBe(`/register/${hackathonId}`) +} + +test.describe("account, from the top bar", () => { + test.use({ storageState: storageStatePath("alice") }) + + test("the header identifies who is signed in", async ({ page }) => { + await page.goto("/dashboard") + await expect( + header(page).getByText(PERSONAS.alice.initial, { exact: true }), + ).toBeVisible() + }) + + test("the account link reaches the account page", async ({ page }) => { + await page.goto("/dashboard") + // By its accessible name, not its position: it is an icon control on + // desktop and a labelled row on phones, and both must work. + await header(page) + .getByRole("link", { name: "Your account" }) + .first() + .click() + + await expect(page).toHaveURL(/\/account$/) + await expect( + page.getByRole("heading", { name: "Your account" }), + ).toBeVisible() + }) + + test("the account page offers the profile edit and the deletion path", async ({ + page, + }) => { + await page.goto("/account") + + // The two things only this page can do. + await expect(page.getByRole("button", { name: "Save" })).toBeVisible() + await expect( + page.getByRole("button", { name: /Delete my profile/ }), + ).toBeVisible() + }) + + test("username and email link out to the identity provider", async ({ + page, + }) => { + await page.goto("/account") + + // Keycloak owns them and re-reads them from the token on every request, so + // editing them here would be undone on the next page load. The page has to + // send you where the change actually sticks. + const out = page.getByRole("link", { name: /Change them there/ }) + await expect(out).toBeVisible() + await expect(out).toHaveAttribute("href", /\/realms\/hackagon\/account$/) + }) + + // ─── The one thing this page sends you AWAY for ──────────────────────────── + // + // Event-specific answers — an event's own questions, and the consents it + // asked for — are not on the profile, and the page says where they are + // instead. That sentence had gone stale: it named "Your registration answers + // → View or edit", a block on the event overview that develop's `c596683c` + // deleted, so anyone following it looked for a control that was not there. + // `76037844` had moved the way in to the participants roster. + // + // Copy that names a control is a promise about the UI, and this repo has now + // shipped the broken version of that promise several times (routes with no + // inbound link; RPCs with no caller). So the check does not read the + // sentence — it FOLLOWS it, and asserts the far end answers 200. + + test("the answers this page does not hold are where it says they are", async ({ + page, + }) => { + await page.goto("/account") + + const note = page.getByTestId("event-answers-note") + await expect( + note, + "the account page no longer says where event-specific answers live — if " + + "that paragraph was removed on purpose, remove this test with it; if it " + + "was renamed, this is the check that was supposed to notice", + ).toBeVisible() + + // The <strong>s ARE the claim: exactly the destination and the control a + // person is told to look for. Read back rather than duplicated here, so a + // re-wording is followed instead of silently diverging from what is tested. + const named = await note.locator("strong").allInnerTexts() + expect( + named.map((s) => s.trim()), + "this note is supposed to name exactly two things — the destination " + + "inside the event, then the control on your own row. Emphasising a " + + "third would make it ambiguous which one this test should follow, so " + + "change the test deliberately rather than let it guess", + ).toHaveLength(2) + + await expectTrailExists(page, h1Id, named[0].trim(), named[1].trim()) + }) + + test("CONTROL: copy naming a control that does not exist fails the check", async ({ + page, + }) => { + // Positive control: the trail as the page describes it today. + await expectTrailExists(page, h1Id, "Participants", "View") + + // And the trail as it described it until this fix — the exact words of the + // block develop removed. An assertion that cannot reject the state that was + // actually shipped is not guarding anything. + await expect( + expectTrailExists( + page, + h1Id, + "Your registration answers", + "View or edit", + ), + "the stale instruction named a destination no event has; following it has " + + "to fail, or this spec would have agreed with the copy it was written " + + "to replace", + ).rejects.toThrow() + }) +}) + +test.describe("sign out", () => { + test.use({ storageState: storageStatePath(PERSONAS.bob.key) }) + + test("returns to the public shell", async ({ page }) => { + await page.goto("/dashboard") + // signOut() is a client call, so this one genuinely needs hydration. + await page.waitForLoadState("networkidle") + + await header(page).getByRole("button", { name: "Log out" }).first().click() + + await page.waitForURL(/localhost:8081\/($|\?)/, { timeout: 15_000 }) + await expect(page.getByRole("button", { name: "Log in" })).toBeVisible() + + // And the session is really gone, not just visually. Asked as the 303 the + // guard answers with, not by landing on it: the /signin interstitial sends + // itself to Keycloak a couple of seconds after it renders, so a browser + // parked there is mid-navigation by the time an assertion runs. The + // redirect is also the stronger claim — it is the SERVER refusing, where a + // rendered page could be a cached one. + const resp = await page.request.get("/dashboard", { maxRedirects: 0 }) + expect(resp.status()).toBe(303) + expect(resp.headers()["location"]).toBe("/signin?returnTo=%2Fdashboard") + }) +}) + +test.describe("every persona can reach their account", () => { + // The regression this guards: a link that exists in a component no route + // renders. Cheap to check per persona, and it is the whole point of the page. + for (const persona of ALL_PERSONAS) { + test(`${persona.key}`, async ({ page, browser }) => { + const ctx = await browser.newContext({ + storageState: storageStatePath(persona.key), + }) + const p = await ctx.newPage() + await p.goto("/account") + await expect( + p.getByRole("heading", { name: "Your account" }), + ).toBeVisible() + await ctx.close() + void page + }) + } +}) diff --git a/.claude/skills/hackathon-e2e/tests/smoke/08-list-views.spec.ts b/.claude/skills/hackathon-e2e/tests/smoke/08-list-views.spec.ts new file mode 100644 index 00000000..1fa1f5fb --- /dev/null +++ b/.claude/skills/hackathon-e2e/tests/smoke/08-list-views.spec.ts @@ -0,0 +1,196 @@ +import { test, expect, type Page } from "@playwright/test" +import { storageStatePath } from "../../helpers/state.js" + +// The management lists (platform pages, users, participants) share one toolbar: +// quick search, dropdown filters, and a cards/table toggle whose choice is +// remembered per list. Each surface is driven the way a person drives it — +// type, pick a filter, switch view — because a control that renders is not the +// same as a control that works. + +/** + * The toolbar is client-side by nature: it re-filters a list the server already + * sent whole, so it does nothing until the page hydrates. Driving it earlier + * silently loses the interaction — every helper below goes through here. + */ +async function open(page: Page, path: string) { + await page.goto(path) + await page.waitForLoadState("networkidle") +} + +function toolbar(page: Page) { + return { + search: page.getByRole("searchbox").first(), + cards: page.getByRole("button", { name: "Card view" }), + table: page.getByRole("button", { name: "Table view" }), + } +} + +test.describe("platform pages list", () => { + test.use({ storageState: storageStatePath("admin") }) + + test("searches by title and clears back to the full list", async ({ + page, + }) => { + await open(page, "/manage/pages") + const rows = page.getByRole("heading", { level: 2 }) + + const before = await rows.count() + expect(before, "seed should have several platform pages").toBeGreaterThan(1) + + await toolbar(page).search.fill("privacy") + await expect(page.getByRole("heading", { name: "Privacy" })).toBeVisible() + await expect( + page.getByRole("heading", { name: "Terms of use" }), + ).toHaveCount(0) + // The count line is what tells you a filter is hiding things. + await expect(page.getByText(/Showing \d+ of \d+/)).toBeVisible() + + await page.getByRole("button", { name: "clear" }).click() + await expect( + page.getByRole("heading", { name: "Terms of use" }), + ).toBeVisible() + }) + + test("searches the page CONTENT, not just the title", async ({ page }) => { + await open(page, "/manage/pages") + // "where did I write that paragraph" is the question these pages get. + await toolbar(page).search.fill("markdown") + await expect(page.getByText(/Showing \d+ of \d+/)).toBeVisible() + }) + + test("filters by draft/published", async ({ page }) => { + await open(page, "/manage/pages") + await page.getByLabel("Status").selectOption("draft") + // The seed publishes all three, so the draft filter empties the list — + // that is the assertion: the filter is applied, not ignored. + await expect(page.getByText("No pages match your search.")).toBeVisible() + + await page.getByLabel("Status").selectOption("published") + await expect(page.getByRole("heading", { name: "Privacy" })).toBeVisible() + }) + + test("switches to the table and offers row actions there", async ({ + page, + }) => { + await open(page, "/manage/pages") + await toolbar(page).table.click() + + const table = page.getByRole("table") + await expect(table).toBeVisible() + await expect( + table.getByRole("columnheader", { name: /Title/ }), + ).toBeVisible() + await expect(table.getByRole("cell", { name: "/privacy" })).toBeVisible() + + // Actions live behind a per-row menu so the row stays one line. + await page.getByRole("button", { name: /Actions for Privacy/ }).click() + await expect(page.getByRole("menu").getByText("Delete")).toBeVisible() + }) + + test("sorts by a column header", async ({ page }) => { + await open(page, "/manage/pages") + await toolbar(page).table.click() + + const header = page.getByRole("button", { name: /^URL/ }) + await header.click() + const first = page.getByRole("row").nth(1) + await expect(first).toContainText("/about") + + await header.click() // descending + await expect(page.getByRole("row").nth(1)).toContainText("/terms") + }) + + test("remembers the chosen view across a reload", async ({ page }) => { + await open(page, "/manage/pages") + await toolbar(page).table.click() + await expect(page.getByRole("table")).toBeVisible() + + await page.reload() + await page.waitForLoadState("networkidle") + // Persisted per list in localStorage: coming back to a list you left in + // table view and getting cards is a small betrayal every single time. + await expect(page.getByRole("table")).toBeVisible() + }) +}) + +test.describe("users list", () => { + test.use({ storageState: storageStatePath("admin") }) + + test("defaults to the table and searches across name, handle and email", async ({ + page, + }) => { + await open(page, "/manage/users") + await expect(page.getByRole("table")).toBeVisible() + + await toolbar(page).search.fill("alice") + await expect( + page.getByRole("cell", { name: "Alice Wonderland" }), + ).toBeVisible() + await expect(page.getByRole("cell", { name: "Bob Henderson" })).toHaveCount( + 0, + ) + }) + + test("filters by global role", async ({ page }) => { + await open(page, "/manage/users") + await page.getByLabel("Role").selectOption("1") + await expect( + page.getByRole("cell", { name: "Hackagon Admin" }), + ).toBeVisible() + await expect(page.getByRole("cell", { name: "Bob Henderson" })).toHaveCount( + 0, + ) + }) + + test("switches to cards", async ({ page }) => { + await open(page, "/manage/users") + await toolbar(page).cards.click() + await expect(page.getByRole("table")).toHaveCount(0) + await expect(page.getByText("Alice Wonderland")).toBeVisible() + }) +}) + +test.describe("participants list", () => { + test.use({ storageState: storageStatePath("admin") }) + + test("filters the roster by membership state", async ({ page }) => { + await open(page, "/dashboard") + await page + .locator("a") + .filter({ hasText: "AI Innovation Challenge 2026" }) + .first() + .click() + await page.getByRole("link", { name: "Participants" }).first().click() + await page.waitForLoadState("networkidle") + + await expect(page.getByText(/confirmed/)).toBeVisible() + + // The seed waitlists charles on this hackathon. + await page.getByLabel("Status").selectOption("waitlisted") + await expect(page.getByText("Charles Whitfield")).toBeVisible() + + await page.getByLabel("Status").selectOption("confirmed") + await expect(page.getByText("Charles Whitfield")).toHaveCount(0) + }) + + test("shows the roster as one sortable table", async ({ page }) => { + await open(page, "/dashboard") + await page + .locator("a") + .filter({ hasText: "AI Innovation Challenge 2026" }) + .first() + .click() + await page.getByRole("link", { name: "Participants" }).first().click() + await page.waitForLoadState("networkidle") + + await toolbar(page).table.click() + const table = page.getByRole("table") + await expect(table).toBeVisible() + // Confirmed and waitlisted are one list here, with the split as a column: + // sorting across two separate tables would not sort anything. + await expect( + table.getByRole("columnheader", { name: /Status/ }), + ).toBeVisible() + await expect(table.getByText("Waitlisted").first()).toBeVisible() + }) +}) diff --git a/.claude/skills/hackathon-e2e/tests/smoke/09-browse-and-join.spec.ts b/.claude/skills/hackathon-e2e/tests/smoke/09-browse-and-join.spec.ts new file mode 100644 index 00000000..9d25ca8b --- /dev/null +++ b/.claude/skills/hackathon-e2e/tests/smoke/09-browse-and-join.spec.ts @@ -0,0 +1,116 @@ +import { test, expect } from "@playwright/test" +import { storageStatePath } from "../../helpers/state.js" +import { SEED_HACKATHONS } from "../../personas.js" + +// The public browse page, and what happens when you join an event that asks +// its registrants questions. + +test.describe("browse hackathons", () => { + test("anyone can browse the panels without an account", async ({ page }) => { + await page.goto("/hackathon") + await page.waitForLoadState("networkidle") + + await expect( + page.getByRole("heading", { name: "Hackathons", level: 1 }), + ).toBeVisible() + // Panels link to the public event page. + const card = page + .locator('a[href^="/hackathon/"]') + .filter({ hasText: SEED_HACKATHONS.h1.name }) + await expect(card.first()).toBeVisible() + + // Private events are filtered server-side, not hidden in the UI. + await expect(page.getByText(SEED_HACKATHONS.h3.name)).toHaveCount(0) + }) + + test("searches and filters across them", async ({ page }) => { + await page.goto("/hackathon") + await page.waitForLoadState("networkidle") + + await page.getByRole("searchbox").first().fill("climate") + await expect(page.getByText(SEED_HACKATHONS.h2.name).first()).toBeVisible() + await expect(page.getByText(SEED_HACKATHONS.h1.name)).toHaveCount(0) + + await page.getByRole("button", { name: "clear" }).click() + // Status filter: the seed's h1 is upcoming, h2 is running. + await page.getByLabel("Status").selectOption("2") + await expect(page.getByText(SEED_HACKATHONS.h2.name).first()).toBeVisible() + await expect(page.getByText(SEED_HACKATHONS.h1.name)).toHaveCount(0) + }) + + test("a panel opens the event", async ({ page }) => { + await page.goto("/hackathon") + await page.waitForLoadState("networkidle") + await page + .locator('a[href^="/hackathon/"]') + .filter({ hasText: SEED_HACKATHONS.h1.name }) + .first() + .click() + await expect(page).toHaveURL(/\/hackathon\/[0-9a-f-]+$/) + // The event page also renders a "Welcome to <name>" page heading, so + // match the hero exactly rather than by substring. + await expect( + page.getByRole("heading", { name: SEED_HACKATHONS.h1.name, exact: true }), + ).toBeVisible() + }) +}) + +test.describe("joining an event that asks questions", () => { + test.use({ storageState: storageStatePath("charles") }) + + test("lands on the organizer's registration form, or actually joins", async ({ + page, + }) => { + await page.goto("/dashboard") + await page.waitForLoadState("networkidle") + + // Not `test.skip` on zero. A skip here fired on EVERY run once the + // instance carried finished events, and a suite cannot tell "nothing to + // join" from "the gate is too strict" — both render zero buttons. The + // dashboard now offers Join only where it can succeed, so zero is a + // failure of the fixture or of the gate, and either deserves saying. + const joinable = page.getByRole("button", { name: "Join" }) + await expect( + joinable.first(), + "the dashboard offers charles no Join button at all — either every " + + "public event refuses registration, or joinIsOffered() is too strict. " + + "This test proves nothing without one.", + ).toBeVisible() + + await joinable.first().click() + await page.waitForLoadState("networkidle") + + // Joining is only half of signing up when an event asks for an affiliation + // or a code-of-conduct consent. Either the form opens, or the event asks + // nothing and the join completes — both are correct. + // + // ⚠ The else-branch must assert a POSITIVE success signal. It used to + // assert only `toHaveURL(/\/dashboard/)`, which a REFUSED join satisfies + // just as well — and that is what was happening: charles's target had its + // `register` capability disabled, so this spec drove a join that always + // failed and reported green for months. A disjunction is only a test when + // every branch is a success; one that also accepts the failure is a + // tautology. The two outcomes are distinguishable because the action + // renders them differently: role="status" on success, role="alert" on + // refusal. + if (page.url().includes("/register/")) { + await expect( + page.getByRole("heading", { name: /Registration|Your registration/ }), + ).toBeVisible() + await expect( + page.getByRole("button", { name: /Submit registration|Save changes/ }), + ).toBeVisible() + } else { + await expect(page).toHaveURL(/\/dashboard/) + await expect( + page.getByRole("status"), + 'a join that asks nothing must SAY it landed — "you\'re in", or which ' + + "place in the queue. Without this the branch passes on a refusal.", + ).toBeVisible() + await expect( + page.getByRole("alert"), + "the join was refused; the button should not have been offered", + ).toHaveCount(0) + } + }) +}) diff --git a/.claude/skills/hackathon-e2e/tests/smoke/09-signed-in-public.spec.ts b/.claude/skills/hackathon-e2e/tests/smoke/09-signed-in-public.spec.ts new file mode 100644 index 00000000..b2fde84e --- /dev/null +++ b/.claude/skills/hackathon-e2e/tests/smoke/09-signed-in-public.spec.ts @@ -0,0 +1,74 @@ +import { test, expect } from "@playwright/test" +import { storageStatePath } from "../../helpers/state.js" + +// The PUBLIC pages, visited by someone who is SIGNED IN. +// +// This combination had no coverage at all, and it is its own hazard: +// `hooks.server.ts` creates `locals.grpc` only for PROTECTED routes, so a +// public loader that reaches for it works for every anonymous visitor and +// throws a 500 for every logged-in one. That is exactly what shipped — the +// event page 500'd for anyone who followed a link while logged in, and every +// anonymous check in the suite passed. +// +// Anything public that a member can also reach belongs here. + +test.describe("public pages while signed in", () => { + test.use({ storageState: storageStatePath("alice") }) + + test("no route in the public shell answers 5xx", async ({ page }) => { + const failures: string[] = [] + page.on("response", (r) => { + if (r.status() >= 500) failures.push(`${r.status()} ${r.url()}`) + }) + + await page.goto("/") + await page.goto("/hackathon") + await page.waitForLoadState("networkidle") + + // Into the first event the browse page offers — the click a member makes + // when a colleague sends them a link. + const first = page.locator('main a[href*="/hackathon/"]').first() + if (await first.count()) { + await first.click() + await page.waitForURL(/\/hackathon\/[0-9a-f-]{36}/, { timeout: 15_000 }) + } + await page.goto("/about") + await page.waitForLoadState("networkidle") + + expect(failures, `server errors:\n${failures.join("\n")}`).toEqual([]) + }) + + test("the nav still offers Dashboard from a public page", async ({ + page, + }) => { + await page.goto("/hackathon") + const nav = page.locator("header").getByRole("navigation").first() + + // The public shell renders the same NavBar; a member on it must be able to + // get back to their own events without editing the URL. + await expect( + nav.getByRole("link", { name: "Dashboard", exact: true }), + ).toBeVisible() + await nav.getByRole("link", { name: "Dashboard", exact: true }).click() + await expect(page).toHaveURL(/\/dashboard$/) + }) + + test("an event page offers the member their own view", async ({ page }) => { + await page.goto("/dashboard") + const mine = page + .locator("section") + .filter({ has: page.getByRole("heading", { name: "Your hackathons" }) }) + .locator('a[href*="/my/hackathon/"]') + .first() + test.skip((await mine.count()) === 0, "alice is in no hackathon here") + + const id = (await mine.getAttribute("href"))!.split("/")[3] + await page.goto(`/hackathon/${id}`) + + // Not "Join": she is already in. The call to action is the one thing on + // this page that depends on who is reading it. + await expect( + page.getByRole("link", { name: "Open your event view" }), + ).toBeVisible() + }) +}) diff --git a/.claude/skills/hackathon-e2e/tests/smoke/10-login-recovery.spec.ts b/.claude/skills/hackathon-e2e/tests/smoke/10-login-recovery.spec.ts new file mode 100644 index 00000000..0a5846e9 --- /dev/null +++ b/.claude/skills/hackathon-e2e/tests/smoke/10-login-recovery.spec.ts @@ -0,0 +1,52 @@ +import { test, expect } from "@playwright/test" + +// Getting back out of the password step. +// +// Keycloak's identity-first flow asks for the username, then shows a SECOND +// page with that username greyed out and the password field below. If you +// mistyped the username there is exactly one way back — and it shipped as a +// bare ↻ glyph whose only label was a hover tooltip, so people did not +// recognise it and reported being stuck on the wrong account. + +test.describe("password step: changing the username", () => { + test.use({ storageState: { cookies: [], origins: [] } }) + + test("offers a labelled way back that actually restarts the login", async ({ + page, + }) => { + await page.goto("/") + await page.waitForLoadState("networkidle") + await page.getByRole("button", { name: "Log in" }).click() + await page.waitForURL(/\/realms\/hackagon\//, { timeout: 45_000 }) + + // Identity-first: username, then the password page. + await page.locator("#username").fill("wrong-person") + if (!(await page.locator("#password").isVisible())) { + await page.locator("#kc-login").click() + await page.locator("#password").waitFor({ timeout: 20_000 }) + } + + const back = page.locator("#reset-login") + await expect(back).toBeVisible() + // A word, not just an icon: this is the whole point of the fix. The text + // comes from CSS (the theme extends keycloak.v2 without copying its + // templates), so it is asserted through the rendered pseudo-element. + const label = await back.evaluate( + (e) => getComputedStyle(e, "::before").content, + ) + expect(label).toContain("Change") + // It must sit inside the button, not float over the icon — PatternFly owns + // ::after on buttons for its border overlay, and a label put there escaped. + const fits = await back.evaluate((e) => { + const r = e.getBoundingClientRect() + + return r.width > 60 && r.height > 20 + }) + expect(fits, "the labelled control should size to its text").toBe(true) + + await back.click() + // Back to a fresh username prompt, nothing carried over. + await expect(page.locator("#username")).toBeEditable() + await expect(page.locator("#password")).toHaveCount(0) + }) +}) diff --git a/.claude/skills/hackathon-e2e/tests/smoke/11-profile.spec.ts b/.claude/skills/hackathon-e2e/tests/smoke/11-profile.spec.ts new file mode 100644 index 00000000..70513621 --- /dev/null +++ b/.claude/skills/hackathon-e2e/tests/smoke/11-profile.spec.ts @@ -0,0 +1,37 @@ +import { test, expect } from "@playwright/test" +import { storageStatePath } from "../../helpers/state.js" + +test.use({ storageState: storageStatePath("alice") }) + +test("the platform profile saves and survives the next request", async ({ + page, +}) => { + await page.goto("/account") + await page.getByLabel("Affiliation").fill("ETH Zurich") + await page.getByLabel("Skills").fill("Python, ML") + await page.getByLabel("Dietary requirements").fill("vegetarian") + await page.getByLabel("Profile picture").fill("https://example.org/me.png") + await page.getByRole("button", { name: "Save", exact: true }).click() + await expect(page.getByText("Saved.")).toBeVisible() + + // The real test: WhoAmI runs on every protected page, and it used to re-sync + // profile fields from the token — which silently reverted any edit. + await page.goto("/dashboard") + await page.goto("/account") + await expect(page.getByLabel("Affiliation")).toHaveValue("ETH Zurich") + await expect(page.getByLabel("Dietary requirements")).toHaveValue( + "vegetarian", + ) + + // Clearing must stick too — a min_len would have made this impossible. + await page.getByLabel("Dietary requirements").fill("") + await page.getByRole("button", { name: "Save", exact: true }).click() + await expect(page.getByText("Saved.")).toBeVisible() + await page.reload() + await expect(page.getByLabel("Dietary requirements")).toHaveValue("") + + // And a javascript: avatar is refused rather than stored. + await page.getByLabel("Profile picture").fill("javascript:alert(1)") + await page.getByRole("button", { name: "Save", exact: true }).click() + await expect(page.getByRole("alert")).toContainText(/http/) +}) diff --git a/.claude/skills/hackathon-e2e/tests/smoke/12-roles.spec.ts b/.claude/skills/hackathon-e2e/tests/smoke/12-roles.spec.ts new file mode 100644 index 00000000..c2432005 --- /dev/null +++ b/.claude/skills/hackathon-e2e/tests/smoke/12-roles.spec.ts @@ -0,0 +1,91 @@ +import { test, expect } from "@playwright/test" +import { storageStatePath } from "../../helpers/state.js" + +// Granting and revoking a global role from /manage/users. +// +// This page has shipped calling AddRole/RemoveRole while both were +// Unimplemented stubs on the backend — its error handler catches +// PERMISSION_DENIED / NOT_FOUND / INVALID_ARGUMENT and lets anything else +// through, so every click rendered a 500. Nothing tested it because nothing +// had ever pressed the button. + +test.describe("global roles", () => { + test.use({ storageState: storageStatePath("admin") }) + + test("an admin can promote and demote a member", async ({ page }) => { + await page.goto("/manage/users") + await page.waitForLoadState("networkidle") + + const row = page.locator("tr").filter({ hasText: "Bob Henderson" }) + await expect(row).toBeVisible() + + // Grant. The control is either a single "Grant <role>" button or a picker, + // depending on how many roles the person is missing. + const grant = row.getByRole("button", { name: /Grant/ }) + const picker = row.locator("select[name=role]") + if (await picker.count()) { + await picker.selectOption("2") // Hackathon Organizer + } + await grant.click() + await page.waitForLoadState("networkidle") + + await expect( + page.locator("tr").filter({ hasText: "Bob Henderson" }).locator(".badge"), + "the granted role should show as a badge on his row", + ).toContainText(/Organizer/i) + + // And take it away again. + const after = page.locator("tr").filter({ hasText: "Bob Henderson" }) + await after + .getByRole("button", { name: /Revoke|Remove/ }) + .first() + .click() + await page.waitForLoadState("networkidle") + // The BADGE, not the row: the row also holds the grant picker, whose + // options are named after the very roles being asserted about. Twice now + // that has made a row-level assertion say what the tester hoped rather + // than what the page shows. + await expect( + page.locator("tr").filter({ hasText: "Bob Henderson" }).locator(".badge"), + ).toHaveCount(0) + }) + + test("an admin is not offered a control to demote themselves", async ({ + page, + }) => { + await page.goto("/manage/users") + await page.waitForLoadState("networkidle") + + // Admin is the only role that can grant Admin, so the last one to do this + // would lock every admin task out of the platform. The backend refuses it + // unconditionally and the page hides the control, so this asserts absence. + // + // It used to click the control and expect the error, with a `test.skip` + // when none rendered — which is the ONLY path it ever took, so it reported + // green while proving nothing. An assertion that opts out when the thing it + // needs is missing is not a test. + const mine = page.locator("tr").filter({ hasText: "Hackagon Admin" }) + await expect(mine.first()).toBeVisible() + await expect( + mine.getByRole("button", { name: "Revoke Admin" }), + "own-Admin revoke is hidden, not merely disabled", + ).toHaveCount(0) + + // Someone ELSE's row does offer one, so the absence above is about self and + // not about the control having quietly disappeared for everybody. + const other = page.locator("tr").filter({ hasText: "Bob Henderson" }) + const picker = other.locator("select[name=role]") + if (await picker.count()) await picker.selectOption("2") + await other.getByRole("button", { name: /Grant/ }).click() + await page.waitForLoadState("networkidle") + + const granted = page.locator("tr").filter({ hasText: "Bob Henderson" }) + await expect(granted.getByRole("button", { name: /^Revoke/ })).toHaveCount( + 1, + ) + + // Leave the fixture as we found it. + await granted.getByRole("button", { name: /^Revoke/ }).click() + await page.waitForLoadState("networkidle") + }) +}) diff --git a/.claude/skills/hackathon-e2e/tests/smoke/13-owners.spec.ts b/.claude/skills/hackathon-e2e/tests/smoke/13-owners.spec.ts new file mode 100644 index 00000000..7f53da62 --- /dev/null +++ b/.claude/skills/hackathon-e2e/tests/smoke/13-owners.spec.ts @@ -0,0 +1,115 @@ +import { test, expect, type Page } from "@playwright/test" +import { storageStatePath } from "../../helpers/state.js" +import { myHackathonId } from "../../helpers/discover.js" +import { PERSONAS, SEED_HACKATHONS } from "../../personas.js" + +// Promoting a participant to co-organizer, and demoting them again. +// +// AddOwner/RemoveOwner were proto-only stubs returning Unimplemented, with no +// caller anywhere — the last of the B15 audit's dormant RPCs. Unlike +// AddRole/RemoveRole they were never a shipped 500, because nothing had ever +// called them; the bug was that a hackathon's organizer could not recruit a +// co-organizer at all, so a global admin had to be in the loop for every event. +// +// Ownership on this branch is a casbin fact, not a column, so what these +// assertions really check is that the role the RPC writes is the one +// ListMembers reads back. +// +// Asserted against the CARD view, which is what the page opens on. The table is +// the same two snippets. + +const BOB = PERSONAS.bob.displayName +const ALICE = PERSONAS.alice.displayName + +/** Alice owns h1; bob is a confirmed member of it. */ +async function participantsPage(page: Page): Promise<void> { + const id = await myHackathonId(page, SEED_HACKATHONS.h1.name) + await page.goto(`/my/hackathon/${id}/participants`) + await page.waitForLoadState("networkidle") +} + +function card(page: Page, name: string) { + return page.locator(".card").filter({ hasText: name }).first() +} + +/** + * The role LINE, not the card. + * + * The card also holds a button named "Make organizer", so a card-level + * /organizer/i assertion is true whether or not the person is one — it passed + * on promotion for the wrong reason and failed on demotion for the right one. + * Third time this family of mistake has shown up; assert on the element that + * states the fact. + */ +function role(page: Page, name: string) { + return card(page, name).locator("p").first() +} + +test.describe.configure({ mode: "serial" }) + +test.describe("hackathon co-organizers", () => { + test.use({ storageState: storageStatePath("alice") }) + + test("an organizer can promote a member and demote them again", async ({ + page, + }) => { + await participantsPage(page) + + await expect(card(page, BOB)).toBeVisible() + await card(page, BOB) + .getByRole("button", { name: "Make organizer" }) + .click() + await page.waitForLoadState("networkidle") + + await expect( + role(page, BOB), + "the role line should read back the casbin role the RPC wrote", + ).toHaveText("Owner") + await expect( + card(page, BOB).getByRole("button", { name: "Make organizer" }), + "already an owner — the promote control should be gone", + ).toHaveCount(0) + + // And back down. Demote restores Member rather than leaving the person + // with no role at all, which renders as a participant with a blank role. + await card(page, BOB).getByRole("button", { name: "Step down" }).click() + await page.waitForLoadState("networkidle") + + await expect( + role(page, BOB), + "demoted to Member, not to no role at all", + ).toHaveText("Member") + await expect( + card(page, BOB).getByRole("button", { name: "Make organizer" }), + "promotable again", + ).toHaveCount(1) + }) + + test("an organizer is not offered a control to demote themselves", async ({ + page, + }) => { + await participantsPage(page) + + // Promote bob first, so a second owner exists. Without that the assertion + // below would pass on the last-organizer guard instead of the self guard, + // and would keep passing if the self guard were deleted. + await card(page, BOB) + .getByRole("button", { name: "Make organizer" }) + .click() + await page.waitForLoadState("networkidle") + await expect( + card(page, BOB).getByRole("button", { name: "Step down" }), + "with two owners, demotion is on offer for the OTHER one", + ).toHaveCount(1) + + // Same reasoning as Admin in /manage/users: the permission you would give + // up is the one that would let you undo it. The backend refuses it, and the + // page does not offer it, so the refusal is never how you find out. + await expect( + card(page, ALICE).getByRole("button", { name: "Step down" }), + ).toHaveCount(0) + + await card(page, BOB).getByRole("button", { name: "Step down" }).click() + await page.waitForLoadState("networkidle") + }) +}) diff --git a/.claude/skills/hackathon-e2e/tests/smoke/14-nav-centering.spec.ts b/.claude/skills/hackathon-e2e/tests/smoke/14-nav-centering.spec.ts new file mode 100644 index 00000000..4116db63 --- /dev/null +++ b/.claude/skills/hackathon-e2e/tests/smoke/14-nav-centering.spec.ts @@ -0,0 +1,57 @@ +import { test, expect, type Page } from "@playwright/test" +import { storageStatePath } from "../../helpers/state.js" + +// The primary nav must sit in the middle of the bar whether or not you are +// signed in. +// +// It used to be a three-child flexbox with justify-between, which centres the +// middle child only when the two outer children happen to be the same width. +// They never are once you sign in: the right side gains a monogram, a display +// name and a sign-out control, so the nav visibly slid left the moment you +// logged in. It is a 3-column grid now (1fr auto 1fr), which centres on the +// VIEWPORT regardless of what either side holds. +// +// Asserted as geometry, not as a class name: a class assertion would pass for +// a grid that had been restyled back into a shifted layout. + +const TOLERANCE = 24 // px — half a character of drift is not a bug + +async function navOffset(page: Page): Promise<number> { + const nav = page.locator("header nav").first() + await expect(nav).toBeVisible() + const box = await nav.boundingBox() + const width = page.viewportSize()!.width + if (!box) throw new Error("nav has no bounding box") + const navCentre = box.x + box.width / 2 + return navCentre - width / 2 +} + +test.describe("nav centering", () => { + test("is centred for a signed-out visitor", async ({ page }) => { + await page.goto("/") + await page.waitForLoadState("networkidle") + expect(Math.abs(await navOffset(page))).toBeLessThanOrEqual(TOLERANCE) + }) + + test.describe("signed in", () => { + test.use({ storageState: storageStatePath("bob") }) + + test("is still centred once the right side gains a name and sign-out", async ({ + page, + }) => { + await page.goto("/dashboard") + await page.waitForLoadState("networkidle") + + // The condition that used to break it: a non-empty right-hand group. + // The control says "Log out", not "Sign out" — asserting the precondition + // rather than assuming it is what turned that into one clear failure + // instead of a centred-looking pass on a signed-OUT bar. + await expect( + page.getByRole("button", { name: /log ?out/i }).first(), + "this test is only meaningful while the signed-in bar is wider on the right", + ).toBeVisible() + + expect(Math.abs(await navOffset(page))).toBeLessThanOrEqual(TOLERANCE) + }) + }) +}) diff --git a/.claude/skills/hackathon-e2e/tests/smoke/15-media-upload.spec.ts b/.claude/skills/hackathon-e2e/tests/smoke/15-media-upload.spec.ts new file mode 100644 index 00000000..945f47fc --- /dev/null +++ b/.claude/skills/hackathon-e2e/tests/smoke/15-media-upload.spec.ts @@ -0,0 +1,103 @@ +import { test, expect } from "@playwright/test" +import { storageStatePath } from "../../helpers/state.js" +import { myHackathonId } from "../../helpers/discover.js" +import { SEED_HACKATHONS } from "../../personas.js" + +// Uploading an image from the page editor. +// +// Until now the only uploader in the UI was the event logo, so a gallery page +// could only reference images hosted somewhere else — which is exactly the +// state docs/storage.md was written to end. The editor presigns, the BROWSER +// PUTs straight to the object store, and the markdown gets the returned public +// path. +// +// The assertion that matters is the last one: that the uploaded bytes are +// actually readable back at the URL written into the page. A presign that +// returns a URL proves nothing — past bugs in this exact path stored images as +// `application/x-www-form-urlencoded`, and uploaded nothing at all while +// reporting success. + +// A 1x1 PNG. Inline rather than a fixture file so the test has no on-disk +// dependency that could drift or go missing. +const PNG = Buffer.from( + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==", + "base64", +) + +test.describe("media upload", () => { + test.use({ storageState: storageStatePath("alice") }) + + test("an organiser uploads an image into a page and it is readable back", async ({ + page, + request, + }) => { + const id = await myHackathonId(page, SEED_HACKATHONS.h1.name) + await page.goto(`/my/hackathon/${id}/pages/new`) + await page.waitForLoadState("networkidle") + + const content = page.locator("textarea[name=content]") + await expect(content).toBeVisible() + await expect( + content, + "the editor should start empty so the assertion below is about OUR upload", + ).toHaveValue("") + + // The control is a <label> wrapping a hidden input — a styled button cannot + // open a file picker — so the input is set directly. + await page.locator("input[type=file]").setInputFiles({ + name: "venue-photo.png", + mimeType: "image/png", + buffer: PNG, + }) + + // Check the editor's own error line FIRST. Without this the failure is + // "the textarea is still empty", which is the symptom; the component + // already knows the cause and puts it on screen. + const uploadError = page + .locator("[role=alert]") + .filter({ hasText: /upload|store|permission|MiB|type/i }) + await expect + .poll( + async () => + (await uploadError.count()) > 0 || + (await content.inputValue()) !== "", + { + timeout: 20_000, + message: "neither an inserted image nor an error appeared", + }, + ) + .toBe(true) + if (await uploadError.count()) { + throw new Error( + `upload reported: ${await uploadError.first().innerText()}`, + ) + } + + // The markdown gains an image pointing at the object store. + await expect(content).toHaveValue(/!\[.*\]\(\/objects\/[^)]+\)/, { + timeout: 20_000, + }) + + const value = await content.inputValue() + const url = value.match(/!\[.*\]\((\/objects\/[^)]+)\)/)?.[1] + expect(url, "an /objects path should have been inserted").toBeTruthy() + + // Alt text from the filename, because an empty alt on a content image is a + // hole for anyone using a screen reader. + expect(value).toContain("venue photo") + + // The bytes are really there. Content-Type too: an image stored under the + // wrong type is one a browser refuses to render, which looks like a broken + // upload long after the upload succeeded. + const got = await request.get(url!) + expect(got.status()).toBe(200) + // WebP, not PNG: the editor re-encodes in the browser before presigning, + // because the size and content type are conditions ON the signature — + // converting after signing would guarantee a mismatch. A 1x1 PNG is the + // one case where the result could come out LARGER, and the converter + // keeps the original when it does, so accept either type and assert the + // stored bytes are a real image rather than pinning the exact encoding. + expect(got.headers()["content-type"]).toMatch(/^image\/(webp|png)$/) + expect((await got.body()).length).toBeGreaterThan(0) + }) +}) diff --git a/.claude/skills/hackathon-e2e/tests/smoke/16-image-upload.spec.ts b/.claude/skills/hackathon-e2e/tests/smoke/16-image-upload.spec.ts new file mode 100644 index 00000000..7eafe4f3 --- /dev/null +++ b/.claude/skills/hackathon-e2e/tests/smoke/16-image-upload.spec.ts @@ -0,0 +1,224 @@ +import { test, expect } from "@playwright/test" +import { storageStatePath } from "../../helpers/state.js" +import { myHackathonId } from "../../helpers/discover.js" +import { generateLogoPng } from "../../helpers/files.js" +import { SEED_HACKATHONS } from "../../personas.js" + +// The two uploaders a person meets first: their own profile picture, and the +// event logo. +// +// Both surfaces existed before this suite and neither worked as a person would +// find it. The account page said, in so many words, "a link, not an upload — +// there is no file storage yet", months after the store landed. The event logo +// DID have an uploader — a bare `<input type=file>` tucked under a full-width +// URL box, which organisers read as "there is only a URL field". So these tests +// assert two different things: that the control is reachable, and that the +// bytes are really there afterwards. +// +// RE-SPECIFIED when the picker dialog landed. The file input is no longer on +// the page: `ImageUploadField` now renders ONE button that opens a modal with +// two ways in — upload (with drag and drop) and, where a listing scope exists, +// whatever is already stored. So each test opens the dialog first. That extra +// click is the point of the change and not overhead: the whole reason for the +// dialog is that a bare file input could only ever offer one of those two. +// +// The last assertion in each test is the one that matters. A presign returning +// a URL proves nothing — this exact path has previously stored images as +// `application/x-www-form-urlencoded`, and uploaded nothing at all while +// reporting success. So each test READS THE STORED URL BACK and checks the +// status and the Content-Type. + +/** Deterministic 96x96 PNG. Same bytes on every machine, every run. */ +const PNG = generateLogoPng(4711) + +/** An /objects path, not an http URL: the DB stores a path, never a presign. */ +const STORED_PATH = /^\/objects\/\S+$/ + +test.describe("profile picture", () => { + // Bob is a plain Member. Deliberate: an avatar is the one upload with no + // hackathon to scope a permission to, so it authorizes on identity — and + // "you, or a global admin" has to include somebody with no roles at all. + test.use({ storageState: storageStatePath("bob") }) + + test("bob uploads a profile picture and it is readable back", async ({ + page, + request, + }) => { + await page.goto("/account") + + const field = page.getByLabel("Profile picture") + await expect(field).toBeVisible() + + // Against the value that is there BEFORE, never against the shape of a + // stored path. A --no-reset rerun starts with the picture the last run + // saved, so "the field matches /^\/objects\//" is already true and this + // test would capture the OLD path, save the NEW one, and fail comparing + // them — which is exactly how it failed the first time it was re-run. + const before = await field.inputValue() + + // One button opens the picker. Clicked, not `goto`-ed past: a control that + // renders is not the same claim as a control you can reach, which is the + // lesson the account menu taught this suite. + await page.getByRole("button", { name: "Upload a picture" }).click() + + const dialog = page.getByRole("dialog") + await expect(dialog).toBeVisible() + // Upload-only here, and deliberately: NO listing scope covers + // `users/<id>/avatar/`, because that prefix is other people's faces. So the + // gallery half must NOT be offered — asserted alongside the positive below, + // since "no gallery tab" also passes on a dialog that failed to render. + await expect( + dialog.getByRole("button", { name: "Choose from gallery" }), + ).toHaveCount(0) + await expect( + dialog.getByRole("region", { name: /drop an image here/i }), + ).toBeVisible() + + // The control inside the dialog is a <label> wrapping a hidden input — a + // styled button cannot open a file picker — so the input is set directly. + // Its accessible name deliberately shares no words with "Profile picture": + // a page-wide getByLabel would otherwise match the file control too and the + // field above would stop being addressable. + await page + .getByLabel("Choose an image file for your account") + .setInputFiles({ + name: "bob-face.png", + mimeType: "image/png", + buffer: PNG, + }) + + // Check the uploader's own error line FIRST. Without this the failure is + // "the field did not change", which is the symptom; the component already + // knows the cause and puts it on screen. + const uploadError = page + .locator("[role=alert]") + .filter({ hasText: /upload|storage|permission|bytes|type/i }) + await expect + .poll( + async () => + (await uploadError.count()) > 0 || + (await field.inputValue()) !== before, + { + timeout: 20_000, + message: "neither a new stored path nor an error appeared", + }, + ) + .toBe(true) + if (await uploadError.count()) { + throw new Error( + `upload reported: ${await uploadError.first().innerText()}`, + ) + } + + const stored = await field.inputValue() + expect(stored).toMatch(STORED_PATH) + expect(stored).not.toBe(before) + // Filed under this person's own prefix — which is what makes + // DeleteAccount's purge complete without a manifest. + expect(stored).toContain("/users/") + expect(stored).toContain("/avatar/") + + // Saving it is a separate act, and it is the half that used to fail: + // EditProfile validated avatar_url as http/https ONLY, so the upload + // succeeded, the bytes were stored, and saving the path came back + // INVALID_ARGUMENT. Reloading proves it reached the database. + await page.getByRole("button", { name: "Save", exact: true }).click() + await expect(page.getByText("Saved.")).toBeVisible() + await page.reload() + await expect(page.getByLabel("Profile picture")).toHaveValue(stored) + + // It is his PICTURE now, not just a string in a field. + await expect(page.locator(`img[src="${stored}"]`).first()).toBeVisible() + + // And the bytes are really there. Content-Type too: an image stored under + // the wrong type is one a browser refuses to render, which looks like a + // broken upload long after the upload succeeded. + const got = await request.get(stored) + expect(got.status()).toBe(200) + // WebP, not PNG: the browser re-encodes before presigning, because the + // size and content type are conditions ON the signature. The converter + // keeps the original whenever WebP would come out larger, so accept either + // and assert the stored object is a real image rather than pinning an + // encoding. + expect(got.headers()["content-type"]).toMatch(/^image\/(webp|png)$/) + expect((await got.body()).length).toBeGreaterThan(0) + }) +}) + +test.describe("event logo", () => { + test.use({ storageState: storageStatePath("alice") }) + + test("an organiser uploads a logo, saves it, and it is readable back", async ({ + page, + request, + }) => { + const id = await myHackathonId(page, SEED_HACKATHONS.h1.name) + await page.goto(`/my/hackathon/${id}/manage/edit`) + + const field = page.getByLabel("Logo (optional)") + await expect(field).toBeVisible() + + // The seed already gives every event a cover, so "the field holds an + // /objects path" would pass without this test doing anything. Everything + // below is stated against the value that was there BEFORE. + const before = await field.inputValue() + + await page.getByRole("button", { name: "Choose a logo" }).click() + const dialog = page.getByRole("dialog") + await expect(dialog).toBeVisible() + // This surface DOES have a listing scope — an event's own media — so both + // ways in are on offer. Its own test is below; here it is the control that + // proves the avatar dialog's missing tab above is a decision and not a + // dialog that simply never renders one. + await expect( + dialog.getByRole("button", { name: "Choose from gallery" }), + ).toBeVisible() + + const filePicker = page.getByLabel( + "Choose an image file for the event logo", + ) + + // First, the refusal — and that it happens BEFORE any transfer. Size and + // content type are conditions on the presign, so a PDF is turned away by + // the backend at the signing step rather than stored and discovered later. + await filePicker.setInputFiles({ + name: "not-an-image.pdf", + mimeType: "application/pdf", + buffer: Buffer.from("%PDF-1.4 not really"), + }) + await expect( + page.locator("[role=alert]").filter({ hasText: /content type/i }), + ).toBeVisible({ timeout: 20_000 }) + await expect( + field, + "a refused file must not change the stored value", + ).toHaveValue(before) + + // Now a real one. + await filePicker.setInputFiles({ + name: "event-logo.png", + mimeType: "image/png", + buffer: PNG, + }) + await expect(field).not.toHaveValue(before, { timeout: 20_000 }) + + const stored = await field.inputValue() + expect(stored).toMatch(STORED_PATH) + // The key the backend derived, from the id in the path — not the seed's. + expect(stored).toContain(`/hackathons/${id}/logo/`) + + // Uploading stores the object; the form is what stores the PATH. Saving + // redirects to Manage Hackathon — the page this form is reached from, and no + // longer the dashboard — so come back and read it off the form. + await page.getByRole("button", { name: "Save changes" }).click() + await page.waitForURL(`**/my/hackathon/${id}/manage`) + await page.goto(`/my/hackathon/${id}/manage/edit`) + await expect(page.getByLabel("Logo (optional)")).toHaveValue(stored) + await expect(page.locator(`img[src="${stored}"]`).first()).toBeVisible() + + const got = await request.get(stored) + expect(got.status()).toBe(200) + expect(got.headers()["content-type"]).toMatch(/^image\/(webp|png)$/) + expect((await got.body()).length).toBeGreaterThan(0) + }) +}) diff --git a/.claude/skills/hackathon-e2e/tests/smoke/17-site-page-editor.spec.ts b/.claude/skills/hackathon-e2e/tests/smoke/17-site-page-editor.spec.ts new file mode 100644 index 00000000..d0ead516 --- /dev/null +++ b/.claude/skills/hackathon-e2e/tests/smoke/17-site-page-editor.spec.ts @@ -0,0 +1,396 @@ +import { test, expect, type Locator } from "@playwright/test" +import { PERSONAS, SEED_HACKATHONS } from "../../personas.js" +import { anonymousContext, contextFor } from "../../helpers/login.js" +import { storageStatePath } from "../../helpers/state.js" +import { generateLogoPng } from "../../helpers/files.js" +import { myHackathonId } from "../../helpers/discover.js" + +// The platform-pages editor (/manage/pages), reported by a user in three parts: +// +// "the text entry box is the same background and doesn't extend to the size +// of the width, can we have a button to preview the markdown, and also the +// upload button we have in others sections?" +// +// All three were one omission. /manage/pages was the last markdown surface in +// the app still using a bare <textarea> instead of MarkdownEditor — which has +// the Write/Preview tabs and the image button — and that textarea carried +// `field-area` WITHOUT `field`, i.e. only the multi-line modifier and none of +// the recipe that draws a box. Tailwind's preflight makes a bare textarea +// transparent, borderless and `cols`-wide, so it rendered as an invisible box +// the colour of the card behind it, about twenty characters across. +// +// Each test therefore states one of the three complaints as a fact about the +// element that carries it, never about a container that merely holds the word. + +/** Deterministic 96x96 PNG — same bytes on every machine, every run. */ +const PNG = generateLogoPng(1707) + +const SLUG = "platform-image-test" +const TITLE = "Platform image test" + +/** The create form. Every existing row also carries name=content/name=slug + * inputs in its edit form, so an unscoped locator is ambiguous. */ +function createForm(page: import("@playwright/test").Page): Locator { + return page.locator('form[action="?/create"]') +} + +/** + * "This textarea draws as a control, not as a hole in the page." + * + * Measured in the browser because this is a CSS fault and nothing in the markup + * would have shown it. Note the FIRST assertion: "its background differs from + * the card's" would have passed on the broken version too — transparent is a + * different value from the card's colour. The fault is that it has no colour of + * its own, so that is what gets asserted. + */ +async function expectDrawsAsField(area: Locator, what: string): Promise<void> { + // A hidden textarea measures 0 wide and would make the ratio meaningless, so + // prove it is the pane on screen first. + await expect(area).toBeVisible() + + const box = await area.evaluate((el) => { + const own = getComputedStyle(el) + const behind = el.closest("form, .card") + const wrapper = el.parentElement + + return { + background: own.backgroundColor, + borderTopWidth: own.borderTopWidth, + behindBackground: behind + ? getComputedStyle(behind).backgroundColor + : "none", + width: el.getBoundingClientRect().width, + wrapperWidth: wrapper ? wrapper.getBoundingClientRect().width : 0, + } + }) + + expect( + box.background, + `${what}: must have its own background, not the page's`, + ).not.toMatch(/^(transparent|rgba\(0, 0, 0, 0\))$/) + expect( + box.background, + `${what}: must not be the same colour as what it sits on`, + ).not.toBe(box.behindBackground) + expect( + parseFloat(box.borderTopWidth), + `${what}: must have a visible border`, + ).toBeGreaterThan(0) + + // Full width of the space it was given. Broken, a textarea falls back to its + // `cols` default — roughly 20 characters, a fifth of the form. + expect(box.wrapperWidth).toBeGreaterThan(200) + expect( + box.width / box.wrapperWidth, + `${what}: must fill the width available to it`, + ).toBeGreaterThan(0.98) +} + +/** Removes the page this spec creates, so the shared seed is left as found. */ +async function deletePageIfPresent( + page: import("@playwright/test").Page, +): Promise<void> { + await page.goto("/manage/pages") + await page.waitForLoadState("networkidle") + const row = page.locator(".card", { hasText: TITLE }) + if ((await row.count()) > 0) { + await row + .first() + .locator('form[action="?/delete"]') + .getByRole("button") + .click() + await expect(page.locator(".card", { hasText: TITLE })).toHaveCount(0) + } +} + +// Deliberately NOT serial, unlike 06-cms-pages. These four assert about four +// independent things, and the upload test clears its own leftovers on the way +// IN — so nothing needs the run to be a chain. Under `mode: "serial"` a red +// styling test skipped the upload and authorization tests entirely, which is +// the wrong trade: one broken CSS class would hide whether uploads still work. +// The config runs one worker, so they stay in order regardless. + +test.describe("platform page editor", () => { + test.use({ storageState: storageStatePath(PERSONAS.admin.key) }) + + // --- complaint 1: the box ------------------------------------------------ + // + // Both a narrow and a wide viewport: "fills the width" is the half of the + // complaint that only means something once there is width to fill. + for (const width of [390, 1280]) { + test(`the content editor draws as a real field at ${width}px`, async ({ + page, + }) => { + await page.setViewportSize({ width, height: 900 }) + await page.goto("/manage/pages") + await page.waitForLoadState("networkidle") + await page.getByRole("button", { name: "New page" }).click() + + await expectDrawsAsField( + createForm(page).locator("textarea[name=content]"), + "the platform page editor", + ) + }) + } + + // --- complaint 2: preview, and the attack surface it adds ---------------- + test("Preview renders the markdown and does not execute it", async ({ + page, + }) => { + await page.goto("/manage/pages") + await page.waitForLoadState("networkidle") + await page.getByRole("button", { name: "New page" }).click() + + const form = createForm(page) + // The same payload act0.about.xss pastes into a real page. The preview is a + // NEW place this content gets rendered — in the admin's own browser, with + // the admin's session — so it has to go through the same sanitizing + // pipeline the public page does. + await form + .locator("textarea[name=content]") + .fill( + [ + "## Be excellent to each other", + "", + "Harassment is **not tolerated** at any Hackagon event.", + "", + "<script>window.__pwned = true</script>", + "", + '<img src=x onerror="window.__pwned = true">', + ].join("\n"), + ) + + // Set the sentinel AFTER typing and BEFORE rendering, so a truthy value can + // only have come from the preview. + await page.evaluate(() => { + ;(window as unknown as { __pwned?: boolean }).__pwned = false + }) + + await form.getByRole("button", { name: "Preview" }).click() + + // Markdown was PARSED, not printed. Assert the rendered elements, not the + // text: the textarea still holds the source, so a text match would pass + // whether or not anything rendered. + const preview = form.locator(".markdown-content") + await expect(preview.locator("h2")).toHaveText("Be excellent to each other") + await expect(preview.locator("strong")).toHaveText("not tolerated") + + // Nothing survived that could execute. These two are the load-bearing + // assertions and they are DELIBERATELY FIRST: they read the DOM, so they are + // deterministic. Verified against a build with DOMPurify removed from + // renderMarkdown — the script count came back 1. + await expect( + preview.locator("script"), + "no script tag may survive sanitizing", + ).toHaveCount(0) + expect( + await preview.locator("*[onerror]").count(), + "no onerror handler may survive sanitizing", + ).toBe(0) + + // The runtime sentinel is a SECONDARY check, and its limit is worth writing + // down rather than trusting: it was originally first, and against that same + // unsanitized build it read `false` and reported nothing. `<script>` inserted + // through innerHTML never runs at all, and the `onerror` fires only once the + // image load has failed — asynchronously — so reading the flag straight after + // the click races the very thing it is watching for. The wait gives it its + // chance; the two assertions above are what actually hold the line. + await page.waitForTimeout(1000) + expect( + await page.evaluate( + () => (window as unknown as { __pwned?: boolean }).__pwned, + ), + "the pasted payload must not run in the preview", + ).toBe(false) + + // Write brings the source back — the tabs pick a pane, they do not commit + // anything, and the field must still be there to submit. + await form.getByRole("button", { name: "Write" }).click() + await expect(form.locator("textarea[name=content]")).toBeVisible() + }) + + // --- complaint 3: the upload button, end to end -------------------------- + test("an admin uploads an image into a platform page and it is readable back", async ({ + page, + request, + browser, + }) => { + await deletePageIfPresent(page) + await page.getByRole("button", { name: "New page" }).click() + + const form = createForm(page) + await form.locator('input[name="slug"]').fill(SLUG) + await form.locator('input[name="title"]').fill(TITLE) + + const content = form.locator("textarea[name=content]") + await content.fill("## Our venue\n") + const before = await content.inputValue() + + // The control is a <label> wrapping a hidden input — a styled button cannot + // open a file picker — so the input is set directly. + await form.locator("input[type=file]").setInputFiles({ + name: "venue-photo.png", + mimeType: "image/png", + buffer: PNG, + }) + + // The editor's own error line FIRST. Without it the failure reads "the + // textarea did not change", which is the symptom; the component already + // knows the cause and puts it on screen. + // + // Deliberately UNFILTERED, unlike its siblings in 15/16. Those filter on + // /upload|store|permission|…/ and this one did too — which hid the first + // real failure here behind "neither an inserted image nor an error + // appeared". The message was + // `validation error: owner_id: value is empty, which is not a valid UUID`, + // and it matched no word in the list. A filter on an error's WORDING can + // only recognise the errors somebody already thought of; the editor's alert + // is the only [role=alert] inside this form, so scoping is enough. + const uploadError = form.locator("[role=alert]") + await expect + .poll( + async () => + (await uploadError.count()) > 0 || + (await content.inputValue()) !== before, + { + timeout: 20_000, + message: "neither an inserted image nor an error appeared", + }, + ) + .toBe(true) + if (await uploadError.count()) { + throw new Error( + `upload reported: ${await uploadError.first().innerText()}`, + ) + } + + const value = await content.inputValue() + const url = value.match(/!\[.*\]\((\/objects\/[^)]+)\)/)?.[1] + expect(url, "an /objects path should have been inserted").toBeTruthy() + // Alt text from the filename the person chose: an empty alt on a content + // image is a hole for anyone using a screen reader. + expect(value).toContain("venue photo") + + // The key the BACKEND derived. `site/media/` is the whole point of the new + // upload kind — a platform page has no event to file media under, and + // borrowing HACKATHON_MEDIA would have needed a hackathon id and an + // organiser's Write permission that no site page can supply. + expect(url).toContain("/site/media/") + expect( + url, + "a platform page must not file media under an event", + ).not.toContain("/hackathons/") + + // The bytes are really there. Content-Type too: an image stored under the + // wrong type is one the browser refuses to render, which looks like a + // broken upload long after the upload succeeded. + const got = await request.get(url!) + expect(got.status()).toBe(200) + // WebP, not PNG: the browser re-encodes before presigning, because size and + // content type are conditions ON the signature. The converter keeps the + // original whenever WebP would come out larger, so accept either and assert + // the stored object is a real image rather than pinning an encoding. + expect(got.headers()["content-type"]).toMatch(/^image\/(webp|png)$/) + expect((await got.body()).length).toBeGreaterThan(0) + + // Publish it, and the picture is on the page for the public. Uploading + // stores the OBJECT; the form is what stores the path — two different acts, + // and the second is the half that has broken before elsewhere. + await form.locator('input[name="visible"]').check() + await form.getByRole("button", { name: "Create page" }).click() + await expect(page.locator(".card", { hasText: TITLE })).toBeVisible() + + const anon = await anonymousContext(browser) + const anonPage = await anon.newPage() + const resp = await anonPage.goto(`/${SLUG}`) + expect(resp?.status()).toBe(200) + await expect( + anonPage.locator(`.markdown-content img[src="${url}"]`), + "the uploaded image must render on the published page", + ).toBeVisible() + await anon.close() + + await deletePageIfPresent(page) + }) + + // --- the authorization rule the new kind added --------------------------- + // + // Site pages have no hackathon domain, so SITE_MEDIA authorizes on the GLOBAL + // Admin role — the same rule every SitePageService mutation uses. Asserted + // through the presign endpoint directly: a per-hackathon Owner is the exact + // near-miss that would pass if the kind had reused HACKATHON_MEDIA's check. + test("only a platform admin may presign a platform-page image", async ({ + page, + browser, + }) => { + const body = { + filename: "x.png", + contentType: "image/png", + sizeBytes: PNG.length, + } + + // Positive control first. Without it "403 for everybody" — a broken route, + // a wrong kind, a typo in the path — reads exactly like a working rule. + await page.goto("/manage/pages") + const asAdmin = await page.request.post("/manage/pages/media", { + data: body, + }) + expect(asAdmin.status(), await asAdmin.text()).toBe(200) + const presigned = (await asAdmin.json()) as { + uploadUrl?: string + publicUrl?: string + } + expect(presigned.uploadUrl).toBeTruthy() + expect(presigned.publicUrl).toContain("/site/media/") + + // alice owns a hackathon. That is a per-hackathon role and grants nothing + // here. + const aliceCtx = await contextFor(browser, PERSONAS.alice.key) + const asOrganizer = await aliceCtx.request.post("/manage/pages/media", { + data: body, + }) + expect( + asOrganizer.status(), + "an event organizer is not a platform admin", + ).toBe(403) + expect(await asOrganizer.text()).not.toContain("uploadUrl") + await aliceCtx.close() + + // Anonymous is a different answer from "not you": the (app) guard bounces + // them to login before the RPC is ever made. + const anon = await anonymousContext(browser) + const asAnon = await anon.request.post("/manage/pages/media", { + data: body, + maxRedirects: 0, + }) + expect([302, 303, 401, 403]).toContain(asAnon.status()) + expect(await asAnon.text()).not.toContain("uploadUrl") + await anon.close() + }) +}) + +// The SAME fault, away from the page that was reported. `field-area` used to be +// a modifier that drew nothing on its own, and eight textareas across four +// routes carried it alone — /manage/pages was simply the one somebody complained +// about. Mounting MarkdownEditor fixed that page (its textarea spells +// `field field-area`); it did nothing for the other seven, which is why the +// class itself now carries the whole recipe. +// +// This test is what pins that half. It is deliberately on a different route with +// a different persona: without it, reverting the CSS change would leave the +// suite green, because every assertion above is satisfied by the `field` that +// MarkdownEditor adds. +test.describe("textareas elsewhere draw as fields too", () => { + test.use({ storageState: storageStatePath(PERSONAS.alice.key) }) + + test("the email-template message box is a real field", async ({ page }) => { + const id = await myHackathonId(page, SEED_HACKATHONS.h1.name) + await page.goto(`/my/hackathon/${id}/email`) + await page.waitForLoadState("networkidle") + + await expectDrawsAsField( + page.locator("textarea.field-area").first(), + "the email template body", + ) + }) +}) diff --git a/.claude/skills/hackathon-e2e/tests/smoke/18-team-import.spec.ts b/.claude/skills/hackathon-e2e/tests/smoke/18-team-import.spec.ts new file mode 100644 index 00000000..b345f783 --- /dev/null +++ b/.claude/skills/hackathon-e2e/tests/smoke/18-team-import.spec.ts @@ -0,0 +1,459 @@ +import { test, expect, type Locator, type Page } from "@playwright/test" +import { storageStatePath } from "../../helpers/state.js" +import { rpcAs } from "../../helpers/api.js" +import { content } from "../../helpers/ui.js" +import { SEED_HACKATHONS } from "../../personas.js" + +// Bulk team composition: the CSV/JSON template an organiser downloads, fills in +// and uploads back. +// +// Three things this file is built to catch, all of which are the shape of a bug +// that reports green: +// +// 1. A TEMPLATE ITS OWN IMPORTER REJECTS. Two halves shipped separately drift +// apart in one commit, and the failure surfaces only when a real organiser +// downloads one. So the template is not merely parsed here — it is fed +// straight back into the importer through the UI, and the importer has to +// accept every row. +// 2. A PARTIAL IMPORT. One bad row must block the whole file. Asserting "the +// Apply button is gone" would pass just as well if the preview never +// rendered, so that test carries a GOOD row alongside the bad one and reads +// the roster back afterwards: the good row must not have been applied +// either. +// 3. AN ASSERTION ON A CONTAINER. Membership is asserted on the people panel's +// badge — the element that STATES which team someone is on — never on a card +// that merely contains the team's name somewhere inside it. +// +// Cast: alice owns h1 in the seed fixture, so she is the organiser here. +// Mutations are undone at the end of the test that makes them, because the smoke +// suite shares one database. + +const CSV = "text/csv" + +/** A file the file input will accept, from a string. */ +function file(name: string, body: string, mimeType = CSV) { + return { name, mimeType, buffer: Buffer.from(body, "utf8") } +} + +/** The people panel row for one participant. */ +function personRow(page: Page, name: string): Locator { + return content(page).locator("aside li").filter({ hasText: name }) +} + +/** + * Which team the people panel says someone is on ("Unassigned" when none). + * + * `.badge` is a design-system primitive defined in the theme, not an ad-hoc + * utility list, and it is the one element on the row that STATES the + * membership — the row itself also carries the person's preferred projects and + * registration answers, so any team name could match it by accident. + */ +function teamBadge(page: Page, name: string): Locator { + return personRow(page, name).locator("span.badge").first() +} + +/** + * The badge's text as `toHaveText` will compare it. + * + * `textContent`, NOT `innerText`: `.badge` uppercases in CSS, so `innerText` + * returns "UNASSIGNED" (the rendered text) while `toHaveText` matches against + * "Unassigned" (the DOM text) — snapshotting with the wrong one fails an + * unchanged badge against itself. + */ +async function teamBadgeText(page: Page, name: string): Promise<string> { + return ((await teamBadge(page, name).textContent()) ?? "").trim() +} + +/** The preview's outcome cell for a row, by the sentence it states. */ +function outcome(page: Page, text: string | RegExp): Locator { + return content(page).getByRole("cell", { name: text }) +} + +async function uploadAndPreview(page: Page, f: ReturnType<typeof file>) { + await page.locator("input[type=file]").setInputFiles(f) + await content(page).getByRole("button", { name: "Preview import" }).click() +} + +/** Team names this file creates; anything matching is its own litter. */ +const SCRATCH_TEAM = "Import Squad" + +/** + * Put Bob back on no team and delete this file's leftover teams, over gRPC. + * + * The smoke suite shares one database and a test that dies midway leaves its + * mutation behind — after which "joins X" becomes "moves from Y to X" and an + * exact-wording assertion fails for a reason that has nothing to do with the + * code. Rather than weaken the wording (which is the part worth pinning), each + * test that depends on Bob's position states it first. + */ +async function resetBob(hackathonId: string): Promise<string> { + const h = await rpcAs("alice", "hackathon.HackathonService/Get", { + hackathonId, + }) + const bobId = (h.data?.hackathon?.members ?? []).find( + (m: { user?: { email?: string } }) => m.user?.email === "bob@mail.org", + )?.user?.id + if (!bobId) { + throw new Error( + `bob@mail.org is not a member of the seeded hackathon (${h.raw.slice(0, 200)})`, + ) + } + + const teams = await rpcAs("alice", "hackathon.TeamService/List", { + hackathonId, + }) + for (const t of teams.data?.teams ?? []) { + if ((t.members ?? []).some((m: { id?: string }) => m.id === bobId)) { + await rpcAs("alice", "hackathon.TeamService/RemoveUser", { + teamId: t.id, + userId: bobId, + }) + } + if (String(t.name ?? "").startsWith(SCRATCH_TEAM)) { + await rpcAs("alice", "hackathon.TeamService/Delete", { id: t.id }) + } + } + + return bobId +} + +test.describe("team import", () => { + test.use({ storageState: storageStatePath("alice") }) + + let hackathonId = "" + + // Over gRPC, not through the dashboard. Every test here needs the id, and a + // browser round-trip to find it is one more page load per test that can fail + // for a reason this file is not about — Playwright also restarts its worker + // after a timeout, which resets any id cached in this closure, so a single + // wobble turned into five identical failures pointing at the dashboard. + test.beforeAll(async () => { + // Retried, because this is environment DISCOVERY and not an assertion: a + // backend that is mid-restart when the first test starts must not be + // reported as "the fixture is not seeded". The error still names what it + // last saw, so a genuinely empty database says so rather than hanging. + let res = await rpcAs("alice", "hackathon.HackathonService/List", {}) + for (let attempt = 1; attempt < 12 && !res.ok; attempt++) { + await new Promise((r) => setTimeout(r, 5_000)) + res = await rpcAs("alice", "hackathon.HackathonService/List", {}) + } + const found = (res.data?.hackathons ?? []).find( + (h: { name?: string }) => h.name === SEED_HACKATHONS.h1.name, + ) + if (!found?.id) { + throw new Error( + `no seeded hackathon "${SEED_HACKATHONS.h1.name}" over the API — is the fixture seeded? (${res.raw.slice(0, 300)})`, + ) + } + hackathonId = found.id as string + }) + + test.beforeEach(async ({ page }) => { + await page.goto(`/my/hackathon/${hackathonId}/teams/manage`) + await expect( + content(page).getByRole("heading", { name: "Import team composition" }), + ).toBeVisible() + }) + + test("the template downloads in both formats, with the columns the importer reads", async ({ + page, + request, + }) => { + const csv = await request.get( + `/my/hackathon/${hackathonId}/teams/manage/template/csv`, + ) + expect(csv.status()).toBe(200) + expect(csv.headers()["content-type"]).toContain("text/csv") + expect( + csv.headers()["content-disposition"], + "a template that renders in the tab instead of downloading is not a template", + ).toContain("attachment") + + const lines = (await csv.text()).split("\r\n").filter(Boolean) + expect(lines[0]).toBe('"user_email","project","team"') + + // REAL data, from this event. A template full of invented project names + // teaches the wrong values, so the seeded roster has to be in here. + const body = lines.slice(1).join("\n") + expect( + lines.length, + "the fixture has confirmed participants, so the template cannot be header-only", + ).toBeGreaterThan(1) + expect(body).toContain("alice@mail.com") + expect(body).toContain("AutoML Pipeline Builder") + expect(body).toContain("Team Alpha") + + const json = await request.get( + `/my/hackathon/${hackathonId}/teams/manage/template/json`, + ) + expect(json.status()).toBe(200) + expect(json.headers()["content-type"]).toContain("application/json") + const rows = JSON.parse(await json.text()) as Record<string, string>[] + expect(rows.length).toBe(lines.length - 1) + for (const r of rows) { + expect(Object.keys(r).sort()).toEqual(["project", "team", "user_email"]) + } + + // A format the endpoint does not know is a 404, not a silent CSV. + expect( + ( + await request.get( + `/my/hackathon/${hackathonId}/teams/manage/template/xlsx`, + ) + ).status(), + ).toBe(404) + }) + + test("the importer accepts the template the page hands out", async ({ + page, + request, + }) => { + // The guard: download, upload the SAME bytes, and require that every row + // resolves. Nothing is applied — this is about the two halves agreeing. + const csv = await ( + await request.get( + `/my/hackathon/${hackathonId}/teams/manage/template/csv`, + ) + ).text() + + await uploadAndPreview(page, file("teams.csv", csv)) + + const summary = content(page).getByRole("status") + await expect(summary).toContainText(/teams\.csv: \d+ rows?/) + await expect(summary).toContainText("Nothing has been changed yet") + + // Positive control: the table has to have rows, or "no row failed" is a + // statement about an empty table. + const rows = content(page).locator("tbody tr") + expect( + await rows.count(), + "the preview must list the template's rows, or the checks below are vacuous", + ).toBeGreaterThan(0) + await expect( + outcome(page, "Cannot apply"), + "a template its own importer refuses is the failure this test exists for", + ).toHaveCount(0) + await expect(content(page).getByRole("alert")).toHaveCount(0) + }) + + test("applying the template repairs someone who is on two teams", async ({ + page, + request, + }) => { + // A participant on TWO teams is a state the DB permits and the product does + // not ("everyone belongs to at most one team"). The template states one of + // them, so applying it has to REMOVE the other — and this is the one apply + // path where the target assignment must be SKIPPED, because + // `team_participants` has a composite primary key on (user_id, team_id) and + // re-adding an existing member is a constraint violation rather than a no-op. + // + // The drift is CREATED here over gRPC rather than taken from the seed. The + // seed happens to ship one, but this test consumes it — so depending on it + // makes the test pass once per database and silently self-defeat on every + // rerun. `AssignUser` is also the only way to reach this state: the drag + // board and the importer both enforce the single-team rule. + const teams = await rpcAs("alice", "hackathon.TeamService/List", { + hackathonId, + }) + const beta = (teams.data?.teams ?? []).find( + (t: { name?: string }) => t.name === "Team Beta", + ) + const alpha = (teams.data?.teams ?? []).find( + (t: { name?: string }) => t.name === "Team Alpha", + ) + expect(beta?.id, "the seed's Team Beta should exist").toBeTruthy() + expect(alpha?.id, "the seed's Team Alpha should exist").toBeTruthy() + + const me = await rpcAs("alice", "user.UserService/WhoAmI", {}) + const aliceId = me.data?.user?.id + expect(aliceId, "WhoAmI should name alice's platform id").toBeTruthy() + + // Idempotent: a rerun finds her already there, which is the same state. + await rpcAs("alice", "hackathon.TeamService/AssignUser", { + teamId: beta.id, + userId: aliceId, + }) + await page.reload() + // The card is reached through its OWN header, not by class or by "a section + // containing this text": the enclosing Projects panel is also a `.card` and + // also contains the string "Team Beta", so filtering on text would scope the + // search to a container holding EVERY team's chips — and then the count below + // would be about the whole board. Only a team card has a `<header>`. + const unassignAlice = (team: string) => + content(page) + .locator("header") + .filter({ hasText: team }) + .locator("xpath=..") + .getByRole("button", { name: "Unassign Alice Wonderland" }) + + // Positive control: the drift this test repairs has to be there first, and + // on BOTH teams — otherwise "she is only on Alpha at the end" is a statement + // about a roster that was already correct. + await expect( + unassignAlice("Team Beta"), + "alice should be on Team Beta — without the drift there is nothing to repair", + ).toHaveCount(1) + await expect(unassignAlice("Team Alpha")).toHaveCount(1) + + const csv = await ( + await request.get( + `/my/hackathon/${hackathonId}/teams/manage/template/csv`, + ) + ).text() + await uploadAndPreview(page, file("teams.csv", csv)) + await expect( + outcome(page, 'stays on "Team Alpha" and leaves "Team Beta"'), + ).toBeVisible() + + await content(page).getByRole("button", { name: "Apply 1 change" }).click() + await expect(content(page).getByRole("status")).toContainText( + "Applied 1 of 1 changes", + ) + await expect( + content(page).getByRole("alert"), + "a row that only needs a departure written must not report a failure", + ).toHaveCount(0) + + await page.reload() + await expect(unassignAlice("Team Beta")).toHaveCount(0) + await expect( + unassignAlice("Team Alpha"), + "the team the file NAMED must keep her — a repair is not a removal", + ).toHaveCount(1) + }) + + test("a clean import creates a team, staffs it, and can take the person back off", async ({ + page, + }) => { + await resetBob(hackathonId) + await page.reload() + + const teamName = `${SCRATCH_TEAM} ${Date.now().toString(36)}` + const before = await teamBadgeText(page, "Bob Henderson") + expect(before, "this test needs Bob unplaced to start").toBe("Unassigned") + + await uploadAndPreview( + page, + file( + "assign.csv", + `user_email,project,team\r\nbob@mail.org,Multilingual Chatbot,${teamName}\r\n`, + ), + ) + + // The preview states the effect before anything happens. + await expect( + outcome( + page, + `joins a new team "${teamName}" under "Multilingual Chatbot"`, + ), + ).toBeVisible() + await expect( + teamBadge(page, "Bob Henderson"), + "the preview must not have moved anyone", + ).toHaveText(before) + + await content(page).getByRole("button", { name: "Apply 1 change" }).click() + + await expect(content(page).getByRole("status")).toContainText( + "Applied 1 of 1 changes from assign.csv, creating 1 team.", + ) + // The end state, read off the element that states it. + await expect(teamBadge(page, "Bob Henderson")).toHaveText(teamName) + + // A blank project and team takes him back off — the same file shape, the + // other direction, which is what makes a downloaded roster editable. + await uploadAndPreview( + page, + file("unassign.csv", `user_email,project,team\r\nbob@mail.org,,\r\n`), + ) + await expect(outcome(page, `leaves "${teamName}"`)).toBeVisible() + await content(page).getByRole("button", { name: "Apply 1 change" }).click() + await expect(teamBadge(page, "Bob Henderson")).toHaveText("Unassigned") + + // Clean up the team this test created: the smoke suite shares one database. + page.on("dialog", (d) => d.accept()) + await content(page) + .getByRole("button", { name: `Delete ${teamName}` }) + .click() + await expect( + content(page).getByRole("button", { name: `Delete ${teamName}` }), + ).toHaveCount(0) + }) + + test("one unknown email blocks the whole file, including its good rows", async ({ + page, + }) => { + await resetBob(hackathonId) + await page.reload() + + const before = await teamBadgeText(page, "Bob Henderson") + expect(before, "this test needs Bob unplaced to start").toBe("Unassigned") + + await uploadAndPreview( + page, + file( + "mixed.csv", + "user_email,project,team\r\n" + + "bob@mail.org,Multilingual Chatbot,Team Beta\r\n" + + "nobody@example.org,Multilingual Chatbot,Team Beta\r\n", + ), + ) + + // The bad row says which row and why, by email. + await expect( + outcome( + page, + 'no participant of this hackathon has the email "nobody@example.org"', + ), + ).toBeVisible() + // The good row is still resolved and shown — the organiser sees the whole + // file judged, not just the first failure. + await expect( + outcome(page, 'joins "Team Beta" (Multilingual Chatbot)'), + ).toBeVisible() + + await expect(content(page).getByRole("alert")).toContainText( + "1 of 2 rows in mixed.csv cannot be applied", + ) + await expect(content(page).getByRole("alert")).toContainText( + "Nothing has been changed", + ) + await expect( + content(page).getByRole("button", { name: /^Apply/ }), + "an all-or-nothing file must not offer to apply its good half", + ).toHaveCount(0) + + // And it really was nothing: the good row did not sneak through. + await page.reload() + await expect(teamBadge(page, "Bob Henderson")).toHaveText(before) + }) + + test("a project this event does not have is named, and nothing is applied", async ({ + page, + }) => { + const before = await teamBadgeText(page, "Bob Henderson") + + await uploadAndPreview( + page, + file( + "badproject.csv", + "user_email,project,team\r\nbob@mail.org,Quantum Blockchain,Team Q\r\n", + ), + ) + + // A different answer to a different question: the person resolved, the + // project did not, and "row 1 failed" would leave the organiser guessing. + await expect( + outcome( + page, + 'no project of this hackathon is titled "Quantum Blockchain"', + ), + ).toBeVisible() + await expect( + content(page).getByRole("button", { name: /^Apply/ }), + ).toHaveCount(0) + + await page.reload() + await expect(teamBadge(page, "Bob Henderson")).toHaveText(before) + }) +}) diff --git a/.claude/skills/hackathon-e2e/tests/smoke/19-markdown-toolbar.spec.ts b/.claude/skills/hackathon-e2e/tests/smoke/19-markdown-toolbar.spec.ts new file mode 100644 index 00000000..72440f8b --- /dev/null +++ b/.claude/skills/hackathon-e2e/tests/smoke/19-markdown-toolbar.spec.ts @@ -0,0 +1,309 @@ +import { test, expect, type Locator, type Page } from "@playwright/test" +import { storageStatePath } from "../../helpers/state.js" + +// The markdown editor's formatting toolbar and its paste-a-table panel, driven +// through the real component in a real browser. +// +// The transformations themselves are pure functions with unit tests +// (`src/lib/utils/markdownEdit.ts`, `markdownTable.ts`) and the component has +// its own jsdom suite (`MarkdownEditor.test.ts`) that already renders the +// Preview pane. What only Firefox can answer is whether a real MOUSE CLICK on a +// real toolbar reaches the selection the textarea is holding, and whether marked +// + DOMPurify in the browser produce the same table the server-side render does. +// +// Nothing here submits a form, so the shared seeded database is untouched. +// +// ⚠ Every button lookup is EXACT. `getByRole("button", {name})` is substring +// AND case-insensitive by default, and this toolbar is twelve short names: +// "Insert table" would also match "Insert image", and a bare "Code" matches +// both "Inline code" and "Code block". +// +// The surface is /hackathons/create rather than a hackathon's page editor — the +// same component, but no HackathonSidebar. That sidebar is viewport-anchored +// chrome under active work, and while it was briefly `md:fixed` it covered this +// toolbar and every click here failed on "aside subtree intercepts pointer +// events". A spec for a shared form component should not be a second assertion +// about somebody else's layout. + +const exactButton = (scope: Page | Locator, name: string) => + scope.getByRole("button", { name, exact: true }) + +const EDITOR_URL = "/hackathons/create" + +/** Put the selection where a mouse drag would leave it. `fill()` leaves focus + * in the field, and the toolbar buttons suppress their own focus change, so + * the selection survives the click. */ +async function select(area: Locator, from: number, to: number) { + await area.evaluate( + (el, [start, end]) => { + const field = el as HTMLTextAreaElement + field.focus() + field.setSelectionRange(start, end) + }, + [from, to], + ) +} + +const caretAt = (area: Locator) => + area.evaluate((el) => (el as HTMLTextAreaElement).selectionStart) + +const selectedText = (area: Locator) => + area.evaluate((el) => { + const field = el as HTMLTextAreaElement + return field.value.slice(field.selectionStart, field.selectionEnd) + }) + +test.describe("markdown editor: formatting toolbar", () => { + // Global Admin, which /hackathons/create requires (an organizer would do too). + test.use({ storageState: storageStatePath("admin") }) + + test.beforeEach(async ({ page }) => { + await page.goto(EDITOR_URL) + await page.waitForLoadState("networkidle") + await expect(page.locator("textarea[name=description]")).toBeVisible() + }) + + test("the toolbar is present, labelled, and one tab stop with arrow keys", async ({ + page, + }) => { + const toolbar = page.getByRole("toolbar", { name: "Formatting" }) + await expect(toolbar).toBeVisible() + + for (const name of [ + "Bold", + "Italic", + "Heading 1", + "Heading 2", + "Heading 3", + "Link", + "Bulleted list", + "Numbered list", + "Quote", + "Inline code", + "Code block", + "Paste a table", + ]) { + await expect(exactButton(toolbar, name)).toHaveCount(1) + } + + // Roving tabindex: exactly one button is tabbable, the rest are reached + // with the arrow keys. Twelve buttons between the label and the field would + // otherwise be twelve stops on the way to typing. + await expect(toolbar.locator('button[tabindex="0"]')).toHaveCount(1) + await expect(toolbar.locator('button[tabindex="-1"]')).toHaveCount(11) + + await exactButton(toolbar, "Bold").focus() + await page.keyboard.press("ArrowRight") + await expect(exactButton(toolbar, "Italic")).toBeFocused() + await page.keyboard.press("End") + await expect(exactButton(toolbar, "Paste a table")).toBeFocused() + await page.keyboard.press("Home") + await expect(exactButton(toolbar, "Bold")).toBeFocused() + }) + + test("bold wraps a selection, keeps it, and unwraps on a second press", async ({ + page, + }) => { + const area = page.locator("textarea[name=description]") + await area.fill("hello world") + await select(area, 6, 11) + + await exactButton(page, "Bold").click() + + await expect(area).toHaveValue("hello **world**") + // The selection lands on the words, not on the asterisks, so pressing + // again toggles the same text instead of nesting. + expect(await selectedText(area)).toBe("world") + + await exactButton(page, "Bold").click() + await expect(area).toHaveValue("hello world") + }) + + test("bold at an empty caret opens a pair and sits inside it", async ({ + page, + }) => { + const area = page.locator("textarea[name=description]") + await area.fill("") + await select(area, 0, 0) + + await exactButton(page, "Bold").click() + + await expect(area).toHaveValue("****") + expect(await caretAt(area)).toBe(2) + }) + + test("headings and lists apply per line", async ({ page }) => { + const area = page.locator("textarea[name=description]") + + await area.fill("Title") + await select(area, 2, 2) + await exactButton(page, "Heading 2").click() + await expect(area).toHaveValue("## Title") + + // Re-levels rather than stacking hashes. + await exactButton(page, "Heading 3").click() + await expect(area).toHaveValue("### Title") + + await area.fill("one\ntwo\nthree") + await select(area, 0, 13) + await exactButton(page, "Numbered list").click() + await expect(area).toHaveValue("1. one\n2. two\n3. three") + + // The two list buttons are one family: this converts, it does not nest. + await exactButton(page, "Bulleted list").click() + await expect(area).toHaveValue("- one\n- two\n- three") + }) + + test("Ctrl+B, Ctrl+I and Ctrl+K work from the keyboard alone", async ({ + page, + }) => { + const area = page.locator("textarea[name=description]") + + await area.fill("hi") + await area.press("Control+a") + await area.press("Control+b") + await expect(area).toHaveValue("**hi**") + + await area.fill("hi") + await area.press("Control+a") + await area.press("Control+i") + await expect(area).toHaveValue("*hi*") + + await area.fill("docs") + await area.press("Control+a") + await area.press("Control+k") + await expect(area).toHaveValue("[docs](url)") + // The URL is what is missing, so that is what is selected to type over. + expect(await selectedText(area)).toBe("url") + }) +}) + +test.describe("markdown editor: paste a table", () => { + test.use({ storageState: storageStatePath("admin") }) + + test.beforeEach(async ({ page }) => { + await page.goto(EDITOR_URL) + await page.waitForLoadState("networkidle") + await expect(page.locator("textarea[name=description]")).toBeVisible() + }) + + const openPanel = async (page: Page) => { + await exactButton(page, "Paste a table").click() + const panel = page.getByRole("group", { name: "Paste a table" }) + await expect(panel).toBeVisible() + return panel + } + + test("a spreadsheet paste becomes a table the preview really renders", async ({ + page, + }) => { + const area = page.locator("textarea[name=description]") + const panel = await openPanel(page) + + // Tab-separated, which is what a spreadsheet selection puts on the + // clipboard. One cell carries a literal pipe: unescaped, it would end that + // cell and shift every column after it — silently, in the middle of the + // data. + await panel + .locator("textarea") + .fill("Track\tLead\nClimate\tAlice\na|b\tBob") + + // The shape is read back BEFORE inserting: "it inserted something" and "it + // found two columns" are different claims. + await expect(panel).toContainText("2 columns × 2 rows") + await expect(panel).toContainText("separated by tab") + + await exactButton(panel, "Insert table").click() + await expect(panel).toBeHidden() + + // Cells are padded to the column width — the raw markdown has to stay + // readable, because the textarea is the source of truth — so the exact run + // of spaces is not what is asserted here. + await expect(area).toHaveValue(/\| Track\s+\| Lead\s+\|/) + // The pipe left as data would have produced `| a|b | Bob |`. + await expect(area).toHaveValue(/\| a\\\|b\s+\| Bob\s+\|/) + + // …and the end of the chain: what the reader sees. + await exactButton(page, "Preview").click() + const table = page.locator(".markdown-content table") + await expect(table).toBeVisible() + await expect(table.locator("thead th")).toHaveCount(2) + await expect(table.locator("tbody tr")).toHaveCount(2) + await expect(table.locator("tbody tr").nth(0).locator("td")).toHaveCount(2) + await expect(table.locator("thead th").nth(0)).toHaveText("Track") + // One cell, not two: the escape held through marked and DOMPurify. + await expect( + table.locator("tbody tr").nth(1).locator("td").nth(0), + ).toHaveText("a|b") + }) + + test("CSV works, and a quoted comma stays inside its cell", async ({ + page, + }) => { + const area = page.locator("textarea[name=description]") + const panel = await openPanel(page) + + await panel.locator("textarea").fill('City,Country\n"Lausanne, VD",CH') + + await expect(panel).toContainText("2 columns × 1 row") + await expect(panel).toContainText("separated by comma") + + await exactButton(panel, "Insert table").click() + await expect(area).toHaveValue(/Lausanne, VD/) + + await exactButton(page, "Preview").click() + const cells = page.locator(".markdown-content tbody td") + await expect(cells).toHaveCount(2) + await expect(cells.nth(0)).toHaveText("Lausanne, VD") + }) + + test("an ambiguous separator is flagged and can be overruled", async ({ + page, + }) => { + const panel = await openPanel(page) + + // European CSV: `;` between fields, `,` as the decimal mark. Splitting on + // either yields a consistent grid, so the person has to choose. + await panel.locator("textarea").fill("1,5;2,5\n3,5;4,5") + await expect(panel).toContainText("More than one separator fits") + + await panel.getByLabel("Separator").selectOption(",") + await expect(panel).toContainText("3 columns × 1 row") + await expect(panel).not.toContainText("More than one separator fits") + + await panel.getByLabel("Separator").selectOption(";") + await expect(panel).toContainText("2 columns × 1 row") + }) + + test("data with no header row gets an empty header, keeping every row", async ({ + page, + }) => { + const panel = await openPanel(page) + await panel.locator("textarea").fill("Alice\tOrganizer\nBob\tParticipant") + await expect(panel).toContainText("2 columns × 1 row") + + await panel.getByLabel("First row is a header").uncheck() + await expect(panel).toContainText("2 columns × 2 rows") + + await exactButton(panel, "Insert table").click() + await exactButton(page, "Preview").click() + + const table = page.locator(".markdown-content table") + await expect(table.locator("tbody tr")).toHaveCount(2) + await expect(table.locator("thead th").nth(0)).toHaveText("") + }) + + test("the panel refuses to insert nothing, and Escape closes it", async ({ + page, + }) => { + const panel = await openPanel(page) + + await expect(panel).toContainText("Nothing pasted yet.") + await expect(exactButton(panel, "Insert table")).toBeDisabled() + + await page.keyboard.press("Escape") + await expect(panel).toBeHidden() + // Focus goes back to the control that opened it, not to the top of the page. + await expect(exactButton(page, "Paste a table")).toBeFocused() + }) +}) diff --git a/.claude/skills/hackathon-e2e/tests/smoke/20-media-gallery.spec.ts b/.claude/skills/hackathon-e2e/tests/smoke/20-media-gallery.spec.ts new file mode 100644 index 00000000..19769c78 --- /dev/null +++ b/.claude/skills/hackathon-e2e/tests/smoke/20-media-gallery.spec.ts @@ -0,0 +1,365 @@ +import { test, expect } from "@playwright/test" +import { storageStatePath } from "../../helpers/state.js" +import { contextFor } from "../../helpers/login.js" +import { myHackathonId } from "../../helpers/discover.js" +import { generateLogoPng } from "../../helpers/files.js" +import { SEED_HACKATHONS } from "../../personas.js" + +// Listing what is already uploaded: the platform media library at +// /manage/gallery, and the "choose from gallery" half of the picker dialog. +// +// Both rest on one backend rule, and the rule is what this file is really +// about: **you may LIST a prefix exactly when you may WRITE to it.** So the +// tests come in pairs — the caller who may see the files does, and the caller +// who may not is refused, with the right status code. A refusal on its own +// proves nothing; a handler that denied everybody would pass half of this file. +// +// Two prefixes are listable by nobody, and the assertions about them carry a +// POSITIVE control in the same test. "The gallery shows no avatars" is true of a +// gallery showing nothing at all, so every one of those checks also names +// something the gallery DOES show. + +/** Deterministic 96x96 PNG. Same bytes on every machine, every run. */ +const PNG = generateLogoPng(9002) + +test.describe("the platform media library", () => { + test.use({ storageState: storageStatePath("admin") }) + + test("an admin reaches it from the dashboard and sees what each picture is", async ({ + page, + }) => { + // CLICKED, not `goto`-ed. `/manage/pages` shipped reachable only by typing + // its URL and nobody noticed for weeks, because every test reached it with + // page.goto — which proves the route works and not that you can get there. + await page.goto("/dashboard") + const platform = page + .locator("section") + .filter({ has: page.getByRole("heading", { name: "Manage platform" }) }) + await expect(platform).toBeVisible() + await platform.getByRole("link", { name: /^Media/ }).click() + + await expect(page).toHaveURL(/\/manage\/gallery/) + await expect( + page.getByRole("heading", { name: "Media library" }), + ).toBeVisible() + + // The store is seeded with three event covers by .devcontainer/rustfs-init.sh + // before anything else runs, so there is always something to list. If this + // is empty the listing is broken, not the fixture. + const tiles = page.locator("li img") + await expect(tiles.first()).toBeVisible() + expect(await tiles.count()).toBeGreaterThan(0) + + // The element that STATES the fact, not the card that contains the word. + // The card also holds the alt text and the link, so asserting on it would + // pass on a tile whose own label line was blank. + const origins = page.getByTestId("image-origin") + expect(await origins.count()).toBeGreaterThan(0) + const labels = await origins.allInnerTexts() + expect( + labels.some((l) => + /Event (logo|image)|Seeded cover|Platform page/.test(l), + ), + `no tile said where it came from; got ${JSON.stringify(labels)}`, + ).toBe(true) + }) + + test("it lists event and platform imagery, and no profile pictures at all", async ({ + page, + browser, + request, + }) => { + // THE POSITIVE CONTROL FIRST: put an avatar in the store as bob, so the + // absence asserted below is an absence of something that exists. Without + // this the check agrees with a gallery that lists nothing. + // + // contextFor(), never browser.newContext(): a bare newContext inside a + // describe with storageState inherits it, so "as bob" would arrive as the + // admin. That exact bug was found in this suite. + const bobContext = await contextFor(browser, "bob") + const bobPage = await bobContext.newPage() + let avatarPath: string + try { + await bobPage.goto("/account") + const field = bobPage.getByLabel("Profile picture") + const before = await field.inputValue() + await bobPage.getByRole("button", { name: "Upload a picture" }).click() + await bobPage + .getByLabel("Choose an image file for your account") + .setInputFiles({ + name: "bob-gallery.png", + mimeType: "image/png", + buffer: PNG, + }) + await expect(field).not.toHaveValue(before, { timeout: 20_000 }) + avatarPath = await field.inputValue() + } finally { + await bobContext.close() + } + expect(avatarPath).toContain("/users/") + expect(avatarPath).toContain("/avatar/") + // It really is in the store — so "the gallery does not show it" is a claim + // about the listing rule and not about a failed upload. + expect((await request.get(avatarPath)).status()).toBe(200) + + // Now the library, read through the endpoint the page and the picker share. + const listing = await page.request.get("/manage/gallery/media?pageSize=200") + expect(listing.status()).toBe(200) + const body = (await listing.json()) as { + objects: { key: string; url: string }[] + } + + // Positive: it lists the prefixes it is supposed to. + expect( + body.objects.length, + "the library should list the seeded event covers", + ).toBeGreaterThan(0) + expect( + body.objects.some((o) => o.key.startsWith("hackathons/")), + `no event imagery listed; got ${JSON.stringify(body.objects.map((o) => o.key))}`, + ).toBe(true) + + // Negative: not one avatar, not the one just uploaded, not any other. + expect(body.objects.filter((o) => o.key.startsWith("users/"))).toEqual([]) + expect(body.objects.map((o) => o.url)).not.toContain(avatarPath) + // Nor a private submission attachment, whose keys would say which teams + // turned work in even if the objects themselves stayed unreadable. + expect(body.objects.filter((o) => o.key.startsWith("teams/"))).toEqual([]) + + // Every url is a stable stored PATH, never a presigned one. A presign + // expires and is a bearer credential; a wall of them would start lapsing + // while the grid was still on screen. + for (const object of body.objects) { + expect(object.url).toMatch(/^\/objects\//) + expect(object.url).not.toContain("X-Amz-Signature") + } + }) + + test("paging is a real URL, and the page says what it is not showing", async ({ + page, + }) => { + await page.goto("/manage/gallery") + // Stated on screen rather than left to be inferred: this page is not an + // inventory of the bucket, and reading it as one would be wrong. + await expect( + page.getByText( + /Profile pictures and team submission files are deliberately not listed/i, + ), + ).toBeVisible() + await expect(page.getByText(/There is also no delete/i)).toBeVisible() + + // One page holds 120 and the fixture has far fewer, so there is no next + // link to follow. Assert the SHAPE instead: a token, when there is one, + // must be a link with the token in its href — because a fetch-driven + // "load more" would not survive a reload and this one has to. + const older = page.getByRole("link", { name: "Older images" }) + if (await older.count()) { + await expect(older).toHaveAttribute("href", /\/manage\/gallery\?page=\d+/) + } + }) +}) + +test.describe("who may list what", () => { + test.use({ storageState: storageStatePath("alice") }) + + test("an organiser is refused the platform library but sees their own event's", async ({ + page, + }) => { + // Alice is a hackathon organiser and owns h1. She may not read every + // event's files — that scope takes the global Admin role. + const denied = await page.request.get("/manage/gallery/media") + expect(denied.status()).toBe(403) + + const deniedPage = await page.request.get("/manage/gallery") + expect(deniedPage.status()).toBe(403) + + // THE POSITIVE CONTROL, same caller, same test: she is not powerless. Her + // own event's media is exactly the prefix she may write to, so it is + // exactly the prefix she may list. + const id = await myHackathonId(page, SEED_HACKATHONS.h1.name) + const own = await page.request.get(`/my/hackathon/${id}/media?pageSize=200`) + expect(own.status()).toBe(200) + const body = (await own.json()) as { objects: { key: string }[] } + // Everything returned is under HER event and nothing else's. + for (const object of body.objects) { + expect(object.key.startsWith(`hackathons/${id}/`)).toBe(true) + } + }) + + test("she picks an already-uploaded image as the event logo", async ({ + page, + request, + }) => { + const id = await myHackathonId(page, SEED_HACKATHONS.h1.name) + + // Make sure the event HAS media to choose from — otherwise the gallery tab + // would legitimately be empty and this test would prove nothing. Uploading + // it through the same dialog is also the (a) half of the feature: the + // upload path and the reuse path are one control. + await page.goto(`/my/hackathon/${id}/manage/edit`) + const field = page.getByLabel("Logo (optional)") + await expect(field).toBeVisible() + + await page.getByRole("button", { name: "Choose a logo" }).click() + const dialog = page.getByRole("dialog") + await expect(dialog).toBeVisible() + await page + .getByLabel("Choose an image file for the event logo") + .setInputFiles({ + name: "reusable-mark.png", + mimeType: "image/png", + buffer: PNG, + }) + await expect(dialog).toBeHidden({ timeout: 20_000 }) + const uploaded = await field.inputValue() + expect(uploaded).toMatch(/^\/objects\//) + + // Clear the field, so what follows cannot be the value the upload left. + await page.getByRole("button", { name: "Remove" }).first().click() + await expect(field).toHaveValue("") + + // Now the second way in: choose from what is already stored. + await page.getByRole("button", { name: "Choose a logo" }).click() + await expect(dialog).toBeVisible() + await dialog.getByRole("button", { name: "Choose from gallery" }).click() + + const tiles = dialog.getByRole("button", { name: /Use this image/ }) + await expect(tiles.first()).toBeVisible({ timeout: 20_000 }) + await tiles.first().click() + + // The dialog closes and the field holds a stored path — a different act + // from the upload above, and the field was empty a moment ago. + await expect(dialog).toBeHidden() + const chosen = await field.inputValue() + expect(chosen).toMatch(/^\/objects\/\S+$/) + expect(chosen).toContain(`/hackathons/${id}/`) + + // And it is a real image, not just a plausible string: the whole reason + // this listing exists is to hand back something that renders. + const got = await request.get(chosen) + expect(got.status()).toBe(200) + expect(got.headers()["content-type"]).toMatch( + /^image\/(webp|png|jpeg|gif)$/, + ) + expect((await got.body()).length).toBeGreaterThan(0) + // The FIELD's preview, addressed by its own alt text — not + // `img[src=…].first()`, which matched the dialog's (now hidden) thumbnail + // of the very same object and reported "hidden" for a picture that was on + // screen. Assert on the element that states the fact. + await expect(page.getByAltText("Current logo")).toHaveAttribute( + "src", + chosen, + ) + }) +}) + +test.describe("the picker dialog itself", () => { + test.use({ storageState: storageStatePath("alice") }) + + test("a dropped file uploads, and the drop target says it is armed", async ({ + page, + }) => { + // Drag and drop is the half of this feature that did not exist before, and + // it is the half no other test can reach: `setInputFiles` drives the file + // INPUT and never runs the drop handler. So the events are synthesised in + // the page — a real File, a real DataTransfer, real DragEvents — which is as + // close to a human dragging a photograph in as a browser automation gets. + const id = await myHackathonId(page, SEED_HACKATHONS.h1.name) + await page.goto(`/my/hackathon/${id}/manage/edit`) + + const field = page.getByLabel("Logo (optional)") + const before = await field.inputValue() + + await page.getByRole("button", { name: "Choose a logo" }).click() + const zone = page.getByRole("region", { name: /drop an image here/i }) + await expect(zone).toBeVisible() + + // The border colour is the only thing that tells a person the box will + // accept what they are holding, and it is driven by a dragenter counter that + // a single boolean got wrong (it flickered off over the button inside). + const armedBefore = await zone.evaluate((el) => el.className) + await zone.dispatchEvent("dragenter", { + dataTransfer: await page.evaluateHandle(() => new DataTransfer()), + }) + await expect + .poll( + async () => (await zone.evaluate((el) => el.className)) !== armedBefore, + { + timeout: 5_000, + message: "the drop target never showed that it was armed", + }, + ) + .toBe(true) + + // Now the drop. The file is built in page context because a File cannot + // cross the Playwright boundary; the bytes are a real 1x1 PNG. + const dropped = await page.evaluateHandle(() => { + const base64 = + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==" + const bytes = Uint8Array.from(atob(base64), (c) => c.charCodeAt(0)) + const file = new File([bytes], "dragged-in.png", { type: "image/png" }) + const dt = new DataTransfer() + dt.items.add(file) + return dt + }) + await zone.dispatchEvent("drop", { dataTransfer: dropped }) + + // The upload really happened: the dialog closed itself and the field holds a + // path under THIS event's prefix that was not there before. + await expect(page.getByRole("dialog")).toBeHidden({ timeout: 20_000 }) + const stored = await field.inputValue() + expect(stored).not.toBe(before) + expect(stored).toMatch(/^\/objects\/\S+$/) + expect(stored).toContain(`/hackathons/${id}/`) + }) + + test("Escape closes it and nothing is chosen", async ({ page }) => { + // A modal that can only be dismissed with a pointer is a trap. This is the + // platform's own behaviour — `showModal()` handles Esc, which is most of why + // a native <dialog> was chosen over a div — so the assertion is that the + // component does not get in its way, and that dismissing changes nothing. + const id = await myHackathonId(page, SEED_HACKATHONS.h1.name) + await page.goto(`/my/hackathon/${id}/manage/edit`) + + const field = page.getByLabel("Logo (optional)") + const before = await field.inputValue() + + const open = page.getByRole("button", { name: "Choose a logo" }) + await open.click() + const dialog = page.getByRole("dialog") + await expect(dialog).toBeVisible() + + await page.keyboard.press("Escape") + await expect(dialog).toBeHidden() + await expect(field).toHaveValue(before) + + // And it reopens — the positive control. A dialog that closed by being + // destroyed would pass the assertion above and never come back. + await open.click() + await expect(dialog).toBeVisible() + await page.keyboard.press("Escape") + await expect(dialog).toBeHidden() + }) +}) + +test.describe("a plain member", () => { + test.use({ storageState: storageStatePath("bob") }) + + test("cannot list an event's media, and is told denied rather than not-found", async ({ + page, + }) => { + // Reading an event is not seeing the drawer it keeps its pictures in: + // listing is authorized as `hackathon:write`, the permission that put the + // files there. 403 and not 404 — the event's existence is not a secret, + // his permission to browse its files is what is missing. + const id = await myHackathonId(page, SEED_HACKATHONS.h1.name) + const denied = await page.request.get(`/my/hackathon/${id}/media`) + expect(denied.status()).toBe(403) + + // The positive control: bob is a real signed-in user whose requests + // otherwise succeed, so the 403 above is about this prefix and not about a + // broken session. + const ok = await page.request.get("/account") + expect(ok.status()).toBe(200) + }) +}) diff --git a/.claude/skills/hackathon-e2e/tests/smoke/21-footer.spec.ts b/.claude/skills/hackathon-e2e/tests/smoke/21-footer.spec.ts new file mode 100644 index 00000000..3ce89740 --- /dev/null +++ b/.claude/skills/hackathon-e2e/tests/smoke/21-footer.spec.ts @@ -0,0 +1,543 @@ +import { test, expect, type Page } from "@playwright/test" +import { PERSONAS, SEED_HACKATHONS } from "../../personas.js" +import { storageStatePath } from "../../helpers/state.js" +import { rpcAnonymous } from "../../helpers/api.js" +import { + expectFooterLinkNamesUnique, + expectFooterOperable, + expectNewTabLinksAnnounced, + footerLinkNames, +} from "../../helpers/reflow.js" + +// The site footer on the SIGNED-IN half of the app, and what it is FOR. +// +// It was missing there entirely. The `(public)`/`(app)` route split (5551b8d) +// gave each group its own copy of the shell markup and only `(public)`'s copy +// mounted AppFooter, so /dashboard, /account, /hackathons/create, both +// /manage/* pages and all 21 /my/hackathon/* pages rendered without one. Both +// existing sweeps looked at "footer" on those routes and reported green, +// because the geometry helpers return early when their scope is absent. +// +// Why that is not cosmetic: the footer is the ONLY inbound link to the +// platform's own SitePages — Privacy, Terms, About. A route with no footer is a +// route from which the privacy policy cannot be reached, and this repo has now +// shipped an unreachable route three times (/account, /manage/pages, and the +// browse page — .claude/CLAUDE.md, "routes with no inbound link"). So this spec +// does not stop at "a <footer> element is present": it CLICKS the links and +// asserts what comes back. +// +// Layout properties (does the footer reflow, is it covered by the consent +// banner) belong to the mobile suite, which sweeps every route at two widths +// and guarantees a consent banner to assert about. This one owns presence and +// reachability, plus the one geometric claim only the signed-in shell can make: +// the hackathon sidebar is anchored to the VIEWPORT, so it can be drawn over a +// footer that is at the bottom of the document. + +/** + * slug -> the <h1> the SitePage renders (cmd/seed/main.go, seedSitePages), and + * the footer nav landmark that links to it. + * + * ⚠ `label` and `title` are the SAME STRING on all three rows now, and that is + * the fix rather than a coincidence. develop's rebuilt footer (`02658384`) put + * the SDSC org site's links beside ours with one of THEM named exactly "About" + * (datascience.ch/about), so footer-wide `name: "About"` matched two links + * pointing at two different places — undisambiguatable in a screen reader's + * link list, which is a flat list of names with the column headings thrown + * away. Our link carries the SitePage's own title now, so the two names differ + * at the source: a page and its inbound link cannot drift while the rule is + * "name the destination the way it names itself". "Terms" → "Terms of use" was + * the same move, made by develop for the same reason. + * + * `nav` stays because it costs nothing and says WHICH link a bare name means. + * The property that keeps the collision gone is asserted directly, on the + * footer as a whole, by `expectFooterLinkNamesUnique` further down. + */ +const SITE_PAGES = [ + { label: "Privacy", nav: "Legal", href: "/privacy", title: "Privacy" }, + { + label: "Terms of use", + nav: "Legal", + href: "/terms", + title: "Terms of use", + }, + { + label: "About Hackagon", + nav: "Platform", + href: "/about", + title: "About Hackagon", + }, +] + +let h1Id = "" + +test.beforeAll(() => { + // Discovered the way a visitor would rather than hard-coded: seed UUIDs are + // regenerated on every reset. + const listed = rpcAnonymous("hackathon.HackathonService/List", { + visibilityFilter: 1, + }) + if (!listed.ok) throw new Error(`HackathonService.List failed: ${listed.raw}`) + const found = ( + listed.data.hackathons as { id: string; name: string }[] | undefined + )?.find((h) => h.name === SEED_HACKATHONS.h1.name) + if (!found) { + throw new Error( + `seed hackathon "${SEED_HACKATHONS.h1.name}" not found — this spec needs ` + + `the seed fixture (scripts/run.sh smoke seeds it)`, + ) + } + h1Id = found.id +}) + +/** + * The hackathon sidebar must not be drawn on top of the footer. + * + * This is the one hazard the signed-in shell has and the public one does not. + * HackathonSidebar is `md:sticky md:top-14 md:h-[calc(100vh-3.5rem)]` — a + * full-screen-height column anchored to the VIEWPORT — and viewport-anchored + * chrome over page-bottom chrome is a bug this repo has already shipped twice + * (the consent banner as a lid; the same sidebar's last four entries under it). + * Adding a footer below that column is exactly the setup for a third. + * + * It holds today for a reason worth writing down, because it is NOT the + * reasoning that first suggests itself. A sticky box taller than its containing + * block cannot move at all, so the sidebar looks like it should overhang the row + * it lives in on any page whose content is shorter than the screen — and the + * footer sits directly below that row. It does not, because the row is a FLEX + * CONTAINER: its height is its tallest item's, and with `md:self-start` on the + * aside that item is the 100vh-3.5rem sidebar. The row therefore grows to the + * sidebar rather than the sidebar escaping the row. Measured, not assumed — the + * belt-and-braces `min-h` this was written to protect turned out to change + * nothing and was dropped. + * + * So this is a guard on a property that currently emerges from flex sizing, and + * would vanish the moment anyone takes the column out of flow (`md:fixed`) or + * gives the row a fixed height. It asserts the OUTCOME, so it does not care + * which of those happens. + * + * Checked at the TOP of the page deliberately: that is where an overhang would + * be, and scrolling to the end moves a sticky column up and out of the way. + */ +async function expectSidebarClearsFooter(page: Page, name: string) { + const overlap = await page.evaluate(() => { + const f = document.querySelector("footer") + const asides = Array.from(document.querySelectorAll("aside")) + if (!f || asides.length === 0) return null + const fr = f.getBoundingClientRect() + const TOL = 2 + const hits: string[] = [] + for (const a of asides) { + const cs = getComputedStyle(a) + if (cs.display === "none" || cs.visibility === "hidden") continue + const r = a.getBoundingClientRect() + const x = Math.min(r.right, fr.right) - Math.max(r.left, fr.left) + const y = Math.min(r.bottom, fr.bottom) - Math.max(r.top, fr.top) + if (x <= TOL || y <= TOL) continue + hits.push( + `aside(${cs.position}, ${Math.round(r.width)}x${Math.round(r.height)}) ` + + `over the footer by ${Math.round(x)}x${Math.round(y)}px`, + ) + } + return hits + }) + + // null means no footer or no sidebar on this page — either makes the claim + // vacuous, and both are bugs on the routes this is called for. + expect( + overlap, + `${name}: expected both a <footer> and the hackathon <aside> to be present, ` + + `or this check verifies nothing`, + ).not.toBeNull() + expect( + overlap, + `${name}: the hackathon sidebar is drawn over the footer: ${overlap?.join(" | ")}`, + ).toEqual([]) +} + +// ─── Presence, on every signed-in surface ──────────────────────────────────── + +test.describe("the footer reaches every signed-in page", () => { + test.describe("as a member", () => { + test.use({ storageState: storageStatePath(PERSONAS.alice.key) }) + + for (const path of ["/dashboard", "/account"]) { + test(`${path} carries a usable footer`, async ({ page }) => { + await page.goto(path) + await page.waitForLoadState("networkidle").catch(() => {}) + await expectFooterOperable(page, `smoke ${path} as alice`) + }) + } + + // The hackathon subtree: the one place in the app with a viewport-anchored + // column beside the page, so the one place a footer can be covered by + // something other than the consent banner. `webinars` and `invites` are here + // because their content is SHORT — a page taller than the screen hides this + // class of bug completely, which is why the route list is not just + // /overview. + for (const leaf of ["overview", "webinars", "invites", "prizes"]) { + test(`/my/hackathon/[id]/${leaf} carries a usable footer`, async ({ + page, + }) => { + const name = `smoke /my/hackathon/[id]/${leaf} as alice` + await page.goto(`/my/hackathon/${h1Id}/${leaf}`) + await page.waitForLoadState("networkidle").catch(() => {}) + await expectFooterOperable(page, name) + await expectSidebarClearsFooter(page, name) + }) + } + }) + + test.describe("as a global admin", () => { + test.use({ storageState: storageStatePath(PERSONAS.admin.key) }) + + for (const path of [ + "/manage/pages", + "/manage/users", + "/hackathons/create", + ]) { + test(`${path} carries a usable footer`, async ({ page }) => { + await page.goto(path) + await page.waitForLoadState("networkidle").catch(() => {}) + await expectFooterOperable(page, `smoke ${path} as admin`) + }) + } + }) +}) + +// ─── Reachability: the links go somewhere ──────────────────────────────────── + +test.describe("the footer's links resolve from inside the app", () => { + test.use({ storageState: storageStatePath(PERSONAS.alice.key) }) + + test("Privacy, Terms and About are reachable from the dashboard", async ({ + page, + }) => { + for (const target of SITE_PAGES) { + await page.goto("/dashboard") + await page.waitForLoadState("networkidle").catch(() => {}) + + // Scoped to the <footer> and then to its nav landmark, never page-wide: + // "Privacy" and "About" also appear in the consent sentence, and a check + // that matches those would pass with no footer at all. The landmark is + // the second half of that — see SITE_PAGES on the duplicate "About". + const link = page + .locator("footer") + .getByRole("navigation", { name: target.nav }) + .getByRole("link", { name: target.label, exact: true }) + await expect(link).toHaveAttribute("href", target.href) + + // Clicked, not `goto`-ed. `goto` proves the route exists; only a click + // proves you can GET there from the dashboard, which is the property that + // was broken (.claude/CLAUDE.md, pass 1: "goto proves nothing"). + await link.click() + await expect(page).toHaveURL(new RegExp(`${target.href}$`)) + + // The rendered title is the proof it RESOLVED: an unknown slug 404s and + // SvelteKit's error page carries a different heading entirely. + await expect( + page.locator("main").getByRole("heading", { level: 1 }), + `following the footer's "${target.label}" link from the dashboard did ` + + `not land on the ${target.href} SitePage`, + ).toHaveText(target.title) + } + }) + + test("every off-site link points off-site, and can be named", async ({ + page, + }) => { + // Re-specified for develop's rebuilt footer (`02658384`). There is no + // GitHub link any more — the off-site row is SDSC's own channels, three + // ICON-ONLY anchors — so the old test is retired rather than repaired: its + // subject left the product. What replaced it is a stronger claim about the + // same row, and one this footer can actually break. + // + // 1. every off-site anchor is absolute https. Same reason as before: a + // relative href here would silently resolve against our own origin. + // 2. every one of them has an ACCESSIBLE NAME. That is new and it is the + // point: an icon-only link whose aria-label is dropped is invisible to + // a screen reader and to every name-based locator, and it looks + // completely fine on screen. `getByRole("link")` returns it either way, + // so the name has to be read back explicitly. + // + // Not followed — an external navigation in a suite with no network contract + // with linkedin.com is a flake waiting to happen. The href is the claim. + // + // The name computation moved to helpers/reflow.ts (`footerLinkNames`) when + // the uniqueness check below needed the same thing. It is shared rather + // than copied for one reason: it is the piece that has already been WRONG + // once — reading textContent alone called the two parent-institution logos + // nameless, because they are `<a><img alt="ETH Zurich"></a>` and the alt + // text is what names them. One copy can be corrected; two drift. + await page.goto("/dashboard") + const offsite = (await footerLinkNames(page)).filter((l) => + /^https?:/.test(l.href), + ) + + // Positive control: a footer whose off-site row went missing would satisfy + // both assertions below with an empty array, which is the vacuous shape + // this repo keeps paying for. + expect( + offsite.length, + "the footer carries no off-site links at all — SDSC's channels and the " + + "two parent-institution logos should all be here", + ).toBeGreaterThanOrEqual(4) + + expect( + offsite.filter((l) => !l.href.startsWith("https://")), + "an off-site footer link is not absolute https", + ).toEqual([]) + expect( + offsite.filter((l) => l.name === "").map((l) => l.href), + "an off-site footer link has no accessible name — icon-only anchors need " + + "aria-label, and nothing on screen shows when one is lost", + ).toEqual([]) + }) + + test("every footer link that opens in a new tab says so in its name", async ({ + page, + }) => { + // `target="_blank"` moves you to a new tab and announces NOTHING. Sighted + // visitors get no icon; a screen reader gets no word. The nav landmark is + // named "Swiss Data Science Center", which would say it — and a link list is + // a flat list of NAMES with landmark context thrown away, which is the same + // reason two "About"s collided one column apart (the test below). + // + // ⚠ Asserted as a PROPERTY over whatever opens a new tab, never against the + // five datascience.ch links it was written for. A five-entry list would be a + // claim about how many off-site links the footer HAS — exactly the mistake + // the uniqueness check already made once, when a five-entry constant broke + // the day develop grew the footer to fourteen links. + // + // Both shells, same reason as the uniqueness check below: the footer is + // mounted by AppShell and a page adding a link of its own would break this + // on one side only. + for (const path of ["/hackathon", "/dashboard"]) { + await page.goto(path) + await page.waitForLoadState("networkidle").catch(() => {}) + await expectNewTabLinksAnnounced(page, `smoke ${path}`) + } + }) + + test("no two footer links answer to the same name", async ({ page }) => { + // The one thing a screen reader's link list is: a flat list of NAMES. Our + // /about and datascience.ch/about were both "About" in it (develop's + // `02658384`), one column apart on screen and side by side in that list. + // + // Asserted on BOTH shells, because the property is about a region and the + // region is mounted by AppShell — a page that added a link of its own into + // the footer would break this on one side only, and the equality test + // further down compares the two footers to each OTHER, so it would agree + // with two identical broken ones. + for (const path of ["/hackathon", "/dashboard"]) { + await page.goto(path) + await page.waitForLoadState("networkidle").catch(() => {}) + await expectFooterLinkNamesUnique(page, `smoke ${path}`) + } + }) + + // ─── Controls: each assertion above, shown failing ───────────────────────── + // + // Every check in this file is an ABSENCE claim ("nothing covers the footer", + // "no route lacks one"), and an absence claim that cannot fail agrees with + // everything. This repo has shipped four of those (.claude/CLAUDE.md, "Ways a + // test reported green while proving nothing") — a vacuous zero-hit grep, a + // count nobody read back, a field that moved out from under a check. + // + // Each control breaks ONE property in the live DOM and asserts the helper + // rejects. Done here rather than by editing a layout and re-running by hand: + // a temporary source edit proves it once, for whoever was watching, and then + // stops existing. (The three defects reproduced below are all real: the + // missing footer is what this whole spec was written for; the lid is the + // consent banner while it was `fixed bottom-0`; the out-of-flow column is + // what `md:fixed` on HackathonSidebar does, verified against the source too — + // it put the aside 287x63px over the footer on the three short leaves.) + + test("CONTROL: a missing footer fails the check", async ({ page }) => { + await page.goto("/dashboard") + await page.waitForLoadState("networkidle").catch(() => {}) + + // Positive control first: it passes on this page as it stands. Without + // this, a helper that threw unconditionally would look like a good check. + await expectFooterOperable(page, "control (footer present)") + + // Now the state the (app) group was actually in. + await page.evaluate(() => document.querySelector("footer")?.remove()) + await expect( + expectFooterOperable(page, "control (footer removed)"), + "expectFooterOperable must reject when the page has no footer — that is " + + "the exact state /dashboard shipped in", + ).rejects.toThrow() + }) + + test("CONTROL: a lid over the page bottom fails the check", async ({ + page, + }) => { + await page.goto("/dashboard") + await page.waitForLoadState("networkidle").catch(() => {}) + + // The consent banner, as it was before it became `sticky`: drawn over the + // bottom of the viewport, taking no space, so no scroll position frees what + // is under it. It covered these four links at every width. + await page.evaluate(() => { + const lid = document.createElement("div") + lid.setAttribute("aria-label", "control lid") + lid.style.cssText = + "position:fixed;left:0;right:0;bottom:0;height:300px;z-index:99" + document.body.appendChild(lid) + }) + + await expect( + expectFooterOperable(page, "control (lid)"), + "the footer's links are present and visible under the lid — only the hit " + + "test can tell that apart from usable, which is why it exists", + ).rejects.toThrow(/cannot be clicked/) + }) + + test("CONTROL: an out-of-flow sidebar fails the overlap check", async ({ + page, + }) => { + await page.goto(`/my/hackathon/${h1Id}/webinars`) + await page.waitForLoadState("networkidle").catch(() => {}) + + await expectSidebarClearsFooter(page, "control (sidebar in flow)") + + // Take the column out of the flex row it is sized by. This is the whole + // mechanism — see the note on expectSidebarClearsFooter. + await page.evaluate(() => { + const aside = document.querySelector("aside") + if (aside) (aside as HTMLElement).style.position = "fixed" + }) + await expect( + expectSidebarClearsFooter(page, "control (sidebar fixed)"), + "a viewport-anchored sidebar is drawn over the footer, and this check is " + + "what would say so", + ).rejects.toThrow(/drawn over the footer/) + }) + + test("CONTROL: two links with one name fails the name check", async ({ + page, + }) => { + await page.goto("/dashboard") + await page.waitForLoadState("networkidle").catch(() => {}) + + // Positive control first, on the footer as it ships. Without it a helper + // that threw unconditionally would look like a good check. + await expectFooterLinkNamesUnique(page, "control (names distinct)") + + // Now put the footer back in exactly the state develop's rebuild shipped + // in: our own About page's link named "About", one column away from + // datascience.ch's. Reproduced in the live DOM rather than by reverting the + // component, so the proof runs on every suite rather than once for whoever + // happened to be watching. + // + // ⚠ It takes TWO mutations now, and that is the point rather than an + // inconvenience: TWO independent fixes hold this pair apart. `fbc81add` + // renamed ours to "About Hackagon", and the off-site suffix added later + // renames theirs to "About (datascience.ch, opens in a new tab)". Undoing + // only one leaves the names distinct — which is how this control started + // reporting green the day the suffix landed, having stopped reproducing + // anything. A control that no longer reaches the defect is the same + // vacuous shape as the check it is guarding. + const collided = await page.evaluate(() => { + const own = document.querySelector('footer a[href="/about"]') + const sdsc = document.querySelector( + 'footer a[href="https://datascience.ch/about"]', + ) + if (!own || !sdsc) return false + own.textContent = "About" + sdsc.querySelector(".sr-only")?.remove() + sdsc.textContent = "About" + return true + }) + expect( + collided, + "the footer is missing /about or datascience.ch/about — this control " + + "cannot reproduce the collision, so it is not proving the check can fail", + ).toBe(true) + + await expect( + expectFooterLinkNamesUnique(page, "control (duplicate name)"), + 'both links are present, visible and correctly labelled on screen — "About" ' + + "under a Platform heading and under an SDSC one. Only the flat list of " + + "names shows the clash, which is why this check reads names rather than " + + "columns", + ).rejects.toThrow(/"About" does not name exactly one link/) + }) + + test("CONTROL: a new-tab link with no announcement fails the check", async ({ + page, + }) => { + await page.goto("/dashboard") + await page.waitForLoadState("networkidle").catch(() => {}) + + // Positive control first, on the footer as it ships — otherwise a helper + // that threw unconditionally would look like a good check. + await expectNewTabLinksAnnounced(page, "control (all announced)") + + // Now put ONE link back in the state every off-site link shipped in until + // this was fixed: target="_blank" and nothing that says so. Stripping the + // suffix in the live DOM rather than reverting the component means the + // proof runs on every suite instead of once for whoever was watching. + const stripped = await page.evaluate(() => { + const a = document.querySelector<HTMLAnchorElement>( + 'footer a[target="_blank"][href*="datascience.ch"]', + ) + if (!a) return null + a.querySelector(".sr-only")?.remove() + return a.href + }) + expect( + stripped, + "no datascience.ch link opens in a new tab — this control cannot " + + "reproduce the defect, so it is not proving the check can fail", + ).not.toBeNull() + + await expect( + expectNewTabLinksAnnounced(page, "control (suffix stripped)"), + "the link is still visible, still correctly labelled on screen and still " + + "reachable — only the flat list of names shows that it silently leaves " + + "the site, which is why this check reads names rather than markup", + ).rejects.toThrow(/without saying so in its accessible name/) + }) + + test("the footer is the same footer on both sides of the login", async ({ + page, + }) => { + // The public group and the app group render one AppShell now. If they drift + // apart again — a second copy of the markup, a group-specific footer — this + // is what says so, and it says it in terms of what a reader can see rather + // than which component was imported. + const labelsOn = async (path: string) => { + await page.goto(path) + await page.waitForLoadState("networkidle").catch(() => {}) + return page + .locator("footer") + .getByRole("link") + .evaluateAll((els) => els.map((e) => (e.textContent ?? "").trim())) + } + + const publicLabels = await labelsOn("/hackathon") + const appLabels = await labelsOn("/dashboard") + + // This used to compare the public footer against the FOOTER_LINKS constant, + // which made the test a claim about the footer's SIZE — and develop's + // rebuild (`02658384`) grew it from four links to fourteen, so the constant + // was wrong the moment a copy edit landed rather than when anything broke. + // Same disease as `03-dashboard`'s `connectedCount: 3`. + // + // The property was always the EQUALITY: one AppShell, so one footer. That + // is asserted directly now, and the presence of the named links is + // expectFooterOperable's job — which runs on both of these routes already + // and does it through the nav landmarks. + expect( + publicLabels.length, + "the public footer has no links at all — two empty lists are equal, and " + + "that is the one way this check could agree with a footer that is gone", + ).toBeGreaterThan(4) + expect( + appLabels, + "the signed-in footer differs from the public one; they are supposed to " + + "be the same component (AppShell)", + ).toEqual(publicLabels) + }) +}) diff --git a/.claude/skills/hackathon-e2e/tests/smoke/22-hackathon-pages.spec.ts b/.claude/skills/hackathon-e2e/tests/smoke/22-hackathon-pages.spec.ts new file mode 100644 index 00000000..4fdc29fc --- /dev/null +++ b/.claude/skills/hackathon-e2e/tests/smoke/22-hackathon-pages.spec.ts @@ -0,0 +1,393 @@ +import { test, expect, type Locator, type Page } from "@playwright/test" +import { storageStatePath } from "../../helpers/state.js" +import { myHackathonId } from "../../helpers/discover.js" +import { rpcAs } from "../../helpers/api.js" +import { SEED_HACKATHONS } from "../../personas.js" + +// A hackathon's own page CMS (`/my/hackathon/<id>/pages`) — the per-event one, +// not the platform's `/manage/pages`, which `06-cms-pages` covers. +// +// Two things this screen learned, and one thing it must never learn: +// +// * each row previews its page's opening line, so the list says something +// about the pages behind it rather than listing three titles; +// * a row can be dragged into place, and — because HTML5 drag fires from +// neither a key press nor a touch — picked up from the keyboard as well, +// both writing the whole sequence with ONE `PageService.SetOrder` call; +// * that preview must never render an author's markup. Page content is +// markdown from the database, `act0.about.xss` pastes a `<script>` into +// content on purpose, and a management screen is exactly where a stored +// payload would be read by the person with the most authority. +// +// Every reorder assertion is made AFTER A RELOAD, and then read back off +// `PageService.List` as well. A drag that only moved a node on screen and a +// drag that was saved look identical until you ask someone else. +// +// The seeded order is captured and restored by each reorder test, so the shared +// database is left as it was found; the XSS probe creates its own page and +// deletes it again. + +const EXCERPT = "[data-testid=page-excerpt]" +const ANNOUNCEMENT = "[data-testid=reorder-announcement]" + +const XSS_TITLE = "Venue notes" +const XSS_MARKER = "__pagePreviewXss" +const XSS_CONTENT = [ + "Doors open at 08:30.", + "", + `<script>window.${XSS_MARKER} = 'executed'</script>`, + "", + `<img src=x onerror="window.${XSS_MARKER} = 'executed'">`, + "", + "See you there.", +].join("\n") + +/** A row of the list, found by the title it STATES (its heading). */ +const row = (page: Page, title: string): Locator => + page + .locator("[data-page-row]") + .filter({ has: page.getByRole("heading", { name: title, exact: true }) }) + +/** The titles in the order the list renders them. */ +async function shownOrder(page: Page): Promise<string[]> { + const titles = await page.locator("[data-page-row] h3").allTextContents() + return titles.map((t) => t.trim()) +} + +/** The titles in the order the BACKEND holds them — `List` sorts by `order`. */ +async function storedOrder(hackathonId: string): Promise<string[]> { + const res = await rpcAs("alice", "hackathon.PageService/List", { + hackathonId, + }) + if (!res.ok) throw new Error(`PageService.List failed: ${res.raw}`) + return ((res.data?.pages ?? []) as { title: string }[]).map((p) => p.title) +} + +/** + * Drag one row onto another with real mouse input. + * + * Deliberately `page.mouse` rather than `dragTo`: the handler moves the row + * under the pointer as it passes, so the drag has to actually TRAVEL over the + * rows in between — a single jump to the destination reorders nothing, and a + * `dragTo` that half-works would be the kind of flake this suite cannot carry. + * The destination box is read before the drag starts, because the list + * rearranges itself underneath the pointer while it moves. + */ +async function dragRowTo(source: Locator, destination: Locator) { + const page = source.page() + // Grab the row by its title: the whole row is draggable, but starting on the + // handle or a link means fighting a control for the mousedown. + const from = await source.locator("h3").boundingBox() + const to = await destination.boundingBox() + if (!from || !to) throw new Error("a row to drag is not on screen") + + const startX = from.x + Math.min(from.width / 2, 40) + const startY = from.y + from.height / 2 + const endY = to.y + to.height / 2 + + await page.mouse.move(startX, startY) + await page.mouse.down() + // A small first move is what makes the browser begin a drag at all; the rest + // travels in steps so `dragover` fires on every row on the way. + await page.mouse.move(startX, startY + (endY > startY ? 8 : -8), { steps: 4 }) + await page.mouse.move(startX, endY, { steps: 24 }) + await page.mouse.move(startX, endY, { steps: 4 }) + await page.mouse.up() +} + +/** Wait for the one `SetOrder` write a reorder is allowed to make. */ +function awaitSetOrder(page: Page) { + return page.waitForResponse( + (r) => r.request().method() === "POST" && r.url().includes("setOrder"), + ) +} + +test.describe.configure({ mode: "serial" }) + +test.describe("hackathon pages: preview and reorder", () => { + test.use({ storageState: storageStatePath("alice") }) + + let hackathonId = "" + + test.beforeEach(async ({ page }) => { + if (!hackathonId) { + hackathonId = await myHackathonId(page, SEED_HACKATHONS.h1.name) + } + await page.goto(`/my/hackathon/${hackathonId}/pages`) + await page.waitForLoadState("networkidle") + }) + + test("each row previews its page as text, with the markdown parsed away", async ({ + page, + }) => { + // Every row carries one, so an assertion below cannot be passing because + // the element it names is simply missing from that row. + const excerpts = page.locator(EXCERPT) + const rowCount = await page.locator("[data-page-row]").count() + expect(rowCount, "the seed should give h1 several pages").toBeGreaterThan(1) + await expect(excerpts).toHaveCount(rowCount) + + // The seeded Schedule page is `## Day 1 – Ideation` followed by a list of + // times. This one string proves three separate things: the `##` is gone, + // the `-` bullets are gone, and a SPACE was put between two blocks rather + // than their words being glued into `Ideation09:00`. + const schedule = row(page, "Schedule").locator(EXCERPT) + await expect(schedule).toContainText( + "Day 1 – Ideation 09:00 Opening ceremony", + ) + await expect(schedule).toContainText("10:00 Team formation") + expect(await schedule.textContent()).not.toContain("##") + expect(await schedule.textContent()).not.toContain("- 09:00") + + const welcome = row(page, "Welcome").locator(EXCERPT) + await expect(welcome).toContainText( + "Welcome to AI Innovation Challenge 2026 Join us for three days", + ) + + // The excerpt's way out. `View` reaches the page as a participant gets it, + // where the same markdown is RENDERED rather than flattened — so the `##` + // that vanished above is a real heading here. + await row(page, "Schedule") + .getByRole("link", { name: "View Schedule" }) + .click() + await expect(page.getByRole("heading", { name: "Schedule" })).toBeVisible() + await expect(page.locator(".markdown-content h2").first()).toHaveText( + "Day 1 – Ideation", + ) + }) + + test("a script pasted into a page's content cannot run from the list", async ({ + page, + }) => { + await page.goto(`/my/hackathon/${hackathonId}/pages/new`) + await page.locator('input[name="title"]').fill(XSS_TITLE) + await page.locator('textarea[name="content"]').fill(XSS_CONTENT) + await page.getByRole("button", { name: "Add page", exact: true }).click() + + const probe = row(page, XSS_TITLE) + await expect(probe).toBeVisible() + + try { + // CONTROL. Without this the rest of the test is a claim about a payload + // that may never have reached the database — an input the CMS silently + // dropped would satisfy every assertion below. + await probe.getByRole("link", { name: `Edit ${XSS_TITLE}` }).click() + const stored = await page.locator('textarea[name="content"]').inputValue() + expect(stored, "the payload must really be stored").toContain( + `<script>window.${XSS_MARKER}`, + ) + expect(stored).toContain("onerror=") + + await page.goto(`/my/hackathon/${hackathonId}/pages`) + await page.waitForLoadState("networkidle") + + const excerpt = row(page, XSS_TITLE).locator(EXCERPT) + // POSITIVE first: the excerpt was produced, and it is the prose either + // side of the payload. An empty row would satisfy every "not" below. + await expect(excerpt).toHaveText("Doors open at 08:30. See you there.") + + const text = (await excerpt.textContent()) ?? "" + expect(text).not.toContain(XSS_MARKER) + expect(text).not.toContain("script") + expect(text).not.toContain("onerror") + // Nothing markup-shaped leaves the flattener, so there is nothing for a + // careless `{@html}` downstream to run either. + const html = await excerpt.innerHTML() + expect(html).not.toContain("<script") + expect(html).not.toContain("onerror") + expect(html).not.toContain(XSS_MARKER) + expect( + await row(page, XSS_TITLE).locator("script").count(), + "no script element may be built out of page content", + ).toBe(0) + expect( + await page.evaluate( + (k) => (window as unknown as Record<string, unknown>)[k], + XSS_MARKER, + ), + "the payload must not have executed on the management list", + ).toBeUndefined() + + // And the same page where the markdown is genuinely RENDERED, since that + // is the surface the flattener inherits its policy from. + await row(page, XSS_TITLE) + .getByRole("link", { name: `View ${XSS_TITLE}` }) + .click() + await expect(page.locator(".markdown-content")).toContainText( + "Doors open at 08:30.", + ) + expect(await page.locator(".markdown-content").innerHTML()).not.toContain( + XSS_MARKER, + ) + expect( + await page.evaluate( + (k) => (window as unknown as Record<string, unknown>)[k], + XSS_MARKER, + ), + "the payload must not have executed on the page itself", + ).toBeUndefined() + } finally { + // Leave the fixture as it was found, whatever happened above. + await page.goto(`/my/hackathon/${hackathonId}/pages`) + await page.waitForLoadState("networkidle") + const stale = row(page, XSS_TITLE) + if ((await stale.count()) > 0) { + await stale.getByRole("link", { name: `Edit ${XSS_TITLE}` }).click() + await page + .getByRole("button", { name: "Delete page", exact: true }) + .click() + await page + .getByRole("button", { name: "Delete permanently", exact: true }) + .click() + await page.waitForURL(`**/my/hackathon/${hackathonId}/pages`) + await expect(row(page, XSS_TITLE)).toHaveCount(0) + } + } + }) + + test("dragging a row saves the whole new order in one write", async ({ + page, + }) => { + const before = await shownOrder(page) + expect( + before.length, + "need at least three pages to prove a move", + ).toBeGreaterThan(2) + expect( + await storedOrder(hackathonId), + "the screen and the database must agree before we touch anything", + ).toEqual(before) + + const last = before[before.length - 1]! + const first = before[0]! + + // --- last row to the top ------------------------------------------------ + let saved = awaitSetOrder(page) + await dragRowTo(row(page, last), row(page, first)) + expect( + (await saved).status(), + "the drop should be one accepted write", + ).toBe(200) + + const expected = [last, ...before.slice(0, -1)] + await page.reload() + await page.waitForLoadState("networkidle") + expect( + await shownOrder(page), + "the order must survive a reload — a moved node is not a saved one", + ).toEqual(expected) + expect( + await storedOrder(hackathonId), + "and the backend must be the one holding it", + ).toEqual(expected) + + // --- and back, which is the restore and a second proof ------------------ + // The moved row is at the TOP now, so the destination is whatever the + // bottom row has become — not `last`, which is the row doing the moving. + saved = awaitSetOrder(page) + await dragRowTo(row(page, last), row(page, expected[expected.length - 1]!)) + expect((await saved).status()).toBe(200) + + await page.reload() + await page.waitForLoadState("networkidle") + expect(await shownOrder(page)).toEqual(before) + expect(await storedOrder(hackathonId)).toEqual(before) + }) + + test("a page can be moved with the keyboard alone, and says so out loud", async ({ + page, + }) => { + const before = await shownOrder(page) + expect(before.length).toBeGreaterThan(2) + const moving = before[0]! + const last = before.length + + const announcement = page.locator(ANNOUNCEMENT) + // Control: the live region starts empty, so every match below is this + // interaction's doing and not text that was on the page all along. + await expect(announcement).toHaveText("") + + const handle = () => + row(page, moving).getByRole("button", { + name: `Reorder ${moving}`, + exact: true, + }) + + await handle().press("Enter") + await expect(announcement).toContainText( + `Picked up ${moving}, position 1 of ${last}`, + ) + await expect(handle()).toHaveAttribute("aria-pressed", "true") + + const saved = awaitSetOrder(page) + for (let i = 2; i <= last; i++) { + await handle().press("ArrowDown") + await expect(announcement).toContainText( + `${moving}, position ${i} of ${last}`, + ) + } + await handle().press("Enter") + await expect(announcement).toContainText( + `Dropped ${moving} at position ${last}`, + ) + expect((await saved).status()).toBe(200) + + const expected = [...before.slice(1), moving] + await page.reload() + await page.waitForLoadState("networkidle") + expect( + await shownOrder(page), + "a keyboard move must persist exactly like a drag", + ).toEqual(expected) + expect(await storedOrder(hackathonId)).toEqual(expected) + + // --- back to the top, restoring the fixture ----------------------------- + const back = awaitSetOrder(page) + await handle().press("Enter") + for (let i = 0; i < last - 1; i++) await handle().press("ArrowUp") + await handle().press("Enter") + expect((await back).status()).toBe(200) + + await page.reload() + await page.waitForLoadState("networkidle") + expect(await shownOrder(page)).toEqual(before) + expect(await storedOrder(hackathonId)).toEqual(before) + }) + + test("Escape puts a picked-up page back and writes nothing", async ({ + page, + }) => { + const before = await shownOrder(page) + const moving = before[0]! + const handle = () => + row(page, moving).getByRole("button", { + name: `Reorder ${moving}`, + exact: true, + }) + + let writes = 0 + page.on("request", (r) => { + if (r.method() === "POST" && r.url().includes("setOrder")) writes++ + }) + + await handle().press("Enter") + await handle().press("ArrowDown") + // The row really did move on screen — otherwise "Escape put it back" is a + // statement about a move that never happened. + expect(await shownOrder(page)).toEqual([ + before[1]!, + moving, + ...before.slice(2), + ]) + + await handle().press("Escape") + await expect(page.locator(ANNOUNCEMENT)).toContainText( + "Reordering cancelled", + ) + expect(await shownOrder(page)).toEqual(before) + + await page.waitForTimeout(500) + expect(writes, "a cancelled move must not reach the backend").toBe(0) + expect(await storedOrder(hackathonId)).toEqual(before) + }) +}) diff --git a/.claude/skills/hackathon-e2e/tests/smoke/23-login-destination.spec.ts b/.claude/skills/hackathon-e2e/tests/smoke/23-login-destination.spec.ts new file mode 100644 index 00000000..ff6b41dc --- /dev/null +++ b/.claude/skills/hackathon-e2e/tests/smoke/23-login-destination.spec.ts @@ -0,0 +1,315 @@ +import { test, expect, type Page } from "@playwright/test" +import { PERSONAS, SEED_HACKATHONS } from "../../personas.js" +import { storageStatePath } from "../../helpers/state.js" +import { + expectSignedIn, + fillKeycloakForm, + keycloakUrlPattern, +} from "../../helpers/login.js" +import { rpcAnonymous } from "../../helpers/api.js" + +// Where a login STARTS and where it ENDS. +// +// Two complaints, one mechanism: +// +// 1. An anonymous visitor who opened a deep link was 303'd to the marketing +// landing page with no word about why. They now land on /signin, which +// says what happened and where it is taking them before it goes. +// 2. Signing in from that bounce dropped them on the dashboard regardless of +// the link they had followed. The parked destination survives the whole +// OIDC round trip now. +// +// The valuable assertion here is the ROUND TRIP, and it is deliberately written +// so the two outcomes cannot both pass by accident: "deep link" asserts the +// final URL IS the deep link (a redirect hard-wired to /dashboard fails it) and +// "no deep link" asserts the final URL is the dashboard (an implementation that +// always echoed the current path would land on "/" and fail it). +// +// The interstitial's own content is asserted with JAVASCRIPT DISABLED, which is +// both the accessibility floor this repo holds itself to and the only way to +// hold the page still: with script on it forwards itself after ~2s, so anything +// asserted there races the navigation it is there to cause. + +const ALICE = PERSONAS.alice +const BOB = PERSONAS.bob + +/** alice OWNS h1 in the SMOKE fixture, so this is a page she can really open. */ +let deepLink = "" + +// Resolved over gRPC rather than by reading the landing page: the first test in +// this file runs in a JS-disabled context, and a browser-based lookup here also +// pays for a cold context plus the landing page's hero and carousel images +// before it can read one href — which is how the first version of this hook blew +// its 60s budget on a fixture the suite had already proved was there. +test.beforeAll(() => { + const listed = rpcAnonymous("hackathon.HackathonService/List", { + visibilityFilter: 1, + }) + if (!listed.ok) throw new Error(`HackathonService.List failed: ${listed.raw}`) + const h1 = ( + listed.data.hackathons as { id: string; name: string }[] | undefined + )?.find((h) => h.name === SEED_HACKATHONS.h1.name) + if (!h1) { + throw new Error( + `seed hackathon "${SEED_HACKATHONS.h1.name}" not found — run scripts/run.sh smoke (it seeds) or scripts/seed.sh first`, + ) + } + deepLink = `/my/hackathon/${h1.id}/manage` +}) + +/** The interstitial's own live region — the element that STATES the facts. */ +function status(page: Page) { + return page.locator("main [role='status']") +} + +/** + * "Am I looking at the interstitial?" — asked of its HEADING, not of a + * `[role=status]` anywhere on the page. Any page in the app may grow a live + * region, and an absence-assertion built on a selector that broad would start + * agreeing with the wrong thing the day one does. + */ +function interstitialHeading(page: Page) { + return page.getByRole("heading", { name: "Sign in to continue" }) +} + +// ─── The interstitial, with no JavaScript at all ───────────────────────────── + +test.describe("the interstitial without JavaScript", () => { + test.use({ + javaScriptEnabled: false, + storageState: { cookies: [], origins: [] }, + }) + + test("explains what happened and names where it is going", async ({ + page, + }) => { + await page.goto(deepLink) + expect( + page.url(), + "the guard must park the refused URL on the interstitial", + ).toContain(`/signin?returnTo=${encodeURIComponent(deepLink)}`) + + // role="status" is an aria-live region: the explanation is the whole point + // of this page, and one that only exists visually is not an explanation for + // everybody. Asserted on the live region ITSELF, not on the section that + // contains it — a container also contains the heading and the buttons, and + // would agree with a page that announced nothing. + const region = status(page) + await expect(region).toBeVisible() + await expect(region).toContainText("not signed in") + await expect( + region, + "the page must name the URL that was refused", + ).toContainText(deepLink) + await expect(region, "and where it is sending them").toContainText( + "login page", + ) + + // Nobody is stuck watching a countdown, and with no script this button is + // the ONLY way onward — so it is also the honest thing for the text to + // point at, which is what the no-script branch of the copy says. + await expect(region).toContainText("button below") + await expect(region).not.toContainText("Taking you") + await expect( + page.getByRole("button", { name: "Go to login now" }), + ).toBeVisible() + }) + + test("does not pretend to forward a browser that cannot forward itself", async ({ + page, + }) => { + await page.goto(deepLink) + const parked = page.url() + // Comfortably past the 2s the scripted hop waits. A page that navigated + // here would mean the timer does not depend on script — which would make + // the "use the button below" copy a lie in the other direction. + await page.waitForTimeout(4_000) + expect(page.url(), "no script ⇒ no automatic hop").toBe(parked) + }) + + test("signs in and lands on the deep link, script or no script", async ({ + page, + }) => { + await page.goto(deepLink) + await page.getByRole("button", { name: "Go to login now" }).click() + + // The form POST is the mechanism, not a decoration: it starts the real OIDC + // flow. (A <meta http-equiv="refresh"> could not — a meta refresh issues a + // GET and Auth.js mints its state/PKCE cookies on a POST.) + await page.waitForURL(keycloakUrlPattern(), { timeout: 45_000 }) + await fillKeycloakForm(page, ALICE) + + await page.waitForURL(/localhost:8081/, { timeout: 30_000 }) + expect( + page.url(), + "the deep link must survive a scriptless login", + ).toContain(deepLink) + // Landed there AND signed in. Without this, a guard that bounced again + // would be the only thing distinguishing success from failure, and the + // bounce would put a different URL in the bar — but "the URL is right" and + // "there is a session" are two claims, and only one of them was made. + await expectSignedIn(page, ALICE.initial) + }) + + test("a crafted returnTo cannot become an off-site callback", async ({ + page, + }) => { + // The form's redirectTo is handed straight to Auth.js as the post-login + // destination. An unvalidated one is an open redirect off the site, so the + // assertion is on the VALUE the page is about to submit. + const redirectTo = page.locator("form input[name='redirectTo']") + + for (const hostile of [ + "https://evil.example/", + "//evil.example", + "/\\evil.example", + ]) { + await page.goto(`/signin?returnTo=${encodeURIComponent(hostile)}`) + await expect( + redirectTo, + `${hostile} must not survive into the callback`, + ).toHaveValue("/dashboard") + await expect(status(page)).not.toContainText("evil.example") + } + + // The positive control: a legitimate path DOES survive, so the assertions + // above are not passing because the field is always "/dashboard". + await page.goto(`/signin?returnTo=${encodeURIComponent(deepLink)}`) + await expect(redirectTo).toHaveValue(deepLink) + }) + + test("renders with its chrome at a phone width", async ({ page }) => { + // The mobile full sweep declares this route UNCOVERED — it cannot hold a + // page that navigates away from itself — so the width that has caught every + // reflow bug in this repo so far is checked in the place that can. + const WIDTH = 360 + await page.setViewportSize({ width: WIDTH, height: 844 }) + await page.goto(deepLink) + + // Presence first. The geometry loop below measures whatever it is handed, + // so a missing element would sail through it — the exact shape of the + // "helper that no-ops when its subject is absent" trap. + for (const scope of ["header", "main", "footer"]) { + await expect(page.locator(scope), `${scope} must render`).toBeVisible() + } + + // The one thing on this page that can genuinely overflow: the destination + // is a real URL with a UUID in it, printed verbatim in a monospace run, and + // an unbroken 55-character string is wider than a 360px phone. `break-all` + // is what keeps it in; this is the assertion that notices if it goes. + for (const [name, locator] of [ + ["heading", page.getByRole("heading", { name: "Sign in to continue" })], + ["status region", status(page)], + ["submit button", page.getByRole("button", { name: "Go to login now" })], + ["footer", page.locator("footer")], + ] as const) { + await expect(locator, `${name} must be visible`).toBeVisible() + const box = await locator.boundingBox() + expect(box, `${name} should have a box`).not.toBeNull() + expect( + Math.round(box!.x + box!.width), + `${name} runs past the right edge of a ${WIDTH}px screen`, + ).toBeLessThanOrEqual(WIDTH) + expect( + Math.round(box!.x), + `${name} starts left of a ${WIDTH}px screen`, + ).toBeGreaterThanOrEqual(0) + } + }) +}) + +// ─── The round trip ────────────────────────────────────────────────────────── + +test.describe("signing in from a deep link", () => { + test.use({ storageState: { cookies: [], origins: [] } }) + + test("forwards itself and comes back to the page that was asked for", async ({ + page, + }) => { + await page.goto(deepLink) + + // With script, the copy promises a hop — and the promise is kept below, + // with no click of any kind. + await expect(status(page)).toContainText("Taking you to the login page") + + await page.waitForURL(keycloakUrlPattern(), { timeout: 45_000 }) + await fillKeycloakForm(page, ALICE) + + await page.waitForURL(/localhost:8081/, { timeout: 30_000 }) + // THE assertion this whole file exists for. Not "somewhere sensible" and + // not "the dashboard" — the page they followed a link to. + expect(page.url()).toContain(deepLink) + await expect(page).not.toHaveURL(/\/dashboard$/) + + // And it is the real page, not an error rendered at that URL. + await expectSignedIn(page, ALICE.initial) + await expect(page.locator("main")).toBeVisible() + }) +}) + +test.describe("signing in with nowhere in particular to go", () => { + test.use({ storageState: { cookies: [], origins: [] } }) + + test("lands on the dashboard", async ({ page }) => { + await page.goto("/") + await page.waitForLoadState("networkidle") + await page.getByRole("button", { name: "Log in" }).click() + + await page.waitForURL(keycloakUrlPattern(), { timeout: 45_000 }) + await fillKeycloakForm(page, BOB) + + await page.waitForURL(/localhost:8081/, { timeout: 30_000 }) + // The dashboard, NOT "/". Coming back to the page you started from reads as + // "nothing happened", and this is the half of the behaviour that an + // implementation echoing the current path would break. + await expect(page).toHaveURL(/\/dashboard$/) + await expect( + page.getByRole("heading", { name: /Welcome back/ }), + ).toBeVisible() + }) +}) + +// ─── Nobody who is already signed in waits for anything ────────────────────── + +test.describe("a signed-in visitor", () => { + test.use({ storageState: storageStatePath("alice") }) + + test("never sees the interstitial, with or without a parked link", async ({ + page, + }) => { + // A positive control first, so "the heading is absent" below cannot be + // absent for the boring reason. Signed out, this exact locator DOES find + // the page — asserted in the no-JS block above and re-checked here in an + // anonymous context of its own. + const anon = await page + .context() + .browser()! + .newContext({ + storageState: { cookies: [], origins: [] }, + javaScriptEnabled: false, + }) + const anonPage = await anon.newPage() + await anonPage.goto(deepLink) + await expect( + interstitialHeading(anonPage), + "control: an anonymous visitor DOES land on the interstitial", + ).toBeVisible() + await anon.close() + + // Reached by hand (a stale link, a Back press out of Keycloak): forwarded + // immediately, no explanation and no delay. + await page.goto(`/signin?returnTo=${encodeURIComponent(deepLink)}`) + expect(page.url()).toContain(deepLink) + await expect(interstitialHeading(page)).toHaveCount(0) + + await page.goto("/signin") + await expect(page).toHaveURL(/\/dashboard$/) + await expect(interstitialHeading(page)).toHaveCount(0) + + // And the deep link itself simply opens — the guard is not in the way for + // someone who is allowed through it. + const resp = await page.goto(deepLink) + expect(resp?.status()).toBe(200) + await expect(interstitialHeading(page)).toHaveCount(0) + }) +}) diff --git a/.claude/skills/hackathon-e2e/tests/smoke/24-chip-states.spec.ts b/.claude/skills/hackathon-e2e/tests/smoke/24-chip-states.spec.ts new file mode 100644 index 00000000..d28c777d --- /dev/null +++ b/.claude/skills/hackathon-e2e/tests/smoke/24-chip-states.spec.ts @@ -0,0 +1,320 @@ +import { test, expect, type Locator, type Page } from "@playwright/test" + +// The chip's four appearances, measured rather than inferred. +// +// The bug: `.chip:hover` is (0,2,0) and `.chip-active` is (0,1,0), so pointing +// at the tab you are already on ERASED its accent tint and painted it with the +// same neutral `raised` an unselected chip gets. Selected and unselected became +// the same pixels for exactly as long as the pointer was on them — which is the +// moment a reader is most likely to be checking which tab is which. +// +// Why this file asserts COMPUTED STYLE and never a class name. `chip-active` was +// on the element the whole time the bug shipped; every class-based assertion +// that could have been written would have passed. That is the +// "locator that contains the thing it asserts about" family from +// .claude/CLAUDE.md, in its CSS form: the class is the INPUT to the rule, and +// what broke was the rule. Only the resolved colour is the fact. +// +// Carrier: the public landing page's Trending tab row — one `chip chip-active` +// ("Hackathons") and two plain `chip` — because it is anonymous, three chips in +// one row, and no fixture data is involved. The rule itself is global to the +// theme, so any `.chip` anywhere would do. + +type RGB = [number, number, number] + +/** + * The colour a reader actually sees, as sRGB bytes. + * + * Not a regex over the computed string, which was the first attempt and does + * not survive contact with this theme: `.chip-active` is + * `color-mix(in oklab, …)` and Firefox reports that back as + * `oklab(0.8 -0.101689 0.11698 / 0.2)`, while `.chip:hover` resolves to a plain + * `rgb(…)`. Two syntaxes for the same kind of fact, and one of them names a + * colour space this file has no business reimplementing. + * + * So the browser is asked to paint it. The colour goes onto a 1×1 canvas OVER + * the page's own background, which resolves both the colour space and the alpha + * exactly the way the page resolves them — a 20%-accent wash and a 30% one are + * two different pixels, which is the claim, and reading them as `rgba(…,0.2)` + * vs `rgba(…,0.3)` would have been a claim about the stylesheet instead. + * + * `fillStyle` silently KEEPS its previous value when handed something it cannot + * parse, so an unsupported syntax would quietly measure whatever was set last + * and every comparison would come out equal — a vacuous pass of the worst kind. + * The sentinel makes that a thrown error. + */ +async function paintedColour( + page: Page, + value: string, + over: string, +): Promise<RGB> { + const out = await page.evaluate( + ([v, backdrop]) => { + const canvas = document.createElement("canvas") + canvas.width = 1 + canvas.height = 1 + const ctx = canvas.getContext("2d") + if (!ctx) return null + + const SENTINEL = "#ff00ff" + ctx.fillStyle = SENTINEL + ctx.fillStyle = v + if (ctx.fillStyle === SENTINEL) return null + + ctx.fillStyle = backdrop + ctx.fillRect(0, 0, 1, 1) + ctx.fillStyle = v + ctx.fillRect(0, 0, 1, 1) + const d = ctx.getImageData(0, 0, 1, 1).data + + return [d[0], d[1], d[2]] + }, + [value, over] as const, + ) + if (!out) { + throw new Error( + `the browser could not paint '${value}' — nothing was measured`, + ) + } + + return out as RGB +} + +/** Straight-line distance in sRGB between two painted colours. */ +function colourDistance(a: RGB, b: RGB): number { + return Math.hypot(a[0] - b[0], a[1] - b[1], a[2] - b[2]) +} + +/** + * A settled computed value. + * + * `.chip` carries `transition: background-color 120ms ease`, and + * `getComputedStyle` mid-transition returns the INTERMEDIATE colour — so a read + * taken straight after `hover()` is a point on the way to the answer and would + * make every comparison below timing-dependent. Reads until two consecutive + * samples agree rather than sleeping a guessed interval. + */ +async function settledStyle( + el: Locator, + prop: "background-color" | "color", +): Promise<string> { + const read = () => + el.evaluate((node, p) => getComputedStyle(node).getPropertyValue(p), prop) + + let previous = await read() + for (let i = 0; i < 25; i++) { + await el.page().waitForTimeout(40) + const current = await read() + if (current === previous) return current + previous = current + } + throw new Error(`${prop} never settled on this element`) +} + +interface ChipPaint { + background: RGB + /** Kept as the computed string: it is compared for equality, never measured. */ + ink: string +} + +/** Move the pointer off every chip and let the transition finish. */ +async function unhover(page: Page) { + await page.mouse.move(0, 0) +} + +async function paintOf(el: Locator, backdrop: string): Promise<ChipPaint> { + const background = await settledStyle(el, "background-color") + + return { + background: await paintedColour(el.page(), background, backdrop), + ink: await settledStyle(el, "color"), + } +} + +/** + * The whole claim, as one function, so the control below can call the exact + * check the real test calls rather than a lookalike. + * + * Four appearances, and every pair that must differ: + * + * selected at rest ─┬─ vs unselected at rest : selection is visible + * └─ vs selected hovered : hover answers on it too + * unselected at rest ─── vs unselected hovered : hover stays visible + * unselected hovered ─── vs selected hovered : THE BUG — these were equal + * + * Plus the "reads as both" claim, which the four inequalities alone do not + * make: a selected-and-hovered chip painted some unrelated third colour would + * satisfy all of them. It has to stay in the accent family, so its background + * is required to be NEARER to selected-at-rest than to unselected-hovered, and + * its ink has to be the selected ink exactly. + */ +async function expectChipStatesDistinct(page: Page) { + const row = page.locator("#trending") + const selected = row.locator("button.chip.chip-active") + const unselected = row.locator("button.chip:not(.chip-active)").first() + + // Positive control for the whole file: without both kinds of chip on screen + // every comparison below is between two reads of the same element. + await expect( + selected, + "the landing page must render one selected chip", + ).toHaveCount(1) + await expect(unselected, "…and at least one unselected chip").toBeVisible() + + // The backdrop these washes are composited over: the chips sit directly on the + // page in the Trending row, with no card between them, so the body's own + // background is what shows through a `transparent` chip. Read once and used + // for all four, which is what makes the four numbers comparable. + const backdrop = await page.evaluate( + () => getComputedStyle(document.body).backgroundColor, + ) + + await unhover(page) + const selectedRest = await paintOf(selected, backdrop) + const unselectedRest = await paintOf(unselected, backdrop) + + await unselected.hover() + const unselectedHover = await paintOf(unselected, backdrop) + + await selected.hover() + const selectedHover = await paintOf(selected, backdrop) + + await unhover(page) + + const d = (a: ChipPaint, b: ChipPaint) => + colourDistance(a.background, b.background) + const show = (c: ChipPaint) => `rgb(${c.background.join(",")})` + // 8 rather than 0: two colours a reader cannot tell apart are not two states, + // and an exact-inequality check would accept a one-unit rounding difference + // as a distinguishable style. + const VISIBLE = 8 + + expect( + d(selectedRest, unselectedRest), + `the selected chip is painted like an unselected one at rest ` + + `(${show(selectedRest)} vs ${show(unselectedRest)})`, + ).toBeGreaterThan(VISIBLE) + + // `> 0`, not `> VISIBLE`, and deliberately so. HOW FAR the unselected hover + // moves is a design decision that differs by colour mode — light mode's + // `raised` is two lightness points from `canvas` — and this file has no + // business ruling on it. That the hover rule REACHES the chip at all is the + // property; the integers come from getImageData, so equality here is exact + // rather than approximate. + expect( + d(unselectedRest, unselectedHover), + `hover does not change an unselected chip at all ` + + `(${show(unselectedRest)} vs ${show(unselectedHover)})`, + ).toBeGreaterThan(0) + + // The regression itself. Before the fix both of these resolved to + // `--color-raised` with `--color-ink`, i.e. the selection vanished under the + // pointer. + expect( + d(unselectedHover, selectedHover), + `THE BUG: a hovered selected chip is painted exactly like a hovered ` + + `unselected one (${show(selectedHover)} vs ${show(unselectedHover)}) — ` + + `.chip:hover (0,2,0) is beating .chip-active (0,1,0)`, + ).toBeGreaterThan(VISIBLE) + + expect( + d(selectedRest, selectedHover), + `the selected chip does not answer the pointer at all ` + + `(${show(selectedRest)} vs ${show(selectedHover)})`, + ).toBeGreaterThan(VISIBLE) + + // Reads as BOTH: still accent-tinted, not merely "some other colour". + expect( + d(selectedHover, selectedRest), + `selected-and-hovered has left the accent family: it is nearer to the ` + + `unselected hover (${show(unselectedHover)}) than to the selected ` + + `rest state (${show(selectedRest)})`, + ).toBeLessThan(d(selectedHover, unselectedHover)) + + expect( + selectedHover.ink, + `a hovered selected chip must keep the selected ink (${selectedRest.ink}); ` + + `it took the unselected hover ink (${unselectedHover.ink}) instead`, + ).toBe(selectedRest.ink) +} + +test.describe("chip states stay distinguishable", () => { + test("selected, hovered, and selected-and-hovered are three different chips", async ({ + page, + }) => { + await page.goto("/") + await expectChipStatesDistinct(page) + }) + + // ─── Control: the check, shown failing ────────────────────────────────────── + // + // Deleting the rule from the live CSSOM restores the exact stylesheet that + // shipped the bug, so this is the defect itself rather than an imitation of + // it. Done in the page rather than by editing the theme and re-running by + // hand: a source edit proves it once, for whoever was watching, and then + // stops existing. + test("CONTROL: without .chip-active:hover the check fails", async ({ + page, + }) => { + await page.goto("/") + + // Passes as it stands, first — a check that threw unconditionally would + // look exactly like a good one from here. + await expectChipStatesDistinct(page) + + const deleted = await page.evaluate(() => { + // Anything that OWNS rules and can drop one: a stylesheet, or a grouping + // rule. `@layer components { … }` wraps this whole theme, so the rule is + // never at the top level of the sheet and the walk has to recurse — and + // deletion is by index ON THE OWNER, which is why the owner is what gets + // passed down rather than a bare CSSRuleList. + type RuleOwner = { + cssRules: CSSRuleList + deleteRule(index: number): void + } + + const strip = (owner: RuleOwner): number => { + let n = 0 + const rules = owner.cssRules + // Backwards: deleting shifts every later index down by one. + for (let i = rules.length - 1; i >= 0; i--) { + const rule = rules[i] as CSSRule & + Partial<RuleOwner> & { selectorText?: string } + if (rule.cssRules && typeof rule.deleteRule === "function") { + n += strip(rule as unknown as RuleOwner) + } + if (rule.selectorText === ".chip-active:hover") { + owner.deleteRule(i) + n++ + } + } + + return n + } + + let total = 0 + for (const sheet of Array.from(document.styleSheets)) { + try { + total += strip(sheet) + } catch { + // A cross-origin sheet cannot be read; none of ours are. + } + } + + return total + }) + + expect( + deleted, + "the control removed no rule — either the selector was renamed (update " + + "this control with it) or the fix is not in the stylesheet at all, and " + + "the test above is passing for some other reason", + ).toBeGreaterThan(0) + + await expect( + expectChipStatesDistinct(page), + "with the rule gone the selected chip is repainted by .chip:hover, and " + + "this check is what must say so", + ).rejects.toThrow(/THE BUG/) + }) +}) diff --git a/.claude/skills/hackathon-e2e/tests/tunnel/admin-shot.spec.ts b/.claude/skills/hackathon-e2e/tests/tunnel/admin-shot.spec.ts new file mode 100644 index 00000000..2b474f0d --- /dev/null +++ b/.claude/skills/hackathon-e2e/tests/tunnel/admin-shot.spec.ts @@ -0,0 +1,66 @@ +import { test } from "@playwright/test" +import { PERSONAS, SEED_HACKATHONS } from "../../personas.js" +import { storageStatePath } from "../../helpers/state.js" +import { myHackathonId } from "../../helpers/discover.js" + +/** + * Screenshot utility, not a test: captures the ORGANIZER surfaces at desktop + * width into .artifacts/admin/, so the management UI can be reviewed without + * driving the app by hand. + * + * Runs only with ADMIN_SHOT=1: + * ADMIN_SHOT=1 pnpm exec playwright test --project=tunnel --grep "admin panel" + * + * Needs the seeded fixture (scripts/run.sh smoke leaves it in place). + */ +const enabled = !!process.env.ADMIN_SHOT + +test.describe("admin panel screenshots", () => { + test.skip(!enabled, "ADMIN_SHOT not set — utility spec, skipped in suites") + test.use({ + storageState: storageStatePath(PERSONAS.admin.key), + viewport: { width: 1440, height: 900 }, + }) + + test("organizer surfaces", async ({ page }) => { + // H2 is the one hackagon-admin owns in the seed fixture. + const id = await myHackathonId(page, SEED_HACKATHONS.h2.name) + + const shots: { name: string; path: string }[] = [ + // The one-page organiser cockpit is gone: this design gives each of its + // sections a route, so the shot list follows them rather than a URL that + // no longer resolves. + { name: "01-edit", path: `/my/hackathon/${id}/manage/edit` }, + { name: "02-projects", path: `/my/hackathon/${id}/projects` }, + { name: "03-teams", path: `/my/hackathon/${id}/teams` }, + { name: "04-voting", path: `/my/hackathon/${id}/voting` }, + { name: "05-participants", path: `/my/hackathon/${id}/participants` }, + { name: "06-submissions", path: `/my/hackathon/${id}/submissions` }, + { name: "07-create-hackathon", path: "/hackathons/create" }, + { name: "08-platform-pages", path: "/manage/pages" }, + { name: "09-windows", path: `/my/hackathon/${id}/windows` }, + { name: "10-forms", path: `/my/hackathon/${id}/forms` }, + { name: "11-email", path: `/my/hackathon/${id}/email` }, + { name: "12-prizes", path: `/my/hackathon/${id}/prizes` }, + { name: "13-invites", path: `/my/hackathon/${id}/invites` }, + ] + + for (const s of shots) { + await page.goto(s.path) + await page.waitForLoadState("networkidle") + await page.screenshot({ + path: `.artifacts/admin/${s.name}.png`, + fullPage: true, + }) + } + + // The account menu only exists once opened, so it needs its own shot. + await page.goto("/dashboard") + await page.waitForLoadState("networkidle") + await page + .locator("header") + .getByRole("button", { name: PERSONAS.admin.initial, exact: true }) + .click() + await page.screenshot({ path: ".artifacts/admin/09-account-menu.png" }) + }) +}) diff --git a/.claude/skills/hackathon-e2e/tests/tunnel/host.ts b/.claude/skills/hackathon-e2e/tests/tunnel/host.ts new file mode 100644 index 00000000..253e3dc5 --- /dev/null +++ b/.claude/skills/hackathon-e2e/tests/tunnel/host.ts @@ -0,0 +1,50 @@ +/** + * The public host these specs are pointed at, and the URL patterns built from + * it. + * + * WHY THIS IS NOT A LITERAL. Both tunnel specs used to wait for + * `/trycloudflare\.com\/realms\/hackagon/`, which is the QUICK tunnel's domain. + * That was fine while a quick tunnel was the only public path there was, and it + * silently became a lie the moment a NAMED tunnel on our own zone became the + * default: every one of those waits would time out on a hostname that is + * working perfectly, and the failure reads as "login is broken through the + * tunnel" — the exact conclusion these specs exist to make trustworthy. + * + * Derive the pattern from TUNNEL_BASE_URL instead. It says the same thing the + * literal said — "the flow must stay on the public host, never dead-end on + * localhost:8180" — for whichever public host is actually in use. + */ + +/** e.g. `hackagon.example.org` or `x-y-z.trycloudflare.com`. */ +export function tunnelHost(base: string | undefined): string { + if (!base) return "" + try { + return new URL(base).host + } catch { + return "" + } +} + +/** Escape a hostname for use inside a RegExp — dots are the whole point. */ +function esc(s: string): string { + return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&") +} + +/** + * Matches any URL on the public host. Used to assert the browser came BACK to + * the app after Keycloak, which is where a wrong `ORIGIN` or a wrong issuer + * dead-ends. + */ +export function onTunnel(base: string | undefined): RegExp { + return new RegExp(esc(tunnelHost(base))) +} + +/** + * Matches Keycloak's login page served from the SAME public host — caddy + * path-multiplexes `/realms/*` to it. This is the step that used to dead-end on + * `localhost:8180` when the issuer rewiring was not in effect, and it is the + * one assertion that distinguishes a login-capable tunnel from a view-only one. + */ +export function onTunnelRealm(base: string | undefined): RegExp { + return new RegExp(`${esc(tunnelHost(base))}/realms/hackagon`) +} diff --git a/.claude/skills/hackathon-e2e/tests/tunnel/login.spec.ts b/.claude/skills/hackathon-e2e/tests/tunnel/login.spec.ts new file mode 100644 index 00000000..e80c3731 --- /dev/null +++ b/.claude/skills/hackathon-e2e/tests/tunnel/login.spec.ts @@ -0,0 +1,64 @@ +import { test, expect } from "@playwright/test" +import { PERSONAS } from "../../personas.js" +import { onTunnel, onTunnelRealm } from "./host.js" + +/** + * Proves OIDC login works through the Cloudflare tunnel — quick or named, the + * spec does not care which. Bring the tunnel up with issuer rewiring first, + * then run with its URL: + * + * bash .claude/skills/cloudflare-tunnel/scripts/up.sh --with-auth + * TUNNEL_BASE_URL=https://hackagon.example.org \ + * pnpm exec playwright test --project=tunnel + * + * The whole flow must stay on the tunnel host: a redirect to localhost:8180 + * means the issuer rewiring is not in effect (view-only tunnel). The host comes + * from TUNNEL_BASE_URL rather than from a literal domain — see host.ts. + */ +const base = process.env.TUNNEL_BASE_URL + +test.describe("login through the tunnel", () => { + test.skip( + !base, + "TUNNEL_BASE_URL not set — start the tunnel with --with-auth first", + ) + + test("alice logs in on the public URL and reaches her dashboard", async ({ + page, + }) => { + const alice = PERSONAS.alice + await page.goto("/") + await page.waitForLoadState("networkidle") + await page.getByRole("button", { name: "Log in" }).click() + + // Keycloak served from the SAME public host (caddy path-multiplexes + // /realms/* to it) — this is the step that used to dead-end on + // localhost:8180. + await page.waitForURL(onTunnelRealm(base), { timeout: 45_000 }) + + await page.locator("#username").fill(alice.username) + if (!(await page.locator("#password").isVisible())) { + await page.locator("#kc-login").click() + await page.locator("#password").waitFor({ timeout: 20_000 }) + } + await page.locator("#password").fill(alice.password) + await page.locator("#kc-login").click() + + // Back on the app through the tunnel, logged in. Identity is a monogram + // <span>, not a button — this design draws it as "who you are" rather than + // an action — so a role-based locator finds nothing even when login worked. + await page.waitForURL(onTunnel(base), { timeout: 30_000 }) + await expect( + page.locator("header").getByText(alice.initial, { exact: true }), + ).toBeVisible({ timeout: 20_000 }) + + // The dashboard runs authenticated gRPC calls server-side — this passing + // proves the backend accepts tokens stamped with the tunnel issuer. + await page.goto("/dashboard") + await page.waitForLoadState("networkidle") + expect(page.url()).toContain("/dashboard") + await expect( + page.locator("header").getByText(alice.initial, { exact: true }), + ).toBeVisible() + }) +}) diff --git a/.claude/skills/hackathon-e2e/tests/tunnel/theme-shot.spec.ts b/.claude/skills/hackathon-e2e/tests/tunnel/theme-shot.spec.ts new file mode 100644 index 00000000..0dedb82e --- /dev/null +++ b/.claude/skills/hackathon-e2e/tests/tunnel/theme-shot.spec.ts @@ -0,0 +1,306 @@ +import { test, expect, type Page } from "@playwright/test" + +/** + * Screenshot + layout utility for the Keycloak `hackagon` login theme. Not a + * product test — it renders the pages the theme owns (username step, password + * step, registration) and reports on them: + * + * - "theme <page> <scheme> <width>" — full-page PNGs into .artifacts/theme/, + * light and dark, desktop → phone, plus a horizontal-overflow probe that + * names the offending elements instead of leaving a mystery. + * - "theme keyboard <page>" — the phone soft-keyboard case. iOS Safari does + * NOT shrink the layout viewport when the keyboard opens; it only shrinks + * the *visual* viewport. So a page that is exactly one viewport tall and + * hard-centred has zero scroll range, and Safari cannot lift a focused + * field (or the submit button) above the keyboard. We model that: keep the + * layout viewport at 390x844, then ask whether some scroll offset exists + * that puts both the focused input and the submit button inside the + * ~460px band left above an iPhone keyboard + autofill accessory bar. + * + * Runs only with THEME_SHOT=1 (project tunnel, --grep theme): + * + * THEME_SHOT=1 pnpm exec playwright test --project=tunnel --grep "theme" + */ +const enabled = !!process.env.THEME_SHOT + +const KC = "http://localhost:8180/realms/hackagon/protocol/openid-connect" +const QS = + "?client_id=hackagon-frontend&response_type=code" + + "&redirect_uri=http://localhost:8081/auth/callback/keycloak&scope=openid" +const LOGIN_URL = `${KC}/auth${QS}` +const REGISTER_URL = `${KC}/registrations${QS}` + +/** + * iPhone 12/13/14 class device: 390x844 CSS px layout viewport. With the + * software keyboard and Safari's autofill accessory bar up, roughly 460px of + * visual viewport is left above them (844 - ~336 keyboard - ~48 accessory). + */ +const PHONE = { width: 390, height: 844 } +const VISIBLE_WITH_KEYBOARD = 460 + +/** The realm splits sign-in: username first, password on a second screen. */ +async function gotoPasswordStep(page: Page) { + await page.goto(LOGIN_URL) + await page.fill("#username", "alice") + await page.click("#kc-login") + await page.waitForSelector("#password") +} + +const pages = [ + { name: "login", open: (p: Page) => p.goto(LOGIN_URL).then(() => {}) }, + { name: "login-password", open: gotoPasswordStep }, + { name: "register", open: (p: Page) => p.goto(REGISTER_URL).then(() => {}) }, +] as const + +const viewports = [ + { tag: "1440", width: 1440, height: 900 }, + { tag: "1280", width: 1280, height: 900 }, + { tag: "768", width: 768, height: 1024 }, + { tag: "390", width: 390, height: 844 }, +] as const + +const schemes = ["light", "dark"] as const + +test.describe("login theme screenshots", () => { + test.skip(!enabled, "THEME_SHOT not set — utility spec, skipped in suites") + + for (const p of pages) { + for (const scheme of schemes) { + for (const vp of viewports) { + test(`theme ${p.name} ${scheme} ${vp.tag}`, async ({ browser }) => { + const ctx = await browser.newContext({ + colorScheme: scheme, + viewport: { width: vp.width, height: vp.height }, + }) + const page = await ctx.newPage() + await p.open(page) + await page.waitForLoadState("networkidle") + + // Horizontal overflow must be exactly 0 at every width. When it is + // not, name the elements poking past the viewport rather than + // guessing (and rather than hiding them with overflow-x: hidden). + const diag = await page.evaluate(() => { + const doc = document.documentElement + const overflow = doc.scrollWidth - window.innerWidth + const offenders: string[] = [] + if (overflow > 0) { + for (const el of Array.from(document.querySelectorAll("*"))) { + const r = el.getBoundingClientRect() + if (r.width === 0) continue + if (r.right > window.innerWidth + 1 || r.left < -1) { + const id = el.id ? `#${el.id}` : "" + const cls = el.className + ? `.${String(el.className).trim().split(/\s+/).join(".")}` + : "" + offenders.push( + `${el.tagName.toLowerCase()}${id}${cls} ` + + `[left=${Math.round(r.left)} right=${Math.round(r.right)} w=${Math.round(r.width)}]`, + ) + } + } + } + return { overflow, offenders: offenders.slice(0, 12) } + }) + + console.log( + `[theme] ${p.name} ${scheme} ${vp.tag}px overflow=${diag.overflow}` + + (diag.offenders.length + ? `\n ${diag.offenders.join("\n ")}` + : ""), + ) + expect( + diag.overflow, + `horizontal overflow at ${vp.tag}px:\n${diag.offenders.join("\n")}`, + ).toBe(0) + + await page.screenshot({ + path: `.artifacts/theme/${p.name}-${scheme}-${vp.tag}.png`, + fullPage: true, + }) + await ctx.close() + }) + } + } + } +}) + +/** + * Error states. Field-level validation messages and the page-level alert are + * the easiest things to leave unreadable in one of the two colour schemes, so + * they get their own captures. + */ +const errorPages = [ + { + name: "register-errors", + open: async (p: Page) => { + await p.goto(REGISTER_URL) + await p.fill("#username", "alice") + await p.fill("#password", "abc") + await p.fill("#password-confirm", "xyz") + await p.fill("#email", "not-an-email") + await p.click("#kc-register-form input[type=submit]") + await p.waitForSelector(".pf-m-error, .pf-v5-c-alert") + }, + }, + { + name: "login-error", + open: async (p: Page) => { + await gotoPasswordStep(p) + await p.fill("#password", "definitely-wrong") + await p.click("#kc-login") + await p.waitForSelector(".pf-v5-c-alert, .pf-m-error") + }, + }, +] as const + +test.describe("login theme error states", () => { + test.skip(!enabled, "THEME_SHOT not set — utility spec, skipped in suites") + + for (const p of errorPages) { + for (const scheme of schemes) { + for (const tag of ["1280", "390"] as const) { + const vp = viewports.find((v) => v.tag === tag)! + test(`theme ${p.name} ${scheme} ${vp.tag}`, async ({ browser }) => { + const ctx = await browser.newContext({ + colorScheme: scheme, + viewport: { width: vp.width, height: vp.height }, + }) + const page = await ctx.newPage() + await p.open(page) + await page.waitForLoadState("networkidle") + const overflow = await page.evaluate( + () => document.documentElement.scrollWidth - window.innerWidth, + ) + console.log( + `[theme] ${p.name} ${scheme} ${vp.tag}px overflow=${overflow}`, + ) + await page.screenshot({ + path: `.artifacts/theme/${p.name}-${scheme}-${vp.tag}.png`, + fullPage: true, + }) + await ctx.close() + expect(overflow).toBe(0) + }) + } + } + } +}) + +/** + * Soft-keyboard occlusion. Reported from a real iPhone: focusing the password + * field left the input half under Safari's autofill bar and the Sign In button + * entirely under the keyboard, with no way to scroll to them. + */ +const keyboardCases = [ + { + name: "login", + open: (p: Page) => p.goto(LOGIN_URL).then(() => {}), + field: "#username", + submit: "#kc-login", + }, + { + name: "login-password", + open: gotoPasswordStep, + field: "#password", + submit: "#kc-login", + }, + { + name: "register", + open: (p: Page) => p.goto(REGISTER_URL).then(() => {}), + field: "#password", + submit: "#kc-register-form input[type=submit]", + }, +] as const + +test.describe("login theme keyboard", () => { + test.skip(!enabled, "THEME_SHOT not set — utility spec, skipped in suites") + + for (const c of keyboardCases) { + test(`theme keyboard ${c.name}`, async ({ browser }) => { + const ctx = await browser.newContext({ + viewport: PHONE, + colorScheme: "light", + }) + const page = await ctx.newPage() + await c.open(page) + await page.waitForLoadState("networkidle") + await page.locator(c.field).focus() + await page.waitForTimeout(150) + + const m = await page.evaluate( + ({ field, submit, band }) => { + const se = document.scrollingElement as HTMLElement + const range = Math.max(0, se.scrollHeight - se.clientHeight) + const box = (sel: string) => { + const el = document.querySelector(sel)! + const r = el.getBoundingClientRect() + return { + top: r.top + window.scrollY, + bottom: r.bottom + window.scrollY, + } + } + const f = box(field) + const s = box(submit) + // An element is reachable when some scroll offset in [0, range] puts + // it inside the band: it needs `bottom - band` px of scroll, and the + // page must be able to give them. A long form legitimately cannot + // show the focused field AND the submit button at the same time — + // what must never happen is either being unreachable. + const needField = Math.max(0, f.bottom - band) + const needSubmit = Math.max(0, s.bottom - band) + const fontSize = parseFloat( + getComputedStyle(document.querySelector(field)!).fontSize, + ) + return { + range, + docHeight: se.scrollHeight, + clientHeight: se.clientHeight, + fieldTop: Math.round(f.top), + submitBottom: Math.round(s.bottom), + needField: Math.round(needField), + needSubmit: Math.round(needSubmit), + fieldReachable: needField <= range, + submitReachable: needSubmit <= range, + fontSize, + } + }, + { field: c.field, submit: c.submit, band: VISIBLE_WITH_KEYBOARD }, + ) + + console.log( + `[keyboard] ${c.name} doc=${m.docHeight} client=${m.clientHeight} ` + + `scrollRange=${m.range} field@${m.fieldTop} submitBottom=${m.submitBottom} ` + + `needField=${m.needField}(ok=${m.fieldReachable}) ` + + `needSubmit=${m.needSubmit}(ok=${m.submitReachable}) ` + + `inputFontSize=${m.fontSize}px`, + ) + + // Screenshot of the "keyboard open" case: shrink to the band height and + // scroll the focused field into view, i.e. what the user actually sees. + await page.setViewportSize({ + width: PHONE.width, + height: VISIBLE_WITH_KEYBOARD, + }) + await page.locator(c.field).scrollIntoViewIfNeeded() + await page.locator(c.field).focus() + await page.waitForTimeout(150) + await page.screenshot({ path: `.artifacts/theme/keyboard-${c.name}.png` }) + await ctx.close() + + // iOS zooms the page when a focused input is under 16px, which makes the + // occlusion worse. + expect( + m.fontSize, + "input font-size must be >= 16px (iOS auto-zoom)", + ).toBeGreaterThanOrEqual(16) + expect( + m.fieldReachable, + `focused field cannot clear the keyboard: needs ${m.needField}px of scroll, range is ${m.range}px`, + ).toBe(true) + expect( + m.submitReachable, + `submit button cannot clear the keyboard: needs ${m.needSubmit}px of scroll, range is ${m.range}px`, + ).toBe(true) + }) + } +}) diff --git a/.claude/skills/hackathon-e2e/tests/tunnel/upload.spec.ts b/.claude/skills/hackathon-e2e/tests/tunnel/upload.spec.ts new file mode 100644 index 00000000..e8f288c5 --- /dev/null +++ b/.claude/skills/hackathon-e2e/tests/tunnel/upload.spec.ts @@ -0,0 +1,145 @@ +import { test, expect } from "@playwright/test" +import { PERSONAS } from "../../personas.js" +import { onTunnel, onTunnelRealm } from "./host.js" + +/** + * Proves a presigned UPLOAD survives the public path, not just localhost. + * + * This exists because a real 403 got through everything else. `smoke/ + * 16-image-upload` uploads an avatar and a logo and reads the bytes back, and + * it passed the whole time someone using the public URL got "Storage rejected + * the upload (403)" on every attempt — because it uploads over localhost, and + * the fault was in a proxy that only the public path goes through. + * + * The asymmetry is the point, and it is not caddy-specific: SigV4 signs the + * HOST header, so every hop between the browser and the object store becomes + * part of the signature. A proxy that passes the incoming host through (the + * default) makes the store recompute a different signature and answer 403 + * SignatureDoesNotMatch. Public READS are unsigned and keep working, so the + * symptom is "the site is fine, but nobody can upload" — invisible to any check + * that fetches a page or an image. + * + * What actually broke: the Host rewrite was present and correct in + * .devcontainer/Caddyfile.tunnel and ABSENT from the config caddy was running, + * because caddy reads its file once at container start and nothing reloaded it. + * cloudflare-tunnel/scripts/up.sh now reloads and verifies that route; this + * spec is the independent check, from the browser, on the deployed path. + * + * bash .claude/skills/cloudflare-tunnel/scripts/up.sh --with-auth + * TUNNEL_BASE_URL=https://hackagon.example.org \ + * pnpm exec playwright test --project=tunnel + * + * Works against a quick tunnel or a named one — the public host is derived from + * TUNNEL_BASE_URL (host.ts). The Host-rewrite fault this catches is caddy's and + * is identical either way; a named hostname does not make it go away. + */ +const base = process.env.TUNNEL_BASE_URL + +test.describe("presigned upload through the tunnel", () => { + test.skip( + !base, + "TUNNEL_BASE_URL not set — start the tunnel with --with-auth first", + ) + + test("an avatar uploads and reads back through the public URL", async ({ + page, + }) => { + const alice = PERSONAS.alice + + // Log in on the tunnel host. Not a saved storage state: those are minted + // against localhost, and the point of this spec is the public path. + await page.goto("/") + await page.waitForLoadState("networkidle") + await page.getByRole("button", { name: "Log in" }).click() + await page.waitForURL(onTunnelRealm(base), { timeout: 45_000 }) + await page.locator("#username").fill(alice.username) + if (!(await page.locator("#password").isVisible())) { + await page.locator("#kc-login").click() + await page.locator("#password").waitFor({ timeout: 20_000 }) + } + await page.locator("#password").fill(alice.password) + await page.locator("#kc-login").click() + await page.waitForURL(onTunnel(base), { timeout: 30_000 }) + + // Presign + PUT exactly as the app does it, in the page, so the request + // travels the same hops with the same headers a person's upload would. + // + // A payload big enough that WebP conversion genuinely shrinks it: a tiny + // PNG comes out BIGGER as WebP, `toWebp` then returns the original, and the + // converted path — the one every real photograph takes — goes untested. + const result = await page.evaluate(async () => { + const canvas = document.createElement("canvas") + canvas.width = canvas.height = 600 + const ctx = canvas.getContext("2d")! + for (let i = 0; i < 600; i += 12) { + ctx.fillStyle = `hsl(${i % 360} 70% ${40 + (i % 30)}%)` + ctx.fillRect(i, 0, 12, 600) + } + const blob = await new Promise<Blob | null>((r) => + canvas.toBlob(r, "image/png"), + ) + if (!blob) return { step: "canvas", ok: false as const } + const file = new File([blob], "probe.png", { type: "image/png" }) + + // The app's own module: a copy of the sequence here could pass while the + // shipped one is broken. + const { uploadImage } = (await import("/src/lib/upload.ts").catch(() => ({ + uploadImage: null, + }))) as { + uploadImage: + | ((e: string, f: File) => Promise<{ publicUrl: string }>) + | null + } + + // The built server serves bundled JS, so the source import above only + // resolves under vite. Fall back to the same three steps inline — + // identical wire behaviour, which is what this spec measures. + if (!uploadImage) { + const presign = await fetch("/account/avatar", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + filename: file.name, + contentType: file.type, + sizeBytes: file.size, + }), + }) + if (!presign.ok) + return { step: "presign", ok: false as const, status: presign.status } + const { uploadUrl, publicUrl } = await presign.json() + const put = await fetch(uploadUrl, { + method: "PUT", + headers: { "Content-Type": file.type }, + body: file, + }) + return { step: "put", ok: put.ok, status: put.status, publicUrl } + } + + const { publicUrl } = await uploadImage("/account/avatar", file) + return { step: "put", ok: true as const, status: 200, publicUrl } + }) + + // Name the hop in the failure, because "403" alone sent this diagnosis down + // two wrong paths before the right one. + expect( + result.ok, + `upload failed at ${result.step} with ${"status" in result ? result.status : "?"}. ` + + `A 403 on the PUT means a proxy between the browser and the object ` + + `store rewrote a signed header — check the Host rewrite on caddy's ` + + `/objects route IS IN THE RUNNING CONFIG, not just in Caddyfile.tunnel.`, + ).toBe(true) + + // And the bytes have to come back, through the same public host. "The store + // accepted it" and "the picture loads" are different claims, and only the + // second one is what a person sees. + const publicUrl = (result as { publicUrl?: string }).publicUrl + expect(publicUrl, "no public path came back from the presign").toBeTruthy() + + const read = await page.request.get(`${base}${publicUrl}`) + expect(read.status(), `reading ${publicUrl} back through the tunnel`).toBe( + 200, + ) + expect(read.headers()["content-type"]).toContain("image/") + expect((await read.body()).byteLength).toBeGreaterThan(1000) + }) +}) diff --git a/.claude/skills/hackathon-e2e/tsconfig.json b/.claude/skills/hackathon-e2e/tsconfig.json new file mode 100644 index 00000000..a40af483 --- /dev/null +++ b/.claude/skills/hackathon-e2e/tsconfig.json @@ -0,0 +1,14 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "types": ["node"], + "noEmit": true + }, + "include": ["**/*.ts"], + "exclude": ["node_modules"] +} diff --git a/.claude/skills/k3d-chart-rig/.gitignore b/.claude/skills/k3d-chart-rig/.gitignore new file mode 100644 index 00000000..f91f7616 --- /dev/null +++ b/.claude/skills/k3d-chart-rig/.gitignore @@ -0,0 +1,6 @@ +# Downloaded, pinned toolchain (k3d, helm, kubectl). Binaries, never tracked. +bin/ +# Cluster kubeconfig, generated passwords, rewritten realm — all per-run. +.state/ +# Anything the scripts write for a human to read. +.artifacts/ diff --git a/.claude/skills/k3d-chart-rig/SKILL.md b/.claude/skills/k3d-chart-rig/SKILL.md new file mode 100644 index 00000000..138e3124 --- /dev/null +++ b/.claude/skills/k3d-chart-rig/SKILL.md @@ -0,0 +1,520 @@ +--- +name: k3d-chart-rig +description: + Install and exercise this repo's Helm chart on a throwaway local Kubernetes + cluster (k3d + ingress-nginx + a test-only object store), then prove the + things a rendered manifest can only assert — that a presigned upload survives + the /objects Host rewrite, that the regex path beats the frontend's /, that + ingress-nginx accepts the ExternalName upstream, and that a real OIDC login + round-trip completes. Use when asked to test, install, debug or change + helm-chart/, or to reproduce a Kubernetes-only failure. +--- + +# A local Kubernetes rig for `helm-chart/` + +The chart renders and lints. Nothing had ever installed it. This makes the +difference observable. + +```bash +bash .claude/skills/k3d-chart-rig/scripts/up.sh # ~4 min cold, ~90 s warm +bash .claude/skills/k3d-chart-rig/scripts/verify.sh # 55 checks, ~4 min +bash .claude/skills/k3d-chart-rig/scripts/down.sh # deletes the cluster +``` + +…and, optionally, the same cluster on a **real public hostname with a real +certificate** (needs the Cloudflare credentials the other tunnels use): + +```bash +bash .claude/skills/k3d-chart-rig/scripts/tunnel.sh up # ~2 min +bash .claude/skills/k3d-chart-rig/scripts/verify.sh # the same 55, over https +bash .claude/skills/k3d-chart-rig/scripts/browser-check.sh # 13 checks a browser must answer +bash .claude/skills/k3d-chart-rig/scripts/tunnel.sh down # back to *.localhost +bash .claude/skills/k3d-chart-rig/scripts/tunnel.sh destroy # …and give the names up +``` + +**Opt-in, loopback-only, and it imports development accounts** — see the warning +at the bottom before running it on a machine anyone else can reach. + +## What it is + +k3d (k3s in Docker) as **sibling containers on the host Docker daemon**. Three +containers: `k3d-hackagon-server-0` (the whole cluster), `-serverlb` (the port +proxy) and a short-lived `-tools`. Not docker-in-docker: no privileged +container, no nested storage driver. + +> The task that commissioned this assumed the devcontainer could drive it, +> because "the devcontainer already speaks to the host socket". **It does not** +> — `.devcontainer/docker-compose.yml` mounts no Docker socket and the image has +> no `docker` CLI. Everything here therefore runs from the **host** shell (Git +> Bash on Windows), which is also where `docker` and the pinned toolchain live. + +``` +scripts/lib.sh names, ports, hostnames, tool wrappers, path translation +scripts/tools.sh downloads pinned k3d / helm / kubectl into bin/ (gitignored) +scripts/up.sh cluster → ingress-nginx → CoreDNS → store → secrets → helm +scripts/install.sh just the `helm upgrade`, for iterating on the chart +scripts/verify.sh the 55 checks — in EITHER mode +scripts/presign.sh SigV4 presigner mirroring internal/storage/sigv4.go +scripts/tunnel.sh the public-https mode: up / down / destroy / status +scripts/browser-check.sh + browser-login.mjs a real browser, the 13 checks + that need one (see "Real HTTPS", below) +scripts/down.sh delete (or --stop, or --purge) +manifests/ ingress-nginx values · the test store · the negative control +helm-chart/values.k3d.yaml the test values (TRACKED, and carries no secret) +.state/values.tunnel.yaml the public-https overlay (GENERATED, gitignored — + it names a hostname, and nothing tracked ever does) +``` + +## Ports it claims on the host + +| Port | What | Why not a dev-stack port | +| -------- | ----------------------------------- | --------------------------------------------------------------- | +| **8090** | ingress-nginx http — the app | dev uses 3000 · 8081 · 8082 · 8180 · 15432 · 9000 · 9001 · 8010 | +| **8443** | ingress-nginx https — Keycloak only | " | +| **6551** | k3s apiserver, bound to `127.0.0.1` | " | + +The public-https mode claims **no further host port**: cloudflared joins the +`k3d-hackagon` docker network and dials the load balancer container directly, so +the tunnel adds a container and nothing that any other rig could collide with. + +Nothing else is published, nothing binds `0.0.0.0` except the two ingress ports +(Docker's default), and the compose project, the dev stack's three tunnels and +`~/.kube/config` are untouched — k3d is called with +`--kubeconfig-update-default=false` and every wrapper points at the rig's own +`.state/kubeconfig.yaml`. + +## What it costs + +| | | +| ---- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| RAM | **~2.7 GB** resident with everything up (`k3d-…-server-0` 2.64 GiB) | +| Disk | **~4.6 GB**: 4.1 GB of images inside the node's containerd, ~420 MB of k3d/k3s images in the host cache, ~46 MB of pinned binaries in `bin/` | +| Time | measured: **68 s** to create the cluster + **123 s** for ingress-nginx, CoreDNS, the store, the secrets and `helm install` — ~3.2 min from nothing, with every in-cluster image pulled fresh. `verify.sh` is ~2 min (two ingress-controller rollouts). | + +The frontend image alone is 1.17 GB (Nix-based). `down.sh` takes the 4.1 GB with +the cluster and prints what is left in the host cache; keeping those saves ~40 s +of the next run, and the in-cluster pulls happen again either way because a new +node starts with an empty containerd. + +## The three decisions worth knowing + +**Traefik is disabled and ingress-nginx installed in its place.** k3d bundles +Traefik, and every annotation the chart writes on the `/objects` Ingress — +`upstream-vhost`, `rewrite-target`, `use-regex`, `proxy-body-size` — is +ingress-nginx's. The Host rewrite is not expressible in a core Ingress object on +Traefik at all. With the bundled controller the one thing this rig exists to +test cannot work, so `up.sh` passes `--disable=traefik`. + +**The controller listens on 8090 in-cluster, not on 80.** The OIDC issuer is ONE +string: the browser follows it to Keycloak and the frontend POD fetches the +discovery document from it, and Auth.js rejects a document whose `issuer` +differs from the configured one. Making the in-cluster port equal the host port +is what lets one URL be true from both sides. CoreDNS is then taught to answer +`app.` and `auth.hackagon.localhost` with the controller's Service — via a +`rewrite` in the `coredns-custom` ConfigMap, **not a `hosts` block**: k3s's +Corefile already has one, a second makes CoreDNS refuse to start ("this plugin +can only be used once per Server Block") and takes cluster DNS down with it. + +**`*.localhost`, not nip.io or sslip.io.** Measured on the machine this was +written on: the local resolver applies DNS-rebinding protection and returns +nothing for `app.127.0.0.1.nip.io`, while the same query to `8.8.8.8` answers +`127.0.0.1`. Whether a wildcard-DNS service works is a property of whoever runs +DNS for the developer. `*.localhost` needs no resolver at all — curl (≥ 7.77) +and every Chromium, plus Firefox ≥ 84, map it to loopback themselves (RFC 6761) +— and nothing is written to any hosts file. + +## The app is on http and Keycloak is on https, on purpose + +Not an oversight, and not laziness in either direction. + +**Keycloak needs TLS.** Its `AUTH_SESSION_ID` / `KC_RESTART` cookies are in the +FEDERATION scope, which is `SameSite=None`, which forces `Secure` — whatever +scheme it is reached over. Measured before the split: over http Keycloak +answered `Set-Cookie: AUTH_SESSION_ID=…;Secure;HttpOnly;SameSite=None`, curl +discarded them per the cookie spec, and the login POST came back 400 "session +expired, it may have been deleted or cookies are disabled". No chart change +could fix that. (A browser would have completed it — `*.localhost` is a +potentially-trustworthy origin, so Chrome and Firefox accept Secure cookies +there over http. curl has no such exception, and a check that only passes in a +browser is a check this rig cannot run.) `up.sh` mints a 90-day self-signed +certificate into the Secret name the chart already defaults to. + +**The app stays on http, and that half is load-bearing.** The SvelteKit node +adapter INFERS the public scheme, and its unconfigured guess is the literal +string `https`. An https-everywhere rig would agree with that guess by accident +and prove nothing. On http, a frontend that guesses wrong advertises https +callback URLs, issues `__Secure-` cookies the browser will not send back, and +login dies — with every page still answering 200. That is exactly what happened +here, and it is what `frontend.protocolHeader` now prevents. + +**That argument has since been checked from the other side, and it held.** +`scripts/tunnel.sh` runs the same cluster behind real edge-terminated TLS: over +https, removing `protocolHeader` breaks nothing at all, because the guess is +right by accident. Which is exactly the state this mode exists not to be in. +**Keep both.** + +## …and a second mode with REAL https, which is the other half + +`scripts/tunnel.sh up` publishes the SAME cluster through a **named Cloudflare +tunnel** on a zone we own. Nothing is simulated: DNS is public, the certificate +is the zone's real one, and a browser verifies it. + +``` +browser ──https──▶ Cloudflare edge ──tunnel──▶ cloudflared ──http──▶ + ingress-nginx :8090 ──▶ frontend · Keycloak · the store +``` + +**TLS terminates at the edge and the origin stays plain http.** That is not a +shortcut, it is the shape a deployment behind any TLS-terminating proxy has, and +it is the only shape in which `frontend.protocolHeader` has an input: +cloudflared is what puts `X-Forwarded-Proto: https` on the request the cluster +receives. + +**Two hostnames, one tunnel.** The chart routes the app and Keycloak by HOST on +two separate Ingresses, so there is no single name that reaches both. + +| | | +| -------- | ------------------------------------------------------------------------ | +| app | `k3d-hackagon.example.org` | +| Keycloak | `k3d-auth-hackagon.example.org` | +| origin | `http://k3d-hackagon-serverlb:8090` on the `k3d-hackagon` docker network | + +⚠ **Both names are ONE label deep and that is a constraint, not a style.** +Cloudflare's free Universal SSL covers the apex and one label and nothing below +it. Measured against the edge before any record was created: SNI +`auth.k3d-hackagon.example.org` gets **TLS alert 40, handshake failure**, while +a one-label sibling gets the zone's certificate. A browser reads that as a +broken site, not as a missing certificate. + +**The port property is REPLACED, not dropped.** In the localhost mode the +controller listens on 8090 in-cluster as well as on the host so that one issuer +string is true from both sides. A public https URL names no port at all, so the +replacement is stronger: the frontend POD resolves the same public hostname +through public DNS and reaches Keycloak the way the browser does — out to +Cloudflare and back down the tunnel, over the same real certificate. There is +one URL and one path to it, so there is nothing left to disagree. Measured from +inside the cluster before this was built: a pod resolves and reaches +Cloudflare-proxied names over IPv4 in ~290 ms. + +That also **removes `NODE_TLS_REJECT_UNAUTHORIZED=0`**, the line values.k3d.yaml +calls the worst in the file. It is there because the frontend has to accept the +self-signed Keycloak certificate; on a real one it does not, so the overlay sets +`frontend.extraEnv: []` and the pod runs with node's trust store intact. + +### What a browser answers and curl cannot + +`browser-check.sh` (13 checks, Firefox, driven inside the devcontainer where the +e2e suite's Playwright already lives). It exists for one reason: **`__Secure-` +is a rule about the USER AGENT.** A browser must refuse to store a `__Secure-` +cookie that did not arrive over a secure connection. curl implements no such +rule — it would keep and replay that cookie over plain http — so a green curl +login is equally consistent with the prefix working and with it being ignored. + +Observed, signing alice in through the public URL: the callback sets +`__Secure-authjs.session-token` (`Secure; HttpOnly; SameSite=Lax`), Firefox +stores it, no unprefixed twin is set beside it, `/auth/session` fetched from the +page returns alice and a Keycloak access token, and it survives a full page +load. On the `*.localhost` mode `cookies.useSecure` is false and none of that is +reachable. + +### The chain, broken and put back + +Three experiments, because a fix you can break and restore is a fix you have +proven. All three are single commands and all three were run. + +| change | advertised origin through the tunnel | sign-in POST | +| -------------------------------------------- | ------------------------------------ | ------------ | +| baseline | `https://k3d-sdsc-hackathons…` | 302 | +| ingress-nginx `use-forwarded-headers: false` | `http://k3d-sdsc-hackathons…` | **403** | +| chart `frontend.protocolHeader: ""` | `https://k3d-sdsc-hackathons…` | 302 | + +The 403's body is SvelteKit's own +`Cross-site POST form submissions are forbidden`: the app computed an http +origin, the browser sent an https `Origin`, and the CSRF check refused them. +Every page still answered 200. + +**And the third row is the honest result: under real https, removing +`protocolHeader` breaks nothing.** adapter-node's unconfigured guess is the +literal string `https`, which is correct here by accident — which is precisely +why the app is on plain http in the default mode, and why that asymmetry is +worth keeping. What is load-bearing over https is the header being TRUE, and +`protocolHeader` is what makes the scheme observed rather than guessed. + +The cheapest proof that it is READ at all needs no restart and no downtime — +**same pod, same Host header, one hop apart**: + +``` +through the tunnel (cloudflared sends X-Forwarded-Proto: https) + "callbackUrl":"https://k3d-hackagon.example.org/auth/callback/keycloak" +loopback to the same ingress, no X-Forwarded-Proto + "callbackUrl":"http://k3d-hackagon.example.org:8090/auth/callback/keycloak" +``` + +With `protocolHeader` removed the second line becomes `https://…:8090` — the +guess, on an origin nothing serves. That is the original bug, reproduced. + +### Teardown, and what it refuses to leave behind + +- `tunnel.sh down` — stops cloudflared, reinstalls the `*.localhost` values, + puts the Keycloak client's redirect URIs back, deletes `.state/mode.env`, and + **prints both hostnames and the give-up command**. +- `tunnel.sh destroy` — the above plus the tunnel, its credentials file and both + CNAMEs. +- `down.sh` **stops the tunnel BEFORE deleting the cluster**, always. The order + matters: in between, the public URL is a 502 from a healthy-looking tunnel, + which is the least informative failure available. It leaves the DNS records + (they are cheap, they make the next `up` a no-op, and deleting records in a + shared zone is not a thing a routine teardown should do quietly) — with the + tunnel stopped they answer Cloudflare's 1033, "tunnel not found", which is + true. `down.sh --purge` destroys them. +- Nothing tracked ever carries the hostname: the overlay is generated into + gitignored `.state/`, and the names live in the same gitignored + `.claude/skills/cloudflare-tunnel/.env` the other three rigs read. + +`tunnel.sh up` is idempotent — the second run reuses the tunnel and reports +`DNS ok` for both records rather than rewriting them. + +### Two traps this mode has, both about DNS and neither about the tunnel + +**A probe run BEFORE the record exists poisons the local resolver for the zone's +negative TTL** — 1800 s on `example.org`. `tunnel.sh status` asks, so the very +first status call on a name you are about to create costs you half an hour of +`NXDOMAIN` on that machine while the record serves perfectly everywhere else. +`lib.sh` answers it by asking Cloudflare over DoH and pinning `--resolve` for +curl; SNI and the certificate check are untouched, so the pin chooses an edge +and nothing more. + +**The devcontainer's network answers AAAA-only with no IPv6 route out** (the +same fault `.claude/CLAUDE.md` records for the dev tunnels): `getent hosts` +returns two v6 addresses, none reachable, and Firefox fails in 3 ms with +`NS_ERROR_UNKNOWN_HOST`. `browser-check.sh` pins `/etc/hosts` inside the +container from a DoH-resolved A record and removes it again on exit. Measured on +the way: `network.dns.disableIPv6` **is not enough on its own** — it stops the +browser preferring v6, and here there is no A record to fall back to, so the +failure is identical. + +## What it found + +Seven things, all in `helm-chart/`, all fixed here, none of which the rendered +manifest showed. The first six were found here; the seventh (below) was found by +reading and could only be _settled_ here. + +1. **`templates/keycloak-ingress.yaml` hard-coded + `ingressClassName: webapprouting.kubernetes.azure.com`**, the cert-manager + issuer and a TLS block. `frontend.ingress.ingressClass` has always existed; + this object ignored it, so on any cluster that is not the AKS app-routing + addon the Ingress was claimed by no controller — a deployment that serves the + product and not its login. `keycloak.ingress.enabled` was decoration too: the + object rendered whatever it said, and values.yaml said `false`. +2. **`backend.config.server.adminkeycloakid` shipped empty and is required at + boot.** `internal/config/config.go` refuses the whole configuration with + "server.adminkeycloakid is required", so `helm install` of the chart's own + defaults succeeds and the backend then sits in CrashLoopBackOff with the + reason only in a pod log. It is `required` at render time now. +3. **The OIDC callback 502'd through the chart's own Ingress.** The session JWT + carries the Keycloak access and refresh tokens, so `Set-Cookie` on + `/auth/callback/keycloak` is chunked and multi-kilobyte; nginx's default 4k + `proxy_buffer_size` refuses it rather than truncating. It presents as well as + a bug can hide: Keycloak had authenticated, the code had been exchanged, and + the frontend logged "JWT Callback: Initial sign-in successful" with the right + user id. Only the browser sees the 502, only on the last redirect. + `frontend.ingress.proxyBufferSize` (16k) is now written as an annotation. + Applies to the AKS addon too — it is ingress-nginx with the same default. +4. **The frontend advertised an origin that did not exist** (see above); + `frontend.protocolHeader` (`x-forwarded-proto`) and a `frontend.extraEnv` + escape hatch were added. +5. **values.yaml named the ExternalName kill switch wrongly.** It said + `disable-service-external-name` "in the controller ConfigMap". Setting that + key changes nothing — no warning, same upstream in the access log. The real + switch is the controller's command-line flag `--disable-svc-external-name`, + and with it the route does answer 503 exactly as the comment predicted. A + cluster believed to have blocked ExternalName that way has not. + +6. **Keycloak's hostname was hard-coded one label deeper than the app's.** + `frontend.ingress.hosts[].host` has always been free-form; + `hackagon.keycloakHost` was `auth.{baseDomain}` with no override, so putting + the app at `k3d-sdsc-hackathons.example.org` forced Keycloak to + `auth.k3d-sdsc-hackathons.example.org`. **A one-label wildcard certificate + covers the first and not the second** — and Cloudflare's free Universal SSL + is exactly such a certificate, answering the deeper name with TLS alert 40. + So a deployment fronted that way publishes the product on a certificate that + does not cover its login: the same shape as finding 1, from a different + cause. `keycloak.ingress.host` now overrides it, defaulting to the old + derivation, so no existing deployment changes. **This is the only chart + change real HTTPS needed** — everything else about the https mode is + configuration. + +7. **A config-only `helm upgrade` was a silent no-op** (2026-08-14). Not found + by this rig — it was read out of the chart and written down in + `docs/deployment.md` as known-broken — but **the rig is what turned it from a + claim into a measurement, and then proved the fix**. With the annotations + reverted: `helm upgrade` returned in 0.9 s with status `deployed`, the + ConfigMap held the new value, and the running pod (same name, same + `metadata.generation`) still served the old one. Both configs and the + frontend Secret are `subPath` mounts, which the kubelet resolves once at + container start, and no template carried a `checksum/*` annotation, so the + pod template never changed and nothing rolled. Three annotations now do. Step + 6 pins it, and was itself run against the reverted chart to watch it fail — + 10 reds, including the original bug reproduced by the check. + +Three more are recorded but deliberately **not** fixed — one is an arguable +design call and two are cosmetic: + +- **The backend hard-exits at boot if Keycloak's JWKS endpoint is not yet + answering.** On a fresh install Keycloak takes ~90 s (image pull plus schema + migration) and the backend crash-loops until then — 3 restarts on the run that + produced these numbers. It self-heals, so nothing is broken; the cost is + diagnostic. An operator watching `helm install` sees a backend in + CrashLoopBackOff and cannot tell this apart from finding 2, which looks + identical and never recovers. Either retry the JWKS fetch at startup, or give + the Deployment an init container that waits on Keycloak. + +- The frontend's `livenessProbe` is `GET /`, which renders the home page, which + calls the backend. A backend outage therefore restarts every frontend pod for + as long as it lasts — and liveness restarts cannot fix an upstream. It also + masked finding 2: the frontend crash-looped alongside the backend and looked + like the same fault. Readiness on `/` is right; liveness probably wants a + route that does not fan out. +- `keycloak.database.external.database` / `.user` in values.yaml are read by the + subchart as `name` / `username`. The two ignored keys happen to carry the same + strings as the subchart's defaults, so it works by coincidence. + `keycloak.persistence` is ignored outright — the subchart has no such value. + +- **`hackagon.frontendHost` — the host the realm ConfigMap rewrites the client's + redirect URIs to — is `app.{baseDomain}` and ignores + `frontend.ingress.hosts[].host` entirely.** Set the app's host to anything + else and the realm is imported allowing redirects to a hostname that does not + exist; Keycloak then answers the login with `Invalid parameter: redirect_uri`, + which names the parameter and not the mistake. Found while wiring the https + mode, and NOT fixed here because this rig cannot observe the failure — `up.sh` + rewrites the realm itself before handing it to the chart, so the chart's own + replaces are already no-ops, and `tunnel.sh` sets the URIs on the running + Keycloak (a realm imports ONCE, so an upgrade could not change them anyway). + Same family as finding 6: one host value free-form, its partner derived. + +## What the checks actually check + +`verify.sh` never asserts "the pods are Running". Every negative assertion has a +positive control, because this repository has a written record of suites staying +green while testing nothing. + +**Claim 1 — a presigned PUT survives the Host rewrite.** Three legs: the signed +URL is accepted through the chart's `/objects` route (200); the SAME signature +is refused with `SignatureDoesNotMatch` through an Ingress identical but for the +missing `upstream-vhost`; and accepted again through the chart's route, so the +refusal is about the annotation and not a stale URL. The control is applied from +`manifests/control-no-vhost-ingress.yaml` and `verify.sh` asserts the two +annotation sets differ by exactly that one key — a control that has drifted is +not a control. The bucket policy is checked both ways too, and the "private +prefix 403s" check is backed by a presigned GET proving the object is there. + +The signing is `scripts/presign.sh`, a mirror of +`components/backend/internal/storage/sigv4.go`. It exists because the published +images (`ghcr.io/…/backend-service:latest`) **predate the object-storage work on +this branch** — the binary contains no `internal/storage` package and no +`CreateUploadUrl`, so there is no deployed handler to ask. That also makes it +the more honest test of the claim, which is about what the ingress does to a +signed request. + +**Claim 2 — the regex path beats `/`.** Both Ingresses claim the same host; `/` +returns the SvelteKit document; `/objects/<bucket>/<key>` returns the object's +bytes; and `/objectsnotaprefix` does NOT reach the store — the `(/|$)` boundary +is what stops the prefix swallowing application routes. + +**Claim 3 — ingress-nginx accepts an ExternalName upstream.** The chart's +Service is ExternalName, pointing at the endpoint host, and is the `/objects` +backend; traffic through it is served rather than 503'd. Then the flag is turned +ON, the route is watched to 503, and turned off again — otherwise "200" is +equally consistent with the kill switch not existing, which is what the first +attempt at this check accidentally demonstrated. + +**Login round-trip.** Discovery names the public issuer; the frontend advertises +the origin it is actually reached on; sign-in redirects to Keycloak; the realm's +username-first flow is driven in two POSTs; Keycloak redirects back with a code; +**the callback returns 302 and not 502**; and `/auth/session` carries alice's +email and a Keycloak access token. + +**A config-only upgrade reaches the running pod** (step 6, added 2026-08-14). +Both halves are asserted, because each is a bug: a config change MUST roll the +pods, and an unchanged one MUST NOT. The second is the one likely to catch a +mistake — a hash over anything non-deterministic would trade a silent no-op for +a rollout on every upgrade, and this chart really does contain such bytes (the +Keycloak and Postgres subcharts mint passwords during rendering). So the render +is done twice and compared, the live Deployment's annotations are compared +against what the chart renders to, and three probe renders prove the hashes +still TRACK their inputs — determinism alone is also what a constant would give +you. Then the behavioural half: an unchanged upgrade must leave +`metadata.generation` untouched (Kubernetes' own answer to "did the pod template +move"), and a changed one must land in the file the RUNNING container has open, +with nobody restarting anything. + +⚠ **Its values come from `helm get values`, never a hard-coded list of `-f` +files.** This script runs in tunnel mode too, and re-installing the localhost +values there would quietly repoint the release mid-run. That the round-trip is +faithful is not assumed — the no-op check is exactly that claim, so a lossy +round-trip fails loudly instead of silently reinstalling something else. + +**Optional blocks absent.** Read out of the LIVE container — not +`helm template`, not the ConfigMap. The frontend image is distroless (no shell, +no tar), so `kubectl exec` and `kubectl cp` are both out; an ephemeral debug +container with `--profile=sysadmin` reads the real mount through `/proc/1/root`. +The positive control runs FIRST, and it earned its place: with the default debug +profile the read returns "Permission denied" even though both containers run as +uid 0, and both absence assertions passed against that error message. + +## ⚠ It imports the development realm + +`up.sh` imports `tools/configs/keycloak/realm-hackagon.json` — the development +export: **alice, bob, charles and hackagon-admin, all with the password +`aliceandbob`**, all with `emailVerified` and no password policy. It is imported +because the login round-trip has to sign somebody in, and inventing a second +realm would test a realm nobody deploys. `--no-realm` skips it. + +### ⚠⚠ …and `tunnel.sh up` PUBLISHES that realm to the internet + +Everything above is written on the assumption that the rig binds loopback only. +**`scripts/tunnel.sh up` is the one thing here that breaks it**, and the +consequence deserves saying plainly: while the tunnel is up, anyone who learns +the hostname can sign in as **alice, bob, charles or hackagon-admin with the +password `aliceandbob`** — the last of those being a global Admin. There is no +authentication in front of the tunnel; a Cloudflare quick-tunnel-style obscure +name is not one either, and these names are guessable by design. + +Treat a tunnelled cluster as a demo you are watching, not as something to leave +running. `tunnel.sh down` is one command and `down.sh` stops the tunnel before +it deletes anything. If it must live longer than a session, put Cloudflare +Access in front of the hostnames or import a realm that is not the development +export (`up.sh --no-realm`, then create the accounts you actually want). + +That is why this rig is opt-in and why it binds loopback only. Nothing it +generates may be copied anywhere: `helm-chart/values.k3d.yaml` is tracked and +contains **no credential of any kind** — the postgres, keycloak-db, OIDC-client +and object-store secrets are minted per cluster into a gitignored +`.state/secrets.env`, and the storage credentials reach the chart as a Secret +`up.sh` creates with `kubectl`, because the chart refuses to read them from +values at all. + +`values.k3d.yaml` does carry one line that must never travel: +`NODE_TLS_REJECT_UNAUTHORIZED=0` on the frontend, so it will accept the +self-signed Keycloak certificate. The honest alternative needs an `extraVolumes` +hook and `NODE_EXTRA_CA_CERTS`; inventing chart surface to make a test pass is +how test-only surface gets into a production chart. + +## Toolchain + +`helm` and `k3d` are not on this Windows host and `scripts/tools.sh` fetches +pinned binaries into the gitignored `bin/` (k3d 25 MB, helm 18 MB, kubectl 3 +MB). Containerised alternatives were considered and rejected: `k3d` in a +container needs the Docker socket bind-mounted AND a shared path for the +kubeconfig, and on Docker Desktop for Windows the socket has to be spelled +`//var/run/docker.sock` to survive MSYS mangling; `alpine/helm` has to reach an +apiserver published on the HOST's loopback, which is not the container's, so it +needs `--network k3d-<cluster>` and a rewritten server URL. If a machine may not +fetch binaries, both are still possible — that is the shape they need. + +**Two Windows traps, both handled in `lib.sh`.** Paths are converted with +`cygpath -m` (`C:/Users/…`), never `cygpath -w`: helm's `--set-file` value goes +through its strvals parser, which treats `\` as an escape, so a Windows path +arrives as `C:UsersKato…` and helm reports the file missing. And every wrapper +sets `MSYS_NO_PATHCONV=1`, because MSYS rewrites arguments that only LOOK like +paths — a JSON patch's `"/spec/template/…"`, `sh -c 'cat /proc/1/root/…'`. diff --git a/.claude/skills/k3d-chart-rig/manifests/control-no-vhost-ingress.yaml b/.claude/skills/k3d-chart-rig/manifests/control-no-vhost-ingress.yaml new file mode 100644 index 00000000..bbd23c44 --- /dev/null +++ b/.claude/skills/k3d-chart-rig/manifests/control-no-vhost-ingress.yaml @@ -0,0 +1,41 @@ +# THE NEGATIVE CONTROL for claim 1. +# +# A copy of the Ingress the chart renders for /objects, differing in EXACTLY ONE +# thing: no `nginx.ingress.kubernetes.io/upstream-vhost`. Same backend, same +# regex path, same rewrite-target, same body size, same class. +# +# Without it, "the presigned PUT returned 200" is only evidence that a PUT can +# reach a store. With it, a 403 SignatureDoesNotMatch here and a 200 through the +# chart's own route are evidence that THE ANNOTATION is what makes uploads work +# — which is the claim, and the one that has already cost this project days +# because unsigned public reads keep working while every upload dies. +# +# On its own hostname so both routes exist at once and the same signed URL can +# be sent down each. verify.sh asserts that the annotation sets differ by that +# one key and nothing else, so this file cannot quietly drift away from what the +# chart renders. +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: rig-objects-control + namespace: NAMESPACE_PLACEHOLDER + labels: + hackagon.rig/purpose: negative-control + annotations: + nginx.ingress.kubernetes.io/rewrite-target: /$2 + nginx.ingress.kubernetes.io/use-regex: "true" + nginx.ingress.kubernetes.io/proxy-body-size: "64m" + nginx.ingress.kubernetes.io/backend-protocol: "HTTP" +spec: + ingressClassName: nginx + rules: + - host: CONTROL_HOST_PLACEHOLDER + http: + paths: + - path: /objects(/|$)(.*) + pathType: ImplementationSpecific + backend: + service: + name: OBJECTS_SERVICE_PLACEHOLDER + port: + number: STORE_PORT_PLACEHOLDER diff --git a/.claude/skills/k3d-chart-rig/manifests/ingress-nginx-values.yaml b/.claude/skills/k3d-chart-rig/manifests/ingress-nginx-values.yaml new file mode 100644 index 00000000..46a2f794 --- /dev/null +++ b/.claude/skills/k3d-chart-rig/manifests/ingress-nginx-values.yaml @@ -0,0 +1,84 @@ +# ingress-nginx for the k3d rig. +# +# k3d ships TRAEFIK, and every annotation the chart writes on its /objects +# Ingress (`nginx.ingress.kubernetes.io/upstream-vhost`, `rewrite-target`, +# `use-regex`, `proxy-body-size`) is ingress-nginx's. On Traefik the Host +# rewrite is not expressible in a core Ingress object at all — it needs a +# Traefik CRD — so with the bundled controller the one thing this rig exists to +# test cannot work. `up.sh` therefore creates the cluster with +# `--disable=traefik` and installs this instead. +# +# THE PORT IS THE INTERESTING PART. The controller Service listens on 8090, not +# 80, so that the port in the public URL is the same port the cluster's own DNS +# answer reaches. Keycloak's issuer is ONE string: the browser fetches +# `http://auth.hackagon.localhost:8090/realms/hackagon` and the frontend pod +# fetches the discovery document from the same string. If the in-cluster +# listener were on :80 the two could not agree, and Auth.js rejects a discovery +# document whose `issuer` differs from the configured one. +# +# k3s's ServiceLB turns a LoadBalancer Service into a DaemonSet with +# hostPort: 8090 on the node, and k3d publishes host 8090 to that same node +# port — so one number runs the whole way through. +controller: + replicaCount: 1 + + ingressClassResource: + name: nginx + enabled: true + default: true + ingressClass: nginx + # An Ingress with no class must NOT be picked up: the point of this rig is to + # observe that the chart's own ingressClassName selects this controller. + watchIngressWithoutClass: false + + service: + type: LoadBalancer + ports: + http: 8090 + https: 8443 + targetPorts: + http: http + https: https + + # Snippets stay off. Nothing in helm-chart/ uses them, and leaving them + # enabled would let a rig-only annotation stand in for a chart-only one. + allowSnippetAnnotations: false + + config: + # THE SCHEME HAS TO SURVIVE A HOP THIS CONTROLLER DID NOT TERMINATE. + # + # ingress-nginx always sends `X-Forwarded-Proto: $pass_access_scheme` + # upstream, and by default `$pass_access_scheme` is `$scheme` — the scheme of + # the connection IT accepted. Behind a tunnel that terminates TLS at + # Cloudflare's edge and speaks plain http to this controller, that is `http`, + # so the frontend would be told `http` on a request the browser made over + # https: it would advertise an http callback URL, drop the `__Secure-` cookie + # prefix, and 403 the sign-in POST whose `Origin` is https. Every page would + # still answer 200. + # + # With this on, the map becomes `default $http_x_forwarded_proto; '' $scheme` + # — so cloudflared's header is believed when present and the connection's own + # scheme is used when it is not. That second half is why this is safe to + # leave on in the plain `*.localhost` mode: nothing sends the header there, + # the fallback is exactly the previous behaviour, and `verify.sh` still + # passes unchanged. + # + # ⚠ It does mean any client that can reach this controller directly can claim + # to be on https. That is the standard trade for running behind a proxy and + # it is why this rig binds loopback only; a deployment pairs it with + # `proxy-real-ip-cidr` scoped to the proxies it actually has. + use-forwarded-headers: "true" + + resources: + requests: + cpu: 50m + memory: 90Mi + + admissionWebhooks: + enabled: true + + # A single-node cluster: no reason to spread or to keep a second copy. + minAvailable: 0 + +defaultBackend: + enabled: false diff --git a/.claude/skills/k3d-chart-rig/manifests/store-init-job.yaml b/.claude/skills/k3d-chart-rig/manifests/store-init-job.yaml new file mode 100644 index 00000000..b6fd6765 --- /dev/null +++ b/.claude/skills/k3d-chart-rig/manifests/store-init-job.yaml @@ -0,0 +1,47 @@ +# Bucket and bucket policy, once, in-cluster. +# +# The chart deliberately creates neither (helm-chart/templates/NOTES.txt says +# so). Without the policy every stored image 403s while uploads succeed, which +# is the same shape of asymmetric failure as the missing Host rewrite — so the +# rig sets it and then PROVES both halves in verify.sh: a public prefix +# readable with no credentials at all, and a private one that is not. +# +# The public prefixes are the ones .devcontainer/rustfs-init.sh publishes, and +# for the same reason: they render on pages that need no login, which is what +# lets their stable path live in a database column. +apiVersion: batch/v1 +kind: Job +metadata: + name: store-init + namespace: hackagon-store +spec: + backoffLimit: 6 + ttlSecondsAfterFinished: 3600 + template: + spec: + restartPolicy: OnFailure + containers: + - name: mc + image: MC_IMAGE_PLACEHOLDER + env: + - name: AK + valueFrom: + { secretKeyRef: { name: store-credentials, key: accessKey } } + - name: SK + valueFrom: + { secretKeyRef: { name: store-credentials, key: secretKey } } + command: ["/bin/sh", "-c"] + args: + - | + set -e + until mc alias set s3 http://store.hackagon-store.svc.cluster.local:9000 "$AK" "$SK" >/dev/null 2>&1; do + echo "waiting for the store"; sleep 2 + done + mc mb --ignore-existing s3/BUCKET_PLACEHOLDER + for p in hackathons users site; do + mc anonymous set download "s3/BUCKET_PLACEHOLDER/$p" + done + # A private object to prove the other half of the policy: teams/* + # must NOT be world-readable. + echo "private-canary" | mc pipe s3/BUCKET_PLACEHOLDER/teams/canary.txt + mc anonymous list s3/BUCKET_PLACEHOLDER diff --git a/.claude/skills/k3d-chart-rig/manifests/store.yaml b/.claude/skills/k3d-chart-rig/manifests/store.yaml new file mode 100644 index 00000000..9f54ede4 --- /dev/null +++ b/.claude/skills/k3d-chart-rig/manifests/store.yaml @@ -0,0 +1,84 @@ +# TEST-ONLY object store for the rig. +# +# The chart deploys no store, on purpose (helm-chart/values.yaml says why: a +# bucket must outlive the release). This is not a chart template and never +# should be — it is the thing the chart POINTS AT, supplied by the rig so the +# /objects route has an upstream to prove itself against. +# +# It lives in its OWN namespace and is addressed by cluster DNS +# (store.hackagon-store.svc.cluster.local:9000). That is deliberate: it means +# `storage.objects.ingress.service.name` stays EMPTY, so the chart renders its +# ExternalName Service — which is the third claim this rig exists to check. +# Naming the Service directly would route the same traffic while skipping the +# object under test. +# +# emptyDir, not a PVC: `down.sh` deletes the cluster, so persistence would be a +# lie. Credentials come from the Secret `store-credentials`, created by up.sh +# from generated values — nothing here carries one. +apiVersion: v1 +kind: Namespace +metadata: + name: hackagon-store +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + name: store + namespace: hackagon-store + labels: { app: store } +spec: + replicas: 1 + selector: + matchLabels: { app: store } + template: + metadata: + labels: { app: store } + spec: + containers: + - name: minio + image: MINIO_IMAGE_PLACEHOLDER + args: + [ + "server", + "/data", + "--address", + ":9000", + "--console-address", + ":9001", + ] + env: + - name: MINIO_ROOT_USER + valueFrom: + { secretKeyRef: { name: store-credentials, key: accessKey } } + - name: MINIO_ROOT_PASSWORD + valueFrom: + { secretKeyRef: { name: store-credentials, key: secretKey } } + # No browser console needed and one less surface to reason about. + - name: MINIO_BROWSER + value: "off" + ports: + - { name: s3, containerPort: 9000 } + volumeMounts: + - { name: data, mountPath: /data } + readinessProbe: + httpGet: { path: /minio/health/ready, port: 9000 } + initialDelaySeconds: 3 + periodSeconds: 3 + resources: + requests: { cpu: 50m, memory: 128Mi } + limits: { memory: 1Gi } + volumes: + - name: data + emptyDir: {} +--- +apiVersion: v1 +kind: Service +metadata: + name: store + namespace: hackagon-store + labels: { app: store } +spec: + type: ClusterIP + selector: { app: store } + ports: + - { name: s3, port: 9000, targetPort: 9000, protocol: TCP } diff --git a/.claude/skills/k3d-chart-rig/scripts/browser-check.sh b/.claude/skills/k3d-chart-rig/scripts/browser-check.sh new file mode 100644 index 00000000..d821f02e --- /dev/null +++ b/.claude/skills/k3d-chart-rig/scripts/browser-check.sh @@ -0,0 +1,92 @@ +#!/usr/bin/env bash +# Sign in through the PUBLIC https hostname with a real browser. +# +# bash scripts/browser-check.sh # alice +# bash scripts/browser-check.sh bob hunter2 +# +# Tunnel mode only — there is nothing here the *.localhost mode can answer, and +# it says so rather than passing vacuously. +# +# WHY A BROWSER AT ALL, when verify.sh already drives a login with curl: the +# property this mode adds is a COOKIE-PREFIX rule, and cookie prefixes live in +# the user agent. `__Secure-` means "a user agent must refuse to store this +# unless it arrived over a secure connection" — curl implements no such rule, so +# a green curl login is equally consistent with the prefix working and with it +# being ignored. See the header of browser-login.mjs. +# +# WHERE IT RUNS. Everything else in this rig runs on the HOST, because that is +# where docker and the pinned k3d/helm/kubectl live. This one runs inside the +# devcontainer, because that is where Playwright and its Firefox already are — +# installing a second 300 MB browser on the host to avoid a docker exec is the +# wrong trade, and the thing under test is a public URL that both can reach. +set -euo pipefail +. "$(dirname "${BASH_SOURCE[0]}")/lib.sh" + +USER_NAME="${1:-alice}" +USER_PASS="${2:-aliceandbob}" +DEV_CONTAINER="${RIG_DEV_CONTAINER:-devcontainer-dev-1}" +# Inside the container the repo is a bind mount at a different path than on the +# host, so the script is addressed by the container's view of it. +IN_CONTAINER_REPO="${RIG_CONTAINER_REPO:-/workspaces/hackagon}" + +[ "$RIG_MODE" = "tunnel" ] || die "not in tunnel mode — run scripts/tunnel.sh up first (this check has no meaning on plain http)" +docker inspect "$DEV_CONTAINER" >/dev/null 2>&1 || + die "container '$DEV_CONTAINER' is not there — it carries Playwright and its Firefox (see devcontainer-up)" + +# --- the address the browser will dial --------------------------------- +# Resolved over DoH on the HOST and handed in, because the container's own +# resolver is the thing that may be unable to answer. A pin is a routing choice, +# not a trust choice: SNI is still the real hostname and Firefox still verifies +# the chain, which is the whole point of this mode. +doh_a() { # <fqdn> -> first IPv4 + curl -sS --max-time 10 "https://1.1.1.1/dns-query?name=$1&type=A" \ + -H "accept: application/dns-json" 2>/dev/null | + tr ',' '\n' | grep -oE '"data":"[0-9.]+"' | head -1 | sed 's/.*:"//;s/"//' +} +APP_IP="$(doh_a "$APP_HOST")" +AUTH_IP="$(doh_a "$AUTH_HOST")" +[ -n "$APP_IP" ] && [ -n "$AUTH_IP" ] || + die "could not resolve $APP_HOST / $AUTH_HOST over DoH — is the tunnel up?" +ok "edge $APP_IP for $APP_HOST · $AUTH_IP for $AUTH_HOST" + +step "signing in as $USER_NAME through $APP_URL" + +# The pin is written and REMOVED by the same shell, including on failure: a +# leftover hosts entry outlives the tunnel it names, and the next thing to look +# that name up would get a stale address with no clue where it came from. +# +# MSYS_NO_PATHCONV=1 is load-bearing on the Git Bash host and its absence is not +# subtle: `/workspaces/hackagon` is an argument that LOOKS like a path, so MSYS +# rewrites it to `C:/Program Files/Git/workspaces/hackagon` before docker sees +# it, and the container reports a directory it has never heard of. Same trap the +# kubectl/helm wrappers in lib.sh carry the flag for. +MSYS_NO_PATHCONV=1 MSYS2_ARG_CONV_EXCL='*' \ + docker exec -i "$DEV_CONTAINER" bash -s -- \ + "$APP_HOST" "$APP_IP" "$AUTH_HOST" "$AUTH_IP" "$APP_URL" \ + "$IN_CONTAINER_REPO" "$USER_NAME" "$USER_PASS" <<'REMOTE' +set -euo pipefail +APP_HOST="$1"; APP_IP="$2"; AUTH_HOST="$3"; AUTH_IP="$4"; APP_URL="$5" +REPO="$6"; U="$7"; P="$8" +MARK="# k3d-chart-rig browser-check" + +unpin() { sed -i "/$MARK\$/d" /etc/hosts 2>/dev/null || true; } +trap unpin EXIT INT TERM +unpin +printf '%s %s %s\n%s %s %s\n' \ + "$APP_IP" "$APP_HOST" "$MARK" "$AUTH_IP" "$AUTH_HOST" "$MARK" >>/etc/hosts + +export PATH="$REPO/.devenv/profile/bin:$PATH" +# Playwright's browsers were installed by the e2e suite as the `vscode` user; +# this exec runs as root, whose cache is empty. Point at the one that exists +# rather than downloading a second copy. +export PLAYWRIGHT_BROWSERS_PATH="${PLAYWRIGHT_BROWSERS_PATH:-/home/vscode/.cache/ms-playwright}" +[ -d "$PLAYWRIGHT_BROWSERS_PATH" ] || { + echo "error: no Playwright browsers at $PLAYWRIGHT_BROWSERS_PATH" >&2 + echo " run the e2e suite once, or set PLAYWRIGHT_BROWSERS_PATH." >&2 + exit 1 +} +export SHOT=/tmp/k3d-tunnel-login.png +cd "$REPO/.claude/skills/hackathon-e2e" +node "$REPO/.claude/skills/k3d-chart-rig/scripts/browser-login.mjs" \ + "$APP_URL" "$AUTH_HOST" "$U" "$P" +REMOTE diff --git a/.claude/skills/k3d-chart-rig/scripts/browser-login.mjs b/.claude/skills/k3d-chart-rig/scripts/browser-login.mjs new file mode 100644 index 00000000..4acc1daa --- /dev/null +++ b/.claude/skills/k3d-chart-rig/scripts/browser-login.mjs @@ -0,0 +1,237 @@ +// A REAL BROWSER signing in through the public https hostname. +// +// node browser-login.mjs <app-url> <auth-host> [username] [password] +// +// Everything else in this rig is curl, and curl cannot answer the question this +// mode exists to ask. `__Secure-` is a COOKIE-PREFIX rule and it lives in the +// browser: a user agent must refuse to store a `__Secure-` cookie that did not +// arrive over a secure connection, and must refuse to send one over an insecure +// one. curl implements no such rule — it would keep and replay that cookie over +// plain http quite happily, so a green curl login says nothing about whether the +// prefix works. The *.localhost mode could not test it either, because it has no +// https at all and `frontend.config.cookies.useSecure` is false there. +// +// So the assertions below are specifically the ones that need a browser: +// +// 1. the whole round-trip completes across TWO public hostnames (the app's and +// Keycloak's), each with its own certificate check by the browser itself; +// 2. the session cookie is NAMED `__Secure-authjs.session-token` and the +// browser STORED it — which it would refuse to do over http; +// 3. a later request carries it back, read out of the app's own /auth/session +// rather than out of the cookie jar: "the browser has a cookie" and "the +// server accepted it" are different claims, and this repo has a written +// record of measuring the first and reporting the second. +// +// Firefox, because it is what the e2e suite already installs in this container +// and because a second engine's cookie rules are not the thing under test. +// +// Playwright is BORROWED from the hackathon-e2e skill rather than installed a +// second time — that skill already carries it and its browsers, and two copies +// of a 300 MB dependency drift. ESM resolves from the SCRIPT's directory, not +// the cwd, so `createRequire` anchored at that package.json is what finds it; +// `PW_FROM` moves the anchor for a machine that keeps it elsewhere. +import { createRequire } from "node:module" +const require = createRequire( + process.env.PW_FROM || + new URL("../../hackathon-e2e/package.json", import.meta.url), +) +const { firefox } = require("@playwright/test") + +const [appUrl, authHost, username = "alice", password = "aliceandbob"] = + process.argv.slice(2) +if (!appUrl || !authHost) { + console.error( + "usage: node browser-login.mjs <app-url> <auth-host> [user] [pass]", + ) + process.exit(2) +} +const appHost = new URL(appUrl).hostname + +let pass = 0 +let fail = 0 +const ok = (m) => { + pass++ + console.log(` ok ${m}`) +} +const bad = (m, detail) => { + fail++ + console.log(` FAIL ${m}`) + if (detail !== undefined) console.log(` ${detail}`) +} +const check = (m, cond, detail) => (cond ? ok(m) : bad(m, detail)) + +// ⚠ THIS PROCESS MAY NEED AN /etc/hosts PIN TO RESOLVE ITS OWN TARGET, and that +// is a property of the network rather than of the tunnel. The one these +// containers run on answers AAAA-ONLY for Cloudflare-proxied names and has no +// IPv6 route out: `getent hosts` returns two v6 addresses, none reachable, and +// Firefox fails in 3 ms with NS_ERROR_UNKNOWN_HOST. `browser-check.sh` writes +// the pin from a DoH-resolved A record and takes it away again — the same fix +// .claude/CLAUDE.md records for the dev tunnels. +// +// Measured on the way: `firefoxUserPrefs: {"network.dns.disableIPv6": true}` is +// NOT enough on its own. It stops the browser PREFERRING v6, and here the +// resolver never offers an A record at all, so turning v6 off leaves nothing to +// fall back to and the failure is identical. The pin has to supply the address. +const browser = await firefox.launch() +// No ignoreHTTPSErrors, and its absence is an assertion: every navigation below +// is verified against Firefox's own trust store. Setting it would turn the one +// thing this mode adds over the *.localhost rig into a no-op. +const ctx = await browser.newContext() +const page = await ctx.newPage() + +try { + // ── 1 · the app, over a certificate the browser accepts ──────────────── + const resp = await page.goto(appUrl, { + waitUntil: "domcontentloaded", + timeout: 60_000, + }) + check( + "the landing page loads over https", + resp?.status() === 200, + `status ${resp?.status()}`, + ) + check( + "…on the public hostname", + new URL(page.url()).protocol === "https:", + page.url(), + ) + + const sec = await page.evaluate(() => window.isSecureContext) + // Not decoration: `*.localhost` over plain http is ALSO a secure context (RFC + // 6761 makes it potentially-trustworthy), so this is true in both modes and + // proves nothing on its own — the certificate check is what `goto` succeeding + // already established. It is asserted because the __Secure- rule below is + // stated in terms of it. + check("the page is a secure context", sec === true, String(sec)) + + // ── 2 · sign in, which crosses to the OTHER public hostname ──────────── + // + // The header's "Log in" is a <button> whose handler only exists after + // hydration, so a click that lands before the bundle has run is SWALLOWED — + // no error, no navigation. That has cost this repo a debugging session + // already (.claude/CLAUDE.md, "the avatar swallowed its first click"), and it + // is worse here: every hop is a public round-trip, so hydration takes longer + // than it does on localhost. Wait for the network to settle, then retry. + const clickLogin = async () => { + const btn = page.getByRole("button", { name: "Log in" }) + const link = page.getByRole("link", { name: /log ?in|sign ?in/i }) + if (await btn.count()) return btn.first().click() + if (await link.count()) return link.first().click() + await page.goto(`${appUrl}/auth/signin`, { waitUntil: "domcontentloaded" }) + const provider = page.getByRole("button", { name: /keycloak/i }) + if (await provider.count()) await provider.first().click() + } + + let reached = false + for (let attempt = 1; attempt <= 3 && !reached; attempt++) { + await page.waitForLoadState("networkidle").catch(() => {}) + await clickLogin() + try { + await page.waitForURL((u) => u.hostname === authHost, { timeout: 30_000 }) + reached = true + } catch { + if (attempt < 3) + await page.goto(appUrl, { waitUntil: "domcontentloaded" }) + } + } + check( + "sign-in reaches Keycloak on its own public hostname", + reached, + page.url(), + ) + if (!reached) throw new Error(`never left ${page.url()}`) + + await page.locator("#username").waitFor({ timeout: 30_000 }) + await page.locator("#username").fill(username) + if (!(await page.locator("#password").isVisible())) { + await page.locator("#kc-login").click() + await page.locator("#password").waitFor({ timeout: 30_000 }) + } + await page.locator("#password").fill(password) + await page.locator("#kc-login").click() + + // Back on the app. THIS is the hop that 502s when the proxy buffer is too + // small — the callback's Set-Cookie block is chunked and multi-kilobyte. + await page.waitForURL((u) => u.hostname === appHost, { timeout: 60_000 }) + check("Keycloak redirects back to the app", true, page.url()) + + // ── 3 · the cookie the browser would have refused over http ──────────── + const cookies = await ctx.cookies() + const names = cookies.map((c) => c.name) + // POSITIVE CONTROL FIRST. An empty jar agrees with every claim below, and + // "no cookie called X" reads identically to "no cookies at all". + check( + "the browser kept cookies for the app host", + cookies.some((c) => c.domain.endsWith(appHost)), + names.join(", ") || "(none)", + ) + + const session = cookies.filter((c) => /authjs\.session-token/.test(c.name)) + check( + "a session cookie exists at all", + session.length > 0, + names.join(", ") || "(none)", + ) + check( + "it is named __Secure-authjs.session-token", + session.every((c) => c.name.startsWith("__Secure-")), + session.map((c) => c.name).join(", "), + ) + check( + "…and carries the Secure and HttpOnly flags", + session.every((c) => c.secure && c.httpOnly), + JSON.stringify( + session.map((c) => ({ n: c.name, s: c.secure, h: c.httpOnly })), + ), + ) + check( + "no unprefixed authjs.session-token was set alongside it", + !names.includes("authjs.session-token"), + names.join(", "), + ) + + // ── 4 · the server accepts it back ───────────────────────────────────── + // Read through the PAGE, so the request carries the real cookie jar and the + // browser's own rules about what it will send where. + const sess = await page.evaluate(async (u) => { + const r = await fetch(`${u}/auth/session`, { credentials: "include" }) + return { status: r.status, body: await r.text() } + }, appUrl) + check( + "/auth/session identifies the signed-in user", + /alice/.test(sess.body), + `${sess.status} ${sess.body.slice(0, 200)}`, + ) + check( + "…and carries a Keycloak access token", + /"accessToken":"ey/.test(sess.body), + sess.body.slice(0, 120), + ) + + // ── 5 · it survives a fresh navigation ───────────────────────────────── + // The cookie being in the jar is not the same as it being sent on the next + // request; a Secure cookie that the browser declines to replay would look + // identical up to here. + await page.goto(`${appUrl}/`, { waitUntil: "domcontentloaded" }) + const after = await page.evaluate(async (u) => { + const r = await fetch(`${u}/auth/session`, { credentials: "include" }) + return await r.text() + }, appUrl) + check( + "the session survives a full page load", + /alice/.test(after), + after.slice(0, 160), + ) + + await page.screenshot({ + path: process.env.SHOT || "/tmp/k3d-tunnel-login.png", + fullPage: false, + }) +} catch (err) { + bad("the run threw", String(err).split("\n")[0]) +} finally { + await browser.close() +} + +console.log(`\n ${pass} passed, ${fail} failed`) +process.exit(fail === 0 ? 0 : 1) diff --git a/.claude/skills/k3d-chart-rig/scripts/down.sh b/.claude/skills/k3d-chart-rig/scripts/down.sh new file mode 100644 index 00000000..633020be --- /dev/null +++ b/.claude/skills/k3d-chart-rig/scripts/down.sh @@ -0,0 +1,114 @@ +#!/usr/bin/env bash +# Delete the cluster. One command, and it takes everything with it. +# +# bash scripts/down.sh # delete the cluster, keep .state/ +# bash scripts/down.sh --purge # …and the credentials, AND give the +# # public hostnames back to Cloudflare +# bash scripts/down.sh --stop # stop the containers, keep the cluster +# +# There is nothing to preserve: the store is an emptyDir, postgres runs without +# a PVC, and every credential is regenerated by the next up.sh. `--purge` exists +# only so a fresh run is provably fresh. +# +# It touches nothing outside its own cluster: the dev stack's compose project, +# its ports (3000/8081/8082/8180/15432/9000/9001/8010) and the three tunnels +# that serve it are untouched, because none of them is a k3d resource. +# +# ── THE ONE THING IT OWNS OUTSIDE THE CLUSTER ──────────────────────────────── +# +# This rig's OWN tunnel (scripts/tunnel.sh) is different: its whole job is to +# point two public hostnames at a cluster that is about to stop existing. +# +# * the cloudflared container is STOPPED, always. Left running it would keep +# answering for hostnames whose origin has been deleted — Cloudflare would +# return 502 from a healthy-looking tunnel, which is the least informative +# failure available. +# * the DNS records and the tunnel are LEFT unless --purge. They are cheap, +# they make the next `tunnel.sh up` a no-op instead of a re-registration, +# and deleting records in a shared zone is not something a routine teardown +# should do quietly. With the tunnel stopped they answer Cloudflare's 1033 +# ("tunnel not found"), which says what is actually true. +# * either way the exact give-up command is PRINTED, every time, naming both +# hostnames. A hostname you have forgotten you own is the failure mode here. +set -euo pipefail +. "$(dirname "${BASH_SOURCE[0]}")/lib.sh" +# shellcheck source=../../lib/cf-named-tunnel.sh +. "$REPO_ROOT/.claude/skills/lib/cf-named-tunnel.sh" + +PURGE=0 +STOP=0 +for arg in "$@"; do + case "$arg" in + --purge) PURGE=1 ;; + --stop) STOP=1 ;; + *) die "unknown flag $arg" ;; + esac +done + +# --- the tunnel, before the cluster it points at ------------------------ +# Before, not after: between deleting the cluster and stopping cloudflared the +# public URL is a 502, and that window is exactly what someone watching the link +# would misread as a broken tunnel. +TUNNEL_NAME="${RIG_TUNNEL_NAME:-hackagon-k3d}" +TUNNEL_HOSTS=() +if cf_configured; then + cf_load >/dev/null 2>&1 || true + # Same fallbacks as tunnel.sh, and derived from the zone for the same + # reason: no tracked file in this repository names a hostname. + TUNNEL_HOSTS=("${K3D_HOSTNAME:-k3d.${CLOUDFLARE_ZONE:-}}" + "${K3D_AUTH_HOSTNAME:-k3d-auth.${CLOUDFLARE_ZONE:-}}") +fi +if cfn_running "$TUNNEL_NAME"; then + step "stopping this rig's tunnel" + cfn_stop "$TUNNEL_NAME" +elif [ -n "$(docker ps -aq -f "name=^$(cfn_container "$TUNNEL_NAME")\$" 2>/dev/null)" ]; then + cfn_stop "$TUNNEL_NAME" +fi +rm -f "$MODE_ENV" + +if ! cluster_exists; then + ok "cluster '$CLUSTER' is already gone" +else + if [ "$STOP" = 1 ]; then + step "stopping cluster '$CLUSTER'" + k3d cluster stop "$CLUSTER" >/dev/null + ok "stopped (k3d cluster start $CLUSTER to resume)" + exit 0 + fi + step "deleting cluster '$CLUSTER'" + k3d cluster delete "$CLUSTER" >/dev/null + ok "cluster, its network and its volumes are gone" +fi + +if [ "$PURGE" = 1 ]; then + rm -f "$KUBECONFIG_FILE" "$GEN_VALUES" "$REALM_FILE" "$SECRETS_ENV" \ + "$TLS_CERT" "$TLS_KEY" "$STATE_DIR/values.tunnel.yaml" + rm -f "$STATE_DIR"/*.html "$STATE_DIR"/*.txt "$STATE_DIR"/*.body 2>/dev/null || true + ok "purged $STATE_DIR" + if [ "${#TUNNEL_HOSTS[@]}" -gt 0 ]; then + step "giving the public hostnames back" + cfn_destroy "$TUNNEL_NAME" "${TUNNEL_HOSTS[@]}" || true + fi +fi + +# --- say what is left, by name ----------------------------------------- +# Printed whether or not a tunnel was ever created here: "there is nothing to +# clean up" and "you were never told" look the same from the outside, and the +# second is how a record survives a laptop. +if [ "$PURGE" != 1 ] && [ "${#TUNNEL_HOSTS[@]}" -gt 0 ]; then + say "" + say " Still registered on Cloudflare (nothing serves them now — 1033):" + say " ${TUNNEL_HOSTS[0]}" + say " ${TUNNEL_HOSTS[1]}" + say " Give them up:" + say " bash $HERE/tunnel.sh destroy" + say " bash $HERE/down.sh --purge # same thing, with the credentials" +fi + +say "" +say " The ~4.1 GB of images inside the node's containerd went with the cluster." +say " What stays is ~420 MB in the HOST cache (k3s, k3d-proxy, k3d-tools):" +say " docker image rm $K3S_IMAGE \\" +say " ghcr.io/k3d-io/k3d-proxy:${K3D_VERSION#v} ghcr.io/k3d-io/k3d-tools:${K3D_VERSION#v}" +say " Leaving them saves ~40 s of the next up.sh; the in-cluster pulls (~2 min)" +say " happen again either way, because a new node has an empty containerd." diff --git a/.claude/skills/k3d-chart-rig/scripts/install.sh b/.claude/skills/k3d-chart-rig/scripts/install.sh new file mode 100644 index 00000000..2746fa4d --- /dev/null +++ b/.claude/skills/k3d-chart-rig/scripts/install.sh @@ -0,0 +1,45 @@ +#!/usr/bin/env bash +# Re-run just the `helm upgrade` against an already-running cluster. +# +# bash scripts/install.sh # upgrade in place +# bash scripts/install.sh --restart # …and roll the app pods afterwards +# +# The point of splitting this out of up.sh: iterating on helm-chart/ costs +# seconds this way, and ~4 minutes if the cluster is rebuilt each time. +set -euo pipefail +. "$(dirname "${BASH_SOURCE[0]}")/lib.sh" + +RESTART=0 +[ "${1:-}" = "--restart" ] && RESTART=1 + +require_cluster +load_secrets +[ -f "$GEN_VALUES" ] || die "no $GEN_VALUES — run scripts/up.sh" + +REALM_ARGS=() +[ -f "$REALM_FILE" ] && REALM_ARGS=(--set-file "realmJson=$(winpath "$REALM_FILE")") + +step "helm upgrade" +helm upgrade --install "$RELEASE" "$(winpath "$CHART_DIR")" \ + --namespace "$NAMESPACE" \ + -f "$(winpath "$CHART_DIR/values.yaml")" \ + -f "$(winpath "$CHART_DIR/values.k3d.yaml")" \ + -f "$(winpath "$GEN_VALUES")" \ + "${REALM_ARGS[@]}" \ + --timeout 15m >&2 + +if [ "$RESTART" = 1 ]; then + step "rolling app pods" + kubectl -n "$NAMESPACE" rollout restart deployment "$RELEASE-backend" "$RELEASE-frontend" >/dev/null +fi + +step "waiting" +for d in backend frontend; do + if kubectl -n "$NAMESPACE" rollout status "deployment/$RELEASE-$d" --timeout=300s >/dev/null 2>&1; then + ok "$RELEASE-$d ready" + else + bad "$RELEASE-$d did NOT become ready" + kubectl -n "$NAMESPACE" get pods + kubectl -n "$NAMESPACE" logs "deployment/$RELEASE-$d" --tail=30 || true + fi +done diff --git a/.claude/skills/k3d-chart-rig/scripts/lib.sh b/.claude/skills/k3d-chart-rig/scripts/lib.sh new file mode 100644 index 00000000..8806f270 --- /dev/null +++ b/.claude/skills/k3d-chart-rig/scripts/lib.sh @@ -0,0 +1,292 @@ +#!/usr/bin/env bash +# Shared settings and helpers for the k3d chart rig. +# +# Sourced by every script here. Nothing in this file starts anything. +set -euo pipefail + +HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +SKILL_DIR="$(cd "$HERE/.." && pwd)" +REPO_ROOT="$(cd "$SKILL_DIR/../../.." && pwd)" +CHART_DIR="$REPO_ROOT/helm-chart" +STATE_DIR="$SKILL_DIR/.state" +BIN_DIR="$SKILL_DIR/bin" +MANIFEST_DIR="$SKILL_DIR/manifests" + +# --- names ------------------------------------------------------------ +# Release name is `hackagon` on purpose: helm-chart/values.yaml hard-codes +# `keycloak.realmImport.existingConfigMap: hackagon-realm`, and the ConfigMap +# the chart actually creates is `<fullname>-realm`. Those two only agree when +# the release is called hackagon. +CLUSTER="${RIG_CLUSTER:-hackagon}" +RELEASE="${RIG_RELEASE:-hackagon}" +NAMESPACE="${RIG_NAMESPACE:-hackagon}" +STORE_NS="${RIG_STORE_NAMESPACE:-hackagon-store}" + +# --- the hostname ------------------------------------------------------ +# `*.localhost`, not nip.io/sslip.io. Both of those were measured failing on +# this machine: the local resolver applies DNS-rebinding protection and refuses +# to hand back an answer inside 127.0.0.0/8, so `app.127.0.0.1.nip.io` does not +# resolve at all while `nslookup … 8.8.8.8` does. That is a property of whoever +# runs DNS for the developer, which is not something a test rig may depend on. +# +# `*.localhost` involves no resolver: curl (>= 7.77) and every Chromium, plus +# Firefox >= 84, map it to loopback themselves per RFC 6761. Nothing is written +# to /etc/hosts or the Windows hosts file. Scripts additionally pass --resolve +# so that an older curl works too. +BASE_DOMAIN="${RIG_BASE_DOMAIN:-hackagon.localhost}" +APP_HOST="app.$BASE_DOMAIN" +AUTH_HOST="auth.$BASE_DOMAIN" + +# --- which mode the cluster is currently installed in ------------------- +# `local` (the default) is everything above: *.localhost, loopback, plain http +# for the app. `tunnel` is the same cluster published through a NAMED Cloudflare +# tunnel on a zone we own — real hostnames, real certificates, TLS terminated at +# the edge, plain http to the origin. See scripts/tunnel.sh. +# +# The mode is read from a file the installer WRITES, not inferred from whether a +# tunnel happens to be running: which URLs the release is configured for is a +# property of the last `helm upgrade`, and a container can be stopped without +# that changing. A stopped tunnel in tunnel mode is a rig whose public URL is +# down — which is what it should report, rather than quietly measuring a +# different URL that would pass. +MODE_ENV="$STATE_DIR/mode.env" +RIG_MODE="${RIG_MODE:-}" +if [ -z "$RIG_MODE" ] && [ -f "$MODE_ENV" ]; then + # shellcheck disable=SC1090 + . "$MODE_ENV" +fi +RIG_MODE="${RIG_MODE:-local}" + +# --- host ports this rig claims --------------------------------------- +# Deliberately clear of everything the dev stack publishes: +# 3000 backend · 8081 frontend (vite) · 8082 frontend (built) · 8180 keycloak +# 15432 postgres · 9000/9001 rustfs · 8010 plausible +HTTP_PORT="${RIG_HTTP_PORT:-8090}" # ingress-nginx, http — the app +HTTPS_PORT="${RIG_HTTPS_PORT:-8443}" # ingress-nginx, https — Keycloak only +API_PORT="${RIG_API_PORT:-6551}" # k3s apiserver + +# THE APP IS ON PLAIN HTTP AND KEYCLOAK IS ON TLS, and the split is deliberate. +# +# Keycloak's AUTH_SESSION_ID / KC_RESTART cookies are in its FEDERATION scope, +# which is `SameSite=None` — and SameSite=None requires Secure, so Keycloak +# marks them Secure whatever scheme it is reached over. Measured on this rig +# before the split: over http it answered +# `Set-Cookie: AUTH_SESSION_ID=…;Secure;HttpOnly;SameSite=None`, curl dropped +# them per the cookie spec, and the login POST came back 400 "session expired, +# it may have been deleted or cookies are disabled". Nothing in the chart could +# have fixed that; it is Keycloak's rule. +# +# (A real browser would have completed it: `*.localhost` is a +# potentially-trustworthy origin, so Chrome and Firefox accept Secure cookies +# there over http. curl has no such exception, and a test that only passes in +# a browser is a test this rig cannot run.) +# +# Leaving the APP on http is not a shortcut either — it is the load-bearing +# half. The SvelteKit node adapter INFERS the public scheme, and its +# unconfigured guess is the literal string "https". An https-everywhere rig +# would agree with that guess by accident and prove nothing. On http, a +# frontend that guesses wrong advertises https callback URLs, issues +# `__Secure-` cookies the browser will not send back, and login dies while +# every page still answers 200. That is the failure `frontend.protocolHeader` +# was added for, and this is where it is observed. +APP_URL="http://$APP_HOST:$HTTP_PORT" +AUTH_URL="https://$AUTH_HOST:$HTTPS_PORT" + +# --- tunnel mode overrides --------------------------------------------- +# The one thing the localhost mode cannot do: a REAL certificate. Cloudflare +# terminates TLS at its edge and cloudflared speaks plain http to the ingress +# controller, so the origin is unencrypted and `X-Forwarded-Proto: https` is the +# only thing that tells the app what the browser actually used. That is +# production's shape, and it is what `frontend.protocolHeader` is for. +# +# THE PORT PROPERTY IS REPLACED, NOT DROPPED. In localhost mode the controller +# listens on 8090 in-cluster as well as on the host so that ONE issuer string is +# true from both sides. A public https URL names no port at all, so the +# replacement is stronger: the frontend POD resolves the same public hostname +# through public DNS and reaches it the same way the browser does — out to +# Cloudflare and back down the tunnel — over the same real certificate. There is +# one URL and one path to it, so there is nothing left to disagree. + +# The loopback URL is kept under its own name in BOTH modes. Two callers need +# it while tunnel mode is on: the Keycloak client's redirect URIs, which stay +# valid for both origins so switching modes cannot lock anyone out, and the +# `localcurl` control below. +LOCAL_APP_HOST="app.$BASE_DOMAIN" +LOCAL_APP_URL="http://$LOCAL_APP_HOST:$HTTP_PORT" + +if [ "$RIG_MODE" = "tunnel" ]; then + APP_HOST="${RIG_APP_HOST:?tunnel mode needs RIG_APP_HOST (scripts/tunnel.sh writes it)}" + AUTH_HOST="${RIG_AUTH_HOST:?tunnel mode needs RIG_AUTH_HOST (scripts/tunnel.sh writes it)}" + APP_URL="https://$APP_HOST" + AUTH_URL="https://$AUTH_HOST" +fi + +# --- when THIS machine cannot look the public name up ------------------ +# Two states produce it and neither is a broken tunnel: the LAN resolver here +# answers AAAA-only for these names on a network with no IPv6 route out (written +# up in .claude/CLAUDE.md), and a resolver that was asked for the name BEFORE the +# record existed caches the NXDOMAIN for minutes afterwards — which is every +# first run, because `tunnel.sh status` asks. +# +# So: ask Cloudflare over DoH and pin the connection to the address it gives. +# This changes WHICH EDGE is dialled and nothing else — SNI is still the real +# hostname and the certificate is still verified, so the thing this mode exists +# to prove is untouched. `-k` would be the shortcut that throws it away. +RIG_RESOLVE_ARGS="${RIG_RESOLVE_ARGS:-}" +rig_pin_edge() { + [ "$RIG_MODE" = "tunnel" ] || return 0 + [ -z "$RIG_RESOLVE_ARGS" ] || return 0 + local h ip + for h in "$APP_HOST" "$AUTH_HOST"; do + ip="$(curl -sS --max-time 10 "https://1.1.1.1/dns-query?name=$h&type=A" \ + -H "accept: application/dns-json" 2>/dev/null | + tr ',' '\n' | grep -oE '"data":"[0-9.]+"' | head -1 | + sed 's/.*:"//;s/"//')" || true + [ -n "$ip" ] && RIG_RESOLVE_ARGS="$RIG_RESOLVE_ARGS --resolve $h:443:$ip" + done + [ -n "$RIG_RESOLVE_ARGS" ] || return 1 +} +# Only when the machine actually needs it: a resolver that works must be left to +# work, or the pin would hide a genuinely dead record. +if [ "$RIG_MODE" = "tunnel" ] && [ -z "$RIG_RESOLVE_ARGS" ]; then + if [ "${RIG_RESOLVE_V4:-0}" = 1 ] || + ! curl -sS -o /dev/null --max-time 8 "https://$APP_HOST/" 2>/dev/null; then + rig_pin_edge || true + fi +fi + +# --- pinned toolchain -------------------------------------------------- +K3D_VERSION="${RIG_K3D_VERSION:-v5.8.3}" +HELM_VERSION="${RIG_HELM_VERSION:-v3.19.0}" +KUBECTL_VERSION="${RIG_KUBECTL_VERSION:-v1.31.5}" +# k3s image: must match the k3d default for the pinned k3d, or say so loudly. +K3S_IMAGE="${RIG_K3S_IMAGE:-rancher/k3s:v1.31.5-k3s1}" +INGRESS_NGINX_CHART_VERSION="${RIG_INGRESS_NGINX_VERSION:-4.13.3}" +MINIO_IMAGE="${RIG_MINIO_IMAGE:-quay.io/minio/minio:RELEASE.2025-04-22T22-12-26Z}" +MC_IMAGE="${RIG_MC_IMAGE:-quay.io/minio/mc:RELEASE.2025-04-16T18-13-26Z}" + +# --- object store ------------------------------------------------------ +# In its OWN namespace, reached by its cluster DNS name. That is what makes the +# chart render its ExternalName Service (claim 3): `storage.endpoint` names a +# host that is not a Service in the release namespace, so +# `storage.objects.ingress.service.name` stays empty and the chart supplies the +# adapter itself. +STORE_HOST="store.$STORE_NS.svc.cluster.local" +STORE_PORT=9000 +STORE_ENDPOINT="http://$STORE_HOST:$STORE_PORT" +STORE_BUCKET="${RIG_BUCKET:-hackagon}" + +# --- generated files --------------------------------------------------- +KUBECONFIG_FILE="$STATE_DIR/kubeconfig.yaml" +GEN_VALUES="$STATE_DIR/values.generated.yaml" +REALM_FILE="$STATE_DIR/realm-rewritten.json" +SECRETS_ENV="$STATE_DIR/secrets.env" +TLS_CERT="$STATE_DIR/auth-tls.crt" +TLS_KEY="$STATE_DIR/auth-tls.key" + +mkdir -p "$STATE_DIR" + +# --- output ------------------------------------------------------------ +if [ -t 1 ]; then + C_OK=$'\033[32m' + C_BAD=$'\033[31m' + C_DIM=$'\033[2m' + C_OFF=$'\033[0m' +else + C_OK='' + C_BAD='' + C_DIM='' + C_OFF='' +fi +say() { printf '%s\n' "$*" >&2; } +step() { printf '\n%s==> %s%s\n' "$C_DIM" "$*" "$C_OFF" >&2; } +ok() { printf '%s ok%s %s\n' "$C_OK" "$C_OFF" "$*" >&2; } +bad() { printf '%s FAIL%s %s\n' "$C_BAD" "$C_OFF" "$*" >&2; } +die() { + bad "$*" + exit 1 +} + +# --- path translation -------------------------------------------------- +# k3d.exe, helm.exe and kubectl.exe are WINDOWS binaries invoked from Git Bash. +# They do not understand /c/Users/... , and MSYS's automatic conversion only +# fires on arguments it recognises as paths — which it does not for +# `--set-file key=/c/...`. Convert explicitly wherever a path is passed. +# +# `cygpath -m` (mixed: `C:/Users/…`), NOT `cygpath -w` (`C:\Users\…`). Helm's +# --set-file value goes through its strvals parser, which treats `\` as an +# escape character: a Windows path arrives as `C:UsersKato…` and helm reports +# the file as missing. Forward slashes are accepted by every Windows binary +# here and survive the parser untouched. +if command -v cygpath >/dev/null 2>&1; then + winpath() { cygpath -m "$1"; } + IS_WINDOWS=1 +else + winpath() { printf '%s' "$1"; } + IS_WINDOWS=0 +fi + +# --- tool wrappers ----------------------------------------------------- +# Every wrapper points at the rig's OWN kubeconfig. The user's ~/.kube/config +# is never read and never written: k3d is called with +# --kubeconfig-update-default=false, so an existing cluster context on this +# machine keeps whatever it was pointing at. +# +# MSYS_NO_PATHCONV=1 throughout: every path these commands need is passed +# through winpath() deliberately, and MSYS's automatic conversion would corrupt +# the ones that only LOOK like paths — a JSON patch's "/spec/template/…", a +# jsonpath, `sh -c 'cat /proc/1/root/…'`. Off is the predictable setting. +k3d() { MSYS_NO_PATHCONV=1 "$BIN_DIR/k3d" "$@"; } +kubectl() { MSYS_NO_PATHCONV=1 KUBECONFIG="$(winpath "$KUBECONFIG_FILE")" "$BIN_DIR/kubectl" "$@"; } +helm() { MSYS_NO_PATHCONV=1 KUBECONFIG="$(winpath "$KUBECONFIG_FILE")" "$BIN_DIR/helm" "$@"; } + +# curl against the rig's ingress. +# --resolve belt and braces: modern curl maps *.localhost itself, older ones +# do not. +# -k the Keycloak certificate is minted by up.sh and signed by +# nothing. Scoped to this function so it can never leak into a +# call that ought to be verifying a real chain. +# +# In TUNNEL mode neither flag is used, and their absence is the assertion: the +# hostname is looked up in public DNS and the certificate is verified against the +# system trust store. `-k` there would throw away the only thing this mode adds. +# (`RIG_RESOLVE_V4=1` pins both names to a DoH-resolved Cloudflare IPv4 for the +# network described in .claude/CLAUDE.md, which answers AAAA-only with no v6 +# route out. It still verifies the chain — it only chooses the edge.) +rigcurl() { + if [ "$RIG_MODE" = "tunnel" ]; then + curl ${RIG_RESOLVE_ARGS:+$RIG_RESOLVE_ARGS} \ + --max-time "${RIG_CURL_TIMEOUT:-30}" "$@" + return + fi + curl --resolve "$APP_HOST:$HTTP_PORT:127.0.0.1" \ + --resolve "$AUTH_HOST:$HTTPS_PORT:127.0.0.1" \ + --resolve "$APP_HOST:$HTTPS_PORT:127.0.0.1" \ + -k --max-time "${RIG_CURL_TIMEOUT:-30}" "$@" +} + +# Reach the ingress controller DIRECTLY on loopback, presenting whatever Host +# the public name is. Same pod, same release, same nginx — the ONLY difference +# from a tunnelled request is that no `X-Forwarded-Proto` arrives. That makes it +# the positive control for the header chain: if the app answers differently to +# these two, the scheme is being READ rather than assumed. +localcurl() { # <host> <path> [curl args…] + local host="$1" path="$2" + shift 2 + curl --resolve "$host:$HTTP_PORT:127.0.0.1" \ + --max-time "${RIG_CURL_TIMEOUT:-30}" "$@" "http://$host:$HTTP_PORT$path" +} + +cluster_exists() { k3d cluster list -o json 2>/dev/null | grep -q "\"name\":\"$CLUSTER\""; } + +require_cluster() { + cluster_exists || die "cluster '$CLUSTER' does not exist — run scripts/up.sh" + [ -f "$KUBECONFIG_FILE" ] || die "no kubeconfig at $KUBECONFIG_FILE — run scripts/up.sh" +} + +# Read a generated secret back out of .state/secrets.env. +load_secrets() { + [ -f "$SECRETS_ENV" ] || die "no $SECRETS_ENV — run scripts/up.sh" + # shellcheck disable=SC1090 + . "$SECRETS_ENV" +} diff --git a/.claude/skills/k3d-chart-rig/scripts/presign.sh b/.claude/skills/k3d-chart-rig/scripts/presign.sh new file mode 100644 index 00000000..713b719f --- /dev/null +++ b/.claude/skills/k3d-chart-rig/scripts/presign.sh @@ -0,0 +1,174 @@ +#!/usr/bin/env bash +# AWS SigV4, query-string ("presigned") flavour — a line-for-line mirror of +# components/backend/internal/storage/sigv4.go and the PresignPut/PresignGet +# wrappers in client.go. +# +# bash presign.sh put <key> <content-type> <size-bytes> [ttl-seconds] +# bash presign.sh get <key> [ttl-seconds] +# bash presign.sh put --direct <key> … # absolute URL at the store +# +# `put` prints the ROOT-RELATIVE browser URL the backend would hand out — +# `<publicPrefix>/<bucket>/<key>?X-Amz-…` — because that is the value the whole +# /objects contract is about. `--direct` prints the absolute URL at the store +# instead, which is what the leg of the test that bypasses the ingress uses. +# +# WHY THIS EXISTS AT ALL, rather than calling StorageService.CreateUploadUrl: +# the published images (ghcr.io/…/backend-service:latest) predate the storage +# work on this branch — their binary contains no `internal/storage` package and +# no CreateUploadUrl — so there is no deployed handler to ask. Signing here is +# also the more honest test of the claim under examination: the claim is about +# what the INGRESS does to a signed request, and this makes the signature the +# known quantity. +# +# Mirrored exactly, because each of these is a way to be silently wrong: +# * signed headers are host + content-type + content-length for PUT, host +# alone for GET (client.go PresignPut/PresignGet); +# * the payload hash is the literal UNSIGNED-PAYLOAD; +# * `/` is left alone in the path and encoded in the query (uriEncode's +# encodeSlash); +# * the signed Host carries the PORT, and under virtual-hosted style the +# bucket prefix — the same rule as `hackagon.storageSignHost` in the chart. +# +# Limitation, stated rather than discovered: uriEncode here is byte-wise over +# ASCII. Keys with non-ASCII characters would need the multi-byte loop that +# sigv4.go has; the rig only ever signs ASCII keys. +set -euo pipefail +. "$(dirname "${BASH_SOURCE[0]}")/lib.sh" + +ALGORITHM="AWS4-HMAC-SHA256" +TERMINATOR="aws4_request" +S3SERVICE="s3" +UNSIGNED_PAYLOAD="UNSIGNED-PAYLOAD" + +# --- configuration, same shape as config.StorageConfig ----------------- +load_secrets +ACCESS_KEY="${STORAGE_ACCESS_KEY:?}" +SECRET_KEY="${STORAGE_SECRET_KEY:?}" +REGION="${RIG_STORAGE_REGION:-us-east-1}" +BUCKET="$STORE_BUCKET" +PUBLIC_PREFIX="${RIG_PUBLIC_PREFIX:-/objects}" +USE_PATH_STYLE="${RIG_USE_PATH_STYLE:-1}" + +if [ "$USE_PATH_STYLE" = 1 ]; then + SIGN_HOST="$STORE_HOST:$STORE_PORT" +else + SIGN_HOST="$BUCKET.$STORE_HOST:$STORE_PORT" +fi +DIRECT_BASE="http://$SIGN_HOST" + +# --- primitives -------------------------------------------------------- +uriencode() { # value encode_slash(0|1) + local s="$1" slash="${2:-0}" out="" i c + local LC_ALL=C + for ((i = 0; i < ${#s}; i++)); do + c="${s:i:1}" + case "$c" in + [a-zA-Z0-9._~-]) out+="$c" ;; + /) if [ "$slash" = 1 ]; then out+="%2F"; else out+="/"; fi ;; + *) out+="$(printf '%%%02X' "'$c")" ;; + esac + done + printf '%s' "$out" +} + +sha256hex() { printf '%s' "$1" | openssl dgst -sha256 -r | awk '{print $1}'; } +tohex() { printf '%s' "$1" | od -An -tx1 | tr -d ' \n'; } +hmachex() { printf '%s' "$2" | openssl dgst -sha256 -mac HMAC -macopt "hexkey:$1" -r | awk '{print $1}'; } + +signing_key() { # datestamp -> hex + local k + k="$(hmachex "$(tohex "AWS4$SECRET_KEY")" "$1")" + k="$(hmachex "$k" "$REGION")" + k="$(hmachex "$k" "$S3SERVICE")" + hmachex "$k" "$TERMINATOR" +} + +canonical_uri() { # key + local key="$1" + if [ "$USE_PATH_STYLE" = 1 ]; then + if [ -z "$key" ]; then + printf '/%s' "$(uriencode "$BUCKET" 0)" + else printf '/%s/%s' "$(uriencode "$BUCKET" 0)" "$(uriencode "$key" 0)"; fi + else + if [ -z "$key" ]; then + printf '/' + else printf '/%s' "$(uriencode "$key" 0)"; fi + fi +} + +# presign METHOD KEY TTL SIGNED_HEADER_LINES... +# SIGNED_HEADER_LINES are "name:value" with the name already lowercased; `host` +# is added here because SigV4 requires it and because it is the one header a +# proxy in front of the store rewrites. +presign() { + local method="$1" key="$2" ttl="$3" + shift 3 + local amz_date datestamp scope uri raw_query + amz_date="$(date -u +%Y%m%dT%H%M%SZ)" + datestamp="$(date -u +%Y%m%d)" + scope="$datestamp/$REGION/$S3SERVICE/$TERMINATOR" + + local lines=("$@" "host:$SIGN_HOST") + # canonicalHeaders: sorted by name, "name:value\n" each, names ';'-joined. + local sorted names="" block="" + sorted="$(printf '%s\n' "${lines[@]}" | LC_ALL=C sort)" + while IFS= read -r line; do + [ -n "$line" ] || continue + names="${names:+$names;}${line%%:*}" + block+="$line"$'\n' + done <<<"$sorted" + + # canonicalQuery: names sorted, both halves encoded with '/' escaped. The + # five parameters below already sort into this order. + raw_query="X-Amz-Algorithm=$(uriencode "$ALGORITHM" 1)" + raw_query+="&X-Amz-Credential=$(uriencode "$ACCESS_KEY/$scope" 1)" + raw_query+="&X-Amz-Date=$amz_date" + raw_query+="&X-Amz-Expires=$ttl" + raw_query+="&X-Amz-SignedHeaders=$(uriencode "$names" 1)" + + uri="$(canonical_uri "$key")" + + local canonical_request string_to_sign signature + canonical_request="$method"$'\n'"$uri"$'\n'"$raw_query"$'\n'"$block"$'\n'"$names"$'\n'"$UNSIGNED_PAYLOAD" + string_to_sign="$ALGORITHM"$'\n'"$amz_date"$'\n'"$scope"$'\n'"$(sha256hex "$canonical_request")" + signature="$(hmachex "$(signing_key "$datestamp")" "$string_to_sign")" + + PRESIGN_URI="$uri" + PRESIGN_QUERY="$raw_query&X-Amz-Signature=$signature" +} + +# --- CLI --------------------------------------------------------------- +main() { + local direct=0 + local verb="${1:-}" + shift || true + if [ "${1:-}" = "--direct" ]; then + direct=1 + shift + fi + + case "$verb" in + put) + local key="$1" ctype="$2" size="$3" ttl="${4:-300}" + presign PUT "$key" "$ttl" "content-type:$ctype" "content-length:$size" + ;; + get) + local key="$1" ttl="${2:-300}" + presign GET "$key" "$ttl" + ;; + *) + say "usage: presign.sh put [--direct] <key> <content-type> <size> [ttl]" + say " presign.sh get [--direct] <key> [ttl]" + exit 2 + ;; + esac + + if [ "$direct" = 1 ]; then + printf '%s%s?%s\n' "$DIRECT_BASE" "$PRESIGN_URI" "$PRESIGN_QUERY" + else + printf '%s%s?%s\n' "$PUBLIC_PREFIX" "$PRESIGN_URI" "$PRESIGN_QUERY" + fi +} + +# Only run the CLI when executed, so verify.sh can source this for presign(). +if [ "${BASH_SOURCE[0]}" = "$0" ]; then main "$@"; fi diff --git a/.claude/skills/k3d-chart-rig/scripts/tools.sh b/.claude/skills/k3d-chart-rig/scripts/tools.sh new file mode 100644 index 00000000..1d2da176 --- /dev/null +++ b/.claude/skills/k3d-chart-rig/scripts/tools.sh @@ -0,0 +1,97 @@ +#!/usr/bin/env bash +# Put a pinned k3d, helm and kubectl in the skill's own gitignored bin/. +# +# bash scripts/tools.sh # install what is missing +# bash scripts/tools.sh --force # re-download everything +# +# Why download rather than run them in containers: +# +# k3d talks to the Docker socket and writes a kubeconfig. In a container +# it needs the socket bind-mounted AND a shared path for the +# kubeconfig, and on Docker Desktop for Windows the socket path has to +# be spelled `//var/run/docker.sock` to survive MSYS mangling. One +# 25 MB static binary removes all of that. +# helm the previous attempt at this used `docker run alpine/helm`, which +# works but has to reach the apiserver: the published API port is on +# the HOST's loopback, which is not the container's, so it would need +# `--network k3d-<cluster>` plus a rewritten server URL. Same +# conclusion. +# kubectl a Windows kubectl may already be on PATH (Docker Desktop ships one). +# It is still installed here, pinned, so the rig does not depend on +# which version happens to be installed. +# +# A container fallback is documented in SKILL.md for a machine where fetching +# binaries is not allowed. +set -euo pipefail +. "$(dirname "${BASH_SOURCE[0]}")/lib.sh" + +FORCE=0 +[ "${1:-}" = "--force" ] && FORCE=1 + +mkdir -p "$BIN_DIR" + +case "$(uname -s)" in +MINGW* | MSYS* | CYGWIN*) + OS=windows + EXT=.exe + ;; +Darwin) + OS=darwin + EXT= + ;; +*) + OS=linux + EXT= + ;; +esac +case "$(uname -m)" in +x86_64 | amd64) ARCH=amd64 ;; +arm64 | aarch64) ARCH=arm64 ;; +*) die "unsupported architecture $(uname -m)" ;; +esac + +fetch() { # name url + local name="$1" url="$2" dest="$BIN_DIR/$1$EXT" + if [ -x "$dest" ] && [ "$FORCE" -eq 0 ]; then + ok "$name already present ($("$dest" version --short 2>/dev/null | head -1 || echo present))" + return + fi + step "fetching $name" + curl -fsSL --retry 3 -o "$dest.part" "$url" || die "download failed: $url" + mv "$dest.part" "$dest" + chmod +x "$dest" + ok "$name -> $dest" +} + +fetch k3d "https://github.com/k3d-io/k3d/releases/download/$K3D_VERSION/k3d-$OS-$ARCH$EXT" + +if [ ! -x "$BIN_DIR/helm$EXT" ] || [ "$FORCE" -eq 1 ]; then + step "fetching helm $HELM_VERSION" + tmp="$STATE_DIR/helm-dl" + rm -rf "$tmp" + mkdir -p "$tmp" + if [ "$OS" = windows ]; then + curl -fsSL --retry 3 -o "$tmp/helm.zip" \ + "https://get.helm.sh/helm-$HELM_VERSION-windows-$ARCH.zip" || die "helm download failed" + (cd "$tmp" && unzip -q helm.zip) + mv "$tmp/windows-$ARCH/helm.exe" "$BIN_DIR/helm.exe" + else + curl -fsSL --retry 3 -o "$tmp/helm.tgz" \ + "https://get.helm.sh/helm-$HELM_VERSION-$OS-$ARCH.tar.gz" || die "helm download failed" + (cd "$tmp" && tar xzf helm.tgz) + mv "$tmp/$OS-$ARCH/helm" "$BIN_DIR/helm" + chmod +x "$BIN_DIR/helm" + fi + rm -rf "$tmp" + ok "helm -> $BIN_DIR/helm$EXT" +else + ok "helm already present" +fi + +fetch kubectl "https://dl.k8s.io/release/$KUBECTL_VERSION/bin/$OS/$ARCH/kubectl$EXT" + +step "versions" +"$BIN_DIR/k3d$EXT" version | sed 's/^/ /' >&2 +"$BIN_DIR/helm$EXT" version --short | sed 's/^/ helm /' >&2 +"$BIN_DIR/kubectl$EXT" version --client=true -o yaml 2>/dev/null | + awk '/gitVersion/{print " kubectl " $2; exit}' >&2 || true diff --git a/.claude/skills/k3d-chart-rig/scripts/tunnel.sh b/.claude/skills/k3d-chart-rig/scripts/tunnel.sh new file mode 100644 index 00000000..6316154f --- /dev/null +++ b/.claude/skills/k3d-chart-rig/scripts/tunnel.sh @@ -0,0 +1,351 @@ +#!/usr/bin/env bash +# Publish the k3d cluster on REAL hostnames with a REAL certificate, through a +# named Cloudflare tunnel — then put it back. +# +# bash scripts/tunnel.sh up # switch the release to https + start the tunnel +# bash scripts/tunnel.sh down # switch back to *.localhost, stop the tunnel +# bash scripts/tunnel.sh destroy # …and give the hostnames up (tunnel + DNS) +# bash scripts/tunnel.sh status # what is running, and what the DNS says +# +# WHY THIS EXISTS. The rig's default mode serves the app over plain http on +# `*.localhost`, on purpose: adapter-node's unconfigured guess for the public +# scheme is the literal string "https", so an https-everywhere rig agrees with a +# wrong guess by accident. That asymmetry found `frontend.protocolHeader`. This +# is the other half — the shape a deployment actually has, and the only one in +# which `__Secure-` cookies, a verified chain and a browser's real HTTPS rules +# are exercised at all. +# +# browser ──https──▶ Cloudflare edge ──tunnel──▶ cloudflared ──http──▶ +# ingress-nginx :8090 ──▶ frontend / Keycloak / the store +# +# TLS terminates at the edge. The origin is plain http and cloudflared is what +# adds `X-Forwarded-Proto: https`, which is exactly the header +# `frontend.protocolHeader` reads. Nothing in the cluster holds a certificate. +# +# WHAT IT DOES NOT TOUCH: the dev stack, the three tunnels that serve it +# (`cf-named-hackagon`, `-plausible`, `-openreplay`), and the rig's own +# `*.localhost` mode — `down` restores that exactly, and `verify.sh` is the +# check that it did. +set -euo pipefail +. "$(dirname "${BASH_SOURCE[0]}")/lib.sh" +# shellcheck source=../../lib/cf-named-tunnel.sh +. "$REPO_ROOT/.claude/skills/lib/cf-named-tunnel.sh" + +# ── names ────────────────────────────────────────────────────────────── +# One tunnel, two hostnames, because the chart routes the app and Keycloak by +# HOST on two separate Ingresses. Path-muxing them onto one name (which is what +# the dev stack's caddy does) would need Keycloak on a relative path and would +# skip the chart's own keycloak Ingress — the object this rig found a bug in. +# +# BOTH NAMES ARE ONE LABEL DEEP, and that is not a style choice. Cloudflare's +# free Universal SSL covers the apex and one label and nothing below it: +# measured against the edge before anything was created, `auth.k3d-sdsc-…` (two +# labels) answers a TLS handshake with alert 40 while `k3d-auth-sdsc-…` (one) +# gets the zone's real certificate. `k3d-` says which cluster, and says it is +# the throwaway one. +TUNNEL_NAME="${RIG_TUNNEL_NAME:-hackagon-k3d}" +TUNNEL_NETWORK="k3d-$CLUSTER" +TUNNEL_ORIGIN="http://k3d-$CLUSTER-serverlb:$HTTP_PORT" +TUNNEL_VALUES="$STATE_DIR/values.tunnel.yaml" + +load_hostnames() { + cf_load || { + say "" + bad "no Cloudflare credentials — this mode needs a zone you own." + cf_explain_unconfigured >&2 + exit 1 + } + : "${CLOUDFLARE_ZONE:?}" + # Read from the same gitignored .env the other three rigs read, so a + # hostname lives in exactly one place and NO TRACKED FILE NAMES ONE. The + # fallbacks are derived from the configured zone rather than written out, + # for that reason: `k3d.<zone>` says which cluster without committing + # anybody's domain to this repository. + TUNNEL_APP_HOST="${K3D_HOSTNAME:-k3d.$CLOUDFLARE_ZONE}" + TUNNEL_AUTH_HOST="${K3D_AUTH_HOSTNAME:-k3d-auth.$CLOUDFLARE_ZONE}" + TUNNEL_APP_URL="https://$TUNNEL_APP_HOST" + TUNNEL_AUTH_URL="https://$TUNNEL_AUTH_HOST" +} + +# ── Keycloak's redirect URIs ─────────────────────────────────────────── +# The realm is imported ONCE, when Keycloak first starts against an empty +# database, so a later `helm upgrade` cannot change the client's redirect URIs — +# the ConfigMap it renders is only ever read by an import that has already +# happened. Switching modes therefore has to talk to the running Keycloak. +# +# Both origins are allowed while the tunnel is up. A single-origin list would +# make the switch a cliff: the first person to sign in on the OTHER URL gets +# "Invalid parameter: redirect_uri" from Keycloak, which names nothing anyone +# can act on. +# +# The script goes to the pod on STDIN, never in argv. Two reasons and both have +# bitten this repo: the values are JSON, so they carry double quotes that a +# `sh -c "…$var…"` on this side would end the string on; and the admin password +# would otherwise be visible in `ps` on this machine for the life of the call +# (cf-api.sh feeds curl its bearer token the same way, for the same reason). +kc_sh() { # remote sh script on stdin + kubectl -n "$NAMESPACE" exec -i "deploy/$RELEASE-keycloak" -c keycloak -- sh -s +} + +# `'` inside a single-quoted shell word, spelled the only way that works. +sq() { printf "'%s'" "$(printf '%s' "$1" | sed "s/'/'\\\\''/g")"; } + +kcadm_login() { # emits the login line for the remote script + local u p + u="$(kubectl -n "$NAMESPACE" get secret "$RELEASE-keycloak-admin" -o jsonpath='{.data.admin-username}' | base64 -d)" + p="$(kubectl -n "$NAMESPACE" get secret "$RELEASE-keycloak-admin" -o jsonpath='{.data.admin-password}' | base64 -d)" + # --config /tmp/…: $HOME here is /opt/keycloak and kcadm's default config + # path under it is not reliably writable by uid 1000. + printf 'set -e\nKC=/opt/keycloak/bin/kcadm.sh\n' + printf '$KC config credentials --config /tmp/kcadm.json --server http://localhost:8080 --realm master --user %s --password %s >/dev/null 2>&1\n' \ + "$(sq "$u")" "$(sq "$p")" +} + +set_redirect_uris() { # <origin…> + local id uris="" origins="" o + id="$({ + kcadm_login + printf '$KC get clients -r hackagon -q clientId=hackagon-frontend --fields id --format csv --noquotes --config /tmp/kcadm.json\n' + } | kc_sh | tr -d '\r' | grep -E '^[0-9a-f-]{36}$' | head -1)" + [ -n "$id" ] || die "could not find the hackagon-frontend client in the realm" + for o in "$@"; do + uris="${uris:+$uris,}\"$o/*\"" + origins="${origins:+$origins,}\"$o\"" + done + { + kcadm_login + printf '$KC update clients/%s -r hackagon -s %s -s %s --config /tmp/kcadm.json\n' \ + "$id" "$(sq "redirectUris=[$uris]")" "$(sq "webOrigins=[$origins]")" + } | kc_sh >/dev/null + ok "Keycloak accepts redirects to: $*" +} + +# ── the controller has to pass the scheme through ────────────────────── +# ingress-nginx overwrites X-Forwarded-Proto with the scheme of the connection +# IT terminated unless `use-forwarded-headers` is on. Without it the frontend is +# told `http` on a request the browser made over https and login dies with every +# page answering 200 — see the comment in manifests/ingress-nginx-values.yaml. +# +# Applied by re-running the same `helm upgrade` up.sh uses, so the values file +# stays the single description of this controller. Skipped when it is already +# set, because that upgrade costs ~40 s. +ensure_forwarded_headers() { + local cur + cur="$(kubectl -n ingress-nginx get configmap ingress-nginx-controller \ + -o jsonpath='{.data.use-forwarded-headers}' 2>/dev/null || true)" + if [ "$cur" = "true" ]; then + ok "ingress-nginx already trusts X-Forwarded-Proto" + return 0 + fi + step "ingress-nginx: trusting X-Forwarded-Proto" + helm upgrade --install ingress-nginx ingress-nginx/ingress-nginx \ + --version "$INGRESS_NGINX_CHART_VERSION" \ + --namespace ingress-nginx --create-namespace \ + -f "$(winpath "$MANIFEST_DIR/ingress-nginx-values.yaml")" \ + --wait --timeout 10m >/dev/null + ok 'use-forwarded-headers=true (absent header still falls back to $scheme)' +} + +# ── the values overlay ───────────────────────────────────────────────── +write_values() { + cat >"$TUNNEL_VALUES" <<YAML +# GENERATED by scripts/tunnel.sh — gitignored, and it carries the public +# hostname, which is why it is not a tracked file. Layered ON TOP of +# helm-chart/values.k3d.yaml, so everything not mentioned here is still the +# rig's normal test configuration. +# +# Read it as the diff between "a laptop on *.localhost" and "the same cluster +# behind a TLS-terminating proxy" — which is the diff between this rig and a +# deployment. + +# The real zone. Only \`hackagon.frontendHost\` still derives from it (the realm +# ConfigMap), and up.sh has already rewritten the realm to the URL in use, so +# every host below is stated outright rather than derived. +baseDomain: "$CLOUDFLARE_ZONE" + +frontend: + config: + cookies: + # TRUE, and this is the property the localhost mode cannot test. Auth.js + # names the session cookie \`__Secure-authjs.session-token\` when it + # believes the connection is secure, and a browser refuses to store a + # \`__Secure-\` cookie that did not arrive over https. On a real + # certificate it does; that is the whole point of this mode. + useSecure: true + oidc: + issuer: "$TUNNEL_AUTH_URL/realms/hackagon" + # EMPTIED ON PURPOSE — values.k3d.yaml sets NODE_TLS_REJECT_UNAUTHORIZED=0 so + # the frontend will accept the self-signed certificate up.sh mints for + # Keycloak. There is no self-signed certificate on this path: the frontend pod + # fetches the discovery document from the same public URL the browser uses, + # over Cloudflare's certificate, verified by node's own trust store. A list + # value replaces rather than merges, so [] really does remove it. + extraEnv: [] + ingress: + hosts: + - host: "$TUNNEL_APP_HOST" + paths: + - path: / + pathType: Prefix + # No TLS block anywhere in this file. Cloudflare holds the certificate; the + # cluster speaks plain http and would only be able to offer a self-signed + # one. An empty list also keeps ingress-nginx's ssl-redirect off, which + # matters: with a TLS block it would 308 the plain-http request the tunnel + # makes, and the browser would bounce between the edge and the origin. + tls: [] + +backend: + config: + oidc: + # issuerurl is COMPARED against the \`iss\` claim, so it is the public URL. + # jwksurl is FETCHED and stays on the in-cluster short path — inherited + # from values.k3d.yaml, unchanged. + issuerurl: "$TUNNEL_AUTH_URL/realms/hackagon" + +keycloak: + hostname: + # No port: this one really is on 443. Keycloak 26 derives the public scheme + # from this URL, and \`proxy.headers: xforwarded\` (values.k3d.yaml) makes it + # believe the X-Forwarded-* headers cloudflared and nginx put on the request. + hostname: "$TUNNEL_AUTH_URL" + ingress: + # The override this mode needed the chart to grow. \`auth.{baseDomain}\` would + # be \`auth.$CLOUDFLARE_ZONE\` — a name that belongs to the zone rather than to + # this throwaway cluster — and any \`k3d-\`-prefixed baseDomain would put + # Keycloak two labels deep, where Universal SSL has no certificate for it. + host: "$TUNNEL_AUTH_HOST" + # Explicit null, not {}: helm deep-merges values files and an empty map + # merges as "no change", so values.yaml's secret name would survive. + tlsSecretName: null +YAML +} + +install() { # <values-overlay…> + local -a extra=() + [ -f "$REALM_FILE" ] && extra=(--set-file "realmJson=$(winpath "$REALM_FILE")") + local -a files=(-f "$(winpath "$CHART_DIR/values.yaml")" + -f "$(winpath "$CHART_DIR/values.k3d.yaml")") + local f + for f in "$@"; do files+=(-f "$(winpath "$f")"); done + files+=(-f "$(winpath "$GEN_VALUES")") + helm upgrade --install "$RELEASE" "$(winpath "$CHART_DIR")" \ + --namespace "$NAMESPACE" "${files[@]}" "${extra[@]}" --timeout 15m >&2 +} + +wait_rollout() { + local d + for d in keycloak frontend backend; do + kubectl -n "$NAMESPACE" rollout status "deployment/$RELEASE-$d" --timeout=300s >/dev/null && + ok "$d rolled out" || bad "$d did NOT roll out" + done +} + +# ===================================================================== +cmd_up() { + require_cluster + load_secrets + load_hostnames + + step "hostnames" + say " app $TUNNEL_APP_URL" + say " keycloak $TUNNEL_AUTH_URL" + say " origin $TUNNEL_ORIGIN on docker network $TUNNEL_NETWORK" + + ensure_forwarded_headers + + step "release → public https" + write_values + install "$TUNNEL_VALUES" + wait_rollout + + step "Keycloak redirect URIs" + set_redirect_uris "$TUNNEL_APP_URL" "$LOCAL_APP_URL" + + step "the tunnel" + cfn_up "$TUNNEL_NAME" "$TUNNEL_APP_HOST" "$TUNNEL_NETWORK" "$TUNNEL_ORIGIN" \ + "$TUNNEL_AUTH_HOST" + + # Written LAST. Every script here reads this file to decide which URLs it is + # talking about, so writing it before the release is actually serving them + # would make a failed switch look like a successful one. + cat >"$MODE_ENV" <<EOF +# Written by scripts/tunnel.sh — the mode the RELEASE is installed in. +RIG_MODE=tunnel +RIG_APP_HOST=$TUNNEL_APP_HOST +RIG_AUTH_HOST=$TUNNEL_AUTH_HOST +EOF + + step "up" + say " app $TUNNEL_APP_URL" + say " keycloak $TUNNEL_AUTH_URL" + say " uploads $TUNNEL_APP_URL/objects/$STORE_BUCKET/…" + say " loopback $LOCAL_APP_URL (still served — no X-Forwarded-Proto, see verify.sh)" + say "" + say " verify: bash $HERE/verify.sh" + say " back: bash $HERE/tunnel.sh down" + say " give up: bash $HERE/tunnel.sh destroy (deletes the DNS records too)" +} + +cmd_down() { + load_hostnames + step "stopping the tunnel" + cfn_stop "$TUNNEL_NAME" + + if cluster_exists && [ -f "$KUBECONFIG_FILE" ]; then + load_secrets + step "release → *.localhost" + install + wait_rollout + step "Keycloak redirect URIs" + set_redirect_uris "$LOCAL_APP_URL" + else + say " (no cluster — nothing to put back)" + fi + rm -f "$MODE_ENV" + + step "what is left on Cloudflare" + say " The tunnel '$TUNNEL_NAME' and these DNS records still EXIST:" + say " $TUNNEL_APP_HOST" + say " $TUNNEL_AUTH_HOST" + say " Nothing serves them now, so they answer Cloudflare's 1033 (\"tunnel not" + say ' found") — which is honest, and is why the container is stopped rather' + say " than left pointing at a cluster that may be gone." + say "" + say " Give the names up: bash $HERE/tunnel.sh destroy" +} + +cmd_destroy() { + load_hostnames + step "destroying the tunnel and its DNS records" + cfn_destroy "$TUNNEL_NAME" "$TUNNEL_APP_HOST" "$TUNNEL_AUTH_HOST" + rm -f "$MODE_ENV" "$TUNNEL_VALUES" + ok "tunnel, credentials file and both CNAMEs are gone" + say "" + say " The RELEASE is still configured for those hostnames if it was up when" + say " this ran. Put it back with: bash $HERE/tunnel.sh down" +} + +cmd_status() { + load_hostnames + say " mode $RIG_MODE (from $MODE_ENV)" + say " app $TUNNEL_APP_URL" + say " keycloak $TUNNEL_AUTH_URL" + if cfn_running "$TUNNEL_NAME"; then + ok "cloudflared $(cfn_container "$TUNNEL_NAME") is running" + else + bad "cloudflared $(cfn_container "$TUNNEL_NAME") is NOT running" + fi + local h + for h in "$TUNNEL_APP_HOST" "$TUNNEL_AUTH_HOST"; do + say " https://$h -> $(cfn_http_code "https://$h/")" + done +} + +case "${1:-}" in +up) cmd_up ;; +down) cmd_down ;; +destroy) cmd_destroy ;; +status) cmd_status ;; +-h | --help | "") sed -n '2,40p' "$0" | sed 's/^# \{0,1\}//' ;; +*) die "unknown command '$1' (up | down | destroy | status)" ;; +esac diff --git a/.claude/skills/k3d-chart-rig/scripts/up.sh b/.claude/skills/k3d-chart-rig/scripts/up.sh new file mode 100644 index 00000000..df4315c5 --- /dev/null +++ b/.claude/skills/k3d-chart-rig/scripts/up.sh @@ -0,0 +1,273 @@ +#!/usr/bin/env bash +# One command from nothing to an installed, reachable Hackagon release on a +# throwaway Kubernetes cluster. +# +# bash scripts/up.sh # cluster + ingress-nginx + store + release +# bash scripts/up.sh --recreate # delete the cluster first +# bash scripts/up.sh --no-realm # skip the Keycloak realm import (see below) +# +# THE REALM IMPORT CARRIES DEV ACCOUNTS. tools/configs/keycloak/realm-hackagon.json +# is the development export: alice, bob, charles and hackagon-admin, all with +# the password `aliceandbob`, all with `emailVerified` and no password policy. +# It is imported here because the login round-trip in verify.sh has to sign +# SOMEBODY in, and inventing a second realm would test a realm nobody deploys. +# It is why this rig is opt-in, why it binds only 127.0.0.1, and why nothing it +# writes may ever be copied into a deployment. `--no-realm` leaves Keycloak with +# an empty realm; the login check then self-reports as skipped. +set -euo pipefail +. "$(dirname "${BASH_SOURCE[0]}")/lib.sh" + +RECREATE=0 +WITH_REALM=1 +for arg in "$@"; do + case "$arg" in + --recreate) RECREATE=1 ;; + --no-realm) WITH_REALM=0 ;; + -h | --help) + sed -n '2,30p' "$0" + exit 0 + ;; + *) die "unknown flag $arg" ;; + esac +done + +# ===================================================================== +step "toolchain" +# ===================================================================== +bash "$HERE/tools.sh" >/dev/null 2>&1 || bash "$HERE/tools.sh" +ok "k3d $K3D_VERSION · helm $HELM_VERSION · kubectl $KUBECTL_VERSION in bin/" + +# ===================================================================== +step "cluster" +# ===================================================================== +if [ "$RECREATE" = 1 ] && cluster_exists; then + say " deleting existing cluster" + k3d cluster delete "$CLUSTER" >/dev/null +fi + +if cluster_exists; then + ok "cluster '$CLUSTER' already exists" + k3d cluster start "$CLUSTER" >/dev/null 2>&1 || true +else + # --disable=traefik is the whole reason this is not `k3d cluster create` with + # defaults. k3d bundles Traefik, and every annotation the chart puts on its + # /objects Ingress is ingress-nginx's; the Host rewrite in particular is not + # expressible in a core Ingress object on Traefik at all. With the bundled + # controller the one thing worth testing here cannot work. + # + # metrics-server is disabled purely to save ~150 MB of RAM; nothing reads it. + k3d cluster create "$CLUSTER" \ + --servers 1 --agents 0 \ + --image "$K3S_IMAGE" \ + --api-port "127.0.0.1:$API_PORT" \ + --port "$HTTP_PORT:$HTTP_PORT@loadbalancer" \ + --port "$HTTPS_PORT:$HTTPS_PORT@loadbalancer" \ + --k3s-arg "--disable=traefik@server:*" \ + --k3s-arg "--disable=metrics-server@server:*" \ + --kubeconfig-update-default=false \ + --kubeconfig-switch-context=false \ + --wait --timeout 300s >&2 + ok "cluster created" +fi + +# The rig's own kubeconfig, never ~/.kube/config: this machine has other +# clusters and other contexts, and a rig that repoints `kubectl` for everything +# else is a rig that breaks someone's day. +k3d kubeconfig get "$CLUSTER" >"$KUBECONFIG_FILE" +ok "kubeconfig -> $KUBECONFIG_FILE" + +# ===================================================================== +step "ingress-nginx" +# ===================================================================== +helm repo add ingress-nginx https://kubernetes.github.io/ingress-nginx >/dev/null 2>&1 || true +helm repo update ingress-nginx >/dev/null 2>&1 || true +helm upgrade --install ingress-nginx ingress-nginx/ingress-nginx \ + --version "$INGRESS_NGINX_CHART_VERSION" \ + --namespace ingress-nginx --create-namespace \ + -f "$(winpath "$MANIFEST_DIR/ingress-nginx-values.yaml")" \ + --wait --timeout 10m >/dev/null +INGRESS_IP="$(kubectl -n ingress-nginx get svc ingress-nginx-controller -o jsonpath='{.spec.clusterIP}')" +ok "ingress-nginx $INGRESS_NGINX_CHART_VERSION on :$HTTP_PORT (ClusterIP $INGRESS_IP)" + +# ===================================================================== +step "in-cluster DNS for the public hostnames" +# ===================================================================== +# The frontend pod fetches the OIDC discovery document from the SAME string the +# browser uses — Auth.js rejects a document whose `issuer` differs from the +# configured one, so there is no second, internal URL to fall back on. Inside +# the cluster `auth.hackagon.localhost` would otherwise resolve to nothing (or, +# with a nip.io-style name, to the pod's own loopback), so CoreDNS is taught to +# answer both names with the ingress controller's ClusterIP. +# +# `coredns-custom` is k3s's supported extension point: its Corefile ends with +# `import /etc/coredns/custom/*.override` INSIDE the `.:53` block, and the +# Deployment mounts the ConfigMap optionally. Editing the Corefile directly +# would work until k3s re-applied its bundled manifest. +# +# `rewrite`, not `hosts`. Two reasons, one of them measured: k3s's Corefile +# already contains `hosts /etc/coredns/NodeHosts`, and a second stanza makes +# CoreDNS refuse to start — "plugin/hosts: this plugin can only be used once +# per Server Block", CrashLoopBackOff, whole cluster's DNS down. `rewrite` is +# explicitly multi-instance. It is also the better answer: it names the ingress +# controller's SERVICE, so reinstalling ingress-nginx (new ClusterIP) does not +# leave a stale address baked into DNS. Plugin order in CoreDNS comes from the +# registry, not from the file, so an import at the end of the block still runs +# `rewrite` before `kubernetes`. +kubectl -n kube-system create configmap coredns-custom \ + --from-literal="hackagon.override=rewrite name $APP_HOST ingress-nginx-controller.ingress-nginx.svc.cluster.local +rewrite name $AUTH_HOST ingress-nginx-controller.ingress-nginx.svc.cluster.local" \ + --dry-run=client -o yaml | kubectl apply -f - >/dev/null +kubectl -n kube-system rollout restart deployment coredns >/dev/null +kubectl -n kube-system rollout status deployment coredns --timeout=120s >/dev/null +ok "$APP_HOST, $AUTH_HOST -> $INGRESS_IP inside the cluster" + +# ===================================================================== +step "generated credentials" +# ===================================================================== +# Never in a tracked file, and never in values.yaml either — `helm get values` +# would print them back out. Hex only: the backend's env loader splits any value +# containing a SPACE into a list, so a credential with one would not arrive +# intact (helm-chart/values.yaml says so about the storage keys). +if [ ! -f "$SECRETS_ENV" ]; then + umask 077 + cat >"$SECRETS_ENV" <<EOF +# Generated by scripts/up.sh — gitignored, throwaway, cluster-local. +POSTGRES_PASSWORD=$(openssl rand -hex 16) +KEYCLOAK_DB_PASSWORD=$(openssl rand -hex 16) +HACKAGON_DB_PASSWORD=$(openssl rand -hex 16) +STORAGE_ACCESS_KEY=rig$(openssl rand -hex 8) +STORAGE_SECRET_KEY=$(openssl rand -hex 24) +FRONTEND_CLIENT_SECRET=$(openssl rand -hex 24) +FRONTEND_AUTH_SECRET=$(openssl rand -hex 32) +EOF + ok "minted $SECRETS_ENV" +else + ok "reusing $SECRETS_ENV" +fi +load_secrets + +# ===================================================================== +step "object store (test-only, not part of the chart)" +# ===================================================================== +kubectl create namespace "$STORE_NS" --dry-run=client -o yaml | kubectl apply -f - >/dev/null +kubectl -n "$STORE_NS" create secret generic store-credentials \ + --from-literal=accessKey="$STORAGE_ACCESS_KEY" \ + --from-literal=secretKey="$STORAGE_SECRET_KEY" \ + --dry-run=client -o yaml | kubectl apply -f - >/dev/null +sed "s|MINIO_IMAGE_PLACEHOLDER|$MINIO_IMAGE|" "$MANIFEST_DIR/store.yaml" | kubectl apply -f - >/dev/null +kubectl -n "$STORE_NS" rollout status deployment store --timeout=300s >/dev/null +kubectl -n "$STORE_NS" delete job store-init --ignore-not-found >/dev/null +sed -e "s|MC_IMAGE_PLACEHOLDER|$MC_IMAGE|" -e "s|BUCKET_PLACEHOLDER|$STORE_BUCKET|g" \ + "$MANIFEST_DIR/store-init-job.yaml" | kubectl apply -f - >/dev/null +kubectl -n "$STORE_NS" wait --for=condition=complete job/store-init --timeout=180s >/dev/null +ok "store at $STORE_ENDPOINT, bucket '$STORE_BUCKET', hackathons|users|site public-read" + +# ===================================================================== +step "release namespace and secrets" +# ===================================================================== +kubectl create namespace "$NAMESPACE" --dry-run=client -o yaml | kubectl apply -f - >/dev/null +# The chart REFUSES to take storage credentials from values (see +# backend-deployment.yaml's `required`), which is the right call — this is the +# Secret it insists on. +kubectl -n "$NAMESPACE" create secret generic hackagon-storage \ + --from-literal=accessKey="$STORAGE_ACCESS_KEY" \ + --from-literal=secretKey="$STORAGE_SECRET_KEY" \ + --dry-run=client -o yaml | kubectl apply -f - >/dev/null +ok "Secret hackagon-storage created from generated values" + +# --- the Keycloak certificate ------------------------------------------ +# Self-signed, 90 days, one hostname. It exists because Keycloak's federation +# cookies are `SameSite=None`, which forces `Secure`, which means no browser and +# no HTTP client will keep them over plain http — so the login round-trip is +# unreachable without TLS somewhere. See lib.sh for the measurement. +# +# The Secret name is the chart's OWN DEFAULT (`{releaseName}-keycloak-tls`), so +# this also exercises the default rather than routing around it. +if [ ! -f "$TLS_CERT" ] || ! openssl x509 -in "$TLS_CERT" -checkend 604800 >/dev/null 2>&1; then + openssl req -x509 -newkey rsa:2048 -sha256 -days 90 -nodes \ + -keyout "$TLS_KEY" -out "$TLS_CERT" \ + -subj "//CN=$AUTH_HOST" \ + -addext "subjectAltName=DNS:$AUTH_HOST" >/dev/null 2>&1 || + die "openssl could not mint the Keycloak certificate" + ok "minted a 90-day self-signed certificate for $AUTH_HOST" +else + ok "reusing the certificate for $AUTH_HOST" +fi +# winpath both: the kubectl wrapper runs with MSYS_NO_PATHCONV=1, so a +# /c/Users/… path reaches kubectl.exe verbatim and it cannot open it. +kubectl -n "$NAMESPACE" create secret tls "$RELEASE-keycloak-tls" \ + --cert="$(winpath "$TLS_CERT")" --key="$(winpath "$TLS_KEY")" \ + --dry-run=client -o yaml | kubectl apply -f - >/dev/null + +cat >"$GEN_VALUES" <<EOF +# Generated by scripts/up.sh. Gitignored: every value below is a credential. +postgresql: + auth: + postgresPassword: "$POSTGRES_PASSWORD" +backend: + config: + database: + postgresPassword: "$HACKAGON_DB_PASSWORD" +keycloak: + database: + external: + password: "$KEYCLOAK_DB_PASSWORD" +frontendSecrets: + clientSecret: "$FRONTEND_CLIENT_SECRET" + authSecret: "$FRONTEND_AUTH_SECRET" +EOF + +# ===================================================================== +step "realm" +# ===================================================================== +REALM_ARGS=() +if [ "$WITH_REALM" = 1 ]; then + SRC_REALM="$REPO_ROOT/tools/configs/keycloak/realm-hackagon.json" + [ -f "$SRC_REALM" ] || die "no realm export at $SRC_REALM" + # Rewritten HERE rather than by the chart. keycloak-realm-configmap.yaml + # rewrites `http://localhost:8081` to `https://app.{baseDomain}` — scheme + # hard-coded, port dropped — which is right for a deployment on :443 and + # wrong for anything else, this rig included. Doing the substitution first + # makes the chart's own replaces no-ops rather than fighting them. + sed "s|http://localhost:8081|$APP_URL|g" "$SRC_REALM" >"$REALM_FILE" + REALM_ARGS=(--set-file "realmJson=$(winpath "$REALM_FILE")") + say " ⚠ importing the DEVELOPMENT realm: alice, bob, charles, hackagon-admin" + say " ⚠ all with the password 'aliceandbob'. Local, opt-in, throwaway only." + ok "realm redirect URIs rewritten to $APP_URL" +else + ok "no realm imported (--no-realm)" +fi + +# ===================================================================== +step "helm install" +# ===================================================================== +helm upgrade --install "$RELEASE" "$(winpath "$CHART_DIR")" \ + --namespace "$NAMESPACE" \ + -f "$(winpath "$CHART_DIR/values.yaml")" \ + -f "$(winpath "$CHART_DIR/values.k3d.yaml")" \ + -f "$(winpath "$GEN_VALUES")" \ + "${REALM_ARGS[@]}" \ + --timeout 15m >&2 +ok "release '$RELEASE' installed in namespace '$NAMESPACE'" + +step "waiting for workloads" +for d in postgresql keycloak backend frontend; do + name="$RELEASE-$d" + kind=deployment + kubectl -n "$NAMESPACE" get statefulset "$name" >/dev/null 2>&1 && kind=statefulset + if kubectl -n "$NAMESPACE" rollout status "$kind/$name" --timeout=420s >/dev/null 2>&1; then + ok "$kind/$name ready" + else + bad "$kind/$name did NOT become ready" + kubectl -n "$NAMESPACE" get pods + fi +done + +step "up" +say " frontend $APP_URL" +say " keycloak $AUTH_URL" +say " uploads $APP_URL/objects/$STORE_BUCKET/…" +say "" +say " kubectl: KUBECONFIG=$KUBECONFIG_FILE $BIN_DIR/kubectl get pods -n $NAMESPACE" +say " verify: bash $HERE/verify.sh" +say " down: bash $HERE/down.sh" diff --git a/.claude/skills/k3d-chart-rig/scripts/verify.sh b/.claude/skills/k3d-chart-rig/scripts/verify.sh new file mode 100644 index 00000000..e227f30c --- /dev/null +++ b/.claude/skills/k3d-chart-rig/scripts/verify.sh @@ -0,0 +1,484 @@ +#!/usr/bin/env bash +# Turn the chart's three arguable claims into observations, and prove login. +# +# bash scripts/verify.sh # everything +# bash scripts/verify.sh --quick # skip the checks that reconfigure the +# # cluster: the two ingress-controller +# # rollouts (~30 s each) and the config- +# # reload upgrade in step 6 (two rollouts). +# # The render-level half of step 6 still runs. +# +# "The pods are Running" is not in here anywhere. Pods can be Running and the +# product unusable — this rig was written after exactly that: every page +# answered 200 while login was dead, and the app's own logs said sign-in had +# succeeded. +# +# Every negative assertion has a positive control. A 403 that would also be a +# 403 if the object never existed, or a "no replay: key" that would also hold if +# the file were empty, proves nothing — and this repository has a written record +# of suites staying green while testing nothing. +set -euo pipefail +. "$(dirname "${BASH_SOURCE[0]}")/lib.sh" +# shellcheck source=presign.sh +. "$HERE/presign.sh" + +QUICK=0 +[ "${1:-}" = "--quick" ] && QUICK=1 + +require_cluster +load_secrets + +PASS=0 +FAIL=0 +check() { # description expected actual + if [ "$2" = "$3" ]; then + ok "$1" + PASS=$((PASS + 1)) + else + bad "$1" + say " expected: $2" + say " actual: $3" + FAIL=$((FAIL + 1)) + fi +} +check_contains() { # description needle haystack + case "$3" in + *"$2"*) + ok "$1" + PASS=$((PASS + 1)) + ;; + *) + bad "$1" + say " expected to contain: $2" + say " got: $(printf '%.200s' "$3")" + FAIL=$((FAIL + 1)) + ;; + esac +} +check_lacks() { # description needle haystack + case "$3" in + *"$2"*) + bad "$1" + say " found: $2" + FAIL=$((FAIL + 1)) + ;; + *) + ok "$1" + PASS=$((PASS + 1)) + ;; + esac +} + +CONTROL_HOST="control.$BASE_DOMAIN" +STAMP="$(date +%s)" + +# Read the config file a RUNNING container has open — not `helm template`, not +# the ConfigMap. Both images are distroless (no shell, no tar), so `kubectl +# exec` and `kubectl cp` are both out; an ephemeral debug container sharing the +# process namespace reads the real mount through /proc/1/root. +# +# `--profile=sysadmin` is needed, not decoration: with the default profile the +# read comes back "cat: can't open '/proc/1/root/…': Permission denied" even +# though both containers run as uid 0. That failure is why EVERY caller asserts +# a positive control on the result — the absence assertions in step 5 all +# passed against that error message once, and would have gone on passing. +# +# The Running field-selector matters after a rollout: a Terminating pod still +# matches the label and can sort first, and reading the OLD pod is exactly the +# mistake step 6 exists to detect. +read_live_config() { # component tag -> the file's text on stdout + local comp="$1" tag="$2" pod dbg out="" + pod="$(kubectl -n "$NAMESPACE" get pod -l "app.kubernetes.io/component=$comp" \ + --field-selector=status.phase=Running \ + -o jsonpath='{.items[0].metadata.name}')" + dbg="rigread$tag" + kubectl -n "$NAMESPACE" debug "$pod" --image=busybox:1.36 --target="$comp" \ + --profile=sysadmin -c "$dbg" -q \ + -- sh -c 'cat /proc/1/root/etc/hackagon/config.yaml' >/dev/null 2>&1 || true + for _ in $(seq 40); do + out="$(kubectl -n "$NAMESPACE" logs "$pod" -c "$dbg" 2>/dev/null || true)" + [ -n "$out" ] && break + sleep 2 + done + printf '%s' "$out" +} + +# ===================================================================== +step "0 · the release is installed and serving" +# ===================================================================== +check "helm reports the release deployed" "deployed" \ + "$(helm -n "$NAMESPACE" status "$RELEASE" -o json 2>/dev/null | grep -o '"status":"[a-z]*"' | head -1 | sed 's/.*:"//;s/"//')" + +for c in frontend backend keycloak; do + ready="$(kubectl -n "$NAMESPACE" get deployment "$RELEASE-$c" -o jsonpath='{.status.readyReplicas}' 2>/dev/null)" + check "$c has a ready replica" "1" "${ready:-0}" +done + +check "GET / is served by the frontend" "200" \ + "$(rigcurl -s -o /dev/null -w '%{http_code}' "$APP_URL/")" + +# ===================================================================== +step "1 · a presigned upload survives the /objects Ingress" +# ===================================================================== +# The claim: the `upstream-vhost` annotation rewrites Host to the name SigV4 was +# computed over, so a presigned PUT sent to the APP's origin authenticates at +# the STORE. Three legs, because the middle one is what makes the first mean +# anything. + +KEY="hackathons/rig/$STAMP.txt" +BODY="$STATE_DIR/upload.txt" +printf 'uploaded through the ingress at %s\n' "$STAMP" >"$BODY" +SIZE="$(wc -c <"$BODY" | tr -d ' ')" + +check "the chart wrote the Host rewrite" "$STORE_HOST:$STORE_PORT" \ + "$(kubectl -n "$NAMESPACE" get ingress "$RELEASE-objects" \ + -o jsonpath='{.metadata.annotations.nginx\.ingress\.kubernetes\.io/upstream-vhost}')" + +URL="$(bash "$HERE/presign.sh" put "$KEY" "text/plain" "$SIZE")" +code="$(rigcurl -s -X PUT --data-binary "@$BODY" -H "Content-Type: text/plain" \ + -o "$STATE_DIR/put.body" -w '%{http_code}' "$APP_URL$URL")" +check "presigned PUT through /objects" "200" "$code" +[ "$code" = "200" ] || say " $(head -c 300 "$STATE_DIR/put.body")" + +got="$(rigcurl -s "$APP_URL/objects/$STORE_BUCKET/$KEY")" +check "the bytes read back are the bytes sent" "$(cat "$BODY")" "$got" +check "the signed Content-Type is what the store kept" "text/plain" \ + "$(rigcurl -s -o /dev/null -w '%{content_type}' "$APP_URL/objects/$STORE_BUCKET/$KEY")" + +# --- the negative control ------------------------------------------- +# An Ingress identical to the chart's but for the one annotation. Applied here +# rather than left lying around, and asserted to differ by exactly that key — +# a control that has drifted is not a control. +sed -e "s|NAMESPACE_PLACEHOLDER|$NAMESPACE|" \ + -e "s|CONTROL_HOST_PLACEHOLDER|$CONTROL_HOST|" \ + -e "s|OBJECTS_SERVICE_PLACEHOLDER|$RELEASE-objects|" \ + -e "s|STORE_PORT_PLACEHOLDER|$STORE_PORT|" \ + "$MANIFEST_DIR/control-no-vhost-ingress.yaml" | kubectl apply -f - >/dev/null + +annots() { # ingress-name -> "key=value" lines, minus the bookkeeping ones + kubectl -n "$NAMESPACE" get ingress "$1" \ + -o go-template='{{range $k,$v := .metadata.annotations}}{{$k}}={{$v}}{{"\n"}}{{end}}' | + tr -d '\r' | grep -vE '^\s*$|^(kubectl\.kubernetes\.io/|meta\.helm\.sh/)' | LC_ALL=C sort +} +diffed="$(diff <(annots "$RELEASE-objects") <(annots rig-objects-control) | grep '^[<>]' | tr -d ' ' || true)" +check "control differs from the chart's Ingress by exactly upstream-vhost" \ + "<nginx.ingress.kubernetes.io/upstream-vhost=$STORE_HOST:$STORE_PORT" "$diffed" + +# Wait for the controller to pick the new Ingress up; a 404 here just means it +# has not synced yet, and racing it would read as a pass. +for _ in $(seq 30); do + ctl="$(rigcurl -s -o /dev/null -w '%{http_code}' --resolve "$CONTROL_HOST:$HTTP_PORT:127.0.0.1" \ + "http://$CONTROL_HOST:$HTTP_PORT/objects/$STORE_BUCKET/$KEY")" + [ "$ctl" = "404" ] || break + sleep 1 +done + +CKEY="hackathons/rig/$STAMP-control.txt" +CURL_PATH="$(bash "$HERE/presign.sh" put "$CKEY" "text/plain" "$SIZE")" +body="$(rigcurl -s -X PUT --data-binary "@$BODY" -H "Content-Type: text/plain" \ + --resolve "$CONTROL_HOST:$HTTP_PORT:127.0.0.1" \ + "http://$CONTROL_HOST:$HTTP_PORT$CURL_PATH")" +check_contains "without the rewrite the same signature is refused" "SignatureDoesNotMatch" "$body" + +# And the same signature IS accepted through the chart's route, so the refusal +# above is about the annotation and not about the URL having gone stale. +check "the same signature is accepted through the chart's route" "200" \ + "$(rigcurl -s -X PUT --data-binary "@$BODY" -H "Content-Type: text/plain" \ + -o /dev/null -w '%{http_code}' "$APP_URL$CURL_PATH")" + +# --- the bucket policy, both halves ---------------------------------- +check "a public prefix is readable with no credentials" "200" \ + "$(rigcurl -s -o /dev/null -w '%{http_code}' "$APP_URL/objects/$STORE_BUCKET/$KEY")" +check "a private prefix is not" "403" \ + "$(rigcurl -s -o /dev/null -w '%{http_code}' "$APP_URL/objects/$STORE_BUCKET/teams/canary.txt")" +# …and it IS there, so the 403 is a refusal and not a miss. +check "the private object exists (presigned GET reaches it)" "200" \ + "$(rigcurl -s -o /dev/null -w '%{http_code}' "$APP_URL$(bash "$HERE/presign.sh" get "teams/canary.txt")")" + +# ===================================================================== +step "2 · the regex path beats the frontend's /" +# ===================================================================== +check "both Ingresses claim the same host" "$APP_HOST $APP_HOST" \ + "$(kubectl -n "$NAMESPACE" get ingress "$RELEASE-frontend" "$RELEASE-objects" \ + -o jsonpath='{.items[*].spec.rules[0].host}')" +check_contains "/ still reaches the SvelteKit app" "<!doctype html" \ + "$(rigcurl -s "$APP_URL/" | head -c 200)" +check "/objects/… reaches the store, not the app" "$(cat "$BODY")" \ + "$(rigcurl -s "$APP_URL/objects/$STORE_BUCKET/$KEY")" +# The regex is `/objects(/|$)(.*)`, so a path that merely STARTS with the word +# must fall through to the frontend. If it did not, the prefix would be +# swallowing application routes. +check_lacks "/objectsnotaprefix is NOT sent to the store" "<Error>" \ + "$(rigcurl -s "$APP_URL/objectsnotaprefix")" + +# ===================================================================== +step "3 · ingress-nginx accepts the ExternalName upstream" +# ===================================================================== +check "the chart created an ExternalName Service" "ExternalName" \ + "$(kubectl -n "$NAMESPACE" get svc "$RELEASE-objects" -o jsonpath='{.spec.type}')" +check "it points at the store's endpoint host" "$STORE_HOST" \ + "$(kubectl -n "$NAMESPACE" get svc "$RELEASE-objects" -o jsonpath='{.spec.externalName}')" +check "it is the /objects backend" "$RELEASE-objects" \ + "$(kubectl -n "$NAMESPACE" get ingress "$RELEASE-objects" \ + -o jsonpath='{.spec.rules[0].http.paths[0].backend.service.name}')" +# Every 200 above already went through it — a rejected ExternalName upstream is +# a 503, not a wrong answer. +check "traffic through it is served, not 503'd" "200" \ + "$(rigcurl -s -o /dev/null -w '%{http_code}' "$APP_URL/objects/$STORE_BUCKET/$KEY")" + +if [ "$QUICK" = 0 ]; then + # The check above is only as good as the alternative being real: a "200" + # would look identical if the kill switch did not exist. So turn it on and + # watch the route die, then turn it off and watch it come back. + # + # ⚠ IT IS A COMMAND-LINE FLAG, `--disable-svc-external-name`, and NOT a + # ConfigMap key. values.yaml used to name it `disable-service-external-name` + # in the controller ConfigMap; that was tried here first and the route kept + # serving, upstream unchanged in the access log, no warning logged — + # an unknown ConfigMap key is silently ignored. A cluster believed to have + # blocked ExternalName that way has not. Corrected in values.yaml. + ARGS_PATH="/spec/template/spec/containers/0/args" + ext_flag_index() { kubectl -n ingress-nginx get deployment ingress-nginx-controller \ + -o go-template="{{range \$i,\$a := (index .spec.template.spec.containers 0).args}}{{if eq \$a \"--disable-svc-external-name=true\"}}{{\$i}}{{end}}{{end}}"; } + kubectl -n ingress-nginx patch deployment ingress-nginx-controller --type=json \ + -p "[{\"op\":\"add\",\"path\":\"$ARGS_PATH/-\",\"value\":\"--disable-svc-external-name=true\"}]" >/dev/null + kubectl -n ingress-nginx rollout status deploy/ingress-nginx-controller --timeout=180s >/dev/null + wait_code() { + local c + for _ in $(seq 40); do + c="$(rigcurl -s -o /dev/null -w '%{http_code}' "$APP_URL/objects/$STORE_BUCKET/$KEY")" + [ "$c" = "$1" ] && break + sleep 1 + done + printf '%s' "$c" + } + check "with --disable-svc-external-name the route 503s (so 200 was not vacuous)" \ + "503" "$(wait_code 503)" + idx="$(ext_flag_index)" + [ -n "$idx" ] && kubectl -n ingress-nginx patch deployment ingress-nginx-controller \ + --type=json -p "[{\"op\":\"remove\",\"path\":\"$ARGS_PATH/$idx\"}]" >/dev/null + kubectl -n ingress-nginx rollout status deploy/ingress-nginx-controller --timeout=180s >/dev/null + check "and it recovers when the flag goes away" "200" "$(wait_code 200)" +fi + +# ===================================================================== +step "4 · a login round-trip through the ingress hostname" +# ===================================================================== +disc="$(rigcurl -s "$AUTH_URL/realms/hackagon/.well-known/openid-configuration")" +check_contains "Keycloak's discovery names the public issuer" \ + "\"issuer\":\"$AUTH_URL/realms/hackagon\"" "$disc" + +# REGRESSION GUARD for the frontend's public origin. adapter-node infers the +# scheme; with nothing configured it assumes "https", and then advertises +# callback URLs on an origin that does not exist, issues `__Secure-` cookies +# over http, and login dies with every page still answering 200. That is what +# frontend.protocolHeader is for. Asserting the ADVERTISED origin rather than +# the env var means the check survives however the chart chooses to fix it. +prov="$(rigcurl -s "$APP_URL/auth/providers")" +check_contains "the frontend advertises the origin it is actually reached on" \ + "\"callbackUrl\":\"$APP_URL/auth/callback/keycloak\"" "$prov" + +J="$STATE_DIR/login-jar.txt" +rm -f "$J" +lc() { rigcurl -s -b "$J" -c "$J" "$@"; } +form_action() { tr '\n' ' ' <"$1" | grep -oE '<form[^>]*action="[^"]*"' | head -1 | + grep -oE 'action="[^"]*"' | sed 's/action="//;s/"$//;s/&/\&/g'; } + +lc -o /dev/null "$APP_URL/auth/signin" +AUTHZ="$(lc -o /dev/null -w '%{redirect_url}' -X POST \ + -H "Origin: $APP_URL" -H "Content-Type: application/x-www-form-urlencoded" \ + --data "csrfToken=&callbackUrl=$APP_URL/" "$APP_URL/auth/signin/keycloak")" +check_contains "sign-in redirects to Keycloak's authorize endpoint" \ + "$AUTH_URL/realms/hackagon/protocol/openid-connect/auth" "$AUTHZ" + +# The realm's browser flow is username-first, so this is two POSTs, not one. +lc -o "$STATE_DIR/login1.html" "$AUTHZ" +lc -o "$STATE_DIR/login2.html" -X POST --data-urlencode "username=alice" \ + "$(form_action "$STATE_DIR/login1.html")" +CB="$(lc -o /dev/null -w '%{redirect_url}' -X POST \ + --data-urlencode "password=aliceandbob" --data "credentialId=" \ + "$(form_action "$STATE_DIR/login2.html")")" +check_contains "Keycloak redirects back to the app with a code" \ + "$APP_URL/auth/callback/keycloak?" "$CB" + +# THE hop. Everything before it can succeed while this returns 502: the +# callback's Set-Cookie block is chunked and multi-kilobyte, and nginx's default +# 4k proxy buffer refuses it AFTER Keycloak has authenticated and the app has +# logged "Initial sign-in successful". See frontend.ingress.proxyBufferSize. +check "the OIDC callback completes (not 502)" "302" \ + "$(lc -o /dev/null -w '%{http_code}' "$CB")" + +sess="$(lc "$APP_URL/auth/session")" +check_contains "the session is established for the user who signed in" \ + '"email":"alice@mail.com"' "$sess" +check_contains "and it carries a Keycloak access token" '"accessToken":"ey' "$sess" + +# ===================================================================== +step "5 · the optional blocks are absent from the RUNNING pod" +# ===================================================================== +# Read out of the live container, not out of `helm template` and not out of the +# ConfigMap: the question is what the process is actually configured with. See +# read_live_config at the top of this file for how, and why the positive +# control below has to come first. +LIVE="$(read_live_config frontend "$STAMP")" + +# POSITIVE CONTROL FIRST. An empty read agrees with every absence assertion +# below, and on the first attempt at this the read WAS empty. +check_contains "the live config was actually read (positive control)" "clientId:" "$LIVE" +check_lacks "no replay: block reaches the running frontend" "replay:" "$LIVE" +check_lacks "no plausible: block reaches the running frontend" "plausible:" "$LIVE" +check_contains "…while the blocks that ARE configured are present" "useSecure:" "$LIVE" + +ENVJSON="$(kubectl -n "$NAMESPACE" get deployment "$RELEASE-frontend" \ + -o go-template='{{range .spec.template.spec.containers}}{{range .env}}{{.name}} {{end}}{{end}}')" +check_lacks "and nothing smuggles them in as environment" "REPLAY" "$ENVJSON" + +# ===================================================================== +step "6 · a config-only upgrade reaches the RUNNING pod" +# ===================================================================== +# THE BUG THIS EXISTS FOR was silent in the worst way: `helm upgrade` reported +# success in 0.9 s, `kubectl get configmap` showed the new value, and every +# running pod went on serving the old one. Two things combined — config.yaml is +# a `subPath` mount, which the kubelet resolves ONCE at container start and +# never refreshes, and no template carried a pod-template annotation, so after +# a config-only upgrade the Deployment was byte-identical and Kubernetes +# correctly did nothing. Rotating the OIDC client secret that way changed +# nothing that was running, with no signal anywhere. +# +# The fix is the checksum annotations in {backend,frontend}-deployment.yaml. +# Both halves are checked here, because each is a bug on its own: a config +# change MUST roll the pods, and an unchanged one MUST NOT — a hash over +# something non-deterministic would trade a silent no-op for a rollout on every +# upgrade, which is worse. +# +# Values come from `helm get values`, never a hard-coded list of -f files: this +# script runs in TUNNEL mode too, and re-installing the localhost values there +# would quietly repoint the release mid-run. That the round-trip is faithful is +# asserted rather than assumed — the no-op check below is exactly that claim. +# +# ⚠ THAT FILE HOLDS EVERY CREDENTIAL THE RELEASE WAS INSTALLED WITH — both DB +# passwords, the OIDC client secret and the Auth.js key. `.state/` is +# gitignored (checked), and the umask matches the one up.sh mints +# `secrets.env` under. +# `rm` first: a umask only applies when the file is CREATED, so rewriting an +# existing world-readable one would keep its mode. +VALS="$STATE_DIR/current-values.yaml" +rm -f "$VALS" +( + umask 077 + helm -n "$NAMESPACE" get values "$RELEASE" -o yaml >"$VALS" +) + +csums() { # component [extra helm args…] -> "checksum/<kind>: <sha256>" lines, sorted + local comp="$1" + shift + helm template "$RELEASE" "$(winpath "$CHART_DIR")" --namespace "$NAMESPACE" \ + -f "$(winpath "$VALS")" --show-only "templates/$comp-deployment.yaml" "$@" 2>/dev/null | + grep -oE 'checksum/[a-z]+: [0-9a-f]{64}' | LC_ALL=C sort +} +live_csums() { # component -> the same shape, off the running Deployment + kubectl -n "$NAMESPACE" get deploy "$RELEASE-$1" \ + -o jsonpath='{.spec.template.metadata.annotations}' | + tr ',' '\n' | grep -oE 'checksum/[a-z]+":"[0-9a-f]{64}' | + sed 's/":"/: /' | LC_ALL=C sort +} + +# --- the annotations are there, and are not empty strings ------------- +# The count assertions are the positive control for every equality below: two +# empty strings compare equal, so "live matches rendered" would hold just as +# well if the annotation had been deleted from both sides. +check "the backend renders exactly one checksum annotation" "1" "$(csums backend | grep -c .)" +check "the frontend renders two (config and secret)" "2" "$(csums frontend | grep -c .)" +check "the running backend carries the checksum it renders to" "$(csums backend)" "$(live_csums backend)" +check "the running frontend carries the checksums it renders to" "$(csums frontend)" "$(live_csums frontend)" + +# --- the hash is deterministic --------------------------------------- +# If anything non-deterministic reached the hashed templates — a timestamp, a +# generated password — every `helm upgrade` would roll every pod forever. The +# Bitnami and Keycloak subcharts DO mint passwords on each render (measured: +# `helm template` twice produces two different keycloak admin-passwords, and a +# different postgres one whenever postgresql.auth.postgresPassword is empty), +# so this is not a hypothetical property of this chart. +# `|| true` on every assignment from csums/grep: they are pipelines under +# `set -o pipefail`, and a missing annotation makes grep exit 1 — which would +# kill this script through errexit at exactly the moment it has something to +# report. A check that cannot survive its own subject being absent is no check. +FE="$(csums frontend || true)" +check "two renders of identical inputs agree (no rollout on every upgrade)" "$FE" "$(csums frontend)" +check "…and so does the backend's" "$(csums backend)" "$(csums backend)" + +# --- …and it still tracks what it is supposed to track ---------------- +# Determinism alone is also what a CONSTANT would give you. These three prove +# the hashes are functions of the right inputs, and that the frontend's two are +# independent of each other — without rotating a real secret on the cluster. +FE_CFG="$(printf '%s' "$FE" | grep 'checksum/config' || true)" +FE_SEC="$(printf '%s' "$FE" | grep 'checksum/secret' || true)" +ALT_CFG="$(csums frontend --set frontend.config.oidc.clientId=rig-checksum-probe || true)" +check_lacks "a changed config value changes checksum/config" "$FE_CFG" "$ALT_CFG" +check_contains "…and leaves checksum/secret alone" "$FE_SEC" "$ALT_CFG" +ALT_SEC="$(csums frontend --set frontendSecrets.authSecret=rig-checksum-probe || true)" +check_lacks "a rotated secret changes checksum/secret" "$FE_SEC" "$ALT_SEC" +check_contains "…and leaves checksum/config alone" "$FE_CFG" "$ALT_SEC" +check_lacks "a changed backend config value changes the backend's checksum" \ + "$(csums backend)" "$(csums backend --set backend.config.logging.level=rigprobe)" + +if [ "$QUICK" = 0 ]; then + # --- the behaviour, on the running cluster ------------------------ + # Everything above is a render. The claim is about a pod. + reupgrade() { # extra helm args… + if ! helm upgrade "$RELEASE" "$(winpath "$CHART_DIR")" --namespace "$NAMESPACE" \ + -f "$(winpath "$VALS")" "$@" --timeout 15m >/dev/null 2>"$STATE_DIR/upgrade.err"; then + bad "helm upgrade failed" + sed 's/^/ /' "$STATE_DIR/upgrade.err" >&2 + FAIL=$((FAIL + 1)) + return 1 + fi + } + gens() { kubectl -n "$NAMESPACE" get deploy "$RELEASE-backend" "$RELEASE-frontend" \ + -o jsonpath='{.items[*].metadata.generation}'; } + + # THE ASSERTION MOST LIKELY TO CATCH A MISTAKE. `metadata.generation` only + # advances when the SPEC changes, so this is Kubernetes' own answer to "did + # the pod template move", not an inference from pod names. + GEN0="$(gens)" + reupgrade || true + check "an upgrade with unchanged values does not touch the pod template" "$GEN0" "$(gens)" + + # Flip a value that is pure logging, computed from what the pod is actually + # running rather than assumed — the tunnel overlay may set it either way. + BEFORE="$(read_live_config frontend "${STAMP}b")" + check_contains "the live config was actually read (positive control)" "forceDevLog:" "$BEFORE" + case "$BEFORE" in *"forceDevLog: true"*) + WAS=true + WANT=false + ;; + *) + WAS=false + WANT=true + ;; + esac + + if reupgrade --set "frontend.config.log.forceDevLog=$WANT"; then + kubectl -n "$NAMESPACE" rollout status "deploy/$RELEASE-frontend" --timeout=300s >/dev/null 2>&1 || true + AFTER="$(read_live_config frontend "${STAMP}c")" + check_contains "the live config was read after the upgrade too (positive control)" \ + "forceDevLog:" "$AFTER" + # NOBODY RESTARTED ANYTHING. No `kubectl rollout restart`, no pod + # deletion — the annotation changed, so the Deployment rolled itself. + check_contains "a config-only upgrade reaches the running pod" \ + "forceDevLog: $WANT" "$AFTER" + check_lacks "…and the old value is gone from it" "forceDevLog: $WAS" "$AFTER" + fi + + # Put it back, and leave the release where this script found it. + reupgrade || true + kubectl -n "$NAMESPACE" rollout status "deploy/$RELEASE-frontend" --timeout=300s >/dev/null 2>&1 || true + check "the restored release renders the checksums it started with" "$FE" "$(csums frontend)" + check "…and the running pod carries them again" "$FE" "$(live_csums frontend)" +fi + +# ===================================================================== +step "result" +# ===================================================================== +say " $PASS passed, $FAIL failed" +[ "$FAIL" -eq 0 ] || exit 1 diff --git a/.claude/skills/lib/cf-api.sh b/.claude/skills/lib/cf-api.sh new file mode 100644 index 00000000..e33d8b4f --- /dev/null +++ b/.claude/skills/lib/cf-api.sh @@ -0,0 +1,364 @@ +# shellcheck shell=bash +# Cloudflare API access for NAMED tunnels — credentials, zone lookup, tunnel +# records, DNS records. Source it (it only defines functions and constants): +# +# HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# source "$HERE/../../lib/cf-api.sh" +# +# WHY NAMED TUNNELS EXIST HERE AT ALL. A quick tunnel hands out a fresh +# *.trycloudflare.com hostname on every start, and that one fact is the root of +# most of the churn in this repo's tunnel tooling: auth-wire.sh re-points BOTH +# OIDC issuers at each new URL, run.sh unwires and re-wires around every suite, +# the adapter-node build reads its issuer once at boot and goes stale, and a +# dead hostname once sat committed in HEAD for several commits. A stable +# hostname removes the CAUSE rather than the symptoms: wire it once and it stays +# correct across restarts, reboots and rebuilds. +# +# Quick tunnels are NOT replaced. They are the zero-config path for anyone +# without a Cloudflare account, and every rig still falls back to one. +# +# ── THE CREDENTIAL ─────────────────────────────────────────────────────────── +# +# Read from a gitignored .env (see cf_env_file below). This file REFUSES to +# read or write it unless `git check-ignore` says git cannot take it — the same +# discipline .secrets.env has in openreplay-stack and plausible-stack, and for +# the same reason: a rule that is present-but-wrong looks exactly like a rule +# that works, so ask git rather than reading .gitignore. +# +# THE TOKEN IS NEVER ECHOED. Not in a log line, not in an error, not in a file +# this code writes, and not in argv — the Authorization header goes to curl +# through `--config -` (stdin), so it never appears in `ps` output on a shared +# machine. Progress output names the HOSTNAME, never the credential. +# +# ⚠ Cloudflare API tokens scope to a ZONE, not to a hostname. A token that can +# edit DNS in example.org can edit ANY record in that zone, including ones +# this tooling never created. There is no narrower grant; see SKILL.md. +# +# ── DEPENDENCIES ───────────────────────────────────────────────────────────── +# +# curl and docker. jq is used when present and otherwise run from the official +# `ghcr.io/jqlang/jq` image — docker is already a hard requirement of every +# script that sources this, and the Git Bash host these scripts run on has no +# jq. Override with HACKAGON_JQ=/path/to/jq. + +CF_API="https://api.cloudflare.com/client/v4" + +# Where the credentials live. ONE location, deliberately: copying a token to a +# second path is how one of them goes stale and how a rotation misses a copy. +# The cloudflare-tunnel skill owns Cloudflare credentials, so the file lives +# beside it; HACKAGON_CF_ENV moves it (move the file, do not duplicate it). +cf_env_file() { + if [ -n "${HACKAGON_CF_ENV:-}" ]; then + echo "$HACKAGON_CF_ENV" + return 0 + fi + local skills root + skills="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" + root="$(cd "$skills/../.." && pwd)" + if [ -f "$skills/cloudflare-tunnel/.env" ]; then + echo "$skills/cloudflare-tunnel/.env" + elif [ -f "$root/.env" ] && grep -q '^CLOUDFLARE_API_TOKEN=' "$root/.env" 2>/dev/null; then + # Repo root is where people put a .env by reflex. Accepted, but only when + # it really is the Cloudflare one — this repo has other .env files. + echo "$root/.env" + else + echo "$skills/cloudflare-tunnel/.env" + fi +} + +# Machine-local state: per-tunnel credentials files and cloudflared configs. +# `.claude/**/.state/` is gitignored repo-wide. +cf_state_dir() { + local skills + skills="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" + echo "${HACKAGON_CF_STATE:-$skills/cloudflare-tunnel/.state}" +} + +# A secret is about to be read from (or written to) this path. Ask git whether +# it could ever be committed, and refuse if it could. Not "does .gitignore +# mention it" — `git check-ignore` is the only answer that accounts for +# negations, precedence and a nested .gitignore. +cf_guard_gitignored() { # <path> + local f="${1:?}" dir + dir="$(dirname "$f")" + command -v git >/dev/null 2>&1 || return 0 + git -C "$dir" rev-parse --is-inside-work-tree >/dev/null 2>&1 || return 0 + git -C "$dir" check-ignore -q "$f" && return 0 + echo "error: $f is NOT gitignored — refusing to touch a Cloudflare token there." >&2 + echo " git could commit it. Add it to .gitignore first." >&2 + return 1 +} + +# Load CLOUDFLARE_* / *_HOSTNAME from the env file. Explicit environment wins, +# so `CLOUDFLARE_ZONE=other bash up.sh` overrides on purpose. +# +# The `\r` strip is load-bearing on Windows and cost an hour in plausible-stack: +# a CR that rides INSIDE a value (not at the end of the line, where every tool +# would treat it as a line ending) survives every obvious inspection and makes +# the far end reject a credential that looks byte-perfect everywhere you check. +cf_load() { + local f + f="$(cf_env_file)" + [ -f "$f" ] || return 1 + cf_guard_gitignored "$f" || return 1 + local line k v + while IFS= read -r line || [ -n "$line" ]; do + line="${line%$'\r'}" + case "$line" in '' | \#*) continue ;; esac + k="${line%%=*}" + case "$k" in *[!A-Za-z0-9_]* | '') continue ;; esac + v="${line#*=}" + v="${v%$'\r'}" + [ -n "${!k:-}" ] || export "$k=$v" + done <"$f" + [ -n "${CLOUDFLARE_API_TOKEN:-}" ] && [ -n "${CLOUDFLARE_ZONE:-}" ] +} + +# "Is named mode available at all?" — the question up.sh asks before choosing a +# mode. Silent: callers decide what to print. +cf_configured() { cf_load >/dev/null 2>&1; } + +# Explain the absence in the terms someone can act on. Never prints a value. +cf_explain_unconfigured() { + local f + f="$(cf_env_file)" + if [ ! -f "$f" ]; then + echo " no Cloudflare credentials at $f" + echo " (copy .env.example beside it and fill it in — see SKILL.md," + echo " 'Named tunnels', for the exact token to mint)" + else + echo " $f exists but has no CLOUDFLARE_API_TOKEN + CLOUDFLARE_ZONE pair" + fi +} + +# ── jq, wherever it is ─────────────────────────────────────────────────────── +# The Git Bash host has no jq; the dev container does; the docker image is the +# floor. Resolved once per process. +CF_JQ="" +cf_jq_init() { + [ -z "$CF_JQ" ] || return 0 + if [ -n "${HACKAGON_JQ:-}" ]; then + CF_JQ="native:$HACKAGON_JQ" + elif command -v jq >/dev/null 2>&1; then + CF_JQ="native:jq" + else + CF_JQ="docker" + fi +} + +# Filter stdin. Always -r: every caller here wants a bare string or an id. +jqr() { # <filter> + cf_jq_init + case "$CF_JQ" in + native:*) "${CF_JQ#native:}" -r "$1" ;; + docker) + # -i, not argv: the JSON may carry a tunnel secret, and stdin to a local + # container keeps it off every process list on the box. + docker run --rm -i "${HACKAGON_JQ_IMAGE:-ghcr.io/jqlang/jq:1.7.1}" -r "$1" + ;; + esac +} + +# ── the API ────────────────────────────────────────────────────────────────── +# +# The bearer token goes through `curl --config -` on STDIN. Putting it in argv +# (`-H "Authorization: Bearer $T"`) publishes it to every process listing on the +# machine for the life of the request; this does not. The request body is not a +# secret and stays on the command line. +cf_api() { # <METHOD> <path> [json-body] -> response JSON on stdout + local method="$1" path="$2" body="${3:-}" + local -a args=(-sS -X "$method" --max-time 30 + -H "Content-Type: application/json" "$CF_API$path") + [ -n "$body" ] && args+=(-d "$body") + printf 'header = "Authorization: Bearer %s"\n' "$CLOUDFLARE_API_TOKEN" | + curl --config - "${args[@]}" +} + +# Cloudflare answers 200 with `"success": false` for most failures, so an exit +# code is not the check. Reads the response on stdin, re-emits it when it is +# good, and names the failure when it is not. +# +# The error text comes back from Cloudflare and never contains the token, but it +# is piped rather than interpolated into a message anyway — one fewer place a +# future edit could widen. +cf_ok() { # <what-was-attempted> (response on stdin, response on stdout) + local what="$1" resp + resp="$(cat)" + if [ "$(printf '%s' "$resp" | jqr '.success // false')" = "true" ]; then + printf '%s' "$resp" + return 0 + fi + { + echo "error: Cloudflare refused: $what" + printf '%s' "$resp" | jqr ' + if (.errors | length) > 0 + then (.errors[] | " [\(.code)] \(.message)") + else " (no error detail in the response)" end' 2>/dev/null || + echo " (unparseable response)" + } >&2 + return 1 +} + +# Is the token alive, and what can it do? Run before anything else: an expired +# or mis-scoped token otherwise surfaces as a confusing failure three calls +# later, on whichever permission happens to be checked first. +cf_verify_token() { + local resp status + resp="$(cf_api GET /user/tokens/verify)" || return 1 + status="$(printf '%s' "$resp" | jqr '.result.status // "unknown"')" + if [ "$(printf '%s' "$resp" | jqr '.success // false')" != "true" ]; then + echo "error: the Cloudflare API token was rejected (status: $status)." >&2 + echo " Mint a new one — SKILL.md, 'Named tunnels' → 'The token'." >&2 + return 1 + fi + [ "$status" = "active" ] || { + echo "error: the token verifies but its status is '$status', not 'active'." >&2 + return 1 + } + return 0 +} + +# ── zone ───────────────────────────────────────────────────────────────────── +# One call answers both ids. Deriving the ACCOUNT id from the zone is why this +# tooling needs no `Account → Account Settings → Read` permission: the zone +# record carries it. +CF_ZONE_ID="" +CF_ACCOUNT_ID="" +cf_resolve_zone() { + [ -z "$CF_ZONE_ID" ] || return 0 + local resp count st + resp="$(cf_api GET "/zones?name=$CLOUDFLARE_ZONE")" || return 1 + printf '%s' "$resp" | cf_ok "looking up the zone $CLOUDFLARE_ZONE" >/dev/null || return 1 + count="$(printf '%s' "$resp" | jqr '.result | length')" + if [ "$count" = "0" ]; then + echo "error: the token cannot see a zone named '$CLOUDFLARE_ZONE'." >&2 + echo " Either the name is wrong, or the token was not granted" >&2 + echo " Zone → DNS → Edit on THAT zone (zone resources are opt-in)." >&2 + return 1 + fi + CF_ZONE_ID="$(printf '%s' "$resp" | jqr '.result[0].id')" + CF_ACCOUNT_ID="${CLOUDFLARE_ACCOUNT_ID:-$(printf '%s' "$resp" | jqr '.result[0].account.id')}" + st="$(printf '%s' "$resp" | jqr '.result[0].status')" + if [ "$st" != "active" ]; then + # Worth failing on: a pending zone accepts DNS writes and resolves nothing, + # so every later step succeeds and the hostname stays dead. + echo "error: zone $CLOUDFLARE_ZONE is '$st', not 'active' — Cloudflare is not" >&2 + echo " authoritative for it yet, so a record written now resolves nowhere." >&2 + return 1 + fi + return 0 +} + +# ── tunnels ────────────────────────────────────────────────────────────────── +cf_tunnel_id() { # <name> -> id on stdout, empty when absent + local resp + resp="$(cf_api GET "/accounts/$CF_ACCOUNT_ID/cfd_tunnel?name=$1&is_deleted=false")" || return 1 + printf '%s' "$resp" | cf_ok "listing tunnels" >/dev/null || return 1 + printf '%s' "$resp" | jqr '.result[0].id // empty' +} + +cf_tunnel_create() { # <name> <base64-secret> -> id on stdout + local resp + # config_src=local: the ingress rules live in a file on this machine (and are + # therefore reviewable here) rather than in the Cloudflare dashboard. + resp="$(cf_api POST "/accounts/$CF_ACCOUNT_ID/cfd_tunnel" \ + "{\"name\":\"$1\",\"tunnel_secret\":\"$2\",\"config_src\":\"local\"}")" || return 1 + printf '%s' "$resp" | cf_ok "creating the tunnel '$1'" >/dev/null || return 1 + printf '%s' "$resp" | jqr '.result.id' +} + +cf_tunnel_delete() { # <id> + cf_api DELETE "/accounts/$CF_ACCOUNT_ID/cfd_tunnel/$1" | + cf_ok "deleting tunnel $1" >/dev/null +} + +# The run token is base64 of {"a":account,"t":tunnel,"s":secret} — the same three +# fields a credentials file holds. That equivalence is what makes an existing +# tunnel recoverable: Cloudflare never returns the secret from the create call +# again, so without this a lost credentials file would mean deleting and +# recreating the tunnel (and re-pointing DNS) to get back to a working state. +cf_tunnel_token() { # <id> -> base64 token on stdout + local resp + resp="$(cf_api GET "/accounts/$CF_ACCOUNT_ID/cfd_tunnel/$1/token")" || return 1 + printf '%s' "$resp" | cf_ok "fetching the run token for tunnel $1" >/dev/null || return 1 + printf '%s' "$resp" | jqr '.result' +} + +# ── DNS ────────────────────────────────────────────────────────────────────── +# A proxied CNAME to <tunnel-id>.cfargotunnel.com is what makes a hostname +# resolve to the tunnel. `proxied: true` is mandatory, not a preference — an +# unproxied cfargotunnel.com name does not resolve publicly at all. +CF_DNS_COMMENT="hackagon dev tunnel — managed by .claude/skills/lib/cf-named-tunnel.sh" + +cf_dns_find() { # <fqdn> -> "<id> <type> <content>" on stdout, empty when absent + local resp + resp="$(cf_api GET "/zones/$CF_ZONE_ID/dns_records?name=$1")" || return 1 + printf '%s' "$resp" | cf_ok "listing DNS records for $1" >/dev/null || return 1 + printf '%s' "$resp" | jqr '.result[0] | if . == null then empty + else "\(.id) \(.type) \(.content)" end' +} + +# Point <fqdn> at <tunnel-id>. Idempotent, and it REFUSES to overwrite a record +# it did not create. +# +# That refusal is the whole safety story of this function. The token is +# zone-wide: nothing in Cloudflare stops this code from replacing the zone's +# apex, a mail record, or a colleague's staging host if a hostname were +# mistyped in .env. So anything that is not already a cfargotunnel.com CNAME is +# left alone and reported, and CF_FORCE_DNS=1 is the deliberate override. +cf_dns_point() { # <fqdn> <tunnel-id> + local fqdn="$1" tid="$2" target="$2.cfargotunnel.com" existing rid rtype rcontent + local body + body="$(printf '{"type":"CNAME","name":"%s","content":"%s","proxied":true,"ttl":1,"comment":"%s"}' \ + "$fqdn" "$target" "$CF_DNS_COMMENT")" + + existing="$(cf_dns_find "$fqdn")" || return 1 + if [ -z "$existing" ]; then + cf_api POST "/zones/$CF_ZONE_ID/dns_records" "$body" | + cf_ok "creating the CNAME $fqdn → $target" >/dev/null || return 1 + echo " DNS created $fqdn → $target (proxied)" + return 0 + fi + + read -r rid rtype rcontent <<<"$existing" + if [ "$rtype" = "CNAME" ] && [ "$rcontent" = "$target" ]; then + echo " DNS ok $fqdn → $target" + return 0 + fi + case "$rtype:$rcontent" in + CNAME:*.cfargotunnel.com) ;; # ours, pointing at a different tunnel — repoint + *) + if [ "${CF_FORCE_DNS:-0}" != "1" ]; then + echo "error: $fqdn already has a $rtype record that this tooling did not create." >&2 + echo " Refusing to replace it. The API token is ZONE-wide, so a typo" >&2 + echo " here could take out an unrelated hostname." >&2 + echo " Override deliberately with CF_FORCE_DNS=1 if it really is stale." >&2 + return 1 + fi + echo " DNS ⚠ replacing a $rtype record (CF_FORCE_DNS=1)" + ;; + esac + cf_api PATCH "/zones/$CF_ZONE_ID/dns_records/$rid" "$body" | + cf_ok "repointing $fqdn → $target" >/dev/null || return 1 + echo " DNS updated $fqdn → $target (proxied)" +} + +cf_dns_delete() { # <fqdn> — only when it is one of ours + local existing rid rtype rcontent + existing="$(cf_dns_find "$1")" || return 1 + [ -n "$existing" ] || { + echo " DNS absent $1" + return 0 + } + read -r rid rtype rcontent <<<"$existing" + case "$rtype:$rcontent" in + CNAME:*.cfargotunnel.com) + cf_api DELETE "/zones/$CF_ZONE_ID/dns_records/$rid" | + cf_ok "deleting the CNAME $1" >/dev/null || return 1 + echo " DNS deleted $1" + ;; + *) + echo " DNS left alone: $1 is a $rtype this tooling did not create" >&2 + ;; + esac +} diff --git a/.claude/skills/lib/cf-named-tunnel.sh b/.claude/skills/lib/cf-named-tunnel.sh new file mode 100644 index 00000000..4e24b459 --- /dev/null +++ b/.claude/skills/lib/cf-named-tunnel.sh @@ -0,0 +1,518 @@ +#!/usr/bin/env bash +# Run a Cloudflare NAMED tunnel — a tunnel with a PERSISTENT hostname on a zone +# you own, instead of a quick tunnel's throwaway *.trycloudflare.com. +# +# cf-named-tunnel.sh up <name> <fqdn> <network> <service-url> [fqdn…] +# cf-named-tunnel.sh ensure <name> <fqdn> [fqdn…] # Cloudflare side only +# cf-named-tunnel.sh stop <name> +# cf-named-tunnel.sh status [name] +# cf-named-tunnel.sh url <name> # from the RUNNING container +# cf-named-tunnel.sh running <name> # exit 0 when it is +# cf-named-tunnel.sh destroy <name> <fqdn> [fqdn…] # tunnel + DNS + container +# cf-named-tunnel.sh check # credentials + zone only +# +# e.g. cf-named-tunnel.sh up hackagon hackagon.example.org \ +# hackagon-dev http://caddy:80 +# +# ONE TUNNEL PER RIG, not one tunnel with three ingress rules. The three rigs +# live in three different compose projects on three different docker networks +# (hackagon-dev, plausible_default, openreplay_openreplay-net); a single +# cloudflared would have to be attached to all three and restarted whenever any +# rig came up or down. Per-rig tunnels are independent, they mirror the +# quick-tunnel-per-rig design that is already here, and a rig that is down +# simply has no tunnel rather than breaking the others'. +# +# ── SEVERAL HOSTNAMES, ONE RIG ─────────────────────────────────────────────── +# +# The rule above is per RIG, not per hostname, and one rig can legitimately need +# more than one public name: the k3d chart rig serves the app and Keycloak from +# two host-based Ingresses on the same ingress controller, because the chart +# routes them by Host. Those extra names take TRAILING ARGUMENTS and share the +# ONE service URL — which is the whole point, since the thing behind the tunnel +# is a single proxy that dispatches on Host itself. A second tunnel would be a +# second container, a second thing to start and stop in step, and a second way +# to leave half a rig public. +# +# Every hostname must be inside the same zone (the token can write nowhere +# else), they all get their own proxied CNAME to the same tunnel, and `destroy` +# takes the same list so no record outlives the tunnel it points at. +# +# ⚠ Cloudflare's free Universal SSL covers the apex and ONE label +# (`example.org`, `a.example.org`) and nothing deeper. `a.b.example.org` gets no +# certificate at the edge and fails the TLS handshake outright — measured, alert +# 40, before anything was created. Pick sibling names, not nested ones, unless +# the zone has Advanced Certificate Manager. +# +# ── WHAT RUNS, AND WITH WHICH CREDENTIAL ───────────────────────────────────── +# +# The container runs cloudflared with a LOCALLY-MANAGED config: a per-tunnel +# credentials file plus an ingress file, both under .state/named/<name>/ and +# both gitignored, mounted read-only at /etc/cloudflared. +# +# This is deliberate and it is the security point. The API token is a SETUP +# credential — it creates the tunnel and writes the DNS record, once. The +# credentials file is the RUN-TIME credential, and it can do exactly one thing: +# serve traffic for that one tunnel. It cannot touch DNS, cannot see the zone, +# and cannot create anything. A machine that only needs to RUN a tunnel should +# hold the credentials file and no token at all — copy .state/named/<name>/ to +# it and never put the .env there. +# +# The alternative (`cloudflared tunnel run --token …`, ingress managed in the +# Cloudflare dashboard) needs no files but puts the ingress rules somewhere this +# repo cannot review or diff, and the run token is a full tunnel credential +# either way. Local config keeps the routing table on disk, next to the +# Caddyfile it hands off to. +set -euo pipefail +CFN_HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# shellcheck source=./cf-api.sh +source "$CFN_HERE/cf-api.sh" + +# Paths handed to docker.exe. On Git Bash/MSYS the automatic POSIX→Windows +# translation has to be off for docker's own /container/paths to survive, which +# means a /c/Users/... host path would reach docker.exe verbatim and resolve as +# C:\c\Users\... — hand it a Windows-style path instead. Same fix as +# openreplay-stack/scripts/lib.sh and plausible-stack/scripts/lib.sh. +CURL_DISCARD="/dev/null" +case "$(uname -s)" in +MINGW* | MSYS*) + export MSYS_NO_PATHCONV=1 MSYS2_ARG_CONV_EXCL="*" + # …and not /dev/null either: curl.exe would try to create a file at that + # literal path and print `client returned ERROR on write` in the middle of + # a check that then passes anyway. + CURL_DISCARD="NUL" + ;; +esac + +CFN_IMAGE="${HACKAGON_CLOUDFLARED_IMAGE:-cloudflare/cloudflared:latest}" + +cfn_container() { echo "cf-named-${1:?}"; } +cfn_dir() { echo "$(cf_state_dir)/named/${1:?}"; } +cfn_dir_docker() { # host path in the form docker.exe accepts + local d + d="$(cfn_dir "$1")" + case "$(uname -s)" in MINGW* | MSYS*) cygpath -m "$d" ;; *) echo "$d" ;; esac +} + +# ── Cloudflare side ────────────────────────────────────────────────────────── +# Create-or-reuse the tunnel, make sure we hold its credentials, point DNS at +# it. Idempotent: run it as often as you like. +cfn_ensure() { # <name> <fqdn> [fqdn…] + local name="$1" fqdn="$2" dir tid secret token host + shift 2 + local extras=("$@") + dir="$(cfn_dir "$name")" + + cf_load || { + echo "error: no usable Cloudflare credentials." >&2 + cf_explain_unconfigured >&2 + return 1 + } + cf_verify_token || return 1 + cf_resolve_zone || return 1 + + # Every fqdn must be inside the zone the token can edit. Checked here because + # the API's own error for this is a bare "record name is invalid". + for host in "$fqdn" "${extras[@]}"; do + case "$host" in + *".$CLOUDFLARE_ZONE" | "$CLOUDFLARE_ZONE") ;; + *) + echo "error: '$host' is not inside the zone '$CLOUDFLARE_ZONE'." >&2 + echo " The token is scoped to that zone and can write nowhere else." >&2 + return 1 + ;; + esac + done + + mkdir -p "$dir" + cf_guard_gitignored "$dir/credentials.json" || return 1 + + tid="$(cf_tunnel_id "$name")" || return 1 + if [ -z "$tid" ]; then + echo "==> Creating the named tunnel '$name'…" + # tr -d '\r\n', not '\n': Git Bash's openssl prints CRLF, and a CR inside a + # base64 secret is not a line ending, it is a byte in the credential. + secret="$(openssl rand -base64 32 | tr -d '\r\n')" + tid="$(cf_tunnel_create "$name" "$secret")" || return 1 + umask 077 + printf '{"AccountTag":"%s","TunnelSecret":"%s","TunnelID":"%s"}\n' \ + "$CF_ACCOUNT_ID" "$secret" "$tid" >"$dir/credentials.json" + echo " tunnel created $name ($tid)" + else + echo " tunnel reusing $name ($tid)" + if [ ! -s "$dir/credentials.json" ]; then + # Cloudflare will not hand the creation secret back, but the RUN TOKEN is + # base64 of the same three fields — so an existing tunnel whose + # credentials file was lost is recoverable without deleting it and + # re-pointing DNS. + echo " creds missing — rebuilding from the tunnel's run token" + token="$(cf_tunnel_token "$tid")" || return 1 + umask 077 + printf '%s' "$token" | base64 -d 2>/dev/null | + jqr '{AccountTag: .a, TunnelSecret: .s, TunnelID: .t}' >"$dir/credentials.json.tmp" || { + echo "error: could not decode the run token into a credentials file." >&2 + rm -f "$dir/credentials.json.tmp" + return 1 + } + # jqr is -r, which prints the object as JSON anyway; verify before commit. + grep -q '"TunnelID"' "$dir/credentials.json.tmp" || { + echo "error: the rebuilt credentials file has no TunnelID." >&2 + rm -f "$dir/credentials.json.tmp" + return 1 + } + mv "$dir/credentials.json.tmp" "$dir/credentials.json" + fi + fi + + for host in "$fqdn" "${extras[@]}"; do + cf_dns_point "$host" "$tid" || return 1 + done + echo "$tid" >"$dir/tunnel-id" + printf '%s\n' "$fqdn" "${extras[@]}" >"$dir/hostname" +} + +# ── the ingress file ───────────────────────────────────────────────────────── +# One rule per hostname, all pointing at the one service, then an explicit 404. +# The catch-all matters: without a final rule cloudflared refuses to start, and +# with a permissive one the tunnel would answer for hostnames it was never given. +# +# All hostnames share the service because the origin is a proxy that dispatches +# on Host itself (caddy here, ingress-nginx for the k3d rig). cloudflared passes +# the requested Host through unchanged, so the far end sees the name the browser +# asked for and routes on it. +cfn_write_config() { # <name> <fqdn> <service-url> [fqdn…] + local name="$1" fqdn="$2" service="$3" dir tid host + shift 3 + dir="$(cfn_dir "$name")" + tid="$(cat "$dir/tunnel-id")" + { + cat <<YAML +# GENERATED by .claude/skills/lib/cf-named-tunnel.sh — do not edit by hand. +# Mounted read-only at /etc/cloudflared inside the cloudflared container. +tunnel: $tid +credentials-file: /etc/cloudflared/credentials.json + +ingress: +YAML + for host in "$fqdn" ${1+"$@"}; do + cat <<YAML + - hostname: $host + service: $service + originRequest: + # The origin is a plain-HTTP proxy on its rig's docker network; TLS + # terminates at Cloudflare's edge, and cloudflared is what puts + # \`X-Forwarded-Proto: https\` on the request the origin receives. Nothing + # here speaks https, so no verification setting applies — this timeout is + # the only knob that has bitten us: a cold vite SSR can take ~19s and the + # default 30s is uncomfortably close. + connectTimeout: 30s +YAML + done + cat <<YAML + # Anything else reaching this tunnel is not ours. Say so rather than serving it. + - service: http_status:404 +YAML + } >"$dir/config.yml" +} + +# ── the container ──────────────────────────────────────────────────────────── +cfn_running() { # <name> + [ -n "$(docker ps -q -f "name=^$(cfn_container "$1")\$" 2>/dev/null)" ] +} + +# The hostname is read back off the RUNNING container's label, not off a state +# file. Same rule as the quick tunnels reading cloudflared's log rather than a +# cached URL: a state file describes what someone intended, a label describes +# what is actually serving. +cfn_url() { # <name> + local host + host="$(docker inspect "$(cfn_container "$1")" \ + --format '{{index .Config.Labels "hackagon.tunnel.hostname"}}' 2>/dev/null || true)" + [ -n "$host" ] || return 1 + echo "https://$host" +} + +cfn_stop() { # <name> + local c + c="$(cfn_container "$1")" + if [ -n "$(docker ps -aq -f "name=^$c\$" 2>/dev/null)" ]; then + docker rm -f "$c" >/dev/null + echo " stopped $c" + else + echo " no container $c" + fi +} + +cfn_run() { # <name> <fqdn> <network> <service-url> [fqdn…] + local name="$1" fqdn="$2" network="$3" service="$4" c dir_d all + shift 4 + c="$(cfn_container "$name")" + dir_d="$(cfn_dir_docker "$name")" + # Every hostname this container serves, on ONE label. `cfn_url` still reads + # `hostname` (the primary) so nothing that asks "what is this rig's URL" + # changes; this one exists so `status` and a teardown can name them all. + all="$(printf '%s,' "$fqdn" ${1+"$@"})" + all="${all%,}" + + cfn_write_config "$name" "$fqdn" "$service" ${1+"$@"} + + docker network inspect "$network" >/dev/null 2>&1 || { + echo "error: docker network '$network' does not exist — is that rig up?" >&2 + return 1 + } + + docker rm -f "$c" >/dev/null 2>&1 || true + docker run -d --name "$c" \ + --restart unless-stopped \ + --network "$network" \ + --label "hackagon.tunnel.name=$name" \ + --label "hackagon.tunnel.hostname=$fqdn" \ + --label "hackagon.tunnel.hostnames=$all" \ + --label "hackagon.tunnel.service=$service" \ + -v "$dir_d:/etc/cloudflared:ro" \ + "$CFN_IMAGE" \ + tunnel --no-autoupdate --config /etc/cloudflared/config.yml run >/dev/null + + # Two waits, because they fail for different reasons and only one of them is + # ours to fix. "Registered tunnel connection" is cloudflared reaching the edge + # — if that never appears the credentials or the network are wrong. The HTTPS + # probe is the hostname resolving and the origin answering, which additionally + # needs the DNS record and a live upstream. + printf " edge " + local i + for i in $(seq 1 40); do + if docker logs "$c" 2>&1 | grep -q "Registered tunnel connection"; then + echo "connected" + break + fi + if [ "$i" = 40 ]; then + echo "FAILED" + echo "error: cloudflared never registered a connection. Last lines:" >&2 + docker logs --tail 20 "$c" 2>&1 | sed 's/^/ /' >&2 + return 1 + fi + sleep 2 + done + + printf " https " + local code=000 + for i in $(seq 1 30); do + # `|| true`, NOT `|| echo 000`: curl writes %{http_code} — which is `000` + # on a connection failure — to stdout itself, so the fallback appended a + # SECOND 000 and the guard below saw the string "000000", which is not + # "000", so the loop broke on its first attempt and printed a code nobody + # could read. The probe reported success-shaped output having waited for + # nothing. + code="$(cfn_probe "$fqdn")" + # Anything but a connection failure means the name resolved AND Cloudflare + # reached the tunnel. A 502 here is the origin's problem, not the tunnel's, + # and saying so is more useful than waiting for it to become a 200. + [ "$code" != "000" ] && break + sleep 2 + done + + if [ "$code" != "000" ]; then + echo "$code" + return 0 + fi + + # ── it did not answer. WHICH of the two failures is it? ──────────────────── + # + # "The tunnel is broken" and "this machine cannot look the name up" produce + # the same silence and want opposite responses, so ask Cloudflare's own + # resolver over DoH and retry against the address it gives. If THAT works, the + # tunnel is fine and the local resolver is the story — which is a real state + # here, not a hypothetical: the network this was built on answers AAAA-only + # for these names and has no IPv6 route out, so every lookup succeeds and + # every connection fails in 9ms. + local ip + ip="$(curl -sS --max-time 10 "https://1.1.1.1/dns-query?name=$fqdn&type=A" \ + -H "accept: application/dns-json" 2>/dev/null | + jqr '[.Answer[]? | select(.type == 1) | .data][0] // empty' 2>/dev/null || true)" + if [ -n "$ip" ]; then + code="$(cfn_probe "$fqdn" "$ip")" + if [ "$code" != "000" ]; then + echo "$code (only via a pinned IPv4 edge)" + echo "warn: https://$fqdn WORKS — the tunnel and the DNS record are fine —" >&2 + echo " but THIS machine's resolver cannot reach it. Most often it is" >&2 + echo " answering AAAA-only on a network with no IPv6 route out." >&2 + echo " Anything running here needs the name pinned to $ip;" >&2 + echo " auth-wire.sh does that inside the dev container automatically." >&2 + return 0 + fi + fi + + echo "no answer" + echo "warn: https://$fqdn did not answer, and neither did $ip via a pinned" >&2 + echo " IPv4 edge. A freshly created record can take a minute; after that," >&2 + echo " check the origin: docker logs $c" >&2 +} + +# Fetch <url> and answer with its HTTP code, falling back to a DoH-resolved +# IPv4 edge when this machine's own resolver cannot get there. +# +# For the pre-flight checks in the rigs' wire-frontend.sh scripts, which refuse +# to write a config pointing at something that does not answer — a good rule +# that becomes a wrong ANSWER on a host whose resolver returns AAAA-only for a +# name that is serving perfectly. The check should fail when the TUNNEL is +# broken, not when the caller's DNS is. +# +# Prints the code; prints nothing else, so it can be used in a `$( )`. +cfn_http_code() { # <url> + local url="$1" host code ip + host="${url#*://}" + host="${host%%/*}" + host="${host%%:*}" + code="$(cfn_url_probe "$url")" + [ "$code" != "000" ] && { + printf '%s' "$code" + return 0 + } + ip="$(curl -sS --max-time 10 "https://1.1.1.1/dns-query?name=$host&type=A" \ + -H "accept: application/dns-json" 2>/dev/null | + jqr '[.Answer[]? | select(.type == 1) | .data][0] // empty' 2>/dev/null || true)" + [ -n "$ip" ] || { + printf '000' + return 0 + } + cfn_url_probe "$url" "$host" "$ip" +} + +cfn_url_probe() { # <url> [host ipv4] + local out + if [ -n "${3:-}" ]; then + out="$(curl -sS -o "$CURL_DISCARD" -w '%{http_code}' --max-time 20 \ + --resolve "$2:443:$3" "$1" 2>/dev/null || true)" + else + out="$(curl -sS -o "$CURL_DISCARD" -w '%{http_code}' --max-time 20 "$1" 2>/dev/null || true)" + fi + out="${out//[^0-9]/}" + [ -n "$out" ] || out=000 + printf '%s' "${out: -3}" +} + +# One probe attempt. <fqdn> [ipv4] — with an address it pins the connection to +# that edge instead of trusting the local resolver. +cfn_probe() { # -> a 3-digit code, 000 when nothing answered + local out + if [ -n "${2:-}" ]; then + out="$(curl -sS -o "$CURL_DISCARD" -w '%{http_code}' --max-time 10 \ + --resolve "$1:443:$2" "https://$1/" 2>/dev/null || true)" + else + out="$(curl -sS -o "$CURL_DISCARD" -w '%{http_code}' --max-time 10 "https://$1/" 2>/dev/null || true)" + fi + out="${out//[^0-9]/}" + [ -n "$out" ] || out=000 + # curl prints its own 000 on failure; keep the LAST three digits so a stray + # concatenation cannot read as success. + printf '%s' "${out: -3}" +} + +cfn_up() { # <name> <fqdn> <network> <service-url> [fqdn…] + local name="$1" fqdn="$2" network="$3" service="$4" + shift 4 + cfn_ensure "$name" "$fqdn" ${1+"$@"} || return 1 + cfn_run "$name" "$fqdn" "$network" "$service" ${1+"$@"} +} + +cfn_destroy() { # <name> <fqdn> [fqdn…] + local name="$1" host + shift + cfn_stop "$name" + cf_load || return 1 + cf_resolve_zone || return 1 + # Every hostname handed in, plus anything the state dir remembers — a record + # this tooling created and then forgot about is exactly the stranded record + # `destroy` exists to prevent. + local -a hosts=("$@") + if [ -s "$(cfn_dir "$name")/hostname" ]; then + while IFS= read -r host; do + [ -n "$host" ] || continue + case " ${hosts[*]-} " in *" $host "*) continue ;; esac + hosts+=("$host") + done <"$(cfn_dir "$name")/hostname" + fi + for host in ${hosts[0]+"${hosts[@]}"}; do + cf_dns_delete "$host" || true + done + local tid + tid="$(cf_tunnel_id "$name")" || return 1 + if [ -n "$tid" ]; then + cf_tunnel_delete "$tid" && echo " tunnel deleted $name ($tid)" + else + echo " tunnel absent $name" + fi + rm -rf "$(cfn_dir "$name")" +} + +cfn_status() { + local c host hosts svc + local found=0 + for c in $(docker ps --format '{{.Names}}' | grep -E '^cf-named-' || true); do + host="$(docker inspect "$c" --format '{{index .Config.Labels "hackagon.tunnel.hostname"}}' 2>/dev/null || true)" + hosts="$(docker inspect "$c" --format '{{index .Config.Labels "hackagon.tunnel.hostnames"}}' 2>/dev/null || true)" + svc="$(docker inspect "$c" --format '{{index .Config.Labels "hackagon.tunnel.service"}}' 2>/dev/null || true)" + printf '%-28s https://%-45s → %s\n' "$c" "$host" "$svc" + case "$hosts" in + "" | "$host") ;; + *) printf '%-28s also %s\n' "" "${hosts#"$host",}" ;; + esac + found=1 + done + [ "$found" -eq 1 ] || echo "no named tunnels running" +} + +# ── CLI ────────────────────────────────────────────────────────────────────── +# Sourced (BASH_SOURCE differs from $0) it defines functions and stops here. +if [ "${BASH_SOURCE[0]}" = "$0" ]; then + case "${1:-}" in + up) + shift + cfn_up "$@" + ;; + ensure) + shift + cfn_ensure "$@" + ;; + stop) + shift + cfn_stop "$@" + ;; + status) + cfn_status + ;; + url) + shift + cfn_url "$@" + ;; + running) + shift + cfn_running "$@" + ;; + destroy) + shift + cfn_destroy "$@" + ;; + check) + cf_load || { + cf_explain_unconfigured + exit 1 + } + cf_verify_token || exit 1 + cf_resolve_zone || exit 1 + echo "credentials ok — zone $CLOUDFLARE_ZONE is active and writable" + echo " app ${HACKAGON_HOSTNAME:-(unset)}" + echo " plausible ${PLAUSIBLE_HOSTNAME:-(unset)}" + echo " openreplay ${OPENREPLAY_HOSTNAME:-(unset)}" + echo " k3d app ${K3D_HOSTNAME:-(unset)}" + echo " k3d auth ${K3D_AUTH_HOSTNAME:-(unset)}" + ;; + -h | --help | "") + sed -n '2,20p' "${BASH_SOURCE[0]}" | sed 's/^# \{0,1\}//' + exit 0 + ;; + *) + echo "unknown command: $1 (see --help)" >&2 + exit 2 + ;; + esac +fi diff --git a/.claude/skills/lib/config-overlay.sh b/.claude/skills/lib/config-overlay.sh new file mode 100644 index 00000000..7476a86a --- /dev/null +++ b/.claude/skills/lib/config-overlay.sh @@ -0,0 +1,208 @@ +#!/usr/bin/env bash +# Edit `config.local.yaml` ONE TOP-LEVEL KEY AT A TIME. +# +# config-overlay.sh set <file> <key> # full block on stdin, first line `<key>:` +# config-overlay.sh get <file> <key> # that block on stdout (empty if absent) +# config-overlay.sh remove <file> <key> +# config-overlay.sh has <file> <key> # exit 0 when present +# config-overlay.sh keys <file> # one top-level key per line +# +# `get` round-trips into `set`, which is what lets a caller borrow a key for +# the duration of something and hand it back untouched. +# +# `set` and `remove` print `changed` or `unchanged` on stdout, so a caller can +# restart a server only when it actually has to. `remove` deletes the FILE when +# it takes the last key with it, which is what keeps an unwired machine looking +# exactly like a fresh clone. +# +# WHY THIS IS NOT JUST AN `rm`. +# +# config.local.yaml is the gitignored overlay both loaders read after (and +# merge over) the tracked config.yaml — components/frontend/src/lib/server/ +# settings.ts and components/backend/internal/config/config.go. It exists so +# machine-specific wiring never dirties a tracked file, after a Cloudflare +# tunnel hostname that dies in a few hours sat committed for several commits. +# +# It now has MORE THAN ONE WRITER, and they are unaware of each other: +# +# oidc: .claude/skills/cloudflare-tunnel/scripts/auth-wire.sh +# replay: .claude/skills/openreplay-stack/scripts/wire-frontend.sh +# plausible: .claude/skills/plausible-stack/scripts/wire-frontend.sh +# +# Each owns exactly one top-level key. If either treated "unwire me" as +# "delete the overlay", it would silently take the other's wiring with it — +# and the failure is invisible in both directions: unwiring the tunnel would +# stop session replay recording (an empty OpenReplay UI, which already looks +# like the correct default), and unwiring replay would drop the tunnel's +# issuer, so the public URL keeps serving pages and only LOGIN breaks. That +# second one is not hypothetical: `hackathon-e2e/scripts/run.sh` calls +# `auth-wire.sh --restore` on the way into EVERY suite run. +# +# So: keys are added and removed independently, and the file disappears only +# when nothing is left in it. +# +# FILE FORMAT, which is this script's alone — nothing hand-edits the overlay: +# * a leading run of comments/blank lines is the HEADER, owned here and +# rewritten on every write; +# * a top-level key starts at column 0 and its block runs to the next +# column-0 key or EOF; +# * comments INSIDE a block must be indented, or they read as the start of +# the next block. Both writers indent theirs. +set -euo pipefail + +HEADER='# GENERATED — machine-local config overlay. Do not edit, do not commit +# (it is gitignored). Deep-merged over config.yaml by the loader, then +# validated by the same schema, so this is not a way in for an invalid config. +# +# Each top-level key below is owned by ONE tool and is added and removed +# independently. Do not delete this file to "turn something off": that takes +# the other tools'"'"' wiring with it. +# +# oidc: .claude/skills/cloudflare-tunnel/scripts/auth-wire.sh [--restore] +# replay: .claude/skills/openreplay-stack/scripts/wire-frontend.sh [--restore] +# plausible: .claude/skills/plausible-stack/scripts/wire-frontend.sh [--restore]' + +usage() { + sed -n '2,7p' "${BASH_SOURCE[0]}" | sed 's/^# \{0,1\}//' >&2 + exit 2 +} + +# Everything except the header and the named key's block. +strip_key() { # <file> <key> + [ -f "$1" ] || return 0 + awk -v key="$2" ' + # Header: comments and blanks before the first top-level key. + !started && /^[[:space:]]*(#.*)?$/ { next } + /^[A-Za-z_][A-Za-z0-9_-]*:/ { started = 1; inblock = (index($0, key ":") == 1) } + !started { next } + inblock { next } + { print } + ' "$1" +} + +trim_blank_lines() { # stdin -> stdout, leading/trailing blank lines removed + awk ' + { line[n++] = $0 } + END { + s = 0; while (s < n && line[s] ~ /^[[:space:]]*$/) s++ + e = n - 1; while (e >= s && line[e] ~ /^[[:space:]]*$/) e-- + for (i = s; i <= e; i++) print line[i] + } + ' +} + +# Just the named key's block, as it currently stands. +extract_key() { # <file> <key> + [ -f "$1" ] || return 0 + awk -v key="$2" ' + /^[A-Za-z_][A-Za-z0-9_-]*:/ { inblock = (index($0, key ":") == 1) } + inblock { print } + ' "$1" +} + +has_key() { # <file> <key> + [ -f "$1" ] || return 1 + awk -v key="$2" ' + /^[A-Za-z_][A-Za-z0-9_-]*:/ && index($0, key ":") == 1 { found = 1 } + END { exit found ? 0 : 1 } + ' "$1" +} + +# Replace <file> with <tmp> only when the bytes differ. The answer is the +# caller's cue to restart a server, and "restart anyway, it is idempotent" is +# not free here: a needless bounce of :8081 mid-suite fails one unrelated test +# and nothing in the output points back. +# +# Compared as strings rather than with `cmp`: this runs both on a Git Bash host +# and inside the Nix dev shell, and diffutils is not guaranteed on the latter's +# PATH. The overlay is generated text that always ends in exactly one newline, +# so the trailing-newline stripping that `$(cat …)` does applies to both sides. +commit() { # <tmp> <file> + if [ -f "$2" ] && [ "$(cat "$1")" = "$(cat "$2")" ]; then + rm -f "$1" + echo "unchanged" + else + mv "$1" "$2" + echo "changed" + fi +} + +cmd="${1:-}" +[ $# -ge 2 ] || usage +file="$2" +key="${3:-}" + +case "$cmd" in +set) + [ -n "$key" ] || usage + block="$(cat | trim_blank_lines)" + # A block that does not start with the key it claims to be would land in + # the file under someone else's name and be removable by nobody. + case "$block" in + "$key:"* | "$key: "*) ;; + *) + echo "config-overlay: block for '$key' must start with '$key:'" >&2 + exit 2 + ;; + esac + # An identical block is a no-op, and saying so matters more than it looks: + # a rewrite would move the key to the END of the file, which changes the + # bytes, which tells the caller to restart a server for nothing. Wiring + # scripts bounce :8081, and a needless bounce mid-suite fails one unrelated + # test with nothing pointing back here. + if [ "$(extract_key "$file" "$key" | trim_blank_lines)" = "$block" ]; then + echo "unchanged" + exit 0 + fi + rest="$(strip_key "$file" "$key" | trim_blank_lines)" + tmp="$file.tmp.$$" + { + printf '%s\n' "$HEADER" + if [ -n "$rest" ]; then printf '\n%s\n' "$rest"; fi + printf '\n%s\n' "$block" + } >"$tmp" + commit "$tmp" "$file" + ;; + +remove) + [ -n "$key" ] || usage + if [ ! -f "$file" ]; then + echo "unchanged" + exit 0 + fi + rest="$(strip_key "$file" "$key" | trim_blank_lines)" + if [ -z "$rest" ]; then + # Nothing but the header would be left. An empty overlay and no overlay + # are the same configuration, and only one of them looks like a fresh + # clone to whoever opens the directory next. + if has_key "$file" "$key"; then + rm -f "$file" + echo "changed" + else + rm -f "$file" + echo "unchanged" + fi + exit 0 + fi + tmp="$file.tmp.$$" + { + printf '%s\n' "$HEADER" + printf '\n%s\n' "$rest" + } >"$tmp" + commit "$tmp" "$file" + ;; + +get) + [ -n "$key" ] || usage + extract_key "$file" "$key" | trim_blank_lines + ;; + +has) has_key "$file" "$key" ;; + +keys) + [ -f "$file" ] || exit 0 + awk '/^[A-Za-z_][A-Za-z0-9_-]*:/ { sub(/:.*/, ""); print }' "$file" + ;; + +*) usage ;; +esac diff --git a/.claude/skills/lib/frontend-build.sh b/.claude/skills/lib/frontend-build.sh new file mode 100644 index 00000000..44cbb269 --- /dev/null +++ b/.claude/skills/lib/frontend-build.sh @@ -0,0 +1,193 @@ +#!/usr/bin/env bash +# THE ONE WRITER of components/frontend/build/service. +# +# frontend-build.sh build # build, unconditionally +# frontend-build.sh if-stale # build only if src/ moved under the last build +# frontend-build.sh stale # exit 0 when a build is needed (no build) +# +# WHY THIS EXISTS. +# +# `pnpm build` in components/frontend is `vite build -m production`, and +# svelte.config.js sends adapter-node's output to +# `${QUITSH_BUILD_DIR:-build}/service`. Nothing in that chain is atomic and +# nothing in it is exclusive, so two builds running at once write the same tree +# — and vite also shares `.svelte-kit/output` between them. Observed +# 2026-08-13, three agents driving the harness concurrently: +# +# Unexpected end of JSON input (a half-written manifest read back) +# Cannot find module '…/build/service/server/index.js' (at server boot) +# +# There were TWO independent callers before this file existed, both writing +# `build/service` with no coordination whatsoever: +# +# hackathon-e2e/scripts/prod-frontend.sh serves that tree on :8081 +# cloudflare-tunnel/scripts/prod-serve.sh serves the SAME tree on :8082 +# +# so the two servers do not merely race to build it, they race to build it out +# from under each other while serving it. +# +# The fix is both halves, because they close different holes: +# +# 1. an exclusive LOCK, so two builds cannot interleave, and so the second +# caller waits and then discovers the first caller's fresh output instead +# of redoing it (staleness is re-checked INSIDE the lock — checking it +# outside is how both callers decide to build); +# +# 2. an atomic SWAP, so `build/service` only ever contains a COMPLETE tree. +# A build is minutes long on the 9p mount and gets interrupted (Ctrl-C, a +# container recreate, a suite timeout); without this, whatever it had +# written so far stays there looking like a build, and the next server to +# boot dies on a missing or truncated file. The lock alone cannot help +# with that — the writer is gone, not concurrent. +# +# Callers should not have to know any of this, which is why staleness lives here +# too rather than being reimplemented per caller. +set -euo pipefail +trap 'echo "frontend-build.sh: aborted at line $LINENO (status $?)" >&2' ERR + +HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +ROOT_DIR="$(cd "$HERE/../../.." && pwd)" +FRONTEND_DIR="$ROOT_DIR/components/frontend" +OUT_PARENT="$FRONTEND_DIR/build" +OUT="$OUT_PARENT/service" +ENTRY="$OUT/index.js" +RUN_DIR="$ROOT_DIR/.output/run" +LOCK="$RUN_DIR/frontend-build.lock" +BUILD_LOG="$RUN_DIR/frontend-build.log" +# Long, because the thing being waited for is a full production build of this app +# on a 9p bind mount. Measured 2026-08-13: ~2 min warm. A caller that gives up +# early is a caller that builds concurrently, which is the bug. +LOCK_WAIT="${FRONTEND_BUILD_LOCK_WAIT:-900}" + +mkdir -p "$RUN_DIR" + +# True when src/ (or the build's own inputs) moved since the last build. Same +# rule prod-frontend.sh used to carry inline; a stale build is worse than no +# build, because a suite then reports green against yesterday's frontend. +stale() { + [ -f "$ENTRY" ] || return 0 + local newer + newer="$(cd "$FRONTEND_DIR" && + find src static package.json pnpm-lock.yaml svelte.config.js vite.config.ts \ + -newer "$ENTRY" -print -quit 2>/dev/null || true)" + [ -n "$newer" ] +} + +# Build into a private directory, then move it into place. +# +# QUITSH_BUILD_DIR is read by svelte.config.js (`out = $QUITSH_BUILD_DIR/service`), +# so the temp tree needs no config change — and it stays inside `build/`, which +# is gitignored, so an interrupted build cannot dirty the worktree either. That +# matters more here than it looks: a dirty worktree is what makes every +# `nix develop` in this repo re-fetch and re-hash the tree under a global lock. +build_locked() { + local tmp="$OUT_PARENT/.build-$$" + # Sweep any temp trees a KILLED build left behind. Safe here and only here: we + # hold the lock, so no live build owns one. Without this they accumulate — the + # whole point of building elsewhere is that an interrupted build leaves its mess + # somewhere nothing reads, but somewhere is still on disk. + rm -rf "$OUT_PARENT"/.build-* "$OUT_PARENT"/.service-old-* + mkdir -p "$tmp" + + echo "==> Building the frontend (exclusive; log: $BUILD_LOG)..." + if ! (cd "$FRONTEND_DIR" && QUITSH_BUILD_DIR="$tmp" pnpm build) >"$BUILD_LOG" 2>&1; then + echo "error: pnpm build failed — see $BUILD_LOG" >&2 + tail -30 "$BUILD_LOG" >&2 + rm -rf "$tmp" + return 1 + fi + # Check the tree BEFORE swapping it in, and check the file that actually went + # missing. "vite exited 0" and "the server can boot" are different claims — the + # reported failure was `Cannot find module …/build/service/server/index.js`, at + # boot, from a build that had reported success. Nothing incomplete gets to + # replace a working tree. + local f + for f in index.js handler.js server/index.js; do + if [ ! -f "$tmp/service/$f" ]; then + echo "error: build reported success but $tmp/service/$f is missing —" >&2 + echo " refusing to swap it over the working build. See $BUILD_LOG." >&2 + rm -rf "$tmp" + return 1 + fi + done + + # Two renames on one filesystem, old tree out of the way first, so the window + # in which `build/service` does not exist is a single rename long. Callers + # start their server after this function returns, so nothing reads it during + # the swap. + # + # RETRIED, because `build/` is on the 9p bind mount and a directory rename + # there intermittently answers EPERM: + # + # mv: cannot move '…/build/service' to '…/build/.service-old-352884': + # Permission denied + # + # Observed 2026-08-13 mid-run and NOT reproducible a minute later with the same + # processes running and no open descriptors anywhere under the tree — so at + # least some of it is the filesystem, not a lock we could take or a handle we + # could close. An abort here is safe (the working tree is untouched) but it + # fails a build for a reason that clears itself, which is its own kind of flake. + # + # ⚠ ONE cause IS reproducible, found 2026-08-16: a server still SERVING this + # tree. `frontend-build.sh build` called with the :8081 adapter-node server up + # failed all five attempts; `prod-frontend.sh stop` and the very next attempt + # succeeded, with the :8082 server still running. So the retries are not the + # answer when a live server holds it — the order is stop, build, start, which + # is exactly what prod-frontend.sh's `start` already does. Calling this script + # directly against a running server is the case that hits the wall. + local old="$OUT_PARENT/.service-old-$$" i + rm -rf "$old" + for i in 1 2 3 4 5; do + [ -d "$OUT" ] || break + mv "$OUT" "$old" 2>/dev/null && break + sleep 2 + done + if [ -d "$OUT" ]; then + echo "error: could not move $OUT aside after 5 attempts (9p EPERM — see above)." >&2 + echo " The existing build is untouched; the new one is in $tmp." >&2 + return 1 + fi + # If THIS one fails the tree would be missing entirely, which is the exact + # state this whole file exists to prevent — put the old one back. + if ! mv "$tmp/service" "$OUT"; then + if [ -d "$old" ]; then mv "$old" "$OUT"; fi + echo "error: could not move the new build into place; restored the previous one." >&2 + return 1 + fi + rm -rf "$old" "$tmp" + echo " built" +} + +case "${1:-if-stale}" in +stale) + # No lock: a read-only question, and the answer is only ever used to decide + # whether to CALL this script, which locks properly. `exit`, not a bare + # `stale`, so "not stale" is an ANSWER rather than an aborted script — the + # ERR trap above would otherwise print a scary line for the normal case. + if stale; then exit 0; else exit 1; fi + ;; +build) + flock -w "$LOCK_WAIT" 9 || { + echo "error: another frontend build held the lock for ${LOCK_WAIT}s" >&2 + exit 1 + } + build_locked + ;; +if-stale) + flock -w "$LOCK_WAIT" 9 || { + echo "error: another frontend build held the lock for ${LOCK_WAIT}s" >&2 + exit 1 + } + # INSIDE the lock. Whoever waited here was very likely waiting for exactly + # the build it wanted; re-asking is what turns N concurrent builds into one. + if stale; then + build_locked + else + echo "==> The frontend build is current — nothing to do." + fi + ;; +*) + echo "usage: frontend-build.sh [build|if-stale|stale]" >&2 + exit 2 + ;; +esac 9>"$LOCK" diff --git a/.claude/skills/openreplay-stack/.gitignore b/.claude/skills/openreplay-stack/.gitignore new file mode 100644 index 00000000..00219516 --- /dev/null +++ b/.claude/skills/openreplay-stack/.gitignore @@ -0,0 +1,9 @@ +# Upstream sources are fetched, not vendored into git (pinned + SHA-recorded +# by scripts/fetch-upstream.sh, so a fetch is reproducible). +vendor/ +.state/ + +# Admin credentials, generated by scripts/signup.sh on first run. This entry +# matters on feat/claude, where the skill is TRACKED — signup.sh refuses to +# write the file at all if git would not ignore it. +.secrets.env diff --git a/.claude/skills/openreplay-stack/SKILL.md b/.claude/skills/openreplay-stack/SKILL.md new file mode 100644 index 00000000..c547c8f3 --- /dev/null +++ b/.claude/skills/openreplay-stack/SKILL.md @@ -0,0 +1,324 @@ +--- +name: openreplay-stack +description: + Spin up a self-hosted OpenReplay (session replay) instance with docker + compose, behind a Cloudflare quick tunnel so the tracker can reach it from a + browser. Vendors the upstream compose into the skill folder, prepares secrets + non-interactively, and points the stack at the tunnel URL. Use when asked to + run/try/evaluate OpenReplay, set up session replay, or get a public ingest + endpoint for the tracker. +--- + +# OpenReplay behind a quick tunnel + +A **debugging rig**, not a deployment. It brings up the full upstream stack (23 +long-running services, plus four one-shot migration containers) with a +`*.trycloudflare.com` URL in front, so you can wire the SvelteKit tracker to a +real ingest endpoint without owning a domain or a VM. + +## Commands + +```bash +bash .claude/skills/openreplay-stack/scripts/doctor.sh # preflight — run this first +bash .claude/skills/openreplay-stack/scripts/up.sh # fetch, prepare, tunnel, start, admin account +bash .claude/skills/openreplay-stack/scripts/up.sh --dry-run # everything except `compose up` +bash .claude/skills/openreplay-stack/scripts/url.sh # current public URL +bash .claude/skills/openreplay-stack/scripts/signup.sh # (re)ensure the admin account — up.sh already does +bash .claude/skills/openreplay-stack/scripts/retention.sh # what is past the cutoff (dry run) +bash .claude/skills/openreplay-stack/scripts/down.sh # stop, keep recorded sessions +bash .claude/skills/openreplay-stack/scripts/down.sh --volumes # stop and delete everything +``` + +First run pulls ~25 images and runs DB migrations — expect a long wait. There is +no signing up by hand: **`up.sh` creates the admin account itself** from +`.secrets.env` (see below) and prints the project key when done. + +## Named hostname (`--named`) + +`up.sh --named` puts this rig on a **persistent hostname you own** instead of a +quick tunnel, using the same Cloudflare credentials as the app's tunnel — +`OPENREPLAY_HOSTNAME` in the gitignored `.claude/skills/cloudflare-tunnel/.env`. +See that skill's SKILL.md for the token and its real (zone-wide) scope. Named is +the default when configured; `--quick` forces the old behaviour, which still +needs no account at all. + +**This is the rig a stable hostname helps most.** `COMMON_DOMAIN_NAME` is baked +into ~25 containers at boot, so every new quick-tunnel URL means rewriting +`common.env` and recreating the whole stack — and the app's `replay.ingestPoint` +with it. A tracker still holding the old ingest host posts sessions into +nothing, and an empty OpenReplay UI looks exactly like the correct default (the +tracker is consent-gated), so that failure is silent in the worst way. With a +named hostname, `wire-frontend.sh` is run once and stays true. + +Only one tunnel runs at a time: `--named` stops the quick tunnel and vice versa, +because `COMMON_DOMAIN_NAME` names exactly one of them. + +## Layout + +``` +compose.tunnel.yaml overlay: adds cloudflared, unpublishes caddy's and minio's host ports +scripts/ doctor · fetch-upstream · up · url · down + signup (create the admin account from .secrets.env) + wire-frontend (point the app at this rig, and back) + retention (purge expired sessions — see below) +vendor/ upstream scripts/docker-compose/ (fetched, gitignored) +vendor/UPSTREAM.txt repo, ref and exact commit — a fetch is reproducible +.state/ tunnel URL, "secrets prepared" marker (gitignored) +.secrets.env admin email + password (generated, gitignored — see below) +``` + +`vendor/` is fetched by sparse checkout at a pinned ref (`OPENREPLAY_REF`, +default `main`) rather than committed: it is upstream's code, it moves, and the +recorded commit makes any fetch repeatable. Upstream's `install.sh` is **not** +run — it prompts for a domain, wants sudo, and ends by starting everything; +`up.sh` does the same preparation non-interactively and keeps control. + +## Why there is no second Caddy + +**OpenReplay already ships Caddy as its ingress** +(`{$CADDY_DOMAIN} { reverse_proxy nginx-openreplay:80 }`), so putting another +proxy in front would be pure overhead. The overlay adds only `cloudflared` +pointing at that Caddy, and unpublishes its host ports — 80/443 are the +likeliest to collide, and the tunnel is the entrypoint anyway. Set +`OPENREPLAY_PUBLISH_PORTS=1` if you want them back. + +Three settings make this work, and they are easy to get wrong: + +| Setting | Value | Why | +| -------------------- | ------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------- | +| `CADDY_DOMAIN` | `:80` | Caddy serves plain HTTP for any Host. Cloudflare terminates TLS at its edge, and ACME could never validate a `trycloudflare.com` hostname from here. | +| `COMMON_PROTOCOL` | `https` | The _public_ URL is https — this is what the app builds links with. | +| `COMMON_DOMAIN_NAME` | bare hostname | No scheme: the scheme lives in `COMMON_PROTOCOL`. | + +## The ordering trap + +`COMMON_DOMAIN_NAME`'s placeholder is `change_me_domain`, which _also_ matches +upstream's secret pattern `change_me_[a-zA-Z0-9_]*`. Randomize the secrets +naively and the domain is silently replaced with hex — the stack then boots +against a nonsense hostname. `up.sh` excludes it and sets the domain separately, +after the tunnel URL is known (upstream sidesteps the same trap by substituting +the domain first). + +This is also why the **tunnel starts before the app**: OpenReplay bakes the +public hostname into its config, so it must be known first. Every `up.sh` mints +a _new_ URL and rewrites the config — fine for debugging, unworkable for +anything lasting. For that, use a **named** Cloudflare tunnel with a stable +hostname and set `COMMON_DOMAIN_NAME` once. + +## The admin account, and where its password lives + +OpenReplay seeds no account: the first signup at `<url>/signup` becomes the +admin, and there is no recovery path — an admin created by hand with an +unrecorded password once made the dashboard permanently unreachable, and the +only fix was `down.sh --volumes`. So `up.sh` runs `scripts/signup.sh`, which +polls `GET /api/signup` until it answers, and: + +- `tenants: false` → `POST /api/signup` with + `{email, password, fullname, organizationName}` (the route read from chalice's + `routers/core_dynamic.py`; it is only registered while no tenant exists, which + is why the GET is always checked first), then verifies a `POST /api/login` + with the stored credentials and prints the project key. +- `tenants: true` → skips signup, still verifies the login. A failed login here + WARNS and exits 0 — an existing account must never fail a bring-up — and names + the two ways out (fix `.secrets.env`, or wipe). + +**Credentials live in `.secrets.env`** (skill root), generated on first run — +`openssl rand -hex 16` — shown once, then readable only from the file. Chosen +because it is the repo's existing convention for dev secrets +(`.devcontainer/post-create.sh` mints the gitignored `secrets.yaml` the same +way), because `sops`/`age` are not in the toolchain and this script runs on the +HOST anyway, and because env-vars-only would recreate exactly the lost-password +incident on the next machine. Environment variables override the file when set. +The file sits OUTSIDE `.state/` on purpose: it must survive `down.sh` and even +`--volumes` — after a wipe, the next `up.sh` re-creates the same account from +it, so the wipe is the recovery path and not a second loss. It is gitignored in +the skill's own `.gitignore` (which is what protects it on `feat/claude`, where +the skill is tracked), and `signup.sh` refuses to write it at all if git would +not ignore it. + +## Wiring the tracker + +`up.sh` prints the project key after creating the account (or +`wire-frontend.sh --print` shows it any time): + +``` +ingestPoint: <url>/ingest +projectKey: <printed by up.sh / signup.sh> +``` + +`docs/frontend/session-replay.md` is the statement of what this collects, when, +and on whose say-so — read it before pointing the tracker at anything real. + +Two of the original blockers were settled differently from the plan, and +`docs/TODO.md` records why. `diet` needed no per-field rule (masking is +default-deny, so every input is hidden and every text node starred), and consent +could **not** reuse the registration-consent mechanism: that is an agreement +with one event, keyed `(hackathon, user)`, while the tracker runs before an +event is chosen and for visitors who have no `User` row. It is a first-party +cookie instead, read server-side. + +## Cost and caveats + +- **2 vCPU / 8 GB RAM / 50 GB disk, x86 only** — below that the backend services + do not start, which presents as a hang. `doctor.sh` checks all four, plus port + conflicts and whether the hackagon dev container is already eating memory + (OpenReplay wants its 8 GB _on top_). +- Upstream documents the Docker Compose path as **experimental**; Kubernetes + (k3s) is the supported one. +- Quick tunnels are free and best-effort. Session replay is chatty (DOM + mutations, network, console) — fine for a handful of debug sessions, not for a + hackathon's worth of traffic. + +## Verification status + +**Booted for real on 2026-08-08** (Windows host, Docker Desktop, 47 GB / 32 +vCPU): all 23 services running, four migrations exited 0, the tunnel serving 200 +on `/` and `/signup`, an account created and a project key read back from +`/api/projects`. Sessions recorded from the SvelteKit app arrive and are stored, +and the e2e `openreplay` project passes against it (7 tests: consent, masking, +Do Not Track — plus the 4 auth-setup tests it depends on). + +Getting there took **five fixes, and `--dry-run` could not have found any of +them** — it proves the compose files merge, which is a different claim from "the +containers can talk to each other and their scripts can run": + +| Symptom | Cause | +| ------------------------------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `Bind for 0.0.0.0:9001 failed` mid-`up` | upstream publishes minio's console port; the hackagon devcontainer's own rustfs already holds 9000-9001. Overlay unpublishes it, as it already did for caddy. | +| `$'\r': command not found` in `minio-migration`, then an S3 signature mismatch | a Windows host with `core.autocrlf=true` checks the vendored tree out as CRLF. `fetch-upstream.sh` clones with `-c core.autocrlf=false -c core.eol=lf`. | +| `chalice`: `Invalid endpoint: https://change_me_domain` | `ln -s common.env .env` silently degrades to a COPY on MSYS, so compose interpolated a `.env` frozen before the domain was known. `up.sh` copies explicitly, after every edit. | +| `network <id> not found` on every subsequent `up` | `down` ran without `COMPOSE_PROFILES=migration`, so the four migration containers survived holding a reference to the deleted network. A profile-gated service is not an "orphan". | +| public URL answered 502 while every container was healthy | the overlay's `tunnel` named no network, so it landed on compose's implicit `default` — alone. `http://caddy:80` failed to RESOLVE, not to connect. | + +## Wiring the app (and unwiring it) + +```bash +bash .claude/skills/openreplay-stack/scripts/wire-frontend.sh # ON +bash .claude/skills/openreplay-stack/scripts/wire-frontend.sh --restore # OFF +``` + +It reads the live tunnel URL and the project key from OpenReplay's own API (a +quick tunnel mints a new hostname on every `up.sh`, and a stale `ingestPoint` +fails silently), writes the `replay:` block, and restarts the frontend. +Credentials come from `.secrets.env` automatically; `OPENREPLAY_EMAIL` / +`OPENREPLAY_PASSWORD` in the environment override it, and +`OPENREPLAY_PROJECT_KEY` skips the login entirely. + +Or get the whole thing — stack, rig, wiring and the recording proof — in one +command: `devcontainer-up/scripts/start.sh --replay`. + +**It writes `config.local.yaml`, never the tracked `config.yaml`.** The block +carries a `*.trycloudflare.com` ingest hostname that is this machine's for the +next few hours; a `sed` into the tracked file left a wired dev machine with a +dirty working tree, which is how the tunnel's OIDC issuer once got committed and +sat dead in HEAD for several commits. The overlay is gitignored and deep-merged +over `config.yaml` by the same loader, validated by the same schema. + +**That file has two writers**, and they do not know about each other: +`cloudflare-tunnel/scripts/auth-wire.sh` owns `oidc`, this script owns `replay`. +So `--restore` removes the **block**, not the file — +`.claude/skills/lib/config-overlay.sh` does the per-key edit for both, and +deletes the file only when the last key leaves it. Clobbering the overlay would +be 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 one is not +hypothetical — `hackathon-e2e/scripts/run.sh` calls `auth-wire.sh --restore` on +the way into every suite run. + +⚠ **Anything reading the replay config must read the MERGED view.** +`tests/openreplay/capture.ts` reads `config.yaml` overlaid with +`config.local.yaml`, because a reader that only looked at the tracked file would +find `enabled` absent on a perfectly well-wired machine — every spec in that +folder would `test.skip`, and the suite would report green having verified +nothing about masking, consent or Do Not Track. + +**Two servers can own :8081.** process-compose's `frontend` is `vite dev`; the +e2e harness replaces it with the adapter-node build via +`hackathon-e2e/scripts/prod-frontend.sh`, and after a suite run that is what +serves. The built server reads its config ONCE at boot, so restarting only +process-compose's copy succeeds, prints "Process frontend restarted", and +changes nothing — the page keeps rendering `replay: null`. `wire-frontend.sh` +bounces both. + +## Retention: there isn't one, so there's a script + +Upstream's compose distribution has **no session-retention setting** — checked, +not assumed. `vendor/docker-envs/*.env` has nothing of the kind (`FS_CLEAN_HRS` +is about a container's own scratch files), and `experimental.sessions` in +`vendor/migration-files/init_ch_schema.sql` carries no TTL. Retention limits are +an EE feature. Left alone, a self-hosted rig keeps every recording of every +visitor forever. + +```bash +bash scripts/retention.sh # dry run, 30 days +bash scripts/retention.sh --days 30 --apply # purge +bash scripts/retention.sh --days 30 --apply --install-ttl +``` + +**A session lives in four places**, and deleting from one leaves the others +holding the same visit: + +| Where | What | How it is deleted | +| ------------ | ------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------- | +| object store | `mobs/<session_id>/dom.mobs`, `/devtools.mob` — the recording itself | boto3 inside `chalice`, which already holds the S3 credentials | +| Postgres | `public.sessions` + ~20 event tables | one `DELETE` on the parent; every FK is `ON DELETE CASCADE` (verified against `pg_constraint`) | +| ClickHouse | `experimental.sessions`, `product_analytics.events`, the by-`session_id` tables | `ALTER … DELETE`, plus an optional declarative TTL | +| Hackagon | nothing, by design — there is no session id on our side | — | + +**Order is not arbitrary.** Postgres is where the expired ids are _enumerated_, +so it is deleted **last**. An interrupted run then leaves rows pointing at +nothing, which the next run fixes; the reverse order would leave recordings that +nothing can enumerate any more — undeletable except by wiping the bucket. + +`--install-ttl` is the closest thing to a built-in setting (ClickHouse expires +parts on its own, no cron), but it reaches neither the recordings nor Postgres, +which is where the actual personal data is. It supplements the purge; it does +not replace it. + +## Consent: nobody is recorded who did not say yes + +Wiring this rig up no longer means "every visitor to localhost:8081 is +recorded". A deployment switch (`replay.enabled`) and a per-browser consent must +**both** be on, and the consent is the visitor's — asked by a banner, +withdrawable at `/account`, honoured before the tracker's config is ever sent to +the page. A browser sending `DNT: 1` is not recorded even if it consented +(`tests/openreplay/dnt.spec.ts`). `docs/frontend/session-replay.md` is the full +statement; the proof is `hackathon-e2e/tests/openreplay/consent.spec.ts`, which +counts bytes on the wire rather than asserting that a flag was read. + +Practical consequence for anyone driving this rig by hand: **after wiring, load +a page and click "Allow recording"**, or the OpenReplay UI will stay empty and +look broken. + +## Three ways this rig looks healthy and records nothing watchable + +Found on 2026-08-11, after every session in the UI spun forever. **Not one of +them could turn a spec red**, because every replay spec measured bytes leaving +the BROWSER and all three faults are downstream of the ingest endpoint — which +answered `200` to every batch throughout. + +| Fault | How it presents | Fix | +| ----------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| the `sink` container was not running | nothing. `docker compose ps` lists what IS there; a service whose container was removed reads exactly like one that was never meant to run. sink writes the raw session file every later stage reads, so no mob file was ever produced. | `up.sh` (or `compose up -d sink-openreplay`). `doctor.sh` now compares `compose config --services` against what is running and fails on the difference. | +| the object store answered `NoSuchBucket` for `mobs` | uploads failed silently; the bucket, its metadata and previously written objects were all present on disk, and a signed `PUT` still 404'd. | `docker restart minio`. Confirmed by signing a request by hand: 404 before, 200 and 7 kB of zstd after. | +| `ender`: `batch meta not at the start of batch`, once per session | a `broken batch(es)` warning at session end. `Iterate` (backend/pkg/messages/iterator.go) RETURNS on the first parse error, so a rejected batch is dropped WHOLE. | **Not ours and not fixed** — every batch the tracker posts was verified well-formed (`playable.spec.ts` walks each one and locates every `BatchMetadata`). It costs one batch per session; the recording still lands and plays. | + +The lasting guard is `hackathon-e2e/tests/openreplay/playable.spec.ts`, which +asks the far end whether the session became a recording instead of trusting a +`200`. It needs `.secrets.env` — reading a stored mob is authenticated — and +deliberately does NOT self-skip when that file is missing. + +## Masking is proved, not configured + +`components/frontend/src/lib/components/observability/SessionReplay.svelte` sets +default-deny masking; `hackathon-e2e/tests/openreplay/masking.spec.ts` types a +sentinel into the registration form and greps the tracker's own ingest bytes for +it. It runs an **unmasked control first**, because a zero-hit grep reads +identically whether the string was masked or nothing was ever captured — and on +the first run nothing was. + +One hole no option closes: the tracker masks TEXT NODES and input values but +sends ATTRIBUTE values verbatim (only `alt`/`placeholder` are starred, `href` +blanked). `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. diff --git a/.claude/skills/openreplay-stack/compose.tunnel.yaml b/.claude/skills/openreplay-stack/compose.tunnel.yaml new file mode 100644 index 00000000..0bf33b7f --- /dev/null +++ b/.claude/skills/openreplay-stack/compose.tunnel.yaml @@ -0,0 +1,46 @@ +# Overlay merged on top of the vendored upstream compose: +# docker compose -f vendor/docker-compose.yaml -f compose.tunnel.yaml … +# +# OpenReplay already ships Caddy as its ingress, so we add exactly one thing: +# a Cloudflare quick tunnel pointing at it. TLS is terminated at Cloudflare's +# edge, so Caddy must serve plain HTTP — up.sh sets CADDY_DOMAIN=:80, which +# makes Caddy listen on 80 for any Host and skip ACME entirely (a +# trycloudflare hostname could never pass an HTTP-01 challenge here). +services: + tunnel: + image: cloudflare/cloudflared:latest + container_name: openreplay-tunnel + restart: unless-stopped + # --no-autoupdate: the container is disposable; an in-place update would + # restart the process and silently mint a NEW public URL. + command: tunnel --no-autoupdate --url http://caddy:80 + depends_on: + - caddy + # Upstream puts every service on `openreplay-net` and defines no default + # network. A service in an overlay that names none of its own therefore + # lands on compose's implicit `default` — alone — and `http://caddy:80` + # fails to RESOLVE rather than to connect: + # dial tcp: lookup caddy on 127.0.0.11:53: no such host + # which Cloudflare renders as a plain 502 on the public URL while every + # container reports healthy. `compose config` cannot catch this: the + # service resolves fine there, it is only the network membership that is + # wrong. + networks: + - openreplay-net + + # Host ports are pointless when the tunnel is the entrypoint, and 80/443 are + # the two most likely to collide with something already running. Publish + # nothing by default; set OPENREPLAY_PUBLISH_PORTS=1 to get them back. + caddy: + ports: !override [] + + # Same reasoning, second offender. Upstream publishes 9001 for the object + # store's web console, which nothing here uses — everything reaches it over + # the compose network. On a machine that already runs the hackagon dev + # container it is not merely useless but fatal: devcontainer's own rustfs + # holds 9000-9001, so `up` dies with + # Bind for 0.0.0.0:9001 failed: port is already allocated + # after the first six containers have started, which reads as a stack + # failure rather than a port clash. + minio: + ports: !override [] diff --git a/.claude/skills/openreplay-stack/scripts/doctor.sh b/.claude/skills/openreplay-stack/scripts/doctor.sh new file mode 100644 index 00000000..b1af7aa4 --- /dev/null +++ b/.claude/skills/openreplay-stack/scripts/doctor.sh @@ -0,0 +1,91 @@ +#!/usr/bin/env bash +# Preflight. OpenReplay's floor is 2 vCPU / 8 GB RAM / 50 GB disk on x86 — +# below it the backend services simply do not start, which looks like a hang +# rather than an error. Check before pulling ~25 images. +set -uo pipefail +HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +source "$HERE/lib.sh" +fail=0 +warn=0 +ok() { printf ' [x] %s\n' "$1"; } +bad() { + printf ' ✕ %s\n' "$1" + fail=1 +} +soft() { + printf ' ! %s\n' "$1" + warn=1 +} + +echo "── docker" +if docker info >/dev/null 2>&1; then ok "daemon reachable"; else bad "daemon not reachable"; fi +docker compose version >/dev/null 2>&1 && ok "compose v2" || bad "docker compose v2 required" + +echo "── architecture" +arch=$(uname -m) +case "$arch" in +x86_64 | amd64) ok "$arch" ;; +*) bad "$arch — OpenReplay images are x86 only; on Apple Silicon expect emulation or outright failure" ;; +esac + +echo "── resources (as seen by the docker host)" +mem=$(docker info --format '{{.MemTotal}}' 2>/dev/null || echo 0) +memgb=$((mem / 1024 / 1024 / 1024)) +if [ "$memgb" -ge 8 ]; then + ok "${memgb} GB RAM" +elif [ "$memgb" -gt 0 ]; then + bad "${memgb} GB RAM — 8 GB is the documented minimum" +else soft "could not read MemTotal"; fi + +cpus=$(docker info --format '{{.NCPU}}' 2>/dev/null || echo 0) +[ "$cpus" -ge 2 ] && ok "${cpus} vCPU" || bad "${cpus} vCPU — 2 is the minimum" + +avail=$(df -Pk "$SKILL_DIR" 2>/dev/null | awk 'NR==2{print int($4/1024/1024)}') +[ -n "$avail" ] && { [ "$avail" -ge 50 ] && ok "${avail} GB free" || soft "${avail} GB free — 50 GB recommended"; } + +echo "── ports" +for p in 80 443; do + if command -v ss >/dev/null 2>&1 && ss -ltn 2>/dev/null | grep -q ":$p "; then + soft "port $p in use (harmless: the overlay publishes no host ports)" + else ok "port $p free"; fi +done + +echo "── conflicts" +if docker ps --format '{{.Names}}' | grep -q hackagon; then + soft "the hackagon dev container is running — OpenReplay wants 8 GB *on top* of it" +fi + +# ── a stack that is up: is all of it up? ──────────────────────────────────── +# `docker compose ps` shows what IS there and says nothing about what is not, +# and a service whose container has been removed reads exactly like a service +# that was never meant to run. `sink` disappeared that way once: it is the +# stage that writes the raw session file every later stage reads, so recording +# kept "working" — the ingest endpoint answered 200 to every batch — and not +# one session became playable. Nothing anywhere reported a fault. +# +# Only meaningful once something is running, so this is silent on a cold +# machine rather than a wall of failures before the first `up.sh`. +if [ -f "$VENDOR/docker-compose.yaml" ] && + [ -n "$(compose ps -q 2>/dev/null)" ]; then + echo "── running stack" + missing="" + running="$(compose ps --format '{{.Service}}' 2>/dev/null | sort -u)" + while IFS= read -r svc; do + [ -n "$svc" ] || continue + printf '%s\n' "$running" | grep -qx "$svc" || missing="$missing $svc" + done <<EOF +$(compose config --services 2>/dev/null | sort -u) +EOF + if [ -n "$missing" ]; then + bad "not running:$missing — start them with: bash $HERE/up.sh" + else + ok "every compose service has a running container" + fi +fi + +echo "" +[ "$fail" -eq 0 ] && echo "Preflight passed$([ "$warn" -eq 1 ] && echo " (with warnings)")." || + { + echo "Preflight FAILED — fix the ✕ items first." + exit 1 + } diff --git a/.claude/skills/openreplay-stack/scripts/down.sh b/.claude/skills/openreplay-stack/scripts/down.sh new file mode 100644 index 00000000..6f84fae5 --- /dev/null +++ b/.claude/skills/openreplay-stack/scripts/down.sh @@ -0,0 +1,52 @@ +#!/usr/bin/env bash +# Stop the stack. +# down.sh stop containers, keep data (pgdata, clickhouse, minio…) +# down.sh --volumes ALSO delete every volume — all recorded sessions go +# down.sh --tunnel stop only the tunnel (kills the public URL, app stays up) +set -euo pipefail +HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +source "$HERE/lib.sh" +require_docker +require_vendor + +# `up.sh` starts with COMPOSE_PROFILES=migration, so the four migration +# containers (fs-permission, minio-migration, db-migration, +# clickhouse-migration) exist. `down` WITHOUT that profile leaves every one of +# them behind, and `--remove-orphans` does not help: a profile-gated service is +# not an orphan, it is simply out of scope. +# +# Left behind, they keep a reference to the network compose just deleted, and +# the next `up` fails on +# failed to set up container networking: network <id> not found +# for a network nobody can see any more — a stale ID with no name attached, +# which reads as a docker bug rather than a leftover container. +export COMPOSE_PROFILES=migration + +# The named tunnel's CONTAINER goes with the stack; its hostname and DNS record +# stay, so the next up.sh reuses both and nothing needs re-wiring. Give them up +# explicitly with: +# bash .claude/skills/lib/cf-named-tunnel.sh destroy hackagon-openreplay <host> +stop_named() { if cfn_running "$NAMED_TUNNEL"; then cfn_stop "$NAMED_TUNNEL"; fi; } + +case "${1:-}" in +--tunnel) + compose stop tunnel || true + stop_named + rm -f "$STATE/tunnel-url" + echo "tunnel stopped — the public URL is gone; the stack is still running." + ;; +--volumes) + echo "==> stopping and DELETING ALL VOLUMES (recorded sessions included)…" + stop_named + compose down --volumes --remove-orphans + rm -f "$STATE/tunnel-url" "$STATE/env.prepared" + echo "done. Next up.sh re-randomizes secrets, re-runs migrations, and" + echo "re-creates the admin account from .secrets.env (kept on purpose)." + ;; +*) + stop_named + compose down --remove-orphans + rm -f "$STATE/tunnel-url" + echo "stopped (data volumes kept)." + ;; +esac diff --git a/.claude/skills/openreplay-stack/scripts/fetch-upstream.sh b/.claude/skills/openreplay-stack/scripts/fetch-upstream.sh new file mode 100644 index 00000000..ba112d02 --- /dev/null +++ b/.claude/skills/openreplay-stack/scripts/fetch-upstream.sh @@ -0,0 +1,67 @@ +#!/usr/bin/env bash +# Fetch upstream's scripts/docker-compose/ tree into vendor/ (sparse, depth-1) +# and record the exact commit in vendor/UPSTREAM.txt so a fetch is reproducible. +# +# Upstream's own install.sh is NOT run: it prompts for a domain, needs sudo, +# and ends by starting everything. up.sh does the same preparation +# non-interactively (secret randomization + CADDY_DOMAIN) and keeps control. +# +# Usage: fetch-upstream.sh [--force] env: OPENREPLAY_REF=v1.2.3 +set -euo pipefail +HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +source "$HERE/lib.sh" + +[ "${1:-}" = "--force" ] && rm -rf "$VENDOR" +if [ -f "$VENDOR/docker-compose.yaml" ]; then + echo "already fetched: $(cat "$VENDOR/UPSTREAM.txt" 2>/dev/null | head -1)" + echo "(use --force to refetch)" + exit 0 +fi + +command -v git >/dev/null || { + echo "error: git not found" >&2 + exit 1 +} +tmp="$(mktemp -d)" +trap 'rm -rf "$tmp"' EXIT + +echo "==> cloning $UPSTREAM_REPO @ $UPSTREAM_REF (sparse)…" +# Everything relative, inside $tmp. lib.sh exports MSYS_NO_PATHCONV=1 for +# docker's sake, which stops Git Bash translating POSIX paths for Windows +# binaries — so git.exe would put an absolute /tmp/... clone somewhere this +# shell cannot see. Relative paths sidestep it on every platform. +# +# `-c core.autocrlf=false -c core.eol=lf`: these files are consumed by LINUX +# containers, not by the host. On a Windows host with the usual global +# `core.autocrlf=true`, git rewrites every one of them to CRLF on checkout and +# the stack fails in ways that name neither git nor line endings: +# `hacks/minio.sh` (bind-mounted and run by the migration container) dies with +# `$'\r': command not found` / `syntax error near unexpected token $'{\r'`, +# and every value in `docker-envs/*.env` gains a trailing CR — which is how a +# correct S3 secret produces "The request signature we calculated does not +# match the signature you provided". +( + cd "$tmp" + git -c core.autocrlf=false -c core.eol=lf \ + clone --depth 1 --filter=blob:none --sparse --branch "$UPSTREAM_REF" \ + "$UPSTREAM_REPO" or 2>&1 | tail -1 + cd or + git -c core.autocrlf=false -c core.eol=lf sparse-checkout set scripts/docker-compose + git rev-parse HEAD >../SHA +) +sha="$(cat "$tmp/SHA")" +[ -d "$tmp/or/scripts/docker-compose" ] || { + echo "error: sparse checkout produced no scripts/docker-compose tree" >&2 + exit 1 +} + +mkdir -p "$VENDOR" +cp -r "$tmp/or/scripts/docker-compose/." "$VENDOR/" +{ + echo "$UPSTREAM_REPO @ $UPSTREAM_REF" + echo "commit $sha" + echo "fetched $(date -u +%Y-%m-%dT%H:%M:%SZ)" +} >"$VENDOR/UPSTREAM.txt" + +echo "==> vendored $(find "$VENDOR" -type f | wc -l) files at ${sha:0:12}" +echo " $VENDOR" diff --git a/.claude/skills/openreplay-stack/scripts/lib.sh b/.claude/skills/openreplay-stack/scripts/lib.sh new file mode 100644 index 00000000..4fefaaf1 --- /dev/null +++ b/.claude/skills/openreplay-stack/scripts/lib.sh @@ -0,0 +1,114 @@ +# shellcheck shell=bash +# Shared helpers. Source after setting HERE. + +SKILL_DIR="$(dirname "$HERE")" +VENDOR="$SKILL_DIR/vendor" +STATE="$SKILL_DIR/.state" +PROJECT="${OPENREPLAY_PROJECT:-openreplay}" +# Pin upstream. Override with OPENREPLAY_REF=v1.x.y for a release tag. +UPSTREAM_REPO="${OPENREPLAY_REPO:-https://github.com/openreplay/openreplay.git}" +UPSTREAM_REF="${OPENREPLAY_REF:-main}" + +mkdir -p "$STATE" + +# Admin credentials for the OpenReplay UI/API — generated by scripts/signup.sh +# on first run, gitignored (skill .gitignore; also .env-ignored repo-wide). +# Lives OUTSIDE .state so that nothing which resets state deletes it: the +# password must survive down.sh, and surviving --volumes is what lets the next +# up.sh re-create the same account after a wipe. +SECRETS_FILE="$SKILL_DIR/.secrets.env" + +# Fill unset OPENREPLAY_* variables from the secrets file. Explicit environment +# wins — an exported OPENREPLAY_PASSWORD is someone overriding on purpose. +# Plain KEY=value lines, no quoting: the value is everything after the first +# '=', so spaces survive without shell-quoting rules getting involved. +load_secrets() { + [ -f "$SECRETS_FILE" ] || return 0 + local line k + while IFS= read -r line || [ -n "$line" ]; do + case "$line" in '' | \#*) continue ;; esac + k="${line%%=*}" + case "$k" in *[!A-Za-z0-9_]* | '') continue ;; esac + [ -n "${!k:-}" ] || export "$k=${line#*=}" + done <"$SECRETS_FILE" +} + +# Paths handed to docker.exe. On Git Bash/MSYS, MSYS_NO_PATHCONV stops the +# automatic POSIX→Windows translation (needed so docker's own /container/paths +# survive), which means a `/c/Users/...` argument reaches docker.exe verbatim +# and it resolves `C:\c\Users\...`. Hand it a Windows-style path instead; +# MSYS leaves those alone. Same fix as devcontainer-up/scripts/lib.sh. +COMPOSE_VENDOR="$VENDOR/docker-compose.yaml" +COMPOSE_OVERLAY="$SKILL_DIR/compose.tunnel.yaml" +COMPOSE_DIR="$VENDOR" +case "$(uname -s)" in +MINGW* | MSYS*) + export MSYS_NO_PATHCONV=1 MSYS2_ARG_CONV_EXCL="*" + COMPOSE_VENDOR="$(cygpath -m "$COMPOSE_VENDOR")" + COMPOSE_OVERLAY="$(cygpath -m "$COMPOSE_OVERLAY")" + COMPOSE_DIR="$(cygpath -m "$COMPOSE_DIR")" + ;; +esac + +compose() { + docker compose -p "$PROJECT" \ + -f "$COMPOSE_VENDOR" -f "$COMPOSE_OVERLAY" \ + --project-directory "$COMPOSE_DIR" "$@" +} + +require_docker() { + command -v docker >/dev/null 2>&1 || { + echo "error: docker not found" >&2 + exit 1 + } + docker info >/dev/null 2>&1 || { + echo "error: docker daemon not reachable" >&2 + exit 1 + } +} + +require_vendor() { + [ -f "$VENDOR/docker-compose.yaml" ] || { + echo "error: upstream not fetched — run scripts/fetch-upstream.sh" >&2 + exit 1 + } +} + +# ── named vs quick tunnel ──────────────────────────────────────────────────── +# Same two modes as the app's own tunnel (see .claude/skills/lib/cf-api.sh for +# why named exists). Named when Cloudflare credentials and OPENREPLAY_HOSTNAME +# are configured; otherwise the quick tunnel, unchanged. +# +# The gain is largest here. COMMON_DOMAIN_NAME is baked into ~25 containers at +# boot, so a new hostname means rewriting common.env and recreating the whole +# stack — and every browser still carrying the old ingest URL posts sessions +# into a host that no longer exists, which looks exactly like "nobody visited". +# A stable hostname means the app's `replay.ingestPoint` wiring stays true. +# shellcheck source=../../lib/cf-named-tunnel.sh +source "$SKILL_DIR/../lib/cf-named-tunnel.sh" +NAMED_TUNNEL="${OPENREPLAY_TUNNEL_NAME:-hackagon-openreplay}" + +named_configured() { + cf_configured && [ -n "${OPENREPLAY_HOSTNAME:-}" ] +} + +# The URL this rig is reachable on, asked of whatever is RUNNING — a named +# tunnel's container label, or the quick tunnel's log, which is the only place +# cloudflared ever prints it. Never the state file: a restarted tunnel has a new +# URL and the same state file, and a stale URL fails silently. +tunnel_url() { + cfn_url "$NAMED_TUNNEL" 2>/dev/null && return 0 + docker logs "$(compose ps -q tunnel 2>/dev/null)" 2>&1 | + grep -oE 'https://[a-z0-9-]+\.trycloudflare\.com' | tail -1 +} + +# The docker network a service is on, read off the live container. Upstream +# defines `openreplay-net` explicitly and compose prefixes it with the project +# name, so guessing it wrong is easy — and it fails as a DNS lookup for `caddy` +# INSIDE cloudflared, which Cloudflare renders as a plain 502 while every +# container reports healthy. That cost an afternoon once already. +rig_network() { # <service> + docker inspect "$(compose ps -q "$1")" \ + --format '{{range $k,$v := .NetworkSettings.Networks}}{{$k}} {{end}}' 2>/dev/null | + awk '{print $1}' +} diff --git a/.claude/skills/openreplay-stack/scripts/retention.sh b/.claude/skills/openreplay-stack/scripts/retention.sh new file mode 100644 index 00000000..4f805f1a --- /dev/null +++ b/.claude/skills/openreplay-stack/scripts/retention.sh @@ -0,0 +1,202 @@ +#!/usr/bin/env bash +# Bound how long recorded sessions live. +# +# WHY THIS SCRIPT EXISTS AT ALL. OpenReplay's docker-compose distribution has +# no retention setting — checked, not assumed: `vendor/docker-envs/*.env` has +# nothing of the kind (`FS_CLEAN_HRS=24` is about scratch files on a +# container's own filesystem, not about stored sessions), and +# `vendor/migration-files/init_ch_schema.sql` gives `experimental.sessions` +# no TTL. Retention limits in the product are an EE feature. So a self-hosted +# rig keeps every recording of every visitor forever, which is not a defensible +# default for data collected under a consent banner that says "to find broken +# buttons". +# +# WHAT A SESSION IS MADE OF, and therefore what has to be deleted. Four stores, +# and deleting from one leaves the others holding the same visit: +# +# object store mobs/<session_id>/dom.mobs and /devtools.mob — THE RECORDING +# ITSELF, the DOM stream the replayer plays back +# postgres public.sessions plus ~20 event tables; every one of those is +# ON DELETE CASCADE off session_id, so the parent row is the +# only delete needed (verified against pg_constraint) +# clickhouse experimental.sessions (analytics), plus the by-session_id +# tables user_viewed_sessions / user_favorite_sessions and +# product_analytics.events +# - nothing in Hackagon. There is no session id on our side to +# clean up, by design (docs/frontend/session-replay.md). +# +# ORDER MATTERS AND IS NOT ARBITRARY. Postgres is where the expired ids are +# ENUMERATED, so it is deleted LAST. A run interrupted after the objects are +# gone leaves rows pointing at nothing — ugly, and fixed by the next run. The +# reverse order would leave orphaned recordings that nothing can enumerate any +# more: undeletable except by wiping the bucket. +# +# Usage: +# retention.sh # dry run, 30 days — prints what it WOULD delete +# retention.sh --days 7 --apply # delete sessions older than 7 days +# retention.sh --install-ttl # also give ClickHouse a declarative TTL +# +# `--install-ttl` is the closest thing to a built-in setting: ClickHouse drops +# expired parts on its own schedule with no cron. It cannot replace this script +# — it reaches neither the object store nor Postgres, which is where the actual +# recording and the actual personal data live — but it does mean the analytics +# side stays bounded even if nobody ever runs the purge again. +# +# Scheduling: this is a debugging rig, so there is no daemon. Run it from cron +# or a scheduled task: +# 0 4 * * * bash /path/to/.claude/skills/openreplay-stack/scripts/retention.sh --days 30 --apply +set -euo pipefail +HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +source "$HERE/lib.sh" + +DAYS=30 +APPLY=0 +INSTALL_TTL=0 + +while [ $# -gt 0 ]; do + case "$1" in + --days) + DAYS="${2:?--days needs a number}" + shift 2 + ;; + --apply) + APPLY=1 + shift + ;; + --install-ttl) + INSTALL_TTL=1 + shift + ;; + -h | --help) + sed -n '2,50p' "${BASH_SOURCE[0]}" + exit 0 + ;; + *) + echo "error: unknown option $1" >&2 + exit 2 + ;; + esac +done + +case "$DAYS" in +'' | *[!0-9]*) + echo "error: --days must be a whole number of days" >&2 + exit 2 + ;; +esac + +require_docker + +# The containers are named, not compose-scaled, so address them directly — +# `compose exec` would need the vendor tree present and this script should work +# against a stack somebody else started. +pg() { docker exec -i postgres sh -lc 'PGPASSWORD="$POSTGRESQL_PASSWORD" psql -U postgres -d postgres -v ON_ERROR_STOP=1 -tAc "$(cat)"'; } +ch() { docker exec -i clickhouse sh -lc 'clickhouse-client --multiquery -q "$(cat)"'; } + +for c in postgres clickhouse chalice minio; do + docker inspect -f '{{.State.Running}}' "$c" 2>/dev/null | grep -q true || { + echo "error: container '$c' is not running — is the stack up? (scripts/up.sh)" >&2 + exit 1 + } +done + +# The object purge, run inside `chalice` (it is the container that already has +# boto3 and the S3 credentials). Session ids arrive on STDIN, one per line, so +# no list is ever interpolated into a command line. +PURGE_OBJECTS=' +import os, sys, boto3 +s3 = boto3.client( + "s3", + endpoint_url="http://minio.db.svc.cluster.local:9000", + aws_access_key_id=os.environ["S3_KEY"], + aws_secret_access_key=os.environ["S3_SECRET"], + region_name="us-east-1", +) +bucket = os.environ.get("sessions_bucket", "mobs") +removed = 0 +for line in sys.stdin: + sid = line.strip() + if not sid: + continue + # Paginated: a long session can hold many canvas/image parts, and + # list_objects_v2 caps at 1000 keys per call. + token = None + while True: + kw = {"Bucket": bucket, "Prefix": sid + "/"} + if token: + kw["ContinuationToken"] = token + page = s3.list_objects_v2(**kw) + keys = [{"Key": o["Key"]} for o in page.get("Contents", [])] + if keys: + s3.delete_objects(Bucket=bucket, Delete={"Objects": keys}) + removed += len(keys) + if not page.get("IsTruncated"): + break + token = page.get("NextContinuationToken") +print(" removed %d objects" % removed) +' + +CUTOFF_MS=$((($(date -u +%s) - DAYS * 86400) * 1000)) +echo "==> Retention: ${DAYS} days (sessions started before $(date -u -d "@$((CUTOFF_MS / 1000))" +%Y-%m-%dT%H:%M:%SZ))" +[ "$APPLY" -eq 1 ] || echo " DRY RUN — nothing will be deleted. Add --apply." + +# ---------------------------------------------------------------- enumerate -- +IDS="$(printf 'SELECT session_id FROM public.sessions WHERE start_ts < %s ORDER BY session_id;' "$CUTOFF_MS" | pg)" +COUNT="$(printf '%s' "$IDS" | grep -c . || true)" +TOTAL="$(printf 'SELECT count(*) FROM public.sessions;' | pg)" +echo "==> ${COUNT} of ${TOTAL} recorded sessions are past the cutoff." + +if [ "$COUNT" -eq 0 ]; then + echo " Nothing to purge." +else + if [ "$APPLY" -eq 0 ]; then + printf '%s\n' "$IDS" | head -20 | sed 's/^/ would delete session /' + [ "$COUNT" -gt 20 ] && echo " ... and $((COUNT - 20)) more" + else + # --------------------------------------------------- 1. the recordings -- + # boto3 lives in chalice and already holds the S3 credentials; the ids + # arrive on stdin so no list is ever interpolated into a command line. + echo "==> Deleting objects under mobs/<session_id>/ ..." + # `python -c "$PURGE"` and NOT `python - <<PY`: a heredoc IS stdin, so it + # would take the file descriptor the session ids arrive on and the loop + # below would read nothing — deleting no objects while reporting success. + printf '%s\n' "$IDS" | docker exec -i chalice python -c "$PURGE_OBJECTS" + + # ----------------------------------------------------- 2. the analytics -- + echo "==> Deleting ClickHouse rows ..." + ch <<CH +ALTER TABLE experimental.sessions DELETE WHERE datetime < toDateTime(${CUTOFF_MS} / 1000); +ALTER TABLE experimental.ios_events DELETE WHERE datetime < toDateTime(${CUTOFF_MS} / 1000); +ALTER TABLE product_analytics.events DELETE WHERE created_at < toDateTime(${CUTOFF_MS} / 1000); +ALTER TABLE experimental.user_viewed_sessions DELETE WHERE session_id NOT IN ( + SELECT session_id FROM experimental.sessions +); +ALTER TABLE experimental.user_favorite_sessions DELETE WHERE session_id NOT IN ( + SELECT session_id FROM experimental.sessions +); +CH + + # ------------------------------------------------------ 3. the metadata -- + # Last, on purpose: this is the list the other two steps are derived from. + echo "==> Deleting Postgres rows (events cascade) ..." + printf 'DELETE FROM public.sessions WHERE start_ts < %s;' "$CUTOFF_MS" | pg >/dev/null + echo " ${COUNT} sessions purged." + fi +fi + +# ------------------------------------------------------------ declarative -- +if [ "$INSTALL_TTL" -eq 1 ]; then + if [ "$APPLY" -eq 0 ]; then + echo "==> Would set a ${DAYS}-day TTL on experimental.sessions / ios_events / product_analytics.events" + else + echo "==> Installing ${DAYS}-day ClickHouse TTLs ..." + ch <<CH +ALTER TABLE experimental.sessions MODIFY TTL datetime + INTERVAL ${DAYS} DAY; +ALTER TABLE experimental.ios_events MODIFY TTL datetime + INTERVAL ${DAYS} DAY; +ALTER TABLE product_analytics.events MODIFY TTL created_at + INTERVAL ${DAYS} DAY; +CH + echo " ClickHouse will now expire those tables on its own." + echo " NOTE: this does NOT cover the recordings themselves or Postgres." + echo " Keep running this script for those." + fi +fi diff --git a/.claude/skills/openreplay-stack/scripts/signup.sh b/.claude/skills/openreplay-stack/scripts/signup.sh new file mode 100644 index 00000000..7408bd88 --- /dev/null +++ b/.claude/skills/openreplay-stack/scripts/signup.sh @@ -0,0 +1,148 @@ +#!/usr/bin/env bash +# Create the OpenReplay admin account non-interactively, from stored secrets. +# +# The stack ships with NO seeded account: the first signup at <url>/signup +# becomes the admin. An account created by hand with an unrecorded password +# means an unreachable dashboard whose only fix is `down.sh --volumes` — that +# has happened once already. So `up.sh` runs this after starting the stack: +# +# /api/signup → tenants:false → sign up from .secrets.env (generated on +# first run), verify login, print project key +# /api/signup → tenants:true → nothing to create; verify the stored +# credentials still log in (warn, don't fail) +# +# The signup route itself was read from the running chalice container +# (routers/core_dynamic.py): POST /api/signup with +# {email, password, fullname, organizationName}, answered with a jwt on +# success or {"errors":[…]} on refusal. The POST route is only REGISTERED +# while no tenant exists at chalice boot, which is why this script always +# checks the GET first and never posts blind. +# +# Usage: signup.sh (idempotent — safe to re-run any time) +# Env: OPENREPLAY_SIGNUP_WAIT seconds to wait for /api/signup (default 300) +set -euo pipefail +HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +source "$HERE/lib.sh" + +WAIT="${OPENREPLAY_SIGNUP_WAIT:-300}" + +# ── secrets: load, or mint on first run ──────────────────────────────────── +# Same convention as .devcontainer/post-create.sh's secrets.yaml: a gitignored +# file, generated with openssl on first run, never committed. Explicitly set +# OPENREPLAY_* environment variables win over the file (load_secrets only +# fills variables that are unset). +load_secrets +if [ -z "${OPENREPLAY_EMAIL:-}" ] || [ -z "${OPENREPLAY_PASSWORD:-}" ]; then + OPENREPLAY_EMAIL="${OPENREPLAY_EMAIL:-admin@example.com}" + OPENREPLAY_PASSWORD="${OPENREPLAY_PASSWORD:-$(openssl rand -hex 16)}" + OPENREPLAY_FULLNAME="${OPENREPLAY_FULLNAME:-Hackagon Debug Admin}" + OPENREPLAY_ORG="${OPENREPLAY_ORG:-Hackagon}" + + # A password is about to be written to disk — make sure git can never take + # it. On the sketch branches all of .claude/ is ignored; on feat/claude the + # skill is TRACKED and only the skill's own .gitignore protects this file. + if command -v git >/dev/null 2>&1 && + git -C "$SKILL_DIR" rev-parse --is-inside-work-tree >/dev/null 2>&1 && + ! git -C "$SKILL_DIR" check-ignore -q "$SECRETS_FILE"; then + echo "error: $SECRETS_FILE would be TRACKED by git — refusing to write a password there." >&2 + echo " Add '.secrets.env' to $SKILL_DIR/.gitignore first." >&2 + exit 1 + fi + + umask 077 + cat >"$SECRETS_FILE" <<EOF +# OpenReplay admin credentials — generated by scripts/signup.sh, gitignored. +# KEY=value, no quotes; everything after the first '=' is the value. +# Survives down.sh. After down.sh --volumes (volumes wiped, signup reopens) +# the next up.sh re-creates the same account from these values. +OPENREPLAY_EMAIL=$OPENREPLAY_EMAIL +OPENREPLAY_PASSWORD=$OPENREPLAY_PASSWORD +OPENREPLAY_FULLNAME=$OPENREPLAY_FULLNAME +OPENREPLAY_ORG=$OPENREPLAY_ORG +EOF + echo "==> generated admin credentials in $SECRETS_FILE" + echo " email $OPENREPLAY_EMAIL" + echo " password $OPENREPLAY_PASSWORD" + echo " ⚠ shown this once — afterwards, read the file." +fi +OPENREPLAY_FULLNAME="${OPENREPLAY_FULLNAME:-Hackagon Debug Admin}" +OPENREPLAY_ORG="${OPENREPLAY_ORG:-Hackagon}" + +# ── wait for the API, read the tenants flag ──────────────────────────────── +url="$(cat "$STATE/tunnel-url" 2>/dev/null || true)" +[ -n "$url" ] || url="$(tunnel_url || true)" +[ -n "$url" ] || { + echo "error: no tunnel URL — is the stack up? (scripts/up.sh)" >&2 + exit 1 +} + +body="" +deadline=$(($(date +%s) + WAIT)) +while :; do + body="$(curl -fsS -m 10 "$url/api/signup" 2>/dev/null || true)" + case "$body" in *'"tenants"'*) break ;; esac + if [ "$(date +%s)" -ge "$deadline" ]; then + echo "error: $url/api/signup did not answer within ${WAIT}s" >&2 + echo " (first run pulls images + migrates — re-run: bash $HERE/signup.sh)" >&2 + exit 1 + fi + sleep 5 +done + +login_jwt() { # stdout: jwt, empty on failure + curl -fsS -m 30 -X POST "$url/api/login" -H 'Content-Type: application/json' \ + -d "{\"email\":\"$OPENREPLAY_EMAIL\",\"password\":\"$OPENREPLAY_PASSWORD\"}" 2>/dev/null | + sed -n 's/.*"jwt":"\([^"]*\)".*/\1/p' +} + +# ── tenant exists: nothing to create, just prove the stored creds work ───── +case "$body" in +*'"tenants": true'* | *'"tenants":true'*) + echo "==> OpenReplay admin account already exists — skipping signup" + jwt="$(login_jwt)" + if [ -z "$jwt" ]; then + echo " ⚠ the stored credentials ($OPENREPLAY_EMAIL) do NOT log in." >&2 + echo " The account was created some other way. Either fix $SECRETS_FILE" >&2 + echo " to match it, or start over: down.sh --volumes && up.sh" >&2 + exit 0 # an existing account must never fail a bring-up + fi + ;; +*) + # ── no tenant: sign up ───────────────────────────────────────────────── + echo "==> creating the OpenReplay admin account ($OPENREPLAY_EMAIL)…" + resp="$(curl -fsS -m 30 -X POST "$url/api/signup" -H 'Content-Type: application/json' \ + -d "{\"email\":\"$OPENREPLAY_EMAIL\",\"password\":\"$OPENREPLAY_PASSWORD\",\"fullname\":\"$OPENREPLAY_FULLNAME\",\"organizationName\":\"$OPENREPLAY_ORG\"}")" + case "$resp" in + *'"errors"'*) + echo "error: signup refused: $resp" >&2 + exit 1 + ;; + *'"jwt"'*) ;; + *) + echo "error: unexpected signup response: ${resp:0:200}" >&2 + exit 1 + ;; + esac + # Authenticate FROM THE FILE, not with the jwt signup returned — this is + # the round trip the owner will actually need later. + jwt="$(login_jwt)" + [ -n "$jwt" ] || { + echo "error: signup succeeded but login with the stored credentials failed" >&2 + exit 1 + } + echo " account created and login verified" + ;; +esac + +# ── project key (signup auto-creates "my first project") ─────────────────── +key="$(curl -fsS -m 30 "$url/api/projects" -H "Authorization: Bearer $jwt" 2>/dev/null | + sed -n 's/.*"projectKey":"\([^"]*\)".*/\1/p' | head -1)" + +echo "" +echo " admin $OPENREPLAY_EMAIL (password: see $SECRETS_FILE)" +if [ -n "$key" ]; then + echo " projectKey $key" + echo " wire-frontend.sh reads these credentials itself — no env vars needed." +else + echo " ⚠ no project key found — check $url/api/projects in the UI" >&2 +fi diff --git a/.claude/skills/openreplay-stack/scripts/up.sh b/.claude/skills/openreplay-stack/scripts/up.sh new file mode 100644 index 00000000..979a11d6 --- /dev/null +++ b/.claude/skills/openreplay-stack/scripts/up.sh @@ -0,0 +1,191 @@ +#!/usr/bin/env bash +# Bring OpenReplay up behind a Cloudflare quick tunnel. +# +# doctor → fetch upstream → prepare env → start tunnel (get URL) +# → point the stack at that URL → start everything +# +# The tunnel starts FIRST because the public hostname has to be known before +# the app boots: OpenReplay bakes it into its config, and a stale value means +# sessions silently never arrive. That is also why quick tunnels are a +# debugging tool here and not a deployment — every restart mints a new URL and +# this script has to rewrite the config again. +# +# Usage: up.sh [--skip-doctor] [--dry-run] +set -euo pipefail +HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +source "$HERE/lib.sh" + +DRY=0 +SKIP_DOCTOR=0 +MODE="" +while [ $# -gt 0 ]; do + case "$1" in + --dry-run) DRY=1 ;; + --skip-doctor) SKIP_DOCTOR=1 ;; + --named) MODE=named ;; + --quick) MODE=quick ;; + -h | --help) + sed -n '2,14p' "${BASH_SOURCE[0]}" | sed 's/^# \{0,1\}//' + exit 0 + ;; + *) + echo "unknown argument: $1" >&2 + exit 2 + ;; + esac + shift +done + +require_docker +[ "$SKIP_DOCTOR" -eq 1 ] || bash "$HERE/doctor.sh" +bash "$HERE/fetch-upstream.sh" +require_vendor + +# ── prepare env, part A: secrets (URL-independent, done once) ────────────── +# Mirrors upstream install.sh, minus the prompts. NOTE the ordering trap: +# COMMON_DOMAIN_NAME's placeholder is `change_me_domain`, which also matches +# the secret pattern — randomize it and the domain is silently destroyed. +# Upstream substitutes the domain first; we simply exclude it here and set it +# in part B, once the tunnel URL is known. +ENV_FILE="$VENDOR/common.env" + +# compose reads `.env` from the project directory for `${VAR}` interpolation, +# and upstream expects it to BE common.env. A symlink is the obvious way to say +# that and the one thing that does not work here: MSYS `ln -s` silently +# degrades to a copy on a Windows host, so `.env` froze at whatever common.env +# held when it was first created — `COMMON_DOMAIN_NAME=change_me_domain`. The +# stack then booted with correct secrets against a placeholder hostname, and +# chalice died on `ValueError: Invalid endpoint: https://change_me_domain` +# while everything else came up green. Copy explicitly, and re-copy after every +# edit to common.env. +sync_dotenv() { cp -f "$ENV_FILE" "$VENDOR/.env"; } + +if [ ! -f "$STATE/env.prepared" ]; then + echo "==> randomizing placeholder secrets in common.env" + mapfile -t tokens < <(grep -oE 'change_me_[a-zA-Z0-9_]*' "$ENV_FILE" | sort -u | grep -v '^change_me_domain$' || true) + for tok in "${tokens[@]}"; do + sed -i "s|\\b$tok\\b|$(openssl rand -hex 10)|g" "$ENV_FILE" + done + echo " ${#tokens[@]} secrets set" + # Caddy serves plain HTTP on :80 for any Host — Cloudflare terminates TLS at + # its edge, and ACME could never validate a trycloudflare hostname from here. + # COMMON_PROTOCOL stays `https` because that is what the PUBLIC URL is. + grep -q '^CADDY_DOMAIN=' "$ENV_FILE" && + sed -i 's|^CADDY_DOMAIN=.*|CADDY_DOMAIN=":80"|' "$ENV_FILE" || + echo 'CADDY_DOMAIN=":80"' >>"$ENV_FILE" + touch "$STATE/env.prepared" +else + echo "==> secrets already prepared (delete .state/env.prepared to redo)" +fi +sync_dotenv + +if [ "$DRY" -eq 1 ]; then + echo "" + echo "[dry-run] would start:" + compose config --services | sed 's/^/ /' + exit 0 +fi + +# ── phase 1: learn the public URL ────────────────────────────────────────── +# NAMED MODE SKIPS THIS PHASE. The hostname is known before anything starts, so +# COMMON_DOMAIN_NAME is written once and never revisited — which is worth more +# here than anywhere else, because that value is baked into ~25 containers and +# changing it recreates the whole stack. +if [ "$MODE" = "named" ] && ! named_configured; then + echo "error: --named needs Cloudflare credentials and OPENREPLAY_HOSTNAME." >&2 + cf_explain_unconfigured >&2 + exit 2 +fi +[ -n "$MODE" ] || { named_configured && MODE=named || MODE=quick; } + +if [ "$MODE" = "named" ]; then + url="https://$OPENREPLAY_HOSTNAME" + echo "==> Mode: NAMED — $url (persistent)" + # One COMMON_DOMAIN_NAME, one hostname. A second tunnel onto the same caddy + # would serve a UI whose API calls all address the other host. + if [ -n "$(compose ps -q tunnel 2>/dev/null)" ]; then + echo " stopping the quick tunnel (named mode owns COMMON_DOMAIN_NAME)" + compose rm -sf tunnel >/dev/null 2>&1 || true + fi +else + echo "==> Mode: QUICK — starting the quick tunnel to learn its hostname…" + if cfn_running "$NAMED_TUNNEL"; then + echo " stopping the named tunnel (one COMMON_DOMAIN_NAME, one hostname)" + cfn_stop "$NAMED_TUNNEL" + fi + compose up -d --no-deps tunnel + url="" + for _ in $(seq 1 30); do + url="$(tunnel_url || true)" + [ -n "$url" ] && break + sleep 2 + done + [ -z "$url" ] && { + echo "error: no tunnel URL after 60s — check: docker logs openreplay-tunnel" >&2 + exit 1 + } + echo " $url" +fi +host="${url#https://}" +echo "$url" >"$STATE/tunnel-url" + +# ── prepare env, part B: the domain (changes on every tunnel restart) ────── +# COMMON_DOMAIN_NAME is a BARE HOSTNAME — the scheme lives in COMMON_PROTOCOL. +echo "==> pointing the stack at $host" +if grep -q '^COMMON_DOMAIN_NAME=' "$ENV_FILE"; then + sed -i "s|^COMMON_DOMAIN_NAME=.*|COMMON_DOMAIN_NAME=$host|" "$ENV_FILE" +else + echo "COMMON_DOMAIN_NAME=$host" >>"$ENV_FILE" +fi +grep -qE '^COMMON_PROTOCOL=https' "$ENV_FILE" || sed -i 's|^COMMON_PROTOCOL=.*|COMMON_PROTOCOL=https|' "$ENV_FILE" +sync_dotenv + +# ── phase 3: everything (migration profile creates schemas on first run) ─── +echo "==> starting OpenReplay (first run pulls ~25 images — this takes a while)…" +if [ "$MODE" = "named" ]; then + # Every service EXCEPT the quick tunnel, named explicitly — a bare `up -d` + # would start it and mint a hostname nothing uses. + # shellcheck disable=SC2046 + COMPOSE_PROFILES=migration compose up -d \ + $(COMPOSE_PROFILES=migration compose config --services | grep -v '^tunnel$' | tr '\n' ' ') +else + COMPOSE_PROFILES=migration compose up -d +fi + +# ── phase 3b: the named tunnel, once caddy exists to point it at ─────────── +if [ "$MODE" = "named" ]; then + cfn_up "$NAMED_TUNNEL" "$OPENREPLAY_HOSTNAME" "$(rig_network caddy)" \ + "http://caddy:80" || { + echo "error: the named tunnel did not come up — OpenReplay is local-only," >&2 + echo " which means the tracker cannot reach /ingest from a browser." >&2 + exit 1 + } +fi + +# ── phase 4: the admin account, from .secrets.env ────────────────────────── +# The stack has no seeded account and the first signup becomes the admin; an +# account created by hand with an unrecorded password has already cost one +# full volume wipe. signup.sh is idempotent: it waits for /api/signup, signs +# up only while it reports tenants:false, and only says so when it skips. +bash "$HERE/signup.sh" + +echo "" +echo "── OpenReplay ─────────────────────────────────────────────" +echo " URL $url" +echo " Login $url/login (credentials: .secrets.env — see above)" +echo " Logs docker compose -p $PROJECT logs -f <service>" +echo " Stop bash $HERE/down.sh" +echo "" +echo " Tracker config for the SvelteKit app:" +echo " ingestPoint: \"$url/ingest\"" +echo " projectKey: printed above, or: bash $HERE/wire-frontend.sh --print" +echo "" +if [ "$MODE" = "named" ]; then + echo " Mode: NAMED — this hostname persists. COMMON_DOMAIN_NAME and any" + echo " wiring done against it stay correct across restarts, so the ~25" + echo " containers do not have to be recreated for a new URL." +else + echo " ⚠ This URL dies with the tunnel. Re-running up.sh mints a new one and" + echo " rewrites the config — fine for debugging, not for anything lasting." + echo " A named hostname removes that churn: see SKILL.md, 'Named tunnels'." +fi diff --git a/.claude/skills/openreplay-stack/scripts/url.sh b/.claude/skills/openreplay-stack/scripts/url.sh new file mode 100644 index 00000000..d6b5fc5a --- /dev/null +++ b/.claude/skills/openreplay-stack/scripts/url.sh @@ -0,0 +1,18 @@ +#!/usr/bin/env bash +# Print the current public URL (live from the tunnel's log, not the cached one). +set -euo pipefail +HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +source "$HERE/lib.sh" +require_docker + +url="$(tunnel_url || true)" +if [ -z "$url" ]; then + echo "no tunnel running (start it with scripts/up.sh)" >&2 + [ -f "$STATE/tunnel-url" ] && echo "last known: $(cat "$STATE/tunnel-url")" >&2 + exit 1 +fi +echo "$url" +[ "${1:-}" = "--all" ] && { + echo "$url/signup" + echo "$url/ingest" +} diff --git a/.claude/skills/openreplay-stack/scripts/wire-frontend.sh b/.claude/skills/openreplay-stack/scripts/wire-frontend.sh new file mode 100644 index 00000000..24c4cb20 --- /dev/null +++ b/.claude/skills/openreplay-stack/scripts/wire-frontend.sh @@ -0,0 +1,197 @@ +#!/usr/bin/env bash +# Point the SvelteKit tracker at this OpenReplay, or unpoint it. +# +# wire-frontend.sh wire: read the live tunnel URL + project key, +# write the `replay:` block, restart the frontend +# wire-frontend.sh --restore turn recording back OFF (the default state) +# wire-frontend.sh --print show what it would write, change nothing +# +# Recording is OFF unless this script (or a human) has written +# `replay.enabled: true`. That is the whole point of the flag: an absent block +# parses to `{enabled:false}`, so nothing records because someone forgot. +# +# The project key is fetched from OpenReplay's own API rather than pasted from +# its UI, because a quick tunnel mints a NEW hostname on every `up.sh` and a +# stale ingestPoint fails silently — the tracker just never delivers anything. +# Credentials come from OPENREPLAY_EMAIL / OPENREPLAY_PASSWORD, or — when +# those are unset — from .secrets.env, the file signup.sh wrote when it +# created the admin account during up.sh. +# +# ⚠ This restarts the frontend. Do not run it while an e2e suite is in flight — +# the pages keep answering 200 through the handover, so the damage shows up as +# one unrelated-looking test failure and nothing points here. +# +# NOTHING TRACKED IS EDITED. The `replay` block goes into config.local.yaml, +# the gitignored overlay the loader deep-merges over config.yaml +# (components/frontend/src/lib/server/settings.ts). This used to `sed` the +# TRACKED config.yaml, which meant a wired dev machine had a dirty working tree +# holding a `*.trycloudflare.com` ingest hostname that dies in a few hours — +# the exact shape of a bug this repo has already paid for once, when a `git add +# -A` committed the tunnel's OIDC issuer and a dead hostname sat in HEAD for +# several commits. +# +# THIS SCRIPT OWNS EXACTLY ONE KEY IN THAT FILE: `replay`. cloudflare-tunnel's +# auth-wire.sh owns `oidc` in the same file and knows nothing about this one, +# so --restore removes the BLOCK, never the file: clobbering the overlay would +# drop the tunnel's issuer, and a tunnel with no issuer keeps serving pages — +# only signing in breaks, which nobody notices until they try. +# .claude/skills/lib/config-overlay.sh does the per-key surgery. +set -euo pipefail +HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +source "$HERE/lib.sh" +ROOT_DIR="$(cd "$HERE/../../../.." && pwd)" +FRONTEND_LOCAL="$ROOT_DIR/components/frontend/data/test/config/config.local.yaml" +OVERLAY="$ROOT_DIR/.claude/skills/lib/config-overlay.sh" + +MODE="wire" +case "${1:-}" in +--restore) MODE="restore" ;; +--print) MODE="print" ;; +"") ;; +*) + echo "unknown argument: $1" >&2 + exit 2 + ;; +esac + +# The frontend reads its config ONCE at boot, so a rewrite is inert until it +# restarts — and getting that restart right is the whole trick here. +# +# TWO different servers can own :8081. process-compose's `frontend` is `vite +# dev`; the e2e harness replaces it with the adapter-node BUILD via +# hackathon-e2e/scripts/prod-frontend.sh, and after a suite run that is what +# is actually serving. Restarting only process-compose's copy therefore +# succeeds, prints "Process frontend restarted", and changes nothing at all: +# the page keeps rendering `replay: null` from a config the live server read +# before this script edited it. Bounce BOTH. +# +# The commands also live in two worlds: `docker` is on the HOST, while `just` +# and process-compose are INSIDE the dev container, where they additionally +# need the Nix dev shell (a bare `just deploy::proc-comp` there dies with +# `process-compose: command not found`). +in_shell() { # run a command in the dev shell, wherever this script started + local pc="$ROOT_DIR/.claude/skills/devcontainer-up/scripts/exec.sh" + if command -v process-compose >/dev/null 2>&1; then + (cd "$ROOT_DIR" && bash -c "$1") + elif [ -f "$pc" ]; then + (cd "$ROOT_DIR" && MSYS_NO_PATHCONV=1 bash "$pc" just nix::develop default bash -c "$1") + else + return 1 + fi +} + +restart_frontend() { + local ok=1 + in_shell 'just deploy::proc-comp process restart frontend' >/dev/null 2>&1 && ok=0 + # The built server, when the e2e harness put one there. + if in_shell 'test -s .output/run/e2e-prod-frontend.pid' >/dev/null 2>&1; then + in_shell 'bash .claude/skills/hackathon-e2e/scripts/prod-frontend.sh stop && + bash .claude/skills/hackathon-e2e/scripts/prod-frontend.sh ensure' >/dev/null 2>&1 && ok=0 + fi + [ "$ok" -eq 0 ] && return 0 + echo "note: restart the frontend for this to take effect:" >&2 + echo " just deploy::proc-comp process restart frontend" >&2 + echo " (or, if the e2e built server is serving :8081)" >&2 + echo " bash .claude/skills/hackathon-e2e/scripts/prod-frontend.sh stop && … ensure" >&2 +} + +# Restart only when the overlay actually changed — config-overlay.sh answers +# `changed` or `unchanged` for exactly this. This script BOUNCES THE FRONTEND, +# so an "idempotent" re-run is not free: calling `--restore` on an +# already-restored config while an e2e suite is mid-flight would restart the +# server the suite is driving. Nothing in the output would say so — the pages +# keep answering 200 — and the failure would land on whichever test happened to +# be in the handover. +# +# ⚠ Do not run this while a suite is running, even so. +apply() { # <changed|unchanged> <message> + if [ "$1" = "changed" ]; then + echo "$2" + restart_frontend + else + echo "$2 (already; nothing changed, frontend left alone)" + fi +} + +if [ "$MODE" = "restore" ]; then + # `remove`, never `rm`: the tunnel's `oidc` block may share this file. + apply "$(bash "$OVERLAY" remove "$FRONTEND_LOCAL" replay)" \ + "==> session replay is OFF" + exit 0 +fi + +require_docker +url="$(tunnel_url || true)" +[ -n "$url" ] || { + echo "error: no OpenReplay tunnel running — scripts/up.sh first" >&2 + exit 1 +} + +# The project key, by preference from OpenReplay's own API. The credentials +# normally come from .secrets.env (written by signup.sh, which up.sh runs); +# the environment overrides it, and `OPENREPLAY_PROJECT_KEY` short-circuits +# the login entirely — e.g. for an account someone created by hand with a +# password the file does not know. The stack itself only stores a hash, but +# the key is plain in Postgres: +# +# docker exec postgres sh -lc \ +# 'PGPASSWORD="$POSTGRESQL_PASSWORD" psql -U postgres -d postgres \ +# -tAc "select project_key from public.projects;"' +key="${OPENREPLAY_PROJECT_KEY:-}" +if [ -z "$key" ]; then + load_secrets + email="${OPENREPLAY_EMAIL:-}" + password="${OPENREPLAY_PASSWORD:-}" + [ -n "$email" ] && [ -n "$password" ] || { + echo "error: no credentials — expected $SECRETS_FILE (written by signup.sh)," >&2 + echo " or OPENREPLAY_EMAIL + OPENREPLAY_PASSWORD in the environment," >&2 + echo " or OPENREPLAY_PROJECT_KEY to skip the login entirely" >&2 + exit 1 + } + + jwt="$(curl -fsS -X POST "$url/api/login" -H 'Content-Type: application/json' \ + -d "{\"email\":\"$email\",\"password\":\"$password\"}" | + sed -n 's/.*"jwt":"\([^"]*\)".*/\1/p')" + [ -n "$jwt" ] || { + echo "error: OpenReplay login failed for $email" >&2 + exit 1 + } + + key="$(curl -fsS "$url/api/projects" -H "Authorization: Bearer $jwt" | + sed -n 's/.*"projectKey":"\([^"]*\)".*/\1/p' | head -1)" +fi +[ -n "$key" ] || { + echo "error: no project found — sign up at $url/signup first" >&2 + exit 1 +} + +# The whole block, key line included — config-overlay.sh checks that it starts +# with the key it is being filed under, so a block can never land in the +# overlay under a name that nothing can remove it by. +block=$( + cat <<YAML +replay: + enabled: true + ingestPoint: $url/ingest + projectKey: $key + # The dev stack is http://localhost, and the tracker refuses to record a + # page that is not on https. True only here, never in a deployment. + allowInsecureOrigin: true +YAML +) + +if [ "$MODE" = "print" ]; then + echo "$block" + exit 0 +fi + +apply "$(printf '%s\n' "$block" | bash "$OVERLAY" set "$FRONTEND_LOCAL" replay)" \ + "==> session replay is ON" +echo " ingestPoint $url/ingest" +echo " projectKey $key" +echo " ⚠ visitors to http://localhost:8081 are now ASKED whether to be recorded." +echo ' Nothing is recorded until somebody clicks "Allow recording" in the' +echo " banner — so an empty OpenReplay UI after wiring is the correct default," +echo " not a broken ingest. What is recorded is masked; see" +echo " components/frontend/src/lib/components/observability/SessionReplay.svelte" +echo " and docs/frontend/session-replay.md." diff --git a/.claude/skills/plausible-stack/.gitignore b/.claude/skills/plausible-stack/.gitignore new file mode 100644 index 00000000..6ef46c93 --- /dev/null +++ b/.claude/skills/plausible-stack/.gitignore @@ -0,0 +1,9 @@ +# Upstream's compose is fetched, not vendored into git (pinned + SHA-recorded +# by scripts/fetch-upstream.sh, so a fetch is reproducible). +vendor/ +.state/ + +# Admin credentials + SECRET_KEY_BASE, generated by scripts/up.sh on first run. +# This entry is what protects them on a branch where .claude/ is TRACKED — +# secrets.sh refuses to write the file at all if git would not ignore it. +.secrets.env diff --git a/.claude/skills/plausible-stack/SKILL.md b/.claude/skills/plausible-stack/SKILL.md new file mode 100644 index 00000000..7fe8b4b1 --- /dev/null +++ b/.claude/skills/plausible-stack/SKILL.md @@ -0,0 +1,313 @@ +--- +name: plausible-stack +description: + Spin up a self-hosted Plausible Analytics (Community Edition) instance with + docker compose, behind a Cloudflare quick tunnel so both the dashboard and the + tracking script have a public origin. Vendors the upstream compose, prepares + secrets and the owner account non-interactively, wires the SvelteKit app at it + and back, and proves a page view lands end to end. Use when asked to + run/try/evaluate Plausible, add privacy-friendly analytics, or count page + views for the hackathon platform. +--- + +# Plausible CE behind a quick tunnel + +A **development rig**, not a deployment. Three containers plus a tunnel: +Plausible, its own Postgres, its own ClickHouse. Same shape as +`openreplay-stack`, one tenth the weight, and it answers a different question — +"is this page used at all", which the RPC journal structurally cannot see and +session replay is far too heavy to answer. + +## Commands + +```bash +bash .claude/skills/plausible-stack/scripts/doctor.sh # preflight +bash .claude/skills/plausible-stack/scripts/up.sh # fetch, secrets, tunnel, start, owner account, lock down +bash .claude/skills/plausible-stack/scripts/up.sh --dry-run # everything except `compose up` +bash .claude/skills/plausible-stack/scripts/url.sh --all # current public URL +bash .claude/skills/plausible-stack/scripts/wire-frontend.sh # point the app at it +bash .claude/skills/plausible-stack/scripts/verify.sh # prove it end to end +bash .claude/skills/plausible-stack/scripts/wire-frontend.sh --restore +bash .claude/skills/plausible-stack/scripts/down.sh # stop, keep the statistics +bash .claude/skills/plausible-stack/scripts/down.sh --volumes # stop and delete everything +``` + +`up.sh` needs no input and asks for none: it mints the secrets, registers the +owner, creates the site and a Stats API key, then **closes registration behind +itself** and checks that `/register` refuses. + +## Named hostname (`--named`) + +`up.sh --named` puts this rig on a **persistent hostname you own** instead of a +quick tunnel, using the same Cloudflare credentials as the app's tunnel — +`PLAUSIBLE_HOSTNAME` in the gitignored `.claude/skills/cloudflare-tunnel/.env`. +See that skill's SKILL.md for the token and its real (zone-wide) scope. Named is +the default when configured; `--quick` forces the old behaviour, which still +needs no account at all. + +It matters more here than it looks. **`BASE_URL` is read once at boot** and used +for link generation and the LiveView origin check, so a new hostname means +rewriting `vendor/.env` and recreating the container — and any browser still +holding the old `scriptUrl` posts into a host that no longer exists, which is +indistinguishable from "nobody visited". With a stable hostname the wiring +written by `wire-frontend.sh` stays true across restarts, and `up.sh --named` +skips the "start a tunnel just to find out what it is called" phase entirely. + +Only one tunnel runs at a time: `--named` stops the quick tunnel and vice versa, +because `BASE_URL` names exactly one of them and a dashboard reached on the +other renders and then never loads any numbers. + +## Layout + +``` +compose.tunnel.yaml overlay: adds cloudflared, publishes :8010 on loopback +scripts/ doctor · fetch-upstream · secrets · up · url · down + signup (owner + site + Stats API key, via `bin/plausible rpc`) + wire-frontend (point the app at this rig, and back) + verify (9 checks, ending at Plausible's own API) + pageview.mjs (drives a real Firefox; used by verify) +vendor/ upstream compose.yml + clickhouse/ (fetched, gitignored) +vendor/UPSTREAM.txt repo, ref, exact commit and the image tag +vendor/.env what compose interpolates (generated) +.state/ tunnel URL (gitignored) +.secrets.env owner credentials, SECRET_KEY_BASE, API key (gitignored) +``` + +`vendor/` is fetched at a pinned tag (`PLAUSIBLE_REF`, default `v3.2.1`) rather +than committed: **the compose file IS the version** — the image tag lives inside +it — so the ref pinned here and the release running are one decision. + +## Why the tunnel starts first + +Plausible reads `BASE_URL` **at boot** and uses it for link generation and for +the LiveView origin check, so a dashboard booted against the wrong hostname +serves HTML and then fails to connect its own websocket: a page that renders and +never loads any numbers. `up.sh` therefore starts `tunnel` alone (`--no-deps`), +reads the URL out of cloudflared's log, writes it, and only then starts the app. +Same ordering trap as openreplay-stack's `COMMON_DOMAIN_NAME`, and the same +consequence: **every `up.sh` mints a new URL**, so this is a debugging tool. A +lasting instance wants a named tunnel and a fixed `BASE_URL`. + +Two differences from that rig, both easy to get backwards: + +| | openreplay-stack | here | +| -------------------- | ----------------------------------------------------- | ------------------------------------------------------------------------ | +| tunnel's `networks:` | must name `openreplay-net` — upstream defines one | must name NOTHING — upstream defines none, so everything is on `default` | +| public hostname | bare host in `COMMON_DOMAIN_NAME` + `COMMON_PROTOCOL` | full URL in `BASE_URL`, scheme included | + +Getting either wrong produces the same symptom: a 502 on the public URL with +every container healthy. + +## The owner account, and why signup is an `rpc` + +Plausible CE seeds no account; whoever registers first owns the instance, and +with no mailer configured there is **no recovery path**. So the credentials are +generated into `.secrets.env` and the account is created from them — +`openreplay-stack` learned this by having to wipe its volumes once. + +The HTTP route _looks_ scriptable and is not. `GET /register` serves a form with +`user[name]`, `user[email]`, `user[password]`, `user[password_confirmation]` and +a CSRF token; **`POST /register` is 404.** The form is a LiveView +(`phx-submit="register"`) — the account is created by a handler on the +websocket, and the form's own `action="/login"` only signs the new user in +afterwards. `signup.sh` therefore runs Elixir inside the release with +`bin/plausible rpc`, which is what that handler would have done. + +The cost, written down rather than discovered: that code names internal +functions (`Plausible.Auth.User.new/1`, `Plausible.Sites.create/2`, +`Plausible.Auth.create_stats_api_key/4`), so it is pinned to the version in +`vendor/UPSTREAM.txt` in a way an HTTP call would not be. Every step matches on +its expected result, so a moved API fails loudly instead of half-working. + +`.secrets.env` sits OUTSIDE `.state/` on purpose: it must survive `down.sh` and +even `--volumes`, so that a wipe is the RECOVERY path (the next `up.sh` +re-creates the same account) and not a second lockout. `secrets.sh` refuses to +write it at all if `git check-ignore` does not cover it. + +**Registration is closed after the account exists.** A quick-tunnel URL is +unguessable, not private, and an open `/register` on it is an invitation. The +check that matters is the one after the restart: "the variable is set" and "the +route refuses" are different claims, and only the second is verified. + +## Wiring the app + +```bash +bash .claude/skills/plausible-stack/scripts/wire-frontend.sh # ON +bash .claude/skills/plausible-stack/scripts/wire-frontend.sh --print # show, change nothing +bash .claude/skills/plausible-stack/scripts/wire-frontend.sh --restore # OFF +``` + +It writes **`config.local.yaml`, never the tracked `config.yaml`** — the value +is a `*.trycloudflare.com` hostname that dies in a few hours, and this repo has +already had one of those committed and left dead in HEAD for several commits. +`internal/config/config_test.go` asserts both tracked configs still say +`localhost`; `verify.sh` re-checks it too. + +**This is the THIRD writer of that overlay** (`oidc` ← cloudflare-tunnel, +`replay` ← openreplay-stack, `plausible` ← here), and none of them knows about +the others, so `--restore` removes the **block** and never the file. +`.claude/skills/lib/config-overlay.sh` does the per-key surgery and deletes the +file only when the last key leaves it. An `rm` here would silently break login +(a tunnel with no issuer keeps serving every page) or stop session replay (an +empty OpenReplay UI looks exactly like the correct default). + +⚠ **Anything that READS this config must read the MERGED view.** Wiring writes +the overlay, so a reader that looked only at `config.yaml` finds `enabled` +absent on a perfectly wired machine, self-skips, and reports success having +checked nothing — precisely how the replay suite once passed while testing +nothing. `verify.sh` reads both. + +### The script variant is two decisions + +`…/js/script.local.manual.js`, and both parts are load-bearing: + +- **`local`** — the stock script refuses to send from a local address. Read out + of the served file, not the docs: + `/localhost$|^127(\.[0-9]+){0,2}\.[0-9]+$|^\[::1?\]$/.test(location.hostname)`. + The dev app is `http://localhost:8081`, so without this variant every page + view is dropped **by the browser** — everything looks wired and nothing + arrives. +- **`manual`** — the stock script otherwise sends a pageview by itself, using + `location.href`. That URL is the one thing this integration must never send. + Manual mode means every page view is one the app decided to send, with a URL + it built. + +`verify.sh` asserts both properties of the fetched script, because a wiring that +names the wrong variant is invisible until somebody asks why the dashboard is +empty. + +### Unwire before running an e2e suite + +Nothing breaks if you do not — the tracker's POSTs are fire-and-forget and no +suite asserts about them — but a journey run is 465 actions of page loads, so +the numbers become mostly Playwright. `run.sh` does NOT borrow this block away +(the same decision as for `replay`: a suite should see what a visitor sees), so +it is a manual `wire-frontend.sh --restore` before, and a re-wire after. + +## Privacy: the decisions, not the defaults + +Full statement in **`docs/frontend/analytics.md`**. The short version: + +- **The URL is never sent.** `u` is SvelteKit's ROUTE ID — + `/my/hackathon/[id]/teams` — so it cannot contain an id, because it never + touched one. `/invite/<token>` (that token is a working credential) is + reported as `/invite/[token]`. Query strings are dropped, so `utm_*` is not + recorded either. The trade is accepted and stated: no per-hackathon + breakdowns, ever. +- **Cookieless, and that is the property consent turns on.** Nothing is stored + in or read from the browser. +- **Plausible still sees the IP and the user agent**, and hashes them with a + daily-rotated salt to count unique visitors. Only the hash is stored — + verified against `system.columns`, which shows no IP or user-agent column in + `events_v2`/`sessions_v2`, rather than against a marketing page. No + geolocation database is shipped, so country/city stay empty. +- **It sits OUTSIDE the session-replay consent gate**, deliberately: that banner + asks about session RECORDING, and consent is scoped to what was asked. A + second banner would ask about something with no artefact to permit or + withdraw. `DNT`/GPC still suppress it, before the script is fetched. +- **Nothing is correlated.** No user id, session id or replay id is ever sent; + these counts cannot be joined to the RPC journal or to a replay. + +## Cost, measured + +Idle, on this machine, with the app stack and the openreplay rig also running: + +| container | RSS | image | +| ---------------------------------- | ------------ | -------- | +| `plausible` | 431 MB | 265 MB | +| `plausible_events_db` (ClickHouse) | 188 MB | 718 MB | +| `plausible_db` (Postgres 16) | 110 MB | 396 MB | +| `tunnel` | 18 MB | — | +| **total** | **≈ 750 MB** | ≈ 1.4 GB | + +Volumes after the first few page views: ~80 MB, nearly all of it Postgres's +initial database. + +**It coexists with the openreplay rig comfortably** — that one wants 8 GB _of +its own_, this one runs in under one, and the two were up together throughout +the verification below. Upstream recommends 2 GB RAM; the measured idle +footprint is well under that, and ClickHouse is configured small by upstream's +own `clickhouse/low-resources.xml`. + +Ports: **one**, `127.0.0.1:8010` (override `PLAUSIBLE_PORT`). Not upstream's +8000 — the most contested port on a developer machine, and a collision fails +`compose up` after the databases have started, which reads as a stack failure +rather than a clash. Loopback only; the tunnel is the entrypoint. + +## Verification status + +**Verified on 2026-08-14** against CE v3.2.1 (commit `ec6c4da`), Windows host, +Docker Desktop. `verify.sh` — all nine checks: + +1. every compose service has a running container +2. the dashboard answers **through the tunnel**, with a real login round-trip + (CSRF → `POST /login` → 302 → `/sites` lists the site) +3. `/register` refuses signup on that public URL +4. the app is wired, read from the **merged** config, and the tracked + `config.yaml` still says `localhost` +5. the tracker script is fetchable and is the `local` + `manual` variant +6. **a real Firefox** visits `/`, clicks through to `/hackathon/<uuid>` and + opens `/invite/<token>`; the captured POST bodies carry `/`, + `/hackathon/[id]`, `/invite/[token]` and **no UUID, no token, no internal + referrer** +7. **Plausible's own Stats API** returns those three pages for today +8. `events_v2`/`sessions_v2` have no IP or user-agent column +9. `--restore` removes `plausible` and leaves `oidc` and `replay` intact + (simulated on a copy; also done for real once, with both surviving) + +Step 6 does not self-skip. Without `--no-browser`, an unavailable browser is a +**failure**: a proof that quietly drops its own hardest step is how this repo +has been lied to before. Unwired, the same script records **zero** requests — +which is the other half of "absent config ⇒ absent script". + +The **wipe-and-recover** claim is verified too, not just written down: +`down.sh --volumes` (databases gone, frontend unwired, `oidc`/`replay` intact) → +`up.sh` → a new tunnel URL, the SAME owner account re-created from +`.secrets.env`, the site re-created, registration closed again → re-wire → +`verify.sh` green. All nine checks above were re-run after that cycle. + +**That round-trip found a bug that only a real wipe could show.** The Stats API +key survives in `.secrets.env` by design, but its ROW was in the wiped database +— so `signup.sh`'s "is the key set" test was true and useless, and the first +thing to notice would have been a 401 in `verify.sh` step 7, pointing nowhere +near the cause. It probes the key against the Stats API now and mints a new one +when the answer is not 200. A file that outlives the thing it describes is not a +cache; it is a claim that needs checking. + +Playwright and Firefox are borrowed from the sibling `hackathon-e2e` skill (the +only place they are installed), through a `createRequire` anchored at that +package — ESM resolves bare specifiers relative to the FILE, and `cd`-ing does +not help. + +### Two traps this cost, both worth keeping + +**A carriage return inside a secret.** Git Bash's `openssl` prints CRLF, so +`openssl rand -base64 32 | tr -d '\n'` leaves a `\r` at the END OF THE VALUE — +no longer a line ending, just a byte in a secret. Plausible then refuses to boot +with `TOTP_VAULT_KEY must be Base64 encoded 32 bytes` about a key that decodes +to exactly 32 bytes in every tool you check it with. It survives every obvious +check: `cat -A` on a file `sed` has since rewritten shows nothing (MSYS strips +CRs on the way through), `docker compose config` shows it clean because that +reads `.env` rather than the process environment, and +`docker inspect --format '{{range .Config.Env}}'` renders the CR as the line +break it looks like. Only `{{json .Config.Env}}` shows it. Stripped in three +places now — at generation, on read, and on write into `.env` — because a +`.secrets.env` already on disk is not fixed by fixing the generator. + +**`curl -o /dev/null` on a Windows host.** `lib.sh` exports `MSYS_NO_PATHCONV=1` +(docker needs its own `/container/paths` left alone), so `/dev/null` reaches +`curl.exe` verbatim, which tries to create a file at that literal path and +prints `curl: (23) client returned ERROR on write` — in the middle of a check +that then PASSES, because `-w '%{http_code}'` already produced the number. Use +`$CURL_DISCARD`. The same shape bites `curl … | grep … | head`: head closes the +pipe at the first match while curl is still writing. Capture, then match. + +**A restart that stops a server and does not start it.** The first +`wire-frontend.sh` ran its restarts as `cmd && cmd` with output on `/dev/null`, +and left the app tunnel's only upstream (`:8082`) stopped while printing +"analytics is ON". The public URL kept answering — caddy falls back to `:8081` — +right up until that was down too. Every restart step now captures its output, +prints `ok`/`FAILED`, and names the port that is down. **Three** servers can be +serving this app (process-compose's vite, `prod-frontend.sh` on :8081, +`prod-serve.sh` on :8082 for the tunnel), and the built ones read their config +once at boot, so all three are bounced. diff --git a/.claude/skills/plausible-stack/compose.tunnel.yaml b/.claude/skills/plausible-stack/compose.tunnel.yaml new file mode 100644 index 00000000..59822510 --- /dev/null +++ b/.claude/skills/plausible-stack/compose.tunnel.yaml @@ -0,0 +1,38 @@ +# Overlay merged on top of the vendored upstream compose: +# docker compose -f vendor/compose.yml -f compose.tunnel.yaml … +# +# Upstream ships no ingress at all (its README tells you to publish ports or +# put a reverse proxy in front), so this adds the two things a debugging rig +# needs: a Cloudflare quick tunnel, and a loopback port. +services: + tunnel: + image: cloudflare/cloudflared:latest + container_name: plausible-tunnel + restart: unless-stopped + # --no-autoupdate: the container is disposable; an in-place update would + # restart the process and silently mint a NEW public URL, which every + # wired browser would keep posting past. + command: tunnel --no-autoupdate --url http://plausible:8000 + depends_on: + - plausible + # ⚠ NO `networks:` KEY HERE, AND THAT IS DELIBERATE — the opposite of the + # openreplay overlay, for the opposite reason. Upstream's compose defines + # no networks, so every service lands on compose's implicit `default` and a + # service that named one would be the odd one out and fail to RESOLVE + # `plausible`. (OpenReplay's upstream DOES define `openreplay-net`, so its + # tunnel had to join it explicitly — that omission cost an afternoon of + # 502s with every container healthy.) The rule is the same either way: + # match whatever upstream does, and check with `compose config` that the + # tunnel and its target share a network. + + plausible: + ports: + # LOOPBACK ONLY. The tunnel is the entrypoint — the browser must reach + # the ingest endpoint from outside — but a local port is what lets a + # diagnostic tell "Plausible is broken" apart from "Cloudflare is + # broken", which are indistinguishable from the public URL. 127.0.0.1 so + # publishing it does not put a dashboard on the LAN. + # + # Container-side 8000 is upstream's HTTP_PORT default; up.sh pins + # HTTP_PORT=8000 in .env rather than relying on it. + - "127.0.0.1:${PLAUSIBLE_PORT:-8010}:8000" diff --git a/.claude/skills/plausible-stack/scripts/doctor.sh b/.claude/skills/plausible-stack/scripts/doctor.sh new file mode 100644 index 00000000..c0d901c7 --- /dev/null +++ b/.claude/skills/plausible-stack/scripts/doctor.sh @@ -0,0 +1,112 @@ +#!/usr/bin/env bash +# Preflight. Cheaper than discovering the same facts three images into a pull. +# +# Plausible's own floor is modest (upstream README: 2 GB RAM for ClickHouse + +# Plausible), which is a third of what the openreplay rig wants — the two +# CAN coexist on this machine, and the measured numbers are in SKILL.md. +set -uo pipefail +HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +source "$HERE/lib.sh" +fail=0 +warn=0 +ok() { printf ' [x] %s\n' "$1"; } +bad() { + printf ' ✕ %s\n' "$1" + fail=1 +} +soft() { + printf ' ! %s\n' "$1" + warn=1 +} + +echo "── docker" +if docker info >/dev/null 2>&1; then ok "daemon reachable"; else bad "daemon not reachable"; fi +docker compose version >/dev/null 2>&1 && ok "compose v2" || bad "docker compose v2 required" + +echo "── architecture" +arch=$(uname -m) +case "$arch" in +x86_64 | amd64 | aarch64 | arm64) ok "$arch (ClickHouse needs SSE4.2 or NEON)" ;; +*) bad "$arch — ClickHouse requires SSE 4.2 (x86) or NEON (arm)" ;; +esac + +echo "── resources (as seen by the docker host)" +mem=$(docker info --format '{{.MemTotal}}' 2>/dev/null || echo 0) +memgb=$((mem / 1024 / 1024 / 1024)) +if [ "$memgb" -ge 4 ]; then + ok "${memgb} GB RAM" +elif [ "$memgb" -gt 0 ]; then + soft "${memgb} GB RAM — 2 GB is upstream's floor, and that is for Plausible ALONE" +else soft "could not read MemTotal"; fi + +cpus=$(docker info --format '{{.NCPU}}' 2>/dev/null || echo 0) +[ "$cpus" -ge 2 ] && ok "${cpus} vCPU" || soft "${cpus} vCPU" + +avail=$(df -Pk "$SKILL_DIR" 2>/dev/null | awk 'NR==2{print int($4/1024/1024)}') +[ -n "$avail" ] && { [ "$avail" -ge 10 ] && ok "${avail} GB free" || soft "${avail} GB free — 10 GB recommended"; } + +echo "── ports" +# The only host port this stack takes. Something else on it does not merely +# collide: `compose up` fails after the databases have started, which reads as +# a stack failure rather than a port clash. +# `ss` on Linux, `netstat` on a Windows host — and when NEITHER is available, +# say so rather than printing "free". A probe that cannot run and a probe that +# found nothing look identical from the outside, and only one of them is +# information. +listeners() { + if command -v ss >/dev/null 2>&1; then + ss -ltn 2>/dev/null + elif command -v netstat >/dev/null 2>&1; then + netstat -ano 2>/dev/null | grep -i listen + else + return 1 + fi +} +if ports="$(listeners)"; then + if printf '%s\n' "$ports" | grep -qE "[:.]$LOCAL_PORT[[:space:]]"; then + # Ours holding it is fine; anything else is not. + if docker ps --format '{{.Names}} {{.Ports}}' | grep -q ":$LOCAL_PORT->"; then + ok "port $LOCAL_PORT held by a container (this stack, presumably)" + else + bad "port $LOCAL_PORT in use by something else — set PLAUSIBLE_PORT" + fi + else + ok "port $LOCAL_PORT free" + fi +else + soft "no ss/netstat here — could not check whether port $LOCAL_PORT is free" +fi + +echo "── neighbours" +if docker ps --format '{{.Names}}' | grep -qx 'clickhouse'; then + soft "the openreplay rig is running — it has its OWN ClickHouse; this one is separate (~1 GB more)" +fi + +# ── a stack that is up: is ALL of it up? ─────────────────────────────────── +# `docker compose ps` shows what IS there and says nothing about what is not. +# A service whose container was removed reads exactly like a service that was +# never meant to run — that is how openreplay's `sink` went missing while every +# client-side check stayed green. +if [ -f "$VENDOR/compose.yml" ] && [ -n "$(compose ps -q 2>/dev/null)" ]; then + echo "── running stack" + missing="" + running="$(compose ps --format '{{.Service}}' 2>/dev/null | sort -u)" + while IFS= read -r svc; do + [ -n "$svc" ] || continue + printf '%s\n' "$running" | grep -qx "$svc" || missing="$missing $svc" + done <<EOF +$(compose config --services 2>/dev/null | sort -u) +EOF + if [ -n "$missing" ]; then + bad "not running:$missing — start them with: bash $HERE/up.sh" + else + ok "every compose service has a running container" + fi +fi + +echo "" +[ "$fail" -eq 0 ] && echo "Preflight passed$([ "$warn" -eq 1 ] && echo " (with warnings)")." || + { + echo "Preflight FAILED — fix the ✕ items first." + exit 1 + } diff --git a/.claude/skills/plausible-stack/scripts/down.sh b/.claude/skills/plausible-stack/scripts/down.sh new file mode 100644 index 00000000..3ced33b3 --- /dev/null +++ b/.claude/skills/plausible-stack/scripts/down.sh @@ -0,0 +1,44 @@ +#!/usr/bin/env bash +# Stop the rig. +# +# down.sh stop the containers, keep the collected statistics +# down.sh --volumes …and delete the databases (Postgres AND ClickHouse) +# +# It also UNWIRES THE FRONTEND, and that is not tidiness. A wired app points at +# a tunnel hostname that stops existing the moment this script runs; leaving the +# block behind means every browser loading the app tries to fetch a tracker +# script from a dead host on every page. Nothing breaks visibly — which is +# exactly why it would stay that way. +# +# `.secrets.env` is never touched: after `--volumes` the next up.sh re-creates +# the same owner account from it, which is what makes a wipe recoverable. +set -euo pipefail +HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +source "$HERE/lib.sh" +require_docker + +VOLUMES=0 +[ "${1:-}" = "--volumes" ] && VOLUMES=1 + +bash "$HERE/wire-frontend.sh" --restore || true + +# The named tunnel's container, if this rig is running one. Its HOSTNAME and DNS +# record are kept: that is the whole point of a named tunnel, and the next up.sh +# reuses both. Give them up explicitly with +# bash .claude/skills/lib/cf-named-tunnel.sh destroy hackagon-plausible <host> +if cfn_running "$NAMED_TUNNEL"; then cfn_stop "$NAMED_TUNNEL"; fi + +if [ "$VOLUMES" -eq 1 ]; then + echo "==> stopping and deleting volumes" + compose down -v --remove-orphans + # The tunnel URL and the "which URL was this wired at" marker describe a + # world that no longer exists. + rm -f "$STATE/tunnel-url" +else + echo "==> stopping (statistics kept)" + compose down --remove-orphans +fi + +echo " done. Bring it back with: bash $HERE/up.sh" +[ "$VOLUMES" -eq 1 ] && echo " (a new tunnel URL will be minted, and the owner account re-created from .secrets.env)" +exit 0 diff --git a/.claude/skills/plausible-stack/scripts/fetch-upstream.sh b/.claude/skills/plausible-stack/scripts/fetch-upstream.sh new file mode 100644 index 00000000..6b7b952c --- /dev/null +++ b/.claude/skills/plausible-stack/scripts/fetch-upstream.sh @@ -0,0 +1,75 @@ +#!/usr/bin/env bash +# Fetch upstream's compose.yml + clickhouse/ into vendor/ and record the exact +# commit in vendor/UPSTREAM.txt, so a fetch is reproducible. +# +# Upstream's README is a manual quick-start (edit .env by hand, then +# `docker compose up -d`, then create the first user in a browser). up.sh does +# the same preparation non-interactively and keeps control — same relationship +# openreplay-stack has with upstream's install.sh. +# +# The COMPOSE FILE IS THE VERSION. `ghcr.io/plausible/community-edition:v3.2.1` +# is written inside it, so the ref pinned here and the release running are one +# decision rather than two that can drift. +# +# Usage: fetch-upstream.sh [--force] env: PLAUSIBLE_REF=v3.2.1 +set -euo pipefail +HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +source "$HERE/lib.sh" + +[ "${1:-}" = "--force" ] && rm -rf "$VENDOR" +if [ -f "$VENDOR/compose.yml" ]; then + echo "already fetched: $(head -1 "$VENDOR/UPSTREAM.txt" 2>/dev/null)" + echo "(use --force to refetch)" + exit 0 +fi + +command -v git >/dev/null || { + echo "error: git not found" >&2 + exit 1 +} +tmp="$(mktemp -d)" +trap 'rm -rf "$tmp"' EXIT + +echo "==> cloning $UPSTREAM_REPO @ $UPSTREAM_REF…" +# Everything relative, inside $tmp. lib.sh exports MSYS_NO_PATHCONV=1 for +# docker's sake, which stops Git Bash translating POSIX paths for Windows +# binaries — so git.exe would put an absolute /tmp/... clone somewhere this +# shell cannot see. Relative paths sidestep it on every platform. +# +# `-c core.autocrlf=false -c core.eol=lf`: these files are read by LINUX +# containers, not by the host. ClickHouse's XML survives CRLF, but a compose +# file with CR line endings puts a trailing CR inside every unquoted scalar — +# which is how `BASE_URL` acquires an invisible character and Plausible starts +# generating links to a hostname nothing resolves. Same class of failure that +# cost openreplay-stack an afternoon of S3 signature mismatches. +( + cd "$tmp" + git -c core.autocrlf=false -c core.eol=lf \ + clone --depth 1 --branch "$UPSTREAM_REF" --single-branch \ + "$UPSTREAM_REPO" ce 2>&1 | tail -1 + cd ce + git rev-parse HEAD >../SHA +) +sha="$(cat "$tmp/SHA")" +[ -f "$tmp/ce/compose.yml" ] || { + echo "error: no compose.yml at $UPSTREAM_REF" >&2 + exit 1 +} +[ -d "$tmp/ce/clickhouse" ] || { + echo "error: no clickhouse/ config dir at $UPSTREAM_REF — compose bind-mounts it" >&2 + exit 1 +} + +mkdir -p "$VENDOR" +cp "$tmp/ce/compose.yml" "$VENDOR/compose.yml" +cp -r "$tmp/ce/clickhouse" "$VENDOR/clickhouse" +cp "$tmp/ce/README.md" "$VENDOR/README.md" 2>/dev/null || true +{ + echo "$UPSTREAM_REPO @ $UPSTREAM_REF" + echo "commit $sha" + echo "fetched $(date -u +%Y-%m-%dT%H:%M:%SZ)" + echo "image $(grep -oE 'ghcr.io/plausible/community-edition:[^ ]*' "$VENDOR/compose.yml" | head -1)" +} >"$VENDOR/UPSTREAM.txt" + +echo "==> vendored $(find "$VENDOR" -type f | wc -l) files at ${sha:0:12}" +sed 's/^/ /' "$VENDOR/UPSTREAM.txt" diff --git a/.claude/skills/plausible-stack/scripts/lib.sh b/.claude/skills/plausible-stack/scripts/lib.sh new file mode 100644 index 00000000..54be9b82 --- /dev/null +++ b/.claude/skills/plausible-stack/scripts/lib.sh @@ -0,0 +1,223 @@ +# shellcheck shell=bash +# Shared helpers. Source after setting HERE. + +SKILL_DIR="$(dirname "$HERE")" +VENDOR="$SKILL_DIR/vendor" +STATE="$SKILL_DIR/.state" +PROJECT="${PLAUSIBLE_PROJECT:-plausible}" +# Pin upstream. The CE compose is a versioned artefact: the image tag inside it +# is the release, so the ref and the running version are one decision. +UPSTREAM_REPO="${PLAUSIBLE_REPO:-https://github.com/plausible/community-edition.git}" +UPSTREAM_REF="${PLAUSIBLE_REF:-v3.2.1}" + +# The port `plausible` is published on, on the LOOPBACK interface only. The +# tunnel is the entrypoint (the browser has to reach the ingest endpoint from +# outside), but a local port is what lets doctor/verify tell "the app is broken" +# apart from "the tunnel is broken" — two failures that look identical from the +# public URL. +# +# 8010, not upstream's 8000: 8000 is the single most contested port on a +# developer machine (it was already taken by an unrelated python process on the +# machine this was built on) and a collision here fails `compose up` AFTER the +# databases have started, which reads as a stack failure rather than a clash. +# The container still listens on 8000 inside; only the host side moves. +LOCAL_PORT="${PLAUSIBLE_PORT:-8010}" + +mkdir -p "$STATE" + +# Admin credentials + SECRET_KEY_BASE — generated by scripts/secrets.sh on +# first run, gitignored (skill .gitignore; also ignored repo-wide). +# +# OUTSIDE .state on purpose, exactly as in openreplay-stack: the password must +# survive down.sh, and surviving `--volumes` is what makes a wipe a RECOVERY +# path rather than a second loss — the next up.sh re-creates the same account +# from this file. +SECRETS_FILE="$SKILL_DIR/.secrets.env" + +# Fill unset PLAUSIBLE_* variables from the secrets file. Explicit environment +# wins — an exported PLAUSIBLE_PASSWORD is someone overriding on purpose. +# Plain KEY=value lines, no quoting: the value is everything after the first +# '=', so spaces survive without shell-quoting rules getting involved. +# +# ⚠ THE `\r` STRIP IS LOad-BEARING ON WINDOWS, and it cost an hour. Git Bash's +# openssl (/mingw64/bin/openssl) prints CRLF, so a naively captured +# `openssl rand -base64 32` ends in a carriage return. That CR then rides +# inside the VALUE — not at the end of the line, where every tool would treat +# it as a line ending — and Plausible dies at boot with +# `TOTP_VAULT_KEY must be Base64 encoded 32 bytes`, an error about a key that +# decodes to exactly 32 bytes everywhere you check it. +# +# It survives every obvious check: `cat -A` on a file sed has since rewritten +# shows nothing (MSYS sed strips CRs on the way through), `docker compose +# config` shows it clean because that reads .env rather than the process +# environment, and `docker inspect --format '{{range .Config.Env}}'` renders +# the CR as the line break it looks like. Only `{{json .Config.Env}}` shows it. +# Stripped here as well as at the point of generation, because a .secrets.env +# already on disk is not fixed by fixing the generator. +load_secrets() { + [ -f "$SECRETS_FILE" ] || return 0 + local line k v + while IFS= read -r line || [ -n "$line" ]; do + line="${line%$'\r'}" + case "$line" in '' | \#*) continue ;; esac + k="${line%%=*}" + case "$k" in *[!A-Za-z0-9_]* | '') continue ;; esac + v="${line#*=}" + v="${v%$'\r'}" + [ -n "${!k:-}" ] || export "$k=$v" + done <"$SECRETS_FILE" +} + +# Paths handed to docker.exe. On Git Bash/MSYS, MSYS_NO_PATHCONV stops the +# automatic POSIX→Windows translation (needed so docker's own /container/paths +# survive), which means a `/c/Users/...` argument reaches docker.exe verbatim +# and it resolves `C:\c\Users\...`. Hand it a Windows-style path instead; +# MSYS leaves those alone. Same fix as openreplay-stack/scripts/lib.sh. +COMPOSE_VENDOR="$VENDOR/compose.yml" +COMPOSE_OVERLAY="$SKILL_DIR/compose.tunnel.yaml" +COMPOSE_DIR="$VENDOR" +case "$(uname -s)" in +MINGW* | MSYS*) + export MSYS_NO_PATHCONV=1 MSYS2_ARG_CONV_EXCL="*" + COMPOSE_VENDOR="$(cygpath -m "$COMPOSE_VENDOR")" + COMPOSE_OVERLAY="$(cygpath -m "$COMPOSE_OVERLAY")" + COMPOSE_DIR="$(cygpath -m "$COMPOSE_DIR")" + ;; +esac + +# `--project-directory vendor` matters twice: upstream's compose bind-mounts +# ./clickhouse/*.xml relative to it, and compose reads `.env` from it — which +# is the file up.sh writes BASE_URL and the secrets into. +# Where curl should throw a body away. +# +# NOT `/dev/null` on a Windows host: this file exports MSYS_NO_PATHCONV=1 for +# docker's sake, so `/dev/null` reaches curl.exe verbatim, curl tries to create +# a file at that literal path, and fails with +# `curl: (23) client returned ERROR on write of 72 bytes` — printed in the +# middle of a check that then passes anyway, because `-w '%{http_code}'` has +# already produced the number the caller wanted. A scary line attached to a +# successful step is worse than either a failure or silence. +CURL_DISCARD="/dev/null" +case "$(uname -s)" in +MINGW* | MSYS*) CURL_DISCARD="NUL" ;; +esac + +compose() { + docker compose -p "$PROJECT" \ + -f "$COMPOSE_VENDOR" -f "$COMPOSE_OVERLAY" \ + --project-directory "$COMPOSE_DIR" "$@" +} + +require_docker() { + command -v docker >/dev/null 2>&1 || { + echo "error: docker not found" >&2 + exit 1 + } + docker info >/dev/null 2>&1 || { + echo "error: docker daemon not reachable" >&2 + exit 1 + } +} + +require_vendor() { + [ -f "$VENDOR/compose.yml" ] || { + echo "error: upstream not fetched — run scripts/fetch-upstream.sh" >&2 + exit 1 + } +} + +ENV_FILE="$VENDOR/.env" + +# Idempotent `KEY=value` in vendor/.env. Compose reads that file for ${VAR} +# interpolation AND for the bare `- TOTP_VAULT_KEY` style pass-throughs in +# upstream's compose, so it is the one place configuration lives. +env_set() { # <key> <value> + mkdir -p "$VENDOR" + touch "$ENV_FILE" + # Same CR strip as load_secrets, for the same reason and one layer further + # out: whatever the value came from, a CR must not reach a container. + set -- "$1" "${2%$'\r'}" + if grep -q "^$1=" "$ENV_FILE"; then + # `|` as the sed delimiter: values here are URLs, and base64 secrets can + # contain `/`. + sed -i "s|^$1=.*|$1=$2|" "$ENV_FILE" + else + printf '%s=%s\n' "$1" "$2" >>"$ENV_FILE" + fi +} + +# Write one KEY=value into .secrets.env, replacing any existing line. +# +# It exists for the API key, and the reason is the recovery story: after +# `down.sh --volumes` the databases are gone but this FILE survives on purpose, +# so a stored key names a row that no longer exists. Appending a second line +# would leave load_secrets reading whichever came first — so this replaces. +secrets_set() { # <key> <value> + local k="$1" v="${2%$'\r'}" + if command -v git >/dev/null 2>&1 && + git -C "$SKILL_DIR" rev-parse --is-inside-work-tree >/dev/null 2>&1 && + ! git -C "$SKILL_DIR" check-ignore -q "$SECRETS_FILE"; then + echo "error: $SECRETS_FILE is not gitignored — refusing to write a secret there." >&2 + return 1 + fi + umask 077 + touch "$SECRETS_FILE" + if grep -q "^$k=" "$SECRETS_FILE"; then + sed -i "s|^$k=.*|$k=$v|" "$SECRETS_FILE" + else + printf '%s=%s\n' "$k" "$v" >>"$SECRETS_FILE" + fi + export "$k=$v" +} + +env_get() { # <key> -> stdout (empty when absent) + [ -f "$ENV_FILE" ] || return 0 + sed -n "s|^$1=||p" "$ENV_FILE" | tail -1 +} + +# ── named vs quick tunnel ──────────────────────────────────────────────────── +# Same two modes as the app's own tunnel (see .claude/skills/lib/cf-api.sh for +# why named exists). Named is chosen when Cloudflare credentials and +# PLAUSIBLE_HOSTNAME are configured; otherwise the quick tunnel, unchanged. +# +# It matters MORE here than for the app. Plausible reads BASE_URL once at boot +# and uses it for link generation and the LiveView origin check, so every new +# quick-tunnel hostname is a config rewrite plus a container recreate — and any +# browser still holding the old tracker URL posts into a host that no longer +# exists, which looks exactly like "nobody visited". +# shellcheck source=../../lib/cf-named-tunnel.sh +source "$SKILL_DIR/../lib/cf-named-tunnel.sh" +NAMED_TUNNEL="${PLAUSIBLE_TUNNEL_NAME:-hackagon-plausible}" + +named_configured() { + cf_configured && [ -n "${PLAUSIBLE_HOSTNAME:-}" ] +} + +# The URL this rig is reachable on, asked of whatever is RUNNING. A named +# tunnel answers from its container label; a quick tunnel from cloudflared's +# log, which is the only place it ever prints it. Neither reads the state file: +# a tunnel that was restarted has a new URL and the same state file, and a stale +# URL fails silently — the tracker keeps posting into nothing. +tunnel_url() { + cfn_url "$NAMED_TUNNEL" 2>/dev/null && return 0 + docker logs "$(compose ps -q tunnel 2>/dev/null)" 2>&1 | + grep -oE 'https://[a-z0-9-]+\.trycloudflare\.com' | tail -1 +} + +# The docker network the rig's containers are on, read off a live container +# rather than assumed: compose derives it from the project name, which is +# overridable. A wrong guess fails as a DNS lookup for `plausible` inside +# cloudflared, which Cloudflare renders as a plain 502 while every container +# reports healthy — an afternoon lost to that once already, in the openreplay +# overlay. +rig_network() { # <service> + docker inspect "$(compose ps -q "$1")" \ + --format '{{range $k,$v := .NetworkSettings.Networks}}{{$k}} {{end}}' 2>/dev/null | + awk '{print $1}' +} + +# The URL to talk to Plausible on from THIS machine. Prefer the loopback port: +# it is the same server, one hop shorter, and it keeps a diagnostic from +# depending on Cloudflare being up. Callers that specifically mean to test the +# public path use tunnel_url. +local_url() { echo "http://127.0.0.1:$LOCAL_PORT"; } diff --git a/.claude/skills/plausible-stack/scripts/pageview.mjs b/.claude/skills/plausible-stack/scripts/pageview.mjs new file mode 100644 index 00000000..166847c3 --- /dev/null +++ b/.claude/skills/plausible-stack/scripts/pageview.mjs @@ -0,0 +1,136 @@ +/* + * Drive a real browser through the wired app and record what the tracker put + * on the wire. + * + * This is the CLIENT half of the proof. It exists because "the app renders a + * script tag" and "a page view was counted" are different claims, and because + * every privacy property this integration has is a property of the BYTES — + * an assertion that a config flag is set would have passed while the tracker + * shipped `/invite/<token>`. + * + * It deliberately does NOT judge whether Plausible stored anything: that is + * the far end, and verify.sh asks Plausible itself. Session replay was green + * for three days here while every recorded session was unplayable, because + * every spec measured what left the browser and nothing asked the server. + * + * Usage (inside the dev container, from the hackathon-e2e skill dir so the + * playwright dependency resolves): + * node …/plausible-stack/scripts/pageview.mjs <appUrl> <plausibleOrigin> <hackathonId> <out.json> + */ +import { createRequire } from "node:module" +import { fileURLToPath } from "node:url" +import path from "node:path" +import { writeFileSync } from "node:fs" + +// Playwright and its Firefox come from the SIBLING skill, which is the only +// place in this repo that installs them. Borrowed through `createRequire` +// anchored at that package rather than imported directly, because ESM resolves +// bare specifiers relative to the FILE, and this file lives in a directory with +// no node_modules — `cd`-ing there first does not help. +const HERE = path.dirname(fileURLToPath(import.meta.url)) +const require = createRequire( + path.join(HERE, "..", "..", "hackathon-e2e", "package.json"), +) +const { firefox } = require("@playwright/test") + +const [appUrl, plausibleOrigin, hackathonId, out] = process.argv.slice(2) +if (!appUrl || !plausibleOrigin || !out) { + console.error( + "usage: pageview.mjs <appUrl> <plausibleOrigin> <hackathonId> <out.json>", + ) + process.exit(2) +} + +// A token that could not possibly be real, but has the SHAPE of one — the +// point is to prove the shape never reaches the wire, so it must be +// recognisable in a grep of the captured bodies. +const FAKE_TOKEN = "plausible-proof-token-4f2a9c7e" + +const events = [] +const browser = await firefox.launch() +const page = await browser.newPage() + +page.on("request", (req) => { + if (req.method() !== "POST") return + if (!req.url().startsWith(`${plausibleOrigin}/api/event`)) return + let body = req.postData() + try { + body = JSON.parse(body) + } catch { + /* keep the raw string — a body we cannot parse is still evidence */ + } + events.push({ url: req.url(), body }) +}) + +async function visit(path, { click } = {}) { + if (click) { + // A CLIENT-SIDE navigation, which is the case `afterNavigate` handles and + // the only case where document.referrer is non-empty and points at one of + // our own paths. + const link = page.locator(`a[href="${path}"]`).first() + if ((await link.count()) > 0) { + await link.click() + await page.waitForURL(`**${path}`, { timeout: 15000 }).catch(() => {}) + await page.waitForTimeout(2500) + return "clicked" + } + } + await page.goto(`${appUrl}${path}`, { waitUntil: "load", timeout: 30000 }) + // The first pageview is fired from an idle callback after first paint. + await page.waitForTimeout(2500) + return "goto" +} + +const visited = [] +visited.push(["/", await visit("/")]) +if (hackathonId) { + visited.push([ + `/hackathon/${hackathonId}`, + await visit(`/hackathon/${hackathonId}`, { click: true }), + ]) +} +visited.push([`/invite/${FAKE_TOKEN}`, await visit(`/invite/${FAKE_TOKEN}`)]) + +await browser.close() + +const raw = JSON.stringify(events) +const sent = events.map((e) => (e.body && e.body.u) || null) + +// ── the client-side claims, judged here because the bodies are here ──────── +const problems = [] +if (events.length === 0) + problems.push("no /api/event request left the browser at all") + +// The positive control comes FIRST: an "id absent" assertion agrees with +// everything when nothing was ever sent, and this repo has shipped exactly +// that mistake more than once. +const expectPage = hackathonId ? "/hackathon/[id]" : "/" +if (!sent.some((u) => u && u.endsWith(expectPage))) + problems.push( + `no pageview carried the route template ${expectPage} (got ${JSON.stringify(sent)})`, + ) + +if (hackathonId && raw.includes(hackathonId)) + problems.push(`the hackathon id ${hackathonId} appeared on the wire`) +if (raw.includes(FAKE_TOKEN)) + problems.push(`the invite token appeared on the wire`) +if (/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/i.test(raw)) + problems.push("a UUID appeared on the wire") + +// Referrers: our own origin must never be sent with a path attached. +for (const e of events) { + const r = e.body && e.body.r + if (r && r.startsWith(appUrl) && r !== appUrl && r !== `${appUrl}/`) + problems.push(`an internal referrer with a path was sent: ${r}`) +} + +writeFileSync(out, JSON.stringify({ visited, events, problems }, null, 2)) + +console.log(`visited: ${visited.map(([p, how]) => `${p} (${how})`).join(", ")}`) +console.log(`events: ${events.length}`) +for (const u of sent) console.log(` u = ${u}`) +if (problems.length) { + for (const p of problems) console.log(`PROBLEM: ${p}`) + process.exit(1) +} +console.log("client-side: OK") diff --git a/.claude/skills/plausible-stack/scripts/secrets.sh b/.claude/skills/plausible-stack/scripts/secrets.sh new file mode 100644 index 00000000..eef012be --- /dev/null +++ b/.claude/skills/plausible-stack/scripts/secrets.sh @@ -0,0 +1,94 @@ +#!/usr/bin/env bash +# Mint everything this rig needs to be brought up without a single prompt, into +# a gitignored .secrets.env. Idempotent: values already present are kept. +# +# SECRET_KEY_BASE signs the dashboard's sessions (≥64 bytes, upstream) +# TOTP_VAULT_KEY encrypts TOTP secrets at rest (32 bytes, base64) +# PLAUSIBLE_EMAIL the admin account up.sh registers +# PLAUSIBLE_PASSWORD …and its password +# PLAUSIBLE_SITE the site (data-domain) events are attributed to +# +# WHY A FILE, NOT ENVIRONMENT VARIABLES. Plausible CE seeds no account: whoever +# registers first is the owner, and there is no recovery path without a working +# mailer. A password that exists only in one shell's environment is a dashboard +# that becomes unreachable the moment that shell closes — openreplay-stack +# learned this by having to wipe its volumes. Same convention as this repo's +# other dev secrets (.devcontainer/post-create.sh mints secrets.yaml the same +# way with openssl). +# +# The two Elixir secrets live here rather than in vendor/.env for one reason: +# vendor/ is deleted by `fetch-upstream.sh --force`, and rotating +# SECRET_KEY_BASE under a live database logs every session out and makes +# TOTP secrets undecryptable. up.sh copies them into vendor/.env on every run. +# +# Usage: secrets.sh [--print] +set -euo pipefail +HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +source "$HERE/lib.sh" + +load_secrets + +# A password is about to be written to disk — make sure git can never take it. +# On this branch .claude/ is TRACKED, so only the skill's own .gitignore +# protects this file, and a rule that is present-but-wrong looks exactly like a +# rule that works. Ask git, do not read the file. +guard_gitignored() { + command -v git >/dev/null 2>&1 || return 0 + git -C "$SKILL_DIR" rev-parse --is-inside-work-tree >/dev/null 2>&1 || return 0 + git -C "$SKILL_DIR" check-ignore -q "$SECRETS_FILE" && return 0 + echo "error: $SECRETS_FILE would be TRACKED by git — refusing to write secrets there." >&2 + echo " Add '.secrets.env' to $SKILL_DIR/.gitignore first." >&2 + exit 1 +} + +fresh=0 +if [ -z "${SECRET_KEY_BASE:-}" ] || [ -z "${PLAUSIBLE_PASSWORD:-}" ]; then + fresh=1 + guard_gitignored + + # `tr -d '\r\n'`, NOT `tr -d '\n'`. Git Bash's openssl prints CRLF, so the + # obvious version leaves a carriage return at the END OF THE VALUE — which + # is not a line ending any more, it is a byte inside a secret. Plausible + # then refuses to boot with "TOTP_VAULT_KEY must be Base64 encoded 32 bytes" + # about a key that decodes to exactly 32 bytes in every tool you check it + # with. See the long note in lib.sh:load_secrets for why nothing shows it. + + # ≥64 bytes. `openssl rand -base64 48` is upstream's own suggestion. + SECRET_KEY_BASE="${SECRET_KEY_BASE:-$(openssl rand -base64 48 | tr -d '\r\n')}" + # Exactly 32 bytes, base64 — Plausible decodes this one and rejects a wrong + # length at boot rather than at first use. + TOTP_VAULT_KEY="${TOTP_VAULT_KEY:-$(openssl rand -base64 32 | tr -d '\r\n')}" + PLAUSIBLE_EMAIL="${PLAUSIBLE_EMAIL:-admin@hackagon.test}" + PLAUSIBLE_PASSWORD="${PLAUSIBLE_PASSWORD:-$(openssl rand -hex 16 | tr -d '\r\n')}" + PLAUSIBLE_NAME="${PLAUSIBLE_NAME:-Hackagon Analytics Admin}" + # RFC 2606 reserves .test for exactly this. It is a LABEL, not a hostname: + # Plausible attributes an event to a site by matching the tracker's + # `data-domain` string, and never resolves or contacts it. Using a domain we + # do not own (hackagon.dev) would work identically and be somebody else's + # namespace. + PLAUSIBLE_SITE="${PLAUSIBLE_SITE:-hackagon.test}" + + umask 077 + cat >"$SECRETS_FILE" <<EOF +# Plausible CE dev rig — generated by scripts/secrets.sh, gitignored. +# KEY=value, no quotes; everything after the first '=' is the value. +# +# Survives down.sh. After down.sh --volumes (databases wiped, registration +# reopens) the next up.sh re-creates the same account from these values, which +# is what makes a wipe a recovery path instead of a second lockout. +SECRET_KEY_BASE=$SECRET_KEY_BASE +TOTP_VAULT_KEY=$TOTP_VAULT_KEY +PLAUSIBLE_EMAIL=$PLAUSIBLE_EMAIL +PLAUSIBLE_PASSWORD=$PLAUSIBLE_PASSWORD +PLAUSIBLE_NAME=$PLAUSIBLE_NAME +PLAUSIBLE_SITE=$PLAUSIBLE_SITE +EOF +fi + +if [ "$fresh" -eq 1 ] || [ "${1:-}" = "--print" ]; then + echo "==> Plausible admin credentials ($SECRETS_FILE)" + echo " email ${PLAUSIBLE_EMAIL:-}" + echo " password ${PLAUSIBLE_PASSWORD:-}" + echo " site ${PLAUSIBLE_SITE:-}" + [ "$fresh" -eq 1 ] && echo " ⚠ dev-only credentials, shown once — afterwards read the file." +fi diff --git a/.claude/skills/plausible-stack/scripts/signup.sh b/.claude/skills/plausible-stack/scripts/signup.sh new file mode 100644 index 00000000..dc14e9ae --- /dev/null +++ b/.claude/skills/plausible-stack/scripts/signup.sh @@ -0,0 +1,192 @@ +#!/usr/bin/env bash +# Create the Plausible owner account, the site, and a Stats API key — without +# a prompt, a mailbox or a browser. Idempotent: safe to re-run any time. +# +# WHY THIS IS AN `rpc` AND NOT AN HTTP POST, which is worth writing down +# because the HTTP route LOOKS like it exists: +# +# GET /register serves a form with user[name], user[email], user[password], +# user[password_confirmation] and a _csrf_token — everything +# a scripted signup would need. +# POST /register is 404. There is no such route. +# +# The form is a LiveView (`phx-submit="register"`): the account is created by a +# handler on the WEBSOCKET, and the form's native `action="/login"` only runs +# afterwards, to log the new user in. So the visible form cannot be driven with +# curl at all, and openreplay-stack's "POST the signup endpoint" shape has no +# equivalent here. `bin/plausible rpc` runs Elixir inside the release, which is +# the same thing the LiveView handler would have done. +# +# The consequence for anyone reading this later: the code below names INTERNAL +# functions (Plausible.Auth.User.new/1, Plausible.Sites.create/2), so it is +# pinned to a Plausible version in a way an HTTP call would not be. It is +# checked against the ref in vendor/UPSTREAM.txt (v3.2.1) and it FAILS LOUDLY +# rather than half-working: every step matches on its expected result. +# +# Usage: signup.sh [--print] +set -euo pipefail +HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +source "$HERE/lib.sh" + +bash "$HERE/secrets.sh" >/dev/null +load_secrets +: "${PLAUSIBLE_EMAIL:?}" "${PLAUSIBLE_PASSWORD:?}" "${PLAUSIBLE_SITE:?}" +NAME="${PLAUSIBLE_NAME:-Hackagon Analytics Admin}" + +base="$(local_url)" + +if [ "${1:-}" = "--print" ]; then + echo "email $PLAUSIBLE_EMAIL" + echo "password $PLAUSIBLE_PASSWORD" + echo "site $PLAUSIBLE_SITE" + echo "apiKey ${PLAUSIBLE_API_KEY:-<none yet>}" + exit 0 +fi + +cid="$(compose ps -q plausible 2>/dev/null || true)" +[ -n "$cid" ] || { + echo "error: the plausible container is not running — scripts/up.sh first" >&2 + exit 1 +} + +rpc() { docker exec -i "$cid" bin/plausible rpc "$1"; } + +# ── the owner, the site ──────────────────────────────────────────────────── +# `email_verified: true` is set explicitly. ENABLE_EMAIL_VERIFICATION=false +# already means nobody is asked to verify, but the COLUMN still exists and a +# future flip of that variable would otherwise lock this account out of an +# instance with no mailer configured — i.e. permanently. +echo "==> ensuring the owner account and site exist" +out="$(rpc ' +email = "'"$PLAUSIBLE_EMAIL"'" +pass = "'"$PLAUSIBLE_PASSWORD"'" +name = "'"$NAME"'" +domain = "'"$PLAUSIBLE_SITE"'" + +user = + case Plausible.Auth.find_user_by(email: email) do + nil -> + u = + %{name: name, email: email, password: pass, password_confirmation: pass} + |> Plausible.Auth.User.new() + |> Ecto.Changeset.put_change(:email_verified, true) + |> Plausible.Repo.insert!() + + IO.puts("user=created") + u + + u -> + IO.puts("user=existing") + u + end + +case Plausible.Sites.get_by_domain(domain) do + nil -> + {:ok, %{site: _}} = Plausible.Sites.create(user, %{"domain" => domain, "timezone" => "UTC"}) + IO.puts("site=created") + + _ -> + IO.puts("site=existing") +end +')" +printf '%s\n' "$out" | sed 's/^/ /' +case "$out" in +*user=*) ;; +*) + echo "error: rpc did not report a user — Plausible internals may have moved" >&2 + exit 1 + ;; +esac + +# ── a Stats API key, so a machine can read the numbers back ──────────────── +# The dashboard is for humans; verify.sh needs to ASK PLAUSIBLE what it stored, +# and the Stats API is the only answer that goes through Plausible's own query +# layer rather than around it into ClickHouse. Created here rather than clicked +# in the UI for the same reason as everything else in this file. +# +# ⚠ A STORED KEY IS NOT A WORKING KEY, and that is the whole point of the probe +# below. `.secrets.env` deliberately survives `down.sh --volumes` so the owner +# account can be re-created after a wipe — but the API key row was IN the wiped +# database, so the file goes on naming a key that no longer exists. "Is it set" +# would be true and useless; the wipe would look recovered and verify.sh would +# then fail on a 401 several steps later, pointing nowhere near here. +if [ -n "${PLAUSIBLE_API_KEY:-}" ]; then + probe="$(curl -s -o "$CURL_DISCARD" -w '%{http_code}' -m 20 \ + -H "Authorization: Bearer $PLAUSIBLE_API_KEY" \ + "$base/api/v1/stats/aggregate?site_id=$PLAUSIBLE_SITE&period=day&metrics=visitors" || true)" + if [ "$probe" != "200" ]; then + echo "==> the stored Stats API key no longer works (HTTP $probe) — minting a new one" + PLAUSIBLE_API_KEY="" + fi +fi + +if [ -z "${PLAUSIBLE_API_KEY:-}" ]; then + echo "==> creating a Stats API key" + key="$(openssl rand -hex 24 | tr -d '\r\n')" + keyout="$(rpc ' +user = Plausible.Auth.find_user_by(email: "'"$PLAUSIBLE_EMAIL"'") +{:ok, team} = Plausible.Teams.get_by_owner(user) + +case Plausible.Auth.create_stats_api_key(user, team, "hackagon-verify", "'"$key"'") do + {:ok, _} -> IO.puts("apikey=created") + {:error, e} -> IO.puts("apikey=error " <> inspect(e)) +end +')" + case "$keyout" in + *apikey=created*) + # secrets_set REPLACES the line and re-checks `git check-ignore` first — + # the generator ran once, long ago, and a .gitignore can be edited since. + secrets_set PLAUSIBLE_API_KEY "$key" || exit 1 + echo " stored in $SECRETS_FILE" + ;; + *) + echo " ⚠ could not create a Stats API key: $keyout" >&2 + ;; + esac +fi + +# ── prove the account actually logs IN ───────────────────────────────────── +# A row in Postgres is not a working dashboard. This is the round trip a person +# will make: fetch the form for its CSRF token, post the credentials, follow +# the session to /sites, and find the site listed there. openreplay-stack +# learned to do this too — an account nobody had ever logged into once cost a +# full volume wipe. +echo "==> verifying login at $base" +jar="$(mktemp)" +trap 'rm -f "$jar"' EXIT +# Captured first, then matched: `curl | grep | head` dies with +# `curl: (23) client returned ERROR on write` on a Windows host, because head +# closes the pipe at the first match while curl is still writing. +login_html="$(curl -fsS -c "$jar" "$base/login")" +csrf="$(printf '%s' "$login_html" | + grep -oE 'name="_csrf_token"[^>]*value="[^"]+"' | sed 's/.*value="//;s/"//' | head -1)" +[ -n "$csrf" ] || { + echo "error: no CSRF token on $base/login" >&2 + exit 1 +} +code="$(curl -fsS -b "$jar" -c "$jar" -o "$CURL_DISCARD" -w '%{http_code}' -X POST "$base/login" \ + --data-urlencode "_csrf_token=$csrf" \ + --data-urlencode "email=$PLAUSIBLE_EMAIL" \ + --data-urlencode "password=$PLAUSIBLE_PASSWORD" || true)" +[ "$code" = "302" ] || { + echo "error: login answered $code (expected a 302 to /sites)" >&2 + exit 1 +} +# Captured, not piped into `grep -q`: grep exits at the first match, and curl +# on a Windows host then dies writing into a closed pipe +# (`curl: (23) client returned ERROR on write`) — a scary line in the middle of +# a successful verification. +sites_html="$(curl -fsS -b "$jar" "$base/sites")" +case "$sites_html" in +*"$PLAUSIBLE_SITE"*) ;; +*) + echo "error: logged in, but $PLAUSIBLE_SITE is not listed on /sites" >&2 + exit 1 + ;; +esac +echo " login OK, $PLAUSIBLE_SITE listed" + +echo "" +echo " owner $PLAUSIBLE_EMAIL (password: $SECRETS_FILE)" +echo " site $PLAUSIBLE_SITE" +echo " apiKey ${PLAUSIBLE_API_KEY:-<none>} (Stats API, read-only)" diff --git a/.claude/skills/plausible-stack/scripts/up.sh b/.claude/skills/plausible-stack/scripts/up.sh new file mode 100644 index 00000000..7588b7e7 --- /dev/null +++ b/.claude/skills/plausible-stack/scripts/up.sh @@ -0,0 +1,247 @@ +#!/usr/bin/env bash +# Bring Plausible Community Edition up behind a Cloudflare quick tunnel. +# +# doctor → fetch upstream → mint secrets → start the tunnel (learn the URL) +# → point BASE_URL at it → start the databases and the app +# → register the admin + the site → close registration again +# +# THE TUNNEL STARTS FIRST, and that ordering is not stylistic. Plausible reads +# BASE_URL at boot and uses it for link generation and for the LiveView +# origin/CSWSH check, so a dashboard booted against the wrong hostname serves +# HTML and then fails to connect its own websocket — which presents as a page +# that renders and never loads any numbers. Same trap as openreplay-stack's +# COMMON_DOMAIN_NAME, and the same consequence: a quick tunnel is a DEBUGGING +# tool here, because every fresh URL means rewriting the config again. +# +# Usage: up.sh [--skip-doctor] [--skip-signup] [--keep-registration-open] [--dry-run] +set -euo pipefail +HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +source "$HERE/lib.sh" + +DRY=0 +SKIP_DOCTOR=0 +SKIP_SIGNUP=0 +KEEP_OPEN=0 +MODE="" +while [ $# -gt 0 ]; do + case "$1" in + --dry-run) DRY=1 ;; + --skip-doctor) SKIP_DOCTOR=1 ;; + --skip-signup) SKIP_SIGNUP=1 ;; + --keep-registration-open) KEEP_OPEN=1 ;; + --named) MODE=named ;; + --quick) MODE=quick ;; + -h | --help) + sed -n '2,17p' "${BASH_SOURCE[0]}" | sed 's/^# \{0,1\}//' + exit 0 + ;; + *) + echo "unknown argument: $1" >&2 + exit 2 + ;; + esac + shift +done + +require_docker +[ "$SKIP_DOCTOR" -eq 1 ] || bash "$HERE/doctor.sh" +bash "$HERE/fetch-upstream.sh" +require_vendor +bash "$HERE/secrets.sh" +load_secrets + +# ── vendor/.env — everything upstream's compose interpolates or passes through ─ +# Rewritten on every run, from .secrets.env, because vendor/ is disposable +# (`fetch-upstream.sh --force` deletes it) while the secrets are not: rotating +# SECRET_KEY_BASE under a live database logs every session out. +env_set SECRET_KEY_BASE "$SECRET_KEY_BASE" +env_set TOTP_VAULT_KEY "$TOTP_VAULT_KEY" +env_set HTTP_PORT 8000 +# No mailer is configured, so email verification would create accounts that can +# never be used. Upstream's default is already false; pinned because "the +# default is what I want" is a claim that expires. +env_set ENABLE_EMAIL_VERIFICATION false +# Placeholder so compose can interpolate ${BASE_URL} for the tunnel-only start +# below. Only the `plausible` service reads it, and that service is not running +# yet — the real value is written before it is. +[ -n "$(env_get BASE_URL)" ] || env_set BASE_URL "http://localhost:$LOCAL_PORT" + +if [ "$DRY" -eq 1 ]; then + echo "" + echo "[dry-run] would start:" + compose config --services | sed 's/^/ /' + exit 0 +fi + +# ── phase 1: learn the public URL ────────────────────────────────────────── +# NAMED MODE SKIPS THIS PHASE ENTIRELY, and that is the point of it. The +# hostname is known before anything starts, so BASE_URL can be written once and +# never revisited; the "start a tunnel just to find out what it is called" dance +# is a quick-tunnel tax. +if [ "$MODE" = "named" ] && ! named_configured; then + echo "error: --named needs Cloudflare credentials and PLAUSIBLE_HOSTNAME." >&2 + cf_explain_unconfigured >&2 + exit 2 +fi +[ -n "$MODE" ] || { named_configured && MODE=named || MODE=quick; } + +if [ "$MODE" = "named" ]; then + url="https://$PLAUSIBLE_HOSTNAME" + echo "==> Mode: NAMED — $url (persistent)" + # Two tunnels onto one Plausible is not redundancy: BASE_URL names ONE of + # them, and the other would serve a dashboard whose websocket fails its own + # origin check — a page that renders and never loads any numbers. + if [ -n "$(compose ps -q tunnel 2>/dev/null)" ]; then + echo " stopping the quick tunnel (named mode owns BASE_URL)" + compose rm -sf tunnel >/dev/null 2>&1 || true + fi +else + # --no-deps: the overlay declares `tunnel → plausible`, and the point of this + # phase is that plausible must NOT start yet. cloudflared happily serves 502s + # until its origin exists; it re-resolves the name per connection. + echo "==> Mode: QUICK — starting the quick tunnel to learn its hostname…" + if cfn_running "$NAMED_TUNNEL"; then + echo " stopping the named tunnel (one BASE_URL, one hostname)" + cfn_stop "$NAMED_TUNNEL" + fi + compose up -d --no-deps tunnel + url="" + for _ in $(seq 1 30); do + url="$(tunnel_url || true)" + [ -n "$url" ] && break + sleep 2 + done + [ -z "$url" ] && { + echo "error: no tunnel URL after 60s — check: docker logs plausible-tunnel" >&2 + exit 1 + } + echo " $url" +fi +echo "$url" >"$STATE/tunnel-url" + +# ── phase 2: point the app at that URL, then start it ────────────────────── +# BASE_URL carries the SCHEME here (unlike OpenReplay's bare-hostname +# COMMON_DOMAIN_NAME + COMMON_PROTOCOL pair). https, because that is what the +# public URL is: Cloudflare terminates TLS at its edge and forwards plain http +# to the container, which is why HTTP_PORT is what the app listens on and no +# certificate is involved anywhere in here. +echo "==> pointing Plausible at $url" +env_set BASE_URL "$url" + +# Registration has to be OPEN for the admin to be created at all — CE seeds no +# account and the first person to register owns the instance. It is closed +# again at the end of this script, which matters here more than it does on a +# laptop: this dashboard is on a PUBLIC URL for as long as the tunnel lives. +[ "$SKIP_SIGNUP" -eq 1 ] || env_set DISABLE_REGISTRATION false + +echo "==> starting Plausible (first run pulls 3 images and migrates — a few minutes)…" +if [ "$MODE" = "named" ]; then + # Every service EXCEPT the quick tunnel, named explicitly. A bare `up -d` + # would start `tunnel` as well and mint a hostname nothing uses. + # shellcheck disable=SC2046 + compose up -d $(compose config --services | grep -v '^tunnel$' | tr '\n' ' ') +else + compose up -d +fi + +# ── phase 3: wait for the app, not for the container ─────────────────────── +# `compose up -d` returns when the containers were CREATED. Plausible then +# creates its database and runs migrations; /api/health is the first thing that +# is true only once it can actually serve. +echo "==> waiting for /api/health…" +health="" +deadline=$(($(date +%s) + 600)) +while :; do + health="$(curl -fsS -m 5 "$(local_url)/api/health" 2>/dev/null || true)" + case "$health" in *'"ok"'* | *'"clickhouse"'*) break ;; esac + + # A crash loop is not slowness, and waiting 600s to say so is the single + # least useful thing this script could do. `restart: always` on a container + # that dies during config evaluation looks exactly like a slow boot from the + # outside — the port simply never answers. Ask the container instead, and + # print the line that names the cause. + state="$(docker inspect -f '{{.State.Status}} {{.RestartCount}}' \ + "$(compose ps -q plausible 2>/dev/null)" 2>/dev/null || true)" + case "$state" in + restarting\ [3-9]* | restarting\ [1-9][0-9]* | exited*) + echo "error: the plausible container is not staying up ($state)" >&2 + compose logs --tail 200 plausible 2>/dev/null | + grep -E '\*\* \(|ERROR!' | head -5 | sed 's/^/ /' >&2 + echo " full log: docker compose -p $PROJECT logs plausible" >&2 + exit 1 + ;; + esac + + if [ "$(date +%s)" -ge "$deadline" ]; then + echo "error: $(local_url)/api/health did not answer within 600s" >&2 + echo " logs: docker compose -p $PROJECT logs plausible" >&2 + exit 1 + fi + sleep 5 +done +echo " $health" + +# ── phase 3b: the named tunnel, once there is an origin to point it at ───── +# AFTER the app is healthy, unlike the quick tunnel which has to run first to +# reveal its own hostname. cloudflared re-resolves per connection so the order +# is not strictly required — but starting it here means the first request +# through the public hostname finds a working dashboard rather than a 502, and +# the readiness probe inside cfn_run is then a real end-to-end check. +if [ "$MODE" = "named" ]; then + cfn_up "$NAMED_TUNNEL" "$PLAUSIBLE_HOSTNAME" "$(rig_network plausible)" \ + "http://plausible:8000" || { + echo "error: the named tunnel did not come up — the dashboard is local-only." >&2 + exit 1 + } +fi + +# ── phase 4: the admin account and the site ──────────────────────────────── +if [ "$SKIP_SIGNUP" -eq 0 ]; then + bash "$HERE/signup.sh" + + # ── phase 5: close registration behind us ──────────────────────────────── + # A quick tunnel URL is unguessable but public, and an open /register on it + # is an invitation. Closing it is one env var and a container recreate; the + # verification below is the part that matters, because "the variable is set" + # and "the route refuses" are different claims. + if [ "$KEEP_OPEN" -eq 0 ]; then + echo "==> closing registration" + env_set DISABLE_REGISTRATION true + # An env change is only picked up when the container is RE-CREATED — a + # restart re-runs the same process with the same baked environment. `up -d` + # notices the difference and recreates just this service. + compose up -d plausible >/dev/null + for _ in $(seq 1 60); do + curl -fsS -m 5 "$(local_url)/api/health" >/dev/null 2>&1 && break + sleep 2 + done + if curl -fsS -m 10 "$(local_url)/register" 2>/dev/null | grep -qi 'password_confirmation'; then + echo " ⚠ /register still serves a signup form — anyone with the tunnel URL can register." >&2 + else + echo " /register no longer offers signup" + fi + fi +fi + +load_secrets +echo "" +echo "── Plausible Community Edition ────────────────────────────" +echo " Public $url" +echo " Local $(local_url) (loopback only)" +echo " Login $url/login ${PLAUSIBLE_EMAIL:-} / see .secrets.env" +echo " Site ${PLAUSIBLE_SITE:-} (the tracker's data-domain must match this exactly)" +echo " Logs docker compose -p $PROJECT logs -f plausible" +echo " Stop bash $HERE/down.sh" +echo "" +echo " Wire the app at it: bash $HERE/wire-frontend.sh" +echo " Prove it works: bash $HERE/verify.sh" +echo "" +if [ "$MODE" = "named" ]; then + echo " Mode: NAMED — this hostname persists. BASE_URL and any wiring done" + echo " against it stay correct across restarts; re-running up.sh rewrites" + echo " nothing and the frontend does not need re-pointing." +else + echo " ⚠ This URL dies with the tunnel. Re-running up.sh mints a new one and" + echo " rewrites BASE_URL — fine for debugging, not for anything lasting." + echo " A named hostname removes that churn: see SKILL.md, 'Named tunnels'." +fi diff --git a/.claude/skills/plausible-stack/scripts/url.sh b/.claude/skills/plausible-stack/scripts/url.sh new file mode 100644 index 00000000..a50d442d --- /dev/null +++ b/.claude/skills/plausible-stack/scripts/url.sh @@ -0,0 +1,20 @@ +#!/usr/bin/env bash +# The rig's current URLs. Reads cloudflared's log rather than the state file: +# a tunnel that was restarted has a new public URL and the same state file, and +# a stale URL fails SILENTLY — the tracker keeps posting into nothing. +set -euo pipefail +HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +source "$HERE/lib.sh" + +url="$(tunnel_url || true)" +if [ -z "$url" ]; then + echo "no tunnel running — bash $HERE/up.sh" >&2 + exit 1 +fi + +echo "$url" +if [ "${1:-}" = "--all" ]; then + echo "local $(local_url)" + echo "cached $(cat "$STATE/tunnel-url" 2>/dev/null || echo '-')" + echo "BASE_URL $(env_get BASE_URL)" +fi diff --git a/.claude/skills/plausible-stack/scripts/verify.sh b/.claude/skills/plausible-stack/scripts/verify.sh new file mode 100644 index 00000000..d389fca4 --- /dev/null +++ b/.claude/skills/plausible-stack/scripts/verify.sh @@ -0,0 +1,253 @@ +#!/usr/bin/env bash +# Prove this rig works — end to end, from a real browser to Plausible's own +# query API and back. +# +# The claims, in the order they are checked, and why each is a SEPARATE claim: +# +# 1. every compose service has a running container +# "the service exists" and "the service works" are different; a +# container that was removed reads exactly like one that never ran. +# 2. the dashboard answers THROUGH THE TUNNEL, with a real login +# a 200 on / proves a proxy is up. Logging in proves Postgres, the +# session store and the app are all working from the outside. +# 3. registration is closed through the tunnel +# the URL is unguessable, not private. +# 4. the app is wired, read from the MERGED config +# a reader that looked only at the tracked config.yaml finds the key +# absent on a perfectly wired machine, skips, and reports success. +# 5. the tracker script is fetchable and is the right VARIANT +# the stock script silently refuses to send from localhost. +# 6. a real browser sends page views, carrying no id and no token +# the privacy properties are properties of the bytes. +# 7. Plausible has them: its own Stats API returns the route templates +# "the server accepted it" is not "the server can use it" — three days +# of green replay specs were bought with that confusion. +# 8. nothing it stored contains an id, and no column could hold an IP +# the positive control for 6 lives in 7, and this is the negative. +# 9. --restore removes ONE key from the shared overlay +# checked on a COPY, so it costs no restart. +# +# Usage: verify.sh [--no-browser] +# --no-browser skip step 6 EXPLICITLY. Without the flag, an unavailable +# browser is a FAILURE, not a skip: a proof that quietly +# removes its own hardest step is how this repo has been +# lied to before. +set -uo pipefail +HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +source "$HERE/lib.sh" +ROOT_DIR="$(cd "$HERE/../../../.." && pwd)" +CONFIG_DIR="$ROOT_DIR/components/frontend/data/test/config" +OVERLAY="$ROOT_DIR/.claude/skills/lib/config-overlay.sh" +APP_URL="${E2E_BASE_URL:-http://localhost:8081}" + +BROWSER=1 +[ "${1:-}" = "--no-browser" ] && BROWSER=0 + +fail=0 +ok() { printf ' [x] %s\n' "$1"; } +bad() { + printf ' ✕ %s\n' "$1" + fail=1 +} + +require_docker +load_secrets + +# ── 1. the stack ─────────────────────────────────────────────────────────── +echo "── containers" +missing="" +running="$(compose ps --format '{{.Service}}' 2>/dev/null | sort -u)" +while IFS= read -r svc; do + [ -n "$svc" ] || continue + printf '%s\n' "$running" | grep -qx "$svc" || missing="$missing $svc" +done <<EOF +$(compose config --services 2>/dev/null | sort -u) +EOF +[ -z "$missing" ] && ok "every compose service is running" || bad "not running:$missing" + +url="$(tunnel_url || true)" +[ -n "$url" ] || { + echo " ✕ no tunnel — scripts/up.sh first" >&2 + exit 1 +} + +# ── 2. the dashboard, through the tunnel, with a real login ──────────────── +echo "── dashboard at $url" +jar="$(mktemp)" +trap 'rm -f "$jar"' EXIT +login_html="$(curl -fsS -m 30 -c "$jar" "$url/login" 2>/dev/null || true)" +case "$login_html" in +*'name="_csrf_token"'*) ok "GET /login serves the form" ;; +*) bad "GET /login did not serve a login form" ;; +esac +csrf="$(printf '%s' "$login_html" | + grep -oE 'name="_csrf_token"[^>]*value="[^"]+"' | sed 's/.*value="//;s/"//' | head -1)" +code="$(curl -fsS -m 30 -b "$jar" -c "$jar" -o "$CURL_DISCARD" -w '%{http_code}' -X POST "$url/login" \ + --data-urlencode "_csrf_token=$csrf" \ + --data-urlencode "email=${PLAUSIBLE_EMAIL:-}" \ + --data-urlencode "password=${PLAUSIBLE_PASSWORD:-}" 2>/dev/null || true)" +[ "$code" = "302" ] && ok "POST /login → 302 (session issued)" || bad "POST /login answered $code" +sites="$(curl -fsS -m 30 -b "$jar" "$url/sites" 2>/dev/null || true)" +case "$sites" in +*"${PLAUSIBLE_SITE:-__none__}"*) ok "/sites lists ${PLAUSIBLE_SITE:-}" ;; +*) bad "/sites does not list ${PLAUSIBLE_SITE:-} — the dashboard is not usable" ;; +esac + +# ── 3. registration closed ───────────────────────────────────────────────── +reg="$(curl -fsS -m 30 "$url/register" 2>/dev/null || true)" +case "$reg" in +*password_confirmation*) bad "/register still offers signup on a PUBLIC url" ;; +*) ok "/register does not offer signup" ;; +esac + +# ── 4. the app's wiring, from the MERGED view ────────────────────────────── +# config.yaml < config.local.yaml, which is what +# components/frontend/src/lib/server/settings.ts does. Reading only the tracked +# file is the trap: wiring never writes there, so the key is always absent and +# a reader that stopped there would report "not wired" on a wired machine — the +# exact shape that made the replay suite pass while testing nothing. +echo "── app wiring (merged config)" +base_block="$(bash "$OVERLAY" get "$CONFIG_DIR/config.yaml" plausible 2>/dev/null || true)" +over_block="$(bash "$OVERLAY" get "$CONFIG_DIR/config.local.yaml" plausible 2>/dev/null || true)" +if [ -n "$base_block" ] && [ -n "$over_block" ]; then + # The only case where "prefer the overlay's block" differs from a deep merge. + # Single quotes: backticks inside a double-quoted string are a command + # substitution, so this line would have tried to RUN `plausible`. + echo ' ! both config.yaml and config.local.yaml define "plausible" — the' + echo " loader deep-merges them; this check reads the overlay's block only." +fi +block="${over_block:-$base_block}" +script_url="$(printf '%s\n' "$block" | sed -n 's/^[[:space:]]*scriptUrl:[[:space:]]*//p')" +domain="$(printf '%s\n' "$block" | sed -n 's/^[[:space:]]*domain:[[:space:]]*//p')" +case "$block" in +*"enabled: true"*) ok "plausible.enabled: true" ;; +*) bad "the app is not wired — scripts/wire-frontend.sh" ;; +esac +[ -n "$script_url" ] && ok "scriptUrl $script_url" || bad "no scriptUrl in the merged config" +[ "$domain" = "${PLAUSIBLE_SITE:-}" ] && + ok "domain $domain matches the registered site" || + bad "domain '$domain' ≠ registered site '${PLAUSIBLE_SITE:-}' — events would be dropped" + +# The tracked file must NEVER carry a tunnel hostname. There is a Go spec for +# the same property on the OIDC issuer (internal/config/config_test.go); +# this is the one for ours, and it is cheap enough to run every time. +if grep -q 'trycloudflare' "$CONFIG_DIR/config.yaml" 2>/dev/null; then + bad "$CONFIG_DIR/config.yaml contains a tunnel hostname — it must stay on localhost" +else + ok "the TRACKED config.yaml has no tunnel hostname" +fi + +# ── 5. the tracker script, and the right variant ─────────────────────────── +echo "── tracker script" +js="$(curl -fsS -m 30 "$script_url" 2>/dev/null || true)" +[ -n "$js" ] && ok "fetchable through the tunnel" || bad "$script_url is not fetchable" +case "$js" in +*"localhost\$|^127"*) + bad "this is the NON-local variant: it silently refuses to send from localhost" + ;; +*) ok "no localhost guard (the .local variant)" ;; +esac +# `manual`: the script must not count anything by itself. The auto-tracking +# variants call the sender on load and on history changes; the manual one only +# installs the queue. Checked by the absence of the pushState patch. +case "$js" in +*pushState*) bad "this variant tracks navigations ITSELF — it would send location.href" ;; +*) ok "manual variant (every pageview is one the app decided to send)" ;; +esac + +# ── 6. a real browser ────────────────────────────────────────────────────── +if [ "$BROWSER" -eq 1 ]; then + echo "── browser" + hackathon_id="$(curl -fsS -m 20 "$APP_URL/" 2>/dev/null | + grep -oE '[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}' | head -1)" + # Firefox and playwright live in the sibling e2e skill, inside the dev + # container. No container ⇒ FAIL, never skip. + cname="$(docker ps --format '{{.Names}}' | grep -E '^devcontainer-dev-1$' | head -1)" + if [ -z "$cname" ]; then + bad "the dev container is not running, so no browser can be driven (--no-browser to accept that)" + else + out="$(docker exec -u vscode "$cname" \ + /workspaces/hackagon/.devenv/profile/bin/node \ + /workspaces/hackagon/.claude/skills/plausible-stack/scripts/pageview.mjs \ + "$APP_URL" "$url" "$hackathon_id" /tmp/plausible-pageview.json 2>&1)" + rc=$? + printf '%s\n' "$out" | sed 's/^/ /' + [ "$rc" -eq 0 ] && ok "a real browser sent page views, with no id and no token on the wire" || + bad "the browser proof failed (above)" + fi +fi + +# ── 7. what Plausible actually stored ────────────────────────────────────── +echo "── read-back (Plausible's own Stats API)" +if [ -z "${PLAUSIBLE_API_KEY:-}" ]; then + bad "no PLAUSIBLE_API_KEY in $SECRETS_FILE — run scripts/signup.sh" +else + q="$(curl -fsS -m 30 -X POST "$url/api/v2/query" \ + -H "Authorization: Bearer $PLAUSIBLE_API_KEY" -H 'Content-Type: application/json' \ + -d "{\"site_id\":\"$domain\",\"metrics\":[\"visitors\",\"pageviews\"],\"date_range\":\"day\",\"dimensions\":[\"event:page\"]}" 2>/dev/null || true)" + # Only the `results` array. The response ECHOES the query back under "query", + # dimensions and all, so a grep over the whole body reports `event:page` as a + # page that was visited — a fake row in a list whose whole job is to be read + # for what is and is not in it. + pages="$(printf '%s' "${q%%\"meta\"*}" | grep -oE '"dimensions":\["[^"]*"\]' | sed 's/.*\["//;s/"\]//')" + if [ -z "$pages" ]; then + bad "Plausible has no page views for $domain today — the browser's 202s went nowhere" + else + ok "pages stored today: $(printf '%s' "$pages" | tr '\n' ' ')" + # The positive control: the deep route must be one of them. Without it, + # "no id was stored" is satisfied by an empty database. + case "$pages" in + */hackathon/\[id\]*) ok "the deep route landed as its TEMPLATE, /hackathon/[id]" ;; + *) [ "$BROWSER" -eq 1 ] && bad "no /hackathon/[id] row — the deep-route pageview did not land" ;; + esac + # …and the negative: nothing stored may look like an id. + if printf '%s' "$pages" | grep -qE '[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}'; then + bad "a UUID is stored in Plausible's page list" + else + ok "no UUID in anything Plausible stored" + fi + fi +fi + +# ── 8. the schema cannot hold an IP ──────────────────────────────────────── +# The privacy claim in docs/frontend/analytics.md, asked of the database rather +# than of the vendor. +ch="$(compose exec -T plausible_events_db clickhouse-client -q \ + "SELECT count() FROM system.columns WHERE database='plausible_events_db' AND table IN ('events_v2','sessions_v2') AND (name ILIKE '%ip%' OR name ILIKE '%user_agent%')" 2>/dev/null | tr -d '\r\n ')" +if [ "$ch" = "0" ]; then + ok "events_v2/sessions_v2 have no IP or user-agent column" +elif [ -n "$ch" ]; then + bad "$ch column(s) in events_v2/sessions_v2 look like they hold an IP or user agent" +else + echo " ! could not query ClickHouse for the column check" +fi + +# ── 9. --restore takes exactly one key ───────────────────────────────────── +# On a COPY: the real thing would restart three servers, and this is a claim +# about config-overlay.sh, not about the running app. +echo "── unwiring (simulated on a copy)" +live="$CONFIG_DIR/config.local.yaml" +if [ -f "$live" ]; then + tmp="$(mktemp -d)/config.local.yaml" + cp "$live" "$tmp" + before="$(bash "$OVERLAY" keys "$tmp" | sort | tr '\n' ' ')" + bash "$OVERLAY" remove "$tmp" plausible >/dev/null + after="$(bash "$OVERLAY" keys "$tmp" 2>/dev/null | sort | tr '\n' ' ')" + expect="$(bash "$OVERLAY" keys "$live" | grep -vx plausible | sort | tr '\n' ' ')" + if [ "$after" = "$expect" ]; then + ok "removes 'plausible' and keeps [ ${after:-—}] (was [ $before])" + else + bad "removal changed the wrong keys: [$after] ≠ [$expect]" + fi + rm -rf "$(dirname "$tmp")" +else + echo " ! no config.local.yaml — nothing to unwire" +fi + +echo "" +if [ "$fail" -eq 0 ]; then + echo "VERIFIED$([ "$BROWSER" -eq 0 ] && echo " (browser step skipped ON PURPOSE — the end-to-end claim is NOT proven)")" +else + echo "FAILED — see the ✕ lines." + exit 1 +fi diff --git a/.claude/skills/plausible-stack/scripts/wire-frontend.sh b/.claude/skills/plausible-stack/scripts/wire-frontend.sh new file mode 100644 index 00000000..2b5878b0 --- /dev/null +++ b/.claude/skills/plausible-stack/scripts/wire-frontend.sh @@ -0,0 +1,227 @@ +#!/usr/bin/env bash +# Point the SvelteKit app at this Plausible, or unpoint it. +# +# wire-frontend.sh wire: read the live tunnel URL, write the +# `plausible:` block, restart the frontend +# wire-frontend.sh --restore turn measurement back OFF (the default state) +# wire-frontend.sh --print show what it would write, change nothing +# +# Measurement is OFF unless this script (or a human) has written +# `plausible.enabled: true`. An absent block parses to `{enabled:false}`, and +# the component then renders no script tag at all — no request, no console +# noise. Nothing counts because somebody forgot a flag. +# +# NOTHING TRACKED IS EDITED. The block goes into config.local.yaml, the +# gitignored overlay the loader deep-merges over config.yaml +# (components/frontend/src/lib/server/settings.ts). The value written here is a +# `*.trycloudflare.com` hostname that dies in a few hours: this repo has +# already had one of those committed and left dead in HEAD for several commits, +# and `internal/config/config_test.go` now asserts both tracked configs still +# say localhost. +# +# THIS SCRIPT OWNS EXACTLY ONE KEY IN THAT FILE: `plausible`. It is the THIRD +# writer — cloudflare-tunnel's auth-wire.sh owns `oidc`, openreplay-stack's +# wire-frontend.sh owns `replay` — and none of them knows about the others. So +# --restore removes the BLOCK, never the file: an `rm` here would silently +# unwire login (a tunnel with no issuer keeps serving every page and breaks +# only signing in) or stop session replay recording (an empty OpenReplay UI +# looks exactly like the correct default). .claude/skills/lib/config-overlay.sh +# does the per-key surgery for all three. +set -euo pipefail +HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +source "$HERE/lib.sh" +ROOT_DIR="$(cd "$HERE/../../../.." && pwd)" +FRONTEND_LOCAL="$ROOT_DIR/components/frontend/data/test/config/config.local.yaml" +OVERLAY="$ROOT_DIR/.claude/skills/lib/config-overlay.sh" + +MODE="wire" +case "${1:-}" in +--restore) MODE="restore" ;; +--print) MODE="print" ;; +"") ;; +*) + echo "unknown argument: $1" >&2 + exit 2 + ;; +esac + +# The frontend reads its config ONCE at boot, so a rewrite is inert until it +# restarts — and getting that restart right is the whole trick, exactly as it +# is for openreplay-stack's wiring. THREE servers can be serving this app: +# +# process-compose `frontend` vite dev on :8081 +# prod-frontend.sh the adapter-node build on :8081 (the e2e +# harness puts it there and leaves it there) +# prod-serve.sh the adapter-node build on :8082, which is what +# the app's own Cloudflare tunnel proxies +# +# Restarting only the first succeeds, prints "Process frontend restarted", and +# changes nothing at all. +in_shell() { # run a command in the dev shell, wherever this script started + local pc="$ROOT_DIR/.claude/skills/devcontainer-up/scripts/exec.sh" + if command -v process-compose >/dev/null 2>&1; then + (cd "$ROOT_DIR" && bash -c "$1") + elif [ -f "$pc" ]; then + (cd "$ROOT_DIR" && MSYS_NO_PATHCONV=1 bash "$pc" just nix::develop default bash -c "$1") + else + return 1 + fi +} + +# ⚠ EVERY STEP HERE REPORTS. The first version of this function ran the +# restarts with `>/dev/null 2>&1` on both ends of an `&&` chain, which is how a +# stopped :8082 — the app tunnel's ONLY upstream — sat down for ten minutes +# with this script printing "analytics is ON" and nothing else. A restart that +# stops a server and fails to start it again is the one outcome that must never +# be quiet, because the public URL keeps answering (caddy falls back) right up +# until the fallback is down too. +# +# Each step is therefore: run, capture, and on failure print the tail and name +# the port that is now down. Slow, honest, and it costs one nix-shell entry per +# step (trap 4 in .claude/CLAUDE.md) — which is why they are three steps and +# not one. +step() { # <label> <command…> + local label="$1" + shift + local out + if out="$(in_shell "$*" 2>&1)"; then + echo " $label: ok" + return 0 + fi + echo " $label: FAILED" >&2 + printf '%s\n' "$out" | tail -6 | sed 's/^/ /' >&2 + return 1 +} + +restart_frontend() { + local ok=1 + + # vite, when process-compose is the thing serving :8081. + in_shell 'just deploy::proc-comp process restart frontend' >/dev/null 2>&1 && ok=0 + + # The built server on :8081, when the e2e harness put one there. `ensure` + # rebuilds if the source is newer than the build — which it is the first time + # this feature is wired, because it also adds a component. That build takes + # minutes, and its failure used to be invisible. + if in_shell 'test -s .output/run/e2e-prod-frontend.pid' >/dev/null 2>&1; then + if step ":8081 built server" \ + 'bash .claude/skills/hackathon-e2e/scripts/prod-frontend.sh stop && + bash .claude/skills/hackathon-e2e/scripts/prod-frontend.sh ensure'; then + ok=0 + else + echo " ⚠ :8081 IS DOWN — restart it: prod-frontend.sh ensure" >&2 + fi + fi + + # The built server on :8082 — the app tunnel's upstream. Restarted with ITS + # OWN origin, read back from the running process: an adapter-node server is + # launched with a FIXED ORIGIN, and handing it the wrong one 403s every form + # POST through the public URL while every page still renders. + local origin + origin="$(in_shell 'bash .claude/skills/cloudflare-tunnel/scripts/prod-serve.sh origin' 2>/dev/null | + tr -d '\r' | grep -oE 'https?://[^ ]+' | tail -1 || true)" + if [ -n "$origin" ]; then + step ":8082 tunnel upstream ($origin)" \ + "bash .claude/skills/cloudflare-tunnel/scripts/prod-serve.sh stop && + bash .claude/skills/cloudflare-tunnel/scripts/prod-serve.sh ensure '$origin'" || + echo " ⚠ THE APP TUNNEL HAS NO UPSTREAM — restart it: prod-serve.sh ensure $origin" >&2 + fi + + [ "$ok" -eq 0 ] && return 0 + echo "note: restart the frontend for this to take effect:" >&2 + echo " just deploy::proc-comp process restart frontend" >&2 + echo " (or, if the e2e built server is serving :8081)" >&2 + echo " bash .claude/skills/hackathon-e2e/scripts/prod-frontend.sh stop && … ensure" >&2 +} + +# Restart only when the overlay actually CHANGED — config-overlay.sh answers +# `changed` or `unchanged` for exactly this. A needless bounce of :8081 mid-suite +# fails one unrelated test and nothing in the output points back here. +# +# ⚠ Do not run this while an e2e suite is in flight, even so. +apply() { # <changed|unchanged> <message> + if [ "$1" = "changed" ]; then + echo "$2" + restart_frontend + else + echo "$2 (already; nothing changed, frontend left alone)" + fi +} + +if [ "$MODE" = "restore" ]; then + # `remove`, never `rm`: `oidc` and `replay` may share this file. + apply "$(bash "$OVERLAY" remove "$FRONTEND_LOCAL" plausible)" \ + "==> analytics is OFF" + exit 0 +fi + +require_docker +url="$(tunnel_url || true)" +[ -n "$url" ] || { + echo "error: no Plausible tunnel running — scripts/up.sh first" >&2 + exit 1 +} +load_secrets +domain="${PLAUSIBLE_SITE:-}" +[ -n "$domain" ] || { + echo "error: no PLAUSIBLE_SITE in $SECRETS_FILE — run scripts/secrets.sh" >&2 + exit 1 +} + +# THE SCRIPT VARIANT IS TWO DECISIONS, and both are load-bearing. +# +# `local` — the stock script REFUSES to send from a local address. Read out of +# the served file, not the docs: +# /localhost$|^127(\.[0-9]+){0,2}\.[0-9]+$|^\[::1?\]$/.test(location.hostname) +# The dev app is http://localhost:8081, so without this variant +# every page view is silently dropped by the browser — the most +# expensive shape of failure there is, because everything looks +# wired and nothing arrives. +# `manual` — the script otherwise sends a pageview by ITSELF, using +# `location.href`. That URL is the one thing this integration must +# never send (invite tokens, hackathon ids). Manual mode means every +# pageview is one PlausibleAnalytics.svelte decided to send, with a +# URL it built out of the route template. +# +# The file name's variant order does not matter (the instance serves every +# permutation); `local.manual` is written for readability. +script_url="$url/js/script.local.manual.js" + +block=$( + cat <<YAML +plausible: + enabled: true + scriptUrl: $script_url + domain: $domain +YAML +) + +if [ "$MODE" = "print" ]; then + echo "$block" + exit 0 +fi + +# Fail before writing a config that points at a script that is not there — a +# 404 on the tracker is invisible in the app (a failed <script> logs one line +# and the page is fine) and looks identical to "nobody visited". +# +# cfn_http_code, not a bare curl: with a NAMED hostname the check must fail on a +# broken TUNNEL and not on a broken RESOLVER, and those are distinguishable — +# it retries against the address Cloudflare's own DoH endpoint gives. The +# machine this was built on answers AAAA-only for these names with no IPv6 route +# out, so a bare curl reports 000 for a dashboard that is serving 200. +code="$(cfn_http_code "$script_url")" +[ "$code" = "200" ] || { + echo "error: $script_url answered $code — is the tunnel healthy? (scripts/url.sh)" >&2 + exit 1 +} + +apply "$(printf '%s\n' "$block" | bash "$OVERLAY" set "$FRONTEND_LOCAL" plausible)" \ + "==> analytics is ON" +echo " script $script_url" +echo " domain $domain" +echo " dashboard $url/$domain" +echo " ⚠ page views from every visitor to the app are now counted." +echo " No cookie is set and no URL is sent — the ROUTE TEMPLATE is" +echo " (see components/frontend/src/lib/utils/analyticsRoute.ts), and" +echo " docs/frontend/analytics.md states what Plausible does with the IP." diff --git a/.claude/skills/seed-past-hackathons/SKILL.md b/.claude/skills/seed-past-hackathons/SKILL.md new file mode 100644 index 00000000..1deee5af --- /dev/null +++ b/.claude/skills/seed-past-hackathons/SKILL.md @@ -0,0 +1,176 @@ +--- +name: seed-past-hackathons +description: + Populate a running Hackagon instance with SDSC's real past hackathons — one + source-cited JSON per edition under data/, with all images downloaded into + static/ and then uploaded into the platform's own object store (covers, + gallery pages, prize art). Covers the editions from the live + sdsc-hackathons.ch platform (Firestore) plus the Energy Data Hackdays. Use + when asked to seed past/previous/archived hackathons, migrate the old + platform's content, fill the platform with real history, add a new past + edition, or demo the archive with authentic content. +--- + +# Seed past SDSC hackathons + +One JSON per edition in `data/`, every image in `static/<slug>/`, so the folder +is self-contained. `scripts/seed.sh` creates them on a running instance and +**uploads their pictures into the platform's object store**; their dates are in +the past, so they render as **Finished** — an instant archive to demo against +and a realistic fixture for the public pages. + +## Editions on file + +| File | Event | When | Where | Source | +| -------------------------------------------- | --------------------------------------- | ------------------- | -------------------------------- | ------------------------------------- | +| `2023-11-generative-ai.json` | SDSC Hackathon: Generative AI | 30 Nov – 1 Dec 2023 | ETH Andreasturm, Zurich Oerlikon | live platform (archived) + event page | +| `2024-10-ord-for-the-sciences.json` | SDSC Hackathon: ORD for the Sciences | 24–25 Oct 2024 | BC Building, EPFL, Lausanne | live platform (archived) + event page | +| `2025-09-energy-data-hackdays.json` | Energy Data Hackdays 2025 | 11–12 Sep 2025 | FHNW Brugg-Windisch | SDSC event page | +| `2026-05-energy-data-hackdays-lausanne.json` | Energy Data Hackdays — Spring, Lausanne | 7–8 May 2026 | Biopôle, Lausanne | SDSC article | +| `2026-06-durham-group-1.json` | Hackathon with Uni Durham — Group 1 | 2–3 Jun 2026 | SDSC, Arginine bld, Biopôle | **live platform** | +| `2026-06-durham-group-2.json` | Hackathon with Uni Durham — Group 2 | 9–10 Jun 2026 | SDSC, Arginine bld, Biopôle | **live platform** | + +The Durham pair and the two SDSC editions come straight from the **current +platform's own database** — sdsc-hackathons.ch is a FlutterFlow app on Firestore +(project `sdschacks`, collection `hackathons`); each record carries its +`platformRef` with the document id, so the mapping old→new is traceable. Read +one directly with: + +```bash +curl "https://firestore.googleapis.com/v1/projects/sdschacks/databases/(default)/documents/hackathons/<docId>" +``` + +Not on file: `qhe3gH7y0I6NpMTSzHyr` ("This is a test hackathon", not visible). + +## Run + +```bash +bash .claude/skills/seed-past-hackathons/scripts/fetch-media.sh # images → static/ +bash .claude/skills/seed-past-hackathons/scripts/validate.sh # JSON + dates + images +bash .claude/skills/seed-past-hackathons/scripts/seed.sh --dry-run +bash .claude/skills/seed-past-hackathons/scripts/seed.sh # needs the stack up +bash .claude/skills/seed-past-hackathons/scripts/seed.sh --refresh # re-apply media + pages +bash .claude/skills/seed-past-hackathons/scripts/prizes.sh # a prize table per edition +bash .claude/skills/seed-past-hackathons/scripts/reseed.sh # delete, then seed again +``` + +Needs `grpcurl`, `jq`, `curl` (Nix dev shell). From a host without them: + +```bash +bash .claude/skills/devcontainer-up/scripts/exec.sh just develop \ + bash .claude/skills/seed-past-hackathons/scripts/seed.sh +``` + +Seeding is **idempotent by NAME** — an edition that already exists is skipped. +That is right for "run it twice" and exactly wrong after the _seeder_ changes: +the editions were created before it uploaded anything, so a plain re-run left +every cover empty forever. `--refresh` re-uploads the media, re-sets the cover +and recreates the pages on an edition that is already there (tracks and phases +are first-create only — there is no natural key to match them on, so a refresh +would duplicate them). `reseed.sh` is the heavier hammer: delete the editions, +then seed from scratch. It clears each event's pages first, because `Delete` +refuses while an event still has any ("archive it instead") — and deleting also +exercises the object store's delete-by-prefix purge, so a re-seed does not +strand the previous run's images in the bucket. + +## Images: fetched into `static/`, uploaded into the platform + +`fetch-media.sh` puts every image in `static/<slug>/` (14 files across the six +editions today) and records `static/checksums.sha256` trust-on-first-use; a +later run verifies and warns if a remote file changed. Two source schemes: + +- `"source": "https://…"` — downloaded (Firebase Storage, SDSC CDN, EDHD site) +- `"source": "repo:components/frontend/static/images/…"` — copied from the repo + +Stored artefacts are **WebP** (`media[].file` ends in `.webp`); `source` keeps +the real provenance URL, so a source that is still JPEG/PNG upstream is +re-encoded at q80 on arrival. That needs `cwebp`, which is not in the dev shell +— run `nix shell nixpkgs#libwebp -c bash scripts/fetch-media.sh`. + +`seed.sh` then pushes them **into the instance** through +`StorageService.CreateUploadUrl` (presigned PUT, uploaded straight to the store, +the returned public path stored in the DB). Four consequences worth knowing: + +- **The banner becomes the event's cover.** The first image marked + `category: banner`/`cover` — or the first image, if none is marked — is + uploaded as `UPLOAD_KIND_HACKATHON_LOGO` and `Edit`ed onto the hackathon as + its logo. Everything else goes up as `UPLOAD_KIND_HACKATHON_MEDIA`. +- **Page markdown is repointed at what was just uploaded.** The paths in the + JSON are the _old_ platform's (`/images/hackathon-ord-2024/…`) and resolve to + nothing here; matching is by basename, because the old tree had a category + folder per image that this platform has no equivalent for. Without the rewrite + ORD 2024's photo pages render as broken images. +- **Unreferenced photos get a generated "Photos" page.** Uploading an image no + page links to would be the same "exists but nothing reaches it" bug the + reachability audits keep finding. +- **The upload has to reach the store, not the browser prefix.** + `CreateUploadUrl` returns a ROOT-RELATIVE `/objects/…` URL — correct for a + browser, which PUTs same-origin — so a CLI must supply a host, and SigV4 signs + `Host`. The seeder tries the store directly (`HACKAGON_STORE_ENDPOINT`, + default `http://rustfs:9000`) and falls back to proxies that rewrite `Host`; a + base that serves `/objects` without rewriting answers 403 and would be the + wrong pick even though it is reachable. SVG uploads are refused on purpose, so + `edhd-logo.svg` is not uploaded. + +## Prizes + +`prizes.sh` gives every seeded edition four prizes — 1st/2nd/3rd plus a rank-0 +discretionary "Community Choice" — each carrying an image uploaded the same way. +The badges in `static/_generated/` are **drawn, not photographed**: abstract +marks with a numeral. A synthetic photo of a trophy attached to a real event's +award would be a fabricated record, which is the same reason the landing page +stopped shipping invented winner cards. + +## Record shape + +```jsonc +{ + "slug": "durham-2026-group-1", + "name": "Hackathon with Uni Durham — Group 1", + "platformRef": { "documentId": "xKRA1U6f8btkHakhky5n", … }, // old-platform trace + "visibility": "VISIBILITY_PUBLIC", + "startsAt": "2026-06-02T08:30:00+02:00", // real local time, real offset + "endsAt": "2026-06-03T17:00:00+02:00", + "venue": "…", "organizers": ["…"], "partners": ["…"], "theme": "…", + "description": "…", // → hackathon description + "tracks": [ { "name": "…", "description": "…" } ], + "phases": [ { "name": "…", "startsAt": "…", "endsAt": "…" } ], + "pages": [ { "title": "Programme", "order": 2, "visible": true, "content": "markdown…" } ], + "media": { "images": [ { "file": "cover.webp", "category": "banner", // banner/cover ⇒ the event's logo + "source": "https://…", "caption": "…", "credit": "…" } ] }, + "votingCategories": [ { "title": "…", "numberOfWinners": 1 } ], + "preEventTimeline": [ { "order": 0, "process": "Registrations", "when": "13.05.2026 - 22.05.2026" } ], + "capabilities": { "can_register": true, … }, // old platform's flags, for reference + "stats": { "teams": 20, "projects": 15 }, + "source": { "urls": ["…"], "retrieved": "2026-08-05", "note": "what was verified" } +} +``` + +`votingCategories`, `preEventTimeline` and `capabilities` are recorded but not +yet seeded — they map onto `VoteService`, `ConfigService.SetWindows` and the +capability rows respectively. Wiring them is the natural next step. + +## Adding an edition + +1. `curl` the Firestore doc (or gather from a citable page). +2. Copy an existing JSON, fill it in, list images with their `source`. +3. `fetch-media.sh` → `validate.sh` → `seed.sh`. + +## Accuracy policy + +Archive material, so **nothing is invented**. Where a source did not publish +something (winners everywhere; participant counts for 2023 and Durham), the +field is empty and `source.note` says so. Two recorded caveats worth keeping: + +- The Durham agenda entries in Firestore carry **May date parts on a June + event** (template leftovers). Only the times are meaningful, so phases are + anchored to the real event days with times converted from UTC to +02:00. +- The Energy Data Hackdays are **EDIH-run with SDSC participating**, not + SDSC-run, and are not in the old platform's database. + +## Notes + +- `PhaseService.Create` silently drops dates (bug B4 in `docs/TODO.md`), so the + seeder creates each phase then `Edit`s the dates in. Drop that once fixed. +- Everything is created as `hackagon-admin` (override with `HACKAGON_ADMIN_USER` + / `HACKAGON_ADMIN_PASS`). diff --git a/.claude/skills/seed-past-hackathons/data/2023-11-generative-ai.json b/.claude/skills/seed-past-hackathons/data/2023-11-generative-ai.json new file mode 100644 index 00000000..345b87b2 --- /dev/null +++ b/.claude/skills/seed-past-hackathons/data/2023-11-generative-ai.json @@ -0,0 +1,177 @@ +{ + "slug": "sdsc-genai-2023", + "name": "SDSC Hackathon: Generative AI", + "edition": "2023", + "platformRef": { + "system": "sdsc-hackathons.ch (FlutterFlow + Firestore, project sdschacks)", + "collection": "hackathons", + "documentId": "DHepetK0DLQ6cRMVcvPb", + "archived": true, + "url": "https://sdsc-hackathons.ch/welcome?hackathonId=DHepetK0DLQ6cRMVcvPb" + }, + "votingCategories": [ + { "title": "Impact potential", "numberOfWinners": 1 }, + { "title": "Technically gorgeous", "numberOfWinners": 1 }, + { "title": "Bold moonshot", "numberOfWinners": 1 } + ], + "capacity": { + "total": 80, + "perSector": 20, + "sectors": ["academia", "private", "public", "SDSC"] + }, + "compute": [ + "Azure ML Studio (Microsoft vouchers)", + "RunAI cluster on Switch (managed by SDSC)", + "Google Colab", + "Renku" + ], + "preEventTimeline": [ + { + "order": 1, + "process": "Registration", + "when": "Anytime, until 80 spots taken — register, validate email, await confirmation" + }, + { + "order": 2, + "process": "Propose a project", + "when": "Until 6 November, noon — organizers validate proposals" + }, + { + "order": 3, + "process": "Project preferences", + "when": "8–14 November — select 3 preferred projects; organizers form teams" + }, + { + "order": 4, + "process": "Preparation", + "when": "2nd half of November — Microsoft/SDSC webinars, dataset preparation" + } + ], + "visibility": "VISIBILITY_PUBLIC", + "startsAt": "2023-11-30T08:00:00+01:00", + "endsAt": "2023-12-01T20:00:00+01:00", + "venue": "ETH Zurich, Andreasturm, Zurich Oerlikon", + "organizers": ["Swiss Data Science Center (SDSC)"], + "partners": ["Microsoft — free Azure resources and training webinars"], + "logo": "", + "description": "Two days bringing together professionals from academia, the private sector and public organizations to explore generative AI tools and technologies. Teams develop proof-of-concepts using GenAI techniques, applied to real-world challenges. Free participation, registration mandatory. Hosted by SDSC at ETH Zurich, Andreasturm, Zurich Oerlikon.", + "goals": [ + "DEVELOP simple PoCs using Generative AI technologies", + "APPLY to real-world challenges arising in the private, public and academic sectors", + "SHARE code, knowledge, best practices, ideas for use cases", + "CONNECT with other data science professionals" + ], + "keynote": { + "speaker": "Dr. Yannic Kilcher", + "role": "CTO & Co-founder, DeepJudge" + }, + "team": [ + { "name": "Valerio Rossetti", "role": "Principal Data Scientist" }, + { "name": "Sean Murphy", "role": "Sr. Systems Engineer" }, + { "name": "Luis Salamanca", "role": "Lead Data Scientist" }, + { + "name": "Carlos Vivar Ríos", + "role": "Senior Open Research Data Engineer" + } + ], + "tracks": [], + "phases": [ + { + "name": "Check-in", + "description": "Registration desk opens.", + "startsAt": "2023-11-30T08:00:00+01:00", + "endsAt": "2023-11-30T09:00:00+01:00" + }, + { + "name": "Keynote", + "description": "Opening keynote by Dr. Yannic Kilcher (DeepJudge).", + "startsAt": "2023-11-30T09:00:00+01:00", + "endsAt": "2023-11-30T10:00:00+01:00" + }, + { + "name": "Group work — day 1", + "description": "Team formation and PoC development (break for lunch 13:00–14:00).", + "startsAt": "2023-11-30T10:00:00+01:00", + "endsAt": "2023-11-30T18:00:00+01:00" + }, + { + "name": "Apéro — day 1", + "description": "Evening networking.", + "startsAt": "2023-11-30T18:00:00+01:00", + "endsAt": "2023-11-30T20:00:00+01:00" + }, + { + "name": "Group work — day 2", + "description": "Continued development through to the submission deadline (lunch 13:00–14:00).", + "startsAt": "2023-12-01T08:00:00+01:00", + "endsAt": "2023-12-01T15:00:00+01:00" + }, + { + "name": "Presentations & voting", + "description": "Project presentations followed by participant voting.", + "startsAt": "2023-12-01T15:30:00+01:00", + "endsAt": "2023-12-01T18:00:00+01:00" + }, + { + "name": "Awards apéro", + "description": "Results and closing apéro.", + "startsAt": "2023-12-01T18:00:00+01:00", + "endsAt": "2023-12-01T20:00:00+01:00" + } + ], + "pages": [ + { + "title": "About", + "visible": true, + "order": 1, + "content": "## SDSC Hackathon: Generative AI\n\nTwo days at **ETH Zurich, Andreasturm** (Zurich Oerlikon), 30 November – 1 December 2023.\n\nProfessionals from academia, the private sector and public organizations came together to explore generative AI tools and technologies, building proof-of-concepts against real-world challenges.\n\n### Goals\n\n- **Develop** simple PoCs using Generative AI technologies\n- **Apply** them to real-world challenges in the private, public and academic sectors\n- **Share** code, knowledge, best practices and use-case ideas\n- **Connect** with other data science professionals\n\nParticipation was free; registration was mandatory." + }, + { + "title": "Programme", + "visible": true, + "order": 2, + "content": "## Day 1 — Thursday 30 November 2023\n\n| Time | Session |\n| --- | --- |\n| 08:00–09:00 | Check-in |\n| 09:00–10:00 | Keynote — Dr. Yannic Kilcher (CTO & Co-founder, DeepJudge) |\n| 10:00–13:00 | Group work |\n| 13:00–14:00 | Lunch |\n| 14:00–18:00 | Group work |\n| 18:00–20:00 | Apéro |\n\n## Day 2 — Friday 1 December 2023\n\n| Time | Session |\n| --- | --- |\n| 08:00–13:00 | Group work |\n| 13:00–14:00 | Lunch |\n| 14:00–15:00 | Group work |\n| 15:30–18:00 | Project presentations & voting |\n| 18:00–20:00 | Apéro |" + }, + { + "title": "Organizers & partners", + "visible": true, + "order": 3, + "content": "Organized by the **Swiss Data Science Center**.\n\n### SDSC team\n\n- Valerio Rossetti — Principal Data Scientist\n- Sean Murphy — Sr. Systems Engineer\n- Luis Salamanca — Lead Data Scientist\n- Carlos Vivar Ríos — Senior Open Research Data Engineer\n\n### Supporting partner\n\n**Microsoft** — free Azure resources and training webinars for participants." + } + ], + "media": { + "images": [ + { + "file": "event.webp", + "source": "https://cdn.prod.website-files.com/63f1f58039379743bd96333e/64dddef6af87ca4d1450fa7a_SDSC-Hackathon_Richemont2023_1.JPG", + "caption": "SDSC Hackathon 2023 — event photo", + "category": "ambiance", + "credit": "SDSC event page" + }, + { + "file": "keynote-yannic-kilcher.webp", + "source": "https://cdn.prod.website-files.com/63f1f58039379743bd96333e/65142ae0b7664864dac6914e_YannicKilcher.jpeg", + "caption": "Keynote — Dr. Yannic Kilcher (CTO & Co-founder, DeepJudge)", + "category": "speaker", + "credit": "SDSC event page" + }, + { + "file": "partner-microsoft.webp", + "source": "https://cdn.prod.website-files.com/63f1f58039379743bd96333e/65142b3f28f6b5905514d79f_logo-microsoft-scaled.jpeg", + "caption": "Supporting partner — Microsoft", + "category": "logo", + "credit": "SDSC event page" + } + ], + "note": "Pulled from the SDSC event page for this edition. The event photo is captioned 'Richemont2023' on the CDN — SDSC's own event photography." + }, + "stats": {}, + "source": { + "urls": [ + "https://www.datascience.ch/event/hackathon-2023", + "https://sdsc-hackathons.ch/" + ], + "retrieved": "2026-08-05", + "note": "Programme, venue, keynote, team and partner verified from the SDSC event page. Team/project counts and winners were not published there — left empty rather than invented." + } +} diff --git a/.claude/skills/seed-past-hackathons/data/2024-10-ord-for-the-sciences.json b/.claude/skills/seed-past-hackathons/data/2024-10-ord-for-the-sciences.json new file mode 100644 index 00000000..b736c905 --- /dev/null +++ b/.claude/skills/seed-past-hackathons/data/2024-10-ord-for-the-sciences.json @@ -0,0 +1,181 @@ +{ + "slug": "sdsc-ord-2024", + "name": "SDSC Hackathon: ORD for the Sciences", + "edition": "2024", + "platformRef": { + "system": "sdsc-hackathons.ch (FlutterFlow + Firestore, project sdschacks)", + "collection": "hackathons", + "documentId": "vUt8BfDJXaAs0UfOesXI", + "archived": true, + "url": "https://sdsc-hackathons.ch/welcome?hackathonId=vUt8BfDJXaAs0UfOesXI" + }, + "speakers": [ + { + "name": "Gilles Dubochet", + "role": "Opening remarks", + "affiliation": "SDSC" + }, + { + "name": "Oksana Riba Grognuz", + "role": "Opening remarks", + "affiliation": "SDSC" + }, + { "name": "Angelika Kalt", "role": "Presentation", "affiliation": "SNSF" }, + { + "name": "Florin Hasler", + "role": "Presentation", + "affiliation": "opendata.ch" + } + ], + "visibility": "VISIBILITY_PUBLIC", + "startsAt": "2024-10-24T08:00:00+02:00", + "endsAt": "2024-10-25T20:00:00+02:00", + "venue": "BC Building, EPFL, Lausanne", + "organizers": ["Swiss Data Science Center (SDSC)", "EPFL Open Science"], + "partners": [], + "logo": "", + "description": "Two days exploring the potential and impact of Open Research Data on the sciences. Participants from academia, industry and the public sector joined forces to unlock the power of open research datasets and infrastructures — optimizing datasets, building analytical models, and sharing code and best practices. Co-organized by SDSC and EPFL Open Science at the BC Building, EPFL, Lausanne. Maximum 100 participants; free participation with mandatory registration.", + "goals": [ + "Harness open research data to shape the future of open research", + "Optimize datasets and build analytical models", + "Share code and best practices across communities", + "Network with data science professionals" + ], + "platform": { + "name": "Renku", + "description": "SDSC's open-source knowledge infrastructure for collaborative and reproducible data science, provided to teams for collaboration and GPU access." + }, + "tracks": [ + { + "name": "Research Data Infrastructures (RDI)", + "description": "Prototyping new research-data-infrastructure initiatives." + }, + { + "name": "Data Science", + "description": "Developing tools, models and knowledge on top of open research data." + } + ], + "phases": [ + { + "name": "Check-in & keynotes", + "description": "Registration, then keynotes on the importance of open data.", + "startsAt": "2024-10-24T08:00:00+02:00", + "endsAt": "2024-10-24T10:00:00+02:00" + }, + { + "name": "Group work — day 1", + "description": "Team formation and project work across both tracks.", + "startsAt": "2024-10-24T10:00:00+02:00", + "endsAt": "2024-10-24T18:00:00+02:00" + }, + { + "name": "Apéro — day 1", + "description": "Evening networking.", + "startsAt": "2024-10-24T18:00:00+02:00", + "endsAt": "2024-10-24T20:00:00+02:00" + }, + { + "name": "Group work — day 2", + "description": "Continued development up to the submission deadline.", + "startsAt": "2024-10-25T08:00:00+02:00", + "endsAt": "2024-10-25T15:00:00+02:00" + }, + { + "name": "Presentations & voting", + "description": "Project submissions, presentations and participant voting.", + "startsAt": "2024-10-25T15:00:00+02:00", + "endsAt": "2024-10-25T18:00:00+02:00" + }, + { + "name": "Awards", + "description": "Awards ceremony and closing apéro.", + "startsAt": "2024-10-25T18:00:00+02:00", + "endsAt": "2024-10-25T20:00:00+02:00" + } + ], + "pages": [ + { + "title": "About", + "visible": true, + "order": 1, + "content": "## ORD for the Sciences\n\nTwo days at the **BC Building, EPFL, Lausanne**, 24–25 October 2024, co-organized by the **Swiss Data Science Center** and **EPFL Open Science**.\n\nThe event explored the potential and impact of Open Research Data on the sciences. Participants from academic, private and public sector organizations joined forces to unlock the power of open research datasets and infrastructures, embracing open principles and fostering innovation.\n\n**20 teams worked on 15 projects** across two tracks, with a maximum of 100 participants." + }, + { + "title": "Tracks", + "visible": true, + "order": 2, + "content": "## Research Data Infrastructures (RDI)\n\nPrototyping new research-data-infrastructure initiatives.\n\n## Data Science\n\nDeveloping tools, models and knowledge on top of open research data.\n\nTeams could optimize datasets, build analytical models, and share code and best practices across both tracks." + }, + { + "title": "Programme", + "visible": true, + "order": 3, + "content": "## Day 1 — Thursday 24 October 2024\n\nCheck-in, keynotes on the importance of open data, then group work sessions. Food and beverages provided.\n\n## Day 2 — Friday 25 October 2024\n\nContinued development, project submissions, presentations, voting and the awards ceremony. Food and beverages provided." + }, + { + "title": "Working environment", + "visible": true, + "order": 4, + "content": "Teams worked on **Renku**, SDSC's open-source knowledge infrastructure for collaborative and reproducible data science — providing shared project environments and GPU access for the duration of the hackathon." + }, + { + "title": "Photos", + "visible": true, + "order": 5, + "content": "![Opening ceremony](/images/hackathon-ord-2024/ambiance/ambiance_1.webp)\n\n![Working sessions](/images/hackathon-ord-2024/ambiance/ambiance_3.webp)\n\n![Team collaboration](/images/hackathon-ord-2024/teams/teams_1.webp)\n\n![Award ceremony](/images/hackathon-ord-2024/winners/winners_1.webp)" + } + ], + "media": { + "images": [ + { + "file": "banner.webp", + "source": "https://cdn.prod.website-files.com/63f1f58039379743bd96333e/66e06b37a6a45350730cec9c_ORD%20Hackathon%20Banner_website_v2.png", + "caption": "ORD Hackathon 2024 — event banner", + "category": "banner", + "credit": "SDSC event page" + }, + { + "file": "ambiance_1.webp", + "source": "repo:components/frontend/static/images/hackathon-ord-2024/ambiance/ambiance_1.webp", + "caption": "ORD Hackathon 2024 — Opening ceremony", + "category": "ambiance", + "credit": "SDSC" + }, + { + "file": "ambiance_3.webp", + "source": "repo:components/frontend/static/images/hackathon-ord-2024/ambiance/ambiance_3.webp", + "caption": "ORD Hackathon 2024 — Working sessions", + "category": "ambiance", + "credit": "SDSC" + }, + { + "file": "teams_1.webp", + "source": "repo:components/frontend/static/images/hackathon-ord-2024/teams/teams_1.webp", + "caption": "ORD Hackathon 2024 — Team collaboration", + "category": "teams", + "credit": "SDSC" + }, + { + "file": "winners_1.webp", + "source": "repo:components/frontend/static/images/hackathon-ord-2024/winners/winners_1.webp", + "caption": "ORD Hackathon 2024 — Award ceremony", + "category": "winners", + "credit": "SDSC" + } + ], + "note": "The four photos also ship in components/frontend/static/images/hackathon-ord-2024/ (the home-page carousel uses them); fetch-media.sh copies them into the skill so this folder is self-contained." + }, + "stats": { + "teams": 20, + "projects": 15, + "maxParticipants": 100 + }, + "source": { + "urls": [ + "https://www.datascience.ch/event/sdsc-hackathons-ord-for-the-sciences", + "https://sdsc-hackathons.ch/" + ], + "retrieved": "2026-08-05", + "note": "Dates, venue, organizers, tracks, team/project counts, capacity and the Renku platform detail verified from the SDSC event page. Individual project names and winners were not published there — omitted rather than invented." + } +} diff --git a/.claude/skills/seed-past-hackathons/data/2025-09-energy-data-hackdays.json b/.claude/skills/seed-past-hackathons/data/2025-09-energy-data-hackdays.json new file mode 100644 index 00000000..92b9b4a6 --- /dev/null +++ b/.claude/skills/seed-past-hackathons/data/2025-09-energy-data-hackdays.json @@ -0,0 +1,78 @@ +{ + "slug": "energy-data-hackdays-2025", + "name": "Energy Data Hackdays 2025", + "edition": "2025 — Autumn", + "visibility": "VISIBILITY_PUBLIC", + "startsAt": "2025-09-11T08:00:00+02:00", + "endsAt": "2025-09-12T18:00:00+02:00", + "venue": "FHNW Campus Brugg-Windisch, Switzerland", + "organizers": [ + "Energy Data Innovation Hub (EDIH)", + "Swiss Data Science Center (SDSC)" + ], + "partners": ["FHNW — host venue"], + "logo": "", + "description": "Almost 32 hours of team-based hacking and co-creation on 12 real-world challenges from energy utilities, startups, public agencies and research institutions. Developers, data scientists, designers, researchers, energy professionals and creative minds tackle real energy problems with data and technology. SDSC provides Renku, its collaborative data science platform, so teams can organize data and code and reach compute resources for rapid experimentation.", + "goals": [ + "Tackle real-world energy challenges using data and technology", + "Bring utilities, startups, public agencies and research institutions into one room", + "Prototype in the open on a shared, reproducible platform" + ], + "platform": { + "name": "Renku", + "description": "SDSC's collaborative data science platform — team data/code organization plus compute for rapid experimentation." + }, + "tracks": [], + "phases": [ + { + "name": "Day 1 — kickoff & hacking", + "description": "Challenge presentations, team formation and the first hacking block.", + "startsAt": "2025-09-11T08:00:00+02:00", + "endsAt": "2025-09-11T18:00:00+02:00" + }, + { + "name": "Day 2 — hacking & results", + "description": "Second hacking block, presentations and closing.", + "startsAt": "2025-09-12T08:00:00+02:00", + "endsAt": "2025-09-12T18:00:00+02:00" + } + ], + "pages": [ + { + "title": "About", + "visible": true, + "order": 1, + "content": "## Energy Data Hackdays 2025\n\n**FHNW Campus Brugg-Windisch**, 11–12 September 2025, 08:00–18:00.\n\nAlmost **32 hours of team-based hacking and co-creation** on **12 real-world challenges** contributed by energy utilities, startups, public agencies and research institutions.\n\nThe event brings together developers, data scientists, designers, researchers, energy professionals and creative minds to tackle real energy problems with data and technology.\n\nOrganized by the **Energy Data Innovation Hub** with the **Swiss Data Science Center**; hosted at FHNW." + }, + { + "title": "Working environment", + "visible": true, + "order": 2, + "content": "SDSC provides **Renku**, its collaborative data science platform: teams organize their data and code in shared projects and get access to compute resources for rapid experimentation during the hackdays." + } + ], + "media": { + "images": [ + { + "file": "hero.webp", + "source": "https://cdn.prod.website-files.com/63f1f58039379743bd96333e/68c294de342f9d13375a06c7_SDSC_EDH_09-25_rev_AdobeStock_1275401847%20Large.jpeg", + "caption": "Energy Data Hackdays 2025 — event banner", + "category": "banner", + "credit": "SDSC event page; underlying artwork licensed from Adobe Stock (promotional image, not an event photo)" + } + ], + "note": "Only the promotional banner is published on the SDSC event page. Event photography lives on energydatahackdays.ch." + }, + "stats": { + "challenges": 12, + "hoursOfHacking": 32 + }, + "source": { + "urls": [ + "https://www.datascience.ch/event/energy-hack-days-2025", + "https://www.energydatahackdays.ch/" + ], + "retrieved": "2026-08-05", + "note": "Dates, venue, organizers, challenge count, 32-hour format and the Renku detail verified from the SDSC event page. Participant count, individual challenge titles and winners were not published there — omitted rather than invented." + } +} diff --git a/.claude/skills/seed-past-hackathons/data/2026-05-energy-data-hackdays-lausanne.json b/.claude/skills/seed-past-hackathons/data/2026-05-energy-data-hackdays-lausanne.json new file mode 100644 index 00000000..7415f93f --- /dev/null +++ b/.claude/skills/seed-past-hackathons/data/2026-05-energy-data-hackdays-lausanne.json @@ -0,0 +1,116 @@ +{ + "slug": "energy-data-hackdays-2026-lausanne", + "name": "Energy Data Hackdays 2026 — Spring Edition, Lausanne", + "edition": "2026 — Spring (Suisse romande)", + "visibility": "VISIBILITY_PUBLIC", + "startsAt": "2026-05-07T08:00:00+02:00", + "endsAt": "2026-05-08T18:00:00+02:00", + "venue": "SDSC headquarters, Biopôle, Lausanne (Canton of Vaud)", + "organizers": [ + "Energy Data Innovation Hub (EDIH)", + "Swiss Data Science Center (SDSC)" + ], + "partners": [ + "Swiss Federal Office of Energy (SFOE)", + "Romande Energie", + "HSLU — Lucerne University of Applied Sciences and Arts", + "HEIG-VD" + ], + "logo": "", + "description": "The first Energy Data Hackdays edition in French-speaking Switzerland: around 100 experts spent 36 hours on 5 real-world climate and grid challenges, hosted at SDSC's headquarters at Biopôle in Lausanne. Challenges were contributed by the SFOE, Romande Energie, HSLU, HEIG-VD and SDSC.", + "goals": [ + "Expand the Energy Data Hackdays to French-speaking Switzerland", + "Work on real climate and grid challenges with the organizations that own them", + "Connect utilities, federal agencies and universities around shared energy data" + ], + "tracks": [], + "challenges": [ + { + "title": "Estimating Hourly Energy Production for Switzerland", + "owner": "Swiss Federal Office of Energy (SFOE)" + }, + { + "title": "Swiss GlassScan — window-to-wall ratio assessment", + "owner": "Swiss Data Science Center (SDSC)" + }, + { + "title": "GridPulseMT — day-ahead load forecasting for the Vaud grid", + "owner": "Romande Energie" + }, + { + "title": "Designing pocket- and grid-friendly electricity communities", + "owner": "HSLU" + }, + { + "title": "Anticipating grid imbalances in Switzerland's energy transition", + "owner": "HEIG-VD" + } + ], + "phases": [ + { + "name": "Day 1 — challenges & hacking", + "description": "Challenge presentations, team formation, first hacking block.", + "startsAt": "2026-05-07T08:00:00+02:00", + "endsAt": "2026-05-07T18:00:00+02:00" + }, + { + "name": "Day 2 — hacking & presentations", + "description": "Second hacking block, presentations and closing.", + "startsAt": "2026-05-08T08:00:00+02:00", + "endsAt": "2026-05-08T18:00:00+02:00" + } + ], + "pages": [ + { + "title": "About", + "visible": true, + "order": 1, + "content": "## Energy Data Hackdays — Spring Edition, Lausanne\n\n**SDSC headquarters, Biopôle, Lausanne**, 7–8 May 2026.\n\nThe first edition in **French-speaking Switzerland**: about **100 experts** spent **36 hours** on **5 real-world climate and grid challenges**, contributed by the organizations that live with them.\n\nOrganized by the **Energy Data Innovation Hub** with the **Swiss Data Science Center**, together with the SFOE, Romande Energie, HSLU and HEIG-VD." + }, + { + "title": "Challenges", + "visible": true, + "order": 2, + "content": "| Challenge | Contributed by |\n| --- | --- |\n| Estimating Hourly Energy Production for Switzerland | Swiss Federal Office of Energy (SFOE) |\n| Swiss GlassScan — window-to-wall ratio assessment | Swiss Data Science Center |\n| GridPulseMT — day-ahead load forecasting for the Vaud grid | Romande Energie |\n| Designing pocket- and grid-friendly electricity communities | HSLU |\n| Anticipating grid imbalances in Switzerland's energy transition | HEIG-VD |" + } + ], + "media": { + "images": [ + { + "file": "thumbnail.webp", + "source": "https://cdn.prod.website-files.com/63f1f58039379743bd96333e/69a6a305f05b59eeb20a6ac9_CFE_thumbnail.jpg", + "caption": "Energy Data Hackdays 2026 — event thumbnail", + "category": "banner", + "credit": "SDSC website" + }, + { + "file": "edhd-logo.svg", + "source": "https://www.energydatahackdays.ch/images/logo/energydata-hackdays-logo.svg", + "caption": "Energy Data Hackdays logo", + "category": "logo", + "credit": "energydatahackdays.ch" + }, + { + "file": "hackdays-day2.webp", + "source": "https://www.energydatahackdays.ch/images/success-stories/_800x400_crop_center-center_90_none/11970/2509_NZ-EDHD-Tag-2-1-13.webp", + "caption": "Energy Data Hackdays — day 2 working session", + "category": "ambiance", + "credit": "energydatahackdays.ch" + } + ], + "note": "Photo is from the EDHD archive (2025 autumn edition imagery used on the site's success-stories section)." + }, + "stats": { + "participants": 100, + "challenges": 5, + "hoursOfHacking": 36 + }, + "source": { + "urls": [ + "https://www.datascience.ch/articles/coding-the-future-energy-data-hackdays-expand-to-french-speaking-switzerland", + "https://www.energydatahackdays.ch/" + ], + "retrieved": "2026-08-05", + "note": "Venue, partners, ~100 participants, 36 hours and all five challenge titles verified from the SDSC article. Winners were not published there — omitted. Dates 7–8 May 2026 per energydatahackdays.ch's spring-edition listing." + } +} diff --git a/.claude/skills/seed-past-hackathons/data/2026-06-durham-group-1.json b/.claude/skills/seed-past-hackathons/data/2026-06-durham-group-1.json new file mode 100644 index 00000000..a88bd66e --- /dev/null +++ b/.claude/skills/seed-past-hackathons/data/2026-06-durham-group-1.json @@ -0,0 +1,151 @@ +{ + "slug": "durham-2026-group-1", + "name": "Hackathon with Uni Durham — Group 1", + "edition": "2026 — Durham, group 1", + "platformRef": { + "system": "sdsc-hackathons.ch (FlutterFlow + Firestore, project sdschacks)", + "collection": "hackathons", + "documentId": "xKRA1U6f8btkHakhky5n", + "url": "https://sdsc-hackathons.ch/welcome?hackathonId=xKRA1U6f8btkHakhky5n" + }, + "visibility": "VISIBILITY_PUBLIC", + "startsAt": "2026-06-02T08:30:00+02:00", + "endsAt": "2026-06-03T17:00:00+02:00", + "venue": "SDSC, Arginine bld, Biopôle, Lausanne", + "organizers": ["Swiss Data Science Center (SDSC)"], + "partners": [ + "University of Durham — participating students", + "Richemont — industry talk" + ], + "logo": "", + "theme": "Prototyping Data Science and AI solutions for real impact", + "description": "Welcome to the Hackathons for the amazing students of the University of Durham. This is the first event, happening on June the 2nd and 3rd. Two days at the SDSC office at Biopôle, Lausanne, prototyping data science and AI solutions for real impact.", + "tracks": [], + "preEventTimeline": [ + { + "order": 0, + "process": "Registrations", + "when": "13.05.2026 - 22.05.2026" + }, + { + "order": 1, + "process": "Propose a project (optional)", + "when": "13.05.2026 - 28.05.2026" + }, + { + "order": 2, + "process": "Choose your team (optional)", + "when": "28.05.2026 - 02.06.2026" + }, + { + "order": 3, + "process": "Hacking time!", + "when": "02.06.2026 - 03.06.2026" + } + ], + "phases": [ + { + "name": "Welcome coffee", + "description": "Check-in — Plenary room.", + "startsAt": "2026-06-02T08:30:00+02:00", + "endsAt": "2026-06-02T09:00:00+02:00" + }, + { + "name": "Industry talk", + "description": "Francesco Calabrese, Head of Group Data Office, Richemont — Plenary room.", + "startsAt": "2026-06-02T09:00:00+02:00", + "endsAt": "2026-06-02T10:00:00+02:00" + }, + { + "name": "Hacking — day 1", + "description": "Plenary room, Rubis, Opale, Jade.", + "startsAt": "2026-06-02T10:00:00+02:00", + "endsAt": "2026-06-02T22:00:00+02:00" + }, + { + "name": "Welcome coffee — day 2", + "description": "Plenary room.", + "startsAt": "2026-06-03T08:30:00+02:00", + "endsAt": "2026-06-03T09:00:00+02:00" + }, + { + "name": "Hacking — day 2", + "description": "Plenary room, Rubis, Opale, Jade.", + "startsAt": "2026-06-03T08:30:00+02:00", + "endsAt": "2026-06-03T15:30:00+02:00" + }, + { + "name": "Presentations & voting", + "description": "Presentations, voting and prizes — Plenary room.", + "startsAt": "2026-06-03T15:30:00+02:00", + "endsAt": "2026-06-03T17:00:00+02:00" + } + ], + "votingCategories": [ + { "title": "A2Q: Answer Any Question", "numberOfWinners": 1 }, + { "title": "Analysis of corporate reports", "numberOfWinners": 1 } + ], + "capabilities": { + "can_register": true, + "can_propose_project": false, + "can_choose_project_preference": false, + "can_send_project_submission": false, + "can_edit_submission": false, + "can_view_proposals": true, + "can_vote": false, + "results_published": false + }, + "pages": [ + { + "title": "Welcome", + "visible": true, + "order": 1, + "content": "## Hackathon with Uni Durham — Group 1\n\nWelcome to the Hackathons for the amazing students of the **University of Durham**. This is the first event, happening on **June the 2nd and 3rd**.\n\n**Theme:** Prototyping Data Science and AI solutions for real impact.\n\n**Venue:** SDSC, Arginine bld, Biopôle, Lausanne." + }, + { + "title": "Programme", + "visible": true, + "order": 2, + "content": "## Day 1 — Tuesday 2 June 2026\n\n| Time | Session | Where |\n| --- | --- | --- |\n| 08:30–09:00 | Welcome coffee | Plenary room |\n| 09:00–10:00 | Industry talk — Francesco Calabrese (Head of Group Data Office, Richemont) | Plenary room |\n| 10:00–22:00 | Hacking | Plenary room, Rubis, Opale, Jade |\n\n## Day 2 — Wednesday 3 June 2026\n\n| Time | Session | Where |\n| --- | --- | --- |\n| 08:30–09:00 | Welcome coffee | Plenary room |\n| 08:30–15:30 | Hacking | Plenary room, Rubis, Opale, Jade |\n| 15:30–17:00 | Presentations, voting, prizes | Plenary room |" + }, + { + "title": "Timeline", + "visible": true, + "order": 3, + "content": "| Step | When |\n| --- | --- |\n| Registrations | 13.05.2026 – 22.05.2026 |\n| Propose a project (optional) | 13.05.2026 – 28.05.2026 |\n| Choose your team (optional) | 28.05.2026 – 02.06.2026 |\n| Hacking time! | 02.06.2026 – 03.06.2026 |" + }, + { + "title": "Awards", + "visible": true, + "order": 4, + "content": "Two categories, one winner each:\n\n- **A2Q: Answer Any Question**\n- **Analysis of corporate reports**" + }, + { + "title": "FAQ", + "visible": true, + "order": 5, + "content": "### How do I get to the hackathon venue?\n\nThe venue is the SDSC office at Biopôle in Lausanne, next to the metro stop Vennes. The office is in the building Arginine, 6th floor. There will be panels outside and inside the building to guide you. [Google Maps link](https://maps.app.goo.gl/4vF6bTQMPtChxu6x9)\n\n### Am I required to propose a project?\n\nProject proposals are optional. We expect only a few proposals per hackathon, and we expect the proponent to be committed in following up their proposals, find and curate the data (if needed), and lead the teams that will tackle that project." + } + ], + "media": { + "images": [ + { + "file": "cover.webp", + "source": "https://firebasestorage.googleapis.com/v0/b/sdschacks.appspot.com/o/users%2FQkOuQKtrXVWfC4FakB0r3Xue3L73%2Fuploads%2F1778577711110000.jpg?alt=media&token=e5b70609-0baa-46dd-9e17-c7601791a104", + "caption": "Hackathon with Uni Durham — Group 1, cover image", + "category": "banner", + "credit": "sdsc-hackathons.ch (Firebase Storage)" + } + ], + "note": "Cover image as published on the live platform." + }, + "stats": {}, + "source": { + "urls": [ + "https://sdsc-hackathons.ch/welcome?hackathonId=xKRA1U6f8btkHakhky5n", + "https://firestore.googleapis.com/v1/projects/sdschacks/databases/(default)/documents/hackathons/xKRA1U6f8btkHakhky5n" + ], + "retrieved": "2026-08-05", + "note": "Read from the live platform's own Firestore document — authoritative. Caveat: the agenda entries' date parts in the source are template leftovers (they carry May dates on a June event); only the times are meaningful, so phases here are anchored to the real event days with the source times converted from UTC to +02:00. Team/participant counts and winners are not stored in the document." + } +} diff --git a/.claude/skills/seed-past-hackathons/data/2026-06-durham-group-2.json b/.claude/skills/seed-past-hackathons/data/2026-06-durham-group-2.json new file mode 100644 index 00000000..d1d285c1 --- /dev/null +++ b/.claude/skills/seed-past-hackathons/data/2026-06-durham-group-2.json @@ -0,0 +1,151 @@ +{ + "slug": "durham-2026-group-2", + "name": "Hackathon with Uni Durham — Group 2", + "edition": "2026 — Durham, group 2", + "platformRef": { + "system": "sdsc-hackathons.ch (FlutterFlow + Firestore, project sdschacks)", + "collection": "hackathons", + "documentId": "tsbDuqqnl8RNQBInCso8", + "url": "https://sdsc-hackathons.ch/welcome?hackathonId=tsbDuqqnl8RNQBInCso8" + }, + "visibility": "VISIBILITY_PUBLIC", + "startsAt": "2026-06-09T08:30:00+02:00", + "endsAt": "2026-06-10T17:00:00+02:00", + "venue": "SDSC, Arginine bld, Biopôle, Lausanne", + "organizers": ["Swiss Data Science Center (SDSC)"], + "partners": [ + "University of Durham — participating students", + "Richemont — industry talk" + ], + "logo": "", + "theme": "Prototyping Data Science and AI solutions for real impact", + "description": "Welcome to the Hackathons for the amazing students of the University of Durham. This is the first event, happening on June the 9th and 10th. Two days at the SDSC office at Biopôle, Lausanne, prototyping data science and AI solutions for real impact.", + "tracks": [], + "preEventTimeline": [ + { + "order": 0, + "process": "Registrations", + "when": "13.05.2026 - 22.05.2026" + }, + { + "order": 1, + "process": "Propose a project (optional)", + "when": "13.05.2026 - 28.05.2026" + }, + { + "order": 2, + "process": "Choose your team (optional)", + "when": "28.05.2026 - 02.06.2026" + }, + { + "order": 3, + "process": "Hacking time!", + "when": "09.06.2026 - 10.06.2026" + } + ], + "phases": [ + { + "name": "Welcome coffee", + "description": "Check-in — Plenary room.", + "startsAt": "2026-06-09T08:30:00+02:00", + "endsAt": "2026-06-09T09:00:00+02:00" + }, + { + "name": "Hacking — day 1", + "description": "Plenary room, Rubis, Opale, Jade.", + "startsAt": "2026-06-09T09:00:00+02:00", + "endsAt": "2026-06-09T22:00:00+02:00" + }, + { + "name": "Welcome coffee — day 2", + "description": "Plenary room.", + "startsAt": "2026-06-10T08:30:00+02:00", + "endsAt": "2026-06-10T09:00:00+02:00" + }, + { + "name": "Industry talk", + "description": "Francesco Calabrese, Head of Group Data Office, Richemont — Plenary room.", + "startsAt": "2026-06-10T09:00:00+02:00", + "endsAt": "2026-06-10T10:00:00+02:00" + }, + { + "name": "Hacking — day 2", + "description": "Plenary room, Rubis, Opale, Jade.", + "startsAt": "2026-06-10T10:00:00+02:00", + "endsAt": "2026-06-10T15:30:00+02:00" + }, + { + "name": "Presentations & voting", + "description": "Presentations, voting and prizes — Plenary room.", + "startsAt": "2026-06-10T15:30:00+02:00", + "endsAt": "2026-06-10T17:00:00+02:00" + } + ], + "votingCategories": [ + { "title": "A2Q: Answer Any Question", "numberOfWinners": 1 }, + { "title": "Analysis of corporate reports", "numberOfWinners": 1 } + ], + "capabilities": { + "can_register": true, + "can_propose_project": true, + "can_choose_project_preference": true, + "can_send_project_submission": true, + "can_edit_submission": true, + "can_view_proposals": true, + "can_vote": false, + "results_published": false + }, + "pages": [ + { + "title": "Welcome", + "visible": true, + "order": 1, + "content": "## Hackathon with Uni Durham — Group 2\n\nWelcome to the Hackathons for the amazing students of the **University of Durham**. This is the first event, happening on **June the 9th and 10th**.\n\n**Theme:** Prototyping Data Science and AI solutions for real impact.\n\n**Venue:** SDSC, Arginine bld, Biopôle, Lausanne." + }, + { + "title": "Programme", + "visible": true, + "order": 2, + "content": "## Day 1 — Tuesday 9 June 2026\n\n| Time | Session | Where |\n| --- | --- | --- |\n| 08:30–09:00 | Welcome coffee | Plenary room |\n| 09:00–22:00 | Hacking | Plenary room, Rubis, Opale, Jade |\n\n## Day 2 — Wednesday 10 June 2026\n\n| Time | Session | Where |\n| --- | --- | --- |\n| 08:30–09:00 | Welcome coffee | Plenary room |\n| 09:00–10:00 | Industry talk — Francesco Calabrese (Head of Group Data Office, Richemont) | Plenary room |\n| 10:00–15:30 | Hacking | Plenary room, Rubis, Opale, Jade |\n| 15:30–17:00 | Presentations, voting, prizes | Plenary room |" + }, + { + "title": "Timeline", + "visible": true, + "order": 3, + "content": "| Step | When |\n| --- | --- |\n| Registrations | 13.05.2026 – 22.05.2026 |\n| Propose a project (optional) | 13.05.2026 – 28.05.2026 |\n| Choose your team (optional) | 28.05.2026 – 02.06.2026 |\n| Hacking time! | 09.06.2026 – 10.06.2026 |" + }, + { + "title": "Awards", + "visible": true, + "order": 4, + "content": "Two categories, one winner each:\n\n- **A2Q: Answer Any Question**\n- **Analysis of corporate reports**" + }, + { + "title": "FAQ", + "visible": true, + "order": 5, + "content": "### How do I get to the hackathon venue?\n\nThe venue is the SDSC office at Biopôle in Lausanne, next to the metro stop Vennes. The office is in the building Arginine, 6th floor. There will be panels outside and inside the building to guide you. [Google Maps link](https://maps.app.goo.gl/4vF6bTQMPtChxu6x9)\n\n### Am I required to propose a project?\n\nProject proposals are optional. We expect only a few proposals per hackathon, and we expect the proponent to be committed in following up their proposals, find and curate the data (if needed), and lead the teams that will tackle that project." + } + ], + "media": { + "images": [ + { + "file": "cover.webp", + "source": "https://firebasestorage.googleapis.com/v0/b/sdschacks.appspot.com/o/users%2FQkOuQKtrXVWfC4FakB0r3Xue3L73%2Fuploads%2F1778579821295000.jpg?alt=media&token=3bf824d4-4470-47f6-8f98-58298383de8b", + "caption": "Hackathon with Uni Durham — Group 2, cover image", + "category": "banner", + "credit": "sdsc-hackathons.ch (Firebase Storage)" + } + ], + "note": "Cover image as published on the live platform." + }, + "stats": {}, + "source": { + "urls": [ + "https://sdsc-hackathons.ch/welcome?hackathonId=tsbDuqqnl8RNQBInCso8", + "https://firestore.googleapis.com/v1/projects/sdschacks/databases/(default)/documents/hackathons/tsbDuqqnl8RNQBInCso8" + ], + "retrieved": "2026-08-05", + "note": "Read from the live platform's own Firestore document — authoritative. Same agenda-date caveat as group 1: the source's agenda entries carry May date parts on a June event, so phases are anchored to the real event days with the source times converted from UTC to +02:00. Note group 2 had the full project/preference/submission capabilities enabled, group 1 did not." + } +} diff --git a/.claude/skills/seed-past-hackathons/scripts/fetch-media.sh b/.claude/skills/seed-past-hackathons/scripts/fetch-media.sh new file mode 100644 index 00000000..477c18be --- /dev/null +++ b/.claude/skills/seed-past-hackathons/scripts/fetch-media.sh @@ -0,0 +1,138 @@ +#!/usr/bin/env bash +# Download / copy every image referenced by data/*.json into static/<slug>/, +# so the skill folder is self-contained (debug hosting — a real image service +# replaces this later; only media[].source changes). +# +# source: "https://…" → downloaded +# source: "repo:path" → copied from the repo +# +# The stored artefacts are WebP, so a source that is still JPEG/PNG upstream is +# re-encoded on arrival (media[].file ends in .webp, media[].source keeps the +# real provenance URL). That needs cwebp; it is not in the dev shell, so get it +# with `nix shell nixpkgs#libwebp -c bash scripts/fetch-media.sh`. +# +# Checksums are recorded in static/checksums.sha256 (trust-on-first-use): a +# later fetch verifies and fails loudly if a remote file changed. +# +# Usage: fetch-media.sh [--force] [file.json ...] +set -euo pipefail +HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +SKILL_DIR="$(dirname "$HERE")" +ROOT_DIR="$(cd "$SKILL_DIR/../../.." && pwd)" +STATIC="$SKILL_DIR/static" +LOCK="$STATIC/checksums.sha256" + +FORCE=0 +FILES=() +while [ $# -gt 0 ]; do + case "$1" in + --force) FORCE=1 ;; + -h | --help) + sed -n '2,12p' "${BASH_SOURCE[0]}" | sed 's/^# \{0,1\}//' + exit 0 + ;; + *) FILES+=("$1") ;; + esac + shift +done +[ ${#FILES[@]} -eq 0 ] && FILES=("$SKILL_DIR"/data/*.json) + +command -v jq >/dev/null || { + echo "error: jq not found (run inside the Nix dev shell)" >&2 + exit 1 +} + +# Put $1 at $2, re-encoding to WebP when the destination says .webp and the +# bytes are not WebP already. Refuses rather than storing, say, JPEG bytes under +# a .webp name: everything downstream trusts the extension. +place() { + local from="$1" to="$2" + if [ "${to##*.}" = "webp" ] && [ "$(head -c4 "$from")" != "RIFF" ]; then + command -v cwebp >/dev/null || { + echo "error: $(basename "$to") must be WebP but the source is not; cwebp not found." >&2 + echo " retry as: nix shell nixpkgs#libwebp -c bash ${BASH_SOURCE[0]}" >&2 + return 1 + } + cwebp -quiet -q 80 -m 6 -metadata none "$from" -o "$to" + else + cp "$from" "$to" + fi +} +mkdir -p "$STATIC" +got=0 +skipped=0 +failed=0 + +for f in "${FILES[@]}"; do + slug=$(jq -r .slug "$f") + n=$(jq '.media.images | length' "$f") + [ "$n" -eq 0 ] && { + echo "── $slug: no images" + continue + } + echo "── $slug ($n)" + mkdir -p "$STATIC/$slug" + + for i in $(seq 0 $((n - 1))); do + file=$(jq -r ".media.images[$i].file" "$f") + src=$(jq -r ".media.images[$i].source" "$f") + dest="$STATIC/$slug/$file" + + if [ -f "$dest" ] && [ "$FORCE" -eq 0 ]; then + echo " [=] $file" + skipped=$((skipped + 1)) + continue + fi + + case "$src" in + repo:*) + srcpath="$ROOT_DIR/${src#repo:}" + if [ -f "$srcpath" ] && place "$srcpath" "$dest"; then + echo " [c] $file ← repo" + else + echo " ✕ missing or unconvertible repo file: $srcpath" + failed=$((failed + 1)) + continue + fi + ;; + http*) + tmp=$(mktemp) + if curl -fsSL -A "hackagon-seed/1.0 (SDSC archive)" -o "$tmp" "$src" && place "$tmp" "$dest"; then + echo " [v] $file ($(du -h "$dest" | cut -f1))" + rm -f "$tmp" + else + echo " ✕ download failed: $src" + failed=$((failed + 1)) + rm -f "$tmp" "$dest" + continue + fi + ;; + *) + echo " ✕ unknown source scheme: $src" + failed=$((failed + 1)) + continue + ;; + esac + got=$((got + 1)) + done +done + +# checksums: verify against the lockfile if present, otherwise record. +cd "$STATIC" +if [ -f "$LOCK" ] && [ "$FORCE" -eq 0 ]; then + echo "" + echo "── verifying checksums" + if sha256sum -c --quiet "$(basename "$LOCK")" 2>/dev/null; then + echo " all files match the lockfile" + else + echo " ! some files differ from the lockfile (re-run with --force to accept)" + fi +else + find . -type f ! -name "checksums.sha256" -print0 | sort -z | xargs -0 sha256sum >"$(basename "$LOCK")" + echo "" + echo "── recorded $(wc -l <"$(basename "$LOCK")") checksums" +fi + +echo "" +echo "fetched $got · skipped $skipped · failed $failed → $STATIC" +[ "$failed" -eq 0 ] diff --git a/.claude/skills/seed-past-hackathons/scripts/prizes.sh b/.claude/skills/seed-past-hackathons/scripts/prizes.sh new file mode 100644 index 00000000..b5c2d2c2 --- /dev/null +++ b/.claude/skills/seed-past-hackathons/scripts/prizes.sh @@ -0,0 +1,99 @@ +#!/usr/bin/env bash +# Give every seeded edition a prize table, each prize carrying a picture. +# +# The badges in static/_generated are DRAWN, not photographed — abstract marks +# with a numeral. That is deliberate: a synthetic photo of a trophy attached to +# a real event's award would be a fabricated record, which is the same reason +# the landing page stopped shipping invented winner cards. +# +# Usage: prizes.sh [--dry-run] +set -euo pipefail +HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +SKILL_DIR="$(dirname "$HERE")" +ROOT_DIR="$(cd "$SKILL_DIR/../../.." && pwd)" +GRPC="${E2E_GRPC_ADDR:-localhost:3000}" +KC="${E2E_KEYCLOAK_URL:-http://localhost:8180}" +STORE_ENDPOINT="${HACKAGON_STORE_ENDPOINT:-http://rustfs:9000}" +ADMIN_USER="${HACKAGON_ADMIN_USER:-hackagon-admin}" +ADMIN_PASS="${HACKAGON_ADMIN_PASS:-aliceandbob}" +DRY=0 +[ "${1:-}" = "--dry-run" ] && DRY=1 + +TOKEN=$(curl -s -X POST "$KC/realms/hackagon/protocol/openid-connect/token" \ + -H "Content-Type: application/x-www-form-urlencoded" \ + -d client_id="hackagon-backend" -d username="$ADMIN_USER" -d password="$ADMIN_PASS" \ + -d grant_type=password -d scope="openid profile" | jq -r .access_token) +[ -z "$TOKEN" ] || [ "$TOKEN" = "null" ] && { + echo "error: no admin token from $KC" >&2 + exit 1 +} +rpc() { grpcurl -plaintext -H "authorization: Bearer $TOKEN" -d "$2" "$GRPC" "$1"; } + +# Upload one badge for one hackathon; prints its public path. +badge() { + local hid="$1" file="$2" path="$SKILL_DIR/static/_generated/$2" + local size req resp url pub code + [ -f "$path" ] || { + echo "" + return 0 + } + size=$(stat -c%s "$path") + req=$(jq -nc --arg o "$hid" --arg f "$file" --argjson s "$size" \ + '{kind:"UPLOAD_KIND_HACKATHON_MEDIA", ownerId:$o, filename:$f, + contentType:"image/webp", sizeBytes:$s}') + resp=$(rpc storage.StorageService/CreateUploadUrl "$req" 2>/dev/null) || { + echo "" + return 0 + } + url=$(jq -r '.uploadUrl // empty' <<<"$resp") + pub=$(jq -r '.publicUrl // empty' <<<"$resp") + [ -z "$url" ] && { + echo "" + return 0 + } + # Straight at the store: /objects is a browser-facing prefix, and the + # signature is computed against the store's own host and path. + code=$(curl -s -o /dev/null -w '%{http_code}' -X PUT -H "Content-Type: image/webp" \ + --data-binary "@$path" "${STORE_ENDPOINT}${url#/objects}") + if [ "$code" != "200" ] && [ "$code" != "204" ]; then + echo "" + return 0 + fi + echo "$pub" +} + +list=$(rpc hackathon.HackathonService/List '{}') +for f in "$SKILL_DIR"/data/*.json; do + name=$(jq -r .name "$f") + hid=$(jq -r --arg n "$name" '.hackathons[]? | select(.name == $n) | .id' <<<"$list") + [ -z "$hid" ] && { + echo "── $name — not seeded, skipping" + continue + } + echo "── $name" + if [ "$DRY" -eq 1 ]; then + echo " [dry-run] would set 4 prizes with images" + continue + fi + + g1=$(badge "$hid" prize-1.webp) + g2=$(badge "$hid" prize-2.webp) + g3=$(badge "$hid" prize-3.webp) + gx=$(badge "$hid" prize-x.webp) + + # rank 0 is the discretionary/special prize — see entities/prize.proto. + req=$(jq -nc --arg h "$hid" --arg a "$g1" --arg b "$g2" --arg c "$g3" --arg d "$gx" '{ + hackathonId: $h, + prizes: [ + {rank: 1, title: "First place", image: $a}, + {rank: 2, title: "Second place", image: $b}, + {rank: 3, title: "Third place", image: $c}, + {rank: 0, title: "Community Choice", image: $d} + ] + }') + if rpc hackathon.PrizeService/Set "$req" >/dev/null 2>&1; then + echo " [+] 4 prizes with images" + else + echo " ✕ could not set prizes" >&2 + fi +done diff --git a/.claude/skills/seed-past-hackathons/scripts/reseed.sh b/.claude/skills/seed-past-hackathons/scripts/reseed.sh new file mode 100644 index 00000000..793117ba --- /dev/null +++ b/.claude/skills/seed-past-hackathons/scripts/reseed.sh @@ -0,0 +1,65 @@ +#!/usr/bin/env bash +# Delete the editions in data/*.json from the running instance, then seed them +# again. Seeding is idempotent by NAME, so it skips anything already there — +# which is exactly wrong after a change to what seeding produces (media, cover, +# rewritten page paths). This is the "I changed the seeder, apply it" path. +# +# Deleting also exercises the delete-by-prefix purge: an edition's uploaded +# objects go with it, so a re-seed does not leave the previous run's images +# orphaned in the bucket. +# +# Usage: reseed.sh [--delete-only] +set -euo pipefail +HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +SKILL_DIR="$(dirname "$HERE")" +ROOT_DIR="$(cd "$SKILL_DIR/../../.." && pwd)" +GRPC="${E2E_GRPC_ADDR:-localhost:3000}" +KC="${E2E_KEYCLOAK_URL:-http://localhost:8180}" +ADMIN_USER="${HACKAGON_ADMIN_USER:-hackagon-admin}" +ADMIN_PASS="${HACKAGON_ADMIN_PASS:-aliceandbob}" + +DELETE_ONLY=0 +[ "${1:-}" = "--delete-only" ] && DELETE_ONLY=1 + +TOKEN=$(curl -s -X POST "$KC/realms/hackagon/protocol/openid-connect/token" \ + -H "Content-Type: application/x-www-form-urlencoded" \ + -d client_id="hackagon-backend" -d username="$ADMIN_USER" -d password="$ADMIN_PASS" \ + -d grant_type=password -d scope="openid profile" | jq -r .access_token) +[ -z "$TOKEN" ] || [ "$TOKEN" = "null" ] && { + echo "error: no admin token from $KC" >&2 + exit 1 +} + +rpc() { grpcurl -plaintext -H "authorization: Bearer $TOKEN" -d "$2" "$GRPC" "$1"; } + +list=$(rpc hackathon.HackathonService/List '{}') +deleted=0 +for f in "$SKILL_DIR"/data/*.json; do + name=$(jq -r .name "$f") + # --arg, not string interpolation: these names carry em-dashes and colons, + # and an earlier version of this loop built the jq filter by concatenation + # and silently matched nothing at all — it reported success having deleted + # zero rows. + id=$(jq -r --arg n "$name" '.hackathons[]? | select(.name == $n) | .id' <<<"$list") + [ -z "$id" ] && continue + + # Delete refuses while an event still has pages — "archive it instead", which + # is the right default for a real event and merely in the way here. Clear them + # first; the seeder recreates every page from the JSON anyway. + pages=$(rpc hackathon.PageService/List "$(jq -nc --arg h "$id" '{hackathonId:$h}')" 2>/dev/null | + jq -r '.pages[]?.id' || true) + for p in $pages; do + rpc hackathon.PageService/Delete "$(jq -nc --arg p "$p" '{pageId:$p}')" >/dev/null 2>&1 || true + done + + if rpc hackathon.HackathonService/Delete "$(jq -nc --arg h "$id" '{hackathonId:$h}')" >/dev/null 2>&1; then + echo " [-] $name" + deleted=$((deleted + 1)) + else + echo " ✕ could not delete $name" >&2 + fi +done +echo "deleted $deleted edition(s)" + +[ "$DELETE_ONLY" -eq 1 ] && exit 0 +exec bash "$HERE/seed.sh" diff --git a/.claude/skills/seed-past-hackathons/scripts/seed.sh b/.claude/skills/seed-past-hackathons/scripts/seed.sh new file mode 100644 index 00000000..4e5fa162 --- /dev/null +++ b/.claude/skills/seed-past-hackathons/scripts/seed.sh @@ -0,0 +1,309 @@ +#!/usr/bin/env bash +# Populate the running platform with SDSC's past hackathons from data/*.json. +# Idempotent: an edition whose name already exists is skipped. +# +# Usage: seed.sh [--dry-run] [file.json ...] (default: every data/*.json) +set -euo pipefail +HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +SKILL_DIR="$(dirname "$HERE")" +ROOT_DIR="$(cd "$SKILL_DIR/../../.." && pwd)" +GRPC="${E2E_GRPC_ADDR:-localhost:3000}" +KC="${E2E_KEYCLOAK_URL:-http://localhost:8180}" +ADMIN_USER="${HACKAGON_ADMIN_USER:-hackagon-admin}" +ADMIN_PASS="${HACKAGON_ADMIN_PASS:-aliceandbob}" + +DRY=0 +REFRESH=0 +FILES=() +while [ $# -gt 0 ]; do + case "$1" in + --dry-run) DRY=1 ;; + --refresh) REFRESH=1 ;; + -h | --help) + sed -n '2,7p' "${BASH_SOURCE[0]}" | sed 's/^# \{0,1\}//' + exit 0 + ;; + *) FILES+=("$1") ;; + esac + shift +done +[ ${#FILES[@]} -eq 0 ] && FILES=("$SKILL_DIR"/data/*.json) + +for bin in grpcurl jq curl; do + command -v "$bin" >/dev/null 2>&1 || { + echo "error: $bin not found — run inside the Nix dev shell (just develop)" >&2 + exit 1 + } +done + +TOKEN=$(curl -s -X POST "$KC/realms/hackagon/protocol/openid-connect/token" \ + -H "Content-Type: application/x-www-form-urlencoded" \ + -d client_id="hackagon-backend" -d username="$ADMIN_USER" -d password="$ADMIN_PASS" \ + -d grant_type=password -d scope="openid profile" | jq -r .access_token) +if [ -z "$TOKEN" ] || [ "$TOKEN" = "null" ]; then + echo "error: could not get an admin token from $KC" >&2 + exit 1 +fi + +rpc() { grpcurl -plaintext -H "authorization: Bearer $TOKEN" -d "$2" "$GRPC" "$1"; } + +# ── media ──────────────────────────────────────────────────────────────────── +# Every edition shipped with `logo: ""` and ORD 2024's pages pointed at +# /images/hackathon-ord-2024/... — paths that resolve to nothing on this +# platform, so the archive rendered as text with four broken images. The files +# were on disk in static/ the whole time; what was missing was somewhere to put +# them. StorageService presigns a PUT, the browser (here: curl) uploads +# directly, and the DB stores the returned public path. + +content_type_for() { + case "${1##*.}" in + webp) echo "image/webp" ;; + png) echo "image/png" ;; + jpg | jpeg) echo "image/jpeg" ;; + gif) echo "image/gif" ;; + *) echo "" ;; # unknown, and svg is refused on purpose — see below + esac +} + +# CreateUploadUrl returns a ROOT-RELATIVE url — right for a browser, which PUTs +# same-origin — so a CLI has to supply the host. Not just any host: SigV4 signs +# Host, and the backend signs for the object store, so the upload only verifies +# through a proxy that rewrites Host to it (vite's `changeOrigin` in dev, +# caddy's `header_up Host {upstream_hostport}` for the tunnel). Rather than +# guess which is live, try each and keep whichever the STORE accepts — a base +# that serves /objects but does not rewrite Host answers 403, and would be the +# wrong pick even though it is reachable. +OBJECTS_BASE="${HACKAGON_OBJECTS_BASE:-}" +tunnel_url() { + bash "$ROOT_DIR/.claude/skills/cloudflare-tunnel/scripts/url.sh" 2>/dev/null | + awk '{print $NF}' | grep -E '^https://' | tail -1 +} +STORE_ENDPOINT="${HACKAGON_STORE_ENDPOINT:-http://rustfs:9000}" + +candidate_bases() { + [ -n "$OBJECTS_BASE" ] && { + echo "$OBJECTS_BASE" + return + } + # The store itself, first. `/objects` is a BROWSER-facing prefix — the backend + # signs the request against the store's own host and path, so stripping the + # prefix and going straight there is the one target that needs no proxy and + # matches the signature exactly. It is also the only one that works + # unattended: the vite proxy may be down, and the production server does not + # proxy /objects at all — a PUT there hits SvelteKit's auth guard and comes + # back 303, which is not obviously an upload failure when you read the log. + echo "STORE" + tunnel_url + echo "http://localhost:8081" + echo "http://localhost:8082" +} + +# Where to PUT for a given base: the store wants the key without /objects, +# everything else is a same-origin proxy that expects the path as issued. +put_target() { + local b="$1" url="$2" + if [ "$b" = "STORE" ]; then echo "${STORE_ENDPOINT}${url#/objects}"; else echo "${b}${url}"; fi +} + +# upload <hackathon-id> <file> <kind> → prints the public URL, or nothing. +upload() { + local hid="$1" path="$2" kind="$3" + local base ctype size req resp url pub code + base="$(basename "$path")" + ctype="$(content_type_for "$base")" + + if [ -z "$ctype" ]; then + # image/svg+xml is excluded by the backend deliberately: /objects is served + # from the app's own origin, so a stored SVG is script running as the + # application. Skipping loudly beats a confusing InvalidArgument. + echo " [~] $base skipped — $([ "${base##*.}" = svg ] && + echo "SVG is refused on purpose (script on our own origin)" || + echo "unsupported type")" >&2 + return 0 + fi + + size=$(stat -c%s "$path") + req=$(jq -nc --arg k "$kind" --arg o "$hid" --arg f "$base" --arg c "$ctype" \ + --argjson s "$size" '{kind:$k, ownerId:$o, filename:$f, contentType:$c, sizeBytes:$s}') + resp=$(rpc storage.StorageService/CreateUploadUrl "$req" 2>/dev/null) || return 0 + url=$(jq -r '.uploadUrl // empty' <<<"$resp") + pub=$(jq -r '.publicUrl // empty' <<<"$resp") + [ -z "$url" ] && return 0 + + # The declared content type is baked into the SIGNATURE, so sending a + # different one here fails at the object store with SignatureDoesNotMatch. + for b in $(candidate_bases); do + code=$(curl -s -o /dev/null -w '%{http_code}' -X PUT \ + -H "Content-Type: $ctype" --data-binary "@$path" "$(put_target "$b" "$url")") + if [ "$code" = "200" ] || [ "$code" = "204" ]; then + [ -z "$OBJECTS_BASE" ] && { + OBJECTS_BASE="$b" + echo " [i] uploading via $b" >&2 + } + echo "$pub" + return 0 + fi + done + echo " ✕ upload of $base failed (last HTTP $code) — no base both served /objects and rewrote Host" >&2 + return 0 +} + +existing=$(rpc hackathon.HackathonService/List '{}' | jq -r '.hackathons[]?.name' || true) + +for f in "${FILES[@]}"; do + name=$(jq -r .name "$f") + echo "── $(basename "$f") — $name" + + # --refresh re-applies media and pages to an edition that already exists. + # Plain seeding is idempotent by NAME, which is right for "run it twice" and + # exactly wrong after the seeder itself changes: the events were created + # before it uploaded anything, so skipping them left every cover empty + # forever. Deleting and re-seeding is not the alternative — phases and tracks + # hold foreign keys, so Delete answers "archive it instead". + hid="" + FRESH=1 + if grep -Fxq "$name" <<<"$existing"; then + if [ "$REFRESH" -eq 0 ]; then + echo " [=] already present, skipping (use --refresh to re-apply media and pages)" + continue + fi + hid=$(rpc hackathon.HackathonService/List '{}' | + jq -r --arg n "$name" '.hackathons[]? | select(.name == $n) | .id') + [ -z "$hid" ] && { + echo " ✕ present but not findable by name" >&2 + continue + } + FRESH=0 + echo " [~] refreshing $hid" + # Pages are recreated from the JSON below, so clear them first rather than + # ending up with two of each. + for p in $(rpc hackathon.PageService/List "$(jq -nc --arg h "$hid" '{hackathonId:$h}')" 2>/dev/null | + jq -r '.pages[]?.id'); do + rpc hackathon.PageService/Delete "$(jq -nc --arg p "$p" '{pageId:$p}')" >/dev/null 2>&1 || true + done + fi + if [ "$DRY" -eq 1 ]; then + echo " [dry-run] would create: $(jq -r '"\(.tracks|length) tracks, \(.phases|length) phases, \(.pages|length) pages, \(.media.images|length) images"' "$f")" + continue + fi + + if [ -z "$hid" ]; then + payload=$(jq -c '{name, description, visibility, startsAt, endsAt, logo}' "$f") + hid=$(rpc hackathon.HackathonService/Create "$payload" | jq -r .hackathonId) + [ -z "$hid" ] || [ "$hid" = "null" ] && { + echo " ✕ create failed" >&2 + exit 1 + } + echo " [+] hackathon $hid" + fi + + # media — after Create, because the upload key is derived from the hackathon + # id and casbin authorises the presign against it. + slug=$(jq -r .slug "$f") + MEDIA_DIR="$SKILL_DIR/static/$slug" + declare -A PUBLIC=() + cover="" + n=$(jq '.media.images | length' "$f") + for i in $(seq 0 $((n - 1))); do + [ "$n" -eq 0 ] && break + file=$(jq -r ".media.images[$i].file" "$f") + cat_=$(jq -r ".media.images[$i].category // \"\"" "$f") + src="$MEDIA_DIR/$file" + [ -f "$src" ] || { + echo " [~] $file missing from static/$slug — run fetch-media.sh" >&2 + continue + } + + # The banner is the event's face, so it becomes the logo; everything else is + # gallery media. First image wins if nothing is marked. + if [ -z "$cover" ] && { [ "$cat_" = "banner" ] || [ "$cat_" = "cover" ] || [ "$i" = "0" ]; }; then + kind="UPLOAD_KIND_HACKATHON_LOGO" + else + kind="UPLOAD_KIND_HACKATHON_MEDIA" + fi + + pub=$(upload "$hid" "$src" "$kind") || true + [ -z "$pub" ] && continue + PUBLIC["$file"]="$pub" + [ "$kind" = "UPLOAD_KIND_HACKATHON_LOGO" ] && [ -z "$cover" ] && cover="$pub" + echo " [↑] $file → $pub" + done + + if [ -n "$cover" ]; then + rpc hackathon.HackathonService/Edit \ + "$(jq -nc --arg h "$hid" --arg l "$cover" '{hackathonId:$h, logo:$l}')" >/dev/null && + echo " [+] cover set" + fi + + # tracks — first create only; a refresh would duplicate them (there is no + # natural key to match on, and Create does not dedupe). + n=0 + [ "${FRESH:-1}" -eq 1 ] && n=$(jq '.tracks|length' "$f") + for i in $(seq 0 $((n - 1))); do + [ "$n" -eq 0 ] && break + t=$(jq -c --arg h "$hid" ".tracks[$i] | {hackathonId:\$h, name, description}" "$f") + rpc hackathon.TrackService/Create "$t" >/dev/null && echo " [+] track $(jq -r ".tracks[$i].name" "$f")" + done + + # phases (Create drops dates — bug B4 — so Edit them in afterwards). + # First create only, same reason as tracks. + n=0 + [ "${FRESH:-1}" -eq 1 ] && n=$(jq '.phases|length' "$f") + for i in $(seq 0 $((n - 1))); do + [ "$n" -eq 0 ] && break + p=$(jq -c --arg h "$hid" ".phases[$i] | {hackathonId:\$h, name, description}" "$f") + pid=$(rpc hackathon.PhaseService/Create "$p" | jq -r '.phaseId // .phase.id // empty') + if [ -n "$pid" ]; then + dates=$(jq -c --arg id "$pid" ".phases[$i] | {phaseId:\$id, startsAt, endsAt}" "$f") + rpc hackathon.PhaseService/Edit "$dates" >/dev/null 2>&1 || true + fi + echo " [+] phase $(jq -r ".phases[$i].name" "$f")" + done + + # pages — markdown, with every image path repointed at what we just uploaded. + # The paths in the JSON are the OLD platform's (/images/hackathon-ord-2024/…) + # and resolve to nothing here, so without this rewrite the archive's photo + # pages render as four broken images. Matched on basename: the old tree had a + # category folder per image that this platform has no equivalent for. + n=$(jq '.pages|length' "$f") + for i in $(seq 0 $((n - 1))); do + [ "$n" -eq 0 ] && break + content=$(jq -r ".pages[$i].content" "$f") + for file in "${!PUBLIC[@]}"; do + content=${content//"/images/"*"/$file"/${PUBLIC[$file]}} + content=${content//"$file"/${PUBLIC[$file]}} + done + pg=$(jq -nc --arg h "$hid" --arg t "$(jq -r ".pages[$i].title" "$f")" \ + --arg c "$content" --argjson v "$(jq ".pages[$i].visible" "$f")" \ + '{hackathonId:$h, title:$t, content:$c, visible:$v}') + rpc hackathon.PageService/Create "$pg" >/dev/null && echo " [+] page $(jq -r ".pages[$i].title" "$f")" + done + + # A gallery page for editions whose photos were never referenced by any page — + # uploading them and leaving them unreachable would be the same "exists but + # nothing links to it" bug the reachability audits keep finding. + gallery="" + for file in "${!PUBLIC[@]}"; do + [ "${PUBLIC[$file]}" = "$cover" ] && continue + grep -q "$file" <<<"$(jq -r '.pages[]?.content' "$f")" && continue + cap=$(jq -r --arg fl "$file" '.media.images[] | select(.file==$fl) | .caption // ""' "$f") + gallery+="![${cap}](${PUBLIC[$file]}) + +*${cap}* + +" + done + if [ -n "$gallery" ]; then + rpc hackathon.PageService/Create \ + "$(jq -nc --arg h "$hid" --arg c "## Photos + +$gallery" '{hackathonId:$h, title:"Photos", content:$c, visible:true}')" >/dev/null && + echo " [+] page Photos (generated from unreferenced media)" + fi + unset PUBLIC + + echo " ✓ done — status will render as Finished (dates are in the past)" +done + +echo "" +echo "Seeded. Browse them at http://localhost:8081" diff --git a/.claude/skills/seed-past-hackathons/scripts/validate.sh b/.claude/skills/seed-past-hackathons/scripts/validate.sh new file mode 100644 index 00000000..8d16e4dc --- /dev/null +++ b/.claude/skills/seed-past-hackathons/scripts/validate.sh @@ -0,0 +1,63 @@ +#!/usr/bin/env bash +# Validate every data/*.json: JSON well-formed, required fields present, dates +# parseable and in the past, media entries complete, and every referenced file +# present in the skill's own static/ folder (run fetch-media.sh first). +set -euo pipefail +HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +SKILL_DIR="$(dirname "$HERE")" +STATIC="$SKILL_DIR/static" +fail=0 + +for f in "$SKILL_DIR"/data/*.json; do + echo "── $(basename "$f")" + jq empty "$f" 2>/dev/null || { + echo " ✕ invalid JSON" + fail=1 + continue + } + + slug=$(jq -r '.slug // empty' "$f") + for field in slug name visibility startsAt endsAt description; do + v=$(jq -r --arg k "$field" '.[$k] // empty' "$f") + [ -z "$v" ] && { + echo " ✕ missing required field: $field" + fail=1 + } + done + + s=$(jq -r .startsAt "$f") + e=$(jq -r .endsAt "$f") + if ! date -d "$s" >/dev/null 2>&1 || ! date -d "$e" >/dev/null 2>&1; then + echo " ✕ unparseable dates: $s / $e" + fail=1 + elif [ "$(date -d "$e" +%s)" -ge "$(date +%s)" ]; then + echo " ! endsAt is not in the past — will not render as Finished" + fi + + # media: every entry needs file+source, and the file must be fetched + n=$(jq '.media.images | length' "$f") + for i in $(seq 0 $((n - 1))); do + [ "$n" -eq 0 ] && break + file=$(jq -r ".media.images[$i].file // empty" "$f") + src=$(jq -r ".media.images[$i].source // empty" "$f") + if [ -z "$file" ] || [ -z "$src" ]; then + echo " ✕ media[$i] needs both 'file' and 'source'" + fail=1 + continue + fi + if [ -f "$STATIC/$slug/$file" ]; then + echo " [x] $slug/$file ($(du -h "$STATIC/$slug/$file" | cut -f1))" + else + echo " ✕ not fetched: static/$slug/$file — run scripts/fetch-media.sh" + fail=1 + fi + done + + echo " $(jq -r '"\(.tracks|length) tracks · \(.phases|length) phases · \(.pages|length) pages · \(.media.images|length) images"' "$f")" +done + +echo "" +if [ "$fail" -eq 0 ]; then echo "All editions valid."; else + echo "Validation failed." + exit 1 +fi diff --git a/.claude/skills/seed-past-hackathons/static/_generated/about.md b/.claude/skills/seed-past-hackathons/static/_generated/about.md new file mode 100644 index 00000000..bc9c8c2e --- /dev/null +++ b/.claude/skills/seed-past-hackathons/static/_generated/about.md @@ -0,0 +1,41 @@ +## About Hackagon + +Hackagon is the hackathon platform built and run by the +**[Swiss Data Science Center](https://www.datascience.ch/)** — a joint venture +of ETH Zurich and EPFL, with a mandate to accelerate the adoption of data +science and machine learning across Swiss academia and industry. + +### What it is for + +An SDSC hackathon runs for two days and gathers researchers, engineers and +domain experts around real, open problems. This platform carries the parts of +that which do not fit in a room: + +- **A call for projects.** Anyone attending can propose a project; the + organisers approve what makes the programme. +- **Teams that form themselves.** Participants mark the projects they want to + work on, and the organisers use those preferences to build balanced teams. +- **Submissions.** Each team turns in what it built — a repository, a demo, a + short write-up — before the deadline the organisers set. +- **A vote, and a decision.** Participants vote on the submissions. The tally is + advisory: an organiser records the actual awards, because a jury that cannot + overrule its own count is not a jury. + +### The archive + +Past editions stay on the platform with their programme, their projects and +their photographs. A hackathon is a small piece of research history, and the +question "what did that team build in 2024?" deserves an answer that is still +here in 2030. + +### Open by default + +The work produced at SDSC hackathons is open: open data, open source, open +research. The platform itself is developed in the open at +[github.com/SwissDataScienceCenter/hackagon](https://github.com/SwissDataScienceCenter/hackagon). + +### Getting in touch + +Questions about an event go to its organisers, who are listed on the event's own +page. Anything about the platform itself belongs in the repository's issue +tracker. diff --git a/.claude/skills/seed-past-hackathons/static/_generated/prize-1.webp b/.claude/skills/seed-past-hackathons/static/_generated/prize-1.webp new file mode 100644 index 00000000..886839b6 Binary files /dev/null and b/.claude/skills/seed-past-hackathons/static/_generated/prize-1.webp differ diff --git a/.claude/skills/seed-past-hackathons/static/_generated/prize-2.webp b/.claude/skills/seed-past-hackathons/static/_generated/prize-2.webp new file mode 100644 index 00000000..22747d47 Binary files /dev/null and b/.claude/skills/seed-past-hackathons/static/_generated/prize-2.webp differ diff --git a/.claude/skills/seed-past-hackathons/static/_generated/prize-3.webp b/.claude/skills/seed-past-hackathons/static/_generated/prize-3.webp new file mode 100644 index 00000000..459e77e7 Binary files /dev/null and b/.claude/skills/seed-past-hackathons/static/_generated/prize-3.webp differ diff --git a/.claude/skills/seed-past-hackathons/static/_generated/prize-x.webp b/.claude/skills/seed-past-hackathons/static/_generated/prize-x.webp new file mode 100644 index 00000000..238e0727 Binary files /dev/null and b/.claude/skills/seed-past-hackathons/static/_generated/prize-x.webp differ diff --git a/.claude/skills/seed-past-hackathons/static/checksums.sha256 b/.claude/skills/seed-past-hackathons/static/checksums.sha256 new file mode 100644 index 00000000..76300c0d --- /dev/null +++ b/.claude/skills/seed-past-hackathons/static/checksums.sha256 @@ -0,0 +1,14 @@ +dde5049a890c391c016668231dc246aa786e0978a15706baf3e8be45360e8bdf *./durham-2026-group-1/cover.webp +dde5049a890c391c016668231dc246aa786e0978a15706baf3e8be45360e8bdf *./durham-2026-group-2/cover.webp +a2e538ee1904f1ecd7512092b95c7a9438442804cd7b6d6fdcbc33371d7950da *./energy-data-hackdays-2025/hero.webp +f76ff938dc2dd736776f7bb6cacad8f8d3e800052356aac0cddd13d416bf0917 *./energy-data-hackdays-2026-lausanne/edhd-logo.svg +fca16d8663a3659f91cacfca264c6cc03899d15da27a440e4f62d932cb4e54a7 *./energy-data-hackdays-2026-lausanne/hackdays-day2.webp +ed0c1474c35c356214971b8623430b99c4c4d12b33f66f47dc0addea1b88e3fe *./energy-data-hackdays-2026-lausanne/thumbnail.webp +b9b7e8aeccf75ca0dd6ac8564a46fafa8527879b8c15fe70272b1a0264981405 *./sdsc-genai-2023/event.webp +6e99fd570f1a545481a9fb82862f5b9f59dfe58108bffca53a9441ea95015aec *./sdsc-genai-2023/keynote-yannic-kilcher.webp +bb5b28af68ccc806b8e937fd100355e3855116cd1a35a6438b87b707cc4d39c2 *./sdsc-genai-2023/partner-microsoft.webp +70a807a01865f6e15e5a201ab87f418d24500dcc9d4cbe07de75ae8581fc516d *./sdsc-ord-2024/ambiance_1.webp +4ebefb415f68366839a27883f4886cbc312c625e26d26977f96e525c55a88d13 *./sdsc-ord-2024/ambiance_3.webp +48399a8722380b4929ebcb19e3e698482f3bccf074e1a985ce0442a95b9ef9b2 *./sdsc-ord-2024/banner.webp +2d23deb2ca1f315b852c84411108feafc1da09beeba1cd3aa99a10dc81c61566 *./sdsc-ord-2024/teams_1.webp +62266d62b9414c915ca238cd404c9966fa2e567ff6e962db5101024014e3b352 *./sdsc-ord-2024/winners_1.webp diff --git a/.claude/skills/seed-past-hackathons/static/durham-2026-group-1/cover.webp b/.claude/skills/seed-past-hackathons/static/durham-2026-group-1/cover.webp new file mode 100644 index 00000000..61456e6c Binary files /dev/null and b/.claude/skills/seed-past-hackathons/static/durham-2026-group-1/cover.webp differ diff --git a/.claude/skills/seed-past-hackathons/static/durham-2026-group-2/cover.webp b/.claude/skills/seed-past-hackathons/static/durham-2026-group-2/cover.webp new file mode 100644 index 00000000..61456e6c Binary files /dev/null and b/.claude/skills/seed-past-hackathons/static/durham-2026-group-2/cover.webp differ diff --git a/.claude/skills/seed-past-hackathons/static/energy-data-hackdays-2025/hero.webp b/.claude/skills/seed-past-hackathons/static/energy-data-hackdays-2025/hero.webp new file mode 100644 index 00000000..3ca58c39 Binary files /dev/null and b/.claude/skills/seed-past-hackathons/static/energy-data-hackdays-2025/hero.webp differ diff --git a/.claude/skills/seed-past-hackathons/static/energy-data-hackdays-2026-lausanne/edhd-logo.svg b/.claude/skills/seed-past-hackathons/static/energy-data-hackdays-2026-lausanne/edhd-logo.svg new file mode 100644 index 00000000..b5412c74 --- /dev/null +++ b/.claude/skills/seed-past-hackathons/static/energy-data-hackdays-2026-lausanne/edhd-logo.svg @@ -0,0 +1,39 @@ +<?xml version="1.0" encoding="UTF-8"?> +<svg xmlns="http://www.w3.org/2000/svg" xmlns:svg="http://www.w3.org/2000/svg" version="1.1" id="Ebene_1" x="0px" y="0px" viewBox="0 0 311.81699 122.2" xml:space="preserve" width="180" height="78" role="img"> + <style type="text/css" id="style1"> + .st0{fill:#0C6A58;} + .st1{fill:none;stroke:#0C6A58;stroke-width:0.8262;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:10;} + .st2{fill:none;stroke:#000000;stroke-width:0.8262;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:10;} + .st3{fill:url(#SVGID_1_);} + </style> + <path class="st0" d="m 56.74,34.55 c -0.5,0 -0.9,-0.16 -1.22,-0.48 -0.32,-0.32 -0.48,-0.73 -0.48,-1.22 V 2.15 c 0,-0.49 0.16,-0.9 0.48,-1.22 0.32,-0.32 0.73,-0.48 1.22,-0.48 h 20.48 c 0.47,0 0.87,0.15 1.2,0.46 0.33,0.31 0.5,0.71 0.5,1.2 0,0.44 -0.17,0.82 -0.5,1.14 -0.34,0.32 -0.74,0.48 -1.2,0.48 H 58.45 v 12.05 h 13.67 c 0.49,0 0.9,0.16 1.22,0.48 0.32,0.32 0.48,0.71 0.48,1.18 0,0.47 -0.16,0.86 -0.48,1.18 -0.32,0.32 -0.73,0.48 -1.22,0.48 H 58.45 v 12.19 h 18.78 c 0.47,0 0.87,0.16 1.2,0.48 0.33,0.32 0.5,0.7 0.5,1.14 0,0.5 -0.17,0.9 -0.5,1.2 -0.34,0.31 -0.74,0.46 -1.2,0.46 H 56.74 Z" id="path1"></path> + <path class="st0" d="m 85.93,34.55 c -0.5,0 -0.9,-0.16 -1.22,-0.48 -0.32,-0.32 -0.48,-0.73 -0.48,-1.22 V 2.15 c 0,-0.49 0.16,-0.9 0.48,-1.22 0.32,-0.32 0.73,-0.48 1.22,-0.48 0.52,0 0.96,0.2 1.31,0.61 l 20.88,27.43 V 2.14 c 0,-0.49 0.16,-0.9 0.48,-1.22 0.32,-0.32 0.73,-0.48 1.22,-0.48 0.47,0 0.87,0.16 1.2,0.48 0.33,0.32 0.5,0.73 0.5,1.22 v 30.7 c 0,0.5 -0.17,0.9 -0.5,1.22 -0.34,0.32 -0.74,0.48 -1.2,0.48 -0.23,0 -0.47,-0.04 -0.7,-0.13 -0.23,-0.09 -0.42,-0.23 -0.57,-0.44 L 87.63,6.54 v 26.29 c 0,0.5 -0.17,0.9 -0.5,1.22 -0.33,0.34 -0.73,0.5 -1.2,0.5 z" id="path2"></path> + <path class="st0" d="m 121.34,34.55 c -0.5,0 -0.9,-0.16 -1.22,-0.48 -0.32,-0.32 -0.48,-0.73 -0.48,-1.22 V 2.15 c 0,-0.49 0.16,-0.9 0.48,-1.22 0.32,-0.32 0.73,-0.48 1.22,-0.48 h 20.48 c 0.47,0 0.87,0.15 1.2,0.46 0.33,0.31 0.5,0.71 0.5,1.2 0,0.44 -0.17,0.82 -0.5,1.14 -0.34,0.32 -0.74,0.48 -1.2,0.48 h -18.78 v 12.05 h 13.67 c 0.49,0 0.9,0.16 1.22,0.48 0.32,0.32 0.48,0.71 0.48,1.18 0,0.47 -0.16,0.86 -0.48,1.18 -0.32,0.32 -0.73,0.48 -1.22,0.48 h -13.67 v 12.19 h 18.78 c 0.47,0 0.87,0.16 1.2,0.48 0.33,0.32 0.5,0.7 0.5,1.14 0,0.5 -0.17,0.9 -0.5,1.2 -0.34,0.31 -0.74,0.46 -1.2,0.46 h -20.48 z" id="path3"></path> + <path class="st0" d="m 170.13,31.93 -7.16,-11.45 c 0.48,-0.17 0.95,-0.37 1.4,-0.61 1.66,-0.87 2.96,-2.08 3.91,-3.63 0.95,-1.54 1.42,-3.3 1.42,-5.28 0,-2.07 -0.47,-3.89 -1.42,-5.48 -0.95,-1.59 -2.25,-2.82 -3.91,-3.71 -1.66,-0.89 -3.55,-1.33 -5.68,-1.33 h -8.17 c -0.5,0 -0.9,0.16 -1.22,0.48 -0.32,0.32 -0.48,0.73 -0.48,1.22 v 30.7 c 0,0.5 0.16,0.9 0.48,1.22 0.32,0.32 0.73,0.48 1.22,0.48 0.49,0 0.9,-0.16 1.22,-0.48 0.32,-0.32 0.48,-0.73 0.48,-1.22 V 21.18 h 6.46 c 0.25,0 0.49,-0.03 0.74,-0.04 l 7.86,12.53 c 0.17,0.29 0.37,0.51 0.59,0.65 0.22,0.15 0.52,0.22 0.9,0.22 0.79,0 1.32,-0.26 1.59,-0.79 0.29,-0.51 0.21,-1.12 -0.23,-1.82 z M 152.23,17.91 V 3.72 h 6.46 c 1.48,0 2.8,0.31 3.95,0.92 1.15,0.61 2.06,1.46 2.73,2.55 0.67,1.09 1,2.35 1,3.78 0,1.34 -0.34,2.53 -1,3.58 -0.67,1.05 -1.58,1.87 -2.73,2.47 -1.15,0.6 -2.47,0.9 -3.95,0.9 h -6.46 z" id="path4"></path><path class="st0" d="m 201.54,17.98 c -0.32,-0.31 -0.73,-0.46 -1.22,-0.46 h -8.43 c -0.5,0 -0.9,0.14 -1.22,0.41 -0.32,0.28 -0.48,0.63 -0.48,1.07 0,0.44 0.16,0.8 0.48,1.09 0.32,0.29 0.73,0.44 1.22,0.44 h 6.81 v 8.7 c -1.06,0.73 -2.16,1.31 -3.32,1.74 -1.43,0.52 -2.94,0.79 -4.54,0.79 -1.89,0 -3.66,-0.37 -5.31,-1.11 -1.65,-0.74 -3.09,-1.77 -4.35,-3.08 -1.25,-1.31 -2.23,-2.82 -2.95,-4.54 -0.71,-1.72 -1.07,-3.57 -1.07,-5.55 0,-1.92 0.36,-3.74 1.07,-5.46 0.71,-1.72 1.7,-3.23 2.95,-4.54 1.25,-1.31 2.7,-2.34 4.35,-3.1 1.64,-0.76 3.41,-1.14 5.31,-1.14 1.78,0 3.33,0.24 4.67,0.72 1.34,0.48 2.58,1.22 3.71,2.21 0.29,0.23 0.59,0.36 0.9,0.39 0.31,0.03 0.57,-0.01 0.81,-0.13 0.23,-0.12 0.38,-0.29 0.44,-0.52 0.23,-0.12 0.41,-0.3 0.52,-0.55 0.12,-0.25 0.15,-0.51 0.11,-0.79 -0.04,-0.28 -0.2,-0.52 -0.46,-0.72 -1.6,-1.34 -3.26,-2.32 -4.98,-2.93 C 194.84,0.31 192.94,0 190.84,0 c -2.39,0 -4.61,0.46 -6.66,1.38 -2.05,0.92 -3.86,2.18 -5.42,3.78 -1.56,1.6 -2.77,3.46 -3.62,5.57 -0.86,2.11 -1.29,4.36 -1.29,6.75 0,2.42 0.44,4.68 1.31,6.79 0.87,2.11 2.09,3.97 3.65,5.59 1.56,1.62 3.36,2.88 5.42,3.78 2.05,0.9 4.26,1.35 6.62,1.35 1.98,0 3.89,-0.28 5.72,-0.85 1.83,-0.57 3.49,-1.54 4.98,-2.9 0.2,-0.2 0.33,-0.4 0.39,-0.59 0.06,-0.19 0.09,-0.4 0.09,-0.63 V 19.18 c -0.01,-0.5 -0.17,-0.9 -0.49,-1.2 z" id="path5"></path> + <path class="st0" d="m 233.31,1.88 c -0.04,-0.41 -0.2,-0.75 -0.48,-1.03 -0.28,-0.28 -0.62,-0.41 -1.03,-0.41 -0.35,0 -0.65,0.07 -0.9,0.22 -0.25,0.15 -0.47,0.38 -0.68,0.7 L 219.4,18.79 208.52,1.27 c -0.17,-0.26 -0.37,-0.47 -0.59,-0.61 -0.22,-0.15 -0.5,-0.22 -0.85,-0.22 -0.44,0 -0.8,0.13 -1.09,0.39 -0.29,0.26 -0.47,0.6 -0.55,1 -0.07,0.41 0.04,0.83 0.33,1.27 l 11.88,18.83 v 10.92 c 0,0.47 0.17,0.87 0.5,1.2 0.33,0.34 0.74,0.5 1.2,0.5 0.47,0 0.87,-0.17 1.2,-0.5 0.33,-0.33 0.5,-0.74 0.5,-1.2 V 21.93 L 232.97,3.11 c 0.27,-0.41 0.38,-0.82 0.34,-1.23 z" id="path6"></path> + <path d="m 1.7,121.76 c -0.5,0 -0.9,-0.16 -1.22,-0.48 C 0.16,120.96 0,120.55 0,120.06 v -30.7 c 0,-0.49 0.16,-0.9 0.48,-1.22 0.32,-0.32 0.73,-0.48 1.22,-0.48 0.49,0 0.9,0.16 1.22,0.48 0.32,0.32 0.48,0.73 0.48,1.22 v 13.63 H 24.71 V 89.36 c 0,-0.49 0.16,-0.9 0.48,-1.22 0.32,-0.32 0.73,-0.48 1.22,-0.48 0.49,0 0.9,0.16 1.22,0.48 0.32,0.32 0.48,0.73 0.48,1.22 v 30.7 c 0,0.5 -0.16,0.9 -0.48,1.22 -0.32,0.32 -0.73,0.48 -1.22,0.48 -0.5,0 -0.9,-0.16 -1.22,-0.48 -0.32,-0.32 -0.48,-0.73 -0.48,-1.22 V 106.04 H 3.4 v 14.02 c 0,0.5 -0.16,0.9 -0.48,1.22 -0.32,0.32 -0.73,0.48 -1.22,0.48 z" id="path7"></path> + <path d="M 62.03,119.35 49.15,88.83 c -0.32,-0.79 -0.86,-1.18 -1.62,-1.18 -0.79,0 -1.34,0.39 -1.66,1.18 l -12.84,30.48 c -0.23,0.52 -0.2,1.06 0.09,1.62 0.29,0.55 0.77,0.83 1.44,0.83 0.35,0 0.66,-0.09 0.94,-0.26 0.28,-0.17 0.49,-0.44 0.63,-0.79 l 2.94,-7.08 h 16.9 l 2.92,7.08 c 0.17,0.35 0.4,0.61 0.68,0.79 0.28,0.17 0.59,0.26 0.94,0.26 0.61,0 1.08,-0.26 1.42,-0.79 0.33,-0.52 0.36,-1.06 0.1,-1.62 z m -21.74,-8.64 7.26,-17.49 7.21,17.49 z" id="path8"></path> + <path d="m 80.73,122.19 c -2.36,0 -4.56,-0.45 -6.62,-1.35 -2.05,-0.9 -3.86,-2.16 -5.42,-3.78 -1.56,-1.62 -2.77,-3.48 -3.65,-5.59 -0.87,-2.11 -1.31,-4.37 -1.31,-6.79 0,-2.39 0.43,-4.64 1.29,-6.75 0.86,-2.11 2.07,-3.97 3.62,-5.57 1.56,-1.6 3.36,-2.86 5.42,-3.78 2.05,-0.92 4.27,-1.38 6.66,-1.38 2.1,0 4,0.31 5.72,0.92 1.72,0.61 3.38,1.59 4.98,2.93 0.26,0.2 0.42,0.44 0.46,0.72 0.04,0.28 0.01,0.54 -0.11,0.79 -0.12,0.25 -0.29,0.43 -0.52,0.55 -0.06,0.23 -0.2,0.41 -0.44,0.52 -0.23,0.12 -0.5,0.16 -0.81,0.13 -0.31,-0.03 -0.6,-0.16 -0.9,-0.39 -1.14,-0.99 -2.37,-1.73 -3.71,-2.21 -1.34,-0.48 -2.9,-0.72 -4.67,-0.72 -1.89,0 -3.66,0.38 -5.31,1.14 -1.65,0.76 -3.09,1.79 -4.35,3.1 -1.25,1.31 -2.23,2.82 -2.95,4.54 -0.71,1.72 -1.07,3.54 -1.07,5.46 0,1.98 0.36,3.83 1.07,5.55 0.71,1.72 1.7,3.23 2.95,4.54 1.25,1.31 2.7,2.34 4.35,3.08 1.64,0.74 3.41,1.11 5.31,1.11 1.6,0 3.12,-0.26 4.54,-0.79 1.43,-0.52 2.78,-1.28 4.06,-2.27 0.35,-0.29 0.72,-0.41 1.11,-0.35 0.39,0.06 0.74,0.23 1.03,0.52 0.29,0.29 0.44,0.67 0.44,1.14 0,0.23 -0.04,0.44 -0.11,0.63 -0.07,0.19 -0.2,0.39 -0.37,0.59 -1.48,1.37 -3.14,2.34 -4.98,2.9 -1.82,0.58 -3.73,0.86 -5.71,0.86 z" id="path9"></path> + <path d="m 99.31,121.76 c -0.47,0 -0.87,-0.17 -1.2,-0.5 -0.34,-0.33 -0.5,-0.74 -0.5,-1.2 v -30.7 c 0,-0.47 0.17,-0.87 0.5,-1.2 0.33,-0.33 0.74,-0.5 1.2,-0.5 0.47,0 0.87,0.17 1.2,0.5 0.33,0.34 0.5,0.74 0.5,1.2 v 14.85 l 15.94,-15.94 c 0.23,-0.23 0.45,-0.39 0.65,-0.48 0.2,-0.09 0.44,-0.13 0.7,-0.13 0.52,0 0.92,0.16 1.18,0.48 0.26,0.32 0.39,0.71 0.37,1.16 -0.01,0.45 -0.2,0.88 -0.55,1.29 l -11.97,11.97 12.1,16.51 c 0.26,0.38 0.37,0.78 0.33,1.2 -0.04,0.42 -0.21,0.78 -0.5,1.07 -0.29,0.29 -0.64,0.44 -1.05,0.44 -0.64,0 -1.12,-0.25 -1.44,-0.74 L 104.89,104.97 101,108.9 v 11.18 c 0,0.47 -0.17,0.87 -0.5,1.2 -0.33,0.31 -0.73,0.48 -1.19,0.48 z" id="path10"></path> + <path d="m 126.88,121.76 c -0.5,0 -0.9,-0.16 -1.22,-0.48 -0.32,-0.32 -0.48,-0.73 -0.48,-1.22 v -30.7 c 0,-0.49 0.16,-0.9 0.48,-1.22 0.32,-0.32 0.73,-0.48 1.22,-0.48 h 7.86 c 2.5,0 4.8,0.41 6.88,1.24 2.08,0.83 3.88,2.01 5.39,3.54 1.51,1.53 2.69,3.33 3.52,5.39 0.83,2.07 1.24,4.35 1.24,6.86 0,2.5 -0.42,4.8 -1.24,6.88 -0.83,2.08 -2,3.89 -3.52,5.42 -1.51,1.53 -3.31,2.71 -5.39,3.54 -2.08,0.83 -4.38,1.24 -6.88,1.24 h -7.86 z m 1.7,-3.06 h 6.16 c 2.68,0 5.04,-0.6 7.1,-1.81 2.05,-1.21 3.66,-2.87 4.83,-4.98 1.16,-2.11 1.75,-4.52 1.75,-7.23 0,-2.71 -0.58,-5.12 -1.75,-7.23 -1.16,-2.11 -2.77,-3.76 -4.83,-4.96 -2.05,-1.19 -4.42,-1.79 -7.1,-1.79 h -6.16 z" id="path11"></path> + <path d="M 182.65,119.35 169.77,88.82 c -0.32,-0.79 -0.86,-1.18 -1.62,-1.18 -0.79,0 -1.34,0.39 -1.66,1.18 l -12.84,30.49 c -0.23,0.52 -0.2,1.06 0.09,1.62 0.29,0.55 0.77,0.83 1.44,0.83 0.35,0 0.66,-0.09 0.94,-0.26 0.28,-0.17 0.49,-0.44 0.63,-0.79 l 2.94,-7.08 h 16.9 l 2.92,7.08 c 0.17,0.35 0.4,0.61 0.68,0.79 0.28,0.17 0.59,0.26 0.94,0.26 0.61,0 1.08,-0.26 1.42,-0.79 0.32,-0.52 0.36,-1.06 0.1,-1.62 z m -21.75,-8.64 7.26,-17.49 7.21,17.49 z" id="path12"></path> + <path d="m 207.7,89.09 c -0.04,-0.41 -0.2,-0.75 -0.48,-1.03 -0.28,-0.28 -0.62,-0.41 -1.03,-0.41 -0.35,0 -0.65,0.07 -0.9,0.22 -0.25,0.15 -0.47,0.38 -0.68,0.7 L 193.78,106 182.9,88.48 c -0.17,-0.26 -0.37,-0.47 -0.59,-0.61 -0.22,-0.15 -0.5,-0.22 -0.85,-0.22 -0.44,0 -0.8,0.13 -1.09,0.39 -0.29,0.26 -0.47,0.6 -0.55,1 -0.07,0.41 0.04,0.83 0.33,1.27 l 11.88,18.82 v 10.92 c 0,0.47 0.17,0.87 0.5,1.2 0.33,0.34 0.74,0.5 1.2,0.5 0.47,0 0.87,-0.17 1.2,-0.5 0.33,-0.33 0.5,-0.74 0.5,-1.2 v -10.92 l 11.92,-18.82 c 0.28,-0.41 0.39,-0.81 0.35,-1.22 z" id="path13"></path> + <path d="m 222,122.19 c -1.86,0 -3.59,-0.27 -5.18,-0.81 -1.59,-0.54 -2.96,-1.3 -4.13,-2.29 -1.16,-0.99 -2.05,-2.14 -2.66,-3.45 -0.23,-0.47 -0.24,-0.9 -0.02,-1.29 0.22,-0.39 0.59,-0.66 1.11,-0.81 0.41,-0.12 0.81,-0.07 1.2,0.13 0.39,0.2 0.69,0.51 0.9,0.92 0.44,0.84 1.08,1.6 1.92,2.27 0.84,0.67 1.85,1.19 3.01,1.55 1.16,0.36 2.45,0.55 3.84,0.55 1.54,0 2.91,-0.25 4.11,-0.76 1.19,-0.51 2.13,-1.25 2.82,-2.23 0.68,-0.97 1.03,-2.16 1.03,-3.56 0,-1.78 -0.65,-3.3 -1.97,-4.59 -1.31,-1.28 -3.36,-2.1 -6.16,-2.45 -3.41,-0.41 -6.07,-1.46 -7.99,-3.17 -1.92,-1.7 -2.88,-3.79 -2.88,-6.27 0,-1.78 0.47,-3.32 1.42,-4.63 0.95,-1.31 2.25,-2.32 3.91,-3.04 1.66,-0.71 3.57,-1.07 5.72,-1.07 1.6,0 3.06,0.25 4.37,0.76 1.31,0.51 2.45,1.18 3.43,2.01 0.98,0.83 1.77,1.74 2.38,2.73 0.29,0.47 0.39,0.92 0.28,1.35 -0.1,0.44 -0.36,0.77 -0.76,1 -0.44,0.2 -0.88,0.23 -1.33,0.09 -0.45,-0.15 -0.79,-0.42 -1.03,-0.83 -0.44,-0.67 -0.98,-1.3 -1.64,-1.9 -0.66,-0.6 -1.45,-1.07 -2.38,-1.42 -0.93,-0.35 -2.05,-0.54 -3.36,-0.57 -2.3,0 -4.15,0.49 -5.55,1.46 -1.4,0.98 -2.1,2.4 -2.1,4.26 0,0.99 0.26,1.9 0.79,2.73 0.52,0.83 1.39,1.54 2.6,2.14 1.21,0.6 2.83,1.04 4.87,1.33 3.64,0.52 6.34,1.64 8.1,3.34 1.76,1.7 2.64,3.92 2.64,6.66 0,1.57 -0.28,2.97 -0.85,4.19 -0.57,1.22 -1.37,2.25 -2.4,3.08 -1.03,0.83 -2.24,1.46 -3.62,1.9 -1.39,0.47 -2.86,0.69 -4.44,0.69 z" id="path14"></path> + <g id="g15" transform="translate(-28.35,-38.11)"> + <path d="m 305.41,97.02 c 0,0 -0.01,21.14 0,22 0.03,1.01 0.41,1.82 1.14,2.43 0.48,0.41 1.01,0.63 1.11,0.66 1.22,0.52 4.21,0.24 5.54,-0.76 1.21,-0.88 1.81,-2.5 1.97,-3.24 3.01,-14.1 7.35,-19.45 8.99,-21.18 14.56,-14.66 16.79,-28.13 7.04,-42.43 -8.38,-12.03 -21.24,-13.33 -26.38,-13.33 -3.51,0 -6.98,0.54 -9.76,1.53 -10.2,3.66 -17.51,10.96 -20.06,20.03 -2.15,9.25 -1.99,17.27 9.55,33.61 4.07,7.44 4.39,12.9 4.62,16.88 0.25,4.19 1.15,7.07 4.04,9.02 3.4,2.34 8.93,3.93 13.8,5.33 6.16,1.77 10.59,3.05 11.82,5.62 0.42,0.87 0.43,1.82 0.04,2.74 -0.44,1.05 -1.44,1.62 -2.97,1.68 l -0.3,0.01 c -0.89,0 -1.98,-0.14 -3.36,-0.44 -2.67,-0.58 -6.09,-1.68 -9.4,-2.73 -2.79,-0.9 -5.64,-1.81 -7.96,-2.39 -0.54,-0.14 -1.38,-0.41 -1.6,0.16 -0.22,0.57 0.8,0.97 1.44,1.23 2.36,0.96 5.37,1.91 8.28,2.82 3.4,1.07 6.59,2.07 9,3.14 1.39,0.61 2.41,1.2 3.11,1.8 1.48,1.26 1.43,2.52 1.27,3.15 -0.18,0.72 -0.65,1.31 -1.31,1.68 -0.6,0.33 -1.37,0.49 -2.35,0.49 -2.84,0 -7.06,-1.31 -13.17,-3.3 -1.69,-0.55 -3.31,-1.08 -4.75,-1.51 -0.26,-0.08 -0.73,-0.27 -0.95,0.27 -0.22,0.54 0.44,0.82 0.49,0.85 2.38,1.13 5.5,2.09 8.52,3.02 2.51,0.77 4.85,1.49 6.6,2.26 1.05,0.46 1.8,0.9 2.35,1.38 1.16,1.01 1.21,2.06 1.13,2.6 -0.12,0.83 -0.6,1.53 -1.35,1.98 -0.44,0.26 -1.2,0.6 -2.78,0.6 -1.85,0 -4.49,-0.48 -7.84,-1.42 -1.42,-0.4 -2.78,-0.83 -3.93,-1.21 l -0.98,-0.32 0.29,0.99 c 0.56,1.9 1.45,3.26 2.58,3.94 0.63,0.38 1.38,0.57 2.23,0.57 1.29,0 2.84,-0.45 4.6,-1.33 0.22,-0.11 0.45,-0.16 0.68,-0.16 0.59,0 1.11,0.33 1.38,0.85 0.18,0.37 0.21,0.78 0.08,1.17 -0.13,0.39 -0.4,0.7 -0.77,0.89 -2.2,1.1 -4.22,1.66 -6.01,1.66 -1.39,0 -2.67,-0.34 -3.8,-1.02 -1.87,-1.13 -3.26,-3.21 -4.02,-6.02 -0.52,-1.94 -0.58,-3.6 -0.58,-3.78 -0.01,-0.51 0.23,-0.99 0.65,-1.29 0.26,-0.18 0.57,-0.28 0.88,-0.28 0.19,0 0.37,0.03 0.55,0.1 0.03,0.01 3.23,1.23 6.82,2.24 1.27,0.36 2.43,0.65 3.46,0.87 0.48,0.1 1.38,0.07 1.5,-0.31 0.12,-0.38 -0.66,-0.75 -1.07,-0.89 -1.1,-0.37 -2.31,-0.74 -3.58,-1.13 -3.19,-0.98 -6.5,-1.99 -9.11,-3.26 -1.51,-0.74 -2.64,-1.48 -3.44,-2.29 -1.35,-1.36 -1.56,-2.7 -1.51,-3.58 0.06,-0.65 0.39,-1.2 0.91,-1.55 0.45,-0.3 0.97,-0.43 1.67,-0.43 1.86,0 5.03,0.99 10.03,2.62 2.81,0.92 5.71,1.86 8.2,2.49 0.55,0.14 1.43,0.35 1.64,-0.19 0.13,-0.33 -0.4,-0.73 -0.71,-0.86 -2.12,-0.84 -4.73,-1.66 -7.5,-2.53 -3.41,-1.08 -6.86,-2.16 -9.47,-3.31 -2.43,-1.06 -5.2,-2.59 -5.11,-4.99 0.03,-0.82 0.41,-1.52 1.07,-1.98 0.59,-0.42 1.35,-0.61 2.39,-0.61 1.02,0 2.35,0.2 4.06,0.6 2.53,0.6 5.65,1.6 8.66,2.57 2.78,0.89 5.63,1.81 7.98,2.4 1.01,0.25 2.53,0.69 2.79,0.01 0.22,-0.52 -0.79,-0.94 -1.5,-1.22 -1.86,-0.73 -4.27,-1.42 -6.83,-2.16 -5.1,-1.47 -10.89,-3.13 -14.7,-5.75 -2.25,-1.55 -3.66,-3.25 -4.43,-5.35 -0.69,-1.87 -0.81,-3.88 -0.93,-6.02 -0.23,-3.87 -0.51,-8.68 -4.16,-15.43 -6.15,-9.3 -8.23,-12.45 -9.86,-18.74 -1.57,-6.02 -1.58,-11.85 -0.03,-17.34 1.42,-5.03 4.07,-9.52 7.88,-13.34 3.8,-3.81 8.55,-6.75 14.11,-8.75 2.44,-0.87 5.36,-1.43 8.45,-1.62 0.8,-0.05 1.61,-0.07 2.41,-0.07 2.73,0 5.46,0.29 8.12,0.87 8.59,1.87 15.76,6.63 20.72,13.76 2.98,4.37 4.95,8.78 5.85,13.13 0.84,4.07 0.78,8.18 -0.19,12.23 -1.66,6.95 -5.92,13.8 -13.01,20.95 -0.15,0.17 -1.43,1.58 -3.02,4.58 -1.52,2.87 -3.66,7.8 -5.16,15.06 -0.07,0.34 -0.72,3.32 -3.08,5.06 -1.35,1.08 -3.1,1.64 -5.06,1.64 -2.55,0 -4.62,-0.86 -5.49,-1.6 -1.4,-1.19 -2.17,-2.8 -2.22,-4.66 0,-0.06 0,-22.11 0,-22.11 z" id="path15"></path> + </g> + <path class="st0" d="m 127.43,78.15 c -0.5,0 -0.9,-0.16 -1.22,-0.48 -0.32,-0.32 -0.48,-0.73 -0.48,-1.22 v -30.7 c 0,-0.49 0.16,-0.9 0.48,-1.22 0.32,-0.32 0.73,-0.48 1.22,-0.48 h 7.86 c 2.5,0 4.8,0.41 6.88,1.24 2.08,0.83 3.88,2.01 5.39,3.54 1.51,1.53 2.69,3.33 3.52,5.39 0.83,2.07 1.24,4.35 1.24,6.86 0,2.5 -0.42,4.8 -1.24,6.88 -0.83,2.08 -2,3.89 -3.52,5.42 -1.51,1.53 -3.31,2.71 -5.39,3.54 -2.08,0.83 -4.38,1.24 -6.88,1.24 h -7.86 z m 1.7,-3.06 h 6.16 c 2.68,0 5.04,-0.6 7.1,-1.81 2.05,-1.21 3.66,-2.87 4.83,-4.98 1.16,-2.11 1.75,-4.52 1.75,-7.23 0,-2.71 -0.58,-5.12 -1.75,-7.23 -1.16,-2.11 -2.77,-3.76 -4.83,-4.96 -2.05,-1.19 -4.42,-1.79 -7.1,-1.79 h -6.16 z" id="path16"></path> + <path class="st0" d="M 183.2,75.75 170.32,45.22 c -0.32,-0.79 -0.86,-1.18 -1.62,-1.18 -0.79,0 -1.34,0.39 -1.66,1.18 L 154.2,75.7 c -0.23,0.52 -0.2,1.06 0.09,1.62 0.29,0.55 0.77,0.83 1.44,0.83 0.35,0 0.66,-0.09 0.94,-0.26 0.28,-0.17 0.49,-0.44 0.63,-0.79 l 2.94,-7.08 h 16.9 l 2.92,7.08 c 0.17,0.35 0.4,0.61 0.68,0.79 0.28,0.17 0.59,0.26 0.94,0.26 0.61,0 1.08,-0.26 1.42,-0.79 0.32,-0.52 0.36,-1.06 0.1,-1.61 z m -21.75,-8.65 7.26,-17.49 7.21,17.49 z" id="path17"></path> + <path class="st0" d="m 206.83,44.48 c -0.29,-0.29 -0.67,-0.44 -1.14,-0.44 h -10.39 -3.41 -10.35 c -0.47,0 -0.84,0.15 -1.14,0.44 -0.29,0.29 -0.44,0.67 -0.44,1.14 0,0.44 0.15,0.8 0.44,1.09 0.29,0.29 0.67,0.44 1.14,0.44 h 10.35 v 29.31 c 0,0.47 0.17,0.87 0.5,1.2 0.33,0.34 0.74,0.5 1.2,0.5 0.49,0 0.9,-0.17 1.22,-0.5 0.32,-0.33 0.48,-0.74 0.48,-1.2 V 47.14 h 10.39 c 0.46,0 0.84,-0.15 1.14,-0.44 0.29,-0.29 0.44,-0.66 0.44,-1.09 0.01,-0.46 -0.14,-0.84 -0.43,-1.13 z" id="path18"></path> + <path class="st0" d="M 233.14,75.75 220.26,45.22 c -0.32,-0.79 -0.86,-1.18 -1.62,-1.18 -0.79,0 -1.34,0.39 -1.66,1.18 L 204.14,75.7 c -0.23,0.52 -0.2,1.06 0.09,1.62 0.29,0.55 0.77,0.83 1.44,0.83 0.35,0 0.66,-0.09 0.94,-0.26 0.28,-0.17 0.49,-0.44 0.63,-0.79 l 2.94,-7.08 h 16.9 L 230,77.1 c 0.17,0.35 0.4,0.61 0.68,0.79 0.28,0.17 0.59,0.26 0.94,0.26 0.61,0 1.08,-0.26 1.42,-0.79 0.33,-0.52 0.36,-1.06 0.1,-1.61 z m -21.74,-8.65 7.26,-17.49 7.21,17.49 z" id="path19"></path> + <path class="st1" d="m 259.24,32.36 c 0,0 1.16,-11.3 4.91,-11.3 3.75,0 7.95,24.11 12.39,24.11 4.38,0 6.17,-21.21 11.61,-21.21 3.94,0 5.28,5.85 5.28,5.85" id="path20"></path> + <path class="st2" d="m 259.57,33.13 c 0,0 1.25,-4.35 3.57,-4.35 3.27,0 3.93,13.37 7.97,13.37 4.04,0 6.73,-25.33 13.71,-25.33 6.98,0 6.34,15.75 6.34,15.75" id="path21"></path> + <path class="st1" d="m 260.8,35.73 c 0,0 2.46,-5.92 5.92,-5.92 3.46,0 7.81,6.88 12.39,6.88 4.58,0 5.01,-5.33 9.25,-5.33 1.9,0.19 3.63,1.34 3.63,1.34" id="path22"></path> + <path class="st0" d="m 294.8,28.95 c -0.7,-0.48 -1.65,-0.3 -2.13,0.4 L 275.53,54.52 258.39,29.35 c -0.48,-0.7 -1.43,-0.88 -2.13,-0.4 -0.7,0.48 -0.88,1.43 -0.4,2.13 L 274,57.71 v 1.19 h 3.07 v -1.19 l 18.14,-26.63 c 0.46,-0.7 0.28,-1.65 -0.41,-2.13 z" id="path23"></path> + <linearGradient id="SVGID_1_" gradientUnits="userSpaceOnUse" x1="303.87741" y1="113.2889" x2="303.87741" y2="66.798103" gradientTransform="translate(-28.35,-38.11)"> + <stop offset="0.5091" style="stop-color:#000000" id="stop23"></stop> + <stop offset="0.8786" style="stop-color:#000000;stop-opacity:0" id="stop24"></stop> + </linearGradient> + <path class="st3" d="m 294.79,28.95 c -0.7,-0.48 -1.65,-0.3 -2.13,0.4 L 275.52,54.52 258.38,29.35 c -0.48,-0.7 -1.43,-0.88 -2.13,-0.4 -0.7,0.48 -0.88,1.43 -0.4,2.13 l 18.14,26.63 v 17.46 h 3.07 V 57.72 l 18.13,-26.63 c 0.48,-0.71 0.3,-1.66 -0.4,-2.14 z" id="path24" style="fill:url(#SVGID_1_)"></path> + <desc id="desc-">Hightech Zentrum Aargau</desc> +</svg> diff --git a/.claude/skills/seed-past-hackathons/static/energy-data-hackdays-2026-lausanne/hackdays-day2.webp b/.claude/skills/seed-past-hackathons/static/energy-data-hackdays-2026-lausanne/hackdays-day2.webp new file mode 100644 index 00000000..963471eb Binary files /dev/null and b/.claude/skills/seed-past-hackathons/static/energy-data-hackdays-2026-lausanne/hackdays-day2.webp differ diff --git a/.claude/skills/seed-past-hackathons/static/energy-data-hackdays-2026-lausanne/thumbnail.webp b/.claude/skills/seed-past-hackathons/static/energy-data-hackdays-2026-lausanne/thumbnail.webp new file mode 100644 index 00000000..507bb907 Binary files /dev/null and b/.claude/skills/seed-past-hackathons/static/energy-data-hackdays-2026-lausanne/thumbnail.webp differ diff --git a/.claude/skills/seed-past-hackathons/static/sdsc-genai-2023/event.webp b/.claude/skills/seed-past-hackathons/static/sdsc-genai-2023/event.webp new file mode 100644 index 00000000..43d248ac Binary files /dev/null and b/.claude/skills/seed-past-hackathons/static/sdsc-genai-2023/event.webp differ diff --git a/.claude/skills/seed-past-hackathons/static/sdsc-genai-2023/keynote-yannic-kilcher.webp b/.claude/skills/seed-past-hackathons/static/sdsc-genai-2023/keynote-yannic-kilcher.webp new file mode 100644 index 00000000..b9df27cd Binary files /dev/null and b/.claude/skills/seed-past-hackathons/static/sdsc-genai-2023/keynote-yannic-kilcher.webp differ diff --git a/.claude/skills/seed-past-hackathons/static/sdsc-genai-2023/partner-microsoft.webp b/.claude/skills/seed-past-hackathons/static/sdsc-genai-2023/partner-microsoft.webp new file mode 100644 index 00000000..4616d0a0 Binary files /dev/null and b/.claude/skills/seed-past-hackathons/static/sdsc-genai-2023/partner-microsoft.webp differ diff --git a/.claude/skills/seed-past-hackathons/static/sdsc-ord-2024/ambiance_1.webp b/.claude/skills/seed-past-hackathons/static/sdsc-ord-2024/ambiance_1.webp new file mode 100644 index 00000000..513866e5 Binary files /dev/null and b/.claude/skills/seed-past-hackathons/static/sdsc-ord-2024/ambiance_1.webp differ diff --git a/.claude/skills/seed-past-hackathons/static/sdsc-ord-2024/ambiance_3.webp b/.claude/skills/seed-past-hackathons/static/sdsc-ord-2024/ambiance_3.webp new file mode 100644 index 00000000..02e0ff4b Binary files /dev/null and b/.claude/skills/seed-past-hackathons/static/sdsc-ord-2024/ambiance_3.webp differ diff --git a/.claude/skills/seed-past-hackathons/static/sdsc-ord-2024/banner.webp b/.claude/skills/seed-past-hackathons/static/sdsc-ord-2024/banner.webp new file mode 100644 index 00000000..3b9f42dc Binary files /dev/null and b/.claude/skills/seed-past-hackathons/static/sdsc-ord-2024/banner.webp differ diff --git a/.claude/skills/seed-past-hackathons/static/sdsc-ord-2024/teams_1.webp b/.claude/skills/seed-past-hackathons/static/sdsc-ord-2024/teams_1.webp new file mode 100644 index 00000000..d1a9c979 Binary files /dev/null and b/.claude/skills/seed-past-hackathons/static/sdsc-ord-2024/teams_1.webp differ diff --git a/.claude/skills/seed-past-hackathons/static/sdsc-ord-2024/winners_1.webp b/.claude/skills/seed-past-hackathons/static/sdsc-ord-2024/winners_1.webp new file mode 100644 index 00000000..5d7ff61d Binary files /dev/null and b/.claude/skills/seed-past-hackathons/static/sdsc-ord-2024/winners_1.webp differ diff --git a/.devcontainer/.dockerignore b/.devcontainer/.dockerignore new file mode 100644 index 00000000..7d0d0d03 --- /dev/null +++ b/.devcontainer/.dockerignore @@ -0,0 +1,7 @@ +# Build context for the dev image is this directory. The Dockerfile COPYs +# nothing, but keep secrets and noise out of the context that is shipped to +# the daemon regardless. +.env +*.env +!.env.example +README.md diff --git a/.devcontainer/.env.example b/.devcontainer/.env.example new file mode 100644 index 00000000..a02e1bf2 --- /dev/null +++ b/.devcontainer/.env.example @@ -0,0 +1,78 @@ +# Copy to .devcontainer/.env to override defaults — every variable is optional. +# Docker Compose reads this file automatically; `.env` is gitignored repo-wide. + +# Compose project name (prefix for containers, volumes, networks). +COMPOSE_PROJECT_NAME=hackagon-devcontainer + +# Base image for the dev container. +HACKAGON_DEV_BASE_IMAGE=mcr.microsoft.com/devcontainers/base:ubuntu-24.04 + +# Extra apt packages baked into the image (space-separated). +HACKAGON_DEV_EXTRA_APT_PACKAGES= + +# Timezone inside the container. +HACKAGON_DEV_TZ=UTC + +# Host ports the container publishes (change on conflicts with local services). +HACKAGON_BACKEND_PORT=3000 +HACKAGON_FRONTEND_PORT=8081 +HACKAGON_KEYCLOAK_PORT=8180 +HACKAGON_POSTGRES_PORT=5432 + +# --- Object store (service "rustfs", started by default) --- +# S3-compatible dev storage. Host ports; inside the compose network the +# service is always rustfs:9000 regardless of what these are set to. +HACKAGON_RUSTFS_IMAGE=rustfs/rustfs:1.0.0-beta.12 +HACKAGON_RUSTFS_PORT=9000 +HACKAGON_RUSTFS_CONSOLE_PORT=9001 +HACKAGON_RUSTFS_REGION=us-east-1 +HACKAGON_RUSTFS_LOG_LEVEL=warn +HACKAGON_RUSTFS_MEMORY=512m +# DEV-ONLY credentials — same status as HACKAGON_PG_PASSWORD below: committed +# so a fresh checkout works unconfigured, never to be used by a deployment. +HACKAGON_RUSTFS_ACCESS_KEY=hackagon-dev +HACKAGON_RUSTFS_SECRET_KEY=hackagon-dev-secret +# Bucket created by .devcontainer/rustfs-init.sh (space-separated for several). +HACKAGON_RUSTFS_BUCKET=hackagon-dev + +# Restart policy for the dev container (no | unless-stopped | always). +HACKAGON_DEV_RESTART=unless-stopped + +# Docker network name (override when running multiple checkouts in parallel). +HACKAGON_DEV_NETWORK=hackagon-dev + +# Set to 1 to skip the codegen/deps bootstrap during post-create. +HACKAGON_SKIP_BOOTSTRAP= + +# Images for the optional Cloudflare quick-tunnel services (profile "tunnel"). +HACKAGON_TUNNEL_IMAGE=cloudflare/cloudflared:latest +HACKAGON_TUNNEL_PROXY_IMAGE=caddy:2-alpine + +# How long `dev` may take to start serving before its healthcheck counts +# failures. A cold `just up` builds the devenv profile, so keep this generous. +HACKAGON_DEV_START_PERIOD=300s + +# Resource limits for the dev container. NOT set by default: this container +# compiles Go, evaluates Nix and drives Firefox, and a wrong ceiling surfaces +# as an OOM kill mid-build. Uncomment the deploy block in docker-compose.yml +# to apply them. +# HACKAGON_DEV_CPUS=6 +# HACKAGON_DEV_MEMORY=12g + +# Sidecar memory limits (these services are genuinely small). +HACKAGON_CADDY_MEMORY=128m +HACKAGON_TUNNEL_MEMORY=128m + +# --- Optional service containers (profile "services") --- +# Postgres and Keycloak as their own containers instead of processes inside +# `dev`. See the README: they are opt-in because `just up` still starts +# devenv's own copies and the two would fight over ports. +HACKAGON_POSTGRES_IMAGE=postgres:18-alpine +HACKAGON_KEYCLOAK_IMAGE=quay.io/keycloak/keycloak:26.5 +HACKAGON_PG_USER=postgres +HACKAGON_PG_PASSWORD=postgres +HACKAGON_PG_DB=hackagon +HACKAGON_PG_MEMORY=1g +HACKAGON_KC_ADMIN=admin +HACKAGON_KC_ADMIN_PASSWORD=admin +HACKAGON_KC_MEMORY=1500m diff --git a/.devcontainer/Caddyfile.tunnel b/.devcontainer/Caddyfile.tunnel new file mode 100644 index 00000000..477ffd0e --- /dev/null +++ b/.devcontainer/Caddyfile.tunnel @@ -0,0 +1,119 @@ +# Path-multiplexer for the Cloudflare quick tunnel: one public hostname +# serves both the frontend and Keycloak, which is what lets the OIDC browser +# redirect work from outside (the phone can reach "Keycloak" on the same +# *.trycloudflare.com host the app lives on). +# +# /realms/* -> Keycloak (OIDC endpoints, login pages, account console) +# /resources/* -> Keycloak (login-page static assets) +# everything -> SvelteKit: the production build on :8082 if it is up, +# otherwise process-compose's `vite dev` on :8081 +# +# The admin console (/admin) is deliberately NOT routed — it stays +# localhost-only. +:80 { + @keycloak path /realms/* /resources/* + # Uploaded files, on the app's own origin. + # + # The database stores a root-relative path (/objects/<bucket>/<key>) rather + # than the object store's hostname, so one stored value resolves from + # localhost, from this tunnel, and from a deployment. Storing + # http://localhost:9000/... would render for whoever uploaded it and for + # nobody else — which is exactly the class of bug this whole session kept + # finding. + # + # Only the public prefixes are reachable this way; the bucket policy still + # decides, and an unsigned read of teams/* answers 403 through here too. + handle_path /objects/* { + reverse_proxy rustfs:9000 { + # REQUIRED for presigned uploads and downloads, not cosmetic. + # + # SigV4 signs the Host header, and the backend signs the object + # store's own hostname because that is the only name it knows. + # Caddy otherwise passes the INCOMING host through + # (*.trycloudflare.com here), which makes the store recompute a + # different signature and answer 403 SignatureDoesNotMatch — while + # unsigned public reads keep working, so the breakage would show up + # only for someone uploading through the tunnel. + # + # vite's proxy does the same thing under the name `changeOrigin` + # (components/frontend/vite.config.ts). + header_up Host {upstream_hostport} + } + } + + handle @keycloak { + reverse_proxy dev:8180 { + # TLS terminates at Cloudflare's edge; the hop into caddy is plain + # http. Force the forwarded proto so Keycloak advertises https + # endpoints and stamps the https issuer into tokens (requires + # proxy-headers=xforwarded on the Keycloak side — see + # tools/nix/hackagon/lib/toolchain.nix). + header_up X-Forwarded-Proto https + } + } + handle { + # Deliberately NOT X-Forwarded-Proto https here, unlike Keycloak above. + # + # Auth.js picks its cookie NAMES from whether it believes the site is + # secure (`__Secure-authjs.session-token` vs `authjs.session-token`). + # The vite dev server does not apply the header to `event.url`, so + # forwarding it made Auth.js see https on some requests and http on + # others: it wrote the session under the plain name, then looked for + # the `__Secure-` one and found nothing. Login through the tunnel + # silently dead-ended back on the public page with tokens successfully + # issued — the worst kind of broken. + # + # The frontend infers the public scheme itself (src/routes/ + # +layout.server.ts) for the absolute URLs link previews need. In + # production the header IS the right answer, together with + # adapter-node's PROTOCOL_HEADER, which makes `event.url` https + # everywhere and keeps Auth.js consistent. + # + # TWO upstreams, tried in order, and that order is the whole point: + # + # dev:8082 the adapter-node PRODUCTION build (prod-serve.sh). Its own + # port, owned by nothing else on the box. + # dev:8081 process-compose's `vite dev` — the fallback, so a tunnel + # brought up without `--prod` still works, and a built server + # that died degrades to slow rather than to 502. + # + # The public link used to BE :8081. The e2e suite stops, wipes and + # restarts everything on that port, and prod mode had to hand :8081 back + # and forth with vite around each run — so every `run.sh` handed the + # public URL ~40s of 502 Bad Gateway. A dedicated port removes the + # collision entirely: a suite run never touches :8082. + # + # ⚠ THE FALLBACK IS ONLY CORRECT WHEN :8081 IS VITE, and caddy cannot tell. + # vite derives the request origin from the Host header, so it answers a tunnel + # hostname correctly. The adapter-node build does not: it was launched with a + # FIXED ORIGIN, and the e2e harness always uses http://localhost:8081 + # (hackathon-e2e/scripts/prod-frontend.sh, which wait-ready.sh starts on every + # run). Reached through the tunnel, that server serves every page while + # SvelteKit 403s every form POST — the public URL looks perfect and login does + # nothing, with nothing in any log to say why. + # + # Nothing here can fix that, so it is fixed one level up: + # cloudflare-tunnel/scripts/up.sh calls `prod-serve.sh ensure <url>`, which + # starts a correct-origin server on :8082 whenever a fixed-origin server holds + # :8081, leaves a vite fallback alone, and refuses to hand over the link when + # it can do neither. Do not "simplify" this fallback away either: a plain + # (non---prod) tunnel served by vite is a supported mode and the fallback is + # what makes it work. + reverse_proxy dev:8082 dev:8081 { + # "first available", not round-robin — :8081 is a fallback, not a peer. + lb_policy first + # Retry the NEXT upstream when the chosen one refuses the connection + # (nothing is bound on :8082 in plain mode). Caddy only retries + # dial failures, so a genuine 4xx/5xx from the app still passes + # straight through instead of being replayed against vite. + lb_try_duration 5s + lb_try_interval 250ms + # Passive health check: after a failed dial, skip :8082 for 10s + # rather than paying the dial on every request. No ACTIVE check here + # on purpose — it would probe both upstreams, and vite answers 403 to + # anything whose Host is not allowlisted, which would mark the + # fallback down and turn every request into a 503. + fail_duration 10s + } + } +} diff --git a/.devcontainer/Dockerfile b/.devcontainer/Dockerfile new file mode 100644 index 00000000..9829be1f --- /dev/null +++ b/.devcontainer/Dockerfile @@ -0,0 +1,87 @@ +ARG BASE_IMAGE=mcr.microsoft.com/devcontainers/base:ubuntu-24.04 +FROM ${BASE_IMAGE} + +# System libraries Playwright's Firefox links against. `playwright install +# --with-deps` apt-installs these at runtime, but that writes to the +# container's writable layer, so they vanish the moment the container is +# recreated (e.g. any docker-compose change) and the e2e suite then dies with +# "libgtk-3.so.0: cannot open shared object file". Baking them into the image +# makes the browser survive recreation. +# Kept in sync with `playwright install --with-deps firefox` on Ubuntu 24.04. +# To re-derive this list after a Playwright bump: run `playwright install +# --with-deps firefox` in a container, then diff `apt-mark showmanual` against +# the same command run in a fresh container of this image. Anything the diff +# names is living in the writable layer and dies at the next recreate. +ARG INSTALL_BROWSER_DEPS=1 +RUN if [ "${INSTALL_BROWSER_DEPS}" = "1" ]; then \ + apt-get update \ + && DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \ + libgtk-3-0t64 \ + libasound2t64 \ + libdbus-glib-1-2 \ + libx11-xcb1 \ + libxcb-shm0 \ + libxcomposite1 \ + libxcursor1 \ + libxdamage1 \ + libxfixes3 \ + libxi6 \ + libxrandr2 \ + libxtst6 \ + libpangocairo-1.0-0 \ + libcairo-gobject2 \ + libatk1.0-0t64 \ + libatk-bridge2.0-0t64 \ + libepoxy0 \ + libnss3 \ + libnspr4 \ + fonts-liberation \ + libcairo2 \ + libdbus-1-3 \ + libfontconfig1 \ + libfreetype6 \ + libgdk-pixbuf-2.0-0 \ + libglib2.0-0t64 \ + libpango-1.0-0 \ + libx11-6 \ + libxcb1 \ + libxext6 \ + libxrender1 \ + libavcodec60 \ + xvfb \ + xfonts-cyrillic \ + xfonts-scalable \ + fonts-freefont-ttf \ + fonts-ipafont-gothic \ + fonts-noto-color-emoji \ + fonts-tlwg-loma-otf \ + fonts-unifont \ + fonts-wqy-zenhei \ + && rm -rf /var/lib/apt/lists/*; \ + fi + +# git-lfs. Three files in this repo are stored in LFS +# (components/frontend/static/favicon.png, static/og-default.jpg and +# tools/configs/keycloak/themes/hackagon/login/resources/img/favicon.ico). +# The workspace is bind-mounted from the host, which HAS git-lfs, so those +# files hold their real bytes in the worktree while HEAD holds the pointer. +# Without the lfs filters installed here, git inside the container compares +# pointer against content and reports all three modified — the worktree is +# dirty with zero edits, `git status` stops being a usable signal, and every +# `nix develop` evaluates a flake it must mark dirty. +# +# `--system` writes /etc/gitconfig on purpose: /home/vscode is a named volume, +# so anything this image writes to the user's home is masked at runtime. +RUN apt-get update \ + && DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends git-lfs \ + && rm -rf /var/lib/apt/lists/* \ + && git lfs install --system --skip-repo + +# Space-separated list of extra apt packages to bake into the image +# (set HACKAGON_DEV_EXTRA_APT_PACKAGES in .devcontainer/.env). +ARG EXTRA_APT_PACKAGES="" +RUN if [ -n "${EXTRA_APT_PACKAGES}" ]; then \ + apt-get update \ + && DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends ${EXTRA_APT_PACKAGES} \ + && rm -rf /var/lib/apt/lists/*; \ + fi diff --git a/.devcontainer/README.md b/.devcontainer/README.md new file mode 100644 index 00000000..1fcaaf9d --- /dev/null +++ b/.devcontainer/README.md @@ -0,0 +1,816 @@ +# Devcontainer + +Docker-compose-based devcontainer for Hackagon. The container provides Nix; +everything else (Go, pnpm, buf, process-compose, Keycloak, Postgres, …) comes +from the repo's flake (`tools/nix`) exactly as on a native setup — so `just` +commands behave identically inside and outside the container. + +## Required vs optional — read this first + +**Required to develop and run Hackagon: Nix (with flakes) and git. That is the +whole list.** Go, pnpm, buf, process-compose, Keycloak, Postgres, psql, grpcurl +and `just` itself all come out of `tools/nix`. Keycloak and Postgres run as +devenv **processes**, not containers (`tools/nix/hackagon/lib/toolchain.nix`), +and nothing in `justfile`, `tools/just/*.just` or `tools/deploy/` shells out to +Docker or Podman. So on Linux (or macOS) with Nix installed there is no +container runtime in the picture at all. + +Everything below is **optional**, and here is what each piece buys you and what +you lose without it: + +| Piece | Buys you | Without it | +| ---------------------------------------- | ------------------------------------------------------------------------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| This devcontainer (Docker) | A Linux box with Nix on a Windows/macOS host; pinned browser libs for e2e | Nothing, if you are on Linux. On Windows you need _some_ Linux (WSL2 works) because Nix does not run natively there. | +| `rustfs` object store (Docker) | File uploads: event logos, page media, submission attachments | The app still **boots and serves** (verified: backend `health.HealthService/Check` OK, frontend `/` 200). Uploads fail at use time and `/objects/*` answers **500**. Nothing warns you at boot. | +| Cloudflare tunnel (`tunnel`+`caddy`) | A public URL with working OIDC login — a throwaway `*.trycloudflare.com` one, or a persistent hostname on a zone you own | Localhost only. No effect on anything else. | +| Debug rigs: OpenReplay, Plausible | Session replay; cookieless audience measurement. Each is its own compose project with its own tunnel | Nothing — both are **off unless** a `replay:` / `plausible:` block reaches the frontend config (`src/lib/schemas/config-schema.ts`); the rigs write theirs into the gitignored `config.local.yaml` overlay. Idle cost when you do run them: ~750 MB for Plausible, 8 GB of its own for OpenReplay. They coexist. | +| `services` compose profile | Postgres + Keycloak as real containers instead of devenv processes | Nothing; the devenv copies are the default and the two sets fight over ports, which is why the profile is opt-in. | +| `.claude/` skills (e2e, tunnel, docs, …) | The Playwright suites, the recipe spec, the mutation manifest and quality report, the tunnel and docs tooling | Nothing in the app's build, test or run path. Grep confirms: outside `.claude/` the only references to it are explanatory comments. | + +### Minimal path from a clean machine to a running app + +Generated code is **not committed**, so a fresh clone must produce it before +anything compiles. This is the whole sequence: + +```bash +git clone https://github.com/SwissDataScienceCenter/hackagon.git +cd hackagon + +# codegen + deps (order is load-bearing: ts_proto comes from node_modules, +# and `go mod tidy` only resolves once the generated packages exist) +just develop bash -c "cd components/frontend && pnpm install --frozen-lockfile" +just develop just codegen::proto +just develop just codegen::db-schema +just develop bash -c "cd components/backend && GOWORK=off go mod tidy" + +# dev secrets for the frontend (gitignored; without it every request 500s) +printf 'oidc:\n clientSecret: "%s"\n authSecret: "%s"\n' \ + "$(openssl rand -base64 32)" "$(openssl rand -base64 32)" \ + > components/frontend/data/test/config/secrets.yaml + +just develop just deploy::up # keycloak + postgres + backend + frontend +just develop just db::seed # optional dev fixture +just develop just deploy::proc-comp process restart backend # casbin reload +``` + +`.devcontainer/bootstrap.sh` is exactly those first four commands; +`post-create.sh` runs it and writes the secrets too. The backend restart after +seeding is not optional: casbin loads its policy once at startup and the seed +writes roles straight into Postgres. + +Neither script prepares the object store — nothing in the app's own build path +knows it exists. If you are using the container, run +`bash .devcontainer/rustfs-init.sh` once (idempotent) or let +`.claude/skills/devcontainer-up/scripts/up.sh` do it for you; without it the +store is up but empty, which shows as three broken `<img>` frames on the +hackathon list and 404s under `/objects`. + +⚠ **The bootstrap leaves the tree dirty.** `GOWORK=off go mod tidy` prunes +exactly 26 lines from the committed `components/backend/go.sum`, so your first +`git status` is not clean. The build is unaffected. `just ci::codegen-check` +runs the same command followed by `git diff --exit-code`, so this needs +resolving rather than ignoring. (Still true: re-run in this container on +2026-08-14 at `a5003590`, same 26 deletions.) + +Nothing _else_ should be dirty. `git-lfs` is in the image as of 2026-08-13, so +the three LFS-tracked binaries (`components/frontend/static/favicon.png`, +`static/og-default.jpg`, the Keycloak theme's `favicon.ico`) no longer read as +permanently modified. They used to: the workspace is bind-mounted from a Windows +host that HAS git-lfs, so the worktree held real bytes while HEAD held a +129-byte pointer, and a container with no `filter.lfs` config compared the two +and reported ` M` forever. That was a filter that was never installed, not an +edit — and while it lasted, `git status` was not a signal anything could read. + +**Budget the first run.** It downloads and partly _compiles_ the toolchain — +devenv's own Rust binaries build from source, because the flake declares its +caches under `extra-trusted-substituters` (permission to use) rather than +`extra-substituters` (actually use them). Measured here on a busy 48 GB machine: +bootstrap ≈ 12 minutes to a ~10 GB Nix store, then `deploy::up` ≈ 5 more minutes +before process-compose reports the stack started (Keycloak's `kc.sh build` runs +in that window), and the frontend needs a few minutes more before Vite listens. + +**After that, entering the shell is cheap — and an earlier claim that it was not +was wrong.** Every `just develop …` (and therefore every service in the stack, +whose start commands are `just develop just run` / `just develop just serve`) +re-enters `nix develop`. Measured in this container on 2026-08-14 at `a5003590`, +`just nix::develop default true`: + +| | measured | +| --------------------------------------- | --------------------------------------------- | +| steady state, tree clean | **4.6–5.0 s** | +| steady state, one tracked file modified | 4.7–5.0 s — **no difference** | +| first entry after a tree edit | 4.7–10.6 s (one 36 s outlier, not reproduced) | +| against a _fixed_ devenv-root file | 3.2–4.5 s | + +That last row is where the avoidable cost is: `tools/just/devenv.sh` rewrites +`.devenv/state/pwd` on **every** invocation, so the `devenv-root` flake input +gets a new `lastModified` and Nix's eval cache misses every single run — about +1.7 s of the 4.8 s, self-inflicted. Clean-versus-dirty is not the variable; the +"44 s floor on a permanently dirty worktree" written down previously was almost +certainly measured while `frontend` was crash-looping through one full +`nix develop` per round (see "When the stack starves itself" below), and +installing git-lfs — which does make the tree genuinely clean — moved the number +not at all. Keep git-lfs for the truthful `git status`, not for speed. + +**On the native path.** These commands are what the container runs — the +container adds nothing but Nix — but the run behind this document was performed +in the devcontainer on a Windows host, where native Nix is not an option. The +"no container runtime" claim above is read off the configuration +(`toolchain.nix` runs Keycloak and Postgres as devenv processes; no recipe in +`justfile`, `tools/just/` or `tools/deploy/` invokes docker or podman), not off +an executed native Linux run. + +## Usage + +- **VS Code**: "Dev Containers: Reopen in Container". Nix is installed by the + devcontainer feature and `post-create.sh` runs automatically. +- **CLI**: `devcontainer up --workspace-folder .` +- **Plain compose** (no devcontainer tooling): + + ```bash + docker compose -f .devcontainer/docker-compose.yml up -d dev + docker compose -f .devcontainer/docker-compose.yml exec -u vscode dev bash + # inside — install Nix once (the feature would normally do this): + sudo mkdir -p /nix && sudo chown "$(id -u):$(id -g)" /nix + sh <(curl -fsSL https://nixos.org/nix/install) --no-daemon + # mkdir first: the installer does not create ~/.config/nix, so `tee` into it + # fails with "No such file or directory" and the flake settings never land. + mkdir -p ~/.config/nix + printf 'experimental-features = nix-command flakes\nsandbox = false\n' \ + | tee -a ~/.config/nix/nix.conf >/dev/null + cd /workspaces/hackagon && bash .devcontainer/post-create.sh + ``` + + The installer writes its PATH line into `~/.profile` and `~/.zshrc`, not + `~/.bashrc`; `post-create.sh` adds the `~/.bashrc` line itself. Scripted + `docker compose exec` calls after this must therefore use a **login** shell + (`bash -lc '…'`) or `just` will not be on `PATH`. + +## Bootstrap (what post-create does) + +`post-create.sh` is idempotent and does, in order: git `safe.directory`, +volume-mountpoint ownership, `just`/`direnv`/`socat` via `nix profile`, +generates the gitignored frontend dev secrets +(`components/frontend/data/test/config/secrets.yaml` — without it the frontend +answers 500), then runs `bootstrap.sh`: + +1. `pnpm install` — provides the `ts_proto` plugin `buf` invokes from + `node_modules` +2. `buf generate` — creates the gitignored `internal/proto` +3. ent codegen — creates the gitignored `ent/` +4. `go mod tidy` — resolves only once the generated packages exist + +This order is load-bearing; the generated code is not committed, so every fresh +workspace needs it. Skip with `HACKAGON_SKIP_BOOTSTRAP=1` in +`.devcontainer/.env`. The first run downloads the full toolchain (multi-GB); the +`nix-store` volume caches it for every rebuild after that. + +Start everything: + +```bash +just develop just deploy::up # keycloak + postgres + backend + frontend +``` + +Dev logins: `alice`, `bob`, `charles`, `hackagon-admin` — password +`aliceandbob`. Seed data: `just develop just db::seed`. + +## Configuration + +Copy `.env.example` to `.devcontainer/.env` (gitignored) and override what you +need — ports, base image, extra apt packages, timezone, restart policy, network +name, compose project name. The compose file uses `${VAR:-default}` everywhere, +so an empty `.env` (or none) gives the standard setup. + +Optional features (docker-in-docker, …) can be enabled by uncommenting them in +`devcontainer.json`. + +### Two checkouts at once + +Nothing stops a second checkout running beside the first, and you do not have to +stop the first one — but the defaults collide on **three** axes: the compose +project name (which prefixes containers and volumes), the network name, and the +published host ports. Override all three in the second checkout's +`.devcontainer/.env`. The in-container ports never change (3000/8081/8180/5432 +and `rustfs:9000`), so no checked-in config needs touching: + +```ini +COMPOSE_PROJECT_NAME=hackagon-fresh +HACKAGON_DEV_NETWORK=hackagon-fresh +HACKAGON_BACKEND_PORT=13000 +HACKAGON_FRONTEND_PORT=18081 +HACKAGON_KEYCLOAK_PORT=18180 +HACKAGON_POSTGRES_PORT=15433 +HACKAGON_RUSTFS_PORT=19000 +HACKAGON_RUSTFS_CONSOLE_PORT=19001 +``` + +This was executed: two full stacks — each with its own Postgres, Keycloak, +backend, frontend and object store — served simultaneously from one host, and +the e2e suite ran in the second without touching the first. The cost is a second +Nix store (~10 GB): the `nix-store` volume is per project, so the new checkout +re-downloads the toolchain. + +There is a **fourth** collision axis if both checkouts use named Cloudflare +tunnels and share one Cloudflare account: the tunnel names default to +`hackagon`, `hackagon-plausible`, `hackagon-openreplay`, so the second checkout +would reuse the first's tunnel and repoint its DNS. Set `HACKAGON_TUNNEL_NAME` +(and the Plausible/OpenReplay equivalents) in +`.claude/skills/cloudflare-tunnel/.env` — a different file from the +`.devcontainer/.env` above. + +## Ports + +| Port | Service | Where it binds | +| ---- | --------------------------- | ------------------------------------------------------ | +| 3000 | backend (gRPC) | inside `dev`, all interfaces | +| 8081 | frontend | inside `dev`, `[::1]` — vite, **or** the built server | +| 8082 | frontend (production build) | inside `dev`, **not published** — caddy's first choice | +| 8180 | keycloak | inside `dev`, 0.0.0.0 | +| 5432 | postgres | inside `dev`, 127.0.0.1 | +| 9000 | rustfs S3 API | own container — see the object store | +| 9001 | rustfs console | own container — subpath `/rustfs/console/` | + +**Two things can hold :8081.** `just up` starts `vite dev` there; the e2e +harness stops vite and parks the adapter-node production build on the same port +(`hackathon-e2e/scripts/prod-frontend.sh`), because after a codegen regeneration +vite's first SSR takes longer than any readiness probe will wait. :8081 is the +port to prefer for anything driving a browser — the realm export's +`hackagon-frontend` client allows exactly one redirect URI, +`http://localhost:8081/*`, so a login on :8082 dies with +`Invalid parameter: redirect_uri`. :8082 exists for the tunnel +(`cloudflare-tunnel/scripts/prod-serve.sh`), whose `Caddyfile.tunnel` tries +`dev:8082` first and falls back to `dev:8081`, so a public URL and a suite run +stop fighting over one port. It binds `HOST=::` (dual-stack) because caddy +reaches it as `dev:8082` on the container's eth0 while local checks use `::1` — +and it is deliberately not published, so nothing on the host can reach it and +mistake it for the app. + +The `rustfs` ports need no bridging: it is its own container, published straight +to the host, and reached from `dev` as `rustfs:9000`. Note that 9000 is **also** +Keycloak's management port inside `dev` — unrelated listener, same number, which +is why the object store cannot be bridged onto `localhost:9000` there. + +For the four services that live inside `dev`: with VS Code / the devcontainer +CLI all of them are forwarded automatically (loopback included). With **plain +compose**, Docker's published ports only reach services binding non-loopback +addresses — run the bridge script once after the services are up to cover the +rest: + +```bash +docker compose -f .devcontainer/docker-compose.yml exec -u vscode dev \ + bash /workspaces/hackagon/.devcontainer/host-bridge.sh +``` + +### Access: public imagery, private everything else + +`rustfs-init.sh` applies a bucket policy on every run. Three prefixes are +readable with **no credentials at all**; nothing else is. + +| Prefix | Read access | Holds | +| --------------- | ----------- | ----------------------------------------------- | +| `hackathons/*` | public | event covers, gallery photos | +| `users/*` | public | profile pictures | +| `site/*` | public | media pasted into platform pages (`SITE_MEDIA`) | +| everything else | private | submission attachments, exports | + +Public-read is a decision, not an accident (see `docs/storage.md`): these images +already render on pages that need no login, so a public prefix gives a stable +URL that never expires and can be cached — which is also what lets an uploaded +image sit in the existing `logo` / `avatar_url` columns with no schema change. +Private files are reached through short-lived presigned GETs, minted only after +casbin has approved the read. + +`rustfs-init.sh --selftest` asserts **both halves**: it uploads a probe under +each prefix, reads them unsigned, and fails unless it sees 200 and 403 +respectively. Getting this backwards is silent — signed callers keep working +while the private half is world-readable — so it is tested rather than assumed. +Run here on 2026-08-14: +`hackathons/* 200, users/* 200, site/* 200, teams/* 403`. + +**The list has to track `storage_service.go`.** A kind the backend marks public +but this policy has no prefix for uploads perfectly and then answers 403 to +every read, because the handler returns a `publicUrl` it has no way to know is +unreadable. That is exactly what happened when `SITE_MEDIA` landed, which is why +`check_public_policy` now probes _every_ public prefix rather than a +representative one. + +### Objects are served from the app's own origin + +The database stores a **root-relative path**, never a hostname: + +``` +/objects/hackagon-dev/hackathons/seed/climate-tech-hackathon-2026/cover.webp +``` + +`http://localhost:9000/...` would render for the machine that wrote it and for +nobody else — not through the Cloudflare tunnel, not in a deployment. A +same-origin path resolves everywhere, and two routes make that true: + +- **`vite dev`** — a `/objects` proxy in `components/frontend/vite.config.ts`. +- **the tunnel and the built server** — `handle_path /objects/*` in + `.devcontainer/Caddyfile.tunnel`, which strips the prefix and proxies to + `rustfs:9000`. + +The bucket policy still decides what comes back: an unsigned read of `teams/*` +answers 403 through this route exactly as it does directly. + +### Seeded event pictures + +`rustfs-init.sh` (and `--seed-media` on its own) uploads a cover for each seeded +hackathon from the repo's own event photographs: + +``` +hackathons/seed/ai-innovation-challenge-2026/cover.webp +hackathons/seed/climate-tech-hackathon-2026/cover.webp +hackathons/seed/internal-product-sprint/cover.webp +``` + +Keyed by **slug, not id**: ids are new on every reseed and the pictures are not, +so `just db::seed` points each event at a cover that is already there instead of +leaving three broken image frames. Content-Type is set from the file extension — +curl otherwise stores `application/x-www-form-urlencoded`, the bytes upload +fine, and the browser then refuses to render them. + +## Public URL (Cloudflare tunnel, optional) + +Two modes, and the tooling picks between them. + +**Quick tunnel — the zero-setup default.** An opt-in `tunnel` service (compose +profile `tunnel`) exposes the running frontend on a random `*.trycloudflare.com` +URL — no Cloudflare account needed, and a new hostname on every start. The +bridge script must be running so the tunnel container can reach Vite: + +```bash +docker compose -f .devcontainer/docker-compose.yml exec -u vscode dev \ + bash /workspaces/hackagon/.devcontainer/host-bridge.sh +docker compose -f .devcontainer/docker-compose.yml --profile tunnel up -d tunnel +docker compose -f .devcontainer/docker-compose.yml logs tunnel | grep -o 'https://.*trycloudflare.com' +``` + +The tunnel targets `caddy`, which path-splits the one public hostname: +`/realms/*` + `/resources/*` reach Keycloak, `/objects/*` the object store, +everything else the frontend (`Caddyfile.tunnel`). Anonymous browsing works out +of the box; **login through the tunnel** additionally needs the OIDC issuers +rewired to the public URL — scripted as +`bash .claude/skills/cloudflare-tunnel/scripts/up.sh --with-auth`, undone by the +matching `down.sh`. Keycloak trusts forwarded headers for this +(`proxy-headers=xforwarded` in toolchain.nix); the admin console is not routed +through the tunnel. Stop with +`docker compose -f .devcontainer/docker-compose.yml --profile tunnel down tunnel caddy`. + +**Named tunnel — a hostname that stops changing.** Most of the re-wiring above +exists only because a quick-tunnel hostname is thrown away on every restart. A +named tunnel is a persistent hostname on a zone you own, and there is one per +rig, driven by `.claude/skills/lib/cf-named-tunnel.sh` from a **gitignored** +`.claude/skills/cloudflare-tunnel/.env` (copy `.env.example` beside it): + +| rig | hostname variable | tunnel | origin | +| ---------- | --------------------- | --------------------- | ----------------------- | +| the app | `HACKAGON_HOSTNAME` | `hackagon` | `http://caddy:80` | +| Plausible | `PLAUSIBLE_HOSTNAME` | `hackagon-plausible` | `http://plausible:8000` | +| OpenReplay | `OPENREPLAY_HOSTNAME` | `hackagon-openreplay` | `http://caddy:80` | + +```bash +bash .claude/skills/lib/cf-named-tunnel.sh check # credentials + zone only +bash .claude/skills/lib/cf-named-tunnel.sh status # which named tunnels run +bash .claude/skills/cloudflare-tunnel/scripts/up.sh --with-auth # auto-selects +bash .claude/skills/cloudflare-tunnel/scripts/up.sh --with-auth --quick # force ephemeral +``` + +`up.sh` chooses named when those credentials exist and quick otherwise, **prints +which mode it is in**, and stops the other mode's tunnel — the OIDC issuer names +exactly one hostname, so a second public URL would serve every page and fail +every login, which is the failure nobody notices until somebody signs in. Caddy +needed no change: `Caddyfile.tunnel` binds `:80` for any Host, so the path mux +applies identically. Nothing tracked ever carries the hostname; the issuer goes +into the gitignored `config.local.yaml` overlay, and a spec in +`components/backend/internal/config/config_test.go` asserts both tracked configs +still say `localhost`. + +⚠ **A Cloudflare API token scopes to a ZONE, not to a hostname.** There is no +per-subdomain grant. The narrowest token that can do this job can edit **any DNS +record in the whole zone** — do not describe it as limited to the three +subdomains above, and use a zone you are willing to hand to a dev script. The +tooling supplies the guard Cloudflare cannot: `cf_dns_point` refuses to replace +a record that is not already a `*.cfargotunnel.com` CNAME (`CF_FORCE_DNS=1` +overrides). + +**The token is a SETUP credential.** Once the tunnels exist, `cloudflared` runs +from a per-tunnel credentials file under `.state/named/<name>/` that can serve +that one tunnel and nothing else: it cannot touch DNS, cannot enumerate the zone +and cannot create anything. A machine that only _runs_ a tunnel should hold that +directory and no token at all. + +⚠ **A named hostname can look dead from the Windows host and be perfectly +healthy.** On this LAN the resolver answers **AAAA-only** for these names on a +network with no IPv6 route out, so every lookup succeeds and every connection +fails in milliseconds — while the same URL works from inside the dev container. +The tooling detects this rather than reporting a broken tunnel: +`cf-named-tunnel.sh` retries against a DoH-resolved IPv4 edge and, when that +answers, says "the tunnel is fine, this machine's resolver is not"; +`auth-wire.sh` pins the name in `/etc/hosts` inside the container, and tests +reachability rather than asking `getent hosts`, which says yes about a name +nothing can reach. The manual check: + +```bash +curl -sS -o /dev/null -w '%{http_code}\n' \ + --resolve app.example.org:443:<a-cloudflare-ipv4> https://app.example.org/ +``` + +## Object store (service `rustfs`) + +An S3-compatible object store so the platform can **store uploaded files +locally** instead of only accepting links. It is [RustFS](https://rustfs.com) — +a single Rust binary, pinned to `rustfs/rustfs:1.0.0-beta.12` (multi-arch: +amd64 + arm64). + +Unlike Postgres and Keycloak this is **not** behind a profile: there is no +devenv copy of it to fight over ports with, so it starts with the rest of the +stack. It is a plain sibling of `dev` with no `depends_on` in either direction — +adding one would alter `dev`'s compose config, and that recreates the container +and kills the process-compose stack inside it. + +```bash +# start it (safe on a running stack — only this service is touched) +docker compose -f .devcontainer/docker-compose.yml up -d rustfs + +# create the bucket — idempotent, re-run whenever +docker compose -f .devcontainer/docker-compose.yml exec -u vscode dev \ + bash /workspaces/hackagon/.devcontainer/rustfs-init.sh + +# prove it: PUT an object, GET it back, compare bytes, list, delete +docker compose -f .devcontainer/docker-compose.yml exec -u vscode dev \ + bash /workspaces/hackagon/.devcontainer/rustfs-init.sh --selftest +``` + +| | | +| ---------------------------------------------- | ------------------------------------------- | +| Endpoint **from `dev`** (and from the backend) | `http://rustfs:9000` | +| Endpoint **from the host** | `http://localhost:9000` | +| Bucket | `hackagon-dev` | +| Access key / secret key | `hackagon-dev` / `hackagon-dev-secret` | +| Region | `us-east-1` | +| Addressing | **path-style only** (`endpoint/bucket/key`) | +| Web console | `http://localhost:9001/rustfs/console/` | +| Data | named volume `rustfs-data` (→ `/data`) | + +**Why the backend addresses it by service name, not `localhost`.** Every other +checked-in config reaches its dependency on `localhost` (bridged by +`service-bridge.sh` when the service is containerised). That is impossible here: +inside `dev`, port 9000 is already **Keycloak's management port**, so there is +nothing to bridge onto. `rustfs:9000` resolves over the compose network instead. +Running the backend natively, outside the container? The published host port +covers it — `HACKAGON_STORAGE_ENDPOINT=http://localhost:9000`. + +Mind the **console subpath**: `http://localhost:9001/` is still the S3 router +and answers `403 AccessDenied`. The UI is at `/rustfs/console/`. + +**Credentials are dev-only.** They sit in `docker-compose.yml` and in +`components/backend/data/test/config/config.yaml` in plain sight, exactly like +`POSTGRES_PASSWORD: postgres` and the Keycloak `admin`/`admin` next to them — +committed so a fresh checkout works with zero setup, and worthless because +nothing but a laptop ever serves on that port. **They must never reach a +deployment.** Override per machine in `.devcontainer/.env` +(`HACKAGON_RUSTFS_ACCESS_KEY`, `HACKAGON_RUSTFS_SECRET_KEY`) and, for the +backend, via `HACKAGON_STORAGE_ACCESSKEY` / `HACKAGON_STORAGE_SECRETKEY` — koanf +maps `HACKAGON_STORAGE_*` onto the `storage.*` config keys, so nothing secret +has to live in a file. + +### Backend configuration + +`components/backend/internal/config/config.go` gained a `storage` section +(`endpoint`, `region`, `bucket`, `accesskey`, `secretkey`, `usepathstyle`), +declared the same way as the existing sections: struct + `yaml:` tags, defaults +in the `confmap` provider, overridable by `HACKAGON_*` env vars. Like the other +sections it carries **no hard validation** — `Load()` still only rejects a +missing `server.adminkeycloakid` — so an unconfigured store fails at first use, +not at boot. + +`usepathstyle: true` is not optional: rustfs serves virtual-hosted-style +requests (`bucket.host/key`) only when `RUSTFS_SERVER_DOMAINS` is set, and there +is no wildcard DNS for `*.rustfs` on a compose network. Configure any S3 SDK +with `forcePathStyle` / `s3_use_path_style` accordingly. + +### Reset + +The store is a named volume, so resetting it is a volume operation — never +`rm -rf` on the workspace (nothing of it lives there): + +```bash +docker compose -f .devcontainer/docker-compose.yml stop rustfs +docker compose -f .devcontainer/docker-compose.yml rm -f rustfs +docker volume rm devcontainer_rustfs-data # prefix = COMPOSE_PROJECT_NAME +docker compose -f .devcontainer/docker-compose.yml up -d rustfs +docker compose -f .devcontainer/docker-compose.yml exec -u vscode dev \ + bash /workspaces/hackagon/.devcontainer/rustfs-init.sh +``` + +`rustfs-init.sh --status` lists what is currently in the bucket. + +### No S3 client is installed, and that is deliberate + +The dev container ships neither `aws` nor `mc`. Adding one would mean editing +the Dockerfile (which recreates `dev` and kills the stack inside it) or a +permanent dev-shell change, so `rustfs-init.sh` signs its own SigV4 requests +with `curl` + `openssl`, both already present. For a full-featured client, +borrow one for the duration of a command: + +```bash +nix shell nixpkgs#awscli2 -c env \ + AWS_ACCESS_KEY_ID=hackagon-dev AWS_SECRET_ACCESS_KEY=hackagon-dev-secret \ + AWS_DEFAULT_REGION=us-east-1 AWS_EC2_METADATA_DISABLED=true \ + aws --endpoint-url=http://rustfs:9000 s3 ls s3://hackagon-dev/ --recursive +``` + +### What changes for a real deployment + +Everything above is shaped for one laptop and none of it is production posture. +A deployment replaces the container with a managed bucket (S3, or MinIO/RustFS +run properly) and changes four things. **Credentials** stop being literals: +inject `HACKAGON_STORAGE_ACCESSKEY`/`SECRETKEY` from the platform's secret store +— better, drop static keys entirely for a workload identity (IRSA/instance role) +and scope the policy to `GetObject`/`PutObject`/ `DeleteObject` on +`arn:…:bucket/*` only, never `s3:*` and never bucket-level admin. **Bucket +policy** stays private — the dev bucket already denies unsigned reads (verified: +`403 AccessDenied`), and public objects should be served through the app or a +CDN with time-limited **presigned URLs** rather than by making a prefix +world-readable; block public access at the account level so a stray ACL cannot +undo it. **TLS** is mandatory: the endpoint becomes `https://`, since SigV4 +authenticates a request but encrypts nothing, and presigned URLs handed to +browsers would otherwise leak in transit. **Lifecycle and durability** have no +dev equivalent at all: versioning + a noncurrent-version expiry so an overwrite +is recoverable, an abort-incomplete-multipart rule (7 days) so failed browser +uploads stop accruing cost, retention/expiry per prefix matching how long a +hackathon's media must outlive the event, and cross-region or at least cross-AZ +replication — the dev store runs a single drive with **zero parity** (it logs +`storage_class_zero_redundancy` on boot), which is fine for a laptop and +unacceptable for anything else. Also size it: uploads want a request-size cap +and a per-user quota enforced by the app before the bytes ever reach the bucket. + +## Optional service containers (profile `services`) + +By default Postgres and Keycloak run _inside_ `dev`, as devenv/process-compose +processes. The compose file also defines them as real containers: + +```bash +docker compose -f .devcontainer/docker-compose.yml --profile services up -d +docker compose -f .devcontainer/docker-compose.yml exec -u vscode dev \ + bash /workspaces/hackagon/.devcontainer/service-bridge.sh +``` + +Both carry healthchecks (`pg_isready`; Keycloak's `/health/ready` over bash's +`/dev/tcp`, since that image ships no curl) and named volumes, and Keycloak +imports the same checked-in realm export the Nix service uses. Their ports are +**not** published — `dev` reaches them by name on the compose network, and +publishing would collide with the ports `dev` already maps. + +`service-bridge.sh` socat-forwards `localhost:5432` and `localhost:8180` inside +`dev` to those containers, so every checked-in config (backend `config.yaml`, +the frontend `oidc.issuer`, `just rpc-as`, the e2e skill) keeps working +unchanged. It is the mirror image of `host-bridge.sh`. + +**They are opt-in because `just up` still starts devenv's own Postgres and +Keycloak, and the two sets would fight over ports.** The Nix shell has a +`withPostgres` flag but no `withKeycloak` one, so making these the default needs +a change in `tools/nix/hackagon/lib/toolchain.nix` first. + +Note for `postgres:18+`: the data volume mounts at `/var/lib/postgresql`, not +`/var/lib/postgresql/data`. The older path makes the entrypoint abort with an +incompatible-data-directory error. + +## Volumes & network + +Named volumes keep expensive state out of the (slow, host-bound) workspace bind +mount and survive container rebuilds: + +- `nix-store` (`/nix`) — the Nix store / toolchain. +- `home-vscode` (`/home/vscode`) — nix profile symlinks, shell rc, caches; makes + a manually installed Nix survive container recreation. +- `devenv-state` (`.devenv`) — devenv state, **including the Postgres data + directory** (`.devenv/state/postgres`). +- `direnv-state` (`.direnv`) — direnv cache. +- `frontend-node-modules` (`components/frontend/node_modules`), + `frontend-svelte-kit` (`components/frontend/.svelte-kit`) and `pnpm-store` + (`.pnpm-store`) — Node dependencies and build caches. +- `rustfs-data` (`/data` in the `rustfs` container) — uploaded objects. A volume + rather than a workspace bind mount for the same small-file-IO reason as + `node_modules` below; an object store is exactly that workload. + +**Why node_modules must be a volume.** The workspace bind mount is a `9p` +filesystem on Windows (and osxfs/virtiofs on macOS); the volumes are native +`ext4`. `node_modules` is ~275 MB of small files, and every `stat()` across that +boundary is expensive. Measured on the bind mount, a single +`require("isomorphic-dompurify")` (which pulls in jsdom) took **52 seconds** — +past vite's 60 s SSR module-transport timeout, so every route returned 500. The +same require is fast from the volume. Confirm which side you are on with +`findmnt -no TARGET,FSTYPE | grep node_modules` — it must say `ext4`, not `9p`. + +The trade-off: the volume **masks** the host directory, so `node_modules` is +invisible from Windows (editors relying on it for IntelliSense should run inside +the container, which is the intended workflow) and starts **empty** on first +creation — `post-create.sh` chowns the mountpoints (they appear root-owned) and +`bootstrap.sh` repopulates them with `pnpm install`. + +Volume names are prefixed with the compose project name, so parallel checkouts +don't collide as long as `COMPOSE_PROJECT_NAME` differs. The network has an +explicit name (`hackagon-dev`, override via `HACKAGON_DEV_NETWORK`) so sidecars +and ad-hoc containers can attach: `docker run --network hackagon-dev …`. + +Inspect / reset: + +```bash +docker volume ls --filter name=devcontainer # or your project name +docker compose -f .devcontainer/docker-compose.yml down # keep state +docker compose -f .devcontainer/docker-compose.yml down --volumes # full reset +``` + +Note: because `/nix` lives in a volume, updating the Nix _feature_ in +`devcontainer.json` has no effect until the `nix-store` volume is removed. + +Known wrinkle after recreating the container: Keycloak's H2 database (in +`devenv-state`) keeps a JGroups cluster-membership row for the previous +container's hostname, so its first boot can hang spamming +`failed sending message ... SocketTimeoutException`. One +`just develop just deploy::proc-comp process restart keycloak` fixes it — the +stale member ages out. The frontend also takes a few minutes on first boot (pnpm +install + svelte-kit sync before vite listens). + +## When the stack starves itself + +Fixed 2026-08-13, and worth recognising because for several days it read as +product bugs in four different places rather than as an infrastructure fault. + +Every process in the stack starts with `just develop just …`, so **entering the +Nix shell is inside every service's startup**, while process-compose's readiness +clock is already running. That is fine at ~5 s a go. It stops being fine when +something enters that shell in a loop. + +What happened: `vite dev` binds `[::1]:8081`, and so does the adapter-node build +the e2e harness parks there. Whenever a previous run had left that server up — +the common case, since nothing stopped it between runs — vite could not bind, +exited 1 with `Error: Port 8081 is already in use`, and an **uncapped** +`restart: on_failure` sent it round again roughly every 55 seconds. Found live +at **54 restarts in 50 minutes**, each one a full `nix develop`. + +Two lessons generalise past this instance: + +- ⚠ **A readiness probe on a PORT cannot say which PROCESS holds it.** + `process list` reported `frontend Running Ready` the entire time, because the + probe is `curl http://localhost:8081` and the _other_ server was answering it. + The `RESTARTS` column said 54 throughout and nothing read it. +- ⚠ **A SIGTERM that lands after the Go signal handler is up exits 0**, and + `restart: on_failure` does not consider 0 a failure — so a backend killed by + its own readiness budget stays down and is recorded as `Completed`, + `exit_code=0`, which reads like a clean stop. The log line sequence is + `grpc server listening` → `received shutdown signal` → nothing, forever. + +The fixes are in `tools/nix/hackagon/lib/toolchain.nix` and the harness, and +none of them is a rule anyone has to remember: + +- frontend: `max_restarts = 3`, so a port conflict costs three shell entries + rather than one an hour. +- backend: `restart = "always"` **plus `max_restarts = 3`**. `always` alone + converts a permanent outage into an unbounded loop (measured with the budget + scaled down to force it: 149 restarts in 151 seconds); the cap is what makes + `always` safe. `failure_threshold` went 50 → 150 (~37 min) because a cold + restart of that service — Nix shell, build quitsh, build the Go service, boot + — was measured at 486 s on a quiet lock. A generous budget costs nothing when + healthy, since probing stops at the first success, and the thing that should + decide "the backend did not come up" is + `hackathon-e2e/scripts/wait-ready.sh`'s own 300 s-per-service timeout, which + names the service. +- `prod-frontend.sh ensure` calls `stop_vite` **unconditionally**. Its fast path + ("the built frontend already serves :8081 — leaving it alone") used to return + without touching process-compose, and that was the whole of how the loop + survived. "Leaving it alone" is about _our_ server, never about vite. +- `wait-ready.sh` reads the restart counters back and warns, with the exit code, + when any service is at ≥3. + +## One writer for the frontend build + +**Never run a bare `pnpm build` in `components/frontend`.** Two independent +callers build _and serve_ the same `build/service` tree — +`hackathon-e2e/scripts/prod-frontend.sh` on :8081 and +`cloudflare-tunnel/scripts/prod-serve.sh` on :8082 — so they do not merely race +to build it, they race to replace it while the other is serving it. Symptoms +(three agents hit this in one day): `Unexpected end of JSON input`, then a +missing `build/service/server/index.js` at boot. + +Both go through `.claude/skills/lib/frontend-build.sh`: + +```bash +bash .claude/skills/lib/frontend-build.sh if-stale # build only if src/ moved +bash .claude/skills/lib/frontend-build.sh build # unconditional +bash .claude/skills/lib/frontend-build.sh stale # exit 0 when a build is due +``` + +It closes two different holes. An exclusive `flock` stops two builds +interleaving, and re-checks staleness **inside** the lock, so the second caller +waits and then finds the first one's fresh output — checking staleness outside +the lock is how both callers decide to build. And it builds into a temp dir and +swaps atomically, so `build/service` only ever holds a complete tree; the lock +cannot help there, because an interrupted build's writer is gone rather than +concurrent, and what it had written so far stays behind looking like a build. + +⚠ **A directory rename on the 9p bind mount intermittently answers `EPERM`** +(`mv: cannot move '…/build/service' to '…/build/.service-old-352884': Permission denied`), +with no open descriptors involved — the same rename succeeded a minute later +with the same servers running. The swap therefore retries and rolls the old tree +back if the second rename fails, so `build/service` is never left missing. +Anything else here that renames a directory on this mount needs the same +treatment. + +## Adding sidecar services + +The app itself (backend, frontend, Keycloak, Postgres) runs in-container via +process-compose; `rustfs` is the one dependency that is a real container by +default. If you need another external service, add it to `docker-compose.yml` +following the same `${VAR:-default}` convention, e.g.: + +```yaml +mailpit: + image: ${HACKAGON_MAILPIT_IMAGE:-axllent/mailpit:latest} + ports: + - "${HACKAGON_MAILPIT_PORT:-8025}:8025" +``` + +Two rules, both learned the hard way (the `rustfs` service follows them): + +- **Add it as a sibling — never touch `dev`'s own definition.** Any change to + `dev`'s compose config recreates that container, which kills the + process-compose stack running inside it (Postgres, Keycloak, backend, + frontend) and wipes anything apt-installed at runtime. That rules out a new + `depends_on` or environment entry on `dev`. +- **Never gate a sidecar on `dev`'s health.** `dev` only reports healthy once + someone runs `just up`, which compose does not manage, so + `depends_on: {dev: {condition: service_healthy}}` deadlocks. + +Pin the image tag (never `latest`) and put persistent state in a named volume, +not the workspace bind mount. + +### Before you recreate `dev`: find what is only in the writable layer + +Anything apt-installed at runtime dies with the container, and it dies silently +— the first symptom is a suite failing on a missing shared library some days +later. That is how Firefox's system libraries were lost once, and a check before +the 2026-08-13 recreate found **21 more Playwright packages** in the same +position (xvfb, libavcodec60, six font packages, and the usual X/cairo/pango +set). All of them are baked into `Dockerfile` now. + +The recovery procedure is recorded in `Dockerfile`'s own comment above the +browser-deps block, and it is the thing to re-run after any Playwright bump or +before any deliberate recreate: install into a container, then **diff +`apt-mark showmanual` against the same list in a fresh container of this +image**. Anything the diff names is living in the writable layer. Add it to the +Dockerfile before recreating, not after. + +When you do recreate, `up -d --no-deps dev` keeps compose from touching `caddy` +and the tunnel alongside it. Afterwards, restart the stack +(`hackathon-e2e/scripts/up.sh` + `wait-ready.sh`) before anything else. + +## Two artefacts you would not guess were there + +Neither is needed to build or run the app — nothing outside `.claude/` +references them — but both answer questions people ask about this repo, so they +are worth knowing about before someone re-derives them by hand. + +**Mutation testing** (`.claude/skills/hackathon-e2e/mutations/`) turns "would +this test go red?" into something that runs. `manifest.jsonl` is a list of +deliberate, reversible breakages, each paired with the exact set of tests that +must notice; the runner applies one, runs them, and asserts exactly that set +failed. `NO REDS` **fails** the run — it means nothing in the suite holds the +property. Only an exact match passes. + +```bash +bash .claude/skills/devcontainer-up/scripts/mutate.sh check # anchors still match source +bash .claude/skills/devcontainer-up/scripts/mutate.sh list +bash .claude/skills/devcontainer-up/scripts/mutate.sh run # fast tier: go + vitest +bash .claude/skills/devcontainer-up/scripts/mutate.sh restore # after a run was killed +``` + +The fast tier drives `go test` and `vitest` straight from source and needs the +container but **no running stack**. `check` is cheap enough for every commit and +is the one to run after touching backend or frontend source: it verifies each +mutation's anchor still matches its file, and an anchor that has drifted is the +same disease as a test that has stopped asserting. Run here on 2026-08-14: +`all 38 mutations still anchor`. + +**The quality report** (`.claude/skills/hackathon-e2e/quality-report.html`) is a +single self-contained page: what is tested, how well, what is not, what is +known-broken. Nothing in it is hand-typed — every figure is read from a file at +build time and then read back out of the finished HTML and re-derived by a +second code path before the build is allowed to succeed. Rebuild it after +changing the recipe, the manifest or a run report: + +```bash +bash .claude/skills/devcontainer-up/scripts/exec.sh \ + just develop node .claude/skills/hackathon-e2e/scripts/build-quality-report.mjs +``` + +(`node` is not on the login shell's `PATH` — it comes from the dev shell, or +from `.devenv/profile/bin` if you would rather not enter one.) The build prints +every claim it re-derived and refuses to write a page it could not verify; a run +on 2026-08-14 reported `205 figures re-derived … and matched`. Its animated +sibling, `recipe-player.html`, is rebuilt separately — same shell, same +directory, with `node .claude/skills/hackathon-e2e/scripts/splice-player.mjs` — +and that rebuild is required rather than cosmetic after any recipe edit. diff --git a/.devcontainer/bootstrap.sh b/.devcontainer/bootstrap.sh new file mode 100755 index 00000000..a64b74c1 --- /dev/null +++ b/.devcontainer/bootstrap.sh @@ -0,0 +1,34 @@ +#!/usr/bin/env bash +# One-shot project bootstrap for a fresh workspace. Idempotent. +# +# Order matters: +# 1. pnpm install — provides the ts_proto plugin that buf invokes from +# components/frontend/node_modules/.bin +# 2. buf generate — creates the gitignored components/backend/internal/proto +# 3. ent codegen — creates the gitignored components/backend/ent +# 4. go mod tidy — only resolves once the generated packages exist; +# without them Go tries to fetch the (private) repo. +# +# The first `just develop` downloads the whole flake toolchain — expect the +# initial run to take a while; the Nix store volume caches it for rebuilds. +set -euo pipefail + +workspace="$(git rev-parse --show-toplevel)" +export USER="${USER:-$(whoami)}" +[ -e "$HOME/.nix-profile/etc/profile.d/nix.sh" ] && . "$HOME/.nix-profile/etc/profile.d/nix.sh" + +cd "${workspace}" + +echo "==> Installing frontend deps (provides buf's ts_proto plugin)..." +just develop bash -c "cd components/frontend && pnpm install --frozen-lockfile" + +echo "==> Generating gRPC stubs (buf)..." +just develop just codegen::proto + +echo "==> Generating Ent ORM code..." +just develop just codegen::db-schema + +echo "==> Syncing backend Go modules..." +just develop bash -c "cd components/backend && GOWORK=off go mod tidy" + +echo "Bootstrap complete. Start everything with: just develop just deploy::up" diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json new file mode 100644 index 00000000..0b9535d8 --- /dev/null +++ b/.devcontainer/devcontainer.json @@ -0,0 +1,43 @@ +{ + "name": "Hackagon", + "dockerComposeFile": "docker-compose.yml", + "service": "dev", + "workspaceFolder": "/workspaces/hackagon", + "shutdownAction": "stopCompose", + "remoteUser": "vscode", + + "features": { + // Nix is required: the whole toolchain comes from tools/nix via devenv. + "ghcr.io/devcontainers/features/nix:1": { + "multiUser": true, + "extraNixConfig": "experimental-features = nix-command flakes,sandbox = false" + }, + "ghcr.io/anthropics/devcontainer-features/claude-code:1.0": {} + // Optional — uncomment to build/run containers inside the dev container: + // ,"ghcr.io/devcontainers/features/docker-in-docker:2": {} + }, + + // Services run inside this container via process-compose (`just start`); + // forwarding reaches them even when they bind to 127.0.0.1. + "forwardPorts": [3000, 5432, 8081, 8180], + "portsAttributes": { + "3000": { "label": "backend (gRPC)" }, + "5432": { "label": "postgres" }, + "8081": { "label": "frontend" }, + "8180": { "label": "keycloak" } + }, + + "postCreateCommand": "bash .devcontainer/post-create.sh", + + "customizations": { + "vscode": { + "extensions": [ + "jnoortheen.nix-ide", + "mkhl.direnv", + "golang.go", + "svelte.svelte-vscode", + "zxh404.vscode-proto3" + ] + } + } +} diff --git a/.devcontainer/docker-compose.yml b/.devcontainer/docker-compose.yml new file mode 100644 index 00000000..c6d5a2d8 --- /dev/null +++ b/.devcontainer/docker-compose.yml @@ -0,0 +1,336 @@ +# Devcontainer compose stack. +# +# Every knob is overridable via `.devcontainer/.env` (see `.env.example`); +# defaults reproduce the standard dev setup. Keycloak, Postgres, backend and +# frontend all run *inside* the `dev` service via `just start` +# (process-compose), so there are no sidecar service containers by default — +# add your own services below if you need external ones. + +services: + dev: + build: + context: . + dockerfile: Dockerfile + args: + BASE_IMAGE: ${HACKAGON_DEV_BASE_IMAGE:-mcr.microsoft.com/devcontainers/base:ubuntu-24.04} + EXTRA_APT_PACKAGES: ${HACKAGON_DEV_EXTRA_APT_PACKAGES:-} + init: true + command: sleep infinity + restart: ${HACKAGON_DEV_RESTART:-unless-stopped} + environment: + TZ: ${HACKAGON_DEV_TZ:-UTC} + # Set to 1 to skip the codegen/deps bootstrap in post-create.sh. + HACKAGON_SKIP_BOOTSTRAP: ${HACKAGON_SKIP_BOOTSTRAP:-} + volumes: + - ..:/workspaces/hackagon:cached + # Persist the Nix store across container rebuilds (devenv downloads a lot). + - nix-store:/nix + # Persist the user's home: nix profile links, shell rc, tool caches. + - home-vscode:/home/vscode + # Keep devenv/direnv state off the (slow) host bind mount. + - devenv-state:/workspaces/hackagon/.devenv + - direnv-state:/workspaces/hackagon/.direnv + # node_modules MUST NOT live on the bind mount. It is ~275 MB across + # tens of thousands of small files, and on Windows/macOS every stat() + # crosses the host filesystem boundary. Measured there: a single + # `require("isomorphic-dompurify")` (jsdom) took 52s, which blew past + # vite's 60s SSR module-transport timeout and 500'd every route. In a + # named volume it is native container-local IO. + # NOTE: the volume masks the host directory, so it starts empty — + # post-create.sh/bootstrap.sh repopulate it with `pnpm install`. + - frontend-node-modules:/workspaces/hackagon/components/frontend/node_modules + # Same reasoning for the build/codegen caches that are written on every + # dev-server start and are cheap to regenerate. + - frontend-svelte-kit:/workspaces/hackagon/components/frontend/.svelte-kit + - pnpm-store:/workspaces/hackagon/.pnpm-store + # Only reachable from the host for services binding 0.0.0.0 (keycloak); + # VS Code / devcontainer CLI port forwarding covers the 127.0.0.1 ones. + ports: + - "${HACKAGON_BACKEND_PORT:-3000}:3000" + - "${HACKAGON_FRONTEND_PORT:-8081}:8081" + - "${HACKAGON_KEYCLOAK_PORT:-8180}:8180" + - "${HACKAGON_POSTGRES_PORT:-5432}:5432" + # "Healthy" means the app stack inside is actually serving, not merely that + # the container is up — that is the only signal caddy/tunnel can usefully + # wait on. So an idle dev container (no `just up` yet) reports unhealthy by + # design; nothing restarts it, the flag is purely a readiness gate. + # Vite binds loopback, and inside the container `localhost` may resolve to + # ::1 first, so try both families. + healthcheck: + test: + [ + "CMD-SHELL", + "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/", + ] + interval: 15s + timeout: 10s + retries: 5 + # Generous: a cold `just up` builds the devenv profile and boots four + # processes. Failures during this window do not count. + start_period: ${HACKAGON_DEV_START_PERIOD:-300s} + # Resource limits are deliberately NOT defaulted here. This container + # compiles Go, evaluates Nix, runs Postgres/Keycloak and drives Firefox; + # a wrong ceiling shows up as an OOM kill mid-build, which is far harder to + # diagnose than slowness. Set them per machine in .devcontainer/.env: + # HACKAGON_DEV_CPUS=6 + # HACKAGON_DEV_MEMORY=12g + # and uncomment the block below. + # deploy: + # resources: + # limits: + # cpus: "${HACKAGON_DEV_CPUS:-}" + # memory: "${HACKAGON_DEV_MEMORY:-}" + + # --------------------------------------------------------------------- + # Development object store — RustFS, an S3-compatible server written in + # Rust. Gives the platform somewhere to PUT uploaded files locally instead + # of only accepting links (infrastructure.md gap I4). + # + # docker compose -f .devcontainer/docker-compose.yml up -d rustfs + # bash .devcontainer/rustfs-init.sh # create the bucket + # bash .devcontainer/rustfs-init.sh --selftest # prove S3 PUT/GET/list + # + # NOT in a profile: unlike postgres/keycloak there is no devenv copy of this + # to fight over ports with, so it is safe as a default service. It is also a + # SIBLING of `dev` with no `depends_on` in either direction — adding one + # would change `dev`'s compose config, and that recreates the container, + # killing the process-compose stack running inside it. + # + # `dev` reaches it as `rustfs:9000` over the compose network (that is what + # the backend's `storage.endpoint` points at); the published port is for the + # host — a browser, or an S3 client run outside the container. + rustfs: + # Pinned, never `latest`: RustFS is pre-1.0 and the S3 surface still moves + # between betas. Multi-arch (amd64 + arm64), so Apple Silicon works too. + image: ${HACKAGON_RUSTFS_IMAGE:-rustfs/rustfs:1.0.0-beta.12} + restart: unless-stopped + # The image entrypoint appends $RUSTFS_VOLUMES, so no data path here. + command: ["server", "--console-enable"] + environment: + RUSTFS_ADDRESS: ":9000" + RUSTFS_CONSOLE_ADDRESS: ":9001" + RUSTFS_VOLUMES: /data + # DEV-ONLY CREDENTIALS. Committed on purpose, exactly like the postgres + # and keycloak passwords above: they exist so a fresh checkout works + # with zero setup, and they are worthless because nothing but a laptop + # ever serves on this port. A deployment MUST override them from a + # secret store — see the README's "real deployment" note. + RUSTFS_ACCESS_KEY: ${HACKAGON_RUSTFS_ACCESS_KEY:-hackagon-dev} + RUSTFS_SECRET_KEY: ${HACKAGON_RUSTFS_SECRET_KEY:-hackagon-dev-secret} + RUSTFS_REGION: ${HACKAGON_RUSTFS_REGION:-us-east-1} + # Empty => log to stdout, i.e. `docker compose logs rustfs`. The image + # default (/logs) writes files into the container's writable layer, + # which no one ever reads and which vanishes on recreate. + RUSTFS_OBS_LOG_DIRECTORY: "" + RUSTFS_OBS_LOGGER_LEVEL: ${HACKAGON_RUSTFS_LOG_LEVEL:-warn} + RUSTFS_OBS_ENVIRONMENT: development + ports: + # 9000 = S3 API. 9001 = web console, served at the SUBPATH + # http://localhost:9001/rustfs/console/ — the console port's `/` is + # still the S3 router and answers 403, which looks broken but is not. + - "${HACKAGON_RUSTFS_PORT:-9000}:9000" + - "${HACKAGON_RUSTFS_CONSOLE_PORT:-9001}:9001" + volumes: + # Named volume, NOT a bind mount into the workspace: the workspace mount + # is 9p on Windows / virtiofs on macOS and object stores are exactly the + # small-file-IO workload that pathologises there (see the node_modules + # measurements on the `dev` service). This is also why resetting the + # store is `docker volume rm`, not `rm -rf`. + - rustfs-data:/data + healthcheck: + # Unauthenticated GET / on the S3 endpoint: rustfs answers 403 + # AccessDenied, which proves the S3 router is up and the bucket is + # private. Deliberately no `-f` — a 403 is the expected answer here, so + # only a connection failure may fail the check. + test: + [ + "CMD", + "curl", + "-sS", + "-o", + "/dev/null", + "--max-time", + "5", + "http://127.0.0.1:9000/", + ] + interval: 10s + timeout: 5s + retries: 5 + start_period: 20s + deploy: + resources: + limits: + memory: ${HACKAGON_RUSTFS_MEMORY:-512m} + + # --------------------------------------------------------------------- + # Optional: run Postgres and Keycloak as their own containers instead of + # as processes inside `dev` (where devenv/process-compose owns them). + # + # docker compose -f .devcontainer/docker-compose.yml --profile services up -d + # + # Why opt-in: `just up` still starts devenv's own postgres+keycloak, and + # the Nix shell has a withPostgres flag but no withKeycloak one, so the two + # sets would fight over ports. Use these when you want the dev container to + # hold only the Go/Node toolchain — then run the stack with + # `.devcontainer/service-bridge.sh`, which points localhost:5432/8180 inside + # `dev` at these containers so every checked-in config keeps working. + # + # Ports are deliberately NOT published: `dev` reaches them over the compose + # network by name, and publishing would collide with the ports `dev` already + # maps. Set HACKAGON_PG_PUBLISH / HACKAGON_KC_PUBLISH if you want them on the + # host anyway. + postgres: + image: ${HACKAGON_POSTGRES_IMAGE:-postgres:18-alpine} + profiles: ["services"] + restart: unless-stopped + environment: + # Match components/backend/data/test/config/config.yaml. + POSTGRES_USER: ${HACKAGON_PG_USER:-postgres} + POSTGRES_PASSWORD: ${HACKAGON_PG_PASSWORD:-postgres} + POSTGRES_DB: ${HACKAGON_PG_DB:-hackagon} + volumes: + # postgres:18+ wants ONE mount at /var/lib/postgresql (it puts the data + # dir in a subdirectory of it). Mounting /var/lib/postgresql/data — the + # convention for 17 and earlier — makes the entrypoint abort with an + # incompatible-data-directory error. + - postgres-data:/var/lib/postgresql + healthcheck: + test: + [ + "CMD-SHELL", + "pg_isready -U ${HACKAGON_PG_USER:-postgres} -d ${HACKAGON_PG_DB:-hackagon}", + ] + interval: 10s + timeout: 5s + retries: 5 + start_period: 30s + deploy: + resources: + limits: + memory: ${HACKAGON_PG_MEMORY:-1g} + + keycloak: + image: ${HACKAGON_KEYCLOAK_IMAGE:-quay.io/keycloak/keycloak:26.5} + profiles: ["services"] + restart: unless-stopped + # start-dev keeps the in-container dev database and skips the production + # hostname/TLS requirements — same posture as the devenv service. + command: start-dev --import-realm --http-port=8180 + environment: + KC_BOOTSTRAP_ADMIN_USERNAME: ${HACKAGON_KC_ADMIN:-admin} + KC_BOOTSTRAP_ADMIN_PASSWORD: ${HACKAGON_KC_ADMIN_PASSWORD:-admin} + # /health/ready on the management port, used by the healthcheck below. + KC_HEALTH_ENABLED: "true" + # Mirrors tools/nix/hackagon/lib/toolchain.nix: trust the tunnel's caddy + # and resolve the hostname from the request instead of pinning localhost. + KC_PROXY_HEADERS: xforwarded + KC_HOSTNAME_STRICT: "false" + volumes: + # Same realm export the Nix service imports — one source of truth. + - ../tools/configs/keycloak/realm-hackagon.json:/opt/keycloak/data/import/realm-hackagon.json:ro + - keycloak-data:/opt/keycloak/data + healthcheck: + # The image ships no curl/wget, so talk to the management port over + # bash's /dev/tcp. + test: + [ + "CMD-SHELL", + "exec 3<>/dev/tcp/127.0.0.1/9000; printf 'GET /health/ready HTTP/1.1\\r\\nHost: localhost\\r\\nConnection: close\\r\\n\\r\\n' >&3; grep -q '\"status\": \"UP\"' <&3", + ] + interval: 15s + timeout: 10s + retries: 10 + start_period: 90s + deploy: + resources: + limits: + memory: ${HACKAGON_KC_MEMORY:-1500m} + + # Optional: public URL for the running stack via a Cloudflare quick + # tunnel (random *.trycloudflare.com, no account needed). Start with: + # docker compose -f .devcontainer/docker-compose.yml --profile tunnel up -d tunnel + # The URL appears in `docker compose logs tunnel`. Requires the frontend to + # be reachable on the dev container's network interface — run + # .devcontainer/host-bridge.sh inside the container first. + # + # The tunnel targets caddy, which path-splits the single public hostname + # between the frontend and Keycloak (see Caddyfile.tunnel) — that is what + # makes OIDC login possible through the tunnel. Anonymous viewing needs + # nothing else; full login additionally needs the issuer rewiring done by + # .claude/skills/cloudflare-tunnel/scripts/up.sh --with-auth. + caddy: + image: ${HACKAGON_TUNNEL_PROXY_IMAGE:-caddy:2-alpine} + profiles: ["tunnel"] + restart: unless-stopped + volumes: + - ./Caddyfile.tunnel:/etc/caddy/Caddyfile:ro + # Plain dependency ON PURPOSE — do NOT gate this on `dev` being healthy. + # `dev` is a long-lived container whose app stack is started by hand + # (`just up`), so its health depends on something compose does not manage. + # With `condition: service_healthy`, bringing up the tunnel while the stack + # happens to be down fails with "dependency failed to start", and any + # compose change to `dev` restarts it — killing the very process-compose + # stack the condition then waits for. Readiness is enforced by the + # cloudflare-tunnel skill's up.sh instead, which waits for the app before + # starting anything. + depends_on: + - dev + healthcheck: + # Caddy's own admin API: proves caddy is alive without depending on the + # upstream, so a dead app shows up as a dev/tunnel problem, not a caddy one. + test: ["CMD", "wget", "-q", "--spider", "http://127.0.0.1:2019/config/"] + interval: 10s + timeout: 5s + retries: 5 + start_period: 15s + deploy: + resources: + limits: + memory: ${HACKAGON_CADDY_MEMORY:-128m} + + tunnel: + image: ${HACKAGON_TUNNEL_IMAGE:-cloudflare/cloudflared:latest} + profiles: ["tunnel"] + restart: unless-stopped + command: tunnel --no-autoupdate --url http://caddy:80 + # cloudflared resolves its target once at startup: starting before caddy is + # listening leaves the tunnel pointing at nothing until it is recreated. + depends_on: + caddy: + condition: service_healthy + deploy: + resources: + limits: + memory: ${HACKAGON_TUNNEL_MEMORY:-128m} + +volumes: + # Nix store — the whole toolchain; survives container rebuilds. + nix-store: + # Home dir — nix profile symlinks, .bashrc additions, caches. + home-vscode: + # devenv state — includes the Postgres data directory + # (.devenv/state/postgres), so the database also survives rebuilds. + devenv-state: + direnv-state: + # Node dependencies and build caches — kept off the host bind mount because + # small-file IO across it is orders of magnitude slower (see the dev service + # comment). All three are reproducible from lockfiles; losing them costs one + # `pnpm install`. + frontend-node-modules: + frontend-svelte-kit: + pnpm-store: + # Object-store data. A volume rather than a workspace bind mount for the + # same small-file-IO reason as the caches above; reset it with + # `docker compose -f .devcontainer/docker-compose.yml down rustfs` + + # `docker volume rm <project>_rustfs-data`. + rustfs-data: + # Only used by the optional "services" profile; harmless otherwise. + postgres-data: + keycloak-data: + +networks: + # Explicit name so sidecars and external tooling can attach predictably + # (docker run --network hackagon-dev ...). Override when running multiple + # checkouts side by side. + default: + name: ${HACKAGON_DEV_NETWORK:-hackagon-dev} diff --git a/.devcontainer/host-bridge.sh b/.devcontainer/host-bridge.sh new file mode 100755 index 00000000..340ba62d --- /dev/null +++ b/.devcontainer/host-bridge.sh @@ -0,0 +1,31 @@ +#!/usr/bin/env bash +# Bridge loopback-bound dev services to the container's network interface so +# Docker's published ports reach them from the host WITHOUT devcontainer +# tooling. VS Code / the devcontainer CLI forward loopback ports themselves — +# this script is only needed for plain `docker compose` usage. +# +# Why: docker-proxy targets the container's eth0 IP, but inside the container +# Vite listens on [::1]:8081 and Postgres on 127.0.0.1:5432 only. The backend +# (3000) binds all interfaces and needs no bridge. Keycloak (8180) binds +# 0.0.0.0 and needs no bridge. +# +# Idempotent — re-running replaces existing bridges. +set -euo pipefail + +export USER="${USER:-$(whoami)}" +[ -e "$HOME/.nix-profile/etc/profile.d/nix.sh" ] && . "$HOME/.nix-profile/etc/profile.d/nix.sh" + +ip=$(hostname -i | awk '{print $1}') +listen="TCP-LISTEN" # split so pkill below never matches this script's cmdline + +bridge() { + local port="$1" target="$2" + pkill -f "socat ${listen}:${port}," 2>/dev/null || true + sleep 0.2 + setsid nohup socat "${listen}:${port},bind=${ip},fork,reuseaddr" "${target}" \ + >"/tmp/socat-${port}.log" 2>&1 & + echo "bridging ${ip}:${port} -> ${target}" +} + +bridge 8081 "TCP6:[::1]:8081" # frontend (vite binds IPv6 loopback) +bridge 5432 "TCP:127.0.0.1:5432" # postgres diff --git a/.devcontainer/post-create.sh b/.devcontainer/post-create.sh new file mode 100755 index 00000000..b871043d --- /dev/null +++ b/.devcontainer/post-create.sh @@ -0,0 +1,69 @@ +#!/usr/bin/env bash +# Runs once after the devcontainer is created (cwd = workspace folder). +# Idempotent — safe to re-run. Plain-compose users run it manually: +# docker compose -f .devcontainer/docker-compose.yml exec -u vscode dev \ +# bash -c "cd /workspaces/hackagon && bash .devcontainer/post-create.sh" +set -euo pipefail + +workspace="$(pwd)" +# docker exec does not set USER; Nix profile scripts silently no-op without it. +export USER="${USER:-$(whoami)}" + +# The bind-mounted repo is owned by the host user. +git config --global --add safe.directory "${workspace}" + +# Named-volume mountpoints are created root-owned; everything below runs as +# vscode, so hand them over. node_modules/.svelte-kit/.pnpm-store are volumes +# because small-file IO on the host bind mount is pathologically slow on +# Windows and macOS (see docker-compose.yml). +sudo chown "$(id -u):$(id -g)" \ + "${workspace}/.devenv" \ + "${workspace}/.direnv" \ + "${workspace}/.pnpm-store" \ + "${workspace}/components/frontend/node_modules" \ + "${workspace}/components/frontend/.svelte-kit" + +# Make a manually installed (single-user) Nix visible to every shell. With the +# devcontainer Nix feature this file does not exist and these are no-ops. +if [ -e "$HOME/.nix-profile/etc/profile.d/nix.sh" ]; then + # The USER guard matters: docker exec shells have no USER set, and + # nix.sh silently no-ops without it. + # shellcheck disable=SC2016 # literal on purpose: this line is APPENDED to + # an rc file, so $USER and $HOME must expand when that shell runs, not now. + line='export USER="${USER:-$(whoami)}"; . "$HOME/.nix-profile/etc/profile.d/nix.sh"' + for rc in "$HOME/.bashrc" "$HOME/.bash_profile"; do + grep -qs "nix-profile/etc/profile.d/nix.sh" "$rc" || echo "$line" >>"$rc" + done + . "$HOME/.nix-profile/etc/profile.d/nix.sh" +fi + +# Bootstrap tools needed to enter the Nix dev shell; everything else comes +# from the flake (tools/nix) once inside. socat serves host-bridge.sh. +for pkg in just direnv socat; do + command -v "$pkg" >/dev/null 2>&1 && continue + nix profile add "nixpkgs#$pkg" 2>/dev/null || nix profile install "nixpkgs#$pkg" +done + +# shellcheck disable=SC2016 # literal on purpose: the substitution must run +# when .bashrc is sourced, not while this script writes it. +grep -qs 'direnv hook bash' "$HOME/.bashrc" || + echo 'eval "$(direnv hook bash)"' >>"$HOME/.bashrc" +direnv allow "${workspace}" || true + +# Dev-only frontend secrets (gitignored) — without them the frontend +# returns 500 "Server Configuration Error" on every request. +secrets="${workspace}/components/frontend/data/test/config/secrets.yaml" +if [ ! -f "$secrets" ]; then + printf 'oidc:\n clientSecret: "%s"\n authSecret: "%s"\n' \ + "$(openssl rand -base64 32)" "$(openssl rand -base64 32)" >"$secrets" + echo "Generated dev secrets at ${secrets}." +fi + +if [ "${HACKAGON_SKIP_BOOTSTRAP:-}" != "1" ]; then + bash "${workspace}/.devcontainer/bootstrap.sh" +else + echo "Skipped project bootstrap (HACKAGON_SKIP_BOOTSTRAP=1)." +fi + +echo "Done. Enter the dev shell with 'just dev' (or let direnv load it)." +echo "Start all services with: just develop just deploy::up" diff --git a/.devcontainer/rustfs-init.sh b/.devcontainer/rustfs-init.sh new file mode 100644 index 00000000..8871dddf --- /dev/null +++ b/.devcontainer/rustfs-init.sh @@ -0,0 +1,472 @@ +#!/usr/bin/env bash +# Bootstrap the development object store (the `rustfs` compose service): +# create the bucket(s) the platform uploads into, and — with --selftest — +# prove the endpoint really speaks S3 rather than merely being up. +# +# docker compose -f .devcontainer/docker-compose.yml up -d rustfs +# bash .devcontainer/rustfs-init.sh # create buckets (idempotent) +# bash .devcontainer/rustfs-init.sh --selftest # PUT / GET / compare / list +# bash .devcontainer/rustfs-init.sh --status # what exists right now +# +# Idempotent: an existing bucket is detected and left alone, and even a bare +# re-create would succeed (see create_bucket). Safe to re-run any time. +# +# No S3 client required. The dev container ships neither `aws` nor `mc`, and +# baking one in would mean editing the image (which recreates `dev` and kills +# the process-compose stack inside it) or a permanent dev-shell change. So the +# ~40 lines below sign requests with SigV4 using curl + openssl, both of which +# are already there. Reach for `nix shell nixpkgs#awscli2 -c aws …` when you +# want a full-featured client for a one-off. +# +# Endpoint resolution, in order: +# 1. $RUSTFS_ENDPOINT, if set. +# 2. http://rustfs:9000 — the compose service name, when it resolves (i.e. +# we are on the devcontainer network). This is what the backend uses. +# 3. http://localhost:9000 — the port compose publishes to the host. Also +# where non-Linux shells land, since they have no `getent`. +set -euo pipefail + +# --- configuration ----------------------------------------------------- +# Defaults MUST match the `rustfs` service in docker-compose.yml. DEV-ONLY +# credentials; see the README before copying them anywhere real. +# Derived from this script's location, not from cwd: seed_media reads image +# files out of the repo, and every other stack script can be run from anywhere. +HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +ROOT_DIR="$(cd "$HERE/.." && pwd)" + +ACCESS_KEY="${HACKAGON_RUSTFS_ACCESS_KEY:-hackagon-dev}" +SECRET_KEY="${HACKAGON_RUSTFS_SECRET_KEY:-hackagon-dev-secret}" +REGION="${HACKAGON_RUSTFS_REGION:-us-east-1}" +BUCKETS="${HACKAGON_RUSTFS_BUCKET:-hackagon-dev}" + +resolve_endpoint() { + if [ -n "${RUSTFS_ENDPOINT:-}" ]; then + echo "${RUSTFS_ENDPOINT%/}" + # The in-container port is fixed at 9000 by RUSTFS_ADDRESS; only the + # published host port is configurable, hence the asymmetry below. + elif getent hosts rustfs >/dev/null 2>&1; then + echo "http://rustfs:9000" + else + echo "http://localhost:${HACKAGON_RUSTFS_PORT:-9000}" + fi +} +ENDPOINT="$(resolve_endpoint)" +HOST="${ENDPOINT#*://}" + +for tool in curl openssl od; do + command -v "$tool" >/dev/null || { + echo "error: $tool not found (needed to sign S3 requests)" >&2 + exit 1 + } +done + +# One scratch dir for the whole run, cleaned once. Per-function `trap … RETURN` +# would be tidier but bash keeps a RETURN trap installed after the function +# that set it returns, so it then fires in the caller against unset locals. +TMPDIR_RUN="$(mktemp -d)" +trap 'rm -rf "$TMPDIR_RUN"' EXIT +tmpfile() { mktemp "${TMPDIR_RUN}/XXXXXX"; } + +# --- SigV4 ------------------------------------------------------------- +# `od -v` matters: without it od collapses repeated input lines to '*' and +# silently corrupts the digest. +hex() { od -An -v -tx1 | tr -d ' \n'; } +sha256_hex() { openssl dgst -sha256 -binary | hex; } +# HMAC-SHA256 with a hex key, printing a hex digest — chains into itself, so +# the whole key-derivation ladder is four calls. +hmac_hex() { openssl dgst -sha256 -mac HMAC -macopt "hexkey:$1" -binary | hex; } + +# Percent-encode a path, leaving '/' and the RFC 3986 unreserved set alone. +uri_encode_path() { + local s="$1" out="" c + for ((i = 0; i < ${#s}; i++)); do + c="${s:i:1}" + case "$c" in + [a-zA-Z0-9._~/-]) out+="$c" ;; + *) out+="$(printf '%%%02X' "'$c")" ;; + esac + done + printf '%s' "$out" +} + +# s3_request <METHOD> <uri-path> <canonical-query> <payload-file> <out-file> +# +# The query string must already be sorted and encoded — every caller here uses +# either "" or a single fixed parameter, so a general encoder would be dead +# code. Prints the HTTP status; the body lands in <out-file>. +s3_request() { + local method="$1" path="$2" query="${3:-}" payload="${4:-}" out="${5:-/dev/null}" + + local amzdate datestamp payload_hash canonical_uri + amzdate="$(date -u +%Y%m%dT%H%M%SZ)" + datestamp="${amzdate%%T*}" + canonical_uri="$(uri_encode_path "$path")" + + if [ -n "$payload" ]; then + payload_hash="$(sha256_hex <"$payload")" + else + payload_hash="$(printf '' | sha256_hex)" + fi + + local signed_headers="host;x-amz-content-sha256;x-amz-date" + local canonical_headers="host:${HOST} +x-amz-content-sha256:${payload_hash} +x-amz-date:${amzdate} +" + local canonical_request="${method} +${canonical_uri} +${query} +${canonical_headers} +${signed_headers} +${payload_hash}" + + local scope="${datestamp}/${REGION}/s3/aws4_request" + # Digest computed on its own line, not inlined into the assignment: with + # `local x="$(…)"` the exit status is the one of `local`, so a failing + # openssl would silently yield an empty digest (shellcheck SC2155). + local request_hash string_to_sign + request_hash="$(printf '%s' "$canonical_request" | sha256_hex)" + string_to_sign="AWS4-HMAC-SHA256 +${amzdate} +${scope} +${request_hash}" + + local k + k="$(printf 'AWS4%s' "$SECRET_KEY" | hex)" + k="$(printf '%s' "$datestamp" | hmac_hex "$k")" + k="$(printf '%s' "$REGION" | hmac_hex "$k")" + k="$(printf '%s' "s3" | hmac_hex "$k")" + k="$(printf '%s' "aws4_request" | hmac_hex "$k")" + local signature + signature="$(printf '%s' "$string_to_sign" | hmac_hex "$k")" + + local auth="AWS4-HMAC-SHA256 Credential=${ACCESS_KEY}/${scope}, \ +SignedHeaders=${signed_headers}, Signature=${signature}" + + local url="${ENDPOINT}${canonical_uri}" + [ -n "$query" ] && url="${url}?${query}" + + local args=(-sS -o "$out" -w '%{http_code}' --max-time 30 + -H "Authorization: ${auth}" + -H "x-amz-content-sha256: ${payload_hash}" + -H "x-amz-date: ${amzdate}") + # `--head`, not `-X HEAD`: with -X curl still expects a response body and + # blocks until the timeout, because the server correctly sends none. + if [ "$method" = "HEAD" ]; then + args+=(--head) + else + args+=(-X "$method") + fi + # Only PUT carries a body; adding --data-binary to a GET makes curl send + # one, which changes the request the signature was computed over. + # + # Content-Type is set from the extension, because curl otherwise sends + # application/x-www-form-urlencoded and S3 stores THAT as the object's type + # — the bytes upload fine and the browser then refuses to render the image. + # It is deliberately not in signed_headers: SigV4 only requires that the + # headers it names match, so an extra unsigned header is valid and keeps the + # signature computation unchanged. + if [ -n "$payload" ]; then + args+=(--data-binary "@${payload}") + case "$payload" in + *.webp) args+=(-H "Content-Type: image/webp") ;; + *.png) args+=(-H "Content-Type: image/png") ;; + *.jpg | *.jpeg) args+=(-H "Content-Type: image/jpeg") ;; + *.svg) args+=(-H "Content-Type: image/svg+xml") ;; + *.json) args+=(-H "Content-Type: application/json") ;; + *.txt) args+=(-H "Content-Type: text/plain") ;; + *) args+=(-H "Content-Type: application/octet-stream") ;; + esac + fi + + curl "${args[@]}" "$url" +} + +# --- commands ---------------------------------------------------------- +wait_ready() { + local tries="${1:-30}" + for _ in $(seq "$tries"); do + # Unauthenticated GET / answers 403 AccessDenied once the S3 router is + # live; any HTTP status at all means "listening and speaking S3". + curl -sS -o /dev/null --max-time 2 "${ENDPOINT}/" 2>/dev/null && return 0 + sleep 1 + done + echo "error: no S3 endpoint at ${ENDPOINT} after ${tries}s." >&2 + echo " start it with: docker compose -f .devcontainer/docker-compose.yml up -d rustfs" >&2 + return 1 +} + +create_bucket() { + local bucket="$1" body status + body="$(tmpfile)" + + # HEAD first purely for honest reporting. CreateBucket is already + # idempotent here — in us-east-1 S3 answers 200 (not 409 + # BucketAlreadyOwnedByYou) when you re-create a bucket you own, and rustfs + # matches that, so a bare PUT could never tell "created" from "existed". + status="$(s3_request HEAD "/${bucket}" "" "" /dev/null)" + if [ "$status" = "200" ]; then + echo " exists ${bucket}" + return 0 + fi + + status="$(s3_request PUT "/${bucket}" "" "" "$body")" + case "$status" in + 200 | 204 | 409) + echo " created ${bucket}" + ;; + *) + echo " FAILED ${bucket} (HTTP ${status})" >&2 + sed 's/^/ /' "$body" >&2 + return 1 + ;; + esac +} + +list_bucket() { # <bucket> [prefix] + local bucket="$1" prefix="${2:-}" body status query="list-type=2" + [ -n "$prefix" ] && query="${query}&prefix=${prefix}" + body="$(tmpfile)" + + status="$(s3_request GET "/${bucket}" "$query" "" "$body")" + if [ "$status" != "200" ]; then + echo " list FAILED (HTTP ${status})" >&2 + sed 's/^/ /' "$body" >&2 + return 1 + fi + # Good enough for a bootstrap script: the listing XML is flat and rustfs + # emits one <Key> per object. + grep -o '<Key>[^<]*</Key>' "$body" | sed -e 's/<[^>]*>//g' || true +} + +# Public-read on the imagery prefixes, private everywhere else. +# +# Decided in docs/storage.md: event logos, gallery photos, avatars and platform +# page imagery already render on pages that need no login, so serving them from +# a public prefix gives stable URLs that never expire and can be cached — which +# is also what lets them sit in the existing `logo` / `avatar_url` columns with +# no schema change. +# +# Everything NOT listed here (team submissions, exports) stays private and is +# reached through short-lived presigned GETs, minted only after casbin has +# approved the read. Verify both halves with --selftest. +# +# ⚠ THIS LIST IS THE SECOND HALF OF `public: true` IN uploadRules +# (components/backend/internal/service/storage_service.go). A kind marked public +# there but missing a prefix here uploads perfectly and then answers 403 to +# every read — the backend hands back a `publicUrl` it has no way to know is +# unreadable. That happened when SITE_MEDIA landed; check_public_policy probes +# every public prefix now so the next one cannot repeat it. +put_public_policy() { + local bucket="$1" body policy status + body="$(tmpfile)" + policy="$(tmpfile)" + + cat >"$policy" <<POLICY +{ + "Version": "2012-10-17", + "Statement": [ + { + "Sid": "PublicReadEventImagery", + "Effect": "Allow", + "Principal": "*", + "Action": "s3:GetObject", + "Resource": [ + "arn:aws:s3:::${bucket}/hackathons/*", + "arn:aws:s3:::${bucket}/users/*", + "arn:aws:s3:::${bucket}/site/*" + ] + } + ] +} +POLICY + + # Query goes in its own argument: SigV4 signs the canonical query + # string separately from the path, so folding "?policy=" into the path + # signs a URI that does not exist and rustfs answers InvalidBucketName. + status="$(s3_request PUT "/${bucket}" "policy=" "$policy" "$body")" + case "$status" in + 200 | 204) + echo " policy ${bucket} (public: hackathons/*, users/*, site/*)" + ;; + *) + echo " FAILED ${bucket} policy (HTTP ${status})" >&2 + sed 's/^/ /' "$body" >&2 + return 1 + ;; + esac +} + +# The pictures the seeded hackathons point at. +# +# `just db::seed` sets each event's logo to /objects/<bucket>/hackathons/seed/ +# <slug>/cover.webp, keyed by slug rather than by id: ids are new on every +# reseed and the pictures are not. This uploads them once so a fresh clone has +# images the first time it boots, instead of three broken <img> frames. +# +# Sources are the repo's own event photographs, already WebP and already sized +# for display. Idempotent: re-uploading overwrites with identical bytes. +seed_media() { + local bucket="${1:-hackagon-dev}" src pair slug file + src="components/frontend/static/images/hackathon-ord-2024" + + if [ ! -d "$ROOT_DIR/$src" ]; then + echo " skipped seed media (no $src)" >&2 + return 0 + fi + + echo "==> Seed media" + for pair in \ + "ai-innovation-challenge-2026:teams/teams_1.webp" \ + "climate-tech-hackathon-2026:ambiance/ambiance_1.webp" \ + "internal-product-sprint:ambiance/ambiance_3.webp"; do + slug="${pair%%:*}" + file="${pair#*:}" + if s3_request PUT "/${bucket}/hackathons/seed/${slug}/cover.webp" "" \ + "$ROOT_DIR/$src/$file" /dev/null >/dev/null; then + echo " uploaded hackathons/seed/${slug}/cover.webp" + else + echo " FAILED hackathons/seed/${slug}/cover.webp" >&2 + fi + done +} + +cmd_init() { + echo "==> Object store ${ENDPOINT}" + wait_ready + echo "==> Buckets:" + for bucket in $BUCKETS; do + create_bucket "$bucket" + put_public_policy "$bucket" + done + seed_media + echo "Done. Endpoint ${ENDPOINT}, buckets: ${BUCKETS}" +} + +cmd_status() { + echo "==> Object store ${ENDPOINT}" + wait_ready 3 + for bucket in $BUCKETS; do + echo "--- ${bucket}" + list_bucket "$bucket" | sed 's/^/ /' + done +} + +# Round-trip proof: PUT an object, GET it back, compare BYTES (not just the +# status code), list the bucket, then delete. Being up is not the same as +# being an object store. +# Proves the POLICY, not just the plumbing: an object under a public prefix must +# be readable with no credentials at all, and one under a private prefix must +# not be. Getting this backwards is silent — everything keeps working for +# signed callers while the private half is world-readable — so it is asserted +# rather than assumed. +# EVERY public prefix is probed, not a representative one. `hackathons/*` alone +# was the check for months and it could not have caught the fault it was written +# to catch: when `site/*` was added to uploadRules but not to the policy above, +# this reported a healthy split while every platform-page image 403'd. +PUBLIC_PREFIXES="hackathons users site" +PRIVATE_PREFIXES="teams" + +check_public_policy() { + local bucket="$1" prefix key code failed=0 + + echo "==> Policy: unsigned reads" + + # Real files, not process substitution: the payload is read TWICE — once to + # hash it for the signature, once by curl to send it — and a pipe is empty + # the second time, which uploads nothing and then 404s on read. + local probe_body + probe_body="$(tmpfile)" + printf 'probe +' >"$probe_body" + + for prefix in $PUBLIC_PREFIXES $PRIVATE_PREFIXES; do + key="${prefix}/_selftest/probe.txt" + s3_request PUT "/${bucket}/${key}" "" "$probe_body" /dev/null >/dev/null + code="$(curl -s -o /dev/null -w '%{http_code}' "${ENDPOINT}/${bucket}/${key}")" + s3_request DELETE "/${bucket}/${key}" "" "" /dev/null >/dev/null + + case " $PUBLIC_PREFIXES " in + *" $prefix "*) + printf ' %-12s unsigned -> %s (want 200)\n' "${prefix}/*" "$code" + [ "$code" = "200" ] || failed=1 + ;; + *) + printf ' %-12s unsigned -> %s (want 403)\n' "${prefix}/*" "$code" + [ "$code" = "200" ] && failed=1 + ;; + esac + done + + if [ "$failed" -ne 0 ]; then + echo "FAIL — the public/private split is not what docs/storage.md says" >&2 + # The backticks quote a config key for a human reader; they are literal + # prose, so single quotes are the correct quoting here. + # shellcheck disable=SC2016 + echo ' (a prefix marked `public: true` in uploadRules must also be' >&2 + echo " listed in put_public_policy above)" >&2 + return 1 + fi +} + +cmd_selftest() { + cmd_init + local bucket key src dst status + bucket="${BUCKETS%% *}" + key="_selftest/$(date -u +%Y%m%dT%H%M%SZ)-$$.bin" + src="$(tmpfile)" + dst="$(tmpfile)" + + # Binary, not text: catches any encoding/chunking mangling in the path. + head -c 65536 /dev/urandom >"$src" + echo + echo "==> PUT ${bucket}/${key} ($(wc -c <"$src") bytes)" + status="$(s3_request PUT "/${bucket}/${key}" "" "$src" /dev/null)" + [ "$status" = "200" ] || { + echo " PUT failed (HTTP ${status})" >&2 + return 1 + } + echo " HTTP ${status} sha256=$(sha256_hex <"$src")" + + echo "==> GET ${bucket}/${key}" + status="$(s3_request GET "/${bucket}/${key}" "" "" "$dst")" + [ "$status" = "200" ] || { + echo " GET failed (HTTP ${status})" >&2 + return 1 + } + echo " HTTP ${status} sha256=$(sha256_hex <"$dst")" + + echo "==> Compare bytes" + if cmp -s "$src" "$dst"; then + echo " OK $(wc -c <"$dst") bytes identical" + else + echo " MISMATCH — the object came back different" >&2 + return 1 + fi + + echo "==> LIST ${bucket}" + list_bucket "$bucket" | sed 's/^/ /' + + echo "==> DELETE ${bucket}/${key}" + status="$(s3_request DELETE "/${bucket}/${key}" "" "" /dev/null)" + echo " HTTP ${status}" + + echo + check_public_policy "$bucket" + + echo "PASS — ${ENDPOINT} round-trips objects over S3 SigV4." +} + +case "${1:---init}" in +--init | init | "") cmd_init ;; +--seed-media | seed-media) seed_media ;; +--selftest | selftest) cmd_selftest ;; +--status | status) cmd_status ;; +-h | --help) + sed -n '2,20p' "$0" | sed 's/^# \{0,1\}//' + ;; +*) + echo "unknown argument: $1 (try --help)" >&2 + exit 2 + ;; +esac diff --git a/.devcontainer/service-bridge.sh b/.devcontainer/service-bridge.sh new file mode 100644 index 00000000..d44108dd --- /dev/null +++ b/.devcontainer/service-bridge.sh @@ -0,0 +1,52 @@ +#!/usr/bin/env bash +# Point localhost:5432 and localhost:8180 inside the dev container at the +# `postgres` and `keycloak` CONTAINERS (compose profile "services"). +# +# Why: every checked-in config addresses those services on localhost — +# components/backend/data/test/config/config.yaml, the frontend's oidc.issuer, +# just rpc-as, the e2e skill. Bridging keeps all of them working when the +# services move out of the dev container, instead of forking the configs. +# +# docker compose -f .devcontainer/docker-compose.yml --profile services up -d +# docker compose -f .devcontainer/docker-compose.yml exec -u vscode dev \ +# bash /workspaces/hackagon/.devcontainer/service-bridge.sh +# +# This is the mirror image of host-bridge.sh, which republishes loopback ports +# onto the container interface so sidecars can reach IN. +# +# NOTE: devenv's own postgres/keycloak must NOT be running, or they already own +# these ports. `just up` starts them today (the Nix shell has a withPostgres +# flag but no withKeycloak one), so this path currently suits running the +# backend/frontend by hand. +set -euo pipefail + +PG_HOST="${HACKAGON_PG_HOST:-postgres}" +PG_PORT="${HACKAGON_PG_PORT:-5432}" +KC_HOST="${HACKAGON_KC_HOST:-keycloak}" +KC_PORT="${HACKAGON_KC_PORT:-8180}" + +command -v socat >/dev/null || { + echo "error: socat not found (post-create.sh installs it)" >&2 + exit 1 +} + +bridge() { # <local-port> <target-host> <target-port> + local lport="$1" thost="$2" tport="$3" + # Build the pattern in a variable so pkill cannot match its own argv. + local listen="TCP-LISTEN" + pkill -f "socat ${listen}:${lport}," 2>/dev/null || true + + if ! getent hosts "$thost" >/dev/null 2>&1; then + echo "error: '$thost' does not resolve — is the services profile up?" >&2 + exit 1 + fi + + socat "TCP-LISTEN:${lport},bind=127.0.0.1,fork,reuseaddr" \ + "TCP:${thost}:${tport}" & + echo " 127.0.0.1:${lport} -> ${thost}:${tport}" +} + +echo "==> Bridging containerised services onto loopback:" +bridge 5432 "$PG_HOST" "$PG_PORT" +bridge 8180 "$KC_HOST" "$KC_PORT" +echo "Done. Verify: pg_isready -h 127.0.0.1 -p 5432 && curl -fsS http://localhost:8180/realms/hackagon/.well-known/openid-configuration" diff --git a/.gitignore b/.gitignore index 5e32cdab..6f220234 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ +.pnpm-store/ # Direnv Tooling. .direnv # Devenv Tooling. @@ -23,17 +24,59 @@ result # Cursor CLI config (machine-specific permissions) .cursor/cli.json -# Claude local permissions file (machine-specific permissions) +# Claude Code config (machine-specific) +# The e2e tooling is TRACKED. It was ignored while it lived only in working +# copies; the recipe is the closest thing this project has to an executable +# spec, and a spec nobody can review or diff is not one. +# +# Runtime state under it stays ignored — regenerated per run, and .secrets.env +# holds a generated password that must never reach a commit. +.claude/**/node_modules/ +.claude/**/.state/ +.claude/**/.artifacts/ +.claude/**/out/ +.claude/**/vendor/docker-envs/ +.claude/**/.secrets.env +.claude/scheduled_tasks.lock .claude/settings.local.json ## Sensitive Information ====================================================== # All .env files .env .env.yaml +# Helm dev values with passwords +helm-chart/values.dev.yaml ## ============================================================================ +# Helm subchart archives, fetched by `helm dependency build` from Chart.lock. +# Not a secret, just generated — the lock file is what belongs in a commit. +helm-chart/charts/ + # Generated files components/backend/internal/proto components/backend/ent/* components/frontend/src/lib/server/grpc/*.proto components/frontend/src/lib/server/grpc/generated + +# pnpm store created inside containers (bind-mount filesystem boundary) +.pnpm-store/ + +# Stray stderr redirects. dbml-error.log was committed empty and unreferenced +# — nothing in the repo writes it — after someone redirected a validation run +# once. A tool that needs a log should write it under its own out/ directory. +*-error.log + +# Machine-local config overlay, beside each component's config.yaml +# (components/*/data/test/config/). Optional and partial: both loaders read it +# AFTER config.yaml and BEFORE the environment, so absent changes nothing. +# +# Anything that has to point this checkout at a machine-specific hostname +# writes HERE — the cloudflare-tunnel skill's auth-wire.sh above all. It used +# to rewrite the tracked config.yaml files, so while a tunnel was wired the +# working tree differed from HEAD and a `git add -A` committed an issuer that +# dies with the tunnel. That happened. +config.local.yaml + +# Fresh-clone scratch area used to verify the from-clean-clone path. +# Never committed; it holds a second checkout plus its own container state. +.fresh-clone/ diff --git a/CLAUDE.md b/CLAUDE.md index e74b7c7e..238d0f55 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -18,7 +18,7 @@ Before making changes: ``` api/proto/ -├── <domain>/ # hackathon, user, health +├── <domain>/ # hackathon, user, health, vote, site │ ├── <name>_service.proto # service definition (RPCs) │ ├── entities/ # nouns (domain types, enums) │ └── messages/<svc>_svc/ # verbs (request/response payloads) @@ -48,10 +48,12 @@ just up # start keycloak + postgres + backend via process-compos just down # stop everything just refresh # wipe state + regen ent + regen proto + tidy + install deps just seed # populate dev hackathons, users, projects (see cmd/seed/README.md) -just generate-proto # buf generate — wipes codegen dirs first (prevents stale shadowing) -just generate-db-schema # ent codegen + Schema.md +just codegen::proto # buf generate — wipes codegen dirs first (prevents stale shadowing) +just codegen::db-schema # ent codegen + Schema.md just rpc-as <user> <password> <method> [json] # authed grpcurl just rpc-unauth <method> [json] # unauthed grpcurl (health) +just version::show # declared version + what a build stamps in the footer +just version::bump patch|minor|major # edit VERSION, commit, annotated tag ``` Dev users (Keycloak password for all is `aliceandbob`): `hackagon-admin` (global @@ -113,6 +115,28 @@ services: - `Add*`/`Remove*` on a relation (e.g. `AssignUser`, `AddOwner`): return the mutated parent. Precedent: `AddRole` returns the user. +## Platform pages vs hackathon pages + +Two different things, easily confused: + +- `hackathon.PageService` / `Page` — content **inside one event** (news, + webinars, photos, the wrap-up blog). The hackathon edge is `Required()`, and + casbin authorizes it in that hackathon's domain. +- `site.SitePageService` / `SitePage` — the **platform's own** pages (about, + privacy, terms), addressed by a unique slug. They belong to no event, so there + is no hackathon domain to scope them to: published pages are readable by + everyone (the footer links reach them before login) and every mutation + requires the global Admin role via `enforcer.RequireGlobalAdmin`. Drafts + (`visible=false`) return `NotFound` to non-admins rather than + `PermissionDenied`, so their existence stays private. + +Frontend: `(public)/[slug=sitepage]` renders them — the `sitepage` param matcher +(`src/params/sitepage.ts`) lists which slugs resolve, and +`PUBLIC_ROUTE_PATTERNS` in `hooks.server.ts` must allow the same set or the auth +guard redirects visitors to login. Admin CRUD lives at `/manage/pages`. Content +is markdown and is parsed + sanitized before rendering — never feed it to +`{@html}` directly. + ## RBAC (casbin) See `components/backend/internal/middleware/rbac.go` and `casbin_model.conf`. @@ -260,13 +284,19 @@ components** — `$lib/server/` is server-only; use raw numbers with reference for a list-row component. Keep `badge`/`badgePreset` as generic string props so the row works for any badge text, not just status labels. +**`VERSION` at the repo root is the only declared version.** Bump it through +`just version::bump`, never by hand — the recipe is what also commits it, tags +it, and keeps `components/frontend/package.json` in step. The frontend reads it +at build time (`vite.config.ts` → `$lib/version` → the footer), so a version +edited without a rebuild will not show up. + ## Don't - Don't edit generated code: `components/backend/internal/proto/**`, `components/backend/ent/**`, `components/frontend/src/lib/server/grpc/generated/**`, `api/proto/API.md`. - Regenerate via `just generate-proto` or `just generate-db-schema`. -- Don't run `just generate-proto` outside the Nix shell — `buf` isn't in PATH. + Regenerate via `just codegen::proto` or `just codegen::db-schema`. +- Don't run `just codegen::proto` outside the Nix shell — `buf` isn't in PATH. Either run it yourself, or stage proto changes and ask the user to regen. - Don't skip the casbin `Enforce` check on mutation handlers — follow the user_service.go pattern. diff --git a/README.md b/README.md index 573da0d5..e0a8e632 100644 --- a/README.md +++ b/README.md @@ -14,30 +14,88 @@ A hackathon management platform built with SvelteKit, Go/gRPC, and Keycloak. ### Prerequisites -- Nix (for development environment) +Exactly two things are required: **Nix** (with flakes) and **git**. Everything +else — Go, pnpm, buf, process-compose, Keycloak, Postgres, psql, grpcurl — comes +from the flake in `tools/nix`, and `just` itself is inside that shell. + +Docker is **not** required to develop or run the app. It is only needed for the +optional pieces: the devcontainer (a convenience for Windows/macOS hosts, where +Nix cannot run natively), the `rustfs` object store, the Cloudflare tunnel and +the OpenReplay rig. See [.devcontainer/README.md](.devcontainer/README.md) for +what each one buys and what breaks without it. ### Development Setup ```bash -# Enter Nix development shell +# One-time: generated code is not committed, so a fresh clone must produce it. +# `just develop <cmd>` runs one command inside the Nix dev shell. +just develop bash -c "cd components/frontend && pnpm install --frozen-lockfile" +just develop just codegen::proto # -> components/backend/internal/proto +just develop just codegen::db-schema # -> components/backend/ent +just develop bash -c "cd components/backend && GOWORK=off go mod tidy" + +# One-time: the frontend refuses to serve without its dev secrets (gitignored). +printf 'oidc:\n clientSecret: "%s"\n authSecret: "%s"\n' \ + "$(openssl rand -base64 32)" "$(openssl rand -base64 32)" \ + > components/frontend/data/test/config/secrets.yaml + +# Enter the Nix development shell (alias of `just develop`) just dev -# Start all services (Keycloak) -just up +# Start Keycloak + Postgres + backend + frontend (process-compose) +just deploy::up # Stop all services just down -# Attach to process-compose TUI -just attach +# Attach to the process-compose TUI +just deploy::attach # Seed the database with sample data (after services are up) -just seed +just db::seed # Show a summary of current DB state -just db-summary +just db::summary +``` + +`just start` bundles dependency syncing with `just deploy::up` and then attaches +to the TUI. `just --list` shows every recipe; module recipes are addressed as +`just <module>::<recipe>` (`deploy`, `db`, `rpc`, `codegen`, `clean`, `check`, +`ci`, `nix`). There are no bare `just up` / `just seed` / `just attach` recipes. + +Full walkthrough, including the devcontainer path: +[docs/getting-started.md](docs/getting-started.md). + +## Versioning + +`VERSION` at the repo root holds the product version, and it is the only place +that version is declared. Releases are semver, tagged `v<x.y.z>`. + +```bash +just version::show # declared version + what a build would stamp +just version::bump patch # also: minor, major +just version::tag # tag HEAD with the version already in VERSION ``` +`bump` refuses on a dirty tree, edits `VERSION`, keeps +`components/frontend/package.json` in step, commits as `chore(release): vX.Y.Z` +and creates the annotated tag. Neither `bump` nor `tag` pushes — they print the +`git push` you need. + +The frontend stamps the version in at build time (`vite.config.ts` reads +`VERSION`, exposes it as `$lib/version`) and shows it in the footer: + +| Build | Footer shows | +| ---------------------------------- | ---------------------- | +| clean checkout on the matching tag | `v0.0.1` | +| any other commit | `v0.0.1+4b87857` | +| uncommitted changes | `v0.0.1+4b87857-dirty` | + +Only a release build claims to be the release, so a version quoted in a bug +report identifies the code that produced it. Reading `VERSION` from a file +rather than from `git describe` means a shallow clone or an unpacked tarball +still builds with a truthful version. + ## Component READMEs - [Frontend](components/frontend/README.md) diff --git a/VERSION b/VERSION new file mode 100644 index 00000000..8acdd82b --- /dev/null +++ b/VERSION @@ -0,0 +1 @@ +0.0.1 diff --git a/api/proto/API.md b/api/proto/API.md index d12380ea..e2f764eb 100644 --- a/api/proto/API.md +++ b/api/proto/API.md @@ -3,6 +3,83 @@ ## Table of Contents +- [hackathon/messages/config_svc/get_email_templates_request.proto](#hackathon_messages_config_svc_get_email_templates_request-proto) + - [GetEmailTemplatesRequest](#hackathon-messages-config_svc-GetEmailTemplatesRequest) + +- [hackathon/messages/config_svc/get_email_templates_response.proto](#hackathon_messages_config_svc_get_email_templates_response-proto) + - [GetEmailTemplatesResponse](#hackathon-messages-config_svc-GetEmailTemplatesResponse) + - [GetEmailTemplatesResponse.TemplatesEntry](#hackathon-messages-config_svc-GetEmailTemplatesResponse-TemplatesEntry) + +- [hackathon/messages/config_svc/get_windows_request.proto](#hackathon_messages_config_svc_get_windows_request-proto) + - [GetWindowsRequest](#hackathon-messages-config_svc-GetWindowsRequest) + +- [hackathon/entities/hackathon_window_set.proto](#hackathon_entities_hackathon_window_set-proto) + - [HackathonWindows](#hackathon-entities-HackathonWindows) + +- [hackathon/messages/config_svc/get_windows_response.proto](#hackathon_messages_config_svc_get_windows_response-proto) + - [GetWindowsResponse](#hackathon-messages-config_svc-GetWindowsResponse) + +- [hackathon/messages/config_svc/override_window_request.proto](#hackathon_messages_config_svc_override_window_request-proto) + - [OverrideWindowRequest](#hackathon-messages-config_svc-OverrideWindowRequest) + +- [hackathon/messages/config_svc/override_window_response.proto](#hackathon_messages_config_svc_override_window_response-proto) + - [OverrideWindowResponse](#hackathon-messages-config_svc-OverrideWindowResponse) + +- [hackathon/messages/config_svc/set_branding_request.proto](#hackathon_messages_config_svc_set_branding_request-proto) + - [SetBrandingRequest](#hackathon-messages-config_svc-SetBrandingRequest) + +- [hackathon/messages/config_svc/set_branding_response.proto](#hackathon_messages_config_svc_set_branding_response-proto) + - [SetBrandingResponse](#hackathon-messages-config_svc-SetBrandingResponse) + +- [hackathon/messages/config_svc/set_email_templates_request.proto](#hackathon_messages_config_svc_set_email_templates_request-proto) + - [SetEmailTemplatesRequest](#hackathon-messages-config_svc-SetEmailTemplatesRequest) + - [SetEmailTemplatesRequest.TemplatesEntry](#hackathon-messages-config_svc-SetEmailTemplatesRequest-TemplatesEntry) + +- [hackathon/messages/config_svc/set_email_templates_response.proto](#hackathon_messages_config_svc_set_email_templates_response-proto) + - [SetEmailTemplatesResponse](#hackathon-messages-config_svc-SetEmailTemplatesResponse) + +- [hackathon/entities/form_schema.proto](#hackathon_entities_form_schema-proto) + - [ConsentField](#hackathon-entities-ConsentField) + - [FormField](#hackathon-entities-FormField) + - [FormSchema](#hackathon-entities-FormSchema) + +- [hackathon/messages/config_svc/set_registration_form_request.proto](#hackathon_messages_config_svc_set_registration_form_request-proto) + - [SetRegistrationFormRequest](#hackathon-messages-config_svc-SetRegistrationFormRequest) + +- [hackathon/messages/config_svc/set_registration_form_response.proto](#hackathon_messages_config_svc_set_registration_form_response-proto) + - [SetRegistrationFormResponse](#hackathon-messages-config_svc-SetRegistrationFormResponse) + +- [hackathon/messages/config_svc/set_submission_form_request.proto](#hackathon_messages_config_svc_set_submission_form_request-proto) + - [SetSubmissionFormRequest](#hackathon-messages-config_svc-SetSubmissionFormRequest) + +- [hackathon/messages/config_svc/set_submission_form_response.proto](#hackathon_messages_config_svc_set_submission_form_response-proto) + - [SetSubmissionFormResponse](#hackathon-messages-config_svc-SetSubmissionFormResponse) + +- [hackathon/messages/config_svc/set_voting_policy_request.proto](#hackathon_messages_config_svc_set_voting_policy_request-proto) + - [ScaleRange](#hackathon-messages-config_svc-ScaleRange) + - [SetVotingPolicyRequest](#hackathon-messages-config_svc-SetVotingPolicyRequest) + +- [hackathon/messages/config_svc/set_voting_policy_response.proto](#hackathon_messages_config_svc_set_voting_policy_response-proto) + - [SetVotingPolicyResponse](#hackathon-messages-config_svc-SetVotingPolicyResponse) + +- [hackathon/messages/config_svc/set_windows_request.proto](#hackathon_messages_config_svc_set_windows_request-proto) + - [SetWindowsRequest](#hackathon-messages-config_svc-SetWindowsRequest) + +- [hackathon/messages/config_svc/set_windows_response.proto](#hackathon_messages_config_svc_set_windows_response-proto) + - [SetWindowsResponse](#hackathon-messages-config_svc-SetWindowsResponse) + +- [hackathon/config_service.proto](#hackathon_config_service-proto) + - [ConfigService](#hackathon-ConfigService) + +- [hackathon/entities/capability.proto](#hackathon_entities_capability-proto) + - [CapabilityStatus](#hackathon-entities-CapabilityStatus) + + - [Capability](#hackathon-entities-Capability) + - [CapabilityState](#hackathon-entities-CapabilityState) + +- [hackathon/entities/hackathon_branding.proto](#hackathon_entities_hackathon_branding-proto) + - [HackathonBranding](#hackathon-entities-HackathonBranding) + - [hackathon/entities/hackathon_role.proto](#hackathon_entities_hackathon_role-proto) - [HackathonRole](#hackathon-entities-HackathonRole) @@ -15,9 +92,20 @@ - [hackathon/entities/hackathon_member.proto](#hackathon_entities_hackathon_member-proto) - [HackathonMember](#hackathon-entities-HackathonMember) +- [hackathon/entities/hackathon_settings.proto](#hackathon_entities_hackathon_settings-proto) + - [HackathonSettings](#hackathon-entities-HackathonSettings) + +- [hackathon/entities/hackathon_state.proto](#hackathon_entities_hackathon_state-proto) + - [CapabilityToggle](#hackathon-entities-CapabilityToggle) + - [HackathonState](#hackathon-entities-HackathonState) + - [hackathon/entities/hackathon_status.proto](#hackathon_entities_hackathon_status-proto) - [HackathonStatus](#hackathon-entities-HackathonStatus) +- [hackathon/entities/hackathon_voting_policy.proto](#hackathon_entities_hackathon_voting_policy-proto) + - [HackathonVotingPolicy](#hackathon-entities-HackathonVotingPolicy) + - [ScaleRange](#hackathon-entities-ScaleRange) + - [hackathon/entities/page.proto](#hackathon_entities_page-proto) - [Page](#hackathon-entities-Page) @@ -38,6 +126,14 @@ - [hackathon/entities/hackathon.proto](#hackathon_entities_hackathon-proto) - [Hackathon](#hackathon-entities-Hackathon) + - [Hackathon.EmailTemplatesEntry](#hackathon-entities-Hackathon-EmailTemplatesEntry) + +- [hackathon/entities/hackathon_invite.proto](#hackathon_entities_hackathon_invite-proto) + - [HackathonInvite](#hackathon-entities-HackathonInvite) + +- [hackathon/entities/prize.proto](#hackathon_entities_prize-proto) + - [Award](#hackathon-entities-Award) + - [Prize](#hackathon-entities-Prize) - [hackathon/entities/project_preference.proto](#hackathon_entities_project_preference-proto) - [ProjectWithPreferences](#hackathon-entities-ProjectWithPreferences) @@ -57,24 +153,61 @@ - [hackathon/messages/hackathon_svc/add_owner_response.proto](#hackathon_messages_hackathon_svc_add_owner_response-proto) - [AddOwnerResponse](#hackathon-messages-hackathon_svc-AddOwnerResponse) +- [hackathon/messages/hackathon_svc/advance_phase_request.proto](#hackathon_messages_hackathon_svc_advance_phase_request-proto) + - [AdvancePhaseRequest](#hackathon-messages-hackathon_svc-AdvancePhaseRequest) + +- [hackathon/messages/hackathon_svc/advance_phase_response.proto](#hackathon_messages_hackathon_svc_advance_phase_response-proto) + - [AdvancePhaseResponse](#hackathon-messages-hackathon_svc-AdvancePhaseResponse) + - [hackathon/messages/hackathon_svc/approve_participant_request.proto](#hackathon_messages_hackathon_svc_approve_participant_request-proto) - [ApproveParticipantRequest](#hackathon-messages-hackathon_svc-ApproveParticipantRequest) - [hackathon/messages/hackathon_svc/approve_participant_response.proto](#hackathon_messages_hackathon_svc_approve_participant_response-proto) - [ApproveParticipantResponse](#hackathon-messages-hackathon_svc-ApproveParticipantResponse) +- [hackathon/messages/hackathon_svc/create_invite_request.proto](#hackathon_messages_hackathon_svc_create_invite_request-proto) + - [CreateInviteRequest](#hackathon-messages-hackathon_svc-CreateInviteRequest) + +- [hackathon/messages/hackathon_svc/create_invite_response.proto](#hackathon_messages_hackathon_svc_create_invite_response-proto) + - [CreateInviteResponse](#hackathon-messages-hackathon_svc-CreateInviteResponse) + - [hackathon/messages/hackathon_svc/create_request.proto](#hackathon_messages_hackathon_svc_create_request-proto) - [CreateRequest](#hackathon-messages-hackathon_svc-CreateRequest) - [hackathon/messages/hackathon_svc/create_response.proto](#hackathon_messages_hackathon_svc_create_response-proto) - [CreateResponse](#hackathon-messages-hackathon_svc-CreateResponse) +- [hackathon/messages/hackathon_svc/delete_request.proto](#hackathon_messages_hackathon_svc_delete_request-proto) + - [DeleteRequest](#hackathon-messages-hackathon_svc-DeleteRequest) + +- [hackathon/messages/hackathon_svc/delete_response.proto](#hackathon_messages_hackathon_svc_delete_response-proto) + - [DeleteResponse](#hackathon-messages-hackathon_svc-DeleteResponse) + +- [hackathon/messages/hackathon_svc/edit_capability_request.proto](#hackathon_messages_hackathon_svc_edit_capability_request-proto) + - [EditCapabilityRequest](#hackathon-messages-hackathon_svc-EditCapabilityRequest) + +- [hackathon/messages/hackathon_svc/edit_capability_response.proto](#hackathon_messages_hackathon_svc_edit_capability_response-proto) + - [EditCapabilityResponse](#hackathon-messages-hackathon_svc-EditCapabilityResponse) + - [hackathon/messages/hackathon_svc/edit_request.proto](#hackathon_messages_hackathon_svc_edit_request-proto) - [EditRequest](#hackathon-messages-hackathon_svc-EditRequest) - [hackathon/messages/hackathon_svc/edit_response.proto](#hackathon_messages_hackathon_svc_edit_response-proto) - [EditResponse](#hackathon-messages-hackathon_svc-EditResponse) +- [hackathon/messages/hackathon_svc/edit_settings_request.proto](#hackathon_messages_hackathon_svc_edit_settings_request-proto) + - [EditSettingsRequest](#hackathon-messages-hackathon_svc-EditSettingsRequest) + +- [hackathon/messages/hackathon_svc/edit_settings_response.proto](#hackathon_messages_hackathon_svc_edit_settings_response-proto) + - [EditSettingsResponse](#hackathon-messages-hackathon_svc-EditSettingsResponse) + +- [hackathon/messages/hackathon_svc/get_registration_response_request.proto](#hackathon_messages_hackathon_svc_get_registration_response_request-proto) + - [GetRegistrationResponseRequest](#hackathon-messages-hackathon_svc-GetRegistrationResponseRequest) + +- [hackathon/messages/hackathon_svc/get_registration_response_response.proto](#hackathon_messages_hackathon_svc_get_registration_response_response-proto) + - [GetRegistrationResponseResponse](#hackathon-messages-hackathon_svc-GetRegistrationResponseResponse) + - [GetRegistrationResponseResponse.ConsentsEntry](#hackathon-messages-hackathon_svc-GetRegistrationResponseResponse-ConsentsEntry) + - [hackathon/messages/hackathon_svc/get_request.proto](#hackathon_messages_hackathon_svc_get_request-proto) - [GetRequest](#hackathon-messages-hackathon_svc-GetRequest) @@ -87,12 +220,32 @@ - [hackathon/messages/hackathon_svc/join_response.proto](#hackathon_messages_hackathon_svc_join_response-proto) - [JoinResponse](#hackathon-messages-hackathon_svc-JoinResponse) +- [hackathon/messages/hackathon_svc/list_invites_request.proto](#hackathon_messages_hackathon_svc_list_invites_request-proto) + - [ListInvitesRequest](#hackathon-messages-hackathon_svc-ListInvitesRequest) + +- [hackathon/messages/hackathon_svc/list_invites_response.proto](#hackathon_messages_hackathon_svc_list_invites_response-proto) + - [ListInvitesResponse](#hackathon-messages-hackathon_svc-ListInvitesResponse) + +- [hackathon/messages/hackathon_svc/list_registration_responses_request.proto](#hackathon_messages_hackathon_svc_list_registration_responses_request-proto) + - [ListRegistrationResponsesRequest](#hackathon-messages-hackathon_svc-ListRegistrationResponsesRequest) + +- [hackathon/messages/hackathon_svc/list_registration_responses_response.proto](#hackathon_messages_hackathon_svc_list_registration_responses_response-proto) + - [ListRegistrationResponsesResponse](#hackathon-messages-hackathon_svc-ListRegistrationResponsesResponse) + - [RegistrationResponseEntry](#hackathon-messages-hackathon_svc-RegistrationResponseEntry) + - [RegistrationResponseEntry.ConsentsEntry](#hackathon-messages-hackathon_svc-RegistrationResponseEntry-ConsentsEntry) + - [hackathon/messages/hackathon_svc/list_request.proto](#hackathon_messages_hackathon_svc_list_request-proto) - [ListRequest](#hackathon-messages-hackathon_svc-ListRequest) - [hackathon/messages/hackathon_svc/list_response.proto](#hackathon_messages_hackathon_svc_list_response-proto) - [ListResponse](#hackathon-messages-hackathon_svc-ListResponse) +- [hackathon/messages/hackathon_svc/preview_invite_request.proto](#hackathon_messages_hackathon_svc_preview_invite_request-proto) + - [PreviewInviteRequest](#hackathon-messages-hackathon_svc-PreviewInviteRequest) + +- [hackathon/messages/hackathon_svc/preview_invite_response.proto](#hackathon_messages_hackathon_svc_preview_invite_response-proto) + - [PreviewInviteResponse](#hackathon-messages-hackathon_svc-PreviewInviteResponse) + - [hackathon/messages/hackathon_svc/remove_owner_request.proto](#hackathon_messages_hackathon_svc_remove_owner_request-proto) - [RemoveOwnerRequest](#hackathon-messages-hackathon_svc-RemoveOwnerRequest) @@ -105,6 +258,31 @@ - [hackathon/messages/hackathon_svc/remove_participant_response.proto](#hackathon_messages_hackathon_svc_remove_participant_response-proto) - [RemoveParticipantResponse](#hackathon-messages-hackathon_svc-RemoveParticipantResponse) +- [hackathon/messages/hackathon_svc/revoke_invite_request.proto](#hackathon_messages_hackathon_svc_revoke_invite_request-proto) + - [RevokeInviteRequest](#hackathon-messages-hackathon_svc-RevokeInviteRequest) + +- [hackathon/messages/hackathon_svc/revoke_invite_response.proto](#hackathon_messages_hackathon_svc_revoke_invite_response-proto) + - [RevokeInviteResponse](#hackathon-messages-hackathon_svc-RevokeInviteResponse) + +- [hackathon/messages/hackathon_svc/set_capabilities_request.proto](#hackathon_messages_hackathon_svc_set_capabilities_request-proto) + - [SetCapabilitiesRequest](#hackathon-messages-hackathon_svc-SetCapabilitiesRequest) + +- [hackathon/messages/hackathon_svc/set_capabilities_response.proto](#hackathon_messages_hackathon_svc_set_capabilities_response-proto) + - [SetCapabilitiesResponse](#hackathon-messages-hackathon_svc-SetCapabilitiesResponse) + +- [hackathon/messages/hackathon_svc/set_current_phase_request.proto](#hackathon_messages_hackathon_svc_set_current_phase_request-proto) + - [SetCurrentPhaseRequest](#hackathon-messages-hackathon_svc-SetCurrentPhaseRequest) + +- [hackathon/messages/hackathon_svc/set_current_phase_response.proto](#hackathon_messages_hackathon_svc_set_current_phase_response-proto) + - [SetCurrentPhaseResponse](#hackathon-messages-hackathon_svc-SetCurrentPhaseResponse) + +- [hackathon/messages/hackathon_svc/submit_registration_form_request.proto](#hackathon_messages_hackathon_svc_submit_registration_form_request-proto) + - [SubmitRegistrationFormRequest](#hackathon-messages-hackathon_svc-SubmitRegistrationFormRequest) + - [SubmitRegistrationFormRequest.ConsentsEntry](#hackathon-messages-hackathon_svc-SubmitRegistrationFormRequest-ConsentsEntry) + +- [hackathon/messages/hackathon_svc/submit_registration_form_response.proto](#hackathon_messages_hackathon_svc_submit_registration_form_response-proto) + - [SubmitRegistrationFormResponse](#hackathon-messages-hackathon_svc-SubmitRegistrationFormResponse) + - [hackathon/hackathon_service.proto](#hackathon_hackathon_service-proto) - [HackathonService](#hackathon-HackathonService) @@ -186,6 +364,30 @@ - [hackathon/messages/phase_svc/list_response.proto](#hackathon_messages_phase_svc_list_response-proto) - [ListResponse](#hackathon-messages-phase_svc-ListResponse) +- [hackathon/messages/prize_svc/edit_request.proto](#hackathon_messages_prize_svc_edit_request-proto) + - [EditRequest](#hackathon-messages-prize_svc-EditRequest) + +- [hackathon/messages/prize_svc/edit_response.proto](#hackathon_messages_prize_svc_edit_response-proto) + - [EditResponse](#hackathon-messages-prize_svc-EditResponse) + +- [hackathon/messages/prize_svc/finalize_request.proto](#hackathon_messages_prize_svc_finalize_request-proto) + - [FinalizeRequest](#hackathon-messages-prize_svc-FinalizeRequest) + +- [hackathon/messages/prize_svc/finalize_response.proto](#hackathon_messages_prize_svc_finalize_response-proto) + - [FinalizeResponse](#hackathon-messages-prize_svc-FinalizeResponse) + +- [hackathon/messages/prize_svc/get_request.proto](#hackathon_messages_prize_svc_get_request-proto) + - [GetRequest](#hackathon-messages-prize_svc-GetRequest) + +- [hackathon/messages/prize_svc/get_response.proto](#hackathon_messages_prize_svc_get_response-proto) + - [GetResponse](#hackathon-messages-prize_svc-GetResponse) + +- [hackathon/messages/prize_svc/set_request.proto](#hackathon_messages_prize_svc_set_request-proto) + - [SetRequest](#hackathon-messages-prize_svc-SetRequest) + +- [hackathon/messages/prize_svc/set_response.proto](#hackathon_messages_prize_svc_set_response-proto) + - [SetResponse](#hackathon-messages-prize_svc-SetResponse) + - [hackathon/messages/project_svc/approve_request.proto](#hackathon_messages_project_svc_approve_request-proto) - [ApproveRequest](#hackathon-messages-project_svc-ApproveRequest) @@ -216,6 +418,12 @@ - [hackathon/messages/project_svc/export_preferences_response.proto](#hackathon_messages_project_svc_export_preferences_response-proto) - [ExportPreferencesResponse](#hackathon-messages-project_svc-ExportPreferencesResponse) +- [hackathon/messages/project_svc/get_preference_request.proto](#hackathon_messages_project_svc_get_preference_request-proto) + - [GetPreferenceRequest](#hackathon-messages-project_svc-GetPreferenceRequest) + +- [hackathon/messages/project_svc/get_preference_response.proto](#hackathon_messages_project_svc_get_preference_response-proto) + - [GetPreferenceResponse](#hackathon-messages-project_svc-GetPreferenceResponse) + - [hackathon/messages/project_svc/get_request.proto](#hackathon_messages_project_svc_get_request-proto) - [GetRequest](#hackathon-messages-project_svc-GetRequest) @@ -234,6 +442,12 @@ - [hackathon/messages/project_svc/propose_response.proto](#hackathon_messages_project_svc_propose_response-proto) - [ProposeResponse](#hackathon-messages-project_svc-ProposeResponse) +- [hackathon/messages/project_svc/remove_preference_request.proto](#hackathon_messages_project_svc_remove_preference_request-proto) + - [RemovePreferenceRequest](#hackathon-messages-project_svc-RemovePreferenceRequest) + +- [hackathon/messages/project_svc/remove_preference_response.proto](#hackathon_messages_project_svc_remove_preference_response-proto) + - [RemovePreferenceResponse](#hackathon-messages-project_svc-RemovePreferenceResponse) + - [hackathon/messages/project_svc/set_preference_request.proto](#hackathon_messages_project_svc_set_preference_request-proto) - [SetPreferenceRequest](#hackathon-messages-project_svc-SetPreferenceRequest) @@ -254,6 +468,7 @@ - [hackathon/messages/team_svc/create_submission_request.proto](#hackathon_messages_team_svc_create_submission_request-proto) - [CreateSubmissionRequest](#hackathon-messages-team_svc-CreateSubmissionRequest) + - [CreateSubmissionRequest.FormEntry](#hackathon-messages-team_svc-CreateSubmissionRequest-FormEntry) - [hackathon/messages/team_svc/create_submission_response.proto](#hackathon_messages_team_svc_create_submission_response-proto) - [CreateSubmissionResponse](#hackathon-messages-team_svc-CreateSubmissionResponse) @@ -270,6 +485,13 @@ - [hackathon/messages/team_svc/edit_response.proto](#hackathon_messages_team_svc_edit_response-proto) - [EditResponse](#hackathon-messages-team_svc-EditResponse) +- [hackathon/messages/team_svc/edit_submission_request.proto](#hackathon_messages_team_svc_edit_submission_request-proto) + - [EditSubmissionRequest](#hackathon-messages-team_svc-EditSubmissionRequest) + - [EditSubmissionRequest.FormEntry](#hackathon-messages-team_svc-EditSubmissionRequest-FormEntry) + +- [hackathon/messages/team_svc/edit_submission_response.proto](#hackathon_messages_team_svc_edit_submission_response-proto) + - [EditSubmissionResponse](#hackathon-messages-team_svc-EditSubmissionResponse) + - [hackathon/messages/team_svc/finalize_submission_request.proto](#hackathon_messages_team_svc_finalize_submission_request-proto) - [FinalizeSubmissionRequest](#hackathon-messages-team_svc-FinalizeSubmissionRequest) @@ -342,6 +564,9 @@ - [hackathon/phase_service.proto](#hackathon_phase_service-proto) - [PhaseService](#hackathon-PhaseService) +- [hackathon/prize_service.proto](#hackathon_prize_service-proto) + - [PrizeService](#hackathon-PrizeService) + - [hackathon/project_service.proto](#hackathon_project_service-proto) - [ProjectService](#hackathon-ProjectService) @@ -360,12 +585,90 @@ - [health/health_service.proto](#health_health_service-proto) - [HealthService](#health-HealthService) +- [site/entities/site_page.proto](#site_entities_site_page-proto) + - [SitePage](#site-entities-SitePage) + +- [site/messages/site_page_svc/create_request.proto](#site_messages_site_page_svc_create_request-proto) + - [CreateRequest](#site-messages-site_page_svc-CreateRequest) + +- [site/messages/site_page_svc/create_response.proto](#site_messages_site_page_svc_create_response-proto) + - [CreateResponse](#site-messages-site_page_svc-CreateResponse) + +- [site/messages/site_page_svc/delete_request.proto](#site_messages_site_page_svc_delete_request-proto) + - [DeleteRequest](#site-messages-site_page_svc-DeleteRequest) + +- [site/messages/site_page_svc/delete_response.proto](#site_messages_site_page_svc_delete_response-proto) + - [DeleteResponse](#site-messages-site_page_svc-DeleteResponse) + +- [site/messages/site_page_svc/edit_request.proto](#site_messages_site_page_svc_edit_request-proto) + - [EditRequest](#site-messages-site_page_svc-EditRequest) + +- [site/messages/site_page_svc/edit_response.proto](#site_messages_site_page_svc_edit_response-proto) + - [EditResponse](#site-messages-site_page_svc-EditResponse) + +- [site/messages/site_page_svc/get_request.proto](#site_messages_site_page_svc_get_request-proto) + - [GetRequest](#site-messages-site_page_svc-GetRequest) + +- [site/messages/site_page_svc/get_response.proto](#site_messages_site_page_svc_get_response-proto) + - [GetResponse](#site-messages-site_page_svc-GetResponse) + +- [site/messages/site_page_svc/list_request.proto](#site_messages_site_page_svc_list_request-proto) + - [ListRequest](#site-messages-site_page_svc-ListRequest) + +- [site/messages/site_page_svc/list_response.proto](#site_messages_site_page_svc_list_response-proto) + - [ListResponse](#site-messages-site_page_svc-ListResponse) + +- [site/site_page_service.proto](#site_site_page_service-proto) + - [SitePageService](#site-SitePageService) + +- [storage/entities/object_scope.proto](#storage_entities_object_scope-proto) + - [ObjectScope](#storage-entities-ObjectScope) + +- [storage/entities/stored_object.proto](#storage_entities_stored_object-proto) + - [StoredObject](#storage-entities-StoredObject) + +- [storage/entities/upload_kind.proto](#storage_entities_upload_kind-proto) + - [UploadKind](#storage-entities-UploadKind) + +- [storage/messages/storage_svc/create_download_url_request.proto](#storage_messages_storage_svc_create_download_url_request-proto) + - [CreateDownloadUrlRequest](#storage-messages-storage_svc-CreateDownloadUrlRequest) + +- [storage/messages/storage_svc/create_download_url_response.proto](#storage_messages_storage_svc_create_download_url_response-proto) + - [CreateDownloadUrlResponse](#storage-messages-storage_svc-CreateDownloadUrlResponse) + +- [storage/messages/storage_svc/create_upload_url_request.proto](#storage_messages_storage_svc_create_upload_url_request-proto) + - [CreateUploadUrlRequest](#storage-messages-storage_svc-CreateUploadUrlRequest) + +- [storage/messages/storage_svc/create_upload_url_response.proto](#storage_messages_storage_svc_create_upload_url_response-proto) + - [CreateUploadUrlResponse](#storage-messages-storage_svc-CreateUploadUrlResponse) + +- [storage/messages/storage_svc/list_objects_request.proto](#storage_messages_storage_svc_list_objects_request-proto) + - [ListObjectsRequest](#storage-messages-storage_svc-ListObjectsRequest) + +- [storage/messages/storage_svc/list_objects_response.proto](#storage_messages_storage_svc_list_objects_response-proto) + - [ListObjectsResponse](#storage-messages-storage_svc-ListObjectsResponse) + +- [storage/storage_service.proto](#storage_storage_service-proto) + - [StorageService](#storage-StorageService) + - [user/messages/user_svc/add_role_request.proto](#user_messages_user_svc_add_role_request-proto) - [AddRoleRequest](#user-messages-user_svc-AddRoleRequest) - [user/messages/user_svc/add_role_response.proto](#user_messages_user_svc_add_role_response-proto) - [AddRoleResponse](#user-messages-user_svc-AddRoleResponse) +- [user/messages/user_svc/delete_account_request.proto](#user_messages_user_svc_delete_account_request-proto) + - [DeleteAccountRequest](#user-messages-user_svc-DeleteAccountRequest) + +- [user/messages/user_svc/delete_account_response.proto](#user_messages_user_svc_delete_account_response-proto) + - [DeleteAccountResponse](#user-messages-user_svc-DeleteAccountResponse) + +- [user/messages/user_svc/edit_profile_request.proto](#user_messages_user_svc_edit_profile_request-proto) + - [EditProfileRequest](#user-messages-user_svc-EditProfileRequest) + +- [user/messages/user_svc/edit_profile_response.proto](#user_messages_user_svc_edit_profile_response-proto) + - [EditProfileResponse](#user-messages-user_svc-EditProfileResponse) + - [user/messages/user_svc/get_request.proto](#user_messages_user_svc_get_request-proto) - [GetRequest](#user-messages-user_svc-GetRequest) @@ -399,93 +702,197 @@ - [user/user_service.proto](#user_user_service-proto) - [UserService](#user-UserService) +- [vote/entities/vote.proto](#vote_entities_vote-proto) + - [PointsVote](#vote-entities-PointsVote) + - [RankedVote](#vote-entities-RankedVote) + - [SingleChoiceVote](#vote-entities-SingleChoiceVote) + - [Vote](#vote-entities-Vote) + +- [vote/entities/voter_type.proto](#vote_entities_voter_type-proto) + - [VoterType](#vote-entities-VoterType) + +- [vote/entities/voting_method.proto](#vote_entities_voting_method-proto) + - [VotingMethod](#vote-entities-VotingMethod) + +- [vote/entities/vote_category.proto](#vote_entities_vote_category-proto) + - [VoteCategory](#vote-entities-VoteCategory) + +- [vote/entities/vote_result.proto](#vote_entities_vote_result-proto) + - [VoteResult](#vote-entities-VoteResult) + +- [vote/messages/vote_svc/create_category_request.proto](#vote_messages_vote_svc_create_category_request-proto) + - [CreateVoteCategoryRequest](#vote-messages-vote_svc-CreateVoteCategoryRequest) + +- [vote/messages/vote_svc/create_category_response.proto](#vote_messages_vote_svc_create_category_response-proto) + - [CreateVoteCategoryResponse](#vote-messages-vote_svc-CreateVoteCategoryResponse) + +- [vote/messages/vote_svc/create_result_request.proto](#vote_messages_vote_svc_create_result_request-proto) + - [CreateVoteResultRequest](#vote-messages-vote_svc-CreateVoteResultRequest) + +- [vote/messages/vote_svc/create_result_response.proto](#vote_messages_vote_svc_create_result_response-proto) + - [CreateVoteResultResponse](#vote-messages-vote_svc-CreateVoteResultResponse) + +- [vote/messages/vote_svc/delete_category_request.proto](#vote_messages_vote_svc_delete_category_request-proto) + - [DeleteVoteCategoryRequest](#vote-messages-vote_svc-DeleteVoteCategoryRequest) + +- [vote/messages/vote_svc/delete_category_response.proto](#vote_messages_vote_svc_delete_category_response-proto) + - [DeleteVoteCategoryResponse](#vote-messages-vote_svc-DeleteVoteCategoryResponse) + +- [vote/messages/vote_svc/delete_result_request.proto](#vote_messages_vote_svc_delete_result_request-proto) + - [DeleteVoteResultRequest](#vote-messages-vote_svc-DeleteVoteResultRequest) + +- [vote/messages/vote_svc/delete_result_response.proto](#vote_messages_vote_svc_delete_result_response-proto) + - [DeleteVoteResultResponse](#vote-messages-vote_svc-DeleteVoteResultResponse) + +- [vote/messages/vote_svc/edit_category_request.proto](#vote_messages_vote_svc_edit_category_request-proto) + - [EditVoteCategoryRequest](#vote-messages-vote_svc-EditVoteCategoryRequest) + +- [vote/messages/vote_svc/edit_category_response.proto](#vote_messages_vote_svc_edit_category_response-proto) + - [EditVoteCategoryResponse](#vote-messages-vote_svc-EditVoteCategoryResponse) + +- [vote/messages/vote_svc/edit_result_request.proto](#vote_messages_vote_svc_edit_result_request-proto) + - [EditVoteResultRequest](#vote-messages-vote_svc-EditVoteResultRequest) + +- [vote/messages/vote_svc/edit_result_response.proto](#vote_messages_vote_svc_edit_result_response-proto) + - [EditVoteResultResponse](#vote-messages-vote_svc-EditVoteResultResponse) + +- [vote/messages/vote_svc/export_votes_request.proto](#vote_messages_vote_svc_export_votes_request-proto) + - [ExportVotesRequest](#vote-messages-vote_svc-ExportVotesRequest) + + - [ExportFormat](#vote-messages-vote_svc-ExportFormat) + +- [vote/messages/vote_svc/export_results_request.proto](#vote_messages_vote_svc_export_results_request-proto) + - [ExportResultsRequest](#vote-messages-vote_svc-ExportResultsRequest) + +- [vote/messages/vote_svc/export_results_response.proto](#vote_messages_vote_svc_export_results_response-proto) + - [ExportResultsResponse](#vote-messages-vote_svc-ExportResultsResponse) + +- [vote/messages/vote_svc/export_votes_response.proto](#vote_messages_vote_svc_export_votes_response-proto) + - [ExportVotesResponse](#vote-messages-vote_svc-ExportVotesResponse) + +- [vote/messages/vote_svc/get_category_request.proto](#vote_messages_vote_svc_get_category_request-proto) + - [GetVoteCategoryRequest](#vote-messages-vote_svc-GetVoteCategoryRequest) + +- [vote/messages/vote_svc/get_category_response.proto](#vote_messages_vote_svc_get_category_response-proto) + - [GetVoteCategoryResponse](#vote-messages-vote_svc-GetVoteCategoryResponse) + +- [vote/messages/vote_svc/get_vote_request.proto](#vote_messages_vote_svc_get_vote_request-proto) + - [GetVoteRequest](#vote-messages-vote_svc-GetVoteRequest) + +- [vote/messages/vote_svc/get_vote_response.proto](#vote_messages_vote_svc_get_vote_response-proto) + - [GetVoteResponse](#vote-messages-vote_svc-GetVoteResponse) + +- [vote/messages/vote_svc/list_categories_request.proto](#vote_messages_vote_svc_list_categories_request-proto) + - [ListVoteCategoriesRequest](#vote-messages-vote_svc-ListVoteCategoriesRequest) + +- [vote/messages/vote_svc/list_categories_response.proto](#vote_messages_vote_svc_list_categories_response-proto) + - [ListVoteCategoriesResponse](#vote-messages-vote_svc-ListVoteCategoriesResponse) + +- [vote/messages/vote_svc/list_results_request.proto](#vote_messages_vote_svc_list_results_request-proto) + - [ListVoteResultsRequest](#vote-messages-vote_svc-ListVoteResultsRequest) + +- [vote/messages/vote_svc/list_results_response.proto](#vote_messages_vote_svc_list_results_response-proto) + - [ListVoteResultsResponse](#vote-messages-vote_svc-ListVoteResultsResponse) + +- [vote/messages/vote_svc/list_votes_request.proto](#vote_messages_vote_svc_list_votes_request-proto) + - [ListVotesRequest](#vote-messages-vote_svc-ListVotesRequest) + +- [vote/messages/vote_svc/list_votes_response.proto](#vote_messages_vote_svc_list_votes_response-proto) + - [ListVotesResponse](#vote-messages-vote_svc-ListVotesResponse) + +- [vote/messages/vote_svc/submit_vote_request.proto](#vote_messages_vote_svc_submit_vote_request-proto) + - [PointsSubmission](#vote-messages-vote_svc-PointsSubmission) + - [PointsVote](#vote-messages-vote_svc-PointsVote) + - [RankedSubmission](#vote-messages-vote_svc-RankedSubmission) + - [RankedVote](#vote-messages-vote_svc-RankedVote) + - [SingleChoiceVote](#vote-messages-vote_svc-SingleChoiceVote) + - [SubmitVoteRequest](#vote-messages-vote_svc-SubmitVoteRequest) + +- [vote/messages/vote_svc/submit_vote_response.proto](#vote_messages_vote_svc_submit_vote_response-proto) + - [SubmitVoteResponse](#vote-messages-vote_svc-SubmitVoteResponse) + +- [vote/messages/vote_svc/suggest_results_request.proto](#vote_messages_vote_svc_suggest_results_request-proto) + - [SuggestResultsRequest](#vote-messages-vote_svc-SuggestResultsRequest) + +- [vote/messages/vote_svc/suggest_results_response.proto](#vote_messages_vote_svc_suggest_results_response-proto) + - [SuggestResultsResponse](#vote-messages-vote_svc-SuggestResultsResponse) + +- [vote/vote_service.proto](#vote_vote_service-proto) + - [VoteService](#vote-VoteService) + - [Scalar Value Types](#scalar-value-types) -<a name="hackathon_entities_hackathon_role-proto"></a> +<a name="hackathon_messages_config_svc_get_email_templates_request-proto"></a> <p align="right"><a href="#top">Top</a></p> -## hackathon/entities/hackathon_role.proto +## hackathon/messages/config_svc/get_email_templates_request.proto - +<a name="hackathon-messages-config_svc-GetEmailTemplatesRequest"></a> -<a name="hackathon-entities-HackathonRole"></a> +### GetEmailTemplatesRequest -### HackathonRole -Per-hackathon role sourced from casbin (not persisted in ent DB). -See components/backend/internal/middleware/rbac.go. -| Name | Number | Description | -| ---- | ------ | ----------- | -| HACKATHON_ROLE_UNSPECIFIED | 0 | | -| HACKATHON_ROLE_OWNER | 1 | | -| HACKATHON_ROLE_MEMBER | 2 | | + +| Field | Type | Label | Description | +| ----- | ---- | ----- | ----------- | +| hackathon_id | [string](#string) | | | + - - + + -<a name="user_entities_global_role-proto"></a> -<p align="right"><a href="#top">Top</a></p> + -## user/entities/global_role.proto - +<a name="hackathon_messages_config_svc_get_email_templates_response-proto"></a> +<p align="right"><a href="#top">Top</a></p> +## hackathon/messages/config_svc/get_email_templates_response.proto -<a name="user-entities-GlobalRole"></a> -### GlobalRole -Global role sourced from casbin (not persisted in ent DB). -See components/backend/internal/middleware/rbac.go. -| Name | Number | Description | -| ---- | ------ | ----------- | -| GLOBAL_ROLE_UNSPECIFIED | 0 | | -| GLOBAL_ROLE_ADMIN | 1 | | -| GLOBAL_ROLE_HACKATHON_ORGANIZER | 2 | | +<a name="hackathon-messages-config_svc-GetEmailTemplatesResponse"></a> +### GetEmailTemplatesResponse +The stored notification copy, keyed exactly as SetEmailTemplates takes it. - +A read is required rather than convenient: Set REPLACES the whole map, so a +form that cannot prefill blanks every template the organizer did not have in +front of them. Same reason GetWindows and PrizeService.Get exist. - +Empty map = nothing authored yet, which is a valid state and not an error. - +| Field | Type | Label | Description | +| ----- | ---- | ----- | ----------- | +| templates | [GetEmailTemplatesResponse.TemplatesEntry](#hackathon-messages-config_svc-GetEmailTemplatesResponse-TemplatesEntry) | repeated | | -<a name="user_entities_user-proto"></a> -<p align="right"><a href="#top">Top</a></p> -## user/entities/user.proto -<a name="user-entities-User"></a> +<a name="hackathon-messages-config_svc-GetEmailTemplatesResponse-TemplatesEntry"></a> -### User +### GetEmailTemplatesResponse.TemplatesEntry | Field | Type | Label | Description | | ----- | ---- | ----- | ----------- | -| id | [string](#string) | | | -| username | [string](#string) | | | -| keycloak_id | [string](#string) | | | -| created_at | [google.protobuf.Timestamp](#google-protobuf-Timestamp) | | | -| display_name | [string](#string) | | | -| email | [string](#string) | | | -| roles | [GlobalRole](#user-entities-GlobalRole) | repeated | Populated from casbin on fetch; not persisted in ent DB. | -| modified_at | [google.protobuf.Timestamp](#google-protobuf-Timestamp) | | | +| key | [string](#string) | | | +| value | [string](#string) | | | @@ -501,27 +908,22 @@ See components/backend/internal/middleware/rbac.go. -<a name="hackathon_entities_hackathon_member-proto"></a> +<a name="hackathon_messages_config_svc_get_windows_request-proto"></a> <p align="right"><a href="#top">Top</a></p> -## hackathon/entities/hackathon_member.proto +## hackathon/messages/config_svc/get_windows_request.proto -<a name="hackathon-entities-HackathonMember"></a> +<a name="hackathon-messages-config_svc-GetWindowsRequest"></a> + +### GetWindowsRequest -### HackathonMember -A user's relationship to a hackathon: DB participation state (is_waiting, joined_at) -combined with casbin role. `role` is HACKATHON_ROLE_UNSPECIFIED for users with no -casbin role for this hackathon; `is_waiting` is false once approved. | Field | Type | Label | Description | | ----- | ---- | ----- | ----------- | -| user | [user.entities.User](#user-entities-User) | | | -| role | [HackathonRole](#hackathon-entities-HackathonRole) | | | -| is_waiting | [bool](#bool) | | | -| joined_at | [google.protobuf.Timestamp](#google-protobuf-Timestamp) | | | +| hackathon_id | [string](#string) | | | @@ -537,26 +939,37 @@ casbin role for this hackathon; `is_waiting` is false once approved. -<a name="hackathon_entities_hackathon_status-proto"></a> +<a name="hackathon_entities_hackathon_window_set-proto"></a> <p align="right"><a href="#top">Top</a></p> -## hackathon/entities/hackathon_status.proto +## hackathon/entities/hackathon_window_set.proto - +<a name="hackathon-entities-HackathonWindows"></a> -<a name="hackathon-entities-HackathonStatus"></a> +### HackathonWindows +HackathonWindows holds the per-hackathon time windows the backend enforces +on the acting RPCs (Join, Propose, SetPreference, CreateSubmission). +Unset fields are not enforced. Overrides are absolute one-shot extensions +anchored at the moment the organizer granted them. + + +| Field | Type | Label | Description | +| ----- | ---- | ----- | ----------- | +| hackathon_id | [string](#string) | | | +| registration_opens | [google.protobuf.Timestamp](#google-protobuf-Timestamp) | optional | | +| registration_closes | [google.protobuf.Timestamp](#google-protobuf-Timestamp) | optional | | +| proposals_close | [google.protobuf.Timestamp](#google-protobuf-Timestamp) | optional | | +| preferences_close | [google.protobuf.Timestamp](#google-protobuf-Timestamp) | optional | | +| submissions_close | [google.protobuf.Timestamp](#google-protobuf-Timestamp) | optional | | +| registration_override_until | [google.protobuf.Timestamp](#google-protobuf-Timestamp) | optional | | +| submissions_override_until | [google.protobuf.Timestamp](#google-protobuf-Timestamp) | optional | | +| late_policy | [string](#string) | optional | Human-readable note on how late submissions are handled. | +| modified_at | [google.protobuf.Timestamp](#google-protobuf-Timestamp) | | | -### HackathonStatus -| Name | Number | Description | -| ---- | ------ | ----------- | -| HACKATHON_STATUS_UNSPECIFIED | 0 | | -| HACKATHON_STATUS_PENDING | 1 | | -| HACKATHON_STATUS_ACTIVE | 2 | | -| HACKATHON_STATUS_FINISHED | 3 | | @@ -565,34 +978,27 @@ casbin role for this hackathon; `is_waiting` is false once approved. + -<a name="hackathon_entities_page-proto"></a> -<p align="right"><a href="#top">Top</a></p> -## hackathon/entities/page.proto +<a name="hackathon_messages_config_svc_get_windows_response-proto"></a> +<p align="right"><a href="#top">Top</a></p> +## hackathon/messages/config_svc/get_windows_response.proto -<a name="hackathon-entities-Page"></a> -### Page +<a name="hackathon-messages-config_svc-GetWindowsResponse"></a> +### GetWindowsResponse +`windows` is absent when the event has no row yet, which reads as "no +deadlines set" — distinct from a row whose fields happen to be empty. | Field | Type | Label | Description | | ----- | ---- | ----- | ----------- | -| id | [string](#string) | | | -| title | [string](#string) | | | -| content | [string](#string) | | | -| visible | [bool](#bool) | | | -| order | [int32](#int32) | | | -| created_at | [google.protobuf.Timestamp](#google-protobuf-Timestamp) | | | -| modified_at | [google.protobuf.Timestamp](#google-protobuf-Timestamp) | | | -| hackathon_id | [string](#string) | | | -| phase_id | [string](#string) | optional | | -| creator_id | [string](#string) | | | -| modifier_id | [string](#string) | | | +| windows | [hackathon.entities.HackathonWindows](#hackathon-entities-HackathonWindows) | optional | | @@ -608,32 +1014,27 @@ casbin role for this hackathon; `is_waiting` is false once approved. -<a name="hackathon_entities_phase-proto"></a> +<a name="hackathon_messages_config_svc_override_window_request-proto"></a> <p align="right"><a href="#top">Top</a></p> -## hackathon/entities/phase.proto - +## hackathon/messages/config_svc/override_window_request.proto -<a name="hackathon-entities-Phase"></a> -### Phase +<a name="hackathon-messages-config_svc-OverrideWindowRequest"></a> +### OverrideWindowRequest +One-shot manual extension: the window stays open until now + extend_minutes +regardless of its configured close (walk-ins at the door, AV issues during +demos). The organizer has the final word over the clock. | Field | Type | Label | Description | | ----- | ---- | ----- | ----------- | -| id | [string](#string) | | | -| name | [string](#string) | | | -| description | [string](#string) | optional | | -| starts_at | [google.protobuf.Timestamp](#google-protobuf-Timestamp) | optional | | -| ends_at | [google.protobuf.Timestamp](#google-protobuf-Timestamp) | optional | | -| created_at | [google.protobuf.Timestamp](#google-protobuf-Timestamp) | | | -| modified_at | [google.protobuf.Timestamp](#google-protobuf-Timestamp) | | | | hackathon_id | [string](#string) | | | -| page_id | [string](#string) | optional | | -| creator_id | [string](#string) | | | -| modifier_id | [string](#string) | | | +| window | [string](#string) | | Which window to extend: "registration" or "submissions". | +| extend_minutes | [int32](#int32) | | | +| reason | [string](#string) | | | @@ -649,27 +1050,29 @@ casbin role for this hackathon; `is_waiting` is false once approved. -<a name="hackathon_entities_project_status-proto"></a> +<a name="hackathon_messages_config_svc_override_window_response-proto"></a> <p align="right"><a href="#top">Top</a></p> -## hackathon/entities/project_status.proto +## hackathon/messages/config_svc/override_window_response.proto - +<a name="hackathon-messages-config_svc-OverrideWindowResponse"></a> -<a name="hackathon-entities-ProjectStatus"></a> +### OverrideWindowResponse -### ProjectStatus -| Name | Number | Description | -| ---- | ------ | ----------- | -| PROJECT_STATUS_UNSPECIFIED | 0 | | -| PROJECT_STATUS_PROPOSED | 1 | | -| PROJECT_STATUS_APPROVED | 2 | | +| Field | Type | Label | Description | +| ----- | ---- | ----- | ----------- | +| windows | [hackathon.entities.HackathonWindows](#hackathon-entities-HackathonWindows) | | | + + + + + @@ -678,32 +1081,25 @@ casbin role for this hackathon; `is_waiting` is false once approved. -<a name="hackathon_entities_project-proto"></a> +<a name="hackathon_messages_config_svc_set_branding_request-proto"></a> <p align="right"><a href="#top">Top</a></p> -## hackathon/entities/project.proto +## hackathon/messages/config_svc/set_branding_request.proto -<a name="hackathon-entities-Project"></a> +<a name="hackathon-messages-config_svc-SetBrandingRequest"></a> -### Project +### SetBrandingRequest | Field | Type | Label | Description | | ----- | ---- | ----- | ----------- | -| id | [string](#string) | | | -| title | [string](#string) | | | -| description | [string](#string) | | | -| status | [ProjectStatus](#hackathon-entities-ProjectStatus) | | | -| image | [string](#string) | optional | | -| created_at | [google.protobuf.Timestamp](#google-protobuf-Timestamp) | | | -| modified_at | [google.protobuf.Timestamp](#google-protobuf-Timestamp) | | | -| track_id | [string](#string) | | | | hackathon_id | [string](#string) | | | -| creator_id | [string](#string) | | | -| modifier_id | [string](#string) | | | +| primary_color | [string](#string) | optional | CSS hex colours (#rgb or #rrggbb); validated server-side. | +| accent_color | [string](#string) | optional | | +| banner_text | [string](#string) | optional | | @@ -719,27 +1115,17 @@ casbin role for this hackathon; `is_waiting` is false once approved. -<a name="hackathon_entities_track-proto"></a> +<a name="hackathon_messages_config_svc_set_branding_response-proto"></a> <p align="right"><a href="#top">Top</a></p> -## hackathon/entities/track.proto - +## hackathon/messages/config_svc/set_branding_response.proto -<a name="hackathon-entities-Track"></a> - -### Track +<a name="hackathon-messages-config_svc-SetBrandingResponse"></a> +### SetBrandingResponse -| Field | Type | Label | Description | -| ----- | ---- | ----- | ----------- | -| id | [string](#string) | | | -| name | [string](#string) | | | -| description | [string](#string) | | | -| created_at | [google.protobuf.Timestamp](#google-protobuf-Timestamp) | | | -| modified_at | [google.protobuf.Timestamp](#google-protobuf-Timestamp) | | | -| hackathon_id | [string](#string) | | | @@ -755,68 +1141,41 @@ casbin role for this hackathon; `is_waiting` is false once approved. -<a name="hackathon_entities_visibility-proto"></a> +<a name="hackathon_messages_config_svc_set_email_templates_request-proto"></a> <p align="right"><a href="#top">Top</a></p> -## hackathon/entities/visibility.proto - - - - - -<a name="hackathon-entities-Visibility"></a> - -### Visibility - +## hackathon/messages/config_svc/set_email_templates_request.proto -| Name | Number | Description | -| ---- | ------ | ----------- | -| VISIBILITY_UNSPECIFIED | 0 | | -| VISIBILITY_PUBLIC | 1 | | -| VISIBILITY_PRIVATE | 2 | | - +<a name="hackathon-messages-config_svc-SetEmailTemplatesRequest"></a> - +### SetEmailTemplatesRequest +Notification COPY, stored per hackathon. Sending is a separate concern (no +notification service exists yet) — this pins the text organizers author so +it survives that gap. - +| Field | Type | Label | Description | +| ----- | ---- | ----- | ----------- | +| hackathon_id | [string](#string) | | | +| templates | [SetEmailTemplatesRequest.TemplatesEntry](#hackathon-messages-config_svc-SetEmailTemplatesRequest-TemplatesEntry) | repeated | Keyed by moment: registrationConfirmed, teamAssigned, deadlineReminder, results. Unknown keys are rejected so a typo is not silently stored. | -<a name="hackathon_entities_hackathon-proto"></a> -<p align="right"><a href="#top">Top</a></p> -## hackathon/entities/hackathon.proto -<a name="hackathon-entities-Hackathon"></a> +<a name="hackathon-messages-config_svc-SetEmailTemplatesRequest-TemplatesEntry"></a> -### Hackathon +### SetEmailTemplatesRequest.TemplatesEntry | Field | Type | Label | Description | | ----- | ---- | ----- | ----------- | -| id | [string](#string) | | | -| name | [string](#string) | | | -| starts_at | [google.protobuf.Timestamp](#google-protobuf-Timestamp) | optional | | -| ends_at | [google.protobuf.Timestamp](#google-protobuf-Timestamp) | optional | | -| created_at | [google.protobuf.Timestamp](#google-protobuf-Timestamp) | | | -| modified_at | [google.protobuf.Timestamp](#google-protobuf-Timestamp) | | | -| visibility | [Visibility](#hackathon-entities-Visibility) | | | -| status | [HackathonStatus](#hackathon-entities-HackathonStatus) | | Computed server-side from starts_at/ends_at; not persisted in DB. | -| description | [string](#string) | optional | | -| logo | [string](#string) | optional | | -| members | [HackathonMember](#hackathon-entities-HackathonMember) | repeated | | -| creator | [user.entities.User](#user-entities-User) | | | -| modifier | [user.entities.User](#user-entities-User) | | | -| tracks | [Track](#hackathon-entities-Track) | repeated | The following collections are populated only on Get responses. | -| projects | [Project](#hackathon-entities-Project) | repeated | | -| pages | [Page](#hackathon-entities-Page) | repeated | | -| phases | [Phase](#hackathon-entities-Phase) | repeated | | -| viewer_membership | [HackathonMember](#hackathon-entities-HackathonMember) | optional | Populated in List responses only when participant_id filter is set. Contains the requesting user's membership in this hackathon (role + is_waiting). | +| key | [string](#string) | | | +| value | [string](#string) | | | @@ -832,31 +1191,17 @@ casbin role for this hackathon; `is_waiting` is false once approved. -<a name="hackathon_entities_project_preference-proto"></a> +<a name="hackathon_messages_config_svc_set_email_templates_response-proto"></a> <p align="right"><a href="#top">Top</a></p> -## hackathon/entities/project_preference.proto - - +## hackathon/messages/config_svc/set_email_templates_response.proto -<a name="hackathon-entities-ProjectWithPreferences"></a> -### ProjectWithPreferences +<a name="hackathon-messages-config_svc-SetEmailTemplatesResponse"></a> +### SetEmailTemplatesResponse -| Field | Type | Label | Description | -| ----- | ---- | ----- | ----------- | -| id | [string](#string) | | | -| title | [string](#string) | | | -| description | [string](#string) | | | -| status | [ProjectStatus](#hackathon-entities-ProjectStatus) | | | -| image | [string](#string) | optional | | -| created_at | [google.protobuf.Timestamp](#google-protobuf-Timestamp) | | | -| modified_at | [google.protobuf.Timestamp](#google-protobuf-Timestamp) | | | -| track_id | [string](#string) | | | -| hackathon_id | [string](#string) | | | -| preferences | [user.entities.User](#user-entities-User) | repeated | List of users who have this project as their preference | @@ -872,60 +1217,61 @@ casbin role for this hackathon; `is_waiting` is false once approved. -<a name="hackathon_entities_submission_status-proto"></a> +<a name="hackathon_entities_form_schema-proto"></a> <p align="right"><a href="#top">Top</a></p> -## hackathon/entities/submission_status.proto +## hackathon/entities/form_schema.proto - +<a name="hackathon-entities-ConsentField"></a> -<a name="hackathon-entities-SubmissionStatus"></a> +### ConsentField +ConsentField is a checkbox the registrant must (or may) tick. -### SubmissionStatus +| Field | Type | Label | Description | +| ----- | ---- | ----- | ----------- | +| key | [string](#string) | | | +| label | [string](#string) | | | +| required | [bool](#bool) | | | -| Name | Number | Description | -| ---- | ------ | ----------- | -| SUBMISSION_STATUS_UNSPECIFIED | 0 | | -| SUBMISSION_STATUS_DRAFT | 1 | | -| SUBMISSION_STATUS_FINAL | 2 | | - - - +<a name="hackathon-entities-FormField"></a> +### FormField +FormField is one input in an organizer-defined form. `type` is a free +string ("text", "tags", "url", "file-or-url", ...) — the backend validates +presence and key membership, not deep typing, until a form engine exists. -<a name="hackathon_entities_submission-proto"></a> -<p align="right"><a href="#top">Top</a></p> -## hackathon/entities/submission.proto +| Field | Type | Label | Description | +| ----- | ---- | ----- | ----------- | +| key | [string](#string) | | | +| label | [string](#string) | | | +| type | [string](#string) | | | +| required | [bool](#bool) | | | +| max_mb | [int32](#int32) | optional | Upload size cap for file-typed fields, in megabytes. | -<a name="hackathon-entities-Submission"></a> -### Submission +<a name="hackathon-entities-FormSchema"></a> + +### FormSchema +FormSchema is an organizer-defined form: fields plus consents. + | Field | Type | Label | Description | | ----- | ---- | ----- | ----------- | -| id | [string](#string) | | | -| result | [string](#string) | optional | | -| status | [SubmissionStatus](#hackathon-entities-SubmissionStatus) | | | -| version | [int32](#int32) | | | -| created_at | [google.protobuf.Timestamp](#google-protobuf-Timestamp) | | | -| modified_at | [google.protobuf.Timestamp](#google-protobuf-Timestamp) | | | -| team_id | [string](#string) | | | -| project_id | [string](#string) | | | -| creator_id | [string](#string) | | | -| modifier_id | [string](#string) | optional | | +| fields | [FormField](#hackathon-entities-FormField) | repeated | | +| consents | [ConsentField](#hackathon-entities-ConsentField) | repeated | | @@ -941,31 +1287,24 @@ casbin role for this hackathon; `is_waiting` is false once approved. -<a name="hackathon_entities_team-proto"></a> +<a name="hackathon_messages_config_svc_set_registration_form_request-proto"></a> <p align="right"><a href="#top">Top</a></p> -## hackathon/entities/team.proto +## hackathon/messages/config_svc/set_registration_form_request.proto -<a name="hackathon-entities-Team"></a> +<a name="hackathon-messages-config_svc-SetRegistrationFormRequest"></a> -### Team +### SetRegistrationFormRequest | Field | Type | Label | Description | | ----- | ---- | ----- | ----------- | -| id | [string](#string) | | | -| name | [string](#string) | | | -| description | [string](#string) | optional | | -| created_at | [google.protobuf.Timestamp](#google-protobuf-Timestamp) | | | -| modified_at | [google.protobuf.Timestamp](#google-protobuf-Timestamp) | | | -| project_id | [string](#string) | | | -| creator_id | [string](#string) | | | -| modifier_id | [string](#string) | optional | | -| members | [user.entities.User](#user-entities-User) | repeated | | -| submissions | [Submission](#hackathon-entities-Submission) | repeated | | +| hackathon_id | [string](#string) | | | +| fields | [hackathon.entities.FormField](#hackathon-entities-FormField) | repeated | | +| consents | [hackathon.entities.ConsentField](#hackathon-entities-ConsentField) | repeated | | @@ -981,23 +1320,22 @@ casbin role for this hackathon; `is_waiting` is false once approved. -<a name="hackathon_messages_hackathon_svc_add_owner_request-proto"></a> +<a name="hackathon_messages_config_svc_set_registration_form_response-proto"></a> <p align="right"><a href="#top">Top</a></p> -## hackathon/messages/hackathon_svc/add_owner_request.proto +## hackathon/messages/config_svc/set_registration_form_response.proto -<a name="hackathon-messages-hackathon_svc-AddOwnerRequest"></a> +<a name="hackathon-messages-config_svc-SetRegistrationFormResponse"></a> -### AddOwnerRequest +### SetRegistrationFormResponse | Field | Type | Label | Description | | ----- | ---- | ----- | ----------- | -| hackathon_id | [string](#string) | | | -| user_id | [string](#string) | | | +| form | [hackathon.entities.FormSchema](#hackathon-entities-FormSchema) | | | @@ -1013,18 +1351,24 @@ casbin role for this hackathon; `is_waiting` is false once approved. -<a name="hackathon_messages_hackathon_svc_add_owner_response-proto"></a> +<a name="hackathon_messages_config_svc_set_submission_form_request-proto"></a> <p align="right"><a href="#top">Top</a></p> -## hackathon/messages/hackathon_svc/add_owner_response.proto +## hackathon/messages/config_svc/set_submission_form_request.proto -<a name="hackathon-messages-hackathon_svc-AddOwnerResponse"></a> +<a name="hackathon-messages-config_svc-SetSubmissionFormRequest"></a> + +### SetSubmissionFormRequest -### AddOwnerResponse +| Field | Type | Label | Description | +| ----- | ---- | ----- | ----------- | +| hackathon_id | [string](#string) | | | +| fields | [hackathon.entities.FormField](#hackathon-entities-FormField) | repeated | | + @@ -1039,23 +1383,22 @@ casbin role for this hackathon; `is_waiting` is false once approved. -<a name="hackathon_messages_hackathon_svc_approve_participant_request-proto"></a> +<a name="hackathon_messages_config_svc_set_submission_form_response-proto"></a> <p align="right"><a href="#top">Top</a></p> -## hackathon/messages/hackathon_svc/approve_participant_request.proto +## hackathon/messages/config_svc/set_submission_form_response.proto -<a name="hackathon-messages-hackathon_svc-ApproveParticipantRequest"></a> +<a name="hackathon-messages-config_svc-SetSubmissionFormResponse"></a> -### ApproveParticipantRequest +### SetSubmissionFormResponse | Field | Type | Label | Description | | ----- | ---- | ----- | ----------- | -| hackathon_id | [string](#string) | | | -| user_id | [string](#string) | | | +| form | [hackathon.entities.FormSchema](#hackathon-entities-FormSchema) | | | @@ -1071,53 +1414,46 @@ casbin role for this hackathon; `is_waiting` is false once approved. -<a name="hackathon_messages_hackathon_svc_approve_participant_response-proto"></a> +<a name="hackathon_messages_config_svc_set_voting_policy_request-proto"></a> <p align="right"><a href="#top">Top</a></p> -## hackathon/messages/hackathon_svc/approve_participant_response.proto - - - -<a name="hackathon-messages-hackathon_svc-ApproveParticipantResponse"></a> - -### ApproveParticipantResponse - - - - +## hackathon/messages/config_svc/set_voting_policy_request.proto - - +<a name="hackathon-messages-config_svc-ScaleRange"></a> - +### ScaleRange - +| Field | Type | Label | Description | +| ----- | ---- | ----- | ----------- | +| min | [int32](#int32) | | | +| max | [int32](#int32) | | | -<a name="hackathon_messages_hackathon_svc_create_request-proto"></a> -<p align="right"><a href="#top">Top</a></p> -## hackathon/messages/hackathon_svc/create_request.proto -<a name="hackathon-messages-hackathon_svc-CreateRequest"></a> -### CreateRequest +<a name="hackathon-messages-config_svc-SetVotingPolicyRequest"></a> +### SetVotingPolicyRequest +Pins the voting mechanism decisions. Stored as configuration; the vote +handlers enforce the parts the platform implements (single ballot per +category today) and the rest documents the organizer's ruling. | Field | Type | Label | Description | | ----- | ---- | ----- | ----------- | -| name | [string](#string) | | | -| starts_at | [google.protobuf.Timestamp](#google-protobuf-Timestamp) | optional | | -| ends_at | [google.protobuf.Timestamp](#google-protobuf-Timestamp) | optional | | -| visibility | [hackathon.entities.Visibility](#hackathon-entities-Visibility) | | | -| description | [string](#string) | optional | | -| logo | [string](#string) | optional | | +| hackathon_id | [string](#string) | | | +| mechanism | [string](#string) | | | +| scale | [ScaleRange](#hackathon-messages-config_svc-ScaleRange) | | | +| one_ballot_per | [string](#string) | | | +| own_team_voting | [bool](#bool) | | | +| organizer_voting | [bool](#bool) | | | +| tie_break | [string](#string) | repeated | | @@ -1133,22 +1469,17 @@ casbin role for this hackathon; `is_waiting` is false once approved. -<a name="hackathon_messages_hackathon_svc_create_response-proto"></a> +<a name="hackathon_messages_config_svc_set_voting_policy_response-proto"></a> <p align="right"><a href="#top">Top</a></p> -## hackathon/messages/hackathon_svc/create_response.proto - - +## hackathon/messages/config_svc/set_voting_policy_response.proto -<a name="hackathon-messages-hackathon_svc-CreateResponse"></a> -### CreateResponse +<a name="hackathon-messages-config_svc-SetVotingPolicyResponse"></a> +### SetVotingPolicyResponse -| Field | Type | Label | Description | -| ----- | ---- | ----- | ----------- | -| hackathon_id | [string](#string) | | | @@ -1164,28 +1495,29 @@ casbin role for this hackathon; `is_waiting` is false once approved. -<a name="hackathon_messages_hackathon_svc_edit_request-proto"></a> +<a name="hackathon_messages_config_svc_set_windows_request-proto"></a> <p align="right"><a href="#top">Top</a></p> -## hackathon/messages/hackathon_svc/edit_request.proto - +## hackathon/messages/config_svc/set_windows_request.proto -<a name="hackathon-messages-hackathon_svc-EditRequest"></a> -### EditRequest +<a name="hackathon-messages-config_svc-SetWindowsRequest"></a> +### SetWindowsRequest +Partial update: only the fields present are written; windows never set are +not enforced. | Field | Type | Label | Description | | ----- | ---- | ----- | ----------- | | hackathon_id | [string](#string) | | | -| name | [string](#string) | optional | | -| description | [string](#string) | optional | | -| visibility | [hackathon.entities.Visibility](#hackathon-entities-Visibility) | optional | | -| starts_at | [google.protobuf.Timestamp](#google-protobuf-Timestamp) | optional | | -| ends_at | [google.protobuf.Timestamp](#google-protobuf-Timestamp) | optional | | -| logo | [string](#string) | optional | | +| registration_opens | [google.protobuf.Timestamp](#google-protobuf-Timestamp) | optional | | +| registration_closes | [google.protobuf.Timestamp](#google-protobuf-Timestamp) | optional | | +| proposals_close | [google.protobuf.Timestamp](#google-protobuf-Timestamp) | optional | | +| preferences_close | [google.protobuf.Timestamp](#google-protobuf-Timestamp) | optional | | +| submissions_close | [google.protobuf.Timestamp](#google-protobuf-Timestamp) | optional | | +| late_policy | [string](#string) | optional | | @@ -1201,22 +1533,22 @@ casbin role for this hackathon; `is_waiting` is false once approved. -<a name="hackathon_messages_hackathon_svc_edit_response-proto"></a> +<a name="hackathon_messages_config_svc_set_windows_response-proto"></a> <p align="right"><a href="#top">Top</a></p> -## hackathon/messages/hackathon_svc/edit_response.proto +## hackathon/messages/config_svc/set_windows_response.proto -<a name="hackathon-messages-hackathon_svc-EditResponse"></a> +<a name="hackathon-messages-config_svc-SetWindowsResponse"></a> -### EditResponse +### SetWindowsResponse | Field | Type | Label | Description | | ----- | ---- | ----- | ----------- | -| hackathon | [hackathon.entities.Hackathon](#hackathon-entities-Hackathon) | | | +| windows | [hackathon.entities.HackathonWindows](#hackathon-entities-HackathonWindows) | | | @@ -1232,53 +1564,67 @@ casbin role for this hackathon; `is_waiting` is false once approved. -<a name="hackathon_messages_hackathon_svc_get_request-proto"></a> +<a name="hackathon_config_service-proto"></a> <p align="right"><a href="#top">Top</a></p> -## hackathon/messages/hackathon_svc/get_request.proto - - - -<a name="hackathon-messages-hackathon_svc-GetRequest"></a> - -### GetRequest - +## hackathon/config_service.proto -| Field | Type | Label | Description | -| ----- | ---- | ----- | ----------- | -| hackathon_id | [string](#string) | | | + + + +<a name="hackathon-ConfigService"></a> - +### ConfigService +Per-hackathon configuration. First slice: enforceable time windows. +Forms, voting policy, email templates and branding land here as further +slices of the same configuration engine. - +| Method Name | Request Type | Response Type | Description | +| ----------- | ------------ | ------------- | ------------| +| GetWindows | [messages.config_svc.GetWindowsRequest](#hackathon-messages-config_svc-GetWindowsRequest) | [messages.config_svc.GetWindowsResponse](#hackathon-messages-config_svc-GetWindowsResponse) | Reads the deadlines back. SetWindows REPLACES every field, so without this an organiser editing one deadline would blank the others they cannot see — the form had no way to prefill, which made saving it destructive. | +| SetWindows | [messages.config_svc.SetWindowsRequest](#hackathon-messages-config_svc-SetWindowsRequest) | [messages.config_svc.SetWindowsResponse](#hackathon-messages-config_svc-SetWindowsResponse) | | +| OverrideWindow | [messages.config_svc.OverrideWindowRequest](#hackathon-messages-config_svc-OverrideWindowRequest) | [messages.config_svc.OverrideWindowResponse](#hackathon-messages-config_svc-OverrideWindowResponse) | | +| SetRegistrationForm | [messages.config_svc.SetRegistrationFormRequest](#hackathon-messages-config_svc-SetRegistrationFormRequest) | [messages.config_svc.SetRegistrationFormResponse](#hackathon-messages-config_svc-SetRegistrationFormResponse) | | +| SetSubmissionForm | [messages.config_svc.SetSubmissionFormRequest](#hackathon-messages-config_svc-SetSubmissionFormRequest) | [messages.config_svc.SetSubmissionFormResponse](#hackathon-messages-config_svc-SetSubmissionFormResponse) | | +| SetVotingPolicy | [messages.config_svc.SetVotingPolicyRequest](#hackathon-messages-config_svc-SetVotingPolicyRequest) | [messages.config_svc.SetVotingPolicyResponse](#hackathon-messages-config_svc-SetVotingPolicyResponse) | | +| GetEmailTemplates | [messages.config_svc.GetEmailTemplatesRequest](#hackathon-messages-config_svc-GetEmailTemplatesRequest) | [messages.config_svc.GetEmailTemplatesResponse](#hackathon-messages-config_svc-GetEmailTemplatesResponse) | Organizer-authored notification copy and event branding. Stored config; delivery/rendering are separate concerns. - +Get exists for the same reason GetWindows does: Set replaces the whole map, so a form that cannot prefill blanks the templates its author never saw. Gated on Write rather than Read — this copy is internal drafting, unlike the deadlines, which the event announces. | +| SetEmailTemplates | [messages.config_svc.SetEmailTemplatesRequest](#hackathon-messages-config_svc-SetEmailTemplatesRequest) | [messages.config_svc.SetEmailTemplatesResponse](#hackathon-messages-config_svc-SetEmailTemplatesResponse) | | +| SetBranding | [messages.config_svc.SetBrandingRequest](#hackathon-messages-config_svc-SetBrandingRequest) | [messages.config_svc.SetBrandingResponse](#hackathon-messages-config_svc-SetBrandingResponse) | | -<a name="hackathon_messages_hackathon_svc_get_response-proto"></a> +<a name="hackathon_entities_capability-proto"></a> <p align="right"><a href="#top">Top</a></p> -## hackathon/messages/hackathon_svc/get_response.proto +## hackathon/entities/capability.proto -<a name="hackathon-messages-hackathon_svc-GetResponse"></a> +<a name="hackathon-entities-CapabilityStatus"></a> -### GetResponse +### CapabilityStatus | Field | Type | Label | Description | | ----- | ---- | ----- | ----------- | -| hackathon | [hackathon.entities.Hackathon](#hackathon-entities-Hackathon) | | | +| capability | [Capability](#hackathon-entities-Capability) | | | +| state | [CapabilityState](#hackathon-entities-CapabilityState) | | | +| modified_at | [google.protobuf.Timestamp](#google-protobuf-Timestamp) | | | +| modifier_id | [string](#string) | optional | | +| opens_at | [google.protobuf.Timestamp](#google-protobuf-Timestamp) | optional | The schedule, derived from the linked phases. Display only: `state` is what the server enforces, and these never widen it. Absent when the capability is manually driven (no linked phase). | +| closes_at | [google.protobuf.Timestamp](#google-protobuf-Timestamp) | optional | | +| open_in_phase_id | [string](#string) | optional | | +| closed_in_phase_id | [string](#string) | optional | | @@ -1286,30 +1632,4747 @@ casbin role for this hackathon; `is_waiting` is false once approved. - - +<a name="hackathon-entities-Capability"></a> + +### Capability +What a member is allowed to do in a hackathon right now. + +Each value is backed by exactly one stored row per hackathon carrying an +`enabled` flag, which is the authoritative gate. Adding a capability is +therefore an enum value plus a row — no schema or message change. + +| Name | Number | Description | +| ---- | ------ | ----------- | +| CAPABILITY_UNSPECIFIED | 0 | | +| CAPABILITY_REGISTER | 1 | HackathonService.Join | +| CAPABILITY_PROPOSE_PROJECTS | 2 | ProjectService.Propose | +| CAPABILITY_SET_TEAM_PREFERENCES | 3 | ProjectService.SetPreference | +| CAPABILITY_CREATE_PROJECT_SUBMISSIONS | 4 | TeamService.CreateSubmission / FinalizeSubmission | +| CAPABILITY_VOTE | 5 | | +| CAPABILITY_VIEW_RESULTS | 6 | The flag doubles as the publish switch, since results are entered one placement at a time and must not leak partial standings. | + + + +<a name="hackathon-entities-CapabilityState"></a> + +### CapabilityState + + +| Name | Number | Description | +| ---- | ------ | ----------- | +| CAPABILITY_STATE_UNSPECIFIED | 0 | | +| CAPABILITY_STATE_COMING | 1 | Closed now, but its open_in_phase starts in the future, so clients can show "opens 12 Aug" and count down to it. | +| CAPABILITY_STATE_OPEN | 2 | | +| CAPABILITY_STATE_CLOSED | 3 | | +| CAPABILITY_STATE_UNGOVERNED | 4 | No row exists for this capability, so the server has no opinion and does not enforce it. Clients must render exactly as they did before capabilities existed. This is what makes partial adoption safe. | + + + + + + + + + + +<a name="hackathon_entities_hackathon_branding-proto"></a> +<p align="right"><a href="#top">Top</a></p> + +## hackathon/entities/hackathon_branding.proto + + + +<a name="hackathon-entities-HackathonBranding"></a> + +### HackathonBranding +Per-hackathon visual identity, written by ConfigService.SetBranding and +stored on the hackathon's forms row. Every field is optional and the message +itself is absent when an organizer never set anything — a hackathon without +branding must render exactly like the default platform theme. + +The event logo is not here: it is a column on the hackathon row itself +(Hackathon.logo), because it predates branding and List already returns it. + + +| Field | Type | Label | Description | +| ----- | ---- | ----- | ----------- | +| primary_color | [string](#string) | optional | Hex colour, #rgb or #rrggbb. SetBranding rejects anything else, but these values end up inside a CSS style attribute, so clients must validate them again before interpolating: a row written before that check existed, or by a future write path that forgets it, would otherwise be injected into CSS. | +| accent_color | [string](#string) | optional | | +| banner_text | [string](#string) | optional | Free text shown as a banner on the event page (e.g. "Registration closes Friday"). Rendered as text, never as markup. | + + + + + + + + + + + + + + + +<a name="hackathon_entities_hackathon_role-proto"></a> +<p align="right"><a href="#top">Top</a></p> + +## hackathon/entities/hackathon_role.proto + + + + + +<a name="hackathon-entities-HackathonRole"></a> + +### HackathonRole +Per-hackathon role sourced from casbin (not persisted in ent DB). +See components/backend/internal/middleware/rbac.go. + +| Name | Number | Description | +| ---- | ------ | ----------- | +| HACKATHON_ROLE_UNSPECIFIED | 0 | | +| HACKATHON_ROLE_OWNER | 1 | | +| HACKATHON_ROLE_MEMBER | 2 | | + + + + + + + + + + +<a name="user_entities_global_role-proto"></a> +<p align="right"><a href="#top">Top</a></p> + +## user/entities/global_role.proto + + + + + +<a name="user-entities-GlobalRole"></a> + +### GlobalRole +Global role sourced from casbin (not persisted in ent DB). +See components/backend/internal/middleware/rbac.go. + +| Name | Number | Description | +| ---- | ------ | ----------- | +| GLOBAL_ROLE_UNSPECIFIED | 0 | | +| GLOBAL_ROLE_ADMIN | 1 | | +| GLOBAL_ROLE_HACKATHON_ORGANIZER | 2 | | + + + + + + + + + + +<a name="user_entities_user-proto"></a> +<p align="right"><a href="#top">Top</a></p> + +## user/entities/user.proto + + + +<a name="user-entities-User"></a> + +### User + + + +| Field | Type | Label | Description | +| ----- | ---- | ----- | ----------- | +| id | [string](#string) | | | +| username | [string](#string) | | | +| keycloak_id | [string](#string) | | | +| created_at | [google.protobuf.Timestamp](#google-protobuf-Timestamp) | | | +| display_name | [string](#string) | | | +| email | [string](#string) | | | +| roles | [GlobalRole](#user-entities-GlobalRole) | repeated | Populated from casbin on fetch; not persisted in ent DB. | +| modified_at | [google.protobuf.Timestamp](#google-protobuf-Timestamp) | | | +| affiliation | [string](#string) | | The platform's own profile, filled in by the person it describes. + +Mirrors the keys registration forms ask for, so an event can prefill rather than ask the same four questions every time. Consents are NOT here: a consent is an agreement with one event, given on a date, and it lives in that event's registration record. + +Empty string means "not filled in", which is a normal state. | +| skills | [string](#string) | | | +| dietary | [string](#string) | | | +| avatar_url | [string](#string) | | | + + + + + + + + + + + + + + + +<a name="hackathon_entities_hackathon_member-proto"></a> +<p align="right"><a href="#top">Top</a></p> + +## hackathon/entities/hackathon_member.proto + + + +<a name="hackathon-entities-HackathonMember"></a> + +### HackathonMember +A user's relationship to a hackathon: DB participation state (is_waiting, joined_at) +combined with casbin role. `role` is HACKATHON_ROLE_UNSPECIFIED for users with no +casbin role for this hackathon; `is_waiting` is false once approved. + + +| Field | Type | Label | Description | +| ----- | ---- | ----- | ----------- | +| user | [user.entities.User](#user-entities-User) | | | +| role | [HackathonRole](#hackathon-entities-HackathonRole) | | | +| is_waiting | [bool](#bool) | | | +| joined_at | [google.protobuf.Timestamp](#google-protobuf-Timestamp) | | | + + + + + + + + + + + + + + + +<a name="hackathon_entities_hackathon_settings-proto"></a> +<p align="right"><a href="#top">Top</a></p> + +## hackathon/entities/hackathon_settings.proto + + + +<a name="hackathon-entities-HackathonSettings"></a> + +### HackathonSettings + + + +| Field | Type | Label | Description | +| ----- | ---- | ----- | ----------- | +| id | [string](#string) | | | +| registrations_enabled | [bool](#bool) | | | +| voting_enabled | [bool](#bool) | | | +| modified_at | [google.protobuf.Timestamp](#google-protobuf-Timestamp) | | | + + + + + + + + + + + + + + + +<a name="hackathon_entities_hackathon_state-proto"></a> +<p align="right"><a href="#top">Top</a></p> + +## hackathon/entities/hackathon_state.proto + + + +<a name="hackathon-entities-CapabilityToggle"></a> + +### CapabilityToggle +Main calls this message `CapabilityState`. It cannot keep that name here: +`hackathon.entities.CapabilityState` is already an ENUM in this package — +COMING / OPEN / CLOSED / UNGOVERNED — and the two would collide outright. +`Toggle` is also the truer name. This carries a boolean intent, in or out; +the four-state answer the server computes from it is the enum. + +The message NAME is not on the wire, so a main client decoding field 5 of +`HackathonState`, or encoding `SetCapabilitiesRequest.capabilities`, is +unaffected by the rename. + + +| Field | Type | Label | Description | +| ----- | ---- | ----- | ----------- | +| capability | [Capability](#hackathon-entities-Capability) | | | +| enabled | [bool](#bool) | | | + + + + + + +<a name="hackathon-entities-HackathonState"></a> + +### HackathonState +A FAÇADE. `HackathonState` is upstream `main`'s shape for "what is switched on +in this event": one record of booleans plus the current phase. It is stored +nowhere here — there is no HackathonState table and no ent entity. Every field +is computed, per request, from the `Capability` rows that already back +`Hackathon.capabilities`. + +It exists so a client written against main's contract decodes ours, which is +why the field numbers below are main's verbatim. Read `Hackathon.capabilities` +instead if you are writing a new client: `CapabilityStatus` carries the four +states, the schedule and the audit that this message flattens away. + +**It carries no enforcement.** The gate is `requireCapability` reading the +stored rows; this message never reaches it. Main enforces by writing casbin +policy from `SetCapabilities`; that path is deliberately not ported, so +nothing here can open or close anything on its own. + + +| Field | Type | Label | Description | +| ----- | ---- | ----- | ----------- | +| id | [string](#string) | | The hackathon's own id. Main's state is a row with an identity of its own; ours is a projection, and the event it belongs to is the only honest identity available. One state per hackathon on both sides, so it is unique in the same way. | +| created_at | [google.protobuf.Timestamp](#google-protobuf-Timestamp) | | The hackathon's created_at: the capability rows are created with the event. | +| modified_at | [google.protobuf.Timestamp](#google-protobuf-Timestamp) | | The most recent modification across the capability rows — when the state last actually changed — falling back to the hackathon's own modified_at when no row has been touched. | +| current_phase_id | [string](#string) | | Empty when no phase has been declared current, matching main. `Hackathon` reports the same value as an optional field. | +| capabilities | [CapabilityToggle](#hackathon-entities-CapabilityToggle) | repeated | One entry per capability in the vocabulary, in vocabulary order, including the ones with no stored row. + +`enabled` is the projection `state == OPEN || state == UNGOVERNED`: the same predicate `capability.State.Allowed` uses to admit a mutation, so a client reading this boolean is told exactly what the server will permit. COMING and CLOSED both flatten to false — the distinction between "not yet" and "no longer" survives only in `CapabilityStatus`. | + + + + + + + + + + + + + + + +<a name="hackathon_entities_hackathon_status-proto"></a> +<p align="right"><a href="#top">Top</a></p> + +## hackathon/entities/hackathon_status.proto + + + + + +<a name="hackathon-entities-HackathonStatus"></a> + +### HackathonStatus + + +| Name | Number | Description | +| ---- | ------ | ----------- | +| HACKATHON_STATUS_UNSPECIFIED | 0 | | +| HACKATHON_STATUS_PENDING | 1 | | +| HACKATHON_STATUS_ACTIVE | 2 | | +| HACKATHON_STATUS_FINISHED | 3 | | + + + + + + + + + + +<a name="hackathon_entities_hackathon_voting_policy-proto"></a> +<p align="right"><a href="#top">Top</a></p> + +## hackathon/entities/hackathon_voting_policy.proto + + + +<a name="hackathon-entities-HackathonVotingPolicy"></a> + +### HackathonVotingPolicy +How this event's vote works, as ConfigService.SetVotingPolicy stored it. + +On the entity rather than behind a GetVotingPolicy RPC because these are the +RULES OF A VOTE, and the people bound by them are exactly the people who +should be able to read them: "may I vote for my own team" is a question a +voter asks, not an organizer's private setting. + +Absent when no policy was ever set, which the backend reads as its defaults — +organizers do not vote, voting for your own team is allowed. `SubmitVote` +enforces both (they were stored and ignored until then). + + +| Field | Type | Label | Description | +| ----- | ---- | ----- | ----------- | +| mechanism | [string](#string) | | Free text describing the count, e.g. "single_choice". Documented rather than enforced: one vote per category is the only mechanism implemented. | +| one_ballot_per | [string](#string) | | What one ballot covers, e.g. "category". | +| own_team_voting | [bool](#bool) | | Whether a voter may vote for a submission from a team they are on. | +| organizer_voting | [bool](#bool) | | Whether the event's own organizers may vote in it. | +| tie_break | [string](#string) | repeated | Ordered tie-break rules, as the organizer wrote them. Advisory: the organizer records the winners, so a tie is theirs to break. | +| scale | [ScaleRange](#hackathon-entities-ScaleRange) | optional | Present only for scored mechanisms. | + + + + + + +<a name="hackathon-entities-ScaleRange"></a> + +### ScaleRange + + + +| Field | Type | Label | Description | +| ----- | ---- | ----- | ----------- | +| min | [int32](#int32) | | | +| max | [int32](#int32) | | | + + + + + + + + + + + + + + + +<a name="hackathon_entities_page-proto"></a> +<p align="right"><a href="#top">Top</a></p> + +## hackathon/entities/page.proto + + + +<a name="hackathon-entities-Page"></a> + +### Page + + + +| Field | Type | Label | Description | +| ----- | ---- | ----- | ----------- | +| id | [string](#string) | | | +| title | [string](#string) | | | +| content | [string](#string) | | | +| visible | [bool](#bool) | | | +| order | [int32](#int32) | | | +| created_at | [google.protobuf.Timestamp](#google-protobuf-Timestamp) | | | +| modified_at | [google.protobuf.Timestamp](#google-protobuf-Timestamp) | | | +| hackathon_id | [string](#string) | | | +| phase_id | [string](#string) | optional | | +| creator_id | [string](#string) | | | +| modifier_id | [string](#string) | | | + + + + + + + + + + + + + + + +<a name="hackathon_entities_phase-proto"></a> +<p align="right"><a href="#top">Top</a></p> + +## hackathon/entities/phase.proto + + + +<a name="hackathon-entities-Phase"></a> + +### Phase + + + +| Field | Type | Label | Description | +| ----- | ---- | ----- | ----------- | +| id | [string](#string) | | | +| name | [string](#string) | | | +| description | [string](#string) | optional | | +| starts_at | [google.protobuf.Timestamp](#google-protobuf-Timestamp) | optional | | +| ends_at | [google.protobuf.Timestamp](#google-protobuf-Timestamp) | optional | | +| created_at | [google.protobuf.Timestamp](#google-protobuf-Timestamp) | | | +| modified_at | [google.protobuf.Timestamp](#google-protobuf-Timestamp) | | | +| hackathon_id | [string](#string) | | | +| page_id | [string](#string) | optional | | +| creator_id | [string](#string) | | | +| modifier_id | [string](#string) | | | + + + + + + + + + + + + + + + +<a name="hackathon_entities_project_status-proto"></a> +<p align="right"><a href="#top">Top</a></p> + +## hackathon/entities/project_status.proto + + + + + +<a name="hackathon-entities-ProjectStatus"></a> + +### ProjectStatus + + +| Name | Number | Description | +| ---- | ------ | ----------- | +| PROJECT_STATUS_UNSPECIFIED | 0 | | +| PROJECT_STATUS_PROPOSED | 1 | | +| PROJECT_STATUS_APPROVED | 2 | | + + + + + + + + + + +<a name="hackathon_entities_project-proto"></a> +<p align="right"><a href="#top">Top</a></p> + +## hackathon/entities/project.proto + + + +<a name="hackathon-entities-Project"></a> + +### Project + + + +| Field | Type | Label | Description | +| ----- | ---- | ----- | ----------- | +| id | [string](#string) | | | +| title | [string](#string) | | | +| description | [string](#string) | | | +| status | [ProjectStatus](#hackathon-entities-ProjectStatus) | | | +| image | [string](#string) | optional | | +| created_at | [google.protobuf.Timestamp](#google-protobuf-Timestamp) | | | +| modified_at | [google.protobuf.Timestamp](#google-protobuf-Timestamp) | | | +| track_id | [string](#string) | | | +| hackathon_id | [string](#string) | | | +| creator_id | [string](#string) | | | +| modifier_id | [string](#string) | | | + + + + + + + + + + + + + + + +<a name="hackathon_entities_track-proto"></a> +<p align="right"><a href="#top">Top</a></p> + +## hackathon/entities/track.proto + + + +<a name="hackathon-entities-Track"></a> + +### Track + + + +| Field | Type | Label | Description | +| ----- | ---- | ----- | ----------- | +| id | [string](#string) | | | +| name | [string](#string) | | | +| description | [string](#string) | | | +| created_at | [google.protobuf.Timestamp](#google-protobuf-Timestamp) | | | +| modified_at | [google.protobuf.Timestamp](#google-protobuf-Timestamp) | | | +| hackathon_id | [string](#string) | | | + + + + + + + + + + + + + + + +<a name="hackathon_entities_visibility-proto"></a> +<p align="right"><a href="#top">Top</a></p> + +## hackathon/entities/visibility.proto + + + + + +<a name="hackathon-entities-Visibility"></a> + +### Visibility + + +| Name | Number | Description | +| ---- | ------ | ----------- | +| VISIBILITY_UNSPECIFIED | 0 | | +| VISIBILITY_PUBLIC | 1 | | +| VISIBILITY_PRIVATE | 2 | | + + + + + + + + + + +<a name="hackathon_entities_hackathon-proto"></a> +<p align="right"><a href="#top">Top</a></p> + +## hackathon/entities/hackathon.proto + + + +<a name="hackathon-entities-Hackathon"></a> + +### Hackathon + + + +| Field | Type | Label | Description | +| ----- | ---- | ----- | ----------- | +| id | [string](#string) | | | +| name | [string](#string) | | | +| starts_at | [google.protobuf.Timestamp](#google-protobuf-Timestamp) | optional | | +| ends_at | [google.protobuf.Timestamp](#google-protobuf-Timestamp) | optional | | +| created_at | [google.protobuf.Timestamp](#google-protobuf-Timestamp) | | | +| modified_at | [google.protobuf.Timestamp](#google-protobuf-Timestamp) | | | +| visibility | [Visibility](#hackathon-entities-Visibility) | | | +| status | [HackathonStatus](#hackathon-entities-HackathonStatus) | | Computed server-side from starts_at/ends_at; not persisted in DB. | +| description | [string](#string) | optional | | +| logo | [string](#string) | optional | | +| members | [HackathonMember](#hackathon-entities-HackathonMember) | repeated | | +| creator | [user.entities.User](#user-entities-User) | | | +| modifier | [user.entities.User](#user-entities-User) | | | +| tracks | [Track](#hackathon-entities-Track) | repeated | The following collections are populated only on Get responses. | +| projects | [Project](#hackathon-entities-Project) | repeated | | +| pages | [Page](#hackathon-entities-Page) | repeated | | +| phases | [Phase](#hackathon-entities-Phase) | repeated | | +| viewer_membership | [HackathonMember](#hackathon-entities-HackathonMember) | optional | Populated in List responses only when participant_id filter is set. Contains the requesting user's membership in this hackathon (role + is_waiting). | +| settings | [HackathonSettings](#hackathon-entities-HackathonSettings) | | Populated in Get responses only. | +| capabilities | [CapabilityStatus](#hackathon-entities-CapabilityStatus) | repeated | Computed server-side from the stored capability rows; not persisted as a whole. Populated on both Get and List, so a list can gate its own buttons rather than firing a mutation to discover something is closed. + +Will become caller-dependent, so clients must not cache it across users. | +| current_phase_id | [string](#string) | optional | The phase an organizer declared current via AdvancePhase. Absent means clients should derive it from phase dates instead — correct before an event, wrong during one, where the schedule slips. | +| branding | [HackathonBranding](#hackathon-entities-HackathonBranding) | optional | Set by ConfigService.SetBranding. Populated on Get and on List — the public event page is built from List, so leaving it Get-only would make an event's own colours invisible exactly where visitors see it. Absent when the organizer set no branding. | +| registration_form | [FormSchema](#hackathon-entities-FormSchema) | optional | Organizer-defined form schemas (ConfigService.SetRegistrationForm / SetSubmissionForm). Populated on Get only — a client needs them to RENDER the form it is about to submit, and without a read path the only way to fill one in was to guess the field keys. Absent when no form is defined, which means "accept anything" on the write side. | +| submission_form | [FormSchema](#hackathon-entities-FormSchema) | optional | | +| email_templates | [Hackathon.EmailTemplatesEntry](#hackathon-entities-Hackathon-EmailTemplatesEntry) | repeated | Organizer-authored notification copy (ConfigService.SetEmailTemplates), keyed "<moment>" for the body and "<moment>Subject" for the subject line. Get only, and readable by members: it is copy about the event, not a secret — but nothing sends it, so organizers compose from it by hand. | +| voting_policy | [HackathonVotingPolicy](#hackathon-entities-HackathonVotingPolicy) | optional | How the vote works (ConfigService.SetVotingPolicy). Readable by anyone who can read the hackathon, because these are the rules the voters are bound by: "may I vote for my own team" is a voter's question. Absent when the organizer set no policy, which means the backend's defaults. | +| state | [HackathonState](#hackathon-entities-HackathonState) | | A FAÇADE over `capabilities` above, in main's flat boolean shape — see `hackathon_state.proto`. Nothing is stored for it and nothing enforces from it; it is `capabilities` projected through `state == OPEN || state == UNGOVERNED`, plus `current_phase_id`. + +Populated wherever `capabilities` is, which is Get AND List: a facade that appeared on only one of them would be a worse contract than no facade. + +Tag 27 because main's 19 is our `settings` and 1-26 are all in use here. A main client therefore finds `state` at a different number than it expects — the shape is compatible, the address on this message is not, and renumbering shipped fields to fix that would break every caller we have. | +| max_participants | [int32](#int32) | optional | Maximum number of CONFIRMED participants; people on the waiting list do not consume a place. Absent or 0 means unlimited. When set, Join hands out free places first-come-first-served while nobody is waiting, and a full event queues new registrants instead of refusing them. Organizers may approve past this number — it is their estimate of the room, not a law. | + + + + + + +<a name="hackathon-entities-Hackathon-EmailTemplatesEntry"></a> + +### Hackathon.EmailTemplatesEntry + + + +| Field | Type | Label | Description | +| ----- | ---- | ----- | ----------- | +| key | [string](#string) | | | +| value | [string](#string) | | | + + + + + + + + + + + + + + + +<a name="hackathon_entities_hackathon_invite-proto"></a> +<p align="right"><a href="#top">Top</a></p> + +## hackathon/entities/hackathon_invite.proto + + + +<a name="hackathon-entities-HackathonInvite"></a> + +### HackathonInvite +A revocable, shareable invitation link for a private hackathon. +Organizer-facing: `token` is the secret in the URL, so this message is only +ever returned to callers who may write the hackathon. + + +| Field | Type | Label | Description | +| ----- | ---- | ----- | ----------- | +| id | [string](#string) | | | +| token | [string](#string) | | | +| hackathon_id | [string](#string) | | | +| note | [string](#string) | | | +| created_at | [google.protobuf.Timestamp](#google-protobuf-Timestamp) | | | +| revoked_at | [google.protobuf.Timestamp](#google-protobuf-Timestamp) | optional | Absent while the link still works. | +| creator_id | [string](#string) | | | + + + + + + + + + + + + + + + +<a name="hackathon_entities_prize-proto"></a> +<p align="right"><a href="#top">Top</a></p> + +## hackathon/entities/prize.proto + + + +<a name="hackathon-entities-Award"></a> + +### Award +Award attaches a submission to a prize once the admin finalizes: by rank +for the ranked prizes, by name for special ones. Votes are advisory until +this happens — the admin has the final voice. + + +| Field | Type | Label | Description | +| ----- | ---- | ----- | ----------- | +| rank | [int32](#int32) | optional | | +| special | [string](#string) | optional | | +| submission_id | [string](#string) | | | + + + + + + +<a name="hackathon-entities-Prize"></a> + +### Prize +Prize is one row of the organizer-defined prize table. rank 0 marks a +discretionary/special prize (e.g. Community Choice). + + +| Field | Type | Label | Description | +| ----- | ---- | ----- | ----------- | +| rank | [int32](#int32) | | | +| title | [string](#string) | | | +| image | [string](#string) | optional | A picture for the prize — the trophy, the sponsor's award, the plaque. Optional, and empty for every prize table written before this existed. + +No migration: the prize table is stored as flexible JSON (`[]map[string]any` in db/schema/hackathonprizes.go), so a new key costs nothing at rest. Same shape as Hackathon.logo — a root-relative /objects path from StorageService, or an http(s) URL. | + + + + + + + + + + + + + + + +<a name="hackathon_entities_project_preference-proto"></a> +<p align="right"><a href="#top">Top</a></p> + +## hackathon/entities/project_preference.proto + + + +<a name="hackathon-entities-ProjectWithPreferences"></a> + +### ProjectWithPreferences + + + +| Field | Type | Label | Description | +| ----- | ---- | ----- | ----------- | +| id | [string](#string) | | | +| title | [string](#string) | | | +| description | [string](#string) | | | +| status | [ProjectStatus](#hackathon-entities-ProjectStatus) | | | +| image | [string](#string) | optional | | +| created_at | [google.protobuf.Timestamp](#google-protobuf-Timestamp) | | | +| modified_at | [google.protobuf.Timestamp](#google-protobuf-Timestamp) | | | +| track_id | [string](#string) | | | +| hackathon_id | [string](#string) | | | +| preferences | [user.entities.User](#user-entities-User) | repeated | List of users who have this project as their preference | + + + + + + + + + + + + + + + +<a name="hackathon_entities_submission_status-proto"></a> +<p align="right"><a href="#top">Top</a></p> + +## hackathon/entities/submission_status.proto + + + + + +<a name="hackathon-entities-SubmissionStatus"></a> + +### SubmissionStatus + + +| Name | Number | Description | +| ---- | ------ | ----------- | +| SUBMISSION_STATUS_UNSPECIFIED | 0 | | +| SUBMISSION_STATUS_DRAFT | 1 | | +| SUBMISSION_STATUS_FINAL | 2 | | + + + + + + + + + + +<a name="hackathon_entities_submission-proto"></a> +<p align="right"><a href="#top">Top</a></p> + +## hackathon/entities/submission.proto + + + +<a name="hackathon-entities-Submission"></a> + +### Submission + + + +| Field | Type | Label | Description | +| ----- | ---- | ----- | ----------- | +| id | [string](#string) | | | +| result | [string](#string) | optional | | +| status | [SubmissionStatus](#hackathon-entities-SubmissionStatus) | | | +| version | [int32](#int32) | | | +| created_at | [google.protobuf.Timestamp](#google-protobuf-Timestamp) | | | +| modified_at | [google.protobuf.Timestamp](#google-protobuf-Timestamp) | | | +| team_id | [string](#string) | | | +| project_id | [string](#string) | | | +| creator_id | [string](#string) | | | +| modifier_id | [string](#string) | optional | | + + + + + + + + + + + + + + + +<a name="hackathon_entities_team-proto"></a> +<p align="right"><a href="#top">Top</a></p> + +## hackathon/entities/team.proto + + + +<a name="hackathon-entities-Team"></a> + +### Team + + + +| Field | Type | Label | Description | +| ----- | ---- | ----- | ----------- | +| id | [string](#string) | | | +| name | [string](#string) | | | +| description | [string](#string) | optional | | +| created_at | [google.protobuf.Timestamp](#google-protobuf-Timestamp) | | | +| modified_at | [google.protobuf.Timestamp](#google-protobuf-Timestamp) | | | +| project_id | [string](#string) | | | +| creator_id | [string](#string) | | | +| modifier_id | [string](#string) | optional | | +| members | [user.entities.User](#user-entities-User) | repeated | | +| submissions | [Submission](#hackathon-entities-Submission) | repeated | | + + + + + + + + + + + + + + + +<a name="hackathon_messages_hackathon_svc_add_owner_request-proto"></a> +<p align="right"><a href="#top">Top</a></p> + +## hackathon/messages/hackathon_svc/add_owner_request.proto + + + +<a name="hackathon-messages-hackathon_svc-AddOwnerRequest"></a> + +### AddOwnerRequest + + + +| Field | Type | Label | Description | +| ----- | ---- | ----- | ----------- | +| hackathon_id | [string](#string) | | | +| user_id | [string](#string) | | | + + + + + + + + + + + + + + + +<a name="hackathon_messages_hackathon_svc_add_owner_response-proto"></a> +<p align="right"><a href="#top">Top</a></p> + +## hackathon/messages/hackathon_svc/add_owner_response.proto + + + +<a name="hackathon-messages-hackathon_svc-AddOwnerResponse"></a> + +### AddOwnerResponse + + + + + + + + + + + + + + + + +<a name="hackathon_messages_hackathon_svc_advance_phase_request-proto"></a> +<p align="right"><a href="#top">Top</a></p> + +## hackathon/messages/hackathon_svc/advance_phase_request.proto + + + +<a name="hackathon-messages-hackathon_svc-AdvancePhaseRequest"></a> + +### AdvancePhaseRequest + + + +| Field | Type | Label | Description | +| ----- | ---- | ----- | ----------- | +| hackathon_id | [string](#string) | | | +| phase_id | [string](#string) | | The phase the hackathon is now in. Must belong to this hackathon. + +EMPTY means "no current phase" — the organizer is clearing the pointer, and the timeline goes back to being read from dates alone. A plain `uuid = true` rule rejected that before the handler ever saw it, which is why the "Clear current phase" button could never succeed: it sends no phase_id by design. + +Clearing deliberately does NOT touch capabilities. Advancing applies the ones scheduled for the target phase; with no target there is nothing to apply, and silently switching things off because someone cleared a label would be the opposite of what they asked for. | + + + + + + + + + + + + + + + +<a name="hackathon_messages_hackathon_svc_advance_phase_response-proto"></a> +<p align="right"><a href="#top">Top</a></p> + +## hackathon/messages/hackathon_svc/advance_phase_response.proto + + + +<a name="hackathon-messages-hackathon_svc-AdvancePhaseResponse"></a> + +### AdvancePhaseResponse + + + +| Field | Type | Label | Description | +| ----- | ---- | ----- | ----------- | +| current_phase_id | [string](#string) | | | +| capabilities | [hackathon.entities.CapabilityStatus](#hackathon-entities-CapabilityStatus) | repeated | Every capability after the move, so the caller can show what changed rather than re-fetching the hackathon. | + + + + + + + + + + + + + + + +<a name="hackathon_messages_hackathon_svc_approve_participant_request-proto"></a> +<p align="right"><a href="#top">Top</a></p> + +## hackathon/messages/hackathon_svc/approve_participant_request.proto + + + +<a name="hackathon-messages-hackathon_svc-ApproveParticipantRequest"></a> + +### ApproveParticipantRequest + + + +| Field | Type | Label | Description | +| ----- | ---- | ----- | ----------- | +| hackathon_id | [string](#string) | | | +| user_id | [string](#string) | | | + + + + + + + + + + + + + + + +<a name="hackathon_messages_hackathon_svc_approve_participant_response-proto"></a> +<p align="right"><a href="#top">Top</a></p> + +## hackathon/messages/hackathon_svc/approve_participant_response.proto + + + +<a name="hackathon-messages-hackathon_svc-ApproveParticipantResponse"></a> + +### ApproveParticipantResponse + + + + + + + + + + + + + + + + +<a name="hackathon_messages_hackathon_svc_create_invite_request-proto"></a> +<p align="right"><a href="#top">Top</a></p> + +## hackathon/messages/hackathon_svc/create_invite_request.proto + + + +<a name="hackathon-messages-hackathon_svc-CreateInviteRequest"></a> + +### CreateInviteRequest + + + +| Field | Type | Label | Description | +| ----- | ---- | ----- | ----------- | +| hackathon_id | [string](#string) | | | +| note | [string](#string) | optional | Optional organizer-facing reminder of who the link was sent to. | + + + + + + + + + + + + + + + +<a name="hackathon_messages_hackathon_svc_create_invite_response-proto"></a> +<p align="right"><a href="#top">Top</a></p> + +## hackathon/messages/hackathon_svc/create_invite_response.proto + + + +<a name="hackathon-messages-hackathon_svc-CreateInviteResponse"></a> + +### CreateInviteResponse + + + +| Field | Type | Label | Description | +| ----- | ---- | ----- | ----------- | +| invite | [hackathon.entities.HackathonInvite](#hackathon-entities-HackathonInvite) | | | + + + + + + + + + + + + + + + +<a name="hackathon_messages_hackathon_svc_create_request-proto"></a> +<p align="right"><a href="#top">Top</a></p> + +## hackathon/messages/hackathon_svc/create_request.proto + + + +<a name="hackathon-messages-hackathon_svc-CreateRequest"></a> + +### CreateRequest + + + +| Field | Type | Label | Description | +| ----- | ---- | ----- | ----------- | +| name | [string](#string) | | | +| starts_at | [google.protobuf.Timestamp](#google-protobuf-Timestamp) | optional | | +| ends_at | [google.protobuf.Timestamp](#google-protobuf-Timestamp) | optional | | +| visibility | [hackathon.entities.Visibility](#hackathon-entities-Visibility) | | | +| description | [string](#string) | optional | | +| logo | [string](#string) | optional | A `uri = true` rule here rejected BOTH values this platform actually produces: the empty string (an event with no cover yet, which is every event at Create time) and the root-relative `/objects/...` path that StorageService returns and cmd/seed writes. Only a full absolute URL passed — the one shape we do not generate. Edit.logo never had the rule, so the same value was accepted or refused depending on which RPC you used. | +| max_participants | [int32](#int32) | optional | Maximum number of CONFIRMED participants. 0 or absent means unlimited. | + + + + + + + + + + + + + + + +<a name="hackathon_messages_hackathon_svc_create_response-proto"></a> +<p align="right"><a href="#top">Top</a></p> + +## hackathon/messages/hackathon_svc/create_response.proto + + + +<a name="hackathon-messages-hackathon_svc-CreateResponse"></a> + +### CreateResponse + + + +| Field | Type | Label | Description | +| ----- | ---- | ----- | ----------- | +| hackathon_id | [string](#string) | | | + + + + + + + + + + + + + + + +<a name="hackathon_messages_hackathon_svc_delete_request-proto"></a> +<p align="right"><a href="#top">Top</a></p> + +## hackathon/messages/hackathon_svc/delete_request.proto + + + +<a name="hackathon-messages-hackathon_svc-DeleteRequest"></a> + +### DeleteRequest + + + +| Field | Type | Label | Description | +| ----- | ---- | ----- | ----------- | +| hackathon_id | [string](#string) | | | + + + + + + + + + + + + + + + +<a name="hackathon_messages_hackathon_svc_delete_response-proto"></a> +<p align="right"><a href="#top">Top</a></p> + +## hackathon/messages/hackathon_svc/delete_response.proto + + + +<a name="hackathon-messages-hackathon_svc-DeleteResponse"></a> + +### DeleteResponse + + + + + + + + + + + + + + + + +<a name="hackathon_messages_hackathon_svc_edit_capability_request-proto"></a> +<p align="right"><a href="#top">Top</a></p> + +## hackathon/messages/hackathon_svc/edit_capability_request.proto + + + +<a name="hackathon-messages-hackathon_svc-EditCapabilityRequest"></a> + +### EditCapabilityRequest + + + +| Field | Type | Label | Description | +| ----- | ---- | ----- | ----------- | +| hackathon_id | [string](#string) | | | +| capability | [hackathon.entities.Capability](#hackathon-entities-Capability) | | Identifies the row, so it is required rather than optional — unlike the mutable fields of the other Edit requests. | +| enabled | [bool](#bool) | optional | | +| open_in_phase_id | [string](#string) | optional | Schedule links, for display only — setting these never opens or closes anything, only `enabled` does. + +Empty string = unlink, non-empty = link to that phase, not set = no change. Same convention as phase_svc/edit_request.proto's page_id. | +| closed_in_phase_id | [string](#string) | optional | | + + + + + + + + + + + + + + + +<a name="hackathon_messages_hackathon_svc_edit_capability_response-proto"></a> +<p align="right"><a href="#top">Top</a></p> + +## hackathon/messages/hackathon_svc/edit_capability_response.proto + + + +<a name="hackathon-messages-hackathon_svc-EditCapabilityResponse"></a> + +### EditCapabilityResponse + + + +| Field | Type | Label | Description | +| ----- | ---- | ----- | ----------- | +| capability | [hackathon.entities.CapabilityStatus](#hackathon-entities-CapabilityStatus) | | | + + + + + + + + + + + + + + + +<a name="hackathon_messages_hackathon_svc_edit_request-proto"></a> +<p align="right"><a href="#top">Top</a></p> + +## hackathon/messages/hackathon_svc/edit_request.proto + + + +<a name="hackathon-messages-hackathon_svc-EditRequest"></a> + +### EditRequest + + + +| Field | Type | Label | Description | +| ----- | ---- | ----- | ----------- | +| hackathon_id | [string](#string) | | | +| name | [string](#string) | optional | | +| description | [string](#string) | optional | | +| visibility | [hackathon.entities.Visibility](#hackathon-entities-Visibility) | optional | | +| starts_at | [google.protobuf.Timestamp](#google-protobuf-Timestamp) | optional | | +| ends_at | [google.protobuf.Timestamp](#google-protobuf-Timestamp) | optional | | +| logo | [string](#string) | optional | Same rule as Create.logo, so a value is accepted or refused by what it IS rather than by which RPC it arrived on. | +| max_participants | [int32](#int32) | optional | Absent leaves the capacity unchanged; 0 clears it back to unlimited. Lowering it below the current confirmed count is allowed and removes nobody — the number is the organizer's estimate, the roster is theirs. | + + + + + + + + + + + + + + + +<a name="hackathon_messages_hackathon_svc_edit_response-proto"></a> +<p align="right"><a href="#top">Top</a></p> + +## hackathon/messages/hackathon_svc/edit_response.proto + + + +<a name="hackathon-messages-hackathon_svc-EditResponse"></a> + +### EditResponse + + + +| Field | Type | Label | Description | +| ----- | ---- | ----- | ----------- | +| hackathon | [hackathon.entities.Hackathon](#hackathon-entities-Hackathon) | | | + + + + + + + + + + + + + + + +<a name="hackathon_messages_hackathon_svc_edit_settings_request-proto"></a> +<p align="right"><a href="#top">Top</a></p> + +## hackathon/messages/hackathon_svc/edit_settings_request.proto + + + +<a name="hackathon-messages-hackathon_svc-EditSettingsRequest"></a> + +### EditSettingsRequest + + + +| Field | Type | Label | Description | +| ----- | ---- | ----- | ----------- | +| hackathon_id | [string](#string) | | | +| registrations_enabled | [bool](#bool) | optional | | +| voting_enabled | [bool](#bool) | optional | | + + + + + + + + + + + + + + + +<a name="hackathon_messages_hackathon_svc_edit_settings_response-proto"></a> +<p align="right"><a href="#top">Top</a></p> + +## hackathon/messages/hackathon_svc/edit_settings_response.proto + + + +<a name="hackathon-messages-hackathon_svc-EditSettingsResponse"></a> + +### EditSettingsResponse + + + +| Field | Type | Label | Description | +| ----- | ---- | ----- | ----------- | +| settings | [hackathon.entities.HackathonSettings](#hackathon-entities-HackathonSettings) | | | + + + + + + + + + + + + + + + +<a name="hackathon_messages_hackathon_svc_get_registration_response_request-proto"></a> +<p align="right"><a href="#top">Top</a></p> + +## hackathon/messages/hackathon_svc/get_registration_response_request.proto + + + +<a name="hackathon-messages-hackathon_svc-GetRegistrationResponseRequest"></a> + +### GetRegistrationResponseRequest +Reads back the registration answers already on file, so a registrant can see +and correct what they submitted. + +Deliberately NOT part of HackathonService.Get: Get denies waitlisted users, +and a waitlisted user is exactly who still needs to review their form. This +takes only the hackathon id and answers for the caller. + +`user_id` lets an organizer (hackathon Write) read someone else's answers — +the check-in desk correcting a paper form, mirroring `on_behalf_of` on +SubmitRegistrationForm. + + +| Field | Type | Label | Description | +| ----- | ---- | ----- | ----------- | +| hackathon_id | [string](#string) | | | +| user_id | [string](#string) | optional | | + + + + + + + + + + + + + + + +<a name="hackathon_messages_hackathon_svc_get_registration_response_response-proto"></a> +<p align="right"><a href="#top">Top</a></p> + +## hackathon/messages/hackathon_svc/get_registration_response_response.proto + + + +<a name="hackathon-messages-hackathon_svc-GetRegistrationResponseResponse"></a> + +### GetRegistrationResponseResponse +`submitted` distinguishes "no answers on file" from "answers that happen to +be empty" — an empty form is a legitimate state when every field is +optional, so a bare empty map would be ambiguous. + + +| Field | Type | Label | Description | +| ----- | ---- | ----- | ----------- | +| submitted | [bool](#bool) | | | +| responses | [google.protobuf.Struct](#google-protobuf-Struct) | | | +| consents | [GetRegistrationResponseResponse.ConsentsEntry](#hackathon-messages-hackathon_svc-GetRegistrationResponseResponse-ConsentsEntry) | repeated | | +| submitted_at | [google.protobuf.Timestamp](#google-protobuf-Timestamp) | optional | | +| modified_at | [google.protobuf.Timestamp](#google-protobuf-Timestamp) | optional | | +| submitted_by_id | [string](#string) | optional | Set when someone else entered the answers (organizer-assisted signup). | + + + + + + +<a name="hackathon-messages-hackathon_svc-GetRegistrationResponseResponse-ConsentsEntry"></a> + +### GetRegistrationResponseResponse.ConsentsEntry + + + +| Field | Type | Label | Description | +| ----- | ---- | ----- | ----------- | +| key | [string](#string) | | | +| value | [bool](#bool) | | | + + + + + + + + + + + + + + + +<a name="hackathon_messages_hackathon_svc_get_request-proto"></a> +<p align="right"><a href="#top">Top</a></p> + +## hackathon/messages/hackathon_svc/get_request.proto + + + +<a name="hackathon-messages-hackathon_svc-GetRequest"></a> + +### GetRequest + + + +| Field | Type | Label | Description | +| ----- | ---- | ----- | ----------- | +| hackathon_id | [string](#string) | | | + + + + + + + + + + + + + + + +<a name="hackathon_messages_hackathon_svc_get_response-proto"></a> +<p align="right"><a href="#top">Top</a></p> + +## hackathon/messages/hackathon_svc/get_response.proto + + + +<a name="hackathon-messages-hackathon_svc-GetResponse"></a> + +### GetResponse + + + +| Field | Type | Label | Description | +| ----- | ---- | ----- | ----------- | +| hackathon | [hackathon.entities.Hackathon](#hackathon-entities-Hackathon) | | | + + + + + + + + + + + + + + + +<a name="hackathon_messages_hackathon_svc_join_request-proto"></a> +<p align="right"><a href="#top">Top</a></p> + +## hackathon/messages/hackathon_svc/join_request.proto + + + +<a name="hackathon-messages-hackathon_svc-JoinRequest"></a> + +### JoinRequest + + + +| Field | Type | Label | Description | +| ----- | ---- | ----- | ----------- | +| hackathon_id | [string](#string) | | | +| invite_token | [string](#string) | optional | Required to join a PRIVATE hackathon: the secret from the invitation link. Public hackathons ignore it. Without this a private event was joinable by anyone who knew its UUID. | + + + + + + + + + + + + + + + +<a name="hackathon_messages_hackathon_svc_join_response-proto"></a> +<p align="right"><a href="#top">Top</a></p> + +## hackathon/messages/hackathon_svc/join_response.proto + + + +<a name="hackathon-messages-hackathon_svc-JoinResponse"></a> + +### JoinResponse + + + +| Field | Type | Label | Description | +| ----- | ---- | ----- | ----------- | +| hackathon_id | [string](#string) | | | +| waitlisted | [bool](#bool) | | True when this join landed on the waiting list (pending an organizer's approval or a freed place); false when it took a confirmed place outright. Joining a full event SUCCEEDS and queues — this flag is how a client tells "you're in" from "you're number N in the queue". | +| queue_position | [int32](#int32) | | 1-based place in the waiting queue (join order) when waitlisted; 0 when confirmed. An idempotent re-join reports the caller's current state. | + + + + + + + + + + + + + + + +<a name="hackathon_messages_hackathon_svc_list_invites_request-proto"></a> +<p align="right"><a href="#top">Top</a></p> + +## hackathon/messages/hackathon_svc/list_invites_request.proto + + + +<a name="hackathon-messages-hackathon_svc-ListInvitesRequest"></a> + +### ListInvitesRequest + + + +| Field | Type | Label | Description | +| ----- | ---- | ----- | ----------- | +| hackathon_id | [string](#string) | | | +| include_revoked | [bool](#bool) | optional | Revoked links are hidden unless asked for. | + + + + + + + + + + + + + + + +<a name="hackathon_messages_hackathon_svc_list_invites_response-proto"></a> +<p align="right"><a href="#top">Top</a></p> + +## hackathon/messages/hackathon_svc/list_invites_response.proto + + + +<a name="hackathon-messages-hackathon_svc-ListInvitesResponse"></a> + +### ListInvitesResponse + + + +| Field | Type | Label | Description | +| ----- | ---- | ----- | ----------- | +| invites | [hackathon.entities.HackathonInvite](#hackathon-entities-HackathonInvite) | repeated | | + + + + + + + + + + + + + + + +<a name="hackathon_messages_hackathon_svc_list_registration_responses_request-proto"></a> +<p align="right"><a href="#top">Top</a></p> + +## hackathon/messages/hackathon_svc/list_registration_responses_request.proto + + + +<a name="hackathon-messages-hackathon_svc-ListRegistrationResponsesRequest"></a> + +### ListRegistrationResponsesRequest +Everyone's registration answers, for one hackathon, in one call. + +GetRegistrationResponse answers for ONE person, which is right for "show me +my own answers" and wrong for every organiser screen: staffing teams or +working a waitlist means reading the whole cohort, and doing that one RPC per +participant is a round-trip per row on every page load. + +Organizer-only — this returns what people wrote about themselves, so it takes +hackathon Write rather than Read. A fellow member cannot read it, the same +rule GetRegistrationResponse already enforces for another person's answers. + + +| Field | Type | Label | Description | +| ----- | ---- | ----- | ----------- | +| hackathon_id | [string](#string) | | | + + + + + + + + + + + + + + + +<a name="hackathon_messages_hackathon_svc_list_registration_responses_response-proto"></a> +<p align="right"><a href="#top">Top</a></p> + +## hackathon/messages/hackathon_svc/list_registration_responses_response.proto + + + +<a name="hackathon-messages-hackathon_svc-ListRegistrationResponsesResponse"></a> + +### ListRegistrationResponsesResponse + + + +| Field | Type | Label | Description | +| ----- | ---- | ----- | ----------- | +| responses | [RegistrationResponseEntry](#hackathon-messages-hackathon_svc-RegistrationResponseEntry) | repeated | | + + + + + + +<a name="hackathon-messages-hackathon_svc-RegistrationResponseEntry"></a> + +### RegistrationResponseEntry +One participant's answers. + +Only people who actually submitted appear. A row per participant with an +empty answer set would make "has not filled the form in" and "filled it in +and left everything blank" the same thing on the wire, and those are +different facts to an organiser chasing people. + + +| Field | Type | Label | Description | +| ----- | ---- | ----- | ----------- | +| user_id | [string](#string) | | The platform user id (the DB UUID), matching Hackathon.members[].user.id so a caller can join this onto the roster it already has. | +| responses | [google.protobuf.Struct](#google-protobuf-Struct) | | | +| consents | [RegistrationResponseEntry.ConsentsEntry](#hackathon-messages-hackathon_svc-RegistrationResponseEntry-ConsentsEntry) | repeated | | +| submitted_at | [google.protobuf.Timestamp](#google-protobuf-Timestamp) | | | +| modified_at | [google.protobuf.Timestamp](#google-protobuf-Timestamp) | | | + + + + + + +<a name="hackathon-messages-hackathon_svc-RegistrationResponseEntry-ConsentsEntry"></a> + +### RegistrationResponseEntry.ConsentsEntry + + + +| Field | Type | Label | Description | +| ----- | ---- | ----- | ----------- | +| key | [string](#string) | | | +| value | [bool](#bool) | | | + + + + + + + + + + + + + + + +<a name="hackathon_messages_hackathon_svc_list_request-proto"></a> +<p align="right"><a href="#top">Top</a></p> + +## hackathon/messages/hackathon_svc/list_request.proto + + + +<a name="hackathon-messages-hackathon_svc-ListRequest"></a> + +### ListRequest + + + +| Field | Type | Label | Description | +| ----- | ---- | ----- | ----------- | +| status_filter | [hackathon.entities.HackathonStatus](#hackathon-entities-HackathonStatus) | repeated | | +| owner_id | [string](#string) | optional | | +| participant_id | [string](#string) | optional | | +| visibility_filter | [hackathon.entities.Visibility](#hackathon-entities-Visibility) | optional | | + + + + + + + + + + + + + + + +<a name="hackathon_messages_hackathon_svc_list_response-proto"></a> +<p align="right"><a href="#top">Top</a></p> + +## hackathon/messages/hackathon_svc/list_response.proto + + + +<a name="hackathon-messages-hackathon_svc-ListResponse"></a> + +### ListResponse + + + +| Field | Type | Label | Description | +| ----- | ---- | ----- | ----------- | +| hackathons | [hackathon.entities.Hackathon](#hackathon-entities-Hackathon) | repeated | | + + + + + + + + + + + + + + + +<a name="hackathon_messages_hackathon_svc_preview_invite_request-proto"></a> +<p align="right"><a href="#top">Top</a></p> + +## hackathon/messages/hackathon_svc/preview_invite_request.proto + + + +<a name="hackathon-messages-hackathon_svc-PreviewInviteRequest"></a> + +### PreviewInviteRequest +Redeeming side of an invite: exchanges the link secret for enough of the +hackathon to render its page. Deliberately takes ONLY the token, so it never +confirms whether a given hackathon id exists. + + +| Field | Type | Label | Description | +| ----- | ---- | ----- | ----------- | +| token | [string](#string) | | | + + + + + + + + + + + + + + + +<a name="hackathon_messages_hackathon_svc_preview_invite_response-proto"></a> +<p align="right"><a href="#top">Top</a></p> + +## hackathon/messages/hackathon_svc/preview_invite_response.proto + + + +<a name="hackathon-messages-hackathon_svc-PreviewInviteResponse"></a> + +### PreviewInviteResponse + + + +| Field | Type | Label | Description | +| ----- | ---- | ----- | ----------- | +| hackathon | [hackathon.entities.Hackathon](#hackathon-entities-Hackathon) | | Shallow entity only — the invite grants visibility, not membership. | +| already_participant | [bool](#bool) | | True when the caller is already on this hackathon's roster. | + + + + + + + + + + + + + + + +<a name="hackathon_messages_hackathon_svc_remove_owner_request-proto"></a> +<p align="right"><a href="#top">Top</a></p> + +## hackathon/messages/hackathon_svc/remove_owner_request.proto + + + +<a name="hackathon-messages-hackathon_svc-RemoveOwnerRequest"></a> + +### RemoveOwnerRequest + + + +| Field | Type | Label | Description | +| ----- | ---- | ----- | ----------- | +| hackathon_id | [string](#string) | | | +| user_id | [string](#string) | | | + + + + + + + + + + + + + + + +<a name="hackathon_messages_hackathon_svc_remove_owner_response-proto"></a> +<p align="right"><a href="#top">Top</a></p> + +## hackathon/messages/hackathon_svc/remove_owner_response.proto + + + +<a name="hackathon-messages-hackathon_svc-RemoveOwnerResponse"></a> + +### RemoveOwnerResponse + + + + + + + + + + + + + + + + +<a name="hackathon_messages_hackathon_svc_remove_participant_request-proto"></a> +<p align="right"><a href="#top">Top</a></p> + +## hackathon/messages/hackathon_svc/remove_participant_request.proto + + + +<a name="hackathon-messages-hackathon_svc-RemoveParticipantRequest"></a> + +### RemoveParticipantRequest + + + +| Field | Type | Label | Description | +| ----- | ---- | ----- | ----------- | +| hackathon_id | [string](#string) | | | +| user_id | [string](#string) | | | + + + + + + + + + + + + + + + +<a name="hackathon_messages_hackathon_svc_remove_participant_response-proto"></a> +<p align="right"><a href="#top">Top</a></p> + +## hackathon/messages/hackathon_svc/remove_participant_response.proto + + + +<a name="hackathon-messages-hackathon_svc-RemoveParticipantResponse"></a> + +### RemoveParticipantResponse + + + + + + + + + + + + + + + + +<a name="hackathon_messages_hackathon_svc_revoke_invite_request-proto"></a> +<p align="right"><a href="#top">Top</a></p> + +## hackathon/messages/hackathon_svc/revoke_invite_request.proto + + + +<a name="hackathon-messages-hackathon_svc-RevokeInviteRequest"></a> + +### RevokeInviteRequest + + + +| Field | Type | Label | Description | +| ----- | ---- | ----- | ----------- | +| invite_id | [string](#string) | | | + + + + + + + + + + + + + + + +<a name="hackathon_messages_hackathon_svc_revoke_invite_response-proto"></a> +<p align="right"><a href="#top">Top</a></p> + +## hackathon/messages/hackathon_svc/revoke_invite_response.proto + + + +<a name="hackathon-messages-hackathon_svc-RevokeInviteResponse"></a> + +### RevokeInviteResponse + + + + + + + + + + + + + + + + +<a name="hackathon_messages_hackathon_svc_set_capabilities_request-proto"></a> +<p align="right"><a href="#top">Top</a></p> + +## hackathon/messages/hackathon_svc/set_capabilities_request.proto + + + +<a name="hackathon-messages-hackathon_svc-SetCapabilitiesRequest"></a> + +### SetCapabilitiesRequest +Batch form of EditCapability: an organiser toggling several switches at once +is one intent, and one call keeps it atomic instead of a burst the UI has to +sequence and half-undo when one of them fails. + +This is also main's write side of `HackathonState`, field-for-field, so a +client written against main's contract can drive our capability rows. What it +does NOT do is what main's does next: main's SetCapabilities writes casbin +policy rows, and that enforcement path is deliberately not ported. Here the +booleans land on the stored `Capability` rows — true opens, false closes — +and `requireCapability` remains the only gate. + + +| Field | Type | Label | Description | +| ----- | ---- | ----- | ----------- | +| hackathon_id | [string](#string) | | | +| capabilities | [hackathon.entities.CapabilityToggle](#hackathon-entities-CapabilityToggle) | repeated | `CapabilityToggle` is main's `CapabilityState` message, renamed because we already have an enum of that name in `hackathon.entities`. It lives in `entities/hackathon_state.proto` — shared with `HackathonState`, exactly as main shares it. The rename is invisible on the wire. | + + + + + + + + + + + + + + + +<a name="hackathon_messages_hackathon_svc_set_capabilities_response-proto"></a> +<p align="right"><a href="#top">Top</a></p> + +## hackathon/messages/hackathon_svc/set_capabilities_response.proto + + + +<a name="hackathon-messages-hackathon_svc-SetCapabilitiesResponse"></a> + +### SetCapabilitiesResponse +Returns the resulting capabilities in the same shape `Get` reports them, so a +caller re-renders from the response instead of refetching the hackathon. + +`CapabilityStatus`, not the booleans that went in: what the server enforces +is the four-state answer, including a schedule derived from the linked +phases, and a toggle is only one input to it. Echoing the request back would +hide the case where a capability is governed by a phase window and the +organiser's switch did not decide it. + + +| Field | Type | Label | Description | +| ----- | ---- | ----- | ----------- | +| capabilities | [hackathon.entities.CapabilityStatus](#hackathon-entities-CapabilityStatus) | repeated | | +| state | [hackathon.entities.HackathonState](#hackathon-entities-HackathonState) | | The same answer flattened into main's `HackathonState`, for clients written against that contract. Field 2, not 1: main puts `state` on tag 1 and ours is already taken by `capabilities`, and renumbering a shipped field to gain decode compatibility would break the callers we actually have. A main client reads `state` from `Get` instead, where the tag was free. | + + + + + + + + + + + + + + + +<a name="hackathon_messages_hackathon_svc_set_current_phase_request-proto"></a> +<p align="right"><a href="#top">Top</a></p> + +## hackathon/messages/hackathon_svc/set_current_phase_request.proto + + + +<a name="hackathon-messages-hackathon_svc-SetCurrentPhaseRequest"></a> + +### SetCurrentPhaseRequest +Main's name for what `AdvancePhase` does. Same two fields, same numbers, so a +client written against main's contract drives ours unchanged. + + +| Field | Type | Label | Description | +| ----- | ---- | ----- | ----------- | +| hackathon_id | [string](#string) | | | +| phase_id | [string](#string) | | EMPTY means "clear the current phase" — main's semantics, and ours: AdvancePhase has read an empty phase_id that way since the "Clear current phase" button was fixed. + +Clearing does not touch capabilities. Advancing applies the ones scheduled for the target phase; with no target there is nothing to apply, and switching things off because someone cleared a label would be the opposite of what they asked for. | + + + + + + + + + + + + + + + +<a name="hackathon_messages_hackathon_svc_set_current_phase_response-proto"></a> +<p align="right"><a href="#top">Top</a></p> + +## hackathon/messages/hackathon_svc/set_current_phase_response.proto + + + +<a name="hackathon-messages-hackathon_svc-SetCurrentPhaseResponse"></a> + +### SetCurrentPhaseResponse +Main's shape verbatim, tag included — this message is new here, so nothing +had to move to make room for it. Native callers should prefer `AdvancePhase`, +whose response carries the full `CapabilityStatus` list this one flattens. + + +| Field | Type | Label | Description | +| ----- | ---- | ----- | ----------- | +| state | [hackathon.entities.HackathonState](#hackathon-entities-HackathonState) | | | + + + + + + + + + + + + + + + +<a name="hackathon_messages_hackathon_svc_submit_registration_form_request-proto"></a> +<p align="right"><a href="#top">Top</a></p> + +## hackathon/messages/hackathon_svc/submit_registration_form_request.proto + + + +<a name="hackathon-messages-hackathon_svc-SubmitRegistrationFormRequest"></a> + +### SubmitRegistrationFormRequest +Responses are validated against the organizer-defined registration form: +unknown keys, missing required fields, and unticked required consents are +InvalidArgument. `on_behalf_of` lets an organizer digitize a paper form +for another registrant (walk-ins at the check-in desk). + + +| Field | Type | Label | Description | +| ----- | ---- | ----- | ----------- | +| hackathon_id | [string](#string) | | | +| responses | [google.protobuf.Struct](#google-protobuf-Struct) | | | +| consents | [SubmitRegistrationFormRequest.ConsentsEntry](#hackathon-messages-hackathon_svc-SubmitRegistrationFormRequest-ConsentsEntry) | repeated | | +| on_behalf_of | [string](#string) | optional | | + + + + + + +<a name="hackathon-messages-hackathon_svc-SubmitRegistrationFormRequest-ConsentsEntry"></a> + +### SubmitRegistrationFormRequest.ConsentsEntry + + + +| Field | Type | Label | Description | +| ----- | ---- | ----- | ----------- | +| key | [string](#string) | | | +| value | [bool](#bool) | | | + + + + + + + + + + + + + + + +<a name="hackathon_messages_hackathon_svc_submit_registration_form_response-proto"></a> +<p align="right"><a href="#top">Top</a></p> + +## hackathon/messages/hackathon_svc/submit_registration_form_response.proto + + + +<a name="hackathon-messages-hackathon_svc-SubmitRegistrationFormResponse"></a> + +### SubmitRegistrationFormResponse + + + +| Field | Type | Label | Description | +| ----- | ---- | ----- | ----------- | +| id | [string](#string) | | | + + + + + + + + + + + + + + + +<a name="hackathon_hackathon_service-proto"></a> +<p align="right"><a href="#top">Top</a></p> + +## hackathon/hackathon_service.proto + + + + + + + + + +<a name="hackathon-HackathonService"></a> + +### HackathonService + + +| Method Name | Request Type | Response Type | Description | +| ----------- | ------------ | ------------- | ------------| +| List | [messages.hackathon_svc.ListRequest](#hackathon-messages-hackathon_svc-ListRequest) | [messages.hackathon_svc.ListResponse](#hackathon-messages-hackathon_svc-ListResponse) | | +| Get | [messages.hackathon_svc.GetRequest](#hackathon-messages-hackathon_svc-GetRequest) | [messages.hackathon_svc.GetResponse](#hackathon-messages-hackathon_svc-GetResponse) | | +| Create | [messages.hackathon_svc.CreateRequest](#hackathon-messages-hackathon_svc-CreateRequest) | [messages.hackathon_svc.CreateResponse](#hackathon-messages-hackathon_svc-CreateResponse) | | +| Edit | [messages.hackathon_svc.EditRequest](#hackathon-messages-hackathon_svc-EditRequest) | [messages.hackathon_svc.EditResponse](#hackathon-messages-hackathon_svc-EditResponse) | | +| Delete | [messages.hackathon_svc.DeleteRequest](#hackathon-messages-hackathon_svc-DeleteRequest) | [messages.hackathon_svc.DeleteResponse](#hackathon-messages-hackathon_svc-DeleteResponse) | | +| EditCapability | [messages.hackathon_svc.EditCapabilityRequest](#hackathon-messages-hackathon_svc-EditCapabilityRequest) | [messages.hackathon_svc.EditCapabilityResponse](#hackathon-messages-hackathon_svc-EditCapabilityResponse) | | +| SetCapabilities | [messages.hackathon_svc.SetCapabilitiesRequest](#hackathon-messages-hackathon_svc-SetCapabilitiesRequest) | [messages.hackathon_svc.SetCapabilitiesResponse](#hackathon-messages-hackathon_svc-SetCapabilitiesResponse) | Batch form of EditCapability: an organiser toggles several at once, so one call is one intent rather than a burst the UI has to sequence. | +| AdvancePhase | [messages.hackathon_svc.AdvancePhaseRequest](#hackathon-messages-hackathon_svc-AdvancePhaseRequest) | [messages.hackathon_svc.AdvancePhaseResponse](#hackathon-messages-hackathon_svc-AdvancePhaseResponse) | | +| SetCurrentPhase | [messages.hackathon_svc.SetCurrentPhaseRequest](#hackathon-messages-hackathon_svc-SetCurrentPhaseRequest) | [messages.hackathon_svc.SetCurrentPhaseResponse](#hackathon-messages-hackathon_svc-SetCurrentPhaseResponse) | Main's name for AdvancePhase, and a thin alias over it: same authorisation, same capability application, same "empty phase_id clears it". Answers in main's flat HackathonState instead of the CapabilityStatus list, so native callers should keep using AdvancePhase. | +| EditSettings | [messages.hackathon_svc.EditSettingsRequest](#hackathon-messages-hackathon_svc-EditSettingsRequest) | [messages.hackathon_svc.EditSettingsResponse](#hackathon-messages-hackathon_svc-EditSettingsResponse) | | +| Join | [messages.hackathon_svc.JoinRequest](#hackathon-messages-hackathon_svc-JoinRequest) | [messages.hackathon_svc.JoinResponse](#hackathon-messages-hackathon_svc-JoinResponse) | | +| ApproveParticipant | [messages.hackathon_svc.ApproveParticipantRequest](#hackathon-messages-hackathon_svc-ApproveParticipantRequest) | [messages.hackathon_svc.ApproveParticipantResponse](#hackathon-messages-hackathon_svc-ApproveParticipantResponse) | | +| RemoveParticipant | [messages.hackathon_svc.RemoveParticipantRequest](#hackathon-messages-hackathon_svc-RemoveParticipantRequest) | [messages.hackathon_svc.RemoveParticipantResponse](#hackathon-messages-hackathon_svc-RemoveParticipantResponse) | | +| SubmitRegistrationForm | [messages.hackathon_svc.SubmitRegistrationFormRequest](#hackathon-messages-hackathon_svc-SubmitRegistrationFormRequest) | [messages.hackathon_svc.SubmitRegistrationFormResponse](#hackathon-messages-hackathon_svc-SubmitRegistrationFormResponse) | | +| GetRegistrationResponse | [messages.hackathon_svc.GetRegistrationResponseRequest](#hackathon-messages-hackathon_svc-GetRegistrationResponseRequest) | [messages.hackathon_svc.GetRegistrationResponseResponse](#hackathon-messages-hackathon_svc-GetRegistrationResponseResponse) | Reads the answers back so a registrant can review and correct them. | +| ListRegistrationResponses | [messages.hackathon_svc.ListRegistrationResponsesRequest](#hackathon-messages-hackathon_svc-ListRegistrationResponsesRequest) | [messages.hackathon_svc.ListRegistrationResponsesResponse](#hackathon-messages-hackathon_svc-ListRegistrationResponsesResponse) | | +| AddOwner | [messages.hackathon_svc.AddOwnerRequest](#hackathon-messages-hackathon_svc-AddOwnerRequest) | [messages.hackathon_svc.AddOwnerResponse](#hackathon-messages-hackathon_svc-AddOwnerResponse) | | +| RemoveOwner | [messages.hackathon_svc.RemoveOwnerRequest](#hackathon-messages-hackathon_svc-RemoveOwnerRequest) | [messages.hackathon_svc.RemoveOwnerResponse](#hackathon-messages-hackathon_svc-RemoveOwnerResponse) | | +| CreateInvite | [messages.hackathon_svc.CreateInviteRequest](#hackathon-messages-hackathon_svc-CreateInviteRequest) | [messages.hackathon_svc.CreateInviteResponse](#hackathon-messages-hackathon_svc-CreateInviteResponse) | --- Invitations: private-hackathon access, see HackathonInvite --- | +| ListInvites | [messages.hackathon_svc.ListInvitesRequest](#hackathon-messages-hackathon_svc-ListInvitesRequest) | [messages.hackathon_svc.ListInvitesResponse](#hackathon-messages-hackathon_svc-ListInvitesResponse) | | +| RevokeInvite | [messages.hackathon_svc.RevokeInviteRequest](#hackathon-messages-hackathon_svc-RevokeInviteRequest) | [messages.hackathon_svc.RevokeInviteResponse](#hackathon-messages-hackathon_svc-RevokeInviteResponse) | | +| PreviewInvite | [messages.hackathon_svc.PreviewInviteRequest](#hackathon-messages-hackathon_svc-PreviewInviteRequest) | [messages.hackathon_svc.PreviewInviteResponse](#hackathon-messages-hackathon_svc-PreviewInviteResponse) | Redemption side: token in, shallow hackathon out. No hackathon id needed. | + + + + + +<a name="hackathon_messages_page_svc_create_request-proto"></a> +<p align="right"><a href="#top">Top</a></p> + +## hackathon/messages/page_svc/create_request.proto + + + +<a name="hackathon-messages-page_svc-CreateRequest"></a> + +### CreateRequest + + + +| Field | Type | Label | Description | +| ----- | ---- | ----- | ----------- | +| hackathon_id | [string](#string) | | | +| title | [string](#string) | | | +| content | [string](#string) | | | +| visible | [bool](#bool) | | order is automatically assigned by backend (max(order) + 1) | + + + + + + + + + + + + + + + +<a name="hackathon_messages_page_svc_create_response-proto"></a> +<p align="right"><a href="#top">Top</a></p> + +## hackathon/messages/page_svc/create_response.proto + + + +<a name="hackathon-messages-page_svc-CreateResponse"></a> + +### CreateResponse + + + +| Field | Type | Label | Description | +| ----- | ---- | ----- | ----------- | +| page_id | [string](#string) | | | + + + + + + + + + + + + + + + +<a name="hackathon_messages_page_svc_delete_request-proto"></a> +<p align="right"><a href="#top">Top</a></p> + +## hackathon/messages/page_svc/delete_request.proto + + + +<a name="hackathon-messages-page_svc-DeleteRequest"></a> + +### DeleteRequest + + + +| Field | Type | Label | Description | +| ----- | ---- | ----- | ----------- | +| page_id | [string](#string) | | | + + + + + + + + + + + + + + + +<a name="hackathon_messages_page_svc_delete_response-proto"></a> +<p align="right"><a href="#top">Top</a></p> + +## hackathon/messages/page_svc/delete_response.proto + + + +<a name="hackathon-messages-page_svc-DeleteResponse"></a> + +### DeleteResponse + + + + + + + + + + + + + + + + +<a name="hackathon_messages_page_svc_edit_request-proto"></a> +<p align="right"><a href="#top">Top</a></p> + +## hackathon/messages/page_svc/edit_request.proto + + + +<a name="hackathon-messages-page_svc-EditRequest"></a> + +### EditRequest + + + +| Field | Type | Label | Description | +| ----- | ---- | ----- | ----------- | +| page_id | [string](#string) | | | +| title | [string](#string) | optional | | +| content | [string](#string) | optional | | +| visible | [bool](#bool) | optional | order should not be modified here - use MoveUp/MoveDown/SetOrder instead | + + + + + + + + + + + + + + + +<a name="hackathon_messages_page_svc_edit_response-proto"></a> +<p align="right"><a href="#top">Top</a></p> + +## hackathon/messages/page_svc/edit_response.proto + + + +<a name="hackathon-messages-page_svc-EditResponse"></a> + +### EditResponse + + + +| Field | Type | Label | Description | +| ----- | ---- | ----- | ----------- | +| page | [hackathon.entities.Page](#hackathon-entities-Page) | | | + + + + + + + + + + + + + + + +<a name="hackathon_messages_page_svc_get_request-proto"></a> +<p align="right"><a href="#top">Top</a></p> + +## hackathon/messages/page_svc/get_request.proto + + + +<a name="hackathon-messages-page_svc-GetRequest"></a> + +### GetRequest + + + +| Field | Type | Label | Description | +| ----- | ---- | ----- | ----------- | +| page_id | [string](#string) | | | + + + + + + + + + + + + + + + +<a name="hackathon_messages_page_svc_get_response-proto"></a> +<p align="right"><a href="#top">Top</a></p> + +## hackathon/messages/page_svc/get_response.proto + + + +<a name="hackathon-messages-page_svc-GetResponse"></a> + +### GetResponse + + + +| Field | Type | Label | Description | +| ----- | ---- | ----- | ----------- | +| page | [hackathon.entities.Page](#hackathon-entities-Page) | | | + + + + + + + + + + + + + + + +<a name="hackathon_messages_page_svc_list_request-proto"></a> +<p align="right"><a href="#top">Top</a></p> + +## hackathon/messages/page_svc/list_request.proto + + + +<a name="hackathon-messages-page_svc-ListRequest"></a> + +### ListRequest + + + +| Field | Type | Label | Description | +| ----- | ---- | ----- | ----------- | +| hackathon_id | [string](#string) | | | + + + + + + + + + + + + + + + +<a name="hackathon_messages_page_svc_list_response-proto"></a> +<p align="right"><a href="#top">Top</a></p> + +## hackathon/messages/page_svc/list_response.proto + + + +<a name="hackathon-messages-page_svc-ListResponse"></a> + +### ListResponse + + + +| Field | Type | Label | Description | +| ----- | ---- | ----- | ----------- | +| pages | [hackathon.entities.Page](#hackathon-entities-Page) | repeated | | + + + + + + + + + + + + + + + +<a name="hackathon_messages_page_svc_move_down_request-proto"></a> +<p align="right"><a href="#top">Top</a></p> + +## hackathon/messages/page_svc/move_down_request.proto + + + +<a name="hackathon-messages-page_svc-MoveDownRequest"></a> + +### MoveDownRequest + + + +| Field | Type | Label | Description | +| ----- | ---- | ----- | ----------- | +| page_id | [string](#string) | | | +| increment | [int32](#int32) | optional | Number of positions to move down (default: 1) Must be >= 1 | + + + + + + + + + + + + + + + +<a name="hackathon_messages_page_svc_move_down_response-proto"></a> +<p align="right"><a href="#top">Top</a></p> + +## hackathon/messages/page_svc/move_down_response.proto + + + +<a name="hackathon-messages-page_svc-MoveDownResponse"></a> + +### MoveDownResponse + + + +| Field | Type | Label | Description | +| ----- | ---- | ----- | ----------- | +| page_id | [string](#string) | | | +| order | [int32](#int32) | | | + + + + + + + + + + + + + + + +<a name="hackathon_messages_page_svc_move_up_request-proto"></a> +<p align="right"><a href="#top">Top</a></p> + +## hackathon/messages/page_svc/move_up_request.proto + + + +<a name="hackathon-messages-page_svc-MoveUpRequest"></a> + +### MoveUpRequest + + + +| Field | Type | Label | Description | +| ----- | ---- | ----- | ----------- | +| page_id | [string](#string) | | | +| increment | [int32](#int32) | optional | Number of positions to move up (default: 1) Must be >= 1 | + + + + + + + + + + + + + + + +<a name="hackathon_messages_page_svc_move_up_response-proto"></a> +<p align="right"><a href="#top">Top</a></p> + +## hackathon/messages/page_svc/move_up_response.proto + + + +<a name="hackathon-messages-page_svc-MoveUpResponse"></a> + +### MoveUpResponse + + + +| Field | Type | Label | Description | +| ----- | ---- | ----- | ----------- | +| page_id | [string](#string) | | | +| order | [int32](#int32) | | | + + + + + + + + + + + + + + + +<a name="hackathon_messages_page_svc_set_order_request-proto"></a> +<p align="right"><a href="#top">Top</a></p> + +## hackathon/messages/page_svc/set_order_request.proto + + + +<a name="hackathon-messages-page_svc-SetOrderRequest"></a> + +### SetOrderRequest + + + +| Field | Type | Label | Description | +| ----- | ---- | ----- | ----------- | +| hackathon_id | [string](#string) | | | +| page_ids | [string](#string) | repeated | List of page IDs in the desired order | + + + + + + + + + + + + + + + +<a name="hackathon_messages_page_svc_set_order_response-proto"></a> +<p align="right"><a href="#top">Top</a></p> + +## hackathon/messages/page_svc/set_order_response.proto + + + +<a name="hackathon-messages-page_svc-SetOrderResponse"></a> + +### SetOrderResponse + + + +| Field | Type | Label | Description | +| ----- | ---- | ----- | ----------- | +| page_ids | [string](#string) | repeated | | + + + + + + + + + + + + + + + +<a name="hackathon_messages_phase_svc_create_request-proto"></a> +<p align="right"><a href="#top">Top</a></p> + +## hackathon/messages/phase_svc/create_request.proto + + + +<a name="hackathon-messages-phase_svc-CreateRequest"></a> + +### CreateRequest + + + +| Field | Type | Label | Description | +| ----- | ---- | ----- | ----------- | +| hackathon_id | [string](#string) | | | +| name | [string](#string) | | | +| description | [string](#string) | | | +| starts_at | [google.protobuf.Timestamp](#google-protobuf-Timestamp) | optional | | +| ends_at | [google.protobuf.Timestamp](#google-protobuf-Timestamp) | optional | | +| page_id | [string](#string) | optional | | + + + + + + + + + + + + + + + +<a name="hackathon_messages_phase_svc_create_response-proto"></a> +<p align="right"><a href="#top">Top</a></p> + +## hackathon/messages/phase_svc/create_response.proto + + + +<a name="hackathon-messages-phase_svc-CreateResponse"></a> + +### CreateResponse + + + +| Field | Type | Label | Description | +| ----- | ---- | ----- | ----------- | +| phase_id | [string](#string) | | | + + + + + + + + + + + + + + + +<a name="hackathon_messages_phase_svc_delete_request-proto"></a> +<p align="right"><a href="#top">Top</a></p> + +## hackathon/messages/phase_svc/delete_request.proto + + + +<a name="hackathon-messages-phase_svc-DeleteRequest"></a> + +### DeleteRequest + + + +| Field | Type | Label | Description | +| ----- | ---- | ----- | ----------- | +| phase_id | [string](#string) | | | + + + + + + + + + + + + + + + +<a name="hackathon_messages_phase_svc_delete_response-proto"></a> +<p align="right"><a href="#top">Top</a></p> + +## hackathon/messages/phase_svc/delete_response.proto + + + +<a name="hackathon-messages-phase_svc-DeleteResponse"></a> + +### DeleteResponse + + + + + + + + + + + + + + + + +<a name="hackathon_messages_phase_svc_edit_request-proto"></a> +<p align="right"><a href="#top">Top</a></p> + +## hackathon/messages/phase_svc/edit_request.proto + + + +<a name="hackathon-messages-phase_svc-EditRequest"></a> + +### EditRequest + + + +| Field | Type | Label | Description | +| ----- | ---- | ----- | ----------- | +| phase_id | [string](#string) | | | +| name | [string](#string) | optional | | +| description | [string](#string) | optional | | +| starts_at | [google.protobuf.Timestamp](#google-protobuf-Timestamp) | optional | | +| ends_at | [google.protobuf.Timestamp](#google-protobuf-Timestamp) | optional | | +| page_id | [string](#string) | optional | Empty string = unlink (unset), non-empty string = link to this phase, not set = no change | + + + + + + + + + + + + + + + +<a name="hackathon_messages_phase_svc_edit_response-proto"></a> +<p align="right"><a href="#top">Top</a></p> + +## hackathon/messages/phase_svc/edit_response.proto + + + +<a name="hackathon-messages-phase_svc-EditResponse"></a> + +### EditResponse + + + +| Field | Type | Label | Description | +| ----- | ---- | ----- | ----------- | +| phase | [hackathon.entities.Phase](#hackathon-entities-Phase) | | | + + + + + + + + + + + + + + + +<a name="hackathon_messages_phase_svc_get_request-proto"></a> +<p align="right"><a href="#top">Top</a></p> + +## hackathon/messages/phase_svc/get_request.proto + + + +<a name="hackathon-messages-phase_svc-GetRequest"></a> + +### GetRequest + + + +| Field | Type | Label | Description | +| ----- | ---- | ----- | ----------- | +| phase_id | [string](#string) | | | + + + + + + + + + + + + + + + +<a name="hackathon_messages_phase_svc_get_response-proto"></a> +<p align="right"><a href="#top">Top</a></p> + +## hackathon/messages/phase_svc/get_response.proto + + + +<a name="hackathon-messages-phase_svc-GetResponse"></a> + +### GetResponse + + + +| Field | Type | Label | Description | +| ----- | ---- | ----- | ----------- | +| phase | [hackathon.entities.Phase](#hackathon-entities-Phase) | | | + + + + + + + + + + + + + + + +<a name="hackathon_messages_phase_svc_list_request-proto"></a> +<p align="right"><a href="#top">Top</a></p> + +## hackathon/messages/phase_svc/list_request.proto + + + +<a name="hackathon-messages-phase_svc-ListRequest"></a> + +### ListRequest + + + +| Field | Type | Label | Description | +| ----- | ---- | ----- | ----------- | +| hackathon_id | [string](#string) | | | + + + + + + + + + + + + + + + +<a name="hackathon_messages_phase_svc_list_response-proto"></a> +<p align="right"><a href="#top">Top</a></p> + +## hackathon/messages/phase_svc/list_response.proto + + + +<a name="hackathon-messages-phase_svc-ListResponse"></a> + +### ListResponse + + + +| Field | Type | Label | Description | +| ----- | ---- | ----- | ----------- | +| phases | [hackathon.entities.Phase](#hackathon-entities-Phase) | repeated | | + + + + + + + + + + + + + + + +<a name="hackathon_messages_prize_svc_edit_request-proto"></a> +<p align="right"><a href="#top">Top</a></p> + +## hackathon/messages/prize_svc/edit_request.proto + + + +<a name="hackathon-messages-prize_svc-EditRequest"></a> + +### EditRequest + + + +| Field | Type | Label | Description | +| ----- | ---- | ----- | ----------- | +| hackathon_id | [string](#string) | | | +| rank | [int32](#int32) | | | +| title | [string](#string) | optional | | + + + + + + + + + + + + + + + +<a name="hackathon_messages_prize_svc_edit_response-proto"></a> +<p align="right"><a href="#top">Top</a></p> + +## hackathon/messages/prize_svc/edit_response.proto + + + +<a name="hackathon-messages-prize_svc-EditResponse"></a> + +### EditResponse + + + +| Field | Type | Label | Description | +| ----- | ---- | ----- | ----------- | +| prize | [hackathon.entities.Prize](#hackathon-entities-Prize) | | | + + + + + + + + + + + + + + + +<a name="hackathon_messages_prize_svc_finalize_request-proto"></a> +<p align="right"><a href="#top">Top</a></p> + +## hackathon/messages/prize_svc/finalize_request.proto + + + +<a name="hackathon-messages-prize_svc-FinalizeRequest"></a> + +### FinalizeRequest + + + +| Field | Type | Label | Description | +| ----- | ---- | ----- | ----------- | +| hackathon_id | [string](#string) | | | +| awards | [hackathon.entities.Award](#hackathon-entities-Award) | repeated | | + + + + + + + + + + + + + + + +<a name="hackathon_messages_prize_svc_finalize_response-proto"></a> +<p align="right"><a href="#top">Top</a></p> + +## hackathon/messages/prize_svc/finalize_response.proto + + + +<a name="hackathon-messages-prize_svc-FinalizeResponse"></a> + +### FinalizeResponse + + + + + + + + + + + + + + + + +<a name="hackathon_messages_prize_svc_get_request-proto"></a> +<p align="right"><a href="#top">Top</a></p> + +## hackathon/messages/prize_svc/get_request.proto + + + +<a name="hackathon-messages-prize_svc-GetRequest"></a> + +### GetRequest + + + +| Field | Type | Label | Description | +| ----- | ---- | ----- | ----------- | +| hackathon_id | [string](#string) | | | + + + + + + + + + + + + + + + +<a name="hackathon_messages_prize_svc_get_response-proto"></a> +<p align="right"><a href="#top">Top</a></p> + +## hackathon/messages/prize_svc/get_response.proto + + + +<a name="hackathon-messages-prize_svc-GetResponse"></a> + +### GetResponse +`finalized` is the organiser's last word: votes are advisory here, and once +the awards are finalized the table is the record of what was actually given. + + +| Field | Type | Label | Description | +| ----- | ---- | ----- | ----------- | +| prizes | [hackathon.entities.Prize](#hackathon-entities-Prize) | repeated | | +| awards | [hackathon.entities.Prize](#hackathon-entities-Prize) | repeated | | +| finalized | [bool](#bool) | | | + + + + + + + + + + + + + + + +<a name="hackathon_messages_prize_svc_set_request-proto"></a> +<p align="right"><a href="#top">Top</a></p> + +## hackathon/messages/prize_svc/set_request.proto + + + +<a name="hackathon-messages-prize_svc-SetRequest"></a> + +### SetRequest + + + +| Field | Type | Label | Description | +| ----- | ---- | ----- | ----------- | +| hackathon_id | [string](#string) | | | +| prizes | [hackathon.entities.Prize](#hackathon-entities-Prize) | repeated | | + + + + + + + + + + + + + + + +<a name="hackathon_messages_prize_svc_set_response-proto"></a> +<p align="right"><a href="#top">Top</a></p> + +## hackathon/messages/prize_svc/set_response.proto + + + +<a name="hackathon-messages-prize_svc-SetResponse"></a> + +### SetResponse + + + +| Field | Type | Label | Description | +| ----- | ---- | ----- | ----------- | +| prizes | [hackathon.entities.Prize](#hackathon-entities-Prize) | repeated | | + + + + + + + + + + + + + + + +<a name="hackathon_messages_project_svc_approve_request-proto"></a> +<p align="right"><a href="#top">Top</a></p> + +## hackathon/messages/project_svc/approve_request.proto + + + +<a name="hackathon-messages-project_svc-ApproveRequest"></a> + +### ApproveRequest + + + +| Field | Type | Label | Description | +| ----- | ---- | ----- | ----------- | +| project_id | [string](#string) | | | + + + + + + + + + + + + + + + +<a name="hackathon_messages_project_svc_approve_response-proto"></a> +<p align="right"><a href="#top">Top</a></p> + +## hackathon/messages/project_svc/approve_response.proto + + + +<a name="hackathon-messages-project_svc-ApproveResponse"></a> + +### ApproveResponse + + + + + + + + + + + + + + + + +<a name="hackathon_messages_project_svc_delete_request-proto"></a> +<p align="right"><a href="#top">Top</a></p> + +## hackathon/messages/project_svc/delete_request.proto + + + +<a name="hackathon-messages-project_svc-DeleteRequest"></a> + +### DeleteRequest + + + +| Field | Type | Label | Description | +| ----- | ---- | ----- | ----------- | +| project_id | [string](#string) | | | + + + + + + + + + + + + + + + +<a name="hackathon_messages_project_svc_delete_response-proto"></a> +<p align="right"><a href="#top">Top</a></p> + +## hackathon/messages/project_svc/delete_response.proto + + + +<a name="hackathon-messages-project_svc-DeleteResponse"></a> + +### DeleteResponse + + + + + + + + + + + + + + + + +<a name="hackathon_messages_project_svc_disapprove_request-proto"></a> +<p align="right"><a href="#top">Top</a></p> + +## hackathon/messages/project_svc/disapprove_request.proto + + + +<a name="hackathon-messages-project_svc-DisapproveRequest"></a> + +### DisapproveRequest + + + +| Field | Type | Label | Description | +| ----- | ---- | ----- | ----------- | +| project_id | [string](#string) | | | + + + + + + + + + + + + + + + +<a name="hackathon_messages_project_svc_disapprove_response-proto"></a> +<p align="right"><a href="#top">Top</a></p> + +## hackathon/messages/project_svc/disapprove_response.proto + + + +<a name="hackathon-messages-project_svc-DisapproveResponse"></a> + +### DisapproveResponse + + + + + + + + + + + + + + + + +<a name="hackathon_messages_project_svc_edit_request-proto"></a> +<p align="right"><a href="#top">Top</a></p> + +## hackathon/messages/project_svc/edit_request.proto + + + +<a name="hackathon-messages-project_svc-EditRequest"></a> + +### EditRequest + + + +| Field | Type | Label | Description | +| ----- | ---- | ----- | ----------- | +| project_id | [string](#string) | | | +| title | [string](#string) | optional | | +| description | [string](#string) | optional | | +| track_id | [string](#string) | optional | | +| image | [string](#string) | optional | | + + + + + + + + + + + + + + + +<a name="hackathon_messages_project_svc_edit_response-proto"></a> +<p align="right"><a href="#top">Top</a></p> + +## hackathon/messages/project_svc/edit_response.proto + + + +<a name="hackathon-messages-project_svc-EditResponse"></a> + +### EditResponse + + + +| Field | Type | Label | Description | +| ----- | ---- | ----- | ----------- | +| project | [hackathon.entities.Project](#hackathon-entities-Project) | | | + + + + + + + + + + + + + + + +<a name="hackathon_messages_project_svc_export_preferences_request-proto"></a> +<p align="right"><a href="#top">Top</a></p> + +## hackathon/messages/project_svc/export_preferences_request.proto + + + +<a name="hackathon-messages-project_svc-ExportPreferencesRequest"></a> + +### ExportPreferencesRequest + + + +| Field | Type | Label | Description | +| ----- | ---- | ----- | ----------- | +| hackathon_id | [string](#string) | | | + + + + + + + + + + + + + + + +<a name="hackathon_messages_project_svc_export_preferences_response-proto"></a> +<p align="right"><a href="#top">Top</a></p> + +## hackathon/messages/project_svc/export_preferences_response.proto + + + +<a name="hackathon-messages-project_svc-ExportPreferencesResponse"></a> + +### ExportPreferencesResponse + + + +| Field | Type | Label | Description | +| ----- | ---- | ----- | ----------- | +| projects | [hackathon.entities.ProjectWithPreferences](#hackathon-entities-ProjectWithPreferences) | repeated | | + + + + + + + + + + + + + + + +<a name="hackathon_messages_project_svc_get_preference_request-proto"></a> +<p align="right"><a href="#top">Top</a></p> + +## hackathon/messages/project_svc/get_preference_request.proto + + + +<a name="hackathon-messages-project_svc-GetPreferenceRequest"></a> + +### GetPreferenceRequest + + + +| Field | Type | Label | Description | +| ----- | ---- | ----- | ----------- | +| hackathon_id | [string](#string) | | | + + + + + + + + + + + + + + + +<a name="hackathon_messages_project_svc_get_preference_response-proto"></a> +<p align="right"><a href="#top">Top</a></p> + +## hackathon/messages/project_svc/get_preference_response.proto + + + +<a name="hackathon-messages-project_svc-GetPreferenceResponse"></a> + +### GetPreferenceResponse + + + +| Field | Type | Label | Description | +| ----- | ---- | ----- | ----------- | +| project_ids | [string](#string) | repeated | | + + + + + + + + + + + + + + + +<a name="hackathon_messages_project_svc_get_request-proto"></a> +<p align="right"><a href="#top">Top</a></p> + +## hackathon/messages/project_svc/get_request.proto + + + +<a name="hackathon-messages-project_svc-GetRequest"></a> + +### GetRequest + + + +| Field | Type | Label | Description | +| ----- | ---- | ----- | ----------- | +| project_id | [string](#string) | | | + + + + + + + + + + + + + + + +<a name="hackathon_messages_project_svc_get_response-proto"></a> +<p align="right"><a href="#top">Top</a></p> + +## hackathon/messages/project_svc/get_response.proto + + + +<a name="hackathon-messages-project_svc-GetResponse"></a> + +### GetResponse + + + +| Field | Type | Label | Description | +| ----- | ---- | ----- | ----------- | +| project | [hackathon.entities.Project](#hackathon-entities-Project) | | | + + + + + + + + + + + + + + + +<a name="hackathon_messages_project_svc_list_request-proto"></a> +<p align="right"><a href="#top">Top</a></p> + +## hackathon/messages/project_svc/list_request.proto + + + +<a name="hackathon-messages-project_svc-ListRequest"></a> + +### ListRequest + + + +| Field | Type | Label | Description | +| ----- | ---- | ----- | ----------- | +| hackathon_id | [string](#string) | | | + + + + + + + + + + + + + + + +<a name="hackathon_messages_project_svc_list_response-proto"></a> +<p align="right"><a href="#top">Top</a></p> + +## hackathon/messages/project_svc/list_response.proto + + + +<a name="hackathon-messages-project_svc-ListResponse"></a> + +### ListResponse + + + +| Field | Type | Label | Description | +| ----- | ---- | ----- | ----------- | +| projects | [hackathon.entities.Project](#hackathon-entities-Project) | repeated | | + + + + + + + + + + + + + + + +<a name="hackathon_messages_project_svc_propose_request-proto"></a> +<p align="right"><a href="#top">Top</a></p> + +## hackathon/messages/project_svc/propose_request.proto + + + +<a name="hackathon-messages-project_svc-ProposeRequest"></a> + +### ProposeRequest + + + +| Field | Type | Label | Description | +| ----- | ---- | ----- | ----------- | +| hackathon_id | [string](#string) | | | +| title | [string](#string) | | | +| description | [string](#string) | | | +| track_id | [string](#string) | optional | | +| image | [string](#string) | optional | | + + + + + + + + + + + + + + + +<a name="hackathon_messages_project_svc_propose_response-proto"></a> +<p align="right"><a href="#top">Top</a></p> + +## hackathon/messages/project_svc/propose_response.proto + + + +<a name="hackathon-messages-project_svc-ProposeResponse"></a> + +### ProposeResponse + + + +| Field | Type | Label | Description | +| ----- | ---- | ----- | ----------- | +| project_id | [string](#string) | | | + + + + + + + + + + + + + + + +<a name="hackathon_messages_project_svc_remove_preference_request-proto"></a> +<p align="right"><a href="#top">Top</a></p> + +## hackathon/messages/project_svc/remove_preference_request.proto + + + +<a name="hackathon-messages-project_svc-RemovePreferenceRequest"></a> + +### RemovePreferenceRequest +Withdraws a participant's project preference. + +Deliberately ORGANIZER-ONLY and deliberately takes a user_id: a +participant's preference is final once expressed, so there is no +self-service unset. Someone who picked in error asks an organizer, which +keeps team formation working from a stable set of choices. + + +| Field | Type | Label | Description | +| ----- | ---- | ----- | ----------- | +| project_id | [string](#string) | | | +| user_id | [string](#string) | | | + + + + + + + + + + + + + + + +<a name="hackathon_messages_project_svc_remove_preference_response-proto"></a> +<p align="right"><a href="#top">Top</a></p> + +## hackathon/messages/project_svc/remove_preference_response.proto + + + +<a name="hackathon-messages-project_svc-RemovePreferenceResponse"></a> + +### RemovePreferenceResponse + + + + + + + + + + + + + + + + +<a name="hackathon_messages_project_svc_set_preference_request-proto"></a> +<p align="right"><a href="#top">Top</a></p> + +## hackathon/messages/project_svc/set_preference_request.proto + + + +<a name="hackathon-messages-project_svc-SetPreferenceRequest"></a> + +### SetPreferenceRequest + + + +| Field | Type | Label | Description | +| ----- | ---- | ----- | ----------- | +| project_id | [string](#string) | | | + + + + + + + + + + + + + + + +<a name="hackathon_messages_project_svc_set_preference_response-proto"></a> +<p align="right"><a href="#top">Top</a></p> + +## hackathon/messages/project_svc/set_preference_response.proto + + + +<a name="hackathon-messages-project_svc-SetPreferenceResponse"></a> + +### SetPreferenceResponse + + + +| Field | Type | Label | Description | +| ----- | ---- | ----- | ----------- | +| project_id | [string](#string) | | | + + + + + + + + + + + + + + + +<a name="hackathon_messages_team_svc_assign_user_request-proto"></a> +<p align="right"><a href="#top">Top</a></p> + +## hackathon/messages/team_svc/assign_user_request.proto + + + +<a name="hackathon-messages-team_svc-AssignUserRequest"></a> + +### AssignUserRequest + + + +| Field | Type | Label | Description | +| ----- | ---- | ----- | ----------- | +| team_id | [string](#string) | | | +| user_id | [string](#string) | | | + + + + + + + + + + + + + + + +<a name="hackathon_messages_team_svc_assign_user_response-proto"></a> +<p align="right"><a href="#top">Top</a></p> + +## hackathon/messages/team_svc/assign_user_response.proto + + + +<a name="hackathon-messages-team_svc-AssignUserResponse"></a> + +### AssignUserResponse + + + + + + + + + + + + + + + + +<a name="hackathon_messages_team_svc_create_request-proto"></a> +<p align="right"><a href="#top">Top</a></p> + +## hackathon/messages/team_svc/create_request.proto + + + +<a name="hackathon-messages-team_svc-CreateRequest"></a> + +### CreateRequest + + + +| Field | Type | Label | Description | +| ----- | ---- | ----- | ----------- | +| project_id | [string](#string) | | | +| name | [string](#string) | | | +| description | [string](#string) | | | + + + + + + + + + + + + + + + +<a name="hackathon_messages_team_svc_create_response-proto"></a> +<p align="right"><a href="#top">Top</a></p> + +## hackathon/messages/team_svc/create_response.proto + + + +<a name="hackathon-messages-team_svc-CreateResponse"></a> + +### CreateResponse + + + +| Field | Type | Label | Description | +| ----- | ---- | ----- | ----------- | +| team_id | [string](#string) | | | + + + + + + + + + + + + + + + +<a name="hackathon_messages_team_svc_create_submission_request-proto"></a> +<p align="right"><a href="#top">Top</a></p> + +## hackathon/messages/team_svc/create_submission_request.proto + + + +<a name="hackathon-messages-team_svc-CreateSubmissionRequest"></a> + +### CreateSubmissionRequest + + + +| Field | Type | Label | Description | +| ----- | ---- | ----- | ----------- | +| team_id | [string](#string) | | | +| project_id | [string](#string) | | | +| result | [string](#string) | optional | | +| form | [CreateSubmissionRequest.FormEntry](#hackathon-messages-team_svc-CreateSubmissionRequest-FormEntry) | repeated | Structured answers keyed by the submission form's field keys (see ConfigService.SetSubmissionForm). Validated against that schema: required fields must be present and unknown keys are rejected, so a submission cannot silently miss what the organizer asked for. | + + + + + + +<a name="hackathon-messages-team_svc-CreateSubmissionRequest-FormEntry"></a> + +### CreateSubmissionRequest.FormEntry + + + +| Field | Type | Label | Description | +| ----- | ---- | ----- | ----------- | +| key | [string](#string) | | | +| value | [string](#string) | | | + + + + + + + + + + + + + + + +<a name="hackathon_messages_team_svc_create_submission_response-proto"></a> +<p align="right"><a href="#top">Top</a></p> + +## hackathon/messages/team_svc/create_submission_response.proto + + + +<a name="hackathon-messages-team_svc-CreateSubmissionResponse"></a> + +### CreateSubmissionResponse + + + +| Field | Type | Label | Description | +| ----- | ---- | ----- | ----------- | +| id | [string](#string) | | | + + + + + + + + + + + + + + + +<a name="hackathon_messages_team_svc_delete_request-proto"></a> +<p align="right"><a href="#top">Top</a></p> + +## hackathon/messages/team_svc/delete_request.proto + + + +<a name="hackathon-messages-team_svc-DeleteRequest"></a> + +### DeleteRequest + + + +| Field | Type | Label | Description | +| ----- | ---- | ----- | ----------- | +| id | [string](#string) | | | + + + + + + + + + + + + + + + +<a name="hackathon_messages_team_svc_delete_response-proto"></a> +<p align="right"><a href="#top">Top</a></p> + +## hackathon/messages/team_svc/delete_response.proto + + + +<a name="hackathon-messages-team_svc-DeleteResponse"></a> + +### DeleteResponse + + + + + + + + + + + + + + + + +<a name="hackathon_messages_team_svc_edit_request-proto"></a> +<p align="right"><a href="#top">Top</a></p> + +## hackathon/messages/team_svc/edit_request.proto + + + +<a name="hackathon-messages-team_svc-EditRequest"></a> + +### EditRequest + + + +| Field | Type | Label | Description | +| ----- | ---- | ----- | ----------- | +| id | [string](#string) | | | +| name | [string](#string) | optional | | +| description | [string](#string) | optional | | + + + + + + + + + + + + + + + +<a name="hackathon_messages_team_svc_edit_response-proto"></a> +<p align="right"><a href="#top">Top</a></p> + +## hackathon/messages/team_svc/edit_response.proto + + + +<a name="hackathon-messages-team_svc-EditResponse"></a> + +### EditResponse + + + +| Field | Type | Label | Description | +| ----- | ---- | ----- | ----------- | +| team | [hackathon.entities.Team](#hackathon-entities-Team) | | | + + + + + + + + + + + + + + + +<a name="hackathon_messages_team_svc_edit_submission_request-proto"></a> +<p align="right"><a href="#top">Top</a></p> + +## hackathon/messages/team_svc/edit_submission_request.proto + + + +<a name="hackathon-messages-team_svc-EditSubmissionRequest"></a> + +### EditSubmissionRequest + + + +| Field | Type | Label | Description | +| ----- | ---- | ----- | ----------- | +| submission_id | [string](#string) | | | +| result | [string](#string) | optional | | +| form | [EditSubmissionRequest.FormEntry](#hackathon-messages-team_svc-EditSubmissionRequest-FormEntry) | repeated | Structured answers, same keys and validation as CreateSubmission. Without this the form was frozen at create time: a team that mistyped a repo URL could edit the free text around it but never the field the organizer actually asked for. Absent (not merely empty) leaves the stored answers untouched, so an edit of `result` alone cannot wipe them. | + + + + + + +<a name="hackathon-messages-team_svc-EditSubmissionRequest-FormEntry"></a> + +### EditSubmissionRequest.FormEntry + + + +| Field | Type | Label | Description | +| ----- | ---- | ----- | ----------- | +| key | [string](#string) | | | +| value | [string](#string) | | | + + + + + + + + + + + + + + + +<a name="hackathon_messages_team_svc_edit_submission_response-proto"></a> +<p align="right"><a href="#top">Top</a></p> + +## hackathon/messages/team_svc/edit_submission_response.proto + + + +<a name="hackathon-messages-team_svc-EditSubmissionResponse"></a> + +### EditSubmissionResponse + + + +| Field | Type | Label | Description | +| ----- | ---- | ----- | ----------- | +| submission | [hackathon.entities.Submission](#hackathon-entities-Submission) | | | + + + + + + + + + + + + + + + +<a name="hackathon_messages_team_svc_finalize_submission_request-proto"></a> +<p align="right"><a href="#top">Top</a></p> + +## hackathon/messages/team_svc/finalize_submission_request.proto + + + +<a name="hackathon-messages-team_svc-FinalizeSubmissionRequest"></a> + +### FinalizeSubmissionRequest + + + +| Field | Type | Label | Description | +| ----- | ---- | ----- | ----------- | +| submission_id | [string](#string) | | | + + + + + + + + + + + + + + + +<a name="hackathon_messages_team_svc_finalize_submission_response-proto"></a> +<p align="right"><a href="#top">Top</a></p> + +## hackathon/messages/team_svc/finalize_submission_response.proto + + + +<a name="hackathon-messages-team_svc-FinalizeSubmissionResponse"></a> + +### FinalizeSubmissionResponse + + + +| Field | Type | Label | Description | +| ----- | ---- | ----- | ----------- | +| submission | [hackathon.entities.Submission](#hackathon-entities-Submission) | | | + + + + + + + + + -<a name="hackathon_messages_hackathon_svc_join_request-proto"></a> + +<a name="hackathon_messages_team_svc_get_request-proto"></a> <p align="right"><a href="#top">Top</a></p> -## hackathon/messages/hackathon_svc/join_request.proto +## hackathon/messages/team_svc/get_request.proto -<a name="hackathon-messages-hackathon_svc-JoinRequest"></a> +<a name="hackathon-messages-team_svc-GetRequest"></a> -### JoinRequest +### GetRequest | Field | Type | Label | Description | | ----- | ---- | ----- | ----------- | -| hackathon_id | [string](#string) | | | +| team_id | [string](#string) | | | @@ -1325,22 +6388,22 @@ casbin role for this hackathon; `is_waiting` is false once approved. -<a name="hackathon_messages_hackathon_svc_join_response-proto"></a> +<a name="hackathon_messages_team_svc_get_response-proto"></a> <p align="right"><a href="#top">Top</a></p> -## hackathon/messages/hackathon_svc/join_response.proto +## hackathon/messages/team_svc/get_response.proto -<a name="hackathon-messages-hackathon_svc-JoinResponse"></a> +<a name="hackathon-messages-team_svc-GetResponse"></a> -### JoinResponse +### GetResponse | Field | Type | Label | Description | | ----- | ---- | ----- | ----------- | -| hackathon_id | [string](#string) | | | +| team | [hackathon.entities.Team](#hackathon-entities-Team) | | | @@ -1356,25 +6419,22 @@ casbin role for this hackathon; `is_waiting` is false once approved. -<a name="hackathon_messages_hackathon_svc_list_request-proto"></a> +<a name="hackathon_messages_team_svc_get_submission_request-proto"></a> <p align="right"><a href="#top">Top</a></p> -## hackathon/messages/hackathon_svc/list_request.proto +## hackathon/messages/team_svc/get_submission_request.proto -<a name="hackathon-messages-hackathon_svc-ListRequest"></a> +<a name="hackathon-messages-team_svc-GetSubmissionRequest"></a> -### ListRequest +### GetSubmissionRequest | Field | Type | Label | Description | | ----- | ---- | ----- | ----------- | -| status_filter | [hackathon.entities.HackathonStatus](#hackathon-entities-HackathonStatus) | repeated | | -| owner_id | [string](#string) | optional | | -| participant_id | [string](#string) | optional | | -| visibility_filter | [hackathon.entities.Visibility](#hackathon-entities-Visibility) | optional | | +| team_id | [string](#string) | | | @@ -1390,22 +6450,22 @@ casbin role for this hackathon; `is_waiting` is false once approved. -<a name="hackathon_messages_hackathon_svc_list_response-proto"></a> +<a name="hackathon_messages_team_svc_get_submission_response-proto"></a> <p align="right"><a href="#top">Top</a></p> -## hackathon/messages/hackathon_svc/list_response.proto +## hackathon/messages/team_svc/get_submission_response.proto -<a name="hackathon-messages-hackathon_svc-ListResponse"></a> +<a name="hackathon-messages-team_svc-GetSubmissionResponse"></a> -### ListResponse +### GetSubmissionResponse | Field | Type | Label | Description | | ----- | ---- | ----- | ----------- | -| hackathons | [hackathon.entities.Hackathon](#hackathon-entities-Hackathon) | repeated | | +| submission | [hackathon.entities.Submission](#hackathon-entities-Submission) | | | @@ -1421,23 +6481,23 @@ casbin role for this hackathon; `is_waiting` is false once approved. -<a name="hackathon_messages_hackathon_svc_remove_owner_request-proto"></a> +<a name="hackathon_messages_team_svc_list_request-proto"></a> <p align="right"><a href="#top">Top</a></p> -## hackathon/messages/hackathon_svc/remove_owner_request.proto +## hackathon/messages/team_svc/list_request.proto -<a name="hackathon-messages-hackathon_svc-RemoveOwnerRequest"></a> +<a name="hackathon-messages-team_svc-ListRequest"></a> -### RemoveOwnerRequest +### ListRequest | Field | Type | Label | Description | | ----- | ---- | ----- | ----------- | -| hackathon_id | [string](#string) | | | -| user_id | [string](#string) | | | +| hackathon_id | [string](#string) | optional | | +| project_id | [string](#string) | optional | | @@ -1453,18 +6513,23 @@ casbin role for this hackathon; `is_waiting` is false once approved. -<a name="hackathon_messages_hackathon_svc_remove_owner_response-proto"></a> +<a name="hackathon_messages_team_svc_list_response-proto"></a> <p align="right"><a href="#top">Top</a></p> -## hackathon/messages/hackathon_svc/remove_owner_response.proto +## hackathon/messages/team_svc/list_response.proto -<a name="hackathon-messages-hackathon_svc-RemoveOwnerResponse"></a> +<a name="hackathon-messages-team_svc-ListResponse"></a> + +### ListResponse -### RemoveOwnerResponse +| Field | Type | Label | Description | +| ----- | ---- | ----- | ----------- | +| teams | [hackathon.entities.Team](#hackathon-entities-Team) | repeated | | + @@ -1479,23 +6544,22 @@ casbin role for this hackathon; `is_waiting` is false once approved. -<a name="hackathon_messages_hackathon_svc_remove_participant_request-proto"></a> +<a name="hackathon_messages_team_svc_list_submissions_request-proto"></a> <p align="right"><a href="#top">Top</a></p> -## hackathon/messages/hackathon_svc/remove_participant_request.proto +## hackathon/messages/team_svc/list_submissions_request.proto -<a name="hackathon-messages-hackathon_svc-RemoveParticipantRequest"></a> +<a name="hackathon-messages-team_svc-ListSubmissionsRequest"></a> -### RemoveParticipantRequest +### ListSubmissionsRequest | Field | Type | Label | Description | | ----- | ---- | ----- | ----------- | -| hackathon_id | [string](#string) | | | -| user_id | [string](#string) | | | +| team_id | [string](#string) | | | @@ -1511,18 +6575,23 @@ casbin role for this hackathon; `is_waiting` is false once approved. -<a name="hackathon_messages_hackathon_svc_remove_participant_response-proto"></a> +<a name="hackathon_messages_team_svc_list_submissions_response-proto"></a> <p align="right"><a href="#top">Top</a></p> -## hackathon/messages/hackathon_svc/remove_participant_response.proto +## hackathon/messages/team_svc/list_submissions_response.proto -<a name="hackathon-messages-hackathon_svc-RemoveParticipantResponse"></a> +<a name="hackathon-messages-team_svc-ListSubmissionsResponse"></a> + +### ListSubmissionsResponse -### RemoveParticipantResponse +| Field | Type | Label | Description | +| ----- | ---- | ----- | ----------- | +| submissions | [hackathon.entities.Submission](#hackathon-entities-Submission) | repeated | | + @@ -1537,10 +6606,26 @@ casbin role for this hackathon; `is_waiting` is false once approved. -<a name="hackathon_hackathon_service-proto"></a> +<a name="hackathon_messages_team_svc_remove_user_request-proto"></a> <p align="right"><a href="#top">Top</a></p> -## hackathon/hackathon_service.proto +## hackathon/messages/team_svc/remove_user_request.proto + + + +<a name="hackathon-messages-team_svc-RemoveUserRequest"></a> + +### RemoveUserRequest + + + +| Field | Type | Label | Description | +| ----- | ---- | ----- | ----------- | +| team_id | [string](#string) | | | +| user_id | [string](#string) | | | + + + @@ -1549,36 +6634,49 @@ casbin role for this hackathon; `is_waiting` is false once approved. + + + + +<a name="hackathon_messages_team_svc_remove_user_response-proto"></a> +<p align="right"><a href="#top">Top</a></p> + +## hackathon/messages/team_svc/remove_user_response.proto + + + +<a name="hackathon-messages-team_svc-RemoveUserResponse"></a> + +### RemoveUserResponse + + + +| Field | Type | Label | Description | +| ----- | ---- | ----- | ----------- | +| team | [hackathon.entities.Team](#hackathon-entities-Team) | | | -<a name="hackathon-HackathonService"></a> -### HackathonService -| Method Name | Request Type | Response Type | Description | -| ----------- | ------------ | ------------- | ------------| -| List | [messages.hackathon_svc.ListRequest](#hackathon-messages-hackathon_svc-ListRequest) | [messages.hackathon_svc.ListResponse](#hackathon-messages-hackathon_svc-ListResponse) | | -| Get | [messages.hackathon_svc.GetRequest](#hackathon-messages-hackathon_svc-GetRequest) | [messages.hackathon_svc.GetResponse](#hackathon-messages-hackathon_svc-GetResponse) | | -| Create | [messages.hackathon_svc.CreateRequest](#hackathon-messages-hackathon_svc-CreateRequest) | [messages.hackathon_svc.CreateResponse](#hackathon-messages-hackathon_svc-CreateResponse) | | -| Edit | [messages.hackathon_svc.EditRequest](#hackathon-messages-hackathon_svc-EditRequest) | [messages.hackathon_svc.EditResponse](#hackathon-messages-hackathon_svc-EditResponse) | | -| Join | [messages.hackathon_svc.JoinRequest](#hackathon-messages-hackathon_svc-JoinRequest) | [messages.hackathon_svc.JoinResponse](#hackathon-messages-hackathon_svc-JoinResponse) | | -| ApproveParticipant | [messages.hackathon_svc.ApproveParticipantRequest](#hackathon-messages-hackathon_svc-ApproveParticipantRequest) | [messages.hackathon_svc.ApproveParticipantResponse](#hackathon-messages-hackathon_svc-ApproveParticipantResponse) | | -| RemoveParticipant | [messages.hackathon_svc.RemoveParticipantRequest](#hackathon-messages-hackathon_svc-RemoveParticipantRequest) | [messages.hackathon_svc.RemoveParticipantResponse](#hackathon-messages-hackathon_svc-RemoveParticipantResponse) | | -| AddOwner | [messages.hackathon_svc.AddOwnerRequest](#hackathon-messages-hackathon_svc-AddOwnerRequest) | [messages.hackathon_svc.AddOwnerResponse](#hackathon-messages-hackathon_svc-AddOwnerResponse) | | -| RemoveOwner | [messages.hackathon_svc.RemoveOwnerRequest](#hackathon-messages-hackathon_svc-RemoveOwnerRequest) | [messages.hackathon_svc.RemoveOwnerResponse](#hackathon-messages-hackathon_svc-RemoveOwnerResponse) | | + + -<a name="hackathon_messages_page_svc_create_request-proto"></a> + + + + +<a name="hackathon_messages_track_svc_create_request-proto"></a> <p align="right"><a href="#top">Top</a></p> -## hackathon/messages/page_svc/create_request.proto +## hackathon/messages/track_svc/create_request.proto -<a name="hackathon-messages-page_svc-CreateRequest"></a> +<a name="hackathon-messages-track_svc-CreateRequest"></a> ### CreateRequest @@ -1587,9 +6685,8 @@ casbin role for this hackathon; `is_waiting` is false once approved. | Field | Type | Label | Description | | ----- | ---- | ----- | ----------- | | hackathon_id | [string](#string) | | | -| title | [string](#string) | | | -| content | [string](#string) | | | -| visible | [bool](#bool) | | order is automatically assigned by backend (max(order) + 1) | +| name | [string](#string) | | | +| description | [string](#string) | | | @@ -1605,14 +6702,14 @@ casbin role for this hackathon; `is_waiting` is false once approved. -<a name="hackathon_messages_page_svc_create_response-proto"></a> +<a name="hackathon_messages_track_svc_create_response-proto"></a> <p align="right"><a href="#top">Top</a></p> -## hackathon/messages/page_svc/create_response.proto +## hackathon/messages/track_svc/create_response.proto -<a name="hackathon-messages-page_svc-CreateResponse"></a> +<a name="hackathon-messages-track_svc-CreateResponse"></a> ### CreateResponse @@ -1620,7 +6717,7 @@ casbin role for this hackathon; `is_waiting` is false once approved. | Field | Type | Label | Description | | ----- | ---- | ----- | ----------- | -| page_id | [string](#string) | | | +| track_id | [string](#string) | | | @@ -1636,14 +6733,14 @@ casbin role for this hackathon; `is_waiting` is false once approved. -<a name="hackathon_messages_page_svc_delete_request-proto"></a> +<a name="hackathon_messages_track_svc_delete_request-proto"></a> <p align="right"><a href="#top">Top</a></p> -## hackathon/messages/page_svc/delete_request.proto +## hackathon/messages/track_svc/delete_request.proto -<a name="hackathon-messages-page_svc-DeleteRequest"></a> +<a name="hackathon-messages-track_svc-DeleteRequest"></a> ### DeleteRequest @@ -1651,7 +6748,7 @@ casbin role for this hackathon; `is_waiting` is false once approved. | Field | Type | Label | Description | | ----- | ---- | ----- | ----------- | -| page_id | [string](#string) | | | +| track_id | [string](#string) | | | @@ -1667,14 +6764,14 @@ casbin role for this hackathon; `is_waiting` is false once approved. -<a name="hackathon_messages_page_svc_delete_response-proto"></a> +<a name="hackathon_messages_track_svc_delete_response-proto"></a> <p align="right"><a href="#top">Top</a></p> -## hackathon/messages/page_svc/delete_response.proto +## hackathon/messages/track_svc/delete_response.proto -<a name="hackathon-messages-page_svc-DeleteResponse"></a> +<a name="hackathon-messages-track_svc-DeleteResponse"></a> ### DeleteResponse @@ -1693,14 +6790,14 @@ casbin role for this hackathon; `is_waiting` is false once approved. -<a name="hackathon_messages_page_svc_edit_request-proto"></a> +<a name="hackathon_messages_track_svc_edit_request-proto"></a> <p align="right"><a href="#top">Top</a></p> -## hackathon/messages/page_svc/edit_request.proto +## hackathon/messages/track_svc/edit_request.proto -<a name="hackathon-messages-page_svc-EditRequest"></a> +<a name="hackathon-messages-track_svc-EditRequest"></a> ### EditRequest @@ -1708,10 +6805,9 @@ casbin role for this hackathon; `is_waiting` is false once approved. | Field | Type | Label | Description | | ----- | ---- | ----- | ----------- | -| page_id | [string](#string) | | | -| title | [string](#string) | optional | | -| content | [string](#string) | optional | | -| visible | [bool](#bool) | optional | order should not be modified here - use MoveUp/MoveDown/SetOrder instead | +| track_id | [string](#string) | | | +| name | [string](#string) | optional | | +| description | [string](#string) | optional | | @@ -1727,14 +6823,14 @@ casbin role for this hackathon; `is_waiting` is false once approved. -<a name="hackathon_messages_page_svc_edit_response-proto"></a> +<a name="hackathon_messages_track_svc_edit_response-proto"></a> <p align="right"><a href="#top">Top</a></p> -## hackathon/messages/page_svc/edit_response.proto +## hackathon/messages/track_svc/edit_response.proto -<a name="hackathon-messages-page_svc-EditResponse"></a> +<a name="hackathon-messages-track_svc-EditResponse"></a> ### EditResponse @@ -1742,7 +6838,7 @@ casbin role for this hackathon; `is_waiting` is false once approved. | Field | Type | Label | Description | | ----- | ---- | ----- | ----------- | -| page | [hackathon.entities.Page](#hackathon-entities-Page) | | | +| track | [hackathon.entities.Track](#hackathon-entities-Track) | | | @@ -1758,14 +6854,14 @@ casbin role for this hackathon; `is_waiting` is false once approved. -<a name="hackathon_messages_page_svc_get_request-proto"></a> +<a name="hackathon_messages_track_svc_get_request-proto"></a> <p align="right"><a href="#top">Top</a></p> -## hackathon/messages/page_svc/get_request.proto +## hackathon/messages/track_svc/get_request.proto -<a name="hackathon-messages-page_svc-GetRequest"></a> +<a name="hackathon-messages-track_svc-GetRequest"></a> ### GetRequest @@ -1773,7 +6869,7 @@ casbin role for this hackathon; `is_waiting` is false once approved. | Field | Type | Label | Description | | ----- | ---- | ----- | ----------- | -| page_id | [string](#string) | | | +| track_id | [string](#string) | | | @@ -1789,14 +6885,14 @@ casbin role for this hackathon; `is_waiting` is false once approved. -<a name="hackathon_messages_page_svc_get_response-proto"></a> +<a name="hackathon_messages_track_svc_get_response-proto"></a> <p align="right"><a href="#top">Top</a></p> -## hackathon/messages/page_svc/get_response.proto +## hackathon/messages/track_svc/get_response.proto -<a name="hackathon-messages-page_svc-GetResponse"></a> +<a name="hackathon-messages-track_svc-GetResponse"></a> ### GetResponse @@ -1804,7 +6900,7 @@ casbin role for this hackathon; `is_waiting` is false once approved. | Field | Type | Label | Description | | ----- | ---- | ----- | ----------- | -| page | [hackathon.entities.Page](#hackathon-entities-Page) | | | +| track | [hackathon.entities.Track](#hackathon-entities-Track) | | | @@ -1820,14 +6916,14 @@ casbin role for this hackathon; `is_waiting` is false once approved. -<a name="hackathon_messages_page_svc_list_request-proto"></a> +<a name="hackathon_messages_track_svc_list_request-proto"></a> <p align="right"><a href="#top">Top</a></p> -## hackathon/messages/page_svc/list_request.proto +## hackathon/messages/track_svc/list_request.proto -<a name="hackathon-messages-page_svc-ListRequest"></a> +<a name="hackathon-messages-track_svc-ListRequest"></a> ### ListRequest @@ -1851,14 +6947,14 @@ casbin role for this hackathon; `is_waiting` is false once approved. -<a name="hackathon_messages_page_svc_list_response-proto"></a> +<a name="hackathon_messages_track_svc_list_response-proto"></a> <p align="right"><a href="#top">Top</a></p> -## hackathon/messages/page_svc/list_response.proto +## hackathon/messages/track_svc/list_response.proto -<a name="hackathon-messages-page_svc-ListResponse"></a> +<a name="hackathon-messages-track_svc-ListResponse"></a> ### ListResponse @@ -1866,10 +6962,26 @@ casbin role for this hackathon; `is_waiting` is false once approved. | Field | Type | Label | Description | | ----- | ---- | ----- | ----------- | -| pages | [hackathon.entities.Page](#hackathon-entities-Page) | repeated | | +| tracks | [hackathon.entities.Track](#hackathon-entities-Track) | repeated | | + + + + + + + + + + + + + +<a name="hackathon_page_service-proto"></a> +<p align="right"><a href="#top">Top</a></p> +## hackathon/page_service.proto @@ -1878,94 +6990,127 @@ casbin role for this hackathon; `is_waiting` is false once approved. - +<a name="hackathon-PageService"></a> +### PageService -<a name="hackathon_messages_page_svc_move_down_request-proto"></a> -<p align="right"><a href="#top">Top</a></p> -## hackathon/messages/page_svc/move_down_request.proto +| Method Name | Request Type | Response Type | Description | +| ----------- | ------------ | ------------- | ------------| +| List | [messages.page_svc.ListRequest](#hackathon-messages-page_svc-ListRequest) | [messages.page_svc.ListResponse](#hackathon-messages-page_svc-ListResponse) | | +| Get | [messages.page_svc.GetRequest](#hackathon-messages-page_svc-GetRequest) | [messages.page_svc.GetResponse](#hackathon-messages-page_svc-GetResponse) | | +| Create | [messages.page_svc.CreateRequest](#hackathon-messages-page_svc-CreateRequest) | [messages.page_svc.CreateResponse](#hackathon-messages-page_svc-CreateResponse) | | +| Edit | [messages.page_svc.EditRequest](#hackathon-messages-page_svc-EditRequest) | [messages.page_svc.EditResponse](#hackathon-messages-page_svc-EditResponse) | | +| Delete | [messages.page_svc.DeleteRequest](#hackathon-messages-page_svc-DeleteRequest) | [messages.page_svc.DeleteResponse](#hackathon-messages-page_svc-DeleteResponse) | | +| MoveUp | [messages.page_svc.MoveUpRequest](#hackathon-messages-page_svc-MoveUpRequest) | [messages.page_svc.MoveUpResponse](#hackathon-messages-page_svc-MoveUpResponse) | Reordering methods - backend manages order uniqueness | +| MoveDown | [messages.page_svc.MoveDownRequest](#hackathon-messages-page_svc-MoveDownRequest) | [messages.page_svc.MoveDownResponse](#hackathon-messages-page_svc-MoveDownResponse) | | +| SetOrder | [messages.page_svc.SetOrderRequest](#hackathon-messages-page_svc-SetOrderRequest) | [messages.page_svc.SetOrderResponse](#hackathon-messages-page_svc-SetOrderResponse) | | + -<a name="hackathon-messages-page_svc-MoveDownRequest"></a> -### MoveDownRequest +<a name="hackathon_phase_service-proto"></a> +<p align="right"><a href="#top">Top</a></p> +## hackathon/phase_service.proto -| Field | Type | Label | Description | -| ----- | ---- | ----- | ----------- | -| page_id | [string](#string) | | | -| increment | [int32](#int32) | optional | Number of positions to move down (default: 1) Must be >= 1 | + + + +<a name="hackathon-PhaseService"></a> - +### PhaseService - - +| Method Name | Request Type | Response Type | Description | +| ----------- | ------------ | ------------- | ------------| +| List | [messages.phase_svc.ListRequest](#hackathon-messages-phase_svc-ListRequest) | [messages.phase_svc.ListResponse](#hackathon-messages-phase_svc-ListResponse) | | +| Get | [messages.phase_svc.GetRequest](#hackathon-messages-phase_svc-GetRequest) | [messages.phase_svc.GetResponse](#hackathon-messages-phase_svc-GetResponse) | | +| Create | [messages.phase_svc.CreateRequest](#hackathon-messages-phase_svc-CreateRequest) | [messages.phase_svc.CreateResponse](#hackathon-messages-phase_svc-CreateResponse) | | +| Edit | [messages.phase_svc.EditRequest](#hackathon-messages-phase_svc-EditRequest) | [messages.phase_svc.EditResponse](#hackathon-messages-phase_svc-EditResponse) | | +| Delete | [messages.phase_svc.DeleteRequest](#hackathon-messages-phase_svc-DeleteRequest) | [messages.phase_svc.DeleteResponse](#hackathon-messages-phase_svc-DeleteResponse) | | -<a name="hackathon_messages_page_svc_move_down_response-proto"></a> +<a name="hackathon_prize_service-proto"></a> <p align="right"><a href="#top">Top</a></p> -## hackathon/messages/page_svc/move_down_response.proto +## hackathon/prize_service.proto + -<a name="hackathon-messages-page_svc-MoveDownResponse"></a> + -### MoveDownResponse + +<a name="hackathon-PrizeService"></a> -| Field | Type | Label | Description | -| ----- | ---- | ----- | ----------- | -| page_id | [string](#string) | | | -| order | [int32](#int32) | | | +### PrizeService +The prize table and the awards. Votes are advisory: nothing is won until +the admin finalizes, and the table stays admin-editable afterwards. +| Method Name | Request Type | Response Type | Description | +| ----------- | ------------ | ------------- | ------------| +| Get | [messages.prize_svc.GetRequest](#hackathon-messages-prize_svc-GetRequest) | [messages.prize_svc.GetResponse](#hackathon-messages-prize_svc-GetResponse) | Reads the table back. Set REPLACES it, so without this an organiser editing one prize would blank the rest they cannot see — the same trap GetWindows was added for. | +| Set | [messages.prize_svc.SetRequest](#hackathon-messages-prize_svc-SetRequest) | [messages.prize_svc.SetResponse](#hackathon-messages-prize_svc-SetResponse) | | +| Finalize | [messages.prize_svc.FinalizeRequest](#hackathon-messages-prize_svc-FinalizeRequest) | [messages.prize_svc.FinalizeResponse](#hackathon-messages-prize_svc-FinalizeResponse) | | +| Edit | [messages.prize_svc.EditRequest](#hackathon-messages-prize_svc-EditRequest) | [messages.prize_svc.EditResponse](#hackathon-messages-prize_svc-EditResponse) | | + - +<a name="hackathon_project_service-proto"></a> +<p align="right"><a href="#top">Top</a></p> - +## hackathon/project_service.proto - + - -<a name="hackathon_messages_page_svc_move_up_request-proto"></a> -<p align="right"><a href="#top">Top</a></p> - -## hackathon/messages/page_svc/move_up_request.proto + +<a name="hackathon-ProjectService"></a> -<a name="hackathon-messages-page_svc-MoveUpRequest"></a> +### ProjectService -### MoveUpRequest +| Method Name | Request Type | Response Type | Description | +| ----------- | ------------ | ------------- | ------------| +| List | [messages.project_svc.ListRequest](#hackathon-messages-project_svc-ListRequest) | [messages.project_svc.ListResponse](#hackathon-messages-project_svc-ListResponse) | | +| Get | [messages.project_svc.GetRequest](#hackathon-messages-project_svc-GetRequest) | [messages.project_svc.GetResponse](#hackathon-messages-project_svc-GetResponse) | | +| Propose | [messages.project_svc.ProposeRequest](#hackathon-messages-project_svc-ProposeRequest) | [messages.project_svc.ProposeResponse](#hackathon-messages-project_svc-ProposeResponse) | | +| Approve | [messages.project_svc.ApproveRequest](#hackathon-messages-project_svc-ApproveRequest) | [messages.project_svc.ApproveResponse](#hackathon-messages-project_svc-ApproveResponse) | | +| Disapprove | [messages.project_svc.DisapproveRequest](#hackathon-messages-project_svc-DisapproveRequest) | [messages.project_svc.DisapproveResponse](#hackathon-messages-project_svc-DisapproveResponse) | | +| SetPreference | [messages.project_svc.SetPreferenceRequest](#hackathon-messages-project_svc-SetPreferenceRequest) | [messages.project_svc.SetPreferenceResponse](#hackathon-messages-project_svc-SetPreferenceResponse) | | +| GetPreference | [messages.project_svc.GetPreferenceRequest](#hackathon-messages-project_svc-GetPreferenceRequest) | [messages.project_svc.GetPreferenceResponse](#hackathon-messages-project_svc-GetPreferenceResponse) | The caller's OWN preferences. ExportPreferences is organiser-only, so until now a participant had no way to see what they had chosen. | +| ExportPreferences | [messages.project_svc.ExportPreferencesRequest](#hackathon-messages-project_svc-ExportPreferencesRequest) | [messages.project_svc.ExportPreferencesResponse](#hackathon-messages-project_svc-ExportPreferencesResponse) | | +| Edit | [messages.project_svc.EditRequest](#hackathon-messages-project_svc-EditRequest) | [messages.project_svc.EditResponse](#hackathon-messages-project_svc-EditResponse) | | +| Delete | [messages.project_svc.DeleteRequest](#hackathon-messages-project_svc-DeleteRequest) | [messages.project_svc.DeleteResponse](#hackathon-messages-project_svc-DeleteResponse) | | +| RemovePreference | [messages.project_svc.RemovePreferenceRequest](#hackathon-messages-project_svc-RemovePreferenceRequest) | [messages.project_svc.RemovePreferenceResponse](#hackathon-messages-project_svc-RemovePreferenceResponse) | | + -| Field | Type | Label | Description | -| ----- | ---- | ----- | ----------- | -| page_id | [string](#string) | | | -| increment | [int32](#int32) | optional | Number of positions to move up (default: 1) Must be >= 1 | +<a name="hackathon_team_service-proto"></a> +<p align="right"><a href="#top">Top</a></p> +## hackathon/team_service.proto @@ -1974,59 +7119,72 @@ casbin role for this hackathon; `is_waiting` is false once approved. - +<a name="hackathon-TeamService"></a> +### TeamService -<a name="hackathon_messages_page_svc_move_up_response-proto"></a> -<p align="right"><a href="#top">Top</a></p> -## hackathon/messages/page_svc/move_up_response.proto +| Method Name | Request Type | Response Type | Description | +| ----------- | ------------ | ------------- | ------------| +| List | [messages.team_svc.ListRequest](#hackathon-messages-team_svc-ListRequest) | [messages.team_svc.ListResponse](#hackathon-messages-team_svc-ListResponse) | | +| Get | [messages.team_svc.GetRequest](#hackathon-messages-team_svc-GetRequest) | [messages.team_svc.GetResponse](#hackathon-messages-team_svc-GetResponse) | | +| Create | [messages.team_svc.CreateRequest](#hackathon-messages-team_svc-CreateRequest) | [messages.team_svc.CreateResponse](#hackathon-messages-team_svc-CreateResponse) | | +| Edit | [messages.team_svc.EditRequest](#hackathon-messages-team_svc-EditRequest) | [messages.team_svc.EditResponse](#hackathon-messages-team_svc-EditResponse) | | +| Delete | [messages.team_svc.DeleteRequest](#hackathon-messages-team_svc-DeleteRequest) | [messages.team_svc.DeleteResponse](#hackathon-messages-team_svc-DeleteResponse) | | +| AssignUser | [messages.team_svc.AssignUserRequest](#hackathon-messages-team_svc-AssignUserRequest) | [messages.team_svc.AssignUserResponse](#hackathon-messages-team_svc-AssignUserResponse) | | +| RemoveUser | [messages.team_svc.RemoveUserRequest](#hackathon-messages-team_svc-RemoveUserRequest) | [messages.team_svc.RemoveUserResponse](#hackathon-messages-team_svc-RemoveUserResponse) | | +| CreateSubmission | [messages.team_svc.CreateSubmissionRequest](#hackathon-messages-team_svc-CreateSubmissionRequest) | [messages.team_svc.CreateSubmissionResponse](#hackathon-messages-team_svc-CreateSubmissionResponse) | | +| GetSubmission | [messages.team_svc.GetSubmissionRequest](#hackathon-messages-team_svc-GetSubmissionRequest) | [messages.team_svc.GetSubmissionResponse](#hackathon-messages-team_svc-GetSubmissionResponse) | | +| ListSubmissions | [messages.team_svc.ListSubmissionsRequest](#hackathon-messages-team_svc-ListSubmissionsRequest) | [messages.team_svc.ListSubmissionsResponse](#hackathon-messages-team_svc-ListSubmissionsResponse) | | +| EditSubmission | [messages.team_svc.EditSubmissionRequest](#hackathon-messages-team_svc-EditSubmissionRequest) | [messages.team_svc.EditSubmissionResponse](#hackathon-messages-team_svc-EditSubmissionResponse) | | +| FinalizeSubmission | [messages.team_svc.FinalizeSubmissionRequest](#hackathon-messages-team_svc-FinalizeSubmissionRequest) | [messages.team_svc.FinalizeSubmissionResponse](#hackathon-messages-team_svc-FinalizeSubmissionResponse) | | + -<a name="hackathon-messages-page_svc-MoveUpResponse"></a> -### MoveUpResponse +<a name="hackathon_track_service-proto"></a> +<p align="right"><a href="#top">Top</a></p> +## hackathon/track_service.proto -| Field | Type | Label | Description | -| ----- | ---- | ----- | ----------- | -| page_id | [string](#string) | | | -| order | [int32](#int32) | | | + + + +<a name="hackathon-TrackService"></a> - +### TrackService - - +| Method Name | Request Type | Response Type | Description | +| ----------- | ------------ | ------------- | ------------| +| List | [messages.track_svc.ListRequest](#hackathon-messages-track_svc-ListRequest) | [messages.track_svc.ListResponse](#hackathon-messages-track_svc-ListResponse) | | +| Get | [messages.track_svc.GetRequest](#hackathon-messages-track_svc-GetRequest) | [messages.track_svc.GetResponse](#hackathon-messages-track_svc-GetResponse) | | +| Create | [messages.track_svc.CreateRequest](#hackathon-messages-track_svc-CreateRequest) | [messages.track_svc.CreateResponse](#hackathon-messages-track_svc-CreateResponse) | | +| Edit | [messages.track_svc.EditRequest](#hackathon-messages-track_svc-EditRequest) | [messages.track_svc.EditResponse](#hackathon-messages-track_svc-EditResponse) | | +| Delete | [messages.track_svc.DeleteRequest](#hackathon-messages-track_svc-DeleteRequest) | [messages.track_svc.DeleteResponse](#hackathon-messages-track_svc-DeleteResponse) | | -<a name="hackathon_messages_page_svc_set_order_request-proto"></a> +<a name="health_messages_health_svc_check_request-proto"></a> <p align="right"><a href="#top">Top</a></p> -## hackathon/messages/page_svc/set_order_request.proto - - +## health/messages/health_svc/check_request.proto -<a name="hackathon-messages-page_svc-SetOrderRequest"></a> -### SetOrderRequest +<a name="health-messages-health_svc-CheckRequest"></a> +### CheckRequest -| Field | Type | Label | Description | -| ----- | ---- | ----- | ----------- | -| hackathon_id | [string](#string) | | | -| page_ids | [string](#string) | repeated | List of page IDs in the desired order | @@ -2042,22 +7200,22 @@ casbin role for this hackathon; `is_waiting` is false once approved. -<a name="hackathon_messages_page_svc_set_order_response-proto"></a> +<a name="health_messages_health_svc_check_response-proto"></a> <p align="right"><a href="#top">Top</a></p> -## hackathon/messages/page_svc/set_order_response.proto +## health/messages/health_svc/check_response.proto -<a name="hackathon-messages-page_svc-SetOrderResponse"></a> +<a name="health-messages-health_svc-CheckResponse"></a> -### SetOrderResponse +### CheckResponse | Field | Type | Label | Description | | ----- | ---- | ----- | ----------- | -| page_ids | [string](#string) | repeated | | +| message | [string](#string) | | | @@ -2073,58 +7231,59 @@ casbin role for this hackathon; `is_waiting` is false once approved. -<a name="hackathon_messages_phase_svc_create_request-proto"></a> +<a name="health_health_service-proto"></a> <p align="right"><a href="#top">Top</a></p> -## hackathon/messages/phase_svc/create_request.proto - - - -<a name="hackathon-messages-phase_svc-CreateRequest"></a> - -### CreateRequest - +## health/health_service.proto -| Field | Type | Label | Description | -| ----- | ---- | ----- | ----------- | -| hackathon_id | [string](#string) | | | -| name | [string](#string) | | | -| description | [string](#string) | | | -| starts_at | [google.protobuf.Timestamp](#google-protobuf-Timestamp) | optional | | -| ends_at | [google.protobuf.Timestamp](#google-protobuf-Timestamp) | optional | | -| page_id | [string](#string) | optional | | + + + +<a name="health-HealthService"></a> - +### HealthService - - +| Method Name | Request Type | Response Type | Description | +| ----------- | ------------ | ------------- | ------------| +| Check | [messages.health_svc.CheckRequest](#health-messages-health_svc-CheckRequest) | [messages.health_svc.CheckResponse](#health-messages-health_svc-CheckResponse) | | -<a name="hackathon_messages_phase_svc_create_response-proto"></a> +<a name="site_entities_site_page-proto"></a> <p align="right"><a href="#top">Top</a></p> -## hackathon/messages/phase_svc/create_response.proto - +## site/entities/site_page.proto -<a name="hackathon-messages-phase_svc-CreateResponse"></a> -### CreateResponse +<a name="site-entities-SitePage"></a> +### SitePage +A platform-level content page (about, privacy, terms), addressed by slug. +Unlike hackathon.entities.Page it belongs to the site itself, so it carries +no hackathon id and is authorized in the fixed "site" casbin domain. | Field | Type | Label | Description | | ----- | ---- | ----- | ----------- | -| phase_id | [string](#string) | | | +| id | [string](#string) | | | +| slug | [string](#string) | | | +| title | [string](#string) | | | +| content | [string](#string) | | Markdown. Rendered through the frontend's sanitizing pipeline — never inject it into the DOM raw. | +| visible | [bool](#bool) | | | +| order | [int32](#int32) | | | +| created_at | [google.protobuf.Timestamp](#google-protobuf-Timestamp) | | | +| modified_at | [google.protobuf.Timestamp](#google-protobuf-Timestamp) | | | +| creator_id | [string](#string) | | | +| modifier_id | [string](#string) | | | @@ -2140,22 +7299,26 @@ casbin role for this hackathon; `is_waiting` is false once approved. -<a name="hackathon_messages_phase_svc_delete_request-proto"></a> +<a name="site_messages_site_page_svc_create_request-proto"></a> <p align="right"><a href="#top">Top</a></p> -## hackathon/messages/phase_svc/delete_request.proto +## site/messages/site_page_svc/create_request.proto -<a name="hackathon-messages-phase_svc-DeleteRequest"></a> +<a name="site-messages-site_page_svc-CreateRequest"></a> -### DeleteRequest +### CreateRequest | Field | Type | Label | Description | | ----- | ---- | ----- | ----------- | -| phase_id | [string](#string) | | | +| slug | [string](#string) | | | +| title | [string](#string) | | | +| content | [string](#string) | | | +| visible | [bool](#bool) | optional | | +| order | [int32](#int32) | optional | | @@ -2171,18 +7334,23 @@ casbin role for this hackathon; `is_waiting` is false once approved. -<a name="hackathon_messages_phase_svc_delete_response-proto"></a> +<a name="site_messages_site_page_svc_create_response-proto"></a> <p align="right"><a href="#top">Top</a></p> -## hackathon/messages/phase_svc/delete_response.proto +## site/messages/site_page_svc/create_response.proto -<a name="hackathon-messages-phase_svc-DeleteResponse"></a> +<a name="site-messages-site_page_svc-CreateResponse"></a> + +### CreateResponse -### DeleteResponse +| Field | Type | Label | Description | +| ----- | ---- | ----- | ----------- | +| id | [string](#string) | | | + @@ -2197,27 +7365,22 @@ casbin role for this hackathon; `is_waiting` is false once approved. -<a name="hackathon_messages_phase_svc_edit_request-proto"></a> +<a name="site_messages_site_page_svc_delete_request-proto"></a> <p align="right"><a href="#top">Top</a></p> -## hackathon/messages/phase_svc/edit_request.proto +## site/messages/site_page_svc/delete_request.proto -<a name="hackathon-messages-phase_svc-EditRequest"></a> +<a name="site-messages-site_page_svc-DeleteRequest"></a> -### EditRequest +### DeleteRequest | Field | Type | Label | Description | | ----- | ---- | ----- | ----------- | -| phase_id | [string](#string) | | | -| name | [string](#string) | optional | | -| description | [string](#string) | optional | | -| starts_at | [google.protobuf.Timestamp](#google-protobuf-Timestamp) | optional | | -| ends_at | [google.protobuf.Timestamp](#google-protobuf-Timestamp) | optional | | -| page_id | [string](#string) | optional | Empty string = unlink (unset), non-empty string = link to this phase, not set = no change | +| slug | [string](#string) | | | @@ -2233,22 +7396,17 @@ casbin role for this hackathon; `is_waiting` is false once approved. -<a name="hackathon_messages_phase_svc_edit_response-proto"></a> +<a name="site_messages_site_page_svc_delete_response-proto"></a> <p align="right"><a href="#top">Top</a></p> -## hackathon/messages/phase_svc/edit_response.proto - +## site/messages/site_page_svc/delete_response.proto -<a name="hackathon-messages-phase_svc-EditResponse"></a> - -### EditResponse +<a name="site-messages-site_page_svc-DeleteResponse"></a> +### DeleteResponse -| Field | Type | Label | Description | -| ----- | ---- | ----- | ----------- | -| phase | [hackathon.entities.Phase](#hackathon-entities-Phase) | | | @@ -2264,22 +7422,28 @@ casbin role for this hackathon; `is_waiting` is false once approved. -<a name="hackathon_messages_phase_svc_get_request-proto"></a> +<a name="site_messages_site_page_svc_edit_request-proto"></a> <p align="right"><a href="#top">Top</a></p> -## hackathon/messages/phase_svc/get_request.proto - +## site/messages/site_page_svc/edit_request.proto -<a name="hackathon-messages-phase_svc-GetRequest"></a> -### GetRequest +<a name="site-messages-site_page_svc-EditRequest"></a> +### EditRequest +Every field optional: absent means "leave unchanged" (see the write-path +convention in CLAUDE.md). The page is identified by its current slug. | Field | Type | Label | Description | | ----- | ---- | ----- | ----------- | -| phase_id | [string](#string) | | | +| slug | [string](#string) | | | +| new_slug | [string](#string) | optional | | +| title | [string](#string) | optional | | +| content | [string](#string) | optional | | +| visible | [bool](#bool) | optional | | +| order | [int32](#int32) | optional | | @@ -2295,22 +7459,22 @@ casbin role for this hackathon; `is_waiting` is false once approved. -<a name="hackathon_messages_phase_svc_get_response-proto"></a> +<a name="site_messages_site_page_svc_edit_response-proto"></a> <p align="right"><a href="#top">Top</a></p> -## hackathon/messages/phase_svc/get_response.proto +## site/messages/site_page_svc/edit_response.proto -<a name="hackathon-messages-phase_svc-GetResponse"></a> +<a name="site-messages-site_page_svc-EditResponse"></a> -### GetResponse +### EditResponse | Field | Type | Label | Description | | ----- | ---- | ----- | ----------- | -| phase | [hackathon.entities.Phase](#hackathon-entities-Phase) | | | +| site_page | [site.entities.SitePage](#site-entities-SitePage) | | | @@ -2326,22 +7490,22 @@ casbin role for this hackathon; `is_waiting` is false once approved. -<a name="hackathon_messages_phase_svc_list_request-proto"></a> +<a name="site_messages_site_page_svc_get_request-proto"></a> <p align="right"><a href="#top">Top</a></p> -## hackathon/messages/phase_svc/list_request.proto - +## site/messages/site_page_svc/get_request.proto -<a name="hackathon-messages-phase_svc-ListRequest"></a> -### ListRequest +<a name="site-messages-site_page_svc-GetRequest"></a> +### GetRequest +Pages are fetched by their stable slug — that is what the router has. | Field | Type | Label | Description | | ----- | ---- | ----- | ----------- | -| hackathon_id | [string](#string) | | | +| slug | [string](#string) | | | @@ -2357,22 +7521,22 @@ casbin role for this hackathon; `is_waiting` is false once approved. -<a name="hackathon_messages_phase_svc_list_response-proto"></a> +<a name="site_messages_site_page_svc_get_response-proto"></a> <p align="right"><a href="#top">Top</a></p> -## hackathon/messages/phase_svc/list_response.proto +## site/messages/site_page_svc/get_response.proto -<a name="hackathon-messages-phase_svc-ListResponse"></a> +<a name="site-messages-site_page_svc-GetResponse"></a> -### ListResponse +### GetResponse | Field | Type | Label | Description | | ----- | ---- | ----- | ----------- | -| phases | [hackathon.entities.Phase](#hackathon-entities-Phase) | repeated | | +| site_page | [site.entities.SitePage](#site-entities-SitePage) | | | @@ -2388,22 +7552,22 @@ casbin role for this hackathon; `is_waiting` is false once approved. -<a name="hackathon_messages_project_svc_approve_request-proto"></a> +<a name="site_messages_site_page_svc_list_request-proto"></a> <p align="right"><a href="#top">Top</a></p> -## hackathon/messages/project_svc/approve_request.proto +## site/messages/site_page_svc/list_request.proto -<a name="hackathon-messages-project_svc-ApproveRequest"></a> +<a name="site-messages-site_page_svc-ListRequest"></a> -### ApproveRequest +### ListRequest | Field | Type | Label | Description | | ----- | ---- | ----- | ----------- | -| project_id | [string](#string) | | | +| include_hidden | [bool](#bool) | optional | When true, drafts are included too (admins only; ignored for others). | @@ -2419,19 +7583,24 @@ casbin role for this hackathon; `is_waiting` is false once approved. -<a name="hackathon_messages_project_svc_approve_response-proto"></a> +<a name="site_messages_site_page_svc_list_response-proto"></a> <p align="right"><a href="#top">Top</a></p> -## hackathon/messages/project_svc/approve_response.proto +## site/messages/site_page_svc/list_response.proto -<a name="hackathon-messages-project_svc-ApproveResponse"></a> +<a name="site-messages-site_page_svc-ListResponse"></a> -### ApproveResponse +### ListResponse +| Field | Type | Label | Description | +| ----- | ---- | ----- | ----------- | +| site_pages | [site.entities.SitePage](#site-entities-SitePage) | repeated | | + + @@ -2445,54 +7614,92 @@ casbin role for this hackathon; `is_waiting` is false once approved. -<a name="hackathon_messages_project_svc_delete_request-proto"></a> +<a name="site_site_page_service-proto"></a> <p align="right"><a href="#top">Top</a></p> -## hackathon/messages/project_svc/delete_request.proto - - +## site/site_page_service.proto -<a name="hackathon-messages-project_svc-DeleteRequest"></a> - -### DeleteRequest + + -| Field | Type | Label | Description | -| ----- | ---- | ----- | ----------- | -| project_id | [string](#string) | | | + +<a name="site-SitePageService"></a> +### SitePageService +Platform-level content pages (about, privacy, terms). Reads are open to +anonymous callers for published pages — these are the pages a visitor +reaches from the footer before ever logging in. Writes require the global +Admin role: there is no per-hackathon owner for site-wide content. +| Method Name | Request Type | Response Type | Description | +| ----------- | ------------ | ------------- | ------------| +| List | [messages.site_page_svc.ListRequest](#site-messages-site_page_svc-ListRequest) | [messages.site_page_svc.ListResponse](#site-messages-site_page_svc-ListResponse) | | +| Get | [messages.site_page_svc.GetRequest](#site-messages-site_page_svc-GetRequest) | [messages.site_page_svc.GetResponse](#site-messages-site_page_svc-GetResponse) | | +| Create | [messages.site_page_svc.CreateRequest](#site-messages-site_page_svc-CreateRequest) | [messages.site_page_svc.CreateResponse](#site-messages-site_page_svc-CreateResponse) | | +| Edit | [messages.site_page_svc.EditRequest](#site-messages-site_page_svc-EditRequest) | [messages.site_page_svc.EditResponse](#site-messages-site_page_svc-EditResponse) | | +| Delete | [messages.site_page_svc.DeleteRequest](#site-messages-site_page_svc-DeleteRequest) | [messages.site_page_svc.DeleteResponse](#site-messages-site_page_svc-DeleteResponse) | | - - - +<a name="storage_entities_object_scope-proto"></a> +<p align="right"><a href="#top">Top</a></p> +## storage/entities/object_scope.proto -<a name="hackathon_messages_project_svc_delete_response-proto"></a> -<p align="right"><a href="#top">Top</a></p> + -## hackathon/messages/project_svc/delete_response.proto +<a name="storage-entities-ObjectScope"></a> +### ObjectScope +Which slice of the object store a listing covers. -<a name="hackathon-messages-project_svc-DeleteResponse"></a> +The scope is the ONLY placement input a client has, exactly as `UploadKind` +is for writes: the backend derives the key prefixes and the authorization +rule from it, and a client-supplied prefix is never trusted. There is no +"give me the bucket" scope, and adding one would be a mistake — see below. -### DeleteResponse + HACKATHON_MEDIA hackathons/<owner_id>/ everything one event has uploaded, + logos and page imagery alike + SITE_MEDIA site/media/ the platform pages' imagery + ALL_MEDIA hackathons/ + site/media/ every listable prefix at once +**The rule, stated once: you may LIST a prefix exactly when you may WRITE to +it.** Each scope's check is the same check `authorizeUpload` makes for the +kind that files objects there — hackathon `write` for the two hackathon +kinds, the global Admin role for SITE_MEDIA. That is deliberate and is the +same construction docs/storage.md uses for uploads: the answer to "may I see +what is in here?" is the same answer as "may I put something in here?" by +construction, rather than by two rules that have to be kept in agreement. +**Two prefixes are absent on purpose, and they are the reason this is an enum +rather than a prefix string.** + users/<id>/avatar/ — other people's faces. Nothing in the product needs to + enumerate them: an avatar is set from the profile that owns it, and a + gallery exists to pick a picture to REUSE somewhere else, which is + precisely what must not be easy to do with someone's photograph. A global + admin fixing one profile still reaches it from that profile. + teams/<id>/submissions/ — private by bucket policy. Those objects have no + stable readable path at all, so a picker row for one would be a broken + image; and the KEYS alone would say which teams turned work in and how + much of it, to anyone allowed to list any scope. +| Name | Number | Description | +| ---- | ------ | ----------- | +| OBJECT_SCOPE_UNSPECIFIED | 0 | | +| OBJECT_SCOPE_HACKATHON_MEDIA | 1 | hackathons/<owner_id>/ — `owner_id` is the hackathon. Requires hackathon `write`, the permission that uploads a logo or page image. | +| OBJECT_SCOPE_SITE_MEDIA | 2 | site/media/ — reads no owner id, for the same reason UPLOAD_KIND_SITE_MEDIA does not: platform pages belong to no event and no person. Requires the global Admin role. | +| OBJECT_SCOPE_ALL_MEDIA | 3 | Every listable prefix in one answer, for the platform's media library. Requires the global Admin role — it spans events the caller may have no part in, so it takes the only role that is allowed to. | - @@ -2502,22 +7709,32 @@ casbin role for this hackathon; `is_waiting` is false once approved. -<a name="hackathon_messages_project_svc_disapprove_request-proto"></a> +<a name="storage_entities_stored_object-proto"></a> <p align="right"><a href="#top">Top</a></p> -## hackathon/messages/project_svc/disapprove_request.proto +## storage/entities/stored_object.proto -<a name="hackathon-messages-project_svc-DisapproveRequest"></a> +<a name="storage-entities-StoredObject"></a> -### DisapproveRequest +### StoredObject +One object already in the store, as a listing reports it. +Deliberately NOT a presigned URL. Every scope a listing can cover is +public-read by bucket policy, so `url` is the same stable, root-relative path +`CreateUploadUrl` handed back when the object was written — the value that +belongs in the database. Signing these would hand out a wall of bearer +credentials for objects that need none, and they would start expiring while +the gallery was still on screen. | Field | Type | Label | Description | | ----- | ---- | ----- | ----------- | -| project_id | [string](#string) | | | +| key | [string](#string) | | The stable object key, e.g. `site/media/<uuid>.webp`. | +| url | [string](#string) | | Root-relative path the object is readable at: `/objects/<bucket>/<key>`. This is what a picker hands back to a form and what a form stores. | +| size_bytes | [int64](#int64) | | | +| last_modified | [google.protobuf.Timestamp](#google-protobuf-Timestamp) | | When the object was last written. This is what a listing is ordered by — newest first — because "the one I just uploaded" is what a person is looking for. Zero when the store did not report a parseable timestamp. | @@ -2533,23 +7750,49 @@ casbin role for this hackathon; `is_waiting` is false once approved. -<a name="hackathon_messages_project_svc_disapprove_response-proto"></a> +<a name="storage_entities_upload_kind-proto"></a> <p align="right"><a href="#top">Top</a></p> -## hackathon/messages/project_svc/disapprove_response.proto +## storage/entities/upload_kind.proto + -<a name="hackathon-messages-project_svc-DisapproveResponse"></a> -### DisapproveResponse +<a name="storage-entities-UploadKind"></a> +### UploadKind +What is being uploaded. The kind is the ONLY thing the client gets to choose +about placement: the backend derives the key prefix, the content-type +allowlist, the size ceiling and the authorization rule from it (see +docs/storage.md, "Keys, not URLs"). A client-supplied path is never trusted. + HACKATHON_LOGO hackathons/<hackathon-id>/logo/<uuid>.<ext> public + HACKATHON_MEDIA hackathons/<hackathon-id>/media/<uuid>.<ext> public + USER_AVATAR users/<user-id>/avatar/<uuid>.<ext> public + SUBMISSION_ATTACHMENT teams/<team-id>/submissions/<submission-id>/<uuid>.<ext> private + SITE_MEDIA site/media/<uuid>.<ext> public +`owner_id` is read against the kind: the hackathon id for the two hackathon +kinds, the platform user id for an avatar, and the SUBMISSION id for an +attachment — the team half of that key is looked up server-side, so a caller +cannot file an attachment under someone else's team. +SITE_MEDIA reads NO owner id, and is the only kind that does not: a platform +page (about, privacy, terms) belongs to no event and no person, so there is +nothing to name and nothing to scope a casbin domain to. It authorizes on the +global Admin role instead — the same rule every SitePageService mutation +uses — and its key carries no owner segment at all. +| Name | Number | Description | +| ---- | ------ | ----------- | +| UPLOAD_KIND_UNSPECIFIED | 0 | | +| UPLOAD_KIND_HACKATHON_LOGO | 1 | | +| UPLOAD_KIND_HACKATHON_MEDIA | 2 | | +| UPLOAD_KIND_USER_AVATAR | 3 | | +| UPLOAD_KIND_SUBMISSION_ATTACHMENT | 4 | | +| UPLOAD_KIND_SITE_MEDIA | 5 | | - @@ -2559,26 +7802,26 @@ casbin role for this hackathon; `is_waiting` is false once approved. -<a name="hackathon_messages_project_svc_edit_request-proto"></a> +<a name="storage_messages_storage_svc_create_download_url_request-proto"></a> <p align="right"><a href="#top">Top</a></p> -## hackathon/messages/project_svc/edit_request.proto +## storage/messages/storage_svc/create_download_url_request.proto -<a name="hackathon-messages-project_svc-EditRequest"></a> +<a name="storage-messages-storage_svc-CreateDownloadUrlRequest"></a> -### EditRequest +### CreateDownloadUrlRequest +Mint a short-lived read URL for a PRIVATE object. +Public imagery does not come through here and is rejected on purpose: those +prefixes are world-readable by bucket policy, so their stored path already +works and signing one would hand out a bearer credential for nothing. | Field | Type | Label | Description | | ----- | ---- | ----- | ----------- | -| project_id | [string](#string) | | | -| title | [string](#string) | optional | | -| description | [string](#string) | optional | | -| track_id | [string](#string) | optional | | -| image | [string](#string) | optional | | +| key | [string](#string) | | An object key as returned by CreateUploadUrl — not a URL, and not a path with a bucket in it. The key's own shape says which entity owns it, and that is what is authorized. | @@ -2594,22 +7837,23 @@ casbin role for this hackathon; `is_waiting` is false once approved. -<a name="hackathon_messages_project_svc_edit_response-proto"></a> +<a name="storage_messages_storage_svc_create_download_url_response-proto"></a> <p align="right"><a href="#top">Top</a></p> -## hackathon/messages/project_svc/edit_response.proto +## storage/messages/storage_svc/create_download_url_response.proto -<a name="hackathon-messages-project_svc-EditResponse"></a> +<a name="storage-messages-storage_svc-CreateDownloadUrlResponse"></a> -### EditResponse +### CreateDownloadUrlResponse | Field | Type | Label | Description | | ----- | ---- | ----- | ----------- | -| project | [hackathon.entities.Project](#hackathon-entities-Project) | | | +| download_url | [string](#string) | | Root-relative and same-origin, like upload_url. Treat it as a bearer credential: anything holding it can read the object until it lapses, which is exactly why it is never written to the database. | +| expires_at | [google.protobuf.Timestamp](#google-protobuf-Timestamp) | | | @@ -2625,22 +7869,36 @@ casbin role for this hackathon; `is_waiting` is false once approved. -<a name="hackathon_messages_project_svc_export_preferences_request-proto"></a> +<a name="storage_messages_storage_svc_create_upload_url_request-proto"></a> <p align="right"><a href="#top">Top</a></p> -## hackathon/messages/project_svc/export_preferences_request.proto +## storage/messages/storage_svc/create_upload_url_request.proto -<a name="hackathon-messages-project_svc-ExportPreferencesRequest"></a> +<a name="storage-messages-storage_svc-CreateUploadUrlRequest"></a> -### ExportPreferencesRequest +### CreateUploadUrlRequest +Ask for permission to upload one object. Nothing here names a path: the key +is the server's to decide, so the worst a hostile client can do is ask for a +kind it may not write, which casbin refuses. +`content_type` and `size_bytes` are DECLARED here and then baked into the +signature as conditions, which is what lets an oversized or wrong-typed +upload be refused before a single byte moves. Declaring them falsely does not +help: the object store recomputes the signature over the headers the browser +actually sent, so a mismatch fails at the store. | Field | Type | Label | Description | | ----- | ---- | ----- | ----------- | -| hackathon_id | [string](#string) | | | +| kind | [storage.entities.UploadKind](#storage-entities-UploadKind) | | | +| owner_id | [string](#string) | | The owning entity, read according to `kind` — see UploadKind. + +A uuid WHEN PRESENT, and empty is allowed, because whether an owner exists at all depends on the kind: SITE_MEDIA files a platform page's imagery, and a platform page belongs to no event and no person. Expressing "required unless kind == SITE_MEDIA" here would need a message-level CEL rule restating the kind table, so the per-kind requirement stays in authorizeUpload, which is where the rest of that table already lives — the kinds that DO name an owner still answer InvalidArgument for an empty one. | +| filename | [string](#string) | | The user's own filename. Used only to cross-check the declared content_type; the stored key gets a fresh uuid and an extension derived from the content type, so nothing a user typed reaches the object store. | +| content_type | [string](#string) | | | +| size_bytes | [int64](#int64) | | Exact byte length of the file about to be uploaded, not an estimate. | @@ -2656,22 +7914,27 @@ casbin role for this hackathon; `is_waiting` is false once approved. -<a name="hackathon_messages_project_svc_export_preferences_response-proto"></a> +<a name="storage_messages_storage_svc_create_upload_url_response-proto"></a> <p align="right"><a href="#top">Top</a></p> -## hackathon/messages/project_svc/export_preferences_response.proto +## storage/messages/storage_svc/create_upload_url_response.proto -<a name="hackathon-messages-project_svc-ExportPreferencesResponse"></a> +<a name="storage-messages-storage_svc-CreateUploadUrlResponse"></a> -### ExportPreferencesResponse +### CreateUploadUrlResponse | Field | Type | Label | Description | | ----- | ---- | ----- | ----------- | -| projects | [hackathon.entities.ProjectWithPreferences](#hackathon-entities-ProjectWithPreferences) | repeated | | +| upload_url | [string](#string) | | Where the BROWSER PUTs the bytes — root-relative and same-origin (/objects/<bucket>/<key>?X-Amz-...), so the file never passes through the app server and no CORS grant is needed. The request must carry exactly the Content-Type and byte count that were declared, because both are signed. | +| key | [string](#string) | | The stable object key. This is what identifies the object forever; the signed URL above stops working in minutes. | +| public_url | [string](#string) | | Root-relative path the object will be readable at once uploaded, for the public kinds. EMPTY for private kinds (submission attachments) — those are read through CreateDownloadUrl, after casbin has approved the read. + +This is the value that goes into Hackathon.logo / User.avatar_url: it never expires and it resolves from localhost, the tunnel and a deployment alike. | +| expires_at | [google.protobuf.Timestamp](#google-protobuf-Timestamp) | | When the signature above stops being accepted. Purely informational — the upload either starts in time or it does not. | @@ -2687,22 +7950,37 @@ casbin role for this hackathon; `is_waiting` is false once approved. -<a name="hackathon_messages_project_svc_get_request-proto"></a> +<a name="storage_messages_storage_svc_list_objects_request-proto"></a> <p align="right"><a href="#top">Top</a></p> -## hackathon/messages/project_svc/get_request.proto +## storage/messages/storage_svc/list_objects_request.proto -<a name="hackathon-messages-project_svc-GetRequest"></a> +<a name="storage-messages-storage_svc-ListObjectsRequest"></a> -### GetRequest +### ListObjectsRequest +List what has already been uploaded into one scope. + +Nothing here names a prefix: the scope is the whole placement input, and the +backend derives both the prefixes and the authorization rule from it — see +ObjectScope, which also records the two prefixes no scope covers. +The answer is bounded whatever is asked for. The store is scanned up to a +fixed ceiling, the results are ordered newest-first, and `truncated` on the +response says when the ceiling was reached — a bucket grows without limit and +a listing that did not bound itself would eventually be the slowest request in +the application. | Field | Type | Label | Description | | ----- | ---- | ----- | ----------- | -| project_id | [string](#string) | | | +| scope | [storage.entities.ObjectScope](#storage-entities-ObjectScope) | | | +| owner_id | [string](#string) | | The owning entity, read according to `scope` — the hackathon id for HACKATHON_MEDIA, and nothing at all for the other two. + +A uuid WHEN PRESENT, and empty is allowed, for the same reason CreateUploadUrlRequest allows it: whether an owner exists depends on the scope, and expressing "required unless scope is …" here would restate the scope table in CEL. The scope that DOES name an owner answers InvalidArgument for an empty one. | +| page_size | [int32](#int32) | | How many objects to return. 0 means the server's default (60); the ceiling is 200 and a larger request is clamped rather than refused, because the number is a rendering preference, not a permission. | +| page_token | [string](#string) | | Opaque cursor from a previous response's `next_page_token`. Empty starts at the newest object. | @@ -2718,22 +7996,24 @@ casbin role for this hackathon; `is_waiting` is false once approved. -<a name="hackathon_messages_project_svc_get_response-proto"></a> +<a name="storage_messages_storage_svc_list_objects_response-proto"></a> <p align="right"><a href="#top">Top</a></p> -## hackathon/messages/project_svc/get_response.proto +## storage/messages/storage_svc/list_objects_response.proto -<a name="hackathon-messages-project_svc-GetResponse"></a> +<a name="storage-messages-storage_svc-ListObjectsResponse"></a> -### GetResponse +### ListObjectsResponse | Field | Type | Label | Description | | ----- | ---- | ----- | ----------- | -| project | [hackathon.entities.Project](#hackathon-entities-Project) | | | +| objects | [storage.entities.StoredObject](#storage-entities-StoredObject) | repeated | Newest first. Only objects whose extension is on the image allowlist are returned: every listable prefix is an imagery prefix, so a stray `.txt` left by a bootstrap probe is noise in a picture gallery and nothing else. | +| next_page_token | [string](#string) | | Pass back as `page_token` for the next page. Empty when this is the last page of what was scanned. | +| truncated | [bool](#bool) | | True when the scan hit its ceiling, so these objects are a window over an unknown remainder rather than everything in the scope. Say so on screen: an exhaustive-looking gallery that silently stops is how someone concludes their upload failed. | @@ -2749,53 +8029,67 @@ casbin role for this hackathon; `is_waiting` is false once approved. -<a name="hackathon_messages_project_svc_list_request-proto"></a> +<a name="storage_storage_service-proto"></a> <p align="right"><a href="#top">Top</a></p> -## hackathon/messages/project_svc/list_request.proto - - - -<a name="hackathon-messages-project_svc-ListRequest"></a> +## storage/storage_service.proto -### ListRequest + + -| Field | Type | Label | Description | -| ----- | ---- | ----- | ----------- | -| hackathon_id | [string](#string) | | | + +<a name="storage-StorageService"></a> +### StorageService +Signed access to the object store (docs/storage.md). +Files do not travel through this service — only permission to move them +does. The backend authorizes the caller, decides the key, pins the +content-type and the byte count into the signature, and hands back a URL the +browser uses directly. - +There is deliberately no Delete RPC. Objects are removed by prefix when their +OWNER is deleted (HackathonService.Delete, UserService.DeleteAccount), which +is what keeps deletion complete without a manifest of what belongs to whom. - +A single-object delete would need that manifest to be safe: an image can be +referenced from any page's markdown, any event's logo column and any prize +row, and nothing records which. Removing one would break those references +silently — the row keeps its path and the page renders a hole — so the only +deletion that exists is the one whose scope is an entity nobody is pointing +at any more. - +| Method Name | Request Type | Response Type | Description | +| ----------- | ------------ | ------------- | ------------| +| CreateUploadUrl | [messages.storage_svc.CreateUploadUrlRequest](#storage-messages-storage_svc-CreateUploadUrlRequest) | [messages.storage_svc.CreateUploadUrlResponse](#storage-messages-storage_svc-CreateUploadUrlResponse) | | +| CreateDownloadUrl | [messages.storage_svc.CreateDownloadUrlRequest](#storage-messages-storage_svc-CreateDownloadUrlRequest) | [messages.storage_svc.CreateDownloadUrlResponse](#storage-messages-storage_svc-CreateDownloadUrlResponse) | | +| ListObjects | [messages.storage_svc.ListObjectsRequest](#storage-messages-storage_svc-ListObjectsRequest) | [messages.storage_svc.ListObjectsResponse](#storage-messages-storage_svc-ListObjectsResponse) | What is already in the store, so a picker can offer it instead of a second copy of the same picture. Reads only; see ObjectScope for who may list what. | -<a name="hackathon_messages_project_svc_list_response-proto"></a> +<a name="user_messages_user_svc_add_role_request-proto"></a> <p align="right"><a href="#top">Top</a></p> -## hackathon/messages/project_svc/list_response.proto +## user/messages/user_svc/add_role_request.proto -<a name="hackathon-messages-project_svc-ListResponse"></a> +<a name="user-messages-user_svc-AddRoleRequest"></a> -### ListResponse +### AddRoleRequest | Field | Type | Label | Description | | ----- | ---- | ----- | ----------- | -| projects | [hackathon.entities.Project](#hackathon-entities-Project) | repeated | | +| user_id | [string](#string) | | | +| role | [user.entities.GlobalRole](#user-entities-GlobalRole) | | | @@ -2811,26 +8105,22 @@ casbin role for this hackathon; `is_waiting` is false once approved. -<a name="hackathon_messages_project_svc_propose_request-proto"></a> +<a name="user_messages_user_svc_add_role_response-proto"></a> <p align="right"><a href="#top">Top</a></p> -## hackathon/messages/project_svc/propose_request.proto +## user/messages/user_svc/add_role_response.proto -<a name="hackathon-messages-project_svc-ProposeRequest"></a> +<a name="user-messages-user_svc-AddRoleResponse"></a> -### ProposeRequest +### AddRoleResponse | Field | Type | Label | Description | | ----- | ---- | ----- | ----------- | -| hackathon_id | [string](#string) | | | -| title | [string](#string) | | | -| description | [string](#string) | | | -| track_id | [string](#string) | optional | | -| image | [string](#string) | optional | | +| user | [user.entities.User](#user-entities-User) | | | @@ -2846,22 +8136,18 @@ casbin role for this hackathon; `is_waiting` is false once approved. -<a name="hackathon_messages_project_svc_propose_response-proto"></a> +<a name="user_messages_user_svc_delete_account_request-proto"></a> <p align="right"><a href="#top">Top</a></p> -## hackathon/messages/project_svc/propose_response.proto - - - -<a name="hackathon-messages-project_svc-ProposeResponse"></a> +## user/messages/user_svc/delete_account_request.proto -### ProposeResponse +<a name="user-messages-user_svc-DeleteAccountRequest"></a> -| Field | Type | Label | Description | -| ----- | ---- | ----- | ----------- | -| project_id | [string](#string) | | | +### DeleteAccountRequest +Deletes the CALLER's own platform profile. Deliberately takes no user id: +this is a self-service GDPR action, not an admin tool for removing people. @@ -2877,22 +8163,17 @@ casbin role for this hackathon; `is_waiting` is false once approved. -<a name="hackathon_messages_project_svc_set_preference_request-proto"></a> +<a name="user_messages_user_svc_delete_account_response-proto"></a> <p align="right"><a href="#top">Top</a></p> -## hackathon/messages/project_svc/set_preference_request.proto - - +## user/messages/user_svc/delete_account_response.proto -<a name="hackathon-messages-project_svc-SetPreferenceRequest"></a> -### SetPreferenceRequest +<a name="user-messages-user_svc-DeleteAccountResponse"></a> +### DeleteAccountResponse -| Field | Type | Label | Description | -| ----- | ---- | ----- | ----------- | -| project_id | [string](#string) | | | @@ -2908,22 +8189,36 @@ casbin role for this hackathon; `is_waiting` is false once approved. -<a name="hackathon_messages_project_svc_set_preference_response-proto"></a> +<a name="user_messages_user_svc_edit_profile_request-proto"></a> <p align="right"><a href="#top">Top</a></p> -## hackathon/messages/project_svc/set_preference_response.proto +## user/messages/user_svc/edit_profile_request.proto -<a name="hackathon-messages-project_svc-SetPreferenceResponse"></a> +<a name="user-messages-user_svc-EditProfileRequest"></a> -### SetPreferenceResponse +### EditProfileRequest +Edits the caller's OWN profile — there is no user id, so this RPC can never +touch anyone else's. + +Only the fields the platform owns are here. `username` and `email` come from +Keycloak on every token and are re-synced by WhoAmI, so accepting them would +be a lie: the next page load would overwrite whatever was stored. Those are +changed in Keycloak's own account console. +Every field is optional; absent means "leave unchanged". | Field | Type | Label | Description | | ----- | ---- | ----- | ----------- | -| project_id | [string](#string) | | | +| display_name | [string](#string) | optional | | +| affiliation | [string](#string) | optional | The rest of the profile. Unlike display_name these MAY be cleared, so the floor is 0: emptying "dietary requirements" is a legitimate edit, and a min_len of 1 would make the field impossible to unset. | +| skills | [string](#string) | optional | | +| dietary | [string](#string) | optional | | +| avatar_url | [string](#string) | optional | A link, not an upload — there is no object store yet. + +Length only, deliberately: `uri: true` would reject the empty string, and clearing your picture is a legitimate edit. The handler checks the SCHEME instead (http/https only), which is the part that matters — a `javascript:` or `data:` value must never reach an <img src>. Same reasoning the hackathon logo field follows. | @@ -2939,23 +8234,22 @@ casbin role for this hackathon; `is_waiting` is false once approved. -<a name="hackathon_messages_team_svc_assign_user_request-proto"></a> +<a name="user_messages_user_svc_edit_profile_response-proto"></a> <p align="right"><a href="#top">Top</a></p> -## hackathon/messages/team_svc/assign_user_request.proto - +## user/messages/user_svc/edit_profile_response.proto -<a name="hackathon-messages-team_svc-AssignUserRequest"></a> -### AssignUserRequest +<a name="user-messages-user_svc-EditProfileResponse"></a> +### EditProfileResponse +Edit returns the updated entity (write-path convention). | Field | Type | Label | Description | | ----- | ---- | ----- | ----------- | -| team_id | [string](#string) | | | -| user_id | [string](#string) | | | +| user | [user.entities.User](#user-entities-User) | | | @@ -2971,19 +8265,24 @@ casbin role for this hackathon; `is_waiting` is false once approved. -<a name="hackathon_messages_team_svc_assign_user_response-proto"></a> +<a name="user_messages_user_svc_get_request-proto"></a> <p align="right"><a href="#top">Top</a></p> -## hackathon/messages/team_svc/assign_user_response.proto +## user/messages/user_svc/get_request.proto -<a name="hackathon-messages-team_svc-AssignUserResponse"></a> +<a name="user-messages-user_svc-GetRequest"></a> -### AssignUserResponse +### GetRequest +| Field | Type | Label | Description | +| ----- | ---- | ----- | ----------- | +| user_id | [string](#string) | | | + + @@ -2997,24 +8296,22 @@ casbin role for this hackathon; `is_waiting` is false once approved. -<a name="hackathon_messages_team_svc_create_request-proto"></a> +<a name="user_messages_user_svc_get_response-proto"></a> <p align="right"><a href="#top">Top</a></p> -## hackathon/messages/team_svc/create_request.proto +## user/messages/user_svc/get_response.proto -<a name="hackathon-messages-team_svc-CreateRequest"></a> +<a name="user-messages-user_svc-GetResponse"></a> -### CreateRequest +### GetResponse | Field | Type | Label | Description | | ----- | ---- | ----- | ----------- | -| project_id | [string](#string) | | | -| name | [string](#string) | | | -| description | [string](#string) | | | +| user | [user.entities.User](#user-entities-User) | | | @@ -3030,22 +8327,17 @@ casbin role for this hackathon; `is_waiting` is false once approved. -<a name="hackathon_messages_team_svc_create_response-proto"></a> +<a name="user_messages_user_svc_list_request-proto"></a> <p align="right"><a href="#top">Top</a></p> -## hackathon/messages/team_svc/create_response.proto - - +## user/messages/user_svc/list_request.proto -<a name="hackathon-messages-team_svc-CreateResponse"></a> -### CreateResponse +<a name="user-messages-user_svc-ListRequest"></a> +### ListRequest -| Field | Type | Label | Description | -| ----- | ---- | ----- | ----------- | -| team_id | [string](#string) | | | @@ -3061,24 +8353,22 @@ casbin role for this hackathon; `is_waiting` is false once approved. -<a name="hackathon_messages_team_svc_create_submission_request-proto"></a> +<a name="user_messages_user_svc_list_response-proto"></a> <p align="right"><a href="#top">Top</a></p> -## hackathon/messages/team_svc/create_submission_request.proto +## user/messages/user_svc/list_response.proto -<a name="hackathon-messages-team_svc-CreateSubmissionRequest"></a> +<a name="user-messages-user_svc-ListResponse"></a> -### CreateSubmissionRequest +### ListResponse | Field | Type | Label | Description | | ----- | ---- | ----- | ----------- | -| team_id | [string](#string) | | | -| project_id | [string](#string) | | | -| result | [string](#string) | optional | | +| users | [user.entities.User](#user-entities-User) | repeated | | @@ -3094,22 +8384,17 @@ casbin role for this hackathon; `is_waiting` is false once approved. -<a name="hackathon_messages_team_svc_create_submission_response-proto"></a> +<a name="user_messages_user_svc_register_request-proto"></a> <p align="right"><a href="#top">Top</a></p> -## hackathon/messages/team_svc/create_submission_response.proto - - +## user/messages/user_svc/register_request.proto -<a name="hackathon-messages-team_svc-CreateSubmissionResponse"></a> -### CreateSubmissionResponse +<a name="user-messages-user_svc-RegisterRequest"></a> +### RegisterRequest -| Field | Type | Label | Description | -| ----- | ---- | ----- | ----------- | -| id | [string](#string) | | | @@ -3125,22 +8410,22 @@ casbin role for this hackathon; `is_waiting` is false once approved. -<a name="hackathon_messages_team_svc_delete_request-proto"></a> +<a name="user_messages_user_svc_register_response-proto"></a> <p align="right"><a href="#top">Top</a></p> -## hackathon/messages/team_svc/delete_request.proto +## user/messages/user_svc/register_response.proto -<a name="hackathon-messages-team_svc-DeleteRequest"></a> +<a name="user-messages-user_svc-RegisterResponse"></a> -### DeleteRequest +### RegisterResponse | Field | Type | Label | Description | | ----- | ---- | ----- | ----------- | -| id | [string](#string) | | | +| user | [user.entities.User](#user-entities-User) | | | @@ -3156,18 +8441,24 @@ casbin role for this hackathon; `is_waiting` is false once approved. -<a name="hackathon_messages_team_svc_delete_response-proto"></a> +<a name="user_messages_user_svc_remove_role_request-proto"></a> <p align="right"><a href="#top">Top</a></p> -## hackathon/messages/team_svc/delete_response.proto +## user/messages/user_svc/remove_role_request.proto -<a name="hackathon-messages-team_svc-DeleteResponse"></a> +<a name="user-messages-user_svc-RemoveRoleRequest"></a> + +### RemoveRoleRequest -### DeleteResponse +| Field | Type | Label | Description | +| ----- | ---- | ----- | ----------- | +| user_id | [string](#string) | | | +| role | [user.entities.GlobalRole](#user-entities-GlobalRole) | | | + @@ -3182,24 +8473,22 @@ casbin role for this hackathon; `is_waiting` is false once approved. -<a name="hackathon_messages_team_svc_edit_request-proto"></a> +<a name="user_messages_user_svc_remove_role_response-proto"></a> <p align="right"><a href="#top">Top</a></p> -## hackathon/messages/team_svc/edit_request.proto +## user/messages/user_svc/remove_role_response.proto -<a name="hackathon-messages-team_svc-EditRequest"></a> +<a name="user-messages-user_svc-RemoveRoleResponse"></a> -### EditRequest +### RemoveRoleResponse | Field | Type | Label | Description | | ----- | ---- | ----- | ----------- | -| id | [string](#string) | | | -| name | [string](#string) | optional | | -| description | [string](#string) | optional | | +| user | [user.entities.User](#user-entities-User) | | | @@ -3215,22 +8504,17 @@ casbin role for this hackathon; `is_waiting` is false once approved. -<a name="hackathon_messages_team_svc_edit_response-proto"></a> +<a name="user_messages_user_svc_who_am_i_request-proto"></a> <p align="right"><a href="#top">Top</a></p> -## hackathon/messages/team_svc/edit_response.proto - - +## user/messages/user_svc/who_am_i_request.proto -<a name="hackathon-messages-team_svc-EditResponse"></a> -### EditResponse +<a name="user-messages-user_svc-WhoAmIRequest"></a> +### WhoAmIRequest -| Field | Type | Label | Description | -| ----- | ---- | ----- | ----------- | -| team | [hackathon.entities.Team](#hackathon-entities-Team) | | | @@ -3246,22 +8530,22 @@ casbin role for this hackathon; `is_waiting` is false once approved. -<a name="hackathon_messages_team_svc_finalize_submission_request-proto"></a> +<a name="user_messages_user_svc_who_am_i_response-proto"></a> <p align="right"><a href="#top">Top</a></p> -## hackathon/messages/team_svc/finalize_submission_request.proto +## user/messages/user_svc/who_am_i_response.proto -<a name="hackathon-messages-team_svc-FinalizeSubmissionRequest"></a> +<a name="user-messages-user_svc-WhoAmIResponse"></a> -### FinalizeSubmissionRequest +### WhoAmIResponse | Field | Type | Label | Description | | ----- | ---- | ----- | ----------- | -| submission_id | [string](#string) | | | +| user | [user.entities.User](#user-entities-User) | | | @@ -3277,84 +8561,113 @@ casbin role for this hackathon; `is_waiting` is false once approved. -<a name="hackathon_messages_team_svc_finalize_submission_response-proto"></a> +<a name="user_user_service-proto"></a> <p align="right"><a href="#top">Top</a></p> -## hackathon/messages/team_svc/finalize_submission_response.proto +## user/user_service.proto + -<a name="hackathon-messages-team_svc-FinalizeSubmissionResponse"></a> + + + -### FinalizeSubmissionResponse +<a name="user-UserService"></a> +### UserService -| Field | Type | Label | Description | -| ----- | ---- | ----- | ----------- | -| submission | [hackathon.entities.Submission](#hackathon-entities-Submission) | | | +| Method Name | Request Type | Response Type | Description | +| ----------- | ------------ | ------------- | ------------| +| List | [messages.user_svc.ListRequest](#user-messages-user_svc-ListRequest) | [messages.user_svc.ListResponse](#user-messages-user_svc-ListResponse) | | +| Get | [messages.user_svc.GetRequest](#user-messages-user_svc-GetRequest) | [messages.user_svc.GetResponse](#user-messages-user_svc-GetResponse) | | +| WhoAmI | [messages.user_svc.WhoAmIRequest](#user-messages-user_svc-WhoAmIRequest) | [messages.user_svc.WhoAmIResponse](#user-messages-user_svc-WhoAmIResponse) | | +| Register | [messages.user_svc.RegisterRequest](#user-messages-user_svc-RegisterRequest) | [messages.user_svc.RegisterResponse](#user-messages-user_svc-RegisterResponse) | | +| EditProfile | [messages.user_svc.EditProfileRequest](#user-messages-user_svc-EditProfileRequest) | [messages.user_svc.EditProfileResponse](#user-messages-user_svc-EditProfileResponse) | Self-service: edits the caller's own profile, no user id in the request. | +| AddRole | [messages.user_svc.AddRoleRequest](#user-messages-user_svc-AddRoleRequest) | [messages.user_svc.AddRoleResponse](#user-messages-user_svc-AddRoleResponse) | | +| RemoveRole | [messages.user_svc.RemoveRoleRequest](#user-messages-user_svc-RemoveRoleRequest) | [messages.user_svc.RemoveRoleResponse](#user-messages-user_svc-RemoveRoleResponse) | | +| DeleteAccount | [messages.user_svc.DeleteAccountRequest](#user-messages-user_svc-DeleteAccountRequest) | [messages.user_svc.DeleteAccountResponse](#user-messages-user_svc-DeleteAccountResponse) | Self-service account deletion (GDPR). Removes the platform profile and every casbin role; the Keycloak identity is NOT touched, so the person can sign in again and start fresh. | + - +<a name="vote_entities_vote-proto"></a> +<p align="right"><a href="#top">Top</a></p> - +## vote/entities/vote.proto - - +<a name="vote-entities-PointsVote"></a> +### PointsVote -<a name="hackathon_messages_team_svc_get_request-proto"></a> -<p align="right"><a href="#top">Top</a></p> -## hackathon/messages/team_svc/get_request.proto + +| Field | Type | Label | Description | +| ----- | ---- | ----- | ----------- | +| submission_id | [string](#string) | | | +| points | [int32](#int32) | | | -<a name="hackathon-messages-team_svc-GetRequest"></a> -### GetRequest + + +<a name="vote-entities-RankedVote"></a> + +### RankedVote | Field | Type | Label | Description | | ----- | ---- | ----- | ----------- | -| team_id | [string](#string) | | | - +| submission_id | [string](#string) | | | +| rank | [int32](#int32) | | 1 is the voter's first preference. | - - - +<a name="vote-entities-SingleChoiceVote"></a> - +### SingleChoiceVote +Each variant describes ONE stored row, because a Vote row is one judgment on +one submission (see the schema comment). A ranked or points ballot is +therefore several Vote entities, one per submission, all sharing a category +and a voter — SubmitVoteResponse.votes hands the whole set back. +| Field | Type | Label | Description | +| ----- | ---- | ----- | ----------- | +| submission_id | [string](#string) | | | -<a name="hackathon_messages_team_svc_get_response-proto"></a> -<p align="right"><a href="#top">Top</a></p> -## hackathon/messages/team_svc/get_response.proto -<a name="hackathon-messages-team_svc-GetResponse"></a> -### GetResponse +<a name="vote-entities-Vote"></a> +### Vote +Vote is a single atomic judgment from one voter on one submission +within one category. The vote payload is method-specific. | Field | Type | Label | Description | | ----- | ---- | ----- | ----------- | -| team | [hackathon.entities.Team](#hackathon-entities-Team) | | | +| id | [string](#string) | | | +| category_id | [string](#string) | | | +| voter_id | [string](#string) | | | +| single_choice | [SingleChoiceVote](#vote-entities-SingleChoiceVote) | | | +| ranked | [RankedVote](#vote-entities-RankedVote) | | | +| points | [PointsVote](#vote-entities-PointsVote) | | | +| created_at | [int64](#int64) | | | +| modified_at | [int64](#int64) | | | @@ -3370,28 +8683,26 @@ casbin role for this hackathon; `is_waiting` is false once approved. -<a name="hackathon_messages_team_svc_get_submission_request-proto"></a> +<a name="vote_entities_voter_type-proto"></a> <p align="right"><a href="#top">Top</a></p> -## hackathon/messages/team_svc/get_submission_request.proto - - - -<a name="hackathon-messages-team_svc-GetSubmissionRequest"></a> - -### GetSubmissionRequest +## vote/entities/voter_type.proto + -| Field | Type | Label | Description | -| ----- | ---- | ----- | ----------- | -| team_id | [string](#string) | | | +<a name="vote-entities-VoterType"></a> +### VoterType +| Name | Number | Description | +| ---- | ------ | ----------- | +| VOTER_TYPE_UNSPECIFIED | 0 | | +| VOTER_TYPE_ALL_PARTICIPANTS | 1 | | +| VOTER_TYPE_JURY | 2 | | - @@ -3401,28 +8712,27 @@ casbin role for this hackathon; `is_waiting` is false once approved. -<a name="hackathon_messages_team_svc_get_submission_response-proto"></a> +<a name="vote_entities_voting_method-proto"></a> <p align="right"><a href="#top">Top</a></p> -## hackathon/messages/team_svc/get_submission_response.proto - - - -<a name="hackathon-messages-team_svc-GetSubmissionResponse"></a> - -### GetSubmissionResponse +## vote/entities/voting_method.proto + -| Field | Type | Label | Description | -| ----- | ---- | ----- | ----------- | -| submission | [hackathon.entities.Submission](#hackathon-entities-Submission) | | | +<a name="vote-entities-VotingMethod"></a> +### VotingMethod +| Name | Number | Description | +| ---- | ------ | ----------- | +| VOTING_METHOD_UNSPECIFIED | 0 | | +| VOTING_METHOD_SINGLE_CHOICE | 1 | | +| VOTING_METHOD_RANKED | 2 | | +| VOTING_METHOD_POINTS | 3 | | - @@ -3432,23 +8742,32 @@ casbin role for this hackathon; `is_waiting` is false once approved. -<a name="hackathon_messages_team_svc_list_request-proto"></a> +<a name="vote_entities_vote_category-proto"></a> <p align="right"><a href="#top">Top</a></p> -## hackathon/messages/team_svc/list_request.proto - +## vote/entities/vote_category.proto -<a name="hackathon-messages-team_svc-ListRequest"></a> -### ListRequest +<a name="vote-entities-VoteCategory"></a> +### VoteCategory +VoteCategory represents a voting category within a hackathon, defining +the criteria and rules for one dimension of evaluation. | Field | Type | Label | Description | | ----- | ---- | ----- | ----------- | -| hackathon_id | [string](#string) | optional | | -| project_id | [string](#string) | optional | | +| id | [string](#string) | | | +| hackathon_id | [string](#string) | | | +| name | [string](#string) | | | +| description | [string](#string) | | | +| voting_method | [VotingMethod](#vote-entities-VotingMethod) | | | +| voter_type | [VoterType](#vote-entities-VoterType) | | | +| jury_members | [user.entities.User](#user-entities-User) | repeated | | +| created_at | [int64](#int64) | | | +| modified_at | [int64](#int64) | | | +| max_points | [int32](#int32) | optional | Points-based voting only: the budget one voter may spread over the submissions. Absent for the other methods. Tag 10 rather than main's 7 — jury_members/created_at/modified_at already hold 7-9 here. | @@ -3464,22 +8783,28 @@ casbin role for this hackathon; `is_waiting` is false once approved. -<a name="hackathon_messages_team_svc_list_response-proto"></a> +<a name="vote_entities_vote_result-proto"></a> <p align="right"><a href="#top">Top</a></p> -## hackathon/messages/team_svc/list_response.proto - +## vote/entities/vote_result.proto -<a name="hackathon-messages-team_svc-ListResponse"></a> -### ListResponse +<a name="vote-entities-VoteResult"></a> +### VoteResult +VoteResult is a placement entry within a vote category. | Field | Type | Label | Description | | ----- | ---- | ----- | ----------- | -| teams | [hackathon.entities.Team](#hackathon-entities-Team) | repeated | | +| id | [string](#string) | | | +| category_id | [string](#string) | | | +| submission_id | [string](#string) | | | +| position | [int32](#int32) | | | +| title | [string](#string) | optional | | +| created_at | [int64](#int64) | | | +| modified_at | [int64](#int64) | | | @@ -3495,22 +8820,28 @@ casbin role for this hackathon; `is_waiting` is false once approved. -<a name="hackathon_messages_team_svc_list_submissions_request-proto"></a> +<a name="vote_messages_vote_svc_create_category_request-proto"></a> <p align="right"><a href="#top">Top</a></p> -## hackathon/messages/team_svc/list_submissions_request.proto +## vote/messages/vote_svc/create_category_request.proto -<a name="hackathon-messages-team_svc-ListSubmissionsRequest"></a> +<a name="vote-messages-vote_svc-CreateVoteCategoryRequest"></a> -### ListSubmissionsRequest +### CreateVoteCategoryRequest | Field | Type | Label | Description | | ----- | ---- | ----- | ----------- | -| team_id | [string](#string) | | | +| hackathon_id | [string](#string) | | | +| name | [string](#string) | | | +| description | [string](#string) | | | +| voting_method | [vote.entities.VotingMethod](#vote-entities-VotingMethod) | | | +| voter_type | [vote.entities.VoterType](#vote-entities-VoterType) | | | +| jury_member_ids | [string](#string) | repeated | | +| max_points | [int32](#int32) | optional | Required (and >0) when voting_method is POINTS, ignored otherwise. | @@ -3526,22 +8857,22 @@ casbin role for this hackathon; `is_waiting` is false once approved. -<a name="hackathon_messages_team_svc_list_submissions_response-proto"></a> +<a name="vote_messages_vote_svc_create_category_response-proto"></a> <p align="right"><a href="#top">Top</a></p> -## hackathon/messages/team_svc/list_submissions_response.proto +## vote/messages/vote_svc/create_category_response.proto -<a name="hackathon-messages-team_svc-ListSubmissionsResponse"></a> +<a name="vote-messages-vote_svc-CreateVoteCategoryResponse"></a> -### ListSubmissionsResponse +### CreateVoteCategoryResponse | Field | Type | Label | Description | | ----- | ---- | ----- | ----------- | -| submissions | [hackathon.entities.Submission](#hackathon-entities-Submission) | repeated | | +| vote_category | [vote.entities.VoteCategory](#vote-entities-VoteCategory) | | | @@ -3557,23 +8888,25 @@ casbin role for this hackathon; `is_waiting` is false once approved. -<a name="hackathon_messages_team_svc_remove_user_request-proto"></a> +<a name="vote_messages_vote_svc_create_result_request-proto"></a> <p align="right"><a href="#top">Top</a></p> -## hackathon/messages/team_svc/remove_user_request.proto +## vote/messages/vote_svc/create_result_request.proto -<a name="hackathon-messages-team_svc-RemoveUserRequest"></a> +<a name="vote-messages-vote_svc-CreateVoteResultRequest"></a> -### RemoveUserRequest +### CreateVoteResultRequest | Field | Type | Label | Description | | ----- | ---- | ----- | ----------- | -| team_id | [string](#string) | | | -| user_id | [string](#string) | | | +| category_id | [string](#string) | | | +| submission_id | [string](#string) | | | +| position | [int32](#int32) | | | +| title | [string](#string) | optional | | @@ -3589,22 +8922,22 @@ casbin role for this hackathon; `is_waiting` is false once approved. -<a name="hackathon_messages_team_svc_remove_user_response-proto"></a> +<a name="vote_messages_vote_svc_create_result_response-proto"></a> <p align="right"><a href="#top">Top</a></p> -## hackathon/messages/team_svc/remove_user_response.proto +## vote/messages/vote_svc/create_result_response.proto -<a name="hackathon-messages-team_svc-RemoveUserResponse"></a> +<a name="vote-messages-vote_svc-CreateVoteResultResponse"></a> -### RemoveUserResponse +### CreateVoteResultResponse | Field | Type | Label | Description | | ----- | ---- | ----- | ----------- | -| team | [hackathon.entities.Team](#hackathon-entities-Team) | | | +| vote_result | [vote.entities.VoteResult](#vote-entities-VoteResult) | | | @@ -3620,24 +8953,22 @@ casbin role for this hackathon; `is_waiting` is false once approved. -<a name="hackathon_messages_track_svc_create_request-proto"></a> +<a name="vote_messages_vote_svc_delete_category_request-proto"></a> <p align="right"><a href="#top">Top</a></p> -## hackathon/messages/track_svc/create_request.proto +## vote/messages/vote_svc/delete_category_request.proto -<a name="hackathon-messages-track_svc-CreateRequest"></a> +<a name="vote-messages-vote_svc-DeleteVoteCategoryRequest"></a> -### CreateRequest +### DeleteVoteCategoryRequest | Field | Type | Label | Description | | ----- | ---- | ----- | ----------- | -| hackathon_id | [string](#string) | | | -| name | [string](#string) | | | -| description | [string](#string) | | | +| id | [string](#string) | | | @@ -3653,22 +8984,17 @@ casbin role for this hackathon; `is_waiting` is false once approved. -<a name="hackathon_messages_track_svc_create_response-proto"></a> +<a name="vote_messages_vote_svc_delete_category_response-proto"></a> <p align="right"><a href="#top">Top</a></p> -## hackathon/messages/track_svc/create_response.proto - - +## vote/messages/vote_svc/delete_category_response.proto -<a name="hackathon-messages-track_svc-CreateResponse"></a> -### CreateResponse +<a name="vote-messages-vote_svc-DeleteVoteCategoryResponse"></a> +### DeleteVoteCategoryResponse -| Field | Type | Label | Description | -| ----- | ---- | ----- | ----------- | -| track_id | [string](#string) | | | @@ -3684,22 +9010,22 @@ casbin role for this hackathon; `is_waiting` is false once approved. -<a name="hackathon_messages_track_svc_delete_request-proto"></a> +<a name="vote_messages_vote_svc_delete_result_request-proto"></a> <p align="right"><a href="#top">Top</a></p> -## hackathon/messages/track_svc/delete_request.proto +## vote/messages/vote_svc/delete_result_request.proto -<a name="hackathon-messages-track_svc-DeleteRequest"></a> +<a name="vote-messages-vote_svc-DeleteVoteResultRequest"></a> -### DeleteRequest +### DeleteVoteResultRequest | Field | Type | Label | Description | | ----- | ---- | ----- | ----------- | -| track_id | [string](#string) | | | +| id | [string](#string) | | | @@ -3715,16 +9041,16 @@ casbin role for this hackathon; `is_waiting` is false once approved. -<a name="hackathon_messages_track_svc_delete_response-proto"></a> +<a name="vote_messages_vote_svc_delete_result_response-proto"></a> <p align="right"><a href="#top">Top</a></p> -## hackathon/messages/track_svc/delete_response.proto +## vote/messages/vote_svc/delete_result_response.proto -<a name="hackathon-messages-track_svc-DeleteResponse"></a> +<a name="vote-messages-vote_svc-DeleteVoteResultResponse"></a> -### DeleteResponse +### DeleteVoteResultResponse @@ -3741,24 +9067,28 @@ casbin role for this hackathon; `is_waiting` is false once approved. -<a name="hackathon_messages_track_svc_edit_request-proto"></a> +<a name="vote_messages_vote_svc_edit_category_request-proto"></a> <p align="right"><a href="#top">Top</a></p> -## hackathon/messages/track_svc/edit_request.proto +## vote/messages/vote_svc/edit_category_request.proto -<a name="hackathon-messages-track_svc-EditRequest"></a> +<a name="vote-messages-vote_svc-EditVoteCategoryRequest"></a> -### EditRequest +### EditVoteCategoryRequest | Field | Type | Label | Description | | ----- | ---- | ----- | ----------- | -| track_id | [string](#string) | | | +| id | [string](#string) | | | | name | [string](#string) | optional | | | description | [string](#string) | optional | | +| voting_method | [vote.entities.VotingMethod](#vote-entities-VotingMethod) | optional | | +| voter_type | [vote.entities.VoterType](#vote-entities-VoterType) | optional | | +| jury_member_ids | [string](#string) | repeated | | +| max_points | [int32](#int32) | optional | Required (and >0) once the category's method is POINTS, cleared otherwise. | @@ -3774,22 +9104,22 @@ casbin role for this hackathon; `is_waiting` is false once approved. -<a name="hackathon_messages_track_svc_edit_response-proto"></a> +<a name="vote_messages_vote_svc_edit_category_response-proto"></a> <p align="right"><a href="#top">Top</a></p> -## hackathon/messages/track_svc/edit_response.proto +## vote/messages/vote_svc/edit_category_response.proto -<a name="hackathon-messages-track_svc-EditResponse"></a> +<a name="vote-messages-vote_svc-EditVoteCategoryResponse"></a> -### EditResponse +### EditVoteCategoryResponse | Field | Type | Label | Description | | ----- | ---- | ----- | ----------- | -| track | [hackathon.entities.Track](#hackathon-entities-Track) | | | +| vote_category | [vote.entities.VoteCategory](#vote-entities-VoteCategory) | | | @@ -3805,22 +9135,25 @@ casbin role for this hackathon; `is_waiting` is false once approved. -<a name="hackathon_messages_track_svc_get_request-proto"></a> +<a name="vote_messages_vote_svc_edit_result_request-proto"></a> <p align="right"><a href="#top">Top</a></p> -## hackathon/messages/track_svc/get_request.proto +## vote/messages/vote_svc/edit_result_request.proto -<a name="hackathon-messages-track_svc-GetRequest"></a> +<a name="vote-messages-vote_svc-EditVoteResultRequest"></a> -### GetRequest +### EditVoteResultRequest | Field | Type | Label | Description | | ----- | ---- | ----- | ----------- | -| track_id | [string](#string) | | | +| id | [string](#string) | | | +| submission_id | [string](#string) | optional | | +| position | [int32](#int32) | optional | | +| title | [string](#string) | optional | | @@ -3836,22 +9169,22 @@ casbin role for this hackathon; `is_waiting` is false once approved. -<a name="hackathon_messages_track_svc_get_response-proto"></a> +<a name="vote_messages_vote_svc_edit_result_response-proto"></a> <p align="right"><a href="#top">Top</a></p> -## hackathon/messages/track_svc/get_response.proto +## vote/messages/vote_svc/edit_result_response.proto -<a name="hackathon-messages-track_svc-GetResponse"></a> +<a name="vote-messages-vote_svc-EditVoteResultResponse"></a> -### GetResponse +### EditVoteResultResponse | Field | Type | Label | Description | | ----- | ---- | ----- | ----------- | -| track | [hackathon.entities.Track](#hackathon-entities-Track) | | | +| vote_result | [vote.entities.VoteResult](#vote-entities-VoteResult) | | | @@ -3867,22 +9200,23 @@ casbin role for this hackathon; `is_waiting` is false once approved. -<a name="hackathon_messages_track_svc_list_request-proto"></a> +<a name="vote_messages_vote_svc_export_votes_request-proto"></a> <p align="right"><a href="#top">Top</a></p> -## hackathon/messages/track_svc/list_request.proto +## vote/messages/vote_svc/export_votes_request.proto -<a name="hackathon-messages-track_svc-ListRequest"></a> +<a name="vote-messages-vote_svc-ExportVotesRequest"></a> -### ListRequest +### ExportVotesRequest | Field | Type | Label | Description | | ----- | ---- | ----- | ----------- | -| hackathon_id | [string](#string) | | | +| category_id | [string](#string) | | | +| format | [ExportFormat](#vote-messages-vote_svc-ExportFormat) | | | @@ -3890,6 +9224,19 @@ casbin role for this hackathon; `is_waiting` is false once approved. + +<a name="vote-messages-vote_svc-ExportFormat"></a> + +### ExportFormat + + +| Name | Number | Description | +| ---- | ------ | ----------- | +| EXPORT_FORMAT_UNSPECIFIED | 0 | | +| EXPORT_FORMAT_CSV | 1 | | +| EXPORT_FORMAT_JSON | 2 | | + + @@ -3898,22 +9245,23 @@ casbin role for this hackathon; `is_waiting` is false once approved. -<a name="hackathon_messages_track_svc_list_response-proto"></a> +<a name="vote_messages_vote_svc_export_results_request-proto"></a> <p align="right"><a href="#top">Top</a></p> -## hackathon/messages/track_svc/list_response.proto +## vote/messages/vote_svc/export_results_request.proto -<a name="hackathon-messages-track_svc-ListResponse"></a> +<a name="vote-messages-vote_svc-ExportResultsRequest"></a> -### ListResponse +### ExportResultsRequest | Field | Type | Label | Description | | ----- | ---- | ----- | ----------- | -| tracks | [hackathon.entities.Track](#hackathon-entities-Track) | repeated | | +| category_id | [string](#string) | | | +| format | [ExportFormat](#vote-messages-vote_svc-ExportFormat) | | | @@ -3929,43 +9277,25 @@ casbin role for this hackathon; `is_waiting` is false once approved. -<a name="hackathon_page_service-proto"></a> +<a name="vote_messages_vote_svc_export_results_response-proto"></a> <p align="right"><a href="#top">Top</a></p> -## hackathon/page_service.proto - - - - - - - +## vote/messages/vote_svc/export_results_response.proto -<a name="hackathon-PageService"></a> -### PageService +<a name="vote-messages-vote_svc-ExportResultsResponse"></a> +### ExportResultsResponse -| Method Name | Request Type | Response Type | Description | -| ----------- | ------------ | ------------- | ------------| -| List | [messages.page_svc.ListRequest](#hackathon-messages-page_svc-ListRequest) | [messages.page_svc.ListResponse](#hackathon-messages-page_svc-ListResponse) | | -| Get | [messages.page_svc.GetRequest](#hackathon-messages-page_svc-GetRequest) | [messages.page_svc.GetResponse](#hackathon-messages-page_svc-GetResponse) | | -| Create | [messages.page_svc.CreateRequest](#hackathon-messages-page_svc-CreateRequest) | [messages.page_svc.CreateResponse](#hackathon-messages-page_svc-CreateResponse) | | -| Edit | [messages.page_svc.EditRequest](#hackathon-messages-page_svc-EditRequest) | [messages.page_svc.EditResponse](#hackathon-messages-page_svc-EditResponse) | | -| Delete | [messages.page_svc.DeleteRequest](#hackathon-messages-page_svc-DeleteRequest) | [messages.page_svc.DeleteResponse](#hackathon-messages-page_svc-DeleteResponse) | | -| MoveUp | [messages.page_svc.MoveUpRequest](#hackathon-messages-page_svc-MoveUpRequest) | [messages.page_svc.MoveUpResponse](#hackathon-messages-page_svc-MoveUpResponse) | Reordering methods - backend manages order uniqueness | -| MoveDown | [messages.page_svc.MoveDownRequest](#hackathon-messages-page_svc-MoveDownRequest) | [messages.page_svc.MoveDownResponse](#hackathon-messages-page_svc-MoveDownResponse) | | -| SetOrder | [messages.page_svc.SetOrderRequest](#hackathon-messages-page_svc-SetOrderRequest) | [messages.page_svc.SetOrderResponse](#hackathon-messages-page_svc-SetOrderResponse) | | - +| Field | Type | Label | Description | +| ----- | ---- | ----- | ----------- | +| data | [bytes](#bytes) | | | -<a name="hackathon_phase_service-proto"></a> -<p align="right"><a href="#top">Top</a></p> -## hackathon/phase_service.proto @@ -3974,62 +9304,29 @@ casbin role for this hackathon; `is_waiting` is false once approved. - -<a name="hackathon-PhaseService"></a> - -### PhaseService - - -| Method Name | Request Type | Response Type | Description | -| ----------- | ------------ | ------------- | ------------| -| List | [messages.phase_svc.ListRequest](#hackathon-messages-phase_svc-ListRequest) | [messages.phase_svc.ListResponse](#hackathon-messages-phase_svc-ListResponse) | | -| Get | [messages.phase_svc.GetRequest](#hackathon-messages-phase_svc-GetRequest) | [messages.phase_svc.GetResponse](#hackathon-messages-phase_svc-GetResponse) | | -| Create | [messages.phase_svc.CreateRequest](#hackathon-messages-phase_svc-CreateRequest) | [messages.phase_svc.CreateResponse](#hackathon-messages-phase_svc-CreateResponse) | | -| Edit | [messages.phase_svc.EditRequest](#hackathon-messages-phase_svc-EditRequest) | [messages.phase_svc.EditResponse](#hackathon-messages-phase_svc-EditResponse) | | -| Delete | [messages.phase_svc.DeleteRequest](#hackathon-messages-phase_svc-DeleteRequest) | [messages.phase_svc.DeleteResponse](#hackathon-messages-phase_svc-DeleteResponse) | | - -<a name="hackathon_project_service-proto"></a> +<a name="vote_messages_vote_svc_export_votes_response-proto"></a> <p align="right"><a href="#top">Top</a></p> -## hackathon/project_service.proto - - - - - - - +## vote/messages/vote_svc/export_votes_response.proto -<a name="hackathon-ProjectService"></a> -### ProjectService +<a name="vote-messages-vote_svc-ExportVotesResponse"></a> +### ExportVotesResponse -| Method Name | Request Type | Response Type | Description | -| ----------- | ------------ | ------------- | ------------| -| List | [messages.project_svc.ListRequest](#hackathon-messages-project_svc-ListRequest) | [messages.project_svc.ListResponse](#hackathon-messages-project_svc-ListResponse) | | -| Get | [messages.project_svc.GetRequest](#hackathon-messages-project_svc-GetRequest) | [messages.project_svc.GetResponse](#hackathon-messages-project_svc-GetResponse) | | -| Propose | [messages.project_svc.ProposeRequest](#hackathon-messages-project_svc-ProposeRequest) | [messages.project_svc.ProposeResponse](#hackathon-messages-project_svc-ProposeResponse) | | -| Approve | [messages.project_svc.ApproveRequest](#hackathon-messages-project_svc-ApproveRequest) | [messages.project_svc.ApproveResponse](#hackathon-messages-project_svc-ApproveResponse) | | -| Disapprove | [messages.project_svc.DisapproveRequest](#hackathon-messages-project_svc-DisapproveRequest) | [messages.project_svc.DisapproveResponse](#hackathon-messages-project_svc-DisapproveResponse) | | -| SetPreference | [messages.project_svc.SetPreferenceRequest](#hackathon-messages-project_svc-SetPreferenceRequest) | [messages.project_svc.SetPreferenceResponse](#hackathon-messages-project_svc-SetPreferenceResponse) | | -| ExportPreferences | [messages.project_svc.ExportPreferencesRequest](#hackathon-messages-project_svc-ExportPreferencesRequest) | [messages.project_svc.ExportPreferencesResponse](#hackathon-messages-project_svc-ExportPreferencesResponse) | | -| Edit | [messages.project_svc.EditRequest](#hackathon-messages-project_svc-EditRequest) | [messages.project_svc.EditResponse](#hackathon-messages-project_svc-EditResponse) | | -| Delete | [messages.project_svc.DeleteRequest](#hackathon-messages-project_svc-DeleteRequest) | [messages.project_svc.DeleteResponse](#hackathon-messages-project_svc-DeleteResponse) | | - +| Field | Type | Label | Description | +| ----- | ---- | ----- | ----------- | +| data | [bytes](#bytes) | | | -<a name="hackathon_team_service-proto"></a> -<p align="right"><a href="#top">Top</a></p> -## hackathon/team_service.proto @@ -4038,73 +9335,59 @@ casbin role for this hackathon; `is_waiting` is false once approved. + -<a name="hackathon-TeamService"></a> -### TeamService +<a name="vote_messages_vote_svc_get_category_request-proto"></a> +<p align="right"><a href="#top">Top</a></p> -| Method Name | Request Type | Response Type | Description | -| ----------- | ------------ | ------------- | ------------| -| List | [messages.team_svc.ListRequest](#hackathon-messages-team_svc-ListRequest) | [messages.team_svc.ListResponse](#hackathon-messages-team_svc-ListResponse) | | -| Get | [messages.team_svc.GetRequest](#hackathon-messages-team_svc-GetRequest) | [messages.team_svc.GetResponse](#hackathon-messages-team_svc-GetResponse) | | -| Create | [messages.team_svc.CreateRequest](#hackathon-messages-team_svc-CreateRequest) | [messages.team_svc.CreateResponse](#hackathon-messages-team_svc-CreateResponse) | | -| Edit | [messages.team_svc.EditRequest](#hackathon-messages-team_svc-EditRequest) | [messages.team_svc.EditResponse](#hackathon-messages-team_svc-EditResponse) | | -| Delete | [messages.team_svc.DeleteRequest](#hackathon-messages-team_svc-DeleteRequest) | [messages.team_svc.DeleteResponse](#hackathon-messages-team_svc-DeleteResponse) | | -| AssignUser | [messages.team_svc.AssignUserRequest](#hackathon-messages-team_svc-AssignUserRequest) | [messages.team_svc.AssignUserResponse](#hackathon-messages-team_svc-AssignUserResponse) | | -| RemoveUser | [messages.team_svc.RemoveUserRequest](#hackathon-messages-team_svc-RemoveUserRequest) | [messages.team_svc.RemoveUserResponse](#hackathon-messages-team_svc-RemoveUserResponse) | | -| CreateSubmission | [messages.team_svc.CreateSubmissionRequest](#hackathon-messages-team_svc-CreateSubmissionRequest) | [messages.team_svc.CreateSubmissionResponse](#hackathon-messages-team_svc-CreateSubmissionResponse) | | -| GetSubmission | [messages.team_svc.GetSubmissionRequest](#hackathon-messages-team_svc-GetSubmissionRequest) | [messages.team_svc.GetSubmissionResponse](#hackathon-messages-team_svc-GetSubmissionResponse) | | -| ListSubmissions | [messages.team_svc.ListSubmissionsRequest](#hackathon-messages-team_svc-ListSubmissionsRequest) | [messages.team_svc.ListSubmissionsResponse](#hackathon-messages-team_svc-ListSubmissionsResponse) | | -| FinalizeSubmission | [messages.team_svc.FinalizeSubmissionRequest](#hackathon-messages-team_svc-FinalizeSubmissionRequest) | [messages.team_svc.FinalizeSubmissionResponse](#hackathon-messages-team_svc-FinalizeSubmissionResponse) | | +## vote/messages/vote_svc/get_category_request.proto - +<a name="vote-messages-vote_svc-GetVoteCategoryRequest"></a> -<a name="hackathon_track_service-proto"></a> -<p align="right"><a href="#top">Top</a></p> +### GetVoteCategoryRequest -## hackathon/track_service.proto - +| Field | Type | Label | Description | +| ----- | ---- | ----- | ----------- | +| id | [string](#string) | | | - - -<a name="hackathon-TrackService"></a> -### TrackService + + -| Method Name | Request Type | Response Type | Description | -| ----------- | ------------ | ------------- | ------------| -| List | [messages.track_svc.ListRequest](#hackathon-messages-track_svc-ListRequest) | [messages.track_svc.ListResponse](#hackathon-messages-track_svc-ListResponse) | | -| Get | [messages.track_svc.GetRequest](#hackathon-messages-track_svc-GetRequest) | [messages.track_svc.GetResponse](#hackathon-messages-track_svc-GetResponse) | | -| Create | [messages.track_svc.CreateRequest](#hackathon-messages-track_svc-CreateRequest) | [messages.track_svc.CreateResponse](#hackathon-messages-track_svc-CreateResponse) | | -| Edit | [messages.track_svc.EditRequest](#hackathon-messages-track_svc-EditRequest) | [messages.track_svc.EditResponse](#hackathon-messages-track_svc-EditResponse) | | -| Delete | [messages.track_svc.DeleteRequest](#hackathon-messages-track_svc-DeleteRequest) | [messages.track_svc.DeleteResponse](#hackathon-messages-track_svc-DeleteResponse) | | + -<a name="health_messages_health_svc_check_request-proto"></a> +<a name="vote_messages_vote_svc_get_category_response-proto"></a> <p align="right"><a href="#top">Top</a></p> -## health/messages/health_svc/check_request.proto +## vote/messages/vote_svc/get_category_response.proto -<a name="health-messages-health_svc-CheckRequest"></a> +<a name="vote-messages-vote_svc-GetVoteCategoryResponse"></a> -### CheckRequest +### GetVoteCategoryResponse +| Field | Type | Label | Description | +| ----- | ---- | ----- | ----------- | +| vote_category | [vote.entities.VoteCategory](#vote-entities-VoteCategory) | | | + + @@ -4118,22 +9401,22 @@ casbin role for this hackathon; `is_waiting` is false once approved. -<a name="health_messages_health_svc_check_response-proto"></a> +<a name="vote_messages_vote_svc_get_vote_request-proto"></a> <p align="right"><a href="#top">Top</a></p> -## health/messages/health_svc/check_response.proto +## vote/messages/vote_svc/get_vote_request.proto -<a name="health-messages-health_svc-CheckResponse"></a> +<a name="vote-messages-vote_svc-GetVoteRequest"></a> -### CheckResponse +### GetVoteRequest | Field | Type | Label | Description | | ----- | ---- | ----- | ----------- | -| message | [string](#string) | | | +| id | [string](#string) | | | @@ -4149,49 +9432,53 @@ casbin role for this hackathon; `is_waiting` is false once approved. -<a name="health_health_service-proto"></a> +<a name="vote_messages_vote_svc_get_vote_response-proto"></a> <p align="right"><a href="#top">Top</a></p> -## health/health_service.proto +## vote/messages/vote_svc/get_vote_response.proto - - +<a name="vote-messages-vote_svc-GetVoteResponse"></a> - +### GetVoteResponse -<a name="health-HealthService"></a> -### HealthService +| Field | Type | Label | Description | +| ----- | ---- | ----- | ----------- | +| vote | [vote.entities.Vote](#vote-entities-Vote) | | | + + -| Method Name | Request Type | Response Type | Description | -| ----------- | ------------ | ------------- | ------------| -| Check | [messages.health_svc.CheckRequest](#health-messages-health_svc-CheckRequest) | [messages.health_svc.CheckResponse](#health-messages-health_svc-CheckResponse) | | + + -<a name="user_messages_user_svc_add_role_request-proto"></a> + + + + +<a name="vote_messages_vote_svc_list_categories_request-proto"></a> <p align="right"><a href="#top">Top</a></p> -## user/messages/user_svc/add_role_request.proto +## vote/messages/vote_svc/list_categories_request.proto -<a name="user-messages-user_svc-AddRoleRequest"></a> +<a name="vote-messages-vote_svc-ListVoteCategoriesRequest"></a> -### AddRoleRequest +### ListVoteCategoriesRequest | Field | Type | Label | Description | | ----- | ---- | ----- | ----------- | -| user_id | [string](#string) | | | -| role | [user.entities.GlobalRole](#user-entities-GlobalRole) | | | +| hackathon_id | [string](#string) | | | @@ -4207,22 +9494,22 @@ casbin role for this hackathon; `is_waiting` is false once approved. -<a name="user_messages_user_svc_add_role_response-proto"></a> +<a name="vote_messages_vote_svc_list_categories_response-proto"></a> <p align="right"><a href="#top">Top</a></p> -## user/messages/user_svc/add_role_response.proto +## vote/messages/vote_svc/list_categories_response.proto -<a name="user-messages-user_svc-AddRoleResponse"></a> +<a name="vote-messages-vote_svc-ListVoteCategoriesResponse"></a> -### AddRoleResponse +### ListVoteCategoriesResponse | Field | Type | Label | Description | | ----- | ---- | ----- | ----------- | -| user | [user.entities.User](#user-entities-User) | | | +| vote_categories | [vote.entities.VoteCategory](#vote-entities-VoteCategory) | repeated | | @@ -4238,22 +9525,22 @@ casbin role for this hackathon; `is_waiting` is false once approved. -<a name="user_messages_user_svc_get_request-proto"></a> +<a name="vote_messages_vote_svc_list_results_request-proto"></a> <p align="right"><a href="#top">Top</a></p> -## user/messages/user_svc/get_request.proto +## vote/messages/vote_svc/list_results_request.proto -<a name="user-messages-user_svc-GetRequest"></a> +<a name="vote-messages-vote_svc-ListVoteResultsRequest"></a> -### GetRequest +### ListVoteResultsRequest | Field | Type | Label | Description | | ----- | ---- | ----- | ----------- | -| user_id | [string](#string) | | | +| category_id | [string](#string) | | | @@ -4269,22 +9556,22 @@ casbin role for this hackathon; `is_waiting` is false once approved. -<a name="user_messages_user_svc_get_response-proto"></a> +<a name="vote_messages_vote_svc_list_results_response-proto"></a> <p align="right"><a href="#top">Top</a></p> -## user/messages/user_svc/get_response.proto +## vote/messages/vote_svc/list_results_response.proto -<a name="user-messages-user_svc-GetResponse"></a> +<a name="vote-messages-vote_svc-ListVoteResultsResponse"></a> -### GetResponse +### ListVoteResultsResponse | Field | Type | Label | Description | | ----- | ---- | ----- | ----------- | -| user | [user.entities.User](#user-entities-User) | | | +| vote_results | [vote.entities.VoteResult](#vote-entities-VoteResult) | repeated | | @@ -4300,17 +9587,32 @@ casbin role for this hackathon; `is_waiting` is false once approved. -<a name="user_messages_user_svc_list_request-proto"></a> +<a name="vote_messages_vote_svc_list_votes_request-proto"></a> <p align="right"><a href="#top">Top</a></p> -## user/messages/user_svc/list_request.proto +## vote/messages/vote_svc/list_votes_request.proto -<a name="user-messages-user_svc-ListRequest"></a> +<a name="vote-messages-vote_svc-ListVotesRequest"></a> -### ListRequest +### ListVotesRequest +The handler treats an empty voter_id / submission_id as "no filter", but a +plain string.uuid rule rejects the empty string before the handler ever runs +— which made every partially-filtered call impossible, including the obvious +"all votes in this category". The CEL rules below allow empty OR a valid +UUID, the same escape hatch page_id uses in +hackathon/messages/phase_svc/edit_request.proto. +category_id keeps the strict rule: listing votes without one is not a query +this service supports. + + +| Field | Type | Label | Description | +| ----- | ---- | ----- | ----------- | +| category_id | [string](#string) | | | +| voter_id | [string](#string) | | | +| submission_id | [string](#string) | | | @@ -4326,22 +9628,22 @@ casbin role for this hackathon; `is_waiting` is false once approved. -<a name="user_messages_user_svc_list_response-proto"></a> +<a name="vote_messages_vote_svc_list_votes_response-proto"></a> <p align="right"><a href="#top">Top</a></p> -## user/messages/user_svc/list_response.proto +## vote/messages/vote_svc/list_votes_response.proto -<a name="user-messages-user_svc-ListResponse"></a> +<a name="vote-messages-vote_svc-ListVotesResponse"></a> -### ListResponse +### ListVotesResponse | Field | Type | Label | Description | | ----- | ---- | ----- | ----------- | -| users | [user.entities.User](#user-entities-User) | repeated | | +| votes | [vote.entities.Vote](#vote-entities-Vote) | repeated | | @@ -4357,80 +9659,107 @@ casbin role for this hackathon; `is_waiting` is false once approved. -<a name="user_messages_user_svc_register_request-proto"></a> +<a name="vote_messages_vote_svc_submit_vote_request-proto"></a> <p align="right"><a href="#top">Top</a></p> -## user/messages/user_svc/register_request.proto +## vote/messages/vote_svc/submit_vote_request.proto -<a name="user-messages-user_svc-RegisterRequest"></a> +<a name="vote-messages-vote_svc-PointsSubmission"></a> -### RegisterRequest +### PointsSubmission +| Field | Type | Label | Description | +| ----- | ---- | ----- | ----------- | +| submission_id | [string](#string) | | | +| points | [int32](#int32) | | | - - - - +<a name="vote-messages-vote_svc-PointsVote"></a> +### PointsVote -<a name="user_messages_user_svc_register_response-proto"></a> -<p align="right"><a href="#top">Top</a></p> -## user/messages/user_svc/register_response.proto +| Field | Type | Label | Description | +| ----- | ---- | ----- | ----------- | +| category_id | [string](#string) | | | +| submissions | [PointsSubmission](#vote-messages-vote_svc-PointsSubmission) | repeated | | -<a name="user-messages-user_svc-RegisterResponse"></a> -### RegisterResponse + + +<a name="vote-messages-vote_svc-RankedSubmission"></a> + +### RankedSubmission | Field | Type | Label | Description | | ----- | ---- | ----- | ----------- | -| user | [user.entities.User](#user-entities-User) | | | +| submission_id | [string](#string) | | | +| rank | [int32](#int32) | | | - - +<a name="vote-messages-vote_svc-RankedVote"></a> - +### RankedVote +Ranks are carried explicitly rather than implied by list order: the voter +types a number per submission, so a gap or a repeat is a mistake the server +has to be able to name instead of one the client silently normalises away. - +| Field | Type | Label | Description | +| ----- | ---- | ----- | ----------- | +| category_id | [string](#string) | | | +| submissions | [RankedSubmission](#vote-messages-vote_svc-RankedSubmission) | repeated | | -<a name="user_messages_user_svc_remove_role_request-proto"></a> -<p align="right"><a href="#top">Top</a></p> -## user/messages/user_svc/remove_role_request.proto -<a name="user-messages-user_svc-RemoveRoleRequest"></a> +<a name="vote-messages-vote_svc-SingleChoiceVote"></a> -### RemoveRoleRequest +### SingleChoiceVote | Field | Type | Label | Description | | ----- | ---- | ----- | ----------- | -| user_id | [string](#string) | | | -| role | [user.entities.GlobalRole](#user-entities-GlobalRole) | | | +| category_id | [string](#string) | | | +| submission_id | [string](#string) | | | + + + + + + +<a name="vote-messages-vote_svc-SubmitVoteRequest"></a> + +### SubmitVoteRequest +One ballot. The variant chosen must match the category's voting_method or +the server refuses it. + + +| Field | Type | Label | Description | +| ----- | ---- | ----- | ----------- | +| single_choice | [SingleChoiceVote](#vote-messages-vote_svc-SingleChoiceVote) | | | +| ranked | [RankedVote](#vote-messages-vote_svc-RankedVote) | | | +| points | [PointsVote](#vote-messages-vote_svc-PointsVote) | | | @@ -4446,22 +9775,23 @@ casbin role for this hackathon; `is_waiting` is false once approved. -<a name="user_messages_user_svc_remove_role_response-proto"></a> +<a name="vote_messages_vote_svc_submit_vote_response-proto"></a> <p align="right"><a href="#top">Top</a></p> -## user/messages/user_svc/remove_role_response.proto +## vote/messages/vote_svc/submit_vote_response.proto -<a name="user-messages-user_svc-RemoveRoleResponse"></a> +<a name="vote-messages-vote_svc-SubmitVoteResponse"></a> -### RemoveRoleResponse +### SubmitVoteResponse | Field | Type | Label | Description | | ----- | ---- | ----- | ----------- | -| user | [user.entities.User](#user-entities-User) | | | +| vote | [vote.entities.Vote](#vote-entities-Vote) | | The first row of the accepted ballot — for single_choice that is the whole ballot, and callers that predate ranked/points keep working unchanged. | +| votes | [vote.entities.Vote](#vote-entities-Vote) | repeated | Every row the ballot produced: one for single_choice, N for ranked and points. | @@ -4477,17 +9807,34 @@ casbin role for this hackathon; `is_waiting` is false once approved. -<a name="user_messages_user_svc_who_am_i_request-proto"></a> +<a name="vote_messages_vote_svc_suggest_results_request-proto"></a> <p align="right"><a href="#top">Top</a></p> -## user/messages/user_svc/who_am_i_request.proto +## vote/messages/vote_svc/suggest_results_request.proto -<a name="user-messages-user_svc-WhoAmIRequest"></a> +<a name="vote-messages-vote_svc-SuggestResultsRequest"></a> -### WhoAmIRequest +### SuggestResultsRequest +Computes the tally for one category and writes it as VoteResult rows. + +SUGGEST, not decide. The vote is advisory on this platform: an organizer +reviews the count and records who actually won, and PrizeService.Finalize is +what freezes that. A jury that cannot overrule its own count is not a jury. +So this fills the results table for review — it never writes prizes. + +Without it the count existed nowhere: placements had to be typed in by hand +from an export, which is both tedious and the easiest possible place to make +an unnoticed mistake about who won. + + +| Field | Type | Label | Description | +| ----- | ---- | ----- | ----------- | +| category_id | [string](#string) | | | +| force | [bool](#bool) | | Recompute over existing results. +Refused without it when results are already present, because a recount that silently replaces a published placement is how a correction becomes an accusation. The caller has to say they mean it. | @@ -4503,22 +9850,22 @@ casbin role for this hackathon; `is_waiting` is false once approved. -<a name="user_messages_user_svc_who_am_i_response-proto"></a> +<a name="vote_messages_vote_svc_suggest_results_response-proto"></a> <p align="right"><a href="#top">Top</a></p> -## user/messages/user_svc/who_am_i_response.proto +## vote/messages/vote_svc/suggest_results_response.proto -<a name="user-messages-user_svc-WhoAmIResponse"></a> +<a name="vote-messages-vote_svc-SuggestResultsResponse"></a> -### WhoAmIResponse +### SuggestResultsResponse | Field | Type | Label | Description | | ----- | ---- | ----- | ----------- | -| user | [user.entities.User](#user-entities-User) | | | +| results | [vote.entities.VoteResult](#vote-entities-VoteResult) | repeated | The rows as written, ordered by position. Ties share a position — two submissions on the same count are both second, and the organizer decides what to do about it rather than the tally inventing an order. | @@ -4534,10 +9881,10 @@ casbin role for this hackathon; `is_waiting` is false once approved. -<a name="user_user_service-proto"></a> +<a name="vote_vote_service-proto"></a> <p align="right"><a href="#top">Top</a></p> -## user/user_service.proto +## vote/vote_service.proto @@ -4547,19 +9894,28 @@ casbin role for this hackathon; `is_waiting` is false once approved. -<a name="user-UserService"></a> +<a name="vote-VoteService"></a> -### UserService +### VoteService | Method Name | Request Type | Response Type | Description | | ----------- | ------------ | ------------- | ------------| -| List | [messages.user_svc.ListRequest](#user-messages-user_svc-ListRequest) | [messages.user_svc.ListResponse](#user-messages-user_svc-ListResponse) | | -| Get | [messages.user_svc.GetRequest](#user-messages-user_svc-GetRequest) | [messages.user_svc.GetResponse](#user-messages-user_svc-GetResponse) | | -| WhoAmI | [messages.user_svc.WhoAmIRequest](#user-messages-user_svc-WhoAmIRequest) | [messages.user_svc.WhoAmIResponse](#user-messages-user_svc-WhoAmIResponse) | | -| Register | [messages.user_svc.RegisterRequest](#user-messages-user_svc-RegisterRequest) | [messages.user_svc.RegisterResponse](#user-messages-user_svc-RegisterResponse) | | -| AddRole | [messages.user_svc.AddRoleRequest](#user-messages-user_svc-AddRoleRequest) | [messages.user_svc.AddRoleResponse](#user-messages-user_svc-AddRoleResponse) | | -| RemoveRole | [messages.user_svc.RemoveRoleRequest](#user-messages-user_svc-RemoveRoleRequest) | [messages.user_svc.RemoveRoleResponse](#user-messages-user_svc-RemoveRoleResponse) | | +| ListVoteCategories | [messages.vote_svc.ListVoteCategoriesRequest](#vote-messages-vote_svc-ListVoteCategoriesRequest) | [messages.vote_svc.ListVoteCategoriesResponse](#vote-messages-vote_svc-ListVoteCategoriesResponse) | VoteCategory CRUD | +| GetVoteCategory | [messages.vote_svc.GetVoteCategoryRequest](#vote-messages-vote_svc-GetVoteCategoryRequest) | [messages.vote_svc.GetVoteCategoryResponse](#vote-messages-vote_svc-GetVoteCategoryResponse) | | +| CreateVoteCategory | [messages.vote_svc.CreateVoteCategoryRequest](#vote-messages-vote_svc-CreateVoteCategoryRequest) | [messages.vote_svc.CreateVoteCategoryResponse](#vote-messages-vote_svc-CreateVoteCategoryResponse) | | +| EditVoteCategory | [messages.vote_svc.EditVoteCategoryRequest](#vote-messages-vote_svc-EditVoteCategoryRequest) | [messages.vote_svc.EditVoteCategoryResponse](#vote-messages-vote_svc-EditVoteCategoryResponse) | | +| DeleteVoteCategory | [messages.vote_svc.DeleteVoteCategoryRequest](#vote-messages-vote_svc-DeleteVoteCategoryRequest) | [messages.vote_svc.DeleteVoteCategoryResponse](#vote-messages-vote_svc-DeleteVoteCategoryResponse) | | +| SubmitVote | [messages.vote_svc.SubmitVoteRequest](#vote-messages-vote_svc-SubmitVoteRequest) | [messages.vote_svc.SubmitVoteResponse](#vote-messages-vote_svc-SubmitVoteResponse) | Voting | +| GetVote | [messages.vote_svc.GetVoteRequest](#vote-messages-vote_svc-GetVoteRequest) | [messages.vote_svc.GetVoteResponse](#vote-messages-vote_svc-GetVoteResponse) | | +| ListVotes | [messages.vote_svc.ListVotesRequest](#vote-messages-vote_svc-ListVotesRequest) | [messages.vote_svc.ListVotesResponse](#vote-messages-vote_svc-ListVotesResponse) | | +| ExportVotes | [messages.vote_svc.ExportVotesRequest](#vote-messages-vote_svc-ExportVotesRequest) | [messages.vote_svc.ExportVotesResponse](#vote-messages-vote_svc-ExportVotesResponse) | | +| ListVoteResults | [messages.vote_svc.ListVoteResultsRequest](#vote-messages-vote_svc-ListVoteResultsRequest) | [messages.vote_svc.ListVoteResultsResponse](#vote-messages-vote_svc-ListVoteResultsResponse) | Vote Results | +| CreateVoteResult | [messages.vote_svc.CreateVoteResultRequest](#vote-messages-vote_svc-CreateVoteResultRequest) | [messages.vote_svc.CreateVoteResultResponse](#vote-messages-vote_svc-CreateVoteResultResponse) | | +| EditVoteResult | [messages.vote_svc.EditVoteResultRequest](#vote-messages-vote_svc-EditVoteResultRequest) | [messages.vote_svc.EditVoteResultResponse](#vote-messages-vote_svc-EditVoteResultResponse) | | +| DeleteVoteResult | [messages.vote_svc.DeleteVoteResultRequest](#vote-messages-vote_svc-DeleteVoteResultRequest) | [messages.vote_svc.DeleteVoteResultResponse](#vote-messages-vote_svc-DeleteVoteResultResponse) | | +| SuggestResults | [messages.vote_svc.SuggestResultsRequest](#vote-messages-vote_svc-SuggestResultsRequest) | [messages.vote_svc.SuggestResultsResponse](#vote-messages-vote_svc-SuggestResultsResponse) | Computes the tally and writes it as results for the organizer to review. Advisory by design — see SuggestResultsRequest, and PrizeService for what actually freezes an award. | +| ExportResults | [messages.vote_svc.ExportResultsRequest](#vote-messages-vote_svc-ExportResultsRequest) | [messages.vote_svc.ExportResultsResponse](#vote-messages-vote_svc-ExportResultsResponse) | | diff --git a/api/proto/hackathon/config_service.proto b/api/proto/hackathon/config_service.proto new file mode 100644 index 00000000..3791ff99 --- /dev/null +++ b/api/proto/hackathon/config_service.proto @@ -0,0 +1,49 @@ +syntax = "proto3"; + +package hackathon; + +import "hackathon/messages/config_svc/get_email_templates_request.proto"; +import "hackathon/messages/config_svc/get_email_templates_response.proto"; +import "hackathon/messages/config_svc/get_windows_request.proto"; +import "hackathon/messages/config_svc/get_windows_response.proto"; +import "hackathon/messages/config_svc/override_window_request.proto"; +import "hackathon/messages/config_svc/override_window_response.proto"; +import "hackathon/messages/config_svc/set_branding_request.proto"; +import "hackathon/messages/config_svc/set_branding_response.proto"; +import "hackathon/messages/config_svc/set_email_templates_request.proto"; +import "hackathon/messages/config_svc/set_email_templates_response.proto"; +import "hackathon/messages/config_svc/set_registration_form_request.proto"; +import "hackathon/messages/config_svc/set_registration_form_response.proto"; +import "hackathon/messages/config_svc/set_submission_form_request.proto"; +import "hackathon/messages/config_svc/set_submission_form_response.proto"; +import "hackathon/messages/config_svc/set_voting_policy_request.proto"; +import "hackathon/messages/config_svc/set_voting_policy_response.proto"; +import "hackathon/messages/config_svc/set_windows_request.proto"; +import "hackathon/messages/config_svc/set_windows_response.proto"; + +option go_package = "github.com/swissdatasciencecenter/hackagon/components/backend/internal/proto/hackathon"; + +// Per-hackathon configuration. First slice: enforceable time windows. +// Forms, voting policy, email templates and branding land here as further +// slices of the same configuration engine. +service ConfigService { + // Reads the deadlines back. SetWindows REPLACES every field, so without this + // an organiser editing one deadline would blank the others they cannot see — + // the form had no way to prefill, which made saving it destructive. + rpc GetWindows(hackathon.messages.config_svc.GetWindowsRequest) returns (hackathon.messages.config_svc.GetWindowsResponse); + rpc SetWindows(hackathon.messages.config_svc.SetWindowsRequest) returns (hackathon.messages.config_svc.SetWindowsResponse); + rpc OverrideWindow(hackathon.messages.config_svc.OverrideWindowRequest) returns (hackathon.messages.config_svc.OverrideWindowResponse); + rpc SetRegistrationForm(hackathon.messages.config_svc.SetRegistrationFormRequest) returns (hackathon.messages.config_svc.SetRegistrationFormResponse); + rpc SetSubmissionForm(hackathon.messages.config_svc.SetSubmissionFormRequest) returns (hackathon.messages.config_svc.SetSubmissionFormResponse); + rpc SetVotingPolicy(hackathon.messages.config_svc.SetVotingPolicyRequest) returns (hackathon.messages.config_svc.SetVotingPolicyResponse); + // Organizer-authored notification copy and event branding. Stored config; + // delivery/rendering are separate concerns. + // + // Get exists for the same reason GetWindows does: Set replaces the whole + // map, so a form that cannot prefill blanks the templates its author never + // saw. Gated on Write rather than Read — this copy is internal drafting, + // unlike the deadlines, which the event announces. + rpc GetEmailTemplates(hackathon.messages.config_svc.GetEmailTemplatesRequest) returns (hackathon.messages.config_svc.GetEmailTemplatesResponse); + rpc SetEmailTemplates(hackathon.messages.config_svc.SetEmailTemplatesRequest) returns (hackathon.messages.config_svc.SetEmailTemplatesResponse); + rpc SetBranding(hackathon.messages.config_svc.SetBrandingRequest) returns (hackathon.messages.config_svc.SetBrandingResponse); +} diff --git a/api/proto/hackathon/entities/capability.proto b/api/proto/hackathon/entities/capability.proto new file mode 100644 index 00000000..6ee328ea --- /dev/null +++ b/api/proto/hackathon/entities/capability.proto @@ -0,0 +1,56 @@ +syntax = "proto3"; + +package hackathon.entities; + +import "google/protobuf/timestamp.proto"; + +option go_package = "github.com/swissdatasciencecenter/hackagon/components/backend/internal/proto/hackathon/entities"; + +// What a member is allowed to do in a hackathon right now. +// +// Each value is backed by exactly one stored row per hackathon carrying an +// `enabled` flag, which is the authoritative gate. Adding a capability is +// therefore an enum value plus a row — no schema or message change. +enum Capability { + CAPABILITY_UNSPECIFIED = 0; + // HackathonService.Join + CAPABILITY_REGISTER = 1; + // ProjectService.Propose + CAPABILITY_PROPOSE_PROJECTS = 2; + // ProjectService.SetPreference + CAPABILITY_SET_TEAM_PREFERENCES = 3; + // TeamService.CreateSubmission / FinalizeSubmission + CAPABILITY_CREATE_PROJECT_SUBMISSIONS = 4; + CAPABILITY_VOTE = 5; + // The flag doubles as the publish switch, since results are entered one + // placement at a time and must not leak partial standings. + CAPABILITY_VIEW_RESULTS = 6; +} + +enum CapabilityState { + CAPABILITY_STATE_UNSPECIFIED = 0; + // Closed now, but its open_in_phase starts in the future, so clients can show + // "opens 12 Aug" and count down to it. + CAPABILITY_STATE_COMING = 1; + CAPABILITY_STATE_OPEN = 2; + CAPABILITY_STATE_CLOSED = 3; + // No row exists for this capability, so the server has no opinion and does not + // enforce it. Clients must render exactly as they did before capabilities + // existed. This is what makes partial adoption safe. + CAPABILITY_STATE_UNGOVERNED = 4; +} + +message CapabilityStatus { + Capability capability = 1; + CapabilityState state = 2; + google.protobuf.Timestamp modified_at = 3; + optional string modifier_id = 4; + + // The schedule, derived from the linked phases. Display only: `state` is what + // the server enforces, and these never widen it. Absent when the capability + // is manually driven (no linked phase). + optional google.protobuf.Timestamp opens_at = 5; + optional google.protobuf.Timestamp closes_at = 6; + optional string open_in_phase_id = 7; + optional string closed_in_phase_id = 8; +} diff --git a/api/proto/hackathon/entities/form_schema.proto b/api/proto/hackathon/entities/form_schema.proto new file mode 100644 index 00000000..81948376 --- /dev/null +++ b/api/proto/hackathon/entities/form_schema.proto @@ -0,0 +1,30 @@ +syntax = "proto3"; + +package hackathon.entities; + +option go_package = "github.com/swissdatasciencecenter/hackagon/components/backend/internal/proto/hackathon/entities"; + +// FormField is one input in an organizer-defined form. `type` is a free +// string ("text", "tags", "url", "file-or-url", ...) — the backend validates +// presence and key membership, not deep typing, until a form engine exists. +message FormField { + string key = 1; + string label = 2; + string type = 3; + bool required = 4; + // Upload size cap for file-typed fields, in megabytes. + optional int32 max_mb = 5; +} + +// ConsentField is a checkbox the registrant must (or may) tick. +message ConsentField { + string key = 1; + string label = 2; + bool required = 3; +} + +// FormSchema is an organizer-defined form: fields plus consents. +message FormSchema { + repeated FormField fields = 1; + repeated ConsentField consents = 2; +} diff --git a/api/proto/hackathon/entities/hackathon.proto b/api/proto/hackathon/entities/hackathon.proto index 4e21c0c0..05478562 100644 --- a/api/proto/hackathon/entities/hackathon.proto +++ b/api/proto/hackathon/entities/hackathon.proto @@ -4,8 +4,14 @@ package hackathon.entities; import "buf/validate/validate.proto"; import "google/protobuf/timestamp.proto"; +import "hackathon/entities/capability.proto"; +import "hackathon/entities/form_schema.proto"; +import "hackathon/entities/hackathon_branding.proto"; import "hackathon/entities/hackathon_member.proto"; +import "hackathon/entities/hackathon_settings.proto"; +import "hackathon/entities/hackathon_state.proto"; import "hackathon/entities/hackathon_status.proto"; +import "hackathon/entities/hackathon_voting_policy.proto"; import "hackathon/entities/page.proto"; import "hackathon/entities/phase.proto"; import "hackathon/entities/project.proto"; @@ -45,4 +51,57 @@ message Hackathon { // Populated in List responses only when participant_id filter is set. // Contains the requesting user's membership in this hackathon (role + is_waiting). optional HackathonMember viewer_membership = 18; + // Populated in Get responses only. + HackathonSettings settings = 19; + // Computed server-side from the stored capability rows; not persisted as a + // whole. Populated on both Get and List, so a list can gate its own buttons + // rather than firing a mutation to discover something is closed. + // + // Will become caller-dependent, so clients must not cache it across users. + repeated CapabilityStatus capabilities = 20; + // The phase an organizer declared current via AdvancePhase. Absent means + // clients should derive it from phase dates instead — correct before an event, + // wrong during one, where the schedule slips. + optional string current_phase_id = 21; + // Set by ConfigService.SetBranding. Populated on Get and on List — the + // public event page is built from List, so leaving it Get-only would make an + // event's own colours invisible exactly where visitors see it. Absent when + // the organizer set no branding. + optional HackathonBranding branding = 22; + // Organizer-defined form schemas (ConfigService.SetRegistrationForm / + // SetSubmissionForm). Populated on Get only — a client needs them to RENDER + // the form it is about to submit, and without a read path the only way to + // fill one in was to guess the field keys. Absent when no form is defined, + // which means "accept anything" on the write side. + optional FormSchema registration_form = 23; + optional FormSchema submission_form = 24; + // Organizer-authored notification copy (ConfigService.SetEmailTemplates), + // keyed "<moment>" for the body and "<moment>Subject" for the subject line. + // Get only, and readable by members: it is copy about the event, not a + // secret — but nothing sends it, so organizers compose from it by hand. + map<string, string> email_templates = 25; + // How the vote works (ConfigService.SetVotingPolicy). Readable by anyone who + // can read the hackathon, because these are the rules the voters are bound + // by: "may I vote for my own team" is a voter's question. Absent when the + // organizer set no policy, which means the backend's defaults. + optional HackathonVotingPolicy voting_policy = 26; + // A FAÇADE over `capabilities` above, in main's flat boolean shape — see + // `hackathon_state.proto`. Nothing is stored for it and nothing enforces from + // it; it is `capabilities` projected through + // `state == OPEN || state == UNGOVERNED`, plus `current_phase_id`. + // + // Populated wherever `capabilities` is, which is Get AND List: a facade that + // appeared on only one of them would be a worse contract than no facade. + // + // Tag 27 because main's 19 is our `settings` and 1-26 are all in use here. + // A main client therefore finds `state` at a different number than it expects + // — the shape is compatible, the address on this message is not, and + // renumbering shipped fields to fix that would break every caller we have. + HackathonState state = 27; + // Maximum number of CONFIRMED participants; people on the waiting list do + // not consume a place. Absent or 0 means unlimited. When set, Join hands out + // free places first-come-first-served while nobody is waiting, and a full + // event queues new registrants instead of refusing them. Organizers may + // approve past this number — it is their estimate of the room, not a law. + optional int32 max_participants = 28; } diff --git a/api/proto/hackathon/entities/hackathon_branding.proto b/api/proto/hackathon/entities/hackathon_branding.proto new file mode 100644 index 00000000..4a3fbb35 --- /dev/null +++ b/api/proto/hackathon/entities/hackathon_branding.proto @@ -0,0 +1,24 @@ +syntax = "proto3"; + +package hackathon.entities; + +option go_package = "github.com/swissdatasciencecenter/hackagon/components/backend/internal/proto/hackathon/entities"; + +// Per-hackathon visual identity, written by ConfigService.SetBranding and +// stored on the hackathon's forms row. Every field is optional and the message +// itself is absent when an organizer never set anything — a hackathon without +// branding must render exactly like the default platform theme. +// +// The event logo is not here: it is a column on the hackathon row itself +// (Hackathon.logo), because it predates branding and List already returns it. +message HackathonBranding { + // Hex colour, #rgb or #rrggbb. SetBranding rejects anything else, but these + // values end up inside a CSS style attribute, so clients must validate them + // again before interpolating: a row written before that check existed, or by + // a future write path that forgets it, would otherwise be injected into CSS. + optional string primary_color = 1; + optional string accent_color = 2; + // Free text shown as a banner on the event page (e.g. "Registration closes + // Friday"). Rendered as text, never as markup. + optional string banner_text = 3; +} diff --git a/api/proto/hackathon/entities/hackathon_invite.proto b/api/proto/hackathon/entities/hackathon_invite.proto new file mode 100644 index 00000000..3428043e --- /dev/null +++ b/api/proto/hackathon/entities/hackathon_invite.proto @@ -0,0 +1,21 @@ +syntax = "proto3"; + +package hackathon.entities; + +import "google/protobuf/timestamp.proto"; + +option go_package = "github.com/swissdatasciencecenter/hackagon/components/backend/internal/proto/hackathon/entities"; + +// A revocable, shareable invitation link for a private hackathon. +// Organizer-facing: `token` is the secret in the URL, so this message is only +// ever returned to callers who may write the hackathon. +message HackathonInvite { + string id = 1; + string token = 2; + string hackathon_id = 3; + string note = 4; + google.protobuf.Timestamp created_at = 5; + // Absent while the link still works. + optional google.protobuf.Timestamp revoked_at = 6; + string creator_id = 7; +} diff --git a/api/proto/hackathon/entities/hackathon_settings.proto b/api/proto/hackathon/entities/hackathon_settings.proto new file mode 100644 index 00000000..2cb30bb2 --- /dev/null +++ b/api/proto/hackathon/entities/hackathon_settings.proto @@ -0,0 +1,14 @@ +syntax = "proto3"; + +package hackathon.entities; + +import "google/protobuf/timestamp.proto"; + +option go_package = "github.com/swissdatasciencecenter/hackagon/components/backend/internal/proto/hackathon/entities"; + +message HackathonSettings { + string id = 1; + bool registrations_enabled = 2; + bool voting_enabled = 3; + google.protobuf.Timestamp modified_at = 4; +} diff --git a/api/proto/hackathon/entities/hackathon_state.proto b/api/proto/hackathon/entities/hackathon_state.proto new file mode 100644 index 00000000..b8c94524 --- /dev/null +++ b/api/proto/hackathon/entities/hackathon_state.proto @@ -0,0 +1,63 @@ +syntax = "proto3"; + +package hackathon.entities; + +import "google/protobuf/timestamp.proto"; +import "hackathon/entities/capability.proto"; + +option go_package = "github.com/swissdatasciencecenter/hackagon/components/backend/internal/proto/hackathon/entities"; + +// A FAÇADE. `HackathonState` is upstream `main`'s shape for "what is switched on +// in this event": one record of booleans plus the current phase. It is stored +// nowhere here — there is no HackathonState table and no ent entity. Every field +// is computed, per request, from the `Capability` rows that already back +// `Hackathon.capabilities`. +// +// It exists so a client written against main's contract decodes ours, which is +// why the field numbers below are main's verbatim. Read `Hackathon.capabilities` +// instead if you are writing a new client: `CapabilityStatus` carries the four +// states, the schedule and the audit that this message flattens away. +// +// **It carries no enforcement.** The gate is `requireCapability` reading the +// stored rows; this message never reaches it. Main enforces by writing casbin +// policy from `SetCapabilities`; that path is deliberately not ported, so +// nothing here can open or close anything on its own. +message HackathonState { + // The hackathon's own id. Main's state is a row with an identity of its own; + // ours is a projection, and the event it belongs to is the only honest + // identity available. One state per hackathon on both sides, so it is unique + // in the same way. + string id = 1; + // The hackathon's created_at: the capability rows are created with the event. + google.protobuf.Timestamp created_at = 2; + // The most recent modification across the capability rows — when the state + // last actually changed — falling back to the hackathon's own modified_at + // when no row has been touched. + google.protobuf.Timestamp modified_at = 3; + // Empty when no phase has been declared current, matching main. `Hackathon` + // reports the same value as an optional field. + string current_phase_id = 4; + // One entry per capability in the vocabulary, in vocabulary order, including + // the ones with no stored row. + // + // `enabled` is the projection `state == OPEN || state == UNGOVERNED`: the same + // predicate `capability.State.Allowed` uses to admit a mutation, so a client + // reading this boolean is told exactly what the server will permit. COMING + // and CLOSED both flatten to false — the distinction between "not yet" and + // "no longer" survives only in `CapabilityStatus`. + repeated CapabilityToggle capabilities = 5; +} + +// Main calls this message `CapabilityState`. It cannot keep that name here: +// `hackathon.entities.CapabilityState` is already an ENUM in this package — +// COMING / OPEN / CLOSED / UNGOVERNED — and the two would collide outright. +// `Toggle` is also the truer name. This carries a boolean intent, in or out; +// the four-state answer the server computes from it is the enum. +// +// The message NAME is not on the wire, so a main client decoding field 5 of +// `HackathonState`, or encoding `SetCapabilitiesRequest.capabilities`, is +// unaffected by the rename. +message CapabilityToggle { + Capability capability = 1; + bool enabled = 2; +} diff --git a/api/proto/hackathon/entities/hackathon_voting_policy.proto b/api/proto/hackathon/entities/hackathon_voting_policy.proto new file mode 100644 index 00000000..e93b872f --- /dev/null +++ b/api/proto/hackathon/entities/hackathon_voting_policy.proto @@ -0,0 +1,37 @@ +syntax = "proto3"; + +package hackathon.entities; + +option go_package = "github.com/swissdatasciencecenter/hackagon/components/backend/internal/proto/hackathon/entities"; + +// How this event's vote works, as ConfigService.SetVotingPolicy stored it. +// +// On the entity rather than behind a GetVotingPolicy RPC because these are the +// RULES OF A VOTE, and the people bound by them are exactly the people who +// should be able to read them: "may I vote for my own team" is a question a +// voter asks, not an organizer's private setting. +// +// Absent when no policy was ever set, which the backend reads as its defaults — +// organizers do not vote, voting for your own team is allowed. `SubmitVote` +// enforces both (they were stored and ignored until then). +message HackathonVotingPolicy { + // Free text describing the count, e.g. "single_choice". Documented rather + // than enforced: one vote per category is the only mechanism implemented. + string mechanism = 1; + // What one ballot covers, e.g. "category". + string one_ballot_per = 2; + // Whether a voter may vote for a submission from a team they are on. + bool own_team_voting = 3; + // Whether the event's own organizers may vote in it. + bool organizer_voting = 4; + // Ordered tie-break rules, as the organizer wrote them. Advisory: the + // organizer records the winners, so a tie is theirs to break. + repeated string tie_break = 5; + // Present only for scored mechanisms. + optional ScaleRange scale = 6; +} + +message ScaleRange { + int32 min = 1; + int32 max = 2; +} diff --git a/api/proto/hackathon/entities/hackathon_window_set.proto b/api/proto/hackathon/entities/hackathon_window_set.proto new file mode 100644 index 00000000..f034ae77 --- /dev/null +++ b/api/proto/hackathon/entities/hackathon_window_set.proto @@ -0,0 +1,25 @@ +syntax = "proto3"; + +package hackathon.entities; + +import "google/protobuf/timestamp.proto"; + +option go_package = "github.com/swissdatasciencecenter/hackagon/components/backend/internal/proto/hackathon/entities"; + +// HackathonWindows holds the per-hackathon time windows the backend enforces +// on the acting RPCs (Join, Propose, SetPreference, CreateSubmission). +// Unset fields are not enforced. Overrides are absolute one-shot extensions +// anchored at the moment the organizer granted them. +message HackathonWindows { + string hackathon_id = 1; + optional google.protobuf.Timestamp registration_opens = 2; + optional google.protobuf.Timestamp registration_closes = 3; + optional google.protobuf.Timestamp proposals_close = 4; + optional google.protobuf.Timestamp preferences_close = 5; + optional google.protobuf.Timestamp submissions_close = 6; + optional google.protobuf.Timestamp registration_override_until = 7; + optional google.protobuf.Timestamp submissions_override_until = 8; + // Human-readable note on how late submissions are handled. + optional string late_policy = 9; + google.protobuf.Timestamp modified_at = 10; +} diff --git a/api/proto/hackathon/entities/prize.proto b/api/proto/hackathon/entities/prize.proto new file mode 100644 index 00000000..d8dd5c96 --- /dev/null +++ b/api/proto/hackathon/entities/prize.proto @@ -0,0 +1,29 @@ +syntax = "proto3"; + +package hackathon.entities; + +option go_package = "github.com/swissdatasciencecenter/hackagon/components/backend/internal/proto/hackathon/entities"; + +// Prize is one row of the organizer-defined prize table. rank 0 marks a +// discretionary/special prize (e.g. Community Choice). +message Prize { + int32 rank = 1; + string title = 2; + // A picture for the prize — the trophy, the sponsor's award, the plaque. + // Optional, and empty for every prize table written before this existed. + // + // No migration: the prize table is stored as flexible JSON + // (`[]map[string]any` in db/schema/hackathonprizes.go), so a new key costs + // nothing at rest. Same shape as Hackathon.logo — a root-relative /objects + // path from StorageService, or an http(s) URL. + optional string image = 3; +} + +// Award attaches a submission to a prize once the admin finalizes: by rank +// for the ranked prizes, by name for special ones. Votes are advisory until +// this happens — the admin has the final voice. +message Award { + optional int32 rank = 1; + optional string special = 2; + string submission_id = 3; +} diff --git a/api/proto/hackathon/hackathon_service.proto b/api/proto/hackathon/hackathon_service.proto index 71f79183..bc58f16d 100644 --- a/api/proto/hackathon/hackathon_service.proto +++ b/api/proto/hackathon/hackathon_service.proto @@ -4,22 +4,48 @@ package hackathon; import "hackathon/messages/hackathon_svc/add_owner_request.proto"; import "hackathon/messages/hackathon_svc/add_owner_response.proto"; +import "hackathon/messages/hackathon_svc/advance_phase_request.proto"; +import "hackathon/messages/hackathon_svc/advance_phase_response.proto"; import "hackathon/messages/hackathon_svc/approve_participant_request.proto"; import "hackathon/messages/hackathon_svc/approve_participant_response.proto"; +import "hackathon/messages/hackathon_svc/create_invite_request.proto"; +import "hackathon/messages/hackathon_svc/create_invite_response.proto"; import "hackathon/messages/hackathon_svc/create_request.proto"; import "hackathon/messages/hackathon_svc/create_response.proto"; +import "hackathon/messages/hackathon_svc/delete_request.proto"; +import "hackathon/messages/hackathon_svc/delete_response.proto"; +import "hackathon/messages/hackathon_svc/edit_capability_request.proto"; +import "hackathon/messages/hackathon_svc/edit_capability_response.proto"; import "hackathon/messages/hackathon_svc/edit_request.proto"; import "hackathon/messages/hackathon_svc/edit_response.proto"; +import "hackathon/messages/hackathon_svc/edit_settings_request.proto"; +import "hackathon/messages/hackathon_svc/edit_settings_response.proto"; +import "hackathon/messages/hackathon_svc/get_registration_response_request.proto"; +import "hackathon/messages/hackathon_svc/get_registration_response_response.proto"; import "hackathon/messages/hackathon_svc/get_request.proto"; import "hackathon/messages/hackathon_svc/get_response.proto"; import "hackathon/messages/hackathon_svc/join_request.proto"; import "hackathon/messages/hackathon_svc/join_response.proto"; +import "hackathon/messages/hackathon_svc/list_invites_request.proto"; +import "hackathon/messages/hackathon_svc/list_invites_response.proto"; +import "hackathon/messages/hackathon_svc/list_registration_responses_request.proto"; +import "hackathon/messages/hackathon_svc/list_registration_responses_response.proto"; import "hackathon/messages/hackathon_svc/list_request.proto"; import "hackathon/messages/hackathon_svc/list_response.proto"; +import "hackathon/messages/hackathon_svc/preview_invite_request.proto"; +import "hackathon/messages/hackathon_svc/preview_invite_response.proto"; import "hackathon/messages/hackathon_svc/remove_owner_request.proto"; import "hackathon/messages/hackathon_svc/remove_owner_response.proto"; import "hackathon/messages/hackathon_svc/remove_participant_request.proto"; import "hackathon/messages/hackathon_svc/remove_participant_response.proto"; +import "hackathon/messages/hackathon_svc/revoke_invite_request.proto"; +import "hackathon/messages/hackathon_svc/revoke_invite_response.proto"; +import "hackathon/messages/hackathon_svc/set_capabilities_request.proto"; +import "hackathon/messages/hackathon_svc/set_capabilities_response.proto"; +import "hackathon/messages/hackathon_svc/set_current_phase_request.proto"; +import "hackathon/messages/hackathon_svc/set_current_phase_response.proto"; +import "hackathon/messages/hackathon_svc/submit_registration_form_request.proto"; +import "hackathon/messages/hackathon_svc/submit_registration_form_response.proto"; option go_package = "github.com/swissdatasciencecenter/hackagon/components/backend/internal/proto/hackathon"; @@ -28,9 +54,31 @@ service HackathonService { rpc Get(hackathon.messages.hackathon_svc.GetRequest) returns (hackathon.messages.hackathon_svc.GetResponse); rpc Create(hackathon.messages.hackathon_svc.CreateRequest) returns (hackathon.messages.hackathon_svc.CreateResponse); rpc Edit(hackathon.messages.hackathon_svc.EditRequest) returns (hackathon.messages.hackathon_svc.EditResponse); + rpc Delete(hackathon.messages.hackathon_svc.DeleteRequest) returns (hackathon.messages.hackathon_svc.DeleteResponse); + rpc EditCapability(hackathon.messages.hackathon_svc.EditCapabilityRequest) returns (hackathon.messages.hackathon_svc.EditCapabilityResponse); + // Batch form of EditCapability: an organiser toggles several at once, so + // one call is one intent rather than a burst the UI has to sequence. + rpc SetCapabilities(hackathon.messages.hackathon_svc.SetCapabilitiesRequest) returns (hackathon.messages.hackathon_svc.SetCapabilitiesResponse); + rpc AdvancePhase(hackathon.messages.hackathon_svc.AdvancePhaseRequest) returns (hackathon.messages.hackathon_svc.AdvancePhaseResponse); + // Main's name for AdvancePhase, and a thin alias over it: same authorisation, + // same capability application, same "empty phase_id clears it". Answers in + // main's flat HackathonState instead of the CapabilityStatus list, so native + // callers should keep using AdvancePhase. + rpc SetCurrentPhase(hackathon.messages.hackathon_svc.SetCurrentPhaseRequest) returns (hackathon.messages.hackathon_svc.SetCurrentPhaseResponse); + rpc EditSettings(hackathon.messages.hackathon_svc.EditSettingsRequest) returns (hackathon.messages.hackathon_svc.EditSettingsResponse); rpc Join(hackathon.messages.hackathon_svc.JoinRequest) returns (hackathon.messages.hackathon_svc.JoinResponse); rpc ApproveParticipant(hackathon.messages.hackathon_svc.ApproveParticipantRequest) returns (hackathon.messages.hackathon_svc.ApproveParticipantResponse); rpc RemoveParticipant(hackathon.messages.hackathon_svc.RemoveParticipantRequest) returns (hackathon.messages.hackathon_svc.RemoveParticipantResponse); + rpc SubmitRegistrationForm(hackathon.messages.hackathon_svc.SubmitRegistrationFormRequest) returns (hackathon.messages.hackathon_svc.SubmitRegistrationFormResponse); + // Reads the answers back so a registrant can review and correct them. + rpc GetRegistrationResponse(hackathon.messages.hackathon_svc.GetRegistrationResponseRequest) returns (hackathon.messages.hackathon_svc.GetRegistrationResponseResponse); + rpc ListRegistrationResponses(hackathon.messages.hackathon_svc.ListRegistrationResponsesRequest) returns (hackathon.messages.hackathon_svc.ListRegistrationResponsesResponse); rpc AddOwner(hackathon.messages.hackathon_svc.AddOwnerRequest) returns (hackathon.messages.hackathon_svc.AddOwnerResponse); rpc RemoveOwner(hackathon.messages.hackathon_svc.RemoveOwnerRequest) returns (hackathon.messages.hackathon_svc.RemoveOwnerResponse); + // --- Invitations: private-hackathon access, see HackathonInvite --- + rpc CreateInvite(hackathon.messages.hackathon_svc.CreateInviteRequest) returns (hackathon.messages.hackathon_svc.CreateInviteResponse); + rpc ListInvites(hackathon.messages.hackathon_svc.ListInvitesRequest) returns (hackathon.messages.hackathon_svc.ListInvitesResponse); + rpc RevokeInvite(hackathon.messages.hackathon_svc.RevokeInviteRequest) returns (hackathon.messages.hackathon_svc.RevokeInviteResponse); + // Redemption side: token in, shallow hackathon out. No hackathon id needed. + rpc PreviewInvite(hackathon.messages.hackathon_svc.PreviewInviteRequest) returns (hackathon.messages.hackathon_svc.PreviewInviteResponse); } diff --git a/api/proto/hackathon/messages/config_svc/get_email_templates_request.proto b/api/proto/hackathon/messages/config_svc/get_email_templates_request.proto new file mode 100644 index 00000000..381b7bf2 --- /dev/null +++ b/api/proto/hackathon/messages/config_svc/get_email_templates_request.proto @@ -0,0 +1,11 @@ +syntax = "proto3"; + +package hackathon.messages.config_svc; + +import "buf/validate/validate.proto"; + +option go_package = "github.com/swissdatasciencecenter/hackagon/components/backend/internal/proto/hackathon/messages/config_svc"; + +message GetEmailTemplatesRequest { + string hackathon_id = 1 [(buf.validate.field).string.uuid = true]; +} diff --git a/api/proto/hackathon/messages/config_svc/get_email_templates_response.proto b/api/proto/hackathon/messages/config_svc/get_email_templates_response.proto new file mode 100644 index 00000000..d4cd32f7 --- /dev/null +++ b/api/proto/hackathon/messages/config_svc/get_email_templates_response.proto @@ -0,0 +1,16 @@ +syntax = "proto3"; + +package hackathon.messages.config_svc; + +option go_package = "github.com/swissdatasciencecenter/hackagon/components/backend/internal/proto/hackathon/messages/config_svc"; + +// The stored notification copy, keyed exactly as SetEmailTemplates takes it. +// +// A read is required rather than convenient: Set REPLACES the whole map, so a +// form that cannot prefill blanks every template the organizer did not have in +// front of them. Same reason GetWindows and PrizeService.Get exist. +// +// Empty map = nothing authored yet, which is a valid state and not an error. +message GetEmailTemplatesResponse { + map<string, string> templates = 1; +} diff --git a/api/proto/hackathon/messages/config_svc/get_windows_request.proto b/api/proto/hackathon/messages/config_svc/get_windows_request.proto new file mode 100644 index 00000000..71605be6 --- /dev/null +++ b/api/proto/hackathon/messages/config_svc/get_windows_request.proto @@ -0,0 +1,11 @@ +syntax = "proto3"; + +package hackathon.messages.config_svc; + +import "buf/validate/validate.proto"; + +option go_package = "github.com/swissdatasciencecenter/hackagon/components/backend/internal/proto/hackathon/messages/config_svc"; + +message GetWindowsRequest { + string hackathon_id = 1 [(buf.validate.field).string.uuid = true]; +} diff --git a/api/proto/hackathon/messages/config_svc/get_windows_response.proto b/api/proto/hackathon/messages/config_svc/get_windows_response.proto new file mode 100644 index 00000000..2ab7ec92 --- /dev/null +++ b/api/proto/hackathon/messages/config_svc/get_windows_response.proto @@ -0,0 +1,13 @@ +syntax = "proto3"; + +package hackathon.messages.config_svc; + +import "hackathon/entities/hackathon_window_set.proto"; + +option go_package = "github.com/swissdatasciencecenter/hackagon/components/backend/internal/proto/hackathon/messages/config_svc"; + +// `windows` is absent when the event has no row yet, which reads as "no +// deadlines set" — distinct from a row whose fields happen to be empty. +message GetWindowsResponse { + optional hackathon.entities.HackathonWindows windows = 1; +} diff --git a/api/proto/hackathon/messages/config_svc/override_window_request.proto b/api/proto/hackathon/messages/config_svc/override_window_request.proto new file mode 100644 index 00000000..a03e4224 --- /dev/null +++ b/api/proto/hackathon/messages/config_svc/override_window_request.proto @@ -0,0 +1,26 @@ +syntax = "proto3"; + +package hackathon.messages.config_svc; + +import "buf/validate/validate.proto"; + +option go_package = "github.com/swissdatasciencecenter/hackagon/components/backend/internal/proto/hackathon/messages/config_svc"; + +// One-shot manual extension: the window stays open until now + extend_minutes +// regardless of its configured close (walk-ins at the door, AV issues during +// demos). The organizer has the final word over the clock. +message OverrideWindowRequest { + string hackathon_id = 1 [(buf.validate.field).string.uuid = true]; + // Which window to extend: "registration" or "submissions". + string window = 2 [(buf.validate.field).string = { + in: [ + "registration", + "submissions" + ] + }]; + int32 extend_minutes = 3 [ + (buf.validate.field).int32.gt = 0, + (buf.validate.field).int32.lte = 1440 + ]; + string reason = 4 [(buf.validate.field).string.max_len = 500]; +} diff --git a/api/proto/hackathon/messages/config_svc/override_window_response.proto b/api/proto/hackathon/messages/config_svc/override_window_response.proto new file mode 100644 index 00000000..c855f0c2 --- /dev/null +++ b/api/proto/hackathon/messages/config_svc/override_window_response.proto @@ -0,0 +1,11 @@ +syntax = "proto3"; + +package hackathon.messages.config_svc; + +import "hackathon/entities/hackathon_window_set.proto"; + +option go_package = "github.com/swissdatasciencecenter/hackagon/components/backend/internal/proto/hackathon/messages/config_svc"; + +message OverrideWindowResponse { + hackathon.entities.HackathonWindows windows = 1; +} diff --git a/api/proto/hackathon/messages/config_svc/set_branding_request.proto b/api/proto/hackathon/messages/config_svc/set_branding_request.proto new file mode 100644 index 00000000..1b6c0806 --- /dev/null +++ b/api/proto/hackathon/messages/config_svc/set_branding_request.proto @@ -0,0 +1,15 @@ +syntax = "proto3"; + +package hackathon.messages.config_svc; + +import "buf/validate/validate.proto"; + +option go_package = "github.com/swissdatasciencecenter/hackagon/components/backend/internal/proto/hackathon/messages/config_svc"; + +message SetBrandingRequest { + string hackathon_id = 1 [(buf.validate.field).string.uuid = true]; + // CSS hex colours (#rgb or #rrggbb); validated server-side. + optional string primary_color = 2; + optional string accent_color = 3; + optional string banner_text = 4; +} diff --git a/api/proto/hackathon/messages/config_svc/set_branding_response.proto b/api/proto/hackathon/messages/config_svc/set_branding_response.proto new file mode 100644 index 00000000..69831b6e --- /dev/null +++ b/api/proto/hackathon/messages/config_svc/set_branding_response.proto @@ -0,0 +1,7 @@ +syntax = "proto3"; + +package hackathon.messages.config_svc; + +option go_package = "github.com/swissdatasciencecenter/hackagon/components/backend/internal/proto/hackathon/messages/config_svc"; + +message SetBrandingResponse {} diff --git a/api/proto/hackathon/messages/config_svc/set_email_templates_request.proto b/api/proto/hackathon/messages/config_svc/set_email_templates_request.proto new file mode 100644 index 00000000..69c47f36 --- /dev/null +++ b/api/proto/hackathon/messages/config_svc/set_email_templates_request.proto @@ -0,0 +1,17 @@ +syntax = "proto3"; + +package hackathon.messages.config_svc; + +import "buf/validate/validate.proto"; + +option go_package = "github.com/swissdatasciencecenter/hackagon/components/backend/internal/proto/hackathon/messages/config_svc"; + +// Notification COPY, stored per hackathon. Sending is a separate concern (no +// notification service exists yet) — this pins the text organizers author so +// it survives that gap. +message SetEmailTemplatesRequest { + string hackathon_id = 1 [(buf.validate.field).string.uuid = true]; + // Keyed by moment: registrationConfirmed, teamAssigned, deadlineReminder, + // results. Unknown keys are rejected so a typo is not silently stored. + map<string, string> templates = 2; +} diff --git a/api/proto/hackathon/messages/config_svc/set_email_templates_response.proto b/api/proto/hackathon/messages/config_svc/set_email_templates_response.proto new file mode 100644 index 00000000..8d0583b2 --- /dev/null +++ b/api/proto/hackathon/messages/config_svc/set_email_templates_response.proto @@ -0,0 +1,7 @@ +syntax = "proto3"; + +package hackathon.messages.config_svc; + +option go_package = "github.com/swissdatasciencecenter/hackagon/components/backend/internal/proto/hackathon/messages/config_svc"; + +message SetEmailTemplatesResponse {} diff --git a/api/proto/hackathon/messages/config_svc/set_registration_form_request.proto b/api/proto/hackathon/messages/config_svc/set_registration_form_request.proto new file mode 100644 index 00000000..d7fad051 --- /dev/null +++ b/api/proto/hackathon/messages/config_svc/set_registration_form_request.proto @@ -0,0 +1,14 @@ +syntax = "proto3"; + +package hackathon.messages.config_svc; + +import "buf/validate/validate.proto"; +import "hackathon/entities/form_schema.proto"; + +option go_package = "github.com/swissdatasciencecenter/hackagon/components/backend/internal/proto/hackathon/messages/config_svc"; + +message SetRegistrationFormRequest { + string hackathon_id = 1 [(buf.validate.field).string.uuid = true]; + repeated hackathon.entities.FormField fields = 2; + repeated hackathon.entities.ConsentField consents = 3; +} diff --git a/api/proto/hackathon/messages/config_svc/set_registration_form_response.proto b/api/proto/hackathon/messages/config_svc/set_registration_form_response.proto new file mode 100644 index 00000000..b2770571 --- /dev/null +++ b/api/proto/hackathon/messages/config_svc/set_registration_form_response.proto @@ -0,0 +1,11 @@ +syntax = "proto3"; + +package hackathon.messages.config_svc; + +import "hackathon/entities/form_schema.proto"; + +option go_package = "github.com/swissdatasciencecenter/hackagon/components/backend/internal/proto/hackathon/messages/config_svc"; + +message SetRegistrationFormResponse { + hackathon.entities.FormSchema form = 1; +} diff --git a/api/proto/hackathon/messages/config_svc/set_submission_form_request.proto b/api/proto/hackathon/messages/config_svc/set_submission_form_request.proto new file mode 100644 index 00000000..0157f2f4 --- /dev/null +++ b/api/proto/hackathon/messages/config_svc/set_submission_form_request.proto @@ -0,0 +1,13 @@ +syntax = "proto3"; + +package hackathon.messages.config_svc; + +import "buf/validate/validate.proto"; +import "hackathon/entities/form_schema.proto"; + +option go_package = "github.com/swissdatasciencecenter/hackagon/components/backend/internal/proto/hackathon/messages/config_svc"; + +message SetSubmissionFormRequest { + string hackathon_id = 1 [(buf.validate.field).string.uuid = true]; + repeated hackathon.entities.FormField fields = 2; +} diff --git a/api/proto/hackathon/messages/config_svc/set_submission_form_response.proto b/api/proto/hackathon/messages/config_svc/set_submission_form_response.proto new file mode 100644 index 00000000..e905f472 --- /dev/null +++ b/api/proto/hackathon/messages/config_svc/set_submission_form_response.proto @@ -0,0 +1,11 @@ +syntax = "proto3"; + +package hackathon.messages.config_svc; + +import "hackathon/entities/form_schema.proto"; + +option go_package = "github.com/swissdatasciencecenter/hackagon/components/backend/internal/proto/hackathon/messages/config_svc"; + +message SetSubmissionFormResponse { + hackathon.entities.FormSchema form = 1; +} diff --git a/api/proto/hackathon/messages/config_svc/set_voting_policy_request.proto b/api/proto/hackathon/messages/config_svc/set_voting_policy_request.proto new file mode 100644 index 00000000..622e9a0e --- /dev/null +++ b/api/proto/hackathon/messages/config_svc/set_voting_policy_request.proto @@ -0,0 +1,25 @@ +syntax = "proto3"; + +package hackathon.messages.config_svc; + +import "buf/validate/validate.proto"; + +option go_package = "github.com/swissdatasciencecenter/hackagon/components/backend/internal/proto/hackathon/messages/config_svc"; + +// Pins the voting mechanism decisions. Stored as configuration; the vote +// handlers enforce the parts the platform implements (single ballot per +// category today) and the rest documents the organizer's ruling. +message SetVotingPolicyRequest { + string hackathon_id = 1 [(buf.validate.field).string.uuid = true]; + string mechanism = 2; + ScaleRange scale = 3; + string one_ballot_per = 4; + bool own_team_voting = 5; + bool organizer_voting = 6; + repeated string tie_break = 7; +} + +message ScaleRange { + int32 min = 1; + int32 max = 2; +} diff --git a/api/proto/hackathon/messages/config_svc/set_voting_policy_response.proto b/api/proto/hackathon/messages/config_svc/set_voting_policy_response.proto new file mode 100644 index 00000000..eacace29 --- /dev/null +++ b/api/proto/hackathon/messages/config_svc/set_voting_policy_response.proto @@ -0,0 +1,7 @@ +syntax = "proto3"; + +package hackathon.messages.config_svc; + +option go_package = "github.com/swissdatasciencecenter/hackagon/components/backend/internal/proto/hackathon/messages/config_svc"; + +message SetVotingPolicyResponse {} diff --git a/api/proto/hackathon/messages/config_svc/set_windows_request.proto b/api/proto/hackathon/messages/config_svc/set_windows_request.proto new file mode 100644 index 00000000..340c8478 --- /dev/null +++ b/api/proto/hackathon/messages/config_svc/set_windows_request.proto @@ -0,0 +1,20 @@ +syntax = "proto3"; + +package hackathon.messages.config_svc; + +import "buf/validate/validate.proto"; +import "google/protobuf/timestamp.proto"; + +option go_package = "github.com/swissdatasciencecenter/hackagon/components/backend/internal/proto/hackathon/messages/config_svc"; + +// Partial update: only the fields present are written; windows never set are +// not enforced. +message SetWindowsRequest { + string hackathon_id = 1 [(buf.validate.field).string.uuid = true]; + optional google.protobuf.Timestamp registration_opens = 2; + optional google.protobuf.Timestamp registration_closes = 3; + optional google.protobuf.Timestamp proposals_close = 4; + optional google.protobuf.Timestamp preferences_close = 5; + optional google.protobuf.Timestamp submissions_close = 6; + optional string late_policy = 7 [(buf.validate.field).string.max_len = 1000]; +} diff --git a/api/proto/hackathon/messages/config_svc/set_windows_response.proto b/api/proto/hackathon/messages/config_svc/set_windows_response.proto new file mode 100644 index 00000000..b9ce086c --- /dev/null +++ b/api/proto/hackathon/messages/config_svc/set_windows_response.proto @@ -0,0 +1,11 @@ +syntax = "proto3"; + +package hackathon.messages.config_svc; + +import "hackathon/entities/hackathon_window_set.proto"; + +option go_package = "github.com/swissdatasciencecenter/hackagon/components/backend/internal/proto/hackathon/messages/config_svc"; + +message SetWindowsResponse { + hackathon.entities.HackathonWindows windows = 1; +} diff --git a/api/proto/hackathon/messages/hackathon_svc/advance_phase_request.proto b/api/proto/hackathon/messages/hackathon_svc/advance_phase_request.proto new file mode 100644 index 00000000..14603d00 --- /dev/null +++ b/api/proto/hackathon/messages/hackathon_svc/advance_phase_request.proto @@ -0,0 +1,27 @@ +syntax = "proto3"; + +package hackathon.messages.hackathon_svc; + +import "buf/validate/validate.proto"; + +option go_package = "github.com/swissdatasciencecenter/hackagon/components/backend/internal/proto/hackathon/messages/hackathon_svc"; + +message AdvancePhaseRequest { + string hackathon_id = 1 [(buf.validate.field).string.uuid = true]; + // The phase the hackathon is now in. Must belong to this hackathon. + // + // EMPTY means "no current phase" — the organizer is clearing the pointer, and + // the timeline goes back to being read from dates alone. A plain `uuid = true` + // rule rejected that before the handler ever saw it, which is why the "Clear + // current phase" button could never succeed: it sends no phase_id by design. + // + // Clearing deliberately does NOT touch capabilities. Advancing applies the + // ones scheduled for the target phase; with no target there is nothing to + // apply, and silently switching things off because someone cleared a label + // would be the opposite of what they asked for. + string phase_id = 2 [(buf.validate.field).cel = { + id: "phase_id.uuid_or_empty" + message: "phase_id must be a UUID, or empty to clear the current phase" + expression: "this == '' || this.matches('^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$')" + }]; +} diff --git a/api/proto/hackathon/messages/hackathon_svc/advance_phase_response.proto b/api/proto/hackathon/messages/hackathon_svc/advance_phase_response.proto new file mode 100644 index 00000000..5e6eab9c --- /dev/null +++ b/api/proto/hackathon/messages/hackathon_svc/advance_phase_response.proto @@ -0,0 +1,14 @@ +syntax = "proto3"; + +package hackathon.messages.hackathon_svc; + +import "hackathon/entities/capability.proto"; + +option go_package = "github.com/swissdatasciencecenter/hackagon/components/backend/internal/proto/hackathon/messages/hackathon_svc"; + +message AdvancePhaseResponse { + string current_phase_id = 1; + // Every capability after the move, so the caller can show what changed rather + // than re-fetching the hackathon. + repeated hackathon.entities.CapabilityStatus capabilities = 2; +} diff --git a/api/proto/hackathon/messages/hackathon_svc/create_invite_request.proto b/api/proto/hackathon/messages/hackathon_svc/create_invite_request.proto new file mode 100644 index 00000000..7b792d7a --- /dev/null +++ b/api/proto/hackathon/messages/hackathon_svc/create_invite_request.proto @@ -0,0 +1,13 @@ +syntax = "proto3"; + +package hackathon.messages.hackathon_svc; + +import "buf/validate/validate.proto"; + +option go_package = "github.com/swissdatasciencecenter/hackagon/components/backend/internal/proto/hackathon/messages/hackathon_svc"; + +message CreateInviteRequest { + string hackathon_id = 1 [(buf.validate.field).string.uuid = true]; + // Optional organizer-facing reminder of who the link was sent to. + optional string note = 2; +} diff --git a/api/proto/hackathon/messages/hackathon_svc/create_invite_response.proto b/api/proto/hackathon/messages/hackathon_svc/create_invite_response.proto new file mode 100644 index 00000000..5a83d36c --- /dev/null +++ b/api/proto/hackathon/messages/hackathon_svc/create_invite_response.proto @@ -0,0 +1,11 @@ +syntax = "proto3"; + +package hackathon.messages.hackathon_svc; + +import "hackathon/entities/hackathon_invite.proto"; + +option go_package = "github.com/swissdatasciencecenter/hackagon/components/backend/internal/proto/hackathon/messages/hackathon_svc"; + +message CreateInviteResponse { + hackathon.entities.HackathonInvite invite = 1; +} diff --git a/api/proto/hackathon/messages/hackathon_svc/create_request.proto b/api/proto/hackathon/messages/hackathon_svc/create_request.proto index 164c0dbc..5ae5c130 100644 --- a/api/proto/hackathon/messages/hackathon_svc/create_request.proto +++ b/api/proto/hackathon/messages/hackathon_svc/create_request.proto @@ -24,5 +24,20 @@ message CreateRequest { hackathon.entities.Visibility visibility = 4 [(buf.validate.field).enum.defined_only = true]; optional string description = 5 [(buf.validate.field).string.max_len = 10000]; - optional string logo = 6 [(buf.validate.field).string.uri = true]; + // A `uri = true` rule here rejected BOTH values this platform actually + // produces: the empty string (an event with no cover yet, which is every + // event at Create time) and the root-relative `/objects/...` path that + // StorageService returns and cmd/seed writes. Only a full absolute URL + // passed — the one shape we do not generate. Edit.logo never had the rule, + // so the same value was accepted or refused depending on which RPC you used. + optional string logo = 6 [ + (buf.validate.field).string.max_len = 5000, + (buf.validate.field).cel = { + id: "logo.path_or_url" + message: "logo must be empty, a root-relative /objects path, an http(s) URL, or an inline data:image URI" + expression: "this == '' || this.startsWith('/') || this.startsWith('http://') || this.startsWith('https://') || this.startsWith('data:image/')" + } + ]; + // Maximum number of CONFIRMED participants. 0 or absent means unlimited. + optional int32 max_participants = 7 [(buf.validate.field).int32.gte = 0]; } diff --git a/api/proto/hackathon/messages/hackathon_svc/delete_request.proto b/api/proto/hackathon/messages/hackathon_svc/delete_request.proto new file mode 100644 index 00000000..56454960 --- /dev/null +++ b/api/proto/hackathon/messages/hackathon_svc/delete_request.proto @@ -0,0 +1,11 @@ +syntax = "proto3"; + +package hackathon.messages.hackathon_svc; + +import "buf/validate/validate.proto"; + +option go_package = "github.com/swissdatasciencecenter/hackagon/components/backend/internal/proto/hackathon/messages/hackathon_svc"; + +message DeleteRequest { + string hackathon_id = 1 [(buf.validate.field).string.uuid = true]; +} diff --git a/api/proto/hackathon/messages/hackathon_svc/delete_response.proto b/api/proto/hackathon/messages/hackathon_svc/delete_response.proto new file mode 100644 index 00000000..663a8267 --- /dev/null +++ b/api/proto/hackathon/messages/hackathon_svc/delete_response.proto @@ -0,0 +1,7 @@ +syntax = "proto3"; + +package hackathon.messages.hackathon_svc; + +option go_package = "github.com/swissdatasciencecenter/hackagon/components/backend/internal/proto/hackathon/messages/hackathon_svc"; + +message DeleteResponse {} diff --git a/api/proto/hackathon/messages/hackathon_svc/edit_capability_request.proto b/api/proto/hackathon/messages/hackathon_svc/edit_capability_request.proto new file mode 100644 index 00000000..67629040 --- /dev/null +++ b/api/proto/hackathon/messages/hackathon_svc/edit_capability_request.proto @@ -0,0 +1,38 @@ +syntax = "proto3"; + +package hackathon.messages.hackathon_svc; + +import "buf/validate/validate.proto"; +import "hackathon/entities/capability.proto"; + +option go_package = "github.com/swissdatasciencecenter/hackagon/components/backend/internal/proto/hackathon/messages/hackathon_svc"; + +message EditCapabilityRequest { + string hackathon_id = 1 [(buf.validate.field).string.uuid = true]; + // Identifies the row, so it is required rather than optional — unlike the + // mutable fields of the other Edit requests. + hackathon.entities.Capability capability = 2 [ + (buf.validate.field).enum.defined_only = true, + (buf.validate.field).enum.not_in = 0 + ]; + optional bool enabled = 3; + + // Schedule links, for display only — setting these never opens or closes + // anything, only `enabled` does. + // + // Empty string = unlink, non-empty = link to that phase, not set = no change. + // Same convention as phase_svc/edit_request.proto's page_id. + optional string open_in_phase_id = 4; + optional string closed_in_phase_id = 5; + + option (buf.validate.message).cel = { + id: "open_in_phase_id_uuid" + message: "open_in_phase_id must be a valid UUID if provided and non-empty" + expression: "!has(this.open_in_phase_id) || this.open_in_phase_id == '' || this.open_in_phase_id.matches('^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$')" + }; + option (buf.validate.message).cel = { + id: "closed_in_phase_id_uuid" + message: "closed_in_phase_id must be a valid UUID if provided and non-empty" + expression: "!has(this.closed_in_phase_id) || this.closed_in_phase_id == '' || this.closed_in_phase_id.matches('^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$')" + }; +} diff --git a/api/proto/hackathon/messages/hackathon_svc/edit_capability_response.proto b/api/proto/hackathon/messages/hackathon_svc/edit_capability_response.proto new file mode 100644 index 00000000..bc09c3b2 --- /dev/null +++ b/api/proto/hackathon/messages/hackathon_svc/edit_capability_response.proto @@ -0,0 +1,11 @@ +syntax = "proto3"; + +package hackathon.messages.hackathon_svc; + +import "hackathon/entities/capability.proto"; + +option go_package = "github.com/swissdatasciencecenter/hackagon/components/backend/internal/proto/hackathon/messages/hackathon_svc"; + +message EditCapabilityResponse { + hackathon.entities.CapabilityStatus capability = 1; +} diff --git a/api/proto/hackathon/messages/hackathon_svc/edit_request.proto b/api/proto/hackathon/messages/hackathon_svc/edit_request.proto index 15fca703..dc73086e 100644 --- a/api/proto/hackathon/messages/hackathon_svc/edit_request.proto +++ b/api/proto/hackathon/messages/hackathon_svc/edit_request.proto @@ -18,7 +18,20 @@ message EditRequest { optional hackathon.entities.Visibility visibility = 4; optional google.protobuf.Timestamp starts_at = 5; optional google.protobuf.Timestamp ends_at = 6; - optional string logo = 7 [(buf.validate.field).string.max_len = 5000]; + // Same rule as Create.logo, so a value is accepted or refused by what it IS + // rather than by which RPC it arrived on. + optional string logo = 7 [ + (buf.validate.field).string.max_len = 5000, + (buf.validate.field).cel = { + id: "logo.path_or_url" + message: "logo must be empty, a root-relative /objects path, an http(s) URL, or an inline data:image URI" + expression: "this == '' || this.startsWith('/') || this.startsWith('http://') || this.startsWith('https://') || this.startsWith('data:image/')" + } + ]; + // Absent leaves the capacity unchanged; 0 clears it back to unlimited. + // Lowering it below the current confirmed count is allowed and removes + // nobody — the number is the organizer's estimate, the roster is theirs. + optional int32 max_participants = 8 [(buf.validate.field).int32.gte = 0]; option (buf.validate.message).cel = { id: "starts_at_requires_ends_at" diff --git a/api/proto/hackathon/messages/hackathon_svc/edit_settings_request.proto b/api/proto/hackathon/messages/hackathon_svc/edit_settings_request.proto new file mode 100644 index 00000000..97c0a87e --- /dev/null +++ b/api/proto/hackathon/messages/hackathon_svc/edit_settings_request.proto @@ -0,0 +1,13 @@ +syntax = "proto3"; + +package hackathon.messages.hackathon_svc; + +import "buf/validate/validate.proto"; + +option go_package = "github.com/swissdatasciencecenter/hackagon/components/backend/internal/proto/hackathon/messages/hackathon_svc"; + +message EditSettingsRequest { + string hackathon_id = 1 [(buf.validate.field).string.uuid = true]; + optional bool registrations_enabled = 2; + optional bool voting_enabled = 3; +} diff --git a/api/proto/hackathon/messages/hackathon_svc/edit_settings_response.proto b/api/proto/hackathon/messages/hackathon_svc/edit_settings_response.proto new file mode 100644 index 00000000..395ec140 --- /dev/null +++ b/api/proto/hackathon/messages/hackathon_svc/edit_settings_response.proto @@ -0,0 +1,11 @@ +syntax = "proto3"; + +package hackathon.messages.hackathon_svc; + +import "hackathon/entities/hackathon_settings.proto"; + +option go_package = "github.com/swissdatasciencecenter/hackagon/components/backend/internal/proto/hackathon/messages/hackathon_svc"; + +message EditSettingsResponse { + hackathon.entities.HackathonSettings settings = 1; +} diff --git a/api/proto/hackathon/messages/hackathon_svc/get_registration_response_request.proto b/api/proto/hackathon/messages/hackathon_svc/get_registration_response_request.proto new file mode 100644 index 00000000..1224fdd1 --- /dev/null +++ b/api/proto/hackathon/messages/hackathon_svc/get_registration_response_request.proto @@ -0,0 +1,22 @@ +syntax = "proto3"; + +package hackathon.messages.hackathon_svc; + +import "buf/validate/validate.proto"; + +option go_package = "github.com/swissdatasciencecenter/hackagon/components/backend/internal/proto/hackathon/messages/hackathon_svc"; + +// Reads back the registration answers already on file, so a registrant can see +// and correct what they submitted. +// +// Deliberately NOT part of HackathonService.Get: Get denies waitlisted users, +// and a waitlisted user is exactly who still needs to review their form. This +// takes only the hackathon id and answers for the caller. +// +// `user_id` lets an organizer (hackathon Write) read someone else's answers — +// the check-in desk correcting a paper form, mirroring `on_behalf_of` on +// SubmitRegistrationForm. +message GetRegistrationResponseRequest { + string hackathon_id = 1 [(buf.validate.field).string.uuid = true]; + optional string user_id = 2 [(buf.validate.field).string.uuid = true]; +} diff --git a/api/proto/hackathon/messages/hackathon_svc/get_registration_response_response.proto b/api/proto/hackathon/messages/hackathon_svc/get_registration_response_response.proto new file mode 100644 index 00000000..c9f69c44 --- /dev/null +++ b/api/proto/hackathon/messages/hackathon_svc/get_registration_response_response.proto @@ -0,0 +1,21 @@ +syntax = "proto3"; + +package hackathon.messages.hackathon_svc; + +import "google/protobuf/struct.proto"; +import "google/protobuf/timestamp.proto"; + +option go_package = "github.com/swissdatasciencecenter/hackagon/components/backend/internal/proto/hackathon/messages/hackathon_svc"; + +// `submitted` distinguishes "no answers on file" from "answers that happen to +// be empty" — an empty form is a legitimate state when every field is +// optional, so a bare empty map would be ambiguous. +message GetRegistrationResponseResponse { + bool submitted = 1; + google.protobuf.Struct responses = 2; + map<string, bool> consents = 3; + optional google.protobuf.Timestamp submitted_at = 4; + optional google.protobuf.Timestamp modified_at = 5; + // Set when someone else entered the answers (organizer-assisted signup). + optional string submitted_by_id = 6; +} diff --git a/api/proto/hackathon/messages/hackathon_svc/join_request.proto b/api/proto/hackathon/messages/hackathon_svc/join_request.proto index 6bd1d0da..d778df56 100644 --- a/api/proto/hackathon/messages/hackathon_svc/join_request.proto +++ b/api/proto/hackathon/messages/hackathon_svc/join_request.proto @@ -6,4 +6,8 @@ option go_package = "github.com/swissdatasciencecenter/hackagon/components/backe message JoinRequest { string hackathon_id = 1; + // Required to join a PRIVATE hackathon: the secret from the invitation link. + // Public hackathons ignore it. Without this a private event was joinable by + // anyone who knew its UUID. + optional string invite_token = 2; } diff --git a/api/proto/hackathon/messages/hackathon_svc/join_response.proto b/api/proto/hackathon/messages/hackathon_svc/join_response.proto index a2625e43..e52a2af3 100644 --- a/api/proto/hackathon/messages/hackathon_svc/join_response.proto +++ b/api/proto/hackathon/messages/hackathon_svc/join_response.proto @@ -6,4 +6,12 @@ option go_package = "github.com/swissdatasciencecenter/hackagon/components/backe message JoinResponse { string hackathon_id = 1; + // True when this join landed on the waiting list (pending an organizer's + // approval or a freed place); false when it took a confirmed place outright. + // Joining a full event SUCCEEDS and queues — this flag is how a client tells + // "you're in" from "you're number N in the queue". + bool waitlisted = 2; + // 1-based place in the waiting queue (join order) when waitlisted; 0 when + // confirmed. An idempotent re-join reports the caller's current state. + int32 queue_position = 3; } diff --git a/api/proto/hackathon/messages/hackathon_svc/list_invites_request.proto b/api/proto/hackathon/messages/hackathon_svc/list_invites_request.proto new file mode 100644 index 00000000..c8e3a041 --- /dev/null +++ b/api/proto/hackathon/messages/hackathon_svc/list_invites_request.proto @@ -0,0 +1,13 @@ +syntax = "proto3"; + +package hackathon.messages.hackathon_svc; + +import "buf/validate/validate.proto"; + +option go_package = "github.com/swissdatasciencecenter/hackagon/components/backend/internal/proto/hackathon/messages/hackathon_svc"; + +message ListInvitesRequest { + string hackathon_id = 1 [(buf.validate.field).string.uuid = true]; + // Revoked links are hidden unless asked for. + optional bool include_revoked = 2; +} diff --git a/api/proto/hackathon/messages/hackathon_svc/list_invites_response.proto b/api/proto/hackathon/messages/hackathon_svc/list_invites_response.proto new file mode 100644 index 00000000..66017015 --- /dev/null +++ b/api/proto/hackathon/messages/hackathon_svc/list_invites_response.proto @@ -0,0 +1,11 @@ +syntax = "proto3"; + +package hackathon.messages.hackathon_svc; + +import "hackathon/entities/hackathon_invite.proto"; + +option go_package = "github.com/swissdatasciencecenter/hackagon/components/backend/internal/proto/hackathon/messages/hackathon_svc"; + +message ListInvitesResponse { + repeated hackathon.entities.HackathonInvite invites = 1; +} diff --git a/api/proto/hackathon/messages/hackathon_svc/list_registration_responses_request.proto b/api/proto/hackathon/messages/hackathon_svc/list_registration_responses_request.proto new file mode 100644 index 00000000..dda08c12 --- /dev/null +++ b/api/proto/hackathon/messages/hackathon_svc/list_registration_responses_request.proto @@ -0,0 +1,21 @@ +syntax = "proto3"; + +package hackathon.messages.hackathon_svc; + +import "buf/validate/validate.proto"; + +option go_package = "github.com/swissdatasciencecenter/hackagon/components/backend/internal/proto/hackathon/messages/hackathon_svc"; + +// Everyone's registration answers, for one hackathon, in one call. +// +// GetRegistrationResponse answers for ONE person, which is right for "show me +// my own answers" and wrong for every organiser screen: staffing teams or +// working a waitlist means reading the whole cohort, and doing that one RPC per +// participant is a round-trip per row on every page load. +// +// Organizer-only — this returns what people wrote about themselves, so it takes +// hackathon Write rather than Read. A fellow member cannot read it, the same +// rule GetRegistrationResponse already enforces for another person's answers. +message ListRegistrationResponsesRequest { + string hackathon_id = 1 [(buf.validate.field).string.uuid = true]; +} diff --git a/api/proto/hackathon/messages/hackathon_svc/list_registration_responses_response.proto b/api/proto/hackathon/messages/hackathon_svc/list_registration_responses_response.proto new file mode 100644 index 00000000..5757d68b --- /dev/null +++ b/api/proto/hackathon/messages/hackathon_svc/list_registration_responses_response.proto @@ -0,0 +1,28 @@ +syntax = "proto3"; + +package hackathon.messages.hackathon_svc; + +import "google/protobuf/struct.proto"; +import "google/protobuf/timestamp.proto"; + +option go_package = "github.com/swissdatasciencecenter/hackagon/components/backend/internal/proto/hackathon/messages/hackathon_svc"; + +message ListRegistrationResponsesResponse { + repeated RegistrationResponseEntry responses = 1; +} + +// One participant's answers. +// +// Only people who actually submitted appear. A row per participant with an +// empty answer set would make "has not filled the form in" and "filled it in +// and left everything blank" the same thing on the wire, and those are +// different facts to an organiser chasing people. +message RegistrationResponseEntry { + // The platform user id (the DB UUID), matching Hackathon.members[].user.id + // so a caller can join this onto the roster it already has. + string user_id = 1; + google.protobuf.Struct responses = 2; + map<string, bool> consents = 3; + google.protobuf.Timestamp submitted_at = 4; + google.protobuf.Timestamp modified_at = 5; +} diff --git a/api/proto/hackathon/messages/hackathon_svc/preview_invite_request.proto b/api/proto/hackathon/messages/hackathon_svc/preview_invite_request.proto new file mode 100644 index 00000000..383e2539 --- /dev/null +++ b/api/proto/hackathon/messages/hackathon_svc/preview_invite_request.proto @@ -0,0 +1,14 @@ +syntax = "proto3"; + +package hackathon.messages.hackathon_svc; + +import "buf/validate/validate.proto"; + +option go_package = "github.com/swissdatasciencecenter/hackagon/components/backend/internal/proto/hackathon/messages/hackathon_svc"; + +// Redeeming side of an invite: exchanges the link secret for enough of the +// hackathon to render its page. Deliberately takes ONLY the token, so it never +// confirms whether a given hackathon id exists. +message PreviewInviteRequest { + string token = 1 [(buf.validate.field).string.uuid = true]; +} diff --git a/api/proto/hackathon/messages/hackathon_svc/preview_invite_response.proto b/api/proto/hackathon/messages/hackathon_svc/preview_invite_response.proto new file mode 100644 index 00000000..7b62ef68 --- /dev/null +++ b/api/proto/hackathon/messages/hackathon_svc/preview_invite_response.proto @@ -0,0 +1,14 @@ +syntax = "proto3"; + +package hackathon.messages.hackathon_svc; + +import "hackathon/entities/hackathon.proto"; + +option go_package = "github.com/swissdatasciencecenter/hackagon/components/backend/internal/proto/hackathon/messages/hackathon_svc"; + +message PreviewInviteResponse { + // Shallow entity only — the invite grants visibility, not membership. + hackathon.entities.Hackathon hackathon = 1; + // True when the caller is already on this hackathon's roster. + bool already_participant = 2; +} diff --git a/api/proto/hackathon/messages/hackathon_svc/revoke_invite_request.proto b/api/proto/hackathon/messages/hackathon_svc/revoke_invite_request.proto new file mode 100644 index 00000000..aa5271f8 --- /dev/null +++ b/api/proto/hackathon/messages/hackathon_svc/revoke_invite_request.proto @@ -0,0 +1,11 @@ +syntax = "proto3"; + +package hackathon.messages.hackathon_svc; + +import "buf/validate/validate.proto"; + +option go_package = "github.com/swissdatasciencecenter/hackagon/components/backend/internal/proto/hackathon/messages/hackathon_svc"; + +message RevokeInviteRequest { + string invite_id = 1 [(buf.validate.field).string.uuid = true]; +} diff --git a/api/proto/hackathon/messages/hackathon_svc/revoke_invite_response.proto b/api/proto/hackathon/messages/hackathon_svc/revoke_invite_response.proto new file mode 100644 index 00000000..2ceae174 --- /dev/null +++ b/api/proto/hackathon/messages/hackathon_svc/revoke_invite_response.proto @@ -0,0 +1,7 @@ +syntax = "proto3"; + +package hackathon.messages.hackathon_svc; + +option go_package = "github.com/swissdatasciencecenter/hackagon/components/backend/internal/proto/hackathon/messages/hackathon_svc"; + +message RevokeInviteResponse {} diff --git a/api/proto/hackathon/messages/hackathon_svc/set_capabilities_request.proto b/api/proto/hackathon/messages/hackathon_svc/set_capabilities_request.proto new file mode 100644 index 00000000..d3988b03 --- /dev/null +++ b/api/proto/hackathon/messages/hackathon_svc/set_capabilities_request.proto @@ -0,0 +1,26 @@ +syntax = "proto3"; + +package hackathon.messages.hackathon_svc; + +import "hackathon/entities/hackathon_state.proto"; + +option go_package = "github.com/swissdatasciencecenter/hackagon/components/backend/internal/proto/hackathon/messages/hackathon_svc"; + +// Batch form of EditCapability: an organiser toggling several switches at once +// is one intent, and one call keeps it atomic instead of a burst the UI has to +// sequence and half-undo when one of them fails. +// +// This is also main's write side of `HackathonState`, field-for-field, so a +// client written against main's contract can drive our capability rows. What it +// does NOT do is what main's does next: main's SetCapabilities writes casbin +// policy rows, and that enforcement path is deliberately not ported. Here the +// booleans land on the stored `Capability` rows — true opens, false closes — +// and `requireCapability` remains the only gate. +message SetCapabilitiesRequest { + string hackathon_id = 1; + // `CapabilityToggle` is main's `CapabilityState` message, renamed because we + // already have an enum of that name in `hackathon.entities`. It lives in + // `entities/hackathon_state.proto` — shared with `HackathonState`, exactly as + // main shares it. The rename is invisible on the wire. + repeated hackathon.entities.CapabilityToggle capabilities = 2; +} diff --git a/api/proto/hackathon/messages/hackathon_svc/set_capabilities_response.proto b/api/proto/hackathon/messages/hackathon_svc/set_capabilities_response.proto new file mode 100644 index 00000000..8ee4f3c6 --- /dev/null +++ b/api/proto/hackathon/messages/hackathon_svc/set_capabilities_response.proto @@ -0,0 +1,26 @@ +syntax = "proto3"; + +package hackathon.messages.hackathon_svc; + +import "hackathon/entities/capability.proto"; +import "hackathon/entities/hackathon_state.proto"; + +option go_package = "github.com/swissdatasciencecenter/hackagon/components/backend/internal/proto/hackathon/messages/hackathon_svc"; + +// Returns the resulting capabilities in the same shape `Get` reports them, so a +// caller re-renders from the response instead of refetching the hackathon. +// +// `CapabilityStatus`, not the booleans that went in: what the server enforces +// is the four-state answer, including a schedule derived from the linked +// phases, and a toggle is only one input to it. Echoing the request back would +// hide the case where a capability is governed by a phase window and the +// organiser's switch did not decide it. +message SetCapabilitiesResponse { + repeated hackathon.entities.CapabilityStatus capabilities = 1; + // The same answer flattened into main's `HackathonState`, for clients written + // against that contract. Field 2, not 1: main puts `state` on tag 1 and ours + // is already taken by `capabilities`, and renumbering a shipped field to gain + // decode compatibility would break the callers we actually have. A main + // client reads `state` from `Get` instead, where the tag was free. + hackathon.entities.HackathonState state = 2; +} diff --git a/api/proto/hackathon/messages/hackathon_svc/set_current_phase_request.proto b/api/proto/hackathon/messages/hackathon_svc/set_current_phase_request.proto new file mode 100644 index 00000000..ec572a74 --- /dev/null +++ b/api/proto/hackathon/messages/hackathon_svc/set_current_phase_request.proto @@ -0,0 +1,26 @@ +syntax = "proto3"; + +package hackathon.messages.hackathon_svc; + +import "buf/validate/validate.proto"; + +option go_package = "github.com/swissdatasciencecenter/hackagon/components/backend/internal/proto/hackathon/messages/hackathon_svc"; + +// Main's name for what `AdvancePhase` does. Same two fields, same numbers, so a +// client written against main's contract drives ours unchanged. +message SetCurrentPhaseRequest { + string hackathon_id = 1 [(buf.validate.field).string.uuid = true]; + // EMPTY means "clear the current phase" — main's semantics, and ours: + // AdvancePhase has read an empty phase_id that way since the "Clear current + // phase" button was fixed. + // + // Clearing does not touch capabilities. Advancing applies the ones scheduled + // for the target phase; with no target there is nothing to apply, and + // switching things off because someone cleared a label would be the opposite + // of what they asked for. + string phase_id = 2 [(buf.validate.field).cel = { + id: "phase_id.uuid_or_empty" + message: "phase_id must be a UUID, or empty to clear the current phase" + expression: "this == '' || this.matches('^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$')" + }]; +} diff --git a/api/proto/hackathon/messages/hackathon_svc/set_current_phase_response.proto b/api/proto/hackathon/messages/hackathon_svc/set_current_phase_response.proto new file mode 100644 index 00000000..903f69a2 --- /dev/null +++ b/api/proto/hackathon/messages/hackathon_svc/set_current_phase_response.proto @@ -0,0 +1,14 @@ +syntax = "proto3"; + +package hackathon.messages.hackathon_svc; + +import "hackathon/entities/hackathon_state.proto"; + +option go_package = "github.com/swissdatasciencecenter/hackagon/components/backend/internal/proto/hackathon/messages/hackathon_svc"; + +// Main's shape verbatim, tag included — this message is new here, so nothing +// had to move to make room for it. Native callers should prefer `AdvancePhase`, +// whose response carries the full `CapabilityStatus` list this one flattens. +message SetCurrentPhaseResponse { + hackathon.entities.HackathonState state = 1; +} diff --git a/api/proto/hackathon/messages/hackathon_svc/submit_registration_form_request.proto b/api/proto/hackathon/messages/hackathon_svc/submit_registration_form_request.proto new file mode 100644 index 00000000..c772b889 --- /dev/null +++ b/api/proto/hackathon/messages/hackathon_svc/submit_registration_form_request.proto @@ -0,0 +1,19 @@ +syntax = "proto3"; + +package hackathon.messages.hackathon_svc; + +import "buf/validate/validate.proto"; +import "google/protobuf/struct.proto"; + +option go_package = "github.com/swissdatasciencecenter/hackagon/components/backend/internal/proto/hackathon/messages/hackathon_svc"; + +// Responses are validated against the organizer-defined registration form: +// unknown keys, missing required fields, and unticked required consents are +// InvalidArgument. `on_behalf_of` lets an organizer digitize a paper form +// for another registrant (walk-ins at the check-in desk). +message SubmitRegistrationFormRequest { + string hackathon_id = 1 [(buf.validate.field).string.uuid = true]; + google.protobuf.Struct responses = 2; + map<string, bool> consents = 3; + optional string on_behalf_of = 4 [(buf.validate.field).string.uuid = true]; +} diff --git a/api/proto/hackathon/messages/hackathon_svc/submit_registration_form_response.proto b/api/proto/hackathon/messages/hackathon_svc/submit_registration_form_response.proto new file mode 100644 index 00000000..76111fd5 --- /dev/null +++ b/api/proto/hackathon/messages/hackathon_svc/submit_registration_form_response.proto @@ -0,0 +1,9 @@ +syntax = "proto3"; + +package hackathon.messages.hackathon_svc; + +option go_package = "github.com/swissdatasciencecenter/hackagon/components/backend/internal/proto/hackathon/messages/hackathon_svc"; + +message SubmitRegistrationFormResponse { + string id = 1; +} diff --git a/api/proto/hackathon/messages/prize_svc/edit_request.proto b/api/proto/hackathon/messages/prize_svc/edit_request.proto new file mode 100644 index 00000000..f512eca2 --- /dev/null +++ b/api/proto/hackathon/messages/prize_svc/edit_request.proto @@ -0,0 +1,13 @@ +syntax = "proto3"; + +package hackathon.messages.prize_svc; + +import "buf/validate/validate.proto"; + +option go_package = "github.com/swissdatasciencecenter/hackagon/components/backend/internal/proto/hackathon/messages/prize_svc"; + +message EditRequest { + string hackathon_id = 1 [(buf.validate.field).string.uuid = true]; + int32 rank = 2; + optional string title = 3 [(buf.validate.field).string.max_len = 500]; +} diff --git a/api/proto/hackathon/messages/prize_svc/edit_response.proto b/api/proto/hackathon/messages/prize_svc/edit_response.proto new file mode 100644 index 00000000..5dbcd791 --- /dev/null +++ b/api/proto/hackathon/messages/prize_svc/edit_response.proto @@ -0,0 +1,11 @@ +syntax = "proto3"; + +package hackathon.messages.prize_svc; + +import "hackathon/entities/prize.proto"; + +option go_package = "github.com/swissdatasciencecenter/hackagon/components/backend/internal/proto/hackathon/messages/prize_svc"; + +message EditResponse { + hackathon.entities.Prize prize = 1; +} diff --git a/api/proto/hackathon/messages/prize_svc/finalize_request.proto b/api/proto/hackathon/messages/prize_svc/finalize_request.proto new file mode 100644 index 00000000..9c80cf82 --- /dev/null +++ b/api/proto/hackathon/messages/prize_svc/finalize_request.proto @@ -0,0 +1,13 @@ +syntax = "proto3"; + +package hackathon.messages.prize_svc; + +import "buf/validate/validate.proto"; +import "hackathon/entities/prize.proto"; + +option go_package = "github.com/swissdatasciencecenter/hackagon/components/backend/internal/proto/hackathon/messages/prize_svc"; + +message FinalizeRequest { + string hackathon_id = 1 [(buf.validate.field).string.uuid = true]; + repeated hackathon.entities.Award awards = 2; +} diff --git a/api/proto/hackathon/messages/prize_svc/finalize_response.proto b/api/proto/hackathon/messages/prize_svc/finalize_response.proto new file mode 100644 index 00000000..e61ae41f --- /dev/null +++ b/api/proto/hackathon/messages/prize_svc/finalize_response.proto @@ -0,0 +1,7 @@ +syntax = "proto3"; + +package hackathon.messages.prize_svc; + +option go_package = "github.com/swissdatasciencecenter/hackagon/components/backend/internal/proto/hackathon/messages/prize_svc"; + +message FinalizeResponse {} diff --git a/api/proto/hackathon/messages/prize_svc/get_request.proto b/api/proto/hackathon/messages/prize_svc/get_request.proto new file mode 100644 index 00000000..560d999d --- /dev/null +++ b/api/proto/hackathon/messages/prize_svc/get_request.proto @@ -0,0 +1,11 @@ +syntax = "proto3"; + +package hackathon.messages.prize_svc; + +import "buf/validate/validate.proto"; + +option go_package = "github.com/swissdatasciencecenter/hackagon/components/backend/internal/proto/hackathon/messages/prize_svc"; + +message GetRequest { + string hackathon_id = 1 [(buf.validate.field).string.uuid = true]; +} diff --git a/api/proto/hackathon/messages/prize_svc/get_response.proto b/api/proto/hackathon/messages/prize_svc/get_response.proto new file mode 100644 index 00000000..6234d587 --- /dev/null +++ b/api/proto/hackathon/messages/prize_svc/get_response.proto @@ -0,0 +1,15 @@ +syntax = "proto3"; + +package hackathon.messages.prize_svc; + +import "hackathon/entities/prize.proto"; + +option go_package = "github.com/swissdatasciencecenter/hackagon/components/backend/internal/proto/hackathon/messages/prize_svc"; + +// `finalized` is the organiser's last word: votes are advisory here, and once +// the awards are finalized the table is the record of what was actually given. +message GetResponse { + repeated hackathon.entities.Prize prizes = 1; + repeated hackathon.entities.Prize awards = 2; + bool finalized = 3; +} diff --git a/api/proto/hackathon/messages/prize_svc/set_request.proto b/api/proto/hackathon/messages/prize_svc/set_request.proto new file mode 100644 index 00000000..07b32f4a --- /dev/null +++ b/api/proto/hackathon/messages/prize_svc/set_request.proto @@ -0,0 +1,13 @@ +syntax = "proto3"; + +package hackathon.messages.prize_svc; + +import "buf/validate/validate.proto"; +import "hackathon/entities/prize.proto"; + +option go_package = "github.com/swissdatasciencecenter/hackagon/components/backend/internal/proto/hackathon/messages/prize_svc"; + +message SetRequest { + string hackathon_id = 1 [(buf.validate.field).string.uuid = true]; + repeated hackathon.entities.Prize prizes = 2; +} diff --git a/api/proto/hackathon/messages/prize_svc/set_response.proto b/api/proto/hackathon/messages/prize_svc/set_response.proto new file mode 100644 index 00000000..d7914e75 --- /dev/null +++ b/api/proto/hackathon/messages/prize_svc/set_response.proto @@ -0,0 +1,11 @@ +syntax = "proto3"; + +package hackathon.messages.prize_svc; + +import "hackathon/entities/prize.proto"; + +option go_package = "github.com/swissdatasciencecenter/hackagon/components/backend/internal/proto/hackathon/messages/prize_svc"; + +message SetResponse { + repeated hackathon.entities.Prize prizes = 1; +} diff --git a/api/proto/hackathon/messages/project_svc/get_preference_request.proto b/api/proto/hackathon/messages/project_svc/get_preference_request.proto new file mode 100644 index 00000000..9b8aa61b --- /dev/null +++ b/api/proto/hackathon/messages/project_svc/get_preference_request.proto @@ -0,0 +1,11 @@ +syntax = "proto3"; + +package hackathon.messages.project_svc; + +import "buf/validate/validate.proto"; + +option go_package = "github.com/swissdatasciencecenter/hackagon/components/backend/internal/proto/hackathon/messages/project_svc"; + +message GetPreferenceRequest { + string hackathon_id = 1 [(buf.validate.field).string.uuid = true]; +} diff --git a/api/proto/hackathon/messages/project_svc/get_preference_response.proto b/api/proto/hackathon/messages/project_svc/get_preference_response.proto new file mode 100644 index 00000000..68595f11 --- /dev/null +++ b/api/proto/hackathon/messages/project_svc/get_preference_response.proto @@ -0,0 +1,9 @@ +syntax = "proto3"; + +package hackathon.messages.project_svc; + +option go_package = "github.com/swissdatasciencecenter/hackagon/components/backend/internal/proto/hackathon/messages/project_svc"; + +message GetPreferenceResponse { + repeated string project_ids = 1; +} diff --git a/api/proto/hackathon/messages/project_svc/remove_preference_request.proto b/api/proto/hackathon/messages/project_svc/remove_preference_request.proto new file mode 100644 index 00000000..661229b2 --- /dev/null +++ b/api/proto/hackathon/messages/project_svc/remove_preference_request.proto @@ -0,0 +1,18 @@ +syntax = "proto3"; + +package hackathon.messages.project_svc; + +import "buf/validate/validate.proto"; + +option go_package = "github.com/swissdatasciencecenter/hackagon/components/backend/internal/proto/hackathon/messages/project_svc"; + +// Withdraws a participant's project preference. +// +// Deliberately ORGANIZER-ONLY and deliberately takes a user_id: a +// participant's preference is final once expressed, so there is no +// self-service unset. Someone who picked in error asks an organizer, which +// keeps team formation working from a stable set of choices. +message RemovePreferenceRequest { + string project_id = 1 [(buf.validate.field).string.uuid = true]; + string user_id = 2 [(buf.validate.field).string.uuid = true]; +} diff --git a/api/proto/hackathon/messages/project_svc/remove_preference_response.proto b/api/proto/hackathon/messages/project_svc/remove_preference_response.proto new file mode 100644 index 00000000..749f4264 --- /dev/null +++ b/api/proto/hackathon/messages/project_svc/remove_preference_response.proto @@ -0,0 +1,7 @@ +syntax = "proto3"; + +package hackathon.messages.project_svc; + +option go_package = "github.com/swissdatasciencecenter/hackagon/components/backend/internal/proto/hackathon/messages/project_svc"; + +message RemovePreferenceResponse {} diff --git a/api/proto/hackathon/messages/team_svc/create_submission_request.proto b/api/proto/hackathon/messages/team_svc/create_submission_request.proto index 8ef2faf7..d465488e 100644 --- a/api/proto/hackathon/messages/team_svc/create_submission_request.proto +++ b/api/proto/hackathon/messages/team_svc/create_submission_request.proto @@ -8,4 +8,9 @@ message CreateSubmissionRequest { string team_id = 1; string project_id = 2; optional string result = 3; + // Structured answers keyed by the submission form's field keys (see + // ConfigService.SetSubmissionForm). Validated against that schema: + // required fields must be present and unknown keys are rejected, so a + // submission cannot silently miss what the organizer asked for. + map<string, string> form = 4; } diff --git a/api/proto/hackathon/messages/team_svc/edit_submission_request.proto b/api/proto/hackathon/messages/team_svc/edit_submission_request.proto new file mode 100644 index 00000000..f6599605 --- /dev/null +++ b/api/proto/hackathon/messages/team_svc/edit_submission_request.proto @@ -0,0 +1,18 @@ +syntax = "proto3"; + +package hackathon.messages.team_svc; + +import "buf/validate/validate.proto"; + +option go_package = "github.com/swissdatasciencecenter/hackagon/components/backend/internal/proto/hackathon/messages/team_svc"; + +message EditSubmissionRequest { + string submission_id = 1 [(buf.validate.field).string.uuid = true]; + optional string result = 2 [(buf.validate.field).string.max_len = 50000]; + // Structured answers, same keys and validation as CreateSubmission. Without + // this the form was frozen at create time: a team that mistyped a repo URL + // could edit the free text around it but never the field the organizer + // actually asked for. Absent (not merely empty) leaves the stored answers + // untouched, so an edit of `result` alone cannot wipe them. + map<string, string> form = 3; +} diff --git a/api/proto/hackathon/messages/team_svc/edit_submission_response.proto b/api/proto/hackathon/messages/team_svc/edit_submission_response.proto new file mode 100644 index 00000000..c753e1e9 --- /dev/null +++ b/api/proto/hackathon/messages/team_svc/edit_submission_response.proto @@ -0,0 +1,11 @@ +syntax = "proto3"; + +package hackathon.messages.team_svc; + +import "hackathon/entities/submission.proto"; + +option go_package = "github.com/swissdatasciencecenter/hackagon/components/backend/internal/proto/hackathon/messages/team_svc"; + +message EditSubmissionResponse { + hackathon.entities.Submission submission = 1; +} diff --git a/api/proto/hackathon/prize_service.proto b/api/proto/hackathon/prize_service.proto new file mode 100644 index 00000000..f0db7538 --- /dev/null +++ b/api/proto/hackathon/prize_service.proto @@ -0,0 +1,26 @@ +syntax = "proto3"; + +package hackathon; + +import "hackathon/messages/prize_svc/edit_request.proto"; +import "hackathon/messages/prize_svc/edit_response.proto"; +import "hackathon/messages/prize_svc/finalize_request.proto"; +import "hackathon/messages/prize_svc/finalize_response.proto"; +import "hackathon/messages/prize_svc/get_request.proto"; +import "hackathon/messages/prize_svc/get_response.proto"; +import "hackathon/messages/prize_svc/set_request.proto"; +import "hackathon/messages/prize_svc/set_response.proto"; + +option go_package = "github.com/swissdatasciencecenter/hackagon/components/backend/internal/proto/hackathon"; + +// The prize table and the awards. Votes are advisory: nothing is won until +// the admin finalizes, and the table stays admin-editable afterwards. +service PrizeService { + // Reads the table back. Set REPLACES it, so without this an organiser + // editing one prize would blank the rest they cannot see — the same trap + // GetWindows was added for. + rpc Get(hackathon.messages.prize_svc.GetRequest) returns (hackathon.messages.prize_svc.GetResponse); + rpc Set(hackathon.messages.prize_svc.SetRequest) returns (hackathon.messages.prize_svc.SetResponse); + rpc Finalize(hackathon.messages.prize_svc.FinalizeRequest) returns (hackathon.messages.prize_svc.FinalizeResponse); + rpc Edit(hackathon.messages.prize_svc.EditRequest) returns (hackathon.messages.prize_svc.EditResponse); +} diff --git a/api/proto/hackathon/project_service.proto b/api/proto/hackathon/project_service.proto index d3079653..9cf25825 100644 --- a/api/proto/hackathon/project_service.proto +++ b/api/proto/hackathon/project_service.proto @@ -12,12 +12,16 @@ import "hackathon/messages/project_svc/edit_request.proto"; import "hackathon/messages/project_svc/edit_response.proto"; import "hackathon/messages/project_svc/export_preferences_request.proto"; import "hackathon/messages/project_svc/export_preferences_response.proto"; +import "hackathon/messages/project_svc/get_preference_request.proto"; +import "hackathon/messages/project_svc/get_preference_response.proto"; import "hackathon/messages/project_svc/get_request.proto"; import "hackathon/messages/project_svc/get_response.proto"; import "hackathon/messages/project_svc/list_request.proto"; import "hackathon/messages/project_svc/list_response.proto"; import "hackathon/messages/project_svc/propose_request.proto"; import "hackathon/messages/project_svc/propose_response.proto"; +import "hackathon/messages/project_svc/remove_preference_request.proto"; +import "hackathon/messages/project_svc/remove_preference_response.proto"; import "hackathon/messages/project_svc/set_preference_request.proto"; import "hackathon/messages/project_svc/set_preference_response.proto"; @@ -30,7 +34,11 @@ service ProjectService { rpc Approve(hackathon.messages.project_svc.ApproveRequest) returns (hackathon.messages.project_svc.ApproveResponse); rpc Disapprove(hackathon.messages.project_svc.DisapproveRequest) returns (hackathon.messages.project_svc.DisapproveResponse); rpc SetPreference(hackathon.messages.project_svc.SetPreferenceRequest) returns (hackathon.messages.project_svc.SetPreferenceResponse); + // The caller's OWN preferences. ExportPreferences is organiser-only, so + // until now a participant had no way to see what they had chosen. + rpc GetPreference(hackathon.messages.project_svc.GetPreferenceRequest) returns (hackathon.messages.project_svc.GetPreferenceResponse); rpc ExportPreferences(hackathon.messages.project_svc.ExportPreferencesRequest) returns (hackathon.messages.project_svc.ExportPreferencesResponse); rpc Edit(hackathon.messages.project_svc.EditRequest) returns (hackathon.messages.project_svc.EditResponse); rpc Delete(hackathon.messages.project_svc.DeleteRequest) returns (hackathon.messages.project_svc.DeleteResponse); + rpc RemovePreference(hackathon.messages.project_svc.RemovePreferenceRequest) returns (hackathon.messages.project_svc.RemovePreferenceResponse); } diff --git a/api/proto/hackathon/team_service.proto b/api/proto/hackathon/team_service.proto index e6bc9ea2..8016bbe6 100644 --- a/api/proto/hackathon/team_service.proto +++ b/api/proto/hackathon/team_service.proto @@ -12,6 +12,8 @@ import "hackathon/messages/team_svc/delete_request.proto"; import "hackathon/messages/team_svc/delete_response.proto"; import "hackathon/messages/team_svc/edit_request.proto"; import "hackathon/messages/team_svc/edit_response.proto"; +import "hackathon/messages/team_svc/edit_submission_request.proto"; +import "hackathon/messages/team_svc/edit_submission_response.proto"; import "hackathon/messages/team_svc/finalize_submission_request.proto"; import "hackathon/messages/team_svc/finalize_submission_response.proto"; import "hackathon/messages/team_svc/get_request.proto"; @@ -38,5 +40,6 @@ service TeamService { rpc CreateSubmission(hackathon.messages.team_svc.CreateSubmissionRequest) returns (hackathon.messages.team_svc.CreateSubmissionResponse); rpc GetSubmission(hackathon.messages.team_svc.GetSubmissionRequest) returns (hackathon.messages.team_svc.GetSubmissionResponse); rpc ListSubmissions(hackathon.messages.team_svc.ListSubmissionsRequest) returns (hackathon.messages.team_svc.ListSubmissionsResponse); + rpc EditSubmission(hackathon.messages.team_svc.EditSubmissionRequest) returns (hackathon.messages.team_svc.EditSubmissionResponse); rpc FinalizeSubmission(hackathon.messages.team_svc.FinalizeSubmissionRequest) returns (hackathon.messages.team_svc.FinalizeSubmissionResponse); } diff --git a/api/proto/site/entities/site_page.proto b/api/proto/site/entities/site_page.proto new file mode 100644 index 00000000..26080f62 --- /dev/null +++ b/api/proto/site/entities/site_page.proto @@ -0,0 +1,25 @@ +syntax = "proto3"; + +package site.entities; + +import "google/protobuf/timestamp.proto"; + +option go_package = "github.com/swissdatasciencecenter/hackagon/components/backend/internal/proto/site/entities"; + +// A platform-level content page (about, privacy, terms), addressed by slug. +// Unlike hackathon.entities.Page it belongs to the site itself, so it carries +// no hackathon id and is authorized in the fixed "site" casbin domain. +message SitePage { + string id = 1; + string slug = 2; + string title = 3; + // Markdown. Rendered through the frontend's sanitizing pipeline — never + // inject it into the DOM raw. + string content = 4; + bool visible = 5; + int32 order = 6; + google.protobuf.Timestamp created_at = 7; + google.protobuf.Timestamp modified_at = 8; + string creator_id = 9; + string modifier_id = 10; +} diff --git a/api/proto/site/messages/site_page_svc/create_request.proto b/api/proto/site/messages/site_page_svc/create_request.proto new file mode 100644 index 00000000..0379fdb1 --- /dev/null +++ b/api/proto/site/messages/site_page_svc/create_request.proto @@ -0,0 +1,15 @@ +syntax = "proto3"; + +package site.messages.site_page_svc; + +import "buf/validate/validate.proto"; + +option go_package = "github.com/swissdatasciencecenter/hackagon/components/backend/internal/proto/site/messages/site_page_svc"; + +message CreateRequest { + string slug = 1 [(buf.validate.field).string.pattern = "^[a-z0-9]+(-[a-z0-9]+)*$"]; + string title = 2 [(buf.validate.field).string.min_len = 1]; + string content = 3; + optional bool visible = 4; + optional int32 order = 5; +} diff --git a/api/proto/site/messages/site_page_svc/create_response.proto b/api/proto/site/messages/site_page_svc/create_response.proto new file mode 100644 index 00000000..0dbb9715 --- /dev/null +++ b/api/proto/site/messages/site_page_svc/create_response.proto @@ -0,0 +1,9 @@ +syntax = "proto3"; + +package site.messages.site_page_svc; + +option go_package = "github.com/swissdatasciencecenter/hackagon/components/backend/internal/proto/site/messages/site_page_svc"; + +message CreateResponse { + string id = 1; +} diff --git a/api/proto/site/messages/site_page_svc/delete_request.proto b/api/proto/site/messages/site_page_svc/delete_request.proto new file mode 100644 index 00000000..a8cef59f --- /dev/null +++ b/api/proto/site/messages/site_page_svc/delete_request.proto @@ -0,0 +1,11 @@ +syntax = "proto3"; + +package site.messages.site_page_svc; + +import "buf/validate/validate.proto"; + +option go_package = "github.com/swissdatasciencecenter/hackagon/components/backend/internal/proto/site/messages/site_page_svc"; + +message DeleteRequest { + string slug = 1 [(buf.validate.field).string.min_len = 1]; +} diff --git a/api/proto/site/messages/site_page_svc/delete_response.proto b/api/proto/site/messages/site_page_svc/delete_response.proto new file mode 100644 index 00000000..31b4f6d7 --- /dev/null +++ b/api/proto/site/messages/site_page_svc/delete_response.proto @@ -0,0 +1,7 @@ +syntax = "proto3"; + +package site.messages.site_page_svc; + +option go_package = "github.com/swissdatasciencecenter/hackagon/components/backend/internal/proto/site/messages/site_page_svc"; + +message DeleteResponse {} diff --git a/api/proto/site/messages/site_page_svc/edit_request.proto b/api/proto/site/messages/site_page_svc/edit_request.proto new file mode 100644 index 00000000..e822b6ae --- /dev/null +++ b/api/proto/site/messages/site_page_svc/edit_request.proto @@ -0,0 +1,18 @@ +syntax = "proto3"; + +package site.messages.site_page_svc; + +import "buf/validate/validate.proto"; + +option go_package = "github.com/swissdatasciencecenter/hackagon/components/backend/internal/proto/site/messages/site_page_svc"; + +// Every field optional: absent means "leave unchanged" (see the write-path +// convention in CLAUDE.md). The page is identified by its current slug. +message EditRequest { + string slug = 1 [(buf.validate.field).string.min_len = 1]; + optional string new_slug = 2; + optional string title = 3; + optional string content = 4; + optional bool visible = 5; + optional int32 order = 6; +} diff --git a/api/proto/site/messages/site_page_svc/edit_response.proto b/api/proto/site/messages/site_page_svc/edit_response.proto new file mode 100644 index 00000000..74ebf617 --- /dev/null +++ b/api/proto/site/messages/site_page_svc/edit_response.proto @@ -0,0 +1,11 @@ +syntax = "proto3"; + +package site.messages.site_page_svc; + +import "site/entities/site_page.proto"; + +option go_package = "github.com/swissdatasciencecenter/hackagon/components/backend/internal/proto/site/messages/site_page_svc"; + +message EditResponse { + site.entities.SitePage site_page = 1; +} diff --git a/api/proto/site/messages/site_page_svc/get_request.proto b/api/proto/site/messages/site_page_svc/get_request.proto new file mode 100644 index 00000000..a1062138 --- /dev/null +++ b/api/proto/site/messages/site_page_svc/get_request.proto @@ -0,0 +1,12 @@ +syntax = "proto3"; + +package site.messages.site_page_svc; + +import "buf/validate/validate.proto"; + +option go_package = "github.com/swissdatasciencecenter/hackagon/components/backend/internal/proto/site/messages/site_page_svc"; + +// Pages are fetched by their stable slug — that is what the router has. +message GetRequest { + string slug = 1 [(buf.validate.field).string.min_len = 1]; +} diff --git a/api/proto/site/messages/site_page_svc/get_response.proto b/api/proto/site/messages/site_page_svc/get_response.proto new file mode 100644 index 00000000..6efb5601 --- /dev/null +++ b/api/proto/site/messages/site_page_svc/get_response.proto @@ -0,0 +1,11 @@ +syntax = "proto3"; + +package site.messages.site_page_svc; + +import "site/entities/site_page.proto"; + +option go_package = "github.com/swissdatasciencecenter/hackagon/components/backend/internal/proto/site/messages/site_page_svc"; + +message GetResponse { + site.entities.SitePage site_page = 1; +} diff --git a/api/proto/site/messages/site_page_svc/list_request.proto b/api/proto/site/messages/site_page_svc/list_request.proto new file mode 100644 index 00000000..27b07f73 --- /dev/null +++ b/api/proto/site/messages/site_page_svc/list_request.proto @@ -0,0 +1,10 @@ +syntax = "proto3"; + +package site.messages.site_page_svc; + +option go_package = "github.com/swissdatasciencecenter/hackagon/components/backend/internal/proto/site/messages/site_page_svc"; + +message ListRequest { + // When true, drafts are included too (admins only; ignored for others). + optional bool include_hidden = 1; +} diff --git a/api/proto/site/messages/site_page_svc/list_response.proto b/api/proto/site/messages/site_page_svc/list_response.proto new file mode 100644 index 00000000..68d0ff20 --- /dev/null +++ b/api/proto/site/messages/site_page_svc/list_response.proto @@ -0,0 +1,11 @@ +syntax = "proto3"; + +package site.messages.site_page_svc; + +import "site/entities/site_page.proto"; + +option go_package = "github.com/swissdatasciencecenter/hackagon/components/backend/internal/proto/site/messages/site_page_svc"; + +message ListResponse { + repeated site.entities.SitePage site_pages = 1; +} diff --git a/api/proto/site/site_page_service.proto b/api/proto/site/site_page_service.proto new file mode 100644 index 00000000..94a81440 --- /dev/null +++ b/api/proto/site/site_page_service.proto @@ -0,0 +1,28 @@ +syntax = "proto3"; + +package site; + +import "site/messages/site_page_svc/create_request.proto"; +import "site/messages/site_page_svc/create_response.proto"; +import "site/messages/site_page_svc/delete_request.proto"; +import "site/messages/site_page_svc/delete_response.proto"; +import "site/messages/site_page_svc/edit_request.proto"; +import "site/messages/site_page_svc/edit_response.proto"; +import "site/messages/site_page_svc/get_request.proto"; +import "site/messages/site_page_svc/get_response.proto"; +import "site/messages/site_page_svc/list_request.proto"; +import "site/messages/site_page_svc/list_response.proto"; + +option go_package = "github.com/swissdatasciencecenter/hackagon/components/backend/internal/proto/site"; + +// Platform-level content pages (about, privacy, terms). Reads are open to +// anonymous callers for published pages — these are the pages a visitor +// reaches from the footer before ever logging in. Writes require the global +// Admin role: there is no per-hackathon owner for site-wide content. +service SitePageService { + rpc List(site.messages.site_page_svc.ListRequest) returns (site.messages.site_page_svc.ListResponse); + rpc Get(site.messages.site_page_svc.GetRequest) returns (site.messages.site_page_svc.GetResponse); + rpc Create(site.messages.site_page_svc.CreateRequest) returns (site.messages.site_page_svc.CreateResponse); + rpc Edit(site.messages.site_page_svc.EditRequest) returns (site.messages.site_page_svc.EditResponse); + rpc Delete(site.messages.site_page_svc.DeleteRequest) returns (site.messages.site_page_svc.DeleteResponse); +} diff --git a/api/proto/storage/entities/object_scope.proto b/api/proto/storage/entities/object_scope.proto new file mode 100644 index 00000000..57c6b697 --- /dev/null +++ b/api/proto/storage/entities/object_scope.proto @@ -0,0 +1,53 @@ +syntax = "proto3"; + +package storage.entities; + +option go_package = "github.com/swissdatasciencecenter/hackagon/components/backend/internal/proto/storage/entities"; + +// Which slice of the object store a listing covers. +// +// The scope is the ONLY placement input a client has, exactly as `UploadKind` +// is for writes: the backend derives the key prefixes and the authorization +// rule from it, and a client-supplied prefix is never trusted. There is no +// "give me the bucket" scope, and adding one would be a mistake — see below. +// +// HACKATHON_MEDIA hackathons/<owner_id>/ everything one event has uploaded, +// logos and page imagery alike +// SITE_MEDIA site/media/ the platform pages' imagery +// ALL_MEDIA hackathons/ + site/media/ every listable prefix at once +// +// **The rule, stated once: you may LIST a prefix exactly when you may WRITE to +// it.** Each scope's check is the same check `authorizeUpload` makes for the +// kind that files objects there — hackathon `write` for the two hackathon +// kinds, the global Admin role for SITE_MEDIA. That is deliberate and is the +// same construction docs/storage.md uses for uploads: the answer to "may I see +// what is in here?" is the same answer as "may I put something in here?" by +// construction, rather than by two rules that have to be kept in agreement. +// +// **Two prefixes are absent on purpose, and they are the reason this is an enum +// rather than a prefix string.** +// +// users/<id>/avatar/ — other people's faces. Nothing in the product needs to +// enumerate them: an avatar is set from the profile that owns it, and a +// gallery exists to pick a picture to REUSE somewhere else, which is +// precisely what must not be easy to do with someone's photograph. A global +// admin fixing one profile still reaches it from that profile. +// +// teams/<id>/submissions/ — private by bucket policy. Those objects have no +// stable readable path at all, so a picker row for one would be a broken +// image; and the KEYS alone would say which teams turned work in and how +// much of it, to anyone allowed to list any scope. +enum ObjectScope { + OBJECT_SCOPE_UNSPECIFIED = 0; + // hackathons/<owner_id>/ — `owner_id` is the hackathon. Requires + // hackathon `write`, the permission that uploads a logo or page image. + OBJECT_SCOPE_HACKATHON_MEDIA = 1; + // site/media/ — reads no owner id, for the same reason + // UPLOAD_KIND_SITE_MEDIA does not: platform pages belong to no event and no + // person. Requires the global Admin role. + OBJECT_SCOPE_SITE_MEDIA = 2; + // Every listable prefix in one answer, for the platform's media library. + // Requires the global Admin role — it spans events the caller may have no + // part in, so it takes the only role that is allowed to. + OBJECT_SCOPE_ALL_MEDIA = 3; +} diff --git a/api/proto/storage/entities/stored_object.proto b/api/proto/storage/entities/stored_object.proto new file mode 100644 index 00000000..4f91459a --- /dev/null +++ b/api/proto/storage/entities/stored_object.proto @@ -0,0 +1,28 @@ +syntax = "proto3"; + +package storage.entities; + +import "google/protobuf/timestamp.proto"; + +option go_package = "github.com/swissdatasciencecenter/hackagon/components/backend/internal/proto/storage/entities"; + +// One object already in the store, as a listing reports it. +// +// Deliberately NOT a presigned URL. Every scope a listing can cover is +// public-read by bucket policy, so `url` is the same stable, root-relative path +// `CreateUploadUrl` handed back when the object was written — the value that +// belongs in the database. Signing these would hand out a wall of bearer +// credentials for objects that need none, and they would start expiring while +// the gallery was still on screen. +message StoredObject { + // The stable object key, e.g. `site/media/<uuid>.webp`. + string key = 1; + // Root-relative path the object is readable at: `/objects/<bucket>/<key>`. + // This is what a picker hands back to a form and what a form stores. + string url = 2; + int64 size_bytes = 3; + // When the object was last written. This is what a listing is ordered by — + // newest first — because "the one I just uploaded" is what a person is + // looking for. Zero when the store did not report a parseable timestamp. + google.protobuf.Timestamp last_modified = 4; +} diff --git a/api/proto/storage/entities/upload_kind.proto b/api/proto/storage/entities/upload_kind.proto new file mode 100644 index 00000000..1bcff7c6 --- /dev/null +++ b/api/proto/storage/entities/upload_kind.proto @@ -0,0 +1,35 @@ +syntax = "proto3"; + +package storage.entities; + +option go_package = "github.com/swissdatasciencecenter/hackagon/components/backend/internal/proto/storage/entities"; + +// What is being uploaded. The kind is the ONLY thing the client gets to choose +// about placement: the backend derives the key prefix, the content-type +// allowlist, the size ceiling and the authorization rule from it (see +// docs/storage.md, "Keys, not URLs"). A client-supplied path is never trusted. +// +// HACKATHON_LOGO hackathons/<hackathon-id>/logo/<uuid>.<ext> public +// HACKATHON_MEDIA hackathons/<hackathon-id>/media/<uuid>.<ext> public +// USER_AVATAR users/<user-id>/avatar/<uuid>.<ext> public +// SUBMISSION_ATTACHMENT teams/<team-id>/submissions/<submission-id>/<uuid>.<ext> private +// SITE_MEDIA site/media/<uuid>.<ext> public +// +// `owner_id` is read against the kind: the hackathon id for the two hackathon +// kinds, the platform user id for an avatar, and the SUBMISSION id for an +// attachment — the team half of that key is looked up server-side, so a caller +// cannot file an attachment under someone else's team. +// +// SITE_MEDIA reads NO owner id, and is the only kind that does not: a platform +// page (about, privacy, terms) belongs to no event and no person, so there is +// nothing to name and nothing to scope a casbin domain to. It authorizes on the +// global Admin role instead — the same rule every SitePageService mutation +// uses — and its key carries no owner segment at all. +enum UploadKind { + UPLOAD_KIND_UNSPECIFIED = 0; + UPLOAD_KIND_HACKATHON_LOGO = 1; + UPLOAD_KIND_HACKATHON_MEDIA = 2; + UPLOAD_KIND_USER_AVATAR = 3; + UPLOAD_KIND_SUBMISSION_ATTACHMENT = 4; + UPLOAD_KIND_SITE_MEDIA = 5; +} diff --git a/api/proto/storage/messages/storage_svc/create_download_url_request.proto b/api/proto/storage/messages/storage_svc/create_download_url_request.proto new file mode 100644 index 00000000..c2ae6c77 --- /dev/null +++ b/api/proto/storage/messages/storage_svc/create_download_url_request.proto @@ -0,0 +1,22 @@ +syntax = "proto3"; + +package storage.messages.storage_svc; + +import "buf/validate/validate.proto"; + +option go_package = "github.com/swissdatasciencecenter/hackagon/components/backend/internal/proto/storage/messages/storage_svc"; + +// Mint a short-lived read URL for a PRIVATE object. +// +// Public imagery does not come through here and is rejected on purpose: those +// prefixes are world-readable by bucket policy, so their stored path already +// works and signing one would hand out a bearer credential for nothing. +message CreateDownloadUrlRequest { + // An object key as returned by CreateUploadUrl — not a URL, and not a path + // with a bucket in it. The key's own shape says which entity owns it, and + // that is what is authorized. + string key = 1 [(buf.validate.field).string = { + min_len: 1 + max_len: 1024 + }]; +} diff --git a/api/proto/storage/messages/storage_svc/create_download_url_response.proto b/api/proto/storage/messages/storage_svc/create_download_url_response.proto new file mode 100644 index 00000000..3da8ec9b --- /dev/null +++ b/api/proto/storage/messages/storage_svc/create_download_url_response.proto @@ -0,0 +1,15 @@ +syntax = "proto3"; + +package storage.messages.storage_svc; + +import "google/protobuf/timestamp.proto"; + +option go_package = "github.com/swissdatasciencecenter/hackagon/components/backend/internal/proto/storage/messages/storage_svc"; + +message CreateDownloadUrlResponse { + // Root-relative and same-origin, like upload_url. Treat it as a bearer + // credential: anything holding it can read the object until it lapses, which + // is exactly why it is never written to the database. + string download_url = 1; + google.protobuf.Timestamp expires_at = 2; +} diff --git a/api/proto/storage/messages/storage_svc/create_upload_url_request.proto b/api/proto/storage/messages/storage_svc/create_upload_url_request.proto new file mode 100644 index 00000000..1de42c82 --- /dev/null +++ b/api/proto/storage/messages/storage_svc/create_upload_url_request.proto @@ -0,0 +1,50 @@ +syntax = "proto3"; + +package storage.messages.storage_svc; + +import "buf/validate/validate.proto"; +import "storage/entities/upload_kind.proto"; + +option go_package = "github.com/swissdatasciencecenter/hackagon/components/backend/internal/proto/storage/messages/storage_svc"; + +// Ask for permission to upload one object. Nothing here names a path: the key +// is the server's to decide, so the worst a hostile client can do is ask for a +// kind it may not write, which casbin refuses. +// +// `content_type` and `size_bytes` are DECLARED here and then baked into the +// signature as conditions, which is what lets an oversized or wrong-typed +// upload be refused before a single byte moves. Declaring them falsely does not +// help: the object store recomputes the signature over the headers the browser +// actually sent, so a mismatch fails at the store. +message CreateUploadUrlRequest { + storage.entities.UploadKind kind = 1 [ + (buf.validate.field).enum.defined_only = true, + (buf.validate.field).enum.not_in = 0 + ]; + // The owning entity, read according to `kind` — see UploadKind. + // + // A uuid WHEN PRESENT, and empty is allowed, because whether an owner exists + // at all depends on the kind: SITE_MEDIA files a platform page's imagery, + // and a platform page belongs to no event and no person. Expressing + // "required unless kind == SITE_MEDIA" here would need a message-level CEL + // rule restating the kind table, so the per-kind requirement stays in + // authorizeUpload, which is where the rest of that table already lives — the + // kinds that DO name an owner still answer InvalidArgument for an empty one. + string owner_id = 2 [ + (buf.validate.field).string.uuid = true, + (buf.validate.field).ignore = IGNORE_IF_ZERO_VALUE + ]; + // The user's own filename. Used only to cross-check the declared + // content_type; the stored key gets a fresh uuid and an extension derived + // from the content type, so nothing a user typed reaches the object store. + string filename = 3 [(buf.validate.field).string = { + min_len: 1 + max_len: 255 + }]; + string content_type = 4 [(buf.validate.field).string = { + min_len: 1 + max_len: 255 + }]; + // Exact byte length of the file about to be uploaded, not an estimate. + int64 size_bytes = 5 [(buf.validate.field).int64.gt = 0]; +} diff --git a/api/proto/storage/messages/storage_svc/create_upload_url_response.proto b/api/proto/storage/messages/storage_svc/create_upload_url_response.proto new file mode 100644 index 00000000..bc218c9b --- /dev/null +++ b/api/proto/storage/messages/storage_svc/create_upload_url_response.proto @@ -0,0 +1,28 @@ +syntax = "proto3"; + +package storage.messages.storage_svc; + +import "google/protobuf/timestamp.proto"; + +option go_package = "github.com/swissdatasciencecenter/hackagon/components/backend/internal/proto/storage/messages/storage_svc"; + +message CreateUploadUrlResponse { + // Where the BROWSER PUTs the bytes — root-relative and same-origin + // (/objects/<bucket>/<key>?X-Amz-...), so the file never passes through the + // app server and no CORS grant is needed. The request must carry exactly the + // Content-Type and byte count that were declared, because both are signed. + string upload_url = 1; + // The stable object key. This is what identifies the object forever; the + // signed URL above stops working in minutes. + string key = 2; + // Root-relative path the object will be readable at once uploaded, for the + // public kinds. EMPTY for private kinds (submission attachments) — those are + // read through CreateDownloadUrl, after casbin has approved the read. + // + // This is the value that goes into Hackathon.logo / User.avatar_url: it never + // expires and it resolves from localhost, the tunnel and a deployment alike. + string public_url = 3; + // When the signature above stops being accepted. Purely informational — the + // upload either starts in time or it does not. + google.protobuf.Timestamp expires_at = 4; +} diff --git a/api/proto/storage/messages/storage_svc/list_objects_request.proto b/api/proto/storage/messages/storage_svc/list_objects_request.proto new file mode 100644 index 00000000..5a1d9603 --- /dev/null +++ b/api/proto/storage/messages/storage_svc/list_objects_request.proto @@ -0,0 +1,48 @@ +syntax = "proto3"; + +package storage.messages.storage_svc; + +import "buf/validate/validate.proto"; +import "storage/entities/object_scope.proto"; + +option go_package = "github.com/swissdatasciencecenter/hackagon/components/backend/internal/proto/storage/messages/storage_svc"; + +// List what has already been uploaded into one scope. +// +// Nothing here names a prefix: the scope is the whole placement input, and the +// backend derives both the prefixes and the authorization rule from it — see +// ObjectScope, which also records the two prefixes no scope covers. +// +// The answer is bounded whatever is asked for. The store is scanned up to a +// fixed ceiling, the results are ordered newest-first, and `truncated` on the +// response says when the ceiling was reached — a bucket grows without limit and +// a listing that did not bound itself would eventually be the slowest request in +// the application. +message ListObjectsRequest { + storage.entities.ObjectScope scope = 1 [ + (buf.validate.field).enum.defined_only = true, + (buf.validate.field).enum.not_in = 0 + ]; + // The owning entity, read according to `scope` — the hackathon id for + // HACKATHON_MEDIA, and nothing at all for the other two. + // + // A uuid WHEN PRESENT, and empty is allowed, for the same reason + // CreateUploadUrlRequest allows it: whether an owner exists depends on the + // scope, and expressing "required unless scope is …" here would restate the + // scope table in CEL. The scope that DOES name an owner answers + // InvalidArgument for an empty one. + string owner_id = 2 [ + (buf.validate.field).string.uuid = true, + (buf.validate.field).ignore = IGNORE_IF_ZERO_VALUE + ]; + // How many objects to return. 0 means the server's default (60); the ceiling + // is 200 and a larger request is clamped rather than refused, because the + // number is a rendering preference, not a permission. + int32 page_size = 3 [(buf.validate.field).int32 = { + gte: 0 + lte: 1000 + }]; + // Opaque cursor from a previous response's `next_page_token`. Empty starts at + // the newest object. + string page_token = 4 [(buf.validate.field).string.max_len = 64]; +} diff --git a/api/proto/storage/messages/storage_svc/list_objects_response.proto b/api/proto/storage/messages/storage_svc/list_objects_response.proto new file mode 100644 index 00000000..45410d83 --- /dev/null +++ b/api/proto/storage/messages/storage_svc/list_objects_response.proto @@ -0,0 +1,22 @@ +syntax = "proto3"; + +package storage.messages.storage_svc; + +import "storage/entities/stored_object.proto"; + +option go_package = "github.com/swissdatasciencecenter/hackagon/components/backend/internal/proto/storage/messages/storage_svc"; + +message ListObjectsResponse { + // Newest first. Only objects whose extension is on the image allowlist are + // returned: every listable prefix is an imagery prefix, so a stray `.txt` + // left by a bootstrap probe is noise in a picture gallery and nothing else. + repeated storage.entities.StoredObject objects = 1; + // Pass back as `page_token` for the next page. Empty when this is the last + // page of what was scanned. + string next_page_token = 2; + // True when the scan hit its ceiling, so these objects are a window over an + // unknown remainder rather than everything in the scope. Say so on screen: + // an exhaustive-looking gallery that silently stops is how someone concludes + // their upload failed. + bool truncated = 3; +} diff --git a/api/proto/storage/storage_service.proto b/api/proto/storage/storage_service.proto new file mode 100644 index 00000000..84fd7415 --- /dev/null +++ b/api/proto/storage/storage_service.proto @@ -0,0 +1,37 @@ +syntax = "proto3"; + +package storage; + +import "storage/messages/storage_svc/create_download_url_request.proto"; +import "storage/messages/storage_svc/create_download_url_response.proto"; +import "storage/messages/storage_svc/create_upload_url_request.proto"; +import "storage/messages/storage_svc/create_upload_url_response.proto"; +import "storage/messages/storage_svc/list_objects_request.proto"; +import "storage/messages/storage_svc/list_objects_response.proto"; + +option go_package = "github.com/swissdatasciencecenter/hackagon/components/backend/internal/proto/storage"; + +// Signed access to the object store (docs/storage.md). +// +// Files do not travel through this service — only permission to move them +// does. The backend authorizes the caller, decides the key, pins the +// content-type and the byte count into the signature, and hands back a URL the +// browser uses directly. +// +// There is deliberately no Delete RPC. Objects are removed by prefix when their +// OWNER is deleted (HackathonService.Delete, UserService.DeleteAccount), which +// is what keeps deletion complete without a manifest of what belongs to whom. +// +// A single-object delete would need that manifest to be safe: an image can be +// referenced from any page's markdown, any event's logo column and any prize +// row, and nothing records which. Removing one would break those references +// silently — the row keeps its path and the page renders a hole — so the only +// deletion that exists is the one whose scope is an entity nobody is pointing +// at any more. +service StorageService { + rpc CreateUploadUrl(storage.messages.storage_svc.CreateUploadUrlRequest) returns (storage.messages.storage_svc.CreateUploadUrlResponse); + rpc CreateDownloadUrl(storage.messages.storage_svc.CreateDownloadUrlRequest) returns (storage.messages.storage_svc.CreateDownloadUrlResponse); + // What is already in the store, so a picker can offer it instead of a second + // copy of the same picture. Reads only; see ObjectScope for who may list what. + rpc ListObjects(storage.messages.storage_svc.ListObjectsRequest) returns (storage.messages.storage_svc.ListObjectsResponse); +} diff --git a/api/proto/user/entities/user.proto b/api/proto/user/entities/user.proto index bfd08072..53c88b22 100644 --- a/api/proto/user/entities/user.proto +++ b/api/proto/user/entities/user.proto @@ -17,4 +17,16 @@ message User { // Populated from casbin on fetch; not persisted in ent DB. repeated GlobalRole roles = 7; google.protobuf.Timestamp modified_at = 8; + // The platform's own profile, filled in by the person it describes. + // + // Mirrors the keys registration forms ask for, so an event can prefill rather + // than ask the same four questions every time. Consents are NOT here: a + // consent is an agreement with one event, given on a date, and it lives in + // that event's registration record. + // + // Empty string means "not filled in", which is a normal state. + string affiliation = 9; + string skills = 10; + string dietary = 11; + string avatar_url = 12; } diff --git a/api/proto/user/messages/user_svc/delete_account_request.proto b/api/proto/user/messages/user_svc/delete_account_request.proto new file mode 100644 index 00000000..5664f0a9 --- /dev/null +++ b/api/proto/user/messages/user_svc/delete_account_request.proto @@ -0,0 +1,9 @@ +syntax = "proto3"; + +package user.messages.user_svc; + +option go_package = "github.com/swissdatasciencecenter/hackagon/components/backend/internal/proto/user/messages/user_svc"; + +// Deletes the CALLER's own platform profile. Deliberately takes no user id: +// this is a self-service GDPR action, not an admin tool for removing people. +message DeleteAccountRequest {} diff --git a/api/proto/user/messages/user_svc/delete_account_response.proto b/api/proto/user/messages/user_svc/delete_account_response.proto new file mode 100644 index 00000000..ff3aa0fd --- /dev/null +++ b/api/proto/user/messages/user_svc/delete_account_response.proto @@ -0,0 +1,7 @@ +syntax = "proto3"; + +package user.messages.user_svc; + +option go_package = "github.com/swissdatasciencecenter/hackagon/components/backend/internal/proto/user/messages/user_svc"; + +message DeleteAccountResponse {} diff --git a/api/proto/user/messages/user_svc/edit_profile_request.proto b/api/proto/user/messages/user_svc/edit_profile_request.proto new file mode 100644 index 00000000..80ac95eb --- /dev/null +++ b/api/proto/user/messages/user_svc/edit_profile_request.proto @@ -0,0 +1,37 @@ +syntax = "proto3"; + +package user.messages.user_svc; + +import "buf/validate/validate.proto"; + +option go_package = "github.com/swissdatasciencecenter/hackagon/components/backend/internal/proto/user/messages/user_svc"; + +// Edits the caller's OWN profile — there is no user id, so this RPC can never +// touch anyone else's. +// +// Only the fields the platform owns are here. `username` and `email` come from +// Keycloak on every token and are re-synced by WhoAmI, so accepting them would +// be a lie: the next page load would overwrite whatever was stored. Those are +// changed in Keycloak's own account console. +// +// Every field is optional; absent means "leave unchanged". +message EditProfileRequest { + optional string display_name = 1 [(buf.validate.field).string = { + min_len: 1 + max_len: 100 + }]; + // The rest of the profile. Unlike display_name these MAY be cleared, so the + // floor is 0: emptying "dietary requirements" is a legitimate edit, and a + // min_len of 1 would make the field impossible to unset. + optional string affiliation = 2 [(buf.validate.field).string.max_len = 200]; + optional string skills = 3 [(buf.validate.field).string.max_len = 500]; + optional string dietary = 4 [(buf.validate.field).string.max_len = 300]; + // A link, not an upload — there is no object store yet. + // + // Length only, deliberately: `uri: true` would reject the empty string, and + // clearing your picture is a legitimate edit. The handler checks the SCHEME + // instead (http/https only), which is the part that matters — a `javascript:` + // or `data:` value must never reach an <img src>. Same reasoning the + // hackathon logo field follows. + optional string avatar_url = 5 [(buf.validate.field).string.max_len = 500]; +} diff --git a/api/proto/user/messages/user_svc/edit_profile_response.proto b/api/proto/user/messages/user_svc/edit_profile_response.proto new file mode 100644 index 00000000..306b61a2 --- /dev/null +++ b/api/proto/user/messages/user_svc/edit_profile_response.proto @@ -0,0 +1,12 @@ +syntax = "proto3"; + +package user.messages.user_svc; + +import "user/entities/user.proto"; + +option go_package = "github.com/swissdatasciencecenter/hackagon/components/backend/internal/proto/user/messages/user_svc"; + +// Edit returns the updated entity (write-path convention). +message EditProfileResponse { + user.entities.User user = 1; +} diff --git a/api/proto/user/user_service.proto b/api/proto/user/user_service.proto index de4e204c..0c9f4c6f 100644 --- a/api/proto/user/user_service.proto +++ b/api/proto/user/user_service.proto @@ -4,6 +4,10 @@ package user; import "user/messages/user_svc/add_role_request.proto"; import "user/messages/user_svc/add_role_response.proto"; +import "user/messages/user_svc/delete_account_request.proto"; +import "user/messages/user_svc/delete_account_response.proto"; +import "user/messages/user_svc/edit_profile_request.proto"; +import "user/messages/user_svc/edit_profile_response.proto"; import "user/messages/user_svc/get_request.proto"; import "user/messages/user_svc/get_response.proto"; import "user/messages/user_svc/list_request.proto"; @@ -22,6 +26,12 @@ service UserService { rpc Get(user.messages.user_svc.GetRequest) returns (user.messages.user_svc.GetResponse); rpc WhoAmI(user.messages.user_svc.WhoAmIRequest) returns (user.messages.user_svc.WhoAmIResponse); rpc Register(user.messages.user_svc.RegisterRequest) returns (user.messages.user_svc.RegisterResponse); + // Self-service: edits the caller's own profile, no user id in the request. + rpc EditProfile(user.messages.user_svc.EditProfileRequest) returns (user.messages.user_svc.EditProfileResponse); rpc AddRole(user.messages.user_svc.AddRoleRequest) returns (user.messages.user_svc.AddRoleResponse); rpc RemoveRole(user.messages.user_svc.RemoveRoleRequest) returns (user.messages.user_svc.RemoveRoleResponse); + // Self-service account deletion (GDPR). Removes the platform profile and + // every casbin role; the Keycloak identity is NOT touched, so the person can + // sign in again and start fresh. + rpc DeleteAccount(user.messages.user_svc.DeleteAccountRequest) returns (user.messages.user_svc.DeleteAccountResponse); } diff --git a/api/proto/vote/entities/vote.proto b/api/proto/vote/entities/vote.proto new file mode 100644 index 00000000..b349cb2c --- /dev/null +++ b/api/proto/vote/entities/vote.proto @@ -0,0 +1,39 @@ +syntax = "proto3"; + +package vote.entities; + +option go_package = "github.com/swissdatasciencecenter/hackagon/components/backend/internal/proto/vote/entities"; + +// Vote is a single atomic judgment from one voter on one submission +// within one category. The vote payload is method-specific. +message Vote { + string id = 1; + string category_id = 2; + string voter_id = 3; + oneof vote { + SingleChoiceVote single_choice = 4; + RankedVote ranked = 5; + PointsVote points = 6; + } + int64 created_at = 7; + int64 modified_at = 8; +} + +// Each variant describes ONE stored row, because a Vote row is one judgment on +// one submission (see the schema comment). A ranked or points ballot is +// therefore several Vote entities, one per submission, all sharing a category +// and a voter — SubmitVoteResponse.votes hands the whole set back. +message SingleChoiceVote { + string submission_id = 1; +} + +message RankedVote { + string submission_id = 1; + // 1 is the voter's first preference. + int32 rank = 2; +} + +message PointsVote { + string submission_id = 1; + int32 points = 2; +} diff --git a/api/proto/vote/entities/vote_category.proto b/api/proto/vote/entities/vote_category.proto new file mode 100644 index 00000000..2d8963b6 --- /dev/null +++ b/api/proto/vote/entities/vote_category.proto @@ -0,0 +1,27 @@ +syntax = "proto3"; + +package vote.entities; + +import "user/entities/user.proto"; +import "vote/entities/voter_type.proto"; +import "vote/entities/voting_method.proto"; + +option go_package = "github.com/swissdatasciencecenter/hackagon/components/backend/internal/proto/vote/entities"; + +// VoteCategory represents a voting category within a hackathon, defining +// the criteria and rules for one dimension of evaluation. +message VoteCategory { + string id = 1; + string hackathon_id = 2; + string name = 3; + string description = 4; + VotingMethod voting_method = 5; + VoterType voter_type = 6; + repeated user.entities.User jury_members = 7; + int64 created_at = 8; + int64 modified_at = 9; + // Points-based voting only: the budget one voter may spread over the + // submissions. Absent for the other methods. Tag 10 rather than main's 7 — + // jury_members/created_at/modified_at already hold 7-9 here. + optional int32 max_points = 10; +} diff --git a/api/proto/vote/entities/vote_result.proto b/api/proto/vote/entities/vote_result.proto new file mode 100644 index 00000000..424f6db1 --- /dev/null +++ b/api/proto/vote/entities/vote_result.proto @@ -0,0 +1,16 @@ +syntax = "proto3"; + +package vote.entities; + +option go_package = "github.com/swissdatasciencecenter/hackagon/components/backend/internal/proto/vote/entities"; + +// VoteResult is a placement entry within a vote category. +message VoteResult { + string id = 1; + string category_id = 2; + string submission_id = 3; + int32 position = 4; + optional string title = 5; + int64 created_at = 6; + int64 modified_at = 7; +} diff --git a/api/proto/vote/entities/voter_type.proto b/api/proto/vote/entities/voter_type.proto new file mode 100644 index 00000000..f3f27302 --- /dev/null +++ b/api/proto/vote/entities/voter_type.proto @@ -0,0 +1,11 @@ +syntax = "proto3"; + +package vote.entities; + +option go_package = "github.com/swissdatasciencecenter/hackagon/components/backend/internal/proto/vote/entities"; + +enum VoterType { + VOTER_TYPE_UNSPECIFIED = 0; + VOTER_TYPE_ALL_PARTICIPANTS = 1; + VOTER_TYPE_JURY = 2; +} diff --git a/api/proto/vote/entities/voting_method.proto b/api/proto/vote/entities/voting_method.proto new file mode 100644 index 00000000..1dd09e21 --- /dev/null +++ b/api/proto/vote/entities/voting_method.proto @@ -0,0 +1,12 @@ +syntax = "proto3"; + +package vote.entities; + +option go_package = "github.com/swissdatasciencecenter/hackagon/components/backend/internal/proto/vote/entities"; + +enum VotingMethod { + VOTING_METHOD_UNSPECIFIED = 0; + VOTING_METHOD_SINGLE_CHOICE = 1; + VOTING_METHOD_RANKED = 2; + VOTING_METHOD_POINTS = 3; +} diff --git a/api/proto/vote/messages/vote_svc/create_category_request.proto b/api/proto/vote/messages/vote_svc/create_category_request.proto new file mode 100644 index 00000000..f18093a4 --- /dev/null +++ b/api/proto/vote/messages/vote_svc/create_category_request.proto @@ -0,0 +1,23 @@ +syntax = "proto3"; + +package vote.messages.vote_svc; + +import "buf/validate/validate.proto"; +import "vote/entities/voter_type.proto"; +import "vote/entities/voting_method.proto"; + +option go_package = "github.com/swissdatasciencecenter/hackagon/components/backend/internal/proto/vote/messages/vote_svc"; + +message CreateVoteCategoryRequest { + string hackathon_id = 1 [(buf.validate.field).string.uuid = true]; + string name = 2 [ + (buf.validate.field).string.min_len = 3, + (buf.validate.field).string.max_len = 255 + ]; + string description = 3 [(buf.validate.field).string.max_len = 10000]; + vote.entities.VotingMethod voting_method = 4 [(buf.validate.field).enum.defined_only = true]; + vote.entities.VoterType voter_type = 5 [(buf.validate.field).enum.defined_only = true]; + repeated string jury_member_ids = 6 [(buf.validate.field).repeated.items.string.uuid = true]; + // Required (and >0) when voting_method is POINTS, ignored otherwise. + optional int32 max_points = 7 [(buf.validate.field).int32.gte = 1]; +} diff --git a/api/proto/vote/messages/vote_svc/create_category_response.proto b/api/proto/vote/messages/vote_svc/create_category_response.proto new file mode 100644 index 00000000..787d3620 --- /dev/null +++ b/api/proto/vote/messages/vote_svc/create_category_response.proto @@ -0,0 +1,11 @@ +syntax = "proto3"; + +package vote.messages.vote_svc; + +import "vote/entities/vote_category.proto"; + +option go_package = "github.com/swissdatasciencecenter/hackagon/components/backend/internal/proto/vote/messages/vote_svc"; + +message CreateVoteCategoryResponse { + vote.entities.VoteCategory vote_category = 1; +} diff --git a/api/proto/vote/messages/vote_svc/create_result_request.proto b/api/proto/vote/messages/vote_svc/create_result_request.proto new file mode 100644 index 00000000..3693dae7 --- /dev/null +++ b/api/proto/vote/messages/vote_svc/create_result_request.proto @@ -0,0 +1,14 @@ +syntax = "proto3"; + +package vote.messages.vote_svc; + +import "buf/validate/validate.proto"; + +option go_package = "github.com/swissdatasciencecenter/hackagon/components/backend/internal/proto/vote/messages/vote_svc"; + +message CreateVoteResultRequest { + string category_id = 1 [(buf.validate.field).string.uuid = true]; + string submission_id = 2 [(buf.validate.field).string.uuid = true]; + int32 position = 3; + optional string title = 4 [(buf.validate.field).string.max_len = 255]; +} diff --git a/api/proto/vote/messages/vote_svc/create_result_response.proto b/api/proto/vote/messages/vote_svc/create_result_response.proto new file mode 100644 index 00000000..624d6ee2 --- /dev/null +++ b/api/proto/vote/messages/vote_svc/create_result_response.proto @@ -0,0 +1,11 @@ +syntax = "proto3"; + +package vote.messages.vote_svc; + +import "vote/entities/vote_result.proto"; + +option go_package = "github.com/swissdatasciencecenter/hackagon/components/backend/internal/proto/vote/messages/vote_svc"; + +message CreateVoteResultResponse { + vote.entities.VoteResult vote_result = 1; +} diff --git a/api/proto/vote/messages/vote_svc/delete_category_request.proto b/api/proto/vote/messages/vote_svc/delete_category_request.proto new file mode 100644 index 00000000..eda79738 --- /dev/null +++ b/api/proto/vote/messages/vote_svc/delete_category_request.proto @@ -0,0 +1,11 @@ +syntax = "proto3"; + +package vote.messages.vote_svc; + +import "buf/validate/validate.proto"; + +option go_package = "github.com/swissdatasciencecenter/hackagon/components/backend/internal/proto/vote/messages/vote_svc"; + +message DeleteVoteCategoryRequest { + string id = 1 [(buf.validate.field).string.uuid = true]; +} diff --git a/api/proto/vote/messages/vote_svc/delete_category_response.proto b/api/proto/vote/messages/vote_svc/delete_category_response.proto new file mode 100644 index 00000000..f66411f3 --- /dev/null +++ b/api/proto/vote/messages/vote_svc/delete_category_response.proto @@ -0,0 +1,7 @@ +syntax = "proto3"; + +package vote.messages.vote_svc; + +option go_package = "github.com/swissdatasciencecenter/hackagon/components/backend/internal/proto/vote/messages/vote_svc"; + +message DeleteVoteCategoryResponse {} diff --git a/api/proto/vote/messages/vote_svc/delete_result_request.proto b/api/proto/vote/messages/vote_svc/delete_result_request.proto new file mode 100644 index 00000000..4739c360 --- /dev/null +++ b/api/proto/vote/messages/vote_svc/delete_result_request.proto @@ -0,0 +1,11 @@ +syntax = "proto3"; + +package vote.messages.vote_svc; + +import "buf/validate/validate.proto"; + +option go_package = "github.com/swissdatasciencecenter/hackagon/components/backend/internal/proto/vote/messages/vote_svc"; + +message DeleteVoteResultRequest { + string id = 1 [(buf.validate.field).string.uuid = true]; +} diff --git a/api/proto/vote/messages/vote_svc/delete_result_response.proto b/api/proto/vote/messages/vote_svc/delete_result_response.proto new file mode 100644 index 00000000..fdda7b2b --- /dev/null +++ b/api/proto/vote/messages/vote_svc/delete_result_response.proto @@ -0,0 +1,7 @@ +syntax = "proto3"; + +package vote.messages.vote_svc; + +option go_package = "github.com/swissdatasciencecenter/hackagon/components/backend/internal/proto/vote/messages/vote_svc"; + +message DeleteVoteResultResponse {} diff --git a/api/proto/vote/messages/vote_svc/edit_category_request.proto b/api/proto/vote/messages/vote_svc/edit_category_request.proto new file mode 100644 index 00000000..25ce49fb --- /dev/null +++ b/api/proto/vote/messages/vote_svc/edit_category_request.proto @@ -0,0 +1,23 @@ +syntax = "proto3"; + +package vote.messages.vote_svc; + +import "buf/validate/validate.proto"; +import "vote/entities/voter_type.proto"; +import "vote/entities/voting_method.proto"; + +option go_package = "github.com/swissdatasciencecenter/hackagon/components/backend/internal/proto/vote/messages/vote_svc"; + +message EditVoteCategoryRequest { + string id = 1 [(buf.validate.field).string.uuid = true]; + optional string name = 2 [ + (buf.validate.field).string.min_len = 1, + (buf.validate.field).string.max_len = 255 + ]; + optional string description = 3 [(buf.validate.field).string.max_len = 10000]; + optional vote.entities.VotingMethod voting_method = 4 [(buf.validate.field).enum.defined_only = true]; + optional vote.entities.VoterType voter_type = 5 [(buf.validate.field).enum.defined_only = true]; + repeated string jury_member_ids = 6 [(buf.validate.field).repeated.items.string.uuid = true]; + // Required (and >0) once the category's method is POINTS, cleared otherwise. + optional int32 max_points = 7 [(buf.validate.field).int32.gte = 1]; +} diff --git a/api/proto/vote/messages/vote_svc/edit_category_response.proto b/api/proto/vote/messages/vote_svc/edit_category_response.proto new file mode 100644 index 00000000..0a7a0149 --- /dev/null +++ b/api/proto/vote/messages/vote_svc/edit_category_response.proto @@ -0,0 +1,11 @@ +syntax = "proto3"; + +package vote.messages.vote_svc; + +import "vote/entities/vote_category.proto"; + +option go_package = "github.com/swissdatasciencecenter/hackagon/components/backend/internal/proto/vote/messages/vote_svc"; + +message EditVoteCategoryResponse { + vote.entities.VoteCategory vote_category = 1; +} diff --git a/api/proto/vote/messages/vote_svc/edit_result_request.proto b/api/proto/vote/messages/vote_svc/edit_result_request.proto new file mode 100644 index 00000000..1cde000d --- /dev/null +++ b/api/proto/vote/messages/vote_svc/edit_result_request.proto @@ -0,0 +1,14 @@ +syntax = "proto3"; + +package vote.messages.vote_svc; + +import "buf/validate/validate.proto"; + +option go_package = "github.com/swissdatasciencecenter/hackagon/components/backend/internal/proto/vote/messages/vote_svc"; + +message EditVoteResultRequest { + string id = 1 [(buf.validate.field).string.uuid = true]; + optional string submission_id = 2 [(buf.validate.field).string.uuid = true]; + optional int32 position = 3; + optional string title = 4 [(buf.validate.field).string.max_len = 255]; +} diff --git a/api/proto/vote/messages/vote_svc/edit_result_response.proto b/api/proto/vote/messages/vote_svc/edit_result_response.proto new file mode 100644 index 00000000..8ad7085e --- /dev/null +++ b/api/proto/vote/messages/vote_svc/edit_result_response.proto @@ -0,0 +1,11 @@ +syntax = "proto3"; + +package vote.messages.vote_svc; + +import "vote/entities/vote_result.proto"; + +option go_package = "github.com/swissdatasciencecenter/hackagon/components/backend/internal/proto/vote/messages/vote_svc"; + +message EditVoteResultResponse { + vote.entities.VoteResult vote_result = 1; +} diff --git a/api/proto/vote/messages/vote_svc/export_results_request.proto b/api/proto/vote/messages/vote_svc/export_results_request.proto new file mode 100644 index 00000000..ce58da6e --- /dev/null +++ b/api/proto/vote/messages/vote_svc/export_results_request.proto @@ -0,0 +1,13 @@ +syntax = "proto3"; + +package vote.messages.vote_svc; + +import "buf/validate/validate.proto"; +import "vote/messages/vote_svc/export_votes_request.proto"; + +option go_package = "github.com/swissdatasciencecenter/hackagon/components/backend/internal/proto/vote/messages/vote_svc"; + +message ExportResultsRequest { + string category_id = 1 [(buf.validate.field).string.uuid = true]; + ExportFormat format = 2 [(buf.validate.field).enum.defined_only = true]; +} diff --git a/api/proto/vote/messages/vote_svc/export_results_response.proto b/api/proto/vote/messages/vote_svc/export_results_response.proto new file mode 100644 index 00000000..1ea36b5f --- /dev/null +++ b/api/proto/vote/messages/vote_svc/export_results_response.proto @@ -0,0 +1,9 @@ +syntax = "proto3"; + +package vote.messages.vote_svc; + +option go_package = "github.com/swissdatasciencecenter/hackagon/components/backend/internal/proto/vote/messages/vote_svc"; + +message ExportResultsResponse { + bytes data = 1; +} diff --git a/api/proto/vote/messages/vote_svc/export_votes_request.proto b/api/proto/vote/messages/vote_svc/export_votes_request.proto new file mode 100644 index 00000000..60218737 --- /dev/null +++ b/api/proto/vote/messages/vote_svc/export_votes_request.proto @@ -0,0 +1,18 @@ +syntax = "proto3"; + +package vote.messages.vote_svc; + +import "buf/validate/validate.proto"; + +option go_package = "github.com/swissdatasciencecenter/hackagon/components/backend/internal/proto/vote/messages/vote_svc"; + +message ExportVotesRequest { + string category_id = 1 [(buf.validate.field).string.uuid = true]; + ExportFormat format = 2 [(buf.validate.field).enum.defined_only = true]; +} + +enum ExportFormat { + EXPORT_FORMAT_UNSPECIFIED = 0; + EXPORT_FORMAT_CSV = 1; + EXPORT_FORMAT_JSON = 2; +} diff --git a/api/proto/vote/messages/vote_svc/export_votes_response.proto b/api/proto/vote/messages/vote_svc/export_votes_response.proto new file mode 100644 index 00000000..7b094f2e --- /dev/null +++ b/api/proto/vote/messages/vote_svc/export_votes_response.proto @@ -0,0 +1,9 @@ +syntax = "proto3"; + +package vote.messages.vote_svc; + +option go_package = "github.com/swissdatasciencecenter/hackagon/components/backend/internal/proto/vote/messages/vote_svc"; + +message ExportVotesResponse { + bytes data = 1; +} diff --git a/api/proto/vote/messages/vote_svc/get_category_request.proto b/api/proto/vote/messages/vote_svc/get_category_request.proto new file mode 100644 index 00000000..41bd9050 --- /dev/null +++ b/api/proto/vote/messages/vote_svc/get_category_request.proto @@ -0,0 +1,11 @@ +syntax = "proto3"; + +package vote.messages.vote_svc; + +import "buf/validate/validate.proto"; + +option go_package = "github.com/swissdatasciencecenter/hackagon/components/backend/internal/proto/vote/messages/vote_svc"; + +message GetVoteCategoryRequest { + string id = 1 [(buf.validate.field).string.uuid = true]; +} diff --git a/api/proto/vote/messages/vote_svc/get_category_response.proto b/api/proto/vote/messages/vote_svc/get_category_response.proto new file mode 100644 index 00000000..f2c8ae3b --- /dev/null +++ b/api/proto/vote/messages/vote_svc/get_category_response.proto @@ -0,0 +1,11 @@ +syntax = "proto3"; + +package vote.messages.vote_svc; + +import "vote/entities/vote_category.proto"; + +option go_package = "github.com/swissdatasciencecenter/hackagon/components/backend/internal/proto/vote/messages/vote_svc"; + +message GetVoteCategoryResponse { + vote.entities.VoteCategory vote_category = 1; +} diff --git a/api/proto/vote/messages/vote_svc/get_vote_request.proto b/api/proto/vote/messages/vote_svc/get_vote_request.proto new file mode 100644 index 00000000..d2158fcc --- /dev/null +++ b/api/proto/vote/messages/vote_svc/get_vote_request.proto @@ -0,0 +1,11 @@ +syntax = "proto3"; + +package vote.messages.vote_svc; + +import "buf/validate/validate.proto"; + +option go_package = "github.com/swissdatasciencecenter/hackagon/components/backend/internal/proto/vote/messages/vote_svc"; + +message GetVoteRequest { + string id = 1 [(buf.validate.field).string.uuid = true]; +} diff --git a/api/proto/vote/messages/vote_svc/get_vote_response.proto b/api/proto/vote/messages/vote_svc/get_vote_response.proto new file mode 100644 index 00000000..4e61851c --- /dev/null +++ b/api/proto/vote/messages/vote_svc/get_vote_response.proto @@ -0,0 +1,11 @@ +syntax = "proto3"; + +package vote.messages.vote_svc; + +import "vote/entities/vote.proto"; + +option go_package = "github.com/swissdatasciencecenter/hackagon/components/backend/internal/proto/vote/messages/vote_svc"; + +message GetVoteResponse { + vote.entities.Vote vote = 1; +} diff --git a/api/proto/vote/messages/vote_svc/list_categories_request.proto b/api/proto/vote/messages/vote_svc/list_categories_request.proto new file mode 100644 index 00000000..8d53bd20 --- /dev/null +++ b/api/proto/vote/messages/vote_svc/list_categories_request.proto @@ -0,0 +1,11 @@ +syntax = "proto3"; + +package vote.messages.vote_svc; + +import "buf/validate/validate.proto"; + +option go_package = "github.com/swissdatasciencecenter/hackagon/components/backend/internal/proto/vote/messages/vote_svc"; + +message ListVoteCategoriesRequest { + string hackathon_id = 1 [(buf.validate.field).string.uuid = true]; +} diff --git a/api/proto/vote/messages/vote_svc/list_categories_response.proto b/api/proto/vote/messages/vote_svc/list_categories_response.proto new file mode 100644 index 00000000..233cfb99 --- /dev/null +++ b/api/proto/vote/messages/vote_svc/list_categories_response.proto @@ -0,0 +1,11 @@ +syntax = "proto3"; + +package vote.messages.vote_svc; + +import "vote/entities/vote_category.proto"; + +option go_package = "github.com/swissdatasciencecenter/hackagon/components/backend/internal/proto/vote/messages/vote_svc"; + +message ListVoteCategoriesResponse { + repeated vote.entities.VoteCategory vote_categories = 1; +} diff --git a/api/proto/vote/messages/vote_svc/list_results_request.proto b/api/proto/vote/messages/vote_svc/list_results_request.proto new file mode 100644 index 00000000..516f6179 --- /dev/null +++ b/api/proto/vote/messages/vote_svc/list_results_request.proto @@ -0,0 +1,11 @@ +syntax = "proto3"; + +package vote.messages.vote_svc; + +import "buf/validate/validate.proto"; + +option go_package = "github.com/swissdatasciencecenter/hackagon/components/backend/internal/proto/vote/messages/vote_svc"; + +message ListVoteResultsRequest { + string category_id = 1 [(buf.validate.field).string.uuid = true]; +} diff --git a/api/proto/vote/messages/vote_svc/list_results_response.proto b/api/proto/vote/messages/vote_svc/list_results_response.proto new file mode 100644 index 00000000..e6f3d519 --- /dev/null +++ b/api/proto/vote/messages/vote_svc/list_results_response.proto @@ -0,0 +1,11 @@ +syntax = "proto3"; + +package vote.messages.vote_svc; + +import "vote/entities/vote_result.proto"; + +option go_package = "github.com/swissdatasciencecenter/hackagon/components/backend/internal/proto/vote/messages/vote_svc"; + +message ListVoteResultsResponse { + repeated vote.entities.VoteResult vote_results = 1; +} diff --git a/api/proto/vote/messages/vote_svc/list_votes_request.proto b/api/proto/vote/messages/vote_svc/list_votes_request.proto new file mode 100644 index 00000000..b5410157 --- /dev/null +++ b/api/proto/vote/messages/vote_svc/list_votes_request.proto @@ -0,0 +1,33 @@ +syntax = "proto3"; + +package vote.messages.vote_svc; + +import "buf/validate/validate.proto"; + +option go_package = "github.com/swissdatasciencecenter/hackagon/components/backend/internal/proto/vote/messages/vote_svc"; + +// The handler treats an empty voter_id / submission_id as "no filter", but a +// plain string.uuid rule rejects the empty string before the handler ever runs +// — which made every partially-filtered call impossible, including the obvious +// "all votes in this category". The CEL rules below allow empty OR a valid +// UUID, the same escape hatch page_id uses in +// hackathon/messages/phase_svc/edit_request.proto. +// +// category_id keeps the strict rule: listing votes without one is not a query +// this service supports. +message ListVotesRequest { + string category_id = 1 [(buf.validate.field).string.uuid = true]; + string voter_id = 2; + string submission_id = 3; + + option (buf.validate.message).cel = { + id: "voter_id_uuid_or_empty" + message: "voter_id must be a valid UUID if provided and non-empty" + expression: "this.voter_id == '' || this.voter_id.matches('^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$')" + }; + option (buf.validate.message).cel = { + id: "submission_id_uuid_or_empty" + message: "submission_id must be a valid UUID if provided and non-empty" + expression: "this.submission_id == '' || this.submission_id.matches('^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$')" + }; +} diff --git a/api/proto/vote/messages/vote_svc/list_votes_response.proto b/api/proto/vote/messages/vote_svc/list_votes_response.proto new file mode 100644 index 00000000..d328c9b9 --- /dev/null +++ b/api/proto/vote/messages/vote_svc/list_votes_response.proto @@ -0,0 +1,11 @@ +syntax = "proto3"; + +package vote.messages.vote_svc; + +import "vote/entities/vote.proto"; + +option go_package = "github.com/swissdatasciencecenter/hackagon/components/backend/internal/proto/vote/messages/vote_svc"; + +message ListVotesResponse { + repeated vote.entities.Vote votes = 1; +} diff --git a/api/proto/vote/messages/vote_svc/submit_vote_request.proto b/api/proto/vote/messages/vote_svc/submit_vote_request.proto new file mode 100644 index 00000000..b1a509b7 --- /dev/null +++ b/api/proto/vote/messages/vote_svc/submit_vote_request.proto @@ -0,0 +1,45 @@ +syntax = "proto3"; + +package vote.messages.vote_svc; + +import "buf/validate/validate.proto"; + +option go_package = "github.com/swissdatasciencecenter/hackagon/components/backend/internal/proto/vote/messages/vote_svc"; + +// One ballot. The variant chosen must match the category's voting_method or +// the server refuses it. +message SubmitVoteRequest { + oneof vote { + SingleChoiceVote single_choice = 1; + RankedVote ranked = 2; + PointsVote points = 3; + } +} + +message SingleChoiceVote { + string category_id = 1 [(buf.validate.field).string.uuid = true]; + string submission_id = 2 [(buf.validate.field).string.uuid = true]; +} + +// Ranks are carried explicitly rather than implied by list order: the voter +// types a number per submission, so a gap or a repeat is a mistake the server +// has to be able to name instead of one the client silently normalises away. +message RankedVote { + string category_id = 1 [(buf.validate.field).string.uuid = true]; + repeated RankedSubmission submissions = 2 [(buf.validate.field).repeated.min_items = 1]; +} + +message RankedSubmission { + string submission_id = 1 [(buf.validate.field).string.uuid = true]; + int32 rank = 2 [(buf.validate.field).int32.gt = 0]; +} + +message PointsVote { + string category_id = 1 [(buf.validate.field).string.uuid = true]; + repeated PointsSubmission submissions = 2 [(buf.validate.field).repeated.min_items = 1]; +} + +message PointsSubmission { + string submission_id = 1 [(buf.validate.field).string.uuid = true]; + int32 points = 2 [(buf.validate.field).int32.gt = 0]; +} diff --git a/api/proto/vote/messages/vote_svc/submit_vote_response.proto b/api/proto/vote/messages/vote_svc/submit_vote_response.proto new file mode 100644 index 00000000..e0c41919 --- /dev/null +++ b/api/proto/vote/messages/vote_svc/submit_vote_response.proto @@ -0,0 +1,16 @@ +syntax = "proto3"; + +package vote.messages.vote_svc; + +import "vote/entities/vote.proto"; + +option go_package = "github.com/swissdatasciencecenter/hackagon/components/backend/internal/proto/vote/messages/vote_svc"; + +message SubmitVoteResponse { + // The first row of the accepted ballot — for single_choice that is the whole + // ballot, and callers that predate ranked/points keep working unchanged. + vote.entities.Vote vote = 1; + // Every row the ballot produced: one for single_choice, N for ranked and + // points. + repeated vote.entities.Vote votes = 2; +} diff --git a/api/proto/vote/messages/vote_svc/suggest_results_request.proto b/api/proto/vote/messages/vote_svc/suggest_results_request.proto new file mode 100644 index 00000000..f815e6fc --- /dev/null +++ b/api/proto/vote/messages/vote_svc/suggest_results_request.proto @@ -0,0 +1,27 @@ +syntax = "proto3"; + +package vote.messages.vote_svc; + +import "buf/validate/validate.proto"; + +option go_package = "github.com/swissdatasciencecenter/hackagon/components/backend/internal/proto/vote/messages/vote_svc"; + +// Computes the tally for one category and writes it as VoteResult rows. +// +// SUGGEST, not decide. The vote is advisory on this platform: an organizer +// reviews the count and records who actually won, and PrizeService.Finalize is +// what freezes that. A jury that cannot overrule its own count is not a jury. +// So this fills the results table for review — it never writes prizes. +// +// Without it the count existed nowhere: placements had to be typed in by hand +// from an export, which is both tedious and the easiest possible place to make +// an unnoticed mistake about who won. +message SuggestResultsRequest { + string category_id = 1 [(buf.validate.field).string.uuid = true]; + // Recompute over existing results. + // + // Refused without it when results are already present, because a recount that + // silently replaces a published placement is how a correction becomes an + // accusation. The caller has to say they mean it. + bool force = 2; +} diff --git a/api/proto/vote/messages/vote_svc/suggest_results_response.proto b/api/proto/vote/messages/vote_svc/suggest_results_response.proto new file mode 100644 index 00000000..81bd6d02 --- /dev/null +++ b/api/proto/vote/messages/vote_svc/suggest_results_response.proto @@ -0,0 +1,14 @@ +syntax = "proto3"; + +package vote.messages.vote_svc; + +import "vote/entities/vote_result.proto"; + +option go_package = "github.com/swissdatasciencecenter/hackagon/components/backend/internal/proto/vote/messages/vote_svc"; + +message SuggestResultsResponse { + // The rows as written, ordered by position. Ties share a position — two + // submissions on the same count are both second, and the organizer decides + // what to do about it rather than the tally inventing an order. + repeated vote.entities.VoteResult results = 1; +} diff --git a/api/proto/vote/vote_service.proto b/api/proto/vote/vote_service.proto new file mode 100644 index 00000000..dfc14736 --- /dev/null +++ b/api/proto/vote/vote_service.proto @@ -0,0 +1,62 @@ +syntax = "proto3"; + +package vote; + +import "vote/messages/vote_svc/create_category_request.proto"; +import "vote/messages/vote_svc/create_category_response.proto"; +import "vote/messages/vote_svc/create_result_request.proto"; +import "vote/messages/vote_svc/create_result_response.proto"; +import "vote/messages/vote_svc/delete_category_request.proto"; +import "vote/messages/vote_svc/delete_category_response.proto"; +import "vote/messages/vote_svc/delete_result_request.proto"; +import "vote/messages/vote_svc/delete_result_response.proto"; +import "vote/messages/vote_svc/edit_category_request.proto"; +import "vote/messages/vote_svc/edit_category_response.proto"; +import "vote/messages/vote_svc/edit_result_request.proto"; +import "vote/messages/vote_svc/edit_result_response.proto"; +import "vote/messages/vote_svc/export_results_request.proto"; +import "vote/messages/vote_svc/export_results_response.proto"; +import "vote/messages/vote_svc/export_votes_request.proto"; +import "vote/messages/vote_svc/export_votes_response.proto"; +import "vote/messages/vote_svc/get_category_request.proto"; +import "vote/messages/vote_svc/get_category_response.proto"; +import "vote/messages/vote_svc/get_vote_request.proto"; +import "vote/messages/vote_svc/get_vote_response.proto"; +import "vote/messages/vote_svc/list_categories_request.proto"; +import "vote/messages/vote_svc/list_categories_response.proto"; +import "vote/messages/vote_svc/list_results_request.proto"; +import "vote/messages/vote_svc/list_results_response.proto"; +import "vote/messages/vote_svc/list_votes_request.proto"; +import "vote/messages/vote_svc/list_votes_response.proto"; +import "vote/messages/vote_svc/submit_vote_request.proto"; +import "vote/messages/vote_svc/submit_vote_response.proto"; +import "vote/messages/vote_svc/suggest_results_request.proto"; +import "vote/messages/vote_svc/suggest_results_response.proto"; + +option go_package = "github.com/swissdatasciencecenter/hackagon/components/backend/internal/proto/vote"; + +service VoteService { + // VoteCategory CRUD + rpc ListVoteCategories(vote.messages.vote_svc.ListVoteCategoriesRequest) returns (vote.messages.vote_svc.ListVoteCategoriesResponse); + rpc GetVoteCategory(vote.messages.vote_svc.GetVoteCategoryRequest) returns (vote.messages.vote_svc.GetVoteCategoryResponse); + rpc CreateVoteCategory(vote.messages.vote_svc.CreateVoteCategoryRequest) returns (vote.messages.vote_svc.CreateVoteCategoryResponse); + rpc EditVoteCategory(vote.messages.vote_svc.EditVoteCategoryRequest) returns (vote.messages.vote_svc.EditVoteCategoryResponse); + rpc DeleteVoteCategory(vote.messages.vote_svc.DeleteVoteCategoryRequest) returns (vote.messages.vote_svc.DeleteVoteCategoryResponse); + + // Voting + rpc SubmitVote(vote.messages.vote_svc.SubmitVoteRequest) returns (vote.messages.vote_svc.SubmitVoteResponse); + rpc GetVote(vote.messages.vote_svc.GetVoteRequest) returns (vote.messages.vote_svc.GetVoteResponse); + rpc ListVotes(vote.messages.vote_svc.ListVotesRequest) returns (vote.messages.vote_svc.ListVotesResponse); + rpc ExportVotes(vote.messages.vote_svc.ExportVotesRequest) returns (vote.messages.vote_svc.ExportVotesResponse); + + // Vote Results + rpc ListVoteResults(vote.messages.vote_svc.ListVoteResultsRequest) returns (vote.messages.vote_svc.ListVoteResultsResponse); + rpc CreateVoteResult(vote.messages.vote_svc.CreateVoteResultRequest) returns (vote.messages.vote_svc.CreateVoteResultResponse); + rpc EditVoteResult(vote.messages.vote_svc.EditVoteResultRequest) returns (vote.messages.vote_svc.EditVoteResultResponse); + rpc DeleteVoteResult(vote.messages.vote_svc.DeleteVoteResultRequest) returns (vote.messages.vote_svc.DeleteVoteResultResponse); + // Computes the tally and writes it as results for the organizer to review. + // Advisory by design — see SuggestResultsRequest, and PrizeService for what + // actually freezes an award. + rpc SuggestResults(vote.messages.vote_svc.SuggestResultsRequest) returns (vote.messages.vote_svc.SuggestResultsResponse); + rpc ExportResults(vote.messages.vote_svc.ExportResultsRequest) returns (vote.messages.vote_svc.ExportResultsResponse); +} diff --git a/components/backend/README.md b/components/backend/README.md index 144d2b9f..3f429fbc 100644 --- a/components/backend/README.md +++ b/components/backend/README.md @@ -30,23 +30,24 @@ grpcurl -plaintext localhost:3000 health.Health/Check ## Database Schema -We use [ent](https://entgo.io/) as an ORM. Use `just generate-db-schema` to +We use [ent](https://entgo.io/) as an ORM. Use `just codegen::db-schema` to generate the db schema files. An overview of the DB schema can be found in [Schema.md](./Schema.md) ## Test Data -`just seed` populates the database with 3 hackathons (upcoming/ongoing/past), a -mix of tracks, projects, teams, and submissions, and 4 users (admin, alice, bob, -charles). Seeding runs in a single transaction and is idempotent — re-running is -a no-op if the seed hackathon already exists. See +`just db::seed` populates the database with 3 hackathons +(upcoming/ongoing/past), a mix of tracks, projects, teams, and submissions, and +4 users (admin, alice, bob, charles). Seeding runs in a single transaction and +is idempotent — re-running is a no-op if the seed hackathon already exists. See [cmd/seed/README.md](cmd/seed/README.md) for a full breakdown with timeline and user-involvement diagrams. -`just db-summary` prints entity counts and per-hackathon/per-user detail. Use it -to sanity-check the DB after seeding or migrations. +`just db::summary` prints entity counts and per-hackathon/per-user detail. Use +it to sanity-check the DB after seeding or migrations. -`just reset` wipes all state; re-seed with `just seed` afterwards. +`just clean::state` wipes Postgres + Keycloak state; restart with +`just deploy::up` and re-seed with `just db::seed` afterwards. ## Development diff --git a/components/backend/Schema.md b/components/backend/Schema.md index bdfcabdc..1598b3f9 100644 --- a/components/backend/Schema.md +++ b/components/backend/Schema.md @@ -26,6 +26,31 @@ Whether one member-facing action is currently open in a hackathon. One row per c - `capability, hackathon_capabilities` *(unique)* +## FormResponse + +One registrant's answers to a hackathon's registration form, validated against the organizer's schema at submission time. + +### Fields + +| Column | Type | Required | Unique | Immutable | Default | Description | +|--------|------|----------|--------|-----------|---------|-------------| +| `responses` | map[string]interface {} | yes | no | no | no | Field answers keyed by the form field key. | +| `consents` | map[string]bool | yes | no | no | no | Consent checkboxes keyed by the consent key. | +| `created_at` | time.Time | yes | no | yes | yes | Timestamp when the response was submitted. | +| `modified_at` | time.Time | yes | no | no | yes | Timestamp of the last modification. | + +### Relationships + +| Edge | Target | Relation | Inverse | Required | Description | +|------|--------|----------|---------|----------|-------------| +| `hackathon` | Hackathon | M2O | yes | yes | The hackathon the response belongs to. | +| `user` | User | M2O | yes | yes | The registrant the response is about. | +| `submitted_by` | User | M2O | yes | yes | Who actually entered it — the registrant, or an organizer digitizing a paper form. | + +### Indexes + +- `hackathon_form_responses, user_form_responses` *(unique)* + ## Hackathon A hackathon event containing tracks, projects, phases, and participants. @@ -42,6 +67,7 @@ A hackathon event containing tracks, projects, phases, and participants. | `visibility` | enum(public, private) | yes | no | no | no | Controls whether non-participants can discover this hackathon. | | `description` | string | no | no | no | no | Detailed description of the hackathon, supports rich text. | | `logo` | string | no | no | no | no | URL or path to the hackathon logo image. | +| `max_participants` | int32 | no | no | no | no | Maximum number of CONFIRMED participants (is_waiting=false); the waiting list is not counted. Nil or 0 means unlimited — the default, and the behaviour of every row that predates the column, so no existing event is silently capped. | | `current_phase_id` | uuid.UUID | no | no | no | no | The phase an organizer has declared current. Nil means fall back to deriving it from phase dates, which is right before the event but wrong during one, where the schedule always slips. | ### Relationships @@ -52,9 +78,16 @@ A hackathon event containing tracks, projects, phases, and participants. | `projects` | Project | O2M | no | no | Projects submitted to this hackathon. | | `participating_users` | User | M2M | yes | no | Users who are participating or waitlisted. | | `pages` | Page | O2M | no | no | Content pages associated with this hackathon. | +| `invites` | HackathonInvite | O2M | no | no | Shareable invitation links granting visibility of this hackathon. | | `phases` | Phase | O2M | no | no | Temporal phases (e.g. ideation, hacking, judging). | | `capabilities` | Capability | O2M | no | no | Which member-facing actions are available on this hackathon. | | `current_phase` | Phase | M2O | yes | no | Set by AdvancePhase; SET NULL so deleting a phase does not orphan it. | +| `vote_categories` | VoteCategory | O2M | no | no | Voting categories scoped to this hackathon. | +| `settings` | HackathonSettings | O2O | no | no | Configuration settings for this hackathon. | +| `windows` | HackathonWindows | O2O | no | no | Enforced time windows for this hackathon. | +| `forms` | HackathonForms | O2O | no | no | Organizer-defined form schemas and voting policy. | +| `form_responses` | FormResponse | O2M | no | no | Registration form responses submitted for this hackathon. | +| `prize_table` | HackathonPrizes | O2O | no | no | The prize table and awards for this hackathon. | | `creator` | User | M2O | yes | yes | The user who created this hackathon. | | `modifier` | User | M2O | yes | yes | The user who last modified this hackathon. | | `participants` | Participant | O2M | yes | no | | @@ -66,6 +99,121 @@ A hackathon event containing tracks, projects, phases, and participants. - `ends_at` - `visibility` +## HackathonForms + +Organizer-defined form schemas and voting policy for a hackathon. Schemas are stored as JSON; SubmitRegistrationForm validates responses against them. + +### Fields + +| Column | Type | Required | Unique | Immutable | Default | Description | +|--------|------|----------|--------|-----------|---------|-------------| +| `registration_fields` | []map[string]interface {} | no | no | no | no | Registration form fields ({key,label,type,required,maxMb}). | +| `registration_consents` | []map[string]interface {} | no | no | no | no | Registration consents ({key,label,required}). | +| `submission_fields` | []map[string]interface {} | no | no | no | no | Submission form fields ({key,label,type,required,maxMb}). | +| `voting_policy` | map[string]interface {} | no | no | no | no | Pinned voting mechanism decisions (mechanism, scale, tie-breaks). | +| `email_templates` | map[string]string | no | no | no | no | Organizer-authored notification copy, keyed by moment (registrationConfirmed, teamAssigned, deadlineReminder, results). Stored only — no notification service sends them yet. | +| `branding` | map[string]string | no | no | no | no | Event branding (primaryColor, accentColor, bannerText). The logo lives on the hackathon row itself. | +| `created_at` | time.Time | yes | no | yes | yes | Timestamp when the forms row was created. | +| `modified_at` | time.Time | yes | no | no | yes | Timestamp of the last modification. | + +### Relationships + +| Edge | Target | Relation | Inverse | Required | Description | +|------|--------|----------|---------|----------|-------------| +| `hackathon` | Hackathon | O2O | yes | yes | The hackathon these forms belong to. | +| `modifier` | User | M2O | yes | yes | The user who last modified these forms. | + +## HackathonInvite + +A revocable, shareable invitation link granting visibility of a private hackathon. + +### Fields + +| Column | Type | Required | Unique | Immutable | Default | Description | +|--------|------|----------|--------|-----------|---------|-------------| +| `token` | uuid.UUID | yes | yes | yes | yes | The secret in the invite URL. Generated server-side; never derived from the hackathon id. | +| `note` | string | no | no | no | no | Free-text reminder of who the link was sent to; organizer-facing only. | +| `created_at` | time.Time | yes | no | yes | yes | Timestamp when the invite was generated. | +| `revoked_at` | time.Time | no | no | no | no | When set, the link stops working. Revoking is preferred over deletion so the audit trail survives. | + +### Relationships + +| Edge | Target | Relation | Inverse | Required | Description | +|------|--------|----------|---------|----------|-------------| +| `hackathon` | Hackathon | M2O | yes | yes | The hackathon this invite grants visibility of. | +| `creator` | User | M2O | yes | yes | The organizer or admin who generated the link. | + +### Indexes + +- `token` *(unique)* + +## HackathonPrizes + +The organizer-defined prize table and, after Finalize, the awards. Votes are advisory: nothing is won until the admin finalizes, and the table stays admin-editable afterwards. + +### Fields + +| Column | Type | Required | Unique | Immutable | Default | Description | +|--------|------|----------|--------|-----------|---------|-------------| +| `prizes` | []map[string]interface {} | no | no | no | no | Prize table ({rank,title}); rank 0 is a special prize. | +| `awards` | []map[string]interface {} | no | no | no | no | Awarded submissions ({rank\|special, submissionId}) set at Finalize. | +| `finalized` | bool | yes | no | no | yes | Whether the admin has spoken; results are advisory before this. | +| `created_at` | time.Time | yes | no | yes | yes | Timestamp when the prize table was created. | +| `modified_at` | time.Time | yes | no | no | yes | Timestamp of the last modification. | + +### Relationships + +| Edge | Target | Relation | Inverse | Required | Description | +|------|--------|----------|---------|----------|-------------| +| `hackathon` | Hackathon | O2O | yes | yes | The hackathon this prize table belongs to. | +| `modifier` | User | M2O | yes | yes | The user who last modified the prize table. | + +## HackathonSettings + +Configuration settings for a hackathon. + +### Fields + +| Column | Type | Required | Unique | Immutable | Default | Description | +|--------|------|----------|--------|-----------|---------|-------------| +| `registrations_enabled` | bool | yes | no | no | yes | Whether new participants can register for this hackathon. | +| `voting_enabled` | bool | yes | no | no | yes | Whether voting is enabled for this hackathon. | +| `created_at` | time.Time | yes | no | yes | yes | Timestamp when the settings were created. | +| `modified_at` | time.Time | yes | no | no | yes | Timestamp of the last modification. | + +### Relationships + +| Edge | Target | Relation | Inverse | Required | Description | +|------|--------|----------|---------|----------|-------------| +| `hackathon` | Hackathon | O2O | yes | yes | The hackathon this settings entry belongs to. | +| `modifier` | User | M2O | yes | yes | The user who last modified these settings. | + +## HackathonWindows + +Per-hackathon time windows enforced on the acting RPCs (Join, Propose, SetPreference, CreateSubmission). Unset windows are not enforced; overrides are one-shot absolute extensions granted by an organizer. + +### Fields + +| Column | Type | Required | Unique | Immutable | Default | Description | +|--------|------|----------|--------|-----------|---------|-------------| +| `registration_opens` | time.Time | no | no | no | no | Join is rejected before this instant. | +| `registration_closes` | time.Time | no | no | no | no | Join is rejected after this instant (unless overridden). | +| `proposals_close` | time.Time | no | no | no | no | Propose is rejected after this instant. | +| `preferences_close` | time.Time | no | no | no | no | SetPreference is rejected after this instant. | +| `submissions_close` | time.Time | no | no | no | no | CreateSubmission is rejected after this instant (unless overridden). | +| `registration_override_until` | time.Time | no | no | no | no | Manual walk-in window: registration stays open until this instant. | +| `submissions_override_until` | time.Time | no | no | no | no | Manual grace window: submissions stay open until this instant. | +| `late_policy` | string | no | no | no | no | Human-readable note on how late submissions are handled. | +| `created_at` | time.Time | yes | no | yes | yes | Timestamp when the windows were created. | +| `modified_at` | time.Time | yes | no | no | yes | Timestamp of the last modification. | + +### Relationships + +| Edge | Target | Relation | Inverse | Required | Description | +|------|--------|----------|---------|----------|-------------| +| `hackathon` | Hackathon | O2O | yes | yes | The hackathon these windows belong to. | +| `modifier` | User | M2O | yes | yes | The user who last modified these windows. | + ## Page A content page associated with a hackathon, used for information display. @@ -180,6 +328,34 @@ A project proposal within a hackathon track. - `title` - `status` +## SitePage + +A platform-level content page (about, privacy, terms), addressed by slug. + +### Fields + +| Column | Type | Required | Unique | Immutable | Default | Description | +|--------|------|----------|--------|-----------|---------|-------------| +| `slug` | string | yes | yes | no | no | URL segment identifying the page (e.g. "about"); lowercase kebab-case. | +| `title` | string | yes | no | no | no | Title of the page. | +| `content` | string | yes | no | no | no | Markdown content of the page. Rendered through the frontend's sanitizing pipeline. | +| `visible` | bool | yes | no | no | yes | Whether the page is published. Drafts are readable by admins only. | +| `order` | int | yes | no | no | yes | Sort order for navigation listings; lower values appear first. | +| `created_at` | time.Time | yes | no | yes | yes | Timestamp when the page was created. | +| `modified_at` | time.Time | yes | no | no | yes | Timestamp of the last modification. | + +### Relationships + +| Edge | Target | Relation | Inverse | Required | Description | +|------|--------|----------|---------|----------|-------------| +| `creator` | User | M2O | yes | yes | The user who created this page. | +| `modifier` | User | M2O | yes | yes | The user who last modified this page. | + +### Indexes + +- `order` +- `visible` + ## Submission A versioned submission from a team for a project. @@ -191,6 +367,7 @@ A versioned submission from a team for a project. | `created_at` | time.Time | yes | no | yes | yes | Timestamp when the submission was created. | | `modified_at` | time.Time | yes | no | no | yes | Timestamp of the last modification. | | `result` | string | no | no | no | no | Result or output of the submission (e.g. a URL). | +| `form` | map[string]string | no | no | no | no | Structured answers keyed by the organizer's submission form fields (ConfigService.SetSubmissionForm). Validated on write against that schema. | | `status` | enum(draft, final) | yes | no | no | no | Whether the submission is a draft or final. | | `version` | int | yes | no | no | no | Monotonically increasing version number, unique per project+team. | @@ -202,6 +379,8 @@ A versioned submission from a team for a project. | `project` | Project | M2O | yes | yes | The project this submission is for. | | `creator` | User | M2O | yes | yes | The user who created this submission. | | `modifier` | User | M2O | yes | no | The user who last modified this submission. | +| `votes` | Vote | O2M | no | no | Votes cast on this submission. | +| `vote_results` | VoteResult | O2M | no | no | Vote results placing this submission. | ### Indexes @@ -288,6 +467,10 @@ An authenticated user, synced from Keycloak on first login. | `keycloak_id` | string | yes | yes | no | no | Unique identifier from Keycloak (sub claim). | | `display_name` | string | no | no | no | yes | Preferred display name of the user. | | `email` | string | no | no | no | yes | Email of the user, same as in Keycloak | +| `affiliation` | string | no | no | no | yes | University, company or institute the user belongs to. | +| `skills` | string | no | no | no | yes | Comma-separated skills, as the registration form's tags field collects them. | +| `dietary` | string | no | no | no | yes | Dietary requirements, for events that cater. | +| `avatar_url` | string | no | no | no | yes | Link to a profile picture. A link, not an upload: there is no object store yet. | | `created_at` | time.Time | yes | no | yes | yes | Timestamp when the user was first seen. | | `modified_at` | time.Time | yes | no | no | yes | Timestamp of the last profile update. | @@ -305,6 +488,9 @@ An authenticated user, synced from Keycloak on first login. | `modified_teams` | Team | O2M | no | no | Teams this user last modified. | | `created_pages` | Page | O2M | no | no | Content pages this user created. | | `modified_pages` | Page | O2M | no | no | Content pages this user last modified. | +| `created_invites` | HackathonInvite | O2M | no | no | Hackathon invitation links this user generated. | +| `created_site_pages` | SitePage | O2M | no | no | Platform pages this user created. | +| `modified_site_pages` | SitePage | O2M | no | no | Platform pages this user last modified. | | `created_phases` | Phase | O2M | no | no | Phases this user created. | | `modified_phases` | Phase | O2M | no | no | Phases this user last modified. | | `created_submissions` | Submission | O2M | no | no | Submissions this user created. | @@ -312,7 +498,83 @@ An authenticated user, synced from Keycloak on first login. | `created_tracks` | Track | O2M | no | no | Tracks this user created. | | `modified_tracks` | Track | O2M | no | no | Tracks this user last modified. | | `modified_capabilities` | Capability | O2M | no | no | Hackathon capabilities this user last opened or closed. | +| `modified_settings` | HackathonSettings | O2M | no | no | Hackathon settings this user last modified. | +| `modified_windows` | HackathonWindows | O2M | no | no | Hackathon windows this user last modified. | +| `modified_forms` | HackathonForms | O2M | no | no | Hackathon forms this user last modified. | +| `form_responses` | FormResponse | O2M | no | no | Registration form responses about this user. | +| `submitted_form_responses` | FormResponse | O2M | no | no | Registration form responses this user entered. | +| `modified_prizes` | HackathonPrizes | O2M | no | no | Prize tables this user last modified. | | `preferred_projects` | Project | M2M | no | no | Projects this user has marked as preferred. | +| `votes` | Vote | O2M | no | no | Votes cast by this user. | +| `jury_categories` | VoteCategory | M2M | no | no | Vote categories where this user is a jury member. | | `participations` | Participant | O2M | yes | no | | | `team_participations` | TeamParticipant | O2M | yes | no | | +## Vote + +A single atomic judgment from one voter on one submission within one category. + +### Fields + +| Column | Type | Required | Unique | Immutable | Default | Description | +|--------|------|----------|--------|-----------|---------|-------------| +| `vote_type` | enum(single_choice, ranked, points) | yes | no | no | no | Discriminator for the vote method. | +| `value` | int | no | no | no | no | Rank position (ranked) or points awarded (points-based). Optional for single_choice. | +| `created_at` | time.Time | yes | no | yes | yes | Timestamp when the vote was created. | +| `modified_at` | time.Time | yes | no | no | yes | Timestamp of the last modification. | + +### Relationships + +| Edge | Target | Relation | Inverse | Required | Description | +|------|--------|----------|---------|----------|-------------| +| `category` | VoteCategory | M2O | yes | yes | The vote category this vote belongs to. | +| `voter` | User | M2O | yes | yes | Keycloak user ID of the voter. | +| `submission` | Submission | M2O | yes | no | The submission this vote is for. | + +### Indexes + +- `vote_category_votes, user_votes, submission_votes` *(unique)* + +## VoteCategory + +A voting category within a hackathon, defining the criteria and rules for one dimension of evaluation. + +### Fields + +| Column | Type | Required | Unique | Immutable | Default | Description | +|--------|------|----------|--------|-----------|---------|-------------| +| `name` | string | yes | no | no | no | Display name of the category (e.g. "Coolness", "Novelty"). | +| `description` | string | no | no | no | no | Criteria and instructions for voters. | +| `voting_method` | enum(single_choice, ranked, points) | yes | no | no | no | How votes are cast: single choice, ranked, or points-based. | +| `voter_type` | enum(all_participants, jury) | yes | no | no | no | Who can vote: all participants or jury only. | +| `max_points` | int | no | no | no | no | Maximum points a voter can distribute across submissions (points-based voting only). | +| `created_at` | time.Time | yes | no | yes | yes | Timestamp when the category was created. | +| `modified_at` | time.Time | yes | no | no | yes | Timestamp of the last modification. | + +### Relationships + +| Edge | Target | Relation | Inverse | Required | Description | +|------|--------|----------|---------|----------|-------------| +| `hackathon` | Hackathon | M2O | yes | yes | The hackathon this category belongs to. | +| `jury_members` | User | M2M | yes | no | Users assigned as jury members for this category (M2M). Only used when voter_type is JURY. | +| `votes` | Vote | O2M | no | no | All votes cast for this category. | +| `results` | VoteResult | O2M | no | no | Placements assigned to this category. | + +## VoteResult + +A placement entry within a vote category. Multiple VoteResults can exist per category. + +### Fields + +| Column | Type | Required | Unique | Immutable | Default | Description | +|--------|------|----------|--------|-----------|---------|-------------| +| `position` | int | yes | no | no | no | Ordering hint (1 = first place, 2 = second, etc.). Not unique — ties allowed. | +| `title` | string | no | no | no | no | Optional custom title for the placement (e.g. "Most Innovative"). | + +### Relationships + +| Edge | Target | Relation | Inverse | Required | Description | +|------|--------|----------|---------|----------|-------------| +| `vote_category` | VoteCategory | M2O | yes | yes | The category this result belongs to. | +| `submission` | Submission | M2O | yes | yes | The submission being placed. | + diff --git a/components/backend/cmd/seed/README.md b/components/backend/cmd/seed/README.md index 837a3024..a229f6a9 100644 --- a/components/backend/cmd/seed/README.md +++ b/components/backend/cmd/seed/README.md @@ -1,7 +1,7 @@ # Seed Data -`just seed` populates the database with a fixture designed to exercise the UI -across past / ongoing / upcoming hackathons, public and private visibility, +`just db::seed` populates the database with a fixture designed to exercise the +UI across past / ongoing / upcoming hackathons, public and private visibility, approved and proposed projects, draft and final submissions, and a waitlisted participant. All timestamps are relative to `time.Now()` at seed time, so re-seeding keeps the ongoing hackathon ongoing. @@ -32,7 +32,7 @@ from config; the other three are hardcoded constants in [main.go](main.go). ## Timeline Illustrative dates below assume a seed run on **2026-04-22**. Actual dates slide -with when you run `just seed`. +with when you run `just db::seed`. ```mermaid gantt diff --git a/components/backend/cmd/seed/main.go b/components/backend/cmd/seed/main.go index 0be294cd..b99fc39d 100644 --- a/components/backend/cmd/seed/main.go +++ b/components/backend/cmd/seed/main.go @@ -9,10 +9,13 @@ import ( _ "github.com/lib/pq" "github.com/swissdatasciencecenter/hackagon/components/backend/ent" + entcapability "github.com/swissdatasciencecenter/hackagon/components/backend/ent/capability" "github.com/swissdatasciencecenter/hackagon/components/backend/ent/hackathon" "github.com/swissdatasciencecenter/hackagon/components/backend/ent/project" + _ "github.com/swissdatasciencecenter/hackagon/components/backend/ent/runtime" // registers schema hooks and default values "github.com/swissdatasciencecenter/hackagon/components/backend/ent/submission" "github.com/swissdatasciencecenter/hackagon/components/backend/ent/user" + "github.com/swissdatasciencecenter/hackagon/components/backend/internal/capability" "github.com/swissdatasciencecenter/hackagon/components/backend/internal/config" "github.com/swissdatasciencecenter/hackagon/components/backend/internal/logx" middleware "github.com/swissdatasciencecenter/hackagon/components/backend/internal/middleware" @@ -27,6 +30,21 @@ const ( // sentinelHackathon is checked to make this script idempotent. const sentinelHackathon = "AI Innovation Challenge 2026" +// seedCover is where an event's picture lives in the object store. +// +// A ROOT-RELATIVE path, not an absolute URL: the same value has to resolve from +// localhost, from a Cloudflare tunnel and from a deployment, and +// "http://localhost:9000/..." renders only for the machine that wrote it. Caddy +// (tunnel, built server) and the vite dev proxy both map /objects onto the +// store -- see docs/storage.md. +// +// Keyed by SLUG rather than by the hackathon's id, because ids are new on every +// reseed and the pictures are not: the objects are uploaded once by +// .devcontainer/rustfs-init.sh --seed-media and reused by every `just db::seed`. +func seedCover(slug string) string { + return "/objects/hackagon-dev/hackathons/seed/" + slug + "/cover.webp" +} + func main() { logx.Setup("") @@ -51,26 +69,29 @@ func main() { logx.Fatal("migrate schema", "err", err) } - exists, err := db.Hackathon.Query().Where(hackathon.NameEQ(sentinelHackathon)).Exist(ctx) - if err != nil { - logx.Fatal("check sentinel", "err", err) - } - if exists { - slog.Info("seed data already present, skipping") - - return - } - enf, err := middleware.NewRBACEnforcer(cfg) if err != nil { logx.Fatal("create enforcer", "err", err) } // alice is a hackathon organizer globally (can create new hackathons). + // Granted before the sentinel check so that re-running the seeder backfills + // the role on an already-seeded database. Casbin grouping writes are + // idempotent, so repeat runs are harmless. if _, err := enf.AddGlobalRole(aliceKeycloakID, middleware.HackathonOrganizer); err != nil { logx.Fatal("assign organizer role to alice", "err", err) } + exists, err := db.Hackathon.Query().Where(hackathon.NameEQ(sentinelHackathon)).Exist(ctx) + if err != nil { + logx.Fatal("check sentinel", "err", err) + } + if exists { + slog.Info("seed data already present, skipping") + + return + } + if err := seed(ctx, db, cfg, enf); err != nil { logx.Fatal("seed", "err", err) } @@ -165,10 +186,182 @@ func seedInTx( if err := seedH3(ctx, db, now, admin, alice, enf); err != nil { return fmt.Errorf("h3: %w", err) } + // Platform-level pages — the footer links (about/privacy/terms) reach + // these, so a dev instance should have them from the first boot. + if err := seedSitePages(ctx, db, admin); err != nil { + return fmt.Errorf("site pages: %w", err) + } return nil } +// seedSitePages creates the platform's own content pages. Unlike hackathon +// pages these belong to no event; the admin owns them. +func seedSitePages(ctx context.Context, db *ent.Client, admin *ent.User) error { + pages := []struct { + slug, title, content string + order int + }{ + { + slug: "about", + title: "About Hackagon", + order: 1, + content: `## What this is + +Hackagon is the hackathon platform built by the **Swiss Data Science Center** +(SDSC), a joint venture of ETH Zurich and EPFL. + +It runs the whole life of an event: publication, registration and waitlists, +project proposals, team formation, submissions, voting and prizes. + +## Who runs it + +The SDSC team, together with the organizers of each event. Every hackathon has +its own owners, who control its pages, phases and participants. + +## Get in touch + +Questions about a specific event go to its organizers. Anything about the +platform itself: [hackagon@sdsc.dev](mailto:hackagon@sdsc.dev).`, + }, + { + slug: "privacy", + title: "Privacy", + order: 2, + content: `## What we store + +Your account details come from the login provider: name, username and email. +Beyond that we keep what you do on the platform — the events you join, the +projects you propose, the teams you belong to, the answers you give on +registration forms, and the submissions your team makes. + +## Who can see it + +Event organizers see the participants of their own event and the answers given +on its forms. Other participants see your name and the teams and projects you +are part of. Votes are counted in aggregate; individual ballots are not shown +to other participants. + +## Your rights + +You can ask for a copy of your data or for your account to be deleted. Until +the self-service tooling lands, write to +[privacy@sdsc.dev](mailto:privacy@sdsc.dev). + +*This is dev-instance placeholder text — replace it before running a real +event.*`, + }, + { + slug: "terms", + title: "Terms of use", + order: 3, + content: `## Taking part + +Register for an event and you agree to follow its rules and its code of +conduct. Organizers decide who is admitted, and may remove anyone who breaks +those rules. + +## Your work + +What you build stays yours. By submitting it you allow the organizers to show +and judge it as part of the event, and to name you among the participants. + +## The platform + +Hackagon is provided as is, without warranty. Organizers are responsible for +the content of their own events. + +*This is dev-instance placeholder text — replace it before running a real +event.*`, + }, + } + + builders := make([]*ent.SitePageCreate, 0, len(pages)) + for _, p := range pages { + builders = append(builders, db.SitePage.Create(). + SetSlug(p.slug). + SetTitle(p.title). + SetContent(p.content). + SetOrder(p.order). + SetVisible(true). + SetCreator(admin). + SetModifier(admin)) + } + if err := db.SitePage.CreateBulk(builders...).Exec(ctx); err != nil { + return fmt.Errorf("create site pages: %w", err) + } + + return nil +} + +// phaseWindow is the pair of phases describing when a capability is expected to +// open and close. Either may be nil; both nil means the capability is manually +// driven and shows members no countdown. +type phaseWindow struct { + opens *ent.Phase + closes *ent.Phase +} + +// seedCapabilities creates the full capability set for a hackathon: `enabled` +// names the ones switched on, `schedule` optionally links them to phases. +// +// Every hackathon gets every row, so none is left ungoverned — dev data should +// exercise the gates rather than bypass them. Must run after the phases exist. +func seedCapabilities( + ctx context.Context, + db *ent.Client, + h *ent.Hackathon, + modifier *ent.User, + enabled []capability.Capability, + schedule map[capability.Capability]phaseWindow, +) error { + on := make(map[capability.Capability]bool, len(enabled)) + for _, c := range enabled { + on[c] = true + } + + all := capability.All() + builders := make([]*ent.CapabilityCreate, 0, len(all)) + for _, c := range all { + b := db.Capability.Create(). + SetCapability(entcapability.Capability(c)). + SetEnabled(on[c]). + SetHackathon(h). + SetModifier(modifier) + if w, ok := schedule[c]; ok { + if w.opens != nil { + b = b.SetOpenInPhase(w.opens) + } + if w.closes != nil { + b = b.SetClosedInPhase(w.closes) + } + } + builders = append(builders, b) + } + + if err := db.Capability.CreateBulk(builders...).Exec(ctx); err != nil { + return fmt.Errorf("capabilities for %q: %w", h.Name, err) + } + + return nil +} + +// seedSettings creates the default HackathonSettings row, mirroring what +// HackathonService.Create does (both flags false). EditSettings UPDATES and +// answers NotFound when the row is missing — so before this existed, no +// seeded hackathon could ever open voting: the organizer's "Open voting" +// button failed with "That item no longer exists", found by the mobile +// full-route sweep's setup, not by any functional suite. +func seedSettings(ctx context.Context, db *ent.Client, h *ent.Hackathon, modifier *ent.User) error { + if _, err := db.HackathonSettings.Create(). + SetHackathon(h). + SetModifier(modifier). + Save(ctx); err != nil { + return fmt.Errorf("settings for %q: %w", h.Name, err) + } + return nil +} + // seedH1 seeds the upcoming public AI Innovation Challenge hackathon. // alice acts as organizer (creator); charles is waitlisted. func seedH1( @@ -180,6 +373,7 @@ func seedH1( ) error { h, err := db.Hackathon.Create(). SetName(sentinelHackathon). + SetLogo(seedCover("ai-innovation-challenge-2026")). SetVisibility(hackathon.VisibilityPublic). SetDescription("A 3-day hackathon focused on building AI-powered applications. Open to all skill levels."). SetStartsAt(now.AddDate(0, 0, 19)). @@ -190,7 +384,11 @@ func seedH1( if err != nil { return err } + if err := seedSettings(ctx, db, h, alice); err != nil { + return err + } + phases := map[string]*ent.Phase{} for _, ph := range []struct { name, desc string start, end time.Time @@ -199,7 +397,7 @@ func seedH1( {"Hacking", "Build your project. Mentors available throughout the day.", now.AddDate(0, 0, 20).Add(9 * time.Hour), now.AddDate(0, 0, 20).Add(21 * time.Hour)}, {"Judging", "Present your project to the judges. Top 3 teams win prizes.", now.AddDate(0, 0, 21).Add(10 * time.Hour), now.AddDate(0, 0, 21).Add(16 * time.Hour)}, } { - if _, err := db.Phase.Create(). + p, err := db.Phase.Create(). SetName(ph.name). SetDescription(ph.desc). SetStartsAt(ph.start). @@ -207,9 +405,31 @@ func seedH1( SetHackathon(h). SetCreator(alice). SetModifier(alice). - Save(ctx); err != nil { + Save(ctx) + if err != nil { return fmt.Errorf("phase %q: %w", ph.name, err) } + phases[ph.name] = p + } + + // Upcoming: sign-ups are open, nothing else has started. The rest are + // scheduled against the phases, so members see "opens in 19 days" rather + // than a bare "closed" — except voting, which is left unlinked because it + // opens abruptly on the day. + if err := seedCapabilities(ctx, db, h, alice, + []capability.Capability{capability.Register}, + map[capability.Capability]phaseWindow{ + capability.ProposeProjects: {opens: phases["Ideation"], closes: phases["Hacking"]}, + capability.SetTeamPreferences: {opens: phases["Ideation"], closes: phases["Hacking"]}, + capability.CreateProjectSubmissions: {opens: phases["Hacking"], closes: phases["Judging"]}, + capability.ViewResults: {opens: phases["Judging"], closes: nil}, + // Unscheduled on purpose: registration is driven by hand, and voting + // opens abruptly on the day, so any countdown would be a guess. + capability.Register: {opens: nil, closes: nil}, + capability.Vote: {opens: nil, closes: nil}, + }, + ); err != nil { + return err } for i, pg := range []struct { @@ -458,6 +678,7 @@ func seedH2( ) error { h, err := db.Hackathon.Create(). SetName("Climate Tech Hackathon 2026"). + SetLogo(seedCover("climate-tech-hackathon-2026")). SetVisibility(hackathon.VisibilityPublic). SetDescription("Build solutions to address climate change through technology. Focus on energy, agriculture, and sustainability."). SetStartsAt(now.AddDate(0, 0, -2)). @@ -468,7 +689,11 @@ func seedH2( if err != nil { return err } + if err := seedSettings(ctx, db, h, admin); err != nil { + return err + } + phases := map[string]*ent.Phase{} for _, ph := range []struct { name, desc string start, end time.Time @@ -477,7 +702,7 @@ func seedH2( {"Hacking", "Build your climate tech solution with support from domain experts.", now.AddDate(0, 0, 0), now.AddDate(0, 0, 1)}, {"Judging", "Demo day: present your solution to a panel of sustainability experts.", now.AddDate(0, 0, 2).Add(9 * time.Hour), now.AddDate(0, 0, 2).Add(17 * time.Hour)}, } { - if _, err := db.Phase.Create(). + p, err := db.Phase.Create(). SetName(ph.name). SetDescription(ph.desc). SetStartsAt(ph.start). @@ -485,9 +710,43 @@ func seedH2( SetHackathon(h). SetCreator(admin). SetModifier(admin). - Save(ctx); err != nil { + Save(ctx) + if err != nil { return fmt.Errorf("phase %q: %w", ph.name, err) } + phases[ph.name] = p + } + + // Mid-event: registration has closed, the building actions are open. Their + // closing phases give the open capabilities a real deadline to show, which is + // the case an upcoming hackathon cannot exercise. + if err := seedCapabilities(ctx, db, h, admin, + []capability.Capability{ + // Register is ON, and this is the fixture's ONLY event a non-member + // can join: alice owns h1 and bob and charles are already in it, h3 + // is private and past, and the archive editions are finished. With + // it off, `Join` answered FailedPrecondition to every attempt — so + // 05-new-user-funnel and 09-browse-and-join clicked a button that + // could not work, and passed anyway because neither asserted that + // the join had LANDED. Late-closing registration is a legitimate + // state, but it cannot be the state of the only joinable event. + capability.Register, + capability.ProposeProjects, + capability.SetTeamPreferences, + capability.CreateProjectSubmissions, + }, + map[capability.Capability]phaseWindow{ + capability.ProposeProjects: {opens: phases["Ideation"], closes: phases["Hacking"]}, + capability.SetTeamPreferences: {opens: phases["Ideation"], closes: phases["Hacking"]}, + capability.CreateProjectSubmissions: {opens: phases["Hacking"], closes: phases["Judging"]}, + capability.ViewResults: {opens: phases["Judging"], closes: nil}, + // Unscheduled on purpose: registration is driven by hand, and voting + // opens abruptly on the day, so any countdown would be a guess. + capability.Register: {opens: nil, closes: nil}, + capability.Vote: {opens: nil, closes: nil}, + }, + ); err != nil { + return err } for i, pg := range []struct { @@ -659,6 +918,7 @@ func seedH3( ) error { h, err := db.Hackathon.Create(). SetName("Internal Product Sprint"). + SetLogo(seedCover("internal-product-sprint")). SetVisibility(hackathon.VisibilityPrivate). SetDescription("An internal sprint to improve developer tooling and data infrastructure."). SetStartsAt(now.AddDate(0, -1, -20)). @@ -669,6 +929,18 @@ func seedH3( if err != nil { return err } + if err := seedSettings(ctx, db, h, admin); err != nil { + return err + } + + // Finished: everything shut except the published results. Left unscheduled — + // there is nothing left to count down to, so members should see a plain + // "closed" rather than a date in the past. + if err := seedCapabilities(ctx, db, h, admin, + []capability.Capability{capability.ViewResults}, nil, + ); err != nil { + return err + } for _, ph := range []struct { name, desc string diff --git a/components/backend/cmd/service/main.go b/components/backend/cmd/service/main.go index 9d70516e..97bb0896 100644 --- a/components/backend/cmd/service/main.go +++ b/components/backend/cmd/service/main.go @@ -10,6 +10,7 @@ import ( _ "github.com/lib/pq" "github.com/swissdatasciencecenter/hackagon/components/backend/ent" + _ "github.com/swissdatasciencecenter/hackagon/components/backend/ent/runtime" // registers schema hooks and default values "github.com/swissdatasciencecenter/hackagon/components/backend/ent/user" "github.com/swissdatasciencecenter/hackagon/components/backend/internal/config" "github.com/swissdatasciencecenter/hackagon/components/backend/internal/logx" diff --git a/components/backend/data/test/config/config.yaml b/components/backend/data/test/config/config.yaml index bf1d99a7..979b7cd5 100644 --- a/components/backend/data/test/config/config.yaml +++ b/components/backend/data/test/config/config.yaml @@ -13,5 +13,33 @@ oidc: jwksurl: "http://localhost:8180/realms/hackagon/protocol/openid-connect/certs" issuerurl: "http://localhost:8180/realms/hackagon" algorithm: "RS256" +# S3-compatible object store for uploaded files: the `rustfs` container from +# .devcontainer/docker-compose.yml. Start it and create the bucket with +# docker compose -f .devcontainer/docker-compose.yml up -d rustfs +# bash .devcontainer/rustfs-init.sh +# Addressed by compose service name, not localhost like the other services: +# inside the dev container port 9000 is already Keycloak's management port, +# so there is nothing to bridge onto. Running the backend natively (no +# devcontainer)? Override with HACKAGON_STORAGE_ENDPOINT=http://localhost:9000. +# +# The credentials below are DEV-ONLY and worthless — same status as the +# postgres password above. A deployment supplies them from a secret store via +# HACKAGON_STORAGE_ACCESSKEY / HACKAGON_STORAGE_SECRETKEY. +storage: + endpoint: "http://rustfs:9000" + region: "us-east-1" + bucket: "hackagon-dev" + accesskey: "hackagon-dev" + secretkey: "hackagon-dev-secret" + usepathstyle: true logging: level: info +# RPC journal: one JSON line per gRPC call, in the shape the e2e recipe uses. +# A LOCAL ANALYSIS TOOL — see docs/backend/rpc-journal.md for exactly what it +# records (actor username, method, allowlisted request fields, status code, +# response ids) and what it never reads (IP, user agent, session id, free +# text). Off here and off by default in code; env override +# HACKAGON_AUDIT_ENABLED=true. `path` is relative to components/backend. +audit: + enabled: false + path: ".output/audit/rpc-journal.jsonl" diff --git a/components/backend/db/schema/formresponse.go b/components/backend/db/schema/formresponse.go new file mode 100644 index 00000000..de1e6b4f --- /dev/null +++ b/components/backend/db/schema/formresponse.go @@ -0,0 +1,70 @@ +package schema + +import ( + "time" + + "entgo.io/ent" + "entgo.io/ent/schema" + "entgo.io/ent/schema/edge" + "entgo.io/ent/schema/field" + "entgo.io/ent/schema/index" +) + +// FormResponse holds the schema definition for the FormResponse entity. +type FormResponse struct { + ent.Schema +} + +func (FormResponse) Annotations() []schema.Annotation { + return []schema.Annotation{ + schema.Comment( + "One registrant's answers to a hackathon's registration form, " + + "validated against the organizer's schema at submission time.", + ), + } +} + +// Fields of the FormResponse. +func (FormResponse) Fields() []ent.Field { + return []ent.Field{ + field.JSON("responses", map[string]any{}). + Comment("Field answers keyed by the form field key."), + field.JSON("consents", map[string]bool{}). + Comment("Consent checkboxes keyed by the consent key."), + field.Time("created_at"). + Immutable(). + Default(time.Now). + Comment("Timestamp when the response was submitted."), + field.Time("modified_at"). + Default(time.Now).UpdateDefault(time.Now). + Comment("Timestamp of the last modification."), + } +} + +// Edges of the FormResponse. +func (FormResponse) Edges() []ent.Edge { + return []ent.Edge{ + edge.From("hackathon", Hackathon.Type). + Ref("form_responses").Unique().Required(). + Comment("The hackathon the response belongs to."), + edge.From("user", User.Type). + Ref("form_responses").Unique().Required(). + Comment("The registrant the response is about."), + edge.From("submitted_by", User.Type). + Ref("submitted_form_responses").Unique().Required(). + Comment("Who actually entered it — the registrant, or an organizer digitizing a paper form."), + } +} + +// Indexes of the FormResponse. +func (FormResponse) Indexes() []ent.Index { + return []ent.Index{ + index.Edges("hackathon", "user").Unique(), + } +} + +func (FormResponse) Mixin() []ent.Mixin { + return []ent.Mixin{ + UUIDMixin{}, + } +} diff --git a/components/backend/db/schema/hackathon.go b/components/backend/db/schema/hackathon.go index 2c55eceb..865f015b 100644 --- a/components/backend/db/schema/hackathon.go +++ b/components/backend/db/schema/hackathon.go @@ -48,6 +48,15 @@ func (Hackathon) Fields() []ent.Field { field.String("logo"). Optional(). Comment("URL or path to the hackathon logo image."), + field.Int32("max_participants"). + Optional().Nillable(). + Min(0). + Comment( + "Maximum number of CONFIRMED participants (is_waiting=false); " + + "the waiting list is not counted. Nil or 0 means unlimited — " + + "the default, and the behaviour of every row that predates " + + "the column, so no existing event is silently capped.", + ), field.UUID("current_phase_id", uuid.UUID{}).Optional().Nillable(). Comment( "The phase an organizer has declared current. Nil means fall back to " + @@ -70,6 +79,8 @@ func (Hackathon) Edges() []ent.Edge { Comment("Users who are participating or waitlisted."), edge.To("pages", Page.Type). Comment("Content pages associated with this hackathon."), + edge.To("invites", HackathonInvite.Type). + Comment("Shareable invitation links granting visibility of this hackathon."), edge.To("phases", Phase.Type). Comment("Temporal phases (e.g. ideation, hacking, judging)."), edge.To("capabilities", Capability.Type). @@ -81,6 +92,22 @@ func (Hackathon) Edges() []ent.Edge { Field("current_phase_id"). Annotations(entsql.OnDelete(entsql.SetNull)). Comment("Set by AdvancePhase; SET NULL so deleting a phase does not orphan it."), + edge.To("vote_categories", VoteCategory.Type). + Comment("Voting categories scoped to this hackathon."), + edge.To("settings", HackathonSettings.Type). + Unique(). + Comment("Configuration settings for this hackathon."), + edge.To("windows", HackathonWindows.Type). + Unique(). + Comment("Enforced time windows for this hackathon."), + edge.To("forms", HackathonForms.Type). + Unique(). + Comment("Organizer-defined form schemas and voting policy."), + edge.To("form_responses", FormResponse.Type). + Comment("Registration form responses submitted for this hackathon."), + edge.To("prize_table", HackathonPrizes.Type). + Unique(). + Comment("The prize table and awards for this hackathon."), edge.From("creator", User.Type). Ref("created_hackathons").Unique().Required().Immutable(). Comment("The user who created this hackathon."), diff --git a/components/backend/db/schema/hackathonforms.go b/components/backend/db/schema/hackathonforms.go new file mode 100644 index 00000000..de4cdabb --- /dev/null +++ b/components/backend/db/schema/hackathonforms.go @@ -0,0 +1,78 @@ +package schema + +import ( + "time" + + "entgo.io/ent" + "entgo.io/ent/schema" + "entgo.io/ent/schema/edge" + "entgo.io/ent/schema/field" +) + +// HackathonForms holds the schema definition for the HackathonForms entity. +type HackathonForms struct { + ent.Schema +} + +func (HackathonForms) Annotations() []schema.Annotation { + return []schema.Annotation{ + schema.Comment( + "Organizer-defined form schemas and voting policy for a hackathon. " + + "Schemas are stored as JSON; SubmitRegistrationForm validates " + + "responses against them.", + ), + } +} + +// Fields of the HackathonForms. +func (HackathonForms) Fields() []ent.Field { + return []ent.Field{ + field.JSON("registration_fields", []map[string]any{}). + Optional(). + Comment("Registration form fields ({key,label,type,required,maxMb})."), + field.JSON("registration_consents", []map[string]any{}). + Optional(). + Comment("Registration consents ({key,label,required})."), + field.JSON("submission_fields", []map[string]any{}). + Optional(). + Comment("Submission form fields ({key,label,type,required,maxMb})."), + field.JSON("voting_policy", map[string]any{}). + Optional(). + Comment("Pinned voting mechanism decisions (mechanism, scale, tie-breaks)."), + field.JSON("email_templates", map[string]string{}). + Optional(). + Comment( + "Organizer-authored notification copy, keyed by moment " + + "(registrationConfirmed, teamAssigned, deadlineReminder, results). " + + "Stored only — no notification service sends them yet.", + ), + field.JSON("branding", map[string]string{}). + Optional(). + Comment("Event branding (primaryColor, accentColor, bannerText). The logo lives on the hackathon row itself."), + field.Time("created_at"). + Immutable(). + Default(time.Now). + Comment("Timestamp when the forms row was created."), + field.Time("modified_at"). + Default(time.Now).UpdateDefault(time.Now). + Comment("Timestamp of the last modification."), + } +} + +// Edges of the HackathonForms. +func (HackathonForms) Edges() []ent.Edge { + return []ent.Edge{ + edge.From("hackathon", Hackathon.Type). + Ref("forms").Unique().Required(). + Comment("The hackathon these forms belong to."), + edge.From("modifier", User.Type). + Ref("modified_forms").Unique().Required(). + Comment("The user who last modified these forms."), + } +} + +func (HackathonForms) Mixin() []ent.Mixin { + return []ent.Mixin{ + UUIDMixin{}, + } +} diff --git a/components/backend/db/schema/hackathoninvite.go b/components/backend/db/schema/hackathoninvite.go new file mode 100644 index 00000000..866d22cd --- /dev/null +++ b/components/backend/db/schema/hackathoninvite.go @@ -0,0 +1,95 @@ +package schema + +import ( + "log/slog" + "time" + + "entgo.io/ent" + "entgo.io/ent/schema" + "entgo.io/ent/schema/edge" + "entgo.io/ent/schema/field" + "entgo.io/ent/schema/index" + "github.com/google/uuid" +) + +// newInviteToken mints the link secret. v4 (random) rather than the v7 used +// for ids: a token must not encode its creation time or be guessable from a +// neighbouring one. +func newInviteToken() uuid.UUID { + t, err := uuid.NewRandom() + if err != nil { + slog.Error("couldn't create invite token", "err", err) + panic(err) + } + + return t +} + +// HackathonInvite is a shareable link that lets someone reach a private +// hackathon they could not otherwise see. +// +// The token is the whole secret: anyone holding it may view the event and ask +// to join, so it is generated server-side (never derived from the hackathon +// id) and can be revoked. Invites are deliberately MULTI-USE — an organizer +// mails one link to a group — and redeeming one does NOT admit anybody: it +// unlocks the event page, and the normal Join → waitlist → approval path still +// applies, so a forwarded link cannot put a stranger into the roster. +type HackathonInvite struct { + ent.Schema +} + +func (HackathonInvite) Annotations() []schema.Annotation { + return []schema.Annotation{ + schema.Comment( + "A revocable, shareable invitation link granting visibility of a private hackathon.", + ), + } +} + +// Fields of the HackathonInvite. +func (HackathonInvite) Fields() []ent.Field { + return []ent.Field{ + field.UUID("token", uuid.UUID{}). + Default(newInviteToken). + Unique(). + Immutable(). + Comment("The secret in the invite URL. Generated server-side; never derived from the hackathon id."), + field.String("note"). + Optional(). + Comment("Free-text reminder of who the link was sent to; organizer-facing only."), + field.Time("created_at"). + Immutable(). + Default(time.Now). + Comment("Timestamp when the invite was generated."), + field.Time("revoked_at"). + Optional(). + Nillable(). + Comment("When set, the link stops working. Revoking is preferred over deletion so the audit trail survives."), + } +} + +// Edges of the HackathonInvite. +func (HackathonInvite) Edges() []ent.Edge { + return []ent.Edge{ + edge.From("hackathon", Hackathon.Type). + Ref("invites").Unique().Required().Immutable(). + Comment("The hackathon this invite grants visibility of."), + edge.From("creator", User.Type). + Ref("created_invites").Unique().Required().Immutable(). + Comment("The organizer or admin who generated the link."), + } +} + +// Indexes of the HackathonInvite. +func (HackathonInvite) Indexes() []ent.Index { + return []ent.Index{ + // Every redemption looks the row up by token alone. + index.Fields("token").Unique(), + } +} + +func (HackathonInvite) Mixin() []ent.Mixin { + return []ent.Mixin{ + UUIDMixin{}, + } +} diff --git a/components/backend/db/schema/hackathonprizes.go b/components/backend/db/schema/hackathonprizes.go new file mode 100644 index 00000000..d06ec32d --- /dev/null +++ b/components/backend/db/schema/hackathonprizes.go @@ -0,0 +1,65 @@ +package schema + +import ( + "time" + + "entgo.io/ent" + "entgo.io/ent/schema" + "entgo.io/ent/schema/edge" + "entgo.io/ent/schema/field" +) + +// HackathonPrizes holds the schema definition for the HackathonPrizes entity. +type HackathonPrizes struct { + ent.Schema +} + +func (HackathonPrizes) Annotations() []schema.Annotation { + return []schema.Annotation{ + schema.Comment( + "The organizer-defined prize table and, after Finalize, the " + + "awards. Votes are advisory: nothing is won until the admin " + + "finalizes, and the table stays admin-editable afterwards.", + ), + } +} + +// Fields of the HackathonPrizes. +func (HackathonPrizes) Fields() []ent.Field { + return []ent.Field{ + field.JSON("prizes", []map[string]any{}). + Optional(). + Comment("Prize table ({rank,title}); rank 0 is a special prize."), + field.JSON("awards", []map[string]any{}). + Optional(). + Comment("Awarded submissions ({rank|special, submissionId}) set at Finalize."), + field.Bool("finalized"). + Default(false). + Comment("Whether the admin has spoken; results are advisory before this."), + field.Time("created_at"). + Immutable(). + Default(time.Now). + Comment("Timestamp when the prize table was created."), + field.Time("modified_at"). + Default(time.Now).UpdateDefault(time.Now). + Comment("Timestamp of the last modification."), + } +} + +// Edges of the HackathonPrizes. +func (HackathonPrizes) Edges() []ent.Edge { + return []ent.Edge{ + edge.From("hackathon", Hackathon.Type). + Ref("prize_table").Unique().Required(). + Comment("The hackathon this prize table belongs to."), + edge.From("modifier", User.Type). + Ref("modified_prizes").Unique().Required(). + Comment("The user who last modified the prize table."), + } +} + +func (HackathonPrizes) Mixin() []ent.Mixin { + return []ent.Mixin{ + UUIDMixin{}, + } +} diff --git a/components/backend/db/schema/hackathonsettings.go b/components/backend/db/schema/hackathonsettings.go new file mode 100644 index 00000000..a8f9280b --- /dev/null +++ b/components/backend/db/schema/hackathonsettings.go @@ -0,0 +1,58 @@ +package schema + +import ( + "time" + + "entgo.io/ent" + "entgo.io/ent/schema" + "entgo.io/ent/schema/edge" + "entgo.io/ent/schema/field" +) + +// HackathonSettings holds the schema definition for the HackathonSettings entity. +type HackathonSettings struct { + ent.Schema +} + +func (HackathonSettings) Annotations() []schema.Annotation { + return []schema.Annotation{ + schema.Comment("Configuration settings for a hackathon."), + } +} + +// Fields of the HackathonSettings. +func (HackathonSettings) Fields() []ent.Field { + return []ent.Field{ + field.Bool("registrations_enabled"). + Default(false). + Comment("Whether new participants can register for this hackathon."), + field.Bool("voting_enabled"). + Default(false). + Comment("Whether voting is enabled for this hackathon."), + field.Time("created_at"). + Immutable(). + Default(time.Now). + Comment("Timestamp when the settings were created."), + field.Time("modified_at"). + Default(time.Now).UpdateDefault(time.Now). + Comment("Timestamp of the last modification."), + } +} + +// Edges of the HackathonSettings. +func (HackathonSettings) Edges() []ent.Edge { + return []ent.Edge{ + edge.From("hackathon", Hackathon.Type). + Ref("settings").Unique().Required(). + Comment("The hackathon this settings entry belongs to."), + edge.From("modifier", User.Type). + Ref("modified_settings").Unique().Required(). + Comment("The user who last modified these settings."), + } +} + +func (HackathonSettings) Mixin() []ent.Mixin { + return []ent.Mixin{ + UUIDMixin{}, + } +} diff --git a/components/backend/db/schema/hackathonwindows.go b/components/backend/db/schema/hackathonwindows.go new file mode 100644 index 00000000..0976ee7e --- /dev/null +++ b/components/backend/db/schema/hackathonwindows.go @@ -0,0 +1,81 @@ +package schema + +import ( + "time" + + "entgo.io/ent" + "entgo.io/ent/schema" + "entgo.io/ent/schema/edge" + "entgo.io/ent/schema/field" +) + +// HackathonWindows holds the schema definition for the HackathonWindows entity. +type HackathonWindows struct { + ent.Schema +} + +func (HackathonWindows) Annotations() []schema.Annotation { + return []schema.Annotation{ + schema.Comment( + "Per-hackathon time windows enforced on the acting RPCs " + + "(Join, Propose, SetPreference, CreateSubmission). " + + "Unset windows are not enforced; overrides are one-shot " + + "absolute extensions granted by an organizer.", + ), + } +} + +// Fields of the HackathonWindows. +func (HackathonWindows) Fields() []ent.Field { + return []ent.Field{ + field.Time("registration_opens"). + Optional().Nillable(). + Comment("Join is rejected before this instant."), + field.Time("registration_closes"). + Optional().Nillable(). + Comment("Join is rejected after this instant (unless overridden)."), + field.Time("proposals_close"). + Optional().Nillable(). + Comment("Propose is rejected after this instant."), + field.Time("preferences_close"). + Optional().Nillable(). + Comment("SetPreference is rejected after this instant."), + field.Time("submissions_close"). + Optional().Nillable(). + Comment("CreateSubmission is rejected after this instant (unless overridden)."), + field.Time("registration_override_until"). + Optional().Nillable(). + Comment("Manual walk-in window: registration stays open until this instant."), + field.Time("submissions_override_until"). + Optional().Nillable(). + Comment("Manual grace window: submissions stay open until this instant."), + field.String("late_policy"). + Optional(). + Comment("Human-readable note on how late submissions are handled."), + field.Time("created_at"). + Immutable(). + Default(time.Now). + Comment("Timestamp when the windows were created."), + field.Time("modified_at"). + Default(time.Now).UpdateDefault(time.Now). + Comment("Timestamp of the last modification."), + } +} + +// Edges of the HackathonWindows. +func (HackathonWindows) Edges() []ent.Edge { + return []ent.Edge{ + edge.From("hackathon", Hackathon.Type). + Ref("windows").Unique().Required(). + Comment("The hackathon these windows belong to."), + edge.From("modifier", User.Type). + Ref("modified_windows").Unique().Required(). + Comment("The user who last modified these windows."), + } +} + +func (HackathonWindows) Mixin() []ent.Mixin { + return []ent.Mixin{ + UUIDMixin{}, + } +} diff --git a/components/backend/db/schema/sitepage.go b/components/backend/db/schema/sitepage.go new file mode 100644 index 00000000..cded7105 --- /dev/null +++ b/components/backend/db/schema/sitepage.go @@ -0,0 +1,89 @@ +package schema + +import ( + "regexp" + "time" + + "entgo.io/ent" + "entgo.io/ent/schema" + "entgo.io/ent/schema/edge" + "entgo.io/ent/schema/field" + "entgo.io/ent/schema/index" +) + +// Slugs go straight into a URL path segment, so keep them to lowercase +// kebab-case rather than escaping surprises later. +var slugPattern = regexp.MustCompile(`^[a-z0-9]+(-[a-z0-9]+)*$`) + +// SitePage holds the schema definition for the SitePage entity. +// +// This is the platform-level counterpart to Page: About, Privacy, Terms and +// friends belong to the site itself, not to any single hackathon, so they +// cannot use Page (whose hackathon edge is Required) nor its casbin domain +// (/hackathon/<uuid>). SitePages are addressed by a stable slug so the +// frontend can route /about straight to one, and are authorized in the fixed +// "site" domain: global admins write, everyone reads the visible ones. +type SitePage struct { + ent.Schema +} + +func (SitePage) Annotations() []schema.Annotation { + return []schema.Annotation{ + schema.Comment("A platform-level content page (about, privacy, terms), addressed by slug."), + } +} + +// Fields of the SitePage. +func (SitePage) Fields() []ent.Field { + return []ent.Field{ + field.String("slug"). + Unique(). + NotEmpty(). + Match(slugPattern). + Comment("URL segment identifying the page (e.g. \"about\"); lowercase kebab-case."), + field.String("title"). + NotEmpty(). + Comment("Title of the page."), + field.Text("content"). + Comment("Markdown content of the page. Rendered through the frontend's sanitizing pipeline."), + field.Bool("visible"). + Default(false). + Comment("Whether the page is published. Drafts are readable by admins only."), + field.Int("order"). + Default(0). + Comment("Sort order for navigation listings; lower values appear first."), + field.Time("created_at"). + Immutable(). + Default(time.Now). + Comment("Timestamp when the page was created."), + field.Time("modified_at"). + Default(time.Now).UpdateDefault(time.Now). + Comment("Timestamp of the last modification."), + } +} + +// Edges of the SitePage. +func (SitePage) Edges() []ent.Edge { + return []ent.Edge{ + edge.From("creator", User.Type). + Ref("created_site_pages").Unique().Required().Immutable(). + Comment("The user who created this page."), + edge.From("modifier", User.Type). + Ref("modified_site_pages").Unique().Required(). + Comment("The user who last modified this page."), + } +} + +// Indexes of the SitePage. +func (SitePage) Indexes() []ent.Index { + return []ent.Index{ + index.Fields("order"), + index.Fields("visible"), + } +} + +func (SitePage) Mixin() []ent.Mixin { + return []ent.Mixin{ + UUIDMixin{}, + } +} diff --git a/components/backend/db/schema/submission.go b/components/backend/db/schema/submission.go index da87b0bf..9a2f643d 100644 --- a/components/backend/db/schema/submission.go +++ b/components/backend/db/schema/submission.go @@ -34,6 +34,12 @@ func (Submission) Fields() []ent.Field { field.String("result"). Optional(). Comment("Result or output of the submission (e.g. a URL)."), + field.JSON("form", map[string]string{}). + Optional(). + Comment( + "Structured answers keyed by the organizer's submission form fields " + + "(ConfigService.SetSubmissionForm). Validated on write against that schema.", + ), field.Enum("status"). Values("draft", "final"). Comment("Whether the submission is a draft or final."), @@ -58,6 +64,10 @@ func (Submission) Edges() []ent.Edge { edge.From("modifier", User.Type). Ref("modified_submissions").Unique(). Comment("The user who last modified this submission."), + edge.To("votes", Vote.Type). + Comment("Votes cast on this submission."), + edge.To("vote_results", VoteResult.Type). + Comment("Vote results placing this submission."), } } diff --git a/components/backend/db/schema/user.go b/components/backend/db/schema/user.go index 45cc1cd5..97bf414f 100644 --- a/components/backend/db/schema/user.go +++ b/components/backend/db/schema/user.go @@ -32,6 +32,25 @@ func (User) Fields() []ent.Field { Comment("Preferred display name of the user."), field.String("email").Optional().Default(""). Comment("Email of the user, same as in Keycloak"), + // The platform's own profile, filled in once by the person it describes. + // + // These mirror the keys the registration forms ask for (affiliation, + // skills, diet, avatar) so an event can prefill from them rather than + // asking the same four questions at every hackathon. Consents are + // deliberately NOT here: a consent is an agreement with one event, given + // on a date, and it belongs to that event's registration record. + // + // All optional and defaulting to "": a profile nobody has filled in is a + // normal state, not a missing one, and Keycloak owns none of these — see + // syncFromKeycloak, which must never overwrite them. + field.String("affiliation").Optional().Default(""). + Comment("University, company or institute the user belongs to."), + field.String("skills").Optional().Default(""). + Comment("Comma-separated skills, as the registration form's tags field collects them."), + field.String("dietary").Optional().Default(""). + Comment("Dietary requirements, for events that cater."), + field.String("avatar_url").Optional().Default(""). + Comment("Link to a profile picture. A link, not an upload: there is no object store yet."), field.Time("created_at").Immutable().Default(time.Now). Comment("Timestamp when the user was first seen."), field.Time("modified_at"). @@ -73,6 +92,15 @@ func (User) Edges() []ent.Edge { edge.To("modified_pages", Page.Type). Annotations(entsql.OnDelete(entsql.Restrict)). Comment("Content pages this user last modified."), + edge.To("created_invites", HackathonInvite.Type). + Annotations(entsql.OnDelete(entsql.Restrict)). + Comment("Hackathon invitation links this user generated."), + edge.To("created_site_pages", SitePage.Type). + Annotations(entsql.OnDelete(entsql.Restrict)). + Comment("Platform pages this user created."), + edge.To("modified_site_pages", SitePage.Type). + Annotations(entsql.OnDelete(entsql.Restrict)). + Comment("Platform pages this user last modified."), edge.To("created_phases", Phase.Type). Annotations(entsql.OnDelete(entsql.Restrict)). Comment("Phases this user created."), @@ -94,9 +122,32 @@ func (User) Edges() []ent.Edge { edge.To("modified_capabilities", Capability.Type). Annotations(entsql.OnDelete(entsql.Restrict)). Comment("Hackathon capabilities this user last opened or closed."), + edge.To("modified_settings", HackathonSettings.Type). + Annotations(entsql.OnDelete(entsql.Restrict)). + Comment("Hackathon settings this user last modified."), + edge.To("modified_windows", HackathonWindows.Type). + Annotations(entsql.OnDelete(entsql.Restrict)). + Comment("Hackathon windows this user last modified."), + edge.To("modified_forms", HackathonForms.Type). + Annotations(entsql.OnDelete(entsql.Restrict)). + Comment("Hackathon forms this user last modified."), + edge.To("form_responses", FormResponse.Type). + Annotations(entsql.OnDelete(entsql.Restrict)). + Comment("Registration form responses about this user."), + edge.To("submitted_form_responses", FormResponse.Type). + Annotations(entsql.OnDelete(entsql.Restrict)). + Comment("Registration form responses this user entered."), + edge.To("modified_prizes", HackathonPrizes.Type). + Annotations(entsql.OnDelete(entsql.Restrict)). + Comment("Prize tables this user last modified."), edge.To("preferred_projects", Project.Type). Annotations(entsql.OnDelete(entsql.Restrict)). Comment("Projects this user has marked as preferred."), + edge.To("votes", Vote.Type). + Annotations(entsql.OnDelete(entsql.Restrict)). + Comment("Votes cast by this user."), + edge.To("jury_categories", VoteCategory.Type). + Comment("Vote categories where this user is a jury member."), } } diff --git a/components/backend/db/schema/vote.go b/components/backend/db/schema/vote.go new file mode 100644 index 00000000..f12ec83c --- /dev/null +++ b/components/backend/db/schema/vote.go @@ -0,0 +1,160 @@ +package schema + +import ( + "context" + "errors" + "fmt" + "time" + + "entgo.io/ent" + "entgo.io/ent/schema" + "entgo.io/ent/schema/edge" + "entgo.io/ent/schema/field" + "entgo.io/ent/schema/index" +) + +// Vote holds the schema definition for the Vote entity. +type Vote struct { + ent.Schema +} + +func (Vote) Annotations() []schema.Annotation { + return []schema.Annotation{ + schema.Comment( + "A single atomic judgment from one voter on one submission within one category.", + ), + } +} + +// VoteType is the discriminator for the vote method. +type VoteType string + +const ( + VoteTypeSingleChoice VoteType = "single_choice" + VoteTypeRanked VoteType = "ranked" + VoteTypePoints VoteType = "points" +) + +// Fields of the Vote. +func (Vote) Fields() []ent.Field { + return []ent.Field{ + field.Enum("vote_type"). + Values(string(VoteTypeSingleChoice), string(VoteTypeRanked), string(VoteTypePoints)). + Comment("Discriminator for the vote method."), + field.Int("value"). + Optional(). + Comment("Rank position (ranked) or points awarded (points-based). Optional for single_choice."), + field.Time("created_at"). + Immutable(). + Default(time.Now). + Comment("Timestamp when the vote was created."), + field.Time("modified_at"). + Default(time.Now).UpdateDefault(time.Now). + Comment("Timestamp of the last modification."), + } +} + +// Edges of the Vote. +func (Vote) Edges() []ent.Edge { + return []ent.Edge{ + edge.From("category", VoteCategory.Type). + Ref("votes").Unique().Required(). + Comment("The vote category this vote belongs to."), + edge.From("voter", User.Type). + Ref("votes").Unique().Required(). + Comment("Keycloak user ID of the voter."), + edge.From("submission", Submission.Type). + Ref("votes"). + Unique(). + Comment("The submission this vote is for."), + } +} + +// Indexes of the Vote. +// +// A ranked or points ballot is several rows sharing a (category, voter), so the +// old (category, voter) unique index could not hold. Uniqueness moves down to +// the submission, and "one ballot per category" — which the DB used to +// guarantee for single_choice — is now VoteService.SubmitVote's job: it refuses +// a second ballot outright and replaces any stale rows inside the same +// transaction that writes the new ones. +func (Vote) Indexes() []ent.Index { + return []ent.Index{ + index.Edges("category", "voter", "submission").Unique(), + } +} + +// Hooks of the Vote. +func (Vote) Hooks() []ent.Hook { + return []ent.Hook{ + ValidateVoteType, + } +} + +func (Vote) Mixin() []ent.Mixin { + return []ent.Mixin{ + UUIDMixin{}, + } +} + +// ValidateVoteType enforces that subtype-specific fields match the vote_type discriminator. +// +//nolint:gocognit // necessary complexity +func ValidateVoteType(next ent.Mutator) ent.Mutator { + return ent.MutateFunc(func(ctx context.Context, m ent.Mutation) (ent.Value, error) { + op := m.Op() + if !op.Is(ent.OpCreate | ent.OpUpdate | ent.OpUpdateOne) { + return next.Mutate(ctx, m) + } + vt, ok := m.Field("vote_type") + if !ok { + return next.Mutate(ctx, m) + } + voteType, ok := vt.(string) + if !ok { + return next.Mutate(ctx, m) + } + + hasSubmission := false + for _, e := range m.AddedEdges() { + if e == "submission" { + hasSubmission = true + break + } + } + + switch voteType { + case string(VoteTypeSingleChoice): + if !hasSubmission { + return nil, errors.New("single_choice vote must have a submission") + } + case string(VoteTypeRanked): + if !hasSubmission { + return nil, errors.New("ranked vote must have a submission") + } + if val, ok := m.Field("value"); ok { + v, ok := val.(int) + if !ok || v <= 0 { + return nil, errors.New("ranked vote value must be a positive integer") + } + } else { + return nil, errors.New("ranked vote must have a value") + } + case string(VoteTypePoints): + if !hasSubmission { + return nil, errors.New("points vote must have a submission") + } + if val, ok := m.Field("value"); ok { + v, ok := val.(int) + if !ok || v <= 0 { + return nil, errors.New("points vote value must be a positive integer") + } + } else { + return nil, errors.New("points vote must have a value") + } + default: + return nil, fmt.Errorf("unknown vote_type: %s", voteType) + } + return next.Mutate(ctx, m) + }) +} diff --git a/components/backend/db/schema/votecategory.go b/components/backend/db/schema/votecategory.go new file mode 100644 index 00000000..3f473204 --- /dev/null +++ b/components/backend/db/schema/votecategory.go @@ -0,0 +1,73 @@ +package schema + +import ( + "time" + + "entgo.io/ent" + "entgo.io/ent/schema" + "entgo.io/ent/schema/edge" + "entgo.io/ent/schema/field" +) + +// VoteCategory holds the schema definition for the VoteCategory entity. +type VoteCategory struct { + ent.Schema +} + +func (VoteCategory) Annotations() []schema.Annotation { + return []schema.Annotation{ + schema.Comment( + "A voting category within a hackathon, defining the criteria and rules for one dimension of evaluation.", + ), + } +} + +// Fields of the VoteCategory. +func (VoteCategory) Fields() []ent.Field { + return []ent.Field{ + field.String("name"). + NotEmpty(). + Comment("Display name of the category (e.g. \"Coolness\", \"Novelty\")."), + field.Text("description"). + Optional(). + Comment("Criteria and instructions for voters."), + field.Enum("voting_method"). + Values("single_choice", "ranked", "points"). + Comment("How votes are cast: single choice, ranked, or points-based."), + field.Enum("voter_type"). + Values("all_participants", "jury"). + Comment("Who can vote: all participants or jury only."), + field.Int("max_points"). + Optional(). + Comment("Maximum points a voter can distribute across submissions (points-based voting only)."), + field.Time("created_at"). + Immutable(). + Default(time.Now). + Comment("Timestamp when the category was created."), + field.Time("modified_at"). + Default(time.Now).UpdateDefault(time.Now). + Comment("Timestamp of the last modification."), + } +} + +// Edges of the VoteCategory. +func (VoteCategory) Edges() []ent.Edge { + return []ent.Edge{ + edge.From("hackathon", Hackathon.Type). + Ref("vote_categories").Unique().Required(). + Comment("The hackathon this category belongs to."), + edge.From("jury_members", User.Type). + Ref("jury_categories"). + Comment("Users assigned as jury members for this category (M2M). Only used when voter_type is JURY."), + edge.To("votes", Vote.Type). + Comment("All votes cast for this category."), + edge.To("results", VoteResult.Type). + Comment("Placements assigned to this category."), + } +} + +func (VoteCategory) Mixin() []ent.Mixin { + return []ent.Mixin{ + UUIDMixin{}, + } +} diff --git a/components/backend/db/schema/voteresult.go b/components/backend/db/schema/voteresult.go new file mode 100644 index 00000000..91ce079c --- /dev/null +++ b/components/backend/db/schema/voteresult.go @@ -0,0 +1,50 @@ +package schema + +import ( + "entgo.io/ent" + "entgo.io/ent/schema" + "entgo.io/ent/schema/edge" + "entgo.io/ent/schema/field" +) + +// VoteResult holds the schema definition for the VoteResult entity. +type VoteResult struct { + ent.Schema +} + +func (VoteResult) Annotations() []schema.Annotation { + return []schema.Annotation{ + schema.Comment( + "A placement entry within a vote category. Multiple VoteResults can exist per category.", + ), + } +} + +// Fields of the VoteResult. +func (VoteResult) Fields() []ent.Field { + return []ent.Field{ + field.Int("position"). + Comment("Ordering hint (1 = first place, 2 = second, etc.). Not unique — ties allowed."), + field.String("title"). + Optional(). + Comment("Optional custom title for the placement (e.g. \"Most Innovative\")."), + } +} + +// Edges of the VoteResult. +func (VoteResult) Edges() []ent.Edge { + return []ent.Edge{ + edge.From("vote_category", VoteCategory.Type). + Ref("results").Unique().Required(). + Comment("The category this result belongs to."), + edge.From("submission", Submission.Type). + Ref("vote_results").Unique().Required(). + Comment("The submission being placed."), + } +} + +func (VoteResult) Mixin() []ent.Mixin { + return []ent.Mixin{ + UUIDMixin{}, + } +} diff --git a/components/backend/internal/audit/actor.go b/components/backend/internal/audit/actor.go new file mode 100644 index 00000000..30e01a1d --- /dev/null +++ b/components/backend/internal/audit/actor.go @@ -0,0 +1,111 @@ +package audit + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "strings" + "sync" +) + +// AnonActor is what an unauthenticated caller is journalled as. It matches the +// subject the auth interceptor injects AND the actor name recipe.jsonl already +// uses, so an anonymous line needs no translation at all. +const AnonActor = "anonymous" + +// unknownPrefix labels a subject that authenticated but owns no platform User +// row — someone who has a Keycloak account and has not called Register yet. +// +// The raw subject is NOT written in that case. It is the Keycloak ID: stable, +// and the join key to every other system that knows the person. What goes to +// disk instead is `unknown:` plus the first 8 hex of its SHA-256, which keeps +// two different strangers distinguishable within a journal — without which a +// trace involving one is unreadable — and identifies neither. +const unknownPrefix = "unknown:" + +const unknownHashLen = 8 + +// A MISS IS NEVER CACHED, and that is a decision the first real capture +// forced. Registration goes WhoAmI (NotFound) -> Register -> WhoAmI, all +// inside a few hundred milliseconds, so any negative cache at all — the +// original was 15s, then 2s — labelled the Register itself, and everything +// after it, `unknown:`. Every extra in the journey cast journalled as a +// stranger creating an account out of nowhere. +// +// The cost of not caching is one indexed SELECT per RPC for a subject that +// has no User row, on the writer goroutine, off every request's critical +// path. The cost of caching was a journal that misnames people. + +// Lookup answers "which platform username owns this Keycloak subject?". +// Returning false means no User row has that keycloak_id. +type Lookup func(ctx context.Context, sub string) (string, bool) + +// Resolver turns JWT subjects into platform usernames, memoized. +// +// It is used from the JOURNAL WRITER, never from the interceptor: resolution +// may touch the database, and requirement one of this package is that a +// request never waits on journalling. The interceptor hands the raw subject +// to the queue and the writer resolves it just before the line is written — +// which also means a Register has already committed by the time its own line +// is resolved, so the caller is named rather than `unknown:`. +type Resolver struct { + lookup Lookup + + mu sync.RWMutex + known map[string]string +} + +func NewResolver(lookup Lookup) *Resolver { + return &Resolver{ + lookup: lookup, + mu: sync.RWMutex{}, + known: make(map[string]string), + } +} + +// Resolve names the actor for a subject. It never returns the subject itself. +func (r *Resolver) Resolve(ctx context.Context, sub string) string { + if sub == "" || sub == AnonActor { + return AnonActor + } + if name, ok := r.cached(sub); ok { + return name + } + if r.lookup != nil { + if name, ok := r.lookup(ctx, sub); ok && name != "" { + r.remember(sub, name) + + return name + } + } + + return pseudonym(sub) +} + +func (r *Resolver) cached(sub string) (string, bool) { + r.mu.RLock() + defer r.mu.RUnlock() + name, ok := r.known[sub] + + return name, ok +} + +func (r *Resolver) remember(sub, name string) { + r.mu.Lock() + defer r.mu.Unlock() + r.known[sub] = name +} + +// pseudonym is the one-way stand-in for an unmapped subject. +func pseudonym(sub string) string { + sum := sha256.Sum256([]byte(sub)) + + return unknownPrefix + hex.EncodeToString(sum[:])[:unknownHashLen] +} + +// IsPseudonym reports whether an actor name is a stand-in rather than a real +// platform username. Used by tests and by anyone reading a journal who needs +// to know that a line names nobody. +func IsPseudonym(actor string) bool { + return strings.HasPrefix(actor, unknownPrefix) +} diff --git a/components/backend/internal/audit/actor_test.go b/components/backend/internal/audit/actor_test.go new file mode 100644 index 00000000..aec67a10 --- /dev/null +++ b/components/backend/internal/audit/actor_test.go @@ -0,0 +1,129 @@ +//go:build test && unittest + +package audit + +import ( + "context" + "strings" + "testing" +) + +// countingLookup is a Lookup plus a call counter, so "the cache works" is an +// assertion rather than a hope — an uncached resolver adds a database +// round-trip per RPC, which is the thing this design promises not to do. +type countingLookup struct { + users map[string]string + calls int +} + +func (c *countingLookup) fn(_ context.Context, sub string) (string, bool) { + c.calls++ + name, ok := c.users[sub] + + return name, ok +} + +const ( + aliceSub = "5f0f4b60-2c0f-4a3e-9c1d-8b7a6e5d4c3b" + ghostSub = "9e8d7c6b-5a49-4382-91f0-0e1d2c3b4a59" +) + +func TestResolveAnonymous(t *testing.T) { + r := NewResolver(nil) + for _, sub := range []string{"", AnonActor} { + if got := r.Resolve(context.Background(), sub); got != AnonActor { + t.Errorf("Resolve(%q) = %q, want %q", sub, got, AnonActor) + } + } +} + +func TestResolveMapsSubjectToUsername(t *testing.T) { + lookup := &countingLookup{users: map[string]string{aliceSub: "alice"}, calls: 0} + r := NewResolver(lookup.fn) + if got := r.Resolve(context.Background(), aliceSub); got != "alice" { + t.Fatalf("Resolve = %q, want alice", got) + } +} + +func TestResolveCachesPositiveLookups(t *testing.T) { + lookup := &countingLookup{users: map[string]string{aliceSub: "alice"}, calls: 0} + r := NewResolver(lookup.fn) + for range 50 { + if got := r.Resolve(context.Background(), aliceSub); got != "alice" { + t.Fatalf("Resolve = %q, want alice", got) + } + } + if lookup.calls != 1 { + t.Errorf("lookup called %d times for one subject — it must be memoized", lookup.calls) + } +} + +// An authenticated stranger must be distinguishable from other strangers and +// from nobody at all, while the Keycloak ID itself stays off disk. +func TestResolveUnknownSubjectIsPseudonymous(t *testing.T) { + lookup := &countingLookup{users: map[string]string{}, calls: 0} + r := NewResolver(lookup.fn) + got := r.Resolve(context.Background(), ghostSub) + + if !IsPseudonym(got) { + t.Fatalf("Resolve = %q, want an unknown: pseudonym", got) + } + if strings.Contains(got, ghostSub) || + strings.Contains(ghostSub, strings.TrimPrefix(got, "unknown:")) { + t.Errorf("pseudonym %q leaks the subject %q", got, ghostSub) + } + if again := NewResolver(nil).Resolve(context.Background(), ghostSub); again != got { + t.Errorf("pseudonym is not stable across resolvers: %q vs %q", got, again) + } + other := NewResolver(nil).Resolve(context.Background(), aliceSub) + if other == got { + t.Errorf("two different subjects share the pseudonym %q", got) + } +} + +// A MISS MUST NOT BE CACHED. Registration is WhoAmI (NotFound) -> Register -> +// WhoAmI within a few hundred milliseconds; a negative cache of any duration +// labels the Register itself, and everything after it, `unknown:`. The first +// real capture had every extra in the cast journalled as a stranger creating +// an account out of nowhere. +func TestResolveNeverCachesAMiss(t *testing.T) { + lookup := &countingLookup{users: map[string]string{}, calls: 0} + r := NewResolver(lookup.fn) + + if got := r.Resolve(context.Background(), ghostSub); !IsPseudonym(got) { + t.Fatalf("first Resolve = %q, want a pseudonym", got) + } + // They register — the very next call, no clock advanced at all. + lookup.users[ghostSub] = "dana.moser" + if got := r.Resolve(context.Background(), ghostSub); got != "dana.moser" { + t.Fatalf("Resolve = %q, want dana.moser — a miss was cached", got) + } + // And once they ARE known, the lookup stops. + before := lookup.calls + for range 10 { + r.Resolve(context.Background(), ghostSub) + } + if lookup.calls != before { + t.Errorf( + "lookup called %d more times after a hit — hits must be memoized", + lookup.calls-before, + ) + } +} + +func TestResolveWithoutLookupNeverReturnsTheSubject(t *testing.T) { + got := NewResolver(nil).Resolve(context.Background(), aliceSub) + if got == aliceSub { + t.Fatal("the raw Keycloak subject was returned as the actor name") + } + if !IsPseudonym(got) { + t.Errorf("Resolve = %q, want a pseudonym when no lookup is configured", got) + } +} + +func TestRecipeMethodStripsLeadingSlash(t *testing.T) { + got := RecipeMethod("/hackathon.HackathonService/Edit") + if got != "hackathon.HackathonService/Edit" { + t.Errorf("RecipeMethod = %q", got) + } +} diff --git a/components/backend/internal/audit/audit_suite_test.go b/components/backend/internal/audit/audit_suite_test.go new file mode 100644 index 00000000..a13e31b5 --- /dev/null +++ b/components/backend/internal/audit/audit_suite_test.go @@ -0,0 +1,27 @@ +//go:build test && unittest + +package audit_test + +import ( + "testing" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +// This package's tests are plain `testing` functions (redact_test.go, +// actor_test.go, produced_test.go), not Ginkgo specs — so this bootstrap +// deliberately runs ZERO specs. It exists because the quitsh test target +// appends `--ginkgo.v` to every package's test binary (see +// components/backend/.component.yaml, target `test-unittest`). Importing +// Ginkgo registers the `-ginkgo.*` flags on flag.CommandLine at init; without +// it the binary exits 1 on "flag provided but not defined: -ginkgo.v" before +// running a single test. +// +// "Ran 0 of 0 Specs" below is therefore expected and is NOT this package's +// result. Its coverage is the TestXxx functions, which `go test -v` runs and +// reports one by one. +func TestAudit(t *testing.T) { + RegisterFailHandler(Fail) + RunSpecs(t, "Audit Suite") +} diff --git a/components/backend/internal/audit/entlookup.go b/components/backend/internal/audit/entlookup.go new file mode 100644 index 00000000..b0dab7c8 --- /dev/null +++ b/components/backend/internal/audit/entlookup.go @@ -0,0 +1,33 @@ +package audit + +import ( + "context" + + "github.com/swissdatasciencecenter/hackagon/components/backend/ent" + "github.com/swissdatasciencecenter/hackagon/components/backend/ent/user" +) + +// EntLookup resolves a Keycloak subject to the platform username stored +// against it. Exactly one column is selected: nothing else about the person is +// read, so nothing else can end up in the journal by accident. +// +// This runs on the journal's writer goroutine and is memoized by Resolver, so +// a given subject costs one query per process (or per negativeTTL while the +// person has not registered yet). +func EntLookup(db *ent.Client) Lookup { + if db == nil { + return nil + } + + return func(ctx context.Context, sub string) (string, bool) { + username, err := db.User.Query(). + Where(user.KeycloakIDEQ(sub)). + Select(user.FieldUsername). + String(ctx) + if err != nil || username == "" { + return "", false + } + + return username, true + } +} diff --git a/components/backend/internal/audit/interceptor.go b/components/backend/internal/audit/interceptor.go new file mode 100644 index 00000000..e0d1e83e --- /dev/null +++ b/components/backend/internal/audit/interceptor.go @@ -0,0 +1,198 @@ +package audit + +import ( + "context" + "encoding/json" + "log/slog" + "regexp" + "strings" + + "github.com/swissdatasciencecenter/hackagon/components/backend/internal/middleware" + "google.golang.org/grpc" + "google.golang.org/grpc/status" + "google.golang.org/protobuf/encoding/protojson" + "google.golang.org/protobuf/proto" +) + +// UnaryServerInterceptor journals every unary call that reaches it. +// +// Chain it AFTER the auth interceptor — it reads the JWT subject that auth +// puts in the context — and BEFORE validation, so a request rejected by +// protovalidate is journalled as InvalidArgument rather than vanishing. The +// one thing it therefore cannot see is a call auth itself rejected (an +// invalid or expired token), which is a transport failure and not an action +// anybody took. +// +// Returns nil when j is nil, and grpc.ChainUnaryInterceptor tolerates being +// handed nothing — so a disabled journal costs exactly one nil check at +// startup and nothing per request. +func UnaryServerInterceptor(j *Journal) grpc.UnaryServerInterceptor { + if j == nil { + return nil + } + + return func( + ctx context.Context, + req any, + info *grpc.UnaryServerInfo, + handler grpc.UnaryHandler, + ) (any, error) { + resp, err := handler(ctx, req) + // Everything below is bookkeeping. It runs after the handler, it + // cannot change what is returned, and a panic in it must not reach + // the caller — journalling is never a reason for an RPC to fail. + func() { + defer func() { + if r := recover(); r != nil { + slog.Warn("rpc journal entry panicked; call was unaffected", + "method", info.FullMethod, "panic", r) + } + }() + j.Record(entryFor(ctx, req, resp, err, info.FullMethod)) + }() + + return resp, err + } +} + +func entryFor( + ctx context.Context, + req any, + resp any, + err error, + fullMethod string, +) Entry { + // The subject only — no peer address is read, no metadata beyond what + // auth already parsed, and the subject itself never reaches the file + // (the writer resolves it to a username first). + sub, _ := middleware.GetSubject(ctx) + + return Entry{ + Seq: 0, // assigned by Record + Ts: "", + Actor: "", // resolved by the writer + Method: RecipeMethod(fullMethod), + Params: paramsOf(req), + Expect: expectOf(err), + Produced: producedIDs(resp), + sub: sub, + } +} + +// RecipeMethod turns grpc's "/pkg.Service/Method" into the recipe's +// "pkg.Service/Method". +func RecipeMethod(fullMethod string) string { + return strings.TrimPrefix(fullMethod, "/") +} + +func paramsOf(req any) map[string]any { + msg, ok := req.(proto.Message) + if !ok { + return map[string]any{} + } + params, err := RedactMessage(msg) + if err != nil { + // Redaction failed, so nothing about this request is known to be + // safe. Record the fact, not the request. + return map[string]any{"_error": "could not redact request"} + } + + return params +} + +func expectOf(err error) map[string]any { + if err == nil { + return map[string]any{"ok": true} + } + + // status.Code().String() is exactly the spelling recipe.jsonl uses in + // expect.error ("PermissionDenied", "NotFound", ...), which is also what + // grpcurl prints — so a journalled failure and a hand-written one compare + // character for character. + return map[string]any{"error": status.Code(err).String()} +} + +// ─── Response ids ──────────────────────────────────────────────────────────── + +// idKey matches the JSON name of a field that holds an object identifier: +// exactly "id", or a lowerCamel name ending in "Id". +var idKey = regexp.MustCompile(`^(id|[a-z][A-Za-z0-9]*Id)$`) + +// uuidValue matches a canonical UUID, which is the only value shape accepted +// as an id. +var uuidValue = regexp.MustCompile( + `^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$`, +) + +// foreignID: names that pass idKey but identify a PERSON in another system +// rather than an object in this one. +// +// `keycloakId` is on WhoAmI's and Register's responses. It is the JWT subject +// — precisely the value the actor field exists to keep off disk — and it is +// UUID-shaped, so the rule above would record it verbatim. This bit the first +// capture run: every persona's Keycloak ID was journalled next to their +// username, which is the one join nobody may make from this file. +// +//nolint:gochecknoglobals // policy set, same as `keep` in redact.go. +var foreignID = newSet("keycloakId") + +const ( + maxIDDepth = 3 + maxIDs = 24 +) + +// producedIDs is the ONLY thing this package ever reads out of a response: the +// UUIDs sitting under id-shaped keys, keyed by their dot-path so the path can +// be reused verbatim as a recipe `save` expression ("hackathonId", +// "voteCategory.id"). +// +// Everything else in the response — names, prose, profile fields, tokens — is +// not inspected, not copied and not written. There is no allowlist to get +// wrong here because there is no path by which a non-UUID value can be +// recorded: the key must look like an id AND the value must be a UUID. +func producedIDs(resp any) map[string]string { + msg, ok := resp.(proto.Message) + if !ok || msg == nil { + return nil + } + raw, err := protojson.Marshal(msg) + if err != nil { + return nil + } + var decoded map[string]any + if err := json.Unmarshal(raw, &decoded); err != nil { + return nil + } + out := make(map[string]string) + collectIDs("", decoded, 1, out) + if len(out) == 0 { + return nil + } + + return out +} + +func collectIDs(prefix string, node map[string]any, depth int, out map[string]string) { + if depth > maxIDDepth || len(out) >= maxIDs { + return + } + for k, v := range node { + path := k + if prefix != "" { + path = prefix + "." + k + } + switch t := v.(type) { + case string: + if idKey.MatchString(k) && !foreignID.has(k) && + uuidValue.MatchString(t) && len(out) < maxIDs { + out[path] = t + } + case map[string]any: + collectIDs(path, t, depth+1, out) + default: + // Lists are skipped on purpose: an id inside a repeated field has + // no stable path to save from, and listing responses are exactly + // where a bulk of unrelated ids would come from. + } + } +} diff --git a/components/backend/internal/audit/journal.go b/components/backend/internal/audit/journal.go new file mode 100644 index 00000000..bbc63e3a --- /dev/null +++ b/components/backend/internal/audit/journal.go @@ -0,0 +1,203 @@ +// Package audit records real gRPC traffic in the shape the e2e recipe uses, +// so lifecycle test actions can be derived from what people actually do +// instead of guessed. +// +// It is OFF unless config `audit.enabled` (env HACKAGON_AUDIT_ENABLED) says +// otherwise — see config.AuditConfig for the full statement of what enabling +// it collects. The short version: actor username, method, an allowlisted +// request, the outcome, and the ids the response reported. No IP address, no +// user agent, no session or trace id, no free text. +// +// Three properties this package must never lose, in order: +// +// 1. A request never fails because journalling failed. Every path here +// either succeeds or gives up quietly, and the interceptor recovers from +// panics in its own bookkeeping. +// 2. A request never waits on file IO. Entries go to a buffered channel and +// a single writer goroutine owns the file; when the buffer is full, +// entries are dropped and counted. +// 3. Nothing reaches disk that the allowlist in redact.go did not pass. +package audit + +import ( + "context" + "encoding/json" + "fmt" + "log/slog" + "os" + "path/filepath" + "sync" + "sync/atomic" + "time" + + "github.com/swissdatasciencecenter/hackagon/components/backend/internal/config" +) + +// lookupTimeout bounds the actor lookup done by the writer goroutine. +const lookupTimeout = 3 * time.Second + +// Entry is one journalled call: one line of JSONL. +// +// The field names are the recipe's own (`actor`, `method`, `params`, +// `expect`), so scripts/journal-to-recipe.mjs is a rewrite rather than a +// translation. `seq` and `ts` are journal bookkeeping and are dropped when a +// draft recipe action is produced. +type Entry struct { + // Seq is assigned when the entry is queued, so the file's order is the + // order calls COMPLETED even though writing is asynchronous. + Seq uint64 `json:"seq"` + // Ts is UTC, RFC3339 with nanoseconds. + Ts string `json:"ts"` + // Actor is a platform username, "anonymous", or an "unknown:xxxxxxxx" + // pseudonym. Never a Keycloak ID. + Actor string `json:"actor"` + // Method is pkg.Service/Method with no leading slash — recipe form. + Method string `json:"method"` + // Params is the redacted request. + Params map[string]any `json:"params"` + // Expect is {"ok":true} or {"error":"<StatusCodeName>"}. + Expect map[string]any `json:"expect"` + // Produced maps a dot-path in the RESPONSE to the UUID found there, for + // id-shaped fields only (see producedIDs). This is the only part of any + // response that is ever read, and it is what lets the converter turn the + // id a Create returned into {{hackathonId}} in every later action. + // Omitted when the response reported no ids. + Produced map[string]string `json:"produced,omitempty"` + + // sub is the raw JWT subject, resolved to Actor by the writer goroutine. + // Unexported: it must not be marshalled, ever. + sub string +} + +// Journal owns the output file and the goroutine that writes it. +type Journal struct { + queue chan Entry + done chan struct{} + resolver *Resolver + + seq atomic.Uint64 + dropped atomic.Uint64 + warnOnce sync.Once + closeOnce sync.Once +} + +const defaultBuffer = 4096 + +// Open creates the journal file (and its parent directories) and starts the +// writer goroutine. Returns nil, nil when auditing is disabled, which callers +// must treat as "no journal" — every method here is safe on a nil receiver. +func Open(cfg config.AuditConfig, resolver *Resolver) (*Journal, error) { + if !cfg.Enabled { + return nil, nil //nolint:nilnil // disabled is not an error; see doc comment + } + path := cfg.Path + if path == "" { + path = ".output/audit/rpc-journal.jsonl" + } + if err := os.MkdirAll(filepath.Dir(path), 0o750); err != nil { + return nil, fmt.Errorf("create journal directory: %w", err) + } + file, err := os.OpenFile(path, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o600) + if err != nil { + return nil, fmt.Errorf("open journal %s: %w", path, err) + } + size := cfg.Buffer + if size <= 0 { + size = defaultBuffer + } + if resolver == nil { + // No lookup: every authenticated subject journals as a pseudonym. + // Degraded, never a leak. + resolver = NewResolver(nil) + } + j := &Journal{ + queue: make(chan Entry, size), + done: make(chan struct{}), + resolver: resolver, + seq: atomic.Uint64{}, + dropped: atomic.Uint64{}, + warnOnce: sync.Once{}, + closeOnce: sync.Once{}, + } + go j.run(file) + + abs, _ := filepath.Abs(path) + slog.Warn("RPC JOURNAL ENABLED — appending one line per gRPC call", + "path", abs, + "collects", "actor username, method, allowlisted request fields, status, response ids", + "never", "ip, user agent, session id, free text") + + return j, nil +} + +// Record queues an entry. It never blocks and never returns an error: a full +// buffer drops the entry and bumps a counter, because an RPC waiting on the +// journal is a worse outcome than a gap in it. +func (j *Journal) Record(e Entry) { + if j == nil { + return + } + e.Seq = j.seq.Add(1) + e.Ts = time.Now().UTC().Format(time.RFC3339Nano) + select { + case j.queue <- e: + default: + j.dropped.Add(1) + j.warnOnce.Do(func() { + slog.Warn("rpc journal buffer full — entries are being dropped", + "first_dropped_seq", e.Seq, "hint", "raise audit.buffer") + }) + } +} + +// Dropped is how many entries never reached the file. +func (j *Journal) Dropped() uint64 { + if j == nil { + return 0 + } + + return j.dropped.Load() +} + +// Close drains the queue and closes the file. Safe to call twice, and on nil. +func (j *Journal) Close() { + if j == nil { + return + } + j.closeOnce.Do(func() { + close(j.queue) + <-j.done + if n := j.dropped.Load(); n > 0 { + slog.Warn("rpc journal finished with dropped entries", "dropped", n) + } + }) +} + +// run is the single writer. It owns the file handle exclusively, so no lock is +// needed and the encoder's buffer is never shared. +func (j *Journal) run(file *os.File) { + defer close(j.done) + defer func() { _ = file.Close() }() + + enc := json.NewEncoder(file) + // Without this, encoding/json rewrites the angle brackets of "<redacted>" + // into their unicode escapes, so every redacted field lands on disk as an + // unreadable blob. It is valid JSON that parses back to the same string — + // and the journal exists to be read by a person. Safe to turn off here + // because every value that could carry markup is redacted before it + // reaches the encoder. + enc.SetEscapeHTML(false) + for e := range j.queue { + // Resolution is here rather than in the interceptor precisely so a + // database round-trip cannot land on a request's critical path. The + // timeout keeps a wedged database from stalling the writer instead. + ctx, cancel := context.WithTimeout(context.Background(), lookupTimeout) + e.Actor = j.resolver.Resolve(ctx, e.sub) + cancel() + if err := enc.Encode(&e); err != nil { + // One log, then keep going: a journal that kills the process it + // is observing is worse than an incomplete journal. + slog.Warn("rpc journal write failed", "seq", e.Seq, "err", err) + } + } +} diff --git a/components/backend/internal/audit/produced_test.go b/components/backend/internal/audit/produced_test.go new file mode 100644 index 00000000..c2e89c4d --- /dev/null +++ b/components/backend/internal/audit/produced_test.go @@ -0,0 +1,66 @@ +//go:build test && unittest + +package audit + +import ( + "testing" + + hmsgs "github.com/swissdatasciencecenter/hackagon/components/backend/internal/proto/hackathon/messages/hackathon_svc" + uents "github.com/swissdatasciencecenter/hackagon/components/backend/internal/proto/user/entities" + umsgs "github.com/swissdatasciencecenter/hackagon/components/backend/internal/proto/user/messages/user_svc" +) + +const ( + dbUUID = "019fe095-35d1-7885-a8a2-01d05d9730f5" + keycloakUUID = "1183370a-46a2-4dad-b8fd-dd927d083e14" +) + +// The response side records ids and only ids. This is what makes the converter +// able to turn "the id a Create returned" into {{hackathonId}} — and it is the +// one place a value from a response can reach disk at all. +func TestProducedIDsCollectsObjectIDs(t *testing.T) { + got := producedIDs(&hmsgs.CreateResponse{HackathonId: dbUUID}) + if got["hackathonId"] != dbUUID { + t.Errorf("producedIDs = %v, want hackathonId=%s", got, dbUUID) + } +} + +// Regression, and the reason foreignID exists: WhoAmI answers with the User +// entity, which carries keycloak_id. It ends in "Id" and it is a UUID, so the +// generic rule recorded the caller's Keycloak subject right next to their +// username — the one join this file must never allow. Caught by reading the +// first real capture, not by review. +func TestProducedIDsNeverRecordsTheKeycloakID(t *testing.T) { + got := producedIDs(&umsgs.WhoAmIResponse{ + User: &uents.User{Id: dbUUID, KeycloakId: keycloakUUID, Username: "hackagon-admin"}, + }) + if got["user.id"] != dbUUID { + t.Errorf("the platform user id must still be collected: %v", got) + } + for path, v := range got { + if v == keycloakUUID { + t.Fatalf("the Keycloak subject was journalled at %q", path) + } + } +} + +// Non-id fields of a response are not read at all, whatever they contain. +func TestProducedIDsIgnoresEverythingThatIsNotAnID(t *testing.T) { + got := producedIDs(&umsgs.WhoAmIResponse{ + User: &uents.User{ + Id: dbUUID, + Username: "dana.moser", + Email: "dana@example.org", + Dietary: "coeliac", + }, + }) + if len(got) != 1 || got["user.id"] != dbUUID { + t.Fatalf("producedIDs = %v, want exactly {user.id: %s}", got, dbUUID) + } +} + +func TestProducedIDsIgnoresNonUUIDValues(t *testing.T) { + if got := producedIDs(&hmsgs.CreateResponse{HackathonId: "not-a-uuid"}); got != nil { + t.Errorf("producedIDs = %v, want nil for a non-UUID id", got) + } +} diff --git a/components/backend/internal/audit/redact.go b/components/backend/internal/audit/redact.go new file mode 100644 index 00000000..c0d7ca99 --- /dev/null +++ b/components/backend/internal/audit/redact.go @@ -0,0 +1,188 @@ +package audit + +import ( + "encoding/json" + "fmt" + + "google.golang.org/protobuf/encoding/protojson" + "google.golang.org/protobuf/proto" +) + +// Redacted is what a field that is not on the allowlist becomes. It is a +// string in every case, whatever the original type was, so a reader can never +// mistake a redacted value for a real one. +const Redacted = "<redacted>" + +// ─── The policy ────────────────────────────────────────────────────────────── +// +// ALLOWLIST-AND-REDACT. A request field is recorded verbatim only if its JSON +// name appears in `keep` below. Everything else — including every field added +// to a proto after this table was last read — becomes "<redacted>". +// +// The default is deliberately the safe one. A journal is written to answer +// "what did people do", which needs the SHAPE of a call (which object, which +// enum, which flag) and never its prose. Free text is where the personal data +// is: SubmitRegistrationForm carries dietary requirements and accessibility +// notes in `responses`, EditProfile carries a person's name, CreateInvite +// carries a note about an invitee. None of those are on this list, so none of +// them are ever written to disk. +// +// The consequence, and it is intended: a new proto field is redacted until +// somebody adds it here on purpose. A journal that quietly starts recording a +// field nobody classified is the failure this table exists to prevent. +// +// To record a new field: add its JSON (lowerCamel) name to the right group. +// Groups are documentation only — the lookup is a single flat set. +// +// A kept field that holds an object or a list is RECURSED into rather than +// copied, so its children face the same table. That is why the container +// names (`fields`, `prizes`, `submissions`, ...) appear here: listing a +// container grants nothing by itself. +// +//nolint:gochecknoglobals // policy table: read per RPC, written once, never mutated. +var keep = newSet( + // ── Identifiers. UUIDs of platform objects. Not personal data, and the + // whole point of the journal: they are what lets one line be recognised + // as acting on the object an earlier line created. + "id", "hackathonId", "userId", "projectId", "teamId", "trackId", + "phaseId", "pageId", "pageIds", "categoryId", "submissionId", "voteId", + "ownerId", "participantId", "voterId", "creatorId", "modifierId", + "currentPhaseId", "openInPhaseId", "closedInPhaseId", "inviteId", + "juryMemberIds", "onBehalfOf", + + // ── Slugs. Short, human-chosen, but they ARE the address of a site page + // (`about`, `privacy`) and a journal that redacted them could not say + // which page was edited. + "slug", "newSlug", + + // ── Enumerations and other closed vocabularies. protojson renders these + // as their enum name; every possible value is in the .proto file, so + // recording one reveals nothing that reading the schema does not. + "visibility", "visibilityFilter", "statusFilter", + "capability", "capabilities", "votingMethod", "voterType", "role", + "kind", "format", "type", "mechanism", "oneBallotPer", "tieBreak", + "latePolicy", "window", + + // ── Flags. A boolean cannot carry free text. + "visible", "enabled", "required", "force", "includeRevoked", + "includeHidden", "registrationsEnabled", "votingEnabled", + "ownTeamVoting", "organizerVoting", "isWaiting", "submitted", + + // ── Numbers. Positions, counts and limits; likewise nothing to hide in + // an int32 the schema already bounds. + "order", "rank", "position", "increment", "points", "maxPoints", + "maxMb", "extendMinutes", "min", "max", "scale", + + // ── Timestamps. Scheduling is structure: the recipe expresses these as + // {{now+Nd}} and a journal without them cannot show an event being + // rescheduled. + "startsAt", "endsAt", "createdAt", "modifiedAt", "submittedAt", + "expiresAt", "opensAt", "closesAt", "registrationOpens", + "registrationCloses", "proposalsClose", "preferencesClose", + "submissionsClose", + + // ── Branding colours. Hex triples chosen by an organizer for an event, + // not by or about a person. + "primaryColor", "accentColor", + + // ── Containers that requests actually carry. Recursed into; listing one + // grants its children nothing. (`points` and `scale` are up in the + // numbers group and are containers in SubmitVote / SetVotingPolicy — + // same rule applies, the name is what is listed, not the shape.) + "fields", "prizes", "awards", "submissions", "singleChoice", "ranked", +) + +// The notable fields the default REDACTS, and why. This list is not consulted +// at runtime — the default already covers it, and covers anything added to a +// proto tomorrow — but a reviewer asking "is X recorded?" should find X in +// this file either way, and a future editor should meet the reasoning before +// moving something up into `keep`: +// +// name, title, description, content, label, note, reason, special, +// bannerText organizer- or member-authored prose +// responses, consents registration answers: dietary needs, accessibility, +// affiliation — and which consents a person gave +// templates, data free-form maps with caller-chosen keys AND values +// displayName, username, email, affiliation, skills, dietary, avatarUrl +// personal data, straight out of the profile +// key, filename caller-chosen strings; an object-store key embeds a +// file name, which is routinely a person's name +// token, inviteToken secrets — recording one would let a reader use it +// logo, image, result may be a multi-megabyte data: URI + +// ─── Application ───────────────────────────────────────────────────────────── + +// RedactMessage marshals a request message the way the frontend and grpcurl +// send it (protojson, lowerCamel names, zero values omitted) and then applies +// the table above. The returned map is safe to write to disk. +// +// Marshalling happens before redaction because protojson is the only thing +// that knows a proto's JSON shape; the full message therefore exists in memory +// for the duration of this call and is then discarded. It is never written. +func RedactMessage(msg proto.Message) (map[string]any, error) { + if msg == nil { + return map[string]any{}, nil + } + raw, err := protojson.Marshal(msg) + if err != nil { + return nil, fmt.Errorf("marshal request: %w", err) + } + var decoded map[string]any + if err := json.Unmarshal(raw, &decoded); err != nil { + return nil, fmt.Errorf("decode request json: %w", err) + } + + return RedactMap(decoded), nil +} + +// RedactMap applies the policy to an already-decoded message. +func RedactMap(in map[string]any) map[string]any { + out := make(map[string]any, len(in)) + for k, v := range in { + if !keep.has(k) { + out[k] = Redacted + + continue + } + out[k] = redactValue(v) + } + + return out +} + +// redactValue handles the value under a KEPT name: scalars are copied, +// containers are walked so their children face the table too. +func redactValue(v any) any { + switch t := v.(type) { + case map[string]any: + return RedactMap(t) + case []any: + out := make([]any, len(t)) + for i, item := range t { + out[i] = redactValue(item) + } + + return out + default: + return v + } +} + +// ─── set ───────────────────────────────────────────────────────────────────── + +type set map[string]struct{} + +func newSet(names ...string) set { + s := make(set, len(names)) + for _, n := range names { + s[n] = struct{}{} + } + + return s +} + +func (s set) has(name string) bool { + _, ok := s[name] + + return ok +} diff --git a/components/backend/internal/audit/redact_test.go b/components/backend/internal/audit/redact_test.go new file mode 100644 index 00000000..ddee3e8f --- /dev/null +++ b/components/backend/internal/audit/redact_test.go @@ -0,0 +1,182 @@ +//go:build test && unittest + +package audit + +import ( + "encoding/json" + "strings" + "testing" + + hmsgs "github.com/swissdatasciencecenter/hackagon/components/backend/internal/proto/hackathon/messages/hackathon_svc" + umsgs "github.com/swissdatasciencecenter/hackagon/components/backend/internal/proto/user/messages/user_svc" + "google.golang.org/protobuf/types/known/structpb" +) + +// The privacy guarantee of this package lives in one table, and a table is +// exactly the kind of thing that rots quietly: a field moved into `keep` +// during a debugging session, a proto that grew a free-text field nobody +// classified. These tests fail on both. + +func TestRedactKeepsStructuralFields(t *testing.T) { + in := map[string]any{ + "hackathonId": "0f2f6b1e-6f1a-4d2e-9a3b-1c2d3e4f5a6b", + "userId": "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee", + "slug": "about", + "visibility": "VISIBILITY_PUBLIC", + "visible": true, + "order": float64(3), + "startsAt": "2027-06-01T09:00:00Z", + } + out := RedactMap(in) + for k, want := range in { + if out[k] != want { + t.Errorf("field %q: got %v, want %v (structural fields must survive)", k, out[k], want) + } + } +} + +func TestRedactRedactsFreeTextAndPersonalFields(t *testing.T) { + // Every one of these is a field somebody could reasonably think is + // harmless. None of them may reach disk. + for _, name := range []string{ + "name", "title", "description", "content", "label", "note", "reason", + "special", "bannerText", "responses", "consents", "templates", "data", + "displayName", "username", "email", "affiliation", "skills", "dietary", + "avatarUrl", "key", "filename", "token", "inviteToken", "logo", + "image", "result", + } { + out := RedactMap(map[string]any{name: "sensitive value"}) + if out[name] != Redacted { + t.Errorf("field %q was recorded as %v — it must be %q", name, out[name], Redacted) + } + } +} + +// The default is the guarantee. A field nobody has classified — including one +// added to a proto after this table was last read — must be redacted, not +// recorded because it happened to look innocent. +func TestRedactDefaultsToRedactedForUnknownFields(t *testing.T) { + out := RedactMap(map[string]any{ + "somethingInventedTomorrow": "a person's home address", + "count": float64(7), + "flag": true, + }) + for k, v := range out { + if v != Redacted { + t.Errorf("unlisted field %q was recorded as %v — the default must redact", k, v) + } + } +} + +// A redacted name loses its WHOLE value, not its leaf strings: `responses` is +// an arbitrary map, so walking into it would record caller-chosen keys even if +// every value were replaced. +func TestRedactReplacesRedactedContainersWholesale(t *testing.T) { + out := RedactMap(map[string]any{ + "responses": map[string]any{ + "dietary": "coeliac", + "accessibility": "step-free access please", + }, + }) + if out["responses"] != Redacted { + t.Fatalf("responses recorded as %#v — must collapse to %q", out["responses"], Redacted) + } + blob, _ := json.Marshal(out) + for _, leak := range []string{"coeliac", "step-free", "accessibility"} { + if strings.Contains(string(blob), leak) { + t.Errorf("%q survived redaction in %s", leak, blob) + } + } +} + +// A kept container grants its children nothing: they face the same table. +func TestRedactRecursesIntoKeptContainers(t *testing.T) { + out := RedactMap(map[string]any{ + "singleChoice": map[string]any{ + "categoryId": "11111111-2222-3333-4444-555555555555", + "submissionId": "66666666-7777-8888-9999-000000000000", + }, + "prizes": []any{ + map[string]any{"rank": float64(1), "title": "Best use of open data"}, + }, + }) + ballot, ok := out["singleChoice"].(map[string]any) + if !ok { + t.Fatalf("singleChoice: got %T, want a recursed map", out["singleChoice"]) + } + if ballot["categoryId"] != "11111111-2222-3333-4444-555555555555" { + t.Errorf("nested id was not kept: %v", ballot["categoryId"]) + } + prizes, ok := out["prizes"].([]any) + if !ok || len(prizes) != 1 { + t.Fatalf("prizes: got %#v, want a one-element list", out["prizes"]) + } + prize, _ := prizes[0].(map[string]any) + if prize["rank"] != float64(1) { + t.Errorf("nested rank was not kept: %v", prize["rank"]) + } + if prize["title"] != Redacted { + t.Errorf( + "nested title recorded as %v — a kept container must not shelter free text", + prize["title"], + ) + } +} + +// The worked example from the brief, against the real generated message: a +// registration form carries dietary requirements, and none of it may be +// journalled. +func TestRedactMessageSubmitRegistrationForm(t *testing.T) { + responses, err := structpb.NewStruct(map[string]any{ + "dietary": "coeliac, no nuts", + "affiliation": "ETH Zurich", + "tshirt": "M", + }) + if err != nil { + t.Fatalf("structpb: %v", err) + } + req := &hmsgs.SubmitRegistrationFormRequest{ + HackathonId: "0f2f6b1e-6f1a-4d2e-9a3b-1c2d3e4f5a6b", + Responses: responses, + Consents: map[string]bool{"photo": true, "coc": true}, + } + out, err := RedactMessage(req) + if err != nil { + t.Fatalf("RedactMessage: %v", err) + } + if out["hackathonId"] != "0f2f6b1e-6f1a-4d2e-9a3b-1c2d3e4f5a6b" { + t.Errorf("hackathonId must survive: %v", out["hackathonId"]) + } + if out["responses"] != Redacted || out["consents"] != Redacted { + t.Errorf( + "responses=%v consents=%v — both must be %q", + out["responses"], + out["consents"], + Redacted, + ) + } + blob, _ := json.Marshal(out) + for _, leak := range []string{"coeliac", "nuts", "ETH Zurich", "tshirt", "photo"} { + if strings.Contains(string(blob), leak) { + t.Errorf("%q reached the journal line %s", leak, blob) + } + } +} + +func TestRedactMessageEditProfile(t *testing.T) { + name := "Dana Moser" + out, err := RedactMessage(&umsgs.EditProfileRequest{DisplayName: &name}) + if err != nil { + t.Fatalf("RedactMessage: %v", err) + } + if out["displayName"] != Redacted { + t.Errorf("a person's name was journalled as %v", out["displayName"]) + } +} + +func TestRedactMessageNilIsEmpty(t *testing.T) { + out, err := RedactMessage(nil) + if err != nil || len(out) != 0 { + t.Fatalf("nil message: got %v, %v — want an empty map and no error", out, err) + } +} diff --git a/components/backend/internal/capability/capability.go b/components/backend/internal/capability/capability.go new file mode 100644 index 00000000..f20626f8 --- /dev/null +++ b/components/backend/internal/capability/capability.go @@ -0,0 +1,209 @@ +// Package capability answers "what is a member allowed to do in this hackathon +// right now". +// +// The gate is always a stored toggle, never a date. Phases may later describe +// when a capability is expected to change, but they never change it: a wrong +// date can then only produce a wrong countdown, never an unauthorized action. +// +// This package is deliberately free of ent and proto imports so the rules can be +// tested as plain data, and so the same function serves both the read path +// (what to show) and the write path (what to allow). Those two must never +// disagree. +package capability + +import "time" + +// Capability is one member-facing action that can be gated. +// +// The string values match the ent enum in db/schema/capability.go and the +// lower-case tail of the proto enum, so the three stay mechanically aligned. +type Capability string + +const ( + Register Capability = "register" + ProposeProjects Capability = "propose_projects" + SetTeamPreferences Capability = "set_team_preferences" + CreateProjectSubmissions Capability = "create_project_submissions" + Vote Capability = "vote" + ViewResults Capability = "view_results" +) + +// All returns the full vocabulary, in the order rows are created for a new +// hackathon. A fresh slice each call, so no caller can reorder it for everyone +// else. +func All() []Capability { + return []Capability{ + Register, + ProposeProjects, + SetTeamPreferences, + CreateProjectSubmissions, + Vote, + ViewResults, + } +} + +// State is the resolved answer for one capability. +type State string + +const ( + // StateOpen means the action is allowed. + StateOpen State = "open" + // StateClosed means a row exists and its flag is off. + StateClosed State = "closed" + // StateComing means closed now, but its open_in_phase starts in the future, so + // callers can count down to it. Still closed for enforcement purposes — the + // distinction is only what the member is told. + StateComing State = "coming" + // StateUngoverned means no row exists, so this package has no opinion. + // + // Callers must behave exactly as they did before capabilities existed — + // mutations proceed, UI renders unchanged. This is what makes it safe to + // adopt one capability at a time, and what stops hackathons that predate a + // capability from having its action silently disappear. + StateUngoverned State = "ungoverned" +) + +// Row is one stored capability flag, reduced to what the rules need. +type Row struct { + Capability Capability + Enabled bool + // OpensAt is the start of the linked open_in_phase, nil when unlinked or when + // that phase has no date. Schedule only: it never opens the capability, it + // only distinguishes "not open yet" from "closed" for the member's benefit. + OpensAt *time.Time + // ClosesAt is the start of the linked closed_in_phase, for display + // alongside an open capability. Nil when unlinked. + ClosesAt *time.Time + // OpenInPhase and CurrentPhase are positions in the hackathon's phase order, + // set only once an organizer has advanced the hackathon by hand. + // + // When CurrentPhase is present it replaces the date comparison below. It has + // to: an organizer advances precisely when the schedule has stopped matching + // reality, and judging by dates then tells members "opens Friday" about + // something the organizer has already declared finished. + OpenInPhase *int + CurrentPhase *int +} + +// pending reports whether the opening moment is still ahead of us. +func (r Row) pending(now time.Time) bool { + if r.CurrentPhase != nil { + // Advanced by hand: order decides, and an unscheduled capability is + // never "coming" because there is no position to compare. + return r.OpenInPhase != nil && *r.OpenInPhase > *r.CurrentPhase + } + + return r.OpensAt != nil && now.Before(*r.OpensAt) +} + +// States is the resolved answer for every capability in the vocabulary. +type States map[Capability]State + +// ResolveRow is the rule for a single stored row, and the only place it lives — +// both the read path (what to show) and the write path (what to allow) go +// through here so they cannot disagree. +// +// Note `Enabled` is checked first and unconditionally: the schedule never +// overrides the flag, so an incorrect phase date cannot open anything. +func ResolveRow(r Row, now time.Time) State { + if r.Enabled { + return StateOpen + } + if r.pending(now) { + return StateComing + } + + return StateClosed +} + +// Resolve maps the stored rows of a single hackathon to a state per capability. +// +// Rows for unknown capabilities are ignored rather than rejected, so a backend +// rolled back to an older binary keeps serving the vocabulary it understands +// instead of failing every read. +func Resolve(rows []Row, now time.Time) States { + byCapability := make(map[Capability]Row, len(rows)) + for _, r := range rows { + byCapability[r.Capability] = r + } + + all := All() + states := make(States, len(all)) + for _, c := range all { + row, ok := byCapability[c] + if !ok { + states[c] = StateUngoverned + + continue + } + states[c] = ResolveRow(row, now) + } + + return states +} + +// AdvanceRow is one capability's schedule expressed as positions in the +// hackathon's phase order, which is what advancing compares against. +// +// Positions rather than dates: advancing is "we are in Judging now", a statement +// about order, and organizers reach for it precisely when the clock has stopped +// matching reality. +type AdvanceRow struct { + Capability Capability + // OpenInPhase is the position of the phase that opens this capability. Nil + // means manually driven, and advancing must not touch it. + OpenInPhase *int + // ClosedInPhase is the position of the phase at whose start it closes. Nil + // means it stays open once opened. + ClosedInPhase *int +} + +// Advance computes the `enabled` flag each scheduled capability should take when +// the hackathon moves to the phase at position `target`. +// +// Capabilities with no opening phase are absent from the result and must be left +// exactly as they are — that is what keeps voting, and anything else an +// organizer drives by hand, immune to advancing. +// +// A capability spanning several phases stays open across them, which is why the +// window is a pair of positions rather than a single one: registration running +// from "registration opens" to "registration closes" cannot be expressed +// otherwise. +func Advance(rows []AdvanceRow, target int) map[Capability]bool { + out := make(map[Capability]bool, len(rows)) + for _, r := range rows { + if r.OpenInPhase == nil { + continue + } + opened := *r.OpenInPhase <= target + closed := r.ClosedInPhase != nil && target >= *r.ClosedInPhase + out[r.Capability] = opened && !closed + } + + return out +} + +// Allowed reports whether a resolved state permits the action it guards. +// +// Note that ungoverned counts as allowed. Callers must use this rather than +// comparing against StateOpen, since that comparison would block every +// capability that has no row yet — including on every hackathon created before +// the capability was introduced. +// +// This is the one predicate for "is it on", and everything that needs a boolean +// goes through it: enforcement via States.Allowed below, and the flat +// HackathonState facade the API exposes for main's contract. A facade that +// disagreed with the gate would tell a client it could do something the server +// then refuses. +func (s State) Allowed() bool { + return s == StateOpen || s == StateUngoverned +} + +// Allowed reports whether a mutation guarded by c may proceed. An absent +// capability is one this map has no opinion about, which is the ungoverned case +// by another route. +func (s States) Allowed(c Capability) bool { + state, ok := s[c] + + return !ok || state.Allowed() +} diff --git a/components/backend/internal/capability/capability_suite_test.go b/components/backend/internal/capability/capability_suite_test.go new file mode 100644 index 00000000..aada9b50 --- /dev/null +++ b/components/backend/internal/capability/capability_suite_test.go @@ -0,0 +1,15 @@ +//go:build test && unittest + +package capability_test + +import ( + "testing" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +func TestCapability(t *testing.T) { + RegisterFailHandler(Fail) + RunSpecs(t, "Capability Suite") +} diff --git a/components/backend/internal/capability/capability_test.go b/components/backend/internal/capability/capability_test.go new file mode 100644 index 00000000..e41ecdee --- /dev/null +++ b/components/backend/internal/capability/capability_test.go @@ -0,0 +1,341 @@ +//go:build test && unittest + +package capability_test + +import ( + "time" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + . "github.com/swissdatasciencecenter/hackagon/components/backend/internal/capability" +) + +var now = time.Date(2026, time.July, 15, 12, 0, 0, 0, time.UTC) + +func at(days int) *time.Time { + t := now.AddDate(0, 0, days) + + return &t +} + +func row(c Capability, enabled bool) Row { + return Row{Capability: c, Enabled: enabled, OpensAt: nil, ClosesAt: nil} +} + +func scheduled(c Capability, enabled bool, opensAt, closesAt *time.Time) Row { + return Row{Capability: c, Enabled: enabled, OpensAt: opensAt, ClosesAt: closesAt} +} + +var _ = Describe("Capability", func() { + Describe("Resolve", func() { + It("opens a capability whose flag is on", func() { + states := Resolve([]Row{row(Register, true)}, now) + + Expect(states[Register]).To(Equal(StateOpen)) + }) + + It("closes a capability whose flag is off", func() { + states := Resolve([]Row{row(Register, false)}, now) + + Expect(states[Register]).To(Equal(StateClosed)) + }) + + It("resolves each capability independently", func() { + states := Resolve([]Row{ + row(Register, false), + row(Vote, true), + }, now) + + Expect(states[Register]).To(Equal(StateClosed)) + Expect(states[Vote]).To(Equal(StateOpen)) + }) + + It("reports capabilities with no row as ungoverned", func() { + states := Resolve([]Row{row(Register, true)}, now) + + Expect(states[ProposeProjects]).To(Equal(StateUngoverned)) + Expect(states[ViewResults]).To(Equal(StateUngoverned)) + }) + + It("reports every capability as ungoverned when there are no rows", func() { + // A hackathon predating the capability table must keep behaving + // exactly as it did, rather than having every action disappear. + states := Resolve(nil, now) + + Expect(states).To(HaveLen(len(All()))) + for _, c := range All() { + Expect(states[c]).To(Equal(StateUngoverned)) + } + }) + + It("answers for every capability in the vocabulary", func() { + states := Resolve([]Row{row(Register, true)}, now) + + Expect(states).To(HaveLen(len(All()))) + for _, c := range All() { + Expect(states).To(HaveKey(c)) + } + }) + + It("ignores rows for capabilities it does not know", func() { + // An older binary reading rows written by a newer one must keep + // serving the vocabulary it understands rather than failing. + states := Resolve([]Row{ + row(Register, true), + row(Capability("teleport"), true), + }, now) + + Expect(states).To(HaveLen(len(All()))) + Expect(states).NotTo(HaveKey(Capability("teleport"))) + Expect(states[Register]).To(Equal(StateOpen)) + }) + + It("lets the last row win when a capability appears twice", func() { + // The unique index prevents this in the database; resolving it + // deterministically means a violated invariant cannot become a + // coin-flip over whether an action is allowed. + states := Resolve([]Row{row(Vote, true), row(Vote, false)}, now) + + Expect(states[Vote]).To(Equal(StateClosed)) + }) + }) + + Describe("ResolveRow schedule", func() { + It("reports coming when the opening phase is still ahead", func() { + r := scheduled(Register, false, at(3), at(10)) + + Expect(ResolveRow(r, now)).To(Equal(StateComing)) + }) + + It("reports closed once the opening phase has passed", func() { + // Passing the opening date does not open anything — only the flag + // does — so this stays closed rather than becoming open. + r := scheduled(Register, false, at(-3), at(10)) + + Expect(ResolveRow(r, now)).To(Equal(StateClosed)) + }) + + It("reports closed at the exact instant the opening phase starts", func() { + r := scheduled(Register, false, &now, nil) + + Expect(ResolveRow(r, now)).To(Equal(StateClosed)) + }) + + It("ignores the schedule entirely when the flag is on", func() { + // The decisive property of the design: an organizer who opens a + // capability early is not overruled by its phase dates. + r := scheduled(Register, true, at(3), at(10)) + + Expect(ResolveRow(r, now)).To(Equal(StateOpen)) + }) + + It("keeps a manually driven capability closed with no countdown", func() { + // Voting opens abruptly, so it links to no phase. It must never + // report coming, since there is no date to count down to. + r := scheduled(Vote, false, nil, nil) + + Expect(ResolveRow(r, now)).To(Equal(StateClosed)) + }) + + It("does not let a past closing phase reopen a coming capability", func() { + r := scheduled(Register, false, at(3), at(-1)) + + Expect(ResolveRow(r, now)).To(Equal(StateComing)) + }) + + It("propagates coming through Resolve", func() { + states := Resolve([]Row{scheduled(ProposeProjects, false, at(5), nil)}, now) + + Expect(states[ProposeProjects]).To(Equal(StateComing)) + }) + }) + + Describe("ResolveRow after a manual advance", func() { + pos := func(i int) *int { return &i } + + // A future date on the opening phase, as happens whenever an event runs + // ahead of its published schedule. + future := now.AddDate(0, 0, 5) + + It("ignores a future date once the organizer has advanced past the phase", func() { + // Without this, a member is told "opens in 5 days" about something the + // organizer has already declared finished. + r := Row{ + Capability: ProposeProjects, Enabled: false, + OpensAt: &future, ClosesAt: nil, + OpenInPhase: pos(0), CurrentPhase: pos(2), + } + + Expect(ResolveRow(r, now)).To(Equal(StateClosed)) + }) + + It("still reports coming for a phase the organizer has not reached", func() { + r := Row{ + Capability: ProposeProjects, Enabled: false, + OpensAt: &future, ClosesAt: nil, + OpenInPhase: pos(3), CurrentPhase: pos(1), + } + + Expect(ResolveRow(r, now)).To(Equal(StateComing)) + }) + + It("reports coming at the boundary only before the phase is reached", func() { + atPhase := Row{ + Capability: ProposeProjects, Enabled: false, + OpensAt: &future, ClosesAt: nil, + OpenInPhase: pos(2), CurrentPhase: pos(2), + } + + Expect(ResolveRow(atPhase, now)).To(Equal(StateClosed)) + }) + + It("never reports coming for an unscheduled capability", func() { + // Voting has no position to compare, so advancing cannot make it + // look imminent. + r := Row{ + Capability: Vote, Enabled: false, + OpensAt: nil, ClosesAt: nil, + OpenInPhase: nil, CurrentPhase: pos(1), + } + + Expect(ResolveRow(r, now)).To(Equal(StateClosed)) + }) + + It("falls back to dates when no advance has happened", func() { + r := Row{ + Capability: ProposeProjects, Enabled: false, + OpensAt: &future, ClosesAt: nil, + OpenInPhase: pos(0), CurrentPhase: nil, + } + + Expect(ResolveRow(r, now)).To(Equal(StateComing)) + }) + + It("keeps the flag decisive regardless of position", func() { + r := Row{ + Capability: ProposeProjects, Enabled: true, + OpensAt: &future, ClosesAt: nil, + OpenInPhase: pos(5), CurrentPhase: pos(0), + } + + Expect(ResolveRow(r, now)).To(Equal(StateOpen)) + }) + }) + + Describe("Advance", func() { + pos := func(i int) *int { return &i } + + // The SDSC-shaped template: registration spans several phases, the rest + // occupy one each, voting is driven by hand. + template := []AdvanceRow{ + {Capability: Register, OpenInPhase: pos(0), ClosedInPhase: pos(3)}, + {Capability: ProposeProjects, OpenInPhase: pos(1), ClosedInPhase: pos(2)}, + {Capability: CreateProjectSubmissions, OpenInPhase: pos(3), ClosedInPhase: pos(4)}, + {Capability: ViewResults, OpenInPhase: pos(4), ClosedInPhase: nil}, + {Capability: Vote, OpenInPhase: nil, ClosedInPhase: nil}, + } + + It("opens a capability once its phase is reached", func() { + Expect(Advance(template, 1)[ProposeProjects]).To(BeTrue()) + }) + + It("keeps a capability closed before its phase", func() { + Expect(Advance(template, 0)[ProposeProjects]).To(BeFalse()) + }) + + It("closes a capability once its closing phase is reached", func() { + Expect(Advance(template, 2)[ProposeProjects]).To(BeFalse()) + }) + + It("keeps a spanning capability open across intermediate phases", func() { + // Registration runs from phase 0 to 3, which a single phase link + // could not express. + for _, target := range []int{0, 1, 2} { + Expect(Advance(template, target)[Register]). + To(BeTrue(), "register should be open at phase %d", target) + } + Expect(Advance(template, 3)[Register]).To(BeFalse()) + }) + + It("keeps an open-ended capability open once reached", func() { + Expect(Advance(template, 4)[ViewResults]).To(BeTrue()) + Expect(Advance(template, 99)[ViewResults]).To(BeTrue()) + }) + + It("omits manually driven capabilities so they are left untouched", func() { + // The property that protects voting from being closed by advancing. + for _, target := range []int{0, 1, 2, 3, 4} { + _, present := Advance(template, target)[Vote] + Expect(present).To(BeFalse(), "vote must not be decided at phase %d", target) + } + }) + + It("is idempotent for the same target", func() { + Expect(Advance(template, 2)).To(Equal(Advance(template, 2))) + }) + + It("restores the earlier flags when advancing backwards", func() { + forward := Advance(template, 1) + Expect(Advance(template, 3)).NotTo(Equal(forward)) + Expect(Advance(template, 1)).To(Equal(forward)) + }) + + It("returns an empty result when nothing is scheduled", func() { + rows := []AdvanceRow{{Capability: Vote, OpenInPhase: nil, ClosedInPhase: nil}} + + Expect(Advance(rows, 0)).To(BeEmpty()) + }) + + It("closes a capability whose window is inverted", func() { + // An organizer can set closes before opens; it must resolve to one + // answer rather than panicking or flapping. + rows := []AdvanceRow{ + {Capability: Register, OpenInPhase: pos(3), ClosedInPhase: pos(1)}, + } + + for _, target := range []int{0, 1, 2, 3, 4} { + Expect(Advance(rows, target)[Register]). + To(BeFalse(), "should stay closed at phase %d", target) + } + }) + }) + + Describe("Allowed", func() { + It("allows an open capability", func() { + Expect(Resolve([]Row{row(Register, true)}, now).Allowed(Register)).To(BeTrue()) + }) + + It("blocks a closed capability", func() { + Expect(Resolve([]Row{row(Register, false)}, now).Allowed(Register)).To(BeFalse()) + }) + + It("blocks a coming capability", func() { + // Coming is a nicer thing to tell a member, not a weaker gate. + states := Resolve([]Row{scheduled(Register, false, at(3), nil)}, now) + + Expect(states[Register]).To(Equal(StateComing)) + Expect(states.Allowed(Register)).To(BeFalse()) + }) + + It("allows an ungoverned capability", func() { + // The regression this guards: enforcing with `state == StateOpen` + // would reject every mutation on every hackathon that has no row + // for the capability yet. + Expect(Resolve(nil, now).Allowed(ProposeProjects)).To(BeTrue()) + }) + + It("allows a capability missing from the map entirely", func() { + Expect(States{}.Allowed(Vote)).To(BeTrue()) + }) + }) + + Describe("All", func() { + It("has no duplicates", func() { + seen := map[Capability]bool{} + for _, c := range All() { + Expect(seen[c]).To(BeFalse(), "duplicate capability %q", c) + seen[c] = true + } + }) + }) +}) diff --git a/components/backend/internal/config/config.go b/components/backend/internal/config/config.go index 6871704f..915277e6 100644 --- a/components/backend/internal/config/config.go +++ b/components/backend/internal/config/config.go @@ -3,6 +3,7 @@ package config import ( "errors" "fmt" + "io/fs" "log/slog" "path" "strings" @@ -18,7 +19,41 @@ type Config struct { Server ServerConfig `yaml:"server"` Database DatabaseConfig `yaml:"database"` Oidc OidcConfig `yaml:"oidc"` + Storage StorageConfig `yaml:"storage"` Logging LoggingConfig `yaml:"logging"` + Audit AuditConfig `yaml:"audit"` +} + +// AuditConfig turns on the RPC journal (internal/audit): a development and +// analysis tool that appends one JSON line per gRPC call to a local file. +// +// WHAT TURNING THIS ON COLLECTS, for every unary RPC that reaches a handler: +// - the platform USERNAME of the caller (resolved from the JWT subject; +// `anonymous` for unauthenticated callers), never the Keycloak ID; +// - the full method name, e.g. hackathon.HackathonService/Edit; +// - the request message, run through the allowlist in internal/audit — +// ids, enums, booleans, numbers, timestamps and slugs are recorded as +// sent, and EVERY other field becomes "<redacted>"; +// - whether the call succeeded, or the gRPC status code name if not; +// - the UUIDs of objects the response reports (id / *Id fields only), so a +// later call referring to the same object can be recognised. +// +// It never reads the peer address, the user agent, any session or trace id, +// or any response field that is not an id. It is off by default because it is +// a local analysis tool and nothing should start recording implicitly: +// enable with `audit.enabled: true` in config.yaml, or +// HACKAGON_AUDIT_ENABLED=true. +type AuditConfig struct { + // Enabled is the master switch. False (the default) means no journal is + // created and no interceptor is installed at all. + Enabled bool `yaml:"enabled"` + // Path is the JSONL file to append to, relative to the backend's working + // directory. Parent directories are created on demand. + Path string `yaml:"path"` + // Buffer is the depth of the hand-off queue to the writer goroutine. + // When it fills, entries are DROPPED (and counted) rather than making an + // RPC wait on file IO. + Buffer int `yaml:"buffer"` } type LoggingConfig struct { @@ -46,6 +81,39 @@ type OidcConfig struct { Algorithm string `yaml:"algorithm"` } +// StorageConfig addresses the S3-compatible object store that holds uploaded +// files. In development that is the `rustfs` container from +// .devcontainer/docker-compose.yml; in a deployment it is S3/MinIO/whatever +// speaks the same API, with the keys injected from a secret store rather than +// read out of this file. +type StorageConfig struct { + // Endpoint is the S3 base URL. Defaults to the compose service name + // because the dev container reaches rustfs over the compose network — + // localhost:9000 is NOT an option there, Keycloak's management port + // already owns 9000 inside that container. Native (non-container) setups + // override with HACKAGON_STORAGE_ENDPOINT=http://localhost:9000. + Endpoint string `yaml:"endpoint"` + Region string `yaml:"region"` + Bucket string `yaml:"bucket"` + // DEV-ONLY defaults, mirroring the database password above: they match the + // committed compose defaults so a fresh checkout works unconfigured. + // Deployments override every field via HACKAGON_STORAGE_* env vars. + AccessKey string `yaml:"accesskey"` + SecretKey string `yaml:"secretkey"` + // UsePathStyle keeps requests as endpoint/bucket/key. rustfs only supports + // virtual-hosted style (bucket.host/key) when RUSTFS_SERVER_DOMAINS is + // set, which the dev service deliberately does not set — there is no + // wildcard DNS for *.rustfs on the compose network. + UsePathStyle bool `yaml:"usepathstyle"` + // PublicPrefix is the path the FRONTEND serves objects under, on its own + // origin — the vite proxy in components/frontend/vite.config.ts and the + // matching caddy route in .devcontainer/Caddyfile.tunnel. Presigned URLs + // and stored paths are both built from it, so they are root-relative and + // resolve from localhost, the tunnel and a deployment alike. Point it at a + // CDN origin to serve uploads from somewhere else. + PublicPrefix string `yaml:"publicprefix"` +} + func (c *Config) ConnectionStr() string { switch c.Database.Driver { case "sqlite3": @@ -71,6 +139,14 @@ func (c *Config) ConnectionStr() string { } } +// Load builds the configuration from four layers, each overriding the one +// before it: +// +// defaults (below) < config.yaml < config.local.yaml < HACKAGON_* env vars +// +// Every layer is a partial: it only has to carry what it changes. +// config.local.yaml is gitignored and optional — see the comment at its Load +// call for why it exists. func Load(configDir string) (*Config, error) { k := koanf.New(".") @@ -91,16 +167,33 @@ func Load(configDir string) (*Config, error) { "issuerurl": "http://localhost:8180/realms/hackagon", "algorithm": "RS256", }, + "storage": map[string]interface{}{ + "endpoint": "http://rustfs:9000", + "region": "us-east-1", + "bucket": "hackagon-dev", + "accesskey": "hackagon-dev", + "secretkey": "hackagon-dev-secret", + "usepathstyle": true, + "publicprefix": "/objects", + }, "logging": map[string]interface{}{ "level": "info", }, + "audit": map[string]interface{}{ + "enabled": false, + "path": ".output/audit/rpc-journal.jsonl", + "buffer": 4096, //nolint:mnd // queue depth; see AuditConfig.Buffer + }, } if err := k.Load(confmap.Provider(defaults, ""), nil); err != nil { return nil, err } - // Override with YAML config file - configPath := path.Join(path.Dir(configDir), "config.yaml") + // Override with YAML config file. Both files below are resolved from the + // SAME derived directory, so the base and its overlay can never end up + // pointing at different places. + dir := path.Dir(configDir) + configPath := path.Join(dir, "config.yaml") if err := k.Load(file.Provider(configPath), yaml.Parser()); err != nil { if !strings.Contains(err.Error(), "no such file") { return nil, err @@ -108,6 +201,33 @@ func Load(configDir string) (*Config, error) { slog.Warn("couldn't load config file", "err", err) } + // Override with the LOCAL OVERLAY: config.local.yaml beside config.yaml, + // gitignored, optional, partial. Absent is the normal case and changes + // nothing. + // + // It exists so that machine-specific wiring never has to edit a TRACKED + // file. The Cloudflare quick tunnel (.claude/skills/cloudflare-tunnel) + // used to rewrite oidc.issuerurl in config.yaml itself and keep a backup + // beside it; while wired, the working tree differed from HEAD, and a + // `git add -A` committed a hostname that dies with the tunnel — which + // happened, and sat committed for several commits. Config.yaml is now + // read-only as far as tooling is concerned; the tunnel writes the overlay + // and `--restore` deletes it. config_test.go asserts the tracked files + // still say localhost. + // + // Koanf merges maps key by key, so an overlay setting only oidc.issuerurl + // leaves jwksurl and algorithm exactly as config.yaml had them. + localPath := path.Join(dir, "config.local.yaml") + if err := k.Load(file.Provider(localPath), yaml.Parser()); err != nil { + // Only "not there" is acceptable — a malformed or unreadable overlay + // must fail loudly, or it silently stops taking effect. + if !errors.Is(err, fs.ErrNotExist) { + return nil, fmt.Errorf("loading %s: %w", localPath, err) + } + } else { + slog.Info("loaded local config overlay", "path", localPath) + } + // Override with environment variables if err := k.Load(env.Provider(".", env.Opt{ Prefix: "HACKAGON_", diff --git a/components/backend/internal/config/config_suite_test.go b/components/backend/internal/config/config_suite_test.go new file mode 100644 index 00000000..ff14bd72 --- /dev/null +++ b/components/backend/internal/config/config_suite_test.go @@ -0,0 +1,15 @@ +//go:build test && unittest + +package config_test + +import ( + "testing" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +func TestConfig(t *testing.T) { + RegisterFailHandler(Fail) + RunSpecs(t, "Config Suite") +} diff --git a/components/backend/internal/config/config_test.go b/components/backend/internal/config/config_test.go new file mode 100644 index 00000000..2c6b600b --- /dev/null +++ b/components/backend/internal/config/config_test.go @@ -0,0 +1,134 @@ +//go:build test && unittest + +package config_test + +import ( + "net/url" + "os" + "path/filepath" + + "github.com/knadh/koanf/parsers/yaml" + "github.com/knadh/koanf/providers/file" + "github.com/knadh/koanf/v2" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + + "github.com/swissdatasciencecenter/hackagon/components/backend/internal/config" +) + +// A config.yaml complete enough for Load to succeed. Load rejects an empty +// server.adminkeycloakid, so that field has to be here. +const baseYAML = ` +server: + port: "3000" + adminkeycloakid: "1183370a-46a2-4dad-b8fd-dd927d083e14" +oidc: + jwksurl: "http://localhost:8180/realms/hackagon/protocol/openid-connect/certs" + issuerurl: "http://localhost:8180/realms/hackagon" + algorithm: "RS256" +` + +// writeConfigDir returns a directory holding the given files, addressed the +// way the real callers address it: with a TRAILING SLASH, because Load runs +// path.Dir over its argument. +func writeConfigDir(files map[string]string) string { + dir := GinkgoT().TempDir() + for name, content := range files { + Expect(os.WriteFile(filepath.Join(dir, name), []byte(content), 0o600)).To(Succeed()) + } + + return dir + "/" +} + +var _ = Describe("Load", func() { + Context("without an overlay", func() { + It("uses config.yaml", func() { + cfg, err := config.Load(writeConfigDir(map[string]string{"config.yaml": baseYAML})) + Expect(err).ToNot(HaveOccurred()) + Expect(cfg.Oidc.IssuerUrl).To(Equal("http://localhost:8180/realms/hackagon")) + }) + }) + + Context("with config.local.yaml beside it", func() { + const overlayYAML = ` +oidc: + issuerurl: "https://example-tunnel.trycloudflare.com/realms/hackagon" +` + + It("overrides config.yaml and leaves untouched keys alone", func() { + cfg, err := config.Load(writeConfigDir(map[string]string{ + "config.yaml": baseYAML, + "config.local.yaml": overlayYAML, + })) + Expect(err).ToNot(HaveOccurred()) + + Expect(cfg.Oidc.IssuerUrl). + To(Equal("https://example-tunnel.trycloudflare.com/realms/hackagon")) + // The overlay is a PARTIAL: naming one key under oidc must not + // erase its siblings. jwksurl deliberately stays on localhost even + // while a tunnel is wired. + Expect(cfg.Oidc.JwksUrl). + To(Equal("http://localhost:8180/realms/hackagon/protocol/openid-connect/certs")) + Expect(cfg.Oidc.Algorithm).To(Equal("RS256")) + // And a key from a section the overlay never mentions. + Expect(cfg.Server.Port).To(Equal("3000")) + }) + + It("still loses to the environment", func() { + GinkgoT().Setenv("HACKAGON_OIDC_ISSUERURL", "http://env.example/realms/hackagon") + + cfg, err := config.Load(writeConfigDir(map[string]string{ + "config.yaml": baseYAML, + "config.local.yaml": overlayYAML, + })) + Expect(err).ToNot(HaveOccurred()) + Expect(cfg.Oidc.IssuerUrl).To(Equal("http://env.example/realms/hackagon")) + }) + + It("fails loudly when the overlay is malformed", func() { + _, err := config.Load(writeConfigDir(map[string]string{ + "config.yaml": baseYAML, + "config.local.yaml": "oidc: [this is not a mapping\n", + })) + // Ignoring a broken overlay would mean the operator's override + // quietly stops applying — the exact failure mode the overlay was + // introduced to end. + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("config.local.yaml")) + }) + }) +}) + +// The tracked configs are the repository's, not this machine's. Nothing that +// wires this checkout to a temporary hostname may write into them: a +// Cloudflare quick-tunnel issuer was once committed this way and sat there for +// several commits, and a fresh clone pointed at a tunnel that no longer +// existed. Tunnel wiring goes in config.local.yaml, which is gitignored. +// +// This guard lives here — in the package that DEFINES the precedence chain — +// rather than in the e2e harness, because the harness is not part of the +// repository on every branch while these two files always are, and because +// `go test ./internal/...` runs in CI. It reaches across to the frontend's +// config on purpose: it is one invariant over two files, and splitting it in +// two leaves two half-guards that can drift apart. +var _ = Describe("the tracked config files", func() { + DescribeTable("keep their OIDC issuer on localhost", + func(relPath, key string) { + k := koanf.New(".") + Expect(k.Load(file.Provider(relPath), yaml.Parser())).To(Succeed()) + + raw := k.String(key) + Expect(raw).ToNot(BeEmpty(), "%s has no %s", relPath, key) + + parsed, err := url.Parse(raw) + Expect(err).ToNot(HaveOccurred()) + Expect(parsed.Hostname()).To(Equal("localhost"), + "%s: %s is %q. A tracked config must never carry a machine- or "+ + "tunnel-specific hostname — write it to config.local.yaml "+ + "instead (gitignored; see .claude/skills/cloudflare-tunnel/"+ + "scripts/auth-wire.sh).", relPath, key, raw) + }, + Entry("backend", "../../data/test/config/config.yaml", "oidc.issuerurl"), + Entry("frontend", "../../../frontend/data/test/config/config.yaml", "oidc.issuer"), + ) +}) diff --git a/components/backend/internal/middleware/auth.go b/components/backend/internal/middleware/auth.go index 40e3d0a8..c6073b56 100644 --- a/components/backend/internal/middleware/auth.go +++ b/components/backend/internal/middleware/auth.go @@ -139,6 +139,27 @@ func GetSubject(ctx context.Context) (string, error) { return sub, nil } +// RequireUser is RequireSubject for endpoints that act on a PERSON: it rejects +// the anonymous subject. +// +// The auth interceptor injects `sub: "anonymous"` when there is no bearer +// token so casbin can evaluate an unauthenticated caller as an unprivileged +// one. That is right for reads, but the self-service user endpoints treat the +// subject as an identity — an anonymous Register happily created a real +// profile with keycloak_id "anonymous", which then showed up in the user +// admin as a person and could have been granted roles. +func RequireUser(ctx context.Context) (string, jwt.MapClaims, error) { + sub, claims, err := RequireSubject(ctx) + if err != nil { + return "", nil, err + } + if sub == AnonSubject { + return "", nil, status.Error(codes.Unauthenticated, "sign in to do that") + } + + return sub, claims, nil +} + func RequireSubject(ctx context.Context) (string, jwt.MapClaims, error) { claims, ok := GetClaims(ctx) if !ok { diff --git a/components/backend/internal/middleware/auth_test.go b/components/backend/internal/middleware/auth_test.go index f403f820..dd0d7127 100644 --- a/components/backend/internal/middleware/auth_test.go +++ b/components/backend/internal/middleware/auth_test.go @@ -10,7 +10,9 @@ import ( "github.com/golang-jwt/jwt/v5" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" + "google.golang.org/grpc/codes" "google.golang.org/grpc/metadata" + "google.golang.org/grpc/status" "github.com/swissdatasciencecenter/hackagon/components/backend/internal/config" "github.com/swissdatasciencecenter/hackagon/components/backend/internal/middleware" @@ -180,6 +182,44 @@ var _ = Describe("Auth Middleware", func() { }) }) + // RequireSubject admits the anonymous subject on purpose — casbin evaluates + // it as an unprivileged caller, which is what makes public reads work + // without a token. RequireUser is the variant for endpoints that treat the + // subject as an IDENTITY, and the whole of it is one rejection. + // + // Nothing else can stand in for it: a handler that lost this check answers + // whatever its next step answers — NotFound for an id that does not exist, + // which lets an unauthenticated caller probe which ids do. + Describe("RequireUser Function", func() { + It("refuses the anonymous subject", func() { + ctx := middleware.CtxWithClaims(middleware.AnonSubject) + + sub, claims, err := middleware.RequireUser(ctx) + Expect(err).To(HaveOccurred()) + Expect(status.Code(err)).To(Equal(codes.Unauthenticated)) + Expect(sub).To(BeEmpty(), "an admitted subject is the bug this guards") + Expect(claims).To(BeNil()) + }) + + // The control: the same call on a real subject has to get through, or + // the rejection above would agree with a function that refused everyone. + It("passes a real subject through with its claims", func() { + ctx := middleware.CtxWithClaims("keycloak-alice") + + sub, claims, err := middleware.RequireUser(ctx) + Expect(err).NotTo(HaveOccurred()) + Expect(sub).To(Equal("keycloak-alice")) + Expect(claims["sub"]).To(Equal("keycloak-alice")) + }) + + It("refuses a context with no claims at all", func() { + sub, _, err := middleware.RequireUser(context.Background()) + Expect(err).To(HaveOccurred()) + Expect(status.Code(err)).To(Equal(codes.Unauthenticated)) + Expect(sub).To(BeEmpty()) + }) + }) + Describe("GetSubject Function", func() { It("returns subject when available", func() { ctx := middleware.CtxWithClaims("test-user-subject") diff --git a/components/backend/internal/middleware/rbac.go b/components/backend/internal/middleware/rbac.go index a2488670..deb42fb1 100644 --- a/components/backend/internal/middleware/rbac.go +++ b/components/backend/internal/middleware/rbac.go @@ -25,6 +25,9 @@ var modelFile string const minPolicyFields = 2 // casbin policy tuples have at least 2 fields: subject and role +// ErrNotAGlobalRole is returned when a hackathon-scoped role is granted globally. +var ErrNotAGlobalRole = errors.New("role cannot be granted globally") + type Role int const ( @@ -111,8 +114,14 @@ func (p Permission) String() string { } } +// Enforcer wraps a casbin.SyncedEnforcer — synced, not plain, because gRPC +// handlers run concurrently and casbin's in-memory model is not safe for a +// policy write (Join's AddRole, owner grants) racing an Enforce read. The +// synced variant takes an RWMutex around both. Check-then-act sequences that +// span SEVERAL casbin calls (the last-organizer guard) still need their own +// lock on top; see HackathonService.ownerMu. type Enforcer struct { - enforcer *casbin.Enforcer + enforcer *casbin.SyncedEnforcer } func NewRBACEnforcer(cfg *config.Config) (*Enforcer, error) { @@ -126,7 +135,7 @@ func NewRBACEnforcer(cfg *config.Config) (*Enforcer, error) { return nil, fmt.Errorf("failed to load model config: %w", err) } - e, err := casbin.NewEnforcer(m, a) + e, err := casbin.NewSyncedEnforcer(m, a) if err != nil { return nil, fmt.Errorf("failed to create enforcer: %w", err) } @@ -154,7 +163,7 @@ func NewRBACEnforcer(cfg *config.Config) (*Enforcer, error) { return &Enforcer{enforcer: e}, nil } -func defaultPolicies(cfg *config.Config, e *casbin.Enforcer) error { +func defaultPolicies(cfg *config.Config, e *casbin.SyncedEnforcer) error { policies := [][]string{ // HackathonOrganizer can create new hackathons {HackathonOrganizer.String(), "/hackathon/*", Hackathon.String(), Create.String()}, @@ -194,6 +203,9 @@ func defaultPolicies(cfg *config.Config, e *casbin.Enforcer) error { {Member.String(), "/hackathon/*", Track.String(), Read.String()}, // Member can read hackathon projects {Member.String(), "/hackathon/*", Project.String(), Read.String()}, + // Members read every team's submissions: demo day and voting both + // require seeing what the other teams turned in. + {Member.String(), "/hackathon/*", Submission.String(), Read.String()}, // Owner can create teams {Owner.String(), "/hackathon/*", Team.String(), Create.String()}, // Owner can edit teams @@ -275,8 +287,59 @@ func (e *Enforcer) RemoveRole( return e.enforcer.RemoveGroupingPolicy(user, role.String(), domain) } +// IsGlobal reports whether a role is meaningful outside a single hackathon. +// Owner and Member describe a user's standing in one hackathon, so granting them +// globally is always a mistake. +func (r Role) IsGlobal() bool { + return r == Admin || r == HackathonOrganizer +} + +// AddGlobalRole grants a role to a user across all hackathons. +// +// It writes both grouping tables, because each is read by a different consumer: +// - g2 (user, role) is what GetGlobalRoles — and therefore WhoAmI — reports. +// - g (user, role, /hackathon/*) is what the matcher can actually enforce. The +// model only consults g2 through the hard-coded g2(r.sub, "admin") clause, so +// a g2 row alone leaves every role other than admin unenforceable. +// +// The g domain is the literal string "/hackathon/*", which is what handlers +// enforcing against all hackathons pass (see HackathonService.Create). No domain +// matching function is registered for g, so g lookups are exact string compares: +// this row cannot match a request scoped to a concrete /hackathon/<id>. +// +// Use AddRole for roles that belong to one hackathon. func (e *Enforcer) AddGlobalRole(user string, role Role) (bool, error) { - return e.enforcer.AddNamedGroupingPolicy("g2", user, role.String()) + if !role.IsGlobal() { + return false, fmt.Errorf("%w: %s is scoped to a single hackathon", ErrNotAGlobalRole, role) + } + + if _, err := e.enforcer.AddNamedGroupingPolicy("g2", user, role.String()); err != nil { + return false, fmt.Errorf("add global role %s for %s: %w", role, user, err) + } + + return e.enforcer.AddGroupingPolicy(user, role.String(), hackathonIdToPath("*")) +} + +// RemoveGlobalRole revokes a role granted by AddGlobalRole. +// +// Symmetric with it on purpose: AddGlobalRole writes BOTH grouping tables — +// `g2` for the global-role checks and a wildcard `g` row so per-hackathon +// policies match too — so removing only the `g2` row would leave the wildcard +// behind and the user would keep the permissions while every UI that lists +// roles showed none. That is the worst shape a permission bug can take. +// +// Idempotent: casbin no-ops on a policy that is not there, so revoking a role +// nobody holds is not an error. +func (e *Enforcer) RemoveGlobalRole(user string, role Role) (bool, error) { + if !role.IsGlobal() { + return false, fmt.Errorf("%w: %s is scoped to a single hackathon", ErrNotAGlobalRole, role) + } + + if _, err := e.enforcer.RemoveNamedGroupingPolicy("g2", user, role.String()); err != nil { + return false, fmt.Errorf("remove global role %s for %s: %w", role, user, err) + } + + return e.enforcer.RemoveGroupingPolicy(user, role.String(), hackathonIdToPath("*")) } func (e *Enforcer) AllowPublicHackathonAccess(hackathonId string) (bool, error) { @@ -315,6 +378,32 @@ func (e *Enforcer) ListG2Policies() ([][]string, error) { return e.enforcer.GetFilteredNamedGroupingPolicy("g2", 0) } +// HackathonOwners returns the Keycloak IDs holding Owner in one hackathon. +// +// Exists for the last-owner guard in RemoveOwner. Ownership is a casbin fact on +// this branch — there is no owners column to count — so the only way to ask +// "would this leave the event unowned?" is to read the grouping table directly. +// +// Filters on fields 1 (role) and 2 (domain) of `g`; field 0 is the subject, +// which is what we are collecting. +func (e *Enforcer) HackathonOwners(hackathonID string) ([]string, error) { + rows, err := e.enforcer.GetFilteredGroupingPolicy( + 1, Owner.String(), hackathonIdToPath(hackathonID), + ) + if err != nil { + return nil, err + } + + owners := make([]string, 0, len(rows)) + for _, r := range rows { + if len(r) > 0 { + owners = append(owners, r[0]) + } + } + + return owners, nil +} + // GetHackathonRole returns the highest-priority casbin role for keycloakID in hackathonID. // Owner takes precedence over Member regardless of slice order — casbin does not sort roles. // Global admin/organizer roles (g2) are not surfaced here — the enum only has OWNER and MEMBER. @@ -351,6 +440,63 @@ func (e *Enforcer) GetHackathonRole( } } +// PurgeUserRoles removes every role a user holds — per-hackathon (g) and +// global (g2) alike. Used by account deletion: leaving grouping rows behind +// would silently re-grant everything if the same Keycloak subject ever +// registered again. +func (e *Enforcer) PurgeUserRoles(keycloakID string) error { + // Field 0 of both grouping tables is the subject. + if _, err := e.enforcer.RemoveFilteredGroupingPolicy(0, keycloakID); err != nil { + return err + } + if _, err := e.enforcer.RemoveFilteredNamedGroupingPolicy("g2", 0, keycloakID); err != nil { + return err + } + + return e.enforcer.SavePolicy() +} + +// IsGlobalAdmin reports whether the Keycloak ID holds the global Admin role +// (casbin g2). Site-wide resources have no hackathon to scope a domain to, so +// they authorize on this directly instead of through Enforce. +func (e *Enforcer) IsGlobalAdmin(keycloakID string) (bool, error) { + globals, err := e.GetGlobalRoles(keycloakID) + if err != nil { + return false, err + } + for _, g := range globals { + if g == userEnts.GlobalRole_GLOBAL_ROLE_ADMIN { + return true, nil + } + } + + return false, nil +} + +// RequireGlobalAdmin is the site-domain counterpart to RequirePermission: +// anonymous callers are told to authenticate, everyone else who is not a +// global admin is denied. +func (e *Enforcer) RequireGlobalAdmin(ctx context.Context) error { + uid, _, err := RequireSubject(ctx) + if err != nil { + return err + } + if uid == AnonSubject { + return status.Error(codes.Unauthenticated, "authentication required") + } + ok, err := e.IsGlobalAdmin(uid) + if err != nil { + slog.Error("check global admin", "err", err) + + return status.Error(codes.Internal, "authorization error") + } + if !ok { + return status.Error(codes.PermissionDenied, "permission denied") + } + + return nil +} + //exhaustruct:optional type enforceOptions struct { teamID string @@ -440,6 +586,16 @@ func (e *Enforcer) RequirePermission( return status.Error(codes.Internal, "authorization error") } if !ok { + // gRPC convention: anonymous callers are told to authenticate + // (UNAUTHENTICATED), authenticated-but-unauthorized callers get + // PERMISSION_DENIED. Matches the hand-written AnonSubject checks in + // Join/ApproveParticipant so every endpoint speaks the same code. + if claims, found := GetClaims(ctx); found { + if sub, err := claims.GetSubject(); err == nil && sub == AnonSubject { + return status.Error(codes.Unauthenticated, "authentication required") + } + } + return status.Error(codes.PermissionDenied, "permission denied") } diff --git a/components/backend/internal/middleware/rbac_test.go b/components/backend/internal/middleware/rbac_test.go index 84307752..513f277a 100644 --- a/components/backend/internal/middleware/rbac_test.go +++ b/components/backend/internal/middleware/rbac_test.go @@ -139,6 +139,45 @@ var _ = Describe("RBAC Enforcer", func() { ) }) + Describe("Global Roles", func() { + organizerID := "organizer-uuid" + + It("lets a global organizer create hackathons", func() { + enf := testutils.NewMockEnforcer("admin-uuid") + _, err := enf.AddGlobalRole(organizerID, HackathonOrganizer) + Expect(err).NotTo(HaveOccurred()) + + allowed, err := enf.CheckPermission(organizerID, "*", Hackathon, Create) + Expect(err).NotTo(HaveOccurred()) + Expect(allowed).To(BeTrue()) + }) + + It("keeps a global organizer out of an individual hackathon", func() { + enf := testutils.NewMockEnforcer("admin-uuid") + _, err := enf.AddGlobalRole(organizerID, HackathonOrganizer) + Expect(err).NotTo(HaveOccurred()) + + allowed, err := enf.CheckPermission(organizerID, "h1", Hackathon, Write) + Expect(err).NotTo(HaveOccurred()) + Expect(allowed).To(BeFalse()) + }) + + DescribeTable("refuses to grant a hackathon-scoped role globally", + func(role Role) { + enf := testutils.NewMockEnforcer("admin-uuid") + + _, err := enf.AddGlobalRole("mallory", role) + Expect(err).To(MatchError(ErrNotAGlobalRole)) + + allowed, err := enf.CheckPermission("mallory", "h1", Hackathon, Write) + Expect(err).NotTo(HaveOccurred()) + Expect(allowed).To(BeFalse()) + }, + Entry("owner", Owner), + Entry("member", Member), + ) + }) + Describe("RequirePermission", func() { var enf *Enforcer adminID := "admin-uuid" diff --git a/components/backend/internal/service/capability.go b/components/backend/internal/service/capability.go new file mode 100644 index 00000000..29f79331 --- /dev/null +++ b/components/backend/internal/service/capability.go @@ -0,0 +1,505 @@ +package service + +import ( + "context" + "log/slog" + "sort" + "time" + + "github.com/google/uuid" + "github.com/swissdatasciencecenter/hackagon/components/backend/ent" + entcapability "github.com/swissdatasciencecenter/hackagon/components/backend/ent/capability" + enthackathon "github.com/swissdatasciencecenter/hackagon/components/backend/ent/hackathon" + entphase "github.com/swissdatasciencecenter/hackagon/components/backend/ent/phase" + "github.com/swissdatasciencecenter/hackagon/components/backend/internal/capability" + mw "github.com/swissdatasciencecenter/hackagon/components/backend/internal/middleware" + hackEnts "github.com/swissdatasciencecenter/hackagon/components/backend/internal/proto/hackathon/entities" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" + "google.golang.org/protobuf/types/known/timestamppb" +) + +// The capability vocabulary exists in three representations — the domain type, +// the ent enum and the proto enum. These functions are the only place they meet. + +func capabilityToProto(c capability.Capability) hackEnts.Capability { + switch c { + case capability.Register: + return hackEnts.Capability_CAPABILITY_REGISTER + case capability.ProposeProjects: + return hackEnts.Capability_CAPABILITY_PROPOSE_PROJECTS + case capability.SetTeamPreferences: + return hackEnts.Capability_CAPABILITY_SET_TEAM_PREFERENCES + case capability.CreateProjectSubmissions: + return hackEnts.Capability_CAPABILITY_CREATE_PROJECT_SUBMISSIONS + case capability.Vote: + return hackEnts.Capability_CAPABILITY_VOTE + case capability.ViewResults: + return hackEnts.Capability_CAPABILITY_VIEW_RESULTS + default: + return hackEnts.Capability_CAPABILITY_UNSPECIFIED + } +} + +// CapabilityFromProto converts a request enum to the domain type. The bool is +// false for UNSPECIFIED and for values this binary does not know. +func CapabilityFromProto(c hackEnts.Capability) (capability.Capability, bool) { + switch c { + case hackEnts.Capability_CAPABILITY_REGISTER: + return capability.Register, true + case hackEnts.Capability_CAPABILITY_PROPOSE_PROJECTS: + return capability.ProposeProjects, true + case hackEnts.Capability_CAPABILITY_SET_TEAM_PREFERENCES: + return capability.SetTeamPreferences, true + case hackEnts.Capability_CAPABILITY_CREATE_PROJECT_SUBMISSIONS: + return capability.CreateProjectSubmissions, true + case hackEnts.Capability_CAPABILITY_VOTE: + return capability.Vote, true + case hackEnts.Capability_CAPABILITY_VIEW_RESULTS: + return capability.ViewResults, true + case hackEnts.Capability_CAPABILITY_UNSPECIFIED: + return "", false + default: + return "", false + } +} + +func capabilityStateToProto(s capability.State) hackEnts.CapabilityState { + switch s { + case capability.StateOpen: + return hackEnts.CapabilityState_CAPABILITY_STATE_OPEN + case capability.StateClosed: + return hackEnts.CapabilityState_CAPABILITY_STATE_CLOSED + case capability.StateComing: + return hackEnts.CapabilityState_CAPABILITY_STATE_COMING + case capability.StateUngoverned: + return hackEnts.CapabilityState_CAPABILITY_STATE_UNGOVERNED + default: + return hackEnts.CapabilityState_CAPABILITY_STATE_UNSPECIFIED + } +} + +// capabilityClosedMessage is what a blocked member is told. +func capabilityClosedMessage(c capability.Capability) string { + switch c { + case capability.Register: + return "registrations are closed" + case capability.ProposeProjects: + return "project proposals are closed" + case capability.SetTeamPreferences: + return "project preferences are closed" + case capability.CreateProjectSubmissions: + return "project submissions are closed" + case capability.Vote: + return "voting is closed" + case capability.ViewResults: + return "results have not been published" + default: + return "this action is closed" + } +} + +// capabilityToEnt converts to the ent enum. The values are identical strings by +// construction; the validator is what stops an unknown one reaching the database. +func capabilityToEnt(c capability.Capability) (entcapability.Capability, bool) { + ec := entcapability.Capability(c) + if err := entcapability.CapabilityValidator(ec); err != nil { + return "", false + } + + return ec, true +} + +// capabilityClock is what a stored row needs to become a resolvable one: the +// hackathon's phase order, and where the organizer says it currently is. +// +// The zero value means "no manual advance", which falls back to comparing dates. +// That is the right default and the right choice for enforcement, where COMING +// and CLOSED are both blocked so the clock cannot change the outcome. +type capabilityClock struct { + order map[uuid.UUID]int + currentPhase *int +} + +func newCapabilityClock( + order map[uuid.UUID]int, + currentPhaseID *uuid.UUID, +) capabilityClock { + clock := capabilityClock{order: order, currentPhase: nil} + if currentPhaseID != nil { + if pos, ok := order[*currentPhaseID]; ok { + clock.currentPhase = &pos + } + } + + return clock +} + +func (c capabilityClock) positionOf(phase *ent.Phase) *int { + if phase == nil || c.order == nil { + return nil + } + if pos, ok := c.order[phase.ID]; ok { + return &pos + } + + return nil +} + +// capabilityRowFromEnt reduces a stored row to what the resolver needs. +// +// Requires `.WithOpenInPhase()` / `.WithClosedInPhase()`; an unloaded edge is +// indistinguishable from an unlinked one, which would silently downgrade a +// COMING capability to CLOSED. +func capabilityRowFromEnt(r *ent.Capability, clock capabilityClock) capability.Row { + row := capability.Row{ + Capability: capability.Capability(r.Capability), + Enabled: r.Enabled, + OpensAt: nil, + ClosesAt: nil, + OpenInPhase: clock.positionOf(r.Edges.OpenInPhase), + CurrentPhase: clock.currentPhase, + } + if p := r.Edges.OpenInPhase; p != nil { + row.OpensAt = p.StartsAt + } + if p := r.Edges.ClosedInPhase; p != nil { + row.ClosesAt = p.StartsAt + } + + return row +} + +func capabilityRows(rows []*ent.Capability, clock capabilityClock) []capability.Row { + out := make([]capability.Row, 0, len(rows)) + for _, r := range rows { + out = append(out, capabilityRowFromEnt(r, clock)) + } + + return out +} + +// capabilityStatusFromEnt maps one stored row. +// +// Requires `.WithModifier()`, `.WithOpenInPhase()` and `.WithClosedInPhase()`. +// A missing modifier is tolerated because seeded and backfilled rows have none. +func capabilityStatusFromEnt( + row *ent.Capability, + clock capabilityClock, + now time.Time, +) *hackEnts.CapabilityStatus { + r := capabilityRowFromEnt(row, clock) + + var modifierID *string + if row.Edges.Modifier != nil { + id := row.Edges.Modifier.ID.String() + modifierID = &id + } + + var openInPhaseID, closedInPhaseID *string + if p := row.Edges.OpenInPhase; p != nil { + id := p.ID.String() + openInPhaseID = &id + } + if p := row.Edges.ClosedInPhase; p != nil { + id := p.ID.String() + closedInPhaseID = &id + } + + return &hackEnts.CapabilityStatus{ + Capability: capabilityToProto(r.Capability), + State: capabilityStateToProto(capability.ResolveRow(r, now)), + ModifiedAt: timestamppb.New(row.ModifiedAt), + ModifierId: modifierID, + OpensAt: optionalTimestamp(r.OpensAt), + ClosesAt: optionalTimestamp(r.ClosesAt), + OpenInPhaseId: openInPhaseID, + ClosedInPhaseId: closedInPhaseID, + } +} + +func optionalTimestamp(t *time.Time) *timestamppb.Timestamp { + if t == nil { + return nil + } + + return timestamppb.New(*t) +} + +// capabilityStatusesFromEnt maps stored rows to one status per capability in the +// vocabulary — including the ones with no row, which report UNGOVERNED. Emitting +// the full set means clients never have to know the vocabulary themselves. +func capabilityStatusesFromEnt( + rows []*ent.Capability, + clock capabilityClock, + now time.Time, +) []*hackEnts.CapabilityStatus { + byCapability := make(map[capability.Capability]*ent.Capability, len(rows)) + for _, r := range rows { + byCapability[capability.Capability(r.Capability)] = r + } + + all := capability.All() + out := make([]*hackEnts.CapabilityStatus, 0, len(all)) + for _, c := range all { + row, ok := byCapability[c] + if !ok { + out = append(out, &hackEnts.CapabilityStatus{ + Capability: capabilityToProto(c), + State: hackEnts.CapabilityState_CAPABILITY_STATE_UNGOVERNED, + ModifiedAt: nil, + ModifierId: nil, + OpensAt: nil, + ClosesAt: nil, + OpenInPhaseId: nil, + ClosedInPhaseId: nil, + }) + + continue + } + out = append(out, capabilityStatusFromEnt(row, clock, now)) + } + + return out +} + +// defaultCapabilityEnabled is the state every capability starts in on a newly +// created hackathon. +// +// Open, deliberately. It makes introducing capabilities behavior-preserving: no +// existing caller changes, and a new hackathon is not bricked before the +// organizer settings screen exists. Closing an action is then an explicit act. +// +// Flipping this to closed-by-default is a one-line change, but it is a product +// decision and wants the organizer UI to land first. +const defaultCapabilityEnabled = true + +// createDefaultCapabilities inserts one row per capability. +// +// Pre-creating the full set is what keeps editing a plain update rather than an +// upsert, and it means a hackathon states its policy explicitly rather than +// being ambiguously ungoverned. Must run inside the same flow that creates the +// hackathon. +func createDefaultCapabilities( + ctx context.Context, + db *ent.Client, + hackathonID uuid.UUID, + modifier *ent.User, +) error { + all := capability.All() + builders := make([]*ent.CapabilityCreate, 0, len(all)) + for _, c := range all { + ec, ok := capabilityToEnt(c) + if !ok { + continue + } + builders = append(builders, db.Capability.Create(). + SetCapability(ec). + SetEnabled(defaultCapabilityEnabled). + SetHackathonID(hackathonID). + SetModifier(modifier)) + } + + return db.Capability.CreateBulk(builders...).Exec(ctx) +} + +func loadCapabilityStates( + ctx context.Context, + db *ent.Client, + hackathonID uuid.UUID, + clock capabilityClock, + now time.Time, +) (capability.States, error) { + rows, err := db.Capability.Query(). + Where(entcapability.HasHackathonWith(enthackathon.IDEQ(hackathonID))). + WithOpenInPhase(). + WithClosedInPhase(). + All(ctx) + if err != nil { + slog.Error("query capabilities", "err", err) + + return nil, status.Error(codes.Internal, "couldn't query database") + } + + return capability.Resolve(capabilityRows(rows, clock), now), nil +} + +// phaseOrderFrom maps each phase to its position in the timeline. +// +// Sorted by starts_at with the id as a tiebreaker, so two phases sharing a start +// still get a stable order — advancing must not depend on which row the database +// happened to return first. Undated phases sort last, matching Postgres' NULLS +// LAST default for ascending order so the query and slice forms agree. +func phaseOrderFrom(phases []*ent.Phase) map[uuid.UUID]int { + sorted := make([]*ent.Phase, len(phases)) + copy(sorted, phases) + sort.SliceStable(sorted, func(i, j int) bool { + a, b := sorted[i], sorted[j] + switch { + case a.StartsAt == nil && b.StartsAt == nil: + return a.ID.String() < b.ID.String() + case a.StartsAt == nil: + return false + case b.StartsAt == nil: + return true + case a.StartsAt.Equal(*b.StartsAt): + return a.ID.String() < b.ID.String() + default: + return a.StartsAt.Before(*b.StartsAt) + } + }) + + order := make(map[uuid.UUID]int, len(sorted)) + for i, p := range sorted { + order[p.ID] = i + } + + return order +} + +// phaseOrder is phaseOrderFrom for callers that have not already loaded phases. +func phaseOrder( + ctx context.Context, + db *ent.Client, + hackathonID uuid.UUID, +) (map[uuid.UUID]int, error) { + phases, err := db.Phase.Query(). + Where(entphase.HasHackathonWith(enthackathon.IDEQ(hackathonID))). + All(ctx) + if err != nil { + slog.Error("query phases for ordering", "err", err) + + return nil, status.Error(codes.Internal, "couldn't query database") + } + + return phaseOrderFrom(phases), nil +} + +// advanceRows expresses each capability's schedule as phase positions. +// +// A link pointing at a phase missing from `order` is treated as unlinked, so a +// capability whose phase was deleted concurrently is left untouched rather than +// being closed by an out-of-range comparison. +func advanceRows(rows []*ent.Capability, order map[uuid.UUID]int) []capability.AdvanceRow { + out := make([]capability.AdvanceRow, 0, len(rows)) + for _, r := range rows { + row := capability.AdvanceRow{ + Capability: capability.Capability(r.Capability), + OpenInPhase: nil, + ClosedInPhase: nil, + } + if p := r.Edges.OpenInPhase; p != nil { + if pos, ok := order[p.ID]; ok { + row.OpenInPhase = &pos + } + } + if p := r.Edges.ClosedInPhase; p != nil { + if pos, ok := order[p.ID]; ok { + row.ClosedInPhase = &pos + } + } + out = append(out, row) + } + + return out +} + +// applyPhaseLink resolves one schedule field of an EditCapability request onto +// the update builder: empty string unlinks, a UUID links after checking the +// phase belongs to this hackathon. +func applyPhaseLink( + ctx context.Context, + db *ent.Client, + hackathonID uuid.UUID, + phaseID string, + unlink func() *ent.CapabilityUpdateOne, + link func(uuid.UUID) *ent.CapabilityUpdateOne, +) error { + if phaseID == "" { + unlink() + + return nil + } + + pid, err := uuid.Parse(phaseID) + if err != nil { + return status.Errorf(codes.InvalidArgument, "invalid phase id %q: %v", phaseID, err) + } + if err := phaseInHackathon(ctx, db, hackathonID, pid); err != nil { + return err + } + link(pid) + + return nil +} + +// phaseInHackathon rejects a schedule link pointing at another hackathon's +// phase, which would otherwise let an organizer read a date they do not own — +// and produce a countdown to a phase their members cannot see. +func phaseInHackathon( + ctx context.Context, + db *ent.Client, + hackathonID, phaseID uuid.UUID, +) error { + ok, err := db.Phase.Query(). + Where( + entphase.IDEQ(phaseID), + entphase.HasHackathonWith(enthackathon.IDEQ(hackathonID)), + ). + Exist(ctx) + if err != nil { + slog.Error("query phase for capability link", "err", err) + + return status.Error(codes.Internal, "couldn't query database") + } + if !ok { + return status.Errorf( + codes.NotFound, + "phase %s not found in hackathon %s", + phaseID, hackathonID, + ) + } + + return nil +} + +// requireCapability blocks a mutation whose capability is closed. +// +// Call it alongside the casbin check, never instead of it: casbin answers "may +// this user ever do this", capabilities answer "is it open right now". +// +// Anyone who can write the hackathon bypasses the gate, because organizers have +// to be able to fix things outside the window — a team that missed the deadline +// by a minute is a support request, not a lockout. +func requireCapability( + ctx context.Context, + db *ent.Client, + enf *mw.Enforcer, + hackathonID uuid.UUID, + c capability.Capability, +) error { + bypass, err := enf.Enforce(ctx, hackathonID.String(), mw.Hackathon, mw.Write) + if err != nil { + slog.Error("enforce capability bypass", "err", err) + + return status.Error(codes.Internal, "authorization error") + } + if bypass { + return nil + } + + // No clock: COMING and CLOSED are both blocked, so the manual-advance + // distinction cannot change whether this mutation is allowed. Skipping it + // keeps every gated mutation off the phase-ordering query. + unclocked := capabilityClock{order: nil, currentPhase: nil} + states, err := loadCapabilityStates(ctx, db, hackathonID, unclocked, time.Now()) + if err != nil { + return err + } + + if !states.Allowed(c) { + return status.Error(codes.FailedPrecondition, capabilityClosedMessage(c)) + } + + return nil +} diff --git a/components/backend/internal/service/capacity.go b/components/backend/internal/service/capacity.go new file mode 100644 index 00000000..5a6a3197 --- /dev/null +++ b/components/backend/internal/service/capacity.go @@ -0,0 +1,47 @@ +package service + +// Capacity: how a hackathon's max_participants field governs Join. +// +// The rule, decided once (see joinLandsWaitlisted): +// +// - Unset or zero capacity means UNLIMITED, and keeps the approval model +// every existing event runs on: everyone who joins lands on the waiting +// list and an organizer confirms them by hand. No existing row is +// silently capped, and no existing flow changes. +// - A positive capacity turns registration first-come-first-served: Join +// takes a confirmed place outright while one is free AND nobody is +// waiting; once the event is full, joining still SUCCEEDS and appends to +// the waiting list — a person who joins a full event has done nothing +// wrong. +// - Capacity counts CONFIRMED participants only (is_waiting = false). +// The waiting list does not consume places; that is the point of one. +// - A freed place (RemoveParticipant) is NOT handed out automatically — +// neither to the head of the queue nor to the next joiner. Nothing +// notifies a promoted person yet, so an automatic promotion would +// silently spend the place on someone who may be long gone, and the +// queue-order-versus-organizer's-pick decision belongs to the person who +// can see the room. The organizer approves from the waiting list; the +// participants page makes the free place obvious. +// - Organizers may approve PAST capacity. The number is their estimate of +// the room, not a law — the UI surfaces the overshoot so it is a +// decision rather than an accident. + +// joinLandsWaitlisted decides where a new registrant lands. +// +// `maxParticipants` is the hackathon's capacity field (nil or <= 0 means +// unlimited), `confirmed` the number of participants with is_waiting=false, +// `waiting` the number with is_waiting=true — both counted at the moment of +// the decision, under HackathonService.capacityMu. +// +// The `waiting == 0` clause is what keeps the queue fair: once anyone is +// waiting, a newly freed place is the organizer's to hand out, and a new +// joiner queues BEHIND the people already there rather than sniping the seat +// from whoever has waited longest. +func joinLandsWaitlisted(maxParticipants *int32, confirmed, waiting int) bool { + if maxParticipants == nil || *maxParticipants <= 0 { + // Unlimited: the approval model — everyone starts on the waiting list. + return true + } + + return confirmed >= int(*maxParticipants) || waiting > 0 +} diff --git a/components/backend/internal/service/capacity_internal_test.go b/components/backend/internal/service/capacity_internal_test.go new file mode 100644 index 00000000..b0ca66d6 --- /dev/null +++ b/components/backend/internal/service/capacity_internal_test.go @@ -0,0 +1,45 @@ +//go:build test && unittest + +package service + +import ( + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +// The seat rule itself, tested as the pure function it is — the service-level +// specs in capacity_test.go prove the handlers enforce it, this file proves +// the rule says what capacity.go documents. + +func capPtr(v int32) *int32 { return &v } + +var _ = Describe("joinLandsWaitlisted", func() { + It("waitlists everyone when capacity is unset (the approval model)", func() { + Expect(joinLandsWaitlisted(nil, 0, 0)).To(BeTrue()) + Expect(joinLandsWaitlisted(nil, 5, 0)).To(BeTrue()) + Expect(joinLandsWaitlisted(nil, 5, 3)).To(BeTrue()) + }) + + It("treats zero and negative capacity as unlimited", func() { + Expect(joinLandsWaitlisted(capPtr(0), 0, 0)).To(BeTrue()) + Expect(joinLandsWaitlisted(capPtr(-1), 0, 0)).To(BeTrue()) + }) + + It("confirms while a place is free and nobody is waiting", func() { + Expect(joinLandsWaitlisted(capPtr(3), 0, 0)).To(BeFalse()) + Expect(joinLandsWaitlisted(capPtr(3), 2, 0)).To(BeFalse()) + Expect(joinLandsWaitlisted(capPtr(1), 0, 0)).To(BeFalse()) + }) + + It("waitlists once the confirmed roster reaches capacity", func() { + Expect(joinLandsWaitlisted(capPtr(3), 3, 0)).To(BeTrue()) + // Over-approved (the organizer went past the cap): still full. + Expect(joinLandsWaitlisted(capPtr(3), 4, 0)).To(BeTrue()) + }) + + It("queues behind existing waiters even when a place is free", func() { + // A freed place with people already waiting is the organizer's to hand + // out; a new joiner must not snipe it from the head of the queue. + Expect(joinLandsWaitlisted(capPtr(3), 2, 1)).To(BeTrue()) + }) +}) diff --git a/components/backend/internal/service/capacity_test.go b/components/backend/internal/service/capacity_test.go new file mode 100644 index 00000000..4a3bfc10 --- /dev/null +++ b/components/backend/internal/service/capacity_test.go @@ -0,0 +1,415 @@ +//go:build test && unittest + +package service_test + +import ( + "context" + "fmt" + "sync" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + "google.golang.org/grpc" + "google.golang.org/grpc/metadata" + + "github.com/google/uuid" + + ent "github.com/swissdatasciencecenter/hackagon/components/backend/ent" + entparticipant "github.com/swissdatasciencecenter/hackagon/components/backend/ent/participant" + "github.com/swissdatasciencecenter/hackagon/components/backend/internal/middleware" + hackathonSvc "github.com/swissdatasciencecenter/hackagon/components/backend/internal/proto/hackathon" + "github.com/swissdatasciencecenter/hackagon/components/backend/internal/proto/hackathon/entities" + msgs "github.com/swissdatasciencecenter/hackagon/components/backend/internal/proto/hackathon/messages/hackathon_svc" + "github.com/swissdatasciencecenter/hackagon/components/backend/internal/testutils" +) + +// The capacity rule end to end: Join hands out confirmed places +// first-come-first-served on capped events, queues on full ones, never +// oversells under concurrency, and leaves freed places to the organizer. +// The rule itself is specified in capacity.go and unit-tested in +// capacity_internal_test.go; these specs prove the handlers enforce it. +var _ = Describe("Capacity", func() { + var ( + dbClient *ent.Client + conn *grpc.ClientConn + client hackathonSvc.HackathonServiceClient + adminCtx context.Context + ) + + BeforeEach(func() { + var enf *middleware.Enforcer + dbClient, conn, enf = testutils.CreateTestServer() + _ = enf + client = hackathonSvc.NewHackathonServiceClient(conn) + adminCtx = ctxFor(testutils.TestAdminKeycloakID) + }) + + // newJoiner inserts a platform user and returns a context carrying their + // token — Join looks the caller up in the users table. + newJoiner := func(username string) (uuid.UUID, context.Context) { + keycloakID := "keycloak-" + username + u, err := dbClient.User.Create(). + SetKeycloakID(keycloakID). + SetUsername(username). + Save(context.Background()) + Expect(err).NotTo(HaveOccurred()) + + return u.ID, ctxFor(keycloakID) + } + + createHackathon := func(maxParticipants int32) string { + req := &msgs.CreateRequest{ + Name: fmt.Sprintf("Capacity Hackathon %s", uuid.NewString()[:8]), + Visibility: entities.Visibility_VISIBILITY_PUBLIC, + } + if maxParticipants > 0 { + req.MaxParticipants = &maxParticipants + } + resp, err := client.Create(adminCtx, req) + Expect(err).NotTo(HaveOccurred()) + + return resp.GetHackathonId() + } + + rosterCounts := func(hackathonID string) (confirmed, waiting int) { + id := uuid.MustParse(hackathonID) + var err error + confirmed, err = dbClient.Participant.Query().Where( + entparticipant.HackathonIDEQ(id), + entparticipant.IsWaitingEQ(false), + ).Count(context.Background()) + Expect(err).NotTo(HaveOccurred()) + waiting, err = dbClient.Participant.Query().Where( + entparticipant.HackathonIDEQ(id), + entparticipant.IsWaitingEQ(true), + ).Count(context.Background()) + Expect(err).NotTo(HaveOccurred()) + + return confirmed, waiting + } + + It("keeps the approval model for uncapped events", func() { + hid := createHackathon(0) + + _, actx := newJoiner("uncapped-a") + resp, err := client.Join(actx, &msgs.JoinRequest{HackathonId: hid}) + Expect(err).NotTo(HaveOccurred()) + Expect(resp.GetWaitlisted()).To(BeTrue(), "uncapped events waitlist everyone") + Expect(resp.GetQueuePosition()).To(BeInt32(1)) + + _, bctx := newJoiner("uncapped-b") + resp, err = client.Join(bctx, &msgs.JoinRequest{HackathonId: hid}) + Expect(err).NotTo(HaveOccurred()) + Expect(resp.GetWaitlisted()).To(BeTrue()) + Expect(resp.GetQueuePosition()).To(BeInt32(2)) + }) + + It("hands out places first-come-first-served on a capped event", func() { + // Capacity 3; the creator already holds one confirmed place. + hid := createHackathon(3) + + _, actx := newJoiner("fcfs-a") + resp, err := client.Join(actx, &msgs.JoinRequest{HackathonId: hid}) + Expect(err).NotTo(HaveOccurred()) + Expect(resp.GetWaitlisted()).To(BeFalse(), "a free place is taken outright") + Expect(resp.GetQueuePosition()).To(BeInt32(0)) + + // Fills the last place. + _, bctx := newJoiner("fcfs-b") + resp, err = client.Join(bctx, &msgs.JoinRequest{HackathonId: hid}) + Expect(err).NotTo(HaveOccurred()) + Expect(resp.GetWaitlisted()).To(BeFalse()) + + // Full: joining SUCCEEDS and queues — not an error. + _, cctx := newJoiner("fcfs-c") + resp, err = client.Join(cctx, &msgs.JoinRequest{HackathonId: hid}) + Expect(err).NotTo(HaveOccurred()) + Expect(resp.GetWaitlisted()).To(BeTrue()) + Expect(resp.GetQueuePosition()).To(BeInt32(1)) + + _, dctx := newJoiner("fcfs-d") + resp, err = client.Join(dctx, &msgs.JoinRequest{HackathonId: hid}) + Expect(err).NotTo(HaveOccurred()) + Expect(resp.GetWaitlisted()).To(BeTrue()) + Expect(resp.GetQueuePosition()).To(BeInt32(2)) + + confirmed, waiting := rosterCounts(hid) + Expect(confirmed).To(Equal(3)) + Expect(waiting).To(Equal(2)) + }) + + It("reports the current state on an idempotent re-join", func() { + hid := createHackathon(2) + + _, actx := newJoiner("rejoin-a") + resp, err := client.Join(actx, &msgs.JoinRequest{HackathonId: hid}) + Expect(err).NotTo(HaveOccurred()) + Expect(resp.GetWaitlisted()).To(BeFalse()) + + // Confirmed stays confirmed. + resp, err = client.Join(actx, &msgs.JoinRequest{HackathonId: hid}) + Expect(err).NotTo(HaveOccurred()) + Expect(resp.GetWaitlisted()).To(BeFalse()) + + _, bctx := newJoiner("rejoin-b") + resp, err = client.Join(bctx, &msgs.JoinRequest{HackathonId: hid}) + Expect(err).NotTo(HaveOccurred()) + Expect(resp.GetWaitlisted()).To(BeTrue()) + Expect(resp.GetQueuePosition()).To(BeInt32(1)) + + // Queued reports the same place, and no duplicate row appears. + resp, err = client.Join(bctx, &msgs.JoinRequest{HackathonId: hid}) + Expect(err).NotTo(HaveOccurred()) + Expect(resp.GetWaitlisted()).To(BeTrue()) + Expect(resp.GetQueuePosition()).To(BeInt32(1)) + + confirmed, waiting := rosterCounts(hid) + Expect(confirmed).To(Equal(2)) + Expect(waiting).To(Equal(1)) + }) + + It("lets an organizer approve past capacity", func() { + // Capacity 1 — the creator holds the only place. + hid := createHackathon(1) + + uid, uctx := newJoiner("over-a") + resp, err := client.Join(uctx, &msgs.JoinRequest{HackathonId: hid}) + Expect(err).NotTo(HaveOccurred()) + Expect(resp.GetWaitlisted()).To(BeTrue()) + + // The room fits one more: approving past the cap must WORK. + _, err = client.ApproveParticipant(adminCtx, &msgs.ApproveParticipantRequest{ + HackathonId: hid, + UserId: uid.String(), + }) + Expect(err).NotTo(HaveOccurred()) + + confirmed, waiting := rosterCounts(hid) + Expect(confirmed).To(Equal(2), "2 confirmed of capacity 1 — deliberate overshoot") + Expect(waiting).To(Equal(0)) + }) + + It("leaves a freed place to the organizer and keeps the queue unjumped", func() { + // Capacity 2: creator + A fill it, B queues. + hid := createHackathon(2) + + aID, actx := newJoiner("freed-a") + resp, err := client.Join(actx, &msgs.JoinRequest{HackathonId: hid}) + Expect(err).NotTo(HaveOccurred()) + Expect(resp.GetWaitlisted()).To(BeFalse()) + + bID, bctx := newJoiner("freed-b") + resp, err = client.Join(bctx, &msgs.JoinRequest{HackathonId: hid}) + Expect(err).NotTo(HaveOccurred()) + Expect(resp.GetWaitlisted()).To(BeTrue()) + + // A confirmed participant leaves: a place frees up. + _, err = client.RemoveParticipant(adminCtx, &msgs.RemoveParticipantRequest{ + HackathonId: hid, + UserId: aID.String(), + }) + Expect(err).NotTo(HaveOccurred()) + + // Nobody is promoted automatically: B still waits. + b, err := dbClient.Participant.Query().Where( + entparticipant.HackathonIDEQ(uuid.MustParse(hid)), + entparticipant.UserIDEQ(bID), + ).Only(context.Background()) + Expect(err).NotTo(HaveOccurred()) + Expect(b.IsWaiting).To(BeTrue(), "freed places are not handed out automatically") + + // And a NEW joiner does not snipe the free place from B — they queue + // behind. + _, cctx := newJoiner("freed-c") + resp, err = client.Join(cctx, &msgs.JoinRequest{HackathonId: hid}) + Expect(err).NotTo(HaveOccurred()) + Expect( + resp.GetWaitlisted(), + ).To(BeTrue(), "a free place with people waiting belongs to the queue") + Expect(resp.GetQueuePosition()).To(BeInt32(2)) + + // The organizer hands the place out by hand. + _, err = client.ApproveParticipant(adminCtx, &msgs.ApproveParticipantRequest{ + HackathonId: hid, + UserId: bID.String(), + }) + Expect(err).NotTo(HaveOccurred()) + + confirmed, waiting := rosterCounts(hid) + Expect(confirmed).To(Equal(2)) + Expect(waiting).To(Equal(1)) + }) + + It("lowering the capacity below the confirmed count removes nobody", func() { + hid := createHackathon(3) + + for i := 0; i < 2; i++ { + _, uctx := newJoiner(fmt.Sprintf("lower-%d", i)) + resp, err := client.Join(uctx, &msgs.JoinRequest{HackathonId: hid}) + Expect(err).NotTo(HaveOccurred()) + Expect(resp.GetWaitlisted()).To(BeFalse()) + } + + one := int32(1) + edited, err := client.Edit(adminCtx, &msgs.EditRequest{ + HackathonId: hid, + MaxParticipants: &one, + }) + Expect(err).NotTo(HaveOccurred()) + Expect(edited.GetHackathon().GetMaxParticipants()).To(BeInt32(1)) + + confirmed, _ := rosterCounts(hid) + Expect(confirmed).To(Equal(3), "3 confirmed stay confirmed under a cap of 1") + + // The event is (over-)full now, so a new joiner queues. + _, uctx := newJoiner("lower-late") + resp, err := client.Join(uctx, &msgs.JoinRequest{HackathonId: hid}) + Expect(err).NotTo(HaveOccurred()) + Expect(resp.GetWaitlisted()).To(BeTrue()) + }) + + It("clears the capacity back to unlimited with 0", func() { + hid := createHackathon(2) + + zero := int32(0) + edited, err := client.Edit(adminCtx, &msgs.EditRequest{ + HackathonId: hid, + MaxParticipants: &zero, + }) + Expect(err).NotTo(HaveOccurred()) + Expect(edited.GetHackathon().MaxParticipants).To(BeNil()) + + // Unlimited means the approval model again: new joiners wait. + _, uctx := newJoiner("cleared-a") + resp, err := client.Join(uctx, &msgs.JoinRequest{HackathonId: hid}) + Expect(err).NotTo(HaveOccurred()) + Expect(resp.GetWaitlisted()).To(BeTrue()) + }) + + // The seat accounting AT the boundary, walked one join at a time. + // + // This is the deterministic twin of the concurrency spec below. That one + // asks whether two simultaneous joins can share the last place; this one + // asks where the last place IS, which is the half an off-by-one cap breaks + // and the half that needs no goroutines to see. The claim is read back from + // the DB after every join rather than inferred from the responses, because + // "Join answered waitlisted" and "the roster holds N" are different facts — + // the same distinction the race spec makes when it counts rows at the end. + // + // Three capacities, because a rule that is off by one is off by one at every + // cap and a single number cannot tell "the boundary moved" from "this + // particular event was set up wrong". Capacity 1 has no free seat to hand + // out at all; capacities 2 and 3 do, and their confirmed joins are the + // positive control that keeps the waitlist assertions from agreeing with an + // event that simply queues everybody. + It("seats exactly the capacity, counting the roster after every join", func() { + for _, capacity := range []int{1, 2, 3} { + hid := createHackathon(int32(capacity)) + + // The creator already holds seat 1, so capacity-1 more joiners fill + // the room exactly. Each one must be seated, and the confirmed count + // must be exactly the seat they took. + for seat := 2; seat <= capacity; seat++ { + _, uctx := newJoiner(fmt.Sprintf("seats-%d-%d", capacity, seat)) + resp, err := client.Join(uctx, &msgs.JoinRequest{HackathonId: hid}) + Expect(err).NotTo(HaveOccurred()) + Expect(resp.GetWaitlisted()).To( + BeFalse(), + "capacity %d: seat %d is still free, so it is handed out", capacity, seat, + ) + + confirmed, waiting := rosterCounts(hid) + Expect(confirmed).To( + Equal(seat), + "capacity %d: confirmed roster after filling seat %d", capacity, seat, + ) + Expect(waiting).To(Equal(0)) + } + + // The room is now exactly full. The next arrival queues, and the + // confirmed roster does NOT move: capacity is the number of seats, + // not the number of seats plus one. + _, overCtx := newJoiner(fmt.Sprintf("seats-%d-over", capacity)) + resp, err := client.Join(overCtx, &msgs.JoinRequest{HackathonId: hid}) + Expect(err).NotTo(HaveOccurred()) + Expect(resp.GetWaitlisted()).To( + BeTrue(), + "capacity %d: there is no seat %d — the room is full", capacity, capacity+1, + ) + Expect(resp.GetQueuePosition()).To(BeInt32(1)) + + confirmed, waiting := rosterCounts(hid) + Expect(confirmed).To( + Equal(capacity), + "capacity %d: the confirmed roster equals the capacity, never one more", capacity, + ) + Expect(waiting).To(Equal(1)) + } + }) + + It("never oversells the last place under simultaneous joins", func() { + // Capacity 3, creator + one joiner confirmed: ONE place left. + hid := createHackathon(3) + _, warmCtx := newJoiner("race-warm") + resp, err := client.Join(warmCtx, &msgs.JoinRequest{HackathonId: hid}) + Expect(err).NotTo(HaveOccurred()) + Expect(resp.GetWaitlisted()).To(BeFalse()) + + // Six people hit Join the moment the link drops. + const racers = 6 + ctxs := make([]context.Context, racers) + for i := range ctxs { + _, ctxs[i] = newJoiner(fmt.Sprintf("racer-%d", i)) + } + + var ( + start sync.WaitGroup + done sync.WaitGroup + ) + start.Add(1) + results := make([]*msgs.JoinResponse, racers) + errs := make([]error, racers) + for i := range ctxs { + done.Add(1) + go func(i int) { + defer done.Done() + defer GinkgoRecover() + start.Wait() // one barrier, so the calls really are simultaneous + results[i], errs[i] = client.Join(ctxs[i], &msgs.JoinRequest{HackathonId: hid}) + }(i) + } + start.Done() + done.Wait() + + // Every join SUCCEEDS — landing on the waiting list is not an error. + confirmedResponses := 0 + for i := range results { + Expect(errs[i]).NotTo(HaveOccurred(), "racer %d", i) + if !results[i].GetWaitlisted() { + confirmedResponses++ + } + } + Expect(confirmedResponses).To(Equal(1), "exactly one racer takes the last place") + + // The end state, not the responses, is the invariant: confirmed == + // capacity exactly, everyone else queued. + confirmed, waiting := rosterCounts(hid) + Expect(confirmed).To(Equal(3), "the confirmed roster must equal capacity — never oversold") + Expect(waiting).To(Equal(racers - 1)) + }) +}) + +func ctxFor(keycloakID string) context.Context { + return metadata.NewOutgoingContext( + context.Background(), + metadata.Pairs( + "authorization", + "Bearer "+testutils.CreateTestJWTToken(keycloakID), + ), + ) +} + +// BeInt32 avoids Equal's int/int32 type mismatch noise. +func BeInt32(v int32) OmegaMatcher { + return BeEquivalentTo(v) +} diff --git a/components/backend/internal/service/config_service.go b/components/backend/internal/service/config_service.go new file mode 100644 index 00000000..c3c21754 --- /dev/null +++ b/components/backend/internal/service/config_service.go @@ -0,0 +1,770 @@ +package service + +import ( + "context" + "log/slog" + "regexp" + "time" + + "github.com/google/uuid" + "github.com/swissdatasciencecenter/hackagon/components/backend/ent" + enthackathon "github.com/swissdatasciencecenter/hackagon/components/backend/ent/hackathon" + enthackathonforms "github.com/swissdatasciencecenter/hackagon/components/backend/ent/hackathonforms" + enthackathonwindows "github.com/swissdatasciencecenter/hackagon/components/backend/ent/hackathonwindows" + entuser "github.com/swissdatasciencecenter/hackagon/components/backend/ent/user" + m "github.com/swissdatasciencecenter/hackagon/components/backend/internal/middleware" + "github.com/swissdatasciencecenter/hackagon/components/backend/internal/proto/hackathon" + ents "github.com/swissdatasciencecenter/hackagon/components/backend/internal/proto/hackathon/entities" + cfgMsgs "github.com/swissdatasciencecenter/hackagon/components/backend/internal/proto/hackathon/messages/config_svc" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" + "google.golang.org/protobuf/types/known/timestamppb" +) + +type ConfigService struct { + hackathon.UnimplementedConfigServiceServer + dbClient *ent.Client + enforcer *m.Enforcer +} + +func NewConfigService(dbClient *ent.Client, enf *m.Enforcer) *ConfigService { + return &ConfigService{ + UnimplementedConfigServiceServer: hackathon.UnimplementedConfigServiceServer{}, + dbClient: dbClient, + enforcer: enf, + } +} + +func windowsEntryFromEnt(w *ent.HackathonWindows, hackathonID uuid.UUID) *ents.HackathonWindows { + //exhaustruct:ignore + entry := &ents.HackathonWindows{ + HackathonId: hackathonID.String(), + ModifiedAt: timestamppb.New(w.ModifiedAt), + } + if w.RegistrationOpens != nil { + entry.RegistrationOpens = timestamppb.New(*w.RegistrationOpens) + } + if w.RegistrationCloses != nil { + entry.RegistrationCloses = timestamppb.New(*w.RegistrationCloses) + } + if w.ProposalsClose != nil { + entry.ProposalsClose = timestamppb.New(*w.ProposalsClose) + } + if w.PreferencesClose != nil { + entry.PreferencesClose = timestamppb.New(*w.PreferencesClose) + } + if w.SubmissionsClose != nil { + entry.SubmissionsClose = timestamppb.New(*w.SubmissionsClose) + } + if w.RegistrationOverrideUntil != nil { + entry.RegistrationOverrideUntil = timestamppb.New(*w.RegistrationOverrideUntil) + } + if w.SubmissionsOverrideUntil != nil { + entry.SubmissionsOverrideUntil = timestamppb.New(*w.SubmissionsOverrideUntil) + } + if w.LatePolicy != "" { + entry.LatePolicy = &w.LatePolicy + } + + return entry +} + +// windowsRowFor returns the hackathon's windows row, or nil when none exists. +func windowsRowFor( + ctx context.Context, + db *ent.Client, + hackathonID uuid.UUID, +) (*ent.HackathonWindows, error) { + w, err := db.HackathonWindows.Query(). + Where(enthackathonwindows.HasHackathonWith(enthackathon.IDEQ(hackathonID))). + Only(ctx) + if err != nil { + if ent.IsNotFound(err) { + //nolint:nilnil // documented contract: nil,nil is "no row yet", every + // caller already checks it as a distinct state from an error + return nil, nil + } + + return nil, err + } + + return w, nil +} + +// callerUser resolves the authenticated caller's platform user row. +func (s *ConfigService) callerUser(ctx context.Context) (*ent.User, error) { + uid, _, err := m.RequireSubject(ctx) + if err != nil { + return nil, err + } + u, err := s.dbClient.User.Query().Where(entuser.KeycloakIDEQ(uid)).Only(ctx) + if err != nil { + if ent.IsNotFound(err) { + return nil, status.Errorf(codes.NotFound, "user %s not found", uid) + } + slog.Error("query user", "err", err) + + return nil, status.Error(codes.Internal, "couldn't query database") + } + + return u, nil +} + +// GetWindows reads the deadlines back. +// +// SetWindows replaces every field, so an organiser editing one deadline on a +// form that could not prefill would blank the rest without seeing them. Read +// access is the same as reading the hackathon: deadlines are announced to +// participants, not organiser-only. +func (s *ConfigService) GetWindows( + ctx context.Context, + req *cfgMsgs.GetWindowsRequest, +) (*cfgMsgs.GetWindowsResponse, error) { + hackathonID, err := uuid.Parse(req.GetHackathonId()) + if err != nil { + return nil, status.Errorf(codes.InvalidArgument, "invalid hackathon_id: %v", err) + } + if err := s.enforcer.RequirePermission(ctx, hackathonID.String(), m.Hackathon, m.Read); err != nil { + return nil, err + } + + row, err := s.dbClient.HackathonWindows.Query(). + Where(enthackathonwindows.HasHackathonWith(enthackathon.IDEQ(hackathonID))). + Only(ctx) + if err != nil { + if ent.IsNotFound(err) { + // No row yet: nothing has been scheduled, which is a valid state and + // not an error the UI should have to translate. + //exhaustruct:ignore + return &cfgMsgs.GetWindowsResponse{}, nil + } + slog.Error("query hackathon windows", "err", err) + + return nil, status.Error(codes.Internal, "couldn't query database") + } + + return &cfgMsgs.GetWindowsResponse{ + Windows: windowsEntryFromEnt(row, hackathonID), + }, nil +} + +func (s *ConfigService) SetWindows( + ctx context.Context, + req *cfgMsgs.SetWindowsRequest, +) (*cfgMsgs.SetWindowsResponse, error) { + hackathonID, err := uuid.Parse(req.GetHackathonId()) + if err != nil { + return nil, status.Errorf(codes.InvalidArgument, "invalid hackathon_id: %v", err) + } + if err := s.enforcer.RequirePermission(ctx, hackathonID.String(), m.Hackathon, m.Write); err != nil { + return nil, err + } + modifier, err := s.callerUser(ctx) + if err != nil { + return nil, err + } + + row, err := s.upsertWindows(ctx, hackathonID, modifier, req) + if err != nil { + return nil, err + } + + return &cfgMsgs.SetWindowsResponse{ + Windows: windowsEntryFromEnt(row, hackathonID), + }, nil +} + +// upsertWindows creates the hackathon's windows row if none exists yet, or +// updates the existing one otherwise. Split out of SetWindows because the +// create/update branches repeat the same five nillable-timestamp setters and +// LatePolicy handling; keeping them inline pushed SetWindows past the nesting +// budget for no benefit. +func (s *ConfigService) upsertWindows( + ctx context.Context, + hackathonID uuid.UUID, + modifier *ent.User, + req *cfgMsgs.SetWindowsRequest, +) (*ent.HackathonWindows, error) { + existing, err := windowsRowFor(ctx, s.dbClient, hackathonID) + if err != nil { + slog.Error("query hackathon windows", "err", err) + + return nil, status.Error(codes.Internal, "couldn't query database") + } + + asTime := func(ts *timestamppb.Timestamp) *time.Time { + if ts == nil { + return nil + } + t := ts.AsTime() + + return &t + } + + if existing == nil { + create := s.dbClient.HackathonWindows.Create(). + SetHackathonID(hackathonID). + SetModifierID(modifier.ID). + SetNillableRegistrationOpens(asTime(req.GetRegistrationOpens())). + SetNillableRegistrationCloses(asTime(req.GetRegistrationCloses())). + SetNillableProposalsClose(asTime(req.GetProposalsClose())). + SetNillablePreferencesClose(asTime(req.GetPreferencesClose())). + SetNillableSubmissionsClose(asTime(req.GetSubmissionsClose())) + if req.LatePolicy != nil { + create.SetLatePolicy(req.GetLatePolicy()) + } + + row, err := create.Save(ctx) + if err != nil { + if ent.IsConstraintError(err) { + return nil, status.Errorf(codes.NotFound, "hackathon %s not found", hackathonID) + } + slog.Error("create hackathon windows", "err", err) + + return nil, status.Error(codes.Internal, "couldn't create hackathon windows") + } + + return row, nil + } + + update := existing.Update(). + SetModifierID(modifier.ID). + SetNillableRegistrationOpens(asTime(req.GetRegistrationOpens())). + SetNillableRegistrationCloses(asTime(req.GetRegistrationCloses())). + SetNillableProposalsClose(asTime(req.GetProposalsClose())). + SetNillablePreferencesClose(asTime(req.GetPreferencesClose())). + SetNillableSubmissionsClose(asTime(req.GetSubmissionsClose())) + if req.LatePolicy != nil { + update.SetLatePolicy(req.GetLatePolicy()) + } + + row, err := update.Save(ctx) + if err != nil { + slog.Error("update hackathon windows", "err", err) + + return nil, status.Error(codes.Internal, "couldn't update hackathon windows") + } + + return row, nil +} + +func (s *ConfigService) OverrideWindow( + ctx context.Context, + req *cfgMsgs.OverrideWindowRequest, +) (*cfgMsgs.OverrideWindowResponse, error) { + hackathonID, err := uuid.Parse(req.GetHackathonId()) + if err != nil { + return nil, status.Errorf(codes.InvalidArgument, "invalid hackathon_id: %v", err) + } + if err := s.enforcer.RequirePermission(ctx, hackathonID.String(), m.Hackathon, m.Write); err != nil { + return nil, err + } + modifier, err := s.callerUser(ctx) + if err != nil { + return nil, err + } + + existing, err := windowsRowFor(ctx, s.dbClient, hackathonID) + if err != nil { + slog.Error("query hackathon windows", "err", err) + + return nil, status.Error(codes.Internal, "couldn't query database") + } + if existing == nil { + return nil, status.Error( + codes.FailedPrecondition, + "no windows configured for this hackathon", + ) + } + + // The override is anchored at NOW, not at the configured close: months of + // story time compress into a run, and "extend by 30 minutes" always means + // "30 more minutes from this moment" to the organizer saying it. + until := time.Now().Add(time.Duration(req.GetExtendMinutes()) * time.Minute) + update := existing.Update().SetModifierID(modifier.ID) + switch req.GetWindow() { + case "registration": + update.SetRegistrationOverrideUntil(until) + case "submissions": + update.SetSubmissionsOverrideUntil(until) + default: + return nil, status.Errorf(codes.InvalidArgument, "unknown window %q", req.GetWindow()) + } + slog.Info("window override", + "hackathon", hackathonID.String(), + "window", req.GetWindow(), + "until", until, + "reason", req.GetReason(), + ) + updated, err := update.Save(ctx) + if err != nil { + slog.Error("override hackathon window", "err", err) + + return nil, status.Error(codes.Internal, "couldn't override window") + } + + return &cfgMsgs.OverrideWindowResponse{ + Windows: windowsEntryFromEnt(updated, hackathonID), + }, nil +} + +// ─── Forms & voting policy ─────────────────────────────────────────── + +// formsRowFor returns the hackathon's forms row, or nil when none exists. +func formsRowFor( + ctx context.Context, + db *ent.Client, + hackathonID uuid.UUID, +) (*ent.HackathonForms, error) { + f, err := db.HackathonForms.Query(). + Where(enthackathonforms.HasHackathonWith(enthackathon.IDEQ(hackathonID))). + Only(ctx) + if err != nil { + if ent.IsNotFound(err) { + //nolint:nilnil // documented contract: nil,nil is "no row yet", every + // caller already checks it as a distinct state from an error + return nil, nil + } + + return nil, err + } + + return f, nil +} + +func fieldsToJSON(fields []*ents.FormField) []map[string]any { + out := make([]map[string]any, 0, len(fields)) + for _, f := range fields { + m := map[string]any{ + "key": f.GetKey(), + "label": f.GetLabel(), + "type": f.GetType(), + "required": f.GetRequired(), + } + if f.MaxMb != nil { + m["maxMb"] = f.GetMaxMb() + } + out = append(out, m) + } + + return out +} + +func consentsToJSON(consents []*ents.ConsentField) []map[string]any { + out := make([]map[string]any, 0, len(consents)) + for _, c := range consents { + out = append(out, map[string]any{ + "key": c.GetKey(), + "label": c.GetLabel(), + "required": c.GetRequired(), + }) + } + + return out +} + +func formSchemaFromJSON(fields, consents []map[string]any) *ents.FormSchema { + str := func(m map[string]any, k string) string { + if v, ok := m[k].(string); ok { + return v + } + + return "" + } + boolean := func(m map[string]any, k string) bool { + if v, ok := m[k].(bool); ok { + return v + } + + return false + } + //exhaustruct:ignore + schema := &ents.FormSchema{} + for _, f := range fields { + //exhaustruct:ignore + schema.Fields = append(schema.Fields, &ents.FormField{ + Key: str(f, "key"), + Label: str(f, "label"), + Type: str(f, "type"), + Required: boolean(f, "required"), + }) + } + for _, c := range consents { + schema.Consents = append(schema.Consents, &ents.ConsentField{ + Key: str(c, "key"), + Label: str(c, "label"), + Required: boolean(c, "required"), + }) + } + + return schema +} + +// upsertForms applies mutate to the hackathon's forms row, creating it first +// when missing. +func (s *ConfigService) upsertForms( + ctx context.Context, + hackathonID uuid.UUID, + modifier *ent.User, + mutateCreate func(*ent.HackathonFormsCreate), + mutateUpdate func(*ent.HackathonFormsUpdateOne), +) (*ent.HackathonForms, error) { + existing, err := formsRowFor(ctx, s.dbClient, hackathonID) + if err != nil { + slog.Error("query hackathon forms", "err", err) + + return nil, status.Error(codes.Internal, "couldn't query database") + } + if existing == nil { + create := s.dbClient.HackathonForms.Create(). + SetHackathonID(hackathonID). + SetModifierID(modifier.ID) + mutateCreate(create) + row, err := create.Save(ctx) + if err != nil { + if ent.IsConstraintError(err) { + return nil, status.Errorf(codes.NotFound, "hackathon %s not found", hackathonID) + } + slog.Error("create hackathon forms", "err", err) + + return nil, status.Error(codes.Internal, "couldn't create hackathon forms") + } + + return row, nil + } + update := existing.Update().SetModifierID(modifier.ID) + mutateUpdate(update) + row, err := update.Save(ctx) + if err != nil { + slog.Error("update hackathon forms", "err", err) + + return nil, status.Error(codes.Internal, "couldn't update hackathon forms") + } + + return row, nil +} + +func (s *ConfigService) SetRegistrationForm( + ctx context.Context, + req *cfgMsgs.SetRegistrationFormRequest, +) (*cfgMsgs.SetRegistrationFormResponse, error) { + hackathonID, err := uuid.Parse(req.GetHackathonId()) + if err != nil { + return nil, status.Errorf(codes.InvalidArgument, "invalid hackathon_id: %v", err) + } + if err := s.enforcer.RequirePermission(ctx, hackathonID.String(), m.Hackathon, m.Write); err != nil { + return nil, err + } + modifier, err := s.callerUser(ctx) + if err != nil { + return nil, err + } + + fields := fieldsToJSON(req.GetFields()) + consents := consentsToJSON(req.GetConsents()) + row, err := s.upsertForms(ctx, hackathonID, modifier, + func(c *ent.HackathonFormsCreate) { + c.SetRegistrationFields(fields).SetRegistrationConsents(consents) + }, + func(u *ent.HackathonFormsUpdateOne) { + u.SetRegistrationFields(fields).SetRegistrationConsents(consents) + }, + ) + if err != nil { + return nil, err + } + + return &cfgMsgs.SetRegistrationFormResponse{ + Form: formSchemaFromJSON(row.RegistrationFields, row.RegistrationConsents), + }, nil +} + +func (s *ConfigService) SetSubmissionForm( + ctx context.Context, + req *cfgMsgs.SetSubmissionFormRequest, +) (*cfgMsgs.SetSubmissionFormResponse, error) { + hackathonID, err := uuid.Parse(req.GetHackathonId()) + if err != nil { + return nil, status.Errorf(codes.InvalidArgument, "invalid hackathon_id: %v", err) + } + if err := s.enforcer.RequirePermission(ctx, hackathonID.String(), m.Hackathon, m.Write); err != nil { + return nil, err + } + modifier, err := s.callerUser(ctx) + if err != nil { + return nil, err + } + + fields := fieldsToJSON(req.GetFields()) + row, err := s.upsertForms(ctx, hackathonID, modifier, + func(c *ent.HackathonFormsCreate) { c.SetSubmissionFields(fields) }, + func(u *ent.HackathonFormsUpdateOne) { u.SetSubmissionFields(fields) }, + ) + if err != nil { + return nil, err + } + + return &cfgMsgs.SetSubmissionFormResponse{ + Form: formSchemaFromJSON(row.SubmissionFields, nil), + }, nil +} + +// emailTemplateKeys are the moments a hackathon can have copy for. Closed set +// on purpose: a typo'd key would otherwise be stored silently and the intended +// message would never be written. +// A message needs a subject as much as a body, so each moment stores both: +// "<moment>" is the body and "<moment>Subject" the subject line. Nothing +// sends these yet — organizers compose from them by hand — and a mail without +// a subject is the one most likely to be ignored. +// +//nolint:gochecknoglobals // closed-set lookup table, not mutable shared state +var emailTemplateKeys = map[string]bool{ + "registrationConfirmed": true, + "registrationConfirmedSubject": true, + "teamAssigned": true, + "teamAssignedSubject": true, + "deadlineReminder": true, + "deadlineReminderSubject": true, + "results": true, + "resultsSubject": true, +} + +// GetEmailTemplates reads the stored copy back. +// +// Write, not Read: the deadlines GetWindows serves are announced to +// participants, but half-written notification copy is the organizers' own +// drafting and has no audience yet. +func (s *ConfigService) GetEmailTemplates( + ctx context.Context, + req *cfgMsgs.GetEmailTemplatesRequest, +) (*cfgMsgs.GetEmailTemplatesResponse, error) { + hackathonID, err := uuid.Parse(req.GetHackathonId()) + if err != nil { + return nil, status.Errorf(codes.InvalidArgument, "invalid hackathon_id: %v", err) + } + if err := s.enforcer.RequirePermission(ctx, hackathonID.String(), m.Hackathon, m.Write); err != nil { + return nil, err + } + + row, err := s.dbClient.HackathonForms.Query(). + Where(enthackathonforms.HasHackathonWith(enthackathon.IDEQ(hackathonID))). + Only(ctx) + if err != nil { + if ent.IsNotFound(err) { + // Nothing authored yet — a valid state, not an error for the UI to + // translate. Same shape as GetWindows on an unscheduled event. + //exhaustruct:ignore + return &cfgMsgs.GetEmailTemplatesResponse{}, nil + } + slog.Error("query hackathon forms", "err", err) + + return nil, status.Error(codes.Internal, "couldn't query database") + } + + return &cfgMsgs.GetEmailTemplatesResponse{Templates: row.EmailTemplates}, nil +} + +func (s *ConfigService) SetEmailTemplates( + ctx context.Context, + req *cfgMsgs.SetEmailTemplatesRequest, +) (*cfgMsgs.SetEmailTemplatesResponse, error) { + hackathonID, err := uuid.Parse(req.GetHackathonId()) + if err != nil { + return nil, status.Errorf(codes.InvalidArgument, "invalid hackathon_id: %v", err) + } + if err := s.enforcer.RequirePermission(ctx, hackathonID.String(), m.Hackathon, m.Write); err != nil { + return nil, err + } + modifier, err := s.callerUser(ctx) + if err != nil { + return nil, err + } + + templates := req.GetTemplates() + for k := range templates { + if !emailTemplateKeys[k] { + return nil, status.Errorf( + codes.InvalidArgument, + "unknown email template %q: expected one of registrationConfirmed, teamAssigned, deadlineReminder, results", + k, + ) + } + } + // Copy: the request map must not be retained in the DB layer. + stored := make(map[string]string, len(templates)) + for k, v := range templates { + stored[k] = v + } + + if _, err := s.upsertForms(ctx, hackathonID, modifier, + func(c *ent.HackathonFormsCreate) { c.SetEmailTemplates(stored) }, + func(u *ent.HackathonFormsUpdateOne) { u.SetEmailTemplates(stored) }, + ); err != nil { + return nil, err + } + + return &cfgMsgs.SetEmailTemplatesResponse{}, nil +} + +// hexColor matches #rgb and #rrggbb — these values are interpolated into CSS. +var hexColor = regexp.MustCompile(`^#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{6})$`) + +func (s *ConfigService) SetBranding( + ctx context.Context, + req *cfgMsgs.SetBrandingRequest, +) (*cfgMsgs.SetBrandingResponse, error) { + hackathonID, err := uuid.Parse(req.GetHackathonId()) + if err != nil { + return nil, status.Errorf(codes.InvalidArgument, "invalid hackathon_id: %v", err) + } + if err := s.enforcer.RequirePermission(ctx, hackathonID.String(), m.Hackathon, m.Write); err != nil { + return nil, err + } + modifier, err := s.callerUser(ctx) + if err != nil { + return nil, err + } + + // Partial update: only the fields the caller sent are touched. + existing := map[string]string{} + if row, err := s.dbClient.HackathonForms.Query(). + Where(enthackathonforms.HasHackathonWith(enthackathon.IDEQ(hackathonID))). + Only(ctx); err == nil && row.Branding != nil { + for k, v := range row.Branding { + existing[k] = v + } + } + + //nolint:protogetter // *string fields: nil vs "" is the "field not sent" signal below + for key, val := range map[string]*string{ + "primaryColor": req.PrimaryColor, + "accentColor": req.AccentColor, + } { + if val == nil { + continue + } + // These end up in CSS, so reject anything that is not a plain hex + // colour rather than trusting the frontend to escape it. + if !hexColor.MatchString(*val) { + return nil, status.Errorf( + codes.InvalidArgument, "%s must be a hex colour like #0A7ACC", key, + ) + } + existing[key] = *val + } + if req.BannerText != nil { + existing["bannerText"] = req.GetBannerText() + } + + if _, err := s.upsertForms(ctx, hackathonID, modifier, + func(c *ent.HackathonFormsCreate) { c.SetBranding(existing) }, + func(u *ent.HackathonFormsUpdateOne) { u.SetBranding(existing) }, + ); err != nil { + return nil, err + } + + return &cfgMsgs.SetBrandingResponse{}, nil +} + +func (s *ConfigService) SetVotingPolicy( + ctx context.Context, + req *cfgMsgs.SetVotingPolicyRequest, +) (*cfgMsgs.SetVotingPolicyResponse, error) { + hackathonID, err := uuid.Parse(req.GetHackathonId()) + if err != nil { + return nil, status.Errorf(codes.InvalidArgument, "invalid hackathon_id: %v", err) + } + if err := s.enforcer.RequirePermission(ctx, hackathonID.String(), m.Hackathon, m.Write); err != nil { + return nil, err + } + modifier, err := s.callerUser(ctx) + if err != nil { + return nil, err + } + + policy := map[string]any{ + "mechanism": req.GetMechanism(), + "oneBallotPer": req.GetOneBallotPer(), + "ownTeamVoting": req.GetOwnTeamVoting(), + "organizerVoting": req.GetOrganizerVoting(), + "tieBreak": req.GetTieBreak(), + } + if req.GetScale() != nil { + policy["scale"] = map[string]any{ + "min": req.GetScale().GetMin(), + "max": req.GetScale().GetMax(), + } + } + if _, err := s.upsertForms(ctx, hackathonID, modifier, + func(c *ent.HackathonFormsCreate) { c.SetVotingPolicy(policy) }, + func(u *ent.HackathonFormsUpdateOne) { u.SetVotingPolicy(policy) }, + ); err != nil { + return nil, err + } + + return &cfgMsgs.SetVotingPolicyResponse{}, nil +} + +// ─── Enforcement (consulted by the acting RPCs) ───────────────────── + +type windowKind int + +const ( + windowRegistration windowKind = iota + windowProposals + windowPreferences + windowSubmissions +) + +// requireWindowOpen returns FailedPrecondition when the hackathon has a +// windows row and the given window is closed at `now`. No row or an unset +// window means no enforcement. +func requireWindowOpen( + ctx context.Context, + db *ent.Client, + hackathonID uuid.UUID, + kind windowKind, + now time.Time, +) error { + w, err := windowsRowFor(ctx, db, hackathonID) + if err != nil { + slog.Error("query hackathon windows", "err", err) + + return status.Error(codes.Internal, "couldn't query hackathon windows") + } + if w == nil { + return nil + } + + closedAfter := func(closes, override *time.Time) bool { + if closes == nil || !now.After(*closes) { + return false + } + + return override == nil || now.After(*override) + } + + switch kind { + case windowRegistration: + if w.RegistrationOpens != nil && now.Before(*w.RegistrationOpens) { + return status.Error(codes.FailedPrecondition, "registration is not open yet") + } + if closedAfter(w.RegistrationCloses, w.RegistrationOverrideUntil) { + return status.Error(codes.FailedPrecondition, "registration is closed") + } + case windowProposals: + if closedAfter(w.ProposalsClose, nil) { + return status.Error(codes.FailedPrecondition, "proposals are closed") + } + case windowPreferences: + if closedAfter(w.PreferencesClose, nil) { + return status.Error(codes.FailedPrecondition, "preferences are closed") + } + case windowSubmissions: + if closedAfter(w.SubmissionsClose, w.SubmissionsOverrideUntil) { + return status.Error(codes.FailedPrecondition, "submissions are closed") + } + } + + return nil +} diff --git a/components/backend/internal/service/config_service_test.go b/components/backend/internal/service/config_service_test.go new file mode 100644 index 00000000..652bede0 --- /dev/null +++ b/components/backend/internal/service/config_service_test.go @@ -0,0 +1,192 @@ +//go:build test && unittest + +package service_test + +import ( + "context" + "time" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + "google.golang.org/grpc" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/metadata" + "google.golang.org/grpc/status" + "google.golang.org/protobuf/types/known/timestamppb" + + "github.com/google/uuid" + + ent "github.com/swissdatasciencecenter/hackagon/components/backend/ent" + enthackathon "github.com/swissdatasciencecenter/hackagon/components/backend/ent/hackathon" + enthackathonwindows "github.com/swissdatasciencecenter/hackagon/components/backend/ent/hackathonwindows" + entparticipant "github.com/swissdatasciencecenter/hackagon/components/backend/ent/participant" + hackathonSvc "github.com/swissdatasciencecenter/hackagon/components/backend/internal/proto/hackathon" + "github.com/swissdatasciencecenter/hackagon/components/backend/internal/proto/hackathon/entities" + cfgMsgs "github.com/swissdatasciencecenter/hackagon/components/backend/internal/proto/hackathon/messages/config_svc" + msgs "github.com/swissdatasciencecenter/hackagon/components/backend/internal/proto/hackathon/messages/hackathon_svc" + "github.com/swissdatasciencecenter/hackagon/components/backend/internal/testutils" +) + +// Window enforcement — requireWindowOpen in config_service.go, seen through the +// RPC that consults it. +// +// Driven through Join rather than by calling the unexported function, because +// the property is "the acting RPC refuses", and the two halves of a window plus +// its override are the same closure for every kind: registration exercises +// `closedAfter` (shared with proposals, preferences and submissions) and the +// opens-before branch that only registration has. +// +// NO CLOCK CONTROL AND NO SLEEP. Every instant here is written relative to +// time.Now() at the moment the window is stored — a deadline an hour in the past +// is closed however slowly the suite runs, and an override thirty minutes out +// cannot expire mid-spec. A test that sleeps to cross a boundary is a test that +// flakes on a loaded machine. +var _ = Describe("Window enforcement", func() { + var ( + dbClient *ent.Client + conn *grpc.ClientConn + hackClient hackathonSvc.HackathonServiceClient + cfgClient hackathonSvc.ConfigServiceClient + adminCtx context.Context + joinerCtx context.Context + joiner *ent.User + hackathonID string + ) + + authed := func(keycloakID string) context.Context { + return metadata.NewOutgoingContext( + context.Background(), + metadata.Pairs( + "authorization", + "Bearer "+testutils.CreateTestJWTToken(keycloakID), + ), + ) + } + + BeforeEach(func() { + dbClient, conn, _ = testutils.CreateTestServer() + hackClient = hackathonSvc.NewHackathonServiceClient(conn) + cfgClient = hackathonSvc.NewConfigServiceClient(conn) + adminCtx = authed(testutils.TestAdminKeycloakID) + + now := time.Now() + created, err := hackClient.Create(adminCtx, &msgs.CreateRequest{ + Name: "Windowed Hackathon", + Visibility: entities.Visibility_VISIBILITY_PUBLIC, + StartsAt: timestamppb.New(now.Add(24 * time.Hour)), + EndsAt: timestamppb.New(now.Add(48 * time.Hour)), + }) + Expect(err).NotTo(HaveOccurred()) + hackathonID = created.GetHackathonId() + + joiner, err = dbClient.User.Create(). + SetKeycloakID("window-joiner"). + SetUsername("window-joiner"). + Save(context.Background()) + Expect(err).NotTo(HaveOccurred()) + joinerCtx = authed("window-joiner") + }) + + join := func() error { + _, err := hackClient.Join(joinerCtx, &msgs.JoinRequest{HackathonId: hackathonID}) + + return err + } + + // onRoster reads the END STATE back. "The call was refused" and "no row was + // written" are different claims, and the second is the one a participant + // would notice. + onRoster := func() int { + n, err := dbClient.Participant.Query().Where( + entparticipant.HackathonIDEQ(uuid.MustParse(hackathonID)), + entparticipant.UserIDEQ(joiner.ID), + ).Count(context.Background()) + Expect(err).NotTo(HaveOccurred()) + + return n + } + + setWindows := func(req *cfgMsgs.SetWindowsRequest) { + GinkgoHelper() + req.HackathonId = hackathonID + _, err := cfgClient.SetWindows(adminCtx, req) + Expect(err).NotTo(HaveOccurred()) + } + + // The control. Every refusal below is only evidence about the WINDOW if the + // same join lands when no window is configured — otherwise the whole + // Describe would agree just as loudly with a Join that refused everybody. + It("lets a join through when no window is configured", func() { + Expect(join()).To(Succeed()) + Expect(onRoster()).To(Equal(1)) + }) + + It("refuses a join once the registration window has closed", func() { + setWindows(&cfgMsgs.SetWindowsRequest{ + RegistrationCloses: timestamppb.New(time.Now().Add(-1 * time.Hour)), + }) + + err := join() + Expect(err).To(HaveOccurred()) + Expect(status.Convert(err).Code()).To(Equal(codes.FailedPrecondition)) + Expect(status.Convert(err).Message()).To(ContainSubstring("registration is closed")) + Expect(onRoster()).To(Equal(0), "a refused join must write no participant row") + }) + + It("refuses a join before the registration window opens", func() { + setWindows(&cfgMsgs.SetWindowsRequest{ + RegistrationOpens: timestamppb.New(time.Now().Add(1 * time.Hour)), + }) + + err := join() + Expect(err).To(HaveOccurred()) + Expect(status.Convert(err).Code()).To(Equal(codes.FailedPrecondition)) + Expect(status.Convert(err).Message()).To(ContainSubstring("not open yet")) + Expect(onRoster()).To(Equal(0), "a refused join must write no participant row") + }) + + It("reopens a closed window for the length of a now-anchored override", func() { + setWindows(&cfgMsgs.SetWindowsRequest{ + RegistrationCloses: timestamppb.New(time.Now().Add(-1 * time.Hour)), + }) + + // Anchored at NOW, not at the configured close — which is what makes a + // late signup a support decision ("let them in for the next 30 minutes") + // rather than an argument about how long ago the deadline was. + _, err := cfgClient.OverrideWindow(adminCtx, &cfgMsgs.OverrideWindowRequest{ + HackathonId: hackathonID, + Window: "registration", + ExtendMinutes: 30, + Reason: "walk-in at the desk", + }) + Expect(err).NotTo(HaveOccurred()) + + Expect(join()).To(Succeed()) + Expect(onRoster()).To(Equal(1)) + }) + + It("stops honouring an override once it has expired", func() { + setWindows(&cfgMsgs.SetWindowsRequest{ + RegistrationCloses: timestamppb.New(time.Now().Add(-1 * time.Hour)), + }) + + // An override that has RUN OUT, written straight to the row. The RPC + // cannot produce this state — protovalidate holds extend_minutes to + // 1..1440, so an override is always granted into the future — and the + // only other way to reach it is to wait for one to expire, which is a + // sleep, and a test that sleeps to cross a boundary flakes. This is what + // the row of a 30-minute grace window looks like an hour later. + _, err := dbClient.HackathonWindows.Update(). + Where(enthackathonwindows.HasHackathonWith( + enthackathon.IDEQ(uuid.MustParse(hackathonID)), + )). + SetRegistrationOverrideUntil(time.Now().Add(-30 * time.Minute)). + Save(context.Background()) + Expect(err).NotTo(HaveOccurred()) + + err = join() + Expect(err).To(HaveOccurred()) + Expect(status.Convert(err).Code()).To(Equal(codes.FailedPrecondition)) + Expect(onRoster()).To(Equal(0)) + }) +}) diff --git a/components/backend/internal/service/hackathon_invite.go b/components/backend/internal/service/hackathon_invite.go new file mode 100644 index 00000000..07256666 --- /dev/null +++ b/components/backend/internal/service/hackathon_invite.go @@ -0,0 +1,280 @@ +package service + +import ( + "context" + "log/slog" + "time" + + "github.com/google/uuid" + "github.com/swissdatasciencecenter/hackagon/components/backend/ent" + enthackathon "github.com/swissdatasciencecenter/hackagon/components/backend/ent/hackathon" + entinvite "github.com/swissdatasciencecenter/hackagon/components/backend/ent/hackathoninvite" + entuser "github.com/swissdatasciencecenter/hackagon/components/backend/ent/user" + m "github.com/swissdatasciencecenter/hackagon/components/backend/internal/middleware" + ents "github.com/swissdatasciencecenter/hackagon/components/backend/internal/proto/hackathon/entities" + msgs "github.com/swissdatasciencecenter/hackagon/components/backend/internal/proto/hackathon/messages/hackathon_svc" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" + "google.golang.org/protobuf/types/known/timestamppb" +) + +// Invitation links for private hackathons. +// +// The token in the URL is the entire secret, so the organizer-facing RPCs +// (which return it) require hackathon write, while the redemption RPC takes +// only the token — never a hackathon id — so it cannot be used to probe which +// events exist. Redeeming grants VISIBILITY, not membership: the holder still +// goes through Join and the organizer's approval. + +// requireLiveInvite fails unless token names a non-revoked invite of this +// hackathon. Every rejection returns the same PermissionDenied, so a caller +// cannot tell a wrong token from a revoked one or from one belonging to a +// different event. +func (s *HackathonService) requireLiveInvite( + ctx context.Context, + hackathonID uuid.UUID, + token string, +) error { + denied := status.Error( + codes.PermissionDenied, + "this hackathon is private: a valid invitation link is required to join", + ) + if token == "" { + return denied + } + t, err := uuid.Parse(token) + if err != nil { + return denied + } + + inv, err := s.dbClient.HackathonInvite.Query(). + Where(entinvite.TokenEQ(t)). + WithHackathon(). + Only(ctx) + if err != nil { + if ent.IsNotFound(err) { + return denied + } + slog.Error("query invite", "err", err) + + return status.Error(codes.Internal, "couldn't query database") + } + if inv.RevokedAt != nil { + return denied + } + if inv.Edges.Hackathon == nil || inv.Edges.Hackathon.ID != hackathonID { + return denied + } + + return nil +} + +func (s *HackathonService) CreateInvite( + ctx context.Context, + req *msgs.CreateInviteRequest, +) (*msgs.CreateInviteResponse, error) { + id, err := uuid.Parse(req.GetHackathonId()) + if err != nil { + return nil, status.Errorf(codes.InvalidArgument, "invalid hackathon_id: %v", err) + } + // Generating a link hands out access, so it is a write on the hackathon. + if err := s.enforcer.RequirePermission(ctx, id.String(), m.Hackathon, m.Write); err != nil { + return nil, err + } + + uid, _, err := m.RequireSubject(ctx) + if err != nil { + return nil, err + } + creator, err := s.dbClient.User.Query().Where(entuser.KeycloakIDEQ(uid)).Only(ctx) + if err != nil { + if ent.IsNotFound(err) { + return nil, status.Error(codes.NotFound, "acting user does not exist") + } + slog.Error("query acting user", "err", err) + + return nil, status.Error(codes.Internal, "couldn't query database") + } + + h, err := s.dbClient.Hackathon.Query().Where(enthackathon.IDEQ(id)).Only(ctx) + if err != nil { + if ent.IsNotFound(err) { + return nil, status.Errorf(codes.NotFound, "hackathon %s not found", id) + } + slog.Error("query hackathon", "err", err) + + return nil, status.Error(codes.Internal, "couldn't query database") + } + + create := s.dbClient.HackathonInvite.Create(). + SetHackathon(h). + SetCreator(creator) + if req.Note != nil { + create = create.SetNote(req.GetNote()) + } + inv, err := create.Save(ctx) + if err != nil { + slog.Error("create invite", "err", err) + + return nil, status.Error(codes.Internal, "couldn't create invite") + } + + return &msgs.CreateInviteResponse{Invite: inviteEntryFromEnt(inv, id, creator.ID)}, nil +} + +func (s *HackathonService) ListInvites( + ctx context.Context, + req *msgs.ListInvitesRequest, +) (*msgs.ListInvitesResponse, error) { + id, err := uuid.Parse(req.GetHackathonId()) + if err != nil { + return nil, status.Errorf(codes.InvalidArgument, "invalid hackathon_id: %v", err) + } + // The response carries live secrets — same permission as creating one. + if err := s.enforcer.RequirePermission(ctx, id.String(), m.Hackathon, m.Write); err != nil { + return nil, err + } + + q := s.dbClient.HackathonInvite.Query(). + Where(entinvite.HasHackathonWith(enthackathon.IDEQ(id))). + WithCreator(). + Order(ent.Desc(entinvite.FieldCreatedAt)) + if !req.GetIncludeRevoked() { + q = q.Where(entinvite.RevokedAtIsNil()) + } + + invites, err := q.All(ctx) + if err != nil { + slog.Error("list invites", "err", err) + + return nil, status.Error(codes.Internal, "couldn't list invites") + } + + out := make([]*ents.HackathonInvite, 0, len(invites)) + for _, inv := range invites { + var creatorID uuid.UUID + if inv.Edges.Creator != nil { + creatorID = inv.Edges.Creator.ID + } + out = append(out, inviteEntryFromEnt(inv, id, creatorID)) + } + + return &msgs.ListInvitesResponse{Invites: out}, nil +} + +func (s *HackathonService) RevokeInvite( + ctx context.Context, + req *msgs.RevokeInviteRequest, +) (*msgs.RevokeInviteResponse, error) { + inviteID, err := uuid.Parse(req.GetInviteId()) + if err != nil { + return nil, status.Errorf(codes.InvalidArgument, "invalid invite_id: %v", err) + } + + inv, err := s.dbClient.HackathonInvite.Query(). + Where(entinvite.IDEQ(inviteID)). + WithHackathon(). + Only(ctx) + if err != nil { + if ent.IsNotFound(err) { + return nil, status.Errorf(codes.NotFound, "invite %s not found", inviteID) + } + slog.Error("query invite", "err", err) + + return nil, status.Error(codes.Internal, "couldn't query database") + } + if inv.Edges.Hackathon == nil { + return nil, status.Error(codes.Internal, "invite has no hackathon") + } + if err := s.enforcer.RequirePermission( + ctx, inv.Edges.Hackathon.ID.String(), m.Hackathon, m.Write, + ); err != nil { + return nil, err + } + + // Revoking twice is a no-op rather than an error: the caller's intent + // (this link must not work) already holds. + if inv.RevokedAt == nil { + if _, err := inv.Update().SetRevokedAt(time.Now()).Save(ctx); err != nil { + slog.Error("revoke invite", "err", err) + + return nil, status.Error(codes.Internal, "couldn't revoke invite") + } + } + + return &msgs.RevokeInviteResponse{}, nil +} + +// PreviewInvite is the redemption side: it takes only the token, so it can +// never confirm whether a given hackathon id exists. Any bad, revoked or +// unknown token yields the same NotFound. +func (s *HackathonService) PreviewInvite( + ctx context.Context, + req *msgs.PreviewInviteRequest, +) (*msgs.PreviewInviteResponse, error) { + notFound := status.Error(codes.NotFound, "this invitation link is not valid") + + t, err := uuid.Parse(req.GetToken()) + if err != nil { + return nil, notFound + } + + inv, err := s.dbClient.HackathonInvite.Query(). + Where(entinvite.TokenEQ(t)). + WithHackathon(func(q *ent.HackathonQuery) { + q.WithParticipants(func(pq *ent.ParticipantQuery) { pq.WithUser() }) + }). + Only(ctx) + if err != nil { + if ent.IsNotFound(err) { + return nil, notFound + } + slog.Error("query invite", "err", err) + + return nil, status.Error(codes.Internal, "couldn't query database") + } + if inv.RevokedAt != nil || inv.Edges.Hackathon == nil { + return nil, notFound + } + + h := inv.Edges.Hackathon + + // Anonymous visitors may preview: the link is the credential, and they are + // sent to sign in before Join. Only report membership for a real user. + already := false + if uid, _, serr := m.RequireSubject(ctx); serr == nil && uid != m.AnonSubject { + for _, p := range h.Edges.Participants { + if p.Edges.User != nil && p.Edges.User.KeycloakID == uid { + already = true + + break + } + } + } + + return &msgs.PreviewInviteResponse{ + Hackathon: hackathonEntryFromEnt(h, time.Now()), + AlreadyParticipant: already, + }, nil +} + +func inviteEntryFromEnt( + inv *ent.HackathonInvite, + hackathonID uuid.UUID, + creatorID uuid.UUID, +) *ents.HackathonInvite { + //exhaustruct:ignore + e := &ents.HackathonInvite{ + Id: inv.ID.String(), + Token: inv.Token.String(), + HackathonId: hackathonID.String(), + Note: inv.Note, + CreatedAt: timestamppb.New(inv.CreatedAt), + CreatorId: creatorID.String(), + } + if inv.RevokedAt != nil { + e.RevokedAt = timestamppb.New(*inv.RevokedAt) + } + + return e +} diff --git a/components/backend/internal/service/hackathon_join_test.go b/components/backend/internal/service/hackathon_join_test.go new file mode 100644 index 00000000..72aa34a8 --- /dev/null +++ b/components/backend/internal/service/hackathon_join_test.go @@ -0,0 +1,203 @@ +//go:build test && unittest + +package service_test + +import ( + "context" + "time" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + "google.golang.org/grpc" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/metadata" + "google.golang.org/grpc/status" + "google.golang.org/protobuf/types/known/timestamppb" + + "github.com/google/uuid" + + ent "github.com/swissdatasciencecenter/hackagon/components/backend/ent" + entparticipant "github.com/swissdatasciencecenter/hackagon/components/backend/ent/participant" + "github.com/swissdatasciencecenter/hackagon/components/backend/internal/middleware" + hackathonSvc "github.com/swissdatasciencecenter/hackagon/components/backend/internal/proto/hackathon" + "github.com/swissdatasciencecenter/hackagon/components/backend/internal/proto/hackathon/entities" + msgs "github.com/swissdatasciencecenter/hackagon/components/backend/internal/proto/hackathon/messages/hackathon_svc" + "github.com/swissdatasciencecenter/hackagon/components/backend/internal/testutils" +) + +// The three guards Join applies before it writes a row, and the role it writes +// afterwards. +// +// None of these is visible in the response: Join answers the same shape for a +// member and for an organizer, so the ROLE it granted has to be read back out +// of casbin, and a refusal has to be read back out of the roster. +var _ = Describe("HackathonService Join guards", func() { + var ( + dbClient *ent.Client + conn *grpc.ClientConn + enf *middleware.Enforcer + client hackathonSvc.HackathonServiceClient + adminCtx context.Context + joinerCtx context.Context + joiner *ent.User + ) + + authed := func(keycloakID string) context.Context { + return metadata.NewOutgoingContext( + context.Background(), + metadata.Pairs( + "authorization", + "Bearer "+testutils.CreateTestJWTToken(keycloakID), + ), + ) + } + + // create makes a hackathon with the given visibility and lifespan, offset + // from now so nothing here depends on the wall clock beyond "past" and + // "future". + create := func(name string, vis entities.Visibility, startsIn, endsIn time.Duration) string { + GinkgoHelper() + now := time.Now() + resp, err := client.Create(adminCtx, &msgs.CreateRequest{ + Name: name, + Visibility: vis, + StartsAt: timestamppb.New(now.Add(startsIn)), + EndsAt: timestamppb.New(now.Add(endsIn)), + }) + Expect(err).NotTo(HaveOccurred()) + + return resp.GetHackathonId() + } + + onRoster := func(hackathonID string) int { + GinkgoHelper() + n, err := dbClient.Participant.Query().Where( + entparticipant.HackathonIDEQ(uuid.MustParse(hackathonID)), + entparticipant.UserIDEQ(joiner.ID), + ).Count(context.Background()) + Expect(err).NotTo(HaveOccurred()) + + return n + } + + BeforeEach(func() { + var err error + dbClient, conn, enf = testutils.CreateTestServer() + client = hackathonSvc.NewHackathonServiceClient(conn) + adminCtx = authed(testutils.TestAdminKeycloakID) + + joiner, err = dbClient.User.Create(). + SetKeycloakID("join-guard-user"). + SetUsername("join-guard-user"). + Save(context.Background()) + Expect(err).NotTo(HaveOccurred()) + joinerCtx = authed("join-guard-user") + }) + + Describe("a private hackathon", func() { + var hackathonID string + + BeforeEach(func() { + hackathonID = create( + "Invite Only", entities.Visibility_VISIBILITY_PRIVATE, + 24*time.Hour, 48*time.Hour, + ) + }) + + It("refuses a join that carries no invitation", func() { + // Privacy was discovery-only before this check: knowing the UUID was + // enough to join. The id is exactly what this caller has. + _, err := client.Join(joinerCtx, &msgs.JoinRequest{HackathonId: hackathonID}) + Expect(err).To(HaveOccurred()) + Expect(status.Convert(err).Code()).To(Equal(codes.PermissionDenied)) + Expect(status.Convert(err).Message()).To(ContainSubstring("invitation link")) + Expect(onRoster(hackathonID)).To(Equal(0)) + }) + + It("refuses a join that carries an invitation to somewhere else", func() { + other := create( + "Some Other Event", entities.Visibility_VISIBILITY_PRIVATE, + 24*time.Hour, 48*time.Hour, + ) + inv, err := client.CreateInvite(adminCtx, &msgs.CreateInviteRequest{ + HackathonId: other, + }) + Expect(err).NotTo(HaveOccurred()) + + token := inv.GetInvite().GetToken() + _, err = client.Join(joinerCtx, &msgs.JoinRequest{ + HackathonId: hackathonID, + InviteToken: &token, + }) + Expect(err).To(HaveOccurred()) + Expect(status.Convert(err).Code()).To(Equal(codes.PermissionDenied)) + Expect(onRoster(hackathonID)).To(Equal(0)) + }) + + // The control for both refusals above. Without it they would both pass + // against a Join that refused every private event outright, which is a + // different product. + It("admits the same join once it carries a live invitation", func() { + inv, err := client.CreateInvite(adminCtx, &msgs.CreateInviteRequest{ + HackathonId: hackathonID, + }) + Expect(err).NotTo(HaveOccurred()) + + token := inv.GetInvite().GetToken() + _, err = client.Join(joinerCtx, &msgs.JoinRequest{ + HackathonId: hackathonID, + InviteToken: &token, + }) + Expect(err).NotTo(HaveOccurred()) + Expect(onRoster(hackathonID)).To(Equal(1)) + }) + }) + + It("refuses a join once the event has finished", func() { + finished := create( + "Last Year's Hackathon", entities.Visibility_VISIBILITY_PUBLIC, + -48*time.Hour, -24*time.Hour, + ) + + _, err := client.Join(joinerCtx, &msgs.JoinRequest{HackathonId: finished}) + Expect(err).To(HaveOccurred()) + Expect(status.Convert(err).Code()).To(Equal(codes.FailedPrecondition)) + Expect(status.Convert(err).Message()).To(ContainSubstring("already finished")) + Expect(onRoster(finished)).To(Equal(0)) + }) + + It("still admits a join into an event that has not ended", func() { + running := create( + "Happening Now", entities.Visibility_VISIBILITY_PUBLIC, + -1*time.Hour, 24*time.Hour, + ) + + _, err := client.Join(joinerCtx, &msgs.JoinRequest{HackathonId: running}) + Expect(err).NotTo(HaveOccurred()) + Expect(onRoster(running)).To(Equal(1)) + }) + + It("grants the joiner Member and nothing more", func() { + open := create( + "Open Event", entities.Visibility_VISIBILITY_PUBLIC, + 24*time.Hour, 48*time.Hour, + ) + + _, err := client.Join(joinerCtx, &msgs.JoinRequest{HackathonId: open}) + Expect(err).NotTo(HaveOccurred()) + + // Member is granted to everyone on the roster, waitlisted included — + // that is what lets them propose and see the event they signed up for. + // The role is read from casbin because the response cannot show it. + role, err := enf.GetHackathonRole(joiner.KeycloakID, open) + Expect(err).NotTo(HaveOccurred()) + Expect(role).To(Equal(entities.HackathonRole_HACKATHON_ROLE_MEMBER)) + + holders, err := enf.HackathonOwners(open) + Expect(err).NotTo(HaveOccurred()) + Expect(holders).NotTo( + ContainElement(joiner.KeycloakID), + "signing up must not make somebody an organizer of the event", + ) + }) +}) diff --git a/components/backend/internal/service/hackathon_owner_test.go b/components/backend/internal/service/hackathon_owner_test.go new file mode 100644 index 00000000..1c41c567 --- /dev/null +++ b/components/backend/internal/service/hackathon_owner_test.go @@ -0,0 +1,211 @@ +//go:build test && unittest + +package service_test + +import ( + "context" + "time" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + "google.golang.org/grpc" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/metadata" + "google.golang.org/grpc/status" + "google.golang.org/protobuf/types/known/timestamppb" + + "github.com/google/uuid" + + ent "github.com/swissdatasciencecenter/hackagon/components/backend/ent" + entuser "github.com/swissdatasciencecenter/hackagon/components/backend/ent/user" + "github.com/swissdatasciencecenter/hackagon/components/backend/internal/middleware" + hackathonSvc "github.com/swissdatasciencecenter/hackagon/components/backend/internal/proto/hackathon" + "github.com/swissdatasciencecenter/hackagon/components/backend/internal/proto/hackathon/entities" + msgs "github.com/swissdatasciencecenter/hackagon/components/backend/internal/proto/hackathon/messages/hackathon_svc" + "github.com/swissdatasciencecenter/hackagon/components/backend/internal/testutils" +) + +// AddOwner / RemoveOwner. +// +// OWNERSHIP IS A CASBIN FACT ON THIS BRANCH — there is no owners column to +// count — so every assertion here goes through the enforcer the server itself +// holds (testutils.CreateTestServer hands back that instance, not a copy). +// Asserting on the gRPC response alone would be the container-holds-the-thing +// trap: RemoveOwner answers with an empty message, so "it returned OK" says +// nothing about who ends up holding what. +// +// Nothing here opens an ent transaction: casbin writes on its own connection, +// and a transaction held across one deadlocks. +var _ = Describe("HackathonService owner roles", func() { + var ( + dbClient *ent.Client + conn *grpc.ClientConn + enf *middleware.Enforcer + client hackathonSvc.HackathonServiceClient + adminCtx context.Context + admin string + hackathonID string + bob *ent.User + ) + + authed := func(keycloakID string) context.Context { + return metadata.NewOutgoingContext( + context.Background(), + metadata.Pairs( + "authorization", + "Bearer "+testutils.CreateTestJWTToken(keycloakID), + ), + ) + } + + owners := func() []string { + GinkgoHelper() + list, err := enf.HackathonOwners(hackathonID) + Expect(err).NotTo(HaveOccurred()) + + return list + } + + roleOf := func(keycloakID string) entities.HackathonRole { + GinkgoHelper() + role, err := enf.GetHackathonRole(keycloakID, hackathonID) + Expect(err).NotTo(HaveOccurred()) + + return role + } + + // The owner RPCs address people by platform UUID while casbin holds Keycloak + // ids, and mixing the two silently addresses nobody. + platformID := func(keycloakID string) string { + GinkgoHelper() + u, err := dbClient.User.Query(). + Where(entuser.KeycloakIDEQ(keycloakID)). + Only(context.Background()) + Expect(err).NotTo(HaveOccurred()) + + return u.ID.String() + } + + BeforeEach(func() { + dbClient, conn, enf = testutils.CreateTestServer() + client = hackathonSvc.NewHackathonServiceClient(conn) + admin = testutils.TestAdminKeycloakID + adminCtx = authed(admin) + + now := time.Now() + created, err := client.Create(adminCtx, &msgs.CreateRequest{ + Name: "Owned Hackathon", + Visibility: entities.Visibility_VISIBILITY_PUBLIC, + StartsAt: timestamppb.New(now.Add(24 * time.Hour)), + EndsAt: timestamppb.New(now.Add(48 * time.Hour)), + }) + Expect(err).NotTo(HaveOccurred()) + hackathonID = created.GetHackathonId() + + // bob is a CONFIRMED participant and nothing more. The row is written + // directly rather than through Join on purpose: Join grants Member, and + // a bob who already held Member could not show that RemoveOwner is what + // gives it back to him. + bob, err = dbClient.User.Create(). + SetKeycloakID("owner-test-bob"). + SetUsername("owner-test-bob"). + Save(context.Background()) + Expect(err).NotTo(HaveOccurred()) + _, err = dbClient.Participant.Create(). + SetHackathonID(uuid.MustParse(hackathonID)). + SetUserID(bob.ID). + SetIsWaiting(false). + Save(context.Background()) + Expect(err).NotTo(HaveOccurred()) + }) + + // Creating the event makes its creator the sole owner. Stated here because + // every spec below is a statement about moving away from that. + It("makes the creator the only owner", func() { + Expect(owners()).To(ConsistOf(admin)) + Expect(roleOf(admin)).To(Equal(entities.HackathonRole_HACKATHON_ROLE_OWNER)) + }) + + Describe("RemoveOwner", func() { + It("refuses to demote the last organizer", func() { + // A SECOND global admin does the asking, so the refusal cannot come + // from the self-demotion rule: this is the last-organizer guard on + // its own, with a caller who is allowed to do everything else. + _, err := enf.AddGlobalRole("owner-test-superadmin", middleware.Admin) + Expect(err).NotTo(HaveOccurred()) + + _, err = client.RemoveOwner( + authed("owner-test-superadmin"), + &msgs.RemoveOwnerRequest{HackathonId: hackathonID, UserId: platformID(admin)}, + ) + Expect(err).To(HaveOccurred()) + Expect(status.Convert(err).Code()).To(Equal(codes.FailedPrecondition)) + Expect(status.Convert(err).Message()).To(ContainSubstring("last organizer")) + + Expect(owners()).To( + ConsistOf(admin), + "an event left with zero owners can be edited by nobody short of a global admin", + ) + }) + + It( + "refuses an organizer demoting themselves, even with a co-organizer to fall back on", + func() { + // The co-organizer matters: with one owner this would be refused by + // the last-organizer guard, which runs first, and the spec would + // pass with the self-demotion rule deleted. + _, err := client.AddOwner( + adminCtx, + &msgs.AddOwnerRequest{HackathonId: hackathonID, UserId: bob.ID.String()}, + ) + Expect(err).NotTo(HaveOccurred()) + Expect(owners()).To(ConsistOf(admin, bob.KeycloakID)) + + _, err = client.RemoveOwner( + adminCtx, + &msgs.RemoveOwnerRequest{HackathonId: hackathonID, UserId: platformID(admin)}, + ) + Expect(err).To(HaveOccurred()) + Expect(status.Convert(err).Code()).To(Equal(codes.PermissionDenied)) + Expect( + status.Convert(err).Message(), + ).To(ContainSubstring("your own organizer role")) + + Expect(owners()).To(ContainElement(admin)) + }, + ) + + It("gives a demoted co-organizer Member back", func() { + _, err := client.AddOwner( + adminCtx, + &msgs.AddOwnerRequest{HackathonId: hackathonID, UserId: bob.ID.String()}, + ) + Expect(err).NotTo(HaveOccurred()) + Expect(roleOf(bob.KeycloakID)). + To(Equal(entities.HackathonRole_HACKATHON_ROLE_OWNER)) + + _, err = client.RemoveOwner( + adminCtx, + &msgs.RemoveOwnerRequest{HackathonId: hackathonID, UserId: bob.ID.String()}, + ) + Expect(err).NotTo(HaveOccurred()) + + Expect(owners()).To(ConsistOf(admin)) + // Not merely "no longer an owner": without the Member grant the role + // resolves to UNSPECIFIED, which renders as a corrupted record + // rather than a demotion. + Expect(roleOf(bob.KeycloakID)). + To(Equal(entities.HackathonRole_HACKATHON_ROLE_MEMBER)) + }) + + It("refuses to demote a participant who is not an organizer", func() { + _, err := client.RemoveOwner( + adminCtx, + &msgs.RemoveOwnerRequest{HackathonId: hackathonID, UserId: bob.ID.String()}, + ) + Expect(err).To(HaveOccurred()) + Expect(status.Convert(err).Code()).To(Equal(codes.NotFound)) + Expect(owners()).To(ConsistOf(admin)) + }) + }) +}) diff --git a/components/backend/internal/service/hackathon_service.go b/components/backend/internal/service/hackathon_service.go index 80a587c7..ff2e70e9 100644 --- a/components/backend/internal/service/hackathon_service.go +++ b/components/backend/internal/service/hackathon_service.go @@ -3,19 +3,30 @@ package service import ( "context" "log/slog" + "sync" "time" "github.com/google/uuid" "github.com/swissdatasciencecenter/hackagon/components/backend/ent" + entcapability "github.com/swissdatasciencecenter/hackagon/components/backend/ent/capability" + entformresponse "github.com/swissdatasciencecenter/hackagon/components/backend/ent/formresponse" enthackathon "github.com/swissdatasciencecenter/hackagon/components/backend/ent/hackathon" + enthackathonforms "github.com/swissdatasciencecenter/hackagon/components/backend/ent/hackathonforms" + enthackathonprizes "github.com/swissdatasciencecenter/hackagon/components/backend/ent/hackathonprizes" + enthackathonsettings "github.com/swissdatasciencecenter/hackagon/components/backend/ent/hackathonsettings" + enthackathonwindows "github.com/swissdatasciencecenter/hackagon/components/backend/ent/hackathonwindows" entparticipant "github.com/swissdatasciencecenter/hackagon/components/backend/ent/participant" entuser "github.com/swissdatasciencecenter/hackagon/components/backend/ent/user" + "github.com/swissdatasciencecenter/hackagon/components/backend/internal/capability" m "github.com/swissdatasciencecenter/hackagon/components/backend/internal/middleware" "github.com/swissdatasciencecenter/hackagon/components/backend/internal/proto/hackathon" ents "github.com/swissdatasciencecenter/hackagon/components/backend/internal/proto/hackathon/entities" msgs "github.com/swissdatasciencecenter/hackagon/components/backend/internal/proto/hackathon/messages/hackathon_svc" + userEnts "github.com/swissdatasciencecenter/hackagon/components/backend/internal/proto/user/entities" + objstore "github.com/swissdatasciencecenter/hackagon/components/backend/internal/storage" "google.golang.org/grpc/codes" "google.golang.org/grpc/status" + "google.golang.org/protobuf/types/known/structpb" "google.golang.org/protobuf/types/known/timestamppb" ) @@ -23,13 +34,37 @@ type HackathonService struct { hackathon.UnimplementedHackathonServiceServer dbClient *ent.Client enforcer *m.Enforcer + // store deletes the event's uploaded imagery when the event goes. nil when + // no object store is configured; see purgeObjects. + store *objstore.Client + // ownerMu serializes owner-role writes. RemoveOwner's last-organizer guard + // is check-then-act over casbin — read the owners, refuse if one, then + // remove — so two owners demoting each other concurrently both counted two + // owners, both passed, and the event ended up with none (measured live). + // AddOwner takes the same lock so a double-add cannot slip a duplicate + // grouping row between casbin's own check and insert. + ownerMu sync.Mutex + // capacityMu serializes every write to the roster a capacity decision + // reads. Join's seat check is check-then-act — count the confirmed roster, + // then insert — so N simultaneous Joins for the last free place all + // counted it free and the event oversold (measured: 6 concurrent joins for + // 1 seat confirmed 3 of them). ApproveParticipant and RemoveParticipant + // take the same lock because they move the counts Join decides on. Same + // single-instance limitation as ownerMu and VoteService.ballotMu. + capacityMu sync.Mutex } -func NewHackathonService(dbClient *ent.Client, enf *m.Enforcer) *HackathonService { +func NewHackathonService( + dbClient *ent.Client, + enf *m.Enforcer, + store *objstore.Client, +) *HackathonService { + //exhaustruct:ignore // ownerMu, capacityMu: zero-value sync.Mutex is the usable initial state return &HackathonService{ UnimplementedHackathonServiceServer: hackathon.UnimplementedHackathonServiceServer{}, dbClient: dbClient, enforcer: enf, + store: store, } } @@ -76,6 +111,11 @@ func (s *HackathonService) Create( if req.GetLogo() != "" { q = q.SetLogo(req.GetLogo()) } + // 0 and absent both mean unlimited, and unlimited is stored as NULL so the + // column has one spelling for it. + if req.GetMaxParticipants() > 0 { + q = q.SetMaxParticipants(req.GetMaxParticipants()) + } h, err := q.Save(ctx) if err != nil { slog.Error("create hackathon", "err", err) @@ -83,6 +123,30 @@ func (s *HackathonService) Create( return nil, status.Errorf(codes.Internal, "couldn't create hackathon in database") } + // One row per capability, so the hackathon states its policy explicitly + // rather than being ambiguously ungoverned, and every later edit is a plain + // update instead of an upsert. See defaultCapabilityEnabled for the default. + if err := createDefaultCapabilities(ctx, s.dbClient, h.ID, creator); err != nil { + slog.Error("create hackathon capabilities", "err", err) + if err := s.dbClient.Hackathon.DeleteOne(h).Exec(ctx); err != nil { + slog.Error("cleanup hackathon creation error", "err", err) + } + + return nil, status.Errorf(codes.Internal, "couldn't create hackathon capabilities") + } + + // Create default settings (both flags false). + _, err = s.dbClient.HackathonSettings.Create(). + SetHackathonID(h.ID). + SetModifier(creator). + Save(ctx) + if err != nil { + slog.Error("create hackathon settings", "err", err) + // Best-effort cleanup. + _ = s.dbClient.Hackathon.DeleteOne(h).Exec(ctx) + return nil, status.Errorf(codes.Internal, "couldn't create hackathon settings") + } + if _, err := s.enforcer.AddRole(uid, m.Owner, h.ID.String()); err != nil { slog.Error("add hackathon owner", "err", err) err := s.dbClient.Hackathon.DeleteOne(h).Exec(ctx) @@ -93,9 +157,63 @@ func (s *HackathonService) Create( return nil, status.Errorf(codes.Internal, "couldn't set hackathon owner") } + // The casbin role above carries permissions only. Membership is read from the + // participants table — Get builds members from it, and List filters on it — + // so without this row the creator would be an owner nobody can see: absent + // from members, and their own hackathon missing from their dashboard. + if _, err := s.dbClient.Participant.Create(). + SetHackathonID(h.ID). + SetUserID(creator.ID). + SetIsWaiting(false). + Save(ctx); err != nil { + slog.Error("add creator as participant", "err", err) + if _, rerr := s.enforcer.RemoveRole(uid, m.Owner, h.ID.String()); rerr != nil { + slog.Error("cleanup hackathon owner role", "err", rerr) + } + if derr := s.dbClient.Hackathon.DeleteOne(h).Exec(ctx); derr != nil { + slog.Error("cleanup hackathon creation error", "err", derr) + } + + return nil, status.Errorf(codes.Internal, "couldn't add creator as participant") + } + return &msgs.CreateResponse{HackathonId: h.ID.String()}, nil } +// viewerMayOpenMemberView reports whether the caller may read the full +// hackathon tree: a non-waiting participant, a casbin Owner, or a global +// admin. Participants must be eager-loaded with their users. +func (s *HackathonService) viewerMayOpenMemberView( + _ context.Context, + uid string, + h *ent.Hackathon, +) bool { + for _, p := range h.Edges.Participants { + if p.Edges.User != nil && p.Edges.User.KeycloakID == uid { + if !p.IsWaiting { + return true + } + + break + } + } + role, err := s.enforcer.GetHackathonRole(uid, h.ID.String()) + if err == nil && role == ents.HackathonRole_HACKATHON_ROLE_OWNER { + return true + } + globals, err := s.enforcer.GetGlobalRoles(uid) + if err != nil { + return false + } + for _, g := range globals { + if g == userEnts.GlobalRole_GLOBAL_ROLE_ADMIN { + return true + } + } + + return false +} + func (s *HackathonService) Get( ctx context.Context, req *msgs.GetRequest, @@ -117,7 +235,14 @@ func (s *HackathonService) Get( WithProjects(func(q *ent.ProjectQuery) { q.WithCreator().WithModifier().WithTrack() }). WithPages(func(q *ent.PageQuery) { q.WithCreator().WithModifier().WithPhase() }). WithPhases(func(q *ent.PhaseQuery) { q.WithCreator().WithModifier().WithPage() }). + WithCapabilities(func(q *ent.CapabilityQuery) { + q.WithModifier().WithOpenInPhase().WithClosedInPhase() + }). WithParticipants(func(q *ent.ParticipantQuery) { q.WithUser() }). + WithSettings(). + // Carries the branding map that hackathonEntryFromEnt turns into + // Hackathon.branding. + WithForms(). Only(ctx) if err != nil { if ent.IsNotFound(err) { @@ -132,7 +257,24 @@ func (s *HackathonService) Get( return nil, status.Error(codes.Internal, "couldn't query database") } - entry := hackathonEntryFromEnt(h, time.Now()) + // The member view is for the confirmed roster: a Member role alone (held + // from Join, including by waitlisted registrants) is not enough. Allow + // non-waiting participants, hackathon owners, and global admins. + uid, _, err := m.RequireSubject(ctx) + if err != nil { + return nil, err + } + if !s.viewerMayOpenMemberView(ctx, uid, h) { + return nil, status.Error( + codes.PermissionDenied, + "hackathon is only open to confirmed participants", + ) + } + + // One instant for the whole response, so the status badge and the capability + // states cannot disagree about what time it is. + now := time.Now() + entry := hackathonEntryFromEnt(h, now) entry.Creator = userEntryFromEnt(h.Edges.Creator) entry.Modifier = userEntryFromEnt(h.Edges.Modifier) @@ -157,6 +299,18 @@ func (s *HackathonService) Get( entry.Phases = append(entry.Phases, phaseEntryFromEnt(p, id)) } + // The organizer's declared phase outranks the dates when resolving COMING, + // so the clock has to reach the mapper. + clock := newCapabilityClock(phaseOrderFrom(h.Edges.Phases), h.CurrentPhaseID) + entry.Capabilities = capabilityStatusesFromEnt(h.Edges.Capabilities, clock, now) + // Main's flat shape over the same rows — a projection, never a second + // answer. See hackathon_state.go; it must follow the line above. + entry.State = hackathonStateFromEntry(entry) + + if h.Edges.Settings != nil { + entry.Settings = settingsEntryFromEnt(h.Edges.Settings) + } + entry.Members = make([]*ents.HackathonMember, 0, len(h.Edges.Participants)) for _, p := range h.Edges.Participants { role, err := s.enforcer.GetHackathonRole(p.Edges.User.KeycloakID, id.String()) @@ -210,10 +364,41 @@ func (s *HackathonService) Join( return nil, status.Error(codes.Internal, "couldn't query database") } - if h.EndsAt.Before(time.Now()) { + // A private hackathon is joinable only with a live invitation link. + // Without this, privacy was discovery-only: anyone who learned the UUID + // could join outright. + // + // Checked BEFORE any state check below: answering "already finished" to a + // caller holding nothing but a guessed UUID would confirm both that the + // private event exists and what state it is in. + if h.Visibility != enthackathon.VisibilityPublic { + if err := s.requireLiveInvite(ctx, id, req.GetInviteToken()); err != nil { + return nil, err + } + } + + // EndsAt is Optional().Nillable(): an undated hackathon has no end, so it + // never counts as finished — same rule computeHackathonStatus applies when + // it only reports FINISHED for a non-nil end date. + if h.EndsAt != nil && h.EndsAt.Before(time.Now()) { return nil, status.Error(codes.FailedPrecondition, "hackathon is already finished") } + // MERGE NOTE (sketch): #87 (Register capability) and #78 + // (settings.registrations_enabled) both gate Join, with contradictory + // defaults — their test suites cannot both pass with both gates active. + // The capability governs here; settings remain editable data (see + // EditSettings) until the team consolidates on one mechanism. + if err := requireCapability( + ctx, s.dbClient, s.enforcer, id, capability.Register, + ); err != nil { + return nil, err + } + + if err := requireWindowOpen(ctx, s.dbClient, id, windowRegistration, time.Now()); err != nil { + return nil, err + } + // First ensure user exists and get their entity ID user, err := s.dbClient.User.Query().Where(entuser.KeycloakIDEQ(uid)).Only(ctx) if err != nil { @@ -225,34 +410,132 @@ func (s *HackathonService) Join( return nil, status.Error(codes.Internal, "couldn't query database") } - // Check if user already exists in hackathon (approved or waitlisted) - _, err = s.dbClient.Participant.Query().Where( - entparticipant.HackathonIDEQ(id), + // The seat decision and the row that takes it are one unit: everything from + // the duplicate check through the insert runs under capacityMu, or two + // joins racing for the last free place both count it free and the event + // oversells. See capacity.go for the rule this section enforces. + s.capacityMu.Lock() + waitlisted, queuePos, err := s.joinRoster(ctx, h, user) + s.capacityMu.Unlock() + if err != nil { + return nil, err + } + + // Everyone on the roster holds the Member role; is_waiting carries the + // approved/waitlisted distinction and gates the sensitive paths (member + // view, voting). This lets waitlisted registrants propose projects and see + // the private hackathons they signed up for. + if _, err := s.enforcer.AddRole(user.KeycloakID, m.Member, h.ID.String()); err != nil { + slog.Error("add hackathon member on join", "err", err) + + return nil, status.Errorf(codes.Internal, "couldn't set hackathon member permission") + } + + return &msgs.JoinResponse{ + HackathonId: h.ID.String(), + Waitlisted: waitlisted, + QueuePosition: queuePos, + }, nil +} + +// joinRoster writes (or finds) the caller's participant row and reports where +// they stand: confirmed, or waitlisted at a 1-based queue position. +// +// MUST be called holding s.capacityMu — the confirmed/waiting counts it reads +// and the row it inserts are one atomic decision, and ApproveParticipant / +// RemoveParticipant move the same counts under the same lock. +func (s *HackathonService) joinRoster( + ctx context.Context, + h *ent.Hackathon, + user *ent.User, +) (waitlisted bool, queuePos int32, err error) { + // Already on the roster (approved or waitlisted): joining again is a no-op + // that reports the current state, so a double-click and a status refresh + // are the same request. + existing, err := s.dbClient.Participant.Query().Where( + entparticipant.HackathonIDEQ(h.ID), entparticipant.UserID(user.ID), ).Only(ctx) if err == nil { - // Already a participant - return success with existing hackathon ID - return &msgs.JoinResponse{HackathonId: h.ID.String()}, nil + if !existing.IsWaiting { + return false, 0, nil + } + pos, err := s.queuePositionOf(ctx, h.ID, existing) + if err != nil { + return false, 0, err + } + + return true, pos, nil } if !ent.IsNotFound(err) { slog.Error("check existing participant", "err", err) - return nil, status.Error(codes.Internal, "couldn't check participant status") + return false, 0, status.Error(codes.Internal, "couldn't check participant status") } - // User doesn't have a participant record - create new participant with is_waiting=true (pending approval) - _, err = s.dbClient.Participant.Create(). - SetHackathonID(id). - SetUserID(user.ID). - SetIsWaiting(true). - Save(ctx) + confirmed, err := s.dbClient.Participant.Query().Where( + entparticipant.HackathonIDEQ(h.ID), + entparticipant.IsWaitingEQ(false), + ).Count(ctx) + if err != nil { + slog.Error("count confirmed participants", "err", err) + + return false, 0, status.Error(codes.Internal, "couldn't count participants") + } + waiting, err := s.dbClient.Participant.Query().Where( + entparticipant.HackathonIDEQ(h.ID), + entparticipant.IsWaitingEQ(true), + ).Count(ctx) if err != nil { + slog.Error("count waitlisted participants", "err", err) + + return false, 0, status.Error(codes.Internal, "couldn't count participants") + } + + waitlisted = joinLandsWaitlisted(h.MaxParticipants, confirmed, waiting) + + if _, err := s.dbClient.Participant.Create(). + SetHackathonID(h.ID). + SetUserID(user.ID). + SetIsWaiting(waitlisted). + Save(ctx); err != nil { slog.Error("create participant", "err", err) - return nil, status.Errorf(codes.Internal, "couldn't join hackathon") + return false, 0, status.Errorf(codes.Internal, "couldn't join hackathon") + } + + if !waitlisted { + return false, 0, nil + } + + // Counted under the lock, so `waiting` is exactly the queue ahead of this + // row — no re-read needed. + //nolint:gosec // G115: a per-hackathon participant count, nowhere near int32 range + return true, int32(waiting) + 1, nil +} + +// queuePositionOf reports a waitlisted participant's 1-based place in the +// queue: rows that joined strictly earlier, plus one. Two rows created the +// same instant share a position, which costs a duplicate number in a corner +// case rather than an arbitrary tiebreak pretending to be an order. +func (s *HackathonService) queuePositionOf( + ctx context.Context, + hackathonID uuid.UUID, + p *ent.Participant, +) (int32, error) { + ahead, err := s.dbClient.Participant.Query().Where( + entparticipant.HackathonIDEQ(hackathonID), + entparticipant.IsWaitingEQ(true), + entparticipant.CreatedAtLT(p.CreatedAt), + ).Count(ctx) + if err != nil { + slog.Error("count queue ahead", "err", err) + + return 0, status.Error(codes.Internal, "couldn't count participants") } - return &msgs.JoinResponse{HackathonId: h.ID.String()}, nil + //nolint:gosec // G115: a per-hackathon waiting-list count, nowhere near int32 range + return int32(ahead) + 1, nil } func (s *HackathonService) ApproveParticipant( @@ -319,7 +602,12 @@ func (s *HackathonService) ApproveParticipant( return nil, status.Error(codes.Internal, "couldn't query database") } - // Update participant record to set is_waiting=false (approved) + // Update participant record to set is_waiting=false (approved). Under + // capacityMu because this raises the confirmed count Join decides on. + // Deliberately NO capacity refusal here: the organizer can see the room, + // and approving past the cap is their call — the participants page shows + // the overshoot so it is a decision, not an accident. + s.capacityMu.Lock() _, err = s.dbClient.Participant.Update(). Where( entparticipant.HackathonIDEQ(id), @@ -327,6 +615,7 @@ func (s *HackathonService) ApproveParticipant( ). SetIsWaiting(false). Save(ctx) + s.capacityMu.Unlock() if err != nil { slog.Error("update participant", "err", err) @@ -406,13 +695,18 @@ func (s *HackathonService) RemoveParticipant( return nil, status.Error(codes.Internal, "couldn't query database") } - // Delete the participant record + // Delete the participant record. Under capacityMu because removing a + // confirmed participant frees a place Join decides on. The freed place is + // NOT handed to the next waitlisted person automatically — see capacity.go + // for why promotion stays the organizer's move. + s.capacityMu.Lock() _, err = s.dbClient.Participant.Delete(). Where( entparticipant.HackathonIDEQ(id), entparticipant.UserID(user.ID), ). Exec(ctx) + s.capacityMu.Unlock() if err != nil { slog.Error("delete participant", "err", err) @@ -427,6 +721,189 @@ func (s *HackathonService) RemoveParticipant( return &msgs.RemoveParticipantResponse{}, nil } +// ownerTarget resolves the (hackathon, user) pair both owner RPCs operate on, +// after checking the caller may write to the hackathon. +// +// It insists the target is already a CONFIRMED participant. Ownership is a +// casbin fact here, and the member list is built from the participants table, +// so granting Owner to someone who never joined would create an owner nobody +// can see — absent from the roster while holding every permission on the event. +// Create guards the same invariant from the other end by inserting a +// participant row for the creator. +func (s *HackathonService) ownerTarget( + ctx context.Context, + hackathonID, userID string, + anonMsg string, +) (*ent.Hackathon, *ent.User, error) { + uid, _, err := m.RequireSubject(ctx) + if err != nil { + return nil, nil, err + } + if uid == m.AnonSubject { + return nil, nil, status.Error(codes.Unauthenticated, anonMsg) + } + + id, err := uuid.Parse(hackathonID) + if err != nil { + return nil, nil, status.Errorf(codes.InvalidArgument, "invalid hackathon_id: %v", err) + } + if err := s.enforcer.RequirePermission(ctx, id.String(), m.Hackathon, m.Write); err != nil { + return nil, nil, err + } + + targetID, err := uuid.Parse(userID) + if err != nil { + return nil, nil, status.Errorf(codes.InvalidArgument, "invalid user_id: %v", err) + } + + h, err := s.dbClient.Hackathon.Query().Where(enthackathon.IDEQ(id)). + WithParticipants(func(pq *ent.ParticipantQuery) { + pq.Where(entparticipant.UserIDEQ(targetID)).WithUser() + }).Only(ctx) + if err != nil { + if ent.IsNotFound(err) { + return nil, nil, status.Errorf(codes.NotFound, "hackathon %s not found", hackathonID) + } + slog.Error("query hackathon", "err", err) + + return nil, nil, status.Error(codes.Internal, "couldn't query database") + } + + if len(h.Edges.Participants) == 0 || h.Edges.Participants[0].Edges.User == nil { + return nil, nil, status.Errorf( + codes.NotFound, + "user %s is not a participant of this hackathon", + userID, + ) + } + p := h.Edges.Participants[0] + if p.IsWaiting { + return nil, nil, status.Error( + codes.FailedPrecondition, + "approve this person before making them an organizer", + ) + } + + return h, p.Edges.User, nil +} + +// AddOwner promotes a confirmed participant to co-organizer of one hackathon. +// +// Anyone who can write to the hackathon can do this, which means owners recruit +// their own co-organizers — the alternative is a global admin having to be in +// the loop for every event, which is the bottleneck this role exists to remove. +// +// Idempotent: casbin reports a duplicate grouping row as "not added" rather +// than an error, and re-promoting someone who is already an owner is a no-op +// worth succeeding at. +func (s *HackathonService) AddOwner( + ctx context.Context, + req *msgs.AddOwnerRequest, +) (*msgs.AddOwnerResponse, error) { + h, user, err := s.ownerTarget( + ctx, req.GetHackathonId(), req.GetUserId(), + "anonymous users cannot add owners", + ) + if err != nil { + return nil, err + } + + s.ownerMu.Lock() + defer s.ownerMu.Unlock() + + if _, err := s.enforcer.AddRole(user.KeycloakID, m.Owner, h.ID.String()); err != nil { + slog.Error("add hackathon owner role", "err", err) + + return nil, status.Error(codes.Internal, "couldn't grant hackathon owner permission") + } + + return &msgs.AddOwnerResponse{}, nil +} + +// RemoveOwner demotes a co-organizer back to ordinary member. +// +// Two things it refuses. The last owner, because an event whose every organizer +// has been demoted cannot be edited by anyone short of a global admin, and +// nothing in the UI would explain why. And a caller demoting themselves, for +// the same reason UserService.RemoveRole refuses it: the permission you are +// giving up is the one that would let you undo it. +// +// The Owner row is removed and a Member row put in its place. Without that the +// person's role resolves to UNSPECIFIED — still a participant, but rendered +// with no role at all, which reads as a corrupted record rather than a demotion. +func (s *HackathonService) RemoveOwner( + ctx context.Context, + req *msgs.RemoveOwnerRequest, +) (*msgs.RemoveOwnerResponse, error) { + uid, _, err := m.RequireSubject(ctx) + if err != nil { + return nil, err + } + + h, user, err := s.ownerTarget( + ctx, req.GetHackathonId(), req.GetUserId(), + "anonymous users cannot remove owners", + ) + if err != nil { + return nil, err + } + + // The owners read below and the removal at the bottom are one decision: + // without the lock, two owners demoting each other concurrently each + // counted two owners, each passed the last-organizer guard, and the event + // was left with none. + s.ownerMu.Lock() + defer s.ownerMu.Unlock() + + owners, err := s.enforcer.HackathonOwners(h.ID.String()) + if err != nil { + slog.Error("list hackathon owners", "err", err) + + return nil, status.Error(codes.Internal, "couldn't read hackathon owners") + } + + isOwner := false + for _, o := range owners { + if o == user.KeycloakID { + isOwner = true + + break + } + } + if !isOwner { + return nil, status.Errorf( + codes.NotFound, + "user %s is not an owner of this hackathon", + req.GetUserId(), + ) + } + if len(owners) == 1 { + return nil, status.Error( + codes.FailedPrecondition, + "this is the last organizer — promote someone else first", + ) + } + if uid == user.KeycloakID { + return nil, status.Error( + codes.PermissionDenied, + "cannot remove your own organizer role", + ) + } + + if _, err := s.enforcer.RemoveRole(user.KeycloakID, m.Owner, h.ID.String()); err != nil { + slog.Error("remove hackathon owner role", "err", err) + + return nil, status.Error(codes.Internal, "couldn't remove hackathon owner permission") + } + if _, err := s.enforcer.AddRole(user.KeycloakID, m.Member, h.ID.String()); err != nil { + slog.Error("restore hackathon member role", "err", err) + + return nil, status.Error(codes.Internal, "couldn't restore hackathon member permission") + } + + return &msgs.RemoveOwnerResponse{}, nil +} + func (s *HackathonService) Edit( ctx context.Context, req *msgs.EditRequest, @@ -507,6 +984,15 @@ func (s *HackathonService) Edit( if req.Logo != nil { update = update.SetLogo(req.GetLogo()) } + if req.MaxParticipants != nil { + if req.GetMaxParticipants() > 0 { + update = update.SetMaxParticipants(req.GetMaxParticipants()) + } else { + // 0 clears back to unlimited, stored as NULL — same one spelling + // Create uses. + update = update.ClearMaxParticipants() + } + } _, err = update.Save(ctx) if err != nil { @@ -533,74 +1019,787 @@ func (s *HackathonService) Edit( return &msgs.EditResponse{Hackathon: entry}, nil } -func (s *HackathonService) List( +// EditCapability opens or closes one member-facing action. +// +// Only the flag is mutable: the capability itself identifies the row, and rows +// are pre-created with the hackathon, so this is deliberately an update and +// never an upsert. +func (s *HackathonService) EditCapability( ctx context.Context, - req *msgs.ListRequest, -) (*msgs.ListResponse, error) { - q := s.dbClient.Hackathon.Query() - if vf := req.GetVisibilityFilter(); vf != ents.Visibility_VISIBILITY_UNSPECIFIED { - entV, ok := VisibilityToEnt(vf) - if !ok { - return nil, status.Errorf(codes.InvalidArgument, "unknown visibility: %v", vf) - } - q = q.Where(enthackathon.VisibilityEQ(entV)) + req *msgs.EditCapabilityRequest, +) (*msgs.EditCapabilityResponse, error) { + uid, _, err := m.RequireSubject(ctx) + if err != nil { + return nil, err } - if ownerID := req.GetOwnerId(); ownerID != "" { - uid, err := uuid.Parse(ownerID) - if err != nil { - return nil, status.Errorf(codes.InvalidArgument, "invalid owner_id: %v", ownerID) + + id, err := uuid.Parse(req.GetHackathonId()) + if err != nil { + return nil, status.Errorf(codes.InvalidArgument, "invalid hackathon_id: %v", err) + } + + if err := s.enforcer.RequirePermission(ctx, id.String(), m.Hackathon, m.Write); err != nil { + return nil, err + } + + c, ok := CapabilityFromProto(req.GetCapability()) + if !ok { + return nil, status.Errorf( + codes.InvalidArgument, + "unknown capability: %v", + req.GetCapability(), + ) + } + entCapability, ok := capabilityToEnt(c) + if !ok { + return nil, status.Errorf( + codes.InvalidArgument, + "unknown capability: %v", + req.GetCapability(), + ) + } + + user, err := s.dbClient.User.Query().Where(entuser.KeycloakIDEQ(uid)).Only(ctx) + if err != nil { + if ent.IsNotFound(err) { + return nil, status.Errorf(codes.NotFound, "user does not exist: %s", uid) } - q = q.Where(enthackathon.HasCreatorWith(entuser.IDEQ(uid))) + slog.Error("query user", "err", err) + + return nil, status.Error(codes.Internal, "couldn't query database") } - var participantUID *uuid.UUID - if participantID := req.GetParticipantId(); participantID != "" { - uid, err := uuid.Parse(participantID) - if err != nil { + // Fetch first so a hackathon with no row for this capability reports + // NotFound rather than silently updating zero rows. + row, err := s.dbClient.Capability.Query(). + Where( + entcapability.HasHackathonWith(enthackathon.IDEQ(id)), + entcapability.CapabilityEQ(entCapability), + ). + Only(ctx) + if err != nil { + if ent.IsNotFound(err) { return nil, status.Errorf( - codes.InvalidArgument, - "invalid participant_id: %v", - participantID, + codes.NotFound, + "hackathon %s has no %s capability", + req.GetHackathonId(), c, ) } - participantUID = &uid - q = q.Where(enthackathon.HasParticipantsWith(entparticipant.UserIDEQ(uid))). - WithParticipants(func(pq *ent.ParticipantQuery) { - pq.Where(entparticipant.UserIDEQ(uid)).WithUser() - }) - } - hs, err := q.Order(ent.Asc(enthackathon.FieldCreatedAt)).All(ctx) - if err != nil { - slog.Error("query hackathon", "err", err) + slog.Error("query capability", "err", err) return nil, status.Error(codes.Internal, "couldn't query database") } - now := time.Now() - wanted := make(map[ents.HackathonStatus]struct{}, len(req.GetStatusFilter())) - for _, sf := range req.GetStatusFilter() { - wanted[sf] = struct{}{} - } + update := row.Update().SetModifier(user) - entries := make([]*ents.Hackathon, 0, len(hs)) - for _, h := range hs { - if h.Visibility == enthackathon.VisibilityPrivate { - ok, err := s.enforcer.Enforce(ctx, h.ID.String(), m.Hackathon, m.Read) - if err != nil { - slog.Error("enforce list hackathon", "err", err) + if req.Enabled != nil { + update = update.SetEnabled(req.GetEnabled()) + } - return nil, status.Error(codes.Internal, "authorization error") - } - if !ok { - continue - } + // Empty string unlinks, a UUID links, unset leaves it alone. Linking never + // opens anything — only `enabled` does — so these are safe to set at any time. + if req.OpenInPhaseId != nil { + if err := applyPhaseLink( + ctx, s.dbClient, id, req.GetOpenInPhaseId(), + update.ClearOpenInPhase, update.SetOpenInPhaseID, + ); err != nil { + return nil, err } - e := hackathonEntryFromEnt(h, now) - if len(wanted) > 0 { - if _, ok := wanted[e.GetStatus()]; !ok { - continue - } + } + if req.ClosedInPhaseId != nil { + if err := applyPhaseLink( + ctx, s.dbClient, id, req.GetClosedInPhaseId(), + update.ClearClosedInPhase, update.SetClosedInPhaseID, + ); err != nil { + return nil, err } + } + + if _, err := update.Save(ctx); err != nil { + slog.Error("update capability", "err", err) + + return nil, status.Error(codes.Internal, "couldn't update capability") + } + + // Re-query: Save() returns no edges, and the response reports the schedule. + updated, err := s.dbClient.Capability.Query(). + Where(entcapability.IDEQ(row.ID)). + WithModifier(). + WithOpenInPhase(). + WithClosedInPhase(). + Only(ctx) + if err != nil { + slog.Error("re-query capability", "err", err) + + return nil, status.Error(codes.Internal, "couldn't query updated capability") + } + + order, err := phaseOrder(ctx, s.dbClient, id) + if err != nil { + return nil, err + } + hack, err := s.dbClient.Hackathon.Get(ctx, id) + if err != nil { + slog.Error("query hackathon for capability clock", "err", err) + + return nil, status.Error(codes.Internal, "couldn't query database") + } + + return &msgs.EditCapabilityResponse{ + Capability: capabilityStatusFromEnt( + updated, + newCapabilityClock(order, hack.CurrentPhaseID), + time.Now(), + ), + }, nil +} + +// SetCapabilities toggles several capabilities in one call. +// +// EditCapability is the precise instrument — one capability, optionally +// relinking its phases. This is the blunt one an organiser reaches for when +// they flip three switches on a settings screen: one intent, one request, one +// transaction. Sending three EditCapability calls instead leaves the event +// half-configured when the second fails, and the UI holding the pieces. +// +// It deliberately does NOT touch phase links. `enabled` is the authoritative +// gate; the schedule is a separate decision made on the capability itself, and +// a batch toggle that silently unlinked phases would be a trap. +func (s *HackathonService) SetCapabilities( + ctx context.Context, + req *msgs.SetCapabilitiesRequest, +) (*msgs.SetCapabilitiesResponse, error) { + uid, _, err := m.RequireUser(ctx) + if err != nil { + return nil, err + } + + id, err := uuid.Parse(req.GetHackathonId()) + if err != nil { + return nil, status.Errorf(codes.InvalidArgument, "invalid hackathon_id: %v", err) + } + if err := s.enforcer.RequirePermission(ctx, id.String(), m.Hackathon, m.Write); err != nil { + return nil, err + } + + user, err := s.dbClient.User.Query().Where(entuser.KeycloakIDEQ(uid)).Only(ctx) + if err != nil { + if ent.IsNotFound(err) { + return nil, status.Errorf(codes.NotFound, "user does not exist: %s", uid) + } + slog.Error("query user", "err", err) + + return nil, status.Error(codes.Internal, "couldn't query database") + } + + // Resolve every requested capability BEFORE writing anything: a batch with + // one unknown name must change nothing, not the prefix before the typo. + wanted := make(map[entcapability.Capability]bool, len(req.GetCapabilities())) + for _, t := range req.GetCapabilities() { + c, ok := CapabilityFromProto(t.GetCapability()) + if !ok { + return nil, status.Errorf( + codes.InvalidArgument, + "unknown capability: %v", + t.GetCapability(), + ) + } + entCapability, ok := capabilityToEnt(c) + if !ok { + return nil, status.Errorf( + codes.InvalidArgument, + "unknown capability: %v", + t.GetCapability(), + ) + } + wanted[entCapability] = t.GetEnabled() + } + + // The hackathon itself, not its capabilities: the batch below CREATES a row + // for anything ungoverned, and a create against an id that names nothing is + // a foreign-key error rather than an answer. Asked once, here, so a caller + // naming a hackathon that does not exist still gets NotFound about the + // HACKATHON — which is the true statement — instead of NotFound about a + // capability, which used to be the same reply and said the wrong thing. + // + // After RequirePermission on purpose: a stranger must not learn which + // hackathon ids exist from the difference between PermissionDenied and + // NotFound. + exists, err := s.dbClient.Hackathon.Query().Where(enthackathon.IDEQ(id)).Exist(ctx) + if err != nil { + slog.Error("query hackathon", "err", err) + + return nil, status.Error(codes.Internal, "couldn't query database") + } + if !exists { + return nil, status.Errorf(codes.NotFound, "hackathon %s not found", id) + } + + rows, err := s.dbClient.Capability.Query(). + Where(entcapability.HasHackathonWith(enthackathon.IDEQ(id))). + All(ctx) + if err != nil { + slog.Error("query capabilities", "err", err) + + return nil, status.Error(codes.Internal, "couldn't query database") + } + + present := make(map[entcapability.Capability]*ent.Capability, len(rows)) + for _, row := range rows { + present[row.Capability] = row + } + + txn, err := s.dbClient.Tx(ctx) + if err != nil { + slog.Error("start transaction", "err", err) + + return nil, status.Error(codes.Internal, "couldn't start transaction") + } + rollback := func(cause error) { + if rbErr := txn.Rollback(); rbErr != nil { + slog.Error("rollback set capabilities", "err", cause, "rollback", rbErr) + } + } + + for c, enabled := range wanted { + row, governed := present[c] + + // An ungoverned capability is CREATED rather than refused. + // + // This used to answer NotFound for the whole batch, and the organiser's + // panel posts all six switches every save — so one absent row made the + // entire capability screen unusable, with a 404 as the only explanation + // and no RPC anywhere that could create the missing row. The panel had + // grown a paragraph of copy warning about it, which is a product + // explaining its own data gap to the person least able to close it. + // + // Of the three possible answers, creating is the only one that is both + // safe and true to what the request says. SKIPPING is the dangerous one: + // an ungoverned capability is ALLOWED (`capability.State` reports + // UNGOVERNED and `Allowed` returns true for it), so quietly dropping a + // row the caller asked to set to `false` would report a save that + // changed nothing while participants kept the permission — a silent + // no-op on a gate. REFUSING with the capability named is honest but + // still leaves the panel dead, because there is no way to act on the + // name. And the schema already calls a full set the invariant — "one row + // per capability per hackathon, pre-created on hackathon creation" — so + // a missing row is a gap (an older event, a partial restore, a + // capability added to the enum after the event was made), never a + // decision anyone took. `SetCapabilities` takes a whole list rather than + // a delta, which means "these are the values afterwards"; creating what + // is missing is what makes that sentence true. + if !governed { + if _, err := txn.Capability.Create(). + SetCapability(c). + SetEnabled(enabled). + SetHackathonID(id). + SetModifier(user). + Save(ctx); err != nil { + rollback(err) + // The unique index is `(capability, hackathon)`, so this is a + // concurrent writer that governed it first — a real outcome, and + // a different one from a broken request. Retrying takes the + // UpdateOne branch. + if ent.IsConstraintError(err) { + return nil, status.Errorf( + codes.Aborted, + "another change to %s landed first; retry", + c, + ) + } + slog.Error("create capability", "err", err) + + return nil, status.Error(codes.Internal, "couldn't update capabilities") + } + + continue + } + + // Already correct: skipping the write keeps modified_at and the modifier + // meaningful, so "who last changed this" stays a real answer. + if row.Enabled == enabled { + continue + } + if _, err := txn.Capability.UpdateOne(row). + SetEnabled(enabled). + SetModifier(user). + Save(ctx); err != nil { + rollback(err) + slog.Error("update capability", "err", err) + + return nil, status.Error(codes.Internal, "couldn't update capabilities") + } + } + + if err := txn.Commit(); err != nil { + slog.Error("commit set capabilities", "err", err) + + return nil, status.Error(codes.Internal, "couldn't update capabilities") + } + + statuses := s.capabilityStatuses(ctx, id) + + currentPhase := "" + if hack, err := s.dbClient.Hackathon.Get(ctx, id); err == nil && hack.CurrentPhaseID != nil { + currentPhase = hack.CurrentPhaseID.String() + } + + return &msgs.SetCapabilitiesResponse{ + Capabilities: statuses, + // The same answer in main's flat shape, projected from `statuses` rather + // than from the booleans that came in: a capability the phase window + // decided did not take the value the organiser sent, and echoing the + // request would hide that. See hackathon_state.go. + State: s.hackathonStateFacade(ctx, id, statuses, currentPhase), + }, nil +} + +// capabilityStatuses reports every capability of a hackathon the way Get does. +// Best-effort: the write already succeeded, so a read failure here costs the +// caller a refetch rather than an error on work that landed. +func (s *HackathonService) capabilityStatuses( + ctx context.Context, + id uuid.UUID, +) []*ents.CapabilityStatus { + rows, err := s.dbClient.Capability.Query(). + Where(entcapability.HasHackathonWith(enthackathon.IDEQ(id))). + WithModifier(). + WithOpenInPhase(). + WithClosedInPhase(). + All(ctx) + if err != nil { + slog.Error("re-query capabilities", "err", err) + + return nil + } + + order, err := phaseOrder(ctx, s.dbClient, id) + if err != nil { + slog.Error("phase order for capability clock", "err", err) + + return nil + } + hack, err := s.dbClient.Hackathon.Get(ctx, id) + if err != nil { + slog.Error("query hackathon for capability clock", "err", err) + + return nil + } + + // `capabilityStatusesFromEnt`, the same mapper Get uses, rather than one + // status per stored row: it fills the vocabulary, reporting UNGOVERNED for a + // capability with no row. Built from the rows alone this reply was SHORT + // wherever Get's was six long — one handler giving two answers to "what are + // this hackathon's capabilities", and the shorter one is the reply a client + // gets immediately after saving. + return capabilityStatusesFromEnt( + rows, + newCapabilityClock(order, hack.CurrentPhaseID), + time.Now(), + ) +} + +// AdvancePhase declares which phase a hackathon is now in, and switches its +// scheduled capabilities to match. +// +// One control instead of six checkboxes, because organizers reach for this at +// the busiest moment of an event. `enabled` stays the authoritative gate — this +// writes those flags rather than introducing a second source of truth, so every +// enforcement site keeps reading a single boolean. +// +// Capabilities with no opening phase are left exactly as they are. That is what +// keeps voting, which opens abruptly and by hand, immune to advancing. + +func (s *HackathonService) AdvancePhase( + ctx context.Context, + req *msgs.AdvancePhaseRequest, +) (*msgs.AdvancePhaseResponse, error) { + uid, _, err := m.RequireSubject(ctx) + if err != nil { + return nil, err + } + + id, err := uuid.Parse(req.GetHackathonId()) + if err != nil { + return nil, status.Errorf(codes.InvalidArgument, "invalid hackathon_id: %v", err) + } + // Empty means "clear the current phase" — see the proto. Handled before the + // UUID parse, which is where this used to fail: the Clear button sends no + // phase_id, so every press answered InvalidArgument. + clearing := req.GetPhaseId() == "" + + var phaseID uuid.UUID + if !clearing { + phaseID, err = uuid.Parse(req.GetPhaseId()) + if err != nil { + return nil, status.Errorf(codes.InvalidArgument, "invalid phase_id: %v", err) + } + } + + if err := s.enforcer.RequirePermission(ctx, id.String(), m.Hackathon, m.Write); err != nil { + return nil, err + } + + if !clearing { + if err := phaseInHackathon(ctx, s.dbClient, id, phaseID); err != nil { + return nil, err + } + } + + user, err := s.dbClient.User.Query().Where(entuser.KeycloakIDEQ(uid)).Only(ctx) + if err != nil { + if ent.IsNotFound(err) { + return nil, status.Errorf(codes.NotFound, "user does not exist: %s", uid) + } + slog.Error("query user", "err", err) + + return nil, status.Error(codes.Internal, "couldn't query database") + } + + order, err := phaseOrder(ctx, s.dbClient, id) + if err != nil { + return nil, err + } + var target int + if !clearing { + pos, ok := order[phaseID] + if !ok { + return nil, status.Errorf(codes.NotFound, "phase %s not found", req.GetPhaseId()) + } + target = pos + } + + rows, err := s.dbClient.Capability.Query(). + Where(entcapability.HasHackathonWith(enthackathon.IDEQ(id))). + WithModifier(). + WithOpenInPhase(). + WithClosedInPhase(). + All(ctx) + if err != nil { + slog.Error("query capabilities", "err", err) + + return nil, status.Error(codes.Internal, "couldn't query database") + } + + // Nothing to apply when clearing: advancing switches on the capabilities + // scheduled for the target phase, and with no target there is no schedule. + // Switching things off because someone cleared a label would be the opposite + // of what they asked for. + var desired map[capability.Capability]bool + if !clearing { + desired = capability.Advance(advanceRows(rows, order), target) + } + + txn, err := s.dbClient.Tx(ctx) + if err != nil { + slog.Error("start transaction", "err", err) + + return nil, status.Error(codes.Internal, "couldn't start transaction") + } + rollback := func(cause error) { + if rbErr := txn.Rollback(); rbErr != nil { + slog.Error("rollback advance phase", "err", cause, "rollback", rbErr) + } + } + + for _, row := range rows { + want, scheduled := desired[capability.Capability(row.Capability)] + // Unscheduled, or already correct. Skipping the write keeps modified_at + // and the modifier meaningful, and makes re-advancing a true no-op. + if !scheduled || row.Enabled == want { + continue + } + if _, err := txn.Capability.UpdateOne(row). + SetEnabled(want). + SetModifier(user). + Save(ctx); err != nil { + rollback(err) + slog.Error("update capability during advance", "err", err) + + return nil, status.Error(codes.Internal, "couldn't update capabilities") + } + } + + // ent's SetNillableX(nil) means "leave unchanged", NOT "set null" — the + // pointer stayed put and the clear looked like it worked because the RPC + // answered {}. Clearing has to say so explicitly. + upd := txn.Hackathon.UpdateOneID(id) + if clearing { + upd = upd.ClearCurrentPhase() + } else { + upd = upd.SetCurrentPhaseID(phaseID) + } + if _, err := upd. + SetModifier(user). + Save(ctx); err != nil { + rollback(err) + slog.Error("set current phase", "err", err) + + return nil, status.Error(codes.Internal, "couldn't set current phase") + } + + if err := txn.Commit(); err != nil { + slog.Error("commit advance phase", "err", err) + + return nil, status.Error(codes.Internal, "couldn't commit transaction") + } + + updated, err := s.dbClient.Capability.Query(). + Where(entcapability.HasHackathonWith(enthackathon.IDEQ(id))). + WithModifier(). + WithOpenInPhase(). + WithClosedInPhase(). + All(ctx) + if err != nil { + slog.Error("re-query capabilities", "err", err) + + return nil, status.Error(codes.Internal, "couldn't query updated capabilities") + } + + // Clock built from the phase just declared, so the response reports states + // consistent with the move rather than with the old dates. + clock := newCapabilityClock(order, &phaseID) + + // Empty when clearing, not the zero UUID: a client reading + // "00000000-0000-0000-0000-000000000000" sees a current phase that points at + // nothing, which is worse than seeing none. + currentPhase := "" + if !clearing { + currentPhase = phaseID.String() + } + + return &msgs.AdvancePhaseResponse{ + CurrentPhaseId: currentPhase, + Capabilities: capabilityStatusesFromEnt(updated, clock, time.Now()), + }, nil +} + +func (s *HackathonService) EditSettings( + ctx context.Context, + req *msgs.EditSettingsRequest, +) (*msgs.EditSettingsResponse, error) { + uid, _, err := m.RequireSubject(ctx) + if err != nil { + return nil, err + } + + id, err := uuid.Parse(req.GetHackathonId()) + if err != nil { + return nil, status.Errorf(codes.InvalidArgument, "invalid hackathon_id: %v", err) + } + + // Check Write permission on hackathon + if err := s.enforcer.RequirePermission(ctx, id.String(), m.Hackathon, m.Write); err != nil { + return nil, err + } + + // Ensure user exists + user, err := s.dbClient.User.Query().Where(entuser.KeycloakIDEQ(uid)).Only(ctx) + if err != nil { + if ent.IsNotFound(err) { + return nil, status.Errorf(codes.NotFound, "user does not exist: %s", uid) + } + slog.Error("query user", "err", err) + return nil, status.Error(codes.Internal, "couldn't query database") + } + + // Build update query + update := s.dbClient.HackathonSettings.Update(). + Where(enthackathonsettings.HasHackathonWith(enthackathon.IDEQ(id))). + SetModifier(user) + + if req.RegistrationsEnabled != nil { + update = update.SetRegistrationsEnabled(req.GetRegistrationsEnabled()) + } + if req.VotingEnabled != nil { + update = update.SetVotingEnabled(req.GetVotingEnabled()) + } + + _, err = update.Save(ctx) + if err != nil { + if ent.IsNotFound(err) { + return nil, status.Errorf(codes.NotFound, "hackathon settings not found") + } + slog.Error("update hackathon settings", "err", err) + return nil, status.Errorf(codes.Internal, "couldn't update hackathon settings") + } + + settings, err := s.dbClient.HackathonSettings.Query(). + Where( + enthackathonsettings.HasHackathonWith(enthackathon.IDEQ(id)), + ).Only(ctx) + if err != nil { + if ent.IsNotFound(err) { + return nil, status.Errorf(codes.NotFound, "hackathon settings not found") + } + slog.Error("query updated settings", "err", err) + return nil, status.Error(codes.Internal, "couldn't query updated settings") + } + + return &msgs.EditSettingsResponse{ + Settings: settingsEntryFromEnt(settings), + }, nil +} + +// resolveListViewerParticipant resolves the CALLER's own participant id for +// `viewer_membership`, when List's participant_id filter did not already +// resolve one for us. Returns q eager-loading that participant's row, and nil +// with no error for an anonymous caller or one never registered here — both +// are real states, not failures. Split out of List because the +// authenticate-then-query-then-eager-load sequence, with its two intentional +// non-error outcomes, was most of that function's complexity budget on its +// own. +func (s *HackathonService) resolveListViewerParticipant( + ctx context.Context, + q *ent.HackathonQuery, +) (*ent.HackathonQuery, *uuid.UUID, error) { + uid, _, err := m.RequireSubject(ctx) + //nolint:nilerr // RequireSubject's only failure is "no claims" (anonymous caller), + // same as an explicit AnonSubject — List serves anonymous callers, so this is + // "no viewer to resolve", not an error to propagate. + if err != nil || uid == m.AnonSubject { + return q, nil, nil + } + + viewer, err := s.dbClient.User.Query(). + Where(entuser.KeycloakIDEQ(uid)). + Only(ctx) + + switch { + case err == nil: + q = q.WithParticipants(func(pq *ent.ParticipantQuery) { + pq.Where(entparticipant.UserIDEQ(viewer.ID)).WithUser() + }) + + return q, &viewer.ID, nil + case ent.IsNotFound(err): + // Authenticated in Keycloak but never registered here — a real state + // during the first request of a new account, and simply means no + // membership anywhere. + return q, nil, nil + default: + slog.Error("query viewer for viewer_membership", "err", err) + + return nil, nil, status.Error(codes.Internal, "couldn't query database") + } +} + +func (s *HackathonService) List( + ctx context.Context, + req *msgs.ListRequest, +) (*msgs.ListResponse, error) { + q := s.dbClient.Hackathon.Query() + if vf := req.GetVisibilityFilter(); vf != ents.Visibility_VISIBILITY_UNSPECIFIED { + entV, ok := VisibilityToEnt(vf) + if !ok { + return nil, status.Errorf(codes.InvalidArgument, "unknown visibility: %v", vf) + } + q = q.Where(enthackathon.VisibilityEQ(entV)) + } + if ownerID := req.GetOwnerId(); ownerID != "" { + uid, err := uuid.Parse(ownerID) + if err != nil { + return nil, status.Errorf(codes.InvalidArgument, "invalid owner_id: %v", ownerID) + } + q = q.Where(enthackathon.HasCreatorWith(entuser.IDEQ(uid))) + } + + var participantUID *uuid.UUID + if participantID := req.GetParticipantId(); participantID != "" { + uid, err := uuid.Parse(participantID) + if err != nil { + return nil, status.Errorf( + codes.InvalidArgument, + "invalid participant_id: %v", + participantID, + ) + } + participantUID = &uid + q = q.Where(enthackathon.HasParticipantsWith(entparticipant.UserIDEQ(uid))). + WithParticipants(func(pq *ent.ParticipantQuery) { + pq.Where(entparticipant.UserIDEQ(uid)).WithUser() + }) + } + + // `viewer_membership` means the CALLER's relationship to each hackathon, so + // it is resolved for any authenticated caller — not only when participant_id + // happens to be passed, which is a FILTER and narrows the list to their own + // events. Without this the public event page (built from an unfiltered List, + // because Get is the member view) could never tell a member from a stranger, + // and offered "Join" to people already in. + // + // One extra lookup for the caller and one eager load, both skipped for + // anonymous callers and when participant_id already did the work. + if participantUID == nil { + var err error + q, participantUID, err = s.resolveListViewerParticipant(ctx, q) + if err != nil { + return nil, err + } + } + // Capabilities and phases, so a list can gate its own buttons instead of + // firing a mutation to discover it is closed. + // + // Phases come too, not just the linked ones: resolving COMING for a hackathon + // an organizer has advanced compares phase *positions*, which needs the whole + // ordering. Without them a list would resolve COMING from dates while the + // detail page resolved it from position, and the two would disagree. + // + // Four extra queries regardless of how many hackathons come back, since ent + // batches each eager load. + // + // Forms come along for the branding map: the public event page is built + // from List, not Get, so without this an event's own colours would never + // reach the one page visitors actually see. + q = q. + WithPhases(). + WithForms(). + WithCapabilities(func(cq *ent.CapabilityQuery) { + cq.WithModifier().WithOpenInPhase().WithClosedInPhase() + }) + + hs, err := q.Order(ent.Asc(enthackathon.FieldCreatedAt)).All(ctx) + if err != nil { + slog.Error("query hackathon", "err", err) + + return nil, status.Error(codes.Internal, "couldn't query database") + } + + now := time.Now() + wanted := make(map[ents.HackathonStatus]struct{}, len(req.GetStatusFilter())) + for _, sf := range req.GetStatusFilter() { + wanted[sf] = struct{}{} + } + + entries := make([]*ents.Hackathon, 0, len(hs)) + for _, h := range hs { + if h.Visibility == enthackathon.VisibilityPrivate { + ok, err := s.enforcer.Enforce(ctx, h.ID.String(), m.Hackathon, m.Read) + if err != nil { + slog.Error("enforce list hackathon", "err", err) + + return nil, status.Error(codes.Internal, "authorization error") + } + if !ok { + continue + } + } + e := hackathonEntryFromEnt(h, now) + if len(wanted) > 0 { + if _, ok := wanted[e.GetStatus()]; !ok { + continue + } + } + // Resolved after the status filter so skipped hackathons cost nothing. + // Same clock as Get builds, which is what keeps the two agreeing. + e.Capabilities = capabilityStatusesFromEnt( + h.Edges.Capabilities, + newCapabilityClock(phaseOrderFrom(h.Edges.Phases), h.CurrentPhaseID), + now, + ) + // Same projection Get applies, from the same statuses, so a list and a + // detail page cannot report different booleans for the same event. + e.State = hackathonStateFromEntry(e) if participantUID != nil && len(h.Edges.Participants) > 0 { p := h.Edges.Participants[0] role, err := s.enforcer.GetHackathonRole(p.Edges.User.KeycloakID, h.ID.String()) @@ -621,3 +1820,453 @@ func (s *HackathonService) List( return &msgs.ListResponse{Hackathons: entries}, nil } + +// resolveRegistrationTarget returns who the form answers belong to: the +// caller themselves, or — when onBehalfOf is set — another registrant an +// organizer is submitting a paper form for at check-in. Split out of +// SubmitRegistrationForm to keep the permission check, id parsing and lookup +// for that one optional case from nesting inside the main flow. +func (s *HackathonService) resolveRegistrationTarget( + ctx context.Context, + hackathonID uuid.UUID, + caller *ent.User, + onBehalfOf *string, +) (*ent.User, error) { + if onBehalfOf == nil { + // The self path still needs a gate. Without one, any authenticated caller + // could file a registration response into ANY hackathon — a private one + // they were never invited to included — and read its form schema off the + // validation errors below (which name missing and unknown fields). + // Registration follows Join, which writes the Participant row and, for a + // private event, requires an invite; a participant row is the proof this + // caller belongs in this form. Waitlisted participants pass — they are + // exactly who still needs to submit or correct their answers. + isParticipant, err := s.dbClient.Participant.Query(). + Where( + entparticipant.HasUserWith(entuser.IDEQ(caller.ID)), + entparticipant.HasHackathonWith(enthackathon.IDEQ(hackathonID)), + ). + Exist(ctx) + if err != nil { + slog.Error("query participant for registration", "err", err) + + return nil, status.Error(codes.Internal, "couldn't query database") + } + if !isParticipant { + return nil, status.Error(codes.PermissionDenied, + "join this hackathon before submitting its registration form") + } + + return caller, nil + } + if err := s.enforcer.RequirePermission(ctx, hackathonID.String(), m.Hackathon, m.Write); err != nil { + return nil, err + } + targetID, err := uuid.Parse(*onBehalfOf) + if err != nil { + return nil, status.Errorf(codes.InvalidArgument, "invalid on_behalf_of: %v", err) + } + target, err := s.dbClient.User.Query().Where(entuser.IDEQ(targetID)).Only(ctx) + if err != nil { + if ent.IsNotFound(err) { + return nil, status.Errorf(codes.NotFound, "user %s not found", targetID) + } + slog.Error("query on_behalf_of user", "err", err) + + return nil, status.Error(codes.Internal, "couldn't query database") + } + + return target, nil +} + +// validateRegistrationForm checks responses and consents against the +// organizer-defined schema: unknown keys, missing required fields, unknown +// consents and unticked required consents are all InvalidArgument. Split out +// of SubmitRegistrationForm because the four validation loops are a coherent +// step of their own and were most of that function's complexity budget. +func validateRegistrationForm( + forms *ent.HackathonForms, + responses map[string]any, + consents map[string]bool, +) error { + fieldByKey := make(map[string]map[string]any, len(forms.RegistrationFields)) + for _, f := range forms.RegistrationFields { + if k, ok := f["key"].(string); ok { + fieldByKey[k] = f + } + } + consentByKey := make(map[string]map[string]any, len(forms.RegistrationConsents)) + for _, c := range forms.RegistrationConsents { + if k, ok := c["key"].(string); ok { + consentByKey[k] = c + } + } + + for k := range responses { + if _, ok := fieldByKey[k]; !ok { + return status.Errorf(codes.InvalidArgument, "unknown field %q", k) + } + } + for k, f := range fieldByKey { + if required, _ := f["required"].(bool); required { + if _, ok := responses[k]; !ok { + return status.Errorf(codes.InvalidArgument, "missing required field %q", k) + } + } + } + for k := range consents { + if _, ok := consentByKey[k]; !ok { + return status.Errorf(codes.InvalidArgument, "unknown consent %q", k) + } + } + for k, c := range consentByKey { + if required, _ := c["required"].(bool); required && !consents[k] { + return status.Errorf(codes.InvalidArgument, "required consent %q not given", k) + } + } + + return nil +} + +// SubmitRegistrationForm records a registrant's answers to the organizer- +// defined registration form. Unknown keys, missing required fields, unknown +// consents and unticked required consents are InvalidArgument. Organizers +// may submit on_behalf_of another registrant (paper forms at check-in). +func (s *HackathonService) SubmitRegistrationForm( + ctx context.Context, + req *msgs.SubmitRegistrationFormRequest, +) (*msgs.SubmitRegistrationFormResponse, error) { + uid, _, err := m.RequireSubject(ctx) + if err != nil { + return nil, err + } + id, err := uuid.Parse(req.GetHackathonId()) + if err != nil { + return nil, status.Errorf(codes.InvalidArgument, "invalid hackathon_id: %v", err) + } + + caller, err := s.dbClient.User.Query().Where(entuser.KeycloakIDEQ(uid)).Only(ctx) + if err != nil { + if ent.IsNotFound(err) { + return nil, status.Errorf(codes.NotFound, "user %s not found", uid) + } + slog.Error("query user", "err", err) + + return nil, status.Error(codes.Internal, "couldn't query database") + } + + //nolint:protogetter // *string field: nil vs "" is the "on behalf of nobody" signal + target, err := s.resolveRegistrationTarget(ctx, id, caller, req.OnBehalfOf) + if err != nil { + return nil, err + } + + forms, err := formsRowFor(ctx, s.dbClient, id) + if err != nil { + slog.Error("query hackathon forms", "err", err) + + return nil, status.Error(codes.Internal, "couldn't query database") + } + if forms == nil || len(forms.RegistrationFields) == 0 { + return nil, status.Error(codes.FailedPrecondition, "no registration form defined") + } + + responses := req.GetResponses().AsMap() + consents := req.GetConsents() + if err := validateRegistrationForm(forms, responses, consents); err != nil { + return nil, err + } + + // Upsert, not insert. People correct their answers — a changed diet, a new + // affiliation, a skill they forgot — and a form you can submit exactly once + // makes the first typo permanent. The unique (hackathon, user) row is the + // current state of the answers, not an append-only log. + existing, err := s.dbClient.FormResponse.Query(). + Where( + entformresponse.HasHackathonWith(enthackathon.IDEQ(id)), + entformresponse.HasUserWith(entuser.IDEQ(target.ID)), + ). + Only(ctx) + if err != nil && !ent.IsNotFound(err) { + slog.Error("query form response", "err", err) + + return nil, status.Error(codes.Internal, "couldn't query database") + } + + if existing != nil { + // submitted_by is re-stamped: the organizer who corrected a walk-in's + // paper form is the one who entered THESE answers. + updated, err := existing.Update(). + SetSubmittedByID(caller.ID). + SetResponses(responses). + SetConsents(consents). + Save(ctx) + if err != nil { + slog.Error("update form response", "err", err) + + return nil, status.Error(codes.Internal, "couldn't store form response") + } + + return &msgs.SubmitRegistrationFormResponse{Id: updated.ID.String()}, nil + } + + row, err := s.dbClient.FormResponse.Create(). + SetHackathonID(id). + SetUserID(target.ID). + SetSubmittedByID(caller.ID). + SetResponses(responses). + SetConsents(consents). + Save(ctx) + if err != nil { + if ent.IsConstraintError(err) { + // Lost a race with a concurrent first submit; the other one won and + // the answers are on file either way. + return nil, status.Error(codes.AlreadyExists, "registration form already submitted") + } + slog.Error("create form response", "err", err) + + return nil, status.Error(codes.Internal, "couldn't store form response") + } + + return &msgs.SubmitRegistrationFormResponse{Id: row.ID.String()}, nil +} + +// GetRegistrationResponse reads back the answers on file so a registrant can +// review and correct them. +// +// No casbin check for the caller's OWN answers: the form is their personal +// data, and Get-level hackathon permission is the wrong gate — waitlisted +// users are denied there and are exactly who still needs to see their form. +// Reading someone ELSE's answers requires hackathon Write. +func (s *HackathonService) GetRegistrationResponse( + ctx context.Context, + req *msgs.GetRegistrationResponseRequest, +) (*msgs.GetRegistrationResponseResponse, error) { + uid, _, err := m.RequireSubject(ctx) + if err != nil { + return nil, err + } + id, err := uuid.Parse(req.GetHackathonId()) + if err != nil { + return nil, status.Errorf(codes.InvalidArgument, "invalid hackathon_id: %v", err) + } + + caller, err := s.dbClient.User.Query().Where(entuser.KeycloakIDEQ(uid)).Only(ctx) + if err != nil { + if ent.IsNotFound(err) { + return nil, status.Errorf(codes.NotFound, "user %s not found", uid) + } + slog.Error("query user", "err", err) + + return nil, status.Error(codes.Internal, "couldn't query database") + } + + targetID := caller.ID + if req.UserId != nil { + parsed, err := uuid.Parse(req.GetUserId()) + if err != nil { + return nil, status.Errorf(codes.InvalidArgument, "invalid user_id: %v", err) + } + if parsed != caller.ID { + if err := s.enforcer.RequirePermission( + ctx, id.String(), m.Hackathon, m.Write, + ); err != nil { + return nil, err + } + } + targetID = parsed + } + + row, err := s.dbClient.FormResponse.Query(). + Where( + entformresponse.HasHackathonWith(enthackathon.IDEQ(id)), + entformresponse.HasUserWith(entuser.IDEQ(targetID)), + ). + WithSubmittedBy(). + Only(ctx) + if err != nil { + if ent.IsNotFound(err) { + // Not an error: "you have not filled this in yet" is a normal + // state the form page renders as an empty form. + //exhaustruct:ignore + return &msgs.GetRegistrationResponseResponse{Submitted: false}, nil + } + slog.Error("query form response", "err", err) + + return nil, status.Error(codes.Internal, "couldn't query database") + } + + responses, err := structpb.NewStruct(row.Responses) + if err != nil { + slog.Error("encode form responses", "err", err) + + return nil, status.Error(codes.Internal, "couldn't encode form response") + } + + //exhaustruct:ignore + out := &msgs.GetRegistrationResponseResponse{ + Submitted: true, + Responses: responses, + Consents: row.Consents, + } + out.SubmittedAt = timestamppb.New(row.CreatedAt) + out.ModifiedAt = timestamppb.New(row.ModifiedAt) + if sb := row.Edges.SubmittedBy; sb != nil && sb.ID != targetID { + submitter := sb.ID.String() + out.SubmittedById = &submitter + } + + return out, nil +} + +// ListRegistrationResponses returns every submitted registration form for one +// hackathon. +// +// Organizer-only. GetRegistrationResponse lets you read your OWN answers and +// requires hackathon Write to read anyone else's; reading the whole cohort is +// the second case for every row at once, so it takes Write and nothing else. +// A fellow member is refused here exactly as they are refused there. +func (s *HackathonService) ListRegistrationResponses( + ctx context.Context, + req *msgs.ListRegistrationResponsesRequest, +) (*msgs.ListRegistrationResponsesResponse, error) { + uid, _, err := m.RequireSubject(ctx) + if err != nil { + return nil, err + } + if uid == m.AnonSubject { + return nil, status.Error(codes.Unauthenticated, "authentication required") + } + + id, err := uuid.Parse(req.GetHackathonId()) + if err != nil { + return nil, status.Errorf(codes.InvalidArgument, "invalid hackathon_id: %v", err) + } + if err := s.enforcer.RequirePermission(ctx, id.String(), m.Hackathon, m.Write); err != nil { + return nil, err + } + + rows, err := s.dbClient.FormResponse.Query(). + Where(entformresponse.HasHackathonWith(enthackathon.IDEQ(id))). + WithUser(). + All(ctx) + if err != nil { + slog.Error("query form responses", "err", err) + + return nil, status.Error(codes.Internal, "couldn't query database") + } + + out := make([]*msgs.RegistrationResponseEntry, 0, len(rows)) + for _, r := range rows { + if r.Edges.User == nil { + // The edge is Required, so this cannot happen through the API. It + // is skipped rather than nil-dereferenced because a corrupt row + // should cost that row, not the organiser's whole page. + slog.Error("form response without user", "response_id", r.ID) + + continue + } + responses, err := structpb.NewStruct(r.Responses) + if err != nil { + slog.Error("encode form responses", "response_id", r.ID, "err", err) + + continue + } + out = append(out, &msgs.RegistrationResponseEntry{ + UserId: r.Edges.User.ID.String(), + Responses: responses, + Consents: r.Consents, + SubmittedAt: timestamppb.New(r.CreatedAt), + ModifiedAt: timestamppb.New(r.ModifiedAt), + }) + } + + return &msgs.ListRegistrationResponsesResponse{Responses: out}, nil +} + +// Delete removes a hackathon and its owned configuration rows. Content-heavy +// hackathons (projects, teams, votes) are out of scope for now — this serves +// the cleanup of drafts that never went live; richer cascades belong to a +// dedicated archival flow. +func (s *HackathonService) Delete( + ctx context.Context, + req *msgs.DeleteRequest, +) (*msgs.DeleteResponse, error) { + id, err := uuid.Parse(req.GetHackathonId()) + if err != nil { + return nil, status.Errorf(codes.InvalidArgument, "invalid hackathon_id: %v", err) + } + if err := s.enforcer.RequirePermission(ctx, id.String(), m.Hackathon, m.Write); err != nil { + return nil, err + } + + exists, err := s.dbClient.Hackathon.Query().Where(enthackathon.IDEQ(id)).Exist(ctx) + if err != nil { + slog.Error("query hackathon", "err", err) + + return nil, status.Error(codes.Internal, "couldn't query database") + } + if !exists { + return nil, status.Errorf(codes.NotFound, "hackathon %s not found", id) + } + + // Owned configuration and roster rows first, then the hackathon itself. + pred := enthackathon.IDEQ(id) + if _, err := s.dbClient.Capability.Delete(). + Where(entcapability.HasHackathonWith(pred)).Exec(ctx); err != nil { + slog.Error("delete capabilities", "err", err) + + return nil, status.Error(codes.Internal, "couldn't delete hackathon") + } + for name, del := range map[string]func() (int, error){ + "settings": func() (int, error) { + return s.dbClient.HackathonSettings.Delete(). + Where(enthackathonsettings.HasHackathonWith(pred)).Exec(ctx) + }, + "windows": func() (int, error) { + return s.dbClient.HackathonWindows.Delete(). + Where(enthackathonwindows.HasHackathonWith(pred)).Exec(ctx) + }, + "forms": func() (int, error) { + return s.dbClient.HackathonForms.Delete(). + Where(enthackathonforms.HasHackathonWith(pred)).Exec(ctx) + }, + "form responses": func() (int, error) { + return s.dbClient.FormResponse.Delete(). + Where(entformresponse.HasHackathonWith(pred)).Exec(ctx) + }, + "prizes": func() (int, error) { + return s.dbClient.HackathonPrizes.Delete(). + Where(enthackathonprizes.HasHackathonWith(pred)).Exec(ctx) + }, + "participants": func() (int, error) { + return s.dbClient.Participant.Delete(). + Where(entparticipant.HackathonIDEQ(id)).Exec(ctx) + }, + } { + if _, err := del(); err != nil { + slog.Error("delete hackathon dependents", "kind", name, "err", err) + + return nil, status.Error(codes.Internal, "couldn't delete hackathon") + } + } + + if err := s.dbClient.Hackathon.DeleteOneID(id).Exec(ctx); err != nil { + if ent.IsConstraintError(err) { + return nil, status.Error(codes.FailedPrecondition, + "hackathon still has content (projects, pages, or teams); archive it instead") + } + slog.Error("delete hackathon", "err", err) + + return nil, status.Error(codes.Internal, "couldn't delete hackathon") + } + + // Only now, and never before: an event that is gone must not leave its + // gallery reachable at a guessable URL, but a purge that ran first and then + // hit a failed delete would leave rows pointing at objects already gone. + // Every key this event owns is under its id, so one prefix is the whole of + // it — no manifest to keep in sync. Failure logs and does not propagate. + purgeObjects(ctx, s.store, hackathonPrefix+id.String()+"/") + + return &msgs.DeleteResponse{}, nil +} diff --git a/components/backend/internal/service/hackathon_service_test.go b/components/backend/internal/service/hackathon_service_test.go index c5dd73c2..44ab5784 100644 --- a/components/backend/internal/service/hackathon_service_test.go +++ b/components/backend/internal/service/hackathon_service_test.go @@ -12,13 +12,18 @@ import ( "google.golang.org/grpc/codes" "google.golang.org/grpc/metadata" "google.golang.org/grpc/status" + "google.golang.org/protobuf/proto" "google.golang.org/protobuf/types/known/timestamppb" "github.com/google/uuid" ent "github.com/swissdatasciencecenter/hackagon/components/backend/ent" + entcapability "github.com/swissdatasciencecenter/hackagon/components/backend/ent/capability" enthackathon "github.com/swissdatasciencecenter/hackagon/components/backend/ent/hackathon" + enthackathonsettings "github.com/swissdatasciencecenter/hackagon/components/backend/ent/hackathonsettings" entparticipant "github.com/swissdatasciencecenter/hackagon/components/backend/ent/participant" + entuser "github.com/swissdatasciencecenter/hackagon/components/backend/ent/user" + "github.com/swissdatasciencecenter/hackagon/components/backend/internal/middleware" hackathonSvc "github.com/swissdatasciencecenter/hackagon/components/backend/internal/proto/hackathon" "github.com/swissdatasciencecenter/hackagon/components/backend/internal/proto/hackathon/entities" msgs "github.com/swissdatasciencecenter/hackagon/components/backend/internal/proto/hackathon/messages/hackathon_svc" @@ -30,18 +35,32 @@ var _ = Describe("HackathonService", func() { var ( dbClient *ent.Client conn *grpc.ClientConn + enf *middleware.Enforcer client hackathonSvc.HackathonServiceClient testAdmin string ) BeforeEach(func() { - dbClient, conn, _ = testutils.CreateTestServer() + dbClient, conn, enf = testutils.CreateTestServer() testAdmin = testutils.TestAdminKeycloakID client = hackathonSvc.NewHackathonServiceClient(conn) }) Describe("Create", func() { + // newUser inserts a user and returns its Keycloak ID. Create looks the + // caller up in the users table, so a token alone is not enough. + newUser := func(username string) string { + keycloakID := "keycloak-" + username + _, err := dbClient.User.Create(). + SetKeycloakID(keycloakID). + SetUsername(username). + Save(context.Background()) + Expect(err).NotTo(HaveOccurred()) + + return keycloakID + } + It("creates hackathon successfully with admin token", func() { token := testutils.CreateTestJWTToken(testAdmin) ctx := metadata.NewOutgoingContext( @@ -74,6 +93,119 @@ var _ = Describe("HackathonService", func() { Expect(h.Edges.Creator).NotTo(BeNil()) Expect(h.Edges.Creator.KeycloakID).To(Equal(testAdmin)) }) + + It("creates hackathon successfully for a global hackathon organizer", func() { + organizer := newUser("organizer") + _, err := enf.AddGlobalRole(organizer, middleware.HackathonOrganizer) + Expect(err).NotTo(HaveOccurred()) + + token := testutils.CreateTestJWTToken(organizer) + ctx := metadata.NewOutgoingContext( + context.Background(), + metadata.Pairs("authorization", "Bearer "+token), + ) + + now := time.Now() + resp, err := client.Create(ctx, &msgs.CreateRequest{ + Name: "Organizer Hackathon", + Visibility: entities.Visibility_VISIBILITY_PUBLIC, + StartsAt: timestamppb.New(now.Add(24 * time.Hour)), + EndsAt: timestamppb.New(now.Add(48 * time.Hour)), + }) + Expect(err).NotTo(HaveOccurred()) + Expect(resp.GetHackathonId()).NotTo(BeEmpty()) + + // The creator must also come out as owner of what they just created. + role, err := enf.GetHackathonRole(organizer, resp.GetHackathonId()) + Expect(err).NotTo(HaveOccurred()) + Expect(role).To(Equal(entities.HackathonRole_HACKATHON_ROLE_OWNER)) + }) + + // The owner shell reads membership from the participants table, not from + // casbin, so a creator missing here is an owner who cannot open their own + // hackathon and does not see it listed on their dashboard. + It("enrolls the creator as a confirmed participant", func() { + organizer := newUser("organizer") + _, err := enf.AddGlobalRole(organizer, middleware.HackathonOrganizer) + Expect(err).NotTo(HaveOccurred()) + + token := testutils.CreateTestJWTToken(organizer) + ctx := metadata.NewOutgoingContext( + context.Background(), + metadata.Pairs("authorization", "Bearer "+token), + ) + + created, err := client.Create(ctx, &msgs.CreateRequest{ + Name: "Owned Hackathon", + Visibility: entities.Visibility_VISIBILITY_PRIVATE, + }) + Expect(err).NotTo(HaveOccurred()) + + p, err := dbClient.Participant.Query(). + Where(entparticipant.HackathonIDEQ(uuid.MustParse(created.GetHackathonId()))). + WithUser(). + Only(context.Background()) + Expect(err).NotTo(HaveOccurred()) + Expect(p.IsWaiting).To(BeFalse()) + Expect(p.Edges.User.KeycloakID).To(Equal(organizer)) + + // ...and surfaces through Get as an owner, which is what the owner + // shell gates on. + got, err := client.Get(ctx, &msgs.GetRequest{ + HackathonId: created.GetHackathonId(), + }) + Expect(err).NotTo(HaveOccurred()) + Expect(got.GetHackathon().GetMembers()).To(HaveLen(1)) + member := got.GetHackathon().GetMembers()[0] + Expect(member.GetUser().GetKeycloakId()).To(Equal(organizer)) + Expect(member.GetRole()).To(Equal(entities.HackathonRole_HACKATHON_ROLE_OWNER)) + }) + + It("denies a user without the organizer role", func() { + plain := newUser("plain") + + token := testutils.CreateTestJWTToken(plain) + ctx := metadata.NewOutgoingContext( + context.Background(), + metadata.Pairs("authorization", "Bearer "+token), + ) + + _, err := client.Create(ctx, &msgs.CreateRequest{ + Name: "Should Not Exist", + Visibility: entities.Visibility_VISIBILITY_PUBLIC, + }) + Expect(status.Code(err)).To(Equal(codes.PermissionDenied)) + }) + + It("does not let an organizer write another owner's hackathon", func() { + organizer := newUser("organizer") + _, err := enf.AddGlobalRole(organizer, middleware.HackathonOrganizer) + Expect(err).NotTo(HaveOccurred()) + + // A hackathon the organizer has no role in. + adminToken := testutils.CreateTestJWTToken(testAdmin) + adminCtx := metadata.NewOutgoingContext( + context.Background(), + metadata.Pairs("authorization", "Bearer "+adminToken), + ) + created, err := client.Create(adminCtx, &msgs.CreateRequest{ + Name: "Admin Hackathon", + Visibility: entities.Visibility_VISIBILITY_PRIVATE, + }) + Expect(err).NotTo(HaveOccurred()) + + token := testutils.CreateTestJWTToken(organizer) + ctx := metadata.NewOutgoingContext( + context.Background(), + metadata.Pairs("authorization", "Bearer "+token), + ) + newName := "Hijacked" + _, err = client.Edit(ctx, &msgs.EditRequest{ + HackathonId: created.GetHackathonId(), + Name: &newName, + }) + Expect(status.Code(err)).To(Equal(codes.PermissionDenied)) + }) }) Describe("List", func() { @@ -267,6 +399,13 @@ var _ = Describe("HackathonService", func() { Expect(err).NotTo(HaveOccurred()) Expect(createResp.GetHackathonId()).NotTo(BeEmpty()) createdHackathonID = createResp.GetHackathonId() + + // Enable registrations (disabled by default) + _, err = client.EditSettings(ctx, &msgs.EditSettingsRequest{ + HackathonId: createdHackathonID, + RegistrationsEnabled: testutils.BoolPtr(true), + }) + Expect(err).NotTo(HaveOccurred()) }) It("allows authorized user to join hackathon", func() { @@ -366,6 +505,44 @@ var _ = Describe("HackathonService", func() { Expect(st.Code()).To(Equal(codes.NotFound)) }) + // MERGE NOTE (sketch): skipped — settings no longer gate Join; the + // Register capability (#87) does. Contradicts #87's default-open + // expectations; team must consolidate on one mechanism. + XIt("returns FAILED_PRECONDITION when registrations are disabled", func() { + // Disable registrations via admin + adminToken := testutils.CreateTestJWTToken(testAdmin) + adminCtx := metadata.NewOutgoingContext( + context.Background(), + metadata.Pairs("authorization", "Bearer "+adminToken), + ) + _, err := client.EditSettings(adminCtx, &msgs.EditSettingsRequest{ + HackathonId: createdHackathonID, + RegistrationsEnabled: testutils.BoolPtr(false), + }) + Expect(err).NotTo(HaveOccurred()) + + // Try to join as non-admin + nonAdminKeycloakID := "non-admin-join" + token := testutils.CreateTestJWTToken(nonAdminKeycloakID) + ctx := metadata.NewOutgoingContext( + context.Background(), + metadata.Pairs("authorization", "Bearer "+token), + ) + + _, err = dbClient.User.Create(). + SetKeycloakID(nonAdminKeycloakID). + SetUsername("test-join-user-3"). + Save(context.Background()) + Expect(err).NotTo(HaveOccurred()) + + joinReq := &msgs.JoinRequest{HackathonId: createdHackathonID} + _, err = client.Join(ctx, joinReq) + Expect(err).To(HaveOccurred()) + + st := status.Convert(err) + Expect(st.Code()).To(Equal(codes.FailedPrecondition)) + }) + It("requires authentication to join", func() { // First create user as admin to simulate sync anonKeycloakID := "anonymous" @@ -813,7 +990,7 @@ var _ = Describe("HackathonService", func() { Expect(err).To(HaveOccurred()) st := status.Convert(err) - Expect(st.Code()).To(Equal(codes.PermissionDenied)) + Expect(st.Code()).To(Equal(codes.Unauthenticated)) }) It("allows editing timestamps", func() { @@ -1011,7 +1188,7 @@ var _ = Describe("HackathonService", func() { Expect(resp).To(BeNil()) st := status.Convert(err) - Expect(st.Code()).To(Equal(codes.PermissionDenied)) + Expect(st.Code()).To(Equal(codes.Unauthenticated)) }) }) @@ -1068,4 +1245,1274 @@ var _ = Describe("HackathonService", func() { }) }) + Describe("EditCapability", func() { + var ( + adminCtx context.Context + hackathonID string + ) + + // newUser inserts a user and returns a context carrying its token. + newUser := func(username string) context.Context { + keycloakID := "keycloak-" + username + _, err := dbClient.User.Create(). + SetKeycloakID(keycloakID). + SetUsername(username). + Save(context.Background()) + Expect(err).NotTo(HaveOccurred()) + + return metadata.NewOutgoingContext( + context.Background(), + metadata.Pairs( + "authorization", + "Bearer "+testutils.CreateTestJWTToken(keycloakID), + ), + ) + } + + BeforeEach(func() { + adminCtx = metadata.NewOutgoingContext( + context.Background(), + metadata.Pairs( + "authorization", + "Bearer "+testutils.CreateTestJWTToken(testAdmin), + ), + ) + + now := time.Now() + createResp, err := client.Create(adminCtx, &msgs.CreateRequest{ + Name: "Capability Test Hackathon", + Visibility: entities.Visibility_VISIBILITY_PUBLIC, + StartsAt: timestamppb.New(now.Add(24 * time.Hour)), + EndsAt: timestamppb.New(now.Add(48 * time.Hour)), + }) + Expect(err).NotTo(HaveOccurred()) + hackathonID = createResp.GetHackathonId() + }) + + It("reports a status for every capability on Get", func() { + resp, err := client.Get(adminCtx, &msgs.GetRequest{HackathonId: hackathonID}) + Expect(err).NotTo(HaveOccurred()) + + caps := resp.GetHackathon().GetCapabilities() + Expect(caps).To(HaveLen(6)) + + seen := map[entities.Capability]entities.CapabilityState{} + for _, c := range caps { + seen[c.GetCapability()] = c.GetState() + } + Expect(seen).To(HaveKey(entities.Capability_CAPABILITY_REGISTER)) + Expect(seen).To(HaveKey(entities.Capability_CAPABILITY_VOTE)) + }) + + It("creates a new hackathon with every capability open", func() { + // Introducing capabilities must not change behavior for existing + // callers, so a fresh hackathon starts permissive. + resp, err := client.Get(adminCtx, &msgs.GetRequest{HackathonId: hackathonID}) + Expect(err).NotTo(HaveOccurred()) + + for _, c := range resp.GetHackathon().GetCapabilities() { + Expect(c.GetState()).To( + Equal(entities.CapabilityState_CAPABILITY_STATE_OPEN), + "capability %v should start open", c.GetCapability(), + ) + } + }) + + It("closes a capability and reports it back", func() { + resp, err := client.EditCapability(adminCtx, &msgs.EditCapabilityRequest{ + HackathonId: hackathonID, + Capability: entities.Capability_CAPABILITY_REGISTER, + Enabled: proto.Bool(false), + }) + Expect(err).NotTo(HaveOccurred()) + Expect(resp.GetCapability().GetState()).To( + Equal(entities.CapabilityState_CAPABILITY_STATE_CLOSED), + ) + }) + + It("blocks Join once registration is closed", func() { + _, err := client.EditCapability(adminCtx, &msgs.EditCapabilityRequest{ + HackathonId: hackathonID, + Capability: entities.Capability_CAPABILITY_REGISTER, + Enabled: proto.Bool(false), + }) + Expect(err).NotTo(HaveOccurred()) + + _, err = client.Join(newUser("late-joiner"), &msgs.JoinRequest{ + HackathonId: hackathonID, + }) + Expect(err).To(HaveOccurred()) + Expect(status.Code(err)).To(Equal(codes.FailedPrecondition)) + Expect(err.Error()).To(ContainSubstring("registrations are closed")) + }) + + It("still allows Join while registration is open", func() { + _, err := client.Join(newUser("early-joiner"), &msgs.JoinRequest{ + HackathonId: hackathonID, + }) + Expect(err).NotTo(HaveOccurred()) + }) + + It("lets an owner join even when registration is closed", func() { + // Organizers must be able to act outside the window; a participant + // who missed a deadline is a support request, not a lockout. + _, err := client.EditCapability(adminCtx, &msgs.EditCapabilityRequest{ + HackathonId: hackathonID, + Capability: entities.Capability_CAPABILITY_REGISTER, + Enabled: proto.Bool(false), + }) + Expect(err).NotTo(HaveOccurred()) + + _, err = client.Join(adminCtx, &msgs.JoinRequest{HackathonId: hackathonID}) + Expect(err).NotTo(HaveOccurred()) + }) + + It("closes each capability independently", func() { + _, err := client.EditCapability(adminCtx, &msgs.EditCapabilityRequest{ + HackathonId: hackathonID, + Capability: entities.Capability_CAPABILITY_VOTE, + Enabled: proto.Bool(false), + }) + Expect(err).NotTo(HaveOccurred()) + + resp, err := client.Get(adminCtx, &msgs.GetRequest{HackathonId: hackathonID}) + Expect(err).NotTo(HaveOccurred()) + + for _, c := range resp.GetHackathon().GetCapabilities() { + if c.GetCapability() == entities.Capability_CAPABILITY_VOTE { + Expect(c.GetState()).To( + Equal(entities.CapabilityState_CAPABILITY_STATE_CLOSED), + ) + + continue + } + Expect(c.GetState()).To( + Equal(entities.CapabilityState_CAPABILITY_STATE_OPEN), + "capability %v should be untouched", c.GetCapability(), + ) + } + }) + + It("denies a non-owner from editing capabilities", func() { + _, err := client.EditCapability( + newUser("meddler"), + &msgs.EditCapabilityRequest{ + HackathonId: hackathonID, + Capability: entities.Capability_CAPABILITY_REGISTER, + Enabled: proto.Bool(false), + }, + ) + Expect(err).To(HaveOccurred()) + Expect(status.Code(err)).To(Equal(codes.PermissionDenied)) + }) + + It("rejects an unspecified capability", func() { + _, err := client.EditCapability(adminCtx, &msgs.EditCapabilityRequest{ + HackathonId: hackathonID, + Capability: entities.Capability_CAPABILITY_UNSPECIFIED, + Enabled: proto.Bool(true), + }) + Expect(err).To(HaveOccurred()) + Expect(status.Code(err)).To(Equal(codes.InvalidArgument)) + }) + + It("returns NOT_FOUND for an unknown hackathon", func() { + _, err := client.EditCapability(adminCtx, &msgs.EditCapabilityRequest{ + HackathonId: uuid.NewString(), + Capability: entities.Capability_CAPABILITY_REGISTER, + Enabled: proto.Bool(false), + }) + Expect(err).To(HaveOccurred()) + Expect(status.Code(err)).To(BeElementOf(codes.NotFound, codes.PermissionDenied)) + }) + + // SetCapabilities used to answer NotFound if ANY capability in the batch + // had no stored row, and the organiser's panel posts all six switches on + // every save — so one absent row made the whole capability screen + // unusable, with a 404 as its only explanation. + Describe("SetCapabilities with an ungoverned capability", func() { + // The six the panel sends, every save. Written out rather than + // derived so a capability added to the enum shows up here as a + // compile-time gap rather than as a batch that quietly got smaller. + wholeForm := func(vote bool) []*entities.CapabilityToggle { + return []*entities.CapabilityToggle{ + {Capability: entities.Capability_CAPABILITY_REGISTER, Enabled: false}, + { + Capability: entities.Capability_CAPABILITY_PROPOSE_PROJECTS, + Enabled: true, + }, + { + Capability: entities.Capability_CAPABILITY_SET_TEAM_PREFERENCES, + Enabled: true, + }, + { + Capability: entities.Capability_CAPABILITY_CREATE_PROJECT_SUBMISSIONS, + Enabled: true, + }, + {Capability: entities.Capability_CAPABILITY_VOTE, Enabled: vote}, + { + Capability: entities.Capability_CAPABILITY_VIEW_RESULTS, + Enabled: true, + }, + } + } + + // stateOf reads one capability's state out of Get, which reports the + // whole vocabulary — including UNGOVERNED for a capability with no + // row, which is the state under test. + stateOf := func(c entities.Capability) entities.CapabilityState { + resp, err := client.Get(adminCtx, &msgs.GetRequest{HackathonId: hackathonID}) + Expect(err).NotTo(HaveOccurred()) + for _, s := range resp.GetHackathon().GetCapabilities() { + if s.GetCapability() == c { + return s.GetState() + } + } + Fail("capability missing from Get response") + + return entities.CapabilityState_CAPABILITY_STATE_UNSPECIFIED + } + + // Deleting the row is the ONLY way to reach this state, and that is + // the whole reason the bug survived: Create seeds all six and no RPC + // removes one, so nothing driven through the API is ever ungoverned. + // The gap is real off the API (an event older than a capability the + // enum gained later, a partial restore) and has to be made here. + BeforeEach(func() { + n, err := dbClient.Capability.Delete(). + Where( + entcapability.HasHackathonWith( + enthackathon.IDEQ(uuid.MustParse(hackathonID)), + ), + entcapability.CapabilityEQ(entcapability.CapabilityVote), + ). + Exec(context.Background()) + Expect(err).NotTo(HaveOccurred()) + Expect( + n, + ).To(Equal(1), "nothing was ungoverned — the specs below would prove nothing") + + // The positive control. Without it, every claim under this + // Describe would also hold against a governed row, i.e. against + // the setup silently failing. + Expect(stateOf(entities.Capability_CAPABILITY_VOTE)).To( + Equal(entities.CapabilityState_CAPABILITY_STATE_UNGOVERNED), + ) + }) + + It("creates the missing row instead of refusing the whole batch", func() { + _, err := client.SetCapabilities(adminCtx, &msgs.SetCapabilitiesRequest{ + HackathonId: hackathonID, + Capabilities: wholeForm(true), + }) + Expect(err).NotTo(HaveOccurred()) + + Expect(stateOf(entities.Capability_CAPABILITY_VOTE)).To( + Equal(entities.CapabilityState_CAPABILITY_STATE_OPEN), + ) + }) + + It("lands the rest of the batch, which the refusal used to lose", func() { + // The cost of the old behaviour was never the one row: it was the + // other five. `register: false` is the switch that decides whether + // anyone can still sign up, and it never reached the database. + _, err := client.SetCapabilities(adminCtx, &msgs.SetCapabilitiesRequest{ + HackathonId: hackathonID, + Capabilities: wholeForm(true), + }) + Expect(err).NotTo(HaveOccurred()) + + Expect(stateOf(entities.Capability_CAPABILITY_REGISTER)).To( + Equal(entities.CapabilityState_CAPABILITY_STATE_CLOSED), + ) + }) + + It("governs a capability asked to be OFF rather than leaving it allowed", func() { + // Why creating beats skipping, stated as the outcome rather than + // as a preference. UNGOVERNED is ALLOWED (`capability.State`'s + // Allowed returns true for it), so a handler that skipped the + // missing row would report a successful save while participants + // kept the permission the organiser had just switched off. + _, err := client.SetCapabilities(adminCtx, &msgs.SetCapabilitiesRequest{ + HackathonId: hackathonID, + Capabilities: wholeForm(false), + }) + Expect(err).NotTo(HaveOccurred()) + + Expect(stateOf(entities.Capability_CAPABILITY_VOTE)).To( + Equal(entities.CapabilityState_CAPABILITY_STATE_CLOSED), + "an ungoverned capability set to false must end up governed and "+ + "closed — UNGOVERNED here would mean the save was a no-op on a gate", + ) + }) + + It("attributes the row it creates to whoever saved the form", func() { + // A created row is a row like any other: "who last changed this" + // has to keep working across the gap, or the first save after a + // restore is the one edit with no author. + _, err := client.SetCapabilities(adminCtx, &msgs.SetCapabilitiesRequest{ + HackathonId: hackathonID, + Capabilities: wholeForm(true), + }) + Expect(err).NotTo(HaveOccurred()) + + row, err := dbClient.Capability.Query(). + Where( + entcapability.HasHackathonWith( + enthackathon.IDEQ(uuid.MustParse(hackathonID)), + ), + entcapability.CapabilityEQ(entcapability.CapabilityVote), + ). + WithModifier(). + Only(context.Background()) + Expect(err).NotTo(HaveOccurred()) + Expect(row.Edges.Modifier).NotTo(BeNil()) + Expect(row.Edges.Modifier.KeycloakID).To(Equal(testAdmin)) + }) + + It("still refuses a hackathon that does not exist, and says so", func() { + // The other half of removing the NotFound: an id that names + // nothing must not be answered by creating rows for it. It keeps + // NotFound — but now about the HACKATHON, which is the true + // statement; the old reply named a capability and was misleading + // about which thing was missing. + missing := uuid.New() + + _, err := client.SetCapabilities(adminCtx, &msgs.SetCapabilitiesRequest{ + HackathonId: missing.String(), + Capabilities: []*entities.CapabilityToggle{ + {Capability: entities.Capability_CAPABILITY_VOTE, Enabled: true}, + }, + }) + Expect(status.Code(err)).To(Equal(codes.NotFound)) + Expect(err.Error()).To(ContainSubstring("hackathon")) + + n, qErr := dbClient.Capability.Query(). + Where(entcapability.HasHackathonWith(enthackathon.IDEQ(missing))). + Count(context.Background()) + Expect(qErr).NotTo(HaveOccurred()) + Expect(n).To(Equal(0), "a refused call must not have written rows") + }) + + It("reports the created row in its own response, not only on the next Get", func() { + // `capabilityStatuses` says it "reports every capability the way + // Get does" and used to build from the stored rows alone, so a + // capability with no row was simply absent from this reply while + // Get reported it as UNGOVERNED. Two answers to the same question + // from one handler. + resp, err := client.SetCapabilities(adminCtx, &msgs.SetCapabilitiesRequest{ + HackathonId: hackathonID, + Capabilities: wholeForm(true), + }) + Expect(err).NotTo(HaveOccurred()) + Expect(resp.GetCapabilities()).To(HaveLen(6)) + }) + }) + + Describe("phase schedule", func() { + var adminUserID uuid.UUID + + BeforeEach(func() { + u, err := dbClient.User.Query(). + Where(entuser.KeycloakIDEQ(testAdmin)). + Only(context.Background()) + Expect(err).NotTo(HaveOccurred()) + adminUserID = u.ID + }) + + // phaseOn creates a phase on `onHackathon` starting `days` from now. + phaseOn := func(onHackathon, name string, days int) *ent.Phase { + p, err := dbClient.Phase.Create(). + SetName(name). + SetStartsAt(time.Now().AddDate(0, 0, days)). + SetEndsAt(time.Now().AddDate(0, 0, days+1)). + SetHackathonID(uuid.MustParse(onHackathon)). + SetCreatorID(adminUserID). + SetModifierID(adminUserID). + Save(context.Background()) + Expect(err).NotTo(HaveOccurred()) + + return p + } + + // newPhase creates a phase on the hackathon under test. + newPhase := func(name string, days int) string { + return phaseOn(hackathonID, name, days).ID.String() + } + + // statusOf pulls one capability out of a Get response. + statusOf := func(c entities.Capability) *entities.CapabilityStatus { + resp, err := client.Get(adminCtx, &msgs.GetRequest{HackathonId: hackathonID}) + Expect(err).NotTo(HaveOccurred()) + for _, s := range resp.GetHackathon().GetCapabilities() { + if s.GetCapability() == c { + return s + } + } + Fail("capability not present in Get response") + + return nil + } + + It("reports COMING with an opens_at once linked to a future phase", func() { + phaseID := newPhase("Proposals", 5) + + _, err := client.EditCapability(adminCtx, &msgs.EditCapabilityRequest{ + HackathonId: hackathonID, + Capability: entities.Capability_CAPABILITY_PROPOSE_PROJECTS, + Enabled: proto.Bool(false), + OpenInPhaseId: proto.String(phaseID), + }) + Expect(err).NotTo(HaveOccurred()) + + got := statusOf(entities.Capability_CAPABILITY_PROPOSE_PROJECTS) + Expect(got.GetState()).To( + Equal(entities.CapabilityState_CAPABILITY_STATE_COMING), + ) + Expect(got.GetOpensAt()).NotTo(BeNil()) + Expect(got.GetOpenInPhaseId()).To(Equal(phaseID)) + }) + + It("reports CLOSED when the linked phase has already started", func() { + phaseID := newPhase("Past Proposals", -5) + + _, err := client.EditCapability(adminCtx, &msgs.EditCapabilityRequest{ + HackathonId: hackathonID, + Capability: entities.Capability_CAPABILITY_PROPOSE_PROJECTS, + Enabled: proto.Bool(false), + OpenInPhaseId: proto.String(phaseID), + }) + Expect(err).NotTo(HaveOccurred()) + + Expect(statusOf(entities.Capability_CAPABILITY_PROPOSE_PROJECTS).GetState()).To( + Equal(entities.CapabilityState_CAPABILITY_STATE_CLOSED), + ) + }) + + It("keeps a scheduled capability blocked for enforcement", func() { + // COMING is a better message, not a weaker gate. + phaseID := newPhase("Registration", 5) + + _, err := client.EditCapability(adminCtx, &msgs.EditCapabilityRequest{ + HackathonId: hackathonID, + Capability: entities.Capability_CAPABILITY_REGISTER, + Enabled: proto.Bool(false), + OpenInPhaseId: proto.String(phaseID), + }) + Expect(err).NotTo(HaveOccurred()) + + _, err = client.Join(newUser("too-early"), &msgs.JoinRequest{ + HackathonId: hackathonID, + }) + Expect(status.Code(err)).To(Equal(codes.FailedPrecondition)) + }) + + It("lets the flag win over a future phase", func() { + // The decisive property: an organizer opening something early is + // not overruled by its schedule. + phaseID := newPhase("Later", 5) + + _, err := client.EditCapability(adminCtx, &msgs.EditCapabilityRequest{ + HackathonId: hackathonID, + Capability: entities.Capability_CAPABILITY_PROPOSE_PROJECTS, + Enabled: proto.Bool(true), + OpenInPhaseId: proto.String(phaseID), + }) + Expect(err).NotTo(HaveOccurred()) + + Expect(statusOf(entities.Capability_CAPABILITY_PROPOSE_PROJECTS).GetState()).To( + Equal(entities.CapabilityState_CAPABILITY_STATE_OPEN), + ) + }) + + It("unlinks on an empty phase id", func() { + phaseID := newPhase("Proposals", 5) + _, err := client.EditCapability(adminCtx, &msgs.EditCapabilityRequest{ + HackathonId: hackathonID, + Capability: entities.Capability_CAPABILITY_PROPOSE_PROJECTS, + Enabled: proto.Bool(false), + OpenInPhaseId: proto.String(phaseID), + }) + Expect(err).NotTo(HaveOccurred()) + + _, err = client.EditCapability(adminCtx, &msgs.EditCapabilityRequest{ + HackathonId: hackathonID, + Capability: entities.Capability_CAPABILITY_PROPOSE_PROJECTS, + OpenInPhaseId: proto.String(""), + }) + Expect(err).NotTo(HaveOccurred()) + + got := statusOf(entities.Capability_CAPABILITY_PROPOSE_PROJECTS) + Expect(got.GetState()).To( + Equal(entities.CapabilityState_CAPABILITY_STATE_CLOSED), + ) + Expect(got.OpensAt).To(BeNil()) + Expect(got.OpenInPhaseId).To(BeNil()) + }) + + It("leaves the flag alone when only the schedule is edited", func() { + phaseID := newPhase("Proposals", 5) + + // propose_projects starts open; editing only the link must not + // close it. + _, err := client.EditCapability(adminCtx, &msgs.EditCapabilityRequest{ + HackathonId: hackathonID, + Capability: entities.Capability_CAPABILITY_PROPOSE_PROJECTS, + OpenInPhaseId: proto.String(phaseID), + }) + Expect(err).NotTo(HaveOccurred()) + + Expect(statusOf(entities.Capability_CAPABILITY_PROPOSE_PROJECTS).GetState()).To( + Equal(entities.CapabilityState_CAPABILITY_STATE_OPEN), + ) + }) + + It("rejects a phase belonging to another hackathon", func() { + other, err := client.Create(adminCtx, &msgs.CreateRequest{ + Name: "Other Hackathon", + Visibility: entities.Visibility_VISIBILITY_PUBLIC, + }) + Expect(err).NotTo(HaveOccurred()) + + foreign := phaseOn(other.GetHackathonId(), "Foreign Phase", 5) + + _, err = client.EditCapability(adminCtx, &msgs.EditCapabilityRequest{ + HackathonId: hackathonID, + Capability: entities.Capability_CAPABILITY_PROPOSE_PROJECTS, + OpenInPhaseId: proto.String(foreign.ID.String()), + }) + Expect(status.Code(err)).To(Equal(codes.NotFound)) + }) + + Describe("AdvancePhase", func() { + var ideation, hacking, judging string + + // stateOf reads one capability's state back from Get. + stateOf := func(c entities.Capability) entities.CapabilityState { + return statusOf(c).GetState() + } + + BeforeEach(func() { + ideation = newPhase("Ideation", 1) + hacking = newPhase("Hacking", 2) + judging = newPhase("Judging", 3) + + // Proposals span Ideation→Hacking, submissions Hacking→Judging, + // results open at Judging. Voting stays unlinked. All start + // closed so advancing is what opens them. + for _, link := range []struct { + capability entities.Capability + opens, closes string + }{ + {entities.Capability_CAPABILITY_PROPOSE_PROJECTS, ideation, hacking}, + {entities.Capability_CAPABILITY_CREATE_PROJECT_SUBMISSIONS, hacking, judging}, + {entities.Capability_CAPABILITY_VIEW_RESULTS, judging, ""}, + } { + _, err := client.EditCapability(adminCtx, &msgs.EditCapabilityRequest{ + HackathonId: hackathonID, + Capability: link.capability, + Enabled: proto.Bool(false), + OpenInPhaseId: proto.String(link.opens), + ClosedInPhaseId: proto.String(link.closes), + }) + Expect(err).NotTo(HaveOccurred()) + } + _, err := client.EditCapability(adminCtx, &msgs.EditCapabilityRequest{ + HackathonId: hackathonID, + Capability: entities.Capability_CAPABILITY_VOTE, + Enabled: proto.Bool(false), + }) + Expect(err).NotTo(HaveOccurred()) + }) + + It("opens the capabilities scheduled for the target phase", func() { + resp, err := client.AdvancePhase(adminCtx, &msgs.AdvancePhaseRequest{ + HackathonId: hackathonID, + PhaseId: ideation, + }) + Expect(err).NotTo(HaveOccurred()) + Expect(resp.GetCurrentPhaseId()).To(Equal(ideation)) + + Expect(stateOf(entities.Capability_CAPABILITY_PROPOSE_PROJECTS)).To( + Equal(entities.CapabilityState_CAPABILITY_STATE_OPEN), + ) + }) + + It("closes what the previous phase opened when moving on", func() { + _, err := client.AdvancePhase(adminCtx, &msgs.AdvancePhaseRequest{ + HackathonId: hackathonID, PhaseId: ideation, + }) + Expect(err).NotTo(HaveOccurred()) + + _, err = client.AdvancePhase(adminCtx, &msgs.AdvancePhaseRequest{ + HackathonId: hackathonID, PhaseId: hacking, + }) + Expect(err).NotTo(HaveOccurred()) + + Expect(stateOf(entities.Capability_CAPABILITY_PROPOSE_PROJECTS)).To( + Equal(entities.CapabilityState_CAPABILITY_STATE_CLOSED), + ) + Expect(stateOf(entities.Capability_CAPABILITY_CREATE_PROJECT_SUBMISSIONS)).To( + Equal(entities.CapabilityState_CAPABILITY_STATE_OPEN), + ) + }) + + It("leaves an unscheduled capability untouched", func() { + // Voting must survive advancing in either direction — it opens + // abruptly and by hand. + for _, target := range []string{ideation, hacking, judging} { + _, err := client.AdvancePhase(adminCtx, &msgs.AdvancePhaseRequest{ + HackathonId: hackathonID, PhaseId: target, + }) + Expect(err).NotTo(HaveOccurred()) + Expect(stateOf(entities.Capability_CAPABILITY_VOTE)).To( + Equal(entities.CapabilityState_CAPABILITY_STATE_CLOSED), + ) + } + + // And an organizer opening it by hand is not undone by a later + // advance. + _, err := client.EditCapability(adminCtx, &msgs.EditCapabilityRequest{ + HackathonId: hackathonID, + Capability: entities.Capability_CAPABILITY_VOTE, + Enabled: proto.Bool(true), + }) + Expect(err).NotTo(HaveOccurred()) + + _, err = client.AdvancePhase(adminCtx, &msgs.AdvancePhaseRequest{ + HackathonId: hackathonID, PhaseId: ideation, + }) + Expect(err).NotTo(HaveOccurred()) + Expect(stateOf(entities.Capability_CAPABILITY_VOTE)).To( + Equal(entities.CapabilityState_CAPABILITY_STATE_OPEN), + ) + }) + + It("is idempotent", func() { + // A double-click at a live event must be harmless. + first, err := client.AdvancePhase(adminCtx, &msgs.AdvancePhaseRequest{ + HackathonId: hackathonID, PhaseId: hacking, + }) + Expect(err).NotTo(HaveOccurred()) + + second, err := client.AdvancePhase(adminCtx, &msgs.AdvancePhaseRequest{ + HackathonId: hackathonID, PhaseId: hacking, + }) + Expect(err).NotTo(HaveOccurred()) + + Expect(second.GetCurrentPhaseId()).To(Equal(first.GetCurrentPhaseId())) + Expect(second.GetCapabilities()).To(HaveLen(len(first.GetCapabilities()))) + }) + + It("restores the earlier flags when advancing backwards", func() { + _, err := client.AdvancePhase(adminCtx, &msgs.AdvancePhaseRequest{ + HackathonId: hackathonID, PhaseId: judging, + }) + Expect(err).NotTo(HaveOccurred()) + Expect(stateOf(entities.Capability_CAPABILITY_CREATE_PROJECT_SUBMISSIONS)).To( + Equal(entities.CapabilityState_CAPABILITY_STATE_CLOSED), + ) + + _, err = client.AdvancePhase(adminCtx, &msgs.AdvancePhaseRequest{ + HackathonId: hackathonID, PhaseId: hacking, + }) + Expect(err).NotTo(HaveOccurred()) + Expect(stateOf(entities.Capability_CAPABILITY_CREATE_PROJECT_SUBMISSIONS)).To( + Equal(entities.CapabilityState_CAPABILITY_STATE_OPEN), + ) + }) + + It("reports the current phase on Get", func() { + _, err := client.AdvancePhase(adminCtx, &msgs.AdvancePhaseRequest{ + HackathonId: hackathonID, PhaseId: hacking, + }) + Expect(err).NotTo(HaveOccurred()) + + got, err := client.Get(adminCtx, &msgs.GetRequest{HackathonId: hackathonID}) + Expect(err).NotTo(HaveOccurred()) + Expect(got.GetHackathon().GetCurrentPhaseId()).To(Equal(hacking)) + }) + + It("denies a non-owner", func() { + _, err := client.AdvancePhase( + newUser("bystander"), + &msgs.AdvancePhaseRequest{ + HackathonId: hackathonID, PhaseId: ideation, + }, + ) + Expect(status.Code(err)).To(Equal(codes.PermissionDenied)) + }) + + It("rejects a phase from another hackathon", func() { + other, err := client.Create(adminCtx, &msgs.CreateRequest{ + Name: "Elsewhere", + Visibility: entities.Visibility_VISIBILITY_PUBLIC, + }) + Expect(err).NotTo(HaveOccurred()) + foreign := phaseOn(other.GetHackathonId(), "Foreign", 1) + + _, err = client.AdvancePhase(adminCtx, &msgs.AdvancePhaseRequest{ + HackathonId: hackathonID, PhaseId: foreign.ID.String(), + }) + Expect(status.Code(err)).To(Equal(codes.NotFound)) + }) + + It("clears the current phase when that phase is deleted", func() { + _, err := client.AdvancePhase(adminCtx, &msgs.AdvancePhaseRequest{ + HackathonId: hackathonID, PhaseId: hacking, + }) + Expect(err).NotTo(HaveOccurred()) + + Expect(dbClient.Phase.DeleteOneID(uuid.MustParse(hacking)). + Exec(context.Background())).To(Succeed()) + + got, err := client.Get(adminCtx, &msgs.GetRequest{HackathonId: hackathonID}) + Expect(err).NotTo(HaveOccurred()) + Expect(got.GetHackathon().CurrentPhaseId).To(BeNil()) + }) + }) + + // HackathonState is main's flat shape projected over our capability + // rows — see internal/service/hackathon_state.go. These pin the two + // properties that make it a facade rather than a second model: it + // agrees with the four-state answer it is derived from, and writing + // through it lands on the same stored rows everything else reads. + Describe("HackathonState facade", func() { + // togglesFrom indexes a state's booleans by capability. + togglesFrom := func(st *entities.HackathonState) map[entities.Capability]bool { + out := map[entities.Capability]bool{} + for _, t := range st.GetCapabilities() { + out[t.GetCapability()] = t.GetEnabled() + } + + return out + } + + It("projects OPEN and UNGOVERNED to true, COMING and CLOSED to false", func() { + // One capability of each reachable state: registration closed by + // hand, proposals COMING behind a future phase, the rest open. + _, err := client.EditCapability(adminCtx, &msgs.EditCapabilityRequest{ + HackathonId: hackathonID, + Capability: entities.Capability_CAPABILITY_REGISTER, + Enabled: proto.Bool(false), + }) + Expect(err).NotTo(HaveOccurred()) + + later := newPhase("Later", 10) + _, err = client.EditCapability(adminCtx, &msgs.EditCapabilityRequest{ + HackathonId: hackathonID, + Capability: entities.Capability_CAPABILITY_PROPOSE_PROJECTS, + Enabled: proto.Bool(false), + OpenInPhaseId: proto.String(later), + }) + Expect(err).NotTo(HaveOccurred()) + + got, err := client.Get(adminCtx, &msgs.GetRequest{HackathonId: hackathonID}) + Expect(err).NotTo(HaveOccurred()) + + // The projection must agree with the statuses it flattens, for + // every capability — that is the whole contract. + toggles := togglesFrom(got.GetHackathon().GetState()) + Expect(toggles).To(HaveLen(6)) + for _, c := range got.GetHackathon().GetCapabilities() { + want := c.GetState() == entities.CapabilityState_CAPABILITY_STATE_OPEN || + c.GetState() == entities.CapabilityState_CAPABILITY_STATE_UNGOVERNED + Expect(toggles[c.GetCapability()]).To( + Equal(want), + "facade and status disagree about %v (state %v)", + c.GetCapability(), c.GetState(), + ) + } + + Expect(statusOf(entities.Capability_CAPABILITY_PROPOSE_PROJECTS).GetState()). + To(Equal(entities.CapabilityState_CAPABILITY_STATE_COMING)) + Expect(toggles[entities.Capability_CAPABILITY_REGISTER]).To(BeFalse()) + Expect(toggles[entities.Capability_CAPABILITY_PROPOSE_PROJECTS]).To(BeFalse()) + Expect(toggles[entities.Capability_CAPABILITY_VOTE]).To(BeTrue()) + }) + + It("reports the same state on List as on Get", func() { + _, err := client.EditCapability(adminCtx, &msgs.EditCapabilityRequest{ + HackathonId: hackathonID, + Capability: entities.Capability_CAPABILITY_VOTE, + Enabled: proto.Bool(false), + }) + Expect(err).NotTo(HaveOccurred()) + + got, err := client.Get(adminCtx, &msgs.GetRequest{HackathonId: hackathonID}) + Expect(err).NotTo(HaveOccurred()) + + listed, err := client.List(adminCtx, &msgs.ListRequest{}) + Expect(err).NotTo(HaveOccurred()) + + var fromList *entities.HackathonState + for _, h := range listed.GetHackathons() { + if h.GetId() == hackathonID { + fromList = h.GetState() + } + } + Expect(fromList).NotTo(BeNil(), "hackathon missing from List response") + Expect(togglesFrom(fromList)). + To(Equal(togglesFrom(got.GetHackathon().GetState()))) + }) + + It("writes SetCapabilities booleans onto the stored rows", func() { + // The round trip: main's boolean payload in, our four-state + // answer out, and the stored row changed to match. + resp, err := client.SetCapabilities(adminCtx, &msgs.SetCapabilitiesRequest{ + HackathonId: hackathonID, + Capabilities: []*entities.CapabilityToggle{ + {Capability: entities.Capability_CAPABILITY_VOTE, Enabled: false}, + { + Capability: entities.Capability_CAPABILITY_VIEW_RESULTS, + Enabled: true, + }, + }, + }) + Expect(err).NotTo(HaveOccurred()) + + Expect(togglesFrom(resp.GetState())[entities.Capability_CAPABILITY_VOTE]). + To(BeFalse()) + Expect( + togglesFrom(resp.GetState())[entities.Capability_CAPABILITY_VIEW_RESULTS], + ). + To(BeTrue()) + + // Our own representation moved, not just the facade's. + Expect(statusOf(entities.Capability_CAPABILITY_VOTE).GetState()). + To(Equal(entities.CapabilityState_CAPABILITY_STATE_CLOSED)) + row, err := dbClient.Capability.Query(). + Where( + entcapability.HasHackathonWith( + enthackathon.IDEQ(uuid.MustParse(hackathonID)), + ), + entcapability.CapabilityEQ(entcapability.CapabilityVote), + ). + Only(context.Background()) + Expect(err).NotTo(HaveOccurred()) + Expect(row.Enabled).To(BeFalse()) + }) + + It("carries no enforcement of its own", func() { + // Closing REGISTER through the facade must block Join for the + // same reason EditCapability does: requireCapability read the + // stored row. Nothing about HackathonState is consulted. + _, err := client.SetCapabilities(adminCtx, &msgs.SetCapabilitiesRequest{ + HackathonId: hackathonID, + Capabilities: []*entities.CapabilityToggle{ + { + Capability: entities.Capability_CAPABILITY_REGISTER, + Enabled: false, + }, + }, + }) + Expect(err).NotTo(HaveOccurred()) + + _, err = client.Join(newUser("facade-late-joiner"), &msgs.JoinRequest{ + HackathonId: hackathonID, + }) + Expect(status.Code(err)).To(Equal(codes.FailedPrecondition)) + Expect(err.Error()).To(ContainSubstring("registrations are closed")) + }) + + Describe("SetCurrentPhase", func() { + It("advances and reports the phase in the state", func() { + target := newPhase("Facade Hacking", 2) + + resp, err := client.SetCurrentPhase( + adminCtx, + &msgs.SetCurrentPhaseRequest{ + HackathonId: hackathonID, PhaseId: target, + }, + ) + Expect(err).NotTo(HaveOccurred()) + Expect(resp.GetState().GetCurrentPhaseId()).To(Equal(target)) + Expect(resp.GetState().GetId()).To(Equal(hackathonID)) + Expect(resp.GetState().GetCapabilities()).To(HaveLen(6)) + + got, err := client.Get( + adminCtx, + &msgs.GetRequest{HackathonId: hackathonID}, + ) + Expect(err).NotTo(HaveOccurred()) + Expect(got.GetHackathon().GetCurrentPhaseId()).To(Equal(target)) + }) + + It("clears the current phase on an empty phase_id", func() { + target := newPhase("Facade Judging", 3) + _, err := client.SetCurrentPhase(adminCtx, &msgs.SetCurrentPhaseRequest{ + HackathonId: hackathonID, PhaseId: target, + }) + Expect(err).NotTo(HaveOccurred()) + + resp, err := client.SetCurrentPhase(adminCtx, &msgs.SetCurrentPhaseRequest{ + HackathonId: hackathonID, PhaseId: "", + }) + Expect(err).NotTo(HaveOccurred()) + Expect(resp.GetState().GetCurrentPhaseId()).To(BeEmpty()) + + got, err := client.Get( + adminCtx, + &msgs.GetRequest{HackathonId: hackathonID}, + ) + Expect(err).NotTo(HaveOccurred()) + Expect(got.GetHackathon().CurrentPhaseId).To(BeNil()) + }) + + It("denies a non-owner, exactly as AdvancePhase does", func() { + // The alias must not become a hole around the casbin check. + target := newPhase("Facade Denied", 4) + + _, err := client.SetCurrentPhase( + newUser("facade-bystander"), + &msgs.SetCurrentPhaseRequest{ + HackathonId: hackathonID, PhaseId: target, + }, + ) + Expect(status.Code(err)).To(Equal(codes.PermissionDenied)) + }) + }) + }) + + Describe("List", func() { + // statesFromList pulls this hackathon's capability states out of a + // List response. + statesFromList := func() map[entities.Capability]entities.CapabilityState { + resp, err := client.List(adminCtx, &msgs.ListRequest{}) + Expect(err).NotTo(HaveOccurred()) + + for _, h := range resp.GetHackathons() { + if h.GetId() != hackathonID { + continue + } + out := map[entities.Capability]entities.CapabilityState{} + for _, c := range h.GetCapabilities() { + out[c.GetCapability()] = c.GetState() + } + + return out + } + Fail("hackathon missing from List response") + + return nil + } + + It("reports every capability", func() { + Expect(statesFromList()).To(HaveLen(6)) + }) + + It("agrees with Get", func() { + phaseID := newPhase("Proposals", 5) + _, err := client.EditCapability(adminCtx, &msgs.EditCapabilityRequest{ + HackathonId: hackathonID, + Capability: entities.Capability_CAPABILITY_PROPOSE_PROJECTS, + Enabled: proto.Bool(false), + OpenInPhaseId: proto.String(phaseID), + }) + Expect(err).NotTo(HaveOccurred()) + + got, err := client.Get(adminCtx, &msgs.GetRequest{HackathonId: hackathonID}) + Expect(err).NotTo(HaveOccurred()) + + listed := statesFromList() + for _, c := range got.GetHackathon().GetCapabilities() { + Expect(listed[c.GetCapability()]).To( + Equal(c.GetState()), + "List and Get disagree about %v", c.GetCapability(), + ) + } + }) + + It("resolves COMING by position once advanced, as Get does", func() { + // The reason List loads phases at all. Every phase here is in + // the future, so dates alone would call both capabilities + // COMING; only the organizer's position separates them. + early := newPhase("Early", 5) + current := newPhase("Current", 6) + ahead := newPhase("Ahead", 7) + + _, err := client.AdvancePhase(adminCtx, &msgs.AdvancePhaseRequest{ + HackathonId: hackathonID, PhaseId: current, + }) + Expect(err).NotTo(HaveOccurred()) + + // Disabled after advancing, so the advance cannot re-open them. + for _, link := range []struct { + capability entities.Capability + opens string + }{ + {entities.Capability_CAPABILITY_PROPOSE_PROJECTS, early}, + {entities.Capability_CAPABILITY_CREATE_PROJECT_SUBMISSIONS, ahead}, + } { + _, err := client.EditCapability(adminCtx, &msgs.EditCapabilityRequest{ + HackathonId: hackathonID, + Capability: link.capability, + Enabled: proto.Bool(false), + OpenInPhaseId: proto.String(link.opens), + }) + Expect(err).NotTo(HaveOccurred()) + } + + listed := statesFromList() + + // Behind the current phase: closed, despite a future date. + Expect(listed[entities.Capability_CAPABILITY_PROPOSE_PROJECTS]). + To(Equal(entities.CapabilityState_CAPABILITY_STATE_CLOSED)) + // Still ahead of it: coming. + Expect(listed[entities.Capability_CAPABILITY_CREATE_PROJECT_SUBMISSIONS]). + To(Equal(entities.CapabilityState_CAPABILITY_STATE_COMING)) + + // And the detail page must say the same. + got, err := client.Get(adminCtx, &msgs.GetRequest{HackathonId: hackathonID}) + Expect(err).NotTo(HaveOccurred()) + for _, c := range got.GetHackathon().GetCapabilities() { + Expect(listed[c.GetCapability()]).To(Equal(c.GetState())) + } + }) + }) + + It("survives deletion of the linked phase", func() { + phaseID := newPhase("Doomed", 5) + _, err := client.EditCapability(adminCtx, &msgs.EditCapabilityRequest{ + HackathonId: hackathonID, + Capability: entities.Capability_CAPABILITY_PROPOSE_PROJECTS, + Enabled: proto.Bool(false), + OpenInPhaseId: proto.String(phaseID), + }) + Expect(err).NotTo(HaveOccurred()) + + // The owner UI can delete phases; that must not take the + // capability with it. + Expect(dbClient.Phase.DeleteOneID(uuid.MustParse(phaseID)). + Exec(context.Background())).To(Succeed()) + + got := statusOf(entities.Capability_CAPABILITY_PROPOSE_PROJECTS) + Expect(got.GetState()).To( + Equal(entities.CapabilityState_CAPABILITY_STATE_CLOSED), + ) + Expect(got.OpenInPhaseId).To(BeNil()) + }) + }) + }) + + Describe("HackathonSettings", func() { + var createdHackathonID string + + BeforeEach(func() { + token := testutils.CreateTestJWTToken(testAdmin) + ctx := metadata.NewOutgoingContext( + context.Background(), + metadata.Pairs("authorization", "Bearer "+token), + ) + + createReq := &msgs.CreateRequest{ + Name: "Settings Test Hackathon", + Visibility: entities.Visibility_VISIBILITY_PUBLIC, + } + + createResp, err := client.Create(ctx, createReq) + Expect(err).NotTo(HaveOccurred()) + createdHackathonID = createResp.GetHackathonId() + }) + + Describe("Create creates default settings", func() { + It("creates settings with both flags disabled by default", func() { + // Verify settings exist in database with defaults + settings, err := dbClient.HackathonSettings.Query(). + Where(enthackathonsettings.HasHackathonWith( + enthackathon.IDEQ(uuid.MustParse(createdHackathonID)), + )).Only(context.Background()) + Expect(err).NotTo(HaveOccurred()) + Expect(settings.RegistrationsEnabled).To(BeFalse()) + Expect(settings.VotingEnabled).To(BeFalse()) + }) + }) + + Describe("Get returns settings", func() { + It("includes settings in Get response", func() { + token := testutils.CreateTestJWTToken(testAdmin) + ctx := metadata.NewOutgoingContext( + context.Background(), + metadata.Pairs("authorization", "Bearer "+token), + ) + + getReq := &msgs.GetRequest{HackathonId: createdHackathonID} + getResp, err := client.Get(ctx, getReq) + Expect(err).NotTo(HaveOccurred()) + + h := getResp.GetHackathon() + Expect(h.GetSettings()).NotTo(BeNil()) + settings := h.GetSettings() + Expect(settings.GetRegistrationsEnabled()).To(BeFalse()) + Expect(settings.GetVotingEnabled()).To(BeFalse()) + Expect(settings.GetId()).NotTo(BeEmpty()) + Expect(settings.GetModifiedAt()).NotTo(BeNil()) + }) + }) + + Describe("EditSettings", func() { + It("enables registrations", func() { + token := testutils.CreateTestJWTToken(testAdmin) + ctx := metadata.NewOutgoingContext( + context.Background(), + metadata.Pairs("authorization", "Bearer "+token), + ) + + enabled := true + req := &msgs.EditSettingsRequest{ + HackathonId: createdHackathonID, + RegistrationsEnabled: &enabled, + } + + resp, err := client.EditSettings(ctx, req) + Expect(err).NotTo(HaveOccurred()) + Expect(resp.GetSettings().GetRegistrationsEnabled()).To(BeTrue()) + Expect(resp.GetSettings().GetVotingEnabled()).To(BeFalse()) + + // Verify in database + settings, err := dbClient.HackathonSettings.Query(). + Where(enthackathonsettings.HasHackathonWith( + enthackathon.IDEQ(uuid.MustParse(createdHackathonID)), + )).Only(context.Background()) + Expect(err).NotTo(HaveOccurred()) + Expect(settings.RegistrationsEnabled).To(BeTrue()) + Expect(settings.VotingEnabled).To(BeFalse()) + }) + + It("enables voting", func() { + token := testutils.CreateTestJWTToken(testAdmin) + ctx := metadata.NewOutgoingContext( + context.Background(), + metadata.Pairs("authorization", "Bearer "+token), + ) + + enabled := true + req := &msgs.EditSettingsRequest{ + HackathonId: createdHackathonID, + VotingEnabled: &enabled, + } + + resp, err := client.EditSettings(ctx, req) + Expect(err).NotTo(HaveOccurred()) + Expect(resp.GetSettings().GetVotingEnabled()).To(BeTrue()) + Expect(resp.GetSettings().GetRegistrationsEnabled()).To(BeFalse()) + }) + + It("enables both flags together", func() { + token := testutils.CreateTestJWTToken(testAdmin) + ctx := metadata.NewOutgoingContext( + context.Background(), + metadata.Pairs("authorization", "Bearer "+token), + ) + + enabled := true + req := &msgs.EditSettingsRequest{ + HackathonId: createdHackathonID, + RegistrationsEnabled: &enabled, + VotingEnabled: &enabled, + } + + resp, err := client.EditSettings(ctx, req) + Expect(err).NotTo(HaveOccurred()) + Expect(resp.GetSettings().GetRegistrationsEnabled()).To(BeTrue()) + Expect(resp.GetSettings().GetVotingEnabled()).To(BeTrue()) + }) + + It("disables previously enabled flags", func() { + // First enable registrations + token := testutils.CreateTestJWTToken(testAdmin) + ctx := metadata.NewOutgoingContext( + context.Background(), + metadata.Pairs("authorization", "Bearer "+token), + ) + enabled := true + _, err := client.EditSettings(ctx, &msgs.EditSettingsRequest{ + HackathonId: createdHackathonID, + RegistrationsEnabled: &enabled, + }) + Expect(err).NotTo(HaveOccurred()) + + // Now disable it + disabled := false + resp, err := client.EditSettings(ctx, &msgs.EditSettingsRequest{ + HackathonId: createdHackathonID, + RegistrationsEnabled: &disabled, + }) + Expect(err).NotTo(HaveOccurred()) + Expect(resp.GetSettings().GetRegistrationsEnabled()).To(BeFalse()) + }) + + It("returns NOT_FOUND for invalid hackathon ID", func() { + token := testutils.CreateTestJWTToken(testAdmin) + ctx := metadata.NewOutgoingContext( + context.Background(), + metadata.Pairs("authorization", "Bearer "+token), + ) + + req := &msgs.EditSettingsRequest{ + HackathonId: uuid.NewString(), + } + + _, err := client.EditSettings(ctx, req) + Expect(err).To(HaveOccurred()) + st := status.Convert(err) + Expect(st.Code()).To(Equal(codes.NotFound)) + }) + + It("requires Write permission", func() { + nonOwnerKeycloakID := "non-owner-settings" + _, err := dbClient.User.Create(). + SetKeycloakID(nonOwnerKeycloakID). + SetUsername("non-owner-settings-username"). + Save(context.Background()) + Expect(err).NotTo(HaveOccurred()) + + token := testutils.CreateTestJWTToken(nonOwnerKeycloakID) + ctx := metadata.NewOutgoingContext( + context.Background(), + metadata.Pairs("authorization", "Bearer "+token), + ) + + enabled := true + req := &msgs.EditSettingsRequest{ + HackathonId: createdHackathonID, + RegistrationsEnabled: &enabled, + } + + _, err = client.EditSettings(ctx, req) + Expect(err).To(HaveOccurred()) + st := status.Convert(err) + Expect(st.Code()).To(Equal(codes.PermissionDenied)) + }) + + It("denies anonymous users", func() { + req := &msgs.EditSettingsRequest{ + HackathonId: createdHackathonID, + RegistrationsEnabled: testutils.BoolPtr(true), + } + + _, err := client.EditSettings(context.Background(), req) + Expect(err).To(HaveOccurred()) + st := status.Convert(err) + Expect(st.Code()).To(Equal(codes.Unauthenticated)) + }) + + It("returns settings with modified_at timestamp", func() { + token := testutils.CreateTestJWTToken(testAdmin) + ctx := metadata.NewOutgoingContext( + context.Background(), + metadata.Pairs("authorization", "Bearer "+token), + ) + + req := &msgs.EditSettingsRequest{ + HackathonId: createdHackathonID, + RegistrationsEnabled: testutils.BoolPtr(true), + } + + resp, err := client.EditSettings(ctx, req) + Expect(err).NotTo(HaveOccurred()) + Expect(resp.GetSettings().GetModifiedAt()).NotTo(BeNil()) + }) + }) + }) }) diff --git a/components/backend/internal/service/hackathon_state.go b/components/backend/internal/service/hackathon_state.go new file mode 100644 index 00000000..d4b3cdd7 --- /dev/null +++ b/components/backend/internal/service/hackathon_state.go @@ -0,0 +1,178 @@ +package service + +import ( + "context" + "log/slog" + + "github.com/google/uuid" + "github.com/swissdatasciencecenter/hackagon/components/backend/internal/capability" + ents "github.com/swissdatasciencecenter/hackagon/components/backend/internal/proto/hackathon/entities" + msgs "github.com/swissdatasciencecenter/hackagon/components/backend/internal/proto/hackathon/messages/hackathon_svc" + "google.golang.org/protobuf/types/known/timestamppb" +) + +// The HackathonState facade. +// +// Upstream `main` models "what is switched on in this event" as one record of +// booleans; we model it as one row per capability with four states, a schedule +// and per-row audit. Ours is the superset and stays the single source of truth. +// This file adds main's shape on top of ours as a pure projection, so a client +// written against main's contract can read and write our rows. +// +// Nothing here enforces anything. Main gates by writing casbin policy from +// SetCapabilities; that path is deliberately not ported. `requireCapability`, +// reading the stored rows, remains the only gate — which is why every function +// below is a mapper and none of them touches the enforcer. +// +// The projection is `capability.State.Allowed()`: OPEN and UNGOVERNED are true, +// COMING and CLOSED are false. That is the same predicate the enforcement path +// uses, so the boolean a client reads is exactly what the server will permit. + +// capabilityStateFromProto is the inverse of capabilityStateToProto. An +// unrecognised value maps to the empty State, which Allowed() reports as false +// — a state this binary cannot name must not be projected as "on". +func capabilityStateFromProto(s ents.CapabilityState) capability.State { + switch s { + case ents.CapabilityState_CAPABILITY_STATE_OPEN: + return capability.StateOpen + case ents.CapabilityState_CAPABILITY_STATE_CLOSED: + return capability.StateClosed + case ents.CapabilityState_CAPABILITY_STATE_COMING: + return capability.StateComing + case ents.CapabilityState_CAPABILITY_STATE_UNGOVERNED: + return capability.StateUngoverned + case ents.CapabilityState_CAPABILITY_STATE_UNSPECIFIED: + return "" + default: + return "" + } +} + +// hackathonStateFrom flattens resolved capability statuses into main's shape. +// +// `modifiedAt` is the most recent modification across the rows, since that is +// when the state last actually changed; `fallbackModifiedAt` (the hackathon's +// own) stands in when no row has ever been touched, which is the case for a +// freshly created event and for every ungoverned capability. +// +// Order is whatever the statuses came in, which is `capability.All()` order +// from capabilityStatusesFromEnt — stable across calls, so a client diffing two +// responses compares like with like. +func hackathonStateFrom( + hackathonID string, + createdAt *timestamppb.Timestamp, + fallbackModifiedAt *timestamppb.Timestamp, + currentPhaseID string, + statuses []*ents.CapabilityStatus, +) *ents.HackathonState { + toggles := make([]*ents.CapabilityToggle, 0, len(statuses)) + modifiedAt := fallbackModifiedAt + + for _, st := range statuses { + toggles = append(toggles, &ents.CapabilityToggle{ + Capability: st.GetCapability(), + Enabled: capabilityStateFromProto(st.GetState()).Allowed(), + }) + if m := st.GetModifiedAt(); m != nil && + (modifiedAt == nil || m.AsTime().After(modifiedAt.AsTime())) { + modifiedAt = m + } + } + + return &ents.HackathonState{ + Id: hackathonID, + CreatedAt: createdAt, + ModifiedAt: modifiedAt, + CurrentPhaseId: currentPhaseID, + Capabilities: toggles, + } +} + +// hackathonStateFromEntry is the read-path form: the entry already carries the +// capabilities, the timestamps and the current phase, so the facade costs no +// query. Call it AFTER `Capabilities` is populated, or it projects an empty +// state over a hackathon that has one. +func hackathonStateFromEntry(e *ents.Hackathon) *ents.HackathonState { + if e == nil { + return nil + } + + return hackathonStateFrom( + e.GetId(), + e.GetCreatedAt(), + e.GetModifiedAt(), + // Optional on Hackathon, a plain string here: absent reads as "" on both, + // and main's field is not optional. + e.GetCurrentPhaseId(), + e.GetCapabilities(), + ) +} + +// hackathonStateFacade is the write-path form, for handlers that hold statuses +// but no entry. Best-effort like capabilityStatuses: the mutation has already +// committed, so a failure to read the timestamps back costs the caller a +// refetch rather than an error on work that landed. +func (s *HackathonService) hackathonStateFacade( + ctx context.Context, + id uuid.UUID, + statuses []*ents.CapabilityStatus, + currentPhaseID string, +) *ents.HackathonState { + var createdAt, modifiedAt *timestamppb.Timestamp + if h, err := s.dbClient.Hackathon.Get(ctx, id); err == nil { + createdAt = timestamppb.New(h.CreatedAt) + modifiedAt = timestamppb.New(h.ModifiedAt) + } else { + slog.Error("query hackathon for state facade", "err", err) + } + + return hackathonStateFrom(id.String(), createdAt, modifiedAt, currentPhaseID, statuses) +} + +// SetCurrentPhase is main's name for AdvancePhase, and a thin alias over it. +// +// Every rule lives in AdvancePhase and none is duplicated here: the casbin +// Write check, the "phase must belong to this hackathon" check, applying the +// capabilities scheduled for the target phase, and reading an empty phase_id as +// "clear the current phase". The request carries the same CEL rules as +// AdvancePhaseRequest so protovalidate has already accepted an empty phase_id +// by the time this runs — this method calls the handler directly, which is +// past the interceptor. +// +// Only the answer differs: main's flat HackathonState rather than the +// CapabilityStatus list. Native callers should keep using AdvancePhase, which +// reports the schedule and the audit this flattens away. +func (s *HackathonService) SetCurrentPhase( + ctx context.Context, + req *msgs.SetCurrentPhaseRequest, +) (*msgs.SetCurrentPhaseResponse, error) { + advanced, err := s.AdvancePhase(ctx, &msgs.AdvancePhaseRequest{ + HackathonId: req.GetHackathonId(), + PhaseId: req.GetPhaseId(), + }) + if err != nil { + return nil, err + } + + // AdvancePhase parsed and authorised this id already, so a parse failure + // here is unreachable; falling back to the raw string keeps the response + // well-formed rather than empty if that ever stops being true. + id, parseErr := uuid.Parse(req.GetHackathonId()) + //nolint:nilerr // unreachable per the comment above: AdvancePhase already + // parsed the same id successfully, so this is a graceful fallback for an + // invariant, not a swallowed real failure. + if parseErr != nil { + return &msgs.SetCurrentPhaseResponse{ + State: hackathonStateFrom( + req.GetHackathonId(), nil, nil, + advanced.GetCurrentPhaseId(), advanced.GetCapabilities(), + ), + }, nil + } + + return &msgs.SetCurrentPhaseResponse{ + State: s.hackathonStateFacade( + ctx, id, advanced.GetCapabilities(), advanced.GetCurrentPhaseId(), + ), + }, nil +} diff --git a/components/backend/internal/service/imageref.go b/components/backend/internal/service/imageref.go new file mode 100644 index 00000000..38db9158 --- /dev/null +++ b/components/backend/internal/service/imageref.go @@ -0,0 +1,65 @@ +package service + +import ( + "net/url" + "strings" + + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" +) + +// imageRefMessage names both accepted shapes, because a person who pasted the +// wrong thing cannot tell which rule they broke from "invalid". +// +// It must keep the substring "http": the profile page shows the backend's own +// message, and tests/smoke/11-profile.spec.ts asserts on it. +const imageRefMessage = "must be an http or https link, or an uploaded image " + + "path like /objects/…" + +// checkImageRef validates a value that will be interpolated into an <img src>. +// +// Two shapes are legitimate and a third is not: +// +// - empty, which is how a picture is REMOVED. This is why the check is here +// rather than a proto `uri: true` constraint — that would refuse the empty +// string and make removal impossible. +// - an http/https absolute URL: imagery hosted somewhere else, which stays a +// legitimate way to point at a picture. +// - a root-relative path, which is what StorageService hands back for an +// upload (`/objects/<bucket>/<key>`). It is deliberately relative so one +// stored value resolves from localhost, from the tunnel and from a +// deployment alike — an absolute http://localhost:9000/… would work only on +// the machine that minted it. Rejecting it is what made "upload a profile +// picture" impossible while the upload itself worked: the presign succeeded, +// the bytes were stored, and saving the path came back INVALID_ARGUMENT. +// +// Refused: anything with another scheme, because `javascript:` executes and +// `data:` lets one user store an arbitrary payload that other people's browsers +// fetch. And two shapes that LOOK like paths and are not — `//host/x` is +// protocol-relative, and `/\host/x` is the same thing to a browser, which +// normalizes the backslash. Both point at another origin. +func checkImageRef(field, value string) error { + if value == "" { + return nil + } + + if strings.HasPrefix(value, "/") { + if strings.HasPrefix(value, "//") || strings.HasPrefix(value, `/\`) { + return status.Errorf(codes.InvalidArgument, "%s %s", field, imageRefMessage) + } + for _, r := range value { + if r < 0x20 || r == 0x7f { + return status.Errorf(codes.InvalidArgument, "%s %s", field, imageRefMessage) + } + } + + return nil + } + + parsed, err := url.Parse(value) + if err != nil || (parsed.Scheme != "http" && parsed.Scheme != "https") { + return status.Errorf(codes.InvalidArgument, "%s %s", field, imageRefMessage) + } + + return nil +} diff --git a/components/backend/internal/service/imageref_internal_test.go b/components/backend/internal/service/imageref_internal_test.go new file mode 100644 index 00000000..8de11e35 --- /dev/null +++ b/components/backend/internal/service/imageref_internal_test.go @@ -0,0 +1,54 @@ +//go:build test && unittest + +package service + +import ( + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +// The <img src> rule as the pure function it is. Worth its own spec because two +// of its cases are the ones that get written wrong: the root-relative path an +// upload produces (rejecting it made "change my profile picture" impossible +// while the upload itself worked), and the two shapes that look like paths and +// point at another origin. + +var _ = Describe("checkImageRef", func() { + It("accepts empty, which is how a picture is removed", func() { + Expect(checkImageRef("avatar_url", "")).To(Succeed()) + }) + + It("accepts an http or https link", func() { + Expect(checkImageRef("avatar_url", "https://example.org/me.png")).To(Succeed()) + Expect(checkImageRef("avatar_url", "http://example.org/me.png")).To(Succeed()) + }) + + It("accepts the root-relative path StorageService mints", func() { + Expect(checkImageRef("avatar_url", + "/objects/hackagon/users/1/avatar/2.webp")).To(Succeed()) + }) + + It("refuses a scheme that executes or embeds a payload", func() { + Expect(checkImageRef("avatar_url", "javascript:alert(1)")).NotTo(Succeed()) + Expect(checkImageRef("avatar_url", "data:image/svg+xml;base64,AAA")).NotTo(Succeed()) + Expect(checkImageRef("avatar_url", "ftp://example.org/me.png")).NotTo(Succeed()) + }) + + It("refuses the two shapes that look like paths and are not", func() { + // Protocol-relative, and the same thing again once a browser + // normalizes the backslash. Both fetch from another origin. + Expect(checkImageRef("avatar_url", "//evil.example/me.png")).NotTo(Succeed()) + Expect(checkImageRef("avatar_url", `/\evil.example/me.png`)).NotTo(Succeed()) + }) + + It("refuses a control character smuggled into a path", func() { + Expect(checkImageRef("avatar_url", "/objects/a\nb.webp")).NotTo(Succeed()) + }) + + It("says which shapes are allowed, and keeps saying http", func() { + // The profile page surfaces this message verbatim. + err := checkImageRef("avatar_url", "javascript:alert(1)") + Expect(err).To(MatchError(ContainSubstring("http"))) + Expect(err).To(MatchError(ContainSubstring("/objects/"))) + }) +}) diff --git a/components/backend/internal/service/mappers.go b/components/backend/internal/service/mappers.go index 2adb54ed..9508b388 100644 --- a/components/backend/internal/service/mappers.go +++ b/components/backend/internal/service/mappers.go @@ -8,6 +8,8 @@ import ( enthackathon "github.com/swissdatasciencecenter/hackagon/components/backend/ent/hackathon" hackEnts "github.com/swissdatasciencecenter/hackagon/components/backend/internal/proto/hackathon/entities" userEnts "github.com/swissdatasciencecenter/hackagon/components/backend/internal/proto/user/entities" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" "google.golang.org/protobuf/types/known/timestamppb" ) @@ -20,6 +22,12 @@ func userEntryFromEnt(u *ent.User) *userEnts.User { Email: u.Email, CreatedAt: timestamppb.New(u.CreatedAt), ModifiedAt: timestamppb.New(u.ModifiedAt), + // The platform's own profile. Sent as stored, empty string and all: an + // unfilled field is a normal state and every reader treats "" that way. + Affiliation: u.Affiliation, + Skills: u.Skills, + Dietary: u.Dietary, + AvatarUrl: u.AvatarURL, } } @@ -58,6 +66,84 @@ func computeHackathonStatus(startsAt, endsAt *time.Time, now time.Time) hackEnts return hackEnts.HackathonStatus_HACKATHON_STATUS_ACTIVE } +// brandingEntryFromEnt maps the free-form branding map stored on the forms row +// (written by ConfigService.SetBranding) onto the wire entity. +// +// Returns nil when nothing usable is set, so "no branding" is an absent field +// rather than a message full of empty strings — clients can then treat presence +// as "this event has a look of its own" without inspecting every member. +func brandingEntryFromEnt(b map[string]string) *hackEnts.HackathonBranding { + if len(b) == 0 { + return nil + } + e := &hackEnts.HackathonBranding{} + set := false + // Each `if` scopes its own copy, so taking the address is safe. + if v, ok := b["primaryColor"]; ok && v != "" { + e.PrimaryColor = &v + set = true + } + if v, ok := b["accentColor"]; ok && v != "" { + e.AccentColor = &v + set = true + } + if v, ok := b["bannerText"]; ok && v != "" { + e.BannerText = &v + set = true + } + if !set { + return nil + } + + return e +} + +// votingPolicyEntryFromEnt maps the free-form policy map stored on the forms row +// (written by ConfigService.SetVotingPolicy) onto the wire entity. +// +// Every value is read defensively: the column is `map[string]any` JSON, so a +// row written by an older build — or by hand — can hold anything. A field that +// is not the expected type is dropped rather than guessed at, and a row with +// nothing usable returns nil so "no policy" stays an absent field. +func votingPolicyEntryFromEnt(p map[string]any) *hackEnts.HackathonVotingPolicy { + if len(p) == 0 { + return nil + } + e := &hackEnts.HackathonVotingPolicy{} + if v, ok := p["mechanism"].(string); ok { + e.Mechanism = v + } + if v, ok := p["oneBallotPer"].(string); ok { + e.OneBallotPer = v + } + if v, ok := p["ownTeamVoting"].(bool); ok { + e.OwnTeamVoting = v + } + if v, ok := p["organizerVoting"].(bool); ok { + e.OrganizerVoting = v + } + if raw, ok := p["tieBreak"].([]any); ok { + for _, item := range raw { + if v, ok := item.(string); ok { + e.TieBreak = append(e.TieBreak, v) + } + } + } + // JSON numbers decode as float64 even when they were written as ints. + if raw, ok := p["scale"].(map[string]any); ok { + scale := &hackEnts.ScaleRange{} + if v, ok := raw["min"].(float64); ok { + scale.Min = int32(v) + } + if v, ok := raw["max"].(float64); ok { + scale.Max = int32(v) + } + e.Scale = scale + } + + return e +} + func hackathonEntryFromEnt(h *ent.Hackathon, now time.Time) *hackEnts.Hackathon { e := &hackEnts.Hackathon{ Id: h.ID.String(), @@ -81,6 +167,47 @@ func hackathonEntryFromEnt(h *ent.Hackathon, now time.Time) *hackEnts.Hackathon l := h.Logo e.Logo = &l } + // A plain column on `hackathons`, not an edge, so this is populated on List + // as well as Get — no eager load needed. + if h.CurrentPhaseID != nil { + p := h.CurrentPhaseID.String() + e.CurrentPhaseId = &p + } + // Also a plain column. Absent on the wire when unlimited (NULL), so the + // one state has one spelling for clients too. + if h.MaxParticipants != nil && *h.MaxParticipants > 0 { + v := *h.MaxParticipants + e.MaxParticipants = &v + } + // Branding lives on the forms row, so it only arrives when the caller + // eager-loaded WithForms(). A missing edge is indistinguishable from no + // branding here on purpose: both mean "render the default theme". + if h.Edges.Forms != nil { + e.Branding = brandingEntryFromEnt(h.Edges.Forms.Branding) + // The rules of the vote, for the people the vote binds — see + // HackathonVotingPolicy on why this is not organizer-only. + e.VotingPolicy = votingPolicyEntryFromEnt(h.Edges.Forms.VotingPolicy) + // The schemas a client needs to RENDER the registration/submission + // forms it is about to fill in. Without them the only way to complete + // one was to guess the organizer's field keys. Nil when unset, which + // the write path reads as "accept anything". + if len(h.Edges.Forms.RegistrationFields) > 0 || + len(h.Edges.Forms.RegistrationConsents) > 0 { + e.RegistrationForm = formSchemaFromJSON( + h.Edges.Forms.RegistrationFields, h.Edges.Forms.RegistrationConsents, + ) + } + if len(h.Edges.Forms.SubmissionFields) > 0 { + e.SubmissionForm = formSchemaFromJSON(h.Edges.Forms.SubmissionFields, nil) + } + if len(h.Edges.Forms.EmailTemplates) > 0 { + // Copy the map: the wire message must not alias the DB row. + e.EmailTemplates = make(map[string]string, len(h.Edges.Forms.EmailTemplates)) + for k, v := range h.Edges.Forms.EmailTemplates { + e.EmailTemplates[k] = v + } + } + } return e } @@ -278,3 +405,54 @@ func phaseEntryFromEnt(p *ent.Phase, hackathonID uuid.UUID) *hackEnts.Phase { return e } + +func settingsEntryFromEnt(s *ent.HackathonSettings) *hackEnts.HackathonSettings { + return &hackEnts.HackathonSettings{ + Id: s.ID.String(), + RegistrationsEnabled: s.RegistrationsEnabled, + VotingEnabled: s.VotingEnabled, + ModifiedAt: timestamppb.New(s.ModifiedAt), + } +} + +// validateAgainstFormSchema checks answers against an organizer-defined form +// schema (the []map{key,label,type,required} shape stored on HackathonForms). +// Same rules the registration form uses: unknown keys are rejected so a typo +// is never silently accepted, and every required field must be present and +// non-empty. +// +// A nil/empty schema means the organizer defined no form, so anything goes — +// validation is opt-in by configuring one. +func validateAgainstFormSchema( + schema []map[string]any, + answers map[string]string, + what string, +) error { + if len(schema) == 0 { + return nil + } + + fieldByKey := make(map[string]map[string]any, len(schema)) + for _, f := range schema { + if k, ok := f["key"].(string); ok { + fieldByKey[k] = f + } + } + + for k := range answers { + if _, ok := fieldByKey[k]; !ok { + return status.Errorf(codes.InvalidArgument, "unknown %s field %q", what, k) + } + } + for k, f := range fieldByKey { + if required, _ := f["required"].(bool); required { + if v, ok := answers[k]; !ok || v == "" { + return status.Errorf( + codes.InvalidArgument, "missing required %s field %q", what, k, + ) + } + } + } + + return nil +} diff --git a/components/backend/internal/service/page_service.go b/components/backend/internal/service/page_service.go index 784dc73f..0ac3fa0a 100644 --- a/components/backend/internal/service/page_service.go +++ b/components/backend/internal/service/page_service.go @@ -42,12 +42,10 @@ func (s *PageService) List( return nil, status.Errorf(codes.InvalidArgument, "invalid hackathon_id: %v", err) } - if err := s.enforcer.RequirePermission(ctx, hackathonID.String(), mw.Page, mw.Read); err != nil { - return nil, err - } - - // Verify hackathon exists - _, err = s.dbClient.Hackathon.Query().Where(enthackathon.IDEQ(hackathonID)).Only(ctx) + // Verify hackathon exists before enforcing — otherwise a nonexistent id + // would surface as the permission error from the fallback below instead of + // NotFound. + h, err := s.dbClient.Hackathon.Query().Where(enthackathon.IDEQ(hackathonID)).Only(ctx) if err != nil { if ent.IsNotFound(err) { return nil, status.Errorf( @@ -61,6 +59,15 @@ func (s *PageService) List( return nil, status.Error(codes.Internal, "couldn't query database") } + if err := s.enforcer.RequirePermission(ctx, hackathonID.String(), mw.Page, mw.Read); err != nil { + // Pages of a PUBLIC hackathon are public content — winners + // announcements and wrap-up posts are meant for everyone. Private ones + // still deny outsiders. + if h.Visibility != enthackathon.VisibilityPublic { + return nil, err + } + } + // Query pages ordered by order field with creator and modifier pageQuery := s.dbClient.Page.Query(). Where(entpage.HasHackathonWith(enthackathon.IDEQ(hackathonID))) @@ -614,7 +621,7 @@ func (s *PageService) SetOrder( if !slices.Contains(pageIDs, page.ID.String()) { return nil, status.Error( codes.InvalidArgument, - "SetOrder requires all pages to be passed for reordering2", + "SetOrder requires all pages to be passed for reordering", ) } } diff --git a/components/backend/internal/service/page_service_test.go b/components/backend/internal/service/page_service_test.go index 72c59a5a..a6507bb0 100644 --- a/components/backend/internal/service/page_service_test.go +++ b/components/backend/internal/service/page_service_test.go @@ -115,7 +115,7 @@ var _ = Describe("PageService", func() { Expect(err).NotTo(BeNil()) st := status.Convert(err) - Expect(st.Code()).To(Equal(codes.PermissionDenied)) + Expect(st.Code()).To(Equal(codes.Unauthenticated)) }) It("denies non-admin users without roles", func() { diff --git a/components/backend/internal/service/phase_service.go b/components/backend/internal/service/phase_service.go index 9da474be..4f6dae44 100644 --- a/components/backend/internal/service/phase_service.go +++ b/components/backend/internal/service/phase_service.go @@ -173,14 +173,23 @@ func (s *PhaseService) Create( return nil, status.Error(codes.Internal, "couldn't start transaction") } - // Create the phase - p, err := txn.Phase.Create(). + // Create the phase. Dates included: the proto has carried starts_at/ends_at + // from the start and Create silently DROPPED them (audit B4) — every phase + // was born undated, the timeline never showed one "In progress", and only + // an Edit after the fact could add what the caller already said. + create := txn.Phase.Create(). SetHackathonID(hackathonID). SetName(req.GetName()). SetDescription(req.GetDescription()). SetCreator(user). - SetModifier(user). - Save(ctx) + SetModifier(user) + if req.GetStartsAt() != nil { + create.SetStartsAt(req.GetStartsAt().AsTime()) + } + if req.GetEndsAt() != nil { + create.SetEndsAt(req.GetEndsAt().AsTime()) + } + p, err := create.Save(ctx) if err != nil { if rbErr := txn.Rollback(); rbErr != nil { slog.Error("rollback transaction after create failure", "err", err, "rollback", rbErr) diff --git a/components/backend/internal/service/phase_service_test.go b/components/backend/internal/service/phase_service_test.go index 8e3756a1..7b6903bd 100644 --- a/components/backend/internal/service/phase_service_test.go +++ b/components/backend/internal/service/phase_service_test.go @@ -4,6 +4,7 @@ package service_test import ( "context" + "time" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" @@ -11,6 +12,7 @@ import ( "google.golang.org/grpc/codes" "google.golang.org/grpc/metadata" "google.golang.org/grpc/status" + "google.golang.org/protobuf/types/known/timestamppb" "github.com/google/uuid" @@ -97,6 +99,43 @@ var _ = Describe("PhaseService", func() { Expect(phase.Description).To(Equal("Test description")) }) + It("persists the dates it was given (audit B4)", func() { + token := testutils.CreateTestJWTToken(testAdmin) + ctx := metadata.AppendToOutgoingContext( + context.Background(), "authorization", "Bearer "+token, + ) + + hackathonClient := hackathonSvc.NewHackathonServiceClient(conn) + createHackResp, err := hackathonClient.Create(ctx, &hackathonMsgs.CreateRequest{ + Name: "Dated Phase Hackathon", + Visibility: 2, // PUBLIC + }) + Expect(err).NotTo(HaveOccurred()) + + starts := timestamppb.New(time.Date(2027, 3, 1, 9, 0, 0, 0, time.UTC)) + ends := timestamppb.New(time.Date(2027, 3, 2, 18, 0, 0, 0, time.UTC)) + resp, err := client.Create(ctx, &phaseMsgs.CreateRequest{ + HackathonId: createHackResp.GetHackathonId(), + Name: "Dated Phase", + Description: "Carries its own schedule", + StartsAt: starts, + EndsAt: ends, + }) + Expect(err).NotTo(HaveOccurred()) + + // The proto carried starts_at/ends_at from the beginning and Create + // silently dropped both — every phase was born undated and only an + // Edit could add what the caller already said. + phase, err := dbClient.Phase.Query(). + Where(entphase.IDEQ(uuid.MustParse(resp.GetPhaseId()))). + Only(context.Background()) + Expect(err).NotTo(HaveOccurred()) + Expect(phase.StartsAt).NotTo(BeNil()) + Expect(phase.StartsAt.UTC()).To(Equal(starts.AsTime())) + Expect(phase.EndsAt).NotTo(BeNil()) + Expect(phase.EndsAt.UTC()).To(Equal(ends.AsTime())) + }) + It("requires authentication to create", func() { // No auth header - anonymous ctx := context.Background() @@ -112,7 +151,7 @@ var _ = Describe("PhaseService", func() { Expect(err).NotTo(BeNil()) st := status.Convert(err) - Expect(st.Code()).To(Equal(codes.PermissionDenied)) + Expect(st.Code()).To(Equal(codes.Unauthenticated)) }) It("denies non-admin users without roles", func() { diff --git a/components/backend/internal/service/prize_service.go b/components/backend/internal/service/prize_service.go new file mode 100644 index 00000000..362c3b12 --- /dev/null +++ b/components/backend/internal/service/prize_service.go @@ -0,0 +1,331 @@ +package service + +import ( + "context" + "log/slog" + "strings" + + "github.com/google/uuid" + "github.com/swissdatasciencecenter/hackagon/components/backend/ent" + enthackathon "github.com/swissdatasciencecenter/hackagon/components/backend/ent/hackathon" + enthackathonprizes "github.com/swissdatasciencecenter/hackagon/components/backend/ent/hackathonprizes" + entuser "github.com/swissdatasciencecenter/hackagon/components/backend/ent/user" + m "github.com/swissdatasciencecenter/hackagon/components/backend/internal/middleware" + "github.com/swissdatasciencecenter/hackagon/components/backend/internal/proto/hackathon" + ents "github.com/swissdatasciencecenter/hackagon/components/backend/internal/proto/hackathon/entities" + prizeMsgs "github.com/swissdatasciencecenter/hackagon/components/backend/internal/proto/hackathon/messages/prize_svc" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" +) + +type PrizeService struct { + hackathon.UnimplementedPrizeServiceServer + dbClient *ent.Client + enforcer *m.Enforcer +} + +func NewPrizeService(dbClient *ent.Client, enf *m.Enforcer) *PrizeService { + return &PrizeService{ + UnimplementedPrizeServiceServer: hackathon.UnimplementedPrizeServiceServer{}, + dbClient: dbClient, + enforcer: enf, + } +} + +func prizesFromJSON(rows []map[string]any) []*ents.Prize { + out := make([]*ents.Prize, 0, len(rows)) + for _, r := range rows { + //exhaustruct:ignore + p := &ents.Prize{} + if v, ok := r["rank"].(float64); ok { + p.Rank = int32(v) + } + if v, ok := r["title"].(string); ok { + p.Title = v + } + // Absent on every row written before the field existed, which is why it + // is only set when non-empty: an empty string would render as a broken + // image rather than as no image. + if v, ok := r["image"].(string); ok && v != "" { + p.Image = &v + } + out = append(out, p) + } + + return out +} + +// prizeRowFor returns the hackathon's prize row, or nil when none exists. +func (s *PrizeService) prizeRowFor( + ctx context.Context, + hackathonID uuid.UUID, +) (*ent.HackathonPrizes, error) { + p, err := s.dbClient.HackathonPrizes.Query(). + Where(enthackathonprizes.HasHackathonWith(enthackathon.IDEQ(hackathonID))). + Only(ctx) + if err != nil { + if ent.IsNotFound(err) { + //nolint:nilnil // documented contract: nil,nil is "no row yet", every + // caller already checks it as a distinct state from an error + return nil, nil + } + slog.Error("query hackathon prizes", "err", err) + + return nil, status.Error(codes.Internal, "couldn't query database") + } + + return p, nil +} + +// requireOrganizer runs the Write check and resolves the caller. +func (s *PrizeService) requireOrganizer( + ctx context.Context, + hackathonID uuid.UUID, +) (*ent.User, error) { + if err := s.enforcer.RequirePermission(ctx, hackathonID.String(), m.Hackathon, m.Write); err != nil { + return nil, err + } + uid, _, err := m.RequireSubject(ctx) + if err != nil { + return nil, err + } + u, err := s.dbClient.User.Query().Where(entuser.KeycloakIDEQ(uid)).Only(ctx) + if err != nil { + if ent.IsNotFound(err) { + return nil, status.Errorf(codes.NotFound, "user %s not found", uid) + } + slog.Error("query user", "err", err) + + return nil, status.Error(codes.Internal, "couldn't query database") + } + + return u, nil +} + +// Get reads the prize table back. +// +// Set replaces it wholesale, so a form that cannot prefill makes editing one +// prize destructive — the same reason GetWindows exists. Read is hackathon +// Read, not Write: the prize list is what an event advertises to attract +// entries, and the awards are the published result. +func (s *PrizeService) Get( + ctx context.Context, + req *prizeMsgs.GetRequest, +) (*prizeMsgs.GetResponse, error) { + hackathonID, err := uuid.Parse(req.GetHackathonId()) + if err != nil { + return nil, status.Errorf(codes.InvalidArgument, "invalid hackathon_id: %v", err) + } + // A PUBLIC event's prizes and awards are published facts: the prize list is + // what it advertises to attract entrants, and the awards are the result it + // announces. So visibility decides first, and only a private event falls + // back to the membership gate — otherwise the platform's own "who won" + // surfaces would be visible to everyone except the public they are for. + public, err := s.dbClient.Hackathon.Query(). + Where( + enthackathon.IDEQ(hackathonID), + enthackathon.VisibilityEQ(enthackathon.VisibilityPublic), + ). + Exist(ctx) + if err != nil { + slog.Error("query hackathon visibility", "err", err) + + return nil, status.Error(codes.Internal, "couldn't query database") + } + if !public { + if err := s.enforcer.RequirePermission(ctx, hackathonID.String(), m.Hackathon, m.Read); err != nil { + return nil, err + } + } + + row, err := s.prizeRowFor(ctx, hackathonID) + if err != nil { + return nil, err + } + if row == nil { + // No table yet is a normal state — an event that has not decided its + // prizes, not an error for the UI to translate. + //exhaustruct:ignore + return &prizeMsgs.GetResponse{}, nil + } + + return &prizeMsgs.GetResponse{ + Prizes: prizesFromJSON(row.Prizes), + Awards: prizesFromJSON(row.Awards), + Finalized: row.Finalized, + }, nil +} + +func (s *PrizeService) Set( + ctx context.Context, + req *prizeMsgs.SetRequest, +) (*prizeMsgs.SetResponse, error) { + hackathonID, err := uuid.Parse(req.GetHackathonId()) + if err != nil { + return nil, status.Errorf(codes.InvalidArgument, "invalid hackathon_id: %v", err) + } + modifier, err := s.requireOrganizer(ctx, hackathonID) + if err != nil { + return nil, err + } + + prizes := make([]map[string]any, 0, len(req.GetPrizes())) + for _, p := range req.GetPrizes() { + row := map[string]any{ + "rank": float64(p.GetRank()), + "title": p.GetTitle(), + } + if img := strings.TrimSpace(p.GetImage()); img != "" { + row["image"] = img + } + prizes = append(prizes, row) + } + + row, err := s.upsertPrizes(ctx, hackathonID, modifier, prizes) + if err != nil { + return nil, err + } + + return &prizeMsgs.SetResponse{Prizes: prizesFromJSON(row.Prizes)}, nil +} + +// upsertPrizes creates the hackathon's prize table if none exists yet, or +// replaces the prizes on the existing one otherwise. Split out of Set because +// inlining both branches pushed Set past the nesting budget for no benefit. +func (s *PrizeService) upsertPrizes( + ctx context.Context, + hackathonID uuid.UUID, + modifier *ent.User, + prizes []map[string]any, +) (*ent.HackathonPrizes, error) { + existing, err := s.prizeRowFor(ctx, hackathonID) + if err != nil { + return nil, err + } + + if existing == nil { + row, err := s.dbClient.HackathonPrizes.Create(). + SetHackathonID(hackathonID). + SetModifierID(modifier.ID). + SetPrizes(prizes). + Save(ctx) + if err != nil { + if ent.IsConstraintError(err) { + return nil, status.Errorf(codes.NotFound, "hackathon %s not found", hackathonID) + } + slog.Error("create hackathon prizes", "err", err) + + return nil, status.Error(codes.Internal, "couldn't create prize table") + } + + return row, nil + } + + row, err := existing.Update(). + SetModifierID(modifier.ID). + SetPrizes(prizes). + Save(ctx) + if err != nil { + slog.Error("update hackathon prizes", "err", err) + + return nil, status.Error(codes.Internal, "couldn't update prize table") + } + + return row, nil +} + +func (s *PrizeService) Finalize( + ctx context.Context, + req *prizeMsgs.FinalizeRequest, +) (*prizeMsgs.FinalizeResponse, error) { + hackathonID, err := uuid.Parse(req.GetHackathonId()) + if err != nil { + return nil, status.Errorf(codes.InvalidArgument, "invalid hackathon_id: %v", err) + } + modifier, err := s.requireOrganizer(ctx, hackathonID) + if err != nil { + return nil, err + } + existing, err := s.prizeRowFor(ctx, hackathonID) + if err != nil { + return nil, err + } + if existing == nil { + return nil, status.Error(codes.FailedPrecondition, "no prize table defined") + } + + awards := make([]map[string]any, 0, len(req.GetAwards())) + for _, a := range req.GetAwards() { + row := map[string]any{"submissionId": a.GetSubmissionId()} + if a.Rank != nil { + row["rank"] = float64(a.GetRank()) + } + if a.Special != nil { + row["special"] = a.GetSpecial() + } + awards = append(awards, row) + } + if _, err := existing.Update(). + SetModifierID(modifier.ID). + SetAwards(awards). + SetFinalized(true). + Save(ctx); err != nil { + slog.Error("finalize hackathon prizes", "err", err) + + return nil, status.Error(codes.Internal, "couldn't finalize awards") + } + + return &prizeMsgs.FinalizeResponse{}, nil +} + +func (s *PrizeService) Edit( + ctx context.Context, + req *prizeMsgs.EditRequest, +) (*prizeMsgs.EditResponse, error) { + hackathonID, err := uuid.Parse(req.GetHackathonId()) + if err != nil { + return nil, status.Errorf(codes.InvalidArgument, "invalid hackathon_id: %v", err) + } + modifier, err := s.requireOrganizer(ctx, hackathonID) + if err != nil { + return nil, err + } + existing, err := s.prizeRowFor(ctx, hackathonID) + if err != nil { + return nil, err + } + if existing == nil { + return nil, status.Error(codes.FailedPrecondition, "no prize table defined") + } + + var edited *ents.Prize + prizes := existing.Prizes + for i, p := range prizes { + rank, _ := p["rank"].(float64) + if int32(rank) != req.GetRank() { + continue + } + if req.Title != nil { + p["title"] = req.GetTitle() + } + prizes[i] = p + title, _ := p["title"].(string) + //exhaustruct:ignore + edited = &ents.Prize{Rank: req.GetRank(), Title: title} + + break + } + if edited == nil { + return nil, status.Errorf(codes.NotFound, "no prize with rank %d", req.GetRank()) + } + if _, err := existing.Update(). + SetModifierID(modifier.ID). + SetPrizes(prizes). + Save(ctx); err != nil { + slog.Error("edit hackathon prize", "err", err) + + return nil, status.Error(codes.Internal, "couldn't edit prize") + } + + return &prizeMsgs.EditResponse{Prize: edited}, nil +} diff --git a/components/backend/internal/service/project_service.go b/components/backend/internal/service/project_service.go index 7fc655a7..da009d8b 100644 --- a/components/backend/internal/service/project_service.go +++ b/components/backend/internal/service/project_service.go @@ -3,6 +3,7 @@ package service import ( "context" "log/slog" + "time" "github.com/google/uuid" "github.com/swissdatasciencecenter/hackagon/components/backend/ent" @@ -11,6 +12,7 @@ import ( entproject "github.com/swissdatasciencecenter/hackagon/components/backend/ent/project" enttrack "github.com/swissdatasciencecenter/hackagon/components/backend/ent/track" entuser "github.com/swissdatasciencecenter/hackagon/components/backend/ent/user" + "github.com/swissdatasciencecenter/hackagon/components/backend/internal/capability" mw "github.com/swissdatasciencecenter/hackagon/components/backend/internal/middleware" "github.com/swissdatasciencecenter/hackagon/components/backend/internal/proto/hackathon" ents "github.com/swissdatasciencecenter/hackagon/components/backend/internal/proto/hackathon/entities" @@ -138,6 +140,16 @@ func (s *ProjectService) Propose( return nil, err } + if err := requireCapability( + ctx, s.dbClient, s.enforcer, hackathonID, capability.ProposeProjects, + ); err != nil { + return nil, err + } + + if err := requireWindowOpen(ctx, s.dbClient, hackathonID, windowProposals, time.Now()); err != nil { + return nil, err + } + // Verify hackathon exists _, err = s.dbClient.Hackathon.Query().Where(enthackathon.IDEQ(hackathonID)).Only(ctx) if err != nil { @@ -251,7 +263,7 @@ func (s *ProjectService) setApproval( projectId string, projectStatus entproject.Status, ) error { - _, _, err := mw.RequireSubject(ctx) + uid, _, err := mw.RequireSubject(ctx) if err != nil { return err } @@ -282,10 +294,22 @@ func (s *ProjectService) setApproval( return err } + // Ensure user exists and get their entity ID + user, err := s.dbClient.User.Query().Where(entuser.KeycloakIDEQ(uid)).Only(ctx) + if err != nil { + if ent.IsNotFound(err) { + return status.Errorf(codes.NotFound, "user does not exist: %s", uid) + } + slog.Error("query user", "err", err) + + return status.Error(codes.Internal, "couldn't query database") + } + // Update the project status to "proposed" _, err = s.dbClient.Project.Update(). Where(entproject.IDEQ(projectID)). SetStatus(projectStatus). + SetModifier(user). Save(ctx) if err != nil { slog.Error("update project status", "err", err) @@ -326,6 +350,22 @@ func (s *ProjectService) SetPreference( hackathonID := project.Edges.Hackathon.ID + // Check Project.Read permission. Read — not Write — is the action the + // existing policy supports for the people allowed to act here: every roster + // member holds project/read (granted with the Member role at Join, waitlist + // included), while project/write is organizer-only. It rejects anonymous + // callers with Unauthenticated before any data is touched; who may act is + // still decided by the participant row below. + if err := s.enforcer.RequirePermission(ctx, hackathonID.String(), mw.Project, mw.Read); err != nil { + return nil, err + } + + if err := requireCapability( + ctx, s.dbClient, s.enforcer, hackathonID, capability.SetTeamPreferences, + ); err != nil { + return nil, err + } + // Verify user is a participant in the hackathon user, err := s.dbClient.User.Query().Where(entuser.KeycloakIDEQ(uid)).Only(ctx) if err != nil { @@ -337,13 +377,14 @@ func (s *ProjectService) SetPreference( return nil, status.Error(codes.Internal, "couldn't query database") } - // Check if user is a participant in the hackathon - participant, err := s.dbClient.Participant.Query(). + // Check if user is a participant in the hackathon. Waitlisted counts: + // preferences are expressed before the roster cut so team formation can + // consider the whole list — same policy family as waitlisted-may-propose. + if _, err := s.dbClient.Participant.Query(). Where( entparticipant.HackathonIDEQ(hackathonID), entparticipant.UserID(user.ID), - ).Only(ctx) - if err != nil { + ).Only(ctx); err != nil { if ent.IsNotFound(err) { return nil, status.Errorf( codes.PermissionDenied, @@ -356,12 +397,8 @@ func (s *ProjectService) SetPreference( return nil, status.Error(codes.Internal, "couldn't query participant") } - // If user is waitlisted, deny the action - if participant.IsWaiting { - return nil, status.Errorf( - codes.PermissionDenied, - "waitlisted users cannot mark projects as preferred", - ) + if err := requireWindowOpen(ctx, s.dbClient, hackathonID, windowPreferences, time.Now()); err != nil { + return nil, err } // Add the user's preference to the project (edge relation) @@ -377,6 +414,58 @@ func (s *ProjectService) SetPreference( return &msgs.SetPreferenceResponse{ProjectId: projectID.String()}, nil } +// GetPreference returns the caller's OWN project preferences for a hackathon. +// +// ExportPreferences is organiser-only (project:write), so until now a +// participant had no way to see what they had chosen — the preference was +// final and invisible, which is a poor combination. This asks for no +// permission beyond being a participant: it reads nothing but your own choice. +func (s *ProjectService) GetPreference( + ctx context.Context, + req *msgs.GetPreferenceRequest, +) (*msgs.GetPreferenceResponse, error) { + uid, _, err := mw.RequireUser(ctx) + if err != nil { + return nil, err + } + + hackathonID, err := uuid.Parse(req.GetHackathonId()) + if err != nil { + return nil, status.Errorf(codes.InvalidArgument, "invalid hackathon_id: %v", err) + } + + caller, err := s.dbClient.User.Query().Where(entuser.KeycloakIDEQ(uid)).Only(ctx) + if err != nil { + if ent.IsNotFound(err) { + return nil, status.Errorf(codes.NotFound, "user does not exist: %s", uid) + } + slog.Error("query user", "err", err) + + return nil, status.Error(codes.Internal, "couldn't query database") + } + + // Scoped to the hackathon: preferences are per event, and a user in three + // hackathons must not see all of them merged into one answer. + projects, err := s.dbClient.Project.Query(). + Where( + entproject.HasHackathonWith(enthackathon.IDEQ(hackathonID)), + entproject.HasPreferredByUsersWith(entuser.IDEQ(caller.ID)), + ). + All(ctx) + if err != nil { + slog.Error("query preferences", "err", err) + + return nil, status.Error(codes.Internal, "couldn't query database") + } + + ids := make([]string, 0, len(projects)) + for _, pr := range projects { + ids = append(ids, pr.ID.String()) + } + + return &msgs.GetPreferenceResponse{ProjectIds: ids}, nil +} + func (s *ProjectService) ExportPreferences( ctx context.Context, req *msgs.ExportPreferencesRequest, @@ -437,7 +526,7 @@ func (s *ProjectService) Edit( ctx context.Context, req *msgs.EditRequest, ) (*msgs.EditResponse, error) { - _, _, err := mw.RequireSubject(ctx) + uid, _, err := mw.RequireSubject(ctx) if err != nil { return nil, err } @@ -478,9 +567,21 @@ func (s *ProjectService) Edit( } } + // Ensure user exists and get their entity ID + user, err := s.dbClient.User.Query().Where(entuser.KeycloakIDEQ(uid)).Only(ctx) + if err != nil { + if ent.IsNotFound(err) { + return nil, status.Errorf(codes.NotFound, "user does not exist: %s", uid) + } + slog.Error("query user", "err", err) + + return nil, status.Error(codes.Internal, "couldn't query database") + } + // Build the update query with only provided fields update := s.dbClient.Project.Update(). - Where(entproject.IDEQ(projectID)) + Where(entproject.IDEQ(projectID)). + SetModifier(user) if req.Title != nil { update = update.SetTitle(req.GetTitle()) @@ -488,33 +589,43 @@ func (s *ProjectService) Edit( if req.Description != nil { update = update.SetDescription(req.GetDescription()) } - if req.GetTrackId() != "" { //nolint:nestif // this is not actually complex... - trackID, err := uuid.Parse(req.GetTrackId()) - if err != nil { - return nil, status.Errorf(codes.InvalidArgument, "invalid track_id: %v", err) - } - track, err := s.dbClient.Track.Query(). - Where(enttrack.IDEQ(trackID)). - WithHackathon(). - Only(ctx) - if err != nil { - if ent.IsNotFound(err) { - return nil, status.Errorf(codes.NotFound, "track %s not found", req.GetTrackId()) + // track_id is optional: a nil pointer means "unchanged", a non-nil empty + // string means "clear the track", anything else re-points the edge. + if req.TrackId != nil { //nolint:nestif // this is not actually complex... + if req.GetTrackId() == "" { + update = update.ClearTrack() + } else { + trackID, err := uuid.Parse(req.GetTrackId()) + if err != nil { + return nil, status.Errorf(codes.InvalidArgument, "invalid track_id: %v", err) } - slog.Error("query track", "err", err) - - return nil, status.Error(codes.Internal, "couldn't query database") - } - // Verify track belongs to the same hackathon - if track.Edges.Hackathon.ID != hackathonID { - return nil, status.Errorf( - codes.InvalidArgument, - "track %s does not belong to hackathon %s", - req.GetTrackId(), - hackathonID, - ) + track, err := s.dbClient.Track.Query(). + Where(enttrack.IDEQ(trackID)). + WithHackathon(). + Only(ctx) + if err != nil { + if ent.IsNotFound(err) { + return nil, status.Errorf( + codes.NotFound, + "track %s not found", + req.GetTrackId(), + ) + } + slog.Error("query track", "err", err) + + return nil, status.Error(codes.Internal, "couldn't query database") + } + // Verify track belongs to the same hackathon + if track.Edges.Hackathon.ID != hackathonID { + return nil, status.Errorf( + codes.InvalidArgument, + "track %s does not belong to hackathon %s", + req.GetTrackId(), + hackathonID, + ) + } + update = update.SetTrack(track) } - update = update.SetTrack(track) } if req.Image != nil { update = update.SetImage(req.GetImage()) @@ -610,3 +721,70 @@ func (s *ProjectService) Delete( return &msgs.DeleteResponse{}, nil } + +// RemovePreference withdraws a participant's project preference. +// +// Organizer-only, and it names the user explicitly: a preference is final once +// a participant expresses it, so there is no self-service unset. Team +// formation reads these choices, and letting people churn them mid-allocation +// would keep moving the ground under it. Someone who picked in error asks an +// organizer, who does it here. +func (s *ProjectService) RemovePreference( + ctx context.Context, + req *msgs.RemovePreferenceRequest, +) (*msgs.RemovePreferenceResponse, error) { + projectID, err := uuid.Parse(req.GetProjectId()) + if err != nil { + return nil, status.Errorf(codes.InvalidArgument, "invalid project_id: %v", err) + } + userID, err := uuid.Parse(req.GetUserId()) + if err != nil { + return nil, status.Errorf(codes.InvalidArgument, "invalid user_id: %v", err) + } + + project, err := s.dbClient.Project.Query(). + Where(entproject.IDEQ(projectID)). + WithHackathon(). + Only(ctx) + if err != nil { + if ent.IsNotFound(err) { + return nil, status.Errorf(codes.NotFound, "project %s not found", projectID) + } + slog.Error("query project", "err", err) + + return nil, status.Error(codes.Internal, "couldn't query database") + } + if project.Edges.Hackathon == nil { + return nil, status.Error(codes.Internal, "project has no hackathon") + } + + // Write, not Read: unlike expressing a preference (which every roster + // member may do), withdrawing someone else's is an organizer action. + if err := s.enforcer.RequirePermission( + ctx, project.Edges.Hackathon.ID.String(), mw.Project, mw.Write, + ); err != nil { + return nil, err + } + + u, err := s.dbClient.User.Query().Where(entuser.IDEQ(userID)).Only(ctx) + if err != nil { + if ent.IsNotFound(err) { + return nil, status.Errorf(codes.NotFound, "user %s not found", userID) + } + slog.Error("query user", "err", err) + + return nil, status.Error(codes.Internal, "couldn't query database") + } + + // Removing a preference nobody expressed is a no-op, not an error: the + // caller's intent (this person does not prefer this project) already holds. + if err := s.dbClient.Project.UpdateOne(project). + RemovePreferredByUsers(u). + Exec(ctx); err != nil { + slog.Error("remove preference", "err", err) + + return nil, status.Error(codes.Internal, "couldn't remove preference") + } + + return &msgs.RemovePreferenceResponse{}, nil +} diff --git a/components/backend/internal/service/project_service_test.go b/components/backend/internal/service/project_service_test.go index 828d9124..a20c5f08 100644 --- a/components/backend/internal/service/project_service_test.go +++ b/components/backend/internal/service/project_service_test.go @@ -19,6 +19,7 @@ import ( ent "github.com/swissdatasciencecenter/hackagon/components/backend/ent" entproject "github.com/swissdatasciencecenter/hackagon/components/backend/ent/project" entuser "github.com/swissdatasciencecenter/hackagon/components/backend/ent/user" + "github.com/swissdatasciencecenter/hackagon/components/backend/internal/middleware" hackathonSvc "github.com/swissdatasciencecenter/hackagon/components/backend/internal/proto/hackathon" ents "github.com/swissdatasciencecenter/hackagon/components/backend/internal/proto/hackathon/entities" msgs "github.com/swissdatasciencecenter/hackagon/components/backend/internal/proto/hackathon/messages/hackathon_svc" @@ -32,13 +33,14 @@ var _ = Describe("ProjectService", func() { var ( dbClient *ent.Client conn *grpc.ClientConn + enf *middleware.Enforcer projectClient hackathonSvc.ProjectServiceClient hackathonClient hackathonSvc.HackathonServiceClient testAdmin string ) BeforeEach(func() { - dbClient, conn, _ = testutils.CreateTestServer() + dbClient, conn, enf = testutils.CreateTestServer() testAdmin = testutils.TestAdminKeycloakID projectClient = hackathonSvc.NewProjectServiceClient(conn) @@ -276,6 +278,13 @@ var _ = Describe("ProjectService", func() { Expect(err).NotTo(HaveOccurred()) hackathonID := hackathonResp.GetHackathonId() + // Enable registrations (disabled by default) + _, err = hackathonClient.EditSettings(adminCtx, &msgs.EditSettingsRequest{ + HackathonId: hackathonID, + RegistrationsEnabled: testutils.BoolPtr(true), + }) + Expect(err).NotTo(HaveOccurred()) + // Join the hackathon as the member (creates waitlisted participant) memberToken := testutils.CreateTestJWTToken(memberKeycloakID) memberCtx := metadata.NewOutgoingContext( @@ -323,7 +332,7 @@ var _ = Describe("ProjectService", func() { Expect(p.Edges.Creator.KeycloakID).To(Equal(memberKeycloakID)) }) - It("denies waitlisted participant from proposing", func() { + It("allows a waitlisted participant to propose", func() { // Create a test user waitlistedKeycloakID := "waitlisted-project-proposer" _, err := dbClient.User.Create(). @@ -350,6 +359,13 @@ var _ = Describe("ProjectService", func() { Expect(err).NotTo(HaveOccurred()) hackathonID := hackathonResp.GetHackathonId() + // Enable registrations (disabled by default) + _, err = hackathonClient.EditSettings(adminCtx, &msgs.EditSettingsRequest{ + HackathonId: hackathonID, + RegistrationsEnabled: testutils.BoolPtr(true), + }) + Expect(err).NotTo(HaveOccurred()) + // Join the hackathon as the waitlisted user (creates is_waiting=true participant) waitlistedToken := testutils.CreateTestJWTToken(waitlistedKeycloakID) waitlistedCtx := metadata.NewOutgoingContext( @@ -361,18 +377,18 @@ var _ = Describe("ProjectService", func() { }) Expect(err).NotTo(HaveOccurred()) - // Try to propose — should be denied (not yet approved) + // Waitlisted participants MAY propose: joining grants the Member + // role; is_waiting only gates the sensitive paths (member view, + // voting). Policy pinned by the lifecycle recipe (act 3). req := &projectMsgs.ProposeRequest{ HackathonId: hackathonID, Title: "Waitlisted Project", - Description: "Should not be allowed", + Description: "Allowed while still on the waitlist", } - _, err = projectClient.Propose(waitlistedCtx, req) - Expect(err).To(HaveOccurred()) - - st := status.Convert(err) - Expect(st.Code()).To(Equal(codes.PermissionDenied)) + resp, err := projectClient.Propose(waitlistedCtx, req) + Expect(err).NotTo(HaveOccurred()) + Expect(resp.GetProjectId()).NotTo(BeEmpty()) }) It("requires Propose permission to propose", func() { @@ -758,6 +774,11 @@ var _ = Describe("ProjectService", func() { SetCreatedAt(now). Save(context.Background()) Expect(err).NotTo(HaveOccurred()) + + // Join grants the Member role alongside the participant row; this + // fixture writes the row directly, so grant the role too. + _, err = enf.AddRole("test-preference-user", middleware.Member, hackathonID) + Expect(err).NotTo(HaveOccurred()) }) It("sets preference for participating user", func() { @@ -828,8 +849,9 @@ var _ = Describe("ProjectService", func() { Expect(st.Code()).To(Equal(codes.PermissionDenied)) }) - It("returns PERMISSION_DENIED for waitlisted users", func() { - // Create waitlisted user + It("allows waitlisted users to mark preferences", func() { + // Waitlisted participants express preferences before the roster + // cut — same policy family as waitlisted-may-propose (recipe act 4). waitlistedUser, err := dbClient.User.Create(). SetKeycloakID("waitlisted-user"). SetUsername("waitlisted-username"). @@ -846,6 +868,11 @@ var _ = Describe("ProjectService", func() { Save(context.Background()) Expect(err).NotTo(HaveOccurred()) + // Waitlisted registrants hold Member too — is_waiting is what marks + // them, not a missing role (see HackathonService.Join). + _, err = enf.AddRole("waitlisted-user", middleware.Member, hackathonID) + Expect(err).NotTo(HaveOccurred()) + token := testutils.CreateTestJWTToken("waitlisted-user") ctx := metadata.NewOutgoingContext( context.Background(), @@ -856,11 +883,9 @@ var _ = Describe("ProjectService", func() { ProjectId: createdProjectID, } - _, err = projectClient.SetPreference(ctx, setReq) - Expect(err).To(HaveOccurred()) - - st := status.Convert(err) - Expect(st.Code()).To(Equal(codes.PermissionDenied)) + resp, err := projectClient.SetPreference(ctx, setReq) + Expect(err).NotTo(HaveOccurred()) + Expect(resp.GetProjectId()).To(Equal(createdProjectID)) }) }) @@ -922,6 +947,11 @@ var _ = Describe("ProjectService", func() { Save(context.Background()) Expect(err).NotTo(HaveOccurred()) + // Join grants the Member role alongside the participant row; this + // fixture writes the row directly, so grant the role too. + _, err = enf.AddRole("test-export-user", middleware.Member, hackathonID) + Expect(err).NotTo(HaveOccurred()) + // Set preference for project 1 prefCtx := metadata.NewOutgoingContext( context.Background(), @@ -1081,6 +1111,18 @@ var _ = Describe("ProjectService", func() { Save(context.Background()) Expect(err).NotTo(HaveOccurred()) + // Enable registrations (disabled by default) + adminToken := testutils.CreateTestJWTToken(testAdmin) + adminCtx := metadata.NewOutgoingContext( + context.Background(), + metadata.Pairs("authorization", "Bearer "+adminToken), + ) + _, err = hackathonClient.EditSettings(adminCtx, &msgs.EditSettingsRequest{ + HackathonId: hackathonID, + RegistrationsEnabled: testutils.BoolPtr(true), + }) + Expect(err).NotTo(HaveOccurred()) + // Creator joins the hackathon (creates waitlisted participant) creatorToken := testutils.CreateTestJWTToken(creatorID) creatorCtx := metadata.NewOutgoingContext( @@ -1093,11 +1135,6 @@ var _ = Describe("ProjectService", func() { Expect(err).NotTo(HaveOccurred()) // Approve the participant as admin - adminToken := testutils.CreateTestJWTToken(testAdmin) - adminCtx := metadata.NewOutgoingContext( - context.Background(), - metadata.Pairs("authorization", "Bearer "+adminToken), - ) creatorUser, err := dbClient.User.Query(). Where(entuser.KeycloakIDEQ(creatorID)). Only(context.Background()) @@ -1410,6 +1447,18 @@ var _ = Describe("ProjectService", func() { Save(context.Background()) Expect(err).NotTo(HaveOccurred()) + // Enable registrations (disabled by default) + adminToken := testutils.CreateTestJWTToken(testAdmin) + adminCtx := metadata.NewOutgoingContext( + context.Background(), + metadata.Pairs("authorization", "Bearer "+adminToken), + ) + _, err = hackathonClient.EditSettings(adminCtx, &msgs.EditSettingsRequest{ + HackathonId: hackathonID, + RegistrationsEnabled: testutils.BoolPtr(true), + }) + Expect(err).NotTo(HaveOccurred()) + // Creator joins the hackathon (creates waitlisted participant) creatorToken := testutils.CreateTestJWTToken(creatorID) creatorCtx := metadata.NewOutgoingContext( @@ -1422,11 +1471,6 @@ var _ = Describe("ProjectService", func() { Expect(err).NotTo(HaveOccurred()) // Approve the participant as admin - adminToken := testutils.CreateTestJWTToken(testAdmin) - adminCtx := metadata.NewOutgoingContext( - context.Background(), - metadata.Pairs("authorization", "Bearer "+adminToken), - ) creatorUser, err := dbClient.User.Query(). Where(entuser.KeycloakIDEQ(creatorID)). Only(context.Background()) diff --git a/components/backend/internal/service/registration_gate_test.go b/components/backend/internal/service/registration_gate_test.go new file mode 100644 index 00000000..aee43922 --- /dev/null +++ b/components/backend/internal/service/registration_gate_test.go @@ -0,0 +1,96 @@ +//go:build test && unittest + +package service_test + +import ( + "context" + "time" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/metadata" + "google.golang.org/grpc/status" + "google.golang.org/protobuf/types/known/timestamppb" + + hackathonSvc "github.com/swissdatasciencecenter/hackagon/components/backend/internal/proto/hackathon" + ents "github.com/swissdatasciencecenter/hackagon/components/backend/internal/proto/hackathon/entities" + msgs "github.com/swissdatasciencecenter/hackagon/components/backend/internal/proto/hackathon/messages/hackathon_svc" + "github.com/swissdatasciencecenter/hackagon/components/backend/internal/testutils" +) + +// D3: SubmitRegistrationForm's self path (on_behalf_of unset) had no gate, so +// any authenticated user could file a response into any hackathon — a private +// one included — and read its form schema off the validation errors. The gate +// requires a Participant row, which Join writes (and a private event's Join +// needs an invite). Waitlisted participants must still pass. +var _ = Describe("SubmitRegistrationForm self-path gate (D3)", func() { + adminCtxFor := func(kc string) context.Context { + return metadata.NewOutgoingContext( + context.Background(), + metadata.Pairs("authorization", "Bearer "+testutils.CreateTestJWTToken(kc)), + ) + } + + It("D3 refuses a non-participant on the self path", func() { + dbClient, conn, _ := testutils.CreateTestServer() + hackathonClient := hackathonSvc.NewHackathonServiceClient(conn) + + adminCtx := adminCtxFor(testutils.TestAdminKeycloakID) + now := time.Now() + h, err := hackathonClient.Create(adminCtx, &msgs.CreateRequest{ + Name: "Reg Gate", + Description: testutils.StringPtr("d"), + Visibility: ents.Visibility_VISIBILITY_PUBLIC, + StartsAt: timestamppb.New(now.Add(24 * time.Hour)), + EndsAt: timestamppb.New(now.Add(48 * time.Hour)), + }) + Expect(err).NotTo(HaveOccurred()) + + // A user who never joined. + outsider := "d3-outsider" + _, err = dbClient.User.Create(). + SetKeycloakID(outsider).SetUsername(outsider).Save(context.Background()) + Expect(err).NotTo(HaveOccurred()) + + _, err = hackathonClient.SubmitRegistrationForm(adminCtxFor(outsider), + &msgs.SubmitRegistrationFormRequest{HackathonId: h.GetHackathonId()}) + Expect(status.Code(err)).To(Equal(codes.PermissionDenied)) + }) + + It("D3 lets a joined (waitlisted) participant past the gate", func() { + dbClient, conn, _ := testutils.CreateTestServer() + hackathonClient := hackathonSvc.NewHackathonServiceClient(conn) + + adminCtx := adminCtxFor(testutils.TestAdminKeycloakID) + now := time.Now() + h, err := hackathonClient.Create(adminCtx, &msgs.CreateRequest{ + Name: "Reg Gate 2", + Description: testutils.StringPtr("d"), + Visibility: ents.Visibility_VISIBILITY_PUBLIC, + StartsAt: timestamppb.New(now.Add(24 * time.Hour)), + EndsAt: timestamppb.New(now.Add(48 * time.Hour)), + }) + Expect(err).NotTo(HaveOccurred()) + _, err = hackathonClient.EditSettings(adminCtx, &msgs.EditSettingsRequest{ + HackathonId: h.GetHackathonId(), + RegistrationsEnabled: testutils.BoolPtr(true), + }) + Expect(err).NotTo(HaveOccurred()) + + member := "d3-member" + _, err = dbClient.User.Create(). + SetKeycloakID(member).SetUsername(member).Save(context.Background()) + Expect(err).NotTo(HaveOccurred()) + memberCtx := adminCtxFor(member) + _, err = hackathonClient.Join(memberCtx, &msgs.JoinRequest{HackathonId: h.GetHackathonId()}) + Expect(err).NotTo(HaveOccurred()) + + // Past the gate now: no form is defined, so the FORM check refuses it — + // FailedPrecondition, not the gate's PermissionDenied. A different code + // is the proof the participation gate let a waitlisted member through. + _, err = hackathonClient.SubmitRegistrationForm(memberCtx, + &msgs.SubmitRegistrationFormRequest{HackathonId: h.GetHackathonId()}) + Expect(status.Code(err)).To(Equal(codes.FailedPrecondition)) + }) +}) diff --git a/components/backend/internal/service/require_user_test.go b/components/backend/internal/service/require_user_test.go new file mode 100644 index 00000000..4b04ae55 --- /dev/null +++ b/components/backend/internal/service/require_user_test.go @@ -0,0 +1,66 @@ +//go:build test && unittest + +package service_test + +import ( + "context" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + "google.golang.org/grpc" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" + + "github.com/google/uuid" + + hackathonSvc "github.com/swissdatasciencecenter/hackagon/components/backend/internal/proto/hackathon" + msgs "github.com/swissdatasciencecenter/hackagon/components/backend/internal/proto/hackathon/messages/team_svc" + "github.com/swissdatasciencecenter/hackagon/components/backend/internal/testutils" +) + +// The anonymous subject at the handler boundary. +// +// The auth interceptor injects `sub: "anonymous"` when there is no bearer +// token, so a call with no credentials reaches the handler like any other. +// TeamService's mutation handlers moved onto middleware.RequireUser for one +// reason: without it they parsed the request and looked the row up first, and +// answered NotFound — which tells an unauthenticated caller which submission +// and team ids exist. +// +// The ids below are therefore RANDOM AND ABSENT ON PURPOSE. "Unauthenticated +// rather than NotFound" is the whole claim, and it can only be made about a +// request whose id would genuinely miss. +var _ = Describe("TeamService with no credentials", func() { + var ( + conn *grpc.ClientConn + client hackathonSvc.TeamServiceClient + ) + + BeforeEach(func() { + _, conn, _ = testutils.CreateTestServer() + client = hackathonSvc.NewTeamServiceClient(conn) + }) + + // No metadata at all: this is what a caller with no session sends. + anon := func() context.Context { return context.Background() } + + It("tells an anonymous caller to sign in rather than whether a submission exists", func() { + _, err := client.EditSubmission(anon(), &msgs.EditSubmissionRequest{ + SubmissionId: uuid.NewString(), + }) + Expect(err).To(HaveOccurred()) + Expect(status.Convert(err).Code()).To( + Equal(codes.Unauthenticated), + "NotFound here is an oracle for which submission ids exist", + ) + }) + + It("tells an anonymous caller to sign in rather than whether a team exists", func() { + _, err := client.CreateSubmission(anon(), &msgs.CreateSubmissionRequest{ + TeamId: uuid.NewString(), + ProjectId: uuid.NewString(), + }) + Expect(err).To(HaveOccurred()) + Expect(status.Convert(err).Code()).To(Equal(codes.Unauthenticated)) + }) +}) diff --git a/components/backend/internal/service/server.go b/components/backend/internal/service/server.go index a5a57744..15e8624a 100644 --- a/components/backend/internal/service/server.go +++ b/components/backend/internal/service/server.go @@ -11,11 +11,16 @@ import ( "github.com/golang-jwt/jwt/v5" "github.com/swissdatasciencecenter/hackagon/components/backend/ent" + "github.com/swissdatasciencecenter/hackagon/components/backend/internal/audit" "github.com/swissdatasciencecenter/hackagon/components/backend/internal/config" mw "github.com/swissdatasciencecenter/hackagon/components/backend/internal/middleware" hackathonSvc "github.com/swissdatasciencecenter/hackagon/components/backend/internal/proto/hackathon" "github.com/swissdatasciencecenter/hackagon/components/backend/internal/proto/health" + siteSvc "github.com/swissdatasciencecenter/hackagon/components/backend/internal/proto/site" + storageSvc "github.com/swissdatasciencecenter/hackagon/components/backend/internal/proto/storage" userSvc "github.com/swissdatasciencecenter/hackagon/components/backend/internal/proto/user" + voteSvc "github.com/swissdatasciencecenter/hackagon/components/backend/internal/proto/vote" + objstore "github.com/swissdatasciencecenter/hackagon/components/backend/internal/storage" ) // NewServer creates a gRPC server with all middleware, services, and registration. @@ -53,22 +58,55 @@ func NewServer( // Create auth interceptor authInterceptor := mw.AuthUnaryServerInterceptor(validator) - // Create gRPC server with middleware chain (matching main.go exactly) + // RPC journal — OFF unless cfg.Audit.Enabled. See internal/audit and + // config.AuditConfig for exactly what enabling it records. + journal, err := audit.Open(cfg.Audit, audit.NewResolver(audit.EntLookup(dbClient))) + if err != nil { + return nil, nil, nil, fmt.Errorf("open rpc journal: %w", err) + } + + // Middleware chain, in order. Auth first: everything downstream reads the + // claims it puts in the context. The journal sits between auth and + // validation so a request rejected by protovalidate is still recorded — + // InvalidArgument is an outcome the recipe asserts on. + chain := []grpc.UnaryServerInterceptor{authInterceptor} + if audited := audit.UnaryServerInterceptor(journal); audited != nil { + chain = append(chain, audited) + } + chain = append(chain, validationInterceptor) + server := grpc.NewServer( - grpc.UnaryInterceptor( - grpc_middleware.ChainUnaryServer(authInterceptor, validationInterceptor), - ), + grpc.UnaryInterceptor(grpc_middleware.ChainUnaryServer(chain...)), ) + // Object store. Optional: with no endpoint configured (which is what the + // unit-test config does) the storage RPCs answer Unavailable and the two + // delete handlers skip their purge, rather than every test needing a + // bucket. New performs no I/O, so a store that is merely DOWN still lets + // the backend start — that failure belongs on the first upload, where + // someone can act on it. + var store *objstore.Client + if cfg.Storage.Endpoint != "" { + store, err = objstore.New(cfg.Storage) + if err != nil { + return nil, nil, nil, fmt.Errorf("create storage client: %w", err) + } + } + // Create services healthService := NewHealthService() - userService := NewUserService(dbClient, enf) - hackathonService := NewHackathonService(dbClient, enf) + userService := NewUserService(dbClient, enf, store) + hackathonService := NewHackathonService(dbClient, enf, store) pageService := NewPageService(dbClient, enf) phaseService := NewPhaseService(dbClient, enf) trackService := NewTrackService(dbClient, enf) projectService := NewProjectService(dbClient, enf) teamService := NewTeamService(dbClient, enf) + voteService := NewVoteService(dbClient, enf) + configService := NewConfigService(dbClient, enf) + prizeService := NewPrizeService(dbClient, enf) + sitePageService := NewSitePageService(dbClient, enf) + storageService := NewStorageService(dbClient, enf, store) // Register services health.RegisterHealthServiceServer(server, healthService) @@ -79,11 +117,18 @@ func NewServer( hackathonSvc.RegisterTrackServiceServer(server, trackService) hackathonSvc.RegisterProjectServiceServer(server, projectService) hackathonSvc.RegisterTeamServiceServer(server, teamService) + voteSvc.RegisterVoteServiceServer(server, voteService) + hackathonSvc.RegisterConfigServiceServer(server, configService) + hackathonSvc.RegisterPrizeServiceServer(server, prizeService) + siteSvc.RegisterSitePageServiceServer(server, sitePageService) + storageSvc.RegisterStorageServiceServer(server, storageService) reflection.Register(server) - // Cleanup: shutdown the gRPC server + // Cleanup: shutdown the gRPC server, then drain the journal so the last + // calls of a session are on disk before the process exits. cleanup := func() { server.GracefulStop() + journal.Close() } return server, cleanup, enf, nil diff --git a/components/backend/internal/service/site_page_service.go b/components/backend/internal/service/site_page_service.go new file mode 100644 index 00000000..7782fcb6 --- /dev/null +++ b/components/backend/internal/service/site_page_service.go @@ -0,0 +1,287 @@ +package service + +import ( + "context" + "log/slog" + + "github.com/swissdatasciencecenter/hackagon/components/backend/ent" + entsitepage "github.com/swissdatasciencecenter/hackagon/components/backend/ent/sitepage" + entuser "github.com/swissdatasciencecenter/hackagon/components/backend/ent/user" + m "github.com/swissdatasciencecenter/hackagon/components/backend/internal/middleware" + site "github.com/swissdatasciencecenter/hackagon/components/backend/internal/proto/site" + ents "github.com/swissdatasciencecenter/hackagon/components/backend/internal/proto/site/entities" + msgs "github.com/swissdatasciencecenter/hackagon/components/backend/internal/proto/site/messages/site_page_svc" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" + "google.golang.org/protobuf/types/known/timestamppb" +) + +// SitePageService serves the platform's own content pages (about, privacy, +// terms). They belong to the site rather than to a hackathon, so there is no +// casbin domain to scope them to: published pages are world-readable — a +// visitor reaches them from the footer before ever logging in — and every +// mutation requires the global Admin role. +type SitePageService struct { + site.UnimplementedSitePageServiceServer + + dbClient *ent.Client + enforcer *m.Enforcer +} + +func NewSitePageService(dbClient *ent.Client, enforcer *m.Enforcer) *SitePageService { + //exhaustruct:ignore + return &SitePageService{dbClient: dbClient, enforcer: enforcer} +} + +// callerIsAdmin reports admin standing without failing the request — reads use +// it to decide whether drafts are visible. +func (s *SitePageService) callerIsAdmin(ctx context.Context) bool { + uid, _, err := m.RequireSubject(ctx) + if err != nil || uid == m.AnonSubject { + return false + } + ok, err := s.enforcer.IsGlobalAdmin(uid) + + return err == nil && ok +} + +func (s *SitePageService) List( + ctx context.Context, + req *msgs.ListRequest, +) (*msgs.ListResponse, error) { + admin := s.callerIsAdmin(ctx) + if req.GetIncludeHidden() && !admin { + return nil, status.Error(codes.PermissionDenied, "only admins may list unpublished pages") + } + + q := s.dbClient.SitePage.Query(). + WithCreator(). + WithModifier(). + Order(ent.Asc(entsitepage.FieldOrder)) + // Drafts stay invisible unless an admin explicitly asks for them. + if !req.GetIncludeHidden() { + q = q.Where(entsitepage.VisibleEQ(true)) + } + + pages, err := q.All(ctx) + if err != nil { + slog.Error("list site pages", "err", err) + + return nil, status.Error(codes.Internal, "couldn't list site pages") + } + + entries := make([]*ents.SitePage, 0, len(pages)) + for _, p := range pages { + entries = append(entries, sitePageFromEnt(p)) + } + + return &msgs.ListResponse{SitePages: entries}, nil +} + +func (s *SitePageService) Get( + ctx context.Context, + req *msgs.GetRequest, +) (*msgs.GetResponse, error) { + p, err := s.dbClient.SitePage.Query(). + Where(entsitepage.SlugEQ(req.GetSlug())). + WithCreator(). + WithModifier(). + Only(ctx) + if err != nil { + if ent.IsNotFound(err) { + return nil, status.Errorf(codes.NotFound, "page %q not found", req.GetSlug()) + } + slog.Error("get site page", "err", err) + + return nil, status.Error(codes.Internal, "couldn't get site page") + } + + // An unpublished page is indistinguishable from a missing one for + // non-admins: revealing the draft's existence leaks the roadmap. + if !p.Visible && !s.callerIsAdmin(ctx) { + return nil, status.Errorf(codes.NotFound, "page %q not found", req.GetSlug()) + } + + return &msgs.GetResponse{SitePage: sitePageFromEnt(p)}, nil +} + +func (s *SitePageService) Create( + ctx context.Context, + req *msgs.CreateRequest, +) (*msgs.CreateResponse, error) { + if err := s.enforcer.RequireGlobalAdmin(ctx); err != nil { + return nil, err + } + u, err := s.actingUser(ctx) + if err != nil { + return nil, err + } + + create := s.dbClient.SitePage.Create(). + SetSlug(req.GetSlug()). + SetTitle(req.GetTitle()). + SetContent(req.GetContent()). + SetCreator(u). + SetModifier(u) + if req.Visible != nil { + create = create.SetVisible(req.GetVisible()) + } + if req.Order != nil { + create = create.SetOrder(int(req.GetOrder())) + } + + p, err := create.Save(ctx) + if err != nil { + if ent.IsConstraintError(err) { + return nil, status.Errorf( + codes.AlreadyExists, "a page with slug %q already exists", req.GetSlug(), + ) + } + if ent.IsValidationError(err) { + return nil, status.Errorf(codes.InvalidArgument, "invalid site page: %v", err) + } + slog.Error("create site page", "err", err) + + return nil, status.Error(codes.Internal, "couldn't create site page") + } + + return &msgs.CreateResponse{Id: p.ID.String()}, nil +} + +func (s *SitePageService) Edit( + ctx context.Context, + req *msgs.EditRequest, +) (*msgs.EditResponse, error) { + if err := s.enforcer.RequireGlobalAdmin(ctx); err != nil { + return nil, err + } + u, err := s.actingUser(ctx) + if err != nil { + return nil, err + } + + p, err := s.dbClient.SitePage.Query(). + Where(entsitepage.SlugEQ(req.GetSlug())). + Only(ctx) + if err != nil { + if ent.IsNotFound(err) { + return nil, status.Errorf(codes.NotFound, "page %q not found", req.GetSlug()) + } + slog.Error("get site page for edit", "err", err) + + return nil, status.Error(codes.Internal, "couldn't get site page") + } + + // Optional fields: nil means unchanged, so an explicit "" clears. + update := p.Update().SetModifier(u) + if req.NewSlug != nil { + update = update.SetSlug(req.GetNewSlug()) + } + if req.Title != nil { + update = update.SetTitle(req.GetTitle()) + } + if req.Content != nil { + update = update.SetContent(req.GetContent()) + } + if req.Visible != nil { + update = update.SetVisible(req.GetVisible()) + } + if req.Order != nil { + update = update.SetOrder(int(req.GetOrder())) + } + + if _, err := update.Save(ctx); err != nil { + if ent.IsConstraintError(err) { + return nil, status.Errorf( + codes.AlreadyExists, "a page with slug %q already exists", req.GetNewSlug(), + ) + } + if ent.IsValidationError(err) { + return nil, status.Errorf(codes.InvalidArgument, "invalid site page: %v", err) + } + slog.Error("edit site page", "err", err) + + return nil, status.Error(codes.Internal, "couldn't edit site page") + } + + // Re-query with edges — Save() doesn't return them. + updated, err := s.dbClient.SitePage.Query(). + Where(entsitepage.IDEQ(p.ID)). + WithCreator(). + WithModifier(). + Only(ctx) + if err != nil { + slog.Error("reload site page after edit", "err", err) + + return nil, status.Error(codes.Internal, "couldn't reload site page") + } + + return &msgs.EditResponse{SitePage: sitePageFromEnt(updated)}, nil +} + +func (s *SitePageService) Delete( + ctx context.Context, + req *msgs.DeleteRequest, +) (*msgs.DeleteResponse, error) { + if err := s.enforcer.RequireGlobalAdmin(ctx); err != nil { + return nil, err + } + + deleted, err := s.dbClient.SitePage.Delete(). + Where(entsitepage.SlugEQ(req.GetSlug())). + Exec(ctx) + if err != nil { + slog.Error("delete site page", "err", err) + + return nil, status.Error(codes.Internal, "couldn't delete site page") + } + if deleted == 0 { + return nil, status.Errorf(codes.NotFound, "page %q not found", req.GetSlug()) + } + + return &msgs.DeleteResponse{}, nil +} + +// actingUser resolves the caller's DB row for the creator/modifier edges. +func (s *SitePageService) actingUser(ctx context.Context) (*ent.User, error) { + uid, _, err := m.RequireSubject(ctx) + if err != nil { + return nil, err + } + u, err := s.dbClient.User.Query(). + Where(entuser.KeycloakIDEQ(uid)). + Only(ctx) + if err != nil { + if ent.IsNotFound(err) { + return nil, status.Error(codes.NotFound, "acting user does not exist") + } + slog.Error("get acting user", "err", err) + + return nil, status.Error(codes.Internal, "couldn't resolve acting user") + } + + return u, nil +} + +func sitePageFromEnt(p *ent.SitePage) *ents.SitePage { + //exhaustruct:ignore + entry := &ents.SitePage{ + Id: p.ID.String(), + Slug: p.Slug, + Title: p.Title, + Content: p.Content, + Visible: p.Visible, + //nolint:gosec // G115: a site page's display order, a handful of platform pages + Order: int32(p.Order), + CreatedAt: timestamppb.New(p.CreatedAt), + ModifiedAt: timestamppb.New(p.ModifiedAt), + } + if p.Edges.Creator != nil { + entry.CreatorId = p.Edges.Creator.ID.String() + } + if p.Edges.Modifier != nil { + entry.ModifierId = p.Edges.Modifier.ID.String() + } + + return entry +} diff --git a/components/backend/internal/service/storage_listing_internal_test.go b/components/backend/internal/service/storage_listing_internal_test.go new file mode 100644 index 00000000..8b0b9c01 --- /dev/null +++ b/components/backend/internal/service/storage_listing_internal_test.go @@ -0,0 +1,121 @@ +//go:build test && unittest + +package service + +import ( + "time" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + + objstore "github.com/swissdatasciencecenter/hackagon/components/backend/internal/storage" +) + +// The three pure decisions ListObjects makes, as the functions they are. Each +// one fails SILENTLY when it is wrong — a gallery still renders, it just shows +// the wrong things in the wrong order, or repeats a row on page two — so they +// are pinned here rather than left to be noticed. + +var _ = Describe("isListableImage", func() { + It("keeps every extension the uploader accepts", func() { + // Derived from imageTypes, so this is a check that the derivation is + // wired up, not a restatement of the list: an image type the uploader + // accepts and the picker cannot show is a picture that vanishes. + for contentType, exts := range imageTypes { + for _, ext := range exts { + Expect(isListableImage("site/media/a."+ext)). + To(BeTrue(), "%s (%s) should be listable", ext, contentType) + } + } + }) + + It("is case-insensitive about the extension", func() { + Expect(isListableImage("site/media/HOLIDAY.PNG")).To(BeTrue()) + }) + + It("drops what is not a picture", func() { + // The one that actually exists: rustfs-init.sh writes a probe under + // every public prefix to prove the bucket policy, and a gallery is a + // grid of <img> tags. + Expect(isListableImage("site/_selftest/probe.txt")).To(BeFalse()) + Expect(isListableImage("hackathons/x/media/notes.pdf")).To(BeFalse()) + Expect(isListableImage("hackathons/x/media/noextension")).To(BeFalse()) + }) + + It("drops svg, which is excluded from uploads for the same reason", func() { + // /objects is the app's own origin, so a stored SVG is script running + // as the application. It cannot be uploaded through this app — but the + // bucket is not ours alone, and a listing must not offer one back. + Expect(isListableImage("site/media/payload.svg")).To(BeFalse()) + }) +}) + +var _ = Describe("sortNewestFirst", func() { + at := func(key string, minute int) objstore.ObjectInfo { + return objstore.ObjectInfo{ + Key: key, + SizeBytes: 1, + LastModified: time.Date(2026, 8, 11, 12, minute, 0, 0, time.UTC), + } + } + + It("puts the most recent upload first", func() { + objects := []objstore.ObjectInfo{at("old", 1), at("new", 9), at("mid", 5)} + sortNewestFirst(objects) + + Expect([]string{objects[0].Key, objects[1].Key, objects[2].Key}). + To(Equal([]string{"new", "mid", "old"})) + }) + + It("breaks a tie on the key, so the offset cursor cannot skip a row", func() { + // Same second, deliberately fed in the wrong order twice: an unstable + // comparison would let these swap between two requests, and page two + // starts at an OFFSET into this ordering. + first := []objstore.ObjectInfo{at("b", 3), at("a", 3), at("c", 3)} + second := []objstore.ObjectInfo{at("c", 3), at("b", 3), at("a", 3)} + sortNewestFirst(first) + sortNewestFirst(second) + + keys := func(objects []objstore.ObjectInfo) []string { + out := make([]string, 0, len(objects)) + for _, o := range objects { + out = append(out, o.Key) + } + + return out + } + Expect(keys(first)).To(Equal([]string{"a", "b", "c"})) + Expect(keys(second)).To(Equal(keys(first))) + }) + + It("sorts an object with no timestamp last, not first", func() { + //exhaustruct:ignore + undated := objstore.ObjectInfo{Key: "undated"} + objects := []objstore.ObjectInfo{undated, at("dated", 1)} + sortNewestFirst(objects) + + Expect(objects[0].Key).To(Equal("dated")) + Expect(objects[1].Key).To(Equal("undated")) + }) +}) + +var _ = Describe("parsePageToken", func() { + It("starts at the newest object when there is no cursor", func() { + offset, err := parsePageToken("") + Expect(err).NotTo(HaveOccurred()) + Expect(offset).To(Equal(0)) + }) + + It("reads an offset back", func() { + offset, err := parsePageToken("60") + Expect(err).NotTo(HaveOccurred()) + Expect(offset).To(Equal(60)) + }) + + It("refuses anything that is not a non-negative number", func() { + for _, bad := range []string{"-1", "abc", "1.5", " 3", "0x10"} { + _, err := parsePageToken(bad) + Expect(err).To(HaveOccurred(), "page_token %q should be refused", bad) + } + }) +}) diff --git a/components/backend/internal/service/storage_service.go b/components/backend/internal/service/storage_service.go new file mode 100644 index 00000000..356dec6a --- /dev/null +++ b/components/backend/internal/service/storage_service.go @@ -0,0 +1,844 @@ +package service + +import ( + "context" + "log/slog" + "path" + "sort" + "strconv" + "strings" + "time" + + "github.com/google/uuid" + "github.com/swissdatasciencecenter/hackagon/components/backend/ent" + enthackathon "github.com/swissdatasciencecenter/hackagon/components/backend/ent/hackathon" + entsubmission "github.com/swissdatasciencecenter/hackagon/components/backend/ent/submission" + entuser "github.com/swissdatasciencecenter/hackagon/components/backend/ent/user" + "github.com/swissdatasciencecenter/hackagon/components/backend/internal/capability" + m "github.com/swissdatasciencecenter/hackagon/components/backend/internal/middleware" + storagepb "github.com/swissdatasciencecenter/hackagon/components/backend/internal/proto/storage" + ents "github.com/swissdatasciencecenter/hackagon/components/backend/internal/proto/storage/entities" + msgs "github.com/swissdatasciencecenter/hackagon/components/backend/internal/proto/storage/messages/storage_svc" + objstore "github.com/swissdatasciencecenter/hackagon/components/backend/internal/storage" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" + "google.golang.org/protobuf/types/known/timestamppb" +) + +// StorageService hands out signed, single-purpose URLs for the object store +// (docs/storage.md). No file ever passes through it: it authorizes the caller, +// decides the key, and pins the content-type and the byte count into the +// signature so an upload that breaks either is refused by the store itself. +type StorageService struct { + storagepb.UnimplementedStorageServiceServer + + dbClient *ent.Client + enforcer *m.Enforcer + // store is nil when no object store is configured (the unit-test config + // leaves storage.endpoint empty). Every RPC then answers Unavailable + // rather than panicking, and the delete handlers skip their purge. + store *objstore.Client +} + +func NewStorageService( + dbClient *ent.Client, + enforcer *m.Enforcer, + store *objstore.Client, +) *StorageService { + //exhaustruct:ignore + return &StorageService{dbClient: dbClient, enforcer: enforcer, store: store} +} + +const ( + // uploadTTL is the window the browser has to START the upload. Short, + // because a presigned URL is a bearer credential; long enough that a slow + // file picker or a re-render does not invalidate it. + uploadTTL = 15 * time.Minute + // downloadTTL is shorter still: a read URL is minted at the moment + // something is clicked and used immediately. + downloadTTL = 5 * time.Minute + + mib int64 = 1 << 20 + + // Prefixes. These ARE the deletion contract: everything an entity owns + // lives under its id, which is what makes DeletePrefix complete without a + // manifest of what belongs to whom. + hackathonPrefix = "hackathons/" + userPrefix = "users/" + teamPrefix = "teams/" + // sitePrefix is the one prefix that is NOT an owner id, because platform + // pages have no owning entity — see authorizeUpload's SITE_MEDIA branch and + // docs/storage.md. Nothing purges it for the same reason. + sitePrefix = "site/" + + // objectPurgeTimeout bounds the post-commit purge. The row is already + // gone; nobody waits minutes to be told the bucket was slow. + objectPurgeTimeout = 20 * time.Second + + // How big a listing may be. The page size is a rendering preference, so an + // over-large ask is clamped rather than refused. + listDefaultPageSize = 60 + listMaxPageSize = 200 + // listScanCap is how many KEYS one ListObjects request will read out of + // the store, across every prefix in the scope. + // + // It exists because the answer is ordered newest-first and S3 orders + // lexicographically by key — and every key here ends in a v4 uuid, so the + // store's order is noise. Sorting by date means holding the candidates, and + // holding candidates means capping how many. Reaching the cap is reported + // (`truncated`) rather than hidden: a gallery that silently stops is how + // someone concludes their upload failed. + listScanCap = 2000 + // listObjectsTimeout bounds the whole scan. Up to listScanCap/1000 round + // trips to the store, and a person is waiting for the grid to appear. + listObjectsTimeout = 15 * time.Second + + // Per-kind byte ceilings for uploadRules below, in MiB. + logoMaxMB = 5 + mediaMaxMB = 15 + avatarMaxMB = 5 + attachmentMaxMB = 50 + siteMediaMaxMB = 15 +) + +// listableExts is every extension the image allowlist accepts, as a set. +// +// Derived from imageTypes rather than restated, so a new image type cannot be +// accepted by the uploader and then be invisible in the picker that is supposed +// to offer it back. +// +//nolint:gochecknoglobals // derived lookup table, not mutable shared state +var listableExts = func() map[string]bool { + exts := make(map[string]bool) + for _, list := range imageTypes { + for _, ext := range list { + exts[ext] = true + } + } + + return exts +}() + +// imageTypes is the allowlist for everything that renders in an <img>. +// +// The value is the extensions accepted from the user's filename; the FIRST is +// the canonical one and the only one that ever reaches a key. +// +// image/svg+xml is deliberately absent. Objects are served from the app's OWN +// origin at /objects (that is what makes stored paths portable), so an SVG is +// a script that runs as the application — an XSS with a stable URL. Adding it +// would need a separate, non-same-origin host to serve from. +// +//nolint:gochecknoglobals // fixed allowlist, not mutable shared state +var imageTypes = map[string][]string{ + "image/webp": {"webp"}, + "image/png": {"png"}, + "image/jpeg": {"jpg", "jpeg"}, + "image/gif": {"gif"}, +} + +// attachmentTypes is what a team may turn in: the imagery above plus the +// document formats a poster or a slide deck actually arrives as. +// +//nolint:gochecknoglobals // derived allowlist, not mutable shared state +var attachmentTypes = func() map[string][]string { + types := map[string][]string{ + "application/pdf": {"pdf"}, + "application/zip": {"zip"}, + "text/plain": {"txt"}, + "text/markdown": {"md"}, + "text/csv": {"csv"}, + } + for contentType, exts := range imageTypes { + types[contentType] = exts + } + + return types +}() + +// uploadRule is everything the KIND decides. The client picks a kind and +// nothing else — not the path, not the ceiling, not the type. +type uploadRule struct { + // public objects are world-readable by bucket policy, so their path is + // stable and goes in the database. Private ones are read through + // CreateDownloadUrl instead. + public bool + maxBytes int64 + contentTypes map[string][]string +} + +// UNSPECIFIED is deliberately absent: it is not a valid upload kind, and the +// comma-ok lookup at its call site already answers InvalidArgument for it +// exactly like any other unmapped kind. +// +//nolint:exhaustive,gochecknoglobals // see comment above; fixed per-kind ceiling table, not mutable shared state +var uploadRules = map[ents.UploadKind]uploadRule{ + ents.UploadKind_UPLOAD_KIND_HACKATHON_LOGO: { + public: true, maxBytes: logoMaxMB * mib, contentTypes: imageTypes, + }, + ents.UploadKind_UPLOAD_KIND_HACKATHON_MEDIA: { + public: true, maxBytes: mediaMaxMB * mib, contentTypes: imageTypes, + }, + ents.UploadKind_UPLOAD_KIND_USER_AVATAR: { + public: true, maxBytes: avatarMaxMB * mib, contentTypes: imageTypes, + }, + ents.UploadKind_UPLOAD_KIND_SUBMISSION_ATTACHMENT: { + public: false, maxBytes: attachmentMaxMB * mib, contentTypes: attachmentTypes, + }, + // The same job as HACKATHON_MEDIA — a picture dropped into prose from a + // markdown editor — so deliberately the same ceiling and the same allowlist. + // A platform page is world-readable, so its imagery has to be too. + ents.UploadKind_UPLOAD_KIND_SITE_MEDIA: { + public: true, maxBytes: siteMediaMaxMB * mib, contentTypes: imageTypes, + }, +} + +func (s *StorageService) CreateUploadUrl( + ctx context.Context, + req *msgs.CreateUploadUrlRequest, +) (*msgs.CreateUploadUrlResponse, error) { + if s.store == nil { + return nil, status.Error(codes.Unavailable, "object storage is not configured") + } + + rule, known := uploadRules[req.GetKind()] + if !known { + return nil, status.Errorf( + codes.InvalidArgument, + "unsupported upload kind %s", + req.GetKind(), + ) + } + + // Limits before authorization lookups: a 4 GB request should cost one + // comparison, not a database round trip. + ext, err := checkContentType(rule, req.GetContentType(), req.GetFilename()) + if err != nil { + return nil, err + } + if req.GetSizeBytes() > rule.maxBytes { + return nil, status.Errorf(codes.InvalidArgument, + "file is %d bytes; the limit for this kind of upload is %d bytes", + req.GetSizeBytes(), rule.maxBytes) + } + + // The KEY is decided here, from ids the server has verified — never from + // anything the client sent. req.filename reached this point only as a + // cross-check on the content type. + key, err := s.authorizeUpload(ctx, req.GetKind(), req.GetOwnerId(), ext) + if err != nil { + return nil, err + } + + uploadURL, expiresAt := s.store.PresignPut( + key, req.GetContentType(), req.GetSizeBytes(), uploadTTL, + ) + + publicURL := "" + if rule.public { + publicURL = s.store.PublicURL(key) + } + + return &msgs.CreateUploadUrlResponse{ + UploadUrl: uploadURL, + Key: key, + PublicUrl: publicURL, + ExpiresAt: timestamppb.New(expiresAt), + }, nil +} + +// checkContentType enforces the allowlist and returns the extension the key +// will carry. The extension comes from the CONTENT TYPE, not from the filename: +// nothing a user typed is allowed to shape a key. +// +// The filename is still consulted, for one thing — if it carries an extension +// that contradicts the declared type, the person almost certainly picked the +// wrong file, and saying so now beats storing a .mov as image/png. +func checkContentType(rule uploadRule, contentType, filename string) (string, error) { + // "image/png; charset=binary" is a legal header value; compare the type. + normalized := strings.ToLower(strings.TrimSpace(contentType)) + if i := strings.IndexByte(normalized, ';'); i >= 0 { + normalized = strings.TrimSpace(normalized[:i]) + } + + exts, allowed := rule.contentTypes[normalized] + if !allowed { + return "", status.Errorf(codes.InvalidArgument, + "content type %q is not accepted for this kind of upload", contentType) + } + + if strings.ContainsAny(filename, "/\\\x00") { + return "", status.Error(codes.InvalidArgument, "filename must not contain a path") + } + if given := strings.ToLower(strings.TrimPrefix(path.Ext(filename), ".")); given != "" { + match := false + for _, ext := range exts { + if ext == given { + match = true + + break + } + } + if !match { + return "", status.Errorf(codes.InvalidArgument, + "%q does not look like a %s file", filename, normalized) + } + } + + return exts[0], nil +} + +// authorizeUpload is the whole access-control surface of the upload path: one +// rule per kind, and the key it returns is built from ids this function has +// just checked. +func (s *StorageService) authorizeUpload( + ctx context.Context, + kind ents.UploadKind, + ownerID, ext string, +) (string, error) { + name := uuid.New().String() + "." + ext + + switch kind { + // A platform page (about, privacy, terms) belongs to no event and no person, + // so this is the one kind that reads NO owner id: there is nothing to name, + // no hackathon domain to scope a casbin check to, and no owner segment in the + // key. It authorizes exactly as every SitePageService mutation does — the + // GLOBAL Admin role. `owner_id` reaches neither the key nor the decision, so + // whatever a caller sends is inert. + case ents.UploadKind_UPLOAD_KIND_SITE_MEDIA: + if err := s.enforcer.RequireGlobalAdmin(ctx); err != nil { + return "", err + } + + return sitePrefix + "media/" + name, nil + + // Writing an event's imagery is writing the event: same permission as + // renaming it, because the logo is as much the event's identity. + case ents.UploadKind_UPLOAD_KIND_HACKATHON_LOGO, + ents.UploadKind_UPLOAD_KIND_HACKATHON_MEDIA: + id, err := ownerUUID(ownerID) + if err != nil { + return "", err + } + if err := s.enforcer.RequirePermission(ctx, id.String(), m.Hackathon, m.Write); err != nil { + return "", err + } + exists, err := s.dbClient.Hackathon.Query().Where(enthackathon.IDEQ(id)).Exist(ctx) + if err != nil { + slog.Error("query hackathon for upload", "err", err) + + return "", status.Error(codes.Internal, "couldn't query database") + } + if !exists { + return "", status.Errorf(codes.NotFound, "hackathon %s not found", id) + } + folder := "logo" + if kind == ents.UploadKind_UPLOAD_KIND_HACKATHON_MEDIA { + folder = "media" + } + + return hackathonPrefix + id.String() + "/" + folder + "/" + name, nil + + // An avatar is the one thing here with no hackathon to scope a domain to, + // so it authorizes on identity: you, or a global admin fixing someone's + // profile. There is no casbin object type for users. + case ents.UploadKind_UPLOAD_KIND_USER_AVATAR: + id, err := ownerUUID(ownerID) + if err != nil { + return "", err + } + if err := s.authorizeAvatarOwner(ctx, id); err != nil { + return "", err + } + + return userPrefix + id.String() + "/avatar/" + name, nil + + // owner_id is the SUBMISSION; the team half of the key is looked up here, + // so a caller cannot file an attachment under a team that is not the one + // that owns the submission they were allowed to write. + case ents.UploadKind_UPLOAD_KIND_SUBMISSION_ATTACHMENT: + id, err := ownerUUID(ownerID) + if err != nil { + return "", err + } + subm, err := s.loadSubmission(ctx, id) + if err != nil { + return "", err + } + team := subm.Edges.Team + hackathonID := team.Edges.Project.Edges.Hackathon.ID + if err := s.enforcer.RequirePermission( + ctx, hackathonID.String(), m.Submission, m.Write, + m.WithTeam(team.ID.String()), + ); err != nil { + return "", err + } + // Presigning an attachment IS a submission write, so it is bound by the + // same clock and switch CreateSubmission is (team_service.go). Without + // these, an attachment could be uploaded after the submissions window + // closes, or while the submit capability is off — slipping work in past + // the deadline every other submission path enforces. + if err := requireWindowOpen( + ctx, s.dbClient, hackathonID, windowSubmissions, time.Now(), + ); err != nil { + return "", err + } + if err := requireCapability( + ctx, s.dbClient, s.enforcer, hackathonID, capability.CreateProjectSubmissions, + ); err != nil { + return "", err + } + + return teamPrefix + team.ID.String() + "/submissions/" + subm.ID.String() + "/" + name, nil + + case ents.UploadKind_UPLOAD_KIND_UNSPECIFIED: + fallthrough + default: + return "", status.Error(codes.InvalidArgument, "unsupported upload kind") + } +} + +// authorizeAvatarOwner allows a profile picture to be written by its owner, or +// by a global admin fixing someone's profile. Its own function only to keep +// authorizeUpload's branches readable at a glance. +func (s *StorageService) authorizeAvatarOwner(ctx context.Context, id uuid.UUID) error { + sub, _, err := m.RequireUser(ctx) + if err != nil { + return err + } + owner, err := s.dbClient.User.Query().Where(entuser.IDEQ(id)).Only(ctx) + if err != nil { + if ent.IsNotFound(err) { + return status.Errorf(codes.NotFound, "user %s not found", id) + } + slog.Error("query user for upload", "err", err) + + return status.Error(codes.Internal, "couldn't query database") + } + if owner.KeycloakID == sub { + return nil + } + + admin, err := s.enforcer.IsGlobalAdmin(sub) + if err != nil { + slog.Error("check global admin", "err", err) + + return status.Error(codes.Internal, "authorization error") + } + if !admin { + return status.Error(codes.PermissionDenied, "permission denied") + } + + return nil +} + +// ownerUUID parses the owner id a kind names. Per-branch rather than up front, +// because SITE_MEDIA names no owner at all and a parse before the switch made +// "there is nothing to own" indistinguishable from "that is not a uuid". +func ownerUUID(ownerID string) (uuid.UUID, error) { + id, err := uuid.Parse(ownerID) + if err != nil { + return uuid.Nil, status.Errorf(codes.InvalidArgument, "invalid owner_id: %v", err) + } + + return id, nil +} + +// loadSubmission fetches a submission with the team → project → hackathon chain +// every authorization decision about it needs. +func (s *StorageService) loadSubmission( + ctx context.Context, + id uuid.UUID, +) (*ent.Submission, error) { + subm, err := s.dbClient.Submission.Query(). + Where(entsubmission.IDEQ(id)). + WithTeam(func(tq *ent.TeamQuery) { + tq.WithProject(func(pq *ent.ProjectQuery) { + pq.WithHackathon() + }) + }). + Only(ctx) + if err != nil { + if ent.IsNotFound(err) { + return nil, status.Errorf(codes.NotFound, "submission %s not found", id) + } + slog.Error("query submission for storage", "err", err) + + return nil, status.Error(codes.Internal, "couldn't query database") + } + if subm.Edges.Team == nil || subm.Edges.Team.Edges.Project == nil || + subm.Edges.Team.Edges.Project.Edges.Hackathon == nil { + return nil, status.Error(codes.Internal, "submission team or hackathon not found") + } + + return subm, nil +} + +// CreateDownloadUrl mints a short read URL for a PRIVATE object, and only after +// casbin has approved the read — the presign carries that decision to the +// object store rather than duplicating it there. +func (s *StorageService) CreateDownloadUrl( + ctx context.Context, + req *msgs.CreateDownloadUrlRequest, +) (*msgs.CreateDownloadUrlResponse, error) { + if s.store == nil { + return nil, status.Error(codes.Unavailable, "object storage is not configured") + } + + key := req.GetKey() + if err := checkKeyShape(key); err != nil { + return nil, err + } + + // Public imagery is world-readable by bucket policy, so its stored path + // already works. Signing one would hand out an expiring bearer credential + // for something that needs none — refuse, and say where to look instead. + if strings.HasPrefix(key, hackathonPrefix) || strings.HasPrefix(key, userPrefix) || + strings.HasPrefix(key, sitePrefix) { + return nil, status.Error(codes.InvalidArgument, + "this object is public; read it at its stored path instead of signing a URL") + } + if !strings.HasPrefix(key, teamPrefix) { + return nil, status.Error(codes.InvalidArgument, "unknown object key") + } + + // teams/<team-id>/submissions/<submission-id>/<name> + const wantSegments = 5 + parts := strings.Split(key, "/") + if len(parts) < wantSegments || parts[2] != "submissions" { + return nil, status.Error(codes.InvalidArgument, "unknown object key") + } + teamID, err := uuid.Parse(parts[1]) + if err != nil { + return nil, status.Error(codes.InvalidArgument, "unknown object key") + } + submissionID, err := uuid.Parse(parts[3]) + if err != nil { + return nil, status.Error(codes.InvalidArgument, "unknown object key") + } + + subm, err := s.loadSubmission(ctx, submissionID) + if err != nil { + return nil, err + } + // The team in the key is checked against the submission's real team, so a + // forged path cannot borrow another team's permissions. + if subm.Edges.Team.ID != teamID { + return nil, status.Error(codes.InvalidArgument, "unknown object key") + } + if err := s.enforcer.RequirePermission( + ctx, subm.Edges.Team.Edges.Project.Edges.Hackathon.ID.String(), + m.Submission, m.Read, m.WithTeam(teamID.String()), + ); err != nil { + return nil, err + } + + downloadURL, expiresAt := s.store.PresignGet(key, downloadTTL) + + return &msgs.CreateDownloadUrlResponse{ + DownloadUrl: downloadURL, + ExpiresAt: timestamppb.New(expiresAt), + }, nil +} + +// ListObjects reports what is already in one scope of the store, newest first, +// so an image can be REUSED rather than uploaded a second time. +// +// The whole access-control surface of the read path is authorizeList, and its +// rule is one sentence: **you may list a prefix exactly when you may write to +// it.** Each scope's check is the check authorizeUpload already makes for the +// kind that files objects there, so "may I see what is in here" and "may I put +// something in here" cannot drift apart into two rules that disagree. +// +// Two prefixes are listable by nobody, whatever their role — `users/…/avatar/` +// and `teams/…/submissions/`. See ObjectScope in the proto for why; the short +// version is that one is other people's faces and the other is private by +// bucket policy and would leak who submitted what through the keys alone. +func (s *StorageService) ListObjects( + ctx context.Context, + req *msgs.ListObjectsRequest, +) (*msgs.ListObjectsResponse, error) { + // Authorization FIRST — before the store-configured check, before the + // page-token parse, before any I/O. + // + // The order is the answer's meaning. "Who are you" and "is this server set + // up" are different questions, and answering the second one first tells an + // anonymous caller something about the deployment in place of the + // Unauthenticated they are owed. It also keeps a caller who may not list + // this scope from telling a malformed cursor from a well-formed one, and + // from costing the store a round trip. + prefixes, err := s.authorizeList(ctx, req.GetScope(), req.GetOwnerId()) + if err != nil { + return nil, err + } + + if s.store == nil { + return nil, status.Error(codes.Unavailable, "object storage is not configured") + } + + offset, err := parsePageToken(req.GetPageToken()) + if err != nil { + return nil, err + } + + pageSize := int(req.GetPageSize()) + if pageSize <= 0 { + pageSize = listDefaultPageSize + } + if pageSize > listMaxPageSize { + pageSize = listMaxPageSize + } + + scanCtx, cancel := context.WithTimeout(ctx, listObjectsTimeout) + defer cancel() + + found, truncated, err := s.scanPrefixes(scanCtx, prefixes) + if err != nil { + slog.Error("list objects", "prefixes", prefixes, "err", err) + + return nil, status.Error(codes.Unavailable, "couldn't read the object store") + } + + sortNewestFirst(found) + + if offset > len(found) { + offset = len(found) + } + end := offset + pageSize + if end > len(found) { + end = len(found) + } + next := "" + if end < len(found) { + next = strconv.Itoa(end) + } + + objects := make([]*ents.StoredObject, 0, end-offset) + for _, info := range found[offset:end] { + objects = append(objects, s.storedObjectFromInfo(info)) + } + + return &msgs.ListObjectsResponse{ + Objects: objects, + NextPageToken: next, + Truncated: truncated, + }, nil +} + +// sortNewestFirst orders a listing the way a person opening a picker reads it: +// "the one I just uploaded" is at the top. +// +// The key breaks ties, and that is not cosmetic. The cursor is an OFFSET into +// this ordering, so two objects written in the same second that swapped places +// between two requests would make the second page skip one and repeat another. +// A store that reports no timestamp at all sorts oldest, which puts the objects +// we know least about last rather than first. +func sortNewestFirst(objects []objstore.ObjectInfo) { + sort.Slice(objects, func(i, j int) bool { + if !objects[i].LastModified.Equal(objects[j].LastModified) { + return objects[i].LastModified.After(objects[j].LastModified) + } + + return objects[i].Key < objects[j].Key + }) +} + +// parsePageToken reads the cursor, which is a plain offset into the +// newest-first ordering. +// +// An offset rather than the store's own continuation token, because the answer +// is not in the store's order: it is re-sorted by date, so a token that means +// "resume the S3 scan here" would resume a DIFFERENT sequence than the one the +// caller was reading. The cost is that each page rescans, which is bounded by +// listScanCap and is the reason that cap is small. +func parsePageToken(token string) (int, error) { + if token == "" { + return 0, nil + } + offset, err := strconv.Atoi(token) + if err != nil || offset < 0 { + return 0, status.Error(codes.InvalidArgument, "invalid page_token") + } + + return offset, nil +} + +// authorizeList turns a scope into the prefixes it covers, and refuses first. +// The prefixes are returned by this function and never accepted from a caller — +// same shape as authorizeUpload returning the key. +func (s *StorageService) authorizeList( + ctx context.Context, + scope ents.ObjectScope, + ownerID string, +) ([]string, error) { + switch scope { + // The platform's own imagery: no owner to name, so it authorizes on the + // global Admin role — identical to UPLOAD_KIND_SITE_MEDIA and to every + // SitePageService mutation. + case ents.ObjectScope_OBJECT_SCOPE_SITE_MEDIA: + if err := s.enforcer.RequireGlobalAdmin(ctx); err != nil { + return nil, err + } + + return []string{sitePrefix + "media/"}, nil + + // Every listable prefix at once, for the platform's media library. It spans + // events the caller may have no part in, so it takes the only role that is + // entitled to look at all of them. + case ents.ObjectScope_OBJECT_SCOPE_ALL_MEDIA: + if err := s.enforcer.RequireGlobalAdmin(ctx); err != nil { + return nil, err + } + + return []string{hackathonPrefix, sitePrefix + "media/"}, nil + + // One event's own imagery — logo and page media alike, since the person + // picking a picture wants everything the event has. The permission is + // hackathon `write`, which is what uploading either of them takes. + case ents.ObjectScope_OBJECT_SCOPE_HACKATHON_MEDIA: + id, err := ownerUUID(ownerID) + if err != nil { + return nil, err + } + if err := s.enforcer.RequirePermission(ctx, id.String(), m.Hackathon, m.Write); err != nil { + return nil, err + } + exists, err := s.dbClient.Hackathon.Query().Where(enthackathon.IDEQ(id)).Exist(ctx) + if err != nil { + slog.Error("query hackathon for listing", "err", err) + + return nil, status.Error(codes.Internal, "couldn't query database") + } + if !exists { + return nil, status.Errorf(codes.NotFound, "hackathon %s not found", id) + } + + return []string{hackathonPrefix + id.String() + "/"}, nil + + case ents.ObjectScope_OBJECT_SCOPE_UNSPECIFIED: + fallthrough + default: + return nil, status.Error(codes.InvalidArgument, "unsupported object scope") + } +} + +// scanPrefixes reads up to listScanCap keys across prefixes and keeps the ones +// that are images. It reports whether the cap stopped it. +// +// The budget counts KEYS READ, not images kept: the cost being bounded is the +// store round trips, and a prefix full of non-images would otherwise be scanned +// without limit. +func (s *StorageService) scanPrefixes( + ctx context.Context, + prefixes []string, +) ([]objstore.ObjectInfo, bool, error) { + found := make([]objstore.ObjectInfo, 0, listDefaultPageSize) + scanned := 0 + + for _, prefix := range prefixes { + token := "" + for { + budget := listScanCap - scanned + if budget <= 0 { + return found, true, nil + } + batch, next, err := s.store.ListPrefix(ctx, prefix, token, budget) + if err != nil { + return nil, false, err + } + scanned += len(batch) + for _, info := range batch { + if isListableImage(info.Key) { + found = append(found, info) + } + } + if next == "" { + break + } + token = next + } + } + + return found, false, nil +} + +// isListableImage keeps a picture gallery to pictures. Every listable prefix is +// an imagery prefix by policy, so this only ever filters out strays — the +// bootstrap script's `_selftest/probe.txt` is the one that exists today — but a +// gallery is a grid of <img> tags and a row that can only ever render broken is +// worse than a row that is not there. +func isListableImage(key string) bool { + ext := strings.ToLower(strings.TrimPrefix(path.Ext(key), ".")) + + return listableExts[ext] +} + +// storedObjectFromInfo is the ent-to-proto mapper's equivalent for the store: +// it maps what ListObjectsV2 reported onto the entity, and its one decision is +// that `url` is the STABLE public path, never a presign. Every listable prefix +// is public-read, so a signature would be an expiring bearer credential handed +// out for something that needs none — sixty of them per gallery page, some +// lapsing while the grid was still on screen. +func (s *StorageService) storedObjectFromInfo(info objstore.ObjectInfo) *ents.StoredObject { + var lastModified *timestamppb.Timestamp + if !info.LastModified.IsZero() { + lastModified = timestamppb.New(info.LastModified) + } + + return &ents.StoredObject{ + Key: info.Key, + Url: s.store.PublicURL(info.Key), + SizeBytes: info.SizeBytes, + LastModified: lastModified, + } +} + +// checkKeyShape rejects the traversal and smuggling shapes before anything is +// parsed. The key is signed literally, but a proxy between the browser and the +// store may normalize ".." on the way, which would resolve to an object the +// signature was never meant to cover. +func checkKeyShape(key string) error { + if key == "" || strings.HasPrefix(key, "/") || strings.Contains(key, "//") || + strings.Contains(key, "..") { + return status.Error(codes.InvalidArgument, "malformed object key") + } + for _, r := range key { + if r < 0x20 || r == 0x7f { + return status.Error(codes.InvalidArgument, "malformed object key") + } + } + + return nil +} + +// purgeObjects deletes everything under prefix, and is the shape docs/storage.md +// asks for on both counts: +// +// - it runs AFTER the database delete has succeeded, so a failed delete never +// leaves rows pointing at objects that are already gone; +// - it CANNOT fail the delete. The event or the account is gone as far as the +// person is concerned, and no bucket timeout is going to resurrect it. A +// failure logs the orphaned prefix loudly enough to be swept by hand. +// +// The context is detached from the caller's: the RPC may be moments from +// returning, and a cancelled purge would be indistinguishable from one that was +// never attempted. +func purgeObjects(ctx context.Context, store *objstore.Client, prefix string) { + if store == nil { + return + } + + purgeCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), objectPurgeTimeout) + defer cancel() + + deleted, err := store.DeletePrefix(purgeCtx, prefix) + if err != nil { + slog.Error( + "ORPHANED OBJECTS: purge failed after delete; sweep this prefix by hand", + "prefix", prefix, "deleted_before_failure", deleted, "err", err, + ) + + return + } + slog.Info("purged objects", "prefix", prefix, "deleted", deleted) +} diff --git a/components/backend/internal/service/storage_service_test.go b/components/backend/internal/service/storage_service_test.go new file mode 100644 index 00000000..1a075dfb --- /dev/null +++ b/components/backend/internal/service/storage_service_test.go @@ -0,0 +1,298 @@ +//go:build test && unittest + +package service_test + +import ( + "context" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + "google.golang.org/grpc" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/metadata" + "google.golang.org/grpc/status" + + "github.com/google/uuid" + + ent "github.com/swissdatasciencecenter/hackagon/components/backend/ent" + "github.com/swissdatasciencecenter/hackagon/components/backend/internal/middleware" + hackathonSvc "github.com/swissdatasciencecenter/hackagon/components/backend/internal/proto/hackathon" + hackathonEnts "github.com/swissdatasciencecenter/hackagon/components/backend/internal/proto/hackathon/entities" + hackathonMsgs "github.com/swissdatasciencecenter/hackagon/components/backend/internal/proto/hackathon/messages/hackathon_svc" + storageSvc "github.com/swissdatasciencecenter/hackagon/components/backend/internal/proto/storage" + storageEnts "github.com/swissdatasciencecenter/hackagon/components/backend/internal/proto/storage/entities" + storageMsgs "github.com/swissdatasciencecenter/hackagon/components/backend/internal/proto/storage/messages/storage_svc" + "github.com/swissdatasciencecenter/hackagon/components/backend/internal/testutils" +) + +// ListObjects is a READ over other people's uploads, so its whole risk is the +// authorization rule. That rule is tested here from BOTH sides for every scope: +// the caller who may not list it is refused, and the caller who may gets past +// the check — the two together are what make a refusal mean something. +// +// "Gets past the check" is `Unavailable`, and that is the point rather than a +// compromise. This suite's config leaves `storage.endpoint` empty, so the +// service holds no object-store client; `Unavailable` is therefore the FIRST +// answer that can only come from the far side of authorizeList. A test that +// only ever saw PermissionDenied would agree just as loudly with a handler that +// refused everybody. +// +// It also pins the ORDER of the two checks. Authorization is answered before +// "is storage configured", so an anonymous caller is told to authenticate +// rather than told about the deployment — and the deny side stays testable on a +// server with no store at all. +var _ = Describe("StorageService.ListObjects", func() { + var ( + dbClient *ent.Client + conn *grpc.ClientConn + enf *middleware.Enforcer + client storageSvc.StorageServiceClient + admin string + ) + + // authed builds a context carrying `keycloakID`'s bearer token. + authed := func(keycloakID string) context.Context { + return metadata.NewOutgoingContext( + context.Background(), + metadata.Pairs( + "authorization", + "Bearer "+testutils.CreateTestJWTToken(keycloakID), + ), + ) + } + + // newUser inserts a user with no roles at all and returns its Keycloak ID. + newUser := func(username string) string { + keycloakID := "keycloak-" + username + _, err := dbClient.User.Create(). + SetKeycloakID(keycloakID). + SetUsername(username). + Save(context.Background()) + Expect(err).NotTo(HaveOccurred()) + + return keycloakID + } + + codeOf := func(err error) codes.Code { + GinkgoHelper() + Expect(err).To(HaveOccurred()) + + return status.Convert(err).Code() + } + + BeforeEach(func() { + dbClient, conn, enf = testutils.CreateTestServer() + admin = testutils.TestAdminKeycloakID + client = storageSvc.NewStorageServiceClient(conn) + }) + + Describe("site media — the platform's own imagery", func() { + req := &storageMsgs.ListObjectsRequest{ + Scope: storageEnts.ObjectScope_OBJECT_SCOPE_SITE_MEDIA, + OwnerId: "", + PageSize: 0, + PageToken: "", + } + + It("tells an anonymous caller to authenticate, never that it is denied", func() { + // A status code is an answer. "Who are you" and "not you" are + // different answers, and this is the pinned policy for every + // endpoint on this branch. + _, err := client.ListObjects(context.Background(), req) + Expect(codeOf(err)).To(Equal(codes.Unauthenticated)) + }) + + It("denies a signed-in user who is not a platform admin", func() { + _, err := client.ListObjects(authed(newUser("nobody")), req) + Expect(codeOf(err)).To(Equal(codes.PermissionDenied)) + }) + + It("denies a hackathon ORGANIZER, who may create events but not edit the site", func() { + // The same split site pages already have: an organizer is refused + // every SitePageService mutation, so they are refused this too, by + // the identical rule rather than by a second one agreeing. + organizer := newUser("organizer") + _, err := enf.AddGlobalRole(organizer, middleware.HackathonOrganizer) + Expect(err).NotTo(HaveOccurred()) + + _, listErr := client.ListObjects(authed(organizer), req) + Expect(codeOf(listErr)).To(Equal(codes.PermissionDenied)) + }) + + It("lets a platform admin through to the store", func() { + _, err := client.ListObjects(authed(admin), req) + Expect(codeOf(err)).To(Equal(codes.Unavailable)) + }) + }) + + Describe("the whole media library", func() { + req := &storageMsgs.ListObjectsRequest{ + Scope: storageEnts.ObjectScope_OBJECT_SCOPE_ALL_MEDIA, + OwnerId: "", + PageSize: 0, + PageToken: "", + } + + It("tells an anonymous caller to authenticate", func() { + _, err := client.ListObjects(context.Background(), req) + Expect(codeOf(err)).To(Equal(codes.Unauthenticated)) + }) + + It("denies an event OWNER — owning one event is not seeing every event's files", func() { + // The interesting deny. This caller may list their own hackathon's + // media (proven below), so a refusal here is the scope boundary + // doing its job rather than the user having no permissions. + owner := newUser("owner-of-one") + _, err := enf.AddGlobalRole(owner, middleware.HackathonOrganizer) + Expect(err).NotTo(HaveOccurred()) + hackathonID := createHackathon(conn, authed(owner), "Owner's event") + + _, listErr := client.ListObjects(authed(owner), req) + Expect(codeOf(listErr)).To(Equal(codes.PermissionDenied)) + + // The positive control, in the same test: this exact caller is NOT + // simply powerless — they reach the store for their own event. + _, ownErr := client.ListObjects( + authed(owner), + &storageMsgs.ListObjectsRequest{ + Scope: storageEnts.ObjectScope_OBJECT_SCOPE_HACKATHON_MEDIA, + OwnerId: hackathonID, + PageSize: 0, + PageToken: "", + }, + ) + Expect(codeOf(ownErr)).To(Equal(codes.Unavailable)) + }) + + It("lets a platform admin through to the store", func() { + _, err := client.ListObjects(authed(admin), req) + Expect(codeOf(err)).To(Equal(codes.Unavailable)) + }) + }) + + Describe("one event's media", func() { + var hackathonID string + + BeforeEach(func() { + hackathonID = createHackathon(conn, authed(admin), "Storage listing event") + }) + + listOf := func(id string) *storageMsgs.ListObjectsRequest { + return &storageMsgs.ListObjectsRequest{ + Scope: storageEnts.ObjectScope_OBJECT_SCOPE_HACKATHON_MEDIA, + OwnerId: id, + PageSize: 0, + PageToken: "", + } + } + + It("tells an anonymous caller to authenticate", func() { + _, err := client.ListObjects(context.Background(), listOf(hackathonID)) + Expect(codeOf(err)).To(Equal(codes.Unauthenticated)) + }) + + It("denies a signed-in user with no standing in the event", func() { + _, err := client.ListObjects(authed(newUser("outsider")), listOf(hackathonID)) + Expect(codeOf(err)).To(Equal(codes.PermissionDenied)) + }) + + It("denies a plain MEMBER of the event", func() { + // Listing an event's media is authorized as `hackathon:write` — + // the permission that put the files there. A member can read the + // event; they cannot see the drawer it keeps its pictures in. + member := newUser("member") + _, err := enf.AddRole(member, middleware.Member, hackathonID) + Expect(err).NotTo(HaveOccurred()) + + _, listErr := client.ListObjects(authed(member), listOf(hackathonID)) + Expect(codeOf(listErr)).To(Equal(codes.PermissionDenied)) + }) + + It("lets the event's owner through to the store", func() { + owner := newUser("event-owner") + _, err := enf.AddRole(owner, middleware.Owner, hackathonID) + Expect(err).NotTo(HaveOccurred()) + + _, listErr := client.ListObjects(authed(owner), listOf(hackathonID)) + Expect(codeOf(listErr)).To(Equal(codes.Unavailable)) + }) + + It("lets a platform admin through to the store without joining", func() { + _, err := client.ListObjects(authed(admin), listOf(hackathonID)) + Expect(codeOf(err)).To(Equal(codes.Unavailable)) + }) + + It("answers NotFound for an event that does not exist", func() { + // Only reachable BY an admin, since the permission check runs + // first — which is the right order: a stranger must not learn + // which hackathon ids are real. + _, err := client.ListObjects(authed(admin), listOf(uuid.NewString())) + Expect(codeOf(err)).To(Equal(codes.NotFound)) + + _, strangerErr := client.ListObjects( + authed(newUser("prober")), listOf(uuid.NewString()), + ) + Expect(codeOf(strangerErr)).To(Equal(codes.PermissionDenied)) + }) + + It("refuses an owner_id that is not a uuid", func() { + _, err := client.ListObjects(authed(admin), listOf("not-a-uuid")) + // protovalidate rejects it before the handler sees it; either way + // the caller is told the argument is wrong, not that it is denied. + Expect(codeOf(err)).To(Equal(codes.InvalidArgument)) + }) + + It("refuses an event scope with no owner named", func() { + _, err := client.ListObjects(authed(admin), listOf("")) + Expect(codeOf(err)).To(Equal(codes.InvalidArgument)) + }) + }) + + Describe("the scopes that do not exist", func() { + It("refuses an unspecified scope", func() { + _, err := client.ListObjects(authed(admin), &storageMsgs.ListObjectsRequest{ + Scope: storageEnts.ObjectScope_OBJECT_SCOPE_UNSPECIFIED, + OwnerId: "", + PageSize: 0, + PageToken: "", + }) + Expect(codeOf(err)).To(Equal(codes.InvalidArgument)) + }) + + It("has no scope for avatars or for submissions, for anyone", func() { + // The absence is the feature: `users/<id>/avatar/` is other + // people's faces and `teams/…/submissions/` is private by bucket + // policy. Neither is reachable by naming a number, which is what + // this asserts — every value outside the enum is refused even for + // the caller with every role there is. + for _, scope := range []storageEnts.ObjectScope{4, 5, 99, -1} { + _, err := client.ListObjects(authed(admin), &storageMsgs.ListObjectsRequest{ + Scope: scope, + OwnerId: "", + PageSize: 0, + PageToken: "", + }) + Expect(codeOf(err)).To( + Equal(codes.InvalidArgument), + "scope %d should not resolve to a prefix", scope, + ) + } + }) + }) +}) + +// createHackathon makes one event and returns its id. Local to this file rather +// than shared: it exists so the specs above read as authorization statements. +func createHackathon(conn *grpc.ClientConn, ctx context.Context, name string) string { + GinkgoHelper() + + resp, err := hackathonSvc.NewHackathonServiceClient(conn). + Create(ctx, &hackathonMsgs.CreateRequest{ //exhaustruct:ignore + Name: name, + Visibility: hackathonEnts.Visibility_VISIBILITY_PUBLIC, + }) + Expect(err).NotTo(HaveOccurred()) + Expect(resp.GetHackathonId()).NotTo(BeEmpty()) + + return resp.GetHackathonId() +} diff --git a/components/backend/internal/service/storage_upload_internal_test.go b/components/backend/internal/service/storage_upload_internal_test.go new file mode 100644 index 00000000..7732a257 --- /dev/null +++ b/components/backend/internal/service/storage_upload_internal_test.go @@ -0,0 +1,87 @@ +//go:build test && unittest + +package service + +import ( + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" + + storageEnts "github.com/swissdatasciencecenter/hackagon/components/backend/internal/proto/storage/entities" +) + +// checkContentType is the whole of the upload allowlist, and it is a pure +// function, so it is tested as one — CreateUploadUrl's own specs cover +// authorization and would need an object store configured to get this far. +// +// The refusals assert the MESSAGE, not merely InvalidArgument. There are two +// different refusals in this function — "that type is not accepted" and "that +// filename does not match the type you declared" — and a rejected type whose +// filename carries the matching extension falls into the second one the moment +// the allowlist stops refusing. Two InvalidArguments for opposite reasons look +// identical from a status code alone, so a code-only assertion here would stay +// green with the allowlist gone. +var _ = Describe("checkContentType", func() { + imageRule := uploadRules[storageEnts.UploadKind_UPLOAD_KIND_HACKATHON_LOGO] + attachmentRule := uploadRules[storageEnts.UploadKind_UPLOAD_KIND_SUBMISSION_ATTACHMENT] + + refused := func(rule uploadRule, contentType, filename string) string { + GinkgoHelper() + ext, err := checkContentType(rule, contentType, filename) + Expect(err).To(HaveOccurred()) + Expect(ext).To(BeEmpty()) + Expect(status.Convert(err).Code()).To(Equal(codes.InvalidArgument)) + + return status.Convert(err).Message() + } + + // The one the allowlist exists for. Objects are served from the app's own + // origin at /objects, so a stored SVG is script running as the application: + // an XSS with a stable URL, uploadable by anyone who may upload a picture. + It("refuses image/svg+xml wherever an image is accepted", func() { + Expect(refused(imageRule, "image/svg+xml", "logo.svg")). + To(ContainSubstring("is not accepted for this kind of upload")) + Expect(refused(attachmentRule, "image/svg+xml", "diagram.svg")). + To(ContainSubstring("is not accepted for this kind of upload")) + }) + + It("refuses a document where only images are accepted", func() { + Expect(refused(imageRule, "application/pdf", "poster.pdf")). + To(ContainSubstring("is not accepted for this kind of upload")) + }) + + It("refuses a type nobody declared at all", func() { + Expect(refused(attachmentRule, "application/x-msdownload", "setup.exe")). + To(ContainSubstring("is not accepted for this kind of upload")) + Expect(refused(imageRule, "", "mystery")). + To(ContainSubstring("is not accepted for this kind of upload")) + }) + + // The control. Without it every refusal above would pass just as loudly + // against a function that accepted nothing. + It("accepts the image types the product does, and names the canonical extension", func() { + Expect(checkContentType(imageRule, "image/png", "cover.png")).To(Equal("png")) + Expect(checkContentType(imageRule, "image/webp", "cover.webp")).To(Equal("webp")) + Expect(checkContentType(imageRule, "image/gif", "cover.gif")).To(Equal("gif")) + // jpg is canonical; jpeg is merely accepted from the filename. + Expect(checkContentType(imageRule, "image/jpeg", "cover.jpeg")).To(Equal("jpg")) + Expect(checkContentType(attachmentRule, "application/pdf", "slides.pdf")).To(Equal("pdf")) + }) + + It("compares the media type, not the whole header", func() { + Expect(checkContentType(imageRule, "image/png; charset=binary", "cover.png")). + To(Equal("png")) + Expect(checkContentType(imageRule, " IMAGE/PNG ", "cover.PNG")).To(Equal("png")) + }) + + It("refuses a filename whose extension contradicts the declared type", func() { + Expect(refused(imageRule, "image/png", "clip.mov")). + To(ContainSubstring("does not look like")) + }) + + It("refuses a filename carrying a path", func() { + Expect(refused(imageRule, "image/png", "../../etc/cover.png")). + To(ContainSubstring("must not contain a path")) + }) +}) diff --git a/components/backend/internal/service/submission_read_gate_test.go b/components/backend/internal/service/submission_read_gate_test.go new file mode 100644 index 00000000..944475ad --- /dev/null +++ b/components/backend/internal/service/submission_read_gate_test.go @@ -0,0 +1,105 @@ +//go:build test && unittest + +package service_test + +import ( + "context" + "time" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/metadata" + "google.golang.org/grpc/status" + "google.golang.org/protobuf/types/known/timestamppb" + + entuser "github.com/swissdatasciencecenter/hackagon/components/backend/ent/user" + hackathonSvc "github.com/swissdatasciencecenter/hackagon/components/backend/internal/proto/hackathon" + ents "github.com/swissdatasciencecenter/hackagon/components/backend/internal/proto/hackathon/entities" + msgs "github.com/swissdatasciencecenter/hackagon/components/backend/internal/proto/hackathon/messages/hackathon_svc" + projectMsgs "github.com/swissdatasciencecenter/hackagon/components/backend/internal/proto/hackathon/messages/project_svc" + teamMsgs "github.com/swissdatasciencecenter/hackagon/components/backend/internal/proto/hackathon/messages/team_svc" + "github.com/swissdatasciencecenter/hackagon/components/backend/internal/testutils" +) + +// D5: a waitlisted registrant holds the Member role (granted at Join so they can +// propose) and Member carries hackathon-wide Submission:Read — so without the +// gate an unapproved registrant could read every team's work. The gate excludes +// callers who hold a waitlisted participant row; approval lifts it. +var _ = Describe("Submission read waitlist gate (D5)", func() { + ctxFor := func(kc string) context.Context { + return metadata.NewOutgoingContext( + context.Background(), + metadata.Pairs("authorization", "Bearer "+testutils.CreateTestJWTToken(kc)), + ) + } + + It("D5 refuses a waitlisted registrant, and approval lets them past", func() { + dbClient, conn, _ := testutils.CreateTestServer() + hackathonClient := hackathonSvc.NewHackathonServiceClient(conn) + projectClient := hackathonSvc.NewProjectServiceClient(conn) + teamClient := hackathonSvc.NewTeamServiceClient(conn) + + adminCtx := ctxFor(testutils.TestAdminKeycloakID) + now := time.Now() + h, err := hackathonClient.Create(adminCtx, &msgs.CreateRequest{ + Name: "Sub Read Gate", + Description: testutils.StringPtr("d"), + Visibility: ents.Visibility_VISIBILITY_PUBLIC, + StartsAt: timestamppb.New(now.Add(24 * time.Hour)), + EndsAt: timestamppb.New(now.Add(48 * time.Hour)), + }) + Expect(err).NotTo(HaveOccurred()) + hackathonID := h.GetHackathonId() + _, err = hackathonClient.EditSettings(adminCtx, &msgs.EditSettingsRequest{ + HackathonId: hackathonID, + RegistrationsEnabled: testutils.BoolPtr(true), + }) + Expect(err).NotTo(HaveOccurred()) + + // A team with a project. No submission is needed: the read gate runs + // before the submission query, so the code it returns is what is under + // test (PermissionDenied from the gate vs NotFound past it). + p, err := projectClient.Propose(adminCtx, &projectMsgs.ProposeRequest{ + HackathonId: hackathonID, + Title: "Gate Project", + Description: "desc", + }) + Expect(err).NotTo(HaveOccurred()) + t, err := teamClient.Create(adminCtx, &teamMsgs.CreateRequest{ + Name: "Gate Team", + ProjectId: p.GetProjectId(), + }) + Expect(err).NotTo(HaveOccurred()) + teamID := t.GetTeamId() + + // An outsider who joins (waitlisted) and is not on the team. + outsider := "d5-outsider" + _, err = dbClient.User.Create(). + SetKeycloakID(outsider).SetUsername(outsider).Save(context.Background()) + Expect(err).NotTo(HaveOccurred()) + outsiderCtx := ctxFor(outsider) + _, err = hackathonClient.Join(outsiderCtx, &msgs.JoinRequest{HackathonId: hackathonID}) + Expect(err).NotTo(HaveOccurred()) + + // Waitlisted → refused by the gate. + _, err = teamClient.GetSubmission(outsiderCtx, + &teamMsgs.GetSubmissionRequest{TeamId: teamID}) + Expect(status.Code(err)).To(Equal(codes.PermissionDenied)) + + // Approve → confirmed participant → past the gate; there is no submission, + // so NotFound. A different code is the proof the gate opened on approval. + outsiderUser, err := dbClient.User.Query(). + Where(entuser.KeycloakIDEQ(outsider)).Only(context.Background()) + Expect(err).NotTo(HaveOccurred()) + _, err = hackathonClient.ApproveParticipant(adminCtx, &msgs.ApproveParticipantRequest{ + HackathonId: hackathonID, + UserId: outsiderUser.ID.String(), + }) + Expect(err).NotTo(HaveOccurred()) + + _, err = teamClient.GetSubmission(outsiderCtx, + &teamMsgs.GetSubmissionRequest{TeamId: teamID}) + Expect(status.Code(err)).To(Equal(codes.NotFound)) + }) +}) diff --git a/components/backend/internal/service/team_service.go b/components/backend/internal/service/team_service.go index 333cf64f..0c44c282 100644 --- a/components/backend/internal/service/team_service.go +++ b/components/backend/internal/service/team_service.go @@ -3,14 +3,18 @@ package service import ( "context" "log/slog" + "time" "github.com/google/uuid" "github.com/swissdatasciencecenter/hackagon/components/backend/ent" enthackathon "github.com/swissdatasciencecenter/hackagon/components/backend/ent/hackathon" + enthackathonforms "github.com/swissdatasciencecenter/hackagon/components/backend/ent/hackathonforms" + entparticipant "github.com/swissdatasciencecenter/hackagon/components/backend/ent/participant" entproject "github.com/swissdatasciencecenter/hackagon/components/backend/ent/project" entsubmission "github.com/swissdatasciencecenter/hackagon/components/backend/ent/submission" entteam "github.com/swissdatasciencecenter/hackagon/components/backend/ent/team" entuser "github.com/swissdatasciencecenter/hackagon/components/backend/ent/user" + "github.com/swissdatasciencecenter/hackagon/components/backend/internal/capability" m "github.com/swissdatasciencecenter/hackagon/components/backend/internal/middleware" "github.com/swissdatasciencecenter/hackagon/components/backend/internal/proto/hackathon" hackEnts "github.com/swissdatasciencecenter/hackagon/components/backend/internal/proto/hackathon/entities" @@ -56,8 +60,11 @@ func (s *TeamService) List( return nil, status.Error(codes.Internal, "couldn't query database") } + // RequirePermission already speaks gRPC: PermissionDenied for an authenticated + // caller, Unauthenticated for the anonymous subject, Internal when the + // enforcer itself fails. Wrapping it would mask the last two as a denial. if err = s.enforcer.RequirePermission(ctx, hackathonID.String(), m.Hackathon, m.Read); err != nil { - return nil, status.Error(codes.PermissionDenied, "cann't get teams") + return nil, err } teams, err := s.dbClient.Team.Query(). @@ -117,7 +124,7 @@ func (s *TeamService) Get( m.Hackathon, m.Read, ); err != nil { - return nil, status.Error(codes.PermissionDenied, "cann't get teams") + return nil, err } return &msgs.GetResponse{Team: teamEntryFromEnt(t)}, nil @@ -127,18 +134,11 @@ func (s *TeamService) Create( ctx context.Context, req *msgs.CreateRequest, ) (*msgs.CreateResponse, error) { - sub, _, err := m.RequireSubject(ctx) + sub, _, err := m.RequireUser(ctx) if err != nil { return nil, err } - u, err := s.dbClient.User.Query(). - Where(entuser.KeycloakIDEQ(sub)). - Only(ctx) - if err != nil { - return nil, status.Errorf(codes.Internal, "user not found: %v", err) - } - projectID, err := uuid.Parse(req.GetProjectId()) if err != nil { return nil, status.Errorf(codes.InvalidArgument, "invalid project_id: %v", err) @@ -153,6 +153,11 @@ func (s *TeamService) Create( return nil, err } + u, err := s.callerUser(ctx, sub) + if err != nil { + return nil, err + } + t, err := s.dbClient.Team.Create(). SetName(req.GetName()). SetDescription(req.GetDescription()). @@ -171,7 +176,7 @@ func (s *TeamService) Edit( ctx context.Context, req *msgs.EditRequest, ) (*msgs.EditResponse, error) { - sub, _, err := m.RequireSubject(ctx) + sub, _, err := m.RequireUser(ctx) if err != nil { return nil, err } @@ -203,25 +208,30 @@ func (s *TeamService) Edit( } } - u, err := s.dbClient.User.Query(). - Where(entuser.KeycloakIDEQ(sub)). - Only(ctx) + u, err := s.callerUser(ctx, sub) if err != nil { - return nil, status.Errorf(codes.Internal, "user not found: %v", err) + return nil, err } update := s.dbClient.Team.UpdateOne(t). SetModifierID(u.ID) - if req.GetName() != "" { + // The proto fields are optional, so the pointer — not the value — carries + // intent: nil means "leave alone", a non-nil pointer applies even when it + // points at "" (which is how a description gets cleared). + if req.Name != nil { update.SetName(req.GetName()) } - if req.GetDescription() != "" { + if req.Description != nil { update.SetDescription(req.GetDescription()) } updatedT, err := update.Save(ctx) if err != nil { + // name is NotEmpty in the schema, so clearing it is a caller error. + if ent.IsValidationError(err) { + return nil, status.Errorf(codes.InvalidArgument, "invalid team: %v", err) + } slog.Error("edit team", "err", err) return nil, status.Errorf(codes.Internal, "couldn't edit team: %v", err) } @@ -249,7 +259,7 @@ func (s *TeamService) Delete( ctx context.Context, req *msgs.DeleteRequest, ) (*msgs.DeleteResponse, error) { - if _, _, err := m.RequireSubject(ctx); err != nil { + if _, _, err := m.RequireUser(ctx); err != nil { return nil, err } @@ -296,7 +306,7 @@ func (s *TeamService) AssignUser( ctx context.Context, req *msgs.AssignUserRequest, ) (*msgs.AssignUserResponse, error) { - if _, _, err := m.RequireSubject(ctx); err != nil { + if _, _, err := m.RequireUser(ctx); err != nil { return nil, err } @@ -330,19 +340,33 @@ func (s *TeamService) AssignUser( return nil, status.Errorf(codes.NotFound, "user %s not found", req.GetUserId()) } - // Add to DB members. - _, err = s.dbClient.Team.UpdateOne(t). + // Membership is written twice — the join row and the team-scoped casbin + // grant — and one without the other is drift: a seat nobody can act from, + // or a permission nobody can see. The two stores cannot share a + // transaction (casbin writes through its own connection; an ent tx held + // open across that write deadlocks the SQLite test harness), so write the + // inert half first and compensate: the join row alone grants nothing + // until the casbin role lands. + if _, err := s.dbClient.Team.UpdateOne(t). AddMembers(u). - Save(ctx) - if err != nil { + Save(ctx); err != nil { slog.Error("assign user to team", "err", err) + return nil, status.Errorf(codes.Internal, "couldn't assign user to team: %v", err) } - // Add to casbin team role. - _, err = s.enforcer.AddRole(u.KeycloakID, m.Member, hackathonID, m.WithTeam(t.ID.String())) - if err != nil { + if _, err := s.enforcer.AddRole( + u.KeycloakID, m.Member, hackathonID, m.WithTeam(t.ID.String()), + ); err != nil { slog.Error("add team role for user", "err", err) + // Take the seat back out so the two stores still agree. + if _, cerr := s.dbClient.Team.UpdateOne(t). + RemoveMembers(u). + Save(ctx); cerr != nil { + slog.Error("compensate: remove member after failed role grant", "err", cerr) + } + + return nil, status.Error(codes.Internal, "couldn't grant team role") } return &msgs.AssignUserResponse{}, nil @@ -352,7 +376,7 @@ func (s *TeamService) RemoveUser( ctx context.Context, req *msgs.RemoveUserRequest, ) (*msgs.RemoveUserResponse, error) { - if _, _, err := m.RequireSubject(ctx); err != nil { + if _, _, err := m.RequireUser(ctx); err != nil { return nil, err } @@ -386,19 +410,30 @@ func (s *TeamService) RemoveUser( return nil, status.Errorf(codes.NotFound, "user %s not found", req.GetUserId()) } - // Remove from DB members. - _, err = s.dbClient.Team.UpdateOne(t). + // Same two-store problem as AssignUser, in reverse — and the same + // no-shared-transaction constraint. Revoke the casbin role first: if the + // row removal then fails, the leftover member is inert (visible but + // powerless) rather than powerful and invisible. + if _, err := s.enforcer.RemoveRole( + u.KeycloakID, m.Member, hackathonID, m.WithTeam(t.ID.String()), + ); err != nil { + slog.Error("remove team role for user", "err", err) + + return nil, status.Error(codes.Internal, "couldn't revoke team role") + } + + if _, err := s.dbClient.Team.UpdateOne(t). RemoveMembers(u). - Save(ctx) - if err != nil { + Save(ctx); err != nil { slog.Error("remove user from team", "err", err) - return nil, status.Errorf(codes.Internal, "couldn't remove user from team: %v", err) - } + // Put the role back so the two stores still agree. + if _, cerr := s.enforcer.AddRole( + u.KeycloakID, m.Member, hackathonID, m.WithTeam(t.ID.String()), + ); cerr != nil { + slog.Error("compensate: restore team role after failed removal", "err", cerr) + } - // Remove from casbin team role. - _, err = s.enforcer.RemoveRole(u.KeycloakID, m.Member, hackathonID, m.WithTeam(t.ID.String())) - if err != nil { - slog.Error("remove team role for user", "err", err) + return nil, status.Errorf(codes.Internal, "couldn't remove user from team: %v", err) } // Re-query with edges — Save() doesn't return edges. @@ -423,7 +458,7 @@ func (s *TeamService) CreateSubmission( ctx context.Context, req *msgs.CreateSubmissionRequest, ) (*msgs.CreateSubmissionResponse, error) { - sub, _, err := m.RequireSubject(ctx) + sub, _, err := m.RequireUser(ctx) if err != nil { return nil, err } @@ -454,37 +489,149 @@ func (s *TeamService) CreateSubmission( return nil, err } - u, err := s.dbClient.User.Query(). - Where(entuser.KeycloakIDEQ(sub)). - Only(ctx) - if err != nil { - return nil, status.Errorf(codes.Internal, "user not found: %v", err) + if err := requireWindowOpen( + ctx, s.dbClient, t.Edges.Project.Edges.Hackathon.ID, windowSubmissions, time.Now(), + ); err != nil { + return nil, err } - // Determine version. - version, err := s.dbClient.Submission.Query(). - Where(entsubmission.HasTeamWith(entteam.IDEQ(teamID)), entsubmission.HasProjectWith(entproject.IDEQ(projectID))). - Count(ctx) - if err != nil { - return nil, status.Errorf(codes.Internal, "couldn't determine submission version: %v", err) + if err := requireCapability( + ctx, s.dbClient, s.enforcer, + t.Edges.Project.Edges.Hackathon.ID, capability.CreateProjectSubmissions, + ); err != nil { + return nil, err } - subm, err := s.dbClient.Submission.Create(). - SetTeamID(teamID). - SetProjectID(projectID). - SetResult(req.GetResult()). - SetStatus(entsubmission.StatusDraft). - SetVersion(version + 1). - SetCreatorID(u.ID). - Save(ctx) + // Structured answers are validated against the organizer's submission form + // (ConfigService.SetSubmissionForm). Without a form defined, anything is + // accepted — validation is opt-in by configuring one. + if forms, err := s.dbClient.HackathonForms.Query(). + Where(enthackathonforms.HasHackathonWith( + enthackathon.IDEQ(t.Edges.Project.Edges.Hackathon.ID), + )). + Only(ctx); err == nil { + if err := validateAgainstFormSchema( + forms.SubmissionFields, req.GetForm(), "submission", + ); err != nil { + return nil, err + } + } else if !ent.IsNotFound(err) { + slog.Error("query submission form", "err", err) + + return nil, status.Error(codes.Internal, "couldn't query database") + } + + u, err := s.callerUser(ctx, sub) if err != nil { - slog.Error("create submission", "err", err) - return nil, status.Errorf(codes.Internal, "couldn't create submission: %v", err) + return nil, err + } + + // The version is derived from a count, so two concurrent creates can pick the + // same number; the unique (version, project, team) index rejects the loser + // rather than letting it duplicate. Recount and try again once — that clears + // an ordinary two-way race — and only ask the caller to retry if it collides + // a second time. + const versionAttempts = 2 + + var subm *ent.Submission + for attempt := 1; attempt <= versionAttempts; attempt++ { + // Determine version. + var version int + version, err = s.dbClient.Submission.Query(). + Where(entsubmission.HasTeamWith(entteam.IDEQ(teamID)), entsubmission.HasProjectWith(entproject.IDEQ(projectID))). + Count(ctx) + if err != nil { + return nil, status.Errorf( + codes.Internal, + "couldn't determine submission version: %v", + err, + ) + } + + subm, err = s.dbClient.Submission.Create(). + SetTeamID(teamID). + SetProjectID(projectID). + SetResult(req.GetResult()). + SetForm(req.GetForm()). + SetStatus(entsubmission.StatusDraft). + SetVersion(version + 1). + SetCreatorID(u.ID). + Save(ctx) + if err == nil { + break + } + if !ent.IsConstraintError(err) { + slog.Error("create submission", "err", err) + + return nil, status.Errorf(codes.Internal, "couldn't create submission: %v", err) + } + if attempt == versionAttempts { + slog.Warn( + "submission version conflict", + "team", + teamID, + "project", + projectID, + "err", + err, + ) + + return nil, status.Error( + codes.Aborted, + "another submission was created at the same time, please retry", + ) + } } return &msgs.CreateSubmissionResponse{Id: subm.ID.String()}, nil } +// authorizeSubmissionRead decides who may read a team's submissions. A member of +// THIS team reads their own team's, team-scoped. The hackathon-wide grant — which +// exists so participants can read every team's work to vote on it — is for +// CONFIRMED participants only (D5): a waitlisted registrant holds the Member role +// (granted at Join so they can propose) but has not been approved into the event, +// so without this an unapproved registrant could read every team's submissions. +// Organizers and admins reach the hackathon-wide grant via Owner/admin, not a +// participant row, so they are unaffected. +func (s *TeamService) authorizeSubmissionRead( + ctx context.Context, + hackathonID, teamID uuid.UUID, +) error { + if err := s.enforcer.RequirePermission( + ctx, hackathonID.String(), m.Submission, m.Read, m.WithTeam(teamID.String()), + ); err == nil { + return nil + } + if err := s.enforcer.RequirePermission( + ctx, hackathonID.String(), m.Submission, m.Read, + ); err != nil { + return err + } + uid, _, err := m.RequireSubject(ctx) + if err != nil { + return err + } + waitlisted, err := s.dbClient.Participant.Query(). + Where( + entparticipant.HasUserWith(entuser.KeycloakIDEQ(uid)), + entparticipant.HasHackathonWith(enthackathon.IDEQ(hackathonID)), + entparticipant.IsWaiting(true), + ). + Exist(ctx) + if err != nil { + slog.Error("query participant for submission read", "err", err) + + return status.Error(codes.Internal, "couldn't query database") + } + if waitlisted { + return status.Error(codes.PermissionDenied, + "your registration is still on the waiting list") + } + + return nil +} + func (s *TeamService) GetSubmission( ctx context.Context, req *msgs.GetSubmissionRequest, @@ -503,16 +650,10 @@ func (s *TeamService) GetSubmission( return nil, status.Error(codes.Internal, "team project or hackathon not found") } - hackathonID := t.Edges.Project.Edges.Hackathon.ID.String() - if err := s.enforcer.RequirePermission( - ctx, hackathonID, m.Submission, m.Read, - m.WithTeam(t.ID.String()), + if err := s.authorizeSubmissionRead( + ctx, t.Edges.Project.Edges.Hackathon.ID, t.ID, ); err != nil { - if err := s.enforcer.RequirePermission( - ctx, hackathonID, m.Submission, m.Read, - ); err != nil { - return nil, err - } + return nil, err } // Find the latest submission for this team (highest version). @@ -556,16 +697,10 @@ func (s *TeamService) ListSubmissions( return nil, status.Error(codes.Internal, "team project or hackathon not found") } - hackathonID := t.Edges.Project.Edges.Hackathon.ID.String() - if err := s.enforcer.RequirePermission( - ctx, hackathonID, m.Submission, m.Read, - m.WithTeam(t.ID.String()), + if err := s.authorizeSubmissionRead( + ctx, t.Edges.Project.Edges.Hackathon.ID, t.ID, ); err != nil { - if err := s.enforcer.RequirePermission( - ctx, hackathonID, m.Submission, m.Read, - ); err != nil { - return nil, err - } + return nil, err } submissions, err := s.dbClient.Submission.Query(). @@ -592,7 +727,7 @@ func (s *TeamService) FinalizeSubmission( ctx context.Context, req *msgs.FinalizeSubmissionRequest, ) (*msgs.FinalizeSubmissionResponse, error) { - sub, _, err := m.RequireSubject(ctx) + sub, _, err := m.RequireUser(ctx) if err != nil { return nil, err } @@ -639,11 +774,19 @@ func (s *TeamService) FinalizeSubmission( return nil, err } - u, err := s.dbClient.User.Query(). - Where(entuser.KeycloakIDEQ(sub)). - Only(ctx) + // Finalizing is the act the deadline actually bites on, so it is gated as + // well as CreateSubmission — otherwise a draft made before the close could + // still be turned in afterwards. + if err := requireCapability( + ctx, s.dbClient, s.enforcer, + subm.Edges.Team.Edges.Project.Edges.Hackathon.ID, capability.CreateProjectSubmissions, + ); err != nil { + return nil, err + } + + u, err := s.callerUser(ctx, sub) if err != nil { - return nil, status.Errorf(codes.Internal, "user not found: %v", err) + return nil, err } updatedSubm, err := s.dbClient.Submission.UpdateOne(subm). @@ -671,6 +814,39 @@ func (s *TeamService) FinalizeSubmission( return &msgs.FinalizeSubmissionResponse{Submission: submissionEntryFromEnt(updatedSubm)}, nil } +// callerUser resolves the Keycloak subject of an ALREADY-AUTHENTICATED caller +// to their platform User row, for the creator/modifier stamp every write here +// carries. +// +// Two status codes this deliberately does not return, both of which it used to: +// +// - Not `Internal`. Every handler below reached this query through +// `m.RequireSubject`, which ADMITS the anonymous subject the auth +// interceptor injects when there is no bearer token — so an unauthenticated +// call looked up keycloak_id "anonymous", missed, and was reported as +// `Internal, "user not found"`. `Internal` means "we broke": it tells the +// client to retry something that will never work, and it buries genuine +// faults among routine unauthenticated traffic. Callers now pass +// `m.RequireUser`, so anonymous is refused with `Unauthenticated` at the top +// of the handler, before any argument is parsed or any row is read. +// - Not `Internal` for a real miss either. A subject that authenticated but +// has no platform profile is a `NotFound` about the user, the same answer +// `HackathonService.SetCapabilities` and `ProjectService.GetPreference` +// give. +func (s *TeamService) callerUser(ctx context.Context, sub string) (*ent.User, error) { + u, err := s.dbClient.User.Query().Where(entuser.KeycloakIDEQ(sub)).Only(ctx) + if err != nil { + if ent.IsNotFound(err) { + return nil, status.Errorf(codes.NotFound, "user does not exist: %s", sub) + } + slog.Error("query user", "err", err) + + return nil, status.Error(codes.Internal, "couldn't query database") + } + + return u, nil +} + func getTeamById(ctx context.Context, s *TeamService, teamID uuid.UUID) (*ent.Team, error) { t, err := s.dbClient.Team.Query(). Where(entteam.IDEQ(teamID)). @@ -687,3 +863,121 @@ func getTeamById(ctx context.Context, s *TeamService, teamID uuid.UUID) (*ent.Te } return t, nil } + +// EditSubmission updates a draft submission's content. Finalized submissions +// are frozen — edits after FinalizeSubmission are refused, and the edit is +// window-gated like CreateSubmission so post-deadline drafts cannot mutate. +func (s *TeamService) EditSubmission( + ctx context.Context, + req *msgs.EditSubmissionRequest, +) (*msgs.EditSubmissionResponse, error) { + sub, _, err := m.RequireUser(ctx) + if err != nil { + return nil, err + } + + submID, err := uuid.Parse(req.GetSubmissionId()) + if err != nil { + return nil, status.Errorf(codes.InvalidArgument, "invalid submission_id: %v", err) + } + + subm, err := s.dbClient.Submission.Query(). + Where(entsubmission.IDEQ(submID)). + WithTeam(func(tq *ent.TeamQuery) { + tq.WithProject(func(pq *ent.ProjectQuery) { + pq.WithHackathon() + }) + }). + Only(ctx) + if err != nil { + if ent.IsNotFound(err) { + return nil, status.Errorf( + codes.NotFound, + "submission %s not found", + req.GetSubmissionId(), + ) + } + + return nil, status.Errorf(codes.Internal, "query submission: %v", err) + } + if subm.Edges.Team == nil || subm.Edges.Team.Edges.Project == nil || + subm.Edges.Team.Edges.Project.Edges.Hackathon == nil { + return nil, status.Error(codes.Internal, "submission team or hackathon not found") + } + + hackathonID := subm.Edges.Team.Edges.Project.Edges.Hackathon.ID + if err := s.enforcer.RequirePermission( + ctx, hackathonID.String(), m.Submission, m.Write, + m.WithTeam(subm.Edges.Team.ID.String()), + ); err != nil { + return nil, err + } + + if subm.Status == entsubmission.StatusFinal { + return nil, status.Error(codes.FailedPrecondition, "submission is finalized and frozen") + } + if err := requireWindowOpen(ctx, s.dbClient, hackathonID, windowSubmissions, time.Now()); err != nil { + return nil, err + } + // The CAPABILITY too, not only the window. CreateSubmission and + // FinalizeSubmission both check it and this one did not, so with project + // submissions switched off a direct POST could still edit a draft's result. + // The UI hides the form, which is exactly what makes a gap like this look + // closed from the outside. + if err := requireCapability( + ctx, s.dbClient, s.enforcer, + hackathonID, capability.CreateProjectSubmissions, + ); err != nil { + return nil, err + } + + u, err := s.callerUser(ctx, sub) + if err != nil { + return nil, err + } + + update := s.dbClient.Submission.UpdateOne(subm).SetModifierID(u.ID) + if req.Result != nil { + update.SetResult(req.GetResult()) + } + // Answers were previously frozen at create time, so a mistyped repo URL + // could never be corrected. An absent map leaves them alone; a present one + // replaces them wholesale and must satisfy the organizer's schema exactly + // as it did on create — a partial map would silently drop required fields. + if len(req.GetForm()) > 0 { + if forms, ferr := s.dbClient.HackathonForms.Query(). + Where(enthackathonforms.HasHackathonWith(enthackathon.IDEQ(hackathonID))). + Only(ctx); ferr == nil { + if verr := validateAgainstFormSchema( + forms.SubmissionFields, req.GetForm(), "submission", + ); verr != nil { + return nil, verr + } + } else if !ent.IsNotFound(ferr) { + slog.Error("query submission form", "err", ferr) + + return nil, status.Error(codes.Internal, "couldn't query database") + } + update.SetForm(req.GetForm()) + } + if _, err := update.Save(ctx); err != nil { + slog.Error("edit submission", "err", err) + + return nil, status.Errorf(codes.Internal, "couldn't edit submission: %v", err) + } + + updated, err := s.dbClient.Submission.Query(). + Where(entsubmission.IDEQ(submID)). + WithTeam(). + WithProject(). + WithCreator(). + WithModifier(). + Only(ctx) + if err != nil { + slog.Error("re-query submission", "err", err) + + return nil, status.Errorf(codes.Internal, "couldn't re-query submission: %v", err) + } + + return &msgs.EditSubmissionResponse{Submission: submissionEntryFromEnt(updated)}, nil +} diff --git a/components/backend/internal/service/team_service_test.go b/components/backend/internal/service/team_service_test.go index b6507440..4849aaf5 100644 --- a/components/backend/internal/service/team_service_test.go +++ b/components/backend/internal/service/team_service_test.go @@ -1310,7 +1310,11 @@ var _ = Describe("TeamService", func() { Expect(resp.GetSubmission().GetVersion()).To(Equal(int32(2))) }) - It("denies get for hackathon member not in team", func() { + // Pinned policy (e2e recipe act 7): hackathon members read ALL + // submissions hackathon-wide — voting requires reviewing other + // teams' work. See {Member, /hackathon/*, Submission, Read} in + // rbac.go defaultPolicies. + It("allows a hackathon member outside the team to get the submission", func() { hackathonMemberID := "hackathon-member-not-in-team-getsub" _, err := dbClient.User.Create(). SetKeycloakID(hackathonMemberID). @@ -1327,11 +1331,11 @@ var _ = Describe("TeamService", func() { metadata.Pairs("authorization", "Bearer "+token), ) - _, err = teamClient.GetSubmission(ctx, &teamMsgs.GetSubmissionRequest{ + resp, err := teamClient.GetSubmission(ctx, &teamMsgs.GetSubmissionRequest{ TeamId: teamID, }) - Expect(err).To(HaveOccurred()) - Expect(status.Code(err)).To(Equal(codes.PermissionDenied)) + Expect(err).NotTo(HaveOccurred()) + Expect(resp.GetSubmission()).NotTo(BeNil()) }) }) @@ -1532,7 +1536,9 @@ var _ = Describe("TeamService", func() { Expect(resp.GetSubmissions()).To(HaveLen(3)) }) - It("denies list for hackathon member not in team", func() { + // Pinned policy (e2e recipe act 7): hackathon members read ALL + // submissions hackathon-wide — see the GetSubmission twin above. + It("allows a hackathon member outside the team to list submissions", func() { hackathonMemberID := "hackathon-member-not-in-team-listsub" _, err := dbClient.User.Create(). SetKeycloakID(hackathonMemberID). @@ -1549,11 +1555,11 @@ var _ = Describe("TeamService", func() { metadata.Pairs("authorization", "Bearer "+token), ) - _, err = teamClient.ListSubmissions(ctx, &teamMsgs.ListSubmissionsRequest{ + resp, err := teamClient.ListSubmissions(ctx, &teamMsgs.ListSubmissionsRequest{ TeamId: teamID, }) - Expect(err).To(HaveOccurred()) - Expect(status.Code(err)).To(Equal(codes.PermissionDenied)) + Expect(err).NotTo(HaveOccurred()) + Expect(resp.GetSubmissions()).To(HaveLen(3)) }) }) }) diff --git a/components/backend/internal/service/user_service.go b/components/backend/internal/service/user_service.go index 31088d4e..3135669d 100644 --- a/components/backend/internal/service/user_service.go +++ b/components/backend/internal/service/user_service.go @@ -3,14 +3,19 @@ package service import ( "context" "log/slog" + "strings" "github.com/google/uuid" "github.com/swissdatasciencecenter/hackagon/components/backend/ent" + entformresponse "github.com/swissdatasciencecenter/hackagon/components/backend/ent/formresponse" + entparticipant "github.com/swissdatasciencecenter/hackagon/components/backend/ent/participant" entuser "github.com/swissdatasciencecenter/hackagon/components/backend/ent/user" + entvote "github.com/swissdatasciencecenter/hackagon/components/backend/ent/vote" m "github.com/swissdatasciencecenter/hackagon/components/backend/internal/middleware" "github.com/swissdatasciencecenter/hackagon/components/backend/internal/proto/user" ents "github.com/swissdatasciencecenter/hackagon/components/backend/internal/proto/user/entities" msgs "github.com/swissdatasciencecenter/hackagon/components/backend/internal/proto/user/messages/user_svc" + objstore "github.com/swissdatasciencecenter/hackagon/components/backend/internal/storage" "google.golang.org/grpc/codes" "google.golang.org/grpc/status" ) @@ -19,13 +24,273 @@ type UserService struct { user.UnimplementedUserServiceServer dbClient *ent.Client enforcer *m.Enforcer + // store removes the person's uploaded files on account deletion. nil when + // no object store is configured; see purgeObjects. + store *objstore.Client } -func NewUserService(dbClient *ent.Client, enf *m.Enforcer) *UserService { +func NewUserService(dbClient *ent.Client, enf *m.Enforcer, store *objstore.Client) *UserService { return &UserService{ UnimplementedUserServiceServer: user.UnimplementedUserServiceServer{}, dbClient: dbClient, enforcer: enf, + store: store, + } +} + +// syncFromKeycloak refreshes the fields the identity provider owns. +// +// Deliberately NOT display_name. Keycloak owns the credentials — username and +// email are re-read from the token on every request, so the platform must not +// let anyone edit them here: the next page load would silently revert it. +// The display name is the platform's own, seeded from Keycloak when the +// profile is first created (Register) and editable afterwards via EditProfile. +// Re-syncing it on every WhoAmI is what made the account page read-only in +// practice, whatever the UI offered. +// +// Empty display names are still backfilled: a profile created before this rule +// (or by a Keycloak account with no name set) would otherwise stay blank +// forever, and a nameless user is worse than a stale one. +func syncFromKeycloak( + ctx context.Context, + u *ent.User, + claims map[string]interface{}, + sub string, +) (*ent.User, error) { + wantUsername := m.UsernameFromClaims(claims, sub) + wantEmail := m.EmailFromClaims(claims) + backfillName := u.DisplayName == "" && m.DisplayNameFromClaims(claims) != "" + + if u.Username == wantUsername && u.Email == wantEmail && !backfillName { + return u, nil + } + + upd := u.Update().SetUsername(wantUsername).SetEmail(wantEmail) + if backfillName { + upd = upd.SetDisplayName(m.DisplayNameFromClaims(claims)) + } + + updated, err := upd.Save(ctx) + if err != nil { + return nil, status.Errorf(codes.Internal, "sync user profile: %v", err) + } + + return updated, nil +} + +// EditProfile updates the caller's own profile. There is no user id in the +// request, so this cannot reach another account — no casbin check is needed +// beyond having a subject at all. +func (s *UserService) EditProfile( + ctx context.Context, + req *msgs.EditProfileRequest, +) (*msgs.EditProfileResponse, error) { + sub, _, err := m.RequireUser(ctx) + if err != nil { + return nil, err + } + + u, err := s.dbClient.User.Query().Where(entuser.KeycloakIDEQ(sub)).Only(ctx) + if err != nil { + if ent.IsNotFound(err) { + return nil, status.Error(codes.NotFound, "user not registered on platform") + } + slog.Error("query user", "err", err) + + return nil, status.Error(codes.Internal, "couldn't query database") + } + + upd := u.Update() + if req.DisplayName != nil { + // Trimmed because a name of spaces renders as a blank byline + // everywhere the platform shows an author. + name := strings.TrimSpace(req.GetDisplayName()) + if name == "" { + return nil, status.Error(codes.InvalidArgument, "display_name cannot be blank") + } + upd = upd.SetDisplayName(name) + } + + // The rest of the profile. Each MAY be cleared — emptying "dietary + // requirements" is a legitimate edit — so unlike display_name a blank value + // is stored rather than refused. Trimmed for the same reason: a field of + // spaces is an empty field that does not look like one. + if req.Affiliation != nil { + upd = upd.SetAffiliation(strings.TrimSpace(req.GetAffiliation())) + } + if req.Skills != nil { + upd = upd.SetSkills(strings.TrimSpace(req.GetSkills())) + } + if req.Dietary != nil { + upd = upd.SetDietary(strings.TrimSpace(req.GetDietary())) + } + if req.AvatarUrl != nil { + // An uploaded picture arrives as the root-relative path StorageService + // minted (`/objects/…`), a pasted one as an absolute link; both are + // accepted, and every other scheme is refused. See checkImageRef. + avatar := strings.TrimSpace(req.GetAvatarUrl()) + if err := checkImageRef("avatar_url", avatar); err != nil { + return nil, err + } + upd = upd.SetAvatarURL(avatar) + } + + u, err = upd.Save(ctx) + if err != nil { + slog.Error("update user profile", "err", err) + + return nil, status.Error(codes.Internal, "couldn't update profile") + } + + globalRoles, err := s.enforcer.GetGlobalRoles(u.KeycloakID) + if err != nil { + slog.Error("get global roles", "err", err) + + return nil, status.Error(codes.Internal, "couldn't resolve user roles") + } + entry := userEntryFromEnt(u) + entry.Roles = append(entry.Roles, globalRoles...) + + return &msgs.EditProfileResponse{User: entry}, nil +} + +// AddRole grants a global role — Admin or Hackathon Organizer. +// +// Ported from main, which implemented these while our copies stayed proto-only +// stubs. That mattered: the /manage/users page has shipped for months calling +// them, catching PERMISSION_DENIED / NOT_FOUND / INVALID_ARGUMENT and letting +// anything else through — so pressing "Grant Hackathon Organizer" answered +// Unimplemented and rendered a 500. +func (s *UserService) AddRole( + ctx context.Context, + req *msgs.AddRoleRequest, +) (*msgs.AddRoleResponse, error) { + if _, _, err := m.RequireUser(ctx); err != nil { + return nil, err + } + if err := s.enforcer.RequirePermission(ctx, "", m.User, m.Write); err != nil { + return nil, err + } + + u, role, err := s.roleTarget(ctx, req.GetUserId(), req.GetRole()) + if err != nil { + return nil, err + } + + if _, err := s.enforcer.AddGlobalRole(u.KeycloakID, role); err != nil { + slog.Error("add global role", "err", err) + + return nil, status.Error(codes.Internal, "couldn't assign role") + } + + entry, err := s.userWithRoles(u) + if err != nil { + return nil, err + } + + return &msgs.AddRoleResponse{User: entry}, nil +} + +// RemoveRole revokes a global role. +func (s *UserService) RemoveRole( + ctx context.Context, + req *msgs.RemoveRoleRequest, +) (*msgs.RemoveRoleResponse, error) { + uid, _, err := m.RequireUser(ctx) + if err != nil { + return nil, err + } + if err := s.enforcer.RequirePermission(ctx, "", m.User, m.Write); err != nil { + return nil, err + } + + u, role, err := s.roleTarget(ctx, req.GetUserId(), req.GetRole()) + if err != nil { + return nil, err + } + + // You cannot demote yourself out of Admin. Not paternalism: Admin is the + // only role that can grant Admin, so the last one to do this locks every + // remaining admin task out of the platform with no way back through the UI. + if uid == u.KeycloakID && role == m.Admin { + return nil, status.Error(codes.PermissionDenied, "cannot remove your own admin role") + } + + // Idempotent — casbin no-ops on a policy that is not there. + if _, err := s.enforcer.RemoveGlobalRole(u.KeycloakID, role); err != nil { + slog.Error("remove global role", "err", err) + + return nil, status.Error(codes.Internal, "couldn't remove role") + } + + entry, err := s.userWithRoles(u) + if err != nil { + return nil, err + } + + return &msgs.RemoveRoleResponse{User: entry}, nil +} + +// roleTarget resolves the user and role both role RPCs take, so the two cannot +// disagree about what counts as a valid request. +func (s *UserService) roleTarget( + ctx context.Context, + userID string, + proto ents.GlobalRole, +) (*ent.User, m.Role, error) { + targetID, err := uuid.Parse(userID) + if err != nil { + return nil, 0, status.Errorf(codes.InvalidArgument, "invalid user_id: %v", err) + } + + u, err := s.dbClient.User.Query().Where(entuser.IDEQ(targetID)).Only(ctx) + if err != nil { + if ent.IsNotFound(err) { + return nil, 0, status.Errorf(codes.NotFound, "user %s not found", userID) + } + slog.Error("query user", "err", err) + + return nil, 0, status.Error(codes.Internal, "couldn't query database") + } + + role, ok := protoRoleToCasbin(proto) + if !ok { + return nil, 0, status.Errorf(codes.InvalidArgument, "invalid role: %v", proto) + } + + return u, role, nil +} + +// userWithRoles reads the roles back from casbin AFTER the write, so the +// response states what is true rather than what was requested. +func (s *UserService) userWithRoles(u *ent.User) (*ents.User, error) { + globalRoles, err := s.enforcer.GetGlobalRoles(u.KeycloakID) + if err != nil { + slog.Error("get global roles", "err", err) + + return nil, status.Error(codes.Internal, "couldn't resolve user roles") + } + entry := userEntryFromEnt(u) + entry.Roles = append(entry.Roles, globalRoles...) + + return entry, nil +} + +// protoRoleToCasbin maps the wire enum onto a casbin role. Owner and Member are +// per-hackathon and deliberately unmappable here — AddGlobalRole refuses them +// too, so a mistake is caught twice. +// +// UNSPECIFIED (and any future value) is deliberately unmappable. +// +//nolint:exhaustive // already falls into default, the correct answer this function documents +func protoRoleToCasbin(r ents.GlobalRole) (m.Role, bool) { + switch r { + case ents.GlobalRole_GLOBAL_ROLE_ADMIN: + return m.Admin, true + case ents.GlobalRole_GLOBAL_ROLE_HACKATHON_ORGANIZER: + return m.HackathonOrganizer, true + default: + return 0, false } } @@ -42,9 +307,22 @@ func (s *UserService) List( return nil, status.Error(codes.Internal, "couldn't query database") } + // Roles come from casbin, like Get and WhoAmI do. Without them the user + // admin cannot answer the one question it exists for — who has global + // rights — and the caller has no other RPC to ask, since Get is per-user. + // The lookup is in-memory (the policy is already loaded), so it costs a map + // read per row rather than a query. entries := make([]*ents.User, 0, len(users)) for _, u := range users { - entries = append(entries, userEntryFromEnt(u)) + entry := userEntryFromEnt(u) + globalRoles, err := s.enforcer.GetGlobalRoles(u.KeycloakID) + if err != nil { + slog.Error("get global roles", "err", err, "user", u.KeycloakID) + + return nil, status.Error(codes.Internal, "couldn't resolve user roles") + } + entry.Roles = append(entry.Roles, globalRoles...) + entries = append(entries, entry) } return &msgs.ListResponse{Users: entries}, nil @@ -86,7 +364,7 @@ func (s *UserService) WhoAmI( ctx context.Context, _ *msgs.WhoAmIRequest, ) (*msgs.WhoAmIResponse, error) { - sub, claims, err := m.RequireSubject(ctx) + sub, claims, err := m.RequireUser(ctx) if err != nil { return nil, err } @@ -102,19 +380,9 @@ func (s *UserService) WhoAmI( return nil, status.Errorf(codes.Internal, "query user: %v", err) } - // Sync profile fields from Keycloak if they changed. - wantUsername := m.UsernameFromClaims(claims, sub) - wantDisplayName := m.DisplayNameFromClaims(claims) - wantEmail := m.EmailFromClaims(claims) - if u.Username != wantUsername || u.DisplayName != wantDisplayName || u.Email != wantEmail { - u, err = u.Update(). - SetUsername(wantUsername). - SetDisplayName(wantDisplayName). - SetEmail(wantEmail). - Save(ctx) - if err != nil { - return nil, status.Errorf(codes.Internal, "sync user profile: %v", err) - } + u, err = syncFromKeycloak(ctx, u, claims, sub) + if err != nil { + return nil, err } globalRoles, err := s.enforcer.GetGlobalRoles(u.KeycloakID) @@ -133,7 +401,7 @@ func (s *UserService) Register( ctx context.Context, _ *msgs.RegisterRequest, ) (*msgs.RegisterResponse, error) { - sub, claims, err := m.RequireSubject(ctx) + sub, claims, err := m.RequireUser(ctx) if err != nil { return nil, err } @@ -143,21 +411,14 @@ func (s *UserService) Register( email := m.EmailFromClaims(claims) // Idempotent: return existing user if already registered, - // syncing profile fields from Keycloak if they changed. + // syncing the Keycloak-owned fields if they changed. existing, err := s.dbClient.User.Query(). Where(entuser.KeycloakIDEQ(sub)). Only(ctx) if err == nil { - if existing.Username != username || existing.DisplayName != displayName || - existing.Email != email { - existing, err = existing.Update(). - SetUsername(username). - SetDisplayName(displayName). - SetEmail(email). - Save(ctx) - if err != nil { - return nil, status.Errorf(codes.Internal, "sync user profile: %v", err) - } + existing, err = syncFromKeycloak(ctx, existing, claims, sub) + if err != nil { + return nil, err } return &msgs.RegisterResponse{User: userEntryFromEnt(existing)}, nil @@ -178,3 +439,116 @@ func (s *UserService) Register( return &msgs.RegisterResponse{User: userEntryFromEnt(u)}, nil } + +// DeleteAccount removes the CALLER's own platform profile (GDPR self-service). +// +// Semantics pinned here, since the recipe asked for them: +// - the platform profile row and every casbin role (g and g2) go; +// - participation rows go with it, so the person disappears from rosters — +// ent's Restrict edges would otherwise block the delete anyway; +// - the KEYCLOAK identity is untouched. This service does not own it, and +// leaving it means the person can sign in again and start fresh rather +// than being locked out of an account they cannot recreate. +// +// Content they authored (pages, projects, submissions) is NOT deleted: those +// edges are Restrict, and silently removing an event's content because an +// author left would damage other people's records. A profile holding such +// content therefore fails with FailedPrecondition rather than cascading. +func (s *UserService) DeleteAccount( + ctx context.Context, + _ *msgs.DeleteAccountRequest, +) (*msgs.DeleteAccountResponse, error) { + uid, _, err := m.RequireUser(ctx) + if err != nil { + return nil, err + } + if uid == m.AnonSubject { + return nil, status.Error(codes.Unauthenticated, "authentication required") + } + + u, err := s.dbClient.User.Query().Where(entuser.KeycloakIDEQ(uid)).Only(ctx) + if err != nil { + if ent.IsNotFound(err) { + // Already gone: deleting twice is not an error, the caller's + // intent already holds. + return &msgs.DeleteAccountResponse{}, nil + } + slog.Error("query user for deletion", "err", err) + + return nil, status.Error(codes.Internal, "couldn't query database") + } + + // The person's OWN data goes with them. The line drawn here: anything that + // is a record *about this user* is theirs to erase (roster rows, the + // registration answers they gave about themselves, their ballots, their + // project preferences, their seats and jury assignments). Content they + // AUTHORED for other people — pages, projects, submissions, tracks — is + // Restrict-guarded below and blocks instead, because deleting an event's + // content when a contributor leaves would damage other people's records. + if _, err := s.dbClient.Participant.Delete(). + Where(entparticipant.HasUserWith(entuser.IDEQ(u.ID))). + Exec(ctx); err != nil { + slog.Error("delete participations", "err", err) + + return nil, status.Error(codes.Internal, "couldn't remove participations") + } + // Registration answers are personal data by definition — affiliation, + // dietary needs, consents. Keeping them after erasing the profile is + // exactly what a deletion request forbids. + if _, err := s.dbClient.FormResponse.Delete(). + Where(entformresponse.HasUserWith(entuser.IDEQ(u.ID))). + Exec(ctx); err != nil { + slog.Error("delete form responses", "err", err) + + return nil, status.Error(codes.Internal, "couldn't remove form responses") + } + // Ballots identify their voter, so they cannot outlive the voter. This + // does change tallies — the alternative is retaining an identified vote + // for someone who asked to be erased. + if _, err := s.dbClient.Vote.Delete(). + Where(entvote.HasVoterWith(entuser.IDEQ(u.ID))). + Exec(ctx); err != nil { + slog.Error("delete votes", "err", err) + + return nil, status.Error(codes.Internal, "couldn't remove votes") + } + // Pure link tables: clearing the edges leaves the projects, teams and + // categories themselves untouched. + if err := u.Update(). + ClearPreferredProjects(). + ClearParticipatesInTeams(). + ClearJuryCategories(). + Exec(ctx); err != nil { + slog.Error("clear user memberships", "err", err) + + return nil, status.Error(codes.Internal, "couldn't clear memberships") + } + + if err := s.dbClient.User.DeleteOne(u).Exec(ctx); err != nil { + if ent.IsConstraintError(err) { + return nil, status.Error( + codes.FailedPrecondition, + "this profile still owns content (pages, projects or submissions); an organizer must reassign or remove it first", + ) + } + slog.Error("delete user", "err", err) + + return nil, status.Error(codes.Internal, "couldn't delete profile") + } + + // Roles last: if this failed after the row is gone, a stale grouping row + // would silently re-grant access to whoever next registers this subject. + if err := s.enforcer.PurgeUserRoles(uid); err != nil { + slog.Error("purge casbin roles after account deletion", "err", err, "sub", uid) + + return nil, status.Error(codes.Internal, "couldn't purge roles") + } + + // This one matters more than the hackathon's: it is a person exercising + // erasure, and a profile picture left behind at a stable public URL would + // make the deletion a lie. After the row is gone, and never at the cost of + // the deletion itself. + purgeObjects(ctx, s.store, userPrefix+u.ID.String()+"/") + + return &msgs.DeleteAccountResponse{}, nil +} diff --git a/components/backend/internal/service/vote_scoring_test.go b/components/backend/internal/service/vote_scoring_test.go new file mode 100644 index 00000000..cd733ea8 --- /dev/null +++ b/components/backend/internal/service/vote_scoring_test.go @@ -0,0 +1,198 @@ +//go:build test && unittest + +// In-package (not service_test) because the arithmetic these specs pin is +// deliberately unexported: Borda scoring and ballot validation are decided +// before anything reaches the wire, and nothing else in the suites exercises +// them — the e2e recipe only ever casts single_choice ballots. +package service + +import ( + . "github.com/onsi/ginkgo/v2" //nolint:staticcheck // dot import in test file is fine + . "github.com/onsi/gomega" //nolint:staticcheck // dot import in test file is fine + + "github.com/google/uuid" + "github.com/swissdatasciencecenter/hackagon/components/backend/ent" + entvote "github.com/swissdatasciencecenter/hackagon/components/backend/ent/vote" + entvotecategory "github.com/swissdatasciencecenter/hackagon/components/backend/ent/votecategory" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" +) + +func row(submissionID uuid.UUID, value int) *ent.Vote { + return &ent.Vote{ + Value: value, + Edges: ent.VoteEdges{Submission: &ent.Submission{ID: submissionID}}, + } +} + +func codeOf(err error) codes.Code { + return status.Code(err) +} + +var _ = Describe("Ballot scoring", func() { + var a, b, c uuid.UUID + + BeforeEach(func() { + a = uuid.MustParse("00000000-0000-0000-0000-0000000000a1") + b = uuid.MustParse("00000000-0000-0000-0000-0000000000b2") + c = uuid.MustParse("00000000-0000-0000-0000-0000000000c3") + }) + + Describe("single choice", func() { + It("counts one point per ballot naming a submission", func() { + scores := scoreBallots(entvotecategory.VotingMethodSingleChoice, []*ent.Vote{ + row(a, 0), row(a, 0), row(b, 0), + }) + Expect(scores).To(Equal(map[uuid.UUID]int{a: 2, b: 1})) + }) + }) + + Describe("ranked", func() { + // Two voters, three submissions. N is 3, so rank 1 is worth 2, rank 2 + // worth 1 and rank 3 worth 0. + It("scores Borda with N taken from the whole field", func() { + scores := scoreBallots(entvotecategory.VotingMethodRanked, []*ent.Vote{ + row(a, 1), row(b, 2), row(c, 3), + row(b, 1), row(a, 2), row(c, 3), + }) + Expect(scores).To(Equal(map[uuid.UUID]int{a: 3, b: 3, c: 0})) + }) + + It("keeps a submission everyone ranked last, on zero rather than absent", func() { + scores := scoreBallots(entvotecategory.VotingMethodRanked, []*ent.Vote{ + row(a, 1), row(b, 2), + }) + Expect(scores).To(HaveKey(b)) + Expect(scores[b]).To(Equal(0)) + }) + }) + + Describe("points", func() { + It("sums what voters awarded", func() { + scores := scoreBallots(entvotecategory.VotingMethodPoints, []*ent.Vote{ + row(a, 5), row(b, 3), row(a, 2), + }) + Expect(scores).To(Equal(map[uuid.UUID]int{a: 7, b: 3})) + }) + }) + + It("returns nothing when no ballot named a submission", func() { + Expect(scoreBallots(entvotecategory.VotingMethodRanked, nil)).To(BeEmpty()) + }) +}) + +var _ = Describe("Ballot validation", func() { + var a, b, c uuid.UUID + var points *ent.VoteCategory + + BeforeEach(func() { + a = uuid.MustParse("00000000-0000-0000-0000-0000000000a1") + b = uuid.MustParse("00000000-0000-0000-0000-0000000000b2") + c = uuid.MustParse("00000000-0000-0000-0000-0000000000c3") + points = &ent.VoteCategory{MaxPoints: 10} + }) + + It("refuses an empty ballot", func() { + err := validateBallot(points, entvote.VoteTypeSingleChoice, nil) + Expect(codeOf(err)).To(Equal(codes.InvalidArgument)) + }) + + It("refuses the same submission twice on one ballot", func() { + err := validateBallot(points, entvote.VoteTypeRanked, []ballotLine{ + {submissionID: a, value: 1}, {submissionID: a, value: 2}, + }) + Expect(codeOf(err)).To(Equal(codes.InvalidArgument)) + }) + + Describe("ranked", func() { + It("accepts a contiguous 1..N in any order", func() { + Expect(validateBallot(points, entvote.VoteTypeRanked, []ballotLine{ + {submissionID: a, value: 3}, {submissionID: b, value: 1}, + {submissionID: c, value: 2}, + })).To(Succeed()) + }) + + It("refuses a repeated rank", func() { + err := validateBallot(points, entvote.VoteTypeRanked, []ballotLine{ + {submissionID: a, value: 1}, {submissionID: b, value: 1}, + }) + Expect(codeOf(err)).To(Equal(codes.InvalidArgument)) + }) + + It("refuses a gap", func() { + err := validateBallot(points, entvote.VoteTypeRanked, []ballotLine{ + {submissionID: a, value: 1}, {submissionID: b, value: 3}, + }) + Expect(codeOf(err)).To(Equal(codes.InvalidArgument)) + }) + }) + + Describe("points", func() { + It("accepts a ballot spending exactly the budget", func() { + Expect(validateBallot(points, entvote.VoteTypePoints, []ballotLine{ + {submissionID: a, value: 7}, {submissionID: b, value: 3}, + })).To(Succeed()) + }) + + It("refuses a ballot over budget", func() { + err := validateBallot(points, entvote.VoteTypePoints, []ballotLine{ + {submissionID: a, value: 7}, {submissionID: b, value: 4}, + }) + Expect(codeOf(err)).To(Equal(codes.InvalidArgument)) + }) + + It("refuses a non-positive award", func() { + err := validateBallot(points, entvote.VoteTypePoints, []ballotLine{ + {submissionID: a, value: 0}, + }) + Expect(codeOf(err)).To(Equal(codes.InvalidArgument)) + }) + + // A category with no budget is misconfigured, not a bad ballot — the + // voter can do nothing about it, so the code says so. + It("refuses every ballot when the category has no budget", func() { + err := validateBallot(&ent.VoteCategory{}, entvote.VoteTypePoints, []ballotLine{ + {submissionID: a, value: 1}, + }) + Expect(codeOf(err)).To(Equal(codes.FailedPrecondition)) + }) + }) + + It("refuses a single_choice ballot naming more than one submission", func() { + err := validateBallot(points, entvote.VoteTypeSingleChoice, []ballotLine{ + {submissionID: a}, {submissionID: b}, + }) + Expect(codeOf(err)).To(Equal(codes.InvalidArgument)) + }) +}) + +var _ = Describe("resolveMaxPoints", func() { + It("clears the budget for methods that do not use one", func() { + requested := int32(9) + got, err := resolveMaxPoints(entvotecategory.VotingMethodRanked, &requested, 4) + Expect(err).ToNot(HaveOccurred()) + Expect(got).To(Equal(0)) + }) + + It("keeps the stored budget when an edit leaves it out", func() { + got, err := resolveMaxPoints(entvotecategory.VotingMethodPoints, nil, 4) + Expect(err).ToNot(HaveOccurred()) + Expect(got).To(Equal(4)) + }) + + It("refuses a points category with no budget at all", func() { + _, err := resolveMaxPoints(entvotecategory.VotingMethodPoints, nil, 0) + Expect(codeOf(err)).To(Equal(codes.InvalidArgument)) + }) +}) + +var _ = Describe("voteTypeForMethod", func() { + It("maps each category method onto the row discriminator", func() { + Expect(voteTypeForMethod(entvotecategory.VotingMethodSingleChoice)). + To(Equal(entvote.VoteTypeSingleChoice)) + Expect(voteTypeForMethod(entvotecategory.VotingMethodRanked)). + To(Equal(entvote.VoteTypeRanked)) + Expect(voteTypeForMethod(entvotecategory.VotingMethodPoints)). + To(Equal(entvote.VoteTypePoints)) + }) +}) diff --git a/components/backend/internal/service/vote_service.go b/components/backend/internal/service/vote_service.go new file mode 100644 index 00000000..51a949f6 --- /dev/null +++ b/components/backend/internal/service/vote_service.go @@ -0,0 +1,1705 @@ +package service + +import ( + "bytes" + "context" + "encoding/csv" + "encoding/json" + "log/slog" + "sort" + "strconv" + "sync" + + "github.com/google/uuid" + "github.com/swissdatasciencecenter/hackagon/components/backend/ent" + enthackathon "github.com/swissdatasciencecenter/hackagon/components/backend/ent/hackathon" + enthackathonforms "github.com/swissdatasciencecenter/hackagon/components/backend/ent/hackathonforms" + enthackathonsettings "github.com/swissdatasciencecenter/hackagon/components/backend/ent/hackathonsettings" + entparticipant "github.com/swissdatasciencecenter/hackagon/components/backend/ent/participant" + "github.com/swissdatasciencecenter/hackagon/components/backend/ent/predicate" + entproject "github.com/swissdatasciencecenter/hackagon/components/backend/ent/project" + entsubmission "github.com/swissdatasciencecenter/hackagon/components/backend/ent/submission" + entteam "github.com/swissdatasciencecenter/hackagon/components/backend/ent/team" + entuser "github.com/swissdatasciencecenter/hackagon/components/backend/ent/user" + entvote "github.com/swissdatasciencecenter/hackagon/components/backend/ent/vote" + entvotecategory "github.com/swissdatasciencecenter/hackagon/components/backend/ent/votecategory" + entvoteresult "github.com/swissdatasciencecenter/hackagon/components/backend/ent/voteresult" + "github.com/swissdatasciencecenter/hackagon/components/backend/internal/capability" + m "github.com/swissdatasciencecenter/hackagon/components/backend/internal/middleware" + hackEnts "github.com/swissdatasciencecenter/hackagon/components/backend/internal/proto/hackathon/entities" + userEnts "github.com/swissdatasciencecenter/hackagon/components/backend/internal/proto/user/entities" + vote "github.com/swissdatasciencecenter/hackagon/components/backend/internal/proto/vote" + voteEnts "github.com/swissdatasciencecenter/hackagon/components/backend/internal/proto/vote/entities" + voteMsgs "github.com/swissdatasciencecenter/hackagon/components/backend/internal/proto/vote/messages/vote_svc" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" +) + +type VoteService struct { + vote.UnimplementedVoteServiceServer + dbClient *ent.Client + enforcer *m.Enforcer + // ballotMu serializes writeBallot. The unique index guards + // (category, voter, submission), so it says nothing about a SECOND ballot + // for a different submission — and the pre-check + delete-and-rewrite in + // writeBallot is check-then-act: two concurrent submits from one voter both + // passed the pre-check and both wrote (measured: 7 of 12 hammer rounds + // ended with two single-choice ballots for one voter in one category). + // A partial unique index would close it in the database, but ent cannot + // express one; an in-process lock closes it for this single-instance + // deployment. One mutex, not per-voter: a ballot write is a few + // milliseconds, and votes arrive at human speed. + ballotMu sync.Mutex +} + +func NewVoteService(dbClient *ent.Client, enf *m.Enforcer) *VoteService { + //exhaustruct:ignore // ballotMu: zero-value sync.Mutex is the usable initial state + return &VoteService{ + UnimplementedVoteServiceServer: vote.UnimplementedVoteServiceServer{}, + dbClient: dbClient, + enforcer: enf, + } +} + +// ─── Enum mappers ──────────────────────────────────────────────────── + +// UNSPECIFIED (and any future value) is deliberately unmappable. +// +//nolint:exhaustive // already falls into default, the correct "unmappable" answer via the bool ok return +func votingMethodToEnt(v voteEnts.VotingMethod) (votecategoryMethod, bool) { + switch v { + case voteEnts.VotingMethod_VOTING_METHOD_SINGLE_CHOICE: + return entvotecategory.VotingMethodSingleChoice, true + case voteEnts.VotingMethod_VOTING_METHOD_RANKED: + return entvotecategory.VotingMethodRanked, true + case voteEnts.VotingMethod_VOTING_METHOD_POINTS: + return entvotecategory.VotingMethodPoints, true + default: + return "", false + } +} + +func votingMethodFromEnt(v votecategoryMethod) voteEnts.VotingMethod { + switch v { + case entvotecategory.VotingMethodSingleChoice: + return voteEnts.VotingMethod_VOTING_METHOD_SINGLE_CHOICE + case entvotecategory.VotingMethodRanked: + return voteEnts.VotingMethod_VOTING_METHOD_RANKED + case entvotecategory.VotingMethodPoints: + return voteEnts.VotingMethod_VOTING_METHOD_POINTS + default: + return voteEnts.VotingMethod_VOTING_METHOD_UNSPECIFIED + } +} + +// voteTypeForMethod names the row discriminator a category's method produces. +// The two enums are separate ent types with the same three values, so the +// mapping is written out rather than cast. +func voteTypeForMethod(v votecategoryMethod) entvote.VoteType { + switch v { + case entvotecategory.VotingMethodRanked: + return entvote.VoteTypeRanked + case entvotecategory.VotingMethodPoints: + return entvote.VoteTypePoints + default: + return entvote.VoteTypeSingleChoice + } +} + +// UNSPECIFIED (and any future value) is deliberately unmappable. +// +//nolint:exhaustive // already falls into default, the correct "unmappable" answer via the bool ok return +func voterTypeToEnt(v voteEnts.VoterType) (votecategoryVoter, bool) { + switch v { + case voteEnts.VoterType_VOTER_TYPE_ALL_PARTICIPANTS: + return entvotecategory.VoterTypeAllParticipants, true + case voteEnts.VoterType_VOTER_TYPE_JURY: + return entvotecategory.VoterTypeJury, true + default: + return "", false + } +} + +func voterTypeFromEnt(v votecategoryVoter) voteEnts.VoterType { + switch v { + case entvotecategory.VoterTypeAllParticipants: + return voteEnts.VoterType_VOTER_TYPE_ALL_PARTICIPANTS + case entvotecategory.VoterTypeJury: + return voteEnts.VoterType_VOTER_TYPE_JURY + default: + return voteEnts.VoterType_VOTER_TYPE_UNSPECIFIED + } +} + +// Aliases keep the mapper signatures readable. +type ( + votecategoryMethod = entvotecategory.VotingMethod + votecategoryVoter = entvotecategory.VoterType +) + +// ─── Entity mappers ────────────────────────────────────────────────── + +// voteCategoryEntryFromEnt maps an ent VoteCategory (with Hackathon and +// JuryMembers eager-loaded) to its proto entity. +func voteCategoryEntryFromEnt(c *ent.VoteCategory) *voteEnts.VoteCategory { + //exhaustruct:ignore + entry := &voteEnts.VoteCategory{ + Id: c.ID.String(), + Name: c.Name, + Description: c.Description, + VotingMethod: votingMethodFromEnt(c.VotingMethod), + VoterType: voterTypeFromEnt(c.VoterType), + CreatedAt: c.CreatedAt.Unix(), + ModifiedAt: c.ModifiedAt.Unix(), + } + // Optional, not Nillable, so zero is how "no budget" reaches us — and a + // budget of zero would be a category nobody can vote in anyway. + if c.MaxPoints > 0 { + //nolint:gosec // G115: resolveMaxPoints only ever stores a value that came + // in as int32 from the proto request, so this round trip cannot overflow. + maxPoints := int32(c.MaxPoints) + entry.MaxPoints = &maxPoints + } + if c.Edges.Hackathon != nil { + entry.HackathonId = c.Edges.Hackathon.ID.String() + } + for _, u := range c.Edges.JuryMembers { + entry.JuryMembers = append(entry.JuryMembers, userEntryFromEnt(u)) + } + + return entry +} + +// categoryWithHackathon fetches a category with its hackathon edge, mapping +// not-found to the right gRPC code. +func (s *VoteService) categoryWithHackathon( + ctx context.Context, + id uuid.UUID, +) (*ent.VoteCategory, error) { + c, err := s.dbClient.VoteCategory.Query(). + Where(entvotecategory.IDEQ(id)). + WithHackathon(). + WithJuryMembers(). + Only(ctx) + if err != nil { + if ent.IsNotFound(err) { + return nil, status.Errorf(codes.NotFound, "vote category %s not found", id) + } + slog.Error("query vote category", "err", err) + + return nil, status.Error(codes.Internal, "couldn't query database") + } + + return c, nil +} + +// ─── VoteCategory CRUD ─────────────────────────────────────────────── + +func (s *VoteService) ListVoteCategories( + ctx context.Context, + req *voteMsgs.ListVoteCategoriesRequest, +) (*voteMsgs.ListVoteCategoriesResponse, error) { + if _, _, err := m.RequireUser(ctx); err != nil { + return nil, err + } + hackathonID, err := uuid.Parse(req.GetHackathonId()) + if err != nil { + return nil, status.Errorf(codes.InvalidArgument, "invalid hackathon_id: %v", err) + } + // Member-scoped, never anonymous: the entry mapper embeds jury members' + // emails, so leaving this on RequireSubject let anyone who could name a + // private event's id read its jury roster. + if err := s.enforcer.RequirePermission( + ctx, hackathonID.String(), m.Hackathon, m.Read, + ); err != nil { + return nil, err + } + categories, err := s.dbClient.VoteCategory.Query(). + Where(entvotecategory.HasHackathonWith(enthackathon.IDEQ(hackathonID))). + WithHackathon(). + WithJuryMembers(). + All(ctx) + if err != nil { + slog.Error("query vote categories", "err", err) + + return nil, status.Error(codes.Internal, "couldn't query database") + } + entries := make([]*voteEnts.VoteCategory, 0, len(categories)) + for _, c := range categories { + entries = append(entries, voteCategoryEntryFromEnt(c)) + } + + return &voteMsgs.ListVoteCategoriesResponse{VoteCategories: entries}, nil +} + +func (s *VoteService) GetVoteCategory( + ctx context.Context, + req *voteMsgs.GetVoteCategoryRequest, +) (*voteMsgs.GetVoteCategoryResponse, error) { + if _, _, err := m.RequireUser(ctx); err != nil { + return nil, err + } + id, err := uuid.Parse(req.GetId()) + if err != nil { + return nil, status.Errorf(codes.InvalidArgument, "invalid id: %v", err) + } + c, err := s.categoryWithHackathon(ctx, id) + if err != nil { + return nil, err + } + // Member-scoped for the same reason as List: the entry carries jury emails. + if err := s.enforcer.RequirePermission( + ctx, c.Edges.Hackathon.ID.String(), m.Hackathon, m.Read, + ); err != nil { + return nil, err + } + + return &voteMsgs.GetVoteCategoryResponse{VoteCategory: voteCategoryEntryFromEnt(c)}, nil +} + +func (s *VoteService) CreateVoteCategory( + ctx context.Context, + req *voteMsgs.CreateVoteCategoryRequest, +) (*voteMsgs.CreateVoteCategoryResponse, error) { + hackathonID, err := uuid.Parse(req.GetHackathonId()) + if err != nil { + return nil, status.Errorf(codes.InvalidArgument, "invalid hackathon_id: %v", err) + } + if err := s.enforcer.RequirePermission(ctx, hackathonID.String(), m.Hackathon, m.Write); err != nil { + return nil, err + } + method, ok := votingMethodToEnt(req.GetVotingMethod()) + if !ok { + return nil, status.Errorf(codes.InvalidArgument, "voting_method must be specified") + } + voter, ok := voterTypeToEnt(req.GetVoterType()) + if !ok { + return nil, status.Errorf(codes.InvalidArgument, "voter_type must be specified") + } + juryIDs, err := parseUUIDs(req.GetJuryMemberIds()) + if err != nil { + return nil, status.Errorf(codes.InvalidArgument, "invalid jury_member_ids: %v", err) + } + maxPoints, err := resolveMaxPoints(method, req.MaxPoints, 0) + if err != nil { + return nil, err + } + + create := s.dbClient.VoteCategory.Create(). + SetHackathonID(hackathonID). + SetName(req.GetName()). + SetDescription(req.GetDescription()). + SetVotingMethod(method). + SetVoterType(voter). + SetMaxPoints(maxPoints). + AddJuryMemberIDs(juryIDs...) + created, err := create.Save(ctx) + if err != nil { + if ent.IsConstraintError(err) { + return nil, status.Errorf(codes.InvalidArgument, "invalid reference: %v", err) + } + slog.Error("create vote category", "err", err) + + return nil, status.Error(codes.Internal, "couldn't create vote category") + } + + c, err := s.categoryWithHackathon(ctx, created.ID) + if err != nil { + return nil, err + } + + return &voteMsgs.CreateVoteCategoryResponse{VoteCategory: voteCategoryEntryFromEnt(c)}, nil +} + +func (s *VoteService) EditVoteCategory( + ctx context.Context, + req *voteMsgs.EditVoteCategoryRequest, +) (*voteMsgs.EditVoteCategoryResponse, error) { + id, err := uuid.Parse(req.GetId()) + if err != nil { + return nil, status.Errorf(codes.InvalidArgument, "invalid id: %v", err) + } + c, err := s.categoryWithHackathon(ctx, id) + if err != nil { + return nil, err + } + if err := s.enforcer.RequirePermission( + ctx, c.Edges.Hackathon.ID.String(), m.Hackathon, m.Write, + ); err != nil { + return nil, err + } + + update := s.dbClient.VoteCategory.UpdateOneID(id) + if req.Name != nil { + update.SetName(req.GetName()) + } + if req.Description != nil { + update.SetDescription(req.GetDescription()) + } + method := c.VotingMethod + if req.VotingMethod != nil { + requested, ok := votingMethodToEnt(req.GetVotingMethod()) + if !ok { + return nil, status.Errorf(codes.InvalidArgument, "invalid voting_method") + } + if err := s.methodChangeAllowed(ctx, id, c.VotingMethod, requested); err != nil { + return nil, err + } + method = requested + update.SetVotingMethod(method) + } + maxPoints, err := resolveMaxPoints(method, req.MaxPoints, c.MaxPoints) + if err != nil { + return nil, err + } + update.SetMaxPoints(maxPoints) + if req.VoterType != nil { + voter, ok := voterTypeToEnt(req.GetVoterType()) + if !ok { + return nil, status.Errorf(codes.InvalidArgument, "invalid voter_type") + } + update.SetVoterType(voter) + } + // proto3 cannot distinguish empty from absent for repeated fields: a + // non-empty list replaces the jury; an empty list leaves it unchanged. + if len(req.GetJuryMemberIds()) > 0 { + juryIDs, err := parseUUIDs(req.GetJuryMemberIds()) + if err != nil { + return nil, status.Errorf(codes.InvalidArgument, "invalid jury_member_ids: %v", err) + } + update.ClearJuryMembers().AddJuryMemberIDs(juryIDs...) + } + if _, err := update.Save(ctx); err != nil { + if ent.IsConstraintError(err) { + return nil, status.Errorf(codes.InvalidArgument, "invalid reference: %v", err) + } + slog.Error("edit vote category", "err", err) + + return nil, status.Error(codes.Internal, "couldn't edit vote category") + } + + updated, err := s.categoryWithHackathon(ctx, id) + if err != nil { + return nil, err + } + + return &voteMsgs.EditVoteCategoryResponse{VoteCategory: voteCategoryEntryFromEnt(updated)}, nil +} + +// methodChangeAllowed refuses to re-shape a category people have already voted +// in. Ballots are cast in the shape the method dictates: a ranked row means +// nothing under points scoring, and a category holding two kinds of row tallies +// to nonsense. Deleting the category is the explicit way to throw ballots away. +func (s *VoteService) methodChangeAllowed( + ctx context.Context, + categoryID uuid.UUID, + current, requested votecategoryMethod, +) error { + if requested == current { + return nil + } + cast, err := s.dbClient.Vote.Query(). + Where(entvote.HasCategoryWith(entvotecategory.IDEQ(categoryID))). + Exist(ctx) + if err != nil { + slog.Error("query votes for method change", "err", err) + + return status.Error(codes.Internal, "couldn't query database") + } + if cast { + return status.Error(codes.FailedPrecondition, + "ballots have already been cast in this category — its voting method cannot change") + } + + return nil +} + +func (s *VoteService) DeleteVoteCategory( + ctx context.Context, + req *voteMsgs.DeleteVoteCategoryRequest, +) (*voteMsgs.DeleteVoteCategoryResponse, error) { + id, err := uuid.Parse(req.GetId()) + if err != nil { + return nil, status.Errorf(codes.InvalidArgument, "invalid id: %v", err) + } + c, err := s.categoryWithHackathon(ctx, id) + if err != nil { + return nil, err + } + if err := s.enforcer.RequirePermission( + ctx, c.Edges.Hackathon.ID.String(), m.Hackathon, m.Write, + ); err != nil { + return nil, err + } + if err := s.dbClient.VoteCategory.DeleteOneID(id).Exec(ctx); err != nil { + slog.Error("delete vote category", "err", err) + + return nil, status.Error(codes.Internal, "couldn't delete vote category") + } + + return &voteMsgs.DeleteVoteCategoryResponse{}, nil +} + +// ─── Voting ────────────────────────────────────────────────────────── + +// voteEntryFromEnt maps an ent Vote (with Category, Voter, Submission +// eager-loaded) to its proto entity. One row is one judgment on one submission, +// so a ranked or points ballot maps to several of these. +func voteEntryFromEnt(v *ent.Vote) *voteEnts.Vote { + //exhaustruct:ignore + entry := &voteEnts.Vote{ + Id: v.ID.String(), + CreatedAt: v.CreatedAt.Unix(), + ModifiedAt: v.ModifiedAt.Unix(), + } + if v.Edges.Category != nil { + entry.CategoryId = v.Edges.Category.ID.String() + } + if v.Edges.Voter != nil { + entry.VoterId = v.Edges.Voter.ID.String() + } + if v.Edges.Submission == nil { + return entry + } + submissionID := v.Edges.Submission.ID.String() + //nolint:gosec // G115: writeBallot only ever stores a rank/points value that + // came in as int32 from the proto ballot, so this round trip cannot overflow. + value := int32(v.Value) + switch v.VoteType { + case entvote.VoteTypeSingleChoice: + entry.Vote = &voteEnts.Vote_SingleChoice{ + SingleChoice: &voteEnts.SingleChoiceVote{SubmissionId: submissionID}, + } + case entvote.VoteTypeRanked: + entry.Vote = &voteEnts.Vote_Ranked{ + Ranked: &voteEnts.RankedVote{SubmissionId: submissionID, Rank: value}, + } + case entvote.VoteTypePoints: + entry.Vote = &voteEnts.Vote_Points{ + Points: &voteEnts.PointsVote{SubmissionId: submissionID, Points: value}, + } + } + + return entry +} + +// ballotLine is one row a ballot will produce: which submission, and the rank +// or point award attached to it (zero for single_choice). +type ballotLine struct { + submissionID uuid.UUID + value int +} + +// parseBallot pulls the category, the method and the rows out of whichever +// oneof variant the caller filled in. It does not consult the category — the +// caller does that, because refusing a ballot for the wrong method needs the +// category loaded first. +func parseBallot( + req *voteMsgs.SubmitVoteRequest, +) (uuid.UUID, entvote.VoteType, []ballotLine, error) { + fail := func(format string, args ...any) (uuid.UUID, entvote.VoteType, []ballotLine, error) { + return uuid.Nil, "", nil, status.Errorf(codes.InvalidArgument, format, args...) + } + + switch v := req.GetVote().(type) { + case *voteMsgs.SubmitVoteRequest_SingleChoice: + categoryID, err := uuid.Parse(v.SingleChoice.GetCategoryId()) + if err != nil { + return fail("invalid category_id: %v", err) + } + submissionID, err := uuid.Parse(v.SingleChoice.GetSubmissionId()) + if err != nil { + return fail("invalid submission_id: %v", err) + } + + return categoryID, entvote.VoteTypeSingleChoice, + []ballotLine{{submissionID: submissionID, value: 0}}, nil + + case *voteMsgs.SubmitVoteRequest_Ranked: + categoryID, err := uuid.Parse(v.Ranked.GetCategoryId()) + if err != nil { + return fail("invalid category_id: %v", err) + } + lines := make([]ballotLine, 0, len(v.Ranked.GetSubmissions())) + for _, entry := range v.Ranked.GetSubmissions() { + submissionID, err := uuid.Parse(entry.GetSubmissionId()) + if err != nil { + return fail("invalid submission_id: %v", err) + } + lines = append(lines, ballotLine{ + submissionID: submissionID, + value: int(entry.GetRank()), + }) + } + + return categoryID, entvote.VoteTypeRanked, lines, nil + + case *voteMsgs.SubmitVoteRequest_Points: + categoryID, err := uuid.Parse(v.Points.GetCategoryId()) + if err != nil { + return fail("invalid category_id: %v", err) + } + lines := make([]ballotLine, 0, len(v.Points.GetSubmissions())) + for _, entry := range v.Points.GetSubmissions() { + submissionID, err := uuid.Parse(entry.GetSubmissionId()) + if err != nil { + return fail("invalid submission_id: %v", err) + } + lines = append(lines, ballotLine{ + submissionID: submissionID, + value: int(entry.GetPoints()), + }) + } + + return categoryID, entvote.VoteTypePoints, lines, nil + + default: + return fail("a ballot must carry single_choice, ranked or points") + } +} + +// validateBallot applies the rules that belong to the method itself. Anything +// needing the database (does this submission belong to the event, has this +// voter already voted) is checked by the caller. +func validateBallot(c *ent.VoteCategory, method entvote.VoteType, lines []ballotLine) error { + if len(lines) == 0 { + return status.Error(codes.InvalidArgument, "a ballot must name at least one submission") + } + // A submission twice in one ballot is a double vote wearing a ranking. + seen := make(map[uuid.UUID]struct{}, len(lines)) + for _, l := range lines { + if _, dup := seen[l.submissionID]; dup { + return status.Errorf(codes.InvalidArgument, + "submission %s appears twice in the same ballot", l.submissionID) + } + seen[l.submissionID] = struct{}{} + } + + switch method { + case entvote.VoteTypeSingleChoice: + if len(lines) != 1 { + return status.Error(codes.InvalidArgument, + "a single_choice ballot names exactly one submission") + } + + case entvote.VoteTypeRanked: + // Ranks must be a contiguous 1..N. A gap or a repeat makes Borda count + // something the voter did not mean: with N submissions ranked 1,1,3 two + // of them share a first preference that only one voter cast. + ranks := make([]int, 0, len(lines)) + for _, l := range lines { + ranks = append(ranks, l.value) + } + sort.Ints(ranks) + for i, r := range ranks { + if r != i+1 { + return status.Errorf(codes.InvalidArgument, + "ranks must be 1..%d with no gaps and no repeats", len(lines)) + } + } + + case entvote.VoteTypePoints: + if c.MaxPoints <= 0 { + return status.Error(codes.FailedPrecondition, + "this points category has no points budget — an organizer must set max_points") + } + total := 0 + for _, l := range lines { + if l.value <= 0 { + return status.Error(codes.InvalidArgument, + "every points award must be greater than zero") + } + total += l.value + } + if total > c.MaxPoints { + return status.Errorf(codes.InvalidArgument, + "this ballot spends %d points but the category allows %d", total, c.MaxPoints) + } + } + + return nil +} + +// submissionsInHackathon refuses a ballot naming a submission from another +// event. A submission belongs to a hackathon through its project. +func (s *VoteService) submissionsInHackathon( + ctx context.Context, + hackathonID uuid.UUID, + lines []ballotLine, +) error { + ids := make([]uuid.UUID, 0, len(lines)) + for _, l := range lines { + ids = append(ids, l.submissionID) + } + found, err := s.dbClient.Submission.Query(). + Where( + entsubmission.IDIn(ids...), + entsubmission.HasProjectWith(entproject.HasHackathonWith(enthackathon.IDEQ(hackathonID))), + ). + Count(ctx) + if err != nil { + slog.Error("query ballot submissions", "err", err) + + return status.Error(codes.Internal, "couldn't query database") + } + if found != len(ids) { + return status.Error(codes.InvalidArgument, + "a ballot may only name submissions from this hackathon") + } + + return nil +} + +// resolveMaxPoints decides what max_points a category should carry given the +// method it will have. Points categories must have a positive budget or nobody +// can cast a valid ballot; the other methods carry none. +func resolveMaxPoints( + method votecategoryMethod, + requested *int32, + current int, +) (int, error) { + if method != entvotecategory.VotingMethodPoints { + return 0, nil + } + effective := current + if requested != nil { + effective = int(*requested) + } + if effective <= 0 { + return 0, status.Error(codes.InvalidArgument, + "points categories need max_points greater than zero") + } + + return effective, nil +} + +// SubmitVote casts one ballot. The voter must be a confirmed participant of +// the category's hackathon (organizers/admins are NOT exempt — voting is a +// participant act), voting must be open (settings.voting_enabled), and for +// jury categories the voter must be on the jury. One ballot per voter per +// category, which the handler now enforces itself: a ranked ballot is several +// rows sharing a (category, voter), so the unique index moved down to the +// submission and can no longer say "you already voted". +// votingPolicy is the organizer's ruling, as SetVotingPolicy stored it. +// +// Every field defaults to the behaviour that was hard-coded before this read +// existed, so an event with no policy row behaves exactly as it always did: +// organizers do not vote, voting for your own team is allowed. Setting a +// policy is what changes anything. +type votingPolicy struct { + organizerVoting bool + ownTeamVoting bool +} + +func (s *VoteService) votingPolicyFor(ctx context.Context, hackathonID uuid.UUID) votingPolicy { + p := votingPolicy{organizerVoting: false, ownTeamVoting: true} + + row, err := s.dbClient.HackathonForms.Query(). + Where(enthackathonforms.HasHackathonWith(enthackathon.IDEQ(hackathonID))). + Only(ctx) + if err != nil { + // No row, or a query that failed: fall back to the defaults rather than + // refusing the vote. A policy nobody set must not close the ballot. + if !ent.IsNotFound(err) { + slog.Error("query voting policy", "err", err) + } + + return p + } + + if v, ok := row.VotingPolicy["organizerVoting"].(bool); ok { + p.organizerVoting = v + } + if v, ok := row.VotingPolicy["ownTeamVoting"].(bool); ok { + p.ownTeamVoting = v + } + + return p +} + +// mayVote answers whether this voter is allowed to cast this ballot in this +// category: jury membership for jury categories, and for everyone else the +// organizer's own ruling plus confirmed participation. +func (s *VoteService) mayVote( + ctx context.Context, + c *ent.VoteCategory, + uid string, + voter *ent.User, + lines []ballotLine, +) error { + hackathonID := c.Edges.Hackathon.ID + + if c.VoterType == entvotecategory.VoterTypeJury { + for _, j := range c.Edges.JuryMembers { + if j.ID == voter.ID { + return nil + } + } + + return status.Error(codes.PermissionDenied, "only jury members may vote in this category") + } + + // The organizer's own ruling, not a constant. Both fields were stored by + // SetVotingPolicy and then never read: organizerVoting was hard-coded here + // and ownTeamVoting was enforced nowhere at all, so an event that set either + // one got no effect from it. + policy := s.votingPolicyFor(ctx, hackathonID) + + // Organizers are neutral by default: whoever runs the event does not also + // vote in it. An event that says otherwise may. + if !policy.organizerVoting && s.isOrganizer(uid, hackathonID) { + return status.Error(codes.PermissionDenied, "organizers do not vote") + } + + // Voting for the submission of a team you are on. Allowed unless the event + // forbids it — a small hackathon where everyone knows everyone often wants + // it, and a competitive one does not. + if !policy.ownTeamVoting { + ids := make([]uuid.UUID, 0, len(lines)) + for _, l := range lines { + ids = append(ids, l.submissionID) + } + ownTeam, err := s.dbClient.Submission.Query(). + Where( + entsubmission.IDIn(ids...), + entsubmission.HasTeamWith(entteam.HasMembersWith(entuser.IDEQ(voter.ID))), + ). + Exist(ctx) + if err != nil { + slog.Error("query own-team submission", "err", err) + + return status.Error(codes.Internal, "couldn't query database") + } + if ownTeam { + return status.Error(codes.PermissionDenied, + "this event does not allow voting for your own team's submission") + } + } + + confirmed, err := s.dbClient.Participant.Query(). + Where( + entparticipant.HasUserWith(entuser.IDEQ(voter.ID)), + entparticipant.HasHackathonWith(enthackathon.IDEQ(hackathonID)), + entparticipant.IsWaiting(false), + ). + Exist(ctx) + if err != nil { + slog.Error("query participant", "err", err) + + return status.Error(codes.Internal, "couldn't query database") + } + if !confirmed { + return status.Error(codes.PermissionDenied, "only confirmed participants may vote") + } + + return nil +} + +// isOrganizer is true for a hackathon Owner and for a global Admin. A role +// lookup that errors is read as "not an organizer": the participant check +// below is the one that has to hold, and a casbin hiccup must not hand someone +// a ballot they would otherwise be refused. +func (s *VoteService) isOrganizer(uid string, hackathonID uuid.UUID) bool { + if role, err := s.enforcer.GetHackathonRole(uid, hackathonID.String()); err == nil && + role == hackEnts.HackathonRole_HACKATHON_ROLE_OWNER { + return true + } + globals, err := s.enforcer.GetGlobalRoles(uid) + if err != nil { + return false + } + for _, g := range globals { + if g == userEnts.GlobalRole_GLOBAL_ROLE_ADMIN { + return true + } + } + + return false +} + +func (s *VoteService) SubmitVote( + ctx context.Context, + req *voteMsgs.SubmitVoteRequest, +) (*voteMsgs.SubmitVoteResponse, error) { + uid, _, err := m.RequireSubject(ctx) + if err != nil { + return nil, err + } + if uid == m.AnonSubject { + return nil, status.Error(codes.Unauthenticated, "authentication required") + } + + categoryID, method, lines, err := parseBallot(req) + if err != nil { + return nil, err + } + + c, err := s.categoryWithHackathon(ctx, categoryID) + if err != nil { + return nil, err + } + // The organizer picks the method; the ballot has to be cast in it. A ranked + // payload against a single_choice category is a client bug, not a vote. + if want := voteTypeForMethod(c.VotingMethod); want != method { + return nil, status.Errorf(codes.InvalidArgument, + "this category takes %s ballots, not %s", want, method) + } + if err := validateBallot(c, method, lines); err != nil { + return nil, err + } + hackathonID := c.Edges.Hackathon.ID + + // Voting window: closed unless the settings row explicitly enables it. + settings, err := s.dbClient.HackathonSettings.Query(). + Where(enthackathonsettings.HasHackathonWith(enthackathon.IDEQ(hackathonID))). + Only(ctx) + if err != nil && !ent.IsNotFound(err) { + slog.Error("query hackathon settings", "err", err) + + return nil, status.Error(codes.Internal, "couldn't query hackathon settings") + } + if settings == nil || !settings.VotingEnabled { + return nil, status.Error(codes.FailedPrecondition, "voting is closed") + } + // And the VOTE capability. It was declared, seeded and rendered as a switch + // in the organiser panel while no handler read it — flipping it changed + // nothing, which is worse than not offering it. The settings flag stays: + // it is the event-wide "voting is running" state, where the capability is + // the schedulable one that a phase can open. + if err := requireCapability(ctx, s.dbClient, s.enforcer, hackathonID, capability.Vote); err != nil { + return nil, err + } + + voter, err := s.dbClient.User.Query().Where(entuser.KeycloakIDEQ(uid)).Only(ctx) + if err != nil { + if ent.IsNotFound(err) { + return nil, status.Errorf(codes.NotFound, "user %s not found", uid) + } + slog.Error("query user", "err", err) + + return nil, status.Error(codes.Internal, "couldn't query database") + } + + if err := s.mayVote(ctx, c, uid, voter, lines); err != nil { + return nil, err + } + + // Every submission on the ballot has to be this event's. The unique index + // used to make voting for a foreign submission merely odd; nothing ever + // refused it. + if err := s.submissionsInHackathon(ctx, hackathonID, lines); err != nil { + return nil, err + } + + written, err := s.writeBallot(ctx, c, voter.ID, method, lines) + if err != nil { + return nil, err + } + + entries := make([]*voteEnts.Vote, 0, len(written)) + for _, v := range written { + entries = append(entries, voteEntryFromEnt(v)) + } + if len(entries) == 0 { + return nil, status.Error(codes.Internal, "the ballot recorded no votes") + } + + return &voteMsgs.SubmitVoteResponse{Vote: entries[0], Votes: entries}, nil +} + +// writeBallot is where "one ballot per voter per category" now lives. The DB +// index guards (category, voter, submission) so that one voter cannot rank the +// same submission twice; it says nothing at all about a SECOND ballot, which +// used to come back as AlreadyExists for free. +// +// So: refuse outright if this voter already has rows in this category, then +// clear and rewrite inside one transaction. The delete is what keeps the +// invariant true if rows ever survive a half-written ballot — without it a +// retry would stack a second ballot on top of the first. +func (s *VoteService) writeBallot( + ctx context.Context, + c *ent.VoteCategory, + voterID uuid.UUID, + method entvote.VoteType, + lines []ballotLine, +) ([]*ent.Vote, error) { + // The pre-check below and the delete-and-rewrite are one decision; without + // this lock two concurrent submits both saw "no ballot yet" and both wrote. + s.ballotMu.Lock() + defer s.ballotMu.Unlock() + + mine := []predicate.Vote{ + entvote.HasCategoryWith(entvotecategory.IDEQ(c.ID)), + entvote.HasVoterWith(entuser.IDEQ(voterID)), + } + voted, err := s.dbClient.Vote.Query().Where(mine...).Exist(ctx) + if err != nil { + slog.Error("query existing ballot", "err", err) + + return nil, status.Error(codes.Internal, "couldn't query database") + } + if voted { + return nil, status.Error(codes.AlreadyExists, "already voted in this category") + } + + txn, err := s.dbClient.Tx(ctx) + if err != nil { + slog.Error("start transaction", "err", err) + + return nil, status.Error(codes.Internal, "couldn't start transaction") + } + fail := func(err error, msg string) error { + _ = txn.Rollback() + if ent.IsConstraintError(err) { + return status.Error(codes.AlreadyExists, "already voted in this category") + } + slog.Error(msg, "err", err) + + return status.Error(codes.Internal, "couldn't record ballot") + } + + if _, err := txn.Vote.Delete().Where(mine...).Exec(ctx); err != nil { + return nil, fail(err, "clear previous ballot") + } + + written := make([]*ent.Vote, 0, len(lines)) + for _, l := range lines { + create := txn.Vote.Create(). + SetCategoryID(c.ID). + SetVoterID(voterID). + SetSubmissionID(l.submissionID). + SetVoteType(method) + // single_choice carries no value, and the schema hook rejects a + // non-positive one on the other two. + if method != entvote.VoteTypeSingleChoice { + create.SetValue(l.value) + } + row, err := create.Save(ctx) + if err != nil { + return nil, fail(err, "create vote") + } + written = append(written, row) + } + + if err := txn.Commit(); err != nil { + slog.Error("commit ballot", "err", err) + + return nil, status.Error(codes.Internal, "couldn't record ballot") + } + + // Read back through the normal path so the response carries the same edges + // every other vote read does. + out := make([]*ent.Vote, 0, len(written)) + for _, row := range written { + v, err := s.voteByID(ctx, row.ID) + if err != nil { + return nil, err + } + out = append(out, v) + } + + return out, nil +} + +func (s *VoteService) voteByID(ctx context.Context, id uuid.UUID) (*ent.Vote, error) { + v, err := s.dbClient.Vote.Query(). + Where(entvote.IDEQ(id)). + WithCategory(). + WithVoter(). + WithSubmission(). + Only(ctx) + if err != nil { + if ent.IsNotFound(err) { + return nil, status.Errorf(codes.NotFound, "vote %s not found", id) + } + slog.Error("query vote", "err", err) + + return nil, status.Error(codes.Internal, "couldn't query database") + } + + return v, nil +} + +func (s *VoteService) GetVote( + ctx context.Context, + req *voteMsgs.GetVoteRequest, +) (*voteMsgs.GetVoteResponse, error) { + if _, _, err := m.RequireUser(ctx); err != nil { + return nil, err + } + id, err := uuid.Parse(req.GetId()) + if err != nil { + return nil, status.Errorf(codes.InvalidArgument, "invalid id: %v", err) + } + v, err := s.voteByID(ctx, id) + if err != nil { + return nil, err + } + // Ballots are secret: gate reading one exactly as ListVotes gates reading + // many — organizer/admin only. Without this any authenticated member could + // fetch any voter's ballot by id, and before it an anonymous caller could. + cat, err := s.categoryWithHackathon(ctx, v.Edges.Category.ID) + if err != nil { + return nil, err + } + if err := s.enforcer.RequirePermission( + ctx, cat.Edges.Hackathon.ID.String(), m.Hackathon, m.Write, + ); err != nil { + return nil, err + } + + return &voteMsgs.GetVoteResponse{Vote: voteEntryFromEnt(v)}, nil +} + +// ListVotes returns raw ballots — organizer/admin only (ballots are not +// public). category_id is required to scope the permission check. +func (s *VoteService) ListVotes( + ctx context.Context, + req *voteMsgs.ListVotesRequest, +) (*voteMsgs.ListVotesResponse, error) { + votes, err := s.votesForExport( + ctx, + req.GetCategoryId(), + req.GetVoterId(), + req.GetSubmissionId(), + ) + if err != nil { + return nil, err + } + entries := make([]*voteEnts.Vote, 0, len(votes)) + for _, v := range votes { + entries = append(entries, voteEntryFromEnt(v)) + } + + return &voteMsgs.ListVotesResponse{Votes: entries}, nil +} + +// votesForExport enforces the organizer/admin gate and returns ballots for a +// category with optional voter/submission filters. +func (s *VoteService) votesForExport( + ctx context.Context, + rawCategoryID, rawVoterID, rawSubmissionID string, +) ([]*ent.Vote, error) { + if rawCategoryID == "" { + return nil, status.Error(codes.InvalidArgument, "category_id is required") + } + categoryID, err := uuid.Parse(rawCategoryID) + if err != nil { + return nil, status.Errorf(codes.InvalidArgument, "invalid category_id: %v", err) + } + c, err := s.categoryWithHackathon(ctx, categoryID) + if err != nil { + return nil, err + } + if err := s.enforcer.RequirePermission( + ctx, c.Edges.Hackathon.ID.String(), m.Hackathon, m.Write, + ); err != nil { + return nil, err + } + + q := s.dbClient.Vote.Query(). + Where(entvote.HasCategoryWith(entvotecategory.IDEQ(categoryID))). + WithCategory(). + WithVoter(). + WithSubmission() + if rawVoterID != "" { + voterID, err := uuid.Parse(rawVoterID) + if err != nil { + return nil, status.Errorf(codes.InvalidArgument, "invalid voter_id: %v", err) + } + q = q.Where(entvote.HasVoterWith(entuser.IDEQ(voterID))) + } + if rawSubmissionID != "" { + submissionID, err := uuid.Parse(rawSubmissionID) + if err != nil { + return nil, status.Errorf(codes.InvalidArgument, "invalid submission_id: %v", err) + } + q = q.Where(entvote.HasSubmissionWith(entsubmission.IDEQ(submissionID))) + } + votes, err := q.All(ctx) + if err != nil { + slog.Error("query votes", "err", err) + + return nil, status.Error(codes.Internal, "couldn't query database") + } + + return votes, nil +} + +// ExportVotes serializes a category's raw ballots (organizer/admin only). +func (s *VoteService) ExportVotes( + ctx context.Context, + req *voteMsgs.ExportVotesRequest, +) (*voteMsgs.ExportVotesResponse, error) { + votes, err := s.votesForExport(ctx, req.GetCategoryId(), "", "") + if err != nil { + return nil, err + } + + // Tags are snake_case on purpose: this struct is the ExportVotes download + // format an organizer opens externally (and the e2e recipe's + // exportBallotCount reads `voter_id` off it directly), not a transient + // in-memory shape — renaming the tags would silently break both. + //nolint:tagliatelle // persisted export format, see comment above + type row struct { + ID string `json:"id"` + CategoryID string `json:"category_id"` + VoterID string `json:"voter_id"` + SubmissionID string `json:"submission_id"` + VoteType string `json:"vote_type"` + // Rank for ranked ballots, points awarded for points ballots, 0 for + // single choice. Without it an export of a ranked category was a list of + // names with the ranking stripped out. + Value int `json:"value"` + } + rows := make([]row, 0, len(votes)) + for _, v := range votes { + //exhaustruct:ignore + r := row{ID: v.ID.String(), VoteType: string(v.VoteType), Value: v.Value} + if v.Edges.Category != nil { + r.CategoryID = v.Edges.Category.ID.String() + } + if v.Edges.Voter != nil { + r.VoterID = v.Edges.Voter.ID.String() + } + if v.Edges.Submission != nil { + r.SubmissionID = v.Edges.Submission.ID.String() + } + rows = append(rows, r) + } + + //nolint:exhaustive // UNSPECIFIED (and any future format) already falls into + // default, which is the correct "format must be CSV or JSON" answer below. + switch req.GetFormat() { + case voteMsgs.ExportFormat_EXPORT_FORMAT_JSON: + data, err := json.MarshalIndent(rows, "", " ") + if err != nil { + slog.Error("marshal votes", "err", err) + + return nil, status.Error(codes.Internal, "couldn't serialize votes") + } + + return &voteMsgs.ExportVotesResponse{Data: data}, nil + case voteMsgs.ExportFormat_EXPORT_FORMAT_CSV: + var buf bytes.Buffer + w := csv.NewWriter(&buf) + _ = w.Write( + []string{"id", "category_id", "voter_id", "submission_id", "vote_type", "value"}, + ) + for _, r := range rows { + _ = w.Write([]string{ + r.ID, r.CategoryID, r.VoterID, r.SubmissionID, r.VoteType, strconv.Itoa(r.Value), + }) + } + w.Flush() + + return &voteMsgs.ExportVotesResponse{Data: buf.Bytes()}, nil + default: + return nil, status.Error(codes.InvalidArgument, "format must be CSV or JSON") + } +} + +// ─── Vote results ──────────────────────────────────────────────────── + +// voteResultEntryFromEnt maps an ent VoteResult (with VoteCategory and +// Submission eager-loaded) to its proto entity. +func voteResultEntryFromEnt(r *ent.VoteResult) *voteEnts.VoteResult { + //exhaustruct:ignore // CreatedAt/ModifiedAt: ent.VoteResult has no such columns + entry := &voteEnts.VoteResult{ + Id: r.ID.String(), + //nolint:gosec // G115: a submission's rank position (a loop index) or a + // value that came in as int32 from the proto request; cannot overflow. + Position: int32(r.Position), + } + if r.Title != "" { + entry.Title = &r.Title + } + if r.Edges.VoteCategory != nil { + entry.CategoryId = r.Edges.VoteCategory.ID.String() + } + if r.Edges.Submission != nil { + entry.SubmissionId = r.Edges.Submission.ID.String() + } + + return entry +} + +func (s *VoteService) resultByID(ctx context.Context, id uuid.UUID) (*ent.VoteResult, error) { + r, err := s.dbClient.VoteResult.Query(). + Where(entvoteresult.IDEQ(id)). + WithVoteCategory(func(q *ent.VoteCategoryQuery) { q.WithHackathon() }). + WithSubmission(). + Only(ctx) + if err != nil { + if ent.IsNotFound(err) { + return nil, status.Errorf(codes.NotFound, "vote result %s not found", id) + } + slog.Error("query vote result", "err", err) + + return nil, status.Error(codes.Internal, "couldn't query database") + } + + return r, nil +} + +func (s *VoteService) ListVoteResults( + ctx context.Context, + req *voteMsgs.ListVoteResultsRequest, +) (*voteMsgs.ListVoteResultsResponse, error) { + // Results are the published outcome — readable by any signed-in user ONCE + // the organiser has published them. VIEW_RESULTS is that switch, and like + // VOTE it was declared and toggleable while nothing read it, so placements + // were visible the moment they were recorded whatever the panel said. + // + // requireCapability lets organisers through regardless, which is what makes + // reviewing a tally before publishing it possible. + // + // RequireUser, not RequireSubject: the comment above says "any signed-in + // user", but RequireSubject admits the anonymous subject, so an unauthenticated + // caller could read placements the moment VIEW_RESULTS was open. Results are + // an in-event surface; there is no anonymous caller of ListVoteResults (only + // the authenticated /my/hackathon/[id]/voting route reads it). + if _, _, err := m.RequireUser(ctx); err != nil { + return nil, err + } + categoryID, err := uuid.Parse(req.GetCategoryId()) + if err != nil { + return nil, status.Errorf(codes.InvalidArgument, "invalid category_id: %v", err) + } + + c, err := s.categoryWithHackathon(ctx, categoryID) + if err != nil { + return nil, err + } + if err := requireCapability( + ctx, s.dbClient, s.enforcer, + c.Edges.Hackathon.ID, capability.ViewResults, + ); err != nil { + return nil, err + } + results, err := s.dbClient.VoteResult.Query(). + Where(entvoteresult.HasVoteCategoryWith(entvotecategory.IDEQ(categoryID))). + WithVoteCategory(). + WithSubmission(). + Order(entvoteresult.ByPosition()). + All(ctx) + if err != nil { + slog.Error("query vote results", "err", err) + + return nil, status.Error(codes.Internal, "couldn't query database") + } + entries := make([]*voteEnts.VoteResult, 0, len(results)) + for _, r := range results { + entries = append(entries, voteResultEntryFromEnt(r)) + } + + return &voteMsgs.ListVoteResultsResponse{VoteResults: entries}, nil +} + +func (s *VoteService) CreateVoteResult( + ctx context.Context, + req *voteMsgs.CreateVoteResultRequest, +) (*voteMsgs.CreateVoteResultResponse, error) { + categoryID, err := uuid.Parse(req.GetCategoryId()) + if err != nil { + return nil, status.Errorf(codes.InvalidArgument, "invalid category_id: %v", err) + } + submissionID, err := uuid.Parse(req.GetSubmissionId()) + if err != nil { + return nil, status.Errorf(codes.InvalidArgument, "invalid submission_id: %v", err) + } + c, err := s.categoryWithHackathon(ctx, categoryID) + if err != nil { + return nil, err + } + if err := s.enforcer.RequirePermission( + ctx, c.Edges.Hackathon.ID.String(), m.Hackathon, m.Write, + ); err != nil { + return nil, err + } + + create := s.dbClient.VoteResult.Create(). + SetVoteCategoryID(categoryID). + SetSubmissionID(submissionID). + SetPosition(int(req.GetPosition())) + if req.Title != nil { + create.SetTitle(req.GetTitle()) + } + created, err := create.Save(ctx) + if err != nil { + if ent.IsConstraintError(err) { + return nil, status.Errorf(codes.InvalidArgument, "invalid reference: %v", err) + } + slog.Error("create vote result", "err", err) + + return nil, status.Error(codes.Internal, "couldn't create vote result") + } + + r, err := s.resultByID(ctx, created.ID) + if err != nil { + return nil, err + } + + return &voteMsgs.CreateVoteResultResponse{VoteResult: voteResultEntryFromEnt(r)}, nil +} + +func (s *VoteService) EditVoteResult( + ctx context.Context, + req *voteMsgs.EditVoteResultRequest, +) (*voteMsgs.EditVoteResultResponse, error) { + id, err := uuid.Parse(req.GetId()) + if err != nil { + return nil, status.Errorf(codes.InvalidArgument, "invalid id: %v", err) + } + r, err := s.resultByID(ctx, id) + if err != nil { + return nil, err + } + if err := s.enforcer.RequirePermission( + ctx, r.Edges.VoteCategory.Edges.Hackathon.ID.String(), m.Hackathon, m.Write, + ); err != nil { + return nil, err + } + + update := s.dbClient.VoteResult.UpdateOneID(id) + if req.SubmissionId != nil { + submissionID, err := uuid.Parse(req.GetSubmissionId()) + if err != nil { + return nil, status.Errorf(codes.InvalidArgument, "invalid submission_id: %v", err) + } + update.SetSubmissionID(submissionID) + } + if req.Position != nil { + update.SetPosition(int(req.GetPosition())) + } + if req.Title != nil { + update.SetTitle(req.GetTitle()) + } + if _, err := update.Save(ctx); err != nil { + if ent.IsConstraintError(err) { + return nil, status.Errorf(codes.InvalidArgument, "invalid reference: %v", err) + } + slog.Error("edit vote result", "err", err) + + return nil, status.Error(codes.Internal, "couldn't edit vote result") + } + + updated, err := s.resultByID(ctx, id) + if err != nil { + return nil, err + } + + return &voteMsgs.EditVoteResultResponse{VoteResult: voteResultEntryFromEnt(updated)}, nil +} + +func (s *VoteService) DeleteVoteResult( + ctx context.Context, + req *voteMsgs.DeleteVoteResultRequest, +) (*voteMsgs.DeleteVoteResultResponse, error) { + id, err := uuid.Parse(req.GetId()) + if err != nil { + return nil, status.Errorf(codes.InvalidArgument, "invalid id: %v", err) + } + r, err := s.resultByID(ctx, id) + if err != nil { + return nil, err + } + if err := s.enforcer.RequirePermission( + ctx, r.Edges.VoteCategory.Edges.Hackathon.ID.String(), m.Hackathon, m.Write, + ); err != nil { + return nil, err + } + if err := s.dbClient.VoteResult.DeleteOneID(id).Exec(ctx); err != nil { + slog.Error("delete vote result", "err", err) + + return nil, status.Error(codes.Internal, "couldn't delete vote result") + } + + return &voteMsgs.DeleteVoteResultResponse{}, nil +} + +// ExportResults serializes a category's placements (organizer/admin only). +// SuggestResults computes the tally for a category and writes it as results. +// +// SUGGEST, not decide: the vote is advisory here and PrizeService.Finalize is +// what freezes an award, so this fills the table an organizer reviews. Before +// it existed the count lived nowhere — placements were typed in by hand from an +// export, which is the easiest possible place to get "who won" quietly wrong. +// +// All three methods are scored; scoreBallots holds the per-method arithmetic. +func (s *VoteService) SuggestResults( + ctx context.Context, + req *voteMsgs.SuggestResultsRequest, +) (*voteMsgs.SuggestResultsResponse, error) { + if _, _, err := m.RequireUser(ctx); err != nil { + return nil, err + } + + categoryID, err := uuid.Parse(req.GetCategoryId()) + if err != nil { + return nil, status.Errorf(codes.InvalidArgument, "invalid category_id: %v", err) + } + + c, err := s.categoryWithHackathon(ctx, categoryID) + if err != nil { + return nil, err + } + // Same gate as writing a result by hand — this writes the same rows. + if err := s.enforcer.RequirePermission( + ctx, c.Edges.Hackathon.ID.String(), m.Hackathon, m.Write, + ); err != nil { + return nil, err + } + + existing, err := s.dbClient.VoteResult.Query(). + Where(entvoteresult.HasVoteCategoryWith(entvotecategory.IDEQ(categoryID))). + All(ctx) + if err != nil { + slog.Error("query vote results", "err", err) + + return nil, status.Error(codes.Internal, "couldn't query database") + } + // A recount that silently replaces a published placement is how a + // correction becomes an accusation. Make the caller say they mean it. + if len(existing) > 0 && !req.GetForce() { + return nil, status.Errorf( + codes.FailedPrecondition, + "this category already has %d recorded result(s) — pass force to recompute", + len(existing), + ) + } + + votes, err := s.dbClient.Vote.Query(). + Where(entvote.HasCategoryWith(entvotecategory.IDEQ(categoryID))). + WithSubmission(). + All(ctx) + if err != nil { + slog.Error("query votes", "err", err) + + return nil, status.Error(codes.Internal, "couldn't query database") + } + + counts := scoreBallots(c.VotingMethod, votes) + if len(counts) == 0 { + return nil, status.Error( + codes.FailedPrecondition, + "no ballots have been cast in this category", + ) + } + + type tally struct { + submissionID uuid.UUID + count int + } + ordered := make([]tally, 0, len(counts)) + for id, n := range counts { + ordered = append(ordered, tally{submissionID: id, count: n}) + } + // Highest first; the id breaks ties so two runs of the same ballots cannot + // disagree about the order rows are written in. + sort.Slice(ordered, func(i, j int) bool { + if ordered[i].count != ordered[j].count { + return ordered[i].count > ordered[j].count + } + + return ordered[i].submissionID.String() < ordered[j].submissionID.String() + }) + + txn, err := s.dbClient.Tx(ctx) + if err != nil { + slog.Error("start transaction", "err", err) + + return nil, status.Error(codes.Internal, "couldn't start transaction") + } + + if len(existing) > 0 { + ids := make([]uuid.UUID, 0, len(existing)) + for _, r := range existing { + ids = append(ids, r.ID) + } + if _, err := txn.VoteResult.Delete(). + Where(entvoteresult.IDIn(ids...)). + Exec(ctx); err != nil { + _ = txn.Rollback() + slog.Error("delete previous results", "err", err) + + return nil, status.Error(codes.Internal, "couldn't replace previous results") + } + } + + // Ties SHARE a position: two submissions on the same count are both second, + // and the organizer decides what to do about it rather than the tally + // inventing an order it cannot justify. + position := 0 + previous := -1 + written := make([]*ent.VoteResult, 0, len(ordered)) + for i, t := range ordered { + if t.count != previous { + position = i + 1 + previous = t.count + } + row, err := txn.VoteResult.Create(). + SetVoteCategoryID(categoryID). + SetSubmissionID(t.submissionID). + SetPosition(position). + Save(ctx) + if err != nil { + _ = txn.Rollback() + slog.Error("write suggested result", "err", err) + + return nil, status.Error(codes.Internal, "couldn't write results") + } + written = append(written, row) + } + + if err := txn.Commit(); err != nil { + slog.Error("commit results", "err", err) + + return nil, status.Error(codes.Internal, "couldn't commit results") + } + + out := make([]*voteEnts.VoteResult, 0, len(written)) + for _, r := range written { + out = append(out, voteResultEntryFromEnt(r)) + } + + return &voteMsgs.SuggestResultsResponse{Results: out}, nil +} + +// scoreBallots turns a category's raw rows into one score per submission. Every +// submission that appears on any ballot gets a key, so a submission ranked last +// by everyone still places rather than vanishing. +// +// - single_choice: one point per ballot naming it. +// - ranked: Borda. With N distinct submissions on the ballots, rank 1 is worth +// N-1 and rank N is worth 0 — the gap between consecutive ranks is the same +// everywhere, which is the property that makes ranks addable at all. +// - points: the sum of what voters awarded it. +func scoreBallots(method votecategoryMethod, votes []*ent.Vote) map[uuid.UUID]int { + scores := map[uuid.UUID]int{} + for _, v := range votes { + if v.Edges.Submission != nil { + scores[v.Edges.Submission.ID] += 0 + } + } + if len(scores) == 0 { + return scores + } + + // N is fixed before scoring: it is the size of the field, not of one ballot, + // so a voter who ranked only some submissions cannot change what a rank is + // worth to everyone else. + field := len(scores) + for _, v := range votes { + if v.Edges.Submission == nil { + continue + } + switch method { + case entvotecategory.VotingMethodRanked: + scores[v.Edges.Submission.ID] += field - v.Value + case entvotecategory.VotingMethodPoints: + scores[v.Edges.Submission.ID] += v.Value + default: + scores[v.Edges.Submission.ID]++ + } + } + + return scores +} + +func (s *VoteService) ExportResults( + ctx context.Context, + req *voteMsgs.ExportResultsRequest, +) (*voteMsgs.ExportResultsResponse, error) { + categoryID, err := uuid.Parse(req.GetCategoryId()) + if err != nil { + return nil, status.Errorf(codes.InvalidArgument, "invalid category_id: %v", err) + } + c, err := s.categoryWithHackathon(ctx, categoryID) + if err != nil { + return nil, err + } + if err := s.enforcer.RequirePermission( + ctx, c.Edges.Hackathon.ID.String(), m.Hackathon, m.Write, + ); err != nil { + return nil, err + } + results, err := s.dbClient.VoteResult.Query(). + Where(entvoteresult.HasVoteCategoryWith(entvotecategory.IDEQ(categoryID))). + WithVoteCategory(). + WithSubmission(). + Order(entvoteresult.ByPosition()). + All(ctx) + if err != nil { + slog.Error("query vote results", "err", err) + + return nil, status.Error(codes.Internal, "couldn't query database") + } + + // Tags are snake_case on purpose, matching ExportVotes' download format — + // this struct is the ExportResults download an organizer opens externally, + // not a transient in-memory shape. + //nolint:tagliatelle // persisted export format, see comment above + type row struct { + ID string `json:"id"` + CategoryID string `json:"category_id"` + SubmissionID string `json:"submission_id"` + Position int `json:"position"` + Title string `json:"title,omitempty"` + } + rows := make([]row, 0, len(results)) + for _, r := range results { + //exhaustruct:ignore + out := row{ID: r.ID.String(), Position: r.Position, Title: r.Title} + if r.Edges.VoteCategory != nil { + out.CategoryID = r.Edges.VoteCategory.ID.String() + } + if r.Edges.Submission != nil { + out.SubmissionID = r.Edges.Submission.ID.String() + } + rows = append(rows, out) + } + + //nolint:exhaustive // UNSPECIFIED (and any future format) already falls into + // default, which is the correct "format must be CSV or JSON" answer below. + switch req.GetFormat() { + case voteMsgs.ExportFormat_EXPORT_FORMAT_JSON: + data, err := json.MarshalIndent(rows, "", " ") + if err != nil { + slog.Error("marshal vote results", "err", err) + + return nil, status.Error(codes.Internal, "couldn't serialize results") + } + + return &voteMsgs.ExportResultsResponse{Data: data}, nil + case voteMsgs.ExportFormat_EXPORT_FORMAT_CSV: + var buf bytes.Buffer + w := csv.NewWriter(&buf) + _ = w.Write([]string{"id", "category_id", "submission_id", "position", "title"}) + for _, r := range rows { + _ = w.Write( + []string{r.ID, r.CategoryID, r.SubmissionID, strconv.Itoa(r.Position), r.Title}, + ) + } + w.Flush() + + return &voteMsgs.ExportResultsResponse{Data: buf.Bytes()}, nil + default: + return nil, status.Error(codes.InvalidArgument, "format must be CSV or JSON") + } +} + +// ─── Shared helpers ────────────────────────────────────────────────── + +func parseUUIDs(raw []string) ([]uuid.UUID, error) { + ids := make([]uuid.UUID, 0, len(raw)) + for _, r := range raw { + id, err := uuid.Parse(r) + if err != nil { + return nil, err + } + ids = append(ids, id) + } + + return ids, nil +} diff --git a/components/backend/internal/storage/client.go b/components/backend/internal/storage/client.go new file mode 100644 index 00000000..a1423f67 --- /dev/null +++ b/components/backend/internal/storage/client.go @@ -0,0 +1,417 @@ +// Package storage talks to the S3-compatible object store that holds uploaded +// files (docs/storage.md). It does three things and no more: +// +// - mint presigned URLs, so the browser uploads and downloads directly and +// the file never passes through the app server; +// - delete every object under a prefix, which is how a deleted hackathon or +// a deleted account takes its images with it; +// - list what is under a prefix, which is what lets a picker offer an image +// that is already there instead of a second copy of it. +// +// Bytes never flow through this package either — the listing and the delete are +// the only things here that open a socket at all. +package storage + +import ( + "context" + "encoding/xml" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "strconv" + "strings" + "time" + + "github.com/swissdatasciencecenter/hackagon/components/backend/internal/config" +) + +var ( + // ErrUnsafePrefix guards DeletePrefix against the one mistake that cannot + // be undone: an empty or unterminated prefix matches the whole bucket. + ErrUnsafePrefix = errors.New("refusing to delete an unbounded prefix") + // ErrIncompleteConfig is returned by New when the endpoint, bucket or + // credentials are missing. + ErrIncompleteConfig = errors.New("incomplete storage configuration") +) + +const ( + // backendTTL is how long the signatures this process issues to ITSELF stay + // valid. They are used within milliseconds; the window only has to survive + // clock skew between the backend and the store. + backendTTL = 1 * time.Minute + // listPageSize is the ListObjectsV2 page size, capped by S3 at 1000. + listPageSize = 1000 + // listPageLimit bounds the pagination loop. A store that kept returning a + // continuation token with no keys would otherwise spin forever, and this + // runs inside a delete handler. + listPageLimit = 1000 + httpTimeout = 30 * time.Second + defaultPublicPrefix = "/objects" +) + +// Client is safe for concurrent use. +type Client struct { + // signHost is the Host value baked into every signature. It is the object + // store's own hostname, NOT the hostname the browser used: uploads and + // downloads go through the app's /objects proxy, and both proxies in this + // repo rewrite Host to the upstream (vite's `changeOrigin`, and caddy's + // `header_up Host` in .devcontainer/Caddyfile.tunnel). Getting that wrong + // shows up as SignatureDoesNotMatch and nothing else. + signHost string + directBase string + publicPrefix string + region string + bucket string + accessKey string + secretKey string + pathStyle bool + http *http.Client +} + +// New builds a client from configuration. It performs no I/O, so a store that +// is down does not stop the backend from starting — the failure surfaces on the +// first upload instead, which is where someone can act on it. +func New(cfg config.StorageConfig) (*Client, error) { + if cfg.Endpoint == "" || cfg.Bucket == "" || cfg.AccessKey == "" || cfg.SecretKey == "" { + return nil, fmt.Errorf( + "%w: endpoint, bucket, accesskey and secretkey are all required", + ErrIncompleteConfig, + ) + } + + endpoint, err := url.Parse(strings.TrimSuffix(cfg.Endpoint, "/")) + if err != nil { + return nil, fmt.Errorf("parse storage endpoint %q: %w", cfg.Endpoint, err) + } + if endpoint.Scheme == "" || endpoint.Host == "" { + return nil, fmt.Errorf("%w: endpoint %q needs a scheme and a host", + ErrIncompleteConfig, cfg.Endpoint) + } + + signHost := endpoint.Host + if !cfg.UsePathStyle { + signHost = cfg.Bucket + "." + endpoint.Host + } + + region := cfg.Region + if region == "" { + region = "us-east-1" + } + + prefix := strings.TrimSuffix(cfg.PublicPrefix, "/") + if prefix == "" { + prefix = defaultPublicPrefix + } + + //exhaustruct:ignore // http.Client{}: only Timeout is ours to set, the rest are stdlib defaults + return &Client{ + signHost: signHost, + directBase: endpoint.Scheme + "://" + signHost, + publicPrefix: prefix, + region: region, + bucket: cfg.Bucket, + accessKey: cfg.AccessKey, + secretKey: cfg.SecretKey, + pathStyle: cfg.UsePathStyle, + http: &http.Client{Timeout: httpTimeout}, + }, nil +} + +// canonicalURI is the path SigV4 signs and the store sees. Path-style keeps the +// bucket in the path; virtual-hosted style moved it into the hostname already. +func (c *Client) canonicalURI(key string) string { + if !c.pathStyle { + if key == "" { + return "/" + } + + return "/" + uriEncode(key, false) + } + if key == "" { + return "/" + uriEncode(c.bucket, false) + } + + return "/" + uriEncode(c.bucket, false) + "/" + uriEncode(key, false) +} + +// PublicURL is the stable, never-expiring path a public object is readable at. +// This is the value that belongs in the database. +func (c *Client) PublicURL(key string) string { + return c.publicPrefix + c.canonicalURI(key) +} + +// browserURL is same-origin and root-relative on purpose: one stored or handed +// out value resolves from localhost, from the Cloudflare tunnel and from a +// deployment. An absolute http://localhost:9000/... would work only on the +// machine that minted it. +func (c *Client) browserURL(uri, rawQuery string) string { + return c.publicPrefix + uri + "?" + rawQuery +} + +func (c *Client) directURL(uri, rawQuery string) string { + return c.directBase + uri + "?" + rawQuery +} + +// PresignPut returns a URL the browser may PUT exactly `sizeBytes` bytes of +// exactly `contentType` to, and the moment it stops working. +// +// Both of those are signed headers, which is what makes them CONDITIONS rather +// than hopes: the store recomputes the signature over the headers it actually +// received, so a body of a different length or a different declared type is +// refused at the authentication stage — before the bytes are stored, and for an +// oversized file, before most of them are even sent. +// +// Content-Type also has to be signed for a duller reason: whatever the client +// sends is what the object is stored as, and a browser that sends none stores +// images as application/x-www-form-urlencoded and then refuses to render them. +// +// Signing these makes every proxy between the browser and the store part of the +// contract, and that is where uploads actually break. A 403 +// SignatureDoesNotMatch means some hop changed a signed value — in practice the +// HOST header, which a reverse proxy passes through by default while the +// signature names the store's own hostname. See the Host rewrite in +// .devcontainer/Caddyfile.tunnel and `changeOrigin` in vite.config.ts; both +// exist for this and their absence is invisible on public reads, which are +// unsigned and keep working. +// +// (Content-Length has been verified to survive both of those proxies and +// Cloudflare's edge, so it stays signed. It was once suspected of being +// re-chunked away and is not.) +func (c *Client) PresignPut( + key, contentType string, + sizeBytes int64, + ttl time.Duration, +) (string, time.Time) { + now := time.Now() + headers := http.Header{} + headers.Set("Content-Type", contentType) + headers.Set("Content-Length", strconv.FormatInt(sizeBytes, 10)) + + uri, rawQuery := c.presign(http.MethodPut, key, nil, headers, ttl, now) + + return c.browserURL(uri, rawQuery), now.Add(ttl) +} + +// PresignGet returns a short-lived read URL for a private object. +func (c *Client) PresignGet(key string, ttl time.Duration) (string, time.Time) { + now := time.Now() + uri, rawQuery := c.presign(http.MethodGet, key, nil, nil, ttl, now) + + return c.browserURL(uri, rawQuery), now.Add(ttl) +} + +// DeletePrefix removes every object whose key starts with prefix and returns +// how many went. Callers are the two delete handlers; per docs/storage.md they +// run it AFTER the database commit and log rather than fail when it errors. +// +// ListObjectsV2 then one DELETE per key, rather than the batch DeleteObjects +// call: the batch form posts an XML body that S3 requires a Content-MD5 (or a +// checksum header) for, and the exact requirement varies between +// implementations. Individual deletes are the same request the rest of this +// file already makes and cannot be got subtly wrong. The counts here are tens +// of objects per event, not millions. +func (c *Client) DeletePrefix(ctx context.Context, prefix string) (int, error) { + if prefix == "" || !strings.HasSuffix(prefix, "/") { + return 0, fmt.Errorf("%w: %q", ErrUnsafePrefix, prefix) + } + + deleted := 0 + token := "" + for range listPageLimit { + objects, next, err := c.ListPrefix(ctx, prefix, token, listPageSize) + if err != nil { + return deleted, err + } + for _, obj := range objects { + // Belt and braces: the store answered the prefix we asked for, but + // this is a delete loop and the cost of checking is nothing. + if !strings.HasPrefix(obj.Key, prefix) { + continue + } + if err := c.deleteObject(ctx, obj.Key); err != nil { + return deleted, err + } + deleted++ + } + if next == "" { + return deleted, nil + } + token = next + } + + return deleted, fmt.Errorf("%w: more than %d pages under %q", + ErrUnsafePrefix, listPageLimit, prefix) +} + +// ObjectInfo is one row of a listing: what the store knows about an object +// without fetching it. There is no content type here, and that is the store's +// limitation rather than a choice — ListObjectsV2 does not report one, and a +// HEAD per object to find out would turn one request into hundreds. +type ObjectInfo struct { + Key string + SizeBytes int64 + // LastModified is the zero time when the store reported a timestamp this + // package could not parse. Callers sort by it, so a zero sorts oldest + // rather than crashing a comparison. + LastModified time.Time +} + +// ListPrefix returns up to maxKeys objects under prefix, plus the continuation +// token for the next page ("" when there is none). +// +// Unlike DeletePrefix this does NOT insist on a trailing slash: an empty prefix +// is a legitimate whole-bucket listing, and the destructive-mistake this guards +// against there simply does not exist for a read. The BOUND is the caller's +// business — nothing here loops. +func (c *Client) ListPrefix( + ctx context.Context, + prefix, token string, + maxKeys int, +) ([]ObjectInfo, string, error) { + if maxKeys <= 0 || maxKeys > listPageSize { + maxKeys = listPageSize + } + + query := url.Values{} + query.Set("list-type", "2") + query.Set("prefix", prefix) + query.Set("max-keys", strconv.Itoa(maxKeys)) + if token != "" { + query.Set("continuation-token", token) + } + + body, err := c.do(ctx, http.MethodGet, "", query) + if err != nil { + return nil, "", err + } + + var result listBucketResult + if err := xml.Unmarshal(body, &result); err != nil { + return nil, "", fmt.Errorf("parse ListObjectsV2 response: %w", err) + } + + objects := make([]ObjectInfo, 0, len(result.Contents)) + for _, item := range result.Contents { + objects = append(objects, ObjectInfo{ + Key: item.Key, + SizeBytes: item.Size, + LastModified: parseListTime(item.LastModified), + }) + } + + next := "" + if result.IsTruncated { + next = result.NextContinuationToken + } + + return objects, next, nil +} + +// listBucketResult is the subset of the ListObjectsV2 response we read. The +// XML carries etags and owners too; neither matters to a purge or a gallery. +type listBucketResult struct { + XMLName xml.Name `xml:"ListBucketResult"` + IsTruncated bool `xml:"IsTruncated"` + NextContinuationToken string `xml:"NextContinuationToken"` + Contents []struct { + Key string `xml:"Key"` + // Kept as a string and parsed by hand rather than as a time.Time: + // encoding/xml accepts ONLY RFC 3339, and a store that answers in any + // other shape would fail the whole unmarshal — losing the keys too, + // over a field that is only used to sort. See parseListTime. + LastModified string `xml:"LastModified"` + Size int64 `xml:"Size"` + } `xml:"Contents"` +} + +// listTimeFormats are the shapes an S3 listing has been seen to use. The first +// is what the specification says and what both S3 and rustfs emit; the rest are +// there so an unusual store degrades to "no timestamp" for one object instead +// of an error for the request. +// +//nolint:gochecknoglobals // fixed lookup table, not mutable shared state +var listTimeFormats = []string{ + time.RFC3339Nano, + time.RFC3339, + "2006-01-02T15:04:05.000Z", + "2006-01-02T15:04:05Z", +} + +func parseListTime(value string) time.Time { + value = strings.TrimSpace(value) + if value == "" { + return time.Time{} + } + for _, layout := range listTimeFormats { + if t, err := time.Parse(layout, value); err == nil { + return t + } + } + + return time.Time{} +} + +func (c *Client) deleteObject(ctx context.Context, key string) error { + _, err := c.do(ctx, http.MethodDelete, key, nil) + + return err +} + +// do issues one presigned request from this process. Only `host` is signed, and +// net/http sets it from the URL, so the request it sends is exactly the one the +// signature covers. +func (c *Client) do( + ctx context.Context, + method, key string, + query url.Values, +) ([]byte, error) { + uri, rawQuery := c.presign(method, key, query, nil, backendTTL, time.Now()) + + req, err := http.NewRequestWithContext(ctx, method, c.directURL(uri, rawQuery), nil) + if err != nil { + return nil, fmt.Errorf("build %s request: %w", method, err) + } + + resp, err := c.http.Do(req) + if err != nil { + return nil, fmt.Errorf("%s %s: %w", method, key, err) + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("read %s response: %w", method, err) + } + + // 404 on a DELETE is the state the caller wanted; S3 answers 204 either + // way, but not every implementation does. + if resp.StatusCode == http.StatusNotFound && method == http.MethodDelete { + return body, nil + } + if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices { + return nil, fmt.Errorf("%s %s: %w", method, key, statusError(resp.StatusCode, body)) + } + + return body, nil +} + +type storeError struct { + status int + body string +} + +func (e *storeError) Error() string { + return fmt.Sprintf("object store returned %d: %s", e.status, e.body) +} + +func statusError(status int, body []byte) error { + const maxBody = 512 + text := strings.TrimSpace(string(body)) + if len(text) > maxBody { + text = text[:maxBody] + } + + return &storeError{status: status, body: text} +} diff --git a/components/backend/internal/storage/listing_test.go b/components/backend/internal/storage/listing_test.go new file mode 100644 index 00000000..9a6c56d8 --- /dev/null +++ b/components/backend/internal/storage/listing_test.go @@ -0,0 +1,116 @@ +//go:build test && unittest + +package storage + +import ( + "encoding/xml" + "testing" + "time" +) + +// Reading a ListObjectsV2 answer, pinned because both halves fail SILENTLY. +// +// A field name that does not match the XML unmarshals to a zero value rather +// than an error, so a listing keeps working while every object claims to be 0 +// bytes and to have been written at the epoch — and the gallery this feeds +// orders BY that timestamp, so getting it wrong means the grid is in an +// arbitrary order and nothing reports anything. + +// listingXML is a ListObjectsV2 response in the shape S3 documents and rustfs +// emits. Deliberately includes a truncated page, so the continuation token is +// covered by the same fixture. +const listingXML = `<?xml version="1.0" encoding="UTF-8"?> +<ListBucketResult xmlns="http://s3.amazonaws.com/doc/2006-03-01/"> + <Name>hackagon-dev</Name> + <Prefix>site/media/</Prefix> + <KeyCount>2</KeyCount> + <MaxKeys>1000</MaxKeys> + <IsTruncated>true</IsTruncated> + <NextContinuationToken>1ueGcxLPRx1Tr</NextContinuationToken> + <Contents> + <Key>site/media/aaa.webp</Key> + <LastModified>2026-08-11T09:15:42.000Z</LastModified> + <ETag>"d41d8cd98f00b204e9800998ecf8427e"</ETag> + <Size>20481</Size> + <StorageClass>STANDARD</StorageClass> + </Contents> + <Contents> + <Key>site/media/bbb.png</Key> + <LastModified>2026-08-10T22:01:00Z</LastModified> + <ETag>"acbd18db4cc2f85cedef654fccc4a4d8"</ETag> + <Size>7</Size> + <StorageClass>STANDARD</StorageClass> + </Contents> +</ListBucketResult>` + +func TestListBucketResultReadsKeySizeAndDate(t *testing.T) { + var result listBucketResult + if err := xml.Unmarshal([]byte(listingXML), &result); err != nil { + t.Fatalf("unmarshal listing: %v", err) + } + + if len(result.Contents) != 2 { + t.Fatalf("want 2 objects, got %d", len(result.Contents)) + } + if got := result.Contents[0].Key; got != "site/media/aaa.webp" { + t.Errorf("key: want site/media/aaa.webp, got %q", got) + } + // The assertion that would otherwise be vacuous: a wrong field name gives + // 0 here and nothing complains. + if got := result.Contents[0].Size; got != 20481 { + t.Errorf("size: want 20481, got %d", got) + } + if got := result.Contents[1].Size; got != 7 { + t.Errorf("size: want 7, got %d", got) + } + + want := time.Date(2026, 8, 11, 9, 15, 42, 0, time.UTC) + if got := parseListTime(result.Contents[0].LastModified); !got.Equal(want) { + t.Errorf("last modified: want %s, got %s", want, got) + } + // The sub-second-less form, which is what a store that trims zeroes sends. + want = time.Date(2026, 8, 10, 22, 1, 0, 0, time.UTC) + if got := parseListTime(result.Contents[1].LastModified); !got.Equal(want) { + t.Errorf("last modified: want %s, got %s", want, got) + } + + if !result.IsTruncated || result.NextContinuationToken != "1ueGcxLPRx1Tr" { + t.Errorf("truncation: got truncated=%v token=%q", + result.IsTruncated, result.NextContinuationToken) + } +} + +// LastModified is a string in the struct, not a time.Time, and that is load +// bearing: encoding/xml accepts ONLY RFC 3339 and fails the WHOLE unmarshal +// otherwise — losing every key over a field that is used to sort. This proves +// the degradation is per-object. +func TestUnparseableDateDoesNotLoseTheKeys(t *testing.T) { + const odd = `<ListBucketResult> + <Contents> + <Key>site/media/ccc.webp</Key> + <LastModified>Mon, 11 Aug 2026 09:15:42 GMT</LastModified> + <Size>12</Size> + </Contents> +</ListBucketResult>` + + var result listBucketResult + if err := xml.Unmarshal([]byte(odd), &result); err != nil { + t.Fatalf("an unusual date must not fail the listing: %v", err) + } + if len(result.Contents) != 1 || result.Contents[0].Key != "site/media/ccc.webp" { + t.Fatalf("the key should survive an unparseable date, got %+v", result.Contents) + } + if got := parseListTime(result.Contents[0].LastModified); !got.IsZero() { + t.Errorf("want the zero time for an unrecognized layout, got %s", got) + } +} + +func TestParseListTimeHandlesEmptyAndPadding(t *testing.T) { + if got := parseListTime(""); !got.IsZero() { + t.Errorf("empty: want zero time, got %s", got) + } + want := time.Date(2026, 8, 11, 9, 15, 42, 0, time.UTC) + if got := parseListTime(" 2026-08-11T09:15:42Z\n"); !got.Equal(want) { + t.Errorf("padded: want %s, got %s", want, got) + } +} diff --git a/components/backend/internal/storage/sigv4.go b/components/backend/internal/storage/sigv4.go new file mode 100644 index 00000000..45b61f89 --- /dev/null +++ b/components/backend/internal/storage/sigv4.go @@ -0,0 +1,189 @@ +package storage + +import ( + "crypto/hmac" + "crypto/sha256" + "encoding/hex" + "fmt" + "net/http" + "net/url" + "sort" + "strconv" + "strings" + "time" +) + +// AWS Signature Version 4, query-string ("presigned") flavour. +// +// Hand-rolled rather than pulled from aws-sdk-go-v2 on purpose. The whole of +// what this file needs is four HMACs and a string built in a fixed order, the +// algorithm is already proven against THIS server in +// .devcontainer/rustfs-init.sh, and adding the SDK would drag ~15 modules into +// go.mod — which in this repo also means recomputing the fixed-output +// `vendorHash` in components/backend/tools/nix/pkgs/service/default.nix on +// every dependency bump. +const ( + algorithm = "AWS4-HMAC-SHA256" + terminator = "aws4_request" + service = "s3" + unsignedPayload = "UNSIGNED-PAYLOAD" + amzDateLayout = "20060102T150405Z" + dateLayout = "20060102" + + // MaxPresignTTL is the ceiling SigV4 itself imposes on X-Amz-Expires. + MaxPresignTTL = 7 * 24 * time.Hour +) + +// uriEncode percent-encodes per RFC 3986, which is what SigV4 canonicalization +// wants and what neither url.QueryEscape (space becomes '+') nor url.PathEscape +// (leaves sub-delims alone) actually does. +// +// Byte-wise, not rune-wise: multi-byte UTF-8 is encoded one octet at a time, +// which is the required behaviour. +func uriEncode(s string, encodeSlash bool) string { + var b strings.Builder + b.Grow(len(s)) + for i := range len(s) { + c := s[i] + switch { + case (c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z') || + (c >= '0' && c <= '9') || c == '-' || c == '_' || c == '.' || c == '~': + b.WriteByte(c) + case c == '/' && !encodeSlash: + b.WriteByte('/') + default: + fmt.Fprintf(&b, "%%%02X", c) + } + } + + return b.String() +} + +// canonicalQuery sorts parameters by name (then by value for repeats) and +// encodes both halves. '/' IS encoded here, unlike in the path. +func canonicalQuery(v url.Values) string { + names := make([]string, 0, len(v)) + for name := range v { + names = append(names, name) + } + sort.Strings(names) + + parts := make([]string, 0, len(v)) + for _, name := range names { + values := append([]string(nil), v[name]...) + sort.Strings(values) + for _, value := range values { + parts = append(parts, uriEncode(name, true)+"="+uriEncode(value, true)) + } + } + + return strings.Join(parts, "&") +} + +// canonicalHeaders returns the ';'-joined signed header names and the +// name:value block. The block ends in a newline, which the canonical request +// then follows with another one — that blank line is part of the format. +// +// Every header named here becomes a CONDITION on the signature: the store +// recomputes the signature over the values it actually received, so a request +// that changes one of them is rejected. That is the mechanism behind the +// content-type and size limits — see Client.PresignPut. +func canonicalHeaders(h http.Header) (string, string) { + names := make([]string, 0, len(h)) + for name := range h { + names = append(names, strings.ToLower(name)) + } + sort.Strings(names) + + var block strings.Builder + for _, name := range names { + // http.Header canonicalizes keys on Set/Add, so read through Get + // rather than indexing with the lowercased name. + block.WriteString(name) + block.WriteByte(':') + block.WriteString(strings.TrimSpace(h.Get(name))) + block.WriteByte('\n') + } + + return strings.Join(names, ";"), block.String() +} + +func hmacSHA256(key []byte, data string) []byte { + mac := hmac.New(sha256.New, key) + mac.Write([]byte(data)) + + return mac.Sum(nil) +} + +func sha256Hex(data string) string { + sum := sha256.Sum256([]byte(data)) + + return hex.EncodeToString(sum[:]) +} + +// signingKey derives the date/region/service-scoped key. Each HMAC feeds the +// next, so the ladder is exactly four calls. +func (c *Client) signingKey(datestamp string) []byte { + k := hmacSHA256([]byte("AWS4"+c.secretKey), datestamp) + k = hmacSHA256(k, c.region) + k = hmacSHA256(k, service) + + return hmacSHA256(k, terminator) +} + +// presign computes the canonical URI and the fully signed query string for one +// request. It never touches the network — callers turn the pair into either a +// browser-facing URL (Client.browserURL) or a direct one (Client.directURL). +// +// `signed` names the headers the caller commits the request to; `host` is added +// here because SigV4 requires it and because it is the one header a proxy in +// front of the store will rewrite. +func (c *Client) presign( + method, key string, + extra url.Values, + signed http.Header, + ttl time.Duration, + now time.Time, +) (string, string) { + now = now.UTC() + amzDate := now.Format(amzDateLayout) + datestamp := now.Format(dateLayout) + scope := strings.Join([]string{datestamp, c.region, service, terminator}, "/") + + headers := http.Header{} + for name, values := range signed { + for _, value := range values { + headers.Add(name, value) + } + } + // Not http.Header.Set("Host", …): net/http gives "Host" no special + // treatment in a plain Header map, so this is an ordinary entry here and + // only becomes the real Host header on the wire. + headers.Set("Host", c.signHost) + signedNames, headerBlock := canonicalHeaders(headers) + + query := url.Values{} + for name, values := range extra { + for _, value := range values { + query.Add(name, value) + } + } + query.Set("X-Amz-Algorithm", algorithm) + query.Set("X-Amz-Credential", c.accessKey+"/"+scope) + query.Set("X-Amz-Date", amzDate) + query.Set("X-Amz-Expires", strconv.Itoa(int(ttl.Seconds()))) + query.Set("X-Amz-SignedHeaders", signedNames) + + uri := c.canonicalURI(key) + rawQuery := canonicalQuery(query) + + canonicalRequest := strings.Join([]string{ + method, uri, rawQuery, headerBlock, signedNames, unsignedPayload, + }, "\n") + stringToSign := strings.Join([]string{ + algorithm, amzDate, scope, sha256Hex(canonicalRequest), + }, "\n") + signature := hex.EncodeToString(hmacSHA256(c.signingKey(datestamp), stringToSign)) + + return uri, rawQuery + "&X-Amz-Signature=" + signature +} diff --git a/components/backend/internal/storage/sigv4_test.go b/components/backend/internal/storage/sigv4_test.go new file mode 100644 index 00000000..3c6e3d05 --- /dev/null +++ b/components/backend/internal/storage/sigv4_test.go @@ -0,0 +1,223 @@ +//go:build test && unittest + +package storage + +import ( + "net/http" + "net/url" + "strings" + "testing" + "time" + + "github.com/swissdatasciencecenter/hackagon/components/backend/internal/config" +) + +// Everything here pins a detail that fails SILENTLY when it is wrong: a bad +// signature comes back as 403 SignatureDoesNotMatch with no clue which of the +// dozen inputs was misencoded, and a mis-signed ListObjectsV2 just returns no +// keys — which in DeletePrefix reads as "nothing to delete" and leaves the +// objects behind while reporting success. + +func testClient(t *testing.T) *Client { + t.Helper() + client, err := New(config.StorageConfig{ + Endpoint: "http://rustfs:9000", + Region: "us-east-1", + Bucket: "hackagon-dev", + AccessKey: "hackagon-dev", + SecretKey: "hackagon-dev-secret", + UsePathStyle: true, + PublicPrefix: "/objects", + }) + if err != nil { + t.Fatalf("New: %v", err) + } + + return client +} + +func TestURIEncode(t *testing.T) { + cases := []struct { + in string + encodeSlash bool + want string + }{ + // url.QueryEscape would give "a+b" here, which SigV4 rejects. + {"a b", false, "a%20b"}, + // Unreserved set survives; everything else does not. + {"a-_.~z", false, "a-_.~z"}, + {"a/b", false, "a/b"}, + // The one that matters for DeletePrefix: a prefix is a query VALUE, + // and there its slashes must be encoded or the store computes a + // different signature and answers with an empty listing. + {"hackathons/abc/", true, "hackathons%2Fabc%2F"}, + {"+", false, "%2B"}, + {"é", false, "%C3%A9"}, // per UTF-8 byte, not per rune + } + for _, c := range cases { + if got := uriEncode(c.in, c.encodeSlash); got != c.want { + t.Errorf("uriEncode(%q, %v) = %q, want %q", c.in, c.encodeSlash, got, c.want) + } + } +} + +func TestCanonicalQuerySortsAndEncodes(t *testing.T) { + query := url.Values{} + query.Set("prefix", "hackathons/abc/") + query.Set("list-type", "2") + query.Set("X-Amz-Date", "20260807T000000Z") + query.Set("max-keys", "1000") + + // Byte order, so the uppercase X-Amz-* parameters sort BEFORE the + // lowercase ones. Getting this backwards is what silently broke a + // hand-written probe of this same endpoint. + want := "X-Amz-Date=20260807T000000Z&list-type=2&max-keys=1000&prefix=hackathons%2Fabc%2F" + if got := canonicalQuery(query); got != want { + t.Errorf("canonicalQuery =\n %q\nwant\n %q", got, want) + } +} + +func TestCanonicalHeaders(t *testing.T) { + headers := http.Header{} + headers.Set("Content-Type", "image/webp") + headers.Set("Host", "rustfs:9000") + headers.Set("Content-Length", " 42 ") // values are trimmed + + names, block := canonicalHeaders(headers) + if names != "content-length;content-type;host" { + t.Errorf("signed names = %q", names) + } + want := "content-length:42\ncontent-type:image/webp\nhost:rustfs:9000\n" + if block != want { + t.Errorf("header block = %q, want %q", block, want) + } +} + +func TestCanonicalURIPathStyle(t *testing.T) { + client := testClient(t) + if got := client.canonicalURI(""); got != "/hackagon-dev" { + t.Errorf("bucket URI = %q", got) + } + if got := client.canonicalURI("a/b.png"); got != "/hackagon-dev/a/b.png" { + t.Errorf("object URI = %q", got) + } +} + +func TestPresignIsStableAndBindsItsSignedHeaders(t *testing.T) { + client := testClient(t) + key := "hackathons/abc/logo/x.webp" + + headers := http.Header{} + headers.Set("Content-Type", "image/webp") + at := time.Date(2026, 8, 7, 5, 14, 6, 0, time.UTC) + + uri, query := client.presign(http.MethodPut, key, nil, headers, 15*time.Minute, at) + if uri != "/hackagon-dev/"+key { + t.Fatalf("uri = %q", uri) + } + if !strings.Contains(query, "X-Amz-Expires=900") { + t.Errorf("missing expiry; query = %q", query) + } + + // Same inputs, same signature — a signer that drifted would break uploads + // only intermittently, which is the hardest form to diagnose. + _, again := client.presign(http.MethodPut, key, nil, headers, 15*time.Minute, at) + if query != again { + t.Error("presign is not deterministic for a fixed clock") + } + + // A signed header must actually be BOUND to its value, or naming it in + // SignedHeaders is decoration. + other := headers.Clone() + other.Set("Content-Type", "image/png") + _, changed := client.presign(http.MethodPut, key, nil, other, 15*time.Minute, at) + if query == changed { + t.Error("signature did not change with a signed header's value") + } +} + +// The signed-header SET is what an upload is committed to, and every proxy in +// front of the store has to preserve all of it — so this asserts on PresignPut +// itself, not on the low-level signer it happens to call. +// +// Both members are load-bearing: content-type decides how the object is served +// back, and content-length is what refuses an oversized body before it moves. +// Both have been verified to survive the vite proxy, caddy and Cloudflare's +// edge, so dropping either to "make uploads work" is fixing the wrong end — a +// 403 here is a hop rewriting a signed value, and it has always been the HOST. +func TestPresignPutSignsSizeAndType(t *testing.T) { + client := testClient(t) + + url, _ := client.PresignPut("hackathons/abc/logo/x.webp", "image/webp", 98028, 15*time.Minute) + + if !strings.Contains(url, "X-Amz-SignedHeaders=content-length%3Bcontent-type%3Bhost") { + t.Errorf("size and type must both be conditions on the URL; url = %q", url) + } + + // And the size must be BOUND to its value, or naming it signs nothing. + // Through presign with a fixed clock rather than two PresignPut calls: + // X-Amz-Date has second granularity, so two live calls that straddle a + // second boundary differ for a legitimate reason — that test would pass a + // few thousand times and then fail once. + at := time.Date(2026, 8, 7, 5, 14, 6, 0, time.UTC) + headers := http.Header{} + headers.Set("Content-Type", "image/webp") + headers.Set("Content-Length", "98028") + _, a := client.presign( + http.MethodPut, + "hackathons/abc/logo/x.webp", + nil, + headers, + 15*time.Minute, + at, + ) + + bigger := headers.Clone() + bigger.Set("Content-Length", "98029") + _, b := client.presign( + http.MethodPut, + "hackathons/abc/logo/x.webp", + nil, + bigger, + 15*time.Minute, + at, + ) + + if a == b { + t.Error("signature did not change with the signed content-length") + } +} + +func TestPublicURLIsRootRelative(t *testing.T) { + client := testClient(t) + got := client.PublicURL("hackathons/abc/logo/x.webp") + // Absolute URLs are the bug this shape exists to prevent: one written as + // http://localhost:9000/... resolves only on the machine that minted it. + if got != "/objects/hackagon-dev/hackathons/abc/logo/x.webp" { + t.Errorf("PublicURL = %q", got) + } +} + +func TestDeletePrefixRefusesUnboundedPrefixes(t *testing.T) { + client := testClient(t) + // No network call may happen for either of these: an empty or unterminated + // prefix matches far more than the caller meant, and this is a delete. + for _, prefix := range []string{"", "hackathons", "users"} { + if _, err := client.DeletePrefix(t.Context(), prefix); err == nil { + t.Errorf("DeletePrefix(%q) was accepted", prefix) + } + } +} + +func TestNewRejectsIncompleteConfig(t *testing.T) { + //exhaustruct:ignore + if _, err := New(config.StorageConfig{Endpoint: "http://rustfs:9000"}); err == nil { + t.Error("New accepted a config with no bucket or credentials") + } + //exhaustruct:ignore + if _, err := New(config.StorageConfig{ + Endpoint: "rustfs:9000", Bucket: "b", AccessKey: "a", SecretKey: "s", + }); err == nil { + t.Error("New accepted an endpoint with no scheme") + } +} diff --git a/components/backend/internal/storage/storage_suite_test.go b/components/backend/internal/storage/storage_suite_test.go new file mode 100644 index 00000000..00a0ae1e --- /dev/null +++ b/components/backend/internal/storage/storage_suite_test.go @@ -0,0 +1,26 @@ +//go:build test && unittest + +package storage_test + +import ( + "testing" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +// This package's tests are plain `testing` functions (sigv4_test.go), not +// Ginkgo specs — so this bootstrap deliberately runs ZERO specs. It exists +// because the quitsh test target appends `--ginkgo.v` to every package's test +// binary (see components/backend/.component.yaml, target `test-unittest`). +// Importing Ginkgo registers the `-ginkgo.*` flags on flag.CommandLine at +// init; without it the binary exits 1 on "flag provided but not defined: +// -ginkgo.v" before running a single test. +// +// "Ran 0 of 0 Specs" below is therefore expected and is NOT this package's +// result. Its coverage is the TestXxx functions, which `go test -v` runs and +// reports one by one. +func TestStorage(t *testing.T) { + RegisterFailHandler(Fail) + RunSpecs(t, "Storage Suite") +} diff --git a/components/backend/internal/testutils/fixtures.go b/components/backend/internal/testutils/fixtures.go index b367ea39..dfdc4f27 100644 --- a/components/backend/internal/testutils/fixtures.go +++ b/components/backend/internal/testutils/fixtures.go @@ -16,6 +16,7 @@ import ( . "github.com/onsi/ginkgo/v2" //nolint:staticcheck // dot import in test file is fine . "github.com/onsi/gomega" //nolint:staticcheck // dot import in test file is fine ent "github.com/swissdatasciencecenter/hackagon/components/backend/ent" + _ "github.com/swissdatasciencecenter/hackagon/components/backend/ent/runtime" // registers schema hooks and default values entuser "github.com/swissdatasciencecenter/hackagon/components/backend/ent/user" config "github.com/swissdatasciencecenter/hackagon/components/backend/internal/config" diff --git a/components/backend/schema.mermaid b/components/backend/schema.mermaid new file mode 100644 index 00000000..60475900 --- /dev/null +++ b/components/backend/schema.mermaid @@ -0,0 +1,90 @@ +erDiagram + HACKATHON { + uuid id PK + string name UK + time starts_at + time ends_at + enum visibility + string description + string logo + } + PAGE { + uuid id PK + string title + string content + bool visible + int order + } + PHASE { + uuid id PK + string name + string description + time starts_at + time ends_at + } + TRACK { + uuid id PK + string name + string description + } + PROJECT { + uuid id PK + string title + enum status + string description + string image + } + TEAM { + uuid id PK + string name + string description + } + SUBMISSION { + uuid id PK + int version + enum status + string result + } + USER { + uuid id PK + string username + string keycloak_id UK + string display_name + string email + } + PARTICIPANT { + uuid hackathon_id FK + uuid user_id FK + bool is_waiting + } + TEAMPARTICIPANT { + uuid team_id FK + uuid user_id FK + } + + HACKATHON o|--o{ TRACK : "tracks (optional FK)" + HACKATHON ||--o{ PAGE : pages + HACKATHON ||--o{ PHASE : phases + HACKATHON ||--o{ PROJECT : projects + + TRACK o|--o{ PROJECT : "projects (optional FK, new)" + PROJECT ||--o{ TEAM : teams + PROJECT }o--o{ USER : preferred_by + + TEAM ||--o{ SUBMISSION : submissions + PROJECT ||--o{ SUBMISSION : submissions + + PHASE |o--o| PAGE : "linked page (optional)" + + HACKATHON ||--o{ PARTICIPANT : participants + USER ||--o{ PARTICIPANT : participations + TEAM ||--o{ TEAMPARTICIPANT : team_participants + USER ||--o{ TEAMPARTICIPANT : team_participations + + USER ||--o{ HACKATHON : "creates/modifies" + USER ||--o{ PAGE : "creates/modifies" + USER ||--o{ PHASE : "creates/modifies" + USER ||--o{ TRACK : "creates/modifies (new, required)" + USER ||--o{ PROJECT : "creates/modifies" + USER ||--o{ TEAM : "creates/modifies" + USER ||--o{ SUBMISSION : "creates/modifies" diff --git a/components/frontend/README.md b/components/frontend/README.md index 3f20fe4f..7b64894b 100644 --- a/components/frontend/README.md +++ b/components/frontend/README.md @@ -18,7 +18,7 @@ Keycloak authentication. ### Prerequisites - Nix development shell (`just dev` from repo root) -- Keycloak running (`just up` from repo root) +- Keycloak running (`just deploy::up` from repo root) - Config files in `data/test/config/` (see Configuration below) ### Development diff --git a/components/frontend/design/og/og-card.html b/components/frontend/design/og/og-card.html new file mode 100644 index 00000000..426ec666 --- /dev/null +++ b/components/frontend/design/og/og-card.html @@ -0,0 +1,224 @@ +<!doctype html> +<html lang="en" data-mode="dark"> + <head> + <meta charset="utf-8" /> + <title>SDSC Hackathons — link preview card + + + +

    + + diff --git a/components/frontend/design/og/render.mjs b/components/frontend/design/og/render.mjs new file mode 100644 index 00000000..a2105272 --- /dev/null +++ b/components/frontend/design/og/render.mjs @@ -0,0 +1,290 @@ +#!/usr/bin/env node +// Render og-card.html into static/og-default.jpg — the image every link +// preview of this platform shows. +// +// Why a script and not a hand-exported binary: the card that was here before +// was produced once by a throwaway satori script, so the only way to change a +// word in it was to rebuild the tooling from the commit message. The template +// beside this file is the source; this turns it into the asset. +// +// node components/frontend/design/og/render.mjs +// node components/frontend/design/og/render.mjs --thumb /tmp/thumb.png +// +// Rendered at 2x and downscaled, because type rasterised at 1200px wide and +// type rasterised at 2400px and resampled are visibly different at the sizes +// this card uses — the second is what survives a feed thumbnail. +// +// Playwright and sharp are borrowed from .claude/skills/hackathon-e2e rather +// than added to the frontend's package.json: this asset is regenerated when +// the wording changes, and that is not worth ~200 MB of devDependency and a +// lockfile entry in the app that serves it. + +import { createRequire } from "node:module" +import { existsSync, statSync } from "node:fs" +import { dirname, join, resolve } from "node:path" +import { fileURLToPath, pathToFileURL } from "node:url" + +const HERE = dirname(fileURLToPath(import.meta.url)) +const FRONTEND = resolve(HERE, "../..") +const ROOT = resolve(FRONTEND, "../..") + +const TEMPLATE = join(HERE, "og-card.html") +const MARK = join(FRONTEND, "static/logos/sdsc_white.svg") + +const WIDTH = 1200 +const HEIGHT = 630 +const SCALE = 2 + +// --hk-canvas, the theme's dark ground, as sRGB. The same value the template +// paints with: a preview card is composited onto whatever colour the client +// uses, so it has to carry its own opaque background rather than inherit one. +const CANVAS = "#0c1212" + +// A preview card is fetched by a scraper on someone else's schedule, and some +// of them give up on a slow one. Comfortably under the brief's ceiling. +const MAX_BYTES = 300 * 1024 + +// The headline is the only thing that has to read at thumbnail size, so how +// much of the card it fills is the one number worth failing on: a stack that +// falls through to this container's CJK generic, or a font that is simply +// absent, lands outside this band — and would otherwise render a perfectly +// plausible-looking card in the wrong typeface with no error at all. +// +// Expressed as a fraction of the content box, not in pixels, so editing the +// wording or the type size does not require re-deriving a magic number. The +// upper bound is what keeps the line off the margins; the lower bound is what +// keeps it dominant enough to survive being drawn ~300px wide. +const HEADLINE_MIN_FILL = 0.55 +const HEADLINE_MAX_FILL = 0.98 +const HEADLINE_MAX_LINE_HEIGHT = 140 + +// The grid behind the card is drawn every GRID_PX, and is checked for rather +// than assumed. It went missing twice while this card was being built — an +// oklch() colour stop in a repeating-linear-gradient renders as nothing at all +// in this Firefox, with a valid computed style and an empty console — and a +// wash that quietly disappears looks exactly like a wash that was never asked +// for. GRID_ROW is a band with no text in it at any point across the card. +const GRID_PX = 60 +const GRID_ROW = 470 +const GRID_MIN_CONTRAST = 5 +const GRID_MIN_LINES = 8 + +function parseArgs(argv) { + const args = { out: join(FRONTEND, "static/og-default.jpg"), thumb: null } + for (let i = 0; i < argv.length; i += 1) { + if (argv[i] === "--out") { + args.out = resolve(argv[(i += 1)]) + } else if (argv[i] === "--thumb") { + args.thumb = resolve(argv[(i += 1)]) + } else { + throw new Error(`unknown argument: ${argv[i]}`) + } + } + return args +} + +// Resolve a dependency from whichever node_modules in this repo has it. +function borrow(name) { + const dirs = [ + join(ROOT, ".claude/skills/hackathon-e2e/node_modules"), + join(FRONTEND, "node_modules"), + ] + for (const dir of dirs) { + try { + return createRequire(join(dir, "borrow.cjs"))(name) + } catch { + continue + } + } + throw new Error( + `${name} not installed. Run 'pnpm install' in ` + + `.claude/skills/hackathon-e2e, which owns this toolchain.`, + ) +} + +// Read one row out of the FINISHED jpeg and prove the grid survived into it. +// Deliberately the finished file and not the browser's PNG: a wash can render +// and still be destroyed by the downscale or the JPEG quantiser, and the file +// is what a scraper fetches. Returns the detected line spacing. +async function assertGridVisible(sharp, file) { + const { data, info } = await sharp(file) + .raw() + .toBuffer({ resolveWithObject: true }) + const row = [] + for (let x = 0; x < info.width; x += 1) { + row.push(data[(GRID_ROW * info.width + x) * info.channels]) + } + + const min = Math.min(...row) + const max = Math.max(...row) + if (max - min < GRID_MIN_CONTRAST) { + throw new Error( + `no grid in the output: row ${GRID_ROW} varies by only ${max - min} ` + + `levels. An oklch() stop in a repeating-linear-gradient is the usual ` + + `cause — see the warning in og-card.html.`, + ) + } + + // Collect runs of above-midpoint pixels; each run is one grid line. + const threshold = min + (max - min) / 2 + const centres = [] + let run = null + row.forEach((v, x) => { + if (v >= threshold) { + run = run ?? { start: x } + run.end = x + } else if (run) { + centres.push((run.start + run.end) / 2) + run = null + } + }) + if (run) centres.push((run.start + run.end) / 2) + + if (centres.length < GRID_MIN_LINES) { + throw new Error( + `found ${centres.length} grid lines in row ${GRID_ROW}, expected at ` + + `least ${GRID_MIN_LINES} — the band may have drifted onto text`, + ) + } + + const gaps = centres.slice(1).map((c, i) => c - centres[i]) + gaps.sort((a, b) => a - b) + const spacing = gaps[Math.floor(gaps.length / 2)] + if (Math.abs(spacing - GRID_PX) > 2) { + throw new Error( + `grid lines are ${spacing}px apart, expected ${GRID_PX}px — that is ` + + `not the grid, so something else is being measured`, + ) + } + return { spacing, lines: centres.length, contrast: max - min } +} + +async function main() { + const args = parseArgs(process.argv.slice(2)) + + for (const [what, path] of [ + ["template", TEMPLATE], + ["SDSC mark", MARK], + ]) { + if (!existsSync(path)) throw new Error(`missing ${what}: ${path}`) + } + + const { firefox } = borrow("@playwright/test") + const sharp = borrow("sharp") + + const browser = await firefox.launch() + let png + let measured + try { + const page = await browser.newPage({ + viewport: { width: WIDTH, height: HEIGHT }, + deviceScaleFactor: SCALE, + }) + await page.goto(pathToFileURL(TEMPLATE).href, { waitUntil: "load" }) + + // The mark is an ; screenshotting before it decodes yields a card + // with a hole where the logo goes, and nothing about that fails loudly. + await page.waitForFunction(() => { + const img = document.querySelector("#mark") + return img && img.complete && img.naturalWidth > 0 + }) + + measured = await page.evaluate(() => { + const cardEl = document.querySelector("#card") + const headlineEl = document.querySelector("#headline") + const card = cardEl.getBoundingClientRect() + + // Measure the TEXT, via a Range over the element's contents — not the + // element. #headline is a block, so its own rect is the width of the + // column it sits in: 1056px whatever font rendered, whatever the words + // say. Asserting on that number is asserting on the padding. + const range = document.createRange() + range.selectNodeContents(headlineEl) + const text = range.getBoundingClientRect() + + return { + card: { width: card.width, height: card.height }, + // The box the headline is allowed to fill, from the element's own + // layout rather than a repeated padding constant. + contentWidth: headlineEl.getBoundingClientRect().width, + headline: { width: text.width, height: text.height, right: text.right }, + } + }) + + png = await page.locator("#card").screenshot({ type: "png" }) + } finally { + await browser.close() + } + + const { card, contentWidth, headline } = measured + const fill = headline.width / contentWidth + + if (card.width !== WIDTH || card.height !== HEIGHT) { + throw new Error( + `template laid out at ${card.width}x${card.height}, expected ` + + `${WIDTH}x${HEIGHT}`, + ) + } + if (fill < HEADLINE_MIN_FILL || fill > HEADLINE_MAX_FILL) { + throw new Error( + `headline fills ${(fill * 100).toFixed(1)}% of the content box ` + + `(allowed ${HEADLINE_MIN_FILL * 100}–${HEADLINE_MAX_FILL * 100}%) — ` + + `the intended font probably did not load, or the wording changed`, + ) + } + if (headline.height > HEADLINE_MAX_LINE_HEIGHT) { + throw new Error( + `headline wrapped to ${Math.round(headline.height)}px tall — it must ` + + `stay on one line`, + ) + } + if (headline.right > WIDTH) { + throw new Error( + `headline overflows the card by ${headline.right - WIDTH}px`, + ) + } + + // flatten() drops any alpha onto the card's own ground rather than letting + // the JPEG encoder pick: the card is composited onto whatever colour the + // client uses, so it has to carry its own background. + await sharp(png) + .resize(WIDTH, HEIGHT, { kernel: "lanczos3" }) + .flatten({ background: CANVAS }) + .jpeg({ quality: 90, chromaSubsampling: "4:4:4", mozjpeg: true }) + .toFile(args.out) + + const meta = await sharp(args.out).metadata() + const bytes = statSync(args.out).size + + if (meta.width !== WIDTH || meta.height !== HEIGHT) { + throw new Error( + `wrote ${meta.width}x${meta.height}, expected ${WIDTH}x${HEIGHT}`, + ) + } + if (meta.hasAlpha) throw new Error("output carries an alpha channel") + if (bytes > MAX_BYTES) { + throw new Error(`${bytes} bytes exceeds the ${MAX_BYTES} byte ceiling`) + } + + const grid = await assertGridVisible(sharp, args.out) + + if (args.thumb) { + // The check that matters: most clients draw this around 300px wide. + await sharp(args.out).resize(300).toFile(args.thumb) + } + + const kib = (bytes / 1024).toFixed(1) + process.stdout.write( + `${args.out}\n` + + ` ${meta.width}x${meta.height} ${kib} KiB ${meta.format} ` + + `alpha=${Boolean(meta.hasAlpha)}\n` + + ` headline text ${Math.round(headline.width)}px wide ` + + `(${(fill * 100).toFixed(0)}% of the content box), rendered at ` + + `${SCALE}x\n` + + ` grid ${grid.lines} lines at ${grid.spacing}px, ` + + `${grid.contrast} levels of contrast\n` + + (args.thumb ? ` thumbnail: ${args.thumb}\n` : ""), + ) +} + +await main() diff --git a/components/frontend/package.json b/components/frontend/package.json index 2db849dd..79154219 100644 --- a/components/frontend/package.json +++ b/components/frontend/package.json @@ -14,15 +14,12 @@ "check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch", "test": "vitest run", "format": "prettier --write .", - "lint": "eslint . && tsc --noEmit", - "proto:generate": "protoc --plugin=./node_modules/.bin/protoc-gen-ts_proto --ts_proto_out=./src/lib/server/grpc/generated --ts_proto_opt=outputServices=nice-grpc,outputServices=generic-definitions,esModuleInterop=true,env=node,useExactTypes=false --proto_path=../../api/proto health/health_service.proto user/user_service.proto hackathon/hackathon_service.proto" + "lint": "eslint . && tsc --noEmit" }, "devDependencies": { "@bufbuild/protobuf": "^2.11.0", "@eslint/compat": "^1.4.0", "@eslint/js": "^9.37.0", - "@skeletonlabs/skeleton": "^3.2.2", - "@skeletonlabs/skeleton-svelte": "^1.5.3", "@sveltejs/kit": "^2.50.1", "@sveltejs/vite-plugin-svelte": "^5.1.1", "@tailwindcss/vite": "^4.1.14", @@ -53,9 +50,12 @@ "dependencies": { "@auth/core": "^0.39.1", "@auth/sveltekit": "^1.11.0", + "@openreplay/tracker": "^18.1.2", "@sveltejs/adapter-node": "^5.5.2", "command-line-args": "^6.0.1", + "isomorphic-dompurify": "^3.21.0", "lucide-svelte": "^0.479.0", + "marked": "^18.0.9", "nice-grpc": "^2.1.15", "nice-grpc-common": "^2.0.3", "pino": "^9.13.1", @@ -63,5 +63,12 @@ "yaml": "^2.8.1", "zod": "^3.25.76" }, - "packageManager": "pnpm@10.7.1+sha512.2d92c86b7928dc8284f53494fb4201f983da65f0fb4f0d40baafa5cf628fa31dae3e5968f12466f17df7e97310e30f343a648baea1b9b350685dafafffdf5808" + "packageManager": "pnpm@10.7.1+sha512.2d92c86b7928dc8284f53494fb4201f983da65f0fb4f0d40baafa5cf628fa31dae3e5968f12466f17df7e97310e30f343a648baea1b9b350685dafafffdf5808", + "pnpm": { + "onlyBuiltDependencies": [ + "@tailwindcss/oxide", + "esbuild", + "protobufjs" + ] + } } diff --git a/components/frontend/pnpm-lock.yaml b/components/frontend/pnpm-lock.yaml index 76a7e8f2..424bc04c 100644 --- a/components/frontend/pnpm-lock.yaml +++ b/components/frontend/pnpm-lock.yaml @@ -1,27 +1,37 @@ -lockfileVersion: "9.0" +lockfileVersion: '9.0' settings: autoInstallPeers: true excludeLinksFromLockfile: false importers: + .: dependencies: - "@auth/core": + '@auth/core': specifier: ^0.39.1 version: 0.39.1 - "@auth/sveltekit": + '@auth/sveltekit': specifier: ^1.11.0 version: 1.11.1(@sveltejs/kit@2.50.1(@sveltejs/vite-plugin-svelte@5.1.1(svelte@5.39.12)(vite@6.4.1(@types/node@22.18.10)(jiti@2.6.1)(lightningcss@1.30.1)(yaml@2.8.1)))(svelte@5.39.12)(typescript@5.9.3)(vite@6.4.1(@types/node@22.18.10)(jiti@2.6.1)(lightningcss@1.30.1)(yaml@2.8.1)))(svelte@5.39.12) - "@sveltejs/adapter-node": + '@openreplay/tracker': + specifier: ^18.1.2 + version: 18.1.2 + '@sveltejs/adapter-node': specifier: ^5.5.2 version: 5.5.2(@sveltejs/kit@2.50.1(@sveltejs/vite-plugin-svelte@5.1.1(svelte@5.39.12)(vite@6.4.1(@types/node@22.18.10)(jiti@2.6.1)(lightningcss@1.30.1)(yaml@2.8.1)))(svelte@5.39.12)(typescript@5.9.3)(vite@6.4.1(@types/node@22.18.10)(jiti@2.6.1)(lightningcss@1.30.1)(yaml@2.8.1))) command-line-args: specifier: ^6.0.1 version: 6.0.1 + isomorphic-dompurify: + specifier: ^3.21.0 + version: 3.21.0 lucide-svelte: specifier: ^0.479.0 version: 0.479.0(svelte@5.39.12) + marked: + specifier: ^18.0.9 + version: 18.0.9 nice-grpc: specifier: ^2.1.15 version: 2.1.15 @@ -41,46 +51,40 @@ importers: specifier: ^3.25.76 version: 3.25.76 devDependencies: - "@bufbuild/protobuf": + '@bufbuild/protobuf': specifier: ^2.11.0 version: 2.11.0 - "@eslint/compat": + '@eslint/compat': specifier: ^1.4.0 version: 1.4.0(eslint@9.37.0(jiti@2.6.1)) - "@eslint/js": + '@eslint/js': specifier: ^9.37.0 version: 9.37.0 - "@skeletonlabs/skeleton": - specifier: ^3.2.2 - version: 3.2.2(tailwindcss@4.1.14) - "@skeletonlabs/skeleton-svelte": - specifier: ^1.5.3 - version: 1.5.3(svelte@5.39.12) - "@sveltejs/kit": + '@sveltejs/kit': specifier: ^2.50.1 version: 2.50.1(@sveltejs/vite-plugin-svelte@5.1.1(svelte@5.39.12)(vite@6.4.1(@types/node@22.18.10)(jiti@2.6.1)(lightningcss@1.30.1)(yaml@2.8.1)))(svelte@5.39.12)(typescript@5.9.3)(vite@6.4.1(@types/node@22.18.10)(jiti@2.6.1)(lightningcss@1.30.1)(yaml@2.8.1)) - "@sveltejs/vite-plugin-svelte": + '@sveltejs/vite-plugin-svelte': specifier: ^5.1.1 version: 5.1.1(svelte@5.39.12)(vite@6.4.1(@types/node@22.18.10)(jiti@2.6.1)(lightningcss@1.30.1)(yaml@2.8.1)) - "@tailwindcss/vite": + '@tailwindcss/vite': specifier: ^4.1.14 version: 4.1.14(vite@6.4.1(@types/node@22.18.10)(jiti@2.6.1)(lightningcss@1.30.1)(yaml@2.8.1)) - "@testing-library/jest-dom": + '@testing-library/jest-dom': specifier: ^6.9.1 version: 6.9.1 - "@testing-library/svelte": + '@testing-library/svelte': specifier: ^5.2.8 - version: 5.3.1(svelte@5.39.12)(vite@6.4.1(@types/node@22.18.10)(jiti@2.6.1)(lightningcss@1.30.1)(yaml@2.8.1))(vitest@3.2.4(@types/node@22.18.10)(@vitest/ui@3.2.4)(jiti@2.6.1)(jsdom@26.1.0)(lightningcss@1.30.1)(yaml@2.8.1)) - "@types/command-line-args": + version: 5.3.1(svelte@5.39.12)(vite@6.4.1(@types/node@22.18.10)(jiti@2.6.1)(lightningcss@1.30.1)(yaml@2.8.1))(vitest@3.2.4) + '@types/command-line-args': specifier: ^5.2.3 version: 5.2.3 - "@types/node": + '@types/node': specifier: ^22.18.10 version: 22.18.10 - "@vitest/coverage-v8": + '@vitest/coverage-v8': specifier: 3.2.4 - version: 3.2.4(vitest@3.2.4(@types/node@22.18.10)(@vitest/ui@3.2.4)(jiti@2.6.1)(jsdom@26.1.0)(lightningcss@1.30.1)(yaml@2.8.1)) - "@vitest/ui": + version: 3.2.4(vitest@3.2.4) + '@vitest/ui': specifier: ^3.2.4 version: 3.2.4(vitest@3.2.4) eslint: @@ -136,1349 +140,906 @@ importers: version: 3.2.4(@types/node@22.18.10)(@vitest/ui@3.2.4)(jiti@2.6.1)(jsdom@26.1.0)(lightningcss@1.30.1)(yaml@2.8.1) packages: - "@adobe/css-tools@4.4.4": - resolution: - { - integrity: sha512-Elp+iwUx5rN5+Y8xLt5/GRoG20WGoDCQ/1Fb+1LiGtvwbDavuSk0jhD/eZdckHAuzcDzccnkv+rEjyWfRx18gg==, - } - - "@ampproject/remapping@2.3.0": - resolution: - { - integrity: sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw==, - } - engines: { node: ">=6.0.0" } - - "@asamuzakjp/css-color@3.2.0": - resolution: - { - integrity: sha512-K1A6z8tS3XsmCMM86xoWdn7Fkdn9m6RSVtocUrJYIwZnFVkng/PvkEoWtOWmP+Scc6saYWHWZYbndEEXxl24jw==, - } - - "@auth/core@0.39.1": - resolution: - { - integrity: sha512-McD8slui0oOA1pjR5sPjLPl5Zm//nLP/8T3kr8hxIsvNLvsiudYvPHhDFPjh1KcZ2nFxCkZmP6bRxaaPd/AnLA==, - } + + '@adobe/css-tools@4.4.4': + resolution: {integrity: sha512-Elp+iwUx5rN5+Y8xLt5/GRoG20WGoDCQ/1Fb+1LiGtvwbDavuSk0jhD/eZdckHAuzcDzccnkv+rEjyWfRx18gg==} + + '@ampproject/remapping@2.3.0': + resolution: {integrity: sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw==} + engines: {node: '>=6.0.0'} + + '@asamuzakjp/css-color@3.2.0': + resolution: {integrity: sha512-K1A6z8tS3XsmCMM86xoWdn7Fkdn9m6RSVtocUrJYIwZnFVkng/PvkEoWtOWmP+Scc6saYWHWZYbndEEXxl24jw==} + + '@asamuzakjp/css-color@6.0.5': + resolution: {integrity: sha512-mbhpPMmnw/kwW19aRNmSUl1QzLbdGo1SCuE49BT98MNwqF6zaHb3o2owssFc/PEO/4t2UjqtCNwocuDtJornzA==} + engines: {node: ^22.13.0 || >=24.0.0} + + '@asamuzakjp/dom-selector@8.3.2': + resolution: {integrity: sha512-93Z1N+BQNXysodoicpOIyNh2drHfz/CTf9nnT0FEx72GJcIiwgydD7tGAr78j41LsYn3hlRn+LdGPuBLn1Bl8Q==} + engines: {node: ^22.13.0 || >=24.0.0} + + '@auth/core@0.39.1': + resolution: {integrity: sha512-McD8slui0oOA1pjR5sPjLPl5Zm//nLP/8T3kr8hxIsvNLvsiudYvPHhDFPjh1KcZ2nFxCkZmP6bRxaaPd/AnLA==} peerDependencies: - "@simplewebauthn/browser": ^9.0.1 - "@simplewebauthn/server": ^9.0.2 + '@simplewebauthn/browser': ^9.0.1 + '@simplewebauthn/server': ^9.0.2 nodemailer: ^6.8.0 peerDependenciesMeta: - "@simplewebauthn/browser": + '@simplewebauthn/browser': optional: true - "@simplewebauthn/server": + '@simplewebauthn/server': optional: true nodemailer: optional: true - "@auth/core@0.41.1": - resolution: - { - integrity: sha512-t9cJ2zNYAdWMacGRMT6+r4xr1uybIdmYa49calBPeTqwgAFPV/88ac9TEvCR85pvATiSPt8VaNf+Gt24JIT/uw==, - } + '@auth/core@0.41.1': + resolution: {integrity: sha512-t9cJ2zNYAdWMacGRMT6+r4xr1uybIdmYa49calBPeTqwgAFPV/88ac9TEvCR85pvATiSPt8VaNf+Gt24JIT/uw==} peerDependencies: - "@simplewebauthn/browser": ^9.0.1 - "@simplewebauthn/server": ^9.0.2 + '@simplewebauthn/browser': ^9.0.1 + '@simplewebauthn/server': ^9.0.2 nodemailer: ^7.0.7 peerDependenciesMeta: - "@simplewebauthn/browser": + '@simplewebauthn/browser': optional: true - "@simplewebauthn/server": + '@simplewebauthn/server': optional: true nodemailer: optional: true - "@auth/sveltekit@1.11.1": - resolution: - { - integrity: sha512-cWNfXcKrNIVtJYOY1tq7H7m03j89Wg7xrTvOJALu18fZdYulzYCPIAdTw8XSEzOp6KyhOGo7tmW7VtzRNtr/8Q==, - } + '@auth/sveltekit@1.11.1': + resolution: {integrity: sha512-cWNfXcKrNIVtJYOY1tq7H7m03j89Wg7xrTvOJALu18fZdYulzYCPIAdTw8XSEzOp6KyhOGo7tmW7VtzRNtr/8Q==} peerDependencies: - "@simplewebauthn/browser": ^9.0.1 - "@simplewebauthn/server": ^9.0.3 - "@sveltejs/kit": ^1.0.0 || ^2.0.0 + '@simplewebauthn/browser': ^9.0.1 + '@simplewebauthn/server': ^9.0.3 + '@sveltejs/kit': ^1.0.0 || ^2.0.0 nodemailer: ^7.0.7 svelte: ^3.54.0 || ^4.0.0 || ^5.0.0-0 peerDependenciesMeta: - "@simplewebauthn/browser": + '@simplewebauthn/browser': optional: true - "@simplewebauthn/server": + '@simplewebauthn/server': optional: true nodemailer: optional: true - "@babel/code-frame@7.29.0": - resolution: - { - integrity: sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==, - } - engines: { node: ">=6.9.0" } - - "@babel/helper-string-parser@7.27.1": - resolution: - { - integrity: sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==, - } - engines: { node: ">=6.9.0" } - - "@babel/helper-validator-identifier@7.28.5": - resolution: - { - integrity: sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==, - } - engines: { node: ">=6.9.0" } - - "@babel/parser@7.29.2": - resolution: - { - integrity: sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA==, - } - engines: { node: ">=6.0.0" } + '@babel/code-frame@7.29.0': + resolution: {integrity: sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==} + engines: {node: '>=6.9.0'} + + '@babel/helper-string-parser@7.27.1': + resolution: {integrity: sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==} + engines: {node: '>=6.9.0'} + + '@babel/helper-validator-identifier@7.28.5': + resolution: {integrity: sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==} + engines: {node: '>=6.9.0'} + + '@babel/parser@7.29.2': + resolution: {integrity: sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA==} + engines: {node: '>=6.0.0'} hasBin: true - "@babel/runtime@7.29.2": - resolution: - { - integrity: sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==, - } - engines: { node: ">=6.9.0" } - - "@babel/types@7.29.0": - resolution: - { - integrity: sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==, - } - engines: { node: ">=6.9.0" } - - "@bcoe/v8-coverage@1.0.2": - resolution: - { - integrity: sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==, - } - engines: { node: ">=18" } - - "@bufbuild/protobuf@2.11.0": - resolution: - { - integrity: sha512-sBXGT13cpmPR5BMgHE6UEEfEaShh5Ror6rfN3yEK5si7QVrtZg8LEPQb0VVhiLRUslD2yLnXtnRzG035J/mZXQ==, - } - - "@csstools/color-helpers@5.1.0": - resolution: - { - integrity: sha512-S11EXWJyy0Mz5SYvRmY8nJYTFFd1LCNV+7cXyAgQtOOuzb4EsgfqDufL+9esx72/eLhsRdGZwaldu/h+E4t4BA==, - } - engines: { node: ">=18" } - - "@csstools/css-calc@2.1.4": - resolution: - { - integrity: sha512-3N8oaj+0juUw/1H3YwmDDJXCgTB1gKU6Hc/bB502u9zR0q2vd786XJH9QfrKIEgFlZmhZiq6epXl4rHqhzsIgQ==, - } - engines: { node: ">=18" } + '@babel/runtime@7.29.2': + resolution: {integrity: sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==} + engines: {node: '>=6.9.0'} + + '@babel/types@7.29.0': + resolution: {integrity: sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==} + engines: {node: '>=6.9.0'} + + '@bcoe/v8-coverage@1.0.2': + resolution: {integrity: sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==} + engines: {node: '>=18'} + + '@bramus/specificity@2.4.2': + resolution: {integrity: sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==} + hasBin: true + + '@bufbuild/protobuf@2.11.0': + resolution: {integrity: sha512-sBXGT13cpmPR5BMgHE6UEEfEaShh5Ror6rfN3yEK5si7QVrtZg8LEPQb0VVhiLRUslD2yLnXtnRzG035J/mZXQ==} + + '@csstools/color-helpers@5.1.0': + resolution: {integrity: sha512-S11EXWJyy0Mz5SYvRmY8nJYTFFd1LCNV+7cXyAgQtOOuzb4EsgfqDufL+9esx72/eLhsRdGZwaldu/h+E4t4BA==} + engines: {node: '>=18'} + + '@csstools/color-helpers@6.1.0': + resolution: {integrity: sha512-064IFJdjTfUqnjpCVpMOdbr8FLQBhinbZj6yRv2An2E41O/pLEXqfFRWqGq/SxlE5PEUYTlvWsG2r8MswAVvkg==} + engines: {node: '>=20.19.0'} + + '@csstools/css-calc@2.1.4': + resolution: {integrity: sha512-3N8oaj+0juUw/1H3YwmDDJXCgTB1gKU6Hc/bB502u9zR0q2vd786XJH9QfrKIEgFlZmhZiq6epXl4rHqhzsIgQ==} + engines: {node: '>=18'} peerDependencies: - "@csstools/css-parser-algorithms": ^3.0.5 - "@csstools/css-tokenizer": ^3.0.4 - - "@csstools/css-color-parser@3.1.0": - resolution: - { - integrity: sha512-nbtKwh3a6xNVIp/VRuXV64yTKnb1IjTAEEh3irzS+HkKjAOYLTGNb9pmVNntZ8iVBHcWDA2Dof0QtPgFI1BaTA==, - } - engines: { node: ">=18" } + '@csstools/css-parser-algorithms': ^3.0.5 + '@csstools/css-tokenizer': ^3.0.4 + + '@csstools/css-calc@3.3.0': + resolution: {integrity: sha512-c5ihYsPkdG6JCkU2zTMm4+k6r7RXuGxtWYhu5DHMIiF1FHzrfmHL5so11AoFpUv/tu61xfcmT4AmKoFfMPoqdQ==} + engines: {node: '>=20.19.0'} + peerDependencies: + '@csstools/css-parser-algorithms': ^4.0.0 + '@csstools/css-tokenizer': ^4.0.0 + + '@csstools/css-color-parser@3.1.0': + resolution: {integrity: sha512-nbtKwh3a6xNVIp/VRuXV64yTKnb1IjTAEEh3irzS+HkKjAOYLTGNb9pmVNntZ8iVBHcWDA2Dof0QtPgFI1BaTA==} + engines: {node: '>=18'} + peerDependencies: + '@csstools/css-parser-algorithms': ^3.0.5 + '@csstools/css-tokenizer': ^3.0.4 + + '@csstools/css-color-parser@4.1.10': + resolution: {integrity: sha512-UZhQLIUyJaaMepqehrCODwCg2KW25vFvLWBmqYFaPclYvvxzj/sG8LBOhBFCp11i9uE7t1EyS+RAoV9tztPFyw==} + engines: {node: '>=20.19.0'} + peerDependencies: + '@csstools/css-parser-algorithms': ^4.0.0 + '@csstools/css-tokenizer': ^4.0.0 + + '@csstools/css-parser-algorithms@3.0.5': + resolution: {integrity: sha512-DaDeUkXZKjdGhgYaHNJTV9pV7Y9B3b644jCLs9Upc3VeNGg6LWARAT6O+Q+/COo+2gg/bM5rhpMAtf70WqfBdQ==} + engines: {node: '>=18'} + peerDependencies: + '@csstools/css-tokenizer': ^3.0.4 + + '@csstools/css-parser-algorithms@4.0.0': + resolution: {integrity: sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w==} + engines: {node: '>=20.19.0'} peerDependencies: - "@csstools/css-parser-algorithms": ^3.0.5 - "@csstools/css-tokenizer": ^3.0.4 - - "@csstools/css-parser-algorithms@3.0.5": - resolution: - { - integrity: sha512-DaDeUkXZKjdGhgYaHNJTV9pV7Y9B3b644jCLs9Upc3VeNGg6LWARAT6O+Q+/COo+2gg/bM5rhpMAtf70WqfBdQ==, - } - engines: { node: ">=18" } + '@csstools/css-tokenizer': ^4.0.0 + + '@csstools/css-syntax-patches-for-csstree@1.1.7': + resolution: {integrity: sha512-fQ+05118eQS1cofO3aJpB5efgpBZMvIzwr/sbC8kDLVA5XLG8q1kJV5yzrUAI1f7lvhPnm8fgIjzFB8/O/5Dig==} peerDependencies: - "@csstools/css-tokenizer": ^3.0.4 - - "@csstools/css-tokenizer@3.0.4": - resolution: - { - integrity: sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw==, - } - engines: { node: ">=18" } - - "@esbuild/aix-ppc64@0.25.10": - resolution: - { - integrity: sha512-0NFWnA+7l41irNuaSVlLfgNT12caWJVLzp5eAVhZ0z1qpxbockccEt3s+149rE64VUI3Ml2zt8Nv5JVc4QXTsw==, - } - engines: { node: ">=18" } + css-tree: ^3.2.1 + peerDependenciesMeta: + css-tree: + optional: true + + '@csstools/css-tokenizer@3.0.4': + resolution: {integrity: sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw==} + engines: {node: '>=18'} + + '@csstools/css-tokenizer@4.0.0': + resolution: {integrity: sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==} + engines: {node: '>=20.19.0'} + + '@esbuild/aix-ppc64@0.25.10': + resolution: {integrity: sha512-0NFWnA+7l41irNuaSVlLfgNT12caWJVLzp5eAVhZ0z1qpxbockccEt3s+149rE64VUI3Ml2zt8Nv5JVc4QXTsw==} + engines: {node: '>=18'} cpu: [ppc64] os: [aix] - "@esbuild/android-arm64@0.25.10": - resolution: - { - integrity: sha512-LSQa7eDahypv/VO6WKohZGPSJDq5OVOo3UoFR1E4t4Gj1W7zEQMUhI+lo81H+DtB+kP+tDgBp+M4oNCwp6kffg==, - } - engines: { node: ">=18" } + '@esbuild/android-arm64@0.25.10': + resolution: {integrity: sha512-LSQa7eDahypv/VO6WKohZGPSJDq5OVOo3UoFR1E4t4Gj1W7zEQMUhI+lo81H+DtB+kP+tDgBp+M4oNCwp6kffg==} + engines: {node: '>=18'} cpu: [arm64] os: [android] - "@esbuild/android-arm@0.25.10": - resolution: - { - integrity: sha512-dQAxF1dW1C3zpeCDc5KqIYuZ1tgAdRXNoZP7vkBIRtKZPYe2xVr/d3SkirklCHudW1B45tGiUlz2pUWDfbDD4w==, - } - engines: { node: ">=18" } + '@esbuild/android-arm@0.25.10': + resolution: {integrity: sha512-dQAxF1dW1C3zpeCDc5KqIYuZ1tgAdRXNoZP7vkBIRtKZPYe2xVr/d3SkirklCHudW1B45tGiUlz2pUWDfbDD4w==} + engines: {node: '>=18'} cpu: [arm] os: [android] - "@esbuild/android-x64@0.25.10": - resolution: - { - integrity: sha512-MiC9CWdPrfhibcXwr39p9ha1x0lZJ9KaVfvzA0Wxwz9ETX4v5CHfF09bx935nHlhi+MxhA63dKRRQLiVgSUtEg==, - } - engines: { node: ">=18" } + '@esbuild/android-x64@0.25.10': + resolution: {integrity: sha512-MiC9CWdPrfhibcXwr39p9ha1x0lZJ9KaVfvzA0Wxwz9ETX4v5CHfF09bx935nHlhi+MxhA63dKRRQLiVgSUtEg==} + engines: {node: '>=18'} cpu: [x64] os: [android] - "@esbuild/darwin-arm64@0.25.10": - resolution: - { - integrity: sha512-JC74bdXcQEpW9KkV326WpZZjLguSZ3DfS8wrrvPMHgQOIEIG/sPXEN/V8IssoJhbefLRcRqw6RQH2NnpdprtMA==, - } - engines: { node: ">=18" } + '@esbuild/darwin-arm64@0.25.10': + resolution: {integrity: sha512-JC74bdXcQEpW9KkV326WpZZjLguSZ3DfS8wrrvPMHgQOIEIG/sPXEN/V8IssoJhbefLRcRqw6RQH2NnpdprtMA==} + engines: {node: '>=18'} cpu: [arm64] os: [darwin] - "@esbuild/darwin-x64@0.25.10": - resolution: - { - integrity: sha512-tguWg1olF6DGqzws97pKZ8G2L7Ig1vjDmGTwcTuYHbuU6TTjJe5FXbgs5C1BBzHbJ2bo1m3WkQDbWO2PvamRcg==, - } - engines: { node: ">=18" } + '@esbuild/darwin-x64@0.25.10': + resolution: {integrity: sha512-tguWg1olF6DGqzws97pKZ8G2L7Ig1vjDmGTwcTuYHbuU6TTjJe5FXbgs5C1BBzHbJ2bo1m3WkQDbWO2PvamRcg==} + engines: {node: '>=18'} cpu: [x64] os: [darwin] - "@esbuild/freebsd-arm64@0.25.10": - resolution: - { - integrity: sha512-3ZioSQSg1HT2N05YxeJWYR+Libe3bREVSdWhEEgExWaDtyFbbXWb49QgPvFH8u03vUPX10JhJPcz7s9t9+boWg==, - } - engines: { node: ">=18" } + '@esbuild/freebsd-arm64@0.25.10': + resolution: {integrity: sha512-3ZioSQSg1HT2N05YxeJWYR+Libe3bREVSdWhEEgExWaDtyFbbXWb49QgPvFH8u03vUPX10JhJPcz7s9t9+boWg==} + engines: {node: '>=18'} cpu: [arm64] os: [freebsd] - "@esbuild/freebsd-x64@0.25.10": - resolution: - { - integrity: sha512-LLgJfHJk014Aa4anGDbh8bmI5Lk+QidDmGzuC2D+vP7mv/GeSN+H39zOf7pN5N8p059FcOfs2bVlrRr4SK9WxA==, - } - engines: { node: ">=18" } + '@esbuild/freebsd-x64@0.25.10': + resolution: {integrity: sha512-LLgJfHJk014Aa4anGDbh8bmI5Lk+QidDmGzuC2D+vP7mv/GeSN+H39zOf7pN5N8p059FcOfs2bVlrRr4SK9WxA==} + engines: {node: '>=18'} cpu: [x64] os: [freebsd] - "@esbuild/linux-arm64@0.25.10": - resolution: - { - integrity: sha512-5luJWN6YKBsawd5f9i4+c+geYiVEw20FVW5x0v1kEMWNq8UctFjDiMATBxLvmmHA4bf7F6hTRaJgtghFr9iziQ==, - } - engines: { node: ">=18" } + '@esbuild/linux-arm64@0.25.10': + resolution: {integrity: sha512-5luJWN6YKBsawd5f9i4+c+geYiVEw20FVW5x0v1kEMWNq8UctFjDiMATBxLvmmHA4bf7F6hTRaJgtghFr9iziQ==} + engines: {node: '>=18'} cpu: [arm64] os: [linux] - "@esbuild/linux-arm@0.25.10": - resolution: - { - integrity: sha512-oR31GtBTFYCqEBALI9r6WxoU/ZofZl962pouZRTEYECvNF/dtXKku8YXcJkhgK/beU+zedXfIzHijSRapJY3vg==, - } - engines: { node: ">=18" } + '@esbuild/linux-arm@0.25.10': + resolution: {integrity: sha512-oR31GtBTFYCqEBALI9r6WxoU/ZofZl962pouZRTEYECvNF/dtXKku8YXcJkhgK/beU+zedXfIzHijSRapJY3vg==} + engines: {node: '>=18'} cpu: [arm] os: [linux] - "@esbuild/linux-ia32@0.25.10": - resolution: - { - integrity: sha512-NrSCx2Kim3EnnWgS4Txn0QGt0Xipoumb6z6sUtl5bOEZIVKhzfyp/Lyw4C1DIYvzeW/5mWYPBFJU3a/8Yr75DQ==, - } - engines: { node: ">=18" } + '@esbuild/linux-ia32@0.25.10': + resolution: {integrity: sha512-NrSCx2Kim3EnnWgS4Txn0QGt0Xipoumb6z6sUtl5bOEZIVKhzfyp/Lyw4C1DIYvzeW/5mWYPBFJU3a/8Yr75DQ==} + engines: {node: '>=18'} cpu: [ia32] os: [linux] - "@esbuild/linux-loong64@0.25.10": - resolution: - { - integrity: sha512-xoSphrd4AZda8+rUDDfD9J6FUMjrkTz8itpTITM4/xgerAZZcFW7Dv+sun7333IfKxGG8gAq+3NbfEMJfiY+Eg==, - } - engines: { node: ">=18" } + '@esbuild/linux-loong64@0.25.10': + resolution: {integrity: sha512-xoSphrd4AZda8+rUDDfD9J6FUMjrkTz8itpTITM4/xgerAZZcFW7Dv+sun7333IfKxGG8gAq+3NbfEMJfiY+Eg==} + engines: {node: '>=18'} cpu: [loong64] os: [linux] - "@esbuild/linux-mips64el@0.25.10": - resolution: - { - integrity: sha512-ab6eiuCwoMmYDyTnyptoKkVS3k8fy/1Uvq7Dj5czXI6DF2GqD2ToInBI0SHOp5/X1BdZ26RKc5+qjQNGRBelRA==, - } - engines: { node: ">=18" } + '@esbuild/linux-mips64el@0.25.10': + resolution: {integrity: sha512-ab6eiuCwoMmYDyTnyptoKkVS3k8fy/1Uvq7Dj5czXI6DF2GqD2ToInBI0SHOp5/X1BdZ26RKc5+qjQNGRBelRA==} + engines: {node: '>=18'} cpu: [mips64el] os: [linux] - "@esbuild/linux-ppc64@0.25.10": - resolution: - { - integrity: sha512-NLinzzOgZQsGpsTkEbdJTCanwA5/wozN9dSgEl12haXJBzMTpssebuXR42bthOF3z7zXFWH1AmvWunUCkBE4EA==, - } - engines: { node: ">=18" } + '@esbuild/linux-ppc64@0.25.10': + resolution: {integrity: sha512-NLinzzOgZQsGpsTkEbdJTCanwA5/wozN9dSgEl12haXJBzMTpssebuXR42bthOF3z7zXFWH1AmvWunUCkBE4EA==} + engines: {node: '>=18'} cpu: [ppc64] os: [linux] - "@esbuild/linux-riscv64@0.25.10": - resolution: - { - integrity: sha512-FE557XdZDrtX8NMIeA8LBJX3dC2M8VGXwfrQWU7LB5SLOajfJIxmSdyL/gU1m64Zs9CBKvm4UAuBp5aJ8OgnrA==, - } - engines: { node: ">=18" } + '@esbuild/linux-riscv64@0.25.10': + resolution: {integrity: sha512-FE557XdZDrtX8NMIeA8LBJX3dC2M8VGXwfrQWU7LB5SLOajfJIxmSdyL/gU1m64Zs9CBKvm4UAuBp5aJ8OgnrA==} + engines: {node: '>=18'} cpu: [riscv64] os: [linux] - "@esbuild/linux-s390x@0.25.10": - resolution: - { - integrity: sha512-3BBSbgzuB9ajLoVZk0mGu+EHlBwkusRmeNYdqmznmMc9zGASFjSsxgkNsqmXugpPk00gJ0JNKh/97nxmjctdew==, - } - engines: { node: ">=18" } + '@esbuild/linux-s390x@0.25.10': + resolution: {integrity: sha512-3BBSbgzuB9ajLoVZk0mGu+EHlBwkusRmeNYdqmznmMc9zGASFjSsxgkNsqmXugpPk00gJ0JNKh/97nxmjctdew==} + engines: {node: '>=18'} cpu: [s390x] os: [linux] - "@esbuild/linux-x64@0.25.10": - resolution: - { - integrity: sha512-QSX81KhFoZGwenVyPoberggdW1nrQZSvfVDAIUXr3WqLRZGZqWk/P4T8p2SP+de2Sr5HPcvjhcJzEiulKgnxtA==, - } - engines: { node: ">=18" } + '@esbuild/linux-x64@0.25.10': + resolution: {integrity: sha512-QSX81KhFoZGwenVyPoberggdW1nrQZSvfVDAIUXr3WqLRZGZqWk/P4T8p2SP+de2Sr5HPcvjhcJzEiulKgnxtA==} + engines: {node: '>=18'} cpu: [x64] os: [linux] - "@esbuild/netbsd-arm64@0.25.10": - resolution: - { - integrity: sha512-AKQM3gfYfSW8XRk8DdMCzaLUFB15dTrZfnX8WXQoOUpUBQ+NaAFCP1kPS/ykbbGYz7rxn0WS48/81l9hFl3u4A==, - } - engines: { node: ">=18" } + '@esbuild/netbsd-arm64@0.25.10': + resolution: {integrity: sha512-AKQM3gfYfSW8XRk8DdMCzaLUFB15dTrZfnX8WXQoOUpUBQ+NaAFCP1kPS/ykbbGYz7rxn0WS48/81l9hFl3u4A==} + engines: {node: '>=18'} cpu: [arm64] os: [netbsd] - "@esbuild/netbsd-x64@0.25.10": - resolution: - { - integrity: sha512-7RTytDPGU6fek/hWuN9qQpeGPBZFfB4zZgcz2VK2Z5VpdUxEI8JKYsg3JfO0n/Z1E/6l05n0unDCNc4HnhQGig==, - } - engines: { node: ">=18" } + '@esbuild/netbsd-x64@0.25.10': + resolution: {integrity: sha512-7RTytDPGU6fek/hWuN9qQpeGPBZFfB4zZgcz2VK2Z5VpdUxEI8JKYsg3JfO0n/Z1E/6l05n0unDCNc4HnhQGig==} + engines: {node: '>=18'} cpu: [x64] os: [netbsd] - "@esbuild/openbsd-arm64@0.25.10": - resolution: - { - integrity: sha512-5Se0VM9Wtq797YFn+dLimf2Zx6McttsH2olUBsDml+lm0GOCRVebRWUvDtkY4BWYv/3NgzS8b/UM3jQNh5hYyw==, - } - engines: { node: ">=18" } + '@esbuild/openbsd-arm64@0.25.10': + resolution: {integrity: sha512-5Se0VM9Wtq797YFn+dLimf2Zx6McttsH2olUBsDml+lm0GOCRVebRWUvDtkY4BWYv/3NgzS8b/UM3jQNh5hYyw==} + engines: {node: '>=18'} cpu: [arm64] os: [openbsd] - "@esbuild/openbsd-x64@0.25.10": - resolution: - { - integrity: sha512-XkA4frq1TLj4bEMB+2HnI0+4RnjbuGZfet2gs/LNs5Hc7D89ZQBHQ0gL2ND6Lzu1+QVkjp3x1gIcPKzRNP8bXw==, - } - engines: { node: ">=18" } + '@esbuild/openbsd-x64@0.25.10': + resolution: {integrity: sha512-XkA4frq1TLj4bEMB+2HnI0+4RnjbuGZfet2gs/LNs5Hc7D89ZQBHQ0gL2ND6Lzu1+QVkjp3x1gIcPKzRNP8bXw==} + engines: {node: '>=18'} cpu: [x64] os: [openbsd] - "@esbuild/openharmony-arm64@0.25.10": - resolution: - { - integrity: sha512-AVTSBhTX8Y/Fz6OmIVBip9tJzZEUcY8WLh7I59+upa5/GPhh2/aM6bvOMQySspnCCHvFi79kMtdJS1w0DXAeag==, - } - engines: { node: ">=18" } + '@esbuild/openharmony-arm64@0.25.10': + resolution: {integrity: sha512-AVTSBhTX8Y/Fz6OmIVBip9tJzZEUcY8WLh7I59+upa5/GPhh2/aM6bvOMQySspnCCHvFi79kMtdJS1w0DXAeag==} + engines: {node: '>=18'} cpu: [arm64] os: [openharmony] - "@esbuild/sunos-x64@0.25.10": - resolution: - { - integrity: sha512-fswk3XT0Uf2pGJmOpDB7yknqhVkJQkAQOcW/ccVOtfx05LkbWOaRAtn5SaqXypeKQra1QaEa841PgrSL9ubSPQ==, - } - engines: { node: ">=18" } + '@esbuild/sunos-x64@0.25.10': + resolution: {integrity: sha512-fswk3XT0Uf2pGJmOpDB7yknqhVkJQkAQOcW/ccVOtfx05LkbWOaRAtn5SaqXypeKQra1QaEa841PgrSL9ubSPQ==} + engines: {node: '>=18'} cpu: [x64] os: [sunos] - "@esbuild/win32-arm64@0.25.10": - resolution: - { - integrity: sha512-ah+9b59KDTSfpaCg6VdJoOQvKjI33nTaQr4UluQwW7aEwZQsbMCfTmfEO4VyewOxx4RaDT/xCy9ra2GPWmO7Kw==, - } - engines: { node: ">=18" } + '@esbuild/win32-arm64@0.25.10': + resolution: {integrity: sha512-ah+9b59KDTSfpaCg6VdJoOQvKjI33nTaQr4UluQwW7aEwZQsbMCfTmfEO4VyewOxx4RaDT/xCy9ra2GPWmO7Kw==} + engines: {node: '>=18'} cpu: [arm64] os: [win32] - "@esbuild/win32-ia32@0.25.10": - resolution: - { - integrity: sha512-QHPDbKkrGO8/cz9LKVnJU22HOi4pxZnZhhA2HYHez5Pz4JeffhDjf85E57Oyco163GnzNCVkZK0b/n4Y0UHcSw==, - } - engines: { node: ">=18" } + '@esbuild/win32-ia32@0.25.10': + resolution: {integrity: sha512-QHPDbKkrGO8/cz9LKVnJU22HOi4pxZnZhhA2HYHez5Pz4JeffhDjf85E57Oyco163GnzNCVkZK0b/n4Y0UHcSw==} + engines: {node: '>=18'} cpu: [ia32] os: [win32] - "@esbuild/win32-x64@0.25.10": - resolution: - { - integrity: sha512-9KpxSVFCu0iK1owoez6aC/s/EdUQLDN3adTxGCqxMVhrPDj6bt5dbrHDXUuq+Bs2vATFBBrQS5vdQ/Ed2P+nbw==, - } - engines: { node: ">=18" } + '@esbuild/win32-x64@0.25.10': + resolution: {integrity: sha512-9KpxSVFCu0iK1owoez6aC/s/EdUQLDN3adTxGCqxMVhrPDj6bt5dbrHDXUuq+Bs2vATFBBrQS5vdQ/Ed2P+nbw==} + engines: {node: '>=18'} cpu: [x64] os: [win32] - "@eslint-community/eslint-utils@4.9.0": - resolution: - { - integrity: sha512-ayVFHdtZ+hsq1t2Dy24wCmGXGe4q9Gu3smhLYALJrr473ZH27MsnSL+LKUlimp4BWJqMDMLmPpx/Q9R3OAlL4g==, - } - engines: { node: ^12.22.0 || ^14.17.0 || >=16.0.0 } + '@eslint-community/eslint-utils@4.9.0': + resolution: {integrity: sha512-ayVFHdtZ+hsq1t2Dy24wCmGXGe4q9Gu3smhLYALJrr473ZH27MsnSL+LKUlimp4BWJqMDMLmPpx/Q9R3OAlL4g==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} peerDependencies: eslint: ^6.0.0 || ^7.0.0 || >=8.0.0 - "@eslint-community/regexpp@4.12.1": - resolution: - { - integrity: sha512-CCZCDJuduB9OUkFkY2IgppNZMi2lBQgD2qzwXkEia16cge2pijY/aXi96CJMquDMn3nJdlPV1A5KrJEXwfLNzQ==, - } - engines: { node: ^12.0.0 || ^14.0.0 || >=16.0.0 } - - "@eslint/compat@1.4.0": - resolution: - { - integrity: sha512-DEzm5dKeDBPm3r08Ixli/0cmxr8LkRdwxMRUIJBlSCpAwSrvFEJpVBzV+66JhDxiaqKxnRzCXhtiMiczF7Hglg==, - } - engines: { node: ^18.18.0 || ^20.9.0 || >=21.1.0 } + '@eslint-community/regexpp@4.12.1': + resolution: {integrity: sha512-CCZCDJuduB9OUkFkY2IgppNZMi2lBQgD2qzwXkEia16cge2pijY/aXi96CJMquDMn3nJdlPV1A5KrJEXwfLNzQ==} + engines: {node: ^12.0.0 || ^14.0.0 || >=16.0.0} + + '@eslint/compat@1.4.0': + resolution: {integrity: sha512-DEzm5dKeDBPm3r08Ixli/0cmxr8LkRdwxMRUIJBlSCpAwSrvFEJpVBzV+66JhDxiaqKxnRzCXhtiMiczF7Hglg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: eslint: ^8.40 || 9 peerDependenciesMeta: eslint: optional: true - "@eslint/config-array@0.21.0": - resolution: - { - integrity: sha512-ENIdc4iLu0d93HeYirvKmrzshzofPw6VkZRKQGe9Nv46ZnWUzcF1xV01dcvEg/1wXUR61OmmlSfyeyO7EvjLxQ==, - } - engines: { node: ^18.18.0 || ^20.9.0 || >=21.1.0 } - - "@eslint/config-helpers@0.4.0": - resolution: - { - integrity: sha512-WUFvV4WoIwW8Bv0KeKCIIEgdSiFOsulyN0xrMu+7z43q/hkOLXjvb5u7UC9jDxvRzcrbEmuZBX5yJZz1741jog==, - } - engines: { node: ^18.18.0 || ^20.9.0 || >=21.1.0 } - - "@eslint/core@0.16.0": - resolution: - { - integrity: sha512-nmC8/totwobIiFcGkDza3GIKfAw1+hLiYVrh3I1nIomQ8PEr5cxg34jnkmGawul/ep52wGRAcyeDCNtWKSOj4Q==, - } - engines: { node: ^18.18.0 || ^20.9.0 || >=21.1.0 } - - "@eslint/eslintrc@3.3.1": - resolution: - { - integrity: sha512-gtF186CXhIl1p4pJNGZw8Yc6RlshoePRvE0X91oPGb3vZ8pM3qOS9W9NGPat9LziaBV7XrJWGylNQXkGcnM3IQ==, - } - engines: { node: ^18.18.0 || ^20.9.0 || >=21.1.0 } - - "@eslint/js@9.37.0": - resolution: - { - integrity: sha512-jaS+NJ+hximswBG6pjNX0uEJZkrT0zwpVi3BA3vX22aFGjJjmgSTSmPpZCRKmoBL5VY/M6p0xsSJx7rk7sy5gg==, - } - engines: { node: ^18.18.0 || ^20.9.0 || >=21.1.0 } - - "@eslint/object-schema@2.1.6": - resolution: - { - integrity: sha512-RBMg5FRL0I0gs51M/guSAj5/e14VQ4tpZnQNWwuDT66P14I43ItmPfIZRhO9fUVIPOAQXU47atlywZ/czoqFPA==, - } - engines: { node: ^18.18.0 || ^20.9.0 || >=21.1.0 } - - "@eslint/plugin-kit@0.4.0": - resolution: - { - integrity: sha512-sB5uyeq+dwCWyPi31B2gQlVlo+j5brPlWx4yZBrEaRo/nhdDE8Xke1gsGgtiBdaBTxuTkceLVuVt/pclrasb0A==, - } - engines: { node: ^18.18.0 || ^20.9.0 || >=21.1.0 } - - "@floating-ui/core@1.7.3": - resolution: - { - integrity: sha512-sGnvb5dmrJaKEZ+LDIpguvdX3bDlEllmv4/ClQ9awcmCZrlx5jQyyMWFM5kBI+EyNOCDDiKk8il0zeuX3Zlg/w==, - } - - "@floating-ui/dom@1.7.2": - resolution: - { - integrity: sha512-7cfaOQuCS27HD7DX+6ib2OrnW+b4ZBwDNnCcT0uTyidcmyWb03FnQqJybDBoCnpdxwBSfA94UAYlRCt7mV+TbA==, - } - - "@floating-ui/utils@0.2.10": - resolution: - { - integrity: sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ==, - } - - "@grpc/grpc-js@1.14.3": - resolution: - { - integrity: sha512-Iq8QQQ/7X3Sac15oB6p0FmUg/klxQvXLeileoqrTRGJYLV+/9tubbr9ipz0GKHjmXVsgFPo/+W+2cA8eNcR+XA==, - } - engines: { node: ">=12.10.0" } - - "@grpc/proto-loader@0.8.0": - resolution: - { - integrity: sha512-rc1hOQtjIWGxcxpb9aHAfLpIctjEnsDehj0DAiVfBlmT84uvR0uUtN2hEi/ecvWVjXUGf5qPF4qEgiLOx1YIMQ==, - } - engines: { node: ">=6" } + '@eslint/config-array@0.21.0': + resolution: {integrity: sha512-ENIdc4iLu0d93HeYirvKmrzshzofPw6VkZRKQGe9Nv46ZnWUzcF1xV01dcvEg/1wXUR61OmmlSfyeyO7EvjLxQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@eslint/config-helpers@0.4.0': + resolution: {integrity: sha512-WUFvV4WoIwW8Bv0KeKCIIEgdSiFOsulyN0xrMu+7z43q/hkOLXjvb5u7UC9jDxvRzcrbEmuZBX5yJZz1741jog==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@eslint/core@0.16.0': + resolution: {integrity: sha512-nmC8/totwobIiFcGkDza3GIKfAw1+hLiYVrh3I1nIomQ8PEr5cxg34jnkmGawul/ep52wGRAcyeDCNtWKSOj4Q==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@eslint/eslintrc@3.3.1': + resolution: {integrity: sha512-gtF186CXhIl1p4pJNGZw8Yc6RlshoePRvE0X91oPGb3vZ8pM3qOS9W9NGPat9LziaBV7XrJWGylNQXkGcnM3IQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@eslint/js@9.37.0': + resolution: {integrity: sha512-jaS+NJ+hximswBG6pjNX0uEJZkrT0zwpVi3BA3vX22aFGjJjmgSTSmPpZCRKmoBL5VY/M6p0xsSJx7rk7sy5gg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@eslint/object-schema@2.1.6': + resolution: {integrity: sha512-RBMg5FRL0I0gs51M/guSAj5/e14VQ4tpZnQNWwuDT66P14I43ItmPfIZRhO9fUVIPOAQXU47atlywZ/czoqFPA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@eslint/plugin-kit@0.4.0': + resolution: {integrity: sha512-sB5uyeq+dwCWyPi31B2gQlVlo+j5brPlWx4yZBrEaRo/nhdDE8Xke1gsGgtiBdaBTxuTkceLVuVt/pclrasb0A==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@exodus/bytes@1.15.1': + resolution: {integrity: sha512-S6mL0yNB/Abt9Ei4tq8gDhcczc4S3+vQ4ra7vxnAf+YHC02srtqxKKZghx2Dq6p0e66THKwR6r8N6P95wEty7Q==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + peerDependencies: + '@noble/hashes': ^1.8.0 || ^2.0.0 + peerDependenciesMeta: + '@noble/hashes': + optional: true + + '@grpc/grpc-js@1.14.3': + resolution: {integrity: sha512-Iq8QQQ/7X3Sac15oB6p0FmUg/klxQvXLeileoqrTRGJYLV+/9tubbr9ipz0GKHjmXVsgFPo/+W+2cA8eNcR+XA==} + engines: {node: '>=12.10.0'} + + '@grpc/proto-loader@0.8.0': + resolution: {integrity: sha512-rc1hOQtjIWGxcxpb9aHAfLpIctjEnsDehj0DAiVfBlmT84uvR0uUtN2hEi/ecvWVjXUGf5qPF4qEgiLOx1YIMQ==} + engines: {node: '>=6'} hasBin: true - "@humanfs/core@0.19.1": - resolution: - { - integrity: sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==, - } - engines: { node: ">=18.18.0" } - - "@humanfs/node@0.16.7": - resolution: - { - integrity: sha512-/zUx+yOsIrG4Y43Eh2peDeKCxlRt/gET6aHfaKpuq267qXdYDFViVHfMaLyygZOnl0kGWxFIgsBy8QFuTLUXEQ==, - } - engines: { node: ">=18.18.0" } - - "@humanwhocodes/module-importer@1.0.1": - resolution: - { - integrity: sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==, - } - engines: { node: ">=12.22" } - - "@humanwhocodes/retry@0.4.3": - resolution: - { - integrity: sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==, - } - engines: { node: ">=18.18" } - - "@isaacs/cliui@8.0.2": - resolution: - { - integrity: sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==, - } - engines: { node: ">=12" } - - "@isaacs/fs-minipass@4.0.1": - resolution: - { - integrity: sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w==, - } - engines: { node: ">=18.0.0" } - - "@istanbuljs/schema@0.1.3": - resolution: - { - integrity: sha512-ZXRY4jNvVgSVQ8DL3LTcakaAtXwTVUxE81hslsyD2AtoXW/wVob10HkOJ1X/pAlcI7D+2YoZKg5do8G/w6RYgA==, - } - engines: { node: ">=8" } - - "@jridgewell/gen-mapping@0.3.13": - resolution: - { - integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==, - } - - "@jridgewell/remapping@2.3.5": - resolution: - { - integrity: sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==, - } - - "@jridgewell/resolve-uri@3.1.2": - resolution: - { - integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==, - } - engines: { node: ">=6.0.0" } - - "@jridgewell/sourcemap-codec@1.5.5": - resolution: - { - integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==, - } - - "@jridgewell/trace-mapping@0.3.31": - resolution: - { - integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==, - } - - "@js-sdsl/ordered-map@4.4.2": - resolution: - { - integrity: sha512-iUKgm52T8HOE/makSxjqoWhe95ZJA1/G1sYsGev2JDKUSS14KAgg1LHb+Ba+IPow0xflbnSkOsZcO08C7w1gYw==, - } - - "@nodelib/fs.scandir@2.1.5": - resolution: - { - integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==, - } - engines: { node: ">= 8" } - - "@nodelib/fs.stat@2.0.5": - resolution: - { - integrity: sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==, - } - engines: { node: ">= 8" } - - "@nodelib/fs.walk@1.2.8": - resolution: - { - integrity: sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==, - } - engines: { node: ">= 8" } - - "@panva/hkdf@1.2.1": - resolution: - { - integrity: sha512-6oclG6Y3PiDFcoyk8srjLfVKyMfVCKJ27JwNPViuXziFpmdz+MZnZN/aKY0JGXgYuO/VghU0jcOAZgWXZ1Dmrw==, - } - - "@pkgjs/parseargs@0.11.0": - resolution: - { - integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==, - } - engines: { node: ">=14" } - - "@polka/url@1.0.0-next.29": - resolution: - { - integrity: sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==, - } - - "@protobufjs/aspromise@1.1.2": - resolution: - { - integrity: sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ==, - } - - "@protobufjs/base64@1.1.2": - resolution: - { - integrity: sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg==, - } - - "@protobufjs/codegen@2.0.4": - resolution: - { - integrity: sha512-YyFaikqM5sH0ziFZCN3xDC7zeGaB/d0IUb9CATugHWbd1FRFwWwt4ld4OYMPWu5a3Xe01mGAULCdqhMlPl29Jg==, - } - - "@protobufjs/eventemitter@1.1.0": - resolution: - { - integrity: sha512-j9ednRT81vYJ9OfVuXG6ERSTdEL1xVsNgqpkxMsbIabzSo3goCjDIveeGv5d03om39ML71RdmrGNjG5SReBP/Q==, - } - - "@protobufjs/fetch@1.1.0": - resolution: - { - integrity: sha512-lljVXpqXebpsijW71PZaCYeIcE5on1w5DlQy5WH6GLbFryLUrBD4932W/E2BSpfRJWseIL4v/KPgBFxDOIdKpQ==, - } - - "@protobufjs/float@1.0.2": - resolution: - { - integrity: sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ==, - } - - "@protobufjs/inquire@1.1.0": - resolution: - { - integrity: sha512-kdSefcPdruJiFMVSbn801t4vFK7KB/5gd2fYvrxhuJYg8ILrmn9SKSX2tZdV6V+ksulWqS7aXjBcRXl3wHoD9Q==, - } - - "@protobufjs/path@1.1.2": - resolution: - { - integrity: sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA==, - } - - "@protobufjs/pool@1.1.0": - resolution: - { - integrity: sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw==, - } - - "@protobufjs/utf8@1.1.0": - resolution: - { - integrity: sha512-Vvn3zZrhQZkkBE8LSuW3em98c0FwgO4nxzv6OdSxPKJIEKY2bGbHn+mhGIPerzI4twdxaP8/0+06HBpwf345Lw==, - } - - "@rollup/plugin-commonjs@28.0.6": - resolution: - { - integrity: sha512-XSQB1K7FUU5QP+3lOQmVCE3I0FcbbNvmNT4VJSj93iUjayaARrTQeoRdiYQoftAJBLrR9t2agwAd3ekaTgHNlw==, - } - engines: { node: ">=16.0.0 || 14 >= 14.17" } + '@humanfs/core@0.19.1': + resolution: {integrity: sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==} + engines: {node: '>=18.18.0'} + + '@humanfs/node@0.16.7': + resolution: {integrity: sha512-/zUx+yOsIrG4Y43Eh2peDeKCxlRt/gET6aHfaKpuq267qXdYDFViVHfMaLyygZOnl0kGWxFIgsBy8QFuTLUXEQ==} + engines: {node: '>=18.18.0'} + + '@humanwhocodes/module-importer@1.0.1': + resolution: {integrity: sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==} + engines: {node: '>=12.22'} + + '@humanwhocodes/retry@0.4.3': + resolution: {integrity: sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==} + engines: {node: '>=18.18'} + + '@isaacs/cliui@8.0.2': + resolution: {integrity: sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==} + engines: {node: '>=12'} + + '@isaacs/fs-minipass@4.0.1': + resolution: {integrity: sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w==} + engines: {node: '>=18.0.0'} + + '@istanbuljs/schema@0.1.3': + resolution: {integrity: sha512-ZXRY4jNvVgSVQ8DL3LTcakaAtXwTVUxE81hslsyD2AtoXW/wVob10HkOJ1X/pAlcI7D+2YoZKg5do8G/w6RYgA==} + engines: {node: '>=8'} + + '@jridgewell/gen-mapping@0.3.13': + resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==} + + '@jridgewell/remapping@2.3.5': + resolution: {integrity: sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==} + + '@jridgewell/resolve-uri@3.1.2': + resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==} + engines: {node: '>=6.0.0'} + + '@jridgewell/sourcemap-codec@1.5.5': + resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} + + '@jridgewell/trace-mapping@0.3.31': + resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} + + '@js-sdsl/ordered-map@4.4.2': + resolution: {integrity: sha512-iUKgm52T8HOE/makSxjqoWhe95ZJA1/G1sYsGev2JDKUSS14KAgg1LHb+Ba+IPow0xflbnSkOsZcO08C7w1gYw==} + + '@nodelib/fs.scandir@2.1.5': + resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==} + engines: {node: '>= 8'} + + '@nodelib/fs.stat@2.0.5': + resolution: {integrity: sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==} + engines: {node: '>= 8'} + + '@nodelib/fs.walk@1.2.8': + resolution: {integrity: sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==} + engines: {node: '>= 8'} + + '@openreplay/network-proxy@1.2.5': + resolution: {integrity: sha512-POexzfkWLH+kCk0+R05sg5+fyZYD2xgs7hkUWJxXcsfJ9B4Bn2unDJO03AyzfkH884cCLMuZk4jBDqZgCA5S1w==} + + '@openreplay/tracker@18.1.2': + resolution: {integrity: sha512-e6Q0OYKdsOc2fArnpGehbJK7d0zHCp7O8DwNx6+izNzqwxpGzpuZoqBCmHF/Si5DawwvagBHvXPYjEVbRwNmdA==} + engines: {node: '>=14.0'} + + '@panva/hkdf@1.2.1': + resolution: {integrity: sha512-6oclG6Y3PiDFcoyk8srjLfVKyMfVCKJ27JwNPViuXziFpmdz+MZnZN/aKY0JGXgYuO/VghU0jcOAZgWXZ1Dmrw==} + + '@pkgjs/parseargs@0.11.0': + resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==} + engines: {node: '>=14'} + + '@polka/url@1.0.0-next.29': + resolution: {integrity: sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==} + + '@protobufjs/aspromise@1.1.2': + resolution: {integrity: sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ==} + + '@protobufjs/base64@1.1.2': + resolution: {integrity: sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg==} + + '@protobufjs/codegen@2.0.4': + resolution: {integrity: sha512-YyFaikqM5sH0ziFZCN3xDC7zeGaB/d0IUb9CATugHWbd1FRFwWwt4ld4OYMPWu5a3Xe01mGAULCdqhMlPl29Jg==} + + '@protobufjs/eventemitter@1.1.0': + resolution: {integrity: sha512-j9ednRT81vYJ9OfVuXG6ERSTdEL1xVsNgqpkxMsbIabzSo3goCjDIveeGv5d03om39ML71RdmrGNjG5SReBP/Q==} + + '@protobufjs/fetch@1.1.0': + resolution: {integrity: sha512-lljVXpqXebpsijW71PZaCYeIcE5on1w5DlQy5WH6GLbFryLUrBD4932W/E2BSpfRJWseIL4v/KPgBFxDOIdKpQ==} + + '@protobufjs/float@1.0.2': + resolution: {integrity: sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ==} + + '@protobufjs/inquire@1.1.0': + resolution: {integrity: sha512-kdSefcPdruJiFMVSbn801t4vFK7KB/5gd2fYvrxhuJYg8ILrmn9SKSX2tZdV6V+ksulWqS7aXjBcRXl3wHoD9Q==} + + '@protobufjs/path@1.1.2': + resolution: {integrity: sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA==} + + '@protobufjs/pool@1.1.0': + resolution: {integrity: sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw==} + + '@protobufjs/utf8@1.1.0': + resolution: {integrity: sha512-Vvn3zZrhQZkkBE8LSuW3em98c0FwgO4nxzv6OdSxPKJIEKY2bGbHn+mhGIPerzI4twdxaP8/0+06HBpwf345Lw==} + + '@rollup/plugin-commonjs@28.0.6': + resolution: {integrity: sha512-XSQB1K7FUU5QP+3lOQmVCE3I0FcbbNvmNT4VJSj93iUjayaARrTQeoRdiYQoftAJBLrR9t2agwAd3ekaTgHNlw==} + engines: {node: '>=16.0.0 || 14 >= 14.17'} peerDependencies: rollup: ^2.68.0||^3.0.0||^4.0.0 peerDependenciesMeta: rollup: optional: true - "@rollup/plugin-json@6.1.0": - resolution: - { - integrity: sha512-EGI2te5ENk1coGeADSIwZ7G2Q8CJS2sF120T7jLw4xFw9n7wIOXHo+kIYRAoVpJAN+kmqZSoO3Fp4JtoNF4ReA==, - } - engines: { node: ">=14.0.0" } + '@rollup/plugin-json@6.1.0': + resolution: {integrity: sha512-EGI2te5ENk1coGeADSIwZ7G2Q8CJS2sF120T7jLw4xFw9n7wIOXHo+kIYRAoVpJAN+kmqZSoO3Fp4JtoNF4ReA==} + engines: {node: '>=14.0.0'} peerDependencies: rollup: ^1.20.0||^2.0.0||^3.0.0||^4.0.0 peerDependenciesMeta: rollup: optional: true - "@rollup/plugin-node-resolve@16.0.3": - resolution: - { - integrity: sha512-lUYM3UBGuM93CnMPG1YocWu7X802BrNF3jW2zny5gQyLQgRFJhV1Sq0Zi74+dh/6NBx1DxFC4b4GXg9wUCG5Qg==, - } - engines: { node: ">=14.0.0" } + '@rollup/plugin-node-resolve@16.0.3': + resolution: {integrity: sha512-lUYM3UBGuM93CnMPG1YocWu7X802BrNF3jW2zny5gQyLQgRFJhV1Sq0Zi74+dh/6NBx1DxFC4b4GXg9wUCG5Qg==} + engines: {node: '>=14.0.0'} peerDependencies: rollup: ^2.78.0||^3.0.0||^4.0.0 peerDependenciesMeta: rollup: optional: true - "@rollup/pluginutils@5.3.0": - resolution: - { - integrity: sha512-5EdhGZtnu3V88ces7s53hhfK5KSASnJZv8Lulpc04cWO3REESroJXg73DFsOmgbU2BhwV0E20bu2IDZb3VKW4Q==, - } - engines: { node: ">=14.0.0" } + '@rollup/pluginutils@5.3.0': + resolution: {integrity: sha512-5EdhGZtnu3V88ces7s53hhfK5KSASnJZv8Lulpc04cWO3REESroJXg73DFsOmgbU2BhwV0E20bu2IDZb3VKW4Q==} + engines: {node: '>=14.0.0'} peerDependencies: rollup: ^1.20.0||^2.0.0||^3.0.0||^4.0.0 peerDependenciesMeta: rollup: optional: true - "@rollup/rollup-android-arm-eabi@4.52.4": - resolution: - { - integrity: sha512-BTm2qKNnWIQ5auf4deoetINJm2JzvihvGb9R6K/ETwKLql/Bb3Eg2H1FBp1gUb4YGbydMA3jcmQTR73q7J+GAA==, - } + '@rollup/rollup-android-arm-eabi@4.52.4': + resolution: {integrity: sha512-BTm2qKNnWIQ5auf4deoetINJm2JzvihvGb9R6K/ETwKLql/Bb3Eg2H1FBp1gUb4YGbydMA3jcmQTR73q7J+GAA==} cpu: [arm] os: [android] - "@rollup/rollup-android-arm64@4.52.4": - resolution: - { - integrity: sha512-P9LDQiC5vpgGFgz7GSM6dKPCiqR3XYN1WwJKA4/BUVDjHpYsf3iBEmVz62uyq20NGYbiGPR5cNHI7T1HqxNs2w==, - } + '@rollup/rollup-android-arm64@4.52.4': + resolution: {integrity: sha512-P9LDQiC5vpgGFgz7GSM6dKPCiqR3XYN1WwJKA4/BUVDjHpYsf3iBEmVz62uyq20NGYbiGPR5cNHI7T1HqxNs2w==} cpu: [arm64] os: [android] - "@rollup/rollup-darwin-arm64@4.52.4": - resolution: - { - integrity: sha512-QRWSW+bVccAvZF6cbNZBJwAehmvG9NwfWHwMy4GbWi/BQIA/laTIktebT2ipVjNncqE6GLPxOok5hsECgAxGZg==, - } + '@rollup/rollup-darwin-arm64@4.52.4': + resolution: {integrity: sha512-QRWSW+bVccAvZF6cbNZBJwAehmvG9NwfWHwMy4GbWi/BQIA/laTIktebT2ipVjNncqE6GLPxOok5hsECgAxGZg==} cpu: [arm64] os: [darwin] - "@rollup/rollup-darwin-x64@4.52.4": - resolution: - { - integrity: sha512-hZgP05pResAkRJxL1b+7yxCnXPGsXU0fG9Yfd6dUaoGk+FhdPKCJ5L1Sumyxn8kvw8Qi5PvQ8ulenUbRjzeCTw==, - } + '@rollup/rollup-darwin-x64@4.52.4': + resolution: {integrity: sha512-hZgP05pResAkRJxL1b+7yxCnXPGsXU0fG9Yfd6dUaoGk+FhdPKCJ5L1Sumyxn8kvw8Qi5PvQ8ulenUbRjzeCTw==} cpu: [x64] os: [darwin] - "@rollup/rollup-freebsd-arm64@4.52.4": - resolution: - { - integrity: sha512-xmc30VshuBNUd58Xk4TKAEcRZHaXlV+tCxIXELiE9sQuK3kG8ZFgSPi57UBJt8/ogfhAF5Oz4ZSUBN77weM+mQ==, - } + '@rollup/rollup-freebsd-arm64@4.52.4': + resolution: {integrity: sha512-xmc30VshuBNUd58Xk4TKAEcRZHaXlV+tCxIXELiE9sQuK3kG8ZFgSPi57UBJt8/ogfhAF5Oz4ZSUBN77weM+mQ==} cpu: [arm64] os: [freebsd] - "@rollup/rollup-freebsd-x64@4.52.4": - resolution: - { - integrity: sha512-WdSLpZFjOEqNZGmHflxyifolwAiZmDQzuOzIq9L27ButpCVpD7KzTRtEG1I0wMPFyiyUdOO+4t8GvrnBLQSwpw==, - } + '@rollup/rollup-freebsd-x64@4.52.4': + resolution: {integrity: sha512-WdSLpZFjOEqNZGmHflxyifolwAiZmDQzuOzIq9L27ButpCVpD7KzTRtEG1I0wMPFyiyUdOO+4t8GvrnBLQSwpw==} cpu: [x64] os: [freebsd] - "@rollup/rollup-linux-arm-gnueabihf@4.52.4": - resolution: - { - integrity: sha512-xRiOu9Of1FZ4SxVbB0iEDXc4ddIcjCv2aj03dmW8UrZIW7aIQ9jVJdLBIhxBI+MaTnGAKyvMwPwQnoOEvP7FgQ==, - } + '@rollup/rollup-linux-arm-gnueabihf@4.52.4': + resolution: {integrity: sha512-xRiOu9Of1FZ4SxVbB0iEDXc4ddIcjCv2aj03dmW8UrZIW7aIQ9jVJdLBIhxBI+MaTnGAKyvMwPwQnoOEvP7FgQ==} cpu: [arm] os: [linux] - "@rollup/rollup-linux-arm-musleabihf@4.52.4": - resolution: - { - integrity: sha512-FbhM2p9TJAmEIEhIgzR4soUcsW49e9veAQCziwbR+XWB2zqJ12b4i/+hel9yLiD8pLncDH4fKIPIbt5238341Q==, - } + '@rollup/rollup-linux-arm-musleabihf@4.52.4': + resolution: {integrity: sha512-FbhM2p9TJAmEIEhIgzR4soUcsW49e9veAQCziwbR+XWB2zqJ12b4i/+hel9yLiD8pLncDH4fKIPIbt5238341Q==} cpu: [arm] os: [linux] - "@rollup/rollup-linux-arm64-gnu@4.52.4": - resolution: - { - integrity: sha512-4n4gVwhPHR9q/g8lKCyz0yuaD0MvDf7dV4f9tHt0C73Mp8h38UCtSCSE6R9iBlTbXlmA8CjpsZoujhszefqueg==, - } + '@rollup/rollup-linux-arm64-gnu@4.52.4': + resolution: {integrity: sha512-4n4gVwhPHR9q/g8lKCyz0yuaD0MvDf7dV4f9tHt0C73Mp8h38UCtSCSE6R9iBlTbXlmA8CjpsZoujhszefqueg==} cpu: [arm64] os: [linux] - "@rollup/rollup-linux-arm64-musl@4.52.4": - resolution: - { - integrity: sha512-u0n17nGA0nvi/11gcZKsjkLj1QIpAuPFQbR48Subo7SmZJnGxDpspyw2kbpuoQnyK+9pwf3pAoEXerJs/8Mi9g==, - } + '@rollup/rollup-linux-arm64-musl@4.52.4': + resolution: {integrity: sha512-u0n17nGA0nvi/11gcZKsjkLj1QIpAuPFQbR48Subo7SmZJnGxDpspyw2kbpuoQnyK+9pwf3pAoEXerJs/8Mi9g==} cpu: [arm64] os: [linux] - "@rollup/rollup-linux-loong64-gnu@4.52.4": - resolution: - { - integrity: sha512-0G2c2lpYtbTuXo8KEJkDkClE/+/2AFPdPAbmaHoE870foRFs4pBrDehilMcrSScrN/fB/1HTaWO4bqw+ewBzMQ==, - } + '@rollup/rollup-linux-loong64-gnu@4.52.4': + resolution: {integrity: sha512-0G2c2lpYtbTuXo8KEJkDkClE/+/2AFPdPAbmaHoE870foRFs4pBrDehilMcrSScrN/fB/1HTaWO4bqw+ewBzMQ==} cpu: [loong64] os: [linux] - "@rollup/rollup-linux-ppc64-gnu@4.52.4": - resolution: - { - integrity: sha512-teSACug1GyZHmPDv14VNbvZFX779UqWTsd7KtTM9JIZRDI5NUwYSIS30kzI8m06gOPB//jtpqlhmraQ68b5X2g==, - } + '@rollup/rollup-linux-ppc64-gnu@4.52.4': + resolution: {integrity: sha512-teSACug1GyZHmPDv14VNbvZFX779UqWTsd7KtTM9JIZRDI5NUwYSIS30kzI8m06gOPB//jtpqlhmraQ68b5X2g==} cpu: [ppc64] os: [linux] - "@rollup/rollup-linux-riscv64-gnu@4.52.4": - resolution: - { - integrity: sha512-/MOEW3aHjjs1p4Pw1Xk4+3egRevx8Ji9N6HUIA1Ifh8Q+cg9dremvFCUbOX2Zebz80BwJIgCBUemjqhU5XI5Eg==, - } + '@rollup/rollup-linux-riscv64-gnu@4.52.4': + resolution: {integrity: sha512-/MOEW3aHjjs1p4Pw1Xk4+3egRevx8Ji9N6HUIA1Ifh8Q+cg9dremvFCUbOX2Zebz80BwJIgCBUemjqhU5XI5Eg==} cpu: [riscv64] os: [linux] - "@rollup/rollup-linux-riscv64-musl@4.52.4": - resolution: - { - integrity: sha512-1HHmsRyh845QDpEWzOFtMCph5Ts+9+yllCrREuBR/vg2RogAQGGBRC8lDPrPOMnrdOJ+mt1WLMOC2Kao/UwcvA==, - } + '@rollup/rollup-linux-riscv64-musl@4.52.4': + resolution: {integrity: sha512-1HHmsRyh845QDpEWzOFtMCph5Ts+9+yllCrREuBR/vg2RogAQGGBRC8lDPrPOMnrdOJ+mt1WLMOC2Kao/UwcvA==} cpu: [riscv64] os: [linux] - "@rollup/rollup-linux-s390x-gnu@4.52.4": - resolution: - { - integrity: sha512-seoeZp4L/6D1MUyjWkOMRU6/iLmCU2EjbMTyAG4oIOs1/I82Y5lTeaxW0KBfkUdHAWN7j25bpkt0rjnOgAcQcA==, - } + '@rollup/rollup-linux-s390x-gnu@4.52.4': + resolution: {integrity: sha512-seoeZp4L/6D1MUyjWkOMRU6/iLmCU2EjbMTyAG4oIOs1/I82Y5lTeaxW0KBfkUdHAWN7j25bpkt0rjnOgAcQcA==} cpu: [s390x] os: [linux] - "@rollup/rollup-linux-x64-gnu@4.52.4": - resolution: - { - integrity: sha512-Wi6AXf0k0L7E2gteNsNHUs7UMwCIhsCTs6+tqQ5GPwVRWMaflqGec4Sd8n6+FNFDw9vGcReqk2KzBDhCa1DLYg==, - } + '@rollup/rollup-linux-x64-gnu@4.52.4': + resolution: {integrity: sha512-Wi6AXf0k0L7E2gteNsNHUs7UMwCIhsCTs6+tqQ5GPwVRWMaflqGec4Sd8n6+FNFDw9vGcReqk2KzBDhCa1DLYg==} cpu: [x64] os: [linux] - "@rollup/rollup-linux-x64-musl@4.52.4": - resolution: - { - integrity: sha512-dtBZYjDmCQ9hW+WgEkaffvRRCKm767wWhxsFW3Lw86VXz/uJRuD438/XvbZT//B96Vs8oTA8Q4A0AfHbrxP9zw==, - } + '@rollup/rollup-linux-x64-musl@4.52.4': + resolution: {integrity: sha512-dtBZYjDmCQ9hW+WgEkaffvRRCKm767wWhxsFW3Lw86VXz/uJRuD438/XvbZT//B96Vs8oTA8Q4A0AfHbrxP9zw==} cpu: [x64] os: [linux] - "@rollup/rollup-openharmony-arm64@4.52.4": - resolution: - { - integrity: sha512-1ox+GqgRWqaB1RnyZXL8PD6E5f7YyRUJYnCqKpNzxzP0TkaUh112NDrR9Tt+C8rJ4x5G9Mk8PQR3o7Ku2RKqKA==, - } + '@rollup/rollup-openharmony-arm64@4.52.4': + resolution: {integrity: sha512-1ox+GqgRWqaB1RnyZXL8PD6E5f7YyRUJYnCqKpNzxzP0TkaUh112NDrR9Tt+C8rJ4x5G9Mk8PQR3o7Ku2RKqKA==} cpu: [arm64] os: [openharmony] - "@rollup/rollup-win32-arm64-msvc@4.52.4": - resolution: - { - integrity: sha512-8GKr640PdFNXwzIE0IrkMWUNUomILLkfeHjXBi/nUvFlpZP+FA8BKGKpacjW6OUUHaNI6sUURxR2U2g78FOHWQ==, - } + '@rollup/rollup-win32-arm64-msvc@4.52.4': + resolution: {integrity: sha512-8GKr640PdFNXwzIE0IrkMWUNUomILLkfeHjXBi/nUvFlpZP+FA8BKGKpacjW6OUUHaNI6sUURxR2U2g78FOHWQ==} cpu: [arm64] os: [win32] - "@rollup/rollup-win32-ia32-msvc@4.52.4": - resolution: - { - integrity: sha512-AIy/jdJ7WtJ/F6EcfOb2GjR9UweO0n43jNObQMb6oGxkYTfLcnN7vYYpG+CN3lLxrQkzWnMOoNSHTW54pgbVxw==, - } + '@rollup/rollup-win32-ia32-msvc@4.52.4': + resolution: {integrity: sha512-AIy/jdJ7WtJ/F6EcfOb2GjR9UweO0n43jNObQMb6oGxkYTfLcnN7vYYpG+CN3lLxrQkzWnMOoNSHTW54pgbVxw==} cpu: [ia32] os: [win32] - "@rollup/rollup-win32-x64-gnu@4.52.4": - resolution: - { - integrity: sha512-UF9KfsH9yEam0UjTwAgdK0anlQ7c8/pWPU2yVjyWcF1I1thABt6WXE47cI71pGiZ8wGvxohBoLnxM04L/wj8mQ==, - } + '@rollup/rollup-win32-x64-gnu@4.52.4': + resolution: {integrity: sha512-UF9KfsH9yEam0UjTwAgdK0anlQ7c8/pWPU2yVjyWcF1I1thABt6WXE47cI71pGiZ8wGvxohBoLnxM04L/wj8mQ==} cpu: [x64] os: [win32] - "@rollup/rollup-win32-x64-msvc@4.52.4": - resolution: - { - integrity: sha512-bf9PtUa0u8IXDVxzRToFQKsNCRz9qLYfR/MpECxl4mRoWYjAeFjgxj1XdZr2M/GNVpT05p+LgQOHopYDlUu6/w==, - } + '@rollup/rollup-win32-x64-msvc@4.52.4': + resolution: {integrity: sha512-bf9PtUa0u8IXDVxzRToFQKsNCRz9qLYfR/MpECxl4mRoWYjAeFjgxj1XdZr2M/GNVpT05p+LgQOHopYDlUu6/w==} cpu: [x64] os: [win32] - "@skeletonlabs/skeleton-svelte@1.5.3": - resolution: - { - integrity: sha512-YFSJbaK6QPhrTyzlNy3fA3lSOg7hB7D/qkLAJDVlqwu5E2cz6WWS+/J3Tu9qOBO50PuSsgdOaFPc+QQ5+vQZHA==, - } - peerDependencies: - svelte: ^5.20.0 + '@standard-schema/spec@1.0.0': + resolution: {integrity: sha512-m2bOd0f2RT9k8QJx1JN85cZYyH1RqFBdlwtkSlf4tBDYLCiiZnv1fIIwacK6cqwXavOydf0NPToMQgpKq+dVlA==} - "@skeletonlabs/skeleton@3.2.2": - resolution: - { - integrity: sha512-dAunBAWqRMcNTGAvCKUgpADJdbtqL65eNEb7pDIKQZ6bI6qsxakR6MuF2E4B3jmUEpcaxaggDp0UdnUjlkAZ1Q==, - } - peerDependencies: - tailwindcss: ^4.0.0 - - "@standard-schema/spec@1.0.0": - resolution: - { - integrity: sha512-m2bOd0f2RT9k8QJx1JN85cZYyH1RqFBdlwtkSlf4tBDYLCiiZnv1fIIwacK6cqwXavOydf0NPToMQgpKq+dVlA==, - } - - "@sveltejs/acorn-typescript@1.0.6": - resolution: - { - integrity: sha512-4awhxtMh4cx9blePWl10HRHj8Iivtqj+2QdDCSMDzxG+XKa9+VCNupQuCuvzEhYPzZSrX+0gC+0lHA/0fFKKQQ==, - } + '@sveltejs/acorn-typescript@1.0.6': + resolution: {integrity: sha512-4awhxtMh4cx9blePWl10HRHj8Iivtqj+2QdDCSMDzxG+XKa9+VCNupQuCuvzEhYPzZSrX+0gC+0lHA/0fFKKQQ==} peerDependencies: acorn: ^8.9.0 - "@sveltejs/adapter-node@5.5.2": - resolution: - { - integrity: sha512-L15Djwpr7HrSAPj/Z8PYfc0pa9A1tllrr18phKI0WJHJeoWw45yinPf0IGgVTmakqx1B3JQ+C/OFl9ZwmxHU1Q==, - } + '@sveltejs/adapter-node@5.5.2': + resolution: {integrity: sha512-L15Djwpr7HrSAPj/Z8PYfc0pa9A1tllrr18phKI0WJHJeoWw45yinPf0IGgVTmakqx1B3JQ+C/OFl9ZwmxHU1Q==} peerDependencies: - "@sveltejs/kit": ^2.4.0 - - "@sveltejs/kit@2.50.1": - resolution: - { - integrity: sha512-XRHD2i3zC4ukhz2iCQzO4mbsts081PAZnnMAQ7LNpWeYgeBmwMsalf0FGSwhFXBbtr2XViPKnFJBDCckWqrsLw==, - } - engines: { node: ">=18.13" } + '@sveltejs/kit': ^2.4.0 + + '@sveltejs/kit@2.50.1': + resolution: {integrity: sha512-XRHD2i3zC4ukhz2iCQzO4mbsts081PAZnnMAQ7LNpWeYgeBmwMsalf0FGSwhFXBbtr2XViPKnFJBDCckWqrsLw==} + engines: {node: '>=18.13'} hasBin: true peerDependencies: - "@opentelemetry/api": ^1.0.0 - "@sveltejs/vite-plugin-svelte": ^3.0.0 || ^4.0.0-next.1 || ^5.0.0 || ^6.0.0-next.0 + '@opentelemetry/api': ^1.0.0 + '@sveltejs/vite-plugin-svelte': ^3.0.0 || ^4.0.0-next.1 || ^5.0.0 || ^6.0.0-next.0 svelte: ^4.0.0 || ^5.0.0-next.0 typescript: ^5.3.3 vite: ^5.0.3 || ^6.0.0 || ^7.0.0-beta.0 peerDependenciesMeta: - "@opentelemetry/api": + '@opentelemetry/api': optional: true typescript: optional: true - "@sveltejs/vite-plugin-svelte-inspector@4.0.1": - resolution: - { - integrity: sha512-J/Nmb2Q2y7mck2hyCX4ckVHcR5tu2J+MtBEQqpDrrgELZ2uvraQcK/ioCV61AqkdXFgriksOKIceDcQmqnGhVw==, - } - engines: { node: ^18.0.0 || ^20.0.0 || >=22 } + '@sveltejs/vite-plugin-svelte-inspector@4.0.1': + resolution: {integrity: sha512-J/Nmb2Q2y7mck2hyCX4ckVHcR5tu2J+MtBEQqpDrrgELZ2uvraQcK/ioCV61AqkdXFgriksOKIceDcQmqnGhVw==} + engines: {node: ^18.0.0 || ^20.0.0 || >=22} peerDependencies: - "@sveltejs/vite-plugin-svelte": ^5.0.0 + '@sveltejs/vite-plugin-svelte': ^5.0.0 svelte: ^5.0.0 vite: ^6.0.0 - "@sveltejs/vite-plugin-svelte@5.1.1": - resolution: - { - integrity: sha512-Y1Cs7hhTc+a5E9Va/xwKlAJoariQyHY+5zBgCZg4PFWNYQ1nMN9sjK1zhw1gK69DuqVP++sht/1GZg1aRwmAXQ==, - } - engines: { node: ^18.0.0 || ^20.0.0 || >=22 } + '@sveltejs/vite-plugin-svelte@5.1.1': + resolution: {integrity: sha512-Y1Cs7hhTc+a5E9Va/xwKlAJoariQyHY+5zBgCZg4PFWNYQ1nMN9sjK1zhw1gK69DuqVP++sht/1GZg1aRwmAXQ==} + engines: {node: ^18.0.0 || ^20.0.0 || >=22} peerDependencies: svelte: ^5.0.0 vite: ^6.0.0 - "@tailwindcss/node@4.1.14": - resolution: - { - integrity: sha512-hpz+8vFk3Ic2xssIA3e01R6jkmsAhvkQdXlEbRTk6S10xDAtiQiM3FyvZVGsucefq764euO/b8WUW9ysLdThHw==, - } - - "@tailwindcss/oxide-android-arm64@4.1.14": - resolution: - { - integrity: sha512-a94ifZrGwMvbdeAxWoSuGcIl6/DOP5cdxagid7xJv6bwFp3oebp7y2ImYsnZBMTwjn5Ev5xESvS3FFYUGgPODQ==, - } - engines: { node: ">= 10" } + '@tailwindcss/node@4.1.14': + resolution: {integrity: sha512-hpz+8vFk3Ic2xssIA3e01R6jkmsAhvkQdXlEbRTk6S10xDAtiQiM3FyvZVGsucefq764euO/b8WUW9ysLdThHw==} + + '@tailwindcss/oxide-android-arm64@4.1.14': + resolution: {integrity: sha512-a94ifZrGwMvbdeAxWoSuGcIl6/DOP5cdxagid7xJv6bwFp3oebp7y2ImYsnZBMTwjn5Ev5xESvS3FFYUGgPODQ==} + engines: {node: '>= 10'} cpu: [arm64] os: [android] - "@tailwindcss/oxide-darwin-arm64@4.1.14": - resolution: - { - integrity: sha512-HkFP/CqfSh09xCnrPJA7jud7hij5ahKyWomrC3oiO2U9i0UjP17o9pJbxUN0IJ471GTQQmzwhp0DEcpbp4MZTA==, - } - engines: { node: ">= 10" } + '@tailwindcss/oxide-darwin-arm64@4.1.14': + resolution: {integrity: sha512-HkFP/CqfSh09xCnrPJA7jud7hij5ahKyWomrC3oiO2U9i0UjP17o9pJbxUN0IJ471GTQQmzwhp0DEcpbp4MZTA==} + engines: {node: '>= 10'} cpu: [arm64] os: [darwin] - "@tailwindcss/oxide-darwin-x64@4.1.14": - resolution: - { - integrity: sha512-eVNaWmCgdLf5iv6Qd3s7JI5SEFBFRtfm6W0mphJYXgvnDEAZ5sZzqmI06bK6xo0IErDHdTA5/t7d4eTfWbWOFw==, - } - engines: { node: ">= 10" } + '@tailwindcss/oxide-darwin-x64@4.1.14': + resolution: {integrity: sha512-eVNaWmCgdLf5iv6Qd3s7JI5SEFBFRtfm6W0mphJYXgvnDEAZ5sZzqmI06bK6xo0IErDHdTA5/t7d4eTfWbWOFw==} + engines: {node: '>= 10'} cpu: [x64] os: [darwin] - "@tailwindcss/oxide-freebsd-x64@4.1.14": - resolution: - { - integrity: sha512-QWLoRXNikEuqtNb0dhQN6wsSVVjX6dmUFzuuiL09ZeXju25dsei2uIPl71y2Ic6QbNBsB4scwBoFnlBfabHkEw==, - } - engines: { node: ">= 10" } + '@tailwindcss/oxide-freebsd-x64@4.1.14': + resolution: {integrity: sha512-QWLoRXNikEuqtNb0dhQN6wsSVVjX6dmUFzuuiL09ZeXju25dsei2uIPl71y2Ic6QbNBsB4scwBoFnlBfabHkEw==} + engines: {node: '>= 10'} cpu: [x64] os: [freebsd] - "@tailwindcss/oxide-linux-arm-gnueabihf@4.1.14": - resolution: - { - integrity: sha512-VB4gjQni9+F0VCASU+L8zSIyjrLLsy03sjcR3bM0V2g4SNamo0FakZFKyUQ96ZVwGK4CaJsc9zd/obQy74o0Fw==, - } - engines: { node: ">= 10" } + '@tailwindcss/oxide-linux-arm-gnueabihf@4.1.14': + resolution: {integrity: sha512-VB4gjQni9+F0VCASU+L8zSIyjrLLsy03sjcR3bM0V2g4SNamo0FakZFKyUQ96ZVwGK4CaJsc9zd/obQy74o0Fw==} + engines: {node: '>= 10'} cpu: [arm] os: [linux] - "@tailwindcss/oxide-linux-arm64-gnu@4.1.14": - resolution: - { - integrity: sha512-qaEy0dIZ6d9vyLnmeg24yzA8XuEAD9WjpM5nIM1sUgQ/Zv7cVkharPDQcmm/t/TvXoKo/0knI3me3AGfdx6w1w==, - } - engines: { node: ">= 10" } + '@tailwindcss/oxide-linux-arm64-gnu@4.1.14': + resolution: {integrity: sha512-qaEy0dIZ6d9vyLnmeg24yzA8XuEAD9WjpM5nIM1sUgQ/Zv7cVkharPDQcmm/t/TvXoKo/0knI3me3AGfdx6w1w==} + engines: {node: '>= 10'} cpu: [arm64] os: [linux] - "@tailwindcss/oxide-linux-arm64-musl@4.1.14": - resolution: - { - integrity: sha512-ISZjT44s59O8xKsPEIesiIydMG/sCXoMBCqsphDm/WcbnuWLxxb+GcvSIIA5NjUw6F8Tex7s5/LM2yDy8RqYBQ==, - } - engines: { node: ">= 10" } + '@tailwindcss/oxide-linux-arm64-musl@4.1.14': + resolution: {integrity: sha512-ISZjT44s59O8xKsPEIesiIydMG/sCXoMBCqsphDm/WcbnuWLxxb+GcvSIIA5NjUw6F8Tex7s5/LM2yDy8RqYBQ==} + engines: {node: '>= 10'} cpu: [arm64] os: [linux] - "@tailwindcss/oxide-linux-x64-gnu@4.1.14": - resolution: - { - integrity: sha512-02c6JhLPJj10L2caH4U0zF8Hji4dOeahmuMl23stk0MU1wfd1OraE7rOloidSF8W5JTHkFdVo/O7uRUJJnUAJg==, - } - engines: { node: ">= 10" } + '@tailwindcss/oxide-linux-x64-gnu@4.1.14': + resolution: {integrity: sha512-02c6JhLPJj10L2caH4U0zF8Hji4dOeahmuMl23stk0MU1wfd1OraE7rOloidSF8W5JTHkFdVo/O7uRUJJnUAJg==} + engines: {node: '>= 10'} cpu: [x64] os: [linux] - "@tailwindcss/oxide-linux-x64-musl@4.1.14": - resolution: - { - integrity: sha512-TNGeLiN1XS66kQhxHG/7wMeQDOoL0S33x9BgmydbrWAb9Qw0KYdd8o1ifx4HOGDWhVmJ+Ul+JQ7lyknQFilO3Q==, - } - engines: { node: ">= 10" } + '@tailwindcss/oxide-linux-x64-musl@4.1.14': + resolution: {integrity: sha512-TNGeLiN1XS66kQhxHG/7wMeQDOoL0S33x9BgmydbrWAb9Qw0KYdd8o1ifx4HOGDWhVmJ+Ul+JQ7lyknQFilO3Q==} + engines: {node: '>= 10'} cpu: [x64] os: [linux] - "@tailwindcss/oxide-wasm32-wasi@4.1.14": - resolution: - { - integrity: sha512-uZYAsaW/jS/IYkd6EWPJKW/NlPNSkWkBlaeVBi/WsFQNP05/bzkebUL8FH1pdsqx4f2fH/bWFcUABOM9nfiJkQ==, - } - engines: { node: ">=14.0.0" } + '@tailwindcss/oxide-wasm32-wasi@4.1.14': + resolution: {integrity: sha512-uZYAsaW/jS/IYkd6EWPJKW/NlPNSkWkBlaeVBi/WsFQNP05/bzkebUL8FH1pdsqx4f2fH/bWFcUABOM9nfiJkQ==} + engines: {node: '>=14.0.0'} cpu: [wasm32] bundledDependencies: - - "@napi-rs/wasm-runtime" - - "@emnapi/core" - - "@emnapi/runtime" - - "@tybys/wasm-util" - - "@emnapi/wasi-threads" + - '@napi-rs/wasm-runtime' + - '@emnapi/core' + - '@emnapi/runtime' + - '@tybys/wasm-util' + - '@emnapi/wasi-threads' - tslib - "@tailwindcss/oxide-win32-arm64-msvc@4.1.14": - resolution: - { - integrity: sha512-Az0RnnkcvRqsuoLH2Z4n3JfAef0wElgzHD5Aky/e+0tBUxUhIeIqFBTMNQvmMRSP15fWwmvjBxZ3Q8RhsDnxAA==, - } - engines: { node: ">= 10" } + '@tailwindcss/oxide-win32-arm64-msvc@4.1.14': + resolution: {integrity: sha512-Az0RnnkcvRqsuoLH2Z4n3JfAef0wElgzHD5Aky/e+0tBUxUhIeIqFBTMNQvmMRSP15fWwmvjBxZ3Q8RhsDnxAA==} + engines: {node: '>= 10'} cpu: [arm64] os: [win32] - "@tailwindcss/oxide-win32-x64-msvc@4.1.14": - resolution: - { - integrity: sha512-ttblVGHgf68kEE4om1n/n44I0yGPkCPbLsqzjvybhpwa6mKKtgFfAzy6btc3HRmuW7nHe0OOrSeNP9sQmmH9XA==, - } - engines: { node: ">= 10" } + '@tailwindcss/oxide-win32-x64-msvc@4.1.14': + resolution: {integrity: sha512-ttblVGHgf68kEE4om1n/n44I0yGPkCPbLsqzjvybhpwa6mKKtgFfAzy6btc3HRmuW7nHe0OOrSeNP9sQmmH9XA==} + engines: {node: '>= 10'} cpu: [x64] os: [win32] - "@tailwindcss/oxide@4.1.14": - resolution: - { - integrity: sha512-23yx+VUbBwCg2x5XWdB8+1lkPajzLmALEfMb51zZUBYaYVPDQvBSD/WYDqiVyBIo2BZFa3yw1Rpy3G2Jp+K0dw==, - } - engines: { node: ">= 10" } - - "@tailwindcss/vite@4.1.14": - resolution: - { - integrity: sha512-BoFUoU0XqgCUS1UXWhmDJroKKhNXeDzD7/XwabjkDIAbMnc4ULn5e2FuEuBbhZ6ENZoSYzKlzvZ44Yr6EUDUSA==, - } + '@tailwindcss/oxide@4.1.14': + resolution: {integrity: sha512-23yx+VUbBwCg2x5XWdB8+1lkPajzLmALEfMb51zZUBYaYVPDQvBSD/WYDqiVyBIo2BZFa3yw1Rpy3G2Jp+K0dw==} + engines: {node: '>= 10'} + + '@tailwindcss/vite@4.1.14': + resolution: {integrity: sha512-BoFUoU0XqgCUS1UXWhmDJroKKhNXeDzD7/XwabjkDIAbMnc4ULn5e2FuEuBbhZ6ENZoSYzKlzvZ44Yr6EUDUSA==} peerDependencies: vite: ^5.2.0 || ^6 || ^7 - "@testing-library/dom@10.4.1": - resolution: - { - integrity: sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==, - } - engines: { node: ">=18" } - - "@testing-library/jest-dom@6.9.1": - resolution: - { - integrity: sha512-zIcONa+hVtVSSep9UT3jZ5rizo2BsxgyDYU7WFD5eICBE7no3881HGeb/QkGfsJs6JTkY1aQhT7rIPC7e+0nnA==, - } - engines: { node: ">=14", npm: ">=6", yarn: ">=1" } - - "@testing-library/svelte-core@1.0.0": - resolution: - { - integrity: sha512-VkUePoLV6oOYwSUvX6ShA8KLnJqZiYMIbP2JW2t0GLWLkJxKGvuH5qrrZBV/X7cXFnLGuFQEC7RheYiZOW68KQ==, - } - engines: { node: ">=16" } + '@testing-library/dom@10.4.1': + resolution: {integrity: sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==} + engines: {node: '>=18'} + + '@testing-library/jest-dom@6.9.1': + resolution: {integrity: sha512-zIcONa+hVtVSSep9UT3jZ5rizo2BsxgyDYU7WFD5eICBE7no3881HGeb/QkGfsJs6JTkY1aQhT7rIPC7e+0nnA==} + engines: {node: '>=14', npm: '>=6', yarn: '>=1'} + + '@testing-library/svelte-core@1.0.0': + resolution: {integrity: sha512-VkUePoLV6oOYwSUvX6ShA8KLnJqZiYMIbP2JW2t0GLWLkJxKGvuH5qrrZBV/X7cXFnLGuFQEC7RheYiZOW68KQ==} + engines: {node: '>=16'} peerDependencies: svelte: ^3 || ^4 || ^5 || ^5.0.0-next.0 - "@testing-library/svelte@5.3.1": - resolution: - { - integrity: sha512-8Ez7ZOqW5geRf9PF5rkuopODe5RGy3I9XR+kc7zHh26gBiktLaxTfKmhlGaSHYUOTQE7wFsLMN9xCJVCszw47w==, - } - engines: { node: ">= 10" } + '@testing-library/svelte@5.3.1': + resolution: {integrity: sha512-8Ez7ZOqW5geRf9PF5rkuopODe5RGy3I9XR+kc7zHh26gBiktLaxTfKmhlGaSHYUOTQE7wFsLMN9xCJVCszw47w==} + engines: {node: '>= 10'} peerDependencies: svelte: ^3 || ^4 || ^5 || ^5.0.0-next.0 - vite: "*" - vitest: "*" + vite: '*' + vitest: '*' peerDependenciesMeta: vite: optional: true vitest: optional: true - "@types/aria-query@5.0.4": - resolution: - { - integrity: sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==, - } - - "@types/chai@5.2.3": - resolution: - { - integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==, - } - - "@types/command-line-args@5.2.3": - resolution: - { - integrity: sha512-uv0aG6R0Y8WHZLTamZwtfsDLVRnOa+n+n5rEvFWL5Na5gZ8V2Teab/duDPFzIIIhs9qizDpcavCusCLJZu62Kw==, - } - - "@types/cookie@0.6.0": - resolution: - { - integrity: sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA==, - } - - "@types/deep-eql@4.0.2": - resolution: - { - integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==, - } - - "@types/estree@1.0.8": - resolution: - { - integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==, - } - - "@types/json-schema@7.0.15": - resolution: - { - integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==, - } - - "@types/node@22.18.10": - resolution: - { - integrity: sha512-anNG/V/Efn/YZY4pRzbACnKxNKoBng2VTFydVu8RRs5hQjikP8CQfaeAV59VFSCzKNp90mXiVXW2QzV56rwMrg==, - } - - "@types/resolve@1.20.2": - resolution: - { - integrity: sha512-60BCwRFOZCQhDncwQdxxeOEEkbc5dIMccYLwbxsS4TUNeVECQ/pBJ0j09mrHOl/JJvpRPGwO9SvE4nR2Nb/a4Q==, - } - - "@typescript-eslint/eslint-plugin@8.46.1": - resolution: - { - integrity: sha512-rUsLh8PXmBjdiPY+Emjz9NX2yHvhS11v0SR6xNJkm5GM1MO9ea/1GoDKlHHZGrOJclL/cZ2i/vRUYVtjRhrHVQ==, - } - engines: { node: ^18.18.0 || ^20.9.0 || >=21.1.0 } + '@types/aria-query@5.0.4': + resolution: {integrity: sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==} + + '@types/chai@5.2.3': + resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} + + '@types/command-line-args@5.2.3': + resolution: {integrity: sha512-uv0aG6R0Y8WHZLTamZwtfsDLVRnOa+n+n5rEvFWL5Na5gZ8V2Teab/duDPFzIIIhs9qizDpcavCusCLJZu62Kw==} + + '@types/cookie@0.6.0': + resolution: {integrity: sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA==} + + '@types/deep-eql@4.0.2': + resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==} + + '@types/estree@1.0.8': + resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==} + + '@types/json-schema@7.0.15': + resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} + + '@types/node@22.18.10': + resolution: {integrity: sha512-anNG/V/Efn/YZY4pRzbACnKxNKoBng2VTFydVu8RRs5hQjikP8CQfaeAV59VFSCzKNp90mXiVXW2QzV56rwMrg==} + + '@types/resolve@1.20.2': + resolution: {integrity: sha512-60BCwRFOZCQhDncwQdxxeOEEkbc5dIMccYLwbxsS4TUNeVECQ/pBJ0j09mrHOl/JJvpRPGwO9SvE4nR2Nb/a4Q==} + + '@types/trusted-types@2.0.7': + resolution: {integrity: sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==} + + '@typescript-eslint/eslint-plugin@8.46.1': + resolution: {integrity: sha512-rUsLh8PXmBjdiPY+Emjz9NX2yHvhS11v0SR6xNJkm5GM1MO9ea/1GoDKlHHZGrOJclL/cZ2i/vRUYVtjRhrHVQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: - "@typescript-eslint/parser": ^8.46.1 + '@typescript-eslint/parser': ^8.46.1 eslint: ^8.57.0 || ^9.0.0 - typescript: ">=4.8.4 <6.0.0" - - "@typescript-eslint/parser@8.46.1": - resolution: - { - integrity: sha512-6JSSaBZmsKvEkbRUkf7Zj7dru/8ZCrJxAqArcLaVMee5907JdtEbKGsZ7zNiIm/UAkpGUkaSMZEXShnN2D1HZA==, - } - engines: { node: ^18.18.0 || ^20.9.0 || >=21.1.0 } + typescript: '>=4.8.4 <6.0.0' + + '@typescript-eslint/parser@8.46.1': + resolution: {integrity: sha512-6JSSaBZmsKvEkbRUkf7Zj7dru/8ZCrJxAqArcLaVMee5907JdtEbKGsZ7zNiIm/UAkpGUkaSMZEXShnN2D1HZA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: eslint: ^8.57.0 || ^9.0.0 - typescript: ">=4.8.4 <6.0.0" - - "@typescript-eslint/project-service@8.46.1": - resolution: - { - integrity: sha512-FOIaFVMHzRskXr5J4Jp8lFVV0gz5ngv3RHmn+E4HYxSJ3DgDzU7fVI1/M7Ijh1zf6S7HIoaIOtln1H5y8V+9Zg==, - } - engines: { node: ^18.18.0 || ^20.9.0 || >=21.1.0 } + typescript: '>=4.8.4 <6.0.0' + + '@typescript-eslint/project-service@8.46.1': + resolution: {integrity: sha512-FOIaFVMHzRskXr5J4Jp8lFVV0gz5ngv3RHmn+E4HYxSJ3DgDzU7fVI1/M7Ijh1zf6S7HIoaIOtln1H5y8V+9Zg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: - typescript: ">=4.8.4 <6.0.0" - - "@typescript-eslint/scope-manager@8.46.1": - resolution: - { - integrity: sha512-weL9Gg3/5F0pVQKiF8eOXFZp8emqWzZsOJuWRUNtHT+UNV2xSJegmpCNQHy37aEQIbToTq7RHKhWvOsmbM680A==, - } - engines: { node: ^18.18.0 || ^20.9.0 || >=21.1.0 } - - "@typescript-eslint/tsconfig-utils@8.46.1": - resolution: - { - integrity: sha512-X88+J/CwFvlJB+mK09VFqx5FE4H5cXD+H/Bdza2aEWkSb8hnWIQorNcscRl4IEo1Cz9VI/+/r/jnGWkbWPx54g==, - } - engines: { node: ^18.18.0 || ^20.9.0 || >=21.1.0 } + typescript: '>=4.8.4 <6.0.0' + + '@typescript-eslint/scope-manager@8.46.1': + resolution: {integrity: sha512-weL9Gg3/5F0pVQKiF8eOXFZp8emqWzZsOJuWRUNtHT+UNV2xSJegmpCNQHy37aEQIbToTq7RHKhWvOsmbM680A==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@typescript-eslint/tsconfig-utils@8.46.1': + resolution: {integrity: sha512-X88+J/CwFvlJB+mK09VFqx5FE4H5cXD+H/Bdza2aEWkSb8hnWIQorNcscRl4IEo1Cz9VI/+/r/jnGWkbWPx54g==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: - typescript: ">=4.8.4 <6.0.0" - - "@typescript-eslint/type-utils@8.46.1": - resolution: - { - integrity: sha512-+BlmiHIiqufBxkVnOtFwjah/vrkF4MtKKvpXrKSPLCkCtAp8H01/VV43sfqA98Od7nJpDcFnkwgyfQbOG0AMvw==, - } - engines: { node: ^18.18.0 || ^20.9.0 || >=21.1.0 } + typescript: '>=4.8.4 <6.0.0' + + '@typescript-eslint/type-utils@8.46.1': + resolution: {integrity: sha512-+BlmiHIiqufBxkVnOtFwjah/vrkF4MtKKvpXrKSPLCkCtAp8H01/VV43sfqA98Od7nJpDcFnkwgyfQbOG0AMvw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: eslint: ^8.57.0 || ^9.0.0 - typescript: ">=4.8.4 <6.0.0" - - "@typescript-eslint/types@8.46.1": - resolution: - { - integrity: sha512-C+soprGBHwWBdkDpbaRC4paGBrkIXxVlNohadL5o0kfhsXqOC6GYH2S/Obmig+I0HTDl8wMaRySwrfrXVP8/pQ==, - } - engines: { node: ^18.18.0 || ^20.9.0 || >=21.1.0 } - - "@typescript-eslint/typescript-estree@8.46.1": - resolution: - { - integrity: sha512-uIifjT4s8cQKFQ8ZBXXyoUODtRoAd7F7+G8MKmtzj17+1UbdzFl52AzRyZRyKqPHhgzvXunnSckVu36flGy8cg==, - } - engines: { node: ^18.18.0 || ^20.9.0 || >=21.1.0 } + typescript: '>=4.8.4 <6.0.0' + + '@typescript-eslint/types@8.46.1': + resolution: {integrity: sha512-C+soprGBHwWBdkDpbaRC4paGBrkIXxVlNohadL5o0kfhsXqOC6GYH2S/Obmig+I0HTDl8wMaRySwrfrXVP8/pQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@typescript-eslint/typescript-estree@8.46.1': + resolution: {integrity: sha512-uIifjT4s8cQKFQ8ZBXXyoUODtRoAd7F7+G8MKmtzj17+1UbdzFl52AzRyZRyKqPHhgzvXunnSckVu36flGy8cg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: - typescript: ">=4.8.4 <6.0.0" - - "@typescript-eslint/utils@8.46.1": - resolution: - { - integrity: sha512-vkYUy6LdZS7q1v/Gxb2Zs7zziuXN0wxqsetJdeZdRe/f5dwJFglmuvZBfTUivCtjH725C1jWCDfpadadD95EDQ==, - } - engines: { node: ^18.18.0 || ^20.9.0 || >=21.1.0 } + typescript: '>=4.8.4 <6.0.0' + + '@typescript-eslint/utils@8.46.1': + resolution: {integrity: sha512-vkYUy6LdZS7q1v/Gxb2Zs7zziuXN0wxqsetJdeZdRe/f5dwJFglmuvZBfTUivCtjH725C1jWCDfpadadD95EDQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: eslint: ^8.57.0 || ^9.0.0 - typescript: ">=4.8.4 <6.0.0" - - "@typescript-eslint/visitor-keys@8.46.1": - resolution: - { - integrity: sha512-ptkmIf2iDkNUjdeu2bQqhFPV1m6qTnFFjg7PPDjxKWaMaP0Z6I9l30Jr3g5QqbZGdw8YdYvLp+XnqnWWZOg/NA==, - } - engines: { node: ^18.18.0 || ^20.9.0 || >=21.1.0 } - - "@vitest/coverage-v8@3.2.4": - resolution: - { - integrity: sha512-EyF9SXU6kS5Ku/U82E259WSnvg6c8KTjppUncuNdm5QHpe17mwREHnjDzozC8x9MZ0xfBUFSaLkRv4TMA75ALQ==, - } + typescript: '>=4.8.4 <6.0.0' + + '@typescript-eslint/visitor-keys@8.46.1': + resolution: {integrity: sha512-ptkmIf2iDkNUjdeu2bQqhFPV1m6qTnFFjg7PPDjxKWaMaP0Z6I9l30Jr3g5QqbZGdw8YdYvLp+XnqnWWZOg/NA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@vitest/coverage-v8@3.2.4': + resolution: {integrity: sha512-EyF9SXU6kS5Ku/U82E259WSnvg6c8KTjppUncuNdm5QHpe17mwREHnjDzozC8x9MZ0xfBUFSaLkRv4TMA75ALQ==} peerDependencies: - "@vitest/browser": 3.2.4 + '@vitest/browser': 3.2.4 vitest: 3.2.4 peerDependenciesMeta: - "@vitest/browser": + '@vitest/browser': optional: true - "@vitest/expect@3.2.4": - resolution: - { - integrity: sha512-Io0yyORnB6sikFlt8QW5K7slY4OjqNX9jmJQ02QDda8lyM6B5oNgVWoSoKPac8/kgnCUzuHQKrSLtu/uOqqrig==, - } - - "@vitest/mocker@3.2.4": - resolution: - { - integrity: sha512-46ryTE9RZO/rfDd7pEqFl7etuyzekzEhUbTW3BvmeO/BcCMEgq59BKhek3dXDWgAj4oMK6OZi+vRr1wPW6qjEQ==, - } + '@vitest/expect@3.2.4': + resolution: {integrity: sha512-Io0yyORnB6sikFlt8QW5K7slY4OjqNX9jmJQ02QDda8lyM6B5oNgVWoSoKPac8/kgnCUzuHQKrSLtu/uOqqrig==} + + '@vitest/mocker@3.2.4': + resolution: {integrity: sha512-46ryTE9RZO/rfDd7pEqFl7etuyzekzEhUbTW3BvmeO/BcCMEgq59BKhek3dXDWgAj4oMK6OZi+vRr1wPW6qjEQ==} peerDependencies: msw: ^2.4.9 vite: ^5.0.0 || ^6.0.0 || ^7.0.0-0 @@ -1488,750 +1049,322 @@ packages: vite: optional: true - "@vitest/pretty-format@3.2.4": - resolution: - { - integrity: sha512-IVNZik8IVRJRTr9fxlitMKeJeXFFFN0JaB9PHPGQ8NKQbGpfjlTx9zO4RefN8gp7eqjNy8nyK3NZmBzOPeIxtA==, - } - - "@vitest/runner@3.2.4": - resolution: - { - integrity: sha512-oukfKT9Mk41LreEW09vt45f8wx7DordoWUZMYdY/cyAk7w5TWkTRCNZYF7sX7n2wB7jyGAl74OxgwhPgKaqDMQ==, - } - - "@vitest/snapshot@3.2.4": - resolution: - { - integrity: sha512-dEYtS7qQP2CjU27QBC5oUOxLE/v5eLkGqPE0ZKEIDGMs4vKWe7IjgLOeauHsR0D5YuuycGRO5oSRXnwnmA78fQ==, - } - - "@vitest/spy@3.2.4": - resolution: - { - integrity: sha512-vAfasCOe6AIK70iP5UD11Ac4siNUNJ9i/9PZ3NKx07sG6sUxeag1LWdNrMWeKKYBLlzuK+Gn65Yd5nyL6ds+nw==, - } - - "@vitest/ui@3.2.4": - resolution: - { - integrity: sha512-hGISOaP18plkzbWEcP/QvtRW1xDXF2+96HbEX6byqQhAUbiS5oH6/9JwW+QsQCIYON2bI6QZBF+2PvOmrRZ9wA==, - } + '@vitest/pretty-format@3.2.4': + resolution: {integrity: sha512-IVNZik8IVRJRTr9fxlitMKeJeXFFFN0JaB9PHPGQ8NKQbGpfjlTx9zO4RefN8gp7eqjNy8nyK3NZmBzOPeIxtA==} + + '@vitest/runner@3.2.4': + resolution: {integrity: sha512-oukfKT9Mk41LreEW09vt45f8wx7DordoWUZMYdY/cyAk7w5TWkTRCNZYF7sX7n2wB7jyGAl74OxgwhPgKaqDMQ==} + + '@vitest/snapshot@3.2.4': + resolution: {integrity: sha512-dEYtS7qQP2CjU27QBC5oUOxLE/v5eLkGqPE0ZKEIDGMs4vKWe7IjgLOeauHsR0D5YuuycGRO5oSRXnwnmA78fQ==} + + '@vitest/spy@3.2.4': + resolution: {integrity: sha512-vAfasCOe6AIK70iP5UD11Ac4siNUNJ9i/9PZ3NKx07sG6sUxeag1LWdNrMWeKKYBLlzuK+Gn65Yd5nyL6ds+nw==} + + '@vitest/ui@3.2.4': + resolution: {integrity: sha512-hGISOaP18plkzbWEcP/QvtRW1xDXF2+96HbEX6byqQhAUbiS5oH6/9JwW+QsQCIYON2bI6QZBF+2PvOmrRZ9wA==} peerDependencies: vitest: 3.2.4 - "@vitest/utils@3.2.4": - resolution: - { - integrity: sha512-fB2V0JFrQSMsCo9HiSq3Ezpdv4iYaXRG1Sx8edX3MwxfyNn83mKiGzOcH+Fkxt4MHxr3y42fQi1oeAInqgX2QA==, - } - - "@zag-js/accordion@1.18.3": - resolution: - { - integrity: sha512-h+Qw9uLZXlSL3vx+pe6sCHLK4pZAzKdj+2CuH3lIAp8GdOcO6MUfcfo905jl0vM0mUyWpELxRypzplcFioIVkw==, - } - - "@zag-js/anatomy@1.18.3": - resolution: - { - integrity: sha512-D1Qaxq1NS+Wud9KEdnO1bQE1Yb1pLxi78iqj007pr+gmFfo2Br3QLJNcMm2x/IWLBCdETwgDhq6nvHTrCjmiwg==, - } - - "@zag-js/aria-hidden@1.18.3": - resolution: - { - integrity: sha512-CQ4BkawuNfL8yezXjT5zsdFNGKCudz+p13TVW2eP8hHGuMQilK32h4fNd2536U9SRQNi0BjF/e9Qgfl8G2ipDg==, - } - - "@zag-js/auto-resize@1.18.3": - resolution: - { - integrity: sha512-r+eP3R51fFPTd4TYJnjDf62o9Rr4EltuhWEEx+jDahP0hFfK74SDvb0HYMu1j9WQIb2O84JlBma+PNsZSsJasQ==, - } - - "@zag-js/avatar@1.18.3": - resolution: - { - integrity: sha512-2yaWSMDG73/2J0NxDtdaAKoto/jg/W/mJ7QGR+1Ay7bNcHnnCaYJcVKPdx/v4k46Swhtt/GKkIqavnRXT6brAw==, - } - - "@zag-js/collection@1.18.3": - resolution: - { - integrity: sha512-0IS4nKgFP6s0XwIBdhNrEtPghlIa+cxl4emkppQS0Q/bGEytA+0tE73ZcIY2i/PN15DRdlLmOWX8g0IWFm8R3g==, - } - - "@zag-js/combobox@1.18.3": - resolution: - { - integrity: sha512-RnJUb67Dv/erKjNv1x+wZvEiHoToBQv8xNh5WOhsLD5TNZEHF7zYsYnKbywR+RlAUysDb8HWfV4OfeFKUWxV3A==, - } - - "@zag-js/core@1.18.3": - resolution: - { - integrity: sha512-FuB4ClNyob6Fqx57mEWbPui59uU1x9I6MvTyJunnPjJMWr1M0bxsgrqkePoEzt+osel8qLMyaa1oHaxszSNxKQ==, - } - - "@zag-js/dialog@1.18.3": - resolution: - { - integrity: sha512-gLWYKYpUyp3IyLr0BX/c6izvX59rCugwv4ClGpojL+chv6KlmgPX6qKj6XVoyWlYsnxvIL3mF2SX+lgUz+SOrA==, - } - - "@zag-js/dismissable@1.18.3": - resolution: - { - integrity: sha512-6q8OlX/W+TvP73r7tDcsLbTZEipczO4TNZnDHGFra+tP8CPslZZ39SZYomhrtRWqOKWu5R3UX+Vgl4gO1wtykA==, - } - - "@zag-js/dom-query@1.18.3": - resolution: - { - integrity: sha512-mPj2xvjxXyB++aGoIIZZ0cCbMu+nfLvks/Q2fe6SgfSaTdGw8jvJtp4F5Qs3Q+MOHbIZRnAqYyBLv56qav3AeA==, - } - - "@zag-js/file-upload@1.18.3": - resolution: - { - integrity: sha512-Noq/DaNwuoDK7klyqy86IJOmxQIKaUY15PBiU7u2sU+VMqKWcWLm1hSlRLpnhkJrhRGJRXxKd8rGuCO/i8t1Xg==, - } - - "@zag-js/file-utils@1.18.3": - resolution: - { - integrity: sha512-JoQJsP3OWJTP/mGzKD/N7RKXdnigaT4ExKQPQaHF+jT/uQtHs+8J088Td/WVkfLTHIyW/s3t09pLk7z6ufZJkA==, - } - - "@zag-js/focus-trap@1.18.3": - resolution: - { - integrity: sha512-EhAJb7xIHaUYP+WxlmN2SKEvsqTWih0FUX4Jf+rh2xr4v/dd/09ki+/yQjtTxVrKshCGe4LxCGeiws7mTkOZrA==, - } - - "@zag-js/focus-visible@1.18.3": - resolution: - { - integrity: sha512-od0TDV0oCwldqyIOLyfLcLlQlsAnlsO03Je2TrL1/48vxbnPaYQRQK8HUjIFnPcr/rPDKojoRjmNi4OryD2/4w==, - } - - "@zag-js/i18n-utils@1.18.3": - resolution: - { - integrity: sha512-7ihl4sJEyTL4LHwLgmRcSn9nGBEbbRkN6W552dFjeV5rAIgRGvrdKvEHGdkSQkrDHNlVU2zEHlN50vEdfsG4Vw==, - } - - "@zag-js/interact-outside@1.18.3": - resolution: - { - integrity: sha512-DDcFBOZRjJ2a4qxQ2QU/37mIRCJivnVV87bKy8i/Zu+ea4URerBAsLp23/UC1aqEnDK+QXWRMQsK02SySR/RiA==, - } - - "@zag-js/live-region@1.18.3": - resolution: - { - integrity: sha512-n3kKr4a+RWwBdkaZc+EZXBMb7joHg1lyxK95oP0/9l+Aeltut5gpjA+VQP49pLagakUnMzt1KbkHekO8FKeX3A==, - } - - "@zag-js/pagination@1.18.3": - resolution: - { - integrity: sha512-n6+BVIR1MtBLu0w2CftbNpmWEL7F1RO/MgltQTI0MVUNUEqWEErn44m6oTckIGpF43B42IVbr5MIZrQyBmjhUg==, - } - - "@zag-js/popover@1.18.3": - resolution: - { - integrity: sha512-60kMLotCgPBKvMmPkQTJpSRWQpIPOvxD3ZhbD2q9ZgvxH0tyLX9YpjDrCFoMCm90gsBeRbXObLcCnMfRecn4EA==, - } - - "@zag-js/popper@1.18.3": - resolution: - { - integrity: sha512-g8qH1fzT3xPYsLfj/07fiNPintf3xr/VkAZ7btW8uO9fjJGe++1Dmk1qze1gFYReMGrSGg/6eB53QN5QxNYLtw==, - } - - "@zag-js/progress@1.18.3": - resolution: - { - integrity: sha512-M37PpfL9ihiVUpeHMXbmm/88WO8RMPVXi5Dd4CJcc0pw5sh53b5SgxFbjm5ICrEqPRoxOIZ7Rg6yCgNMHMvaQg==, - } - - "@zag-js/radio-group@1.18.3": - resolution: - { - integrity: sha512-LwsO1tgSYjQksWN3l9wLA8qisP+tLl/bex6hAGhaH1SFAbbr51xS+f1Sfyxiqv6Fk9P78ONW1Rb3eoIkTeZmUw==, - } - - "@zag-js/rating-group@1.18.3": - resolution: - { - integrity: sha512-+2tqw7XwXf3Gv2uYBxYYIHfwQtuf7C/LjsakngtNxMfAyYwoldux/EKlm7Y7wEruKK7WqifsTbFxEdCvLrc3cw==, - } - - "@zag-js/remove-scroll@1.18.3": - resolution: - { - integrity: sha512-cqWdN2uCRHiuXxLQq/HPTOLddHwp0UzGk/9fySox3kbZ2bsHtG1FXza2nrG99PlSrgorp3FIOsM8cT97cJfhCw==, - } - - "@zag-js/slider@1.18.3": - resolution: - { - integrity: sha512-H85nDzQBl/Ab9ZCSqG3gHPyf/0TbFLKVdsPGLBVkbhZRhdcsOCwHQIcBIzbb7if6XM4zuOFClHJhDm3WaFfMEA==, - } - - "@zag-js/store@1.18.3": - resolution: - { - integrity: sha512-9Df5Zr1pi9B7+2/OFdhyVDOkUaFUWLqgyKYx+DGaHh1LC6QbPJKoOsQ1zr23Q8G4//Dh1vNnES1SXojJA5+Nlg==, - } - - "@zag-js/svelte@1.18.3": - resolution: - { - integrity: sha512-eGtlAtw2eQHASMs4wmJBpK6uGwFNibIQ+5Zw4TLPrvms0ZOOcZm4//DYqEdOhsunm95y8lYFRhaeDyVagrabtQ==, - } - peerDependencies: - svelte: ^5.0.0-next.1 - - "@zag-js/switch@1.18.3": - resolution: - { - integrity: sha512-JpdJR9pWMqfQWy3jcYwlNO2Av4UfY6ZvVnScOMU72bg8DWiv32SVZrdhBghhAPngWO8B181mJ30y9bUNths0tg==, - } - - "@zag-js/tabs@1.18.3": - resolution: - { - integrity: sha512-Bo+V5w0Lh2uVEyY8la7t8A0RxljyVwZmii+SzhWmsuSRBBvQ1y82Gyk0CbwuranARryIFHwWFl8c4sp4fSZvqA==, - } - - "@zag-js/tags-input@1.18.3": - resolution: - { - integrity: sha512-gqC8r5m8Cp6B0wfGwivxl2gEQuiezua1nlonQSGgt/AQqPnILTqvziwkPurRKuG8S7e0M12pDTcJjSnsdZb2Nw==, - } - - "@zag-js/toast@1.18.3": - resolution: - { - integrity: sha512-q+dH7Z8uUBezxWlJWdUqCDxkuIXQw9KN3AtNbCvM2ZFbJFHzvzvYwSiU3VBuML0cLxmNjXO3EpenKyPAla/VyA==, - } - - "@zag-js/tooltip@1.18.3": - resolution: - { - integrity: sha512-FzG2epZX/ZmnrK9G1u9f3nmYLC1/a6mrp9BI2elaqO00cQNg7+WH+jhmrRofv2YrfpCgFYeo4yzAOcX6OkOiKA==, - } - - "@zag-js/types@1.18.3": - resolution: - { - integrity: sha512-M99ji5nha2/C2IQFkTkIA4SMR5w9rE0havAN55P8qpVtFzbcncCkSUZ4O0J2I4pA+NnJpCF5TcT1t7WnsyWlZQ==, - } - - "@zag-js/utils@1.18.3": - resolution: - { - integrity: sha512-yS8M286qUp6gf4d4tnnsNehdGIlI0Feuug9QiWkWSTbAUNmGJyh5cmjNxNSuLWVCPMREC89BIIWq09s113zPig==, - } + '@vitest/utils@3.2.4': + resolution: {integrity: sha512-fB2V0JFrQSMsCo9HiSq3Ezpdv4iYaXRG1Sx8edX3MwxfyNn83mKiGzOcH+Fkxt4MHxr3y42fQi1oeAInqgX2QA==} abort-controller-x@0.5.0: - resolution: - { - integrity: sha512-yTt9CI0x+nRfX6BFMenEGP8ooPvErGH6AbFz20C2IeOLIlDsrw/VHpgne3GsCEuTA410IiFiaLVFKmgM4bKEPQ==, - } + resolution: {integrity: sha512-yTt9CI0x+nRfX6BFMenEGP8ooPvErGH6AbFz20C2IeOLIlDsrw/VHpgne3GsCEuTA410IiFiaLVFKmgM4bKEPQ==} acorn-jsx@5.3.2: - resolution: - { - integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==, - } + resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==} peerDependencies: acorn: ^6.0.0 || ^7.0.0 || ^8.0.0 acorn@8.15.0: - resolution: - { - integrity: sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==, - } - engines: { node: ">=0.4.0" } + resolution: {integrity: sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==} + engines: {node: '>=0.4.0'} hasBin: true agent-base@7.1.4: - resolution: - { - integrity: sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==, - } - engines: { node: ">= 14" } + resolution: {integrity: sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==} + engines: {node: '>= 14'} ajv@6.12.6: - resolution: - { - integrity: sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==, - } + resolution: {integrity: sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==} ansi-regex@5.0.1: - resolution: - { - integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==, - } - engines: { node: ">=8" } + resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} + engines: {node: '>=8'} ansi-regex@6.2.2: - resolution: - { - integrity: sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==, - } - engines: { node: ">=12" } + resolution: {integrity: sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==} + engines: {node: '>=12'} ansi-styles@4.3.0: - resolution: - { - integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==, - } - engines: { node: ">=8" } + resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==} + engines: {node: '>=8'} ansi-styles@5.2.0: - resolution: - { - integrity: sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==, - } - engines: { node: ">=10" } + resolution: {integrity: sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==} + engines: {node: '>=10'} ansi-styles@6.2.3: - resolution: - { - integrity: sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==, - } - engines: { node: ">=12" } + resolution: {integrity: sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==} + engines: {node: '>=12'} argparse@2.0.1: - resolution: - { - integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==, - } + resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} aria-query@5.3.0: - resolution: - { - integrity: sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==, - } + resolution: {integrity: sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==} aria-query@5.3.2: - resolution: - { - integrity: sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw==, - } - engines: { node: ">= 0.4" } + resolution: {integrity: sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw==} + engines: {node: '>= 0.4'} array-back@6.2.2: - resolution: - { - integrity: sha512-gUAZ7HPyb4SJczXAMUXMGAvI976JoK3qEx9v1FTmeYuJj0IBiaKttG1ydtGKdkfqWkIkouke7nG8ufGy77+Cvw==, - } - engines: { node: ">=12.17" } + resolution: {integrity: sha512-gUAZ7HPyb4SJczXAMUXMGAvI976JoK3qEx9v1FTmeYuJj0IBiaKttG1ydtGKdkfqWkIkouke7nG8ufGy77+Cvw==} + engines: {node: '>=12.17'} assertion-error@2.0.1: - resolution: - { - integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==, - } - engines: { node: ">=12" } + resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} + engines: {node: '>=12'} ast-v8-to-istanbul@0.3.12: - resolution: - { - integrity: sha512-BRRC8VRZY2R4Z4lFIL35MwNXmwVqBityvOIwETtsCSwvjl0IdgFsy9NhdaA6j74nUdtJJlIypeRhpDam19Wq3g==, - } + resolution: {integrity: sha512-BRRC8VRZY2R4Z4lFIL35MwNXmwVqBityvOIwETtsCSwvjl0IdgFsy9NhdaA6j74nUdtJJlIypeRhpDam19Wq3g==} atomic-sleep@1.0.0: - resolution: - { - integrity: sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ==, - } - engines: { node: ">=8.0.0" } + resolution: {integrity: sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ==} + engines: {node: '>=8.0.0'} axobject-query@4.1.0: - resolution: - { - integrity: sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==, - } - engines: { node: ">= 0.4" } + resolution: {integrity: sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==} + engines: {node: '>= 0.4'} balanced-match@1.0.2: - resolution: - { - integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==, - } + resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} balanced-match@4.0.4: - resolution: - { - integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==, - } - engines: { node: 18 || 20 || >=22 } + resolution: {integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==} + engines: {node: 18 || 20 || >=22} + + bidi-js@1.0.3: + resolution: {integrity: sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==} brace-expansion@1.1.12: - resolution: - { - integrity: sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==, - } + resolution: {integrity: sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==} brace-expansion@2.0.2: - resolution: - { - integrity: sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==, - } + resolution: {integrity: sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==} brace-expansion@5.0.5: - resolution: - { - integrity: sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==, - } - engines: { node: 18 || 20 || >=22 } + resolution: {integrity: sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==} + engines: {node: 18 || 20 || >=22} braces@3.0.3: - resolution: - { - integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==, - } - engines: { node: ">=8" } + resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==} + engines: {node: '>=8'} cac@6.7.14: - resolution: - { - integrity: sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==, - } - engines: { node: ">=8" } + resolution: {integrity: sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==} + engines: {node: '>=8'} callsites@3.1.0: - resolution: - { - integrity: sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==, - } - engines: { node: ">=6" } + resolution: {integrity: sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==} + engines: {node: '>=6'} case-anything@2.1.13: - resolution: - { - integrity: sha512-zlOQ80VrQ2Ue+ymH5OuM/DlDq64mEm+B9UTdHULv5osUMD6HalNTblf2b1u/m6QecjsnOkBpqVZ+XPwIVsy7Ng==, - } - engines: { node: ">=12.13" } + resolution: {integrity: sha512-zlOQ80VrQ2Ue+ymH5OuM/DlDq64mEm+B9UTdHULv5osUMD6HalNTblf2b1u/m6QecjsnOkBpqVZ+XPwIVsy7Ng==} + engines: {node: '>=12.13'} chai@5.3.3: - resolution: - { - integrity: sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==, - } - engines: { node: ">=18" } + resolution: {integrity: sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==} + engines: {node: '>=18'} chalk@4.1.2: - resolution: - { - integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==, - } - engines: { node: ">=10" } + resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==} + engines: {node: '>=10'} check-error@2.1.3: - resolution: - { - integrity: sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==, - } - engines: { node: ">= 16" } + resolution: {integrity: sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==} + engines: {node: '>= 16'} chokidar@4.0.3: - resolution: - { - integrity: sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==, - } - engines: { node: ">= 14.16.0" } + resolution: {integrity: sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==} + engines: {node: '>= 14.16.0'} chownr@3.0.0: - resolution: - { - integrity: sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g==, - } - engines: { node: ">=18" } + resolution: {integrity: sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g==} + engines: {node: '>=18'} cliui@8.0.1: - resolution: - { - integrity: sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==, - } - engines: { node: ">=12" } + resolution: {integrity: sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==} + engines: {node: '>=12'} clsx@2.1.1: - resolution: - { - integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==, - } - engines: { node: ">=6" } + resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==} + engines: {node: '>=6'} color-convert@2.0.1: - resolution: - { - integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==, - } - engines: { node: ">=7.0.0" } + resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} + engines: {node: '>=7.0.0'} color-name@1.1.4: - resolution: - { - integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==, - } + resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==} colorette@2.0.20: - resolution: - { - integrity: sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w==, - } + resolution: {integrity: sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w==} command-line-args@6.0.1: - resolution: - { - integrity: sha512-Jr3eByUjqyK0qd8W0SGFW1nZwqCaNCtbXjRo2cRJC1OYxWl3MZ5t1US3jq+cO4sPavqgw4l9BMGX0CBe+trepg==, - } - engines: { node: ">=12.20" } + resolution: {integrity: sha512-Jr3eByUjqyK0qd8W0SGFW1nZwqCaNCtbXjRo2cRJC1OYxWl3MZ5t1US3jq+cO4sPavqgw4l9BMGX0CBe+trepg==} + engines: {node: '>=12.20'} peerDependencies: - "@75lb/nature": latest + '@75lb/nature': latest peerDependenciesMeta: - "@75lb/nature": + '@75lb/nature': optional: true commondir@1.0.1: - resolution: - { - integrity: sha512-W9pAhw0ja1Edb5GVdIF1mjZw/ASI0AlShXM83UUGe2DVr5TdAPEA1OA8m/g8zWp9x6On7gqufY+FatDbC3MDQg==, - } + resolution: {integrity: sha512-W9pAhw0ja1Edb5GVdIF1mjZw/ASI0AlShXM83UUGe2DVr5TdAPEA1OA8m/g8zWp9x6On7gqufY+FatDbC3MDQg==} concat-map@0.0.1: - resolution: - { - integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==, - } + resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==} cookie@0.6.0: - resolution: - { - integrity: sha512-U71cyTamuh1CRNCfpGY6to28lxvNwPG4Guz/EVjgf3Jmzv0vlDp1atT9eS5dDjMYHucpHbWns6Lwf3BKz6svdw==, - } - engines: { node: ">= 0.6" } + resolution: {integrity: sha512-U71cyTamuh1CRNCfpGY6to28lxvNwPG4Guz/EVjgf3Jmzv0vlDp1atT9eS5dDjMYHucpHbWns6Lwf3BKz6svdw==} + engines: {node: '>= 0.6'} cross-spawn@7.0.6: - resolution: - { - integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==, - } - engines: { node: ">= 8" } + resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} + engines: {node: '>= 8'} + + css-tree@3.2.1: + resolution: {integrity: sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==} + engines: {node: ^10 || ^12.20.0 || ^14.13.0 || >=15.0.0} css.escape@1.5.1: - resolution: - { - integrity: sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==, - } + resolution: {integrity: sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==} cssesc@3.0.0: - resolution: - { - integrity: sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==, - } - engines: { node: ">=4" } + resolution: {integrity: sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==} + engines: {node: '>=4'} hasBin: true cssstyle@4.6.0: - resolution: - { - integrity: sha512-2z+rWdzbbSZv6/rhtvzvqeZQHrBaqgogqt85sqFNbabZOuFbCVFb8kPeEtZjiKkbrm395irpNKiYeFeLiQnFPg==, - } - engines: { node: ">=18" } - - csstype@3.1.3: - resolution: - { - integrity: sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==, - } + resolution: {integrity: sha512-2z+rWdzbbSZv6/rhtvzvqeZQHrBaqgogqt85sqFNbabZOuFbCVFb8kPeEtZjiKkbrm395irpNKiYeFeLiQnFPg==} + engines: {node: '>=18'} data-urls@5.0.0: - resolution: - { - integrity: sha512-ZYP5VBHshaDAiVZxjbRVcFJpc+4xGgT0bK3vzy1HLN8jTO975HEbuYzZJcHoQEY5K1a0z8YayJkyVETa08eNTg==, - } - engines: { node: ">=18" } + resolution: {integrity: sha512-ZYP5VBHshaDAiVZxjbRVcFJpc+4xGgT0bK3vzy1HLN8jTO975HEbuYzZJcHoQEY5K1a0z8YayJkyVETa08eNTg==} + engines: {node: '>=18'} + + data-urls@7.0.0: + resolution: {integrity: sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} dateformat@4.6.3: - resolution: - { - integrity: sha512-2P0p0pFGzHS5EMnhdxQi7aJN+iMheud0UhG4dlE1DLAlvL8JHjJJTX/CSm4JXwV0Ka5nGk3zC5mcb5bUQUxxMA==, - } + resolution: {integrity: sha512-2P0p0pFGzHS5EMnhdxQi7aJN+iMheud0UhG4dlE1DLAlvL8JHjJJTX/CSm4JXwV0Ka5nGk3zC5mcb5bUQUxxMA==} debug@4.4.3: - resolution: - { - integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==, - } - engines: { node: ">=6.0" } + resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} + engines: {node: '>=6.0'} peerDependencies: - supports-color: "*" + supports-color: '*' peerDependenciesMeta: supports-color: optional: true decimal.js@10.6.0: - resolution: - { - integrity: sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==, - } + resolution: {integrity: sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==} deep-eql@5.0.2: - resolution: - { - integrity: sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==, - } - engines: { node: ">=6" } + resolution: {integrity: sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==} + engines: {node: '>=6'} deep-is@0.1.4: - resolution: - { - integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==, - } + resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==} deepmerge@4.3.1: - resolution: - { - integrity: sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==, - } - engines: { node: ">=0.10.0" } + resolution: {integrity: sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==} + engines: {node: '>=0.10.0'} dequal@2.0.3: - resolution: - { - integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==, - } - engines: { node: ">=6" } + resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==} + engines: {node: '>=6'} detect-libc@1.0.3: - resolution: - { - integrity: sha512-pGjwhsmsp4kL2RTz08wcOlGN83otlqHeD/Z5T8GXZB+/YcpQ/dgo+lbU8ZsGxV0HIvqqxo9l7mqYwyYMD9bKDg==, - } - engines: { node: ">=0.10" } + resolution: {integrity: sha512-pGjwhsmsp4kL2RTz08wcOlGN83otlqHeD/Z5T8GXZB+/YcpQ/dgo+lbU8ZsGxV0HIvqqxo9l7mqYwyYMD9bKDg==} + engines: {node: '>=0.10'} hasBin: true detect-libc@2.1.2: - resolution: - { - integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==, - } - engines: { node: ">=8" } + resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} + engines: {node: '>=8'} devalue@5.6.2: - resolution: - { - integrity: sha512-nPRkjWzzDQlsejL1WVifk5rvcFi/y1onBRxjaFMjZeR9mFpqu2gmAZ9xUB9/IEanEP/vBtGeGganC/GO1fmufg==, - } + resolution: {integrity: sha512-nPRkjWzzDQlsejL1WVifk5rvcFi/y1onBRxjaFMjZeR9mFpqu2gmAZ9xUB9/IEanEP/vBtGeGganC/GO1fmufg==} dom-accessibility-api@0.5.16: - resolution: - { - integrity: sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==, - } + resolution: {integrity: sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==} dom-accessibility-api@0.6.3: - resolution: - { - integrity: sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==, - } + resolution: {integrity: sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==} + + dompurify@3.4.13: + resolution: {integrity: sha512-2vmYIoqjze2d+kakP8S/nS5shfsl587kzwEjcGlTdiksUVgFHnFCsLYDVj/JNqJVOQZGSYBTmuycv0PodwmnMQ==} dprint-node@1.0.8: - resolution: - { - integrity: sha512-iVKnUtYfGrYcW1ZAlfR/F59cUVL8QIhWoBJoSjkkdua/dkWIgjZfiLMeTjiB06X0ZLkQ0M2C1VbUj/CxkIf1zg==, - } + resolution: {integrity: sha512-iVKnUtYfGrYcW1ZAlfR/F59cUVL8QIhWoBJoSjkkdua/dkWIgjZfiLMeTjiB06X0ZLkQ0M2C1VbUj/CxkIf1zg==} eastasianwidth@0.2.0: - resolution: - { - integrity: sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==, - } + resolution: {integrity: sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==} emoji-regex@8.0.0: - resolution: - { - integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==, - } + resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==} emoji-regex@9.2.2: - resolution: - { - integrity: sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==, - } + resolution: {integrity: sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==} end-of-stream@1.4.5: - resolution: - { - integrity: sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==, - } + resolution: {integrity: sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==} enhanced-resolve@5.18.3: - resolution: - { - integrity: sha512-d4lC8xfavMeBjzGr2vECC3fsGXziXZQyJxD868h2M/mBI3PwAuODxAkLkq5HYuvrPYcUtiLzsTo8U3PgX3Ocww==, - } - engines: { node: ">=10.13.0" } + resolution: {integrity: sha512-d4lC8xfavMeBjzGr2vECC3fsGXziXZQyJxD868h2M/mBI3PwAuODxAkLkq5HYuvrPYcUtiLzsTo8U3PgX3Ocww==} + engines: {node: '>=10.13.0'} entities@6.0.1: - resolution: - { - integrity: sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==, - } - engines: { node: ">=0.12" } + resolution: {integrity: sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==} + engines: {node: '>=0.12'} + + entities@8.0.0: + resolution: {integrity: sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA==} + engines: {node: '>=20.19.0'} + + error-stack-parser-es@2.0.1: + resolution: {integrity: sha512-J36ntO+rMQVRuR/umlmxmfLi4TpWwtmTnHoJoXTiC2xDNazs0VDPKcX6pbhZMDd2HFtH9isMBJXMdbki+A++Pg==} es-module-lexer@1.7.0: - resolution: - { - integrity: sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==, - } + resolution: {integrity: sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==} esbuild@0.25.10: - resolution: - { - integrity: sha512-9RiGKvCwaqxO2owP61uQ4BgNborAQskMR6QusfWzQqv7AZOg5oGehdY2pRJMTKuwxd1IDBP4rSbI5lHzU7SMsQ==, - } - engines: { node: ">=18" } + resolution: {integrity: sha512-9RiGKvCwaqxO2owP61uQ4BgNborAQskMR6QusfWzQqv7AZOg5oGehdY2pRJMTKuwxd1IDBP4rSbI5lHzU7SMsQ==} + engines: {node: '>=18'} hasBin: true escalade@3.2.0: - resolution: - { - integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==, - } - engines: { node: ">=6" } + resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} + engines: {node: '>=6'} escape-string-regexp@4.0.0: - resolution: - { - integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==, - } - engines: { node: ">=10" } + resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==} + engines: {node: '>=10'} eslint-config-prettier@10.1.8: - resolution: - { - integrity: sha512-82GZUjRS0p/jganf6q1rEO25VSoHH0hKPCTrgillPjdI/3bgBhAE1QzHrHTizjpRvy6pGAvKjDJtk2pF9NDq8w==, - } + resolution: {integrity: sha512-82GZUjRS0p/jganf6q1rEO25VSoHH0hKPCTrgillPjdI/3bgBhAE1QzHrHTizjpRvy6pGAvKjDJtk2pF9NDq8w==} hasBin: true peerDependencies: - eslint: ">=7.0.0" + eslint: '>=7.0.0' eslint-plugin-svelte@3.12.4: - resolution: - { - integrity: sha512-hD7wPe+vrPgx3U2X2b/wyTMtWobm660PygMGKrWWYTc9lvtY8DpNFDaU2CJQn1szLjGbn/aJ3g8WiXuKakrEkw==, - } - engines: { node: ^18.18.0 || ^20.9.0 || >=21.1.0 } + resolution: {integrity: sha512-hD7wPe+vrPgx3U2X2b/wyTMtWobm660PygMGKrWWYTc9lvtY8DpNFDaU2CJQn1szLjGbn/aJ3g8WiXuKakrEkw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: eslint: ^8.57.1 || ^9.0.0 svelte: ^3.37.0 || ^4.0.0 || ^5.0.0 @@ -2240,154 +1373,88 @@ packages: optional: true eslint-scope@8.4.0: - resolution: - { - integrity: sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==, - } - engines: { node: ^18.18.0 || ^20.9.0 || >=21.1.0 } + resolution: {integrity: sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} eslint-visitor-keys@3.4.3: - resolution: - { - integrity: sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==, - } - engines: { node: ^12.22.0 || ^14.17.0 || >=16.0.0 } + resolution: {integrity: sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} eslint-visitor-keys@4.2.1: - resolution: - { - integrity: sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==, - } - engines: { node: ^18.18.0 || ^20.9.0 || >=21.1.0 } + resolution: {integrity: sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} eslint@9.37.0: - resolution: - { - integrity: sha512-XyLmROnACWqSxiGYArdef1fItQd47weqB7iwtfr9JHwRrqIXZdcFMvvEcL9xHCmL0SNsOvF0c42lWyM1U5dgig==, - } - engines: { node: ^18.18.0 || ^20.9.0 || >=21.1.0 } + resolution: {integrity: sha512-XyLmROnACWqSxiGYArdef1fItQd47weqB7iwtfr9JHwRrqIXZdcFMvvEcL9xHCmL0SNsOvF0c42lWyM1U5dgig==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} hasBin: true peerDependencies: - jiti: "*" + jiti: '*' peerDependenciesMeta: jiti: optional: true esm-env@1.2.2: - resolution: - { - integrity: sha512-Epxrv+Nr/CaL4ZcFGPJIYLWFom+YeV1DqMLHJoEd9SYRxNbaFruBwfEX/kkHUJf55j2+TUbmDcmuilbP1TmXHA==, - } + resolution: {integrity: sha512-Epxrv+Nr/CaL4ZcFGPJIYLWFom+YeV1DqMLHJoEd9SYRxNbaFruBwfEX/kkHUJf55j2+TUbmDcmuilbP1TmXHA==} espree@10.4.0: - resolution: - { - integrity: sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==, - } - engines: { node: ^18.18.0 || ^20.9.0 || >=21.1.0 } + resolution: {integrity: sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} esquery@1.6.0: - resolution: - { - integrity: sha512-ca9pw9fomFcKPvFLXhBKUK90ZvGibiGOvRJNbjljY7s7uq/5YO4BOzcYtJqExdx99rF6aAcnRxHmcUHcz6sQsg==, - } - engines: { node: ">=0.10" } + resolution: {integrity: sha512-ca9pw9fomFcKPvFLXhBKUK90ZvGibiGOvRJNbjljY7s7uq/5YO4BOzcYtJqExdx99rF6aAcnRxHmcUHcz6sQsg==} + engines: {node: '>=0.10'} esrap@2.1.0: - resolution: - { - integrity: sha512-yzmPNpl7TBbMRC5Lj2JlJZNPml0tzqoqP5B1JXycNUwtqma9AKCO0M2wHrdgsHcy1WRW7S9rJknAMtByg3usgA==, - } + resolution: {integrity: sha512-yzmPNpl7TBbMRC5Lj2JlJZNPml0tzqoqP5B1JXycNUwtqma9AKCO0M2wHrdgsHcy1WRW7S9rJknAMtByg3usgA==} esrecurse@4.3.0: - resolution: - { - integrity: sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==, - } - engines: { node: ">=4.0" } + resolution: {integrity: sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==} + engines: {node: '>=4.0'} estraverse@5.3.0: - resolution: - { - integrity: sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==, - } - engines: { node: ">=4.0" } + resolution: {integrity: sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==} + engines: {node: '>=4.0'} estree-walker@2.0.2: - resolution: - { - integrity: sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==, - } + resolution: {integrity: sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==} estree-walker@3.0.3: - resolution: - { - integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==, - } + resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} esutils@2.0.3: - resolution: - { - integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==, - } - engines: { node: ">=0.10.0" } + resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==} + engines: {node: '>=0.10.0'} expect-type@1.3.0: - resolution: - { - integrity: sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==, - } - engines: { node: ">=12.0.0" } + resolution: {integrity: sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==} + engines: {node: '>=12.0.0'} fast-copy@3.0.2: - resolution: - { - integrity: sha512-dl0O9Vhju8IrcLndv2eU4ldt1ftXMqqfgN4H1cpmGV7P6jeB9FwpN9a2c8DPGE1Ys88rNUJVYDHq73CGAGOPfQ==, - } + resolution: {integrity: sha512-dl0O9Vhju8IrcLndv2eU4ldt1ftXMqqfgN4H1cpmGV7P6jeB9FwpN9a2c8DPGE1Ys88rNUJVYDHq73CGAGOPfQ==} fast-deep-equal@3.1.3: - resolution: - { - integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==, - } + resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} fast-glob@3.3.3: - resolution: - { - integrity: sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==, - } - engines: { node: ">=8.6.0" } + resolution: {integrity: sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==} + engines: {node: '>=8.6.0'} fast-json-stable-stringify@2.1.0: - resolution: - { - integrity: sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==, - } + resolution: {integrity: sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==} fast-levenshtein@2.0.6: - resolution: - { - integrity: sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==, - } + resolution: {integrity: sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==} fast-safe-stringify@2.1.1: - resolution: - { - integrity: sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA==, - } + resolution: {integrity: sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA==} fastq@1.19.1: - resolution: - { - integrity: sha512-GwLTyxkCXjXbxqIhTsMI2Nui8huMPtnxg7krajPJAjnEG/iiOS7i+zCtWGZR9G0NBKbXKh6X9m9UIsYX/N6vvQ==, - } + resolution: {integrity: sha512-GwLTyxkCXjXbxqIhTsMI2Nui8huMPtnxg7krajPJAjnEG/iiOS7i+zCtWGZR9G0NBKbXKh6X9m9UIsYX/N6vvQ==} fdir@6.5.0: - resolution: - { - integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==, - } - engines: { node: ">=12.0.0" } + resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} + engines: {node: '>=12.0.0'} peerDependencies: picomatch: ^3 || ^4 peerDependenciesMeta: @@ -2395,870 +1462,527 @@ packages: optional: true fflate@0.8.2: - resolution: - { - integrity: sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A==, - } + resolution: {integrity: sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A==} file-entry-cache@8.0.0: - resolution: - { - integrity: sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==, - } - engines: { node: ">=16.0.0" } + resolution: {integrity: sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==} + engines: {node: '>=16.0.0'} fill-range@7.1.1: - resolution: - { - integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==, - } - engines: { node: ">=8" } + resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==} + engines: {node: '>=8'} find-replace@5.0.2: - resolution: - { - integrity: sha512-Y45BAiE3mz2QsrN2fb5QEtO4qb44NcS7en/0y9PEVsg351HsLeVclP8QPMH79Le9sH3rs5RSwJu99W0WPZO43Q==, - } - engines: { node: ">=14" } + resolution: {integrity: sha512-Y45BAiE3mz2QsrN2fb5QEtO4qb44NcS7en/0y9PEVsg351HsLeVclP8QPMH79Le9sH3rs5RSwJu99W0WPZO43Q==} + engines: {node: '>=14'} peerDependencies: - "@75lb/nature": latest + '@75lb/nature': latest peerDependenciesMeta: - "@75lb/nature": + '@75lb/nature': optional: true find-up@5.0.0: - resolution: - { - integrity: sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==, - } - engines: { node: ">=10" } + resolution: {integrity: sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==} + engines: {node: '>=10'} flat-cache@4.0.1: - resolution: - { - integrity: sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==, - } - engines: { node: ">=16" } + resolution: {integrity: sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==} + engines: {node: '>=16'} flatted@3.3.3: - resolution: - { - integrity: sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==, - } + resolution: {integrity: sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==} foreground-child@3.3.1: - resolution: - { - integrity: sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==, - } - engines: { node: ">=14" } + resolution: {integrity: sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==} + engines: {node: '>=14'} fsevents@2.3.3: - resolution: - { - integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==, - } - engines: { node: ^8.16.0 || ^10.6.0 || >=11.0.0 } + resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} os: [darwin] function-bind@1.1.2: - resolution: - { - integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==, - } + resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==} get-caller-file@2.0.5: - resolution: - { - integrity: sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==, - } - engines: { node: 6.* || 8.* || >= 10.* } + resolution: {integrity: sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==} + engines: {node: 6.* || 8.* || >= 10.*} glob-parent@5.1.2: - resolution: - { - integrity: sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==, - } - engines: { node: ">= 6" } + resolution: {integrity: sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==} + engines: {node: '>= 6'} glob-parent@6.0.2: - resolution: - { - integrity: sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==, - } - engines: { node: ">=10.13.0" } + resolution: {integrity: sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==} + engines: {node: '>=10.13.0'} glob@10.5.0: - resolution: - { - integrity: sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==, - } + resolution: {integrity: sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==} deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me hasBin: true globals@14.0.0: - resolution: - { - integrity: sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==, - } - engines: { node: ">=18" } + resolution: {integrity: sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==} + engines: {node: '>=18'} globals@16.4.0: - resolution: - { - integrity: sha512-ob/2LcVVaVGCYN+r14cnwnoDPUufjiYgSqRhiFD0Q1iI4Odora5RE8Iv1D24hAz5oMophRGkGz+yuvQmmUMnMw==, - } - engines: { node: ">=18" } + resolution: {integrity: sha512-ob/2LcVVaVGCYN+r14cnwnoDPUufjiYgSqRhiFD0Q1iI4Odora5RE8Iv1D24hAz5oMophRGkGz+yuvQmmUMnMw==} + engines: {node: '>=18'} graceful-fs@4.2.11: - resolution: - { - integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==, - } + resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} graphemer@1.4.0: - resolution: - { - integrity: sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==, - } + resolution: {integrity: sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==} has-flag@4.0.0: - resolution: - { - integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==, - } - engines: { node: ">=8" } + resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==} + engines: {node: '>=8'} hasown@2.0.2: - resolution: - { - integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==, - } - engines: { node: ">= 0.4" } + resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==} + engines: {node: '>= 0.4'} help-me@5.0.0: - resolution: - { - integrity: sha512-7xgomUX6ADmcYzFik0HzAxh/73YlKR9bmFzf51CZwR+b6YtzU2m0u49hQCqV6SvlqIqsaxovfwdvbnsw3b/zpg==, - } + resolution: {integrity: sha512-7xgomUX6ADmcYzFik0HzAxh/73YlKR9bmFzf51CZwR+b6YtzU2m0u49hQCqV6SvlqIqsaxovfwdvbnsw3b/zpg==} html-encoding-sniffer@4.0.0: - resolution: - { - integrity: sha512-Y22oTqIU4uuPgEemfz7NDJz6OeKf12Lsu+QC+s3BVpda64lTiMYCyGwg5ki4vFxkMwQdeZDl2adZoqUgdFuTgQ==, - } - engines: { node: ">=18" } + resolution: {integrity: sha512-Y22oTqIU4uuPgEemfz7NDJz6OeKf12Lsu+QC+s3BVpda64lTiMYCyGwg5ki4vFxkMwQdeZDl2adZoqUgdFuTgQ==} + engines: {node: '>=18'} + + html-encoding-sniffer@6.0.0: + resolution: {integrity: sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} html-escaper@2.0.2: - resolution: - { - integrity: sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==, - } + resolution: {integrity: sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==} http-proxy-agent@7.0.2: - resolution: - { - integrity: sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==, - } - engines: { node: ">= 14" } + resolution: {integrity: sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==} + engines: {node: '>= 14'} https-proxy-agent@7.0.6: - resolution: - { - integrity: sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==, - } - engines: { node: ">= 14" } + resolution: {integrity: sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==} + engines: {node: '>= 14'} iconv-lite@0.6.3: - resolution: - { - integrity: sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==, - } - engines: { node: ">=0.10.0" } + resolution: {integrity: sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==} + engines: {node: '>=0.10.0'} ignore@5.3.2: - resolution: - { - integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==, - } - engines: { node: ">= 4" } + resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==} + engines: {node: '>= 4'} ignore@7.0.5: - resolution: - { - integrity: sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==, - } - engines: { node: ">= 4" } + resolution: {integrity: sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==} + engines: {node: '>= 4'} import-fresh@3.3.1: - resolution: - { - integrity: sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==, - } - engines: { node: ">=6" } + resolution: {integrity: sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==} + engines: {node: '>=6'} imurmurhash@0.1.4: - resolution: - { - integrity: sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==, - } - engines: { node: ">=0.8.19" } + resolution: {integrity: sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==} + engines: {node: '>=0.8.19'} indent-string@4.0.0: - resolution: - { - integrity: sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==, - } - engines: { node: ">=8" } + resolution: {integrity: sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==} + engines: {node: '>=8'} is-core-module@2.16.1: - resolution: - { - integrity: sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==, - } - engines: { node: ">= 0.4" } + resolution: {integrity: sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==} + engines: {node: '>= 0.4'} is-extglob@2.1.1: - resolution: - { - integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==, - } - engines: { node: ">=0.10.0" } + resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==} + engines: {node: '>=0.10.0'} is-fullwidth-code-point@3.0.0: - resolution: - { - integrity: sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==, - } - engines: { node: ">=8" } + resolution: {integrity: sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==} + engines: {node: '>=8'} is-glob@4.0.3: - resolution: - { - integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==, - } - engines: { node: ">=0.10.0" } + resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==} + engines: {node: '>=0.10.0'} is-module@1.0.0: - resolution: - { - integrity: sha512-51ypPSPCoTEIN9dy5Oy+h4pShgJmPCygKfyRCISBI+JoWT/2oJvK8QPxmwv7b/p239jXrm9M1mlQbyKJ5A152g==, - } + resolution: {integrity: sha512-51ypPSPCoTEIN9dy5Oy+h4pShgJmPCygKfyRCISBI+JoWT/2oJvK8QPxmwv7b/p239jXrm9M1mlQbyKJ5A152g==} is-number@7.0.0: - resolution: - { - integrity: sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==, - } - engines: { node: ">=0.12.0" } + resolution: {integrity: sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==} + engines: {node: '>=0.12.0'} is-potential-custom-element-name@1.0.1: - resolution: - { - integrity: sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==, - } + resolution: {integrity: sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==} is-reference@1.2.1: - resolution: - { - integrity: sha512-U82MsXXiFIrjCK4otLT+o2NA2Cd2g5MLoOVXUZjIOhLurrRxpEXzI8O0KZHr3IjLvlAH1kTPYSuqer5T9ZVBKQ==, - } + resolution: {integrity: sha512-U82MsXXiFIrjCK4otLT+o2NA2Cd2g5MLoOVXUZjIOhLurrRxpEXzI8O0KZHr3IjLvlAH1kTPYSuqer5T9ZVBKQ==} is-reference@3.0.3: - resolution: - { - integrity: sha512-ixkJoqQvAP88E6wLydLGGqCJsrFUnqoH6HnaczB8XmDH1oaWU+xxdptvikTgaEhtZ53Ky6YXiBuUI2WXLMCwjw==, - } + resolution: {integrity: sha512-ixkJoqQvAP88E6wLydLGGqCJsrFUnqoH6HnaczB8XmDH1oaWU+xxdptvikTgaEhtZ53Ky6YXiBuUI2WXLMCwjw==} isexe@2.0.0: - resolution: - { - integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==, - } + resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} + + isomorphic-dompurify@3.21.0: + resolution: {integrity: sha512-Bg5mqtMnMdBBwz7MjvH3oAuLOA/j0xtawVkcAZsOpVhOsfaLYwnAvhEvz94f8585Q6LCnnNr2XNApbXlB0J3GQ==} + engines: {node: ^22.22.2 || ^24.15.0 || >=26.0.0} istanbul-lib-coverage@3.2.2: - resolution: - { - integrity: sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==, - } - engines: { node: ">=8" } + resolution: {integrity: sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==} + engines: {node: '>=8'} istanbul-lib-report@3.0.1: - resolution: - { - integrity: sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==, - } - engines: { node: ">=10" } + resolution: {integrity: sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==} + engines: {node: '>=10'} istanbul-lib-source-maps@5.0.6: - resolution: - { - integrity: sha512-yg2d+Em4KizZC5niWhQaIomgf5WlL4vOOjZ5xGCmF8SnPE/mDWWXgvRExdcpCgh9lLRRa1/fSYp2ymmbJ1pI+A==, - } - engines: { node: ">=10" } + resolution: {integrity: sha512-yg2d+Em4KizZC5niWhQaIomgf5WlL4vOOjZ5xGCmF8SnPE/mDWWXgvRExdcpCgh9lLRRa1/fSYp2ymmbJ1pI+A==} + engines: {node: '>=10'} istanbul-reports@3.2.0: - resolution: - { - integrity: sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==, - } - engines: { node: ">=8" } + resolution: {integrity: sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==} + engines: {node: '>=8'} jackspeak@3.4.3: - resolution: - { - integrity: sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==, - } + resolution: {integrity: sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==} jiti@2.6.1: - resolution: - { - integrity: sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==, - } + resolution: {integrity: sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==} hasBin: true jose@6.2.2: - resolution: - { - integrity: sha512-d7kPDd34KO/YnzaDOlikGpOurfF0ByC2sEV4cANCtdqLlTfBlw2p14O/5d/zv40gJPbIQxfES3nSx1/oYNyuZQ==, - } + resolution: {integrity: sha512-d7kPDd34KO/YnzaDOlikGpOurfF0ByC2sEV4cANCtdqLlTfBlw2p14O/5d/zv40gJPbIQxfES3nSx1/oYNyuZQ==} joycon@3.1.1: - resolution: - { - integrity: sha512-34wB/Y7MW7bzjKRjUKTa46I2Z7eV62Rkhva+KkopW7Qvv/OSWBqvkSY7vusOPrNuZcUG3tApvdVgNB8POj3SPw==, - } - engines: { node: ">=10" } + resolution: {integrity: sha512-34wB/Y7MW7bzjKRjUKTa46I2Z7eV62Rkhva+KkopW7Qvv/OSWBqvkSY7vusOPrNuZcUG3tApvdVgNB8POj3SPw==} + engines: {node: '>=10'} js-tokens@10.0.0: - resolution: - { - integrity: sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q==, - } + resolution: {integrity: sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q==} js-tokens@4.0.0: - resolution: - { - integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==, - } + resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} js-tokens@9.0.1: - resolution: - { - integrity: sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==, - } + resolution: {integrity: sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==} js-yaml@4.1.0: - resolution: - { - integrity: sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==, - } + resolution: {integrity: sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==} hasBin: true jsdom@26.1.0: - resolution: - { - integrity: sha512-Cvc9WUhxSMEo4McES3P7oK3QaXldCfNWp7pl2NNeiIFlCoLr3kfq9kb1fxftiwk1FLV7CvpvDfonxtzUDeSOPg==, - } - engines: { node: ">=18" } + resolution: {integrity: sha512-Cvc9WUhxSMEo4McES3P7oK3QaXldCfNWp7pl2NNeiIFlCoLr3kfq9kb1fxftiwk1FLV7CvpvDfonxtzUDeSOPg==} + engines: {node: '>=18'} peerDependencies: canvas: ^3.0.0 peerDependenciesMeta: canvas: optional: true + jsdom@30.0.1: + resolution: {integrity: sha512-52v7mUVUfNQVYYqE1lcdaymWL0njO7lTLUog6ZvW2U5KsbiLk/GnZlVJ+qx0xfNJZ6Gn+KSpPNE52vurbxZwrA==} + engines: {node: ^22.22.2 || ^24.15.0 || >=26.0.0} + peerDependencies: + canvas: ^3.2.3 + peerDependenciesMeta: + canvas: + optional: true + json-buffer@3.0.1: - resolution: - { - integrity: sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==, - } + resolution: {integrity: sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==} json-schema-traverse@0.4.1: - resolution: - { - integrity: sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==, - } + resolution: {integrity: sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==} json-stable-stringify-without-jsonify@1.0.1: - resolution: - { - integrity: sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==, - } + resolution: {integrity: sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==} keyv@4.5.4: - resolution: - { - integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==, - } + resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==} kleur@4.1.5: - resolution: - { - integrity: sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==, - } - engines: { node: ">=6" } + resolution: {integrity: sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==} + engines: {node: '>=6'} known-css-properties@0.37.0: - resolution: - { - integrity: sha512-JCDrsP4Z1Sb9JwG0aJ8Eo2r7k4Ou5MwmThS/6lcIe1ICyb7UBJKGRIUUdqc2ASdE/42lgz6zFUnzAIhtXnBVrQ==, - } + resolution: {integrity: sha512-JCDrsP4Z1Sb9JwG0aJ8Eo2r7k4Ou5MwmThS/6lcIe1ICyb7UBJKGRIUUdqc2ASdE/42lgz6zFUnzAIhtXnBVrQ==} levn@0.4.1: - resolution: - { - integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==, - } - engines: { node: ">= 0.8.0" } + resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==} + engines: {node: '>= 0.8.0'} lightningcss-darwin-arm64@1.30.1: - resolution: - { - integrity: sha512-c8JK7hyE65X1MHMN+Viq9n11RRC7hgin3HhYKhrMyaXflk5GVplZ60IxyoVtzILeKr+xAJwg6zK6sjTBJ0FKYQ==, - } - engines: { node: ">= 12.0.0" } + resolution: {integrity: sha512-c8JK7hyE65X1MHMN+Viq9n11RRC7hgin3HhYKhrMyaXflk5GVplZ60IxyoVtzILeKr+xAJwg6zK6sjTBJ0FKYQ==} + engines: {node: '>= 12.0.0'} cpu: [arm64] os: [darwin] lightningcss-darwin-x64@1.30.1: - resolution: - { - integrity: sha512-k1EvjakfumAQoTfcXUcHQZhSpLlkAuEkdMBsI/ivWw9hL+7FtilQc0Cy3hrx0AAQrVtQAbMI7YjCgYgvn37PzA==, - } - engines: { node: ">= 12.0.0" } + resolution: {integrity: sha512-k1EvjakfumAQoTfcXUcHQZhSpLlkAuEkdMBsI/ivWw9hL+7FtilQc0Cy3hrx0AAQrVtQAbMI7YjCgYgvn37PzA==} + engines: {node: '>= 12.0.0'} cpu: [x64] os: [darwin] lightningcss-freebsd-x64@1.30.1: - resolution: - { - integrity: sha512-kmW6UGCGg2PcyUE59K5r0kWfKPAVy4SltVeut+umLCFoJ53RdCUWxcRDzO1eTaxf/7Q2H7LTquFHPL5R+Gjyig==, - } - engines: { node: ">= 12.0.0" } + resolution: {integrity: sha512-kmW6UGCGg2PcyUE59K5r0kWfKPAVy4SltVeut+umLCFoJ53RdCUWxcRDzO1eTaxf/7Q2H7LTquFHPL5R+Gjyig==} + engines: {node: '>= 12.0.0'} cpu: [x64] os: [freebsd] lightningcss-linux-arm-gnueabihf@1.30.1: - resolution: - { - integrity: sha512-MjxUShl1v8pit+6D/zSPq9S9dQ2NPFSQwGvxBCYaBYLPlCWuPh9/t1MRS8iUaR8i+a6w7aps+B4N0S1TYP/R+Q==, - } - engines: { node: ">= 12.0.0" } + resolution: {integrity: sha512-MjxUShl1v8pit+6D/zSPq9S9dQ2NPFSQwGvxBCYaBYLPlCWuPh9/t1MRS8iUaR8i+a6w7aps+B4N0S1TYP/R+Q==} + engines: {node: '>= 12.0.0'} cpu: [arm] os: [linux] lightningcss-linux-arm64-gnu@1.30.1: - resolution: - { - integrity: sha512-gB72maP8rmrKsnKYy8XUuXi/4OctJiuQjcuqWNlJQ6jZiWqtPvqFziskH3hnajfvKB27ynbVCucKSm2rkQp4Bw==, - } - engines: { node: ">= 12.0.0" } + resolution: {integrity: sha512-gB72maP8rmrKsnKYy8XUuXi/4OctJiuQjcuqWNlJQ6jZiWqtPvqFziskH3hnajfvKB27ynbVCucKSm2rkQp4Bw==} + engines: {node: '>= 12.0.0'} cpu: [arm64] os: [linux] lightningcss-linux-arm64-musl@1.30.1: - resolution: - { - integrity: sha512-jmUQVx4331m6LIX+0wUhBbmMX7TCfjF5FoOH6SD1CttzuYlGNVpA7QnrmLxrsub43ClTINfGSYyHe2HWeLl5CQ==, - } - engines: { node: ">= 12.0.0" } + resolution: {integrity: sha512-jmUQVx4331m6LIX+0wUhBbmMX7TCfjF5FoOH6SD1CttzuYlGNVpA7QnrmLxrsub43ClTINfGSYyHe2HWeLl5CQ==} + engines: {node: '>= 12.0.0'} cpu: [arm64] os: [linux] lightningcss-linux-x64-gnu@1.30.1: - resolution: - { - integrity: sha512-piWx3z4wN8J8z3+O5kO74+yr6ze/dKmPnI7vLqfSqI8bccaTGY5xiSGVIJBDd5K5BHlvVLpUB3S2YCfelyJ1bw==, - } - engines: { node: ">= 12.0.0" } + resolution: {integrity: sha512-piWx3z4wN8J8z3+O5kO74+yr6ze/dKmPnI7vLqfSqI8bccaTGY5xiSGVIJBDd5K5BHlvVLpUB3S2YCfelyJ1bw==} + engines: {node: '>= 12.0.0'} cpu: [x64] os: [linux] lightningcss-linux-x64-musl@1.30.1: - resolution: - { - integrity: sha512-rRomAK7eIkL+tHY0YPxbc5Dra2gXlI63HL+v1Pdi1a3sC+tJTcFrHX+E86sulgAXeI7rSzDYhPSeHHjqFhqfeQ==, - } - engines: { node: ">= 12.0.0" } + resolution: {integrity: sha512-rRomAK7eIkL+tHY0YPxbc5Dra2gXlI63HL+v1Pdi1a3sC+tJTcFrHX+E86sulgAXeI7rSzDYhPSeHHjqFhqfeQ==} + engines: {node: '>= 12.0.0'} cpu: [x64] os: [linux] lightningcss-win32-arm64-msvc@1.30.1: - resolution: - { - integrity: sha512-mSL4rqPi4iXq5YVqzSsJgMVFENoa4nGTT/GjO2c0Yl9OuQfPsIfncvLrEW6RbbB24WtZ3xP/2CCmI3tNkNV4oA==, - } - engines: { node: ">= 12.0.0" } + resolution: {integrity: sha512-mSL4rqPi4iXq5YVqzSsJgMVFENoa4nGTT/GjO2c0Yl9OuQfPsIfncvLrEW6RbbB24WtZ3xP/2CCmI3tNkNV4oA==} + engines: {node: '>= 12.0.0'} cpu: [arm64] os: [win32] lightningcss-win32-x64-msvc@1.30.1: - resolution: - { - integrity: sha512-PVqXh48wh4T53F/1CCu8PIPCxLzWyCnn/9T5W1Jpmdy5h9Cwd+0YQS6/LwhHXSafuc61/xg9Lv5OrCby6a++jg==, - } - engines: { node: ">= 12.0.0" } + resolution: {integrity: sha512-PVqXh48wh4T53F/1CCu8PIPCxLzWyCnn/9T5W1Jpmdy5h9Cwd+0YQS6/LwhHXSafuc61/xg9Lv5OrCby6a++jg==} + engines: {node: '>= 12.0.0'} cpu: [x64] os: [win32] lightningcss@1.30.1: - resolution: - { - integrity: sha512-xi6IyHML+c9+Q3W0S4fCQJOym42pyurFiJUHEcEyHS0CeKzia4yZDEsLlqOFykxOdHpNy0NmvVO31vcSqAxJCg==, - } - engines: { node: ">= 12.0.0" } + resolution: {integrity: sha512-xi6IyHML+c9+Q3W0S4fCQJOym42pyurFiJUHEcEyHS0CeKzia4yZDEsLlqOFykxOdHpNy0NmvVO31vcSqAxJCg==} + engines: {node: '>= 12.0.0'} lilconfig@2.1.0: - resolution: - { - integrity: sha512-utWOt/GHzuUxnLKxB6dk81RoOeoNeHgbrXiuGk4yyF5qlRz+iIVWu56E2fqGHFrXz0QNUhLB/8nKqvRH66JKGQ==, - } - engines: { node: ">=10" } + resolution: {integrity: sha512-utWOt/GHzuUxnLKxB6dk81RoOeoNeHgbrXiuGk4yyF5qlRz+iIVWu56E2fqGHFrXz0QNUhLB/8nKqvRH66JKGQ==} + engines: {node: '>=10'} locate-character@3.0.0: - resolution: - { - integrity: sha512-SW13ws7BjaeJ6p7Q6CO2nchbYEc3X3J6WrmTTDto7yMPqVSZTUyY5Tjbid+Ab8gLnATtygYtiDIJGQRRn2ZOiA==, - } + resolution: {integrity: sha512-SW13ws7BjaeJ6p7Q6CO2nchbYEc3X3J6WrmTTDto7yMPqVSZTUyY5Tjbid+Ab8gLnATtygYtiDIJGQRRn2ZOiA==} locate-path@6.0.0: - resolution: - { - integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==, - } - engines: { node: ">=10" } + resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==} + engines: {node: '>=10'} lodash.camelcase@4.3.0: - resolution: - { - integrity: sha512-TwuEnCnxbc3rAvhf/LbG7tJUDzhqXyFnv3dtzLOPgCG/hODL7WFnsbwktkD7yUV0RrreP/l1PALq/YSg6VvjlA==, - } + resolution: {integrity: sha512-TwuEnCnxbc3rAvhf/LbG7tJUDzhqXyFnv3dtzLOPgCG/hODL7WFnsbwktkD7yUV0RrreP/l1PALq/YSg6VvjlA==} lodash.merge@4.6.2: - resolution: - { - integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==, - } + resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==} long@5.3.2: - resolution: - { - integrity: sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==, - } + resolution: {integrity: sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==} loupe@3.2.1: - resolution: - { - integrity: sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==, - } + resolution: {integrity: sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==} lru-cache@10.4.3: - resolution: - { - integrity: sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==, - } + resolution: {integrity: sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==} + + lru-cache@11.5.2: + resolution: {integrity: sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==} + engines: {node: 20 || >=22} lucide-svelte@0.479.0: - resolution: - { - integrity: sha512-epCj6WL86ykxg7oCQTmPEth5e11pwJUzIfG9ROUsWsTP+WPtb3qat+VmAjfx/r4TRW7memTFcbTPvMrZvKthqw==, - } + resolution: {integrity: sha512-epCj6WL86ykxg7oCQTmPEth5e11pwJUzIfG9ROUsWsTP+WPtb3qat+VmAjfx/r4TRW7memTFcbTPvMrZvKthqw==} peerDependencies: svelte: ^3 || ^4 || ^5.0.0-next.42 lz-string@1.5.0: - resolution: - { - integrity: sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==, - } + resolution: {integrity: sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==} hasBin: true magic-string@0.30.19: - resolution: - { - integrity: sha512-2N21sPY9Ws53PZvsEpVtNuSW+ScYbQdp4b9qUaL+9QkHUrGFKo56Lg9Emg5s9V/qrtNBmiR01sYhUOwu3H+VOw==, - } + resolution: {integrity: sha512-2N21sPY9Ws53PZvsEpVtNuSW+ScYbQdp4b9qUaL+9QkHUrGFKo56Lg9Emg5s9V/qrtNBmiR01sYhUOwu3H+VOw==} magicast@0.3.5: - resolution: - { - integrity: sha512-L0WhttDl+2BOsybvEOLK7fW3UA0OQ0IQ2d6Zl2x/a6vVRs3bAY0ECOSHHeL5jD+SbOpOCUEi0y1DgHEn9Qn1AQ==, - } + resolution: {integrity: sha512-L0WhttDl+2BOsybvEOLK7fW3UA0OQ0IQ2d6Zl2x/a6vVRs3bAY0ECOSHHeL5jD+SbOpOCUEi0y1DgHEn9Qn1AQ==} make-dir@4.0.0: - resolution: - { - integrity: sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==, - } - engines: { node: ">=10" } + resolution: {integrity: sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==} + engines: {node: '>=10'} + + marked@18.0.9: + resolution: {integrity: sha512-/Sa4qiiHZxf0/FQdBBowr9q4r10krCwMvpK48FUBdXdUXScDxiQGR9zCPrFgRVR5LU3iySOiIjy09ZQvADir1w==} + engines: {node: '>= 20'} + hasBin: true + + mdn-data@2.27.1: + resolution: {integrity: sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==} merge2@1.4.1: - resolution: - { - integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==, - } - engines: { node: ">= 8" } + resolution: {integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==} + engines: {node: '>= 8'} micromatch@4.0.8: - resolution: - { - integrity: sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==, - } - engines: { node: ">=8.6" } + resolution: {integrity: sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==} + engines: {node: '>=8.6'} min-indent@1.0.1: - resolution: - { - integrity: sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==, - } - engines: { node: ">=4" } + resolution: {integrity: sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==} + engines: {node: '>=4'} minimatch@10.2.5: - resolution: - { - integrity: sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==, - } - engines: { node: 18 || 20 || >=22 } + resolution: {integrity: sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==} + engines: {node: 18 || 20 || >=22} minimatch@3.1.2: - resolution: - { - integrity: sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==, - } + resolution: {integrity: sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==} minimatch@9.0.5: - resolution: - { - integrity: sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==, - } - engines: { node: ">=16 || 14 >=14.17" } + resolution: {integrity: sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==} + engines: {node: '>=16 || 14 >=14.17'} minimist@1.2.8: - resolution: - { - integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==, - } + resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==} minipass@7.1.2: - resolution: - { - integrity: sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==, - } - engines: { node: ">=16 || 14 >=14.17" } + resolution: {integrity: sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==} + engines: {node: '>=16 || 14 >=14.17'} minizlib@3.1.0: - resolution: - { - integrity: sha512-KZxYo1BUkWD2TVFLr0MQoM8vUUigWD3LlD83a/75BqC+4qE0Hb1Vo5v1FgcfaNXvfXzr+5EhQ6ing/CaBijTlw==, - } - engines: { node: ">= 18" } + resolution: {integrity: sha512-KZxYo1BUkWD2TVFLr0MQoM8vUUigWD3LlD83a/75BqC+4qE0Hb1Vo5v1FgcfaNXvfXzr+5EhQ6ing/CaBijTlw==} + engines: {node: '>= 18'} mri@1.2.0: - resolution: - { - integrity: sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA==, - } - engines: { node: ">=4" } + resolution: {integrity: sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA==} + engines: {node: '>=4'} mrmime@2.0.1: - resolution: - { - integrity: sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ==, - } - engines: { node: ">=10" } + resolution: {integrity: sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ==} + engines: {node: '>=10'} ms@2.1.3: - resolution: - { - integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==, - } + resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} nanoid@3.3.11: - resolution: - { - integrity: sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==, - } - engines: { node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1 } + resolution: {integrity: sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==} + engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} hasBin: true natural-compare@1.4.0: - resolution: - { - integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==, - } + resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==} nice-grpc-common@2.0.3: - resolution: - { - integrity: sha512-MEhnD3JMah0mgyivpb9hpRDbOBuXBxI/TVO+OK1h6rC97WM42HsPMR+zzRNQ0C5BqYJTw1nyWiQRD0DucO+pjQ==, - } + resolution: {integrity: sha512-MEhnD3JMah0mgyivpb9hpRDbOBuXBxI/TVO+OK1h6rC97WM42HsPMR+zzRNQ0C5BqYJTw1nyWiQRD0DucO+pjQ==} nice-grpc@2.1.15: - resolution: - { - integrity: sha512-agPIt7dtQASnN5p1X3c2S5amDhWRWRT0Jp62trmpMBKSbkm87l8bG2slqxSthlrGa4AnRPG8+lFf4y8nn+Pogw==, - } + resolution: {integrity: sha512-agPIt7dtQASnN5p1X3c2S5amDhWRWRT0Jp62trmpMBKSbkm87l8bG2slqxSthlrGa4AnRPG8+lFf4y8nn+Pogw==} nwsapi@2.2.23: - resolution: - { - integrity: sha512-7wfH4sLbt4M0gCDzGE6vzQBo0bfTKjU7Sfpqy/7gs1qBfYz2vEJH6vXcBKpO3+6Yu1telwd0t9HpyOoLEQQbIQ==, - } + resolution: {integrity: sha512-7wfH4sLbt4M0gCDzGE6vzQBo0bfTKjU7Sfpqy/7gs1qBfYz2vEJH6vXcBKpO3+6Yu1telwd0t9HpyOoLEQQbIQ==} oauth4webapi@3.8.5: - resolution: - { - integrity: sha512-A8jmyUckVhRJj5lspguklcl90Ydqk61H3dcU0oLhH3Yv13KpAliKTt5hknpGGPZSSfOwGyraNEFmofDYH+1kSg==, - } + resolution: {integrity: sha512-A8jmyUckVhRJj5lspguklcl90Ydqk61H3dcU0oLhH3Yv13KpAliKTt5hknpGGPZSSfOwGyraNEFmofDYH+1kSg==} on-exit-leak-free@2.1.2: - resolution: - { - integrity: sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA==, - } - engines: { node: ">=14.0.0" } + resolution: {integrity: sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA==} + engines: {node: '>=14.0.0'} once@1.4.0: - resolution: - { - integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==, - } + resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==} optionator@0.9.4: - resolution: - { - integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==, - } - engines: { node: ">= 0.8.0" } + resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==} + engines: {node: '>= 0.8.0'} p-limit@3.1.0: - resolution: - { - integrity: sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==, - } - engines: { node: ">=10" } + resolution: {integrity: sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==} + engines: {node: '>=10'} p-locate@5.0.0: - resolution: - { - integrity: sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==, - } - engines: { node: ">=10" } + resolution: {integrity: sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==} + engines: {node: '>=10'} package-json-from-dist@1.0.1: - resolution: - { - integrity: sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==, - } + resolution: {integrity: sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==} parent-module@1.0.1: - resolution: - { - integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==, - } - engines: { node: ">=6" } + resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==} + engines: {node: '>=6'} parse5@7.3.0: - resolution: - { - integrity: sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==, - } + resolution: {integrity: sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==} + + parse5@8.0.1: + resolution: {integrity: sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==} path-exists@4.0.0: - resolution: - { - integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==, - } - engines: { node: ">=8" } + resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==} + engines: {node: '>=8'} path-key@3.1.1: - resolution: - { - integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==, - } - engines: { node: ">=8" } + resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} + engines: {node: '>=8'} path-parse@1.0.7: - resolution: - { - integrity: sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==, - } + resolution: {integrity: sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==} path-scurry@1.11.1: - resolution: - { - integrity: sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==, - } - engines: { node: ">=16 || 14 >=14.18" } + resolution: {integrity: sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==} + engines: {node: '>=16 || 14 >=14.18'} pathe@2.0.3: - resolution: - { - integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==, - } + resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} pathval@2.0.1: - resolution: - { - integrity: sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==, - } - engines: { node: ">= 14.16" } + resolution: {integrity: sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==} + engines: {node: '>= 14.16'} picocolors@1.1.1: - resolution: - { - integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==, - } + resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} picomatch@2.3.1: - resolution: - { - integrity: sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==, - } - engines: { node: ">=8.6" } + resolution: {integrity: sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==} + engines: {node: '>=8.6'} picomatch@4.0.3: - resolution: - { - integrity: sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==, - } - engines: { node: ">=12" } + resolution: {integrity: sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==} + engines: {node: '>=12'} pino-abstract-transport@2.0.0: - resolution: - { - integrity: sha512-F63x5tizV6WCh4R6RHyi2Ml+M70DNRXt/+HANowMflpgGFMAym/VKm6G7ZOQRjqN7XbGxK1Lg9t6ZrtzOaivMw==, - } + resolution: {integrity: sha512-F63x5tizV6WCh4R6RHyi2Ml+M70DNRXt/+HANowMflpgGFMAym/VKm6G7ZOQRjqN7XbGxK1Lg9t6ZrtzOaivMw==} pino-pretty@13.1.2: - resolution: - { - integrity: sha512-3cN0tCakkT4f3zo9RXDIhy6GTvtYD6bK4CRBLN9j3E/ePqN1tugAXD5rGVfoChW6s0hiek+eyYlLNqc/BG7vBQ==, - } + resolution: {integrity: sha512-3cN0tCakkT4f3zo9RXDIhy6GTvtYD6bK4CRBLN9j3E/ePqN1tugAXD5rGVfoChW6s0hiek+eyYlLNqc/BG7vBQ==} hasBin: true pino-std-serializers@7.0.0: - resolution: - { - integrity: sha512-e906FRY0+tV27iq4juKzSYPbUj2do2X2JX4EzSca1631EB2QJQUqGbDuERal7LCtOpxl6x3+nvo9NPZcmjkiFA==, - } + resolution: {integrity: sha512-e906FRY0+tV27iq4juKzSYPbUj2do2X2JX4EzSca1631EB2QJQUqGbDuERal7LCtOpxl6x3+nvo9NPZcmjkiFA==} pino@9.13.1: - resolution: - { - integrity: sha512-Szuj+ViDTjKPQYiKumGmEn3frdl+ZPSdosHyt9SnUevFosOkMY2b7ipxlEctNKPmMD/VibeBI+ZcZCJK+4DPuw==, - } + resolution: {integrity: sha512-Szuj+ViDTjKPQYiKumGmEn3frdl+ZPSdosHyt9SnUevFosOkMY2b7ipxlEctNKPmMD/VibeBI+ZcZCJK+4DPuw==} hasBin: true postcss-load-config@3.1.4: - resolution: - { - integrity: sha512-6DiM4E7v4coTE4uzA8U//WhtPwyhiim3eyjEMFCnUpzbrkK9wJHgKDT2mR+HbtSrd/NubVaYTOpSpjUl8NQeRg==, - } - engines: { node: ">= 10" } + resolution: {integrity: sha512-6DiM4E7v4coTE4uzA8U//WhtPwyhiim3eyjEMFCnUpzbrkK9wJHgKDT2mR+HbtSrd/NubVaYTOpSpjUl8NQeRg==} + engines: {node: '>= 10'} peerDependencies: - postcss: ">=8.0.9" - ts-node: ">=9.0.0" + postcss: '>=8.0.9' + ts-node: '>=9.0.0' peerDependenciesMeta: postcss: optional: true @@ -3266,107 +1990,80 @@ packages: optional: true postcss-safe-parser@7.0.1: - resolution: - { - integrity: sha512-0AioNCJZ2DPYz5ABT6bddIqlhgwhpHZ/l65YAYo0BCIn0xiDpsnTHz0gnoTGk0OXZW0JRs+cDwL8u/teRdz+8A==, - } - engines: { node: ">=18.0" } + resolution: {integrity: sha512-0AioNCJZ2DPYz5ABT6bddIqlhgwhpHZ/l65YAYo0BCIn0xiDpsnTHz0gnoTGk0OXZW0JRs+cDwL8u/teRdz+8A==} + engines: {node: '>=18.0'} peerDependencies: postcss: ^8.4.31 postcss-scss@4.0.9: - resolution: - { - integrity: sha512-AjKOeiwAitL/MXxQW2DliT28EKukvvbEWx3LBmJIRN8KfBGZbRTxNYW0kSqi1COiTZ57nZ9NW06S6ux//N1c9A==, - } - engines: { node: ">=12.0" } + resolution: {integrity: sha512-AjKOeiwAitL/MXxQW2DliT28EKukvvbEWx3LBmJIRN8KfBGZbRTxNYW0kSqi1COiTZ57nZ9NW06S6ux//N1c9A==} + engines: {node: '>=12.0'} peerDependencies: postcss: ^8.4.29 postcss-selector-parser@7.1.0: - resolution: - { - integrity: sha512-8sLjZwK0R+JlxlYcTuVnyT2v+htpdrjDOKuMcOVdYjt52Lh8hWRYpxBPoKx/Zg+bcjc3wx6fmQevMmUztS/ccA==, - } - engines: { node: ">=4" } + resolution: {integrity: sha512-8sLjZwK0R+JlxlYcTuVnyT2v+htpdrjDOKuMcOVdYjt52Lh8hWRYpxBPoKx/Zg+bcjc3wx6fmQevMmUztS/ccA==} + engines: {node: '>=4'} postcss@8.5.6: - resolution: - { - integrity: sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==, - } - engines: { node: ^10 || ^12 || >=14 } + resolution: {integrity: sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==} + engines: {node: ^10 || ^12 || >=14} preact-render-to-string@6.5.11: - resolution: - { - integrity: sha512-ubnauqoGczeGISiOh6RjX0/cdaF8v/oDXIjO85XALCQjwQP+SB4RDXXtvZ6yTYSjG+PC1QRP2AhPgCEsM2EvUw==, - } + resolution: {integrity: sha512-ubnauqoGczeGISiOh6RjX0/cdaF8v/oDXIjO85XALCQjwQP+SB4RDXXtvZ6yTYSjG+PC1QRP2AhPgCEsM2EvUw==} peerDependencies: - preact: ">=10" + preact: '>=10' preact@10.24.3: - resolution: - { - integrity: sha512-Z2dPnBnMUfyQfSQ+GBdsGa16hz35YmLmtTLhM169uW944hYL6xzTYkJjC07j+Wosz733pMWx0fgON3JNw1jJQA==, - } + resolution: {integrity: sha512-Z2dPnBnMUfyQfSQ+GBdsGa16hz35YmLmtTLhM169uW944hYL6xzTYkJjC07j+Wosz733pMWx0fgON3JNw1jJQA==} prelude-ls@1.2.1: - resolution: - { - integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==, - } - engines: { node: ">= 0.8.0" } + resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==} + engines: {node: '>= 0.8.0'} prettier-plugin-svelte@3.4.0: - resolution: - { - integrity: sha512-pn1ra/0mPObzqoIQn/vUTR3ZZI6UuZ0sHqMK5x2jMLGrs53h0sXhkVuDcrlssHwIMk7FYrMjHBPoUSyyEEDlBQ==, - } + resolution: {integrity: sha512-pn1ra/0mPObzqoIQn/vUTR3ZZI6UuZ0sHqMK5x2jMLGrs53h0sXhkVuDcrlssHwIMk7FYrMjHBPoUSyyEEDlBQ==} peerDependencies: prettier: ^3.0.0 svelte: ^3.2.0 || ^4.0.0-next.0 || ^5.0.0-next.0 prettier-plugin-tailwindcss@0.6.14: - resolution: - { - integrity: sha512-pi2e/+ZygeIqntN+vC573BcW5Cve8zUB0SSAGxqpB4f96boZF4M3phPVoOFCeypwkpRYdi7+jQ5YJJUwrkGUAg==, - } - engines: { node: ">=14.21.3" } + resolution: {integrity: sha512-pi2e/+ZygeIqntN+vC573BcW5Cve8zUB0SSAGxqpB4f96boZF4M3phPVoOFCeypwkpRYdi7+jQ5YJJUwrkGUAg==} + engines: {node: '>=14.21.3'} peerDependencies: - "@ianvs/prettier-plugin-sort-imports": "*" - "@prettier/plugin-hermes": "*" - "@prettier/plugin-oxc": "*" - "@prettier/plugin-pug": "*" - "@shopify/prettier-plugin-liquid": "*" - "@trivago/prettier-plugin-sort-imports": "*" - "@zackad/prettier-plugin-twig": "*" + '@ianvs/prettier-plugin-sort-imports': '*' + '@prettier/plugin-hermes': '*' + '@prettier/plugin-oxc': '*' + '@prettier/plugin-pug': '*' + '@shopify/prettier-plugin-liquid': '*' + '@trivago/prettier-plugin-sort-imports': '*' + '@zackad/prettier-plugin-twig': '*' prettier: ^3.0 - prettier-plugin-astro: "*" - prettier-plugin-css-order: "*" - prettier-plugin-import-sort: "*" - prettier-plugin-jsdoc: "*" - prettier-plugin-marko: "*" - prettier-plugin-multiline-arrays: "*" - prettier-plugin-organize-attributes: "*" - prettier-plugin-organize-imports: "*" - prettier-plugin-sort-imports: "*" - prettier-plugin-style-order: "*" - prettier-plugin-svelte: "*" + prettier-plugin-astro: '*' + prettier-plugin-css-order: '*' + prettier-plugin-import-sort: '*' + prettier-plugin-jsdoc: '*' + prettier-plugin-marko: '*' + prettier-plugin-multiline-arrays: '*' + prettier-plugin-organize-attributes: '*' + prettier-plugin-organize-imports: '*' + prettier-plugin-sort-imports: '*' + prettier-plugin-style-order: '*' + prettier-plugin-svelte: '*' peerDependenciesMeta: - "@ianvs/prettier-plugin-sort-imports": + '@ianvs/prettier-plugin-sort-imports': optional: true - "@prettier/plugin-hermes": + '@prettier/plugin-hermes': optional: true - "@prettier/plugin-oxc": + '@prettier/plugin-oxc': optional: true - "@prettier/plugin-pug": + '@prettier/plugin-pug': optional: true - "@shopify/prettier-plugin-liquid": + '@shopify/prettier-plugin-liquid': optional: true - "@trivago/prettier-plugin-sort-imports": + '@trivago/prettier-plugin-sort-imports': optional: true - "@zackad/prettier-plugin-twig": + '@zackad/prettier-plugin-twig': optional: true prettier-plugin-astro: optional: true @@ -3392,345 +2089,196 @@ packages: optional: true prettier@3.6.2: - resolution: - { - integrity: sha512-I7AIg5boAr5R0FFtJ6rCfD+LFsWHp81dolrFD8S79U9tb8Az2nGrJncnMSnys+bpQJfRUzqs9hnA81OAA3hCuQ==, - } - engines: { node: ">=14" } + resolution: {integrity: sha512-I7AIg5boAr5R0FFtJ6rCfD+LFsWHp81dolrFD8S79U9tb8Az2nGrJncnMSnys+bpQJfRUzqs9hnA81OAA3hCuQ==} + engines: {node: '>=14'} hasBin: true pretty-format@27.5.1: - resolution: - { - integrity: sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==, - } - engines: { node: ^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0 } + resolution: {integrity: sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==} + engines: {node: ^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0} process-warning@5.0.0: - resolution: - { - integrity: sha512-a39t9ApHNx2L4+HBnQKqxxHNs1r7KF+Intd8Q/g1bUh6q0WIp9voPXJ/x0j+ZL45KF1pJd9+q2jLIRMfvEshkA==, - } + resolution: {integrity: sha512-a39t9ApHNx2L4+HBnQKqxxHNs1r7KF+Intd8Q/g1bUh6q0WIp9voPXJ/x0j+ZL45KF1pJd9+q2jLIRMfvEshkA==} protobufjs@7.5.4: - resolution: - { - integrity: sha512-CvexbZtbov6jW2eXAvLukXjXUW1TzFaivC46BpWc/3BpcCysb5Vffu+B3XHMm8lVEuy2Mm4XGex8hBSg1yapPg==, - } - engines: { node: ">=12.0.0" } - - proxy-compare@3.0.1: - resolution: - { - integrity: sha512-V9plBAt3qjMlS1+nC8771KNf6oJ12gExvaxnNzN/9yVRLdTv/lc+oJlnSzrdYDAvBfTStPCoiaCOTmTs0adv7Q==, - } + resolution: {integrity: sha512-CvexbZtbov6jW2eXAvLukXjXUW1TzFaivC46BpWc/3BpcCysb5Vffu+B3XHMm8lVEuy2Mm4XGex8hBSg1yapPg==} + engines: {node: '>=12.0.0'} pump@3.0.3: - resolution: - { - integrity: sha512-todwxLMY7/heScKmntwQG8CXVkWUOdYxIvY2s0VWAAMh/nd8SoYiRaKjlr7+iCs984f2P8zvrfWcDDYVb73NfA==, - } + resolution: {integrity: sha512-todwxLMY7/heScKmntwQG8CXVkWUOdYxIvY2s0VWAAMh/nd8SoYiRaKjlr7+iCs984f2P8zvrfWcDDYVb73NfA==} punycode@2.3.1: - resolution: - { - integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==, - } - engines: { node: ">=6" } + resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} + engines: {node: '>=6'} queue-microtask@1.2.3: - resolution: - { - integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==, - } + resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==} quick-format-unescaped@4.0.4: - resolution: - { - integrity: sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==, - } + resolution: {integrity: sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==} react-is@17.0.2: - resolution: - { - integrity: sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==, - } + resolution: {integrity: sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==} readdirp@4.1.2: - resolution: - { - integrity: sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==, - } - engines: { node: ">= 14.18.0" } + resolution: {integrity: sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==} + engines: {node: '>= 14.18.0'} real-require@0.2.0: - resolution: - { - integrity: sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg==, - } - engines: { node: ">= 12.13.0" } + resolution: {integrity: sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg==} + engines: {node: '>= 12.13.0'} redent@3.0.0: - resolution: - { - integrity: sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==, - } - engines: { node: ">=8" } + resolution: {integrity: sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==} + engines: {node: '>=8'} require-directory@2.1.1: - resolution: - { - integrity: sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==, - } - engines: { node: ">=0.10.0" } + resolution: {integrity: sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==} + engines: {node: '>=0.10.0'} + + require-from-string@2.0.2: + resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} + engines: {node: '>=0.10.0'} resolve-from@4.0.0: - resolution: - { - integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==, - } - engines: { node: ">=4" } + resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==} + engines: {node: '>=4'} resolve@1.22.10: - resolution: - { - integrity: sha512-NPRy+/ncIMeDlTAsuqwKIiferiawhefFJtkNSW0qZJEqMEb+qBt/77B/jGeeek+F0uOeN05CDa6HXbbIgtVX4w==, - } - engines: { node: ">= 0.4" } + resolution: {integrity: sha512-NPRy+/ncIMeDlTAsuqwKIiferiawhefFJtkNSW0qZJEqMEb+qBt/77B/jGeeek+F0uOeN05CDa6HXbbIgtVX4w==} + engines: {node: '>= 0.4'} hasBin: true reusify@1.1.0: - resolution: - { - integrity: sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==, - } - engines: { iojs: ">=1.0.0", node: ">=0.10.0" } + resolution: {integrity: sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==} + engines: {iojs: '>=1.0.0', node: '>=0.10.0'} rollup@4.52.4: - resolution: - { - integrity: sha512-CLEVl+MnPAiKh5pl4dEWSyMTpuflgNQiLGhMv8ezD5W/qP8AKvmYpCOKRRNOh7oRKnauBZ4SyeYkMS+1VSyKwQ==, - } - engines: { node: ">=18.0.0", npm: ">=8.0.0" } + resolution: {integrity: sha512-CLEVl+MnPAiKh5pl4dEWSyMTpuflgNQiLGhMv8ezD5W/qP8AKvmYpCOKRRNOh7oRKnauBZ4SyeYkMS+1VSyKwQ==} + engines: {node: '>=18.0.0', npm: '>=8.0.0'} hasBin: true rrweb-cssom@0.8.0: - resolution: - { - integrity: sha512-guoltQEx+9aMf2gDZ0s62EcV8lsXR+0w8915TC3ITdn2YueuNjdAYh/levpU9nFaoChh9RUS5ZdQMrKfVEN9tw==, - } + resolution: {integrity: sha512-guoltQEx+9aMf2gDZ0s62EcV8lsXR+0w8915TC3ITdn2YueuNjdAYh/levpU9nFaoChh9RUS5ZdQMrKfVEN9tw==} run-parallel@1.2.0: - resolution: - { - integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==, - } + resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==} sade@1.8.1: - resolution: - { - integrity: sha512-xal3CZX1Xlo/k4ApwCFrHVACi9fBqJ7V+mwhBsuf/1IOKbBy098Fex+Wa/5QMubw09pSZ/u8EY8PWgevJsXp1A==, - } - engines: { node: ">=6" } + resolution: {integrity: sha512-xal3CZX1Xlo/k4ApwCFrHVACi9fBqJ7V+mwhBsuf/1IOKbBy098Fex+Wa/5QMubw09pSZ/u8EY8PWgevJsXp1A==} + engines: {node: '>=6'} safe-stable-stringify@2.5.0: - resolution: - { - integrity: sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA==, - } - engines: { node: ">=10" } + resolution: {integrity: sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA==} + engines: {node: '>=10'} safer-buffer@2.1.2: - resolution: - { - integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==, - } + resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} saxes@6.0.0: - resolution: - { - integrity: sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==, - } - engines: { node: ">=v12.22.7" } + resolution: {integrity: sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==} + engines: {node: '>=v12.22.7'} secure-json-parse@4.1.0: - resolution: - { - integrity: sha512-l4KnYfEyqYJxDwlNVyRfO2E4NTHfMKAWdUuA8J0yve2Dz/E/PdBepY03RvyJpssIpRFwJoCD55wA+mEDs6ByWA==, - } + resolution: {integrity: sha512-l4KnYfEyqYJxDwlNVyRfO2E4NTHfMKAWdUuA8J0yve2Dz/E/PdBepY03RvyJpssIpRFwJoCD55wA+mEDs6ByWA==} semver@7.7.3: - resolution: - { - integrity: sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==, - } - engines: { node: ">=10" } + resolution: {integrity: sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==} + engines: {node: '>=10'} hasBin: true set-cookie-parser@2.7.1: - resolution: - { - integrity: sha512-IOc8uWeOZgnb3ptbCURJWNjWUPcO3ZnTTdzsurqERrP6nPyv+paC55vJM0LpOlT2ne+Ix+9+CRG1MNLlyZ4GjQ==, - } + resolution: {integrity: sha512-IOc8uWeOZgnb3ptbCURJWNjWUPcO3ZnTTdzsurqERrP6nPyv+paC55vJM0LpOlT2ne+Ix+9+CRG1MNLlyZ4GjQ==} shebang-command@2.0.0: - resolution: - { - integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==, - } - engines: { node: ">=8" } + resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==} + engines: {node: '>=8'} shebang-regex@3.0.0: - resolution: - { - integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==, - } - engines: { node: ">=8" } + resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==} + engines: {node: '>=8'} siginfo@2.0.0: - resolution: - { - integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==, - } + resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} signal-exit@4.1.0: - resolution: - { - integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==, - } - engines: { node: ">=14" } + resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==} + engines: {node: '>=14'} sirv@3.0.2: - resolution: - { - integrity: sha512-2wcC/oGxHis/BoHkkPwldgiPSYcpZK3JU28WoMVv55yHJgcZ8rlXvuG9iZggz+sU1d4bRgIGASwyWqjxu3FM0g==, - } - engines: { node: ">=18" } + resolution: {integrity: sha512-2wcC/oGxHis/BoHkkPwldgiPSYcpZK3JU28WoMVv55yHJgcZ8rlXvuG9iZggz+sU1d4bRgIGASwyWqjxu3FM0g==} + engines: {node: '>=18'} slow-redact@0.3.2: - resolution: - { - integrity: sha512-MseHyi2+E/hBRqdOi5COy6wZ7j7DxXRz9NkseavNYSvvWC06D8a5cidVZX3tcG5eCW3NIyVU4zT63hw0Q486jw==, - } + resolution: {integrity: sha512-MseHyi2+E/hBRqdOi5COy6wZ7j7DxXRz9NkseavNYSvvWC06D8a5cidVZX3tcG5eCW3NIyVU4zT63hw0Q486jw==} sonic-boom@4.2.0: - resolution: - { - integrity: sha512-INb7TM37/mAcsGmc9hyyI6+QR3rR1zVRu36B0NeGXKnOOLiZOfER5SA+N7X7k3yUYRzLWafduTDvJAfDswwEww==, - } + resolution: {integrity: sha512-INb7TM37/mAcsGmc9hyyI6+QR3rR1zVRu36B0NeGXKnOOLiZOfER5SA+N7X7k3yUYRzLWafduTDvJAfDswwEww==} source-map-js@1.2.1: - resolution: - { - integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==, - } - engines: { node: ">=0.10.0" } + resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} + engines: {node: '>=0.10.0'} split2@4.2.0: - resolution: - { - integrity: sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==, - } - engines: { node: ">= 10.x" } + resolution: {integrity: sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==} + engines: {node: '>= 10.x'} stackback@0.0.2: - resolution: - { - integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==, - } + resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} std-env@3.10.0: - resolution: - { - integrity: sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==, - } + resolution: {integrity: sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==} string-width@4.2.3: - resolution: - { - integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==, - } - engines: { node: ">=8" } + resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==} + engines: {node: '>=8'} string-width@5.1.2: - resolution: - { - integrity: sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==, - } - engines: { node: ">=12" } + resolution: {integrity: sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==} + engines: {node: '>=12'} strip-ansi@6.0.1: - resolution: - { - integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==, - } - engines: { node: ">=8" } + resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==} + engines: {node: '>=8'} strip-ansi@7.2.0: - resolution: - { - integrity: sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==, - } - engines: { node: ">=12" } + resolution: {integrity: sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==} + engines: {node: '>=12'} strip-indent@3.0.0: - resolution: - { - integrity: sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==, - } - engines: { node: ">=8" } + resolution: {integrity: sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==} + engines: {node: '>=8'} strip-json-comments@3.1.1: - resolution: - { - integrity: sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==, - } - engines: { node: ">=8" } + resolution: {integrity: sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==} + engines: {node: '>=8'} strip-json-comments@5.0.3: - resolution: - { - integrity: sha512-1tB5mhVo7U+ETBKNf92xT4hrQa3pm0MZ0PQvuDnWgAAGHDsfp4lPSpiS6psrSiet87wyGPh9ft6wmhOMQ0hDiw==, - } - engines: { node: ">=14.16" } + resolution: {integrity: sha512-1tB5mhVo7U+ETBKNf92xT4hrQa3pm0MZ0PQvuDnWgAAGHDsfp4lPSpiS6psrSiet87wyGPh9ft6wmhOMQ0hDiw==} + engines: {node: '>=14.16'} strip-literal@3.1.0: - resolution: - { - integrity: sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg==, - } + resolution: {integrity: sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg==} supports-color@7.2.0: - resolution: - { - integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==, - } - engines: { node: ">=8" } + resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==} + engines: {node: '>=8'} supports-preserve-symlinks-flag@1.0.0: - resolution: - { - integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==, - } - engines: { node: ">= 0.4" } + resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==} + engines: {node: '>= 0.4'} svelte-check@4.3.3: - resolution: - { - integrity: sha512-RYP0bEwenDXzfv0P1sKAwjZSlaRyqBn0Fz1TVni58lqyEiqgwztTpmodJrGzP6ZT2aHl4MbTvWP6gbmQ3FOnBg==, - } - engines: { node: ">= 18.0.0" } + resolution: {integrity: sha512-RYP0bEwenDXzfv0P1sKAwjZSlaRyqBn0Fz1TVni58lqyEiqgwztTpmodJrGzP6ZT2aHl4MbTvWP6gbmQ3FOnBg==} + engines: {node: '>= 18.0.0'} hasBin: true peerDependencies: svelte: ^4.0.0 || ^5.0.0-next.0 - typescript: ">=5.0.0" + typescript: '>=5.0.0' svelte-eslint-parser@1.3.3: - resolution: - { - integrity: sha512-oTrDR8Z7Wnguut7QH3YKh7JR19xv1seB/bz4dxU5J/86eJtZOU4eh0/jZq4dy6tAlz/KROxnkRQspv5ZEt7t+Q==, - } - engines: { node: ^18.18.0 || ^20.9.0 || >=21.1.0 } + resolution: {integrity: sha512-oTrDR8Z7Wnguut7QH3YKh7JR19xv1seB/bz4dxU5J/86eJtZOU4eh0/jZq4dy6tAlz/KROxnkRQspv5ZEt7t+Q==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: svelte: ^3.37.0 || ^4.0.0 || ^5.0.0 peerDependenciesMeta: @@ -3738,245 +2286,165 @@ packages: optional: true svelte@5.39.12: - resolution: - { - integrity: sha512-CEzwxFuEycokU8K8CE/OuwVbmei+ivu2HvBGYIdASfMa1hCRSNr4RRkzNSvbAvu6h+BOig2CsZTAEY+WKvwZpA==, - } - engines: { node: ">=18" } + resolution: {integrity: sha512-CEzwxFuEycokU8K8CE/OuwVbmei+ivu2HvBGYIdASfMa1hCRSNr4RRkzNSvbAvu6h+BOig2CsZTAEY+WKvwZpA==} + engines: {node: '>=18'} symbol-tree@3.2.4: - resolution: - { - integrity: sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==, - } + resolution: {integrity: sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==} tailwindcss@4.1.14: - resolution: - { - integrity: sha512-b7pCxjGO98LnxVkKjaZSDeNuljC4ueKUddjENJOADtubtdo8llTaJy7HwBMeLNSSo2N5QIAgklslK1+Ir8r6CA==, - } + resolution: {integrity: sha512-b7pCxjGO98LnxVkKjaZSDeNuljC4ueKUddjENJOADtubtdo8llTaJy7HwBMeLNSSo2N5QIAgklslK1+Ir8r6CA==} tapable@2.3.0: - resolution: - { - integrity: sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg==, - } - engines: { node: ">=6" } + resolution: {integrity: sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg==} + engines: {node: '>=6'} tar@7.5.1: - resolution: - { - integrity: sha512-nlGpxf+hv0v7GkWBK2V9spgactGOp0qvfWRxUMjqHyzrt3SgwE48DIv/FhqPHJYLHpgW1opq3nERbz5Anq7n1g==, - } - engines: { node: ">=18" } + resolution: {integrity: sha512-nlGpxf+hv0v7GkWBK2V9spgactGOp0qvfWRxUMjqHyzrt3SgwE48DIv/FhqPHJYLHpgW1opq3nERbz5Anq7n1g==} + engines: {node: '>=18'} test-exclude@7.0.2: - resolution: - { - integrity: sha512-u9E6A+ZDYdp7a4WnarkXPZOx8Ilz46+kby6p1yZ8zsGTz9gYa6FIS7lj2oezzNKmtdyyJNNmmXDppga5GB7kSw==, - } - engines: { node: ">=18" } + resolution: {integrity: sha512-u9E6A+ZDYdp7a4WnarkXPZOx8Ilz46+kby6p1yZ8zsGTz9gYa6FIS7lj2oezzNKmtdyyJNNmmXDppga5GB7kSw==} + engines: {node: '>=18'} thread-stream@3.1.0: - resolution: - { - integrity: sha512-OqyPZ9u96VohAyMfJykzmivOrY2wfMSf3C5TtFJVgN+Hm6aj+voFhlK+kZEIv2FBh1X6Xp3DlnCOfEQ3B2J86A==, - } + resolution: {integrity: sha512-OqyPZ9u96VohAyMfJykzmivOrY2wfMSf3C5TtFJVgN+Hm6aj+voFhlK+kZEIv2FBh1X6Xp3DlnCOfEQ3B2J86A==} tinybench@2.9.0: - resolution: - { - integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==, - } + resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} tinyexec@0.3.2: - resolution: - { - integrity: sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==, - } + resolution: {integrity: sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==} tinyglobby@0.2.15: - resolution: - { - integrity: sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==, - } - engines: { node: ">=12.0.0" } + resolution: {integrity: sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==} + engines: {node: '>=12.0.0'} tinypool@1.1.1: - resolution: - { - integrity: sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==, - } - engines: { node: ^18.0.0 || >=20.0.0 } + resolution: {integrity: sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==} + engines: {node: ^18.0.0 || >=20.0.0} tinyrainbow@2.0.0: - resolution: - { - integrity: sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw==, - } - engines: { node: ">=14.0.0" } + resolution: {integrity: sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw==} + engines: {node: '>=14.0.0'} tinyspy@4.0.4: - resolution: - { - integrity: sha512-azl+t0z7pw/z958Gy9svOTuzqIk6xq+NSheJzn5MMWtWTFywIacg2wUlzKFGtt3cthx0r2SxMK0yzJOR0IES7Q==, - } - engines: { node: ">=14.0.0" } + resolution: {integrity: sha512-azl+t0z7pw/z958Gy9svOTuzqIk6xq+NSheJzn5MMWtWTFywIacg2wUlzKFGtt3cthx0r2SxMK0yzJOR0IES7Q==} + engines: {node: '>=14.0.0'} tldts-core@6.1.86: - resolution: - { - integrity: sha512-Je6p7pkk+KMzMv2XXKmAE3McmolOQFdxkKw0R8EYNr7sELW46JqnNeTX8ybPiQgvg1ymCoF8LXs5fzFaZvJPTA==, - } + resolution: {integrity: sha512-Je6p7pkk+KMzMv2XXKmAE3McmolOQFdxkKw0R8EYNr7sELW46JqnNeTX8ybPiQgvg1ymCoF8LXs5fzFaZvJPTA==} + + tldts-core@7.4.10: + resolution: {integrity: sha512-KnQjp53ZekKgm/r3l+u8kJGGzYgrWdP8+Mql7a4vijh2WE0IrZWspQj/TpTxDho/YxO+AnOZnIjQcCD+q6iJsw==} tldts@6.1.86: - resolution: - { - integrity: sha512-WMi/OQ2axVTf/ykqCQgXiIct+mSQDFdH2fkwhPwgEwvJ1kSzZRiinb0zF2Xb8u4+OqPChmyI6MEu4EezNJz+FQ==, - } + resolution: {integrity: sha512-WMi/OQ2axVTf/ykqCQgXiIct+mSQDFdH2fkwhPwgEwvJ1kSzZRiinb0zF2Xb8u4+OqPChmyI6MEu4EezNJz+FQ==} + hasBin: true + + tldts@7.4.10: + resolution: {integrity: sha512-GgouD1B+sWwvkaEq8vXC15DjQitxbvs12oIXELpconwm+Tg3zfcEv4jgzq3vtKverDXsg3VI8aRgNL2Nra0Iog==} hasBin: true to-regex-range@5.0.1: - resolution: - { - integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==, - } - engines: { node: ">=8.0" } + resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} + engines: {node: '>=8.0'} totalist@3.0.1: - resolution: - { - integrity: sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ==, - } - engines: { node: ">=6" } + resolution: {integrity: sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ==} + engines: {node: '>=6'} tough-cookie@5.1.2: - resolution: - { - integrity: sha512-FVDYdxtnj0G6Qm/DhNPSb8Ju59ULcup3tuJxkFb5K8Bv2pUXILbf0xZWU8PX8Ov19OXljbUyveOFwRMwkXzO+A==, - } - engines: { node: ">=16" } + resolution: {integrity: sha512-FVDYdxtnj0G6Qm/DhNPSb8Ju59ULcup3tuJxkFb5K8Bv2pUXILbf0xZWU8PX8Ov19OXljbUyveOFwRMwkXzO+A==} + engines: {node: '>=16'} + + tough-cookie@6.0.2: + resolution: {integrity: sha512-exgYmnmL/sJpR3upZfXG5PoatXQii55xAiXGXzY+sROLZ/Y+SLcp9PgJNI9Vz37HpQ74WvDcLT8eqm+kV3FzrA==} + engines: {node: '>=16'} tr46@5.1.1: - resolution: - { - integrity: sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw==, - } - engines: { node: ">=18" } + resolution: {integrity: sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw==} + engines: {node: '>=18'} + + tr46@6.0.0: + resolution: {integrity: sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==} + engines: {node: '>=20'} ts-api-utils@2.1.0: - resolution: - { - integrity: sha512-CUgTZL1irw8u29bzrOD/nH85jqyc74D6SshFgujOIA7osm2Rz7dYH77agkx7H4FBNxDq7Cjf+IjaX/8zwFW+ZQ==, - } - engines: { node: ">=18.12" } + resolution: {integrity: sha512-CUgTZL1irw8u29bzrOD/nH85jqyc74D6SshFgujOIA7osm2Rz7dYH77agkx7H4FBNxDq7Cjf+IjaX/8zwFW+ZQ==} + engines: {node: '>=18.12'} peerDependencies: - typescript: ">=4.8.4" + typescript: '>=4.8.4' ts-error@1.0.6: - resolution: - { - integrity: sha512-tLJxacIQUM82IR7JO1UUkKlYuUTmoY9HBJAmNWFzheSlDS5SPMcNIepejHJa4BpPQLAcbRhRf3GDJzyj6rbKvA==, - } + resolution: {integrity: sha512-tLJxacIQUM82IR7JO1UUkKlYuUTmoY9HBJAmNWFzheSlDS5SPMcNIepejHJa4BpPQLAcbRhRf3GDJzyj6rbKvA==} ts-poet@6.12.0: - resolution: - { - integrity: sha512-xo+iRNMWqyvXpFTaOAvLPA5QAWO6TZrSUs5s4Odaya3epqofBu/fMLHEWl8jPmjhA0s9sgj9sNvF1BmaQlmQkA==, - } + resolution: {integrity: sha512-xo+iRNMWqyvXpFTaOAvLPA5QAWO6TZrSUs5s4Odaya3epqofBu/fMLHEWl8jPmjhA0s9sgj9sNvF1BmaQlmQkA==} ts-proto-descriptors@2.1.0: - resolution: - { - integrity: sha512-S5EZYEQ6L9KLFfjSRpZWDIXDV/W7tAj8uW7pLsihIxyr62EAVSiKuVPwE8iWnr849Bqa53enex1jhDUcpgquzA==, - } + resolution: {integrity: sha512-S5EZYEQ6L9KLFfjSRpZWDIXDV/W7tAj8uW7pLsihIxyr62EAVSiKuVPwE8iWnr849Bqa53enex1jhDUcpgquzA==} ts-proto@2.11.6: - resolution: - { - integrity: sha512-2rPkH5W/KeXOyVUC6o06RdRabVK8zSDmQpnRz4XbRiYMHRdI12KqDjAdGW7ebxzzMNE5cw/j+ptA0WMVqZILrQ==, - } + resolution: {integrity: sha512-2rPkH5W/KeXOyVUC6o06RdRabVK8zSDmQpnRz4XbRiYMHRdI12KqDjAdGW7ebxzzMNE5cw/j+ptA0WMVqZILrQ==} hasBin: true type-check@0.4.0: - resolution: - { - integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==, - } - engines: { node: ">= 0.8.0" } + resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==} + engines: {node: '>= 0.8.0'} typescript-eslint@8.46.1: - resolution: - { - integrity: sha512-VHgijW803JafdSsDO8I761r3SHrgk4T00IdyQ+/UsthtgPRsBWQLqoSxOolxTpxRKi1kGXK0bSz4CoAc9ObqJA==, - } - engines: { node: ^18.18.0 || ^20.9.0 || >=21.1.0 } + resolution: {integrity: sha512-VHgijW803JafdSsDO8I761r3SHrgk4T00IdyQ+/UsthtgPRsBWQLqoSxOolxTpxRKi1kGXK0bSz4CoAc9ObqJA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: eslint: ^8.57.0 || ^9.0.0 - typescript: ">=4.8.4 <6.0.0" + typescript: '>=4.8.4 <6.0.0' typescript@5.9.3: - resolution: - { - integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==, - } - engines: { node: ">=14.17" } + resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} + engines: {node: '>=14.17'} hasBin: true typical@7.3.0: - resolution: - { - integrity: sha512-ya4mg/30vm+DOWfBg4YK3j2WD6TWtRkCbasOJr40CseYENzCUby/7rIvXA99JGsQHeNxLbnXdyLLxKSv3tauFw==, - } - engines: { node: ">=12.17" } + resolution: {integrity: sha512-ya4mg/30vm+DOWfBg4YK3j2WD6TWtRkCbasOJr40CseYENzCUby/7rIvXA99JGsQHeNxLbnXdyLLxKSv3tauFw==} + engines: {node: '>=12.17'} undici-types@6.21.0: - resolution: - { - integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==, - } + resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==} + + undici@8.10.0: + resolution: {integrity: sha512-HvltHd7avK13QIw/oLe4qoOLyoVSoafqJ2jYOrtMRBkbYT31eiBQ8O0ehRKZiEZCMEyLFQNIADpgCWC5fALvYQ==} + engines: {node: '>=22.19.0'} uri-js@4.4.1: - resolution: - { - integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==, - } + resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==} util-deprecate@1.0.2: - resolution: - { - integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==, - } + resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} vite-node@3.2.4: - resolution: - { - integrity: sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg==, - } - engines: { node: ^18.0.0 || ^20.0.0 || >=22.0.0 } + resolution: {integrity: sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg==} + engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0} hasBin: true vite@6.4.1: - resolution: - { - integrity: sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g==, - } - engines: { node: ^18.0.0 || ^20.0.0 || >=22.0.0 } + resolution: {integrity: sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g==} + engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0} hasBin: true peerDependencies: - "@types/node": ^18.0.0 || ^20.0.0 || >=22.0.0 - jiti: ">=1.21.0" - less: "*" + '@types/node': ^18.0.0 || ^20.0.0 || >=22.0.0 + jiti: '>=1.21.0' + less: '*' lightningcss: ^1.21.0 - sass: "*" - sass-embedded: "*" - stylus: "*" - sugarss: "*" + sass: '*' + sass-embedded: '*' + stylus: '*' + sugarss: '*' terser: ^5.16.0 tsx: ^4.8.1 yaml: ^2.4.2 peerDependenciesMeta: - "@types/node": + '@types/node': optional: true jiti: optional: true @@ -4000,10 +2468,7 @@ packages: optional: true vitefu@1.1.1: - resolution: - { - integrity: sha512-B/Fegf3i8zh0yFbpzZ21amWzHmuNlLlmJT6n7bu5e+pCHUKQIfXSYokrqOBGEMMe9UG2sostKQF9mml/vYaWJQ==, - } + resolution: {integrity: sha512-B/Fegf3i8zh0yFbpzZ21amWzHmuNlLlmJT6n7bu5e+pCHUKQIfXSYokrqOBGEMMe9UG2sostKQF9mml/vYaWJQ==} peerDependencies: vite: ^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0-beta.0 peerDependenciesMeta: @@ -4011,30 +2476,27 @@ packages: optional: true vitest@3.2.4: - resolution: - { - integrity: sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A==, - } - engines: { node: ^18.0.0 || ^20.0.0 || >=22.0.0 } + resolution: {integrity: sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A==} + engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0} hasBin: true peerDependencies: - "@edge-runtime/vm": "*" - "@types/debug": ^4.1.12 - "@types/node": ^18.0.0 || ^20.0.0 || >=22.0.0 - "@vitest/browser": 3.2.4 - "@vitest/ui": 3.2.4 - happy-dom: "*" - jsdom: "*" + '@edge-runtime/vm': '*' + '@types/debug': ^4.1.12 + '@types/node': ^18.0.0 || ^20.0.0 || >=22.0.0 + '@vitest/browser': 3.2.4 + '@vitest/ui': 3.2.4 + happy-dom: '*' + jsdom: '*' peerDependenciesMeta: - "@edge-runtime/vm": + '@edge-runtime/vm': optional: true - "@types/debug": + '@types/debug': optional: true - "@types/node": + '@types/node': optional: true - "@vitest/browser": + '@vitest/browser': optional: true - "@vitest/ui": + '@vitest/ui': optional: true happy-dom: optional: true @@ -4042,93 +2504,76 @@ packages: optional: true w3c-xmlserializer@5.0.0: - resolution: - { - integrity: sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==, - } - engines: { node: ">=18" } + resolution: {integrity: sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==} + engines: {node: '>=18'} + + web-vitals@5.3.0: + resolution: {integrity: sha512-q6LWsLatGYZp5VGBIOvbTj6JBV2nOmC8KvWztXBmwJcfFAzhwKwbOxhUH306XY3CcaZDUlSmSuNPBsCn0bFu+g==} webidl-conversions@7.0.0: - resolution: - { - integrity: sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==, - } - engines: { node: ">=12" } + resolution: {integrity: sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==} + engines: {node: '>=12'} + + webidl-conversions@8.0.1: + resolution: {integrity: sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==} + engines: {node: '>=20'} whatwg-encoding@3.1.1: - resolution: - { - integrity: sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==, - } - engines: { node: ">=18" } + resolution: {integrity: sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==} + engines: {node: '>=18'} deprecated: Use @exodus/bytes instead for a more spec-conformant and faster implementation whatwg-mimetype@4.0.0: - resolution: - { - integrity: sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==, - } - engines: { node: ">=18" } + resolution: {integrity: sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==} + engines: {node: '>=18'} + + whatwg-mimetype@5.0.0: + resolution: {integrity: sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==} + engines: {node: '>=20'} whatwg-url@14.2.0: - resolution: - { - integrity: sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw==, - } - engines: { node: ">=18" } + resolution: {integrity: sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw==} + engines: {node: '>=18'} + + whatwg-url@16.0.1: + resolution: {integrity: sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + + whatwg-url@17.1.0: + resolution: {integrity: sha512-3GeworPmc2ZfEEHP7lEbUfBX/L75wdEsi0rLNhXcXxnoN5jyq0SL5gCy06SGW2cyTIZdTvWIDQNQoza++vKeaw==} + engines: {node: ^22.14.0 || >=24.0.0} which@2.0.2: - resolution: - { - integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==, - } - engines: { node: ">= 8" } + resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==} + engines: {node: '>= 8'} hasBin: true why-is-node-running@2.3.0: - resolution: - { - integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==, - } - engines: { node: ">=8" } + resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==} + engines: {node: '>=8'} hasBin: true word-wrap@1.2.5: - resolution: - { - integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==, - } - engines: { node: ">=0.10.0" } + resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==} + engines: {node: '>=0.10.0'} wrap-ansi@7.0.0: - resolution: - { - integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==, - } - engines: { node: ">=10" } + resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==} + engines: {node: '>=10'} wrap-ansi@8.1.0: - resolution: - { - integrity: sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==, - } - engines: { node: ">=12" } + resolution: {integrity: sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==} + engines: {node: '>=12'} wrappy@1.0.2: - resolution: - { - integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==, - } + resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} ws@8.20.0: - resolution: - { - integrity: sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA==, - } - engines: { node: ">=10.0.0" } + resolution: {integrity: sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA==} + engines: {node: '>=10.0.0'} peerDependencies: bufferutil: ^4.0.1 - utf-8-validate: ">=5.0.2" + utf-8-validate: '>=5.0.2' peerDependenciesMeta: bufferutil: optional: true @@ -4136,272 +2581,283 @@ packages: optional: true xml-name-validator@5.0.0: - resolution: - { - integrity: sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==, - } - engines: { node: ">=18" } + resolution: {integrity: sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==} + engines: {node: '>=18'} xmlchars@2.2.0: - resolution: - { - integrity: sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==, - } + resolution: {integrity: sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==} y18n@5.0.8: - resolution: - { - integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==, - } - engines: { node: ">=10" } + resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==} + engines: {node: '>=10'} yallist@5.0.0: - resolution: - { - integrity: sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw==, - } - engines: { node: ">=18" } + resolution: {integrity: sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw==} + engines: {node: '>=18'} yaml@1.10.2: - resolution: - { - integrity: sha512-r3vXyErRCYJ7wg28yvBY5VSoAF8ZvlcW9/BwUzEtUsjvX/DKs24dIkuwjtuprwJJHsbyUbLApepYTR1BN4uHrg==, - } - engines: { node: ">= 6" } + resolution: {integrity: sha512-r3vXyErRCYJ7wg28yvBY5VSoAF8ZvlcW9/BwUzEtUsjvX/DKs24dIkuwjtuprwJJHsbyUbLApepYTR1BN4uHrg==} + engines: {node: '>= 6'} yaml@2.8.1: - resolution: - { - integrity: sha512-lcYcMxX2PO9XMGvAJkJ3OsNMw+/7FKes7/hgerGUYWIoWu5j/+YQqcZr5JnPZWzOsEBgMbSbiSTn/dv/69Mkpw==, - } - engines: { node: ">= 14.6" } + resolution: {integrity: sha512-lcYcMxX2PO9XMGvAJkJ3OsNMw+/7FKes7/hgerGUYWIoWu5j/+YQqcZr5JnPZWzOsEBgMbSbiSTn/dv/69Mkpw==} + engines: {node: '>= 14.6'} hasBin: true yargs-parser@21.1.1: - resolution: - { - integrity: sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==, - } - engines: { node: ">=12" } + resolution: {integrity: sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==} + engines: {node: '>=12'} yargs@17.7.2: - resolution: - { - integrity: sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==, - } - engines: { node: ">=12" } + resolution: {integrity: sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==} + engines: {node: '>=12'} yocto-queue@0.1.0: - resolution: - { - integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==, - } - engines: { node: ">=10" } + resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} + engines: {node: '>=10'} zimmerframe@1.1.4: - resolution: - { - integrity: sha512-B58NGBEoc8Y9MWWCQGl/gq9xBCe4IiKM0a2x7GZdQKOW5Exr8S1W24J6OgM1njK8xCRGvAJIL/MxXHf6SkmQKQ==, - } + resolution: {integrity: sha512-B58NGBEoc8Y9MWWCQGl/gq9xBCe4IiKM0a2x7GZdQKOW5Exr8S1W24J6OgM1njK8xCRGvAJIL/MxXHf6SkmQKQ==} zod@3.25.76: - resolution: - { - integrity: sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==, - } + resolution: {integrity: sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==} snapshots: - "@adobe/css-tools@4.4.4": {} - "@ampproject/remapping@2.3.0": + '@adobe/css-tools@4.4.4': {} + + '@ampproject/remapping@2.3.0': dependencies: - "@jridgewell/gen-mapping": 0.3.13 - "@jridgewell/trace-mapping": 0.3.31 + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 - "@asamuzakjp/css-color@3.2.0": + '@asamuzakjp/css-color@3.2.0': dependencies: - "@csstools/css-calc": 2.1.4(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4) - "@csstools/css-color-parser": 3.1.0(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4) - "@csstools/css-parser-algorithms": 3.0.5(@csstools/css-tokenizer@3.0.4) - "@csstools/css-tokenizer": 3.0.4 + '@csstools/css-calc': 2.1.4(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4) + '@csstools/css-color-parser': 3.1.0(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4) + '@csstools/css-parser-algorithms': 3.0.5(@csstools/css-tokenizer@3.0.4) + '@csstools/css-tokenizer': 3.0.4 lru-cache: 10.4.3 - "@auth/core@0.39.1": + '@asamuzakjp/css-color@6.0.5': + dependencies: + '@csstools/css-calc': 3.3.0(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0) + '@csstools/css-color-parser': 4.1.10(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0) + '@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0) + '@csstools/css-tokenizer': 4.0.0 + lru-cache: 11.5.2 + + '@asamuzakjp/dom-selector@8.3.2': + dependencies: + bidi-js: 1.0.3 + css-tree: 3.2.1 + is-potential-custom-element-name: 1.0.1 + lru-cache: 11.5.2 + + '@auth/core@0.39.1': dependencies: - "@panva/hkdf": 1.2.1 + '@panva/hkdf': 1.2.1 jose: 6.2.2 oauth4webapi: 3.8.5 preact: 10.24.3 preact-render-to-string: 6.5.11(preact@10.24.3) - "@auth/core@0.41.1": + '@auth/core@0.41.1': dependencies: - "@panva/hkdf": 1.2.1 + '@panva/hkdf': 1.2.1 jose: 6.2.2 oauth4webapi: 3.8.5 preact: 10.24.3 preact-render-to-string: 6.5.11(preact@10.24.3) - "@auth/sveltekit@1.11.1(@sveltejs/kit@2.50.1(@sveltejs/vite-plugin-svelte@5.1.1(svelte@5.39.12)(vite@6.4.1(@types/node@22.18.10)(jiti@2.6.1)(lightningcss@1.30.1)(yaml@2.8.1)))(svelte@5.39.12)(typescript@5.9.3)(vite@6.4.1(@types/node@22.18.10)(jiti@2.6.1)(lightningcss@1.30.1)(yaml@2.8.1)))(svelte@5.39.12)": + '@auth/sveltekit@1.11.1(@sveltejs/kit@2.50.1(@sveltejs/vite-plugin-svelte@5.1.1(svelte@5.39.12)(vite@6.4.1(@types/node@22.18.10)(jiti@2.6.1)(lightningcss@1.30.1)(yaml@2.8.1)))(svelte@5.39.12)(typescript@5.9.3)(vite@6.4.1(@types/node@22.18.10)(jiti@2.6.1)(lightningcss@1.30.1)(yaml@2.8.1)))(svelte@5.39.12)': dependencies: - "@auth/core": 0.41.1 - "@sveltejs/kit": 2.50.1(@sveltejs/vite-plugin-svelte@5.1.1(svelte@5.39.12)(vite@6.4.1(@types/node@22.18.10)(jiti@2.6.1)(lightningcss@1.30.1)(yaml@2.8.1)))(svelte@5.39.12)(typescript@5.9.3)(vite@6.4.1(@types/node@22.18.10)(jiti@2.6.1)(lightningcss@1.30.1)(yaml@2.8.1)) + '@auth/core': 0.41.1 + '@sveltejs/kit': 2.50.1(@sveltejs/vite-plugin-svelte@5.1.1(svelte@5.39.12)(vite@6.4.1(@types/node@22.18.10)(jiti@2.6.1)(lightningcss@1.30.1)(yaml@2.8.1)))(svelte@5.39.12)(typescript@5.9.3)(vite@6.4.1(@types/node@22.18.10)(jiti@2.6.1)(lightningcss@1.30.1)(yaml@2.8.1)) set-cookie-parser: 2.7.1 svelte: 5.39.12 - "@babel/code-frame@7.29.0": + '@babel/code-frame@7.29.0': dependencies: - "@babel/helper-validator-identifier": 7.28.5 + '@babel/helper-validator-identifier': 7.28.5 js-tokens: 4.0.0 picocolors: 1.1.1 - "@babel/helper-string-parser@7.27.1": {} + '@babel/helper-string-parser@7.27.1': {} - "@babel/helper-validator-identifier@7.28.5": {} + '@babel/helper-validator-identifier@7.28.5': {} - "@babel/parser@7.29.2": + '@babel/parser@7.29.2': dependencies: - "@babel/types": 7.29.0 + '@babel/types': 7.29.0 - "@babel/runtime@7.29.2": {} + '@babel/runtime@7.29.2': {} - "@babel/types@7.29.0": + '@babel/types@7.29.0': dependencies: - "@babel/helper-string-parser": 7.27.1 - "@babel/helper-validator-identifier": 7.28.5 + '@babel/helper-string-parser': 7.27.1 + '@babel/helper-validator-identifier': 7.28.5 - "@bcoe/v8-coverage@1.0.2": {} + '@bcoe/v8-coverage@1.0.2': {} - "@bufbuild/protobuf@2.11.0": {} + '@bramus/specificity@2.4.2': + dependencies: + css-tree: 3.2.1 + + '@bufbuild/protobuf@2.11.0': {} + + '@csstools/color-helpers@5.1.0': {} + + '@csstools/color-helpers@6.1.0': {} + + '@csstools/css-calc@2.1.4(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4)': + dependencies: + '@csstools/css-parser-algorithms': 3.0.5(@csstools/css-tokenizer@3.0.4) + '@csstools/css-tokenizer': 3.0.4 - "@csstools/color-helpers@5.1.0": {} + '@csstools/css-calc@3.3.0(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0)': + dependencies: + '@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0) + '@csstools/css-tokenizer': 4.0.0 - "@csstools/css-calc@2.1.4(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4)": + '@csstools/css-color-parser@3.1.0(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4)': dependencies: - "@csstools/css-parser-algorithms": 3.0.5(@csstools/css-tokenizer@3.0.4) - "@csstools/css-tokenizer": 3.0.4 + '@csstools/color-helpers': 5.1.0 + '@csstools/css-calc': 2.1.4(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4) + '@csstools/css-parser-algorithms': 3.0.5(@csstools/css-tokenizer@3.0.4) + '@csstools/css-tokenizer': 3.0.4 - "@csstools/css-color-parser@3.1.0(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4)": + '@csstools/css-color-parser@4.1.10(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0)': dependencies: - "@csstools/color-helpers": 5.1.0 - "@csstools/css-calc": 2.1.4(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4) - "@csstools/css-parser-algorithms": 3.0.5(@csstools/css-tokenizer@3.0.4) - "@csstools/css-tokenizer": 3.0.4 + '@csstools/color-helpers': 6.1.0 + '@csstools/css-calc': 3.3.0(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0) + '@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0) + '@csstools/css-tokenizer': 4.0.0 - "@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4)": + '@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4)': dependencies: - "@csstools/css-tokenizer": 3.0.4 + '@csstools/css-tokenizer': 3.0.4 - "@csstools/css-tokenizer@3.0.4": {} + '@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0)': + dependencies: + '@csstools/css-tokenizer': 4.0.0 + + '@csstools/css-syntax-patches-for-csstree@1.1.7(css-tree@3.2.1)': + optionalDependencies: + css-tree: 3.2.1 - "@esbuild/aix-ppc64@0.25.10": + '@csstools/css-tokenizer@3.0.4': {} + + '@csstools/css-tokenizer@4.0.0': {} + + '@esbuild/aix-ppc64@0.25.10': optional: true - "@esbuild/android-arm64@0.25.10": + '@esbuild/android-arm64@0.25.10': optional: true - "@esbuild/android-arm@0.25.10": + '@esbuild/android-arm@0.25.10': optional: true - "@esbuild/android-x64@0.25.10": + '@esbuild/android-x64@0.25.10': optional: true - "@esbuild/darwin-arm64@0.25.10": + '@esbuild/darwin-arm64@0.25.10': optional: true - "@esbuild/darwin-x64@0.25.10": + '@esbuild/darwin-x64@0.25.10': optional: true - "@esbuild/freebsd-arm64@0.25.10": + '@esbuild/freebsd-arm64@0.25.10': optional: true - "@esbuild/freebsd-x64@0.25.10": + '@esbuild/freebsd-x64@0.25.10': optional: true - "@esbuild/linux-arm64@0.25.10": + '@esbuild/linux-arm64@0.25.10': optional: true - "@esbuild/linux-arm@0.25.10": + '@esbuild/linux-arm@0.25.10': optional: true - "@esbuild/linux-ia32@0.25.10": + '@esbuild/linux-ia32@0.25.10': optional: true - "@esbuild/linux-loong64@0.25.10": + '@esbuild/linux-loong64@0.25.10': optional: true - "@esbuild/linux-mips64el@0.25.10": + '@esbuild/linux-mips64el@0.25.10': optional: true - "@esbuild/linux-ppc64@0.25.10": + '@esbuild/linux-ppc64@0.25.10': optional: true - "@esbuild/linux-riscv64@0.25.10": + '@esbuild/linux-riscv64@0.25.10': optional: true - "@esbuild/linux-s390x@0.25.10": + '@esbuild/linux-s390x@0.25.10': optional: true - "@esbuild/linux-x64@0.25.10": + '@esbuild/linux-x64@0.25.10': optional: true - "@esbuild/netbsd-arm64@0.25.10": + '@esbuild/netbsd-arm64@0.25.10': optional: true - "@esbuild/netbsd-x64@0.25.10": + '@esbuild/netbsd-x64@0.25.10': optional: true - "@esbuild/openbsd-arm64@0.25.10": + '@esbuild/openbsd-arm64@0.25.10': optional: true - "@esbuild/openbsd-x64@0.25.10": + '@esbuild/openbsd-x64@0.25.10': optional: true - "@esbuild/openharmony-arm64@0.25.10": + '@esbuild/openharmony-arm64@0.25.10': optional: true - "@esbuild/sunos-x64@0.25.10": + '@esbuild/sunos-x64@0.25.10': optional: true - "@esbuild/win32-arm64@0.25.10": + '@esbuild/win32-arm64@0.25.10': optional: true - "@esbuild/win32-ia32@0.25.10": + '@esbuild/win32-ia32@0.25.10': optional: true - "@esbuild/win32-x64@0.25.10": + '@esbuild/win32-x64@0.25.10': optional: true - "@eslint-community/eslint-utils@4.9.0(eslint@9.37.0(jiti@2.6.1))": + '@eslint-community/eslint-utils@4.9.0(eslint@9.37.0(jiti@2.6.1))': dependencies: eslint: 9.37.0(jiti@2.6.1) eslint-visitor-keys: 3.4.3 - "@eslint-community/regexpp@4.12.1": {} + '@eslint-community/regexpp@4.12.1': {} - "@eslint/compat@1.4.0(eslint@9.37.0(jiti@2.6.1))": + '@eslint/compat@1.4.0(eslint@9.37.0(jiti@2.6.1))': dependencies: - "@eslint/core": 0.16.0 + '@eslint/core': 0.16.0 optionalDependencies: eslint: 9.37.0(jiti@2.6.1) - "@eslint/config-array@0.21.0": + '@eslint/config-array@0.21.0': dependencies: - "@eslint/object-schema": 2.1.6 + '@eslint/object-schema': 2.1.6 debug: 4.4.3 minimatch: 3.1.2 transitivePeerDependencies: - supports-color - "@eslint/config-helpers@0.4.0": + '@eslint/config-helpers@0.4.0': dependencies: - "@eslint/core": 0.16.0 + '@eslint/core': 0.16.0 - "@eslint/core@0.16.0": + '@eslint/core@0.16.0': dependencies: - "@types/json-schema": 7.0.15 + '@types/json-schema': 7.0.15 - "@eslint/eslintrc@3.3.1": + '@eslint/eslintrc@3.3.1': dependencies: ajv: 6.12.6 debug: 4.4.3 @@ -4415,50 +2871,41 @@ snapshots: transitivePeerDependencies: - supports-color - "@eslint/js@9.37.0": {} + '@eslint/js@9.37.0': {} - "@eslint/object-schema@2.1.6": {} + '@eslint/object-schema@2.1.6': {} - "@eslint/plugin-kit@0.4.0": + '@eslint/plugin-kit@0.4.0': dependencies: - "@eslint/core": 0.16.0 + '@eslint/core': 0.16.0 levn: 0.4.1 - "@floating-ui/core@1.7.3": - dependencies: - "@floating-ui/utils": 0.2.10 + '@exodus/bytes@1.15.1': {} - "@floating-ui/dom@1.7.2": + '@grpc/grpc-js@1.14.3': dependencies: - "@floating-ui/core": 1.7.3 - "@floating-ui/utils": 0.2.10 + '@grpc/proto-loader': 0.8.0 + '@js-sdsl/ordered-map': 4.4.2 - "@floating-ui/utils@0.2.10": {} - - "@grpc/grpc-js@1.14.3": - dependencies: - "@grpc/proto-loader": 0.8.0 - "@js-sdsl/ordered-map": 4.4.2 - - "@grpc/proto-loader@0.8.0": + '@grpc/proto-loader@0.8.0': dependencies: lodash.camelcase: 4.3.0 long: 5.3.2 protobufjs: 7.5.4 yargs: 17.7.2 - "@humanfs/core@0.19.1": {} + '@humanfs/core@0.19.1': {} - "@humanfs/node@0.16.7": + '@humanfs/node@0.16.7': dependencies: - "@humanfs/core": 0.19.1 - "@humanwhocodes/retry": 0.4.3 + '@humanfs/core': 0.19.1 + '@humanwhocodes/retry': 0.4.3 - "@humanwhocodes/module-importer@1.0.1": {} + '@humanwhocodes/module-importer@1.0.1': {} - "@humanwhocodes/retry@0.4.3": {} + '@humanwhocodes/retry@0.4.3': {} - "@isaacs/cliui@8.0.2": + '@isaacs/cliui@8.0.2': dependencies: string-width: 5.1.2 string-width-cjs: string-width@4.2.3 @@ -4467,78 +2914,86 @@ snapshots: wrap-ansi: 8.1.0 wrap-ansi-cjs: wrap-ansi@7.0.0 - "@isaacs/fs-minipass@4.0.1": + '@isaacs/fs-minipass@4.0.1': dependencies: minipass: 7.1.2 - "@istanbuljs/schema@0.1.3": {} + '@istanbuljs/schema@0.1.3': {} - "@jridgewell/gen-mapping@0.3.13": + '@jridgewell/gen-mapping@0.3.13': dependencies: - "@jridgewell/sourcemap-codec": 1.5.5 - "@jridgewell/trace-mapping": 0.3.31 + '@jridgewell/sourcemap-codec': 1.5.5 + '@jridgewell/trace-mapping': 0.3.31 - "@jridgewell/remapping@2.3.5": + '@jridgewell/remapping@2.3.5': dependencies: - "@jridgewell/gen-mapping": 0.3.13 - "@jridgewell/trace-mapping": 0.3.31 + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 - "@jridgewell/resolve-uri@3.1.2": {} + '@jridgewell/resolve-uri@3.1.2': {} - "@jridgewell/sourcemap-codec@1.5.5": {} + '@jridgewell/sourcemap-codec@1.5.5': {} - "@jridgewell/trace-mapping@0.3.31": + '@jridgewell/trace-mapping@0.3.31': dependencies: - "@jridgewell/resolve-uri": 3.1.2 - "@jridgewell/sourcemap-codec": 1.5.5 + '@jridgewell/resolve-uri': 3.1.2 + '@jridgewell/sourcemap-codec': 1.5.5 - "@js-sdsl/ordered-map@4.4.2": {} + '@js-sdsl/ordered-map@4.4.2': {} - "@nodelib/fs.scandir@2.1.5": + '@nodelib/fs.scandir@2.1.5': dependencies: - "@nodelib/fs.stat": 2.0.5 + '@nodelib/fs.stat': 2.0.5 run-parallel: 1.2.0 - "@nodelib/fs.stat@2.0.5": {} + '@nodelib/fs.stat@2.0.5': {} - "@nodelib/fs.walk@1.2.8": + '@nodelib/fs.walk@1.2.8': dependencies: - "@nodelib/fs.scandir": 2.1.5 + '@nodelib/fs.scandir': 2.1.5 fastq: 1.19.1 - "@panva/hkdf@1.2.1": {} + '@openreplay/network-proxy@1.2.5': {} + + '@openreplay/tracker@18.1.2': + dependencies: + '@openreplay/network-proxy': 1.2.5 + error-stack-parser-es: 2.0.1 + web-vitals: 5.3.0 + + '@panva/hkdf@1.2.1': {} - "@pkgjs/parseargs@0.11.0": + '@pkgjs/parseargs@0.11.0': optional: true - "@polka/url@1.0.0-next.29": {} + '@polka/url@1.0.0-next.29': {} - "@protobufjs/aspromise@1.1.2": {} + '@protobufjs/aspromise@1.1.2': {} - "@protobufjs/base64@1.1.2": {} + '@protobufjs/base64@1.1.2': {} - "@protobufjs/codegen@2.0.4": {} + '@protobufjs/codegen@2.0.4': {} - "@protobufjs/eventemitter@1.1.0": {} + '@protobufjs/eventemitter@1.1.0': {} - "@protobufjs/fetch@1.1.0": + '@protobufjs/fetch@1.1.0': dependencies: - "@protobufjs/aspromise": 1.1.2 - "@protobufjs/inquire": 1.1.0 + '@protobufjs/aspromise': 1.1.2 + '@protobufjs/inquire': 1.1.0 - "@protobufjs/float@1.0.2": {} + '@protobufjs/float@1.0.2': {} - "@protobufjs/inquire@1.1.0": {} + '@protobufjs/inquire@1.1.0': {} - "@protobufjs/path@1.1.2": {} + '@protobufjs/path@1.1.2': {} - "@protobufjs/pool@1.1.0": {} + '@protobufjs/pool@1.1.0': {} - "@protobufjs/utf8@1.1.0": {} + '@protobufjs/utf8@1.1.0': {} - "@rollup/plugin-commonjs@28.0.6(rollup@4.52.4)": + '@rollup/plugin-commonjs@28.0.6(rollup@4.52.4)': dependencies: - "@rollup/pluginutils": 5.3.0(rollup@4.52.4) + '@rollup/pluginutils': 5.3.0(rollup@4.52.4) commondir: 1.0.1 estree-walker: 2.0.2 fdir: 6.5.0(picomatch@4.0.3) @@ -4548,141 +3003,116 @@ snapshots: optionalDependencies: rollup: 4.52.4 - "@rollup/plugin-json@6.1.0(rollup@4.52.4)": + '@rollup/plugin-json@6.1.0(rollup@4.52.4)': dependencies: - "@rollup/pluginutils": 5.3.0(rollup@4.52.4) + '@rollup/pluginutils': 5.3.0(rollup@4.52.4) optionalDependencies: rollup: 4.52.4 - "@rollup/plugin-node-resolve@16.0.3(rollup@4.52.4)": + '@rollup/plugin-node-resolve@16.0.3(rollup@4.52.4)': dependencies: - "@rollup/pluginutils": 5.3.0(rollup@4.52.4) - "@types/resolve": 1.20.2 + '@rollup/pluginutils': 5.3.0(rollup@4.52.4) + '@types/resolve': 1.20.2 deepmerge: 4.3.1 is-module: 1.0.0 resolve: 1.22.10 optionalDependencies: rollup: 4.52.4 - "@rollup/pluginutils@5.3.0(rollup@4.52.4)": + '@rollup/pluginutils@5.3.0(rollup@4.52.4)': dependencies: - "@types/estree": 1.0.8 + '@types/estree': 1.0.8 estree-walker: 2.0.2 picomatch: 4.0.3 optionalDependencies: rollup: 4.52.4 - "@rollup/rollup-android-arm-eabi@4.52.4": + '@rollup/rollup-android-arm-eabi@4.52.4': optional: true - "@rollup/rollup-android-arm64@4.52.4": + '@rollup/rollup-android-arm64@4.52.4': optional: true - "@rollup/rollup-darwin-arm64@4.52.4": + '@rollup/rollup-darwin-arm64@4.52.4': optional: true - "@rollup/rollup-darwin-x64@4.52.4": + '@rollup/rollup-darwin-x64@4.52.4': optional: true - "@rollup/rollup-freebsd-arm64@4.52.4": + '@rollup/rollup-freebsd-arm64@4.52.4': optional: true - "@rollup/rollup-freebsd-x64@4.52.4": + '@rollup/rollup-freebsd-x64@4.52.4': optional: true - "@rollup/rollup-linux-arm-gnueabihf@4.52.4": + '@rollup/rollup-linux-arm-gnueabihf@4.52.4': optional: true - "@rollup/rollup-linux-arm-musleabihf@4.52.4": + '@rollup/rollup-linux-arm-musleabihf@4.52.4': optional: true - "@rollup/rollup-linux-arm64-gnu@4.52.4": + '@rollup/rollup-linux-arm64-gnu@4.52.4': optional: true - "@rollup/rollup-linux-arm64-musl@4.52.4": + '@rollup/rollup-linux-arm64-musl@4.52.4': optional: true - "@rollup/rollup-linux-loong64-gnu@4.52.4": + '@rollup/rollup-linux-loong64-gnu@4.52.4': optional: true - "@rollup/rollup-linux-ppc64-gnu@4.52.4": + '@rollup/rollup-linux-ppc64-gnu@4.52.4': optional: true - "@rollup/rollup-linux-riscv64-gnu@4.52.4": + '@rollup/rollup-linux-riscv64-gnu@4.52.4': optional: true - "@rollup/rollup-linux-riscv64-musl@4.52.4": + '@rollup/rollup-linux-riscv64-musl@4.52.4': optional: true - "@rollup/rollup-linux-s390x-gnu@4.52.4": + '@rollup/rollup-linux-s390x-gnu@4.52.4': optional: true - "@rollup/rollup-linux-x64-gnu@4.52.4": + '@rollup/rollup-linux-x64-gnu@4.52.4': optional: true - "@rollup/rollup-linux-x64-musl@4.52.4": + '@rollup/rollup-linux-x64-musl@4.52.4': optional: true - "@rollup/rollup-openharmony-arm64@4.52.4": + '@rollup/rollup-openharmony-arm64@4.52.4': optional: true - "@rollup/rollup-win32-arm64-msvc@4.52.4": + '@rollup/rollup-win32-arm64-msvc@4.52.4': optional: true - "@rollup/rollup-win32-ia32-msvc@4.52.4": + '@rollup/rollup-win32-ia32-msvc@4.52.4': optional: true - "@rollup/rollup-win32-x64-gnu@4.52.4": + '@rollup/rollup-win32-x64-gnu@4.52.4': optional: true - "@rollup/rollup-win32-x64-msvc@4.52.4": + '@rollup/rollup-win32-x64-msvc@4.52.4': optional: true - "@skeletonlabs/skeleton-svelte@1.5.3(svelte@5.39.12)": - dependencies: - "@zag-js/accordion": 1.18.3 - "@zag-js/avatar": 1.18.3 - "@zag-js/combobox": 1.18.3 - "@zag-js/dialog": 1.18.3 - "@zag-js/file-upload": 1.18.3 - "@zag-js/pagination": 1.18.3 - "@zag-js/popover": 1.18.3 - "@zag-js/progress": 1.18.3 - "@zag-js/radio-group": 1.18.3 - "@zag-js/rating-group": 1.18.3 - "@zag-js/slider": 1.18.3 - "@zag-js/svelte": 1.18.3(svelte@5.39.12) - "@zag-js/switch": 1.18.3 - "@zag-js/tabs": 1.18.3 - "@zag-js/tags-input": 1.18.3 - "@zag-js/toast": 1.18.3 - "@zag-js/tooltip": 1.18.3 - svelte: 5.39.12 - - "@skeletonlabs/skeleton@3.2.2(tailwindcss@4.1.14)": - dependencies: - tailwindcss: 4.1.14 - - "@standard-schema/spec@1.0.0": {} + '@standard-schema/spec@1.0.0': {} - "@sveltejs/acorn-typescript@1.0.6(acorn@8.15.0)": + '@sveltejs/acorn-typescript@1.0.6(acorn@8.15.0)': dependencies: acorn: 8.15.0 - "@sveltejs/adapter-node@5.5.2(@sveltejs/kit@2.50.1(@sveltejs/vite-plugin-svelte@5.1.1(svelte@5.39.12)(vite@6.4.1(@types/node@22.18.10)(jiti@2.6.1)(lightningcss@1.30.1)(yaml@2.8.1)))(svelte@5.39.12)(typescript@5.9.3)(vite@6.4.1(@types/node@22.18.10)(jiti@2.6.1)(lightningcss@1.30.1)(yaml@2.8.1)))": + '@sveltejs/adapter-node@5.5.2(@sveltejs/kit@2.50.1(@sveltejs/vite-plugin-svelte@5.1.1(svelte@5.39.12)(vite@6.4.1(@types/node@22.18.10)(jiti@2.6.1)(lightningcss@1.30.1)(yaml@2.8.1)))(svelte@5.39.12)(typescript@5.9.3)(vite@6.4.1(@types/node@22.18.10)(jiti@2.6.1)(lightningcss@1.30.1)(yaml@2.8.1)))': dependencies: - "@rollup/plugin-commonjs": 28.0.6(rollup@4.52.4) - "@rollup/plugin-json": 6.1.0(rollup@4.52.4) - "@rollup/plugin-node-resolve": 16.0.3(rollup@4.52.4) - "@sveltejs/kit": 2.50.1(@sveltejs/vite-plugin-svelte@5.1.1(svelte@5.39.12)(vite@6.4.1(@types/node@22.18.10)(jiti@2.6.1)(lightningcss@1.30.1)(yaml@2.8.1)))(svelte@5.39.12)(typescript@5.9.3)(vite@6.4.1(@types/node@22.18.10)(jiti@2.6.1)(lightningcss@1.30.1)(yaml@2.8.1)) + '@rollup/plugin-commonjs': 28.0.6(rollup@4.52.4) + '@rollup/plugin-json': 6.1.0(rollup@4.52.4) + '@rollup/plugin-node-resolve': 16.0.3(rollup@4.52.4) + '@sveltejs/kit': 2.50.1(@sveltejs/vite-plugin-svelte@5.1.1(svelte@5.39.12)(vite@6.4.1(@types/node@22.18.10)(jiti@2.6.1)(lightningcss@1.30.1)(yaml@2.8.1)))(svelte@5.39.12)(typescript@5.9.3)(vite@6.4.1(@types/node@22.18.10)(jiti@2.6.1)(lightningcss@1.30.1)(yaml@2.8.1)) rollup: 4.52.4 - "@sveltejs/kit@2.50.1(@sveltejs/vite-plugin-svelte@5.1.1(svelte@5.39.12)(vite@6.4.1(@types/node@22.18.10)(jiti@2.6.1)(lightningcss@1.30.1)(yaml@2.8.1)))(svelte@5.39.12)(typescript@5.9.3)(vite@6.4.1(@types/node@22.18.10)(jiti@2.6.1)(lightningcss@1.30.1)(yaml@2.8.1))": + '@sveltejs/kit@2.50.1(@sveltejs/vite-plugin-svelte@5.1.1(svelte@5.39.12)(vite@6.4.1(@types/node@22.18.10)(jiti@2.6.1)(lightningcss@1.30.1)(yaml@2.8.1)))(svelte@5.39.12)(typescript@5.9.3)(vite@6.4.1(@types/node@22.18.10)(jiti@2.6.1)(lightningcss@1.30.1)(yaml@2.8.1))': dependencies: - "@standard-schema/spec": 1.0.0 - "@sveltejs/acorn-typescript": 1.0.6(acorn@8.15.0) - "@sveltejs/vite-plugin-svelte": 5.1.1(svelte@5.39.12)(vite@6.4.1(@types/node@22.18.10)(jiti@2.6.1)(lightningcss@1.30.1)(yaml@2.8.1)) - "@types/cookie": 0.6.0 + '@standard-schema/spec': 1.0.0 + '@sveltejs/acorn-typescript': 1.0.6(acorn@8.15.0) + '@sveltejs/vite-plugin-svelte': 5.1.1(svelte@5.39.12)(vite@6.4.1(@types/node@22.18.10)(jiti@2.6.1)(lightningcss@1.30.1)(yaml@2.8.1)) + '@types/cookie': 0.6.0 acorn: 8.15.0 cookie: 0.6.0 devalue: 5.6.2 @@ -4698,18 +3128,18 @@ snapshots: optionalDependencies: typescript: 5.9.3 - "@sveltejs/vite-plugin-svelte-inspector@4.0.1(@sveltejs/vite-plugin-svelte@5.1.1(svelte@5.39.12)(vite@6.4.1(@types/node@22.18.10)(jiti@2.6.1)(lightningcss@1.30.1)(yaml@2.8.1)))(svelte@5.39.12)(vite@6.4.1(@types/node@22.18.10)(jiti@2.6.1)(lightningcss@1.30.1)(yaml@2.8.1))": + '@sveltejs/vite-plugin-svelte-inspector@4.0.1(@sveltejs/vite-plugin-svelte@5.1.1(svelte@5.39.12)(vite@6.4.1(@types/node@22.18.10)(jiti@2.6.1)(lightningcss@1.30.1)(yaml@2.8.1)))(svelte@5.39.12)(vite@6.4.1(@types/node@22.18.10)(jiti@2.6.1)(lightningcss@1.30.1)(yaml@2.8.1))': dependencies: - "@sveltejs/vite-plugin-svelte": 5.1.1(svelte@5.39.12)(vite@6.4.1(@types/node@22.18.10)(jiti@2.6.1)(lightningcss@1.30.1)(yaml@2.8.1)) + '@sveltejs/vite-plugin-svelte': 5.1.1(svelte@5.39.12)(vite@6.4.1(@types/node@22.18.10)(jiti@2.6.1)(lightningcss@1.30.1)(yaml@2.8.1)) debug: 4.4.3 svelte: 5.39.12 vite: 6.4.1(@types/node@22.18.10)(jiti@2.6.1)(lightningcss@1.30.1)(yaml@2.8.1) transitivePeerDependencies: - supports-color - "@sveltejs/vite-plugin-svelte@5.1.1(svelte@5.39.12)(vite@6.4.1(@types/node@22.18.10)(jiti@2.6.1)(lightningcss@1.30.1)(yaml@2.8.1))": + '@sveltejs/vite-plugin-svelte@5.1.1(svelte@5.39.12)(vite@6.4.1(@types/node@22.18.10)(jiti@2.6.1)(lightningcss@1.30.1)(yaml@2.8.1))': dependencies: - "@sveltejs/vite-plugin-svelte-inspector": 4.0.1(@sveltejs/vite-plugin-svelte@5.1.1(svelte@5.39.12)(vite@6.4.1(@types/node@22.18.10)(jiti@2.6.1)(lightningcss@1.30.1)(yaml@2.8.1)))(svelte@5.39.12)(vite@6.4.1(@types/node@22.18.10)(jiti@2.6.1)(lightningcss@1.30.1)(yaml@2.8.1)) + '@sveltejs/vite-plugin-svelte-inspector': 4.0.1(@sveltejs/vite-plugin-svelte@5.1.1(svelte@5.39.12)(vite@6.4.1(@types/node@22.18.10)(jiti@2.6.1)(lightningcss@1.30.1)(yaml@2.8.1)))(svelte@5.39.12)(vite@6.4.1(@types/node@22.18.10)(jiti@2.6.1)(lightningcss@1.30.1)(yaml@2.8.1)) debug: 4.4.3 deepmerge: 4.3.1 kleur: 4.1.5 @@ -4720,9 +3150,9 @@ snapshots: transitivePeerDependencies: - supports-color - "@tailwindcss/node@4.1.14": + '@tailwindcss/node@4.1.14': dependencies: - "@jridgewell/remapping": 2.3.5 + '@jridgewell/remapping': 2.3.5 enhanced-resolve: 5.18.3 jiti: 2.6.1 lightningcss: 1.30.1 @@ -4730,131 +3160,134 @@ snapshots: source-map-js: 1.2.1 tailwindcss: 4.1.14 - "@tailwindcss/oxide-android-arm64@4.1.14": + '@tailwindcss/oxide-android-arm64@4.1.14': optional: true - "@tailwindcss/oxide-darwin-arm64@4.1.14": + '@tailwindcss/oxide-darwin-arm64@4.1.14': optional: true - "@tailwindcss/oxide-darwin-x64@4.1.14": + '@tailwindcss/oxide-darwin-x64@4.1.14': optional: true - "@tailwindcss/oxide-freebsd-x64@4.1.14": + '@tailwindcss/oxide-freebsd-x64@4.1.14': optional: true - "@tailwindcss/oxide-linux-arm-gnueabihf@4.1.14": + '@tailwindcss/oxide-linux-arm-gnueabihf@4.1.14': optional: true - "@tailwindcss/oxide-linux-arm64-gnu@4.1.14": + '@tailwindcss/oxide-linux-arm64-gnu@4.1.14': optional: true - "@tailwindcss/oxide-linux-arm64-musl@4.1.14": + '@tailwindcss/oxide-linux-arm64-musl@4.1.14': optional: true - "@tailwindcss/oxide-linux-x64-gnu@4.1.14": + '@tailwindcss/oxide-linux-x64-gnu@4.1.14': optional: true - "@tailwindcss/oxide-linux-x64-musl@4.1.14": + '@tailwindcss/oxide-linux-x64-musl@4.1.14': optional: true - "@tailwindcss/oxide-wasm32-wasi@4.1.14": + '@tailwindcss/oxide-wasm32-wasi@4.1.14': optional: true - "@tailwindcss/oxide-win32-arm64-msvc@4.1.14": + '@tailwindcss/oxide-win32-arm64-msvc@4.1.14': optional: true - "@tailwindcss/oxide-win32-x64-msvc@4.1.14": + '@tailwindcss/oxide-win32-x64-msvc@4.1.14': optional: true - "@tailwindcss/oxide@4.1.14": + '@tailwindcss/oxide@4.1.14': dependencies: detect-libc: 2.1.2 tar: 7.5.1 optionalDependencies: - "@tailwindcss/oxide-android-arm64": 4.1.14 - "@tailwindcss/oxide-darwin-arm64": 4.1.14 - "@tailwindcss/oxide-darwin-x64": 4.1.14 - "@tailwindcss/oxide-freebsd-x64": 4.1.14 - "@tailwindcss/oxide-linux-arm-gnueabihf": 4.1.14 - "@tailwindcss/oxide-linux-arm64-gnu": 4.1.14 - "@tailwindcss/oxide-linux-arm64-musl": 4.1.14 - "@tailwindcss/oxide-linux-x64-gnu": 4.1.14 - "@tailwindcss/oxide-linux-x64-musl": 4.1.14 - "@tailwindcss/oxide-wasm32-wasi": 4.1.14 - "@tailwindcss/oxide-win32-arm64-msvc": 4.1.14 - "@tailwindcss/oxide-win32-x64-msvc": 4.1.14 - - "@tailwindcss/vite@4.1.14(vite@6.4.1(@types/node@22.18.10)(jiti@2.6.1)(lightningcss@1.30.1)(yaml@2.8.1))": - dependencies: - "@tailwindcss/node": 4.1.14 - "@tailwindcss/oxide": 4.1.14 + '@tailwindcss/oxide-android-arm64': 4.1.14 + '@tailwindcss/oxide-darwin-arm64': 4.1.14 + '@tailwindcss/oxide-darwin-x64': 4.1.14 + '@tailwindcss/oxide-freebsd-x64': 4.1.14 + '@tailwindcss/oxide-linux-arm-gnueabihf': 4.1.14 + '@tailwindcss/oxide-linux-arm64-gnu': 4.1.14 + '@tailwindcss/oxide-linux-arm64-musl': 4.1.14 + '@tailwindcss/oxide-linux-x64-gnu': 4.1.14 + '@tailwindcss/oxide-linux-x64-musl': 4.1.14 + '@tailwindcss/oxide-wasm32-wasi': 4.1.14 + '@tailwindcss/oxide-win32-arm64-msvc': 4.1.14 + '@tailwindcss/oxide-win32-x64-msvc': 4.1.14 + + '@tailwindcss/vite@4.1.14(vite@6.4.1(@types/node@22.18.10)(jiti@2.6.1)(lightningcss@1.30.1)(yaml@2.8.1))': + dependencies: + '@tailwindcss/node': 4.1.14 + '@tailwindcss/oxide': 4.1.14 tailwindcss: 4.1.14 vite: 6.4.1(@types/node@22.18.10)(jiti@2.6.1)(lightningcss@1.30.1)(yaml@2.8.1) - "@testing-library/dom@10.4.1": + '@testing-library/dom@10.4.1': dependencies: - "@babel/code-frame": 7.29.0 - "@babel/runtime": 7.29.2 - "@types/aria-query": 5.0.4 + '@babel/code-frame': 7.29.0 + '@babel/runtime': 7.29.2 + '@types/aria-query': 5.0.4 aria-query: 5.3.0 dom-accessibility-api: 0.5.16 lz-string: 1.5.0 picocolors: 1.1.1 pretty-format: 27.5.1 - "@testing-library/jest-dom@6.9.1": + '@testing-library/jest-dom@6.9.1': dependencies: - "@adobe/css-tools": 4.4.4 + '@adobe/css-tools': 4.4.4 aria-query: 5.3.2 css.escape: 1.5.1 dom-accessibility-api: 0.6.3 picocolors: 1.1.1 redent: 3.0.0 - "@testing-library/svelte-core@1.0.0(svelte@5.39.12)": + '@testing-library/svelte-core@1.0.0(svelte@5.39.12)': dependencies: svelte: 5.39.12 - "@testing-library/svelte@5.3.1(svelte@5.39.12)(vite@6.4.1(@types/node@22.18.10)(jiti@2.6.1)(lightningcss@1.30.1)(yaml@2.8.1))(vitest@3.2.4(@types/node@22.18.10)(@vitest/ui@3.2.4)(jiti@2.6.1)(jsdom@26.1.0)(lightningcss@1.30.1)(yaml@2.8.1))": + '@testing-library/svelte@5.3.1(svelte@5.39.12)(vite@6.4.1(@types/node@22.18.10)(jiti@2.6.1)(lightningcss@1.30.1)(yaml@2.8.1))(vitest@3.2.4)': dependencies: - "@testing-library/dom": 10.4.1 - "@testing-library/svelte-core": 1.0.0(svelte@5.39.12) + '@testing-library/dom': 10.4.1 + '@testing-library/svelte-core': 1.0.0(svelte@5.39.12) svelte: 5.39.12 optionalDependencies: vite: 6.4.1(@types/node@22.18.10)(jiti@2.6.1)(lightningcss@1.30.1)(yaml@2.8.1) vitest: 3.2.4(@types/node@22.18.10)(@vitest/ui@3.2.4)(jiti@2.6.1)(jsdom@26.1.0)(lightningcss@1.30.1)(yaml@2.8.1) - "@types/aria-query@5.0.4": {} + '@types/aria-query@5.0.4': {} - "@types/chai@5.2.3": + '@types/chai@5.2.3': dependencies: - "@types/deep-eql": 4.0.2 + '@types/deep-eql': 4.0.2 assertion-error: 2.0.1 - "@types/command-line-args@5.2.3": {} + '@types/command-line-args@5.2.3': {} - "@types/cookie@0.6.0": {} + '@types/cookie@0.6.0': {} - "@types/deep-eql@4.0.2": {} + '@types/deep-eql@4.0.2': {} - "@types/estree@1.0.8": {} + '@types/estree@1.0.8': {} - "@types/json-schema@7.0.15": {} + '@types/json-schema@7.0.15': {} - "@types/node@22.18.10": + '@types/node@22.18.10': dependencies: undici-types: 6.21.0 - "@types/resolve@1.20.2": {} + '@types/resolve@1.20.2': {} - "@typescript-eslint/eslint-plugin@8.46.1(@typescript-eslint/parser@8.46.1(eslint@9.37.0(jiti@2.6.1))(typescript@5.9.3))(eslint@9.37.0(jiti@2.6.1))(typescript@5.9.3)": + '@types/trusted-types@2.0.7': + optional: true + + '@typescript-eslint/eslint-plugin@8.46.1(@typescript-eslint/parser@8.46.1(eslint@9.37.0(jiti@2.6.1))(typescript@5.9.3))(eslint@9.37.0(jiti@2.6.1))(typescript@5.9.3)': dependencies: - "@eslint-community/regexpp": 4.12.1 - "@typescript-eslint/parser": 8.46.1(eslint@9.37.0(jiti@2.6.1))(typescript@5.9.3) - "@typescript-eslint/scope-manager": 8.46.1 - "@typescript-eslint/type-utils": 8.46.1(eslint@9.37.0(jiti@2.6.1))(typescript@5.9.3) - "@typescript-eslint/utils": 8.46.1(eslint@9.37.0(jiti@2.6.1))(typescript@5.9.3) - "@typescript-eslint/visitor-keys": 8.46.1 + '@eslint-community/regexpp': 4.12.1 + '@typescript-eslint/parser': 8.46.1(eslint@9.37.0(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/scope-manager': 8.46.1 + '@typescript-eslint/type-utils': 8.46.1(eslint@9.37.0(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/utils': 8.46.1(eslint@9.37.0(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/visitor-keys': 8.46.1 eslint: 9.37.0(jiti@2.6.1) graphemer: 1.4.0 ignore: 7.0.5 @@ -4864,41 +3297,41 @@ snapshots: transitivePeerDependencies: - supports-color - "@typescript-eslint/parser@8.46.1(eslint@9.37.0(jiti@2.6.1))(typescript@5.9.3)": + '@typescript-eslint/parser@8.46.1(eslint@9.37.0(jiti@2.6.1))(typescript@5.9.3)': dependencies: - "@typescript-eslint/scope-manager": 8.46.1 - "@typescript-eslint/types": 8.46.1 - "@typescript-eslint/typescript-estree": 8.46.1(typescript@5.9.3) - "@typescript-eslint/visitor-keys": 8.46.1 + '@typescript-eslint/scope-manager': 8.46.1 + '@typescript-eslint/types': 8.46.1 + '@typescript-eslint/typescript-estree': 8.46.1(typescript@5.9.3) + '@typescript-eslint/visitor-keys': 8.46.1 debug: 4.4.3 eslint: 9.37.0(jiti@2.6.1) typescript: 5.9.3 transitivePeerDependencies: - supports-color - "@typescript-eslint/project-service@8.46.1(typescript@5.9.3)": + '@typescript-eslint/project-service@8.46.1(typescript@5.9.3)': dependencies: - "@typescript-eslint/tsconfig-utils": 8.46.1(typescript@5.9.3) - "@typescript-eslint/types": 8.46.1 + '@typescript-eslint/tsconfig-utils': 8.46.1(typescript@5.9.3) + '@typescript-eslint/types': 8.46.1 debug: 4.4.3 typescript: 5.9.3 transitivePeerDependencies: - supports-color - "@typescript-eslint/scope-manager@8.46.1": + '@typescript-eslint/scope-manager@8.46.1': dependencies: - "@typescript-eslint/types": 8.46.1 - "@typescript-eslint/visitor-keys": 8.46.1 + '@typescript-eslint/types': 8.46.1 + '@typescript-eslint/visitor-keys': 8.46.1 - "@typescript-eslint/tsconfig-utils@8.46.1(typescript@5.9.3)": + '@typescript-eslint/tsconfig-utils@8.46.1(typescript@5.9.3)': dependencies: typescript: 5.9.3 - "@typescript-eslint/type-utils@8.46.1(eslint@9.37.0(jiti@2.6.1))(typescript@5.9.3)": + '@typescript-eslint/type-utils@8.46.1(eslint@9.37.0(jiti@2.6.1))(typescript@5.9.3)': dependencies: - "@typescript-eslint/types": 8.46.1 - "@typescript-eslint/typescript-estree": 8.46.1(typescript@5.9.3) - "@typescript-eslint/utils": 8.46.1(eslint@9.37.0(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/types': 8.46.1 + '@typescript-eslint/typescript-estree': 8.46.1(typescript@5.9.3) + '@typescript-eslint/utils': 8.46.1(eslint@9.37.0(jiti@2.6.1))(typescript@5.9.3) debug: 4.4.3 eslint: 9.37.0(jiti@2.6.1) ts-api-utils: 2.1.0(typescript@5.9.3) @@ -4906,14 +3339,14 @@ snapshots: transitivePeerDependencies: - supports-color - "@typescript-eslint/types@8.46.1": {} + '@typescript-eslint/types@8.46.1': {} - "@typescript-eslint/typescript-estree@8.46.1(typescript@5.9.3)": + '@typescript-eslint/typescript-estree@8.46.1(typescript@5.9.3)': dependencies: - "@typescript-eslint/project-service": 8.46.1(typescript@5.9.3) - "@typescript-eslint/tsconfig-utils": 8.46.1(typescript@5.9.3) - "@typescript-eslint/types": 8.46.1 - "@typescript-eslint/visitor-keys": 8.46.1 + '@typescript-eslint/project-service': 8.46.1(typescript@5.9.3) + '@typescript-eslint/tsconfig-utils': 8.46.1(typescript@5.9.3) + '@typescript-eslint/types': 8.46.1 + '@typescript-eslint/visitor-keys': 8.46.1 debug: 4.4.3 fast-glob: 3.3.3 is-glob: 4.0.3 @@ -4924,26 +3357,26 @@ snapshots: transitivePeerDependencies: - supports-color - "@typescript-eslint/utils@8.46.1(eslint@9.37.0(jiti@2.6.1))(typescript@5.9.3)": + '@typescript-eslint/utils@8.46.1(eslint@9.37.0(jiti@2.6.1))(typescript@5.9.3)': dependencies: - "@eslint-community/eslint-utils": 4.9.0(eslint@9.37.0(jiti@2.6.1)) - "@typescript-eslint/scope-manager": 8.46.1 - "@typescript-eslint/types": 8.46.1 - "@typescript-eslint/typescript-estree": 8.46.1(typescript@5.9.3) + '@eslint-community/eslint-utils': 4.9.0(eslint@9.37.0(jiti@2.6.1)) + '@typescript-eslint/scope-manager': 8.46.1 + '@typescript-eslint/types': 8.46.1 + '@typescript-eslint/typescript-estree': 8.46.1(typescript@5.9.3) eslint: 9.37.0(jiti@2.6.1) typescript: 5.9.3 transitivePeerDependencies: - supports-color - "@typescript-eslint/visitor-keys@8.46.1": + '@typescript-eslint/visitor-keys@8.46.1': dependencies: - "@typescript-eslint/types": 8.46.1 + '@typescript-eslint/types': 8.46.1 eslint-visitor-keys: 4.2.1 - "@vitest/coverage-v8@3.2.4(vitest@3.2.4(@types/node@22.18.10)(@vitest/ui@3.2.4)(jiti@2.6.1)(jsdom@26.1.0)(lightningcss@1.30.1)(yaml@2.8.1))": + '@vitest/coverage-v8@3.2.4(vitest@3.2.4)': dependencies: - "@ampproject/remapping": 2.3.0 - "@bcoe/v8-coverage": 1.0.2 + '@ampproject/remapping': 2.3.0 + '@bcoe/v8-coverage': 1.0.2 ast-v8-to-istanbul: 0.3.12 debug: 4.4.3 istanbul-lib-coverage: 3.2.2 @@ -4959,45 +3392,45 @@ snapshots: transitivePeerDependencies: - supports-color - "@vitest/expect@3.2.4": + '@vitest/expect@3.2.4': dependencies: - "@types/chai": 5.2.3 - "@vitest/spy": 3.2.4 - "@vitest/utils": 3.2.4 + '@types/chai': 5.2.3 + '@vitest/spy': 3.2.4 + '@vitest/utils': 3.2.4 chai: 5.3.3 tinyrainbow: 2.0.0 - "@vitest/mocker@3.2.4(vite@6.4.1(@types/node@22.18.10)(jiti@2.6.1)(lightningcss@1.30.1)(yaml@2.8.1))": + '@vitest/mocker@3.2.4(vite@6.4.1(@types/node@22.18.10)(jiti@2.6.1)(lightningcss@1.30.1)(yaml@2.8.1))': dependencies: - "@vitest/spy": 3.2.4 + '@vitest/spy': 3.2.4 estree-walker: 3.0.3 magic-string: 0.30.19 optionalDependencies: vite: 6.4.1(@types/node@22.18.10)(jiti@2.6.1)(lightningcss@1.30.1)(yaml@2.8.1) - "@vitest/pretty-format@3.2.4": + '@vitest/pretty-format@3.2.4': dependencies: tinyrainbow: 2.0.0 - "@vitest/runner@3.2.4": + '@vitest/runner@3.2.4': dependencies: - "@vitest/utils": 3.2.4 + '@vitest/utils': 3.2.4 pathe: 2.0.3 strip-literal: 3.1.0 - "@vitest/snapshot@3.2.4": + '@vitest/snapshot@3.2.4': dependencies: - "@vitest/pretty-format": 3.2.4 + '@vitest/pretty-format': 3.2.4 magic-string: 0.30.19 pathe: 2.0.3 - "@vitest/spy@3.2.4": + '@vitest/spy@3.2.4': dependencies: tinyspy: 4.0.4 - "@vitest/ui@3.2.4(vitest@3.2.4)": + '@vitest/ui@3.2.4(vitest@3.2.4)': dependencies: - "@vitest/utils": 3.2.4 + '@vitest/utils': 3.2.4 fflate: 0.8.2 flatted: 3.3.3 pathe: 2.0.3 @@ -5006,241 +3439,12 @@ snapshots: tinyrainbow: 2.0.0 vitest: 3.2.4(@types/node@22.18.10)(@vitest/ui@3.2.4)(jiti@2.6.1)(jsdom@26.1.0)(lightningcss@1.30.1)(yaml@2.8.1) - "@vitest/utils@3.2.4": + '@vitest/utils@3.2.4': dependencies: - "@vitest/pretty-format": 3.2.4 + '@vitest/pretty-format': 3.2.4 loupe: 3.2.1 tinyrainbow: 2.0.0 - "@zag-js/accordion@1.18.3": - dependencies: - "@zag-js/anatomy": 1.18.3 - "@zag-js/core": 1.18.3 - "@zag-js/dom-query": 1.18.3 - "@zag-js/types": 1.18.3 - "@zag-js/utils": 1.18.3 - - "@zag-js/anatomy@1.18.3": {} - - "@zag-js/aria-hidden@1.18.3": {} - - "@zag-js/auto-resize@1.18.3": - dependencies: - "@zag-js/dom-query": 1.18.3 - - "@zag-js/avatar@1.18.3": - dependencies: - "@zag-js/anatomy": 1.18.3 - "@zag-js/core": 1.18.3 - "@zag-js/dom-query": 1.18.3 - "@zag-js/types": 1.18.3 - "@zag-js/utils": 1.18.3 - - "@zag-js/collection@1.18.3": - dependencies: - "@zag-js/utils": 1.18.3 - - "@zag-js/combobox@1.18.3": - dependencies: - "@zag-js/anatomy": 1.18.3 - "@zag-js/aria-hidden": 1.18.3 - "@zag-js/collection": 1.18.3 - "@zag-js/core": 1.18.3 - "@zag-js/dismissable": 1.18.3 - "@zag-js/dom-query": 1.18.3 - "@zag-js/popper": 1.18.3 - "@zag-js/types": 1.18.3 - "@zag-js/utils": 1.18.3 - - "@zag-js/core@1.18.3": - dependencies: - "@zag-js/dom-query": 1.18.3 - "@zag-js/utils": 1.18.3 - - "@zag-js/dialog@1.18.3": - dependencies: - "@zag-js/anatomy": 1.18.3 - "@zag-js/aria-hidden": 1.18.3 - "@zag-js/core": 1.18.3 - "@zag-js/dismissable": 1.18.3 - "@zag-js/dom-query": 1.18.3 - "@zag-js/focus-trap": 1.18.3 - "@zag-js/remove-scroll": 1.18.3 - "@zag-js/types": 1.18.3 - "@zag-js/utils": 1.18.3 - - "@zag-js/dismissable@1.18.3": - dependencies: - "@zag-js/dom-query": 1.18.3 - "@zag-js/interact-outside": 1.18.3 - "@zag-js/utils": 1.18.3 - - "@zag-js/dom-query@1.18.3": - dependencies: - "@zag-js/types": 1.18.3 - - "@zag-js/file-upload@1.18.3": - dependencies: - "@zag-js/anatomy": 1.18.3 - "@zag-js/core": 1.18.3 - "@zag-js/dom-query": 1.18.3 - "@zag-js/file-utils": 1.18.3 - "@zag-js/i18n-utils": 1.18.3 - "@zag-js/types": 1.18.3 - "@zag-js/utils": 1.18.3 - - "@zag-js/file-utils@1.18.3": - dependencies: - "@zag-js/i18n-utils": 1.18.3 - - "@zag-js/focus-trap@1.18.3": - dependencies: - "@zag-js/dom-query": 1.18.3 - - "@zag-js/focus-visible@1.18.3": - dependencies: - "@zag-js/dom-query": 1.18.3 - - "@zag-js/i18n-utils@1.18.3": - dependencies: - "@zag-js/dom-query": 1.18.3 - - "@zag-js/interact-outside@1.18.3": - dependencies: - "@zag-js/dom-query": 1.18.3 - "@zag-js/utils": 1.18.3 - - "@zag-js/live-region@1.18.3": {} - - "@zag-js/pagination@1.18.3": - dependencies: - "@zag-js/anatomy": 1.18.3 - "@zag-js/core": 1.18.3 - "@zag-js/dom-query": 1.18.3 - "@zag-js/types": 1.18.3 - "@zag-js/utils": 1.18.3 - - "@zag-js/popover@1.18.3": - dependencies: - "@zag-js/anatomy": 1.18.3 - "@zag-js/aria-hidden": 1.18.3 - "@zag-js/core": 1.18.3 - "@zag-js/dismissable": 1.18.3 - "@zag-js/dom-query": 1.18.3 - "@zag-js/focus-trap": 1.18.3 - "@zag-js/popper": 1.18.3 - "@zag-js/remove-scroll": 1.18.3 - "@zag-js/types": 1.18.3 - "@zag-js/utils": 1.18.3 - - "@zag-js/popper@1.18.3": - dependencies: - "@floating-ui/dom": 1.7.2 - "@zag-js/dom-query": 1.18.3 - "@zag-js/utils": 1.18.3 - - "@zag-js/progress@1.18.3": - dependencies: - "@zag-js/anatomy": 1.18.3 - "@zag-js/core": 1.18.3 - "@zag-js/dom-query": 1.18.3 - "@zag-js/types": 1.18.3 - "@zag-js/utils": 1.18.3 - - "@zag-js/radio-group@1.18.3": - dependencies: - "@zag-js/anatomy": 1.18.3 - "@zag-js/core": 1.18.3 - "@zag-js/dom-query": 1.18.3 - "@zag-js/focus-visible": 1.18.3 - "@zag-js/types": 1.18.3 - "@zag-js/utils": 1.18.3 - - "@zag-js/rating-group@1.18.3": - dependencies: - "@zag-js/anatomy": 1.18.3 - "@zag-js/core": 1.18.3 - "@zag-js/dom-query": 1.18.3 - "@zag-js/types": 1.18.3 - "@zag-js/utils": 1.18.3 - - "@zag-js/remove-scroll@1.18.3": - dependencies: - "@zag-js/dom-query": 1.18.3 - - "@zag-js/slider@1.18.3": - dependencies: - "@zag-js/anatomy": 1.18.3 - "@zag-js/core": 1.18.3 - "@zag-js/dom-query": 1.18.3 - "@zag-js/types": 1.18.3 - "@zag-js/utils": 1.18.3 - - "@zag-js/store@1.18.3": - dependencies: - proxy-compare: 3.0.1 - - "@zag-js/svelte@1.18.3(svelte@5.39.12)": - dependencies: - "@zag-js/core": 1.18.3 - "@zag-js/types": 1.18.3 - "@zag-js/utils": 1.18.3 - svelte: 5.39.12 - - "@zag-js/switch@1.18.3": - dependencies: - "@zag-js/anatomy": 1.18.3 - "@zag-js/core": 1.18.3 - "@zag-js/dom-query": 1.18.3 - "@zag-js/focus-visible": 1.18.3 - "@zag-js/types": 1.18.3 - "@zag-js/utils": 1.18.3 - - "@zag-js/tabs@1.18.3": - dependencies: - "@zag-js/anatomy": 1.18.3 - "@zag-js/core": 1.18.3 - "@zag-js/dom-query": 1.18.3 - "@zag-js/types": 1.18.3 - "@zag-js/utils": 1.18.3 - - "@zag-js/tags-input@1.18.3": - dependencies: - "@zag-js/anatomy": 1.18.3 - "@zag-js/auto-resize": 1.18.3 - "@zag-js/core": 1.18.3 - "@zag-js/dom-query": 1.18.3 - "@zag-js/interact-outside": 1.18.3 - "@zag-js/live-region": 1.18.3 - "@zag-js/types": 1.18.3 - "@zag-js/utils": 1.18.3 - - "@zag-js/toast@1.18.3": - dependencies: - "@zag-js/anatomy": 1.18.3 - "@zag-js/core": 1.18.3 - "@zag-js/dismissable": 1.18.3 - "@zag-js/dom-query": 1.18.3 - "@zag-js/types": 1.18.3 - "@zag-js/utils": 1.18.3 - - "@zag-js/tooltip@1.18.3": - dependencies: - "@zag-js/anatomy": 1.18.3 - "@zag-js/core": 1.18.3 - "@zag-js/dom-query": 1.18.3 - "@zag-js/focus-visible": 1.18.3 - "@zag-js/popper": 1.18.3 - "@zag-js/store": 1.18.3 - "@zag-js/types": 1.18.3 - "@zag-js/utils": 1.18.3 - - "@zag-js/types@1.18.3": - dependencies: - csstype: 3.1.3 - - "@zag-js/utils@1.18.3": {} - abort-controller-x@0.5.0: {} acorn-jsx@5.3.2(acorn@8.15.0): @@ -5284,7 +3488,7 @@ snapshots: ast-v8-to-istanbul@0.3.12: dependencies: - "@jridgewell/trace-mapping": 0.3.31 + '@jridgewell/trace-mapping': 0.3.31 estree-walker: 3.0.3 js-tokens: 10.0.0 @@ -5296,6 +3500,10 @@ snapshots: balanced-match@4.0.4: {} + bidi-js@1.0.3: + dependencies: + require-from-string: 2.0.2 + brace-expansion@1.1.12: dependencies: balanced-match: 1.0.2 @@ -5375,22 +3583,32 @@ snapshots: shebang-command: 2.0.0 which: 2.0.2 + css-tree@3.2.1: + dependencies: + mdn-data: 2.27.1 + source-map-js: 1.2.1 + css.escape@1.5.1: {} cssesc@3.0.0: {} cssstyle@4.6.0: dependencies: - "@asamuzakjp/css-color": 3.2.0 + '@asamuzakjp/css-color': 3.2.0 rrweb-cssom: 0.8.0 - csstype@3.1.3: {} - data-urls@5.0.0: dependencies: whatwg-mimetype: 4.0.0 whatwg-url: 14.2.0 + data-urls@7.0.0: + dependencies: + whatwg-mimetype: 5.0.0 + whatwg-url: 16.0.1 + transitivePeerDependencies: + - '@noble/hashes' + dateformat@4.6.3: {} debug@4.4.3: @@ -5417,6 +3635,10 @@ snapshots: dom-accessibility-api@0.6.3: {} + dompurify@3.4.13: + optionalDependencies: + '@types/trusted-types': 2.0.7 + dprint-node@1.0.8: dependencies: detect-libc: 1.0.3 @@ -5438,36 +3660,40 @@ snapshots: entities@6.0.1: {} + entities@8.0.0: {} + + error-stack-parser-es@2.0.1: {} + es-module-lexer@1.7.0: {} esbuild@0.25.10: optionalDependencies: - "@esbuild/aix-ppc64": 0.25.10 - "@esbuild/android-arm": 0.25.10 - "@esbuild/android-arm64": 0.25.10 - "@esbuild/android-x64": 0.25.10 - "@esbuild/darwin-arm64": 0.25.10 - "@esbuild/darwin-x64": 0.25.10 - "@esbuild/freebsd-arm64": 0.25.10 - "@esbuild/freebsd-x64": 0.25.10 - "@esbuild/linux-arm": 0.25.10 - "@esbuild/linux-arm64": 0.25.10 - "@esbuild/linux-ia32": 0.25.10 - "@esbuild/linux-loong64": 0.25.10 - "@esbuild/linux-mips64el": 0.25.10 - "@esbuild/linux-ppc64": 0.25.10 - "@esbuild/linux-riscv64": 0.25.10 - "@esbuild/linux-s390x": 0.25.10 - "@esbuild/linux-x64": 0.25.10 - "@esbuild/netbsd-arm64": 0.25.10 - "@esbuild/netbsd-x64": 0.25.10 - "@esbuild/openbsd-arm64": 0.25.10 - "@esbuild/openbsd-x64": 0.25.10 - "@esbuild/openharmony-arm64": 0.25.10 - "@esbuild/sunos-x64": 0.25.10 - "@esbuild/win32-arm64": 0.25.10 - "@esbuild/win32-ia32": 0.25.10 - "@esbuild/win32-x64": 0.25.10 + '@esbuild/aix-ppc64': 0.25.10 + '@esbuild/android-arm': 0.25.10 + '@esbuild/android-arm64': 0.25.10 + '@esbuild/android-x64': 0.25.10 + '@esbuild/darwin-arm64': 0.25.10 + '@esbuild/darwin-x64': 0.25.10 + '@esbuild/freebsd-arm64': 0.25.10 + '@esbuild/freebsd-x64': 0.25.10 + '@esbuild/linux-arm': 0.25.10 + '@esbuild/linux-arm64': 0.25.10 + '@esbuild/linux-ia32': 0.25.10 + '@esbuild/linux-loong64': 0.25.10 + '@esbuild/linux-mips64el': 0.25.10 + '@esbuild/linux-ppc64': 0.25.10 + '@esbuild/linux-riscv64': 0.25.10 + '@esbuild/linux-s390x': 0.25.10 + '@esbuild/linux-x64': 0.25.10 + '@esbuild/netbsd-arm64': 0.25.10 + '@esbuild/netbsd-x64': 0.25.10 + '@esbuild/openbsd-arm64': 0.25.10 + '@esbuild/openbsd-x64': 0.25.10 + '@esbuild/openharmony-arm64': 0.25.10 + '@esbuild/sunos-x64': 0.25.10 + '@esbuild/win32-arm64': 0.25.10 + '@esbuild/win32-ia32': 0.25.10 + '@esbuild/win32-x64': 0.25.10 escalade@3.2.0: {} @@ -5479,8 +3705,8 @@ snapshots: eslint-plugin-svelte@3.12.4(eslint@9.37.0(jiti@2.6.1))(svelte@5.39.12): dependencies: - "@eslint-community/eslint-utils": 4.9.0(eslint@9.37.0(jiti@2.6.1)) - "@jridgewell/sourcemap-codec": 1.5.5 + '@eslint-community/eslint-utils': 4.9.0(eslint@9.37.0(jiti@2.6.1)) + '@jridgewell/sourcemap-codec': 1.5.5 eslint: 9.37.0(jiti@2.6.1) esutils: 2.0.3 globals: 16.4.0 @@ -5506,19 +3732,19 @@ snapshots: eslint@9.37.0(jiti@2.6.1): dependencies: - "@eslint-community/eslint-utils": 4.9.0(eslint@9.37.0(jiti@2.6.1)) - "@eslint-community/regexpp": 4.12.1 - "@eslint/config-array": 0.21.0 - "@eslint/config-helpers": 0.4.0 - "@eslint/core": 0.16.0 - "@eslint/eslintrc": 3.3.1 - "@eslint/js": 9.37.0 - "@eslint/plugin-kit": 0.4.0 - "@humanfs/node": 0.16.7 - "@humanwhocodes/module-importer": 1.0.1 - "@humanwhocodes/retry": 0.4.3 - "@types/estree": 1.0.8 - "@types/json-schema": 7.0.15 + '@eslint-community/eslint-utils': 4.9.0(eslint@9.37.0(jiti@2.6.1)) + '@eslint-community/regexpp': 4.12.1 + '@eslint/config-array': 0.21.0 + '@eslint/config-helpers': 0.4.0 + '@eslint/core': 0.16.0 + '@eslint/eslintrc': 3.3.1 + '@eslint/js': 9.37.0 + '@eslint/plugin-kit': 0.4.0 + '@humanfs/node': 0.16.7 + '@humanwhocodes/module-importer': 1.0.1 + '@humanwhocodes/retry': 0.4.3 + '@types/estree': 1.0.8 + '@types/json-schema': 7.0.15 ajv: 6.12.6 chalk: 4.1.2 cross-spawn: 7.0.6 @@ -5560,7 +3786,7 @@ snapshots: esrap@2.1.0: dependencies: - "@jridgewell/sourcemap-codec": 1.5.5 + '@jridgewell/sourcemap-codec': 1.5.5 esrecurse@4.3.0: dependencies: @@ -5572,7 +3798,7 @@ snapshots: estree-walker@3.0.3: dependencies: - "@types/estree": 1.0.8 + '@types/estree': 1.0.8 esutils@2.0.3: {} @@ -5584,8 +3810,8 @@ snapshots: fast-glob@3.3.3: dependencies: - "@nodelib/fs.stat": 2.0.5 - "@nodelib/fs.walk": 1.2.8 + '@nodelib/fs.stat': 2.0.5 + '@nodelib/fs.walk': 1.2.8 glob-parent: 5.1.2 merge2: 1.4.1 micromatch: 4.0.8 @@ -5677,6 +3903,12 @@ snapshots: dependencies: whatwg-encoding: 3.1.1 + html-encoding-sniffer@6.0.0: + dependencies: + '@exodus/bytes': 1.15.1 + transitivePeerDependencies: + - '@noble/hashes' + html-escaper@2.0.2: {} http-proxy-agent@7.0.2: @@ -5730,14 +3962,22 @@ snapshots: is-reference@1.2.1: dependencies: - "@types/estree": 1.0.8 + '@types/estree': 1.0.8 is-reference@3.0.3: dependencies: - "@types/estree": 1.0.8 + '@types/estree': 1.0.8 isexe@2.0.0: {} + isomorphic-dompurify@3.21.0: + dependencies: + dompurify: 3.4.13 + jsdom: 30.0.1 + transitivePeerDependencies: + - '@noble/hashes' + - canvas + istanbul-lib-coverage@3.2.2: {} istanbul-lib-report@3.0.1: @@ -5748,7 +3988,7 @@ snapshots: istanbul-lib-source-maps@5.0.6: dependencies: - "@jridgewell/trace-mapping": 0.3.31 + '@jridgewell/trace-mapping': 0.3.31 debug: 4.4.3 istanbul-lib-coverage: 3.2.2 transitivePeerDependencies: @@ -5761,9 +4001,9 @@ snapshots: jackspeak@3.4.3: dependencies: - "@isaacs/cliui": 8.0.2 + '@isaacs/cliui': 8.0.2 optionalDependencies: - "@pkgjs/parseargs": 0.11.0 + '@pkgjs/parseargs': 0.11.0 jiti@2.6.1: {} @@ -5808,6 +4048,32 @@ snapshots: - supports-color - utf-8-validate + jsdom@30.0.1: + dependencies: + '@asamuzakjp/css-color': 6.0.5 + '@asamuzakjp/dom-selector': 8.3.2 + '@bramus/specificity': 2.4.2 + '@csstools/css-syntax-patches-for-csstree': 1.1.7(css-tree@3.2.1) + '@exodus/bytes': 1.15.1 + css-tree: 3.2.1 + data-urls: 7.0.0 + decimal.js: 10.6.0 + html-encoding-sniffer: 6.0.0 + is-potential-custom-element-name: 1.0.1 + lru-cache: 11.5.2 + parse5: 8.0.1 + saxes: 6.0.0 + symbol-tree: 3.2.4 + tough-cookie: 6.0.2 + undici: 8.10.0 + w3c-xmlserializer: 5.0.0 + webidl-conversions: 8.0.1 + whatwg-mimetype: 5.0.0 + whatwg-url: 17.1.0 + xml-name-validator: 5.0.0 + transitivePeerDependencies: + - '@noble/hashes' + json-buffer@3.0.1: {} json-schema-traverse@0.4.1: {} @@ -5890,6 +4156,8 @@ snapshots: lru-cache@10.4.3: {} + lru-cache@11.5.2: {} + lucide-svelte@0.479.0(svelte@5.39.12): dependencies: svelte: 5.39.12 @@ -5898,18 +4166,22 @@ snapshots: magic-string@0.30.19: dependencies: - "@jridgewell/sourcemap-codec": 1.5.5 + '@jridgewell/sourcemap-codec': 1.5.5 magicast@0.3.5: dependencies: - "@babel/parser": 7.29.2 - "@babel/types": 7.29.0 + '@babel/parser': 7.29.2 + '@babel/types': 7.29.0 source-map-js: 1.2.1 make-dir@4.0.0: dependencies: semver: 7.7.3 + marked@18.0.9: {} + + mdn-data@2.27.1: {} + merge2@1.4.1: {} micromatch@4.0.8: @@ -5955,7 +4227,7 @@ snapshots: nice-grpc@2.1.15: dependencies: - "@grpc/grpc-js": 1.14.3 + '@grpc/grpc-js': 1.14.3 abort-controller-x: 0.5.0 nice-grpc-common: 2.0.3 @@ -5996,6 +4268,10 @@ snapshots: dependencies: entities: 6.0.1 + parse5@8.0.1: + dependencies: + entities: 8.0.0 + path-exists@4.0.0: {} path-key@3.1.1: {} @@ -6110,21 +4386,19 @@ snapshots: protobufjs@7.5.4: dependencies: - "@protobufjs/aspromise": 1.1.2 - "@protobufjs/base64": 1.1.2 - "@protobufjs/codegen": 2.0.4 - "@protobufjs/eventemitter": 1.1.0 - "@protobufjs/fetch": 1.1.0 - "@protobufjs/float": 1.0.2 - "@protobufjs/inquire": 1.1.0 - "@protobufjs/path": 1.1.2 - "@protobufjs/pool": 1.1.0 - "@protobufjs/utf8": 1.1.0 - "@types/node": 22.18.10 + '@protobufjs/aspromise': 1.1.2 + '@protobufjs/base64': 1.1.2 + '@protobufjs/codegen': 2.0.4 + '@protobufjs/eventemitter': 1.1.0 + '@protobufjs/fetch': 1.1.0 + '@protobufjs/float': 1.0.2 + '@protobufjs/inquire': 1.1.0 + '@protobufjs/path': 1.1.2 + '@protobufjs/pool': 1.1.0 + '@protobufjs/utf8': 1.1.0 + '@types/node': 22.18.10 long: 5.3.2 - proxy-compare@3.0.1: {} - pump@3.0.3: dependencies: end-of-stream: 1.4.5 @@ -6149,6 +4423,8 @@ snapshots: require-directory@2.1.1: {} + require-from-string@2.0.2: {} + resolve-from@4.0.0: {} resolve@1.22.10: @@ -6161,30 +4437,30 @@ snapshots: rollup@4.52.4: dependencies: - "@types/estree": 1.0.8 + '@types/estree': 1.0.8 optionalDependencies: - "@rollup/rollup-android-arm-eabi": 4.52.4 - "@rollup/rollup-android-arm64": 4.52.4 - "@rollup/rollup-darwin-arm64": 4.52.4 - "@rollup/rollup-darwin-x64": 4.52.4 - "@rollup/rollup-freebsd-arm64": 4.52.4 - "@rollup/rollup-freebsd-x64": 4.52.4 - "@rollup/rollup-linux-arm-gnueabihf": 4.52.4 - "@rollup/rollup-linux-arm-musleabihf": 4.52.4 - "@rollup/rollup-linux-arm64-gnu": 4.52.4 - "@rollup/rollup-linux-arm64-musl": 4.52.4 - "@rollup/rollup-linux-loong64-gnu": 4.52.4 - "@rollup/rollup-linux-ppc64-gnu": 4.52.4 - "@rollup/rollup-linux-riscv64-gnu": 4.52.4 - "@rollup/rollup-linux-riscv64-musl": 4.52.4 - "@rollup/rollup-linux-s390x-gnu": 4.52.4 - "@rollup/rollup-linux-x64-gnu": 4.52.4 - "@rollup/rollup-linux-x64-musl": 4.52.4 - "@rollup/rollup-openharmony-arm64": 4.52.4 - "@rollup/rollup-win32-arm64-msvc": 4.52.4 - "@rollup/rollup-win32-ia32-msvc": 4.52.4 - "@rollup/rollup-win32-x64-gnu": 4.52.4 - "@rollup/rollup-win32-x64-msvc": 4.52.4 + '@rollup/rollup-android-arm-eabi': 4.52.4 + '@rollup/rollup-android-arm64': 4.52.4 + '@rollup/rollup-darwin-arm64': 4.52.4 + '@rollup/rollup-darwin-x64': 4.52.4 + '@rollup/rollup-freebsd-arm64': 4.52.4 + '@rollup/rollup-freebsd-x64': 4.52.4 + '@rollup/rollup-linux-arm-gnueabihf': 4.52.4 + '@rollup/rollup-linux-arm-musleabihf': 4.52.4 + '@rollup/rollup-linux-arm64-gnu': 4.52.4 + '@rollup/rollup-linux-arm64-musl': 4.52.4 + '@rollup/rollup-linux-loong64-gnu': 4.52.4 + '@rollup/rollup-linux-ppc64-gnu': 4.52.4 + '@rollup/rollup-linux-riscv64-gnu': 4.52.4 + '@rollup/rollup-linux-riscv64-musl': 4.52.4 + '@rollup/rollup-linux-s390x-gnu': 4.52.4 + '@rollup/rollup-linux-x64-gnu': 4.52.4 + '@rollup/rollup-linux-x64-musl': 4.52.4 + '@rollup/rollup-openharmony-arm64': 4.52.4 + '@rollup/rollup-win32-arm64-msvc': 4.52.4 + '@rollup/rollup-win32-ia32-msvc': 4.52.4 + '@rollup/rollup-win32-x64-gnu': 4.52.4 + '@rollup/rollup-win32-x64-msvc': 4.52.4 fsevents: 2.3.3 rrweb-cssom@0.8.0: {} @@ -6223,7 +4499,7 @@ snapshots: sirv@3.0.2: dependencies: - "@polka/url": 1.0.0-next.29 + '@polka/url': 1.0.0-next.29 mrmime: 2.0.1 totalist: 3.0.1 @@ -6281,7 +4557,7 @@ snapshots: svelte-check@4.3.3(picomatch@4.0.3)(svelte@5.39.12)(typescript@5.9.3): dependencies: - "@jridgewell/trace-mapping": 0.3.31 + '@jridgewell/trace-mapping': 0.3.31 chokidar: 4.0.3 fdir: 6.5.0(picomatch@4.0.3) picocolors: 1.1.1 @@ -6304,10 +4580,10 @@ snapshots: svelte@5.39.12: dependencies: - "@jridgewell/remapping": 2.3.5 - "@jridgewell/sourcemap-codec": 1.5.5 - "@sveltejs/acorn-typescript": 1.0.6(acorn@8.15.0) - "@types/estree": 1.0.8 + '@jridgewell/remapping': 2.3.5 + '@jridgewell/sourcemap-codec': 1.5.5 + '@sveltejs/acorn-typescript': 1.0.6(acorn@8.15.0) + '@types/estree': 1.0.8 acorn: 8.15.0 aria-query: 5.3.2 axobject-query: 4.1.0 @@ -6327,7 +4603,7 @@ snapshots: tar@7.5.1: dependencies: - "@isaacs/fs-minipass": 4.0.1 + '@isaacs/fs-minipass': 4.0.1 chownr: 3.0.0 minipass: 7.1.2 minizlib: 3.1.0 @@ -6335,7 +4611,7 @@ snapshots: test-exclude@7.0.2: dependencies: - "@istanbuljs/schema": 0.1.3 + '@istanbuljs/schema': 0.1.3 glob: 10.5.0 minimatch: 10.2.5 @@ -6360,10 +4636,16 @@ snapshots: tldts-core@6.1.86: {} + tldts-core@7.4.10: {} + tldts@6.1.86: dependencies: tldts-core: 6.1.86 + tldts@7.4.10: + dependencies: + tldts-core: 7.4.10 + to-regex-range@5.0.1: dependencies: is-number: 7.0.0 @@ -6374,10 +4656,18 @@ snapshots: dependencies: tldts: 6.1.86 + tough-cookie@6.0.2: + dependencies: + tldts: 7.4.10 + tr46@5.1.1: dependencies: punycode: 2.3.1 + tr46@6.0.0: + dependencies: + punycode: 2.3.1 + ts-api-utils@2.1.0(typescript@5.9.3): dependencies: typescript: 5.9.3 @@ -6390,11 +4680,11 @@ snapshots: ts-proto-descriptors@2.1.0: dependencies: - "@bufbuild/protobuf": 2.11.0 + '@bufbuild/protobuf': 2.11.0 ts-proto@2.11.6: dependencies: - "@bufbuild/protobuf": 2.11.0 + '@bufbuild/protobuf': 2.11.0 case-anything: 2.1.13 ts-poet: 6.12.0 ts-proto-descriptors: 2.1.0 @@ -6405,10 +4695,10 @@ snapshots: typescript-eslint@8.46.1(eslint@9.37.0(jiti@2.6.1))(typescript@5.9.3): dependencies: - "@typescript-eslint/eslint-plugin": 8.46.1(@typescript-eslint/parser@8.46.1(eslint@9.37.0(jiti@2.6.1))(typescript@5.9.3))(eslint@9.37.0(jiti@2.6.1))(typescript@5.9.3) - "@typescript-eslint/parser": 8.46.1(eslint@9.37.0(jiti@2.6.1))(typescript@5.9.3) - "@typescript-eslint/typescript-estree": 8.46.1(typescript@5.9.3) - "@typescript-eslint/utils": 8.46.1(eslint@9.37.0(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/eslint-plugin': 8.46.1(@typescript-eslint/parser@8.46.1(eslint@9.37.0(jiti@2.6.1))(typescript@5.9.3))(eslint@9.37.0(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/parser': 8.46.1(eslint@9.37.0(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/typescript-estree': 8.46.1(typescript@5.9.3) + '@typescript-eslint/utils': 8.46.1(eslint@9.37.0(jiti@2.6.1))(typescript@5.9.3) eslint: 9.37.0(jiti@2.6.1) typescript: 5.9.3 transitivePeerDependencies: @@ -6420,6 +4710,8 @@ snapshots: undici-types@6.21.0: {} + undici@8.10.0: {} + uri-js@4.4.1: dependencies: punycode: 2.3.1 @@ -6434,7 +4726,7 @@ snapshots: pathe: 2.0.3 vite: 6.4.1(@types/node@22.18.10)(jiti@2.6.1)(lightningcss@1.30.1)(yaml@2.8.1) transitivePeerDependencies: - - "@types/node" + - '@types/node' - jiti - less - lightningcss @@ -6456,7 +4748,7 @@ snapshots: rollup: 4.52.4 tinyglobby: 0.2.15 optionalDependencies: - "@types/node": 22.18.10 + '@types/node': 22.18.10 fsevents: 2.3.3 jiti: 2.6.1 lightningcss: 1.30.1 @@ -6468,14 +4760,14 @@ snapshots: vitest@3.2.4(@types/node@22.18.10)(@vitest/ui@3.2.4)(jiti@2.6.1)(jsdom@26.1.0)(lightningcss@1.30.1)(yaml@2.8.1): dependencies: - "@types/chai": 5.2.3 - "@vitest/expect": 3.2.4 - "@vitest/mocker": 3.2.4(vite@6.4.1(@types/node@22.18.10)(jiti@2.6.1)(lightningcss@1.30.1)(yaml@2.8.1)) - "@vitest/pretty-format": 3.2.4 - "@vitest/runner": 3.2.4 - "@vitest/snapshot": 3.2.4 - "@vitest/spy": 3.2.4 - "@vitest/utils": 3.2.4 + '@types/chai': 5.2.3 + '@vitest/expect': 3.2.4 + '@vitest/mocker': 3.2.4(vite@6.4.1(@types/node@22.18.10)(jiti@2.6.1)(lightningcss@1.30.1)(yaml@2.8.1)) + '@vitest/pretty-format': 3.2.4 + '@vitest/runner': 3.2.4 + '@vitest/snapshot': 3.2.4 + '@vitest/spy': 3.2.4 + '@vitest/utils': 3.2.4 chai: 5.3.3 debug: 4.4.3 expect-type: 1.3.0 @@ -6492,8 +4784,8 @@ snapshots: vite-node: 3.2.4(@types/node@22.18.10)(jiti@2.6.1)(lightningcss@1.30.1)(yaml@2.8.1) why-is-node-running: 2.3.0 optionalDependencies: - "@types/node": 22.18.10 - "@vitest/ui": 3.2.4(vitest@3.2.4) + '@types/node': 22.18.10 + '@vitest/ui': 3.2.4(vitest@3.2.4) jsdom: 26.1.0 transitivePeerDependencies: - jiti @@ -6513,19 +4805,41 @@ snapshots: dependencies: xml-name-validator: 5.0.0 + web-vitals@5.3.0: {} + webidl-conversions@7.0.0: {} + webidl-conversions@8.0.1: {} + whatwg-encoding@3.1.1: dependencies: iconv-lite: 0.6.3 whatwg-mimetype@4.0.0: {} + whatwg-mimetype@5.0.0: {} + whatwg-url@14.2.0: dependencies: tr46: 5.1.1 webidl-conversions: 7.0.0 + whatwg-url@16.0.1: + dependencies: + '@exodus/bytes': 1.15.1 + tr46: 6.0.0 + webidl-conversions: 8.0.1 + transitivePeerDependencies: + - '@noble/hashes' + + whatwg-url@17.1.0: + dependencies: + '@exodus/bytes': 1.15.1 + tr46: 6.0.0 + webidl-conversions: 8.0.1 + transitivePeerDependencies: + - '@noble/hashes' + which@2.0.2: dependencies: isexe: 2.0.0 diff --git a/components/frontend/src/app.css b/components/frontend/src/app.css index 0d723226..2054a773 100644 --- a/components/frontend/src/app.css +++ b/components/frontend/src/app.css @@ -1,6 +1,8 @@ @import "tailwindcss"; -@import "@skeletonlabs/skeleton"; -@import "@skeletonlabs/skeleton/optional/presets"; -@import "./themes/hackathonsdsc.css"; -@source '../node_modules/@skeletonlabs/skeleton-svelte/dist'; +@import "./themes/hackagon.css"; + +/* Colour mode is an attribute on , not a media query: LightSwitch.svelte + * owns it and persists the choice. The theme's own tokens key off the same + * attribute, so this variant is only needed for the handful of places where a + * non-colour property differs by mode — e.g. swapping the light/dark logo. */ @custom-variant dark (&:where([data-mode="dark"], [data-mode="dark"] *)); diff --git a/components/frontend/src/app.d.ts b/components/frontend/src/app.d.ts index 2c20df34..3eecf902 100644 --- a/components/frontend/src/app.d.ts +++ b/components/frontend/src/app.d.ts @@ -7,6 +7,12 @@ import type { Session } from "@auth/core/types" import type { User } from "$lib/server/grpc/generated/user/entities/user" declare global { + /** + * Build version, substituted by Vite's `define` (see vite.config.ts). Read it + * through `$lib/version`, which guards the case where the define is absent. + */ + const __APP_VERSION__: string + namespace App { // interface Error {} export interface Locals { diff --git a/components/frontend/src/app.html b/components/frontend/src/app.html index b5dcb8f9..43a950d7 100644 --- a/components/frontend/src/app.html +++ b/components/frontend/src/app.html @@ -1,8 +1,27 @@ - + - + + + + + + + %sveltekit.head% diff --git a/components/frontend/src/auth.callback.test.ts b/components/frontend/src/auth.callback.test.ts index 15a2f131..a99489ac 100644 --- a/components/frontend/src/auth.callback.test.ts +++ b/components/frontend/src/auth.callback.test.ts @@ -96,7 +96,7 @@ describe("Auth.js jwt Callback", () => { expect(result.error).toBeUndefined() // idToken and organization are intentionally not stored in the JWT // to keep the session cookie under the 4096 byte limit - expect(result.idToken).toBeUndefined() + expect(result).not.toHaveProperty("idToken") expect(result.organization).toBeUndefined() }) @@ -114,10 +114,35 @@ describe("Auth.js jwt Callback", () => { account: null, } as JwtCallbackParams)) as CustomJWT - expect(result).toBe(mockToken) // Should return the exact same object + // Value-equal rather than identical: the callback rebuilds the token to + // strip a legacy `idToken`, so the still-valid path returns a copy. + expect(result).toEqual(mockToken) expect(mockFetch).not.toHaveBeenCalled() // Fetch should not be called }) + it("should strip a legacy idToken without waiting for a refresh", async () => { + const mockToken = { + sub: "user1", + accessToken: "valid_access", + refreshToken: "valid_refresh", + expiresAt: Math.floor(Date.now() / 1000) + 600, // Nowhere near expiry + userId: "user1", + // Minted before the cookie-size fix. The refresh path is not reached on + // this request, so evicting only there would leave the oversized cookie — + // and the 502 — in place until this token neared expiry. + idToken: "stale_id", + } as CustomJWT & { idToken?: string } + + const result = (await jwtCallback({ + token: mockToken as JWT, + account: null, + } as JwtCallbackParams)) as CustomJWT + + expect(result).not.toHaveProperty("idToken") + expect(result.accessToken).toBe("valid_access") + expect(mockFetch).not.toHaveBeenCalled() + }) + it("should proactively refresh if token expires within 30 seconds", async () => { const mockToken: CustomJWT = { sub: "user1", @@ -148,13 +173,17 @@ describe("Auth.js jwt Callback", () => { }) it("should attempt refresh if token is expired", async () => { - const mockToken: CustomJWT = { + const mockToken = { sub: "user1", accessToken: "expired_access", refreshToken: "valid_refresh", // Need this to refresh expiresAt: Math.floor(Date.now() / 1000) - 60, // Expired 1 min ago userId: "user1", - } + // A session minted before the cookie-size fix still carries this. Refresh + // must evict it rather than spread it forward, or those sessions keep the + // oversized cookie — and the 502 — forever. + idToken: "stale_id", + } as CustomJWT & { idToken?: string } // Mock a successful fetch response for refresh mockFetch.mockResolvedValueOnce({ @@ -174,7 +203,10 @@ describe("Auth.js jwt Callback", () => { expect(mockFetch).toHaveBeenCalledOnce() // Ensure fetch was called expect(result.accessToken).toBe("refreshed_access") - expect(result.idToken).toBe("refreshed_id") + // Same budget as initial sign-in: the refreshed id_token must not be stored + // either, or the cookie crosses the chunk threshold ~5 min into every + // session and the proxy answers 502. + expect(result).not.toHaveProperty("idToken") expect(result.refreshToken).toBe("rotated_refresh") // Check if refresh token updated expect(result.expiresAt).toBeGreaterThan(mockToken.expiresAt!) expect(result.error).toBeUndefined() diff --git a/components/frontend/src/auth.d.ts b/components/frontend/src/auth.d.ts index 44ff709e..554c5d5c 100644 --- a/components/frontend/src/auth.d.ts +++ b/components/frontend/src/auth.d.ts @@ -19,8 +19,10 @@ declare module "@auth/core/types" { declare module "@auth/core/jwt" { interface JWT extends DefaultJWT { + // Every field here is encrypted into the session cookie, which Auth.js + // chunks past 3936 bytes. Keep it to what is actually read: the access + // token (gRPC auth) and the refresh token (renewal). No id_token. accessToken?: string - idToken?: string refreshToken?: string expiresAt?: number organization?: unknown diff --git a/components/frontend/src/auth.ts b/components/frontend/src/auth.ts index fbb8f322..b15c8b70 100644 --- a/components/frontend/src/auth.ts +++ b/components/frontend/src/auth.ts @@ -46,7 +46,16 @@ export const getAuthOptions = ( callbacks: { // --- JWT Callback: Handles token creation and refresh --- async jwt(params: JwtCallbackParams): Promise { - const token = params.token as CustomJWT + // Sessions minted before the cookie-size fix still carry `idToken`, and + // it has to come off ahead of every return path below — the still-valid + // branch hands `token` straight back, so evicting only on refresh left + // those sessions oversized, and 502-ing, until their access token neared + // expiry. It is off the JWT type deliberately, so this cast is the only + // place that admits the legacy field exists. + const { idToken, ...token } = params.token as CustomJWT & { + idToken?: string + } + void idToken const { account, profile } = params // Initial Sign-in (`account` is available) if (account && profile) { @@ -114,11 +123,21 @@ export const getAuthOptions = ( } logger.info("JWT Callback: Token refreshed successfully.") + + // Why the refreshed `id_token` is dropped rather than stored: this + // object is encrypted straight into the session cookie, and Auth.js + // splits that cookie into chunks once the value passes 3936 bytes + // (@auth/core ALLOWED_COOKIE_SIZE 4096, less 160 for attributes). + // Access + refresh token alone encrypt to ~3.8 kB, so adding the + // ~1.2 kB id_token pushed it to ~5.4 kB — two ~4 kB Set-Cookie + // headers, which overflows a reverse proxy's default 4 kB + // response-header buffer and turns every response into a 502. + // Nothing reads it, so nothing is lost. + // Update token with new values return { ...token, // Keep existing info like userId, organization, etc. accessToken: refreshedTokens.access_token, - idToken: refreshedTokens.id_token, // Keycloak often sends updated id_token expiresAt: Math.floor(Date.now() / 1000) + refreshedTokens.expires_in, diff --git a/components/frontend/src/hooks.guard.test.ts b/components/frontend/src/hooks.guard.test.ts index 42a1e106..979ab941 100644 --- a/components/frontend/src/hooks.guard.test.ts +++ b/components/frontend/src/hooks.guard.test.ts @@ -1,5 +1,7 @@ import { describe, it, expect } from "vitest" import { isProtectedRoute } from "./hooks.server" +import { reservedSlugs } from "$lib/utils/sitePageSlug" +import { LOGIN_PATH, loginUrlFor } from "$lib/utils/returnTo" describe("isProtectedRoute", () => { it("should protect /manage routes", () => { @@ -15,15 +17,54 @@ describe("isProtectedRoute", () => { expect(isProtectedRoute("/hackathon/abc/")).toBe(false) }) - it("should protect /welcome", () => { - expect(isProtectedRoute("/welcome")).toBe(true) - expect(isProtectedRoute("/welcome/")).toBe(true) + // Both of these were redirect loops in the wild: the route exists inside the + // (app) group, but the guard read the path as public, so no gRPC client was + // created, the group's own guard bounced to login, and hooks.server.ts sent + // the signed-in user straight back to the page. Forever. + it("protects routes that live in the (app) group", () => { + expect(isProtectedRoute("/account")).toBe(true) + expect(isProtectedRoute("/account/")).toBe(true) + expect(isProtectedRoute("/dashboard")).toBe(true) + expect(isProtectedRoute("/hackathon/create")).toBe(true) + expect(isProtectedRoute("/register/some-hackathon-id")).toBe(true) }) - it("should protect unknown routes by default", () => { - expect(isProtectedRoute("/dashboard")).toBe(true) - expect(isProtectedRoute("/settings")).toBe(true) + it("reserves every top-level segment the route tree owns", () => { + // Derived from the tree, so a new route reserves itself. If this ever + // shrinks, some route just became shadowable by a CMS page. + for (const segment of [ + "account", + "dashboard", + "hackathon", + "manage", + "my", + "register", + // The sign-in interstitial. It only became route-owned when it gained a + // +page.svelte — before that the segment was derivable by nothing, and a + // CMS page called "signin" would have been offered the URL the login + // flow bounces every anonymous visitor to. + "signin", + ]) { + expect(reservedSlugs.has(segment), `${segment} must be reserved`).toBe( + true, + ) + } + }) + + it("should protect unknown MULTI-segment routes by default", () => { expect(isProtectedRoute("/some/new/page")).toBe(true) + expect(isProtectedRoute("/settings/profile")).toBe(true) + }) + + // Deliberate: a one-segment path that no route owns is a candidate SitePage + // (admins create those at runtime, and the footer links reach them before + // login). Letting it through is what makes /about work without a code change + // per page; an unknown slug still 404s at the loader. + it("lets unknown single-segment paths through as candidate SitePages", () => { + expect(isProtectedRoute("/about")).toBe(false) + expect(isProtectedRoute("/privacy")).toBe(false) + expect(isProtectedRoute("/welcome")).toBe(false) + expect(isProtectedRoute("/settings")).toBe(false) }) it("should not protect public routes", () => { @@ -33,4 +74,16 @@ describe("isProtectedRoute", () => { expect(isProtectedRoute("/auth")).toBe(false) expect(isProtectedRoute("/error")).toBe(false) }) + + // The interstitial is where the guard SENDS an anonymous visitor. If it were + // ever protected the guard would send them to a page that bounces them to + // itself — an infinite redirect on the one route whose job is to end one. + // Asserted against the constant the guard actually redirects to, so renaming + // the route cannot leave this test agreeing with a path nothing uses. + it("never protects the page the guard bounces anonymous visitors to", () => { + expect(isProtectedRoute(LOGIN_PATH)).toBe(false) + expect(isProtectedRoute(`${LOGIN_PATH}/`)).toBe(false) + const parked = loginUrlFor("/my/hackathon/abc/manage") + expect(isProtectedRoute(new URL(parked, "http://x").pathname)).toBe(false) + }) }) diff --git a/components/frontend/src/hooks.server.ts b/components/frontend/src/hooks.server.ts index 1ea5f5c5..fe393038 100644 --- a/components/frontend/src/hooks.server.ts +++ b/components/frontend/src/hooks.server.ts @@ -1,4 +1,3 @@ -import { resolve as resolvePath } from "$app/paths" import { sequence } from "@sveltejs/kit/hooks" import { error, @@ -8,6 +7,8 @@ import { type RequestEvent, } from "@sveltejs/kit" import { parseArgs } from "$lib/server/args" +import { isSitePageSlug, singleSegment } from "$lib/utils/sitePageSlug" +import { loginUrlFor, safeReturnTo } from "$lib/utils/returnTo" import { handle as authHandle } from "./auth" import { setupLogger, logger } from "$lib/server/logger" import { ConfigLoader } from "$lib/server/settings" @@ -28,7 +29,26 @@ let configLoader: ConfigLoader // --- CONSTANTS --- const PUBLIC_ROUTE_PATTERNS = [ /^\/$/, - /^\/hackathon(\/|$)/, + // Uploaded objects. Event logos and gallery photos render on PUBLIC event + // pages, before login, so the path that serves them cannot require a + // session — the public-read policy on those prefixes is the access decision, + // and it was already made in the object store. + /^\/objects\//, + // Public EVENT pages, but not /hackathons/create — that route lives in the + // (app) group and needs locals.grpc. Matching it here would make it public, + // the group's own guard would find no client and redirect to login, and the + // signed-in user would be sent straight back: an infinite redirect on the one + // page that creates a hackathon. + /^\/hackathon$/, + /^\/hackathon\/(?!create(\/|$))/, + // Invitation links must open for someone who is not signed in yet — the token + // in the URL is the credential, and they sign in from that page. + /^\/invite(\/|$)/, + // Recording consent. The tracker runs on public pages, so the person being + // asked may have no account at all — a consent endpoint behind a login would + // only ever be answerable by people who had already been recorded on the way + // in. + /^\/consent(\/|$)/, /^\/signin($|\/)/, /^\/signout($|\/)/, /^\/auth($|\/)/, @@ -36,13 +56,31 @@ const PUBLIC_ROUTE_PATTERNS = [ ] export function isProtectedRoute(pathname: string): boolean { - return !PUBLIC_ROUTE_PATTERNS.some((p) => p.test(pathname)) + if (PUBLIC_ROUTE_PATTERNS.some((p) => p.test(pathname))) return false + + // Platform pages (SitePage records served by [slug=sitepage]) are reached + // from the footer by visitors who have never logged in, so any slug an admin + // publishes must be public — enumerating them here would mean a code change + // per page. `isSitePageSlug` is the same rule the param matcher uses, and it + // derives its reserved set from the route tree, so it cannot shadow a real + // route. Unknown slugs still reach the loader and 404 there. + const segment = singleSegment(pathname) + if (segment && isSitePageSlug(segment)) return false + + return true } +// Bounced to the sign-in interstitial (/signin), NOT to the landing page. +// +// It used to be `/?returnTo=…`: a visitor who opened a deep link was dropped on +// the marketing front page with no word about why, and the parked destination +// was then thrown away — the "Log in" button computed its own callbackUrl from +// the pathname and never read the query it had just been handed. The +// interstitial says what happened, names where it is going, and hands the SAME +// parked value to Auth.js as the post-login destination. function redirectToLogin(url: URL, logger: Logger, reason: string) { - const returnTo = encodeURIComponent(url.pathname + url.search) logger.debug(`HOOKS: ${reason} -> Redirecting to login.`) - throw redirect(303, `/?returnTo=${returnTo}`) + throw redirect(303, loginUrlFor(url.pathname + url.search)) } function hasLoggedInUserContext( @@ -163,8 +201,24 @@ const sessionSetupHandle: Handle = async ({ event, resolve }) => { event.locals.logger.info( "HOOKS: User not in DB, auto-registering via Register RPC.", ) - const regResp = await event.locals.grpc.user.register({}) - event.locals.platformUser = regResp.user ?? undefined + try { + const regResp = await event.locals.grpc.user.register({}) + event.locals.platformUser = regResp.user ?? undefined + } catch (regErr) { + // Same rescue as WhoAmI below: the backend can drop between the two + // calls, and letting that escape the hook turns a first login into an + // unexpected 500 rather than a handled "backend is down". + if ( + regErr instanceof ClientError && + regErr.code === Status.UNAVAILABLE + ) { + event.locals.logger.warn( + "HOOKS: Backend unavailable for Register, proceeding without platform user.", + ) + } else { + throw regErr + } + } } else if ( err instanceof ClientError && err.code === Status.UNAVAILABLE @@ -181,19 +235,25 @@ const sessionSetupHandle: Handle = async ({ event, resolve }) => { return resolve(event) } -// If a logged-in user visits the root page (without returnTo), send them to the dashboard. +// A logged-in visitor arriving at `/?returnTo=X` is forwarded to X. +// +// The guards no longer PRODUCE that shape — they bounce to /signin, which owns +// the destination now — so this is a backstop for links that still carry it: a +// bookmarked or pasted `/?returnTo=…`, and anything that predates the change. +// It stays because it costs one comparison and its absence would silently +// swallow a destination. const redirectHandle: Handle = async ({ event, resolve }) => { const isRootPath = event.url.pathname === "/" - const hasReturnTo = event.url.searchParams.has("returnTo") - - if (isRootPath && !hasReturnTo) { - if (event.locals.session?.user?.id) { - event.locals.logger.debug( - { userId: event.locals.session.user.id }, - "HOOKS: Logged-in user on login page -> Redirecting to dashboard.", - ) - throw redirect(303, resolvePath("/(app)/dashboard")) - } + // Validated, not taken at face value: `returnTo` comes from the URL, so an + // unchecked value is an open redirect off the site. + const parked = safeReturnTo(event.url.searchParams.get("returnTo")) + + if (isRootPath && event.locals.session?.user?.id && parked) { + event.locals.logger.debug( + { userId: event.locals.session.user.id, target: parked }, + "HOOKS: Logged-in user with a parked returnTo -> Redirecting.", + ) + throw redirect(303, parked) } return resolve(event) @@ -205,7 +265,7 @@ export const handle = sequence( loggerHandle, // Observe Requests via logging authHandle, // Setup Authentication (this is imported on a custom Handler) sessionSetupHandle, // Sanitize session + guard protected routes + setup gRPC clients - redirectHandle, // Logged-in users on / -> /dashboard (unless returnTo is present) + redirectHandle, // Logged-in users on /?returnTo=X -> X (legacy links only) ) // ---------------------------------------------------------- diff --git a/components/frontend/src/lib/components/dashboard/DashboardView.svelte b/components/frontend/src/lib/components/dashboard/DashboardView.svelte index be31a707..233994aa 100644 --- a/components/frontend/src/lib/components/dashboard/DashboardView.svelte +++ b/components/frontend/src/lib/components/dashboard/DashboardView.svelte @@ -1,10 +1,13 @@ - -
    + +
    -

    Welcome back, {userName}

    -

    + +

    +

    Welcome back, {userName}

    + {#if roleBadges.length > 0} +
    + + Your roles: + {#each roleBadges as role (role)} + + + {globalRoleLabel(role)} + + {/each} +
    + {/if} +
    +

    You are connected to {myHackathons.length} hackathon{myHackathons.length === 1 ? '' : 's'}

    -
    - -
    - -
    + +
    +
    -

    Your hackathons

    +

    Your hackathons

    {#if myHackathons.length === 0} -

    You are not connected to any hackathons yet.

    +

    You are not connected to any hackathons yet.

    {:else} -
    +
    {#each myHackathons as h, i (h.id)} {@const mem = h.viewerMembership} -
    + +
    + {#if mem} - + {membershipBadgeLabel(mem.isWaiting, mem.role)} {/if} @@ -108,14 +234,32 @@
    -

    Other hackathons

    +

    Other hackathons

    + + {#if joinError} + + + {/if} + {#if joinNotice} + +

    {joinNotice}

    + {/if} {#if otherHackathons.length === 0} -

    No other hackathons available.

    +

    No other hackathons available.

    {:else} -
    +
    {#each otherHackathons as h, i (h.id)} -
    + +
    - + {#if h.canJoin === false} + + + Registration closed + + {:else} +
    { + joiningIds.add(h.id); + return async ({ update }) => { + await update(); + joiningIds.delete(h.id); + }; + }} + > + + +
    + {/if}
    {/each}
    {/if}
    -
    - -
    - - -
    -
    - Notifications - - 2 - -
    -
    - -
    -

    - Project proposals are due in 5 days for ORD Hackathon 2026. -

    - 2 hours ago -
    -
    -
    - -
    -

    - You were added to Team DataFlow by Carlos. -

    - 1 day ago + + {#if adminItems.length > 0} +
    +

    Manage platform

    + +
    + {#each adminItems as item (item.id)} + {#if item.href} + + + {@render tile(item, true)} + + + {:else} + +
    + {@render tile(item, false)} +
    + {/if} + {/each}
    -
    -
    +
    + {/if}
    + + +{#snippet tile(item: NavItem, linked: boolean)} + {@const Icon = item.icon} +
    + + +

    {item.label}

    + {#if linked} +
    + {#if item.description} + +

    {item.description}

    + {/if} +{/snippet} diff --git a/components/frontend/src/lib/components/dashboard/DashboardView.test.ts b/components/frontend/src/lib/components/dashboard/DashboardView.test.ts new file mode 100644 index 00000000..f3fbd6cb --- /dev/null +++ b/components/frontend/src/lib/components/dashboard/DashboardView.test.ts @@ -0,0 +1,98 @@ +/** + * The Join control on "Other hackathons". + * + * `HackathonService.List` returns `capabilities` per hackathon so a list can + * gate its own buttons "instead of firing a mutation to discover it is closed" + * — its own comment — and this page ignored it. On a populated instance that + * was six Join buttons that answered `FailedPrecondition` and could never do + * anything else. + * + * Every case here asserts on the CONTROL, never on the row: the row contains + * the event's name, its badges and its thumbnail, so "the row is visible" is + * true whether or not the button is. That mistake has been made three times in + * this repo already. + * + * And every absence-assertion carries a positive control in the same test — + * "no Join button" is satisfied just as well by a component that rendered + * nothing at all. + */ +import { cleanup, render, screen } from "@testing-library/svelte" +import { afterEach, describe, expect, it } from "vitest" +import DashboardView from "./DashboardView.svelte" + +afterEach(cleanup) + +/** Enum numbers, not imports: `$lib/server/` is server-only and must not reach + * a component test any more than it may reach a component. */ +const RUNNING = 2 +const FINISHED = 3 + +interface Row { + id: string + name: string + status: number + canJoin?: boolean +} + +function row(over: Partial = {}): Row { + return { + id: "h-1", + name: "Climate Tech Hackathon 2026", + status: RUNNING, + canJoin: true, + ...over, + } +} + +function mount(others: Row[]) { + render(DashboardView, { + props: { + session: { user: { name: "Charles", id: "u-1" } }, + myHackathons: [], + otherHackathons: others, + }, + }) +} + +const joinButton = () => screen.queryByRole("button", { name: "Join" }) + +describe("the Join control", () => { + it("is offered on an event that can be joined", () => { + mount([row()]) + expect(joinButton()).not.toBeNull() + expect(screen.queryByText("Registration closed")).toBeNull() + }) + + it("is withheld when the loader says the event cannot be joined", () => { + mount([row({ canJoin: false })]) + expect(joinButton()).toBeNull() + }) + + it("says why rather than silently dropping the control", () => { + // Withholding alone reads as a rendering fault. The row must still answer. + mount([row({ canJoin: false, status: FINISHED })]) + expect(screen.getByText("Registration closed")).toBeTruthy() + // Positive control: the event itself is still listed, so the absence above + // is about the BUTTON and not about a component that rendered nothing. + expect(screen.getByText("Climate Tech Hackathon 2026")).toBeTruthy() + }) + + it("gates each row on its own answer", () => { + // The bug this guards: one flag read for the whole section, so a single + // closed event would take every other event's button with it. + mount([ + row({ id: "open", name: "Open Event", canJoin: true }), + row({ id: "shut", name: "Shut Event", canJoin: false }), + ]) + expect(screen.getAllByRole("button", { name: "Join" })).toHaveLength(1) + expect(screen.getAllByText("Registration closed")).toHaveLength(1) + }) + + it("offers Join when the loader did not decide", () => { + // `canJoin` is optional and `false`-checked, not truthy-checked: a caller + // that never computes it must not lose its controls. Undefined means "no + // opinion", which is what the backend then answers for real. + mount([row({ canJoin: undefined })]) + expect(joinButton()).not.toBeNull() + }) +}) diff --git a/components/frontend/src/lib/components/data/DataTable.svelte b/components/frontend/src/lib/components/data/DataTable.svelte new file mode 100644 index 00000000..69d37a28 --- /dev/null +++ b/components/frontend/src/lib/components/data/DataTable.svelte @@ -0,0 +1,112 @@ + + +{#if rows.length === 0} +

    {empty}

    +{:else} + +
    + + {#if caption}{/if} + + + {#each columns as col (col.key)} + + {/each} + + + + {#each sorted as r (rowKey(r))} + + {@render row(r)} + + {/each} + +
    {caption}
    + {#if col.sort} + + {:else} + {col.label} + {/if} +
    +
    +{/if} diff --git a/components/frontend/src/lib/components/data/DataToolbar.svelte b/components/frontend/src/lib/components/data/DataToolbar.svelte new file mode 100644 index 00000000..077b08fd --- /dev/null +++ b/components/frontend/src/lib/components/data/DataToolbar.svelte @@ -0,0 +1,145 @@ + + +
    +
    + {#if summary} + {summary} + {/if} + + {#if filtering && shown >= 0 && total >= 0} + + Showing {shown} of {total} + + + {/if} +
    + +
    + +
    +
    + + {#each filters as f (f.id)} + + {/each} + + +
    + + +
    +
    +
    diff --git a/components/frontend/src/lib/components/data/RowActions.svelte b/components/frontend/src/lib/components/data/RowActions.svelte new file mode 100644 index 00000000..c3eca437 --- /dev/null +++ b/components/frontend/src/lib/components/data/RowActions.svelte @@ -0,0 +1,62 @@ + + +
    + + + + + + + + + + +
    diff --git a/components/frontend/src/lib/components/forms/ImagePickerDialog.svelte b/components/frontend/src/lib/components/forms/ImagePickerDialog.svelte new file mode 100644 index 00000000..146b512b --- /dev/null +++ b/components/frontend/src/lib/components/forms/ImagePickerDialog.svelte @@ -0,0 +1,461 @@ + + + + { + // The backdrop IS the dialog element; its content is a child. So a click + // whose target is the dialog itself landed outside the panel. + if (event.target === dialog) close(); + }} +> +
    +
    +

    {title}

    + +
    + + {#if browseEndpoint} + +
    + + +
    + {/if} + + {#if tab === 'upload' || !browseEndpoint} + +
    { + event.preventDefault(); + dropDepth += 1; + }} + ondragover={(event) => { + // Required: without preventDefault on dragover the browser + // refuses the drop and there is no event to handle. + event.preventDefault(); + }} + ondragleave={() => { + // A counter, not a boolean: dragenter/dragleave fire for + // every child the pointer crosses, so a single flag flickers + // off the moment the cursor passes over the button inside. + dropDepth = Math.max(0, dropDepth - 1); + }} + ondrop={onDrop} + > +

    + {multiple ? 'Drag images here' : 'Drag an image here'} +

    +

    or

    + + + + +

    + PNG, JPEG, WebP or GIF.{maxMb ? ` Up to ${maxMb} MB.` : ''} The file + goes straight to storage; nothing is saved on the form until you + submit it. +

    +
    + + {#if uploadError} + + {/if} + {:else} + {#if browseError} + + {:else if browsing && images.length === 0} +

    Loading…

    + {:else if images.length === 0} +

    + Nothing has been uploaded here yet. Switch to Upload to add the first + picture. +

    + {:else} +
      + {#each images as image (image.key || image.url)} +
    • + +
    • + {/each} +
    + +
    + {#if nextPageToken} + + {/if} + {#if browseTruncated} + +

    + Showing the most recent uploads only. +

    + {/if} +
    + {/if} + {/if} +
    +
    + + diff --git a/components/frontend/src/lib/components/forms/ImageUploadField.svelte b/components/frontend/src/lib/components/forms/ImageUploadField.svelte new file mode 100644 index 00000000..5cbcf6f1 --- /dev/null +++ b/components/frontend/src/lib/components/forms/ImageUploadField.svelte @@ -0,0 +1,166 @@ + + +
    + {#if !compact} + + {/if} + +
    + + + + {#if value} + + + {/if} + + {#if picked} + Chosen — save to keep it. + {/if} +
    + + { + value = url; + picked = true; + }} + /> + + {#if allowUrl} + + + {:else} + + + {/if} + + {#if !compact} +

    + {hint || 'PNG, JPEG, WebP or GIF.'}{maxMb ? ` Up to ${maxMb} MB.` : ''} + The file goes straight to storage; nothing is stored on this event or + profile until you save. +

    + {/if} + + {#if value} + {previewAlt} + {/if} +
    diff --git a/components/frontend/src/lib/components/forms/MarkdownContent.svelte b/components/frontend/src/lib/components/forms/MarkdownContent.svelte new file mode 100644 index 00000000..b9485873 --- /dev/null +++ b/components/frontend/src/lib/components/forms/MarkdownContent.svelte @@ -0,0 +1,101 @@ + + +
    + + {@html html} +
    + + diff --git a/components/frontend/src/lib/components/forms/MarkdownEditor.svelte b/components/frontend/src/lib/components/forms/MarkdownEditor.svelte new file mode 100644 index 00000000..a8ebb84c --- /dev/null +++ b/components/frontend/src/lib/components/forms/MarkdownEditor.svelte @@ -0,0 +1,538 @@ + + + + { + if (tableOpen && event.key === 'Escape') closeTable(true); + }} +/> + +
    + +
    + + + + {#if uploadEndpoint} + + + {/if} +
    + + {#if uploadEndpoint} + + insertAtCaret(`![${alt}](${url})`)} + /> + {/if} + + {#if mode === 'write'} + + + {/if} + + {#if tableOpen} + +
    + + +
    + + + +
    + + +

    + {#if !pasted.trim()} + Nothing pasted yet. + {:else if !table} + Nothing in there to convert. + {:else} + {table.columns} + {table.columns === 1 ? 'column' : 'columns'} × {table.rows} + {table.rows === 1 ? 'row' : 'rows'}, separated by {DELIMITER_LABELS[ + table.delimiter + ].toLowerCase()}. + {#if table.ambiguous} + More than one separator fits — pick one if this looks wrong. + {/if} + {/if} +

    + + {#if tableTooLong} + + {/if} + +
    + + +
    +
    + {/if} + + + + + +
    + {#if text.trim()} + + {:else} +

    Nothing to preview yet.

    + {/if} +
    + +

    Markdown supported.

    +
    diff --git a/components/frontend/src/lib/components/forms/MarkdownEditor.test.ts b/components/frontend/src/lib/components/forms/MarkdownEditor.test.ts new file mode 100644 index 00000000..f951bfab --- /dev/null +++ b/components/frontend/src/lib/components/forms/MarkdownEditor.test.ts @@ -0,0 +1,466 @@ +/** + * The editor's toolbar, wired up. + * + * The transformations are pure functions with their own tests + * (`$lib/utils/markdownEdit`, `$lib/utils/markdownTable`). What is only true + * once mounted is that each BUTTON reaches the right one of them, that it acts + * on the selection the textarea is holding, and — the claim the table button + * actually makes — that what it inserts renders in the Preview pane as a real + * `` with the right number of rows and columns. "The markdown contains + * pipes" is a different claim, and a weaker one. + * + * Note the difference from the Playwright specs: `getByRole(role, {name})` here + * matches the accessible name EXACTLY (DOM Testing Library compares the whole + * string), whereas Playwright's is a case-insensitive substring. A toolbar of + * twelve short names is exactly where that bites. + */ + +import { afterEach, describe, expect, it } from "vitest" +import { cleanup, fireEvent, render, screen } from "@testing-library/svelte" +import { tick } from "svelte" +import MarkdownEditor from "./MarkdownEditor.svelte" + +// No auto-cleanup is configured, so a second render in the same file would +// leave two editors mounted and every query would be ambiguous. +afterEach(cleanup) + +function mount(value = "") { + render(MarkdownEditor, { props: { name: "content", id: "content", value } }) + const area = screen.getByRole("textbox", { + name: "", + }) as HTMLTextAreaElement + return area +} + +/** The main field. Named by `id`, because the paste panel adds a second + * textarea and "the one with name=content" is the document. */ +function field(): HTMLTextAreaElement { + const el = document.querySelector("textarea[name=content]") + if (!el) throw new Error("the editor's textarea is not mounted") + return el as HTMLTextAreaElement +} + +/** Type into the field the way a person would, then place the selection. */ +async function type(value: string, start?: number, end?: number) { + const area = field() + await fireEvent.input(area, { target: { value } }) + area.focus() + area.setSelectionRange(start ?? value.length, end ?? start ?? value.length) + return area +} + +async function press(name: string) { + await fireEvent.click(screen.getByRole("button", { name })) + // `apply()` awaits a tick before restoring the selection. + await tick() + await tick() +} + +const selectionOf = (area: HTMLTextAreaElement) => + area.value.slice(area.selectionStart, area.selectionEnd) + +describe("MarkdownEditor: the toolbar is wired to the field", () => { + it("wraps a selection in bold and keeps the words selected", async () => { + mount() + const area = await type("hello world", 6, 11) + + await press("Bold") + + expect(area.value).toBe("hello **world**") + // The markers are not part of the selection, so pressing again toggles the + // same words rather than nesting. + expect(selectionOf(area)).toBe("world") + }) + + it("opens an empty pair at a bare caret and sits between them", async () => { + mount() + const area = await type("", 0, 0) + + await press("Bold") + + expect(area.value).toBe("****") + expect(area.selectionStart).toBe(2) + expect(area.selectionEnd).toBe(2) + }) + + it("unwraps on a second press", async () => { + mount() + const area = await type("hello world", 6, 11) + + await press("Bold") + await press("Bold") + + expect(area.value).toBe("hello world") + }) + + it("does not confuse italic with bold", async () => { + mount() + // `world` is 8..13 — the two asterisks are 6 and 7. + const area = await type("hello **world** here", 8, 13) + + await press("Italic") + + expect(area.value).toBe("hello ***world*** here") + }) + + it("re-levels a heading instead of stacking hashes", async () => { + mount() + const area = await type("Title", 2, 2) + + await press("Heading 2") + expect(area.value).toBe("## Title") + + await press("Heading 3") + expect(area.value).toBe("### Title") + + await press("Heading 3") + expect(area.value).toBe("Title") + }) + + it("lists every line the selection touches, and converts between kinds", async () => { + mount() + const area = await type("one\ntwo\nthree", 0, 13) + + await press("Numbered list") + expect(area.value).toBe("1. one\n2. two\n3. three") + + await press("Bulleted list") + expect(area.value).toBe("- one\n- two\n- three") + }) + + it("quotes, inline-codes and fences", async () => { + mount() + const area = await type("note", 0, 4) + + await press("Quote") + expect(area.value).toBe("> note") + + await type("code", 0, 4) + await press("Inline code") + expect(area.value).toBe("`code`") + + await type("const x = 1", 0, 11) + await press("Code block") + expect(area.value).toBe("```\nconst x = 1\n```") + }) + + it("links the selection and selects the URL to type over", async () => { + mount() + const area = await type("see the docs", 8, 12) + + await press("Link") + + expect(area.value).toBe("see the [docs](url)") + expect(selectionOf(area)).toBe("url") + }) + + it("answers Ctrl+B, Ctrl+I and Ctrl+K from the field itself", async () => { + mount() + const area = await type("hi", 0, 2) + + await fireEvent.keyDown(area, { key: "b", ctrlKey: true }) + await tick() + expect(area.value).toBe("**hi**") + + await type("hi", 0, 2) + await fireEvent.keyDown(area, { key: "i", ctrlKey: true }) + await tick() + expect(area.value).toBe("*hi*") + + await type("docs", 0, 4) + await fireEvent.keyDown(area, { key: "k", ctrlKey: true }) + await tick() + expect(area.value).toBe("[docs](url)") + }) + + it("ignores a plain keypress and a modified one it does not own", async () => { + mount() + const area = await type("hi", 0, 2) + + await fireEvent.keyDown(area, { key: "b" }) + await fireEvent.keyDown(area, { key: "b", ctrlKey: true, shiftKey: true }) + await fireEvent.keyDown(area, { key: "b", ctrlKey: true, altKey: true }) + await tick() + + expect(area.value).toBe("hi") + }) +}) + +describe("MarkdownEditor: toolbar accessibility", () => { + it("is a labelled toolbar naming the field it edits", () => { + mount() + const toolbar = screen.getByRole("toolbar", { name: "Formatting" }) + + expect(toolbar).toBeInTheDocument() + expect(toolbar).toHaveAttribute("aria-controls", "content") + }) + + it("gives every control an accessible name of its own", () => { + mount() + for (const name of [ + "Bold", + "Italic", + "Heading 1", + "Heading 2", + "Heading 3", + "Link", + "Bulleted list", + "Numbered list", + "Quote", + "Inline code", + "Code block", + "Paste a table", + ]) { + expect(screen.getByRole("button", { name })).toBeInTheDocument() + } + }) + + it("is a single tab stop with the rest reached by arrow key", async () => { + mount() + const toolbar = screen.getByRole("toolbar", { name: "Formatting" }) + const buttons = Array.from(toolbar.querySelectorAll("button")) + + expect(buttons).toHaveLength(12) + expect(buttons.filter((b) => b.tabIndex === 0)).toHaveLength(1) + expect(buttons.filter((b) => b.tabIndex === -1)).toHaveLength(11) + + const bold = screen.getByRole("button", { name: "Bold" }) + bold.focus() + await fireEvent.keyDown(bold, { key: "ArrowRight" }) + expect(document.activeElement).toBe( + screen.getByRole("button", { name: "Italic" }), + ) + + await fireEvent.keyDown(document.activeElement!, { key: "End" }) + expect(document.activeElement).toBe( + screen.getByRole("button", { name: "Paste a table" }), + ) + + // Wraps, so the toolbar cannot dead-end. + await fireEvent.keyDown(document.activeElement!, { key: "ArrowRight" }) + expect(document.activeElement).toBe(bold) + }) + + it("keeps the external label pointed at the textarea, not at a button", () => { + // Buttons are labelable, so a control added above the field can silently + // steal a `
    ", async () => { + mount() + const { paste } = await openPanel() + + // Tab-separated, as a spreadsheet selection arrives, with a literal pipe in + // one cell: unescaped it would end that cell and shift every column after + // it — silently, in the middle of the data. + await fillPaste(paste, "Track\tLead\nClimate\tAlice\na|b\tBob") + await fireEvent.click(screen.getByRole("button", { name: "Insert table" })) + await tick() + await tick() + + const value = field().value + expect(value).toMatch(/\| Track\s+\| Lead\s+\|/) + expect(value).toMatch(/\| a\\\|b\s+\| Bob\s+\|/) + + await fireEvent.click(screen.getByRole("button", { name: "Preview" })) + await tick() + + const table = document.querySelector(".markdown-content table") + expect(table).not.toBeNull() + expect(table!.querySelectorAll("thead th")).toHaveLength(2) + const rows = Array.from(table!.querySelectorAll("tbody tr")) + expect(rows).toHaveLength(2) + expect(rows[0]?.querySelectorAll("td")).toHaveLength(2) + expect(table!.querySelectorAll("thead th")[0]?.textContent).toBe("Track") + // One cell, not two: the escape held through marked AND DOMPurify. + const escaped = rows[1]?.querySelectorAll("td") + expect(escaped).toHaveLength(2) + expect(escaped?.[0]?.textContent).toBe("a|b") + }) + + it("keeps a quoted comma inside its cell when converting CSV", async () => { + mount() + const { panel, paste } = await openPanel() + + await fillPaste(paste, 'City,Country\n"Lausanne, VD",CH') + expect(panel).toHaveTextContent("separated by comma") + + await fireEvent.click(screen.getByRole("button", { name: "Insert table" })) + await tick() + await fireEvent.click(screen.getByRole("button", { name: "Preview" })) + await tick() + + const cells = document.querySelectorAll(".markdown-content tbody td") + expect(cells).toHaveLength(2) + expect(cells[0]?.textContent).toBe("Lausanne, VD") + }) + + it("flags an ambiguous separator and lets it be overruled", async () => { + mount() + const { panel, paste } = await openPanel() + + // European CSV: `;` between the fields, `,` as the decimal mark. Both + // produce a consistent grid, so this genuinely needs a person. + await fillPaste(paste, "1,5;2,5\n3,5;4,5") + expect(panel).toHaveTextContent("More than one separator fits") + expect(panel).toHaveTextContent("2 columns × 1 row") + + const select = panel.querySelector("select")! + await fireEvent.change(select, { target: { value: "," } }) + await tick() + + expect(panel).toHaveTextContent("3 columns × 1 row") + // An explicit choice is not ambiguous by definition. + expect(panel).not.toHaveTextContent("More than one separator fits") + }) + + it("gives header-less data an empty header row, keeping every row", async () => { + mount() + const { panel, paste } = await openPanel() + + await fillPaste(paste, "Alice\tOrganizer\nBob\tParticipant") + expect(panel).toHaveTextContent("2 columns × 1 row") + + await fireEvent.click( + screen.getByRole("checkbox", { name: "First row is a header" }), + ) + await tick() + expect(panel).toHaveTextContent("2 columns × 2 rows") + + await fireEvent.click(screen.getByRole("button", { name: "Insert table" })) + await tick() + await fireEvent.click(screen.getByRole("button", { name: "Preview" })) + await tick() + + expect( + document.querySelectorAll(".markdown-content tbody tr"), + ).toHaveLength(2) + expect( + document.querySelector(".markdown-content thead th")?.textContent, + ).toBe("") + }) + + it("separates the table from the paragraph above it", async () => { + // Glued to a paragraph, markdown reads the table as more paragraph — and + // then the Preview shows a line of pipes rather than a table. + mount() + await type("Here are the tracks:") + const { paste } = await openPanel() + + await fillPaste(paste, "a\tb") + await fireEvent.click(screen.getByRole("button", { name: "Insert table" })) + await tick() + await tick() + + expect(field().value).toMatch(/^Here are the tracks:\n\n\| a\s+\| b\s+\|/) + + await fireEvent.click(screen.getByRole("button", { name: "Preview" })) + await tick() + expect(document.querySelector(".markdown-content table")).not.toBeNull() + }) + + it("lands at the caret, not at the end, even though the panel took focus", async () => { + // Opening the panel moves focus off the field. The selection survives on a + // blurred textarea, which is the only reason reading it later works — if + // that ever stops holding, every insert silently goes to the end of the + // document instead of where the caret was. + mount() + await type("before\n\nafter", 8, 8) + const { paste } = await openPanel() + + await fillPaste(paste, "a\tb") + await fireEvent.click(screen.getByRole("button", { name: "Insert table" })) + await tick() + await tick() + + expect(field().value).toMatch( + /^before\n\n\| a\s+\| b\s+\|\n\| -+ \| -+ \|\n\nafter$/, + ) + }) + + it("closes on Escape and gives focus back to the control that opened it", async () => { + mount() + await openPanel() + + await fireEvent.keyDown(window, { key: "Escape" }) + await tick() + + expect( + screen.queryByRole("group", { name: "Paste a table" }), + ).not.toBeInTheDocument() + expect(document.activeElement).toBe( + screen.getByRole("button", { name: "Paste a table" }), + ) + }) + + it("says so rather than inserting past the field's maxlength", async () => { + // A programmatic insert bypasses maxlength — the browser only enforces it + // against typing — so without this the field submits a value the server + // rejects. + render(MarkdownEditor, { + props: { name: "content", id: "content", maxlength: 20 }, + }) + const { panel, paste } = await openPanel() + + await fillPaste(paste, "Track\tLead\nClimate\tAlice") + + expect(panel).toHaveTextContent("past its 20-character limit") + expect(screen.getByRole("button", { name: "Insert table" })).toBeDisabled() + }) +}) diff --git a/components/frontend/src/lib/components/hackathon/CapabilitiesPanel.svelte b/components/frontend/src/lib/components/hackathon/CapabilitiesPanel.svelte new file mode 100644 index 00000000..ac5c78fa --- /dev/null +++ b/components/frontend/src/lib/components/hackathon/CapabilitiesPanel.svelte @@ -0,0 +1,243 @@ + + + +
    + {#if rows.length > 0} +
    +
    + + What participants can do + + +

    + Applies to the whole hackathon. A capability that names the phase it opens + in moves WITH the timeline: advancing to that phase switches it on + here. Ones with no phase linked change only when you change them. +

    + +
    + {#each rows as capability (capability.capability)} + + {/each} +
    +
    + + {#if message} + + {:else if saved} +

    Saved.

    + {/if} + + + + + + + {#if ungoverned.length > 0} + +

    + {ungovernedSentence} + {ungoverned.length === 1 ? 'has' : 'have'} no stored setting on this hackathon, so + the server allows + {ungoverned.length === 1 ? 'it' : 'them'} right now, whatever the box above says. + Saving this form creates the + {ungoverned.length === 1 ? 'setting' : 'settings'} and puts + {ungoverned.length === 1 ? 'it' : 'them'} under these switches. +

    + {/if} + + + {#if unmet.length > 0} +
    +
    +
    +
    + + + + Only switches things on — nothing already allowed is turned off. + +
    + {/if} + {:else} + +

    + What participants can do +

    +

    + No capability settings were loaded for this hackathon, so there is nothing to + change here. +

    + {/if} +
    diff --git a/components/frontend/src/lib/components/hackathon/CapabilitiesPanel.test.ts b/components/frontend/src/lib/components/hackathon/CapabilitiesPanel.test.ts new file mode 100644 index 00000000..c2af487f --- /dev/null +++ b/components/frontend/src/lib/components/hackathon/CapabilitiesPanel.test.ts @@ -0,0 +1,236 @@ +/** + * The organiser's switches, mounted. + * + * The table's own tests prove the four states have four distinct words. What is + * only true once mounted is that those words REACH THE SCREEN — the panel used + * to take a boolean per capability, computed in a loader as `state === OPEN`, + * and three of our four states arrived as the same unticked box. A table with + * four distinct entries feeding a component that renders two of them would pass + * every test in `capability.test.ts`. + * + * So the assertions here are on the element that STATES each capability's + * state, never on the row that contains it: the row holds a checkbox, a label, + * a description and the state, and "the row mentions Open" is true of a row + * whose switch says nothing of the kind. Each one is checked for not containing + * the checkbox, which is the mechanical form of "this is not the container". + */ + +import { afterEach, describe, expect, it } from "vitest" +import { cleanup, render, screen, within } from "@testing-library/svelte" +import CapabilitiesPanel from "./CapabilitiesPanel.svelte" +import { + CapabilityState, + capabilityDescription, + capabilityLabel, + capabilitySubject, +} from "$lib/utils/capability" + +// No auto-cleanup is configured, so a second render would leave two panels +// mounted and every query would be ambiguous. +afterEach(cleanup) + +const REGISTER = 1 +const PROPOSE = 2 +const SUBMISSIONS = 4 +const VOTE = 5 + +/** One row of each state, so nothing can pass by only ever being on or off. */ +const FOUR_STATES = [ + { capability: REGISTER, state: CapabilityState.OPEN }, + { capability: PROPOSE, state: CapabilityState.CLOSED }, + { + capability: SUBMISSIONS, + state: CapabilityState.COMING, + opensAt: new Date(2026, 7, 12, 9, 0), + }, + { capability: VOTE, state: CapabilityState.UNGOVERNED }, +] + +function mount( + capabilities: { capability: number; state: number; opensAt?: Date }[], + unmet: number[] = [], +) { + render(CapabilitiesPanel, { + props: { currentPhaseName: "Hacking", capabilities, unmet }, + }) +} + +/** The `
    ") + expect(html).toContain('') + expect(html).toContain('') + }) + + it("dedents markdown indented to match surrounding Svelte markup", () => { + // Without dedenting, markdown reads the four-space indent as a code block + // and the whole document renders as one
    .
    +    const html = renderMarkdown("\n    ## About\n\n    Some text.\n")
    +
    +    expect(html).toContain("

    About

    ") + expect(html).not.toContain("
    ")
    +  })
    +
    +  it("still renders the indented raw-HTML literal call sites pass today", () => {
    +    // MarkdownSection's only existing caller passes hand-written HTML in an
    +    // indented template literal; it has to survive the new pipeline unescaped.
    +    const html = renderMarkdown(`
    +    

    About the Hackathon

    +

    + A two-day event. +

    + +

    What to expect

    +
      +
    • Day 1: keynotes
    • +
    +`) + + expect(html).toContain("

    About the Hackathon

    ") + expect(html).toContain("

    What to expect

    ") + expect(html).toContain("
  • Day 1: keynotes
  • ") + expect(html).toContain("A two-day event.") + expect(html).not.toContain("<") + }) + + it("returns an empty string for empty, null or undefined input", () => { + expect(renderMarkdown("")).toBe("") + expect(renderMarkdown(null)).toBe("") + expect(renderMarkdown(undefined)).toBe("") + }) +}) + +describe("renderMarkdown: XSS defences", () => { + it("strips \n\nWorld", + ) + + expect(html).not.toContain(" inside a paragraph", () => { + const html = renderMarkdown("Hi there") + + expect(html).not.toContain(" { + const html = renderMarkdown( + '\n\n

    click

    ', + ) + + expect(html).not.toContain("onerror") + expect(html).not.toContain("onclick") + expect(html).not.toContain("alert") + // The elements themselves survive, only the handlers go. + expect(html).toContain(" { + const fromHtml = renderMarkdown('click') + const fromMarkdown = renderMarkdown("[click](javascript:alert)") + const caseVariant = renderMarkdown( + 'click', + ) + + for (const html of [fromHtml, fromMarkdown, caseVariant]) { + expect(html.toLowerCase()).not.toContain("javascript:") + expect(html).toContain("click") + } + }) + + it("strips data: URLs from href and src", () => { + const html = renderMarkdown( + '\n\n' + + 'x', + ) + + expect(html).not.toContain("data:") + expect(html).not.toContain(" tags and style attributes", () => { + const html = renderMarkdown( + '\n\n

    x

    ', + ) + + expect(html).not.toContain(", and form controls", () => { + const html = renderMarkdown( + '\n\n\n\n' + + '
    ', + ) + + expect(html).not.toContain(" { + const html = renderMarkdown( + '

    overlay

    ', + ) + + expect(html).not.toContain("class=") + expect(html).toContain("overlay") + expect(renderMarkdown("```ts\nx\n```")).toContain('class="language-ts"') + }) + + it("strips data-* attributes", () => { + const html = renderMarkdown('

    x

    ') + + expect(html).not.toContain("data-testid") + }) + + it("forces rel on links and opens external ones in a new tab", () => { + const external = renderMarkdown("[out](https://example.com)") + const internal = renderMarkdown("[in](/hackathon/123)") + const authorTarget = renderMarkdown('y') + + expect(external).toContain('rel="noopener noreferrer"') + expect(external).toContain('target="_blank"') + + expect(internal).toContain('rel="noopener noreferrer"') + expect(internal).not.toContain("target=") + + // Content does not get to choose target for same-site links. + expect(authorTarget).not.toContain("target=") + }) +}) + +describe("renderMarkdown: iframe embed allowlist", () => { + it("keeps YouTube and Vimeo player embeds", () => { + const youtube = renderMarkdown( + '', + ) + const vimeo = renderMarkdown( + '', + ) + + expect(youtube).toContain('src="https://www.youtube.com/embed/ACDgPmRkniU"') + expect(youtube).toContain( + 'referrerpolicy="strict-origin-when-cross-origin"', + ) + expect(youtube).toContain('loading="lazy"') + expect(youtube).toContain("allowfullscreen") + expect(vimeo).toContain('src="https://player.vimeo.com/video/76979871"') + }) + + it("removes iframes pointing anywhere else", () => { + const cases = [ + '', + '', // not https + '', + '', // not /embed/ + '', + "", + ] + + for (const source of cases) { + expect(renderMarkdown(source)).not.toContain(" { + const html = renderMarkdown( + '', + ) + + expect(html).toContain(" { + it("drops the syntax and keeps the words", () => { + expect( + markdownToPlainText("## Welcome!\n\nBring a **laptop** and a `charger`."), + ).toBe("Welcome! Bring a laptop and a charger.") + }) + + it("puts a space between two blocks rather than joining their words", () => { + // "First.Second." would be the giveaway that tags were deleted instead of + // replaced — and the same bug glues every list item into one long word. + expect(markdownToPlainText("First.\n\nSecond.")).toBe("First. Second.") + expect(markdownToPlainText("- one\n- two\n- three")).toBe("one two three") + }) + + it("shows a link's text, not its URL", () => { + expect(markdownToPlainText("See the [rules](https://example.com/x).")).toBe( + "See the rules.", + ) + }) + + it("unescapes what the renderer escaped", () => { + expect(markdownToPlainText("Teams of 3 & up, size < 6")).toBe( + "Teams of 3 & up, size < 6", + ) + }) + + it("is empty for empty, null or undefined input", () => { + expect(markdownToPlainText("")).toBe("") + expect(markdownToPlainText(null)).toBe("") + expect(markdownToPlainText(undefined)).toBe("") + }) + + it("is empty for content that is only whitespace", () => { + expect(markdownToPlainText(" \n\n ")).toBe("") + }) + + it("keeps a tag whole when an attribute value contains a bracket", () => { + // A serializer does not escape `>` inside an attribute, so a pattern that + // stops at the first one spills the rest of the tag into the text as + // `b">rules`. Titles like `3 -> 5 people` are the everyday form of this. + expect(markdownToPlainText('See the [rules](/x "a>b") now.')).toBe( + "See the rules now.", + ) + expect(markdownToPlainText("![width>height](s.png) caption")).toBe( + "width>height caption", + ) + }) + + it("reads an image's alt text, so an illustrated page is not a blank one", () => { + expect(markdownToPlainText("![Schedule graphic](s.png)")).toBe( + "Schedule graphic", + ) + expect(markdownToPlainText("Doors at 09:00 ![](x.png)")).toBe( + "Doors at 09:00", + ) + }) + + it("takes nothing from an HTML comment", () => { + // The sanitizer drops comment nodes, so nothing here strips them by hand. + // This is the test that says so — if that ever changes, the hidden text + // starts showing up in list rows and this fails. + expect(markdownToPlainText("Visible")).toBe("Visible") + }) +}) + +// An excerpt is author-controlled content displayed on a management screen, so +// it is a stored-XSS surface in its own right. It is text, not markup — but it +// is text produced by rendering, and these say what the rendering threw away. +// Every one of them asserts a POSITIVE too: "the payload is absent" is a claim +// an empty string satisfies, and the surrounding words are what prove the +// excerpt was produced at all. +describe("markdownToPlainText: XSS defences", () => { + it("takes nothing from a script the sanitizer removed", () => { + const text = markdownToPlainText("Hello") + + expect(text).toBe("Hello") + expect(text).not.toContain("alert") + }) + + it("takes nothing from a script buried mid-paragraph", () => { + const text = markdownToPlainText( + "Doors at 09:00 sharp.", + ) + + expect(text).toContain("Doors at 09:00") + expect(text).toContain("sharp.") + expect(text).not.toContain("pwned") + expect(text).not.toContain("window.") + }) + + it("takes nothing from a style block", () => { + const text = markdownToPlainText( + "Schedule", + ) + + expect(text).toBe("Schedule") + expect(text).not.toContain("display:none") + }) + + it("carries no markup out, so nothing downstream can execute it", () => { + // Whatever an author writes, what leaves here is characters. The row + // interpolates this (Svelte escapes it); nobody may `{@html}` it. + const text = markdownToPlainText( + ' go', + ) + + expect(text).toContain("go") + expect(text).not.toContain("<") + expect(text).not.toContain("onerror") + expect(text).not.toContain("javascript:") + }) +}) + +describe("markdownExcerpt", () => { + it("leaves content that already fits alone, with no ellipsis", () => { + expect(markdownExcerpt("Doors open at 09:00.")).toBe("Doors open at 09:00.") + }) + + it("cuts at a word boundary and marks the cut", () => { + expect(markdownExcerpt("alpha bravo charlie delta", 20)).toBe( + "alpha bravo charlie…", + ) + }) + + it("marks the cut when only the opening of a long body was parsed", () => { + // Flattening stops at 2 000 characters for cost, so a long body whose text + // is short still has more page behind it than the excerpt shows. The + // ellipsis has to come from that, not only from hitting the cap — otherwise + // a 9 000-character page can present itself as complete. + const long = `Doors at 09:00.\n\n` + expect(long.length).toBeGreaterThan(2000) + expect(markdownExcerpt(long)).toBe("Doors at 09:00.…") + }) + + it("breaks one very long token rather than returning almost nothing", () => { + // The boundary is only worth honouring if it is near the end; a 40-character + // URL with a space at index 1 would otherwise leave a one-letter excerpt. + expect(markdownExcerpt(`a ${"x".repeat(40)}`, 20)).toBe( + `a ${"x".repeat(18)}…`, + ) + }) + + it("is empty for empty, null or undefined input", () => { + expect(markdownExcerpt("")).toBe("") + expect(markdownExcerpt(null)).toBe("") + expect(markdownExcerpt(undefined)).toBe("") + }) +}) diff --git a/components/frontend/src/lib/utils/markdown.ts b/components/frontend/src/lib/utils/markdown.ts new file mode 100644 index 00000000..5c3f20bf --- /dev/null +++ b/components/frontend/src/lib/utils/markdown.ts @@ -0,0 +1,459 @@ +/** + * Safe markdown -> HTML rendering (audit finding F6, stored XSS). + * + * `MarkdownSection.svelte` used to `{@html}` its input with neither a markdown + * parser nor a sanitizer. The moment that input stops being a hard-coded + * literal and becomes database content (SitePage.content, Page.content, + * Hackathon.description) that is stored XSS: any author — or anyone who can + * get a string into those columns — could ship ` -
    - -
    - {@render children()} -
    - -
    + + {@render children()} + diff --git a/components/frontend/src/routes/(app)/account/+page.server.ts b/components/frontend/src/routes/(app)/account/+page.server.ts new file mode 100644 index 00000000..747ea645 --- /dev/null +++ b/components/frontend/src/routes/(app)/account/+page.server.ts @@ -0,0 +1,103 @@ +import type { Actions, PageServerLoad } from "./$types" +import { requireGrpc } from "$lib/server/grpc/client" +import { fail, redirect } from "@sveltejs/kit" +import { ClientError, Status } from "nice-grpc-common" + +// The account page: what the platform knows about you, what you can change, +// and how to leave. +// +// DeleteAccount and EditProfile are both self-service by design — neither +// takes a user id, so this page can only ever touch the caller's own profile. + +export const load: PageServerLoad = async (event) => { + return { + user: event.locals.platformUser ?? null, + // Keycloak owns the credentials, so "change my email/password" has to + // happen in its own account console. Deriving the link from the configured + // issuer keeps it correct through the tunnel, where the issuer moves. + identityConsoleUrl: `${event.locals.config.oidc.issuer}/account`, + } +} + +export const actions: Actions = { + profile: async (event) => { + const { user } = requireGrpc(event.locals.grpc) + const form = await event.request.formData() + + const displayName = String(form.get("displayName") ?? "").trim() + if (!displayName) { + // Echo the typed value back so a rejected save does not also wipe the + // field the person was editing. + return fail(400, { + displayName, + profileMessage: "Your display name cannot be empty.", + }) + } + + // The rest of the profile. Sent as typed, empty included: these MAY be + // cleared, and an absent field would read as "leave unchanged" — which + // would make removing your dietary requirements impossible. + const affiliation = String(form.get("affiliation") ?? "").trim() + const skills = String(form.get("skills") ?? "").trim() + const dietary = String(form.get("dietary") ?? "").trim() + const avatarUrl = String(form.get("avatarUrl") ?? "").trim() + + try { + await user.editProfile({ + displayName, + affiliation, + skills, + dietary, + avatarUrl, + }) + } catch (e) { + if (e instanceof ClientError && e.code === Status.INVALID_ARGUMENT) { + return fail(400, { + displayName, + affiliation, + skills, + dietary, + avatarUrl, + profileMessage: e.details || "That profile is not valid.", + }) + } + throw e + } + + return { profileSaved: true } + }, + + delete: async (event) => { + const { user } = requireGrpc(event.locals.grpc) + const form = await event.request.formData() + + // Typing the username is the confirmation: this removes the profile and + // every role, and nothing here can undo it. + const typed = String(form.get("confirm") ?? "").trim() + const expected = event.locals.platformUser?.username ?? "" + if (!expected || typed !== expected) { + return fail(400, { + message: `Type your username (${expected}) exactly to confirm.`, + }) + } + + try { + await user.deleteAccount({}) + } catch (e) { + if (e instanceof ClientError && e.code === Status.FAILED_PRECONDITION) { + // Authored content is Restrict-guarded: the backend refuses rather + // than cascading away pages or submissions other people rely on. + return fail(409, { + message: + e.details || + "Your profile still owns content an organizer must reassign or remove first.", + }) + } + throw e + } + + // The Keycloak identity survives deletion, so sign out to clear the + // session rather than leaving a token for a profile that no longer exists. + redirect(303, "/signout") + }, +} diff --git a/components/frontend/src/routes/(app)/account/+page.svelte b/components/frontend/src/routes/(app)/account/+page.svelte new file mode 100644 index 00000000..9ca6bb70 --- /dev/null +++ b/components/frontend/src/routes/(app)/account/+page.svelte @@ -0,0 +1,292 @@ + + +Your account · Hackagon + +
    +

    Your account

    + + {#if data.user} +
    +

    Your profile

    + + +
    + + + + + + + + + + + + + + {#if form?.profileMessage} + + {:else if form?.profileSaved} +

    Saved.

    + {/if} + +
    + +
    + + + +

    + This is your profile across the whole platform. Answers you gave to a + particular event — its own extra questions, and the consents you + agreed to, such as its code of conduct or event photography — live with that + event's registration, not here: open the event, go to + Participants, and use View on your own row. + Changing this profile does not change what an event already recorded, and + organisers see the answers you gave them at the time. +

    + +
    +
    Username
    +
    {data.user.username}
    +
    Email
    +
    {data.user.email || '—'}
    +
    + +

    + Your username, email and password live with your sign-in provider. + + + Change them there + + — the new values appear here on your next sign-in. +

    +
    + {/if} + + + {#if data.replay.configured} +
    +

    Session recording

    +

    + We can record how this browser moves through the pages — + clicks, scrolls and the structure of the page — to find buttons and links that + do nothing. What you type and the text on the page are never sent, and a + recording is never linked to your account. +

    + +

    + {#if data.replay.consent === 'granted'} + Recording is on for this browser. + {:else if data.replay.consent === 'denied'} + Recording is off for this browser. + {:else} + Recording is off — you have not been asked yet. + {/if} +

    + +
    + + {#if data.replay.consent === 'granted'} + + {:else} + + {/if} + +
    + {/if} + +
    +

    Delete your profile

    +

    + This removes your Hackagon profile, your place on every hackathon roster, and all + your roles. Your sign-in account is not deleted — you can sign in again later and + start fresh. +

    +

    + If you've published pages or submissions, an organiser has to reassign or remove + them first; deleting your profile won't take other people's event records with it. +

    + + {#if form?.message} + + {/if} + + {#if !confirming} +
    + +
    + {:else} +
    + +
    + + +
    + + {/if} +
    +
    diff --git a/components/frontend/src/routes/(app)/account/avatar/+server.ts b/components/frontend/src/routes/(app)/account/avatar/+server.ts new file mode 100644 index 00000000..ef597330 --- /dev/null +++ b/components/frontend/src/routes/(app)/account/avatar/+server.ts @@ -0,0 +1,20 @@ +import type { RequestHandler } from "./$types" +import { error } from "@sveltejs/kit" +import { presignUpload } from "$lib/server/upload" +import { UploadKind } from "$lib/server/grpc/generated/storage/entities/upload_kind" + +/** + * Presign a profile picture for the SIGNED-IN person. + * + * The owner is `locals.platformUser.id` — never anything the request carried — + * so this endpoint cannot be pointed at someone else's profile even by a caller + * writing its body by hand. The backend checks the same thing again from the + * token (`owner.KeycloakID != sub` unless you are a global admin), which is + * where the actual rule lives. + */ +export const POST: RequestHandler = (event) => { + const me = event.locals.platformUser + if (!me) error(401, "Sign in to change your profile picture") + + return presignUpload(event, UploadKind.UPLOAD_KIND_USER_AVATAR, me.id) +} diff --git a/components/frontend/src/routes/(app)/dashboard/+page.server.ts b/components/frontend/src/routes/(app)/dashboard/+page.server.ts index 58e395c7..b50f71b9 100644 --- a/components/frontend/src/routes/(app)/dashboard/+page.server.ts +++ b/components/frontend/src/routes/(app)/dashboard/+page.server.ts @@ -1,11 +1,33 @@ -import type { PageServerLoad } from "./$types" +import type { Actions, PageServerLoad } from "./$types" import { requireGrpc } from "$lib/server/grpc/client" import { Visibility } from "$lib/server/grpc/generated/hackathon/entities/visibility" +import { joinIsOffered } from "$lib/server/hackathon/joinOffer" +import { error, fail, redirect } from "@sveltejs/kit" +import { ClientError, Status } from "nice-grpc-common" export const load: PageServerLoad = async (event) => { const { hackathon } = requireGrpc(event.locals.grpc) - const participantId = event.locals.platformUser!.id + // hooks.server.ts leaves platformUser undefined when WhoAmI (or the + // auto-Register that follows it) came back UNAVAILABLE, and also when it + // succeeded but returned no user. Without this guard the first page after + // login died on a bare TypeError, surfaced as an unexpected 500 that named + // nothing. The message stays on the symptom rather than blaming the + // connection, since both causes land here. + const participantId = event.locals.platformUser?.id + if (!participantId) { + error( + 503, + "Could not load your account from the backend. Please try again.", + ) + } + const { isGlobalAdmin } = await event.parent() + // TODO(backend: enroll creator as participant): myResult is participation, not + // ownership, so a hackathon the viewer created never reaches myHackathons. A + // public one lands under "other" as though it belonged to someone else; a + // private one appears nowhere, since the other list is filtered to public. + // Resolves itself once Create writes the Participant row — no change needed + // on this side. const [allResult, myResult] = await Promise.all([ hackathon.list({ visibilityFilter: Visibility.VISIBILITY_PUBLIC }), hackathon.list({ participantId }), @@ -16,6 +38,81 @@ export const load: PageServerLoad = async (event) => { return { session: event.locals.session, myHackathons: myResult.hackathons, - otherHackathons: allResult.hackathons.filter((h) => !myIds.has(h.id)), + // `canJoin` per row, from the `capabilities` and `status` List already + // returns — see joinOffer.ts. Six finished events used to render a Join + // button that could only ever answer FailedPrecondition. + otherHackathons: allResult.hackathons + .filter((h) => !myIds.has(h.id)) + .map((h) => ({ ...h, canJoin: joinIsOffered(h) })), + isGlobalAdmin, } } + +export const actions: Actions = { + join: async (event) => { + const { hackathon } = requireGrpc(event.locals.grpc) + + const form = await event.request.formData() + const hackathonId = form.get("hackathonId") + if (typeof hackathonId !== "string" || hackathonId === "") + return fail(400, { message: "No hackathon was given" }) + + // Does this event ask its registrants anything? Read it BEFORE joining: + // the answer is the same afterwards, and asking first means a failed join + // costs one call rather than two. Same listing the page loaded from, so it + // sees exactly what this caller may see, and a failure here must not block + // joining — worst case they reach the form from the event overview. + const asksQuestions = await hackathon + .list({ visibilityFilter: Visibility.VISIBILITY_PUBLIC }) + .then((r) => { + const form = r.hackathons.find( + (h) => h.id === hackathonId, + )?.registrationForm + + return Boolean( + form && (form.fields.length > 0 || form.consents.length > 0), + ) + }) + .catch(() => false) + + let joinResult + try { + joinResult = await hackathon.join({ hackathonId }) + } catch (e) { + // A closed window is a clock, not a permission: the backend says + // FAILED_PRECONDITION so this can say "registration is not open" rather + // than "you are not allowed". + if (e instanceof ClientError && e.code === Status.FAILED_PRECONDITION) + return fail(409, { + message: "Registration is not open for this hackathon.", + }) + if (e instanceof ClientError && e.code === Status.ALREADY_EXISTS) + return fail(409, { message: "You have already joined this hackathon." }) + if (e instanceof ClientError && e.code === Status.PERMISSION_DENIED) + return fail(403, { message: "You can't join this hackathon" }) + if (e instanceof ClientError && e.code === Status.NOT_FOUND) + return fail(404, { message: "This hackathon no longer exists" }) + throw e + } + + // Straight into the organiser's registration form. Joining is only half of + // signing up when an event asks for an affiliation, dietary needs or a + // code-of-conduct consent: without this the questions exist, the page + // exists, and nothing ever sends anyone to it. + // + // Waitlisted registrants are redirected too — the form is independent of + // approval, and their answers are exactly what an organiser reviews. + if (asksQuestions) redirect(303, `/register/${hackathonId}`) + + // Otherwise no redirect: SvelteKit re-runs `load` after an action, so the + // hackathon moves into "Your hackathons" with its badge on its own. The + // backend's verdict rides along — a join that landed on a full event's + // waiting list SUCCEEDED, and the page should say "you're in" or "you're + // number 4 in the queue" rather than leave the badge to be discovered. + return { + joined: hackathonId, + waitlisted: joinResult.waitlisted, + queuePosition: joinResult.queuePosition, + } + }, +} diff --git a/components/frontend/src/routes/(app)/dashboard/+page.svelte b/components/frontend/src/routes/(app)/dashboard/+page.svelte index 7c6822c7..9c1b2ab8 100644 --- a/components/frontend/src/routes/(app)/dashboard/+page.svelte +++ b/components/frontend/src/routes/(app)/dashboard/+page.svelte @@ -1,9 +1,35 @@
    - + +
    diff --git a/components/frontend/src/routes/(app)/hackathons/create/+page.server.ts b/components/frontend/src/routes/(app)/hackathons/create/+page.server.ts new file mode 100644 index 00000000..ceb871dd --- /dev/null +++ b/components/frontend/src/routes/(app)/hackathons/create/+page.server.ts @@ -0,0 +1,105 @@ +import type { Actions, PageServerLoad } from "./$types" +import { requireGrpc } from "$lib/server/grpc/client" +import { GlobalRole } from "$lib/server/grpc/generated/user/entities/global_role" +import { Visibility } from "$lib/server/grpc/generated/hackathon/entities/visibility" +import { error, fail, redirect } from "@sveltejs/kit" +import { ClientError, Status } from "nice-grpc-common" + +// The roles casbin reports through WhoAmI, already on locals — the same source +// the sidebar uses to decide whether to offer this page. `hackathon:create` has +// no read endpoint to probe, so this is the closest the frontend can get to +// asking the backend without a write; Create itself stays authoritative below. +function mayCreate(roles: GlobalRole[]): boolean { + return ( + roles.includes(GlobalRole.GLOBAL_ROLE_ADMIN) || + roles.includes(GlobalRole.GLOBAL_ROLE_HACKATHON_ORGANIZER) + ) +} + +export const load: PageServerLoad = async (event) => { + // The URL is guessable even though the sidebar only offers it to an organizer + // or admin. Refuse up front rather than rendering a form whose submit is the + // first thing to fail. + if (!mayCreate(event.locals.platformUser?.roles ?? [])) { + error(403, "You don't have permission to create a hackathon") + } + + return {} +} + +export const actions: Actions = { + create: async (event) => { + const { hackathon } = requireGrpc(event.locals.grpc) + const form = await event.request.formData() + + const name = form.get("name") + const visibility = form.get("visibility") + const description = form.get("description") + const startsAt = form.get("startsAt") + const endsAt = form.get("endsAt") + const logo = form.get("logo") + + if (typeof name !== "string" || name.trim().length < 3) { + return fail(400, { message: "Name must be at least 3 characters" }) + } + if (visibility !== "public" && visibility !== "private") { + return fail(400, { message: "Visibility is required" }) + } + + // Status is computed server-side from both dates, so one without the other + // leaves a hackathon that can never be anything but PENDING. + const hasStartsAt = typeof startsAt === "string" && startsAt !== "" + const hasEndsAt = typeof endsAt === "string" && endsAt !== "" + if (hasStartsAt !== hasEndsAt) { + return fail(400, { + message: "Start and end date must be set together", + }) + } + if ( + hasStartsAt && + hasEndsAt && + new Date(endsAt as string) < new Date(startsAt as string) + ) { + return fail(400, { message: "End date must not precede the start date" }) + } + + let hackathonId: string + try { + const result = await hackathon.create({ + name: name.trim(), + visibility: + visibility === "public" + ? Visibility.VISIBILITY_PUBLIC + : Visibility.VISIBILITY_PRIVATE, + description: + typeof description === "string" && description.trim() !== "" + ? description + : undefined, + startsAt: hasStartsAt ? new Date(startsAt as string) : undefined, + endsAt: hasEndsAt ? new Date(endsAt as string) : undefined, + logo: typeof logo === "string" && logo.trim() !== "" ? logo : undefined, + }) + hackathonId = result.hackathonId + } catch (e) { + if (e instanceof ClientError && e.code === Status.INVALID_ARGUMENT) { + return fail(400, { message: e.details }) + } + if (e instanceof ClientError && e.code === Status.PERMISSION_DENIED) { + return fail(403, { + message: "You don't have permission to create a hackathon", + }) + } + throw e + } + + // Reachable because Create grants the caller the casbin Owner role, which + // satisfies Get's hackathon:read. + // + // TODO(backend: enroll creator as participant): Create writes no Participant + // row, so until it does, the hackathon the organizer just made is missing + // from every participant-filtered list — My Hackathons and the sidebar — and + // this redirect is the only route to it. The hero also shows no membership + // badge, since that comes from the member list Get builds from that table. + redirect(303, `/my/hackathon/${hackathonId}/overview`) + }, +} diff --git a/components/frontend/src/routes/(app)/hackathons/create/+page.svelte b/components/frontend/src/routes/(app)/hackathons/create/+page.svelte new file mode 100644 index 00000000..0f05f382 --- /dev/null +++ b/components/frontend/src/routes/(app)/hackathons/create/+page.svelte @@ -0,0 +1,104 @@ + + +
    +
    + + ← Back to my hackathons + +

    Create Hackathon

    +

    + You become its owner and can edit everything else afterwards. +

    +
    + + +
    + {#if form?.message} + + {/if} + + +
    + + +
    + Visibility + + +
    + + + + + + + +
    + + +
    + + +
    + + + +
    diff --git a/components/frontend/src/routes/(app)/manage/gallery/+page.server.ts b/components/frontend/src/routes/(app)/manage/gallery/+page.server.ts new file mode 100644 index 00000000..f62633af --- /dev/null +++ b/components/frontend/src/routes/(app)/manage/gallery/+page.server.ts @@ -0,0 +1,73 @@ +import type { PageServerLoad } from "./$types" +import { requireGrpc } from "$lib/server/grpc/client" +import { failListing } from "$lib/server/upload" +import { ObjectScope } from "$lib/server/grpc/generated/storage/entities/object_scope" + +/** + * The platform media library — every picture the instance has been uploaded. + * + * Admin-only, and the backend is what says so: `OBJECT_SCOPE_ALL_MEDIA` spans + * events the caller may have no part in, so it authorizes on the global Admin + * role. This route only translates that verdict (`failListing`); the URL is + * guessable and an organiser who types it gets a 403 from the one layer that + * decides. + * + * Two prefixes are NOT here and cannot be: `users//avatar/` and + * `teams/…/submissions/`. No listing scope covers them — see ObjectScope in + * the proto. So "all pictures" means all pictures the platform PUBLISHES, not + * every byte in the bucket, and the page says so on screen rather than + * implying otherwise by omission. + * + * Pagination is a query parameter rather than a client fetch, so the second + * page is a real URL that survives a reload and works with no JavaScript. + */ +/** + * How many tiles a page shows. Larger than the picker's 60 because this page IS + * the library rather than a chooser, and capped well under the backend's own 200 + * so the grid stays scrollable rather than endless. + */ +const PAGE_SIZE = 120 + +export const load: PageServerLoad = async (event) => { + const { storage, hackathon } = requireGrpc(event.locals.grpc) + const pageToken = event.url.searchParams.get("page") ?? "" + + let listing + try { + listing = await storage.listObjects({ + scope: ObjectScope.OBJECT_SCOPE_ALL_MEDIA, + ownerId: "", + pageSize: PAGE_SIZE, + pageToken, + }) + } catch (e) { + failListing(e) + } + + // Event names, so a tile says which hackathon a picture belongs to instead of + // showing a uuid. Best-effort on purpose: a failure here must not take the + // gallery down, because the pictures are the point and the label is a + // courtesy. An id with no name falls back to the id. + let eventNames: Record = {} + try { + const events = await hackathon.list({}) + eventNames = Object.fromEntries( + events.hackathons.map((h) => [h.id, h.name]), + ) + } catch { + eventNames = {} + } + + return { + images: listing.objects.map((o) => ({ + key: o.key, + url: o.url, + sizeBytes: Number(o.sizeBytes), + lastModified: o.lastModified?.toISOString(), + })), + nextPageToken: listing.nextPageToken, + truncated: listing.truncated, + pageToken, + eventNames, + } +} diff --git a/components/frontend/src/routes/(app)/manage/gallery/+page.svelte b/components/frontend/src/routes/(app)/manage/gallery/+page.svelte new file mode 100644 index 00000000..1c937b46 --- /dev/null +++ b/components/frontend/src/routes/(app)/manage/gallery/+page.svelte @@ -0,0 +1,294 @@ + + + + Media library · Hackagon + + +
    +
    +
    +

    Media library

    +

    + Every picture uploaded into an event or a platform page. Newest first. +

    +
    + +
    + + +

    + Profile pictures and team submission files are deliberately not listed here. + There is also no delete: an image can be referenced from any page's markdown, + any event's logo or any prize row, and nothing records which — so removing one + would break those references silently. Deleting an event or an account still + takes its files with it. +

    + + {#if data.images.length === 0} +

    + Nothing has been uploaded yet. Add a picture from an event's page editor, or + use Upload images above. +

    + {:else} +
    + +
    + {/if} + + {#if data.images.length > 0 && visible.length === 0} +

    No images match your search.

    + {/if} + + {#if visible.length > 0 && view === 'table'} + r.key} + caption="Uploaded images" + > + {#snippet row(image)} +
    + + + + + + {/snippet} + + {:else if visible.length > 0} +
      + {#each visible as image (image.key)} +
    • + + + + {image.eventName + + +
      + +

      + {image.label} +

      + {#if image.eventName} +

      + {image.eventName} +

      + {/if} +

      + {formatBytes(image.sizeBytes)}{image.lastModified + ? ` · ${new Date(image.lastModified).toLocaleDateString()}` + : ''} +

      + +
      +
    • + {/each} +
    + {/if} + +
    + {#if data.nextPageToken} + + + Older images + + {/if} + {#if data.pageToken} + + Back to newest + + {/if} + {#if data.truncated} +

    + The store holds more than one listing can scan; the most recent uploads + are shown. +

    + {/if} +
    + + + { + // Re-run the load so the new picture appears in the grid. The dialog + // closes itself; this is what makes the page agree with the store. + void invalidateAll(); + }} + /> + diff --git a/components/frontend/src/routes/(app)/manage/gallery/media/+server.ts b/components/frontend/src/routes/(app)/manage/gallery/media/+server.ts new file mode 100644 index 00000000..0c765277 --- /dev/null +++ b/components/frontend/src/routes/(app)/manage/gallery/media/+server.ts @@ -0,0 +1,29 @@ +import type { RequestHandler } from "./$types" +import { presignUpload, listStoredImages } from "$lib/server/upload" +import { UploadKind } from "$lib/server/grpc/generated/storage/entities/upload_kind" +import { ObjectScope } from "$lib/server/grpc/generated/storage/entities/object_scope" + +/** + * Storage endpoint for the platform media library at `/manage/gallery`. + * + * POST presigns an upload as SITE_MEDIA (`site/media/…`) — the platform's own + * prefix, which is the only one an upload made from a page that belongs to no + * event could sensibly land in. It cannot upload INTO a hackathon: that would + * need the event's id, and choosing one on a platform page is a decision this + * route has no basis for making. + * + * GET lists ALL_MEDIA — `hackathons/` and `site/media/` together. Both halves + * require the global Admin role, so an organiser who reaches this URL is + * refused by the backend rather than shown a filtered view; the frontend does + * not decide access, it translates the verdict ($lib/server/upload). + * + * The page itself renders the first listing from its own `load`, server-side. + * This GET exists for the picker dialog, which loads a fresh listing after an + * upload — the same endpoint answering both keeps them from disagreeing about + * what the library contains. + */ +export const POST: RequestHandler = (event) => + presignUpload(event, UploadKind.UPLOAD_KIND_SITE_MEDIA, "") + +export const GET: RequestHandler = (event) => + listStoredImages(event, ObjectScope.OBJECT_SCOPE_ALL_MEDIA, "") diff --git a/components/frontend/src/routes/(app)/manage/pages/+page.server.ts b/components/frontend/src/routes/(app)/manage/pages/+page.server.ts new file mode 100644 index 00000000..3cbc078a --- /dev/null +++ b/components/frontend/src/routes/(app)/manage/pages/+page.server.ts @@ -0,0 +1,109 @@ +import type { Actions, PageServerLoad } from "./$types" +import { requireGrpc } from "$lib/server/grpc/client" +import { error, fail } from "@sveltejs/kit" +import { ClientError, Status } from "nice-grpc-common" + +// Platform-page administration. The backend requires the global Admin role for +// every mutation and for listing drafts, so this route only translates its +// verdicts — it never decides access itself. + +/** Maps a gRPC failure onto a form error, rethrowing anything unexpected. */ +function formError(e: unknown) { + if (e instanceof ClientError) { + if (e.code === Status.PERMISSION_DENIED) + return fail(403, { + message: "Only platform admins can manage these pages.", + }) + if (e.code === Status.UNAUTHENTICATED) + return fail(401, { message: "Please sign in again." }) + if (e.code === Status.NOT_FOUND) + return fail(404, { message: "That page no longer exists." }) + if (e.code === Status.ALREADY_EXISTS) + return fail(409, { message: "A page with that slug already exists." }) + if (e.code === Status.INVALID_ARGUMENT) + return fail(400, { + message: + "Invalid page: the slug must be lowercase words joined by dashes, and the title cannot be empty.", + }) + } + throw e +} + +export const load: PageServerLoad = async (event) => { + const { sitePage } = requireGrpc(event.locals.grpc) + + try { + // Admins manage drafts too, so ask for everything. + const result = await sitePage.list({ includeHidden: true }) + + return { pages: result.sitePages } + } catch (e) { + if (e instanceof ClientError && e.code === Status.PERMISSION_DENIED) + error(403, "Access denied") + if (e instanceof ClientError && e.code === Status.UNAUTHENTICATED) + error(401, "Authentication required") + throw e + } +} + +export const actions: Actions = { + create: async (event) => { + const { sitePage } = requireGrpc(event.locals.grpc) + const form = await event.request.formData() + const slug = String(form.get("slug") ?? "").trim() + const title = String(form.get("title") ?? "").trim() + if (!slug || !title) + return fail(400, { message: "Slug and title are required." }) + + try { + await sitePage.create({ + slug, + title, + content: String(form.get("content") ?? ""), + visible: form.get("visible") === "on", + order: Number(form.get("order") ?? 0) || 0, + }) + } catch (e) { + return formError(e) + } + + return { created: slug } + }, + + edit: async (event) => { + const { sitePage } = requireGrpc(event.locals.grpc) + const form = await event.request.formData() + const slug = String(form.get("slug") ?? "") + if (!slug) return fail(400, { message: "Missing page slug." }) + + try { + await sitePage.edit({ + slug, + title: String(form.get("title") ?? ""), + content: String(form.get("content") ?? ""), + // An unchecked checkbox submits nothing, so absence means "unpublish". + visible: form.get("visible") === "on", + order: Number(form.get("order") ?? 0) || 0, + }) + } catch (e) { + return formError(e) + } + + return { edited: slug } + }, + + delete: async (event) => { + const { sitePage } = requireGrpc(event.locals.grpc) + const form = await event.request.formData() + const slug = String(form.get("slug") ?? "") + if (!slug) return fail(400, { message: "Missing page slug." }) + + try { + await sitePage.delete({ slug }) + } catch (e) { + return formError(e) + } + + return { deleted: slug } + }, +} diff --git a/components/frontend/src/routes/(app)/manage/pages/+page.svelte b/components/frontend/src/routes/(app)/manage/pages/+page.svelte new file mode 100644 index 00000000..47da284c --- /dev/null +++ b/components/frontend/src/routes/(app)/manage/pages/+page.svelte @@ -0,0 +1,267 @@ + + + + Platform pages · Hackagon + + +
    +
    +
    +

    Platform pages

    +

    + About, Privacy, Terms and any other site-wide page. Content is markdown. +

    +
    + +
    + + {#if form?.message} +

    {form.message}

    + {/if} + + {#if creating} +
    async ({ update }) => { await update(); creating = false; }} + class="card mb-6 flex flex-col gap-3 p-4" + > +

    New page

    +
    + + + +
    + +
    + + +
    + +
    + +
    + + {/if} + + {#if data.pages.length === 0} +

    + No platform pages yet. Create one — the footer links to + about, privacy and terms. +

    + {:else} +
    + +
    + {/if} + + {#if data.pages.length > 0 && visible.length === 0} +

    No pages match your search.

    + {/if} + + {#if visible.length > 0 && view === 'table'} + p.slug} + caption="Platform pages" + > + {#snippet row(page)} +
    + + + + + {/snippet} + + {:else if visible.length > 0} +
    + {#each visible as page (page.slug)} +
    +
    +
    +
    +

    {page.title}

    + + {page.visible ? 'Published' : 'Draft'} + +
    +

    + /{page.slug} · order {page.order} +

    +
    +
    + +
    + + + +
    +
    + + {#if editing === page.slug} +
    async ({ update }) => { await update(); editing = null; }} + class="mt-4 flex flex-col gap-3 border-t border-line pt-4" + > + +
    + + +
    +
    + + +
    + +
    + + + + View page + +
    + + {/if} +
    + {/each} +
    + {/if} + diff --git a/components/frontend/src/routes/(app)/manage/pages/media/+server.ts b/components/frontend/src/routes/(app)/manage/pages/media/+server.ts new file mode 100644 index 00000000..a6b18f63 --- /dev/null +++ b/components/frontend/src/routes/(app)/manage/pages/media/+server.ts @@ -0,0 +1,35 @@ +import type { RequestHandler } from "./$types" +import { presignUpload, listStoredImages } from "$lib/server/upload" +import { UploadKind } from "$lib/server/grpc/generated/storage/entities/upload_kind" +import { ObjectScope } from "$lib/server/grpc/generated/storage/entities/object_scope" + +/** + * Presign one image upload for a PLATFORM page (about, privacy, terms). + * + * The owner id is deliberately empty, and this is the only presign route where + * that is correct: a site page belongs to no event and no person, so there is + * nothing to file it under. `UPLOAD_KIND_SITE_MEDIA` keys it as `site/media/…` + * and authorizes on the GLOBAL Admin role — the same rule every + * SitePageService mutation uses. + * + * No permission check here on purpose. The backend is authoritative and answers + * PermissionDenied to anyone who is not a platform admin, which + * $lib/server/upload turns into a 403; duplicating the rule in the frontend is + * how the two get to disagree. An endpoint rather than a form action, because + * the caller is MarkdownEditor and an action is only reachable from the route + * that declares it. + */ +export const POST: RequestHandler = (event) => + presignUpload(event, UploadKind.UPLOAD_KIND_SITE_MEDIA, "") + +/** + * What the platform pages have already uploaded (`site/media/`), so a picture + * can be reused across About, Privacy and Terms instead of stored twice. + * + * Same route as the presign, same rule: `OBJECT_SCOPE_SITE_MEDIA` authorizes on + * the GLOBAL Admin role, which is what UPLOAD_KIND_SITE_MEDIA above already + * requires. No permission check here — the backend is authoritative and + * duplicating the rule is how the two get to disagree. + */ +export const GET: RequestHandler = (event) => + listStoredImages(event, ObjectScope.OBJECT_SCOPE_SITE_MEDIA, "") diff --git a/components/frontend/src/routes/(app)/manage/users/+page.server.ts b/components/frontend/src/routes/(app)/manage/users/+page.server.ts index 8848187b..47e2ac70 100644 --- a/components/frontend/src/routes/(app)/manage/users/+page.server.ts +++ b/components/frontend/src/routes/(app)/manage/users/+page.server.ts @@ -1,8 +1,92 @@ -import type { PageServerLoad } from "./$types" +import type { Actions, PageServerLoad } from "./$types" import { requireGrpc } from "$lib/server/grpc/client" +import { error, fail } from "@sveltejs/kit" +import { ClientError, Status } from "nice-grpc-common" export const load: PageServerLoad = async (event) => { const { user } = requireGrpc(event.locals.grpc) - const result = await user.list({}) - return { users: result.users } + + // The sidebar only offers this page to a global admin, but the URL is + // guessable and `UserService.List` requires user:read — which only admin + // holds. Translate that denial rather than letting it surface as a 500. + try { + const result = await user.list({}) + return { + users: result.users, + // So the template can hide "revoke your own Admin role" — a courtesy, + // not the real gate: `RemoveRole` blocks it server-side regardless. + currentUserId: event.locals.platformUser?.id, + } + } catch (e) { + if (e instanceof ClientError && e.code === Status.PERMISSION_DENIED) { + error(403, "You don't have permission to view the user list") + } + throw e + } +} + +export const actions: Actions = { + addRole: async (event) => { + const { user } = requireGrpc(event.locals.grpc) + const formData = await event.request.formData() + const userId = formData.get("userId") + const role = Number(formData.get("role")) + + if (typeof userId !== "string" || !userId) { + return fail(400, { message: "Missing user" }) + } + + try { + await user.addRole({ userId, role }) + } catch (e) { + if (e instanceof ClientError && e.code === Status.PERMISSION_DENIED) { + return fail(403, { + message: "You don't have permission to assign roles", + }) + } + if (e instanceof ClientError && e.code === Status.NOT_FOUND) { + return fail(404, { message: "That user no longer exists" }) + } + if (e instanceof ClientError && e.code === Status.INVALID_ARGUMENT) { + return fail(400, { message: e.details }) + } + throw e + } + + return { assigned: true } + }, + + removeRole: async (event) => { + const { user } = requireGrpc(event.locals.grpc) + const formData = await event.request.formData() + const userId = formData.get("userId") + const role = Number(formData.get("role")) + + if (typeof userId !== "string" || !userId) { + return fail(400, { message: "Missing user" }) + } + + try { + await user.removeRole({ userId, role }) + } catch (e) { + // Covers both a caller lacking user:write and the backend's own guard + // against an admin removing their own Admin role — the latter should + // be unreachable through this page since the button is hidden for + // that case, but a direct resubmit still lands here. + if (e instanceof ClientError && e.code === Status.PERMISSION_DENIED) { + return fail(403, { + message: "You don't have permission to remove roles", + }) + } + if (e instanceof ClientError && e.code === Status.NOT_FOUND) { + return fail(404, { message: "That user no longer exists" }) + } + if (e instanceof ClientError && e.code === Status.INVALID_ARGUMENT) { + return fail(400, { message: e.details }) + } + throw e + } + + return { removed: true } + }, } diff --git a/components/frontend/src/routes/(app)/manage/users/+page.svelte b/components/frontend/src/routes/(app)/manage/users/+page.svelte index 72d7da98..e494da36 100644 --- a/components/frontend/src/routes/(app)/manage/users/+page.svelte +++ b/components/frontend/src/routes/(app)/manage/users/+page.svelte @@ -1,29 +1,287 @@ -
    -

    Users

    +
    +
    +
    +

    Users

    +

    {countLabel} registered on the platform

    +
    +
    + + + + + {#if form?.message} + + {/if} + {#if data.users.length === 0} -

    No users found.

    - {:else} -
    a2 + + + {image.eventName + + + {image.label} + + {image.eventName ?? '—'} + + {formatBytes(image.sizeBytes)} + + {image.lastModified + ? new Date(image.lastModified).toLocaleDateString() + : '—'} + + + {page.title}/{page.slug} + + {page.visible ? 'Published' : 'Draft'} + + {page.order} + + + + + View page +
    + + +
    +
    +
    - - - - - - - - - {#each data.users as user (user.keycloakId)} - - - - +

    No users found.

    + {:else if filtered.length === 0} +

    No users match “{search}”.

    + {:else if view === 'table'} +
    +
    NameKeycloak IDCreated
    {user.name}{user.keycloakId}{user.createdAt ? new Date(user.createdAt).toLocaleDateString() : '—'}
    + + + + + + + + + - {/each} - -
    + Avatar + Display NameUsernameEmailRolesJoinedActions
    + + + {#each filtered as user (user.id)} + {@const missing = missingRoles(user.roles)} + + + + + + {user.displayName || user.username} + + {user.username} + {user.email || '—'} + + {#if user.roles.length === 0} + + {:else} +
    + {#each user.roles as role (role)} + + {globalRoleLabel(role) ?? 'Unknown'} + {#if !(user.id === data.currentUserId && role === 1)} + +
    + + + +
    + {/if} +
    + {/each} +
    + {/if} + + + {user.createdAt + ? new Date(user.createdAt).toLocaleDateString() + : '—'} + + + {#if missing.length === 0} + + {:else} +
    + + {#if missing.length === 1 && missing[0] !== undefined} + {@const only = missing[0]} + + + {:else} + + + {/if} +
    + {/if} + + + {/each} + + +
    + {:else} + +
    + {#each filtered as user (user.id)} +
    + +
    + + {user.displayName || user.username} + + {user.email} +
    + {#each user.roles as role (role)} + + {globalRoleLabel(role)} + + {/each} +
    +
    +
    + {/each} +
    {/if}
    diff --git a/components/frontend/src/routes/(app)/my/hackathon/[id]/+layout.server.ts b/components/frontend/src/routes/(app)/my/hackathon/[id]/+layout.server.ts index c0d1ae44..c6bcc858 100644 --- a/components/frontend/src/routes/(app)/my/hackathon/[id]/+layout.server.ts +++ b/components/frontend/src/routes/(app)/my/hackathon/[id]/+layout.server.ts @@ -4,7 +4,7 @@ import { error } from "@sveltejs/kit" import { ClientError, Status } from "nice-grpc-common" export const load: LayoutServerLoad = async (event) => { - const { hackathon } = requireGrpc(event.locals.grpc) + const { hackathon, page } = requireGrpc(event.locals.grpc) const platformUserId = event.locals.platformUser?.id let result @@ -27,5 +27,34 @@ export const load: LayoutServerLoad = async (event) => { const myMembership = result.hackathon.members.find((m) => m.user?.id === platformUserId) ?? null - return { hackathon: result.hackathon, myMembership } + // The sidebar lists this hackathon's content pages. A separate PageService.List + // rather than reading result.hackathon.pages: List is the authoritative source + // for what a viewer may see, while hackathon.get hands hidden pages to plain + // members too. + // + // `visible` is carried through rather than assumed: List only filters + // `visible: false` out for callers *without* `page:write` + // (`page_service.go:31`), so an organiser's list mixes hidden pages in with + // published ones and the sidebar has to be able to tell them apart. For a + // participant every entry here is visible by construction. + // + // A failure here degrades the nav to its fixed entries rather than failing this + // load and blanking the hackathon — the content area is the part that has to + // report a real error, and hackathon.get above already did if there was one. + let hackathonPages: { id: string; title: string; visible: boolean }[] = [] + try { + const { pages } = await page.list({ hackathonId: event.params.id }) + hackathonPages = pages.map((p) => ({ + id: p.id, + title: p.title, + visible: p.visible, + })) + } catch (err) { + event.locals.logger.warn( + { err }, + "LAYOUT: page list failed, rendering the hackathon nav without content pages", + ) + } + + return { hackathon: result.hackathon, myMembership, hackathonPages } } diff --git a/components/frontend/src/routes/(app)/my/hackathon/[id]/+layout.svelte b/components/frontend/src/routes/(app)/my/hackathon/[id]/+layout.svelte index 250eb344..b71d5c76 100644 --- a/components/frontend/src/routes/(app)/my/hackathon/[id]/+layout.svelte +++ b/components/frontend/src/routes/(app)/my/hackathon/[id]/+layout.svelte @@ -1,27 +1,22 @@ -
    - -
    - -{#if !hideHeroAndTimeline} - +
    + - {#if phases.length > 0} - - {/if} -{/if} + +
    + {#if showHero} + + + + + + + {#if phases.length > 0} + + {/if} + {/if} -
    - {@render children()} +
    + {@render children()} +
    +
    diff --git a/components/frontend/src/routes/(app)/my/hackathon/[id]/email/+page.server.ts b/components/frontend/src/routes/(app)/my/hackathon/[id]/email/+page.server.ts new file mode 100644 index 00000000..c94a2800 --- /dev/null +++ b/components/frontend/src/routes/(app)/my/hackathon/[id]/email/+page.server.ts @@ -0,0 +1,135 @@ +import type { Actions, PageServerLoad } from "./$types" +import { requireGrpc } from "$lib/server/grpc/client" +import { GlobalRole } from "$lib/server/grpc/generated/user/entities/global_role" +import { mayManagePhases } from "$lib/server/hackathon/capabilities" +import { error, fail } from "@sveltejs/kit" +import { ClientError, Status } from "nice-grpc-common" + +// The copy this event sends people, and the audiences it sends it to. +// +// Nothing here delivers mail: Hackagon has no notification service, and +// pretending otherwise would be the worst outcome — an organiser believing a +// deadline reminder went out. So the page does the two things it honestly can: +// store the copy (`SetEmailTemplates`) and hand it to the mail client the +// organiser already uses, addressed to the right group. +// +// The four moments are fixed backend-side (`emailTemplateKeys` in +// config_service.go) because the day a notification service does land, it will +// send them by name. A free-form list would be unaddressable. + +/** HackathonRole: OWNER=1. */ +const OWNER = 1 + +const MOMENTS = [ + { + key: "registrationConfirmed", + label: "Registration confirmed", + hint: "Sent when someone's place is confirmed — the first mail they get from you.", + }, + { + key: "teamAssigned", + label: "Team assigned", + hint: "Who they are working with, and where to find them.", + }, + { + key: "deadlineReminder", + label: "Deadline reminder", + hint: "The one people act on. Say what closes and when, not that something closes.", + }, + { + key: "results", + label: "Results", + hint: "Sent after the awards are finalised.", + }, +] as const + +function formError(e: unknown) { + if (e instanceof ClientError) { + if (e.code === Status.PERMISSION_DENIED) + return fail(403, { message: "Only this event's organisers can do that." }) + if (e.code === Status.INVALID_ARGUMENT) + return fail(400, { message: e.details }) + } + throw e +} + +export const load: PageServerLoad = async (event) => { + const { hackathon, myMembership } = await event.parent() + const { config } = requireGrpc(event.locals.grpc) + + const isAdmin = (event.locals.platformUser?.roles ?? []).includes( + GlobalRole.GLOBAL_ROLE_ADMIN, + ) + if (!mayManagePhases(myMembership ?? undefined, isAdmin)) { + error(403, "Only this event's organisers can write its notification copy") + } + + // Prefilled, because Set replaces the whole map — see GetEmailTemplates. + const { templates } = await config.getEmailTemplates({ + hackathonId: event.params.id, + }) + + // Audiences are derived from the roster rather than typed by hand: the point + // of composing here rather than in a mail client is that the address list is + // never stale. Waitlisted people are their own group — they are exactly who + // an organiser writes to separately, and exactly who must not receive + // "you're in". + const members = hackathon.members ?? [] + const person = (m: (typeof members)[number]) => ({ + email: m.user?.email ?? "", + name: m.user?.displayName || m.user?.username || "", + }) + + return { + moments: MOMENTS.map((m) => ({ + ...m, + subject: templates[`${m.key}Subject`] ?? "", + body: templates[m.key] ?? "", + })), + eventName: hackathon.name, + audiences: [ + { + id: "confirmed", + label: "Confirmed participants", + people: members.filter((m) => !m.isWaiting).map(person), + }, + { + id: "waitlist", + label: "Waitlisted", + people: members.filter((m) => m.isWaiting).map(person), + }, + { + id: "organisers", + label: "Organisers", + people: members.filter((m) => m.role === OWNER).map(person), + }, + ], + } +} + +export const actions: Actions = { + save: async (event) => { + const { config } = requireGrpc(event.locals.grpc) + const form = await event.request.formData() + + // Every moment is posted every time, empty included: Set replaces the map, + // so omitting a blank field would be indistinguishable from clearing it — + // and clearing it is a thing an organiser may legitimately want. + const templates: Record = {} + for (const m of MOMENTS) { + templates[m.key] = String(form.get(m.key) ?? "") + templates[`${m.key}Subject`] = String(form.get(`${m.key}Subject`) ?? "") + } + + try { + await config.setEmailTemplates({ + hackathonId: event.params.id, + templates, + }) + } catch (e) { + return formError(e) + } + + return { saved: true } + }, +} diff --git a/components/frontend/src/routes/(app)/my/hackathon/[id]/email/+page.svelte b/components/frontend/src/routes/(app)/my/hackathon/[id]/email/+page.svelte new file mode 100644 index 00000000..7bf349de --- /dev/null +++ b/components/frontend/src/routes/(app)/my/hackathon/[id]/email/+page.svelte @@ -0,0 +1,118 @@ + + +
    +
    +

    Notifications

    +

    + Hackagon does not send mail. What it does is keep your copy with the event and + hand it to your own mail client, addressed to a group that is never out of date. +

    +
    + + {#if form?.message} + + {/if} + +
    +
    +

    The four moments

    +
    + {#if form?.saved}Saved.{/if} + +
    +
    + + {#each rows as row (row.key)} +
    +
    +

    {row.label}

    +

    {row.hint}

    +
    + + + + + +
    + + + +
    +
    + {/each} +
    +
    diff --git a/components/frontend/src/routes/(app)/my/hackathon/[id]/forms/+page.server.ts b/components/frontend/src/routes/(app)/my/hackathon/[id]/forms/+page.server.ts new file mode 100644 index 00000000..82c2aba5 --- /dev/null +++ b/components/frontend/src/routes/(app)/my/hackathon/[id]/forms/+page.server.ts @@ -0,0 +1,102 @@ +import type { Actions, PageServerLoad } from "./$types" +import { requireGrpc } from "$lib/server/grpc/client" +import { GlobalRole } from "$lib/server/grpc/generated/user/entities/global_role" +import { mayManagePhases } from "$lib/server/hackathon/capabilities" +import { + consentRows, + duplicateKey, + formFieldRows, +} from "$lib/server/hackathon/formSchema" +import { error, fail } from "@sveltejs/kit" +import { ClientError, Status } from "nice-grpc-common" + +// What this event asks people, in two schemas: +// +// registration — answered when someone joins (/register/[id]) +// submission — answered when a team turns work in +// +// Both are organiser-defined, so the questions differ per event and neither is +// hard-coded anywhere. Without this screen the registration page renders a +// schema nobody can author, which is how it stood after the design swap. + +function formError(e: unknown) { + if (e instanceof ClientError) { + if (e.code === Status.PERMISSION_DENIED) + return fail(403, { message: "Only this event's organisers can do that." }) + if (e.code === Status.INVALID_ARGUMENT) + return fail(400, { message: e.details }) + } + throw e +} + +export const load: PageServerLoad = async (event) => { + const { hackathon, myMembership } = await event.parent() + + const isAdmin = (event.locals.platformUser?.roles ?? []).includes( + GlobalRole.GLOBAL_ROLE_ADMIN, + ) + if (!mayManagePhases(myMembership ?? undefined, isAdmin)) { + error(403, "Only this event's organisers can edit its forms") + } + + // Both schemas come nested on the hackathon, so this needs no RPC of its own. + return { + registration: { + fields: hackathon.registrationForm?.fields ?? [], + consents: hackathon.registrationForm?.consents ?? [], + }, + submission: { + fields: hackathon.submissionForm?.fields ?? [], + }, + } +} + +export const actions: Actions = { + registration: async (event) => { + const { config } = requireGrpc(event.locals.grpc) + const form = await event.request.formData() + + const fields = formFieldRows(form) + if (fields.length === 0) + return fail(400, { message: "Add at least one question." }) + + const consents = consentRows(form) + const dupe = + duplicateKey(fields.map((f) => f.key)) ?? + duplicateKey(consents.map((c) => c.key)) + if (dupe) return fail(400, { message: `Two rows share the key "${dupe}".` }) + + try { + // Set REPLACES the whole schema, which is why the form posts every row. + await config.setRegistrationForm({ + hackathonId: event.params.id, + fields, + consents, + }) + } catch (e) { + return formError(e) + } + + return { savedRegistration: true } + }, + + submission: async (event) => { + const { config } = requireGrpc(event.locals.grpc) + const form = await event.request.formData() + + const fields = formFieldRows(form) + if (fields.length === 0) + return fail(400, { message: "Add at least one question." }) + + const dupe = duplicateKey(fields.map((f) => f.key)) + if (dupe) return fail(400, { message: `Two rows share the key "${dupe}".` }) + + try { + await config.setSubmissionForm({ hackathonId: event.params.id, fields }) + } catch (e) { + return formError(e) + } + + return { savedSubmission: true } + }, +} diff --git a/components/frontend/src/routes/(app)/my/hackathon/[id]/forms/+page.svelte b/components/frontend/src/routes/(app)/my/hackathon/[id]/forms/+page.svelte new file mode 100644 index 00000000..aca573b7 --- /dev/null +++ b/components/frontend/src/routes/(app)/my/hackathon/[id]/forms/+page.svelte @@ -0,0 +1,188 @@ + + +{#snippet fieldRows(rows: FieldRow[], remove: (i: number) => void)} + {#each rows as row, i (i)} +
    + + + + + +
    + {/each} +{/snippet} + +
    +
    +

    Forms

    +

    + What this event asks people — when they register, and when their team turns work + in. Saving replaces the whole form, so every row on this page is submitted. +

    +
    + + {#if form?.message} + + {/if} + + +
    +
    +

    Registration form

    + {#if form?.savedRegistration} + Saved. + {/if} +
    + + {@render fieldRows(regFields, (i) => (regFields = regFields.filter((_, n) => n !== i)))} + +
    + +
    + +
    +

    Consents

    +

    + Tick-boxes a registrant answers yes or no to. A required one blocks + registration until it is ticked — a code of conduct, typically. +

    + + {#each regConsents as row, i (i)} +
    + + + + +
    + {/each} + +
    + +
    +
    + +
    + +
    +
    + + +
    +
    +

    Submission form

    + {#if form?.savedSubmission} + Saved. + {/if} +
    +

    + Answered per team when they submit. A required field the team leaves out is + rejected by the backend, so this is the contract for what counts as finished work. +

    + + {@render fieldRows(subFields, (i) => (subFields = subFields.filter((_, n) => n !== i)))} + +
    + + +
    +
    +
    diff --git a/components/frontend/src/routes/(app)/my/hackathon/[id]/invites/+page.server.ts b/components/frontend/src/routes/(app)/my/hackathon/[id]/invites/+page.server.ts new file mode 100644 index 00000000..7413d741 --- /dev/null +++ b/components/frontend/src/routes/(app)/my/hackathon/[id]/invites/+page.server.ts @@ -0,0 +1,88 @@ +import type { Actions, PageServerLoad } from "./$types" +import { requireGrpc } from "$lib/server/grpc/client" +import { GlobalRole } from "$lib/server/grpc/generated/user/entities/global_role" +import { mayManagePhases } from "$lib/server/hackathon/capabilities" +import { error, fail } from "@sveltejs/kit" +import { ClientError, Status } from "nice-grpc-common" + +// Invitation links: how a PRIVATE event is shared. +// +// Anyone holding a link can see the event and request a place — approval is +// still an organiser's decision on the participants page, so a forwarded link +// cannot insert a stranger into the roster. A link that spreads further than +// intended is revoked here. +// +// Lifted out of the old one-page cockpit into a route of its own, which is how +// this design organises organiser tools. + +function formError(e: unknown) { + if (e instanceof ClientError) { + if (e.code === Status.PERMISSION_DENIED) + return fail(403, { message: "Only this event's organisers can do that." }) + if (e.code === Status.NOT_FOUND) + return fail(404, { message: "That link no longer exists." }) + if (e.code === Status.INVALID_ARGUMENT) + return fail(400, { message: e.details }) + } + throw e +} + +export const load: PageServerLoad = async (event) => { + const { hackathon, myMembership } = await event.parent() + const { hackathon: client } = requireGrpc(event.locals.grpc) + + const isAdmin = (event.locals.platformUser?.roles ?? []).includes( + GlobalRole.GLOBAL_ROLE_ADMIN, + ) + // Same gate the rest of the organiser tools use. + if (!mayManagePhases(myMembership ?? undefined, isAdmin)) { + error(403, "Only this event's organisers can manage invitations") + } + + const { invites } = await client.listInvites({ hackathonId: event.params.id }) + + return { + hackathonName: hackathon.name, + isPrivate: hackathon.visibility === 2, + invites: invites.map((i) => ({ + id: i.id, + token: i.token, + note: i.note ?? "", + createdAt: i.createdAt, + revokedAt: i.revokedAt ?? undefined, + })), + } +} + +export const actions: Actions = { + create: async (event) => { + const { hackathon } = requireGrpc(event.locals.grpc) + const form = await event.request.formData() + + try { + await hackathon.createInvite({ + hackathonId: event.params.id, + note: String(form.get("note") ?? ""), + }) + } catch (e) { + return formError(e) + } + + return { created: true } + }, + + revoke: async (event) => { + const { hackathon } = requireGrpc(event.locals.grpc) + const form = await event.request.formData() + const inviteId = String(form.get("inviteId") ?? "") + if (!inviteId) return fail(400, { message: "Missing invite." }) + + try { + await hackathon.revokeInvite({ inviteId }) + } catch (e) { + return formError(e) + } + + return { revoked: inviteId } + }, +} diff --git a/components/frontend/src/routes/(app)/my/hackathon/[id]/invites/+page.svelte b/components/frontend/src/routes/(app)/my/hackathon/[id]/invites/+page.svelte new file mode 100644 index 00000000..efa28686 --- /dev/null +++ b/components/frontend/src/routes/(app)/my/hackathon/[id]/invites/+page.svelte @@ -0,0 +1,106 @@ + + +
    +
    +

    Invitation links

    +

    + Anyone with a link can see this event and request a place — you still approve them + on the Participants page. Revoke a link if it spreads further than you meant. +

    +
    + + {#if !data.isPrivate} +

    + This event is public, so anyone can already find it. Links still work, and are + useful for inviting specific people directly. +

    + {/if} + + {#if form?.message} + + {/if} + +
    + +
    + +
    +
    + + {#if data.invites.length === 0} +

    No links yet.

    + {:else} +
    + {#each [...live, ...revoked] as invite (invite.id)} +
    +
    +
    + + {invite.note || 'Untitled link'} + + {#if invite.revokedAt} + Revoked + {/if} +
    + + {linkFor(invite.token)} + Created {when(invite.createdAt)} +
    + + {#if !invite.revokedAt} +
    + +
    + + +
    +
    + {/if} +
    + {/each} +
    + {/if} +
    diff --git a/components/frontend/src/routes/(app)/my/hackathon/[id]/logo/+server.ts b/components/frontend/src/routes/(app)/my/hackathon/[id]/logo/+server.ts new file mode 100644 index 00000000..134a6fc8 --- /dev/null +++ b/components/frontend/src/routes/(app)/my/hackathon/[id]/logo/+server.ts @@ -0,0 +1,19 @@ +import type { RequestHandler } from "./$types" +import { presignUpload } from "$lib/server/upload" +import { UploadKind } from "$lib/server/grpc/generated/storage/entities/upload_kind" + +/** + * Presign the event logo for this hackathon. + * + * Its own kind rather than reusing `media`, because the logo has its own + * prefix (`hackathons//logo/`) and its own 5 MB ceiling — both decided by + * the backend from the kind alone. + * + * This replaces a `presignLogo` FORM ACTION on the edit page: an action can + * only be reached from the route that declares it, so the uploader could not + * be a component, and the page had to hand-roll `use:enhance`'s wire protocol + * to talk to it. A plain JSON endpoint is what makes one shared + * `ImageUploadField` possible. + */ +export const POST: RequestHandler = (event) => + presignUpload(event, UploadKind.UPLOAD_KIND_HACKATHON_LOGO, event.params.id) diff --git a/components/frontend/src/routes/(app)/my/hackathon/[id]/manage/+page.server.ts b/components/frontend/src/routes/(app)/my/hackathon/[id]/manage/+page.server.ts new file mode 100644 index 00000000..9f402036 --- /dev/null +++ b/components/frontend/src/routes/(app)/my/hackathon/[id]/manage/+page.server.ts @@ -0,0 +1,116 @@ +import type { Actions, PageServerLoad } from "./$types" +import { mayManagePhases } from "$lib/server/hackathon/capabilities" +import { + enabledCapabilities, + phaseCapabilities, +} from "$lib/server/hackathon/phaseForm" +import { + applyPhaseCapabilities, + saveCapabilities, + setCurrentPhase, +} from "$lib/server/hackathon/stateActions" +import { currentAndNextPhase, unmetPhaseCapabilities } from "$lib/utils/phase" +import { error } from "@sveltejs/kit" + +// The organiser's overview of the hackathon itself, as against `/overview`, +// which is the member's and stays participant-shaped for every viewer. +// +// Everything hackathon-wide that an organiser sets lives here: what participants +// may do, and which phase is current. Per-phase editing stayed on the timeline, +// which is a list of phases — this is a single page about the hackathon, and +// mixing the two was what buried the capability switches under a heading nobody +// opened unless they already suspected something. +// +// No RPC of its own: the layout's `hackathon.get` already carries the phases, +// the capabilities and the membership this page reads. +// +// Deliberately NOT a `hackathonState` blob on the layout, which is how main +// carries the same fields. Ours is derived here, in the one route that reads it, +// because on this branch `HackathonState` is a projection over the Capability +// model with no enforcement behind it — see `.claude/CLAUDE.md`. A second +// subtree-wide object of that name would read as the gate main made it, and two +// gates that can disagree are worse than either. + +export const load: PageServerLoad = async (event) => { + const { hackathon, myMembership, isGlobalAdmin } = await event.parent() + + // The same owner-or-admin gate `manageNav` applies, so the sidebar can never + // offer this page to someone it then refuses. Deliberately NOT + // `canEditHackathon`, which additionally wants the owner confirmed: a + // waitlisted owner gets the section in the sidebar, so gating the page it + // leads to more narrowly than the entry is a link that 403s. Editing the + // hackathon's own record is the narrower thing, and it is gated on its own + // below. + if (!mayManagePhases(myMembership ?? undefined, isGlobalAdmin)) { + error(403, "Only this event's organisers can manage it") + } + + const enabled = enabledCapabilities(hackathon.capabilities) + + // Empty string rather than undefined when nothing is declared, which + // `currentAndNextPhase` reads as "fall back to the dates" — the same + // precedence `resolvePhaseStatus` applies on the timeline, so the two surfaces + // cannot name different phases as the live one. + const currentPhaseId = hackathon.currentPhaseId ?? "" + const { current, next, declared } = currentAndNextPhase( + hackathon.phases, + currentPhaseId || undefined, + ) + + const currentCapabilities = current + ? phaseCapabilities(hackathon.capabilities, current.id) + : [] + + return { + hackathonId: hackathon.id, + hackathonName: hackathon.name, + // Whether "now" is an organiser's declaration or just the calendar. This is + // the one screen where the difference is actionable, since only a + // declaration can be cleared. + declared, + currentPhase: current + ? { + id: current.id, + name: current.name, + startsAt: current.startsAt, + endsAt: current.endsAt, + } + : null, + nextPhase: next + ? { + id: next.id, + name: next.name, + startsAt: next.startsAt, + endsAt: next.endsAt, + } + : null, + // Passed through exactly as `Hackathon.capabilities` arrives, which is what + // `CapabilitiesPanel` takes: the four states — COMING, OPEN, CLOSED, + // UNGOVERNED — are what the panel is for, and a `{value, enabled}` + // projection made here is a place three of them collapse into one unticked + // box on the way to the one screen whose job is to tell them apart. + capabilities: hackathon.capabilities, + // What the current phase says should be happening but is switched off. Only + // ever computed against the CURRENT phase: a future phase planning a + // capability that is off is not a problem, it is simply not time yet. + unmet: unmetPhaseCapabilities(currentCapabilities, enabled), + // Surfaces the approval queue, which is the organiser action most easily + // forgotten — nothing about a waitlisted participant is visible from + // anywhere else, and a waitlisted user holds no read on anything in the + // hackathon until approved. Counted the way the participants page counts its + // rows — it drops a member with no user too — so the two cannot disagree. + waitingCount: hackathon.members.filter( + (m) => m.user !== undefined && m.isWaiting, + ).length, + } +} + +// All three are shared with the timeline, which keeps its per-phase "Make +// current" buttons. An action can only be reached from the route that declares +// it, hence the module rather than a second copy. +export const actions: Actions = { + setCurrent: (event) => setCurrentPhase(event, event.params.id), + saveCapabilities: (event) => saveCapabilities(event, event.params.id), + applyPhaseCapabilities: (event) => + applyPhaseCapabilities(event, event.params.id), +} diff --git a/components/frontend/src/routes/(app)/my/hackathon/[id]/manage/+page.svelte b/components/frontend/src/routes/(app)/my/hackathon/[id]/manage/+page.svelte new file mode 100644 index 00000000..a12a2ee7 --- /dev/null +++ b/components/frontend/src/routes/(app)/my/hackathon/[id]/manage/+page.svelte @@ -0,0 +1,269 @@ + + +Manage · {data.hackathonName} + + +
    + +
    +
    +

    Manage Hackathon

    + + What participants may do, and where {data.hackathonName} is now. + +
    +
    + + {#if data.waitingCount > 0} + + + {/if} + {#if mayEdit} + + + {/if} +
    +
    + + +
    + Where the hackathon is + +
    +
    +
    + Now + + {#if data.currentPhase} + + {data.declared ? 'Declared' : 'By dates'} + + {/if} +
    + {#if data.currentPhase} +

    {data.currentPhase.name}

    + + {formatPhaseRange(data.currentPhase.startsAt, data.currentPhase.endsAt)} + + {:else} +

    Nothing running

    + + No phase is current and no dates cover today. + + {/if} +
    + +
    + Next + {#if data.nextPhase} +

    {data.nextPhase.name}

    + + {formatPhaseRange(data.nextPhase.startsAt, data.nextPhase.endsAt)} + + {:else} +

    Nothing after this

    + + Add a phase on the timeline to carry on. + + {/if} +
    +
    + +
    + {#if phaseAction} + +
    + + +
    + {/if} + + {#if data.declared} + +
    + +
    + {/if} + + All phases → + +
    + + +

    + Moving the marker also applies what the phase it lands on opens. Anything no phase + names stays exactly as the panel below leaves it. +

    +
    + + + + + +
    + Manage + +
    +
    diff --git a/components/frontend/src/routes/(app)/my/hackathon/[id]/manage/edit/+page.server.ts b/components/frontend/src/routes/(app)/my/hackathon/[id]/manage/edit/+page.server.ts new file mode 100644 index 00000000..1631709e --- /dev/null +++ b/components/frontend/src/routes/(app)/my/hackathon/[id]/manage/edit/+page.server.ts @@ -0,0 +1,157 @@ +import type { Actions, PageServerLoad } from "./$types" +import { requireGrpc } from "$lib/server/grpc/client" +import { Visibility } from "$lib/server/grpc/generated/hackathon/entities/visibility" +import { canEditHackathon } from "$lib/navigation" +import { error, fail, redirect } from "@sveltejs/kit" +import { ClientError, Status } from "nice-grpc-common" + +// `data.hackathon` reaches the page through the `[id]` layout's own load — +// nothing here needs to re-fetch it, only gate who may see the form. +export const load: PageServerLoad = async (event) => { + const { myMembership, isGlobalAdmin } = await event.parent() + + if (!canEditHackathon(myMembership ?? undefined, isGlobalAdmin)) { + error(403, "You don't have permission to edit this hackathon") + } +} + +export const actions: Actions = { + // The logo upload is NOT here. It was a `presignLogo` action, and an action + // can only be reached from the route that declares it — which is why the + // logo uploader could not become a component and stayed the only one in the + // app. It lives at `./logo` (+server.ts) now, alongside `./media`, and the + // page mounts the shared `ImageUploadField` against it. + edit: async (event) => { + const { hackathon } = requireGrpc(event.locals.grpc) + const form = await event.request.formData() + + const name = form.get("name") + const visibility = form.get("visibility") + const description = form.get("description") + const startsAt = form.get("startsAt") + const endsAt = form.get("endsAt") + const logo = form.get("logo") + const maxParticipantsRaw = form.get("maxParticipants") + + if (typeof name !== "string" || name.trim().length < 3) { + return fail(400, { message: "Name must be at least 3 characters" }) + } + + // Empty clears back to unlimited: this form is always prefilled, so a + // cleared field is a deliberate removal — the backend reads 0 as "no cap". + let maxParticipants = 0 + if ( + typeof maxParticipantsRaw === "string" && + maxParticipantsRaw.trim() !== "" + ) { + const n = Number(maxParticipantsRaw) + if (!Number.isInteger(n) || n < 0) { + return fail(400, { + message: "Capacity must be a whole number of people", + }) + } + maxParticipants = n + } + if (visibility !== "public" && visibility !== "private") { + return fail(400, { message: "Visibility is required" }) + } + + // Status is computed server-side from both dates, so one without the other + // leaves a hackathon that can never be anything but PENDING. + const hasStartsAt = typeof startsAt === "string" && startsAt !== "" + const hasEndsAt = typeof endsAt === "string" && endsAt !== "" + if (hasStartsAt !== hasEndsAt) { + return fail(400, { + message: "Start and end date must be set together", + }) + } + if ( + hasStartsAt && + hasEndsAt && + new Date(endsAt as string) < new Date(startsAt as string) + ) { + return fail(400, { message: "End date must not precede the start date" }) + } + + try { + await hackathon.edit({ + hackathonId: event.params.id, + name: name.trim(), + visibility: + visibility === "public" + ? Visibility.VISIBILITY_PUBLIC + : Visibility.VISIBILITY_PRIVATE, + // Sent as typed, not `|| undefined`: unlike Create, this form is always + // pre-filled with the current value, so an empty string is the user + // clearing the field on purpose and must reach the backend as "". + description: typeof description === "string" ? description : undefined, + logo: typeof logo === "string" ? logo : undefined, + // TODO(backend: hackathon-edit-clear-dates): `hasStartsAt === hasEndsAt + // === false` sends both as `undefined`, which `Edit` reads as "leave + // unchanged" rather than "clear them" — there is no request that + // returns an already-dated hackathon to dateless. Harmless here: dates + // can still be *changed* freely, only full removal silently no-ops. + startsAt: hasStartsAt ? new Date(startsAt as string) : undefined, + endsAt: hasEndsAt ? new Date(endsAt as string) : undefined, + // Always sent: the field is always on the form, so absence cannot mean + // "leave unchanged" here — 0 means unlimited. + maxParticipants, + }) + } catch (e) { + if (e instanceof ClientError && e.code === Status.INVALID_ARGUMENT) { + return fail(400, { message: e.details }) + } + if (e instanceof ClientError && e.code === Status.PERMISSION_DENIED) { + return fail(403, { + message: "You don't have permission to edit this hackathon", + }) + } + if (e instanceof ClientError && e.code === Status.NOT_FOUND) { + return fail(404, { message: "Hackathon not found" }) + } + throw e + } + + // Back to the page this form is reached from, which is no longer the + // dashboard: the dashboard's job is finding a hackathon, and saving a change + // to one should leave you looking at the hackathon you changed. + redirect(303, `/my/hackathon/${event.params.id}/manage`) + }, + + // Its own action because it is its own RPC: ConfigService.SetBranding writes + // the forms row, HackathonService.Edit writes the hackathon row, and one + // submit that half-succeeds is worse than two that each say what they did. + // + // No redirect: an organiser adjusting colours wants to see the preview + // update, not to be thrown back to the dashboard. + branding: async (event) => { + const { config } = requireGrpc(event.locals.grpc) + const form = await event.request.formData() + + const text = (key: string) => String(form.get(key) ?? "").trim() + + try { + await config.setBranding({ + hackathonId: event.params.id, + // Empty means "unset" and must reach the backend as an empty string + // rather than undefined: this form is always prefilled, so a cleared + // field is a deliberate removal, not an untouched one. + primaryColor: text("primaryColor"), + accentColor: text("accentColor"), + bannerText: text("bannerText"), + }) + } catch (e) { + if (e instanceof ClientError && e.code === Status.INVALID_ARGUMENT) { + return fail(400, { message: e.details }) + } + if (e instanceof ClientError && e.code === Status.PERMISSION_DENIED) { + return fail(403, { + message: "You don't have permission to edit this hackathon", + }) + } + throw e + } + + return { branded: true } + }, +} diff --git a/components/frontend/src/routes/(app)/my/hackathon/[id]/manage/edit/+page.svelte b/components/frontend/src/routes/(app)/my/hackathon/[id]/manage/edit/+page.svelte new file mode 100644 index 00000000..f8529ffc --- /dev/null +++ b/components/frontend/src/routes/(app)/my/hackathon/[id]/manage/edit/+page.svelte @@ -0,0 +1,324 @@ + + +
    + + + +
    + {#if form?.message} + + {/if} + +
    + + +
    + Visibility + + +
    + + + + + + + + + {#if hasDates} +

    + Dates can be changed but not removed. +

    + {/if} + +
    + +
    +
    + + +
    + + +
    + + +
    + + +
    +
    +

    Branding

    + {#if form?.branded}Saved.{/if} +
    +

    + Applied to this event's pages only — never to the rest of the platform. Leave + both colours empty and it renders in the platform theme. +

    + +
    + + + + + +
    + + +
    + Preview + +
    +

    {hackathon.name}

    +

    + Body text renders in the platform theme; only the banner and + accents take your colours. +

    +
    +
    +
    + + {#if invalidHex.length > 0} + + {/if} + + +
    +
    diff --git a/components/frontend/src/routes/(app)/my/hackathon/[id]/manage/tiles.test.ts b/components/frontend/src/routes/(app)/my/hackathon/[id]/manage/tiles.test.ts new file mode 100644 index 00000000..6cd3d7d3 --- /dev/null +++ b/components/frontend/src/routes/(app)/my/hackathon/[id]/manage/tiles.test.ts @@ -0,0 +1,300 @@ +/** + * The Manage Hackathon hub, as a claim about where its tiles come from. + * + * The hub says in a comment that it reads `manageNav` "rather than listed again + * here, so an entry added to the sidebar reaches these tiles too and is never + * described in two places". That is a property, not a style note: a later edit + * could hand-list the tiles, keep every current entry, and look correct in + * review — the duplication only shows up the day someone adds an eleventh + * organiser destination and it appears in the sidebar and nowhere else. + * + * So the property is asserted directly. `manageNav` is mocked to return the real + * list PLUS one entry that exists nowhere in the app, and the hub has to render + * it without this file's subject ever being edited. + */ + +import { afterEach, describe, expect, it, vi } from "vitest" +import { cleanup, render, screen } from "@testing-library/svelte" +import type { ComponentProps } from "svelte" +import Sparkles from "lucide-svelte/icons/sparkles" +import type { NavItem } from "$lib/navigation" + +/** A destination that does not exist. Nothing but derivation can render it. */ +const INVENTED: NavItem = { + id: "manage:invented", + label: "Invented Destination", + icon: Sparkles, + href: "/my/hackathon/h1/invented", + description: "Added by the test, never by the page.", + // Badge text deliberately shares no words with anything else on this page. + // "3 waiting" was the first choice and made "no approval prompt when nobody is + // waiting" pass against this tile instead — a fixture that answers the + // assertion it was not written for. + badge: "Beta", + badgeVariant: "badge-warning", +} + +vi.mock("$lib/navigation", async (importOriginal) => { + const actual = await importOriginal() + + return { + ...actual, + manageNav: (...args: Parameters) => { + const real = actual.manageNav(...args) + + // Empty stays empty: a participant must not gain a tile from the mock, or + // the "no section for a member" case below would be testing the mock. + return real.length === 0 ? real : [...real, INVENTED] + }, + } +}) + +import { manageNav } from "$lib/navigation" +import ManagePage from "./+page.svelte" + +const OWNER = 1 +const MEMBER = 2 + +const owner = { role: OWNER, isWaiting: false } + +const phase = (id: string, name: string) => ({ + id, + name, + startsAt: new Date("2026-03-01T09:00:00Z"), + endsAt: new Date("2026-03-02T17:00:00Z"), +}) + +function data(overrides: Record = {}) { + return { + hackathonId: "h1", + hackathonName: "ORD Hackathon", + myMembership: owner, + isGlobalAdmin: false, + declared: false, + currentPhase: null, + nextPhase: null, + // As `Hackathon.capabilities` arrives — OPEN is 2. The panel takes the rows + // rather than a boolean projection; see CapabilitiesPanel. + capabilities: [{ capability: 1, state: 2 }], + unmet: [], + waitingCount: 0, + ...overrides, + } +} + +/** Every tile in the "Manage" grid, by its heading text. */ +function tileLabels(): string[] { + return screen + .getAllByRole("link") + .map((a) => a.querySelector("span > span")?.textContent?.trim() ?? "") + .filter(Boolean) +} + +// `PageData` comes from SvelteKit's generated `./$types`, which carries a great +// deal this page never reads (the whole layout tree). The shape under test is +// what `+page.server.ts` returns, built by `data()` above, so the props are cast +// once here rather than each fixture being widened to satisfy the generated type. +const mount = (overrides: Record = {}) => + render(ManagePage, { + data: data(overrides), + form: null, + } as unknown as ComponentProps) + +afterEach(cleanup) + +describe("the Manage Hackathon hub's tiles", () => { + // The property. If this file's subject ever hand-lists its tiles, the invented + // entry stops appearing and this fails — which is the only moment the + // duplication is visible. + it("renders an entry added to manageNav without the page being edited", () => { + mount() + + const tile = screen.getByRole("link", { name: /Invented Destination/ }) + expect(tile).toHaveAttribute("href", "/my/hackathon/h1/invented") + }) + + // Equality in both directions, which "the invented one is there" alone does + // not give: a page that rendered its own list PLUS whatever manageNav returned + // would pass the test above. + it("renders exactly manageNav's entries, minus its own", () => { + mount() + + const expected = manageNav("h1", owner, false) + .filter((i) => i.id !== "manage:hackathon") + .map((i) => i.label) + + expect(expected.length).toBeGreaterThan(1) + expect(tileLabels()).toEqual(expected) + }) + + // The tile grid is reached FROM this page, so a tile leading back to it is a + // link to where you already are — the dead-link shape this nav fixed once. + it("leaves out its own entry", () => { + mount() + + expect(tileLabels()).not.toContain("Manage Hackathon") + }) + + // Named one at a time rather than counted: these are the entries main's Manage + // panel does not have, and the whole point of this port was to adopt main's + // hub without losing them. + it("gives every entry this branch has that main does not a tile", () => { + mount() + + for (const label of [ + "Prizes", + "Deadlines", + "Manage Forms", + "Notifications", + "Invitation Links", + "New Phase", + ]) { + expect( + tileLabels(), + `"${label}" is an organiser destination main's panel does not have`, + ).toContain(label) + } + }) + + // Sidebar rows have always rendered `NavItem.badge`; the tiles used to drop + // it, so an entry that carried a state chip lost it on the way here. + it("carries an entry's own state badge onto its tile", () => { + mount() + + expect(screen.getByText("Beta")).toHaveClass("badge", "badge-warning") + }) + + it("describes a tile from the entry rather than from the page", () => { + mount() + + expect( + screen.getByText("Added by the test, never by the page."), + ).toBeInTheDocument() + }) +}) + +describe("the hub's heading actions", () => { + it("offers Edit details to a confirmed owner, nested under manage", () => { + mount() + + expect(screen.getByRole("link", { name: /Edit details/ })).toHaveAttribute( + "href", + "/my/hackathon/h1/manage/edit", + ) + }) + + // The page's own gate is the broader owner-or-admin, so a waitlisted owner + // gets here; `hackathon:write` additionally wants them confirmed, and offering + // a form that then refuses is worse than not offering it. + it("withholds Edit details from a waitlisted owner who may still manage", () => { + mount({ myMembership: { role: OWNER, isWaiting: true } }) + + expect(screen.queryByRole("link", { name: /Edit details/ })).toBeNull() + // The control, so "no link" cannot pass because the page rendered nothing. + expect(tileLabels().length).toBeGreaterThan(1) + }) + + // Absent rather than "0 waiting": a readout nobody has to act on becomes + // furniture, and this is the one number on the page that asks for something. + it("shows no approval prompt when nobody is waiting", () => { + mount({ waitingCount: 0 }) + + expect(screen.queryByRole("link", { name: /waiting/ })).toBeNull() + }) + + it("prompts for the waiting queue and links where approving happens", () => { + mount({ waitingCount: 4 }) + + expect( + screen.getByRole("link", { name: /Review 4 waiting/ }), + ).toHaveAttribute("href", "/my/hackathon/h1/participants") + }) +}) + +// All three are the same POST; what differs is which phase it writes, which a +// single "Advance to X" label collapsed into one and got wrong in the middle +// case — it offered to advance PAST the phase the dates say is running. +describe("the hub's one phase action", () => { + const submitLabel = () => + screen + .getAllByRole("button") + .map((b) => b.textContent?.trim() ?? "") + .find((t) => /Advance|Declare|Start/.test(t)) + + it("advances to the phase after the declared one", () => { + mount({ + declared: true, + currentPhase: phase("p1", "Hacking"), + nextPhase: phase("p2", "Judging"), + }) + + expect(submitLabel()).toMatch(/Advance to Judging/) + }) + + it("declares the phase the dates say is running, rather than skipping it", () => { + mount({ + declared: false, + currentPhase: phase("p1", "Hacking"), + nextPhase: phase("p2", "Judging"), + }) + + expect(submitLabel()).toMatch(/Declare Hacking current/) + }) + + it("starts the first phase still to come when nothing is running", () => { + mount({ + declared: false, + currentPhase: null, + nextPhase: phase("p2", "Judging"), + }) + + expect(submitLabel()).toMatch(/Start Judging/) + }) + + it("offers nothing to advance to past the last phase", () => { + mount({ + declared: true, + currentPhase: phase("p1", "Wrap-up"), + nextPhase: null, + }) + + expect(submitLabel()).toBeUndefined() + }) + + // With the marker unset, "Now" comes from the dates and clearing would post a + // change that leaves the page looking exactly as it did. + it("offers Clear the marker only against a declaration", () => { + mount({ declared: true, currentPhase: phase("p1", "Hacking") }) + expect( + screen.getByRole("button", { name: "Clear the marker" }), + ).toBeInTheDocument() + + cleanup() + mount({ declared: false, currentPhase: phase("p1", "Hacking") }) + expect( + screen.queryByRole("button", { name: "Clear the marker" }), + ).toBeNull() + }) + + // The one screen where the difference is actionable, so it is the one screen + // that names it. + it("says whether Now is declared or merely running by the calendar", () => { + mount({ declared: true, currentPhase: phase("p1", "Hacking") }) + expect(screen.getByText("Declared")).toBeInTheDocument() + + cleanup() + mount({ declared: false, currentPhase: phase("p1", "Hacking") }) + expect(screen.getByText("By dates")).toBeInTheDocument() + }) +}) + +describe("the hub for a participant", () => { + // The route's own load answers 403 first; this is the component half of the + // same rule, and it is what keeps the mock above honest — an empty manageNav + // must stay empty. + it("renders no tiles at all", () => { + mount({ myMembership: { role: MEMBER, isWaiting: false } }) + + expect(tileLabels()).toEqual([]) + }) +}) diff --git a/components/frontend/src/routes/(app)/my/hackathon/[id]/media/+server.ts b/components/frontend/src/routes/(app)/my/hackathon/[id]/media/+server.ts new file mode 100644 index 00000000..5d768fa7 --- /dev/null +++ b/components/frontend/src/routes/(app)/my/hackathon/[id]/media/+server.ts @@ -0,0 +1,38 @@ +import type { RequestHandler } from "./$types" +import { presignUpload, listStoredImages } from "$lib/server/upload" +import { UploadKind } from "$lib/server/grpc/generated/storage/entities/upload_kind" +import { ObjectScope } from "$lib/server/grpc/generated/storage/entities/object_scope" + +/** + * Presign one gallery/media upload for this hackathon. + * + * An endpoint rather than a form action, because the caller is a reusable + * editor component that has no idea which page it is mounted on — a form + * action would have to be copy-pasted into every route that embeds a markdown + * field, which is how the logo uploader ended up being the only one. + * + * The KIND is this route's decision, not the caller's, and the owner is the + * hackathon in the path. Everything else — the key, the ceiling, the + * allowlist, the permission — is the backend's; see $lib/server/upload. + */ +export const POST: RequestHandler = (event) => + presignUpload(event, UploadKind.UPLOAD_KIND_HACKATHON_MEDIA, event.params.id) + +/** + * What this hackathon has already uploaded — logos and page media alike, since + * someone picking a picture wants everything the event has. + * + * The SAME route as the presign above, and that is the point: listing a prefix + * takes the same permission as writing to it (hackathon `write`), so a caller + * who may reach POST here may reach GET here, and neither this file nor the + * component calling it has to know the rule. + * + * Covers `hackathons//` and nothing else. In particular it cannot reach + * `users//avatar/` — no scope can; see ObjectScope in the proto. + */ +export const GET: RequestHandler = (event) => + listStoredImages( + event, + ObjectScope.OBJECT_SCOPE_HACKATHON_MEDIA, + event.params.id, + ) diff --git a/components/frontend/src/routes/(app)/my/hackathon/[id]/overview/+page.server.ts b/components/frontend/src/routes/(app)/my/hackathon/[id]/overview/+page.server.ts new file mode 100644 index 00000000..e2cc936f --- /dev/null +++ b/components/frontend/src/routes/(app)/my/hackathon/[id]/overview/+page.server.ts @@ -0,0 +1,117 @@ +import type { PageServerLoad } from "./$types" +import { requireGrpc } from "$lib/server/grpc/client" +import { ProjectStatus } from "$lib/server/grpc/generated/hackathon/entities/project_status" +import { Capability } from "$lib/server/grpc/generated/hackathon/entities/capability" +import { mayManagePhases } from "$lib/server/hackathon/capabilities" +import { enabledCapabilities } from "$lib/server/hackathon/phaseForm" +import { currentAndNextPhase } from "$lib/utils/phase" +import { projectStatusLabel } from "$lib/utils/projectStatus" + +export const load: PageServerLoad = async (event) => { + const { hackathon, myMembership, isGlobalAdmin } = await event.parent() + const { team } = requireGrpc(event.locals.grpc) + const platformUserId = event.locals.platformUser?.id + + // Approved only, so this page's counts agree with what the projects page + // actually lists. Counting pending proposals here would make the two disagree, + // and a proposal waiting is an organiser's business — Manage Hackathon badges + // that count onto the tile that clears it. + const approved = hackathon.projects.filter( + (p) => p.status === ProjectStatus.PROJECT_STATUS_APPROVED, + ) + + const trackCounts = hackathon.tracks.map((t) => ({ + id: t.id, + name: t.name, + count: approved.filter((p) => p.trackId === t.id).length, + })) + + const { teams } = await team.list({ hackathonId: event.params.id }) + + // The first team the viewer is on. Nothing stops a participant from being on + // more than one, but ParticipationCard has room for a single team — the + // submissions page is where every team of theirs shows up. + const myTeam = teams.find((t) => + t.members.some((m) => m.id === platformUserId), + ) + + const project = myTeam + ? hackathon.projects.find((p) => p.id === myTeam.projectId) + : undefined + const track = project + ? hackathon.tracks.find((t) => t.id === project.trackId) + : undefined + + const enabled = enabledCapabilities(hackathon.capabilities) + + // Empty string rather than undefined when nothing is declared, which + // `currentAndNextPhase` reads as "fall back to the dates" — the same + // precedence `resolvePhaseStatus` applies on the timeline, so the two surfaces + // cannot name different phases as the live one. Same rule Manage Hackathon + // applies (`manage/+page.server.ts`). + const currentPhaseId = hackathon.currentPhaseId ?? "" + const { current, next, declared } = currentAndNextPhase( + hackathon.phases, + currentPhaseId || undefined, + ) + + // Whether the submission nudge on the participation card is an action or a + // statement of fact. The card must not offer a button into a capability that is + // switched off: `CreateSubmission` would refuse it. + const canSubmit = enabled.includes( + Capability.CAPABILITY_CREATE_PROJECT_SUBMISSIONS, + ) + + return { + // Waitlisted members reach this page too — the badge should say so rather + // than claim they are registered. The flag travels alongside the label so + // the badge colour keys off it rather than string-matching the label. + membershipLabel: myMembership?.isWaiting ? "Waitlisted" : "Registered", + membershipIsWaiting: myMembership?.isWaiting ?? false, + myTeam: myTeam + ? { + id: myTeam.id, + name: myTeam.name, + // Names rather than a count: the card draws initials from them, and + // `Team.members` are full `User`s so there is nothing to look up. Falls + // back to the username, the precedence used everywhere else. + memberNames: myTeam.members.map( + (m) => m.displayName || m.username || "Unknown", + ), + // Team membership carries no role; creator is the one distinction the + // schema makes, so that is what the card can honestly show. + role: myTeam.creatorId === platformUserId ? "Creator" : "Member", + projectName: project?.title ?? "Unknown project", + projectTrack: track?.name ?? "No track", + projectStatus: project + ? (projectStatusLabel(project.status) ?? "Unknown") + : "Unknown", + submissionCount: myTeam.submissions.length, + } + : null, + canSubmit, + approvedCount: approved.length, + trackCounts, + + // CurrentStateCard's contract. Deliberately NOT reduced to `enabled: + // number[]` the way main's `hackathonState` carries it — that would + // collapse UNGOVERNED back into "closed" and drop COMING's date. Passed + // through exactly as `Hackathon.capabilities` arrives, same as + // `CapabilitiesPanel` takes it. See `manage/+page.server.ts` for why this + // branch has no subtree-wide `hackathonState` on the layout. + capabilities: hackathon.capabilities, + organiserVoice: mayManagePhases(myMembership ?? undefined, isGlobalAdmin), + declared, + currentPhase: current + ? { + name: current.name, + description: current.description ?? "", + startsAt: current.startsAt, + endsAt: current.endsAt, + } + : null, + nextPhase: next + ? { name: next.name, startsAt: next.startsAt, endsAt: next.endsAt } + : null, + } +} diff --git a/components/frontend/src/routes/(app)/my/hackathon/[id]/overview/+page.svelte b/components/frontend/src/routes/(app)/my/hackathon/[id]/overview/+page.svelte index 4702f937..7e279fa3 100644 --- a/components/frontend/src/routes/(app)/my/hackathon/[id]/overview/+page.svelte +++ b/components/frontend/src/routes/(app)/my/hackathon/[id]/overview/+page.svelte @@ -1,82 +1,110 @@ -
    -
    - + +
    + + + + +
    + {#if data.myTeam} + + {:else} +
    +
    +

    Your team

    + + {data.membershipLabel} +
    - {/each} +

    You are not on a team yet.

    + + {#if !data.membershipIsWaiting} + + Browse the projects → + + {/if} +
    + {/if} - - View all 16 proposals → - -
    +
    - +
    +

    About

    + {#if data.hackathon.description} + +
    + +
    + {:else} +

    No description provided.

    + {/if} +
    diff --git a/components/frontend/src/routes/(app)/my/hackathon/[id]/pages/+page.server.ts b/components/frontend/src/routes/(app)/my/hackathon/[id]/pages/+page.server.ts new file mode 100644 index 00000000..20e43c74 --- /dev/null +++ b/components/frontend/src/routes/(app)/my/hackathon/[id]/pages/+page.server.ts @@ -0,0 +1,174 @@ +import type { Actions, PageServerLoad } from "./$types" +import { GlobalRole } from "$lib/server/grpc/generated/user/entities/global_role" +import { markdownExcerpt } from "$lib/utils/markdown" +import { mayManagePages } from "$lib/server/hackathon/capabilities" +import { requireGrpc } from "$lib/server/grpc/client" +import { error, fail } from "@sveltejs/kit" +import { ClientError, Status } from "nice-grpc-common" + +export const load: PageServerLoad = async (event) => { + // No RPC of its own: the layout's `hackathon.get` already returns the pages, + // unfiltered — same source Timeline's list uses for phases. + const { hackathon, myMembership } = await event.parent() + + const isAdmin = (event.locals.platformUser?.roles ?? []).includes( + GlobalRole.GLOBAL_ROLE_ADMIN, + ) + if (!mayManagePages(myMembership ?? undefined, isAdmin)) { + error(403, "Only the hackathon organizer can manage pages") + } + + // The phase (if any) each page is linked from, for display only — the link + // itself is set on the phase's own edit form, not here. + const phaseNameByPageId = new Map( + hackathon.phases + .filter((p) => p.pageId) + .map((p) => [p.pageId as string, p.name]), + ) + + // `hackathon.get` nests pages in whatever order ent returned them, not + // `order` — unlike `PageService.List`, which sorts server-side. Sorting here + // is what makes the list mean anything, since `order` is exactly what + // MoveUp/MoveDown exist to control. + const pages = [...hackathon.pages] + .sort((a, b) => a.order - b.order) + .map((p) => ({ + id: p.id, + title: p.title, + visible: p.visible, + phaseName: phaseNameByPageId.get(p.id), + // Flattened here rather than in the row so the bodies — 10 000 characters + // each, and every page of the hackathon is in this list — never cross the + // wire. The row only ever needs the opening line or two. + // + // It is also the reason the row cannot be an XSS surface: what arrives is + // TEXT that the audited marked -> DOMPurify pipeline already produced and + // then had its tags removed, so the row interpolates a string rather than + // rendering author markup. See $lib/utils/markdown. + excerpt: markdownExcerpt(p.content), + // An excerpt can come out empty from a page that is not: one holding only + // an image with no alt text has nothing to quote. The row needs to tell + // those two apart rather than call a written page blank. + hasContent: p.content.trim() !== "", + })) + + return { hackathonId: hackathon.id, pages } +} + +export const actions: Actions = { + toggleVisible: async (event) => { + const formData = await event.request.formData() + const pageId = formData.get("pageId") + const visible = formData.get("visible") + if ( + typeof pageId !== "string" || + pageId === "" || + (visible !== "true" && visible !== "false") + ) { + return fail(400, { message: "Invalid page" }) + } + + const { page } = requireGrpc(event.locals.grpc) + try { + await page.edit({ pageId, visible: visible === "true" }) + } catch (e) { + if (e instanceof ClientError && e.code === Status.PERMISSION_DENIED) { + return fail(403, { + message: "You don't have permission to edit this page", + }) + } + if (e instanceof ClientError && e.code === Status.NOT_FOUND) { + return fail(404, { message: "Page not found" }) + } + throw e + } + }, + + // What a reorder submits: the whole sequence in one call, which is what + // `SetOrder` insists on — it refuses a list that is not every page of the + // hackathon exactly once, and renumbers them inside one transaction. + // + // One call rather than a run of MoveUp/MoveDown: dragging a page from + // position 5 to position 1 is four swaps, and a failure on the third leaves + // the order half-applied with nothing to roll it back. This is one write that + // either lands or does not. MoveUp/MoveDown below stay for the arrows, which + // are a single swap by definition and work with no JavaScript at all. + setOrder: async (event) => { + const raw = (await event.request.formData()).get("pageIds") + if (typeof raw !== "string" || raw === "") { + return fail(400, { message: "Invalid page order" }) + } + const pageIds = raw.split(",").filter((id) => id !== "") + if (pageIds.length === 0) { + return fail(400, { message: "Invalid page order" }) + } + + const { page } = requireGrpc(event.locals.grpc) + try { + await page.setOrder({ hackathonId: event.params.id, pageIds }) + } catch (e) { + if (e instanceof ClientError && e.code === Status.PERMISSION_DENIED) { + return fail(403, { + message: "You don't have permission to reorder pages", + }) + } + if (e instanceof ClientError && e.code === Status.NOT_FOUND) { + return fail(404, { message: "Page not found" }) + } + // The list we sent is no longer the hackathon's set of pages — one was + // added or deleted elsewhere while this tab held a stale copy. The page + // refetches on any failure, so saying so is all that is left to do. + if (e instanceof ClientError && e.code === Status.INVALID_ARGUMENT) { + return fail(409, { + message: + "The pages changed while you were reordering. The list has been refreshed — please try again.", + }) + } + throw e + } + }, + + moveUp: async (event) => { + const pageId = (await event.request.formData()).get("pageId") + if (typeof pageId !== "string" || pageId === "") { + return fail(400, { message: "Invalid page" }) + } + + const { page } = requireGrpc(event.locals.grpc) + try { + await page.moveUp({ pageId }) + } catch (e) { + if (e instanceof ClientError && e.code === Status.PERMISSION_DENIED) { + return fail(403, { + message: "You don't have permission to reorder pages", + }) + } + if (e instanceof ClientError && e.code === Status.NOT_FOUND) { + return fail(404, { message: "Page not found" }) + } + throw e + } + }, + + moveDown: async (event) => { + const pageId = (await event.request.formData()).get("pageId") + if (typeof pageId !== "string" || pageId === "") { + return fail(400, { message: "Invalid page" }) + } + + const { page } = requireGrpc(event.locals.grpc) + try { + await page.moveDown({ pageId }) + } catch (e) { + if (e instanceof ClientError && e.code === Status.PERMISSION_DENIED) { + return fail(403, { + message: "You don't have permission to reorder pages", + }) + } + if (e instanceof ClientError && e.code === Status.NOT_FOUND) { + return fail(404, { message: "Page not found" }) + } + throw e + } + }, +} diff --git a/components/frontend/src/routes/(app)/my/hackathon/[id]/pages/+page.svelte b/components/frontend/src/routes/(app)/my/hackathon/[id]/pages/+page.svelte new file mode 100644 index 00000000..f539c7bf --- /dev/null +++ b/components/frontend/src/routes/(app)/my/hackathon/[id]/pages/+page.svelte @@ -0,0 +1,424 @@ + + + + + + +
    +
    +
    +

    Manage Pages

    + + {data.pages.length === 1 ? '1 page' : `${data.pages.length} pages`} + +
    + + +
    + + +

    + Want a page tied to a phase? Link it from + + that phase's edit form + + on the Timeline. +

    + + {#if form?.message} + + {/if} + + {#if data.pages.length === 0} +

    + No pages yet. Add one to give participants something to read. +

    + {:else} + +

    + Drag a page by its handle to reorder it. From the keyboard, press Enter on a + handle to pick a page up, move it with the arrow keys and press Enter again + to drop it. The up and down buttons move a page one place at a time. +

    + + +

    + {announcement} +

    + + +
      { + if (draggingId !== null) e.preventDefault(); + }} + ondrop={drop} + > + {#each rows as page, index (page.id)} +
    1. startDrag(e, page.id)} + ondragover={(e) => dragOver(e, page.id)} + ondrop={drop} + ondragend={endDrag} + data-page-row={page.id} + class="card card-raised box-border w-full cursor-grab px-5 py-4 + transition-opacity active:cursor-grabbing" + class:opacity-40={draggingId === page.id} + class:ring-2={grabbedId === page.id} + class:ring-accent-ink={grabbedId === page.id} + > +
      + + +
      +
      + + +
      +
      + + +
      +
      + +
      +
      +

      + {page.title} +

      +
      + + + +
      + {#if page.phaseName} + + + {page.phaseName} + + {/if} + + +
      + + {#if page.excerpt} +

      + {page.excerpt} +

      + {:else} +

      + {page.hasContent ? 'Nothing to quote here' : 'No content yet'} +

      + {/if} +
      +
      +
    2. + {/each} +
    + {/if} +
    diff --git a/components/frontend/src/routes/(app)/my/hackathon/[id]/pages/[pageId]/+page.server.ts b/components/frontend/src/routes/(app)/my/hackathon/[id]/pages/[pageId]/+page.server.ts new file mode 100644 index 00000000..2a02073b --- /dev/null +++ b/components/frontend/src/routes/(app)/my/hackathon/[id]/pages/[pageId]/+page.server.ts @@ -0,0 +1,39 @@ +import type { PageServerLoad } from "./$types" +import { requireGrpc } from "$lib/server/grpc/client" +import { error } from "@sveltejs/kit" +import { ClientError, Status } from "nice-grpc-common" + +export const load: PageServerLoad = async (event) => { + const { page } = requireGrpc(event.locals.grpc) + + // Fetched rather than picked out of the layout's `hackathon.get` data, even + // though that response nests the pages: it includes ones with + // `visible: false`, while PageService.Get denies a hidden page to anyone + // without write permission. Asking PageService keeps the backend the one + // deciding what a member may read, instead of the frontend filtering content + // it has already been handed. + let result + try { + result = await page.get({ pageId: event.params.pageId }) + } catch (e) { + if (e instanceof ClientError && e.code === Status.PERMISSION_DENIED) { + error(403, "This page is not available") + } + if (e instanceof ClientError && e.code === Status.NOT_FOUND) { + error(404, "Page not found") + } + throw e + } + + if (!result.page) { + error(404, "Page not found") + } + + // A page id from another hackathon would otherwise render inside this + // hackathon's shell, under its nav and header. + if (result.page.hackathonId !== event.params.id) { + error(404, "Page not found") + } + + return { page: { title: result.page.title, content: result.page.content } } +} diff --git a/components/frontend/src/routes/(app)/my/hackathon/[id]/pages/[pageId]/+page.svelte b/components/frontend/src/routes/(app)/my/hackathon/[id]/pages/[pageId]/+page.svelte new file mode 100644 index 00000000..15003ca9 --- /dev/null +++ b/components/frontend/src/routes/(app)/my/hackathon/[id]/pages/[pageId]/+page.svelte @@ -0,0 +1,24 @@ + + + +
    +
    +

    {data.page.title}

    +
    + +
    + {#if data.page.content.trim()} + + {:else} +

    This page has no content yet.

    + {/if} +
    +
    diff --git a/components/frontend/src/routes/(app)/my/hackathon/[id]/pages/[pageId]/edit/+page.server.ts b/components/frontend/src/routes/(app)/my/hackathon/[id]/pages/[pageId]/edit/+page.server.ts new file mode 100644 index 00000000..3c06d40b --- /dev/null +++ b/components/frontend/src/routes/(app)/my/hackathon/[id]/pages/[pageId]/edit/+page.server.ts @@ -0,0 +1,114 @@ +import type { Actions, PageServerLoad } from "./$types" +import { requireGrpc } from "$lib/server/grpc/client" +import { GlobalRole } from "$lib/server/grpc/generated/user/entities/global_role" +import { mayManagePages } from "$lib/server/hackathon/capabilities" +import { parsePageForm } from "$lib/server/hackathon/pageForm" +import { resolve } from "$app/paths" +import { error, fail, redirect } from "@sveltejs/kit" +import { ClientError, Status } from "nice-grpc-common" + +export const load: PageServerLoad = async (event) => { + const { hackathon, myMembership } = await event.parent() + const { page } = requireGrpc(event.locals.grpc) + + const isAdmin = (event.locals.platformUser?.roles ?? []).includes( + GlobalRole.GLOBAL_ROLE_ADMIN, + ) + if (!mayManagePages(myMembership ?? undefined, isAdmin)) { + error(403, "Only the hackathon organizer can edit pages") + } + + // Fetched rather than picked out of the layout's `hackathon.get`, even though + // that response nests the pages: after a save this page reloads, and the + // layout's copy can still be the pre-edit tree. Asking PageService means the + // form always shows what was actually stored. + let result + try { + result = await page.get({ pageId: event.params.pageId }) + } catch (e) { + if (e instanceof ClientError && e.code === Status.PERMISSION_DENIED) { + error(403, "This page is not available") + } + if (e instanceof ClientError && e.code === Status.NOT_FOUND) { + error(404, "Page not found") + } + throw e + } + + if (!result.page) { + error(404, "Page not found") + } + + // A page id from another hackathon would otherwise render inside this + // hackathon's shell, under its nav and header — and `Edit` would then happily + // write to it, since it takes the hackathon from the page rather than the URL. + if (result.page.hackathonId !== event.params.id) { + error(404, "Page not found") + } + + return { + hackathonId: hackathon.id, + page: { + id: result.page.id, + title: result.page.title, + content: result.page.content, + visible: result.page.visible, + }, + } +} + +export const actions: Actions = { + save: async (event) => { + const { page } = requireGrpc(event.locals.grpc) + + const parsed = parsePageForm(await event.request.formData()) + if (!parsed.ok) { + return fail(400, { message: parsed.message }) + } + const values = parsed.values + + try { + await page.edit({ + pageId: event.params.pageId, + title: values.title, + content: values.content, + visible: values.visible, + }) + } catch (e) { + if (e instanceof ClientError && e.code === Status.INVALID_ARGUMENT) { + return fail(400, { message: e.details }) + } + if (e instanceof ClientError && e.code === Status.PERMISSION_DENIED) { + return fail(403, { + message: "You don't have permission to edit this page", + }) + } + if (e instanceof ClientError && e.code === Status.NOT_FOUND) { + return fail(404, { message: e.details }) + } + throw e + } + + redirect(303, resolve(`/my/hackathon/${event.params.id}/pages`)) + }, + + delete: async (event) => { + const { page } = requireGrpc(event.locals.grpc) + + try { + await page.delete({ pageId: event.params.pageId }) + } catch (e) { + if (e instanceof ClientError && e.code === Status.PERMISSION_DENIED) { + return fail(403, { + message: "You don't have permission to delete this page", + }) + } + if (e instanceof ClientError && e.code === Status.NOT_FOUND) { + return fail(404, { message: "Page not found" }) + } + throw e + } + + redirect(303, resolve(`/my/hackathon/${event.params.id}/pages`)) + }, +} diff --git a/components/frontend/src/routes/(app)/my/hackathon/[id]/pages/[pageId]/edit/+page.svelte b/components/frontend/src/routes/(app)/my/hackathon/[id]/pages/[pageId]/edit/+page.svelte new file mode 100644 index 00000000..9368b334 --- /dev/null +++ b/components/frontend/src/routes/(app)/my/hackathon/[id]/pages/[pageId]/edit/+page.svelte @@ -0,0 +1,67 @@ + + +
    +
    + + ← Back to pages + +

    Edit Page

    +

    + Changes are visible to participants immediately. +

    +
    + + + +
    +

    Delete this page

    + {#if confirming} +

    + Deleting {data.page.title} cannot + be undone. Any phase linked to it stays, only the page goes. +

    +
    + + +
    + {:else} +

    + Removes the page from the sidebar for everyone. +

    + + {/if} +
    +
    diff --git a/components/frontend/src/routes/(app)/my/hackathon/[id]/pages/new/+page.server.ts b/components/frontend/src/routes/(app)/my/hackathon/[id]/pages/new/+page.server.ts new file mode 100644 index 00000000..a093f332 --- /dev/null +++ b/components/frontend/src/routes/(app)/my/hackathon/[id]/pages/new/+page.server.ts @@ -0,0 +1,57 @@ +import type { Actions, PageServerLoad } from "./$types" +import { requireGrpc } from "$lib/server/grpc/client" +import { GlobalRole } from "$lib/server/grpc/generated/user/entities/global_role" +import { mayManagePages } from "$lib/server/hackathon/capabilities" +import { parsePageForm } from "$lib/server/hackathon/pageForm" +import { resolve } from "$app/paths" +import { error, fail, redirect } from "@sveltejs/kit" +import { ClientError, Status } from "nice-grpc-common" + +export const load: PageServerLoad = async (event) => { + const { hackathon, myMembership } = await event.parent() + + const isAdmin = (event.locals.platformUser?.roles ?? []).includes( + GlobalRole.GLOBAL_ROLE_ADMIN, + ) + if (!mayManagePages(myMembership ?? undefined, isAdmin)) { + error(403, "Only the hackathon organizer can add pages") + } + + return { hackathonId: hackathon.id } +} + +export const actions: Actions = { + save: async (event) => { + const { page } = requireGrpc(event.locals.grpc) + + const parsed = parsePageForm(await event.request.formData()) + if (!parsed.ok) { + return fail(400, { message: parsed.message }) + } + const values = parsed.values + + try { + await page.create({ + hackathonId: event.params.id, + title: values.title, + content: values.content, + visible: values.visible, + }) + } catch (e) { + if (e instanceof ClientError && e.code === Status.INVALID_ARGUMENT) { + return fail(400, { message: e.details }) + } + if (e instanceof ClientError && e.code === Status.PERMISSION_DENIED) { + return fail(403, { + message: "You don't have permission to add pages here", + }) + } + if (e instanceof ClientError && e.code === Status.NOT_FOUND) { + return fail(404, { message: e.details }) + } + throw e + } + + redirect(303, resolve(`/my/hackathon/${event.params.id}/pages`)) + }, +} diff --git a/components/frontend/src/routes/(app)/my/hackathon/[id]/pages/new/+page.svelte b/components/frontend/src/routes/(app)/my/hackathon/[id]/pages/new/+page.svelte new file mode 100644 index 00000000..93dde8bb --- /dev/null +++ b/components/frontend/src/routes/(app)/my/hackathon/[id]/pages/new/+page.svelte @@ -0,0 +1,36 @@ + + +
    +
    + + ← Back to pages + +

    New Page

    +

    + Visible pages appear in the sidebar for every participant as soon as they're + saved. +

    +
    + + +
    diff --git a/components/frontend/src/routes/(app)/my/hackathon/[id]/participants/+page.server.ts b/components/frontend/src/routes/(app)/my/hackathon/[id]/participants/+page.server.ts new file mode 100644 index 00000000..cd8f2c30 --- /dev/null +++ b/components/frontend/src/routes/(app)/my/hackathon/[id]/participants/+page.server.ts @@ -0,0 +1,135 @@ +import type { Actions, PageServerLoad } from "./$types" +import { membershipBadgeLabel } from "$lib/utils/hackathonStatus" +import { mayManageParticipants } from "$lib/server/hackathon/capabilities" +import { HackathonRole } from "$lib/server/grpc/generated/hackathon/entities/hackathon_role" +import { requireGrpc } from "$lib/server/grpc/client" +import { fail } from "@sveltejs/kit" +import { ClientError, Status } from "nice-grpc-common" + +export const load: PageServerLoad = async (event) => { + // No RPC of its own: the layout's `hackathon.get` already returns every + // participant with their casbin role and waitlist flag. + const { hackathon, myMembership, isGlobalAdmin } = await event.parent() + + // Waitlisted members are listed too, carrying a "Waitlisted" label. They are + // real rows in the hackathon's membership, and the label says which is which + // — hiding them would make the page disagree with the count in the header. + const participants = hackathon.members + .filter((m) => m.user !== undefined) + .map((m) => ({ + id: m.user!.id, + name: m.user!.displayName || m.user!.username, + roleLabel: membershipBadgeLabel(m.isWaiting, m.role), + isWaiting: m.isWaiting, + isOwner: m.role === HackathonRole.HACKATHON_ROLE_OWNER, + isSelf: m.user!.id === event.locals.platformUser?.id, + })) + + // The backend refuses to demote the last organizer. Counting them here is + // what keeps that refusal from being the way people find out: with one owner + // left the button is not offered at all. + const ownerCount = participants.filter((p) => p.isOwner).length + + return { + participants, + ownerCount, + hackathonId: event.params.id, + // 0 means unlimited — the page renders the fullness gauge and the + // over-capacity warning only when a cap is set. + maxParticipants: hackathon.maxParticipants ?? 0, + mayManage: mayManageParticipants(myMembership ?? undefined, isGlobalAdmin), + } +} + +/** The gRPC errors these write paths can return, as SvelteKit failures. */ +function failFor(e: unknown, denied: string) { + if (e instanceof ClientError && e.code === Status.PERMISSION_DENIED) { + return fail(403, { message: denied }) + } + if (e instanceof ClientError && e.code === Status.NOT_FOUND) { + return fail(404, { message: "That participant no longer exists" }) + } + // The owner RPCs refuse the last organizer and the not-yet-approved. Both + // carry a message written for the person reading it, so pass it through + // rather than replacing it with a generic one. + if (e instanceof ClientError && e.code === Status.FAILED_PRECONDITION) { + return fail(409, { message: e.details }) + } + throw e +} + +function userIdFrom(form: FormData): string | undefined { + const id = form.get("userId") + return typeof id === "string" && id !== "" ? id : undefined +} + +export const actions: Actions = { + approve: async (event) => { + const { hackathon } = requireGrpc(event.locals.grpc) + + const userId = userIdFrom(await event.request.formData()) + if (!userId) return fail(400, { message: "No participant was given" }) + + try { + await hackathon.approveParticipant({ + hackathonId: event.params.id, + userId, + }) + } catch (e) { + return failFor( + e, + "You don't have permission to approve participants here", + ) + } + + return { ok: true } + }, + + remove: async (event) => { + const { hackathon } = requireGrpc(event.locals.grpc) + + const userId = userIdFrom(await event.request.formData()) + if (!userId) return fail(400, { message: "No participant was given" }) + + try { + await hackathon.removeParticipant({ + hackathonId: event.params.id, + userId, + }) + } catch (e) { + return failFor(e, "You don't have permission to remove participants here") + } + + return { ok: true } + }, + + promote: async (event) => { + const { hackathon } = requireGrpc(event.locals.grpc) + + const userId = userIdFrom(await event.request.formData()) + if (!userId) return fail(400, { message: "No participant was given" }) + + try { + await hackathon.addOwner({ hackathonId: event.params.id, userId }) + } catch (e) { + return failFor(e, "You don't have permission to add organizers here") + } + + return { ok: true } + }, + + demote: async (event) => { + const { hackathon } = requireGrpc(event.locals.grpc) + + const userId = userIdFrom(await event.request.formData()) + if (!userId) return fail(400, { message: "No participant was given" }) + + try { + await hackathon.removeOwner({ hackathonId: event.params.id, userId }) + } catch (e) { + return failFor(e, "You don't have permission to remove organizers here") + } + + return { ok: true } + }, +} diff --git a/components/frontend/src/routes/(app)/my/hackathon/[id]/participants/+page.svelte b/components/frontend/src/routes/(app)/my/hackathon/[id]/participants/+page.svelte index fb921530..7fa6023c 100644 --- a/components/frontend/src/routes/(app)/my/hackathon/[id]/participants/+page.svelte +++ b/components/frontend/src/routes/(app)/my/hackathon/[id]/participants/+page.svelte @@ -1,102 +1,267 @@ + +{#snippet action(participant: Participant, verb: string, label: string, cls: string)} +
    { + pendingIds.add(participant.id); + return async ({ update }) => { + await update(); + pendingIds.delete(participant.id); + }; + }} + > + + +
    +{/snippet} + +{#snippet rowActions(participant: Participant)} + {#if data.mayManage && participant.isWaiting} + {@render action(participant, 'approve', 'Approve', 'btn-accent')} + {/if} + {#if mayPromote(participant)} + {@render action(participant, 'promote', 'Make organizer', '')} + {/if} + {#if mayDemote(participant)} + {@render action(participant, 'demote', 'Step down', '')} + {/if} + {#if data.mayManage && !participant.isWaiting && !participant.isOwner} + {@render action(participant, 'remove', 'Remove', 'btn-danger')} + {/if} +{/snippet} +
    + {#if form?.message} + +

    {form.message}

    + {/if} +
    -

    All Participants

    - {countLabel} -
    -
    -
    -
    +

    All Participants

    + {countLabel}
    + + + + + {#if capacity > 0} + {#if overBy > 0} +

    + Over capacity: {confirmedCount} confirmed of {capacity} + {capacity === 1 ? 'place' : 'places'}. +

    + {:else if freePlaces === 0 && data.mayManage && waitingCount > 0} +

    + This event is full ({confirmedCount} of {capacity} confirmed). + Approving more people will go over capacity. +

    + {:else if freePlaces > 0 && data.mayManage && waitingCount > 0} +

    + {freePlaces} + {freePlaces === 1 ? 'place' : 'places'} free — {waitingCount} + {waitingCount === 1 ? 'person is' : 'people are'} waiting. Nobody + is promoted automatically: approve from the waiting list to hand + a place out. +

    + {/if} + {/if} +
    - {#if filtered.length === 0} -

    + {#if data.participants.length === 0} +

    + No one has joined this hackathon yet. +

    + {:else if filtered.length === 0} +

    No participants match your search.

    + {:else if view === 'table'} + +
    + + + + + + + + {#if data.mayManage} + + {/if} + + + + {#each filtered as participant (participant.id)} + + + + + {#if data.mayManage} + + {/if} + + {/each} + +
    Participants
    NameRoleStatusActions
    {participant.name}{participant.roleLabel} + + {participant.isWaiting ? 'Waitlisted' : 'Confirmed'} + + +
    + {@render rowActions(participant)} +
    +
    +
    {:else} - {#each filtered as participant (participant.name)} + {#each filtered as participant (participant.id)} + role={participant.roleLabel} + profileDetailsHref={viewHref(participant)} + > + {#snippet actions()} + {@render rowActions(participant)} + {/snippet} + {/each} {/if}
    diff --git a/components/frontend/src/routes/(app)/my/hackathon/[id]/photos/+page.server.ts b/components/frontend/src/routes/(app)/my/hackathon/[id]/photos/+page.server.ts new file mode 100644 index 00000000..e4a0eb17 --- /dev/null +++ b/components/frontend/src/routes/(app)/my/hackathon/[id]/photos/+page.server.ts @@ -0,0 +1,46 @@ +import type { PageServerLoad } from "./$types" +import { requireGrpc } from "$lib/server/grpc/client" +import { PHOTO_HINT } from "$lib/pageCollections" +import { error } from "@sveltejs/kit" +import { ClientError, Status } from "nice-grpc-common" + +// There is no photo entity and no blob store: media is links-first until +// object storage lands (docs/roadmap.md), so this tab has no uploads to show +// and will not pretend otherwise. What does exist is the way the lifecycle +// recipe actually publishes a gallery — `act8.photos` creates an event page +// titled "Photos & Winners" pointing at where the material lives. So the tab +// reads the real pages and renders whatever the organizers put there. +// +// PageService.List rather than the layout's `hackathon.pages`: the backend +// applies the visibility rule there (drafts only for page-writers), while the +// layout's Get embeds every page including unpublished ones. + +export const load: PageServerLoad = async (event) => { + const { page } = requireGrpc(event.locals.grpc) + + let pages + try { + pages = (await page.list({ hackathonId: event.params.id })).pages + } catch (e) { + if (e instanceof ClientError && e.code === Status.PERMISSION_DENIED) + error(403, "Access denied") + if (e instanceof ClientError && e.code === Status.NOT_FOUND) + error(404, "Hackathon not found") + throw e + } + + // Backend order (the `order` column) is preserved. + const shaped = pages.map((p) => ({ + id: p.id, + title: p.title, + content: p.content, + updatedAt: p.modifiedAt ?? p.createdAt ?? null, + })) + + // A hint, not a filter — pages that do not read like galleries are still + // listed under their own heading rather than dropped. + return { + galleries: shaped.filter((p) => PHOTO_HINT.test(p.title)), + otherPages: shaped.filter((p) => !PHOTO_HINT.test(p.title)), + } +} diff --git a/components/frontend/src/routes/(app)/my/hackathon/[id]/photos/+page.svelte b/components/frontend/src/routes/(app)/my/hackathon/[id]/photos/+page.svelte index 1774a3f9..e8478fcb 100644 --- a/components/frontend/src/routes/(app)/my/hackathon/[id]/photos/+page.svelte +++ b/components/frontend/src/routes/(app)/my/hackathon/[id]/photos/+page.svelte @@ -1,5 +1,94 @@ - +{#snippet pageEntry(p: PageData['galleries'][number])} +
    +

    {p.title}

    + {#if updated(p.updatedAt)} + Updated {updated(p.updatedAt)} + {/if} +
    + +
    +
    +{/snippet} + +
    +
    +

    Photos

    + +

    + Galleries are event pages the organizers publish. Images can be uploaded + straight into a page from its editor, or linked from wherever they already + live. Those pages are shown here as published. +

    +
    + + {#if !hasAnything} +

    + Nothing published yet. When the organizers publish a gallery page, it appears + here. +

    + {:else} + {#if data.galleries.length > 0} +
    + {#each data.galleries as p (p.id)} + {@render pageEntry(p)} + {/each} +
    + {:else} +

    + No gallery page yet. The organizers have published other event pages, + listed below. +

    + {/if} + + {#if data.otherPages.length > 0} + +
    + + Other pages published by the organizers ({data.otherPages.length}) + +
    + {#each data.otherPages as p (p.id)} + {@render pageEntry(p)} + {/each} +
    +
    + {/if} + {/if} +
    + + diff --git a/components/frontend/src/routes/(app)/my/hackathon/[id]/prizes/+page.server.ts b/components/frontend/src/routes/(app)/my/hackathon/[id]/prizes/+page.server.ts new file mode 100644 index 00000000..dfb3f6eb --- /dev/null +++ b/components/frontend/src/routes/(app)/my/hackathon/[id]/prizes/+page.server.ts @@ -0,0 +1,102 @@ +import type { Actions, PageServerLoad } from "./$types" +import { requireGrpc } from "$lib/server/grpc/client" +import { GlobalRole } from "$lib/server/grpc/generated/user/entities/global_role" +import { mayManagePhases } from "$lib/server/hackathon/capabilities" +import { error, fail } from "@sveltejs/kit" +import { ClientError, Status } from "nice-grpc-common" + +// Prizes, and the awards that end up against them. +// +// The vote is advisory: an organiser reviews the tally and records who actually +// won, which is what Finalize freezes. That is a deliberate policy — a jury +// that cannot overrule its own count is not a jury — and it is why this screen +// exists rather than the results page simply publishing the leaderboard. + +function formError(e: unknown) { + if (e instanceof ClientError) { + if (e.code === Status.PERMISSION_DENIED) + return fail(403, { message: "Only this event's organisers can do that." }) + if (e.code === Status.INVALID_ARGUMENT) + return fail(400, { message: e.details }) + if (e.code === Status.FAILED_PRECONDITION) + return fail(409, { + message: e.details || "The awards are already finalised.", + }) + } + throw e +} + +export const load: PageServerLoad = async (event) => { + const { myMembership } = await event.parent() + const { prize } = requireGrpc(event.locals.grpc) + + const isAdmin = (event.locals.platformUser?.roles ?? []).includes( + GlobalRole.GLOBAL_ROLE_ADMIN, + ) + if (!mayManagePhases(myMembership ?? undefined, isAdmin)) { + error(403, "Only this event's organisers can manage prizes") + } + + // Prefilled, because Set replaces the whole table — see PrizeService.Get. + const result = await prize.get({ hackathonId: event.params.id }) + + return { + prizes: result.prizes.map((p) => ({ + rank: p.rank, + title: p.title, + image: p.image ?? "", + })), + awards: result.awards.map((p) => ({ rank: p.rank, title: p.title })), + finalized: result.finalized, + } +} + +export const actions: Actions = { + save: async (event) => { + const { prize } = requireGrpc(event.locals.grpc) + const form = await event.request.formData() + + const ranks = form.getAll("rank") + const titles = form.getAll("title") + // Set replaces the table wholesale, so anything the form does not send back + // is deleted — carrying the image through is mandatory rather than optional. + // One input named `image` per row, in row order: it pairs POSITIONALLY with + // `rank` and `title`, which is why the row's uploader renders a single + // hidden field instead of an upload box and a URL box both. + const images = form.getAll("image") + const prizes: { rank: number; title: string; image?: string }[] = [] + for (let i = 0; i < titles.length; i++) { + const title = String(titles[i] ?? "").trim() + // An empty title is a row the organiser abandoned, not a nameless prize. + if (!title) continue + const image = String(images[i] ?? "").trim() + prizes.push({ + rank: Number(ranks[i] ?? 0) || 0, + title, + ...(image ? { image } : {}), + }) + } + if (prizes.length === 0) + return fail(400, { message: "Add at least one prize." }) + + try { + await prize.set({ hackathonId: event.params.id, prizes }) + } catch (e) { + return formError(e) + } + + return { saved: true } + }, + + finalize: async (event) => { + const { prize } = requireGrpc(event.locals.grpc) + + try { + await prize.finalize({ hackathonId: event.params.id }) + } catch (e) { + return formError(e) + } + + return { finalized: true } + }, +} diff --git a/components/frontend/src/routes/(app)/my/hackathon/[id]/prizes/+page.svelte b/components/frontend/src/routes/(app)/my/hackathon/[id]/prizes/+page.svelte new file mode 100644 index 00000000..42c0d567 --- /dev/null +++ b/components/frontend/src/routes/(app)/my/hackathon/[id]/prizes/+page.svelte @@ -0,0 +1,152 @@ + + +
    +
    +

    Prizes

    +

    + What this event awards. The vote is advisory — you review the tally and record who + actually won, then finalise. +

    +
    + + {#if form?.message} + + {/if} + + {#if data.finalized} +

    + Finalised + The awards are frozen. Editing the table no longer changes what was given. +

    + {/if} + +
    +
    +

    The prize table

    + {#if form?.saved}Saved.{/if} +
    + + {#each rows as row, i (i)} +
    + + + + + +
    + {/each} + +
    + + +
    +
    + + {#if data.awards.length > 0} +
    +

    Awarded

    +
      + {#each data.awards as award (award.rank + award.title)} +
    • + #{award.rank} + {award.title} +
    • + {/each} +
    +
    + {/if} + + {#if !data.finalized} +
    +

    Finalise the awards

    +

    + Freezes what was given. Do this once the winners are announced — it is the + record of the event, and the tally stops being able to change it. +

    + + {#if !confirmingFinalize} +
    + +
    + {:else} +
    + + +
    + {/if} +
    + {/if} +
    diff --git a/components/frontend/src/routes/(app)/my/hackathon/[id]/projects/+page.server.ts b/components/frontend/src/routes/(app)/my/hackathon/[id]/projects/+page.server.ts new file mode 100644 index 00000000..2cf0d867 --- /dev/null +++ b/components/frontend/src/routes/(app)/my/hackathon/[id]/projects/+page.server.ts @@ -0,0 +1,208 @@ +import type { Actions, PageServerLoad } from "./$types" +import { requireGrpc } from "$lib/server/grpc/client" +import { ProjectStatus } from "$lib/server/grpc/generated/hackathon/entities/project_status" +import { GlobalRole } from "$lib/server/grpc/generated/user/entities/global_role" +import { HackathonRole } from "$lib/server/grpc/generated/hackathon/entities/hackathon_role" +import { mayPreferProjects } from "$lib/server/hackathon/capabilities" +import { fail } from "@sveltejs/kit" +import { ClientError, Status } from "nice-grpc-common" + +export const load: PageServerLoad = async (event) => { + // No RPC of its own: the layout's `hackathon.get` already returns every + // project at every status. + const { hackathon, myMembership } = await event.parent() + + const myId = event.locals.platformUser?.id + const isAdmin = (event.locals.platformUser?.roles ?? []).includes( + GlobalRole.GLOBAL_ROLE_ADMIN, + ) + const isHackathonOwner = + myMembership?.role === HackathonRole.HACKATHON_ROLE_OWNER + + // The subjects `Approve`/`Disapprove` accept — hackathon-level `project:write` + // (`project_service.go:281`), held by the casbin Owner and by an admin through + // the escape hatch. Courtesy only: both handlers enforce it for real. + const mayReview = isHackathonOwner || isAdmin + + // Which of these projects the caller already prefers. Read-only and + // decorative — swallow the error and show nothing preferred rather than + // fail the whole load, same as the `hackathon.list`/`page.list` chrome calls + // in `(app)/+layout.server.ts`. + let preferredIds = new Set() + const mayPrefer = mayPreferProjects(myMembership ?? undefined, isAdmin) + if (mayPrefer) { + try { + const { project } = requireGrpc(event.locals.grpc) + const { projectIds } = await project.getPreference({ + hackathonId: hackathon.id, + }) + preferredIds = new Set(projectIds) + } catch { + // No preferences to show — the "Prefer" button still works either way. + } + } + + // A reviewer sees proposals too — that is the whole point of deciding from + // this page. Everyone else sees approved projects only: a proposal awaiting a + // decision is its author's business, and Proposals is where they follow it. + // + // Frontend-only. `hackathon.get` returns every project whatever the caller's + // role, so this shapes the page rather than enforcing anything; a member + // calling the API directly still sees pending proposals. + const visible = hackathon.projects.filter( + (p) => + p.status === ProjectStatus.PROJECT_STATUS_APPROVED || + (mayReview && p.status === ProjectStatus.PROJECT_STATUS_PROPOSED), + ) + + const isPending = (s: number) => s === ProjectStatus.PROJECT_STATUS_PROPOSED + + // Awaiting review first for a reviewer — those are the ones asking for an + // action. Newest first within each group, matching how the page has read. + const ordered = [...visible].sort((a, b) => { + if (isPending(a.status) !== isPending(b.status)) { + return isPending(a.status) ? -1 : 1 + } + return (b.createdAt?.getTime() ?? 0) - (a.createdAt?.getTime() ?? 0) + }) + + // `Project` carries only `creatorId`, so the name comes from the membership + // list that arrived in the same response. A creator who has since left the + // hackathon resolves to nothing and the card omits the line — better than + // printing a raw uuid at someone. + const memberNames = new Map( + hackathon.members + .filter((m) => m.user !== undefined) + .map((m) => [m.user!.id, m.user!.displayName || m.user!.username]), + ) + + // Tracks arrive nested in the same response. A project whose track was + // deleted resolves to nothing and the card omits it. + const trackNames = new Map(hackathon.tracks.map((t) => [t.id, t.name])) + + // TODO(backend: display-ordinals): `num` is a position in this list, not an + // identifier. Project has no display number, so two viewers sorting the same + // set agree, but the number a project shows changes as approvals land. Swap + // in the real field once it exists. + const projects = ordered.map((p, i) => ({ + id: p.id, + num: ordered.length - i, + title: p.title, + description: p.description, + creator: memberNames.get(p.creatorId), + track: p.trackId ? trackNames.get(p.trackId) : undefined, + imageUrl: p.image, + status: p.status, + // Derived here rather than in the component, so no page has to import the + // generated enum across the server-only boundary to compare a status. + isPending: isPending(p.status), + // The three subjects `ProjectService.Edit` accepts, and the same test the + // edit route gates on: the proposer, the hackathon owner, an admin. Per + // project, because the proposer differs row to row. + // + // Note the second and third let an owner or admin edit someone else's + // proposal. That is what the backend allows — `Edit` falls back to a + // hackathon-wide project:write check (`project_service.go:479-484`) — so + // offering it here matches the existing edit route rather than quietly + // narrowing it. Whether it *should* be allowed is a separate question. + mayEdit: (myId !== undefined && p.creatorId === myId) || mayReview, + isPreferred: preferredIds.has(p.id), + })) + + // `hackathonId` so the page can build the link to the propose form — + // unresolved, since `resolve()` belongs at the anchor itself. + return { + projects, + hackathonId: hackathon.id, + mayReview, + mayPrefer, + } +} + +/** Shared by every action here: they all act on one project id from the form. */ +function projectIdFrom(form: FormData): string | undefined { + const id = form.get("projectId") + return typeof id === "string" && id !== "" ? id : undefined +} + +/** The gRPC errors all three write paths can return, as SvelteKit failures. */ +function failFor(e: unknown, denied: string) { + if (e instanceof ClientError && e.code === Status.PERMISSION_DENIED) { + return fail(403, { message: denied }) + } + if (e instanceof ClientError && e.code === Status.NOT_FOUND) { + return fail(404, { message: "That project no longer exists" }) + } + if (e instanceof ClientError && e.code === Status.INVALID_ARGUMENT) { + return fail(400, { message: e.details }) + } + throw e +} + +export const actions: Actions = { + approve: async (event) => { + const { project } = requireGrpc(event.locals.grpc) + + const projectId = projectIdFrom(await event.request.formData()) + if (!projectId) return fail(400, { message: "No project was given" }) + + try { + await project.approve({ projectId }) + } catch (e) { + return failFor(e, "You don't have permission to approve projects here") + } + + // No redirect: SvelteKit re-runs `load` after an action, so the badge turns + // Approved and the card moves out of the awaiting-review group on its own. + return { approvedId: projectId } + }, + + // Revoking an approval, not rejecting. `ProjectService.Disapprove` sets the + // status back to PROPOSED (`project_service.go:242`) — the state a project was + // in before anyone looked at it — so this returns a project to the queue. + // + // TODO(backend: project-rejected-status): there is no reject, so this page + // offers none. `ProjectStatus` has only PROPOSED and APPROVED, and a rejected + // proposal would be indistinguishable from an unreviewed one. Once a REJECTED + // status (ideally with a reason) exists, add that as a separate action and + // leave this one meaning what its name says. + disapprove: async (event) => { + const { project } = requireGrpc(event.locals.grpc) + + const projectId = projectIdFrom(await event.request.formData()) + if (!projectId) return fail(400, { message: "No project was given" }) + + try { + await project.disapprove({ projectId }) + } catch (e) { + return failFor(e, "You don't have permission to review projects here") + } + + return { disapprovedId: projectId } + }, + + prefer: async (event) => { + const { project } = requireGrpc(event.locals.grpc) + + const projectId = projectIdFrom(await event.request.formData()) + if (!projectId) return fail(400, { message: "No project was given" }) + + try { + await project.setPreference({ projectId }) + } catch (e) { + return failFor( + e, + "You can't mark projects as preferred in this hackathon", + ) + } + + return { preferredId: projectId } + }, + + // No `unprefer` action, deliberately. RemovePreference is organiser-only + // here — it takes the user_id of the person whose preference is being + // withdrawn and requires project:write — so a member-facing version could + // only ever answer INVALID_ARGUMENT. There was one, wired to a button that + // always failed. An organiser-facing caller belongs on the team-matching + // page, next to where the preferences are read. +} diff --git a/components/frontend/src/routes/(app)/my/hackathon/[id]/projects/+page.svelte b/components/frontend/src/routes/(app)/my/hackathon/[id]/projects/+page.svelte new file mode 100644 index 00000000..6b421e6c --- /dev/null +++ b/components/frontend/src/routes/(app)/my/hackathon/[id]/projects/+page.svelte @@ -0,0 +1,160 @@ + + + +
    + +
    +

    All Projects

    + + {countLabel}{#if data.mayReview && pendingCount > 0} + · {pendingCount} awaiting review{/if} + +
    + + {#if form?.message} + + {/if} + +
    + {#if data.projects.length === 0} +

    + {#if data.mayReview} + No projects have been proposed yet. + {:else} + No projects have been approved yet. + {/if} +

    + {:else} + {#each pagedProjects as project (project.id)} + + {#snippet actions()} + {#if project.mayEdit} + + + Edit + + {/if} + {#if data.mayReview} + {#if project.isPending} +
    + + +
    + {:else} + +
    + + +
    + {/if} + {/if} + {#if data.mayPrefer && !project.isPending} + {#if project.isPreferred} + + + ★ Preferred + + {:else} +
    + + +
    + {/if} + {/if} + {/snippet} +
    + {/each} + {/if} +
    + + {#if pageCount > 1} + + {/if} +
    diff --git a/components/frontend/src/routes/(app)/my/hackathon/[id]/projects/[projectId]/+page.server.ts b/components/frontend/src/routes/(app)/my/hackathon/[id]/projects/[projectId]/+page.server.ts new file mode 100644 index 00000000..abee980f --- /dev/null +++ b/components/frontend/src/routes/(app)/my/hackathon/[id]/projects/[projectId]/+page.server.ts @@ -0,0 +1,173 @@ +import type { Actions, PageServerLoad } from "./$types" +import { requireGrpc } from "$lib/server/grpc/client" +import { GlobalRole } from "$lib/server/grpc/generated/user/entities/global_role" +import { HackathonRole } from "$lib/server/grpc/generated/hackathon/entities/hackathon_role" +import { ProjectStatus } from "$lib/server/grpc/generated/hackathon/entities/project_status" +import { mayPreferProjects } from "$lib/server/hackathon/capabilities" +import { error, fail } from "@sveltejs/kit" +import { ClientError, Status } from "nice-grpc-common" + +export const load: PageServerLoad = async (event) => { + // No RPC of its own: the layout's `hackathon.get` already returns every + // project at every status, plus the tracks and the members that name the + // proposer. Same source All Projects and Proposals read. + const { hackathon, myMembership } = await event.parent() + + const project = hackathon.projects.find( + (p) => p.id === event.params.projectId, + ) + if (!project) { + error(404, "Project not found") + } + + const isCreator = project.creatorId === event.locals.platformUser?.id + const isAdmin = (event.locals.platformUser?.roles ?? []).includes( + GlobalRole.GLOBAL_ROLE_ADMIN, + ) + const isHackathonOwner = + myMembership?.role === HackathonRole.HACKATHON_ROLE_OWNER + + // The same subjects `Approve` accepts — hackathon-level `project:write`, held + // by the casbin Owner and by an admin through the escape hatch. A proposer's + // project-scoped Owner role sits in a different casbin domain, so it does not + // satisfy this and nobody can approve their own proposal. + const mayReview = isHackathonOwner || isAdmin + + // A proposal awaiting a decision is its author's business and the reviewer's, + // not something to browse. This mirrors All Projects, which lists proposals + // only for a reviewer, and Proposals, which lists only the viewer's own. + // + // Frontend-only, and deliberately so: `ProjectService.Get` grants + // `project:read` to any member of the hackathon whatever the project's + // status, so this hides a pending proposal from the UI rather than enforcing + // anything. Anyone calling the API directly still sees it. + const isPending = project.status === ProjectStatus.PROJECT_STATUS_PROPOSED + if (isPending && !isCreator && !mayReview) { + error(403, "This project is still awaiting review") + } + + // `Project` carries only `creatorId`, so the name comes from the membership + // list that arrived in the same response. A proposer who has since left the + // hackathon resolves to nothing and the page omits the line, rather than + // printing a raw uuid at someone. + const memberNames = new Map( + hackathon.members + .filter((m) => m.user !== undefined) + .map((m) => [m.user!.id, m.user!.displayName || m.user!.username]), + ) + const trackNames = new Map(hackathon.tracks.map((t) => [t.id, t.name])) + + return { + project: { + id: project.id, + title: project.title, + description: project.description, + status: project.status, + imageUrl: project.image, + track: project.trackId ? trackNames.get(project.trackId) : undefined, + proposer: memberNames.get(project.creatorId), + createdAt: project.createdAt, + modifiedAt: project.modifiedAt, + }, + // What the viewer may do here, decided server-side. `Edit` accepts the same + // three subjects the edit page gates on; `Approve` only the two above. + mayEdit: isCreator || isHackathonOwner || isAdmin, + mayApprove: mayReview && isPending, + // The other half of the same decision, matching the projects list: an + // approved project can be returned to the queue, which is what Disapprove + // does. Nothing to revoke on one that was never approved. + mayRevoke: mayReview && !isPending, + // `!isPending` too: a proposal nobody has approved is not yet something to + // express a preference between. + mayPrefer: + !isPending && mayPreferProjects(myMembership ?? undefined, isAdmin), + hackathonId: hackathon.id, + } +} + +export const actions: Actions = { + // TODO(backend: project-rejected-status): there is no reject, so this page + // offers none — only approve and its undo. `ProjectStatus` has only PROPOSED + // and APPROVED, and `ProjectService.Disapprove` sets a project back to + // PROPOSED, the state it was in before anyone looked at it. A rejected + // proposal is therefore indistinguishable from an unreviewed one. Once a + // REJECTED status (ideally with a reason) exists, add that as a third action + // and show the decision here. + approve: async (event) => { + const { project } = requireGrpc(event.locals.grpc) + + try { + await project.approve({ projectId: event.params.projectId }) + } catch (e) { + if (e instanceof ClientError && e.code === Status.PERMISSION_DENIED) { + return fail(403, { + message: "You don't have permission to approve projects here", + }) + } + if (e instanceof ClientError && e.code === Status.NOT_FOUND) { + return fail(404, { message: "That project no longer exists" }) + } + if (e instanceof ClientError && e.code === Status.INVALID_ARGUMENT) { + return fail(400, { message: e.details }) + } + throw e + } + + // No redirect: SvelteKit re-runs `load` after an action, so the badge turns + // Approved and the button disappears on its own. + return { approved: true } + }, + + // Revoking an approval, not rejecting: Disapprove returns the project to the + // queue at PROPOSED. See the TODO above. + disapprove: async (event) => { + const { project } = requireGrpc(event.locals.grpc) + + try { + await project.disapprove({ projectId: event.params.projectId }) + } catch (e) { + if (e instanceof ClientError && e.code === Status.PERMISSION_DENIED) { + return fail(403, { + message: "You don't have permission to review projects here", + }) + } + if (e instanceof ClientError && e.code === Status.NOT_FOUND) { + return fail(404, { message: "That project no longer exists" }) + } + if (e instanceof ClientError && e.code === Status.INVALID_ARGUMENT) { + return fail(400, { message: e.details }) + } + throw e + } + + return { disapproved: true } + }, + + // TODO(backend: project-preferences-readback): one-way on purpose, same gap + // as the projects list. Nothing reads a member's own preferences back — + // `hackathon.get`'s Project carries none and `ExportPreferences` is gated on + // project:write — and no RPC undoes one, so the confirmation below lasts only + // until the next load. Make it a real toggle once both exist. + prefer: async (event) => { + const { project } = requireGrpc(event.locals.grpc) + + try { + await project.setPreference({ projectId: event.params.projectId }) + } catch (e) { + if (e instanceof ClientError && e.code === Status.PERMISSION_DENIED) { + return fail(403, { + message: "You can't mark projects as preferred in this hackathon", + }) + } + if (e instanceof ClientError && e.code === Status.NOT_FOUND) { + return fail(404, { message: "That project no longer exists" }) + } + if (e instanceof ClientError && e.code === Status.INVALID_ARGUMENT) { + return fail(400, { message: e.details }) + } + throw e + } + + return { preferred: true } + }, +} diff --git a/components/frontend/src/routes/(app)/my/hackathon/[id]/projects/[projectId]/+page.svelte b/components/frontend/src/routes/(app)/my/hackathon/[id]/projects/[projectId]/+page.svelte new file mode 100644 index 00000000..d78fe49e --- /dev/null +++ b/components/frontend/src/routes/(app)/my/hackathon/[id]/projects/[projectId]/+page.svelte @@ -0,0 +1,183 @@ + + + +
    + + ← Back to projects + + + +
    + {#if data.project.imageUrl} +
    + +
    + {:else} +
    + {initials} +
    + {/if} + +
    +
    +

    + {data.project.title} +

    + {#if statusText} + + {statusText} + + {/if} +
    +

    + {#if data.project.proposer}Proposed by {data.project.proposer}{/if} + {#if data.project.proposer && proposedOn}·{/if} + {#if proposedOn}{proposedOn}{/if} + {#if editedOn}· edited {editedOn}{/if} +

    +
    +
    + + {#if form?.message} + + {/if} + + +
    +
    +
    Status
    +
    + {statusText ?? 'Unknown'} +
    +
    +
    +
    Track
    +
    + {data.project.track ?? 'No track'} +
    +
    +
    + +
    +

    Description

    + {#if data.project.description} + +
    + +
    + {:else} +

    No description was given.

    + {/if} +
    + + {#if data.mayApprove || data.mayRevoke || data.mayEdit || data.mayPrefer} +
    + {#if data.mayApprove} + +
    + +
    + {/if} + {#if data.mayRevoke} + +
    + +
    + {/if} + {#if data.mayPrefer} + + {#if form?.preferred} + + Marked as preferred + + {:else} +
    + +
    + {/if} + {/if} + {#if data.mayEdit} + + Edit + + {/if} +
    + {/if} +
    diff --git a/components/frontend/src/routes/(app)/my/hackathon/[id]/projects/[projectId]/edit/+page.server.ts b/components/frontend/src/routes/(app)/my/hackathon/[id]/projects/[projectId]/edit/+page.server.ts new file mode 100644 index 00000000..fb78c5db --- /dev/null +++ b/components/frontend/src/routes/(app)/my/hackathon/[id]/projects/[projectId]/edit/+page.server.ts @@ -0,0 +1,45 @@ +import type { Actions, PageServerLoad } from "./$types" +import { requireGrpc } from "$lib/server/grpc/client" +import { resolve } from "$app/paths" +import { + projectEditData, + saveProjectEdit, +} from "$lib/server/hackathon/projectEdit" +import { redirect } from "@sveltejs/kit" + +export const load: PageServerLoad = async (event) => { + // Same source as the list and the project page: the layout's `hackathon.get` + // already carries this project, so editing it needs no read of its own. + const { hackathon, myMembership } = await event.parent() + + return projectEditData( + hackathon, + event.params.projectId, + myMembership, + event.locals.platformUser, + ) +} + +export const actions: Actions = { + save: async (event) => { + const grpc = requireGrpc(event.locals.grpc) + + const failure = await saveProjectEdit( + grpc, + event.params.projectId, + await event.request.formData(), + ) + if (failure) return failure + + // Back to the project, not to Proposals — this route is entered from the + // project's own page and from the All Projects rows, and an approved project + // is not on the Proposals list at all, so landing there would strand the + // editor somewhere their project isn't. + redirect( + 303, + resolve( + `/my/hackathon/${event.params.id}/projects/${event.params.projectId}`, + ), + ) + }, +} diff --git a/components/frontend/src/routes/(app)/my/hackathon/[id]/projects/[projectId]/edit/+page.svelte b/components/frontend/src/routes/(app)/my/hackathon/[id]/projects/[projectId]/edit/+page.svelte new file mode 100644 index 00000000..477320ef --- /dev/null +++ b/components/frontend/src/routes/(app)/my/hackathon/[id]/projects/[projectId]/edit/+page.svelte @@ -0,0 +1,52 @@ + + +
    +
    + + ← Back to {data.project.title} + +
    +

    Edit Project

    + {#if statusText} + + {statusText} + + {/if} +
    +

    + Changes apply immediately, whether or not the project has been approved. +

    +
    + + +
    diff --git a/components/frontend/src/routes/(app)/my/hackathon/[id]/projects/proposals/+page.server.ts b/components/frontend/src/routes/(app)/my/hackathon/[id]/projects/proposals/+page.server.ts new file mode 100644 index 00000000..9c99ca26 --- /dev/null +++ b/components/frontend/src/routes/(app)/my/hackathon/[id]/projects/proposals/+page.server.ts @@ -0,0 +1,79 @@ +import type { PageServerLoad } from "./$types" +import { ProjectStatus } from "$lib/server/grpc/generated/hackathon/entities/project_status" +import { GlobalRole } from "$lib/server/grpc/generated/user/entities/global_role" +import { mayManagePhases } from "$lib/server/hackathon/capabilities" + +export const load: PageServerLoad = async (event) => { + // No RPC of its own: the layout's `hackathon.get` already returns every + // project, at every status, each carrying `creatorId`. Same source the All + // Projects page reads — this one just filters differently. + const { hackathon, myMembership } = await event.parent() + const myId = event.locals.platformUser?.id + + const authored = hackathon.projects.filter( + (p) => myId !== undefined && p.creatorId === myId, + ) + + // Proposals awaiting a decision only. Once a proposal is approved it stops + // being a proposal and becomes one of the hackathon's projects, where All + // Projects is the page that lists it — so it leaves this one. + const pending = authored.filter( + (p) => p.status === ProjectStatus.PROJECT_STATUS_PROPOSED, + ) + + // Newest first, matching the All Projects page. + const ordered = [...pending].sort( + (a, b) => (b.createdAt?.getTime() ?? 0) - (a.createdAt?.getTime() ?? 0), + ) + + // Tracks arrive nested in the same response. A project whose track was + // deleted resolves to nothing and the card omits it. + const trackNames = new Map(hackathon.tracks.map((t) => [t.id, t.name])) + + // Every row here is the viewer's own, so the author is always the same person. + // Carried anyway, because the shared card shows author and track together and + // a row that omitted one would read as missing rather than redundant. + const memberNames = new Map( + hackathon.members + .filter((m) => m.user !== undefined) + .map((m) => [m.user!.id, m.user!.displayName || m.user!.username]), + ) + + // TODO(backend: display-ordinals): `num` is a position in this list, not an + // identifier. Project has no display number, so the number a project shows + // here differs from the one it shows on the All Projects page — different + // list, different position. Swap in the real field once it exists. + const projects = ordered.map((p, i) => ({ + id: p.id, + num: ordered.length - i, + title: p.title, + description: p.description, + status: p.status, + creator: memberNames.get(p.creatorId), + track: p.trackId ? trackNames.get(p.trackId) : undefined, + imageUrl: p.image, + })) + + // Unresolved on purpose: `resolve()` prepends `base`, and every consumer here + // — the anchor in the page, `ProjectCard` for the edit link — calls it at the + // link itself, as `svelte/no-navigation-without-resolve` requires. Resolving + // here too would prefix `base` twice. + return { + projects, + hackathonId: hackathon.id, + // So the empty state can tell "you have never proposed anything" apart from + // "everything you proposed was approved and has moved on". Without it the + // page tells the second author they have not proposed a project. + approvedCount: authored.length - pending.length, + // The preferences export is organiser-only (ExportPreferences is + // Project.Write). Offering the link to everyone would hand most of the room + // a 403; the endpoint refuses regardless, this only decides who is told it + // exists. Same owner-or-admin gate the other organiser tools use. + mayExport: mayManagePhases( + myMembership ?? undefined, + (event.locals.platformUser?.roles ?? []).includes( + GlobalRole.GLOBAL_ROLE_ADMIN, + ), + ), + } +} diff --git a/components/frontend/src/routes/(app)/my/hackathon/[id]/projects/proposals/+page.svelte b/components/frontend/src/routes/(app)/my/hackathon/[id]/projects/proposals/+page.svelte new file mode 100644 index 00000000..4ad0d99a --- /dev/null +++ b/components/frontend/src/routes/(app)/my/hackathon/[id]/projects/proposals/+page.svelte @@ -0,0 +1,84 @@ + + + +
    +
    +
    +

    Proposals

    + {countLabel} awaiting review +
    +
    + {#if data.mayExport} + + + + Export preferences + + {/if} + + + Propose a Project + +
    +
    + + +
    + {#if data.projects.length === 0} +

    + {#if data.approvedCount > 0} + + Nothing awaiting review — all {data.approvedCount === 1 + ? 'your proposal has' + : `${data.approvedCount} of your proposals have`} been approved. + {:else} + You haven't proposed a project yet. + {/if} +

    + {:else} + {#each data.projects as project (project.id)} + + {/each} + {/if} +
    +
    diff --git a/components/frontend/src/routes/(app)/my/hackathon/[id]/projects/proposals/[projectId]/edit/+page.server.ts b/components/frontend/src/routes/(app)/my/hackathon/[id]/projects/proposals/[projectId]/edit/+page.server.ts new file mode 100644 index 00000000..0fec4134 --- /dev/null +++ b/components/frontend/src/routes/(app)/my/hackathon/[id]/projects/proposals/[projectId]/edit/+page.server.ts @@ -0,0 +1,41 @@ +import type { Actions, PageServerLoad } from "./$types" +import { requireGrpc } from "$lib/server/grpc/client" +import { resolve } from "$app/paths" +import { + projectEditData, + saveProjectEdit, +} from "$lib/server/hackathon/projectEdit" +import { redirect } from "@sveltejs/kit" + +export const load: PageServerLoad = async (event) => { + // Same source as the list: the layout's `hackathon.get` already carries this + // project, so editing it needs no read of its own. + const { hackathon, myMembership } = await event.parent() + + return projectEditData( + hackathon, + event.params.projectId, + myMembership, + event.locals.platformUser, + ) +} + +export const actions: Actions = { + save: async (event) => { + const grpc = requireGrpc(event.locals.grpc) + + const failure = await saveProjectEdit( + grpc, + event.params.projectId, + await event.request.formData(), + ) + if (failure) return failure + + // Back to Proposals, which is where this route is entered from. A proposal + // that was edited is still awaiting review, so it is still on that list. + redirect( + 303, + resolve(`/my/hackathon/${event.params.id}/projects/proposals`), + ) + }, +} diff --git a/components/frontend/src/routes/(app)/my/hackathon/[id]/projects/proposals/[projectId]/edit/+page.svelte b/components/frontend/src/routes/(app)/my/hackathon/[id]/projects/proposals/[projectId]/edit/+page.svelte new file mode 100644 index 00000000..51e170f2 --- /dev/null +++ b/components/frontend/src/routes/(app)/my/hackathon/[id]/projects/proposals/[projectId]/edit/+page.svelte @@ -0,0 +1,49 @@ + + +
    +
    + + ← Back to proposals + +
    +

    Edit Proposal

    + {#if statusText} + + {statusText} + + {/if} +
    +

    + Changes apply immediately, whether or not the project has been approved. +

    +
    + + +
    diff --git a/components/frontend/src/routes/(app)/my/hackathon/[id]/projects/proposals/export/+server.ts b/components/frontend/src/routes/(app)/my/hackathon/[id]/projects/proposals/export/+server.ts new file mode 100644 index 00000000..92e48651 --- /dev/null +++ b/components/frontend/src/routes/(app)/my/hackathon/[id]/projects/proposals/export/+server.ts @@ -0,0 +1,60 @@ +import type { RequestHandler } from "./$types" +import { requireGrpc } from "$lib/server/grpc/client" +import { error } from "@sveltejs/kit" +import { ClientError, Status } from "nice-grpc-common" + +// ProjectStatus: PROPOSED=1, APPROVED=2 +const STATUS_LABEL: Partial> = { + 1: "proposed", + 2: "approved", +} + +/** RFC 4180 quoting: a field is safe only once its own quotes are doubled. */ +function csvCell(v: string): string { + return `"${v.replaceAll('"', '""')}"` +} + +// Who wants to work on what, as a file an organizer can sort teams from. +// ExportPreferences is Project.Write, so the backend refuses anyone who is not +// an organizer and this endpoint just relays that. +export const GET: RequestHandler = async (event) => { + const { project } = requireGrpc(event.locals.grpc) + + let res + try { + res = await project.exportPreferences({ hackathonId: event.params.id }) + } catch (e) { + if (e instanceof ClientError && e.code === Status.PERMISSION_DENIED) + error(403, "Only this event's organizers can export preferences") + if (e instanceof ClientError && e.code === Status.NOT_FOUND) + error(404, "Hackathon not found") + throw e + } + + const rows = [["project", "status", "participant", "username", "email"]] + for (const p of res.projects) { + const status = STATUS_LABEL[p.status] ?? "unknown" + if (p.preferences.length === 0) { + rows.push([p.title, status, "", "", ""]) + continue + } + for (const u of p.preferences) { + rows.push([ + p.title, + status, + u.displayName || u.username, + u.username, + u.email, + ]) + } + } + + const csv = rows.map((r) => r.map(csvCell).join(",")).join("\r\n") + + return new Response(csv, { + headers: { + "content-type": "text/csv; charset=utf-8", + "content-disposition": `attachment; filename="preferences-${event.params.id}.csv"`, + }, + }) +} diff --git a/components/frontend/src/routes/(app)/my/hackathon/[id]/projects/proposals/propose/+page.server.ts b/components/frontend/src/routes/(app)/my/hackathon/[id]/projects/proposals/propose/+page.server.ts new file mode 100644 index 00000000..ba29bb2f --- /dev/null +++ b/components/frontend/src/routes/(app)/my/hackathon/[id]/projects/proposals/propose/+page.server.ts @@ -0,0 +1,82 @@ +import type { Actions, PageServerLoad } from "./$types" +import { requireGrpc } from "$lib/server/grpc/client" +import { resolve } from "$app/paths" +import { error, fail, redirect } from "@sveltejs/kit" +import { ClientError, Status } from "nice-grpc-common" + +export const load: PageServerLoad = async (event) => { + const { hackathon, myMembership } = await event.parent() + + // The only case the layout lets through that cannot propose. Everyone else + // who gets this far — confirmed member, hackathon owner, global admin — holds + // a casbin role that grants `project:propose`, so there is nothing further + // for the frontend to decide. `Propose` itself stays authoritative below. + if (myMembership?.isWaiting) { + error(403, "Your membership is still awaiting approval") + } + + return { + hackathonId: hackathon.id, + // Tracks arrive nested in the layout's `hackathon.get` — no RPC needed. + tracks: hackathon.tracks.map((t) => ({ id: t.id, name: t.name })), + } +} + +export const actions: Actions = { + propose: async (event) => { + const { project } = requireGrpc(event.locals.grpc) + const form = await event.request.formData() + + const title = form.get("title") + const description = form.get("description") + const trackId = form.get("trackId") + const image = form.get("image") + + if (typeof title !== "string" || title.trim().length < 3) { + return fail(400, { message: "Title must be at least 3 characters" }) + } + if (title.trim().length > 255) { + return fail(400, { message: "Title must be at most 255 characters" }) + } + if (typeof description === "string" && description.length > 10000) { + return fail(400, { + message: "Description must be at most 10000 characters", + }) + } + + try { + await project.propose({ + hackathonId: event.params.id, + title: title.trim(), + // Propose accepts an empty description; only Edit insists on one. + description: typeof description === "string" ? description : "", + // Whether the track belongs to this hackathon is the backend's call — + // it checks, and says so. Sending nothing means "no track". + trackId: + typeof trackId === "string" && trackId !== "" ? trackId : undefined, + image: + typeof image === "string" && image.trim() !== "" + ? image.trim() + : undefined, + }) + } catch (e) { + if (e instanceof ClientError && e.code === Status.INVALID_ARGUMENT) { + return fail(400, { message: e.details }) + } + if (e instanceof ClientError && e.code === Status.PERMISSION_DENIED) { + return fail(403, { + message: "You don't have permission to propose a project here", + }) + } + throw e + } + + // Proposals rather than All Projects: the new project is `Proposed`, and the + // Projects page shows approved ones only — landing there would look like + // the proposal vanished. + redirect( + 303, + resolve(`/my/hackathon/${event.params.id}/projects/proposals`), + ) + }, +} diff --git a/components/frontend/src/routes/(app)/my/hackathon/[id]/projects/proposals/propose/+page.svelte b/components/frontend/src/routes/(app)/my/hackathon/[id]/projects/proposals/propose/+page.svelte new file mode 100644 index 00000000..4410a3fb --- /dev/null +++ b/components/frontend/src/routes/(app)/my/hackathon/[id]/projects/proposals/propose/+page.svelte @@ -0,0 +1,83 @@ + + +
    +
    + + ← Back to my projects + +

    Propose a Project

    +

    + An organizer reviews it before it appears on the Projects page. You can keep editing + it in the meantime. +

    +
    + + +
    + {#if form?.message} + + {/if} + +
    + + + + {#if data.tracks.length > 0} + + {/if} + + +
    + + +
    + + +
    + + +
    +
    diff --git a/components/frontend/src/routes/(app)/my/hackathon/[id]/proposals/+page.svelte b/components/frontend/src/routes/(app)/my/hackathon/[id]/proposals/+page.svelte deleted file mode 100644 index ae8ba990..00000000 --- a/components/frontend/src/routes/(app)/my/hackathon/[id]/proposals/+page.svelte +++ /dev/null @@ -1,99 +0,0 @@ - - - -
    -
    -
    -

    Proposals

    - {proposals.length} proposals -
    - - - Propose a Project - -
    - -
    - {#each pagedProposals as proposal (proposal.num)} - - {/each} -
    - - {#if pageCount > 1} - - {/if} -
    diff --git a/components/frontend/src/routes/(app)/my/hackathon/[id]/submissions/+page.server.ts b/components/frontend/src/routes/(app)/my/hackathon/[id]/submissions/+page.server.ts new file mode 100644 index 00000000..c82fab1a --- /dev/null +++ b/components/frontend/src/routes/(app)/my/hackathon/[id]/submissions/+page.server.ts @@ -0,0 +1,231 @@ +import type { Actions, PageServerLoad } from "./$types" +import { requireGrpc } from "$lib/server/grpc/client" +import { fail } from "@sveltejs/kit" +import { ClientError, Status } from "nice-grpc-common" + +// Turning work in. +// +// The read half survived the design swap; the write half did not, and nothing +// flagged it because the route still existed. CreateSubmission, EditSubmission +// and FinalizeSubmission had no caller anywhere in the frontend — the one step +// the whole event builds towards was grpcurl-only again. +// +// A submission belongs to a TEAM, so the controls appear only on teams the +// viewer is on. The backend enforces that with a team-scoped casbin domain +// regardless, plus the submissions window and the submissions capability; this +// only decides what to draw. + +/** Maps a gRPC failure onto a form error, rethrowing anything unexpected. */ +function formError(e: unknown) { + if (e instanceof ClientError) { + if (e.code === Status.PERMISSION_DENIED) + return fail(403, { message: "Only this team's members can do that." }) + if (e.code === Status.UNAUTHENTICATED) + return fail(401, { message: "Please sign in again." }) + if (e.code === Status.NOT_FOUND) + return fail(404, { message: "That item no longer exists." }) + if (e.code === Status.ALREADY_EXISTS) + return fail(409, { message: "That already exists." }) + // A closed submissions window and an already-finalised submission both land + // here. Left generic it reads as a refusal, so name the clock explicitly. + if (e.code === Status.FAILED_PRECONDITION) + return fail(409, { + message: `${e.details || "That isn't possible right now."} — this is a deadline, not a permission problem. An organiser can reopen or extend it.`, + }) + if (e.code === Status.ABORTED) + return fail(409, { + message: e.details || "Something changed underneath — try again.", + }) + // The organiser's submission schema validates here, and the details name + // the exact field that is missing or unknown, which is the useful part. + if (e.code === Status.INVALID_ARGUMENT) + return fail(400, { message: e.details || "Invalid input." }) + } + throw e +} + +/** Capability: CREATE_PROJECT_SUBMISSIONS=4 */ +const CAP_SUBMISSIONS = 4 + +/** + * Turns the server-computed capability row into a gate the page can narrate. + * + * CapabilityState: COMING=1, OPEN=2, CLOSED=3, UNGOVERNED=4. UNGOVERNED and a + * missing row both mean the server has no opinion, so the page behaves exactly + * as it did before capabilities existed. + */ +function gateFor( + capabilities: { + capability: number + state: number + opensAt?: Date | undefined + closesAt?: Date | undefined + }[], + capability: number, +) { + const c = capabilities.find((x) => x.capability === capability) + const state = c?.state ?? 4 + + return { + open: !c || state === 2 || state === 4 || state === 0, + state, + opensAt: c?.opensAt ?? null, + closesAt: c?.closesAt ?? null, + } +} + +/** + * Reads the answers back off the form. + * + * Keys come from the organiser's schema when there is one — it rides on the + * hackathon entity, so the page can render real labelled fields — and from a + * parallel key/value editor when there is not. Either way the backend has the + * last word: unknown or missing keys come back as INVALID_ARGUMENT naming the + * field. + */ +function answerMap(form: FormData): Record { + const answers: Record = {} + + for (const [name, value] of form.entries()) { + if (!name.startsWith("field:")) continue + const key = name.slice(6).trim() + if (key) answers[key] = String(value).trim() + } + + const keys = form.getAll("answerKey") + const values = form.getAll("answerValue") + for (let i = 0; i < keys.length; i++) { + const key = String(keys[i] ?? "").trim() + if (key) answers[key] = String(values[i] ?? "").trim() + } + + return answers +} + +export const load: PageServerLoad = async (event) => { + const { hackathon } = await event.parent() + const { team } = requireGrpc(event.locals.grpc) + const platformUserId = event.locals.platformUser?.id + + const { teams } = await team.list({ hackathonId: event.params.id }) + + const projectTitles = new Map(hackathon.projects.map((p) => [p.id, p.title])) + + // EVERY team's submissions, not only the viewer's own. + // + // A pinned policy: members read all submissions hackathon-wide. The whole + // room watching what the room built is most of what this page is for on the + // final day, and the vote is cast on exactly these. Narrowing it to your own + // teams — which is how it arrived in the design swap — leaves a participant + // on one team seeing one card. + // + // What stays scoped is WRITING: `isMine` gates the controls, and the backend + // enforces it with a team-scoped casbin domain regardless. + const groups = await Promise.all( + teams.map(async (t) => { + // ListSubmissions rather than the submissions nested in `team.list`: the + // nested ones carry no ordering guarantee, and "which version counts" + // depends entirely on order. + // + // One team denying must cost that team's card, not the page: a policy + // change could make a single team unreadable. + const { submissions } = await team + .listSubmissions({ teamId: t.id }) + .catch(() => ({ submissions: [] })) + + const byVersion = [...submissions].sort((a, b) => a.version - b.version) + const views = byVersion.map((s) => ({ + id: s.id, + version: s.version, + status: s.status, + result: s.result, + createdAt: s.createdAt, + modifiedAt: s.modifiedAt, + })) + + return { + teamId: t.id, + teamName: t.name, + projectId: t.projectId, + projectTitle: projectTitles.get(t.projectId) ?? "Unknown project", + isMine: t.members.some((m) => m.id === platformUserId), + // Highest version is the one that counts; null when the team has none. + latest: views.length > 0 ? views[views.length - 1]! : null, + // Superseded versions, newest first. + earlier: views.slice(0, -1).reverse(), + } + }), + ) + + return { + groups, + // What the organiser asks a submission to carry. Absent means "anything + // goes" — the backend reads an unset schema the same way. + submissionFields: hackathon.submissionForm?.fields ?? [], + submissionsGate: gateFor(hackathon.capabilities, CAP_SUBMISSIONS), + } +} + +export const actions: Actions = { + create: async (event) => { + const { team } = requireGrpc(event.locals.grpc) + const form = await event.request.formData() + + const teamId = String(form.get("teamId") ?? "") + const projectId = String(form.get("projectId") ?? "") + if (!teamId || !projectId) + return fail(400, { + message: "A submission needs a team and its project.", + }) + + try { + await team.createSubmission({ + teamId, + projectId, + result: String(form.get("result") ?? "").trim(), + form: answerMap(form), + }) + } catch (e) { + return formError(e) + } + + return { created: teamId } + }, + + edit: async (event) => { + const { team } = requireGrpc(event.locals.grpc) + const form = await event.request.formData() + + const submissionId = String(form.get("submissionId") ?? "") + if (!submissionId) return fail(400, { message: "Missing submission." }) + + try { + // Only `result` is editable; the structured answers are fixed at create, + // and a new version is how you change them. + await team.editSubmission({ + submissionId, + result: String(form.get("result") ?? ""), + }) + } catch (e) { + return formError(e) + } + + return { edited: submissionId } + }, + + finalize: async (event) => { + const { team } = requireGrpc(event.locals.grpc) + const form = await event.request.formData() + + const submissionId = String(form.get("submissionId") ?? "") + if (!submissionId) return fail(400, { message: "Missing submission." }) + + try { + await team.finalizeSubmission({ submissionId }) + } catch (e) { + return formError(e) + } + + return { finalized: submissionId } + }, +} diff --git a/components/frontend/src/routes/(app)/my/hackathon/[id]/submissions/+page.svelte b/components/frontend/src/routes/(app)/my/hackathon/[id]/submissions/+page.svelte index 4266acf1..a16c1d92 100644 --- a/components/frontend/src/routes/(app)/my/hackathon/[id]/submissions/+page.svelte +++ b/components/frontend/src/routes/(app)/my/hackathon/[id]/submissions/+page.svelte @@ -1,5 +1,321 @@ - + +
    +
    +

    Submissions

    + Your team's submitted work +
    + + {#if form?.message} + + {/if} + + {#if gateNote} +

    {gateNote}

    + {/if} + + {#if data.groups.length === 0} +

    + No teams have been formed yet, so there is nothing to submit. +

    + {:else} + {#each data.groups as group (group.teamId)} +
    +
    +
    +

    + {group.teamName} +

    + + {#if group.isMine} + Your team + {/if} +
    + {group.projectTitle} +
    + + {#if !group.latest} +

    + No submission yet. +

    + {#if gate.open && group.isMine} +
    + +
    + {/if} + {:else} +
    +
    + + Version {group.latest.version} + + + {submissionStatusLabel(group.latest.status) ?? 'Unknown'} + + + {formatDate(group.latest.modifiedAt ?? group.latest.createdAt)} + +
    + {#if group.latest.result} +

    + {group.latest.result} +

    + {/if} + + {#if group.latest.status !== FINAL && gate.open && group.isMine} + +
    + + +
    + + + {#if confirmingFinalize !== group.latest.id} + + {/if} +
    +
    + + {#if confirmingFinalize === group.latest.id} + +
    + +

    + Finalising freezes this version — it is what the vote + sees, and it cannot be edited afterwards. +

    +
    + + +
    +
    + {/if} + {/if} +
    + + {#if group.earlier.length > 0} +
    + + {group.earlier.length === 1 + ? '1 earlier version' + : `${group.earlier.length} earlier versions`} + +
      + {#each group.earlier as submission (submission.id)} +
    • + + Version {submission.version} + + + {submissionStatusLabel(submission.status) ?? 'Unknown'} + + + {formatDate(submission.modifiedAt ?? submission.createdAt)} + +
    • + {/each} +
    +
    + {/if} + {/if} + + {#if composing === group.teamId && group.isMine} + +
    + async ({ update }) => { + await update(); + composing = null; + }} + class="mt-3 flex flex-col gap-3 border-t border-line pt-3" + > + + + +

    + {group.latest ? `Version ${group.latest.version + 1}` : 'Your submission'} +

    + + + + {#if data.submissionFields.length > 0} + + {#each data.submissionFields as field (field.key)} + + {/each} + {/if} + +
    + + +
    +
    + {/if} +
    + {/each} + {/if} +
    diff --git a/components/frontend/src/routes/(app)/my/hackathon/[id]/teams/+page.server.ts b/components/frontend/src/routes/(app)/my/hackathon/[id]/teams/+page.server.ts new file mode 100644 index 00000000..11fb7fa3 --- /dev/null +++ b/components/frontend/src/routes/(app)/my/hackathon/[id]/teams/+page.server.ts @@ -0,0 +1,49 @@ +import type { PageServerLoad } from "./$types" +import { requireGrpc } from "$lib/server/grpc/client" + +// No owner/admin check here: the way into team management is the sidebar's +// Manage section, which gates itself on the same subjects (see $lib/navigation's +// manageNav). This page is the participant view and reads the same for everyone. +export const load: PageServerLoad = async (event) => { + const { hackathon } = await event.parent() + const { team } = requireGrpc(event.locals.grpc) + const platformUserId = event.locals.platformUser?.id + + // Teams are the one collection `hackathon.get` does not nest, so this page + // needs its own call. No error translation here: `TeamService.List` gates on + // the same Hackathon/Read permission the layout's `hackathon.get` already + // passed, so a denial at this point is a backend inconsistency and should + // surface rather than be dressed up as a 403. + const { teams } = await team.list({ hackathonId: event.params.id }) + + const projectsById = new Map(hackathon.projects.map((p) => [p.id, p])) + + // Newest first, matching the projects page. + const ordered = [...teams].sort( + (a, b) => (b.createdAt?.getTime() ?? 0) - (a.createdAt?.getTime() ?? 0), + ) + + const rows = ordered.map((t, i) => { + const project = projectsById.get(t.projectId) + + return { + id: t.id, + // TODO(backend: display-ordinals): positional, not an identifier — Team + // has no display number. See the same note on the projects page. + num: ordered.length - i, + title: t.name, + // Which project the team is on is the useful line here; the team's own + // description is the fallback for a team whose project went missing. + projectDescription: project?.title ?? t.description ?? "", + // TODO(backend: team-image): Team has no image of its own, so this is the + // project's. A team without one falls back to the card's empty avatar — + // the four /images/hackathon-ord-2024/* files this page used to cycle + // through were decorative filler, unrelated to any team. + imageUrl: project?.image, + members: t.members.map((m) => ({ name: m.displayName || m.username })), + isOwn: t.members.some((m) => m.id === platformUserId), + } + }) + + return { teams: rows, hackathonId: event.params.id } +} diff --git a/components/frontend/src/routes/(app)/my/hackathon/[id]/teams/+page.svelte b/components/frontend/src/routes/(app)/my/hackathon/[id]/teams/+page.svelte index 9ed4ef32..2c420789 100644 --- a/components/frontend/src/routes/(app)/my/hackathon/[id]/teams/+page.svelte +++ b/components/frontend/src/routes/(app)/my/hackathon/[id]/teams/+page.svelte @@ -1,117 +1,14 @@
    -

    Teams

    - {countLabel} +

    Teams

    + {countLabel}
    - {#if filtered.length === 0} -

    + {#if data.teams.length === 0} +

    + No teams have been formed yet. +

    + {:else if filtered.length === 0} +

    No teams match your search.

    {:else} - {#each pagedTeams as team (team.num)} + {#each pagedTeams as team (team.id)} + {/each} {/if} @@ -208,9 +106,8 @@ + {/if} +
    +{/snippet} + +
    +
    +
    +

    Manage Teams

    +

    + Drag a participant onto a team to assign them. Everyone belongs to at most one team. +

    +
    + + Back to Teams + +
    + + {#if form?.message} + + {/if} + + +
    +
    +

    Import team composition

    + + +
    + +

    + A CSV or JSON file with the columns user_email, project + and team. The template is this event's own roster, already filled in. + Anyone the file leaves out keeps the team they are on; a row whose project and team + are both empty takes that person off theirs. A team the file names but this event + does not have yet is created. +

    + +
    + + +
    + + {#if form?.importError} + + {/if} + + {#if importResult} + + {#if importResult.failures.length === 0} +

    + Applied {importResult.applied} of {importResult.planned} changes from + {importResult.filename}{importResult.created > 0 + ? `, creating ${importResult.created} team${importResult.created === 1 ? '' : 's'}` + : ''}. +

    + {:else} + + {/if} + {/if} + + {#if preview} + {@const counts = preview.plan.counts} +
    + {#if counts.errors > 0} + + {:else} + +

    + {preview.filename}: {counts.total === 1 + ? '1 row' + : `${counts.total} rows`} — {planSummary(counts)}. Nothing has been + changed yet. +

    + {/if} + +
    + + + + + + + + + + + + + {#each preview.plan.rows as r (r.row)} + + + + + + + + {/each} + +
    Import preview
    RowParticipantProjectTeamOutcome
    {r.row} + {r.name || r.email} + {#if r.name} + {r.email} + {/if} + {r.project || '—'}{r.team || '—'} + + {STATUS_LABEL[r.status]} + + {r.detail} +
    +
    + + {#if counts.errors === 0 && counts.changes > 0} +
    { + pending = true; + return async ({ update }) => { + await update(); + pending = false; + }; + }} + > + + + + +
    + {:else if counts.errors === 0} +

    + This file matches the current teams exactly — there is nothing to apply. +

    + {/if} +
    + {/if} +
    + + +
    +
    + +
    +

    + Projects +

    + {#if projectRows.length === 0} +

    No projects have been approved yet.

    + {:else} +
    + {#each projectRows as p (p.id)} +
    +
    + + {p.title} + + {#if !p.isApproved} + + (Proposed) + + {/if} +
    + +
    + {#each p.teams as t (t.id)} + +
    dragOver(e, t.id)} + ondragleave={() => dragLeave(t.id)} + ondrop={(e) => drop(e, t.id)} + class="card flex w-56 flex-col" + class:border-accent={dropTarget === t.id} + > +
    + {#if editingTeamId === t.id} +
    { + return async ({ result, update }) => { + if (result.type !== 'failure') { + editingTeamId = null; + } + await update(); + }; + }} + > + + { + if (e.key === 'Escape') cancelEdit(); + }} + class="field h-6 min-w-0 flex-1 px-1" + /> + + +
    + {:else} + + {t.name} + + + + {/if} +
    +
    + {#if t.members.length === 0} +

    + Drop a participant here. +

    + {:else} + {#each t.members as member (member.id)} + {@render personChip(member, t.id)} + {/each} + {/if} +
    +
    + {/each} + +
    + + +
    +
    +
    + {/each} +
    + {/if} +
    + + +
    dragOver(e, POOL)} + ondragleave={() => dragLeave(POOL)} + ondrop={(e) => drop(e, POOL)} + class="card card-raised flex flex-col gap-3 p-3" + class:border-accent={dropTarget === POOL} + > +

    + Unassigned ({unassigned.length}) +

    + {#if unassigned.length === 0} +

    + Every confirmed participant is on a team. +

    + {:else} +
    + {#each unassigned as person (person.id)} + {@render personChip(person, POOL)} + {/each} +
    + {/if} +
    +
    + + + +
    +
    diff --git a/components/frontend/src/routes/(app)/my/hackathon/[id]/teams/manage/template/[format]/+server.ts b/components/frontend/src/routes/(app)/my/hackathon/[id]/teams/manage/template/[format]/+server.ts new file mode 100644 index 00000000..d738fb48 --- /dev/null +++ b/components/frontend/src/routes/(app)/my/hackathon/[id]/teams/manage/template/[format]/+server.ts @@ -0,0 +1,47 @@ +import type { RequestHandler } from "./$types" +import { requireGrpc } from "$lib/server/grpc/client" +import { buildTemplate } from "$lib/server/hackathon/teamImport" +import { importWorld } from "$lib/server/hackathon/teamImportWorld" +import { error } from "@sveltejs/kit" +import { ClientError, Status } from "nice-grpc-common" + +/** + * The team-composition template: `user_email, project, team`, prefilled with + * this event's own roster. `…/template/csv` or `…/template/json`. + * + * The format is a path segment rather than `?format=`, so both links are plain + * resolvable routes (`svelte/no-navigation-without-resolve` cannot see through a + * query string appended to a `resolve()` call). + * + * The organiser gate is a real backend check, not a page-level one: `importWorld` + * calls `ExportPreferences`, which the backend guards with `Project:Write` — the + * same permission the manage-teams page is already gated on. Without it any + * confirmed member could pull the whole event's email addresses down as a file. + */ +export const GET: RequestHandler = async (event) => { + const format = event.params.format + if (format !== "csv" && format !== "json") { + error(404, "Unknown template format") + } + + let world + try { + world = await importWorld(requireGrpc(event.locals.grpc), event.params.id) + } catch (e) { + if (e instanceof ClientError && e.code === Status.PERMISSION_DENIED) + error(403, "Only this event's organizers can download the team template") + if (e instanceof ClientError && e.code === Status.NOT_FOUND) + error(404, "Hackathon not found") + throw e + } + + return new Response(buildTemplate(world, format), { + headers: { + "content-type": + format === "json" + ? "application/json; charset=utf-8" + : "text/csv; charset=utf-8", + "content-disposition": `attachment; filename="teams-${event.params.id}.${format}"`, + }, + }) +} diff --git a/components/frontend/src/routes/(app)/my/hackathon/[id]/timeline/+page.server.ts b/components/frontend/src/routes/(app)/my/hackathon/[id]/timeline/+page.server.ts new file mode 100644 index 00000000..18993042 --- /dev/null +++ b/components/frontend/src/routes/(app)/my/hackathon/[id]/timeline/+page.server.ts @@ -0,0 +1,78 @@ +import type { Actions, PageServerLoad } from "./$types" +import { + extraEnabledCapabilities, + resolvePhaseStatus, + sortPhasesByStart, +} from "$lib/utils/phase" +import { GlobalRole } from "$lib/server/grpc/generated/user/entities/global_role" +import { mayManagePhases } from "$lib/server/hackathon/capabilities" +import { + enabledCapabilities, + phaseCapabilities, +} from "$lib/server/hackathon/phaseForm" +import { setCurrentPhase } from "$lib/server/hackathon/stateActions" + +export const load: PageServerLoad = async (event) => { + // No RPC of its own: the layout's `hackathon.get` already returns the phases + // and the state. + const { hackathon, myMembership } = await event.parent() + + const isAdmin = (event.locals.platformUser?.roles ?? []).includes( + GlobalRole.GLOBAL_ROLE_ADMIN, + ) + const mayManage = mayManagePhases(myMembership ?? undefined, isAdmin) + + // Empty string rather than undefined when nothing is declared — `state` itself + // is absent on a hackathon with no state row, which no longer happens for + // seeded or app-created ones but is still the shape the proto allows. + const currentPhaseId = hackathon.currentPhaseId ?? "" + const enabled = enabledCapabilities(hackathon.capabilities) + + // Same ordering the header bar uses, so the two never disagree about the + // sequence a participant is looking at. + const phases = sortPhasesByStart(hackathon.phases).map((p) => ({ + id: p.id, + name: p.name, + description: p.description, + startsAt: p.startsAt, + endsAt: p.endsAt, + // A declared current phase wins over the dates — see `resolvePhaseStatus`. + status: resolvePhaseStatus(p, currentPhaseId || undefined), + // Raw enum numbers — `capabilityLabel` in `$lib/utils/phase` is keyed by + // them, so the page needs no server-only import to render them. + capabilities: phaseCapabilities(hackathon.capabilities, p.id), + // The page a phase links to. `hackathon.get` nests the pages, so the link + // needs no lookup of its own; only phases with a page get one. + pageId: p.pageId ?? "", + })) + + const current = phases.find((p) => p.id === currentPhaseId) + + return { + hackathonId: hackathon.id, + phases, + mayManage, + // The switches themselves, the "no state row" case and the plan-vs-reality + // warning have all moved to Manage Hackathon along with the panel that + // rendered them. What is left here is per-phase and read-only. + // + // Lets the page tick off which of the current phase's plans are actually + // live. Organizer-only, so a participant's tags stay plain — and + // deliberately used for the current phase alone: marking a future phase's + // plan "not enabled" would read as broken when it is simply not time yet. + enabled: mayManage ? enabled : [], + // Switched on beyond what this phase planned for. Information, not a problem. + alsoEnabled: mayManage + ? extraEnabledCapabilities(current?.capabilities ?? [], enabled) + : [], + } +} + +// Only the phase pointer is written from here now. The two capability actions +// went with the panel to Manage Hackathon; all three live in +// `$lib/server/hackathon/stateActions` because an action can only be reached +// from the route that declares it, and this one still has "Make current" and +// "Clear current phase" on every row. +export const actions: Actions = { + setCurrent: (event) => setCurrentPhase(event, event.params.id), +} diff --git a/components/frontend/src/routes/(app)/my/hackathon/[id]/timeline/+page.svelte b/components/frontend/src/routes/(app)/my/hackathon/[id]/timeline/+page.svelte index c669be95..73fec318 100644 --- a/components/frontend/src/routes/(app)/my/hackathon/[id]/timeline/+page.svelte +++ b/components/frontend/src/routes/(app)/my/hackathon/[id]/timeline/+page.svelte @@ -1,5 +1,263 @@ - + +
    +

    Timeline

    + + + {#if data.mayManage} + + + {/if} + + +
    +
    +

    Phases

    + + {data.phases.length === 1 ? '1 phase' : `${data.phases.length} phases`} + +
    + {#if data.mayManage} + + + {/if} +
    + + + {#if form?.message} + + {/if} + + {#if data.phases.length === 0} +

    + {#if data.mayManage} + No phases yet. Add one to give participants a timeline to follow. + {:else} + No phases have been defined for this hackathon yet. + {/if} +

    + {:else} +
      + {#each data.phases as phase (phase.id)} +
    1. +
      +
      + +

      + {phase.name} +

      + + {#if phase.status === 'completed'} + + {#if data.mayManage} + + + {/if} +
      + + {formatRange(phase.startsAt, phase.endsAt)} + + {#if phase.description} +

      + {phase.description} +

      + {/if} + + + {#if phase.capabilities.length > 0} +
      + + Planned for this phase: + + {#each phase.capabilities as capability (capability)} + {@const live = + phase.status === 'current' && + data.enabled.includes(capability)} + {@const pending = + phase.status === 'current' && + !data.enabled.includes(capability)} + + + {#if live} + + {/each} +
      + {/if} + + + {#if phase.status === 'current' && data.alsoEnabled.length > 0} +
      + + Also enabled: + + {#each data.alsoEnabled as capability (capability)} + + + {/each} +
      + {/if} + + {#if phase.pageId || data.mayManage} +
      + {#if phase.pageId} + + + {/if} + + {#if data.mayManage} + {#if phase.status === 'current'} +
      + +
      + {:else} +
      + + +
      + {/if} + {/if} +
      + {/if} +
      +
    2. + {/each} +
    + {/if} +
    diff --git a/components/frontend/src/routes/(app)/my/hackathon/[id]/timeline/[phaseId]/edit/+page.server.ts b/components/frontend/src/routes/(app)/my/hackathon/[id]/timeline/[phaseId]/edit/+page.server.ts new file mode 100644 index 00000000..e77ad5e6 --- /dev/null +++ b/components/frontend/src/routes/(app)/my/hackathon/[id]/timeline/[phaseId]/edit/+page.server.ts @@ -0,0 +1,148 @@ +import type { Actions, PageServerLoad } from "./$types" +import { requireGrpc } from "$lib/server/grpc/client" +import { GlobalRole } from "$lib/server/grpc/generated/user/entities/global_role" +import { mayManagePhases } from "$lib/server/hackathon/capabilities" +import { + parsePhaseForm, + phaseCapabilities, + syncPhaseCapabilities, +} from "$lib/server/hackathon/phaseForm" +import { resolve } from "$app/paths" +import { error, fail, redirect } from "@sveltejs/kit" +import { ClientError, Status } from "nice-grpc-common" + +export const load: PageServerLoad = async (event) => { + const { hackathon, myMembership } = await event.parent() + const { phase } = requireGrpc(event.locals.grpc) + + const isAdmin = (event.locals.platformUser?.roles ?? []).includes( + GlobalRole.GLOBAL_ROLE_ADMIN, + ) + if (!mayManagePhases(myMembership ?? undefined, isAdmin)) { + error(403, "Only the hackathon organizer can edit phases") + } + + // Fetched rather than picked out of the layout's `hackathon.get`, even though + // that response nests the phases: after a save this page reloads, and the + // layout's copy can still be the pre-edit tree. Asking PhaseService means the + // form always shows what was actually stored. + let result + try { + result = await phase.get({ phaseId: event.params.phaseId }) + } catch (e) { + if (e instanceof ClientError && e.code === Status.PERMISSION_DENIED) { + error(403, "This phase is not available") + } + if (e instanceof ClientError && e.code === Status.NOT_FOUND) { + error(404, "Phase not found") + } + throw e + } + + if (!result.phase) { + error(404, "Phase not found") + } + + // A phase id from another hackathon would otherwise render inside this + // hackathon's shell, under its nav and header — and `Edit` would then happily + // write to it, since it takes the hackathon from the phase rather than the URL. + if (result.phase.hackathonId !== event.params.id) { + error(404, "Phase not found") + } + + return { + hackathonId: hackathon.id, + phase: { + id: result.phase.id, + name: result.phase.name, + description: result.phase.description ?? "", + startsAt: result.phase.startsAt, + endsAt: result.phase.endsAt, + pageId: result.phase.pageId ?? "", + // Raw enum numbers — what the form's checkboxes carry, and what + // `capabilityLabel` in `$lib/utils/phase` is keyed by. + capabilities: phaseCapabilities(hackathon.capabilities, result.phase.id), + }, + pages: hackathon.pages.map((p) => ({ id: p.id, title: p.title })), + } +} + +export const actions: Actions = { + save: async (event) => { + const { phase, hackathon } = requireGrpc(event.locals.grpc) + + const parsed = parsePhaseForm(await event.request.formData()) + if (!parsed.ok) { + return fail(400, { message: parsed.message }) + } + const values = parsed.values + + try { + await phase.edit({ + phaseId: event.params.phaseId, + name: values.name, + description: values.description, + // TODO(backend: phase-edit-clear-dates): `Edit` tests + // `req.GetStartsAt() != nil` rather than presence + // (`phase_service.go:284-291`), so it never calls `ClearStartsAt`. Once a + // phase has dates there is no request that takes them off again — + // sending nothing reads as "no change", not "unset". The form says so, + // and an emptied date field silently keeps the old value. Send the + // cleared state here once the handler can accept it. + startsAt: values.startsAt, + endsAt: values.endsAt, + // Empty string is meaningful on Edit and unlinks the page — unlike + // Create, where it would fail the UUID rule. + pageId: values.pageId, + }) + + // Then the capability links, which live on the capability rather than on + // the phase — see syncPhaseCapabilities. + const { hackathon: current } = await hackathon.get({ + hackathonId: event.params.id, + }) + await syncPhaseCapabilities( + hackathon, + event.params.id, + event.params.phaseId, + values.capabilities, + current?.capabilities, + ) + } catch (e) { + if (e instanceof ClientError && e.code === Status.INVALID_ARGUMENT) { + return fail(400, { message: e.details }) + } + if (e instanceof ClientError && e.code === Status.PERMISSION_DENIED) { + return fail(403, { + message: "You don't have permission to edit this phase", + }) + } + if (e instanceof ClientError && e.code === Status.NOT_FOUND) { + return fail(404, { message: e.details }) + } + throw e + } + + redirect(303, resolve(`/my/hackathon/${event.params.id}/timeline`)) + }, + + delete: async (event) => { + const { phase } = requireGrpc(event.locals.grpc) + + try { + await phase.delete({ phaseId: event.params.phaseId }) + } catch (e) { + if (e instanceof ClientError && e.code === Status.PERMISSION_DENIED) { + return fail(403, { + message: "You don't have permission to delete this phase", + }) + } + if (e instanceof ClientError && e.code === Status.NOT_FOUND) { + return fail(404, { message: "Phase not found" }) + } + throw e + } + + redirect(303, resolve(`/my/hackathon/${event.params.id}/timeline`)) + }, +} diff --git a/components/frontend/src/routes/(app)/my/hackathon/[id]/timeline/[phaseId]/edit/+page.svelte b/components/frontend/src/routes/(app)/my/hackathon/[id]/timeline/[phaseId]/edit/+page.svelte new file mode 100644 index 00000000..2eec2fda --- /dev/null +++ b/components/frontend/src/routes/(app)/my/hackathon/[id]/timeline/[phaseId]/edit/+page.svelte @@ -0,0 +1,74 @@ + + +
    +
    + + ← Back to timeline + +

    Edit Phase

    +

    + Changes are visible to participants immediately. +

    +
    + + + +
    +

    Delete this phase

    + {#if confirming} +

    + Deleting {data.phase.name} cannot + be undone. Any page linked to it stays, only the phase goes. +

    +
    + + +
    + {:else} +

    + Removes the phase from the timeline for everyone. +

    + + {/if} +
    +
    diff --git a/components/frontend/src/routes/(app)/my/hackathon/[id]/timeline/new/+page.server.ts b/components/frontend/src/routes/(app)/my/hackathon/[id]/timeline/new/+page.server.ts new file mode 100644 index 00000000..aea9f98b --- /dev/null +++ b/components/frontend/src/routes/(app)/my/hackathon/[id]/timeline/new/+page.server.ts @@ -0,0 +1,94 @@ +import type { Actions, PageServerLoad } from "./$types" +import { requireGrpc } from "$lib/server/grpc/client" +import { GlobalRole } from "$lib/server/grpc/generated/user/entities/global_role" +import { mayManagePhases } from "$lib/server/hackathon/capabilities" +import { + parsePhaseForm, + syncPhaseCapabilities, +} from "$lib/server/hackathon/phaseForm" +import { resolve } from "$app/paths" +import { error, fail, redirect } from "@sveltejs/kit" +import { ClientError, Status } from "nice-grpc-common" + +export const load: PageServerLoad = async (event) => { + // No RPC of its own: the layout's `hackathon.get` already returns the pages a + // phase can link to. + const { hackathon, myMembership } = await event.parent() + + const isAdmin = (event.locals.platformUser?.roles ?? []).includes( + GlobalRole.GLOBAL_ROLE_ADMIN, + ) + if (!mayManagePhases(myMembership ?? undefined, isAdmin)) { + error(403, "Only the hackathon organizer can add phases") + } + + return { + hackathonId: hackathon.id, + pages: hackathon.pages.map((p) => ({ id: p.id, title: p.title })), + } +} + +export const actions: Actions = { + save: async (event) => { + const { phase, hackathon } = requireGrpc(event.locals.grpc) + + const parsed = parsePhaseForm(await event.request.formData()) + if (!parsed.ok) { + return fail(400, { message: parsed.message }) + } + const values = parsed.values + + try { + const created = await phase.create({ + hackathonId: event.params.id, + name: values.name, + description: values.description, + // TODO(backend: phase-create-drops-dates): dates are deliberately not + // sent, and the form does not offer them. `CreateRequest` accepts + // `starts_at`/`ends_at` and buf.validate checks that they agree, but the + // handler's builder never calls `SetStartsAt`/`SetEndsAt` + // (`phase_service.go:181-188`) — so they are accepted, reported as + // created, and discarded. Confirmed live: create with dates, then Get + // returns null for both. `Edit` sets them correctly, which is why + // scheduling happens there. Send them here, and restore the fields in + // `PhaseForm` via `datesEditable`, once Create stores them. + // + // Sending nothing for pageId means "no linked page" — `page_id` is + // optional and its CEL rule only checks the shape of a value that is + // present. + pageId: values.pageId !== "" ? values.pageId : undefined, + }) + + // Capabilities are linked after the phase exists: in our model the link + // lives on the capability, so it needs the new phase's id. + if (created.phaseId) { + const { hackathon: current } = await hackathon.get({ + hackathonId: event.params.id, + }) + await syncPhaseCapabilities( + hackathon, + event.params.id, + created.phaseId, + values.capabilities, + current?.capabilities, + ) + } + } catch (e) { + if (e instanceof ClientError && e.code === Status.INVALID_ARGUMENT) { + return fail(400, { message: e.details }) + } + if (e instanceof ClientError && e.code === Status.PERMISSION_DENIED) { + return fail(403, { + message: "You don't have permission to add phases here", + }) + } + // A page id from another hackathon, or one that has since been deleted. + if (e instanceof ClientError && e.code === Status.NOT_FOUND) { + return fail(404, { message: e.details }) + } + throw e + } + + redirect(303, resolve(`/my/hackathon/${event.params.id}/timeline`)) + }, +} diff --git a/components/frontend/src/routes/(app)/my/hackathon/[id]/timeline/new/+page.svelte b/components/frontend/src/routes/(app)/my/hackathon/[id]/timeline/new/+page.svelte new file mode 100644 index 00000000..bd5984c2 --- /dev/null +++ b/components/frontend/src/routes/(app)/my/hackathon/[id]/timeline/new/+page.svelte @@ -0,0 +1,47 @@ + + +
    +
    + + ← Back to timeline + +

    Add Phase

    +

    + Participants see the phase as soon as it is saved. Undated phases sort to the + top of the timeline until they are scheduled. +

    +
    + + +
    diff --git a/components/frontend/src/routes/(app)/my/hackathon/[id]/tracks/+page.server.ts b/components/frontend/src/routes/(app)/my/hackathon/[id]/tracks/+page.server.ts new file mode 100644 index 00000000..e5256de7 --- /dev/null +++ b/components/frontend/src/routes/(app)/my/hackathon/[id]/tracks/+page.server.ts @@ -0,0 +1,26 @@ +import type { PageServerLoad } from "./$types" +import { GlobalRole } from "$lib/server/grpc/generated/user/entities/global_role" +import { mayManageTracks } from "$lib/server/hackathon/capabilities" +import { error } from "@sveltejs/kit" + +export const load: PageServerLoad = async (event) => { + // No RPC of its own: the layout's `hackathon.get` already returns the + // tracks, same source the propose form and the overview counts use. + const { hackathon, myMembership } = await event.parent() + + const isAdmin = (event.locals.platformUser?.roles ?? []).includes( + GlobalRole.GLOBAL_ROLE_ADMIN, + ) + if (!mayManageTracks(myMembership ?? undefined, isAdmin)) { + error(403, "Only the hackathon organizer can manage tracks") + } + + return { + hackathonId: hackathon.id, + tracks: hackathon.tracks.map((t) => ({ + id: t.id, + name: t.name, + description: t.description, + })), + } +} diff --git a/components/frontend/src/routes/(app)/my/hackathon/[id]/tracks/+page.svelte b/components/frontend/src/routes/(app)/my/hackathon/[id]/tracks/+page.svelte new file mode 100644 index 00000000..4843c885 --- /dev/null +++ b/components/frontend/src/routes/(app)/my/hackathon/[id]/tracks/+page.svelte @@ -0,0 +1,66 @@ + + + +
    +
    +
    +

    Manage Tracks

    + + {data.tracks.length === 1 ? '1 track' : `${data.tracks.length} tracks`} + +
    + + +
    + +

    + Tracks are optional. When a hackathon has none, participants propose and browse + projects with no track picker at all. +

    + + {#if data.tracks.length === 0} +

    + No tracks yet. Add one to let participants sort their projects into it. +

    + {:else} +
      + {#each data.tracks as track (track.id)} +
    1. +
      +
      +

      + {track.name} +

      + + +
      + {#if track.description} +

      + {track.description} +

      + {/if} +
      +
    2. + {/each} +
    + {/if} +
    diff --git a/components/frontend/src/routes/(app)/my/hackathon/[id]/tracks/[trackId]/edit/+page.server.ts b/components/frontend/src/routes/(app)/my/hackathon/[id]/tracks/[trackId]/edit/+page.server.ts new file mode 100644 index 00000000..036ea26b --- /dev/null +++ b/components/frontend/src/routes/(app)/my/hackathon/[id]/tracks/[trackId]/edit/+page.server.ts @@ -0,0 +1,119 @@ +import type { Actions, PageServerLoad } from "./$types" +import { requireGrpc } from "$lib/server/grpc/client" +import { GlobalRole } from "$lib/server/grpc/generated/user/entities/global_role" +import { mayManageTracks } from "$lib/server/hackathon/capabilities" +import { resolve } from "$app/paths" +import { error, fail, redirect } from "@sveltejs/kit" +import { ClientError, Status } from "nice-grpc-common" + +export const load: PageServerLoad = async (event) => { + const { hackathon, myMembership } = await event.parent() + const { track } = requireGrpc(event.locals.grpc) + + const isAdmin = (event.locals.platformUser?.roles ?? []).includes( + GlobalRole.GLOBAL_ROLE_ADMIN, + ) + if (!mayManageTracks(myMembership ?? undefined, isAdmin)) { + error(403, "Only the hackathon organizer can edit tracks") + } + + // Fetched rather than picked out of the layout's `hackathon.get`, even + // though that response nests the tracks: after a save this page reloads, + // and the layout's copy can still be the pre-edit tree. Asking + // TrackService means the form always shows what was actually stored. + let result + try { + result = await track.get({ trackId: event.params.trackId }) + } catch (e) { + if (e instanceof ClientError && e.code === Status.PERMISSION_DENIED) { + error(403, "This track is not available") + } + if (e instanceof ClientError && e.code === Status.NOT_FOUND) { + error(404, "Track not found") + } + throw e + } + + if (!result.track) { + error(404, "Track not found") + } + + // A track id from another hackathon would otherwise render inside this + // hackathon's shell, under its nav and header — and `Edit` would then + // happily write to it, since it takes the hackathon from the track rather + // than the URL. + if (result.track.hackathonId !== event.params.id) { + error(404, "Track not found") + } + + return { + hackathonId: hackathon.id, + track: { + id: result.track.id, + name: result.track.name, + description: result.track.description, + }, + } +} + +export const actions: Actions = { + save: async (event) => { + const { track } = requireGrpc(event.locals.grpc) + const form = await event.request.formData() + + const name = form.get("name") + const description = form.get("description") + + if (typeof name !== "string" || name.trim().length < 3) { + return fail(400, { message: "Name must be at least 3 characters" }) + } + if (typeof description !== "string" || description.trim().length < 3) { + return fail(400, { + message: "Description must be at least 3 characters", + }) + } + + try { + await track.edit({ + trackId: event.params.trackId, + name: name.trim(), + description: description.trim(), + }) + } catch (e) { + if (e instanceof ClientError && e.code === Status.INVALID_ARGUMENT) { + return fail(400, { message: e.details }) + } + if (e instanceof ClientError && e.code === Status.PERMISSION_DENIED) { + return fail(403, { + message: "You don't have permission to edit this track", + }) + } + if (e instanceof ClientError && e.code === Status.NOT_FOUND) { + return fail(404, { message: "Track not found" }) + } + throw e + } + + redirect(303, resolve(`/my/hackathon/${event.params.id}/tracks`)) + }, + + delete: async (event) => { + const { track } = requireGrpc(event.locals.grpc) + + try { + await track.delete({ trackId: event.params.trackId }) + } catch (e) { + if (e instanceof ClientError && e.code === Status.PERMISSION_DENIED) { + return fail(403, { + message: "You don't have permission to delete this track", + }) + } + if (e instanceof ClientError && e.code === Status.NOT_FOUND) { + return fail(404, { message: "Track not found" }) + } + throw e + } + + redirect(303, resolve(`/my/hackathon/${event.params.id}/tracks`)) + }, +} diff --git a/components/frontend/src/routes/(app)/my/hackathon/[id]/tracks/[trackId]/edit/+page.svelte b/components/frontend/src/routes/(app)/my/hackathon/[id]/tracks/[trackId]/edit/+page.svelte new file mode 100644 index 00000000..a396b04d --- /dev/null +++ b/components/frontend/src/routes/(app)/my/hackathon/[id]/tracks/[trackId]/edit/+page.svelte @@ -0,0 +1,73 @@ + + +
    +
    + + ← Back to tracks + +

    Edit Track

    +

    + Changes are visible to participants immediately. +

    +
    + + + +
    +

    Delete this track

    + {#if confirming} +

    + Deleting {data.track.name} cannot be + undone. Projects already in it keep no track, they aren't deleted. +

    +
    + + +
    + {:else} +

    + Removes the track for everyone. Projects already assigned to it fall + back to no track. +

    + + {/if} +
    +
    diff --git a/components/frontend/src/routes/(app)/my/hackathon/[id]/tracks/new/+page.server.ts b/components/frontend/src/routes/(app)/my/hackathon/[id]/tracks/new/+page.server.ts new file mode 100644 index 00000000..ed522b00 --- /dev/null +++ b/components/frontend/src/routes/(app)/my/hackathon/[id]/tracks/new/+page.server.ts @@ -0,0 +1,59 @@ +import type { Actions, PageServerLoad } from "./$types" +import { requireGrpc } from "$lib/server/grpc/client" +import { GlobalRole } from "$lib/server/grpc/generated/user/entities/global_role" +import { mayManageTracks } from "$lib/server/hackathon/capabilities" +import { resolve } from "$app/paths" +import { error, fail, redirect } from "@sveltejs/kit" +import { ClientError, Status } from "nice-grpc-common" + +export const load: PageServerLoad = async (event) => { + const { hackathon, myMembership } = await event.parent() + + const isAdmin = (event.locals.platformUser?.roles ?? []).includes( + GlobalRole.GLOBAL_ROLE_ADMIN, + ) + if (!mayManageTracks(myMembership ?? undefined, isAdmin)) { + error(403, "Only the hackathon organizer can add tracks") + } + + return { hackathonId: hackathon.id } +} + +export const actions: Actions = { + save: async (event) => { + const { track } = requireGrpc(event.locals.grpc) + const form = await event.request.formData() + + const name = form.get("name") + const description = form.get("description") + + if (typeof name !== "string" || name.trim().length < 3) { + return fail(400, { message: "Name must be at least 3 characters" }) + } + if (typeof description !== "string" || description.trim().length < 3) { + return fail(400, { + message: "Description must be at least 3 characters", + }) + } + + try { + await track.create({ + hackathonId: event.params.id, + name: name.trim(), + description: description.trim(), + }) + } catch (e) { + if (e instanceof ClientError && e.code === Status.INVALID_ARGUMENT) { + return fail(400, { message: e.details }) + } + if (e instanceof ClientError && e.code === Status.PERMISSION_DENIED) { + return fail(403, { + message: "You don't have permission to add tracks here", + }) + } + throw e + } + + redirect(303, resolve(`/my/hackathon/${event.params.id}/tracks`)) + }, +} diff --git a/components/frontend/src/routes/(app)/my/hackathon/[id]/tracks/new/+page.svelte b/components/frontend/src/routes/(app)/my/hackathon/[id]/tracks/new/+page.svelte new file mode 100644 index 00000000..18d15dd0 --- /dev/null +++ b/components/frontend/src/routes/(app)/my/hackathon/[id]/tracks/new/+page.svelte @@ -0,0 +1,37 @@ + + +
    +
    + + ← Back to tracks + +

    New Track

    +

    + Participants can pick it when proposing or editing a project. +

    +
    + + +
    diff --git a/components/frontend/src/routes/(app)/my/hackathon/[id]/voting/+page.server.ts b/components/frontend/src/routes/(app)/my/hackathon/[id]/voting/+page.server.ts new file mode 100644 index 00000000..8b2c2981 --- /dev/null +++ b/components/frontend/src/routes/(app)/my/hackathon/[id]/voting/+page.server.ts @@ -0,0 +1,705 @@ +import type { Actions, PageServerLoad } from "./$types" +import type { ActionFailure, Cookies } from "@sveltejs/kit" +import { requireGrpc } from "$lib/server/grpc/client" +import { fail } from "@sveltejs/kit" +import { ClientError, Status } from "nice-grpc-common" + +// The voting phase end to end: organizers shape the categories and publish the +// placements, participants cast one ballot per category, everyone reads the +// results. +// +// Every rule below is enforced in vote_service.go — who may vote, whether +// voting is open, and the one-ballot-per-(category, voter) unique index. This +// route only asks and translates the answer; it never decides. + +/** VotingMethod: SINGLE_CHOICE=1, RANKED=2, POINTS=3 */ +const VOTING_METHOD_LABEL: Partial> = { + 1: "Single choice", + 2: "Ranked", + 3: "Points", +} +const METHOD_RANKED = 2 +const METHOD_POINTS = 3 + +/** VoterType: ALL_PARTICIPANTS=1, JURY=2 */ +const VOTER_TYPE_LABEL: Partial> = { + 1: "All participants", + 2: "Jury only", +} +const VOTER_TYPE_JURY = 2 + +/** SubmissionStatus: DRAFT=1, FINAL=2 */ +const SUBMISSION_STATUS_LABEL: Partial> = { + 1: "draft", + 2: "final", +} + +/** ExportFormat: CSV=1, JSON=2 */ +const EXPORT_CSV = 1 +const EXPORT_JSON = 2 + +/** HackathonRole: UNSPECIFIED=0, OWNER=1, MEMBER=2 */ +const HACKATHON_ROLE_OWNER = 1 + +/** Every action answers with this one shape, so `form?.x` stays typed. */ +type VotingForm = { + message?: string + /** Category whose ballot was just accepted. */ + castIn?: string + /** Category the caller had already voted in (ALREADY_EXISTS). */ + alreadyVotedIn?: string + /** A payload to copy or download, produced by the export actions. */ + exported?: { title: string; filename: string; text: string } + done?: string +} + +function ok(data: VotingForm): VotingForm { + return data +} + +function bad(status: number, data: VotingForm): ActionFailure { + return fail(status, data) +} + +/** Maps a gRPC failure onto a form error, rethrowing anything unexpected. */ +function formError(e: unknown): ActionFailure { + if (e instanceof ClientError) { + if (e.code === Status.PERMISSION_DENIED) + return bad(403, { + message: e.details || "You are not allowed to do that.", + }) + if (e.code === Status.UNAUTHENTICATED) + return bad(401, { message: "Please sign in again." }) + if (e.code === Status.NOT_FOUND) + return bad(404, { message: "That item no longer exists." }) + if (e.code === Status.ALREADY_EXISTS) + return bad(409, { message: "That already exists." }) + if (e.code === Status.FAILED_PRECONDITION) + return bad(409, { + message: e.details || "That isn't possible right now.", + }) + // The vote service answers bad input with InvalidArgument and never with + // Unimplemented, which the e2e capability probe reads as "the RPC does + // not exist" — so a 400 here really is bad input, not a missing feature. + if (e.code === Status.INVALID_ARGUMENT) + return bad(400, { message: e.details || "That input was rejected." }) + if (e.code === Status.UNIMPLEMENTED) + return bad(501, { + message: "This server does not run the voting service yet.", + }) + } + throw e +} + +// Only organizers may list ballots, so a voter cannot ask the server "what did +// I vote?" — the id handed back when the ballot was accepted is remembered here +// instead. The cookie is a lookup hint and never an authorization: GetVote is +// what actually returns the ballot, and its voter must match the reader. +const BALLOT_COOKIE = "hackagon_ballots" +const BALLOT_COOKIE_MAX = 40 + +function readBallots(cookies: Cookies): Record { + const raw = cookies.get(BALLOT_COOKIE) + if (!raw) return {} + try { + const parsed: unknown = JSON.parse(raw) + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) + return {} + + return parsed as Record + } catch { + return {} + } +} + +function rememberBallot(cookies: Cookies, categoryId: string, voteId: string) { + const entries = Object.entries(readBallots(cookies)).filter( + ([k]) => k !== categoryId, + ) + entries.push([categoryId, voteId]) + cookies.set( + BALLOT_COOKIE, + JSON.stringify(Object.fromEntries(entries.slice(-BALLOT_COOKIE_MAX))), + { + path: "/", + httpOnly: true, + sameSite: "lax", + maxAge: 60 * 60 * 24 * 90, + }, + ) +} + +/** Structural shapes, so this file does not depend on the generated types. */ +type Category = { + id: string + name: string + description: string + votingMethod: number + voterType: number + /** Points categories only; absent on the wire for the other methods. */ + maxPoints?: number | undefined + juryMembers: { id: string; displayName: string; username: string }[] +} +type VoteResult = { + id: string + submissionId: string + position: number + title?: string | undefined +} +type Ballot = { + id: string + categoryId: string + voterId: string + singleChoice?: { submissionId: string } | undefined +} + +/** Reads the points budget off a category form; undefined when left blank. */ +function maxPointsFrom(form: FormData): number | undefined { + const raw = Number(form.get("maxPoints") ?? 0) + + return Number.isInteger(raw) && raw > 0 ? raw : undefined +} + +function safeName(raw: string): string { + return ( + raw + .replace(/[^a-zA-Z0-9]+/g, "-") + .replace(/^-+|-+$/g, "") + .toLowerCase() || "export" + ) +} + +export const load: PageServerLoad = async (event) => { + const { vote, team } = requireGrpc(event.locals.grpc) + const { hackathon, myMembership } = await event.parent() + const hackathonId = event.params.id + const myUserId = event.locals.platformUser?.id ?? "" + + // The parent layout's Get only admits confirmed participants, hackathon + // owners and global admins — so a viewer who reached this page with no + // membership row at all is an admin looking in. + const isOrganizer = + !myMembership || myMembership.role === HACKATHON_ROLE_OWNER + + let categories: Category[] = [] + let serviceAvailable = true + try { + const res = await vote.listVoteCategories({ hackathonId }) + categories = res.voteCategories + } catch (e) { + if (e instanceof ClientError && e.code === Status.UNIMPLEMENTED) + serviceAvailable = false + else if ( + e instanceof ClientError && + (e.code === Status.PERMISSION_DENIED || e.code === Status.NOT_FOUND) + ) + categories = [] + else throw e + } + + // Ballots point at submissions, and there is no per-hackathon submission + // listing — teams carry them one team at a time. Members are allowed to read + // every team's submissions precisely so that they can vote on them. + const projectTitles = new Map(hackathon.projects.map((p) => [p.id, p.title])) + let submissions: { id: string; label: string; status: string }[] = [] + try { + const { teams } = await team.list({ hackathonId }) + const perTeam = await Promise.all( + teams.map(async (t) => { + const res = await team.listSubmissions({ teamId: t.id }) + + return res.submissions.map((s) => { + const project = projectTitles.get(s.projectId) ?? "" + + return { + id: s.id, + label: project + ? `${project} · ${t.name} · v${s.version}` + : `${t.name} · v${s.version}`, + status: SUBMISSION_STATUS_LABEL[s.status] ?? "unknown", + } + }) + }), + ) + submissions = perTeam.flat() + } catch (e) { + if ( + !( + e instanceof ClientError && + (e.code === Status.PERMISSION_DENIED || e.code === Status.NOT_FOUND) + ) + ) { + throw e + } + } + const submissionLabels = new Map(submissions.map((s) => [s.id, s.label])) + const labelFor = (id: string) => + submissionLabels.get(id) ?? "Unknown submission" + + const remembered = readBallots(event.cookies) + + async function resultsFor(categoryId: string): Promise { + try { + const res = await vote.listVoteResults({ categoryId }) + + return res.voteResults + } catch (e) { + if (e instanceof ClientError) return [] + throw e + } + } + + // ListVotes needs voter_id and submission_id to be UUIDs even when they are + // meant as "no filter", so the tally is read from the export instead — the + // same organizer-only gate, one call, already shaped as rows. + // + // Scored the same way SuggestResults scores it, because a ranked category + // counted as "how many rows mention you" ranks every submission equally — + // every voter names every one of them. + async function tallyFor(categoryId: string, votingMethod: number) { + try { + const res = await vote.exportVotes({ categoryId, format: EXPORT_JSON }) + const rows: unknown = JSON.parse( + new TextDecoder().decode(res.data) || "[]", + ) + if (!Array.isArray(rows)) return [] + const ballots = (rows as { submission_id?: string; value?: number }[]) + .map((row) => ({ + id: row.submission_id ?? "", + value: Number(row.value ?? 0), + })) + .filter((row) => row.id) + + const scores = new Map() + for (const b of ballots) scores.set(b.id, 0) + // Borda's N is the size of the field, fixed before scoring starts. + const field = scores.size + for (const b of ballots) { + const worth = + votingMethod === METHOD_RANKED + ? field - b.value + : votingMethod === METHOD_POINTS + ? b.value + : 1 + scores.set(b.id, (scores.get(b.id) ?? 0) + worth) + } + + return [...scores] + .map(([submissionId, score]) => ({ + submissionId, + label: labelFor(submissionId), + score, + })) + .sort((a, b) => b.score - a.score) + } catch (e) { + if (e instanceof ClientError) return [] + throw e + } + } + + // A ranked or points ballot is several Vote rows, so the remembered id is + // only ever one of them — enough to prove a ballot was cast, not enough to + // replay it. Single choice is the one case where the row IS the ballot. + async function myBallotFor(categoryId: string) { + const voteId = remembered[categoryId] + if (!voteId) return { cast: false, submissionId: "" } + try { + const res = await vote.getVote({ id: voteId }) + const ballot: Ballot | undefined = res.vote + // A shared browser could carry someone else's hint; the server's + // answer is what decides whose ballot this is. + if (!ballot || ballot.voterId !== myUserId) + return { cast: false, submissionId: "" } + + return { + cast: true, + submissionId: ballot.singleChoice?.submissionId ?? "", + } + } catch (e) { + if (e instanceof ClientError) return { cast: false, submissionId: "" } + throw e + } + } + + const detailed = await Promise.all( + categories.map(async (c) => { + const [results, tally, myBallot] = await Promise.all([ + resultsFor(c.id), + isOrganizer ? tallyFor(c.id, c.votingMethod) : Promise.resolve([]), + isOrganizer + ? Promise.resolve({ cast: false, submissionId: "" }) + : myBallotFor(c.id), + ]) + + return { + id: c.id, + name: c.name, + description: c.description ?? "", + votingMethod: c.votingMethod, + maxPoints: c.maxPoints ?? 0, + methodLabel: VOTING_METHOD_LABEL[c.votingMethod] ?? "Unknown", + voterType: c.voterType, + voterTypeLabel: VOTER_TYPE_LABEL[c.voterType] ?? "Unknown", + isJuryOnly: c.voterType === VOTER_TYPE_JURY, + juryMemberIds: c.juryMembers.map((u) => u.id), + juryNames: c.juryMembers.map((u) => u.displayName || u.username), + results: results.map((r) => ({ + id: r.id, + position: r.position, + title: r.title ?? "", + submissionId: r.submissionId, + submissionLabel: labelFor(r.submissionId), + })), + tally, + myBallotCast: myBallot.cast, + myVoteLabel: myBallot.submissionId + ? labelFor(myBallot.submissionId) + : "", + } + }), + ) + + return { + serviceAvailable, + isOrganizer, + // Prefilled from the entity, because SetVotingPolicy replaces the whole + // record — the same trap GetWindows, PrizeService.Get and + // GetEmailTemplates each exist to avoid. It rides on the hackathon + // rather than behind a read RPC because these are the rules the voters + // are bound by, not an organiser's private setting. + policy: { + ownTeamVoting: hackathon.votingPolicy?.ownTeamVoting ?? true, + organizerVoting: hackathon.votingPolicy?.organizerVoting ?? false, + mechanism: hackathon.votingPolicy?.mechanism || "single_choice", + oneBallotPer: hackathon.votingPolicy?.oneBallotPer || "category", + tieBreak: hackathon.votingPolicy?.tieBreak ?? [], + }, + // Authoritative: SubmitVote reads this very flag before accepting a ballot. + votingOpen: hackathon.settings?.votingEnabled ?? false, + isWaiting: myMembership?.isWaiting ?? false, + categories: detailed, + submissions, + members: hackathon.members + .filter((m) => m.user) + .map((m) => ({ + id: m.user?.id ?? "", + name: m.user?.displayName || m.user?.username || "", + })), + } +} + +export const actions: Actions = { + // Opening and closing the ballot. + // + // `settings.votingEnabled` gates every SubmitVote and defaults to FALSE, and + // nothing in the UI could set it: the vote — the act the whole event builds + // to — was openable only over grpcurl. HackathonService.EditSettings is the + // RPC that does it and had no caller anywhere. + // + // Deliberately does not touch `registrationsEnabled`, the other field on + // that request: it is enforced nowhere (audit B3, two contradictory + // registration gates) and the `register` capability governs instead. + // Offering a switch that does nothing is worse than offering none. + setVotingOpen: async (event) => { + const { hackathon } = requireGrpc(event.locals.grpc) + const form = await event.request.formData() + + try { + await hackathon.editSettings({ + hackathonId: event.params.id, + votingEnabled: form.get("votingEnabled") === "on", + }) + } catch (e) { + return formError(e) + } + + return ok({ + done: + form.get("votingEnabled") === "on" + ? "Voting is open." + : "Voting is closed.", + }) + }, + + // The rules of the vote. Until this existed the policy was write-only in + // both directions: nothing set it, and SubmitVote ignored what was there. + setPolicy: async (event) => { + const { config } = requireGrpc(event.locals.grpc) + const form = await event.request.formData() + + try { + await config.setVotingPolicy({ + hackathonId: event.params.id, + // Documented, not enforced: one vote per category is the only + // mechanism implemented, so these two are recorded as the + // organiser's ruling rather than offered as choices. + mechanism: "single_choice", + oneBallotPer: "category", + ownTeamVoting: form.get("ownTeamVoting") === "on", + organizerVoting: form.get("organizerVoting") === "on", + tieBreak: String(form.get("tieBreak") ?? "") + .split(",") + .map((t) => t.trim()) + .filter(Boolean), + }) + } catch (e) { + return formError(e) + } + + return ok({ done: "Voting rules saved." }) + }, + + createCategory: async (event) => { + const { vote } = requireGrpc(event.locals.grpc) + const form = await event.request.formData() + const name = String(form.get("name") ?? "").trim() + if (name.length < 3) + return bad(400, { message: "A category name needs three characters." }) + const votingMethod = Number(form.get("votingMethod") ?? 0) + try { + await vote.createVoteCategory({ + hackathonId: event.params.id, + name, + description: String(form.get("description") ?? "").trim(), + votingMethod, + voterType: Number(form.get("voterType") ?? 0), + // Only points categories carry a budget; the server refuses one + // without it and clears it on the other methods. + maxPoints: + votingMethod === METHOD_POINTS ? maxPointsFrom(form) : undefined, + juryMemberIds: form.getAll("juryMemberIds").map(String).filter(Boolean), + }) + } catch (e) { + return formError(e) + } + + return ok({ done: `Category "${name}" created.` }) + }, + + editCategory: async (event) => { + const { vote } = requireGrpc(event.locals.grpc) + const form = await event.request.formData() + const id = String(form.get("categoryId") ?? "") + if (!id) return bad(400, { message: "Missing category." }) + const votingMethod = Number(form.get("votingMethod") ?? 0) || undefined + try { + await vote.editVoteCategory({ + id, + name: String(form.get("name") ?? ""), + description: String(form.get("description") ?? ""), + votingMethod, + voterType: Number(form.get("voterType") ?? 0) || undefined, + maxPoints: + votingMethod === METHOD_POINTS ? maxPointsFrom(form) : undefined, + // An empty list leaves the jury untouched — proto3 cannot tell + // "no jury" from "field absent" on a repeated field. + juryMemberIds: form.getAll("juryMemberIds").map(String).filter(Boolean), + }) + } catch (e) { + return formError(e) + } + + return ok({ done: "Category saved." }) + }, + + deleteCategory: async (event) => { + const { vote } = requireGrpc(event.locals.grpc) + const form = await event.request.formData() + const id = String(form.get("categoryId") ?? "") + if (!id) return bad(400, { message: "Missing category." }) + try { + await vote.deleteVoteCategory({ id }) + } catch (e) { + return formError(e) + } + + return ok({ done: "Category deleted." }) + }, + + // All three methods. The ranked and points forms emit a hidden + // `submissionId` beside every number input, so `getAll` returns the two + // lists in the same document order and they zip together. + // + // Nothing here decides whether a ballot is valid: contiguity of ranks, the + // points budget and one-ballot-per-category are all checked in + // vote_service.go. The only checks below are about the FORM being filled + // in at all. + castBallot: async (event) => { + const { vote } = requireGrpc(event.locals.grpc) + const form = await event.request.formData() + const categoryId = String(form.get("categoryId") ?? "") + if (!categoryId) return bad(400, { message: "Missing category." }) + const method = Number(form.get("votingMethod") ?? 0) + + let ballot + if (method === METHOD_RANKED) { + const ids = form.getAll("submissionId").map(String) + const ranks = form.getAll("rank").map((r) => Number(r)) + if (ids.length === 0) return bad(400, { message: "Nothing to rank." }) + if (ranks.some((r) => !Number.isInteger(r) || r < 1)) + return bad(400, { + message: "Give every submission a rank of 1 or more.", + }) + ballot = { + ranked: { + categoryId, + submissions: ids.map((submissionId, i) => ({ + submissionId, + rank: ranks[i] ?? 0, + })), + }, + } + } else if (method === METHOD_POINTS) { + const ids = form.getAll("submissionId").map(String) + const points = form.getAll("points").map((p) => Number(p)) + // Zero means "awarded nothing", which is not a row: only positive + // awards are stored, and the server refuses a non-positive one. + const submissions = ids + .map((submissionId, i) => ({ submissionId, points: points[i] ?? 0 })) + .filter((s) => Number.isInteger(s.points) && s.points > 0) + if (submissions.length === 0) + return bad(400, { message: "Award points to at least one submission." }) + ballot = { points: { categoryId, submissions } } + } else { + const submissionId = String(form.get("submissionId") ?? "") + if (!submissionId) + return bad(400, { message: "Pick a submission first." }) + ballot = { singleChoice: { categoryId, submissionId } } + } + + try { + const res = await vote.submitVote(ballot) + if (res.vote?.id) rememberBallot(event.cookies, categoryId, res.vote.id) + } catch (e) { + if (e instanceof ClientError && e.code === Status.ALREADY_EXISTS) { + return bad(409, { + alreadyVotedIn: categoryId, + message: + "You have already voted in this category. Ballots are final.", + }) + } + if (e instanceof ClientError && e.code === Status.FAILED_PRECONDITION) { + // Surface the server's specific reason. SubmitVote answers + // FAILED_PRECONDITION for two distinct states — the votingEnabled + // master switch being off ("voting is closed") and the VOTE capability + // being closed by the current phase (capabilityClosedMessage) — and one + // hardcoded string cannot tell an organiser which of the two to fix. + return bad(409, { + message: e.details || "Voting is not open for this hackathon.", + }) + } + return formError(e) + } + + return ok({ castIn: categoryId, done: "Your ballot was recorded." }) + }, + + createResult: async (event) => { + const { vote } = requireGrpc(event.locals.grpc) + const form = await event.request.formData() + const categoryId = String(form.get("categoryId") ?? "") + const submissionId = String(form.get("submissionId") ?? "") + if (!categoryId) return bad(400, { message: "Missing category." }) + if (!submissionId) + return bad(400, { message: "Pick the submission to place." }) + const title = String(form.get("title") ?? "").trim() + try { + await vote.createVoteResult({ + categoryId, + submissionId, + position: Number(form.get("position") ?? 1) || 1, + title: title || undefined, + }) + } catch (e) { + return formError(e) + } + + return ok({ done: "Placement recorded." }) + }, + + editResult: async (event) => { + const { vote } = requireGrpc(event.locals.grpc) + const form = await event.request.formData() + const id = String(form.get("resultId") ?? "") + if (!id) return bad(400, { message: "Missing placement." }) + const title = String(form.get("title") ?? "").trim() + try { + await vote.editVoteResult({ + id, + submissionId: String(form.get("submissionId") ?? "") || undefined, + position: Number(form.get("position") ?? 0) || undefined, + title: title || undefined, + }) + } catch (e) { + return formError(e) + } + + return ok({ done: "Placement saved." }) + }, + + deleteResult: async (event) => { + const { vote } = requireGrpc(event.locals.grpc) + const form = await event.request.formData() + const id = String(form.get("resultId") ?? "") + if (!id) return bad(400, { message: "Missing placement." }) + try { + await vote.deleteVoteResult({ id }) + } catch (e) { + return formError(e) + } + + return ok({ done: "Placement removed." }) + }, + + exportVotes: async (event) => { + const { vote } = requireGrpc(event.locals.grpc) + const form = await event.request.formData() + const categoryId = String(form.get("categoryId") ?? "") + if (!categoryId) return bad(400, { message: "Missing category." }) + const json = String(form.get("format") ?? "json") === "json" + let res + try { + res = await vote.exportVotes({ + categoryId, + format: json ? EXPORT_JSON : EXPORT_CSV, + }) + } catch (e) { + return formError(e) + } + const name = safeName(String(form.get("categoryName") ?? "category")) + + return ok({ + exported: { + title: `Ballots · ${String(form.get("categoryName") ?? "")}`, + filename: `votes-${name}.${json ? "json" : "csv"}`, + text: new TextDecoder().decode(res.data), + }, + }) + }, + + exportResults: async (event) => { + const { vote } = requireGrpc(event.locals.grpc) + const form = await event.request.formData() + const categoryId = String(form.get("categoryId") ?? "") + if (!categoryId) return bad(400, { message: "Missing category." }) + const json = String(form.get("format") ?? "json") === "json" + let res + try { + res = await vote.exportResults({ + categoryId, + format: json ? EXPORT_JSON : EXPORT_CSV, + }) + } catch (e) { + return formError(e) + } + const name = safeName(String(form.get("categoryName") ?? "category")) + + return ok({ + exported: { + title: `Results · ${String(form.get("categoryName") ?? "")}`, + filename: `results-${name}.${json ? "json" : "csv"}`, + text: new TextDecoder().decode(res.data), + }, + }) + }, +} diff --git a/components/frontend/src/routes/(app)/my/hackathon/[id]/voting/+page.svelte b/components/frontend/src/routes/(app)/my/hackathon/[id]/voting/+page.svelte new file mode 100644 index 00000000..dcb4737e --- /dev/null +++ b/components/frontend/src/routes/(app)/my/hackathon/[id]/voting/+page.svelte @@ -0,0 +1,598 @@ + + +
    +
    +

    Voting

    +

    + One ballot per category, cast by the people in the room. The results below are + what the organizers publish from the tally. +

    +
    + + {#if form?.message} +

    {form.message}

    + {:else if form?.done} +

    {form.done}

    + {/if} + + {#if !data.serviceAvailable} +

    + This server does not run the voting service yet. +

    + {:else} +

    + {#if data.votingOpen} + Voting is open — ballots are being accepted. + {:else} + Voting is not open. Ballots are refused until the organizers open it. + {/if} +

    + + {#if data.isOrganizer} + +

    + + {data.policy.organizerVoting + ? 'This event lets its organisers vote.' + : 'You run this event, so you do not vote in it.'} + + + {data.policy.organizerVoting + ? 'Your ballot counts like anyone else’s. Change that in the rules below.' + : 'Ballots from organisers and admins are refused by the server.'} + Shape the categories here, watch the tally come in, then publish the + placements. + +

    + + +
    +
    +

    + {data.votingOpen ? 'Voting is open' : 'Voting is closed'} +

    +

    + {data.votingOpen + ? 'Ballots are being accepted. Close it when the room has voted.' + : 'Ballots are refused until you open it. Categories and rules can be set up first.'} +

    +
    + + + +
    + + +
    +

    Rules

    + + + + + + + +
    + +
    +
    + + {#if form?.exported} + + {/if} + +
    +
    +
    +

    Categories

    +

    + Each one collects a separate ballot from every voter. +

    +
    + +
    + + {#if creatingCategory} +
    (creatingCategory = false))} + class="card flex flex-col gap-3 p-4" + > + + +
    + + +
    + + +
    + +
    +
    + {/if} + + {#each data.categories as c (c.id)} +
    +
    +
    +
    + {c.name} + {c.methodLabel} + {c.voterTypeLabel} +
    + {#if c.description} +

    {c.description}

    + {/if} + {#if c.isJuryOnly && c.juryNames.length > 0} +

    + Jury: {c.juryNames.join(', ')} +

    + {/if} +
    +
    + +
    + + +
    +
    +
    + + {#if editingCategory === c.id} +
    (editingCategory = null))} + class="flex flex-col gap-3 border-t border-line pt-4" + > + + + +
    + + +
    + + +

    + Selecting nobody leaves the jury as it is — an empty list cannot + be told apart from an untouched one on the wire. +

    +
    + +
    +
    + {/if} + +
    +
    +

    Tally

    + {#if c.tally.length === 0} +

    No ballots yet.

    + {:else} +
      + {#each c.tally as t (t.submissionId)} +
    • + {t.label} + {t.score} +
    • + {/each} +
    +

    + {#if c.votingMethod === METHOD_POINTS} + Points awarded, summed across ballots. + {:else if c.votingMethod === 2} + Borda points, scored the way SuggestResults scores them. + {:else} + Ballots cast for each submission. + {/if} +

    + {/if} +
    + +
    +
    +

    Placements

    + +
    + + {#if addingResult === c.id} +
    (addingResult = null))} + class="flex flex-col gap-2 border-b border-line pb-3" + > + + +
    + + +
    +
    + +
    +
    + {/if} + + {#each c.results as r (r.id)} +
    +
    + + #{r.position} + {r.submissionLabel} + {#if r.title} + {r.title} + {/if} + +
    + +
    + + +
    +
    +
    + {#if editingResult === r.id} +
    (editingResult = null))} + class="flex flex-col gap-2" + > + + +
    + + +
    +
    + +
    +
    + {/if} +
    + {:else} +

    Nothing published yet.

    + {/each} +
    +
    + +
    + Export + {#each [{ action: 'exportVotes', label: 'Ballots' }, { action: 'exportResults', label: 'Results' }] as ex (ex.action)} + {#each ['json', 'csv'] as fmt (fmt)} +
    + + + + +
    + {/each} + {/each} +
    +
    + {:else} +

    + No categories yet. Add one before opening voting. +

    + {/each} +
    + {:else} +
    +
    +

    Your ballots

    +

    + One vote per category, and it cannot be taken back. +

    +
    + + {#each data.categories as c (c.id)} + + {:else} +

    + The organizers have not set up any vote categories yet. +

    + {/each} +
    + +
    +

    Results

    + {#each data.categories as c (c.id)} +
    +

    {c.name}

    + +
    + {:else} +

    Nothing to show yet.

    + {/each} +
    + {/if} + {/if} +
    diff --git a/components/frontend/src/routes/(app)/my/hackathon/[id]/webinars/+page.server.ts b/components/frontend/src/routes/(app)/my/hackathon/[id]/webinars/+page.server.ts new file mode 100644 index 00000000..fc20989c --- /dev/null +++ b/components/frontend/src/routes/(app)/my/hackathon/[id]/webinars/+page.server.ts @@ -0,0 +1,48 @@ +import type { PageServerLoad } from "./$types" +import { requireGrpc } from "$lib/server/grpc/client" +import { SESSION_HINT } from "$lib/pageCollections" +import { error } from "@sveltejs/kit" +import { ClientError, Status } from "nice-grpc-common" + +// There is no webinar entity in the backend, and there is no session or talk +// entity either — so anything shaped like a speaker line-up here would be +// invented. The product models pre-event sessions the way the lifecycle +// recipe publishes them: as event pages (`act4.webinars` creates a page +// titled "Pre-event webinars" carrying the two sessions and their recording +// links). This tab therefore reads the real pages. +// +// PageService.List is used rather than the layout's `hackathon.pages` because +// the backend decides visibility there: a plain member sees published pages +// only, while someone with page-write also sees drafts. The layout's Get +// embeds every page regardless, which would leak unpublished drafts. + +export const load: PageServerLoad = async (event) => { + const { page } = requireGrpc(event.locals.grpc) + + let pages + try { + pages = (await page.list({ hackathonId: event.params.id })).pages + } catch (e) { + if (e instanceof ClientError && e.code === Status.PERMISSION_DENIED) + error(403, "Access denied") + if (e instanceof ClientError && e.code === Status.NOT_FOUND) + error(404, "Hackathon not found") + throw e + } + + // Backend order (the `order` column) is preserved. + const shaped = pages.map((p) => ({ + id: p.id, + title: p.title, + content: p.content, + updatedAt: p.modifiedAt ?? p.createdAt ?? null, + })) + + // Titles are organizer-written prose, so this split is a hint and never a + // filter: pages that do not read like session announcements are still + // listed, just under their own heading. Nothing published is hidden. + return { + sessions: shaped.filter((p) => SESSION_HINT.test(p.title)), + otherPages: shaped.filter((p) => !SESSION_HINT.test(p.title)), + } +} diff --git a/components/frontend/src/routes/(app)/my/hackathon/[id]/webinars/+page.svelte b/components/frontend/src/routes/(app)/my/hackathon/[id]/webinars/+page.svelte index 4b8d9279..99855f99 100644 --- a/components/frontend/src/routes/(app)/my/hackathon/[id]/webinars/+page.svelte +++ b/components/frontend/src/routes/(app)/my/hackathon/[id]/webinars/+page.svelte @@ -1,5 +1,97 @@ - +{#snippet pageEntry(p: PageData['sessions'][number])} +
    +

    {p.title}

    + {#if updated(p.updatedAt)} + Updated {updated(p.updatedAt)} + {/if} +
    + +
    +
    +{/snippet} + +
    +
    +

    Webinars

    + +

    + Hackagon has no separate sessions feature. Organizers announce webinars — and + link their recordings — as event pages, shown here exactly as published. +

    +
    + + {#if !hasAnything} +

    + Nothing published yet. Webinar announcements and recording links will appear + here once the organizers publish them; the event schedule lives on the Timeline + tab. +

    + {:else} + {#if data.sessions.length > 0} +
    + {#each data.sessions as p (p.id)} + {@render pageEntry(p)} + {/each} +
    + {:else} +

    + No session pages yet. The organizers have published other event pages, + listed below. +

    + {/if} + + {#if data.otherPages.length > 0} + +
    + + Other pages published by the organizers ({data.otherPages.length}) + +
    + {#each data.otherPages as p (p.id)} + {@render pageEntry(p)} + {/each} +
    +
    + {/if} + {/if} +
    + + diff --git a/components/frontend/src/routes/(app)/my/hackathon/[id]/windows/+page.server.ts b/components/frontend/src/routes/(app)/my/hackathon/[id]/windows/+page.server.ts new file mode 100644 index 00000000..feff713e --- /dev/null +++ b/components/frontend/src/routes/(app)/my/hackathon/[id]/windows/+page.server.ts @@ -0,0 +1,128 @@ +import type { Actions, PageServerLoad } from "./$types" +import { requireGrpc } from "$lib/server/grpc/client" +import { GlobalRole } from "$lib/server/grpc/generated/user/entities/global_role" +import { mayManagePhases } from "$lib/server/hackathon/capabilities" +import { error, fail } from "@sveltejs/kit" +import { ClientError, Status } from "nice-grpc-common" + +// The event's deadlines, and the override for when a deadline meets reality. +// +// A window is a clock, not a permission: the backend enforces it, and a closed +// window answers FAILED_PRECONDITION rather than PERMISSION_DENIED so the UI +// can say "the deadline passed" instead of "you are not allowed". The override +// exists because AV problems during demos are not a reason to lose an event's +// submissions — it extends from NOW, so an organiser never has to compute a +// wall-clock time under pressure. + +function optionalText(form: FormData, key: string): string | undefined { + const v = String(form.get(key) ?? "").trim() + + return v === "" ? undefined : v +} + +function optionalTime(form: FormData, key: string): Date | undefined { + const v = optionalText(form, key) + if (!v) return undefined + const d = new Date(v) + + return Number.isNaN(d.getTime()) ? undefined : d +} + +/** `datetime-local` wants "YYYY-MM-DDTHH:mm" in LOCAL time, not an ISO string. */ +function forInput(d: Date | undefined): string { + if (!d) return "" + const local = new Date(d.getTime() - d.getTimezoneOffset() * 60_000) + + return local.toISOString().slice(0, 16) +} + +function formError(e: unknown) { + if (e instanceof ClientError) { + if (e.code === Status.PERMISSION_DENIED) + return fail(403, { message: "Only this event's organisers can do that." }) + if (e.code === Status.INVALID_ARGUMENT) + return fail(400, { message: e.details }) + if (e.code === Status.FAILED_PRECONDITION) + return fail(409, { message: e.details }) + } + throw e +} + +export const load: PageServerLoad = async (event) => { + const { myMembership } = await event.parent() + const { config } = requireGrpc(event.locals.grpc) + + const isAdmin = (event.locals.platformUser?.roles ?? []).includes( + GlobalRole.GLOBAL_ROLE_ADMIN, + ) + if (!mayManagePhases(myMembership ?? undefined, isAdmin)) { + error(403, "Only this event's organisers can set its deadlines") + } + + // Read through ConfigService: the deadlines are not nested on the hackathon, + // and SetWindows replaces every field, so the form MUST prefill or saving it + // would blank whatever the organiser could not see. + const { windows: w } = await config.getWindows({ + hackathonId: event.params.id, + }) + + return { + windows: { + registrationOpens: forInput(w?.registrationOpens), + registrationCloses: forInput(w?.registrationCloses), + proposalsClose: forInput(w?.proposalsClose), + preferencesClose: forInput(w?.preferencesClose), + submissionsClose: forInput(w?.submissionsClose), + latePolicy: w?.latePolicy ?? "", + }, + } +} + +export const actions: Actions = { + save: async (event) => { + const { config } = requireGrpc(event.locals.grpc) + const form = await event.request.formData() + + try { + // Every field optional and absent means "no deadline": an event that + // never closes proposals is a legitimate configuration, not a gap. + await config.setWindows({ + hackathonId: event.params.id, + registrationOpens: optionalTime(form, "registrationOpens"), + registrationCloses: optionalTime(form, "registrationCloses"), + proposalsClose: optionalTime(form, "proposalsClose"), + preferencesClose: optionalTime(form, "preferencesClose"), + submissionsClose: optionalTime(form, "submissionsClose"), + latePolicy: optionalText(form, "latePolicy"), + }) + } catch (e) { + return formError(e) + } + + return { saved: true } + }, + + override: async (event) => { + const { config } = requireGrpc(event.locals.grpc) + const form = await event.request.formData() + + const minutes = Number(form.get("extendMinutes") ?? 0) + if (!minutes || minutes < 1) + return fail(400, { message: "Say how many minutes to add." }) + + try { + // Anchored on now, not on the original deadline: an organiser reaching + // for this is already past it and wants "fifteen more minutes from here". + await config.overrideWindow({ + hackathonId: event.params.id, + window: String(form.get("window") ?? ""), + extendMinutes: minutes, + reason: String(form.get("reason") ?? ""), + }) + } catch (e) { + return formError(e) + } + + return { overrode: true } + }, +} diff --git a/components/frontend/src/routes/(app)/my/hackathon/[id]/windows/+page.svelte b/components/frontend/src/routes/(app)/my/hackathon/[id]/windows/+page.svelte new file mode 100644 index 00000000..95176822 --- /dev/null +++ b/components/frontend/src/routes/(app)/my/hackathon/[id]/windows/+page.svelte @@ -0,0 +1,124 @@ + + +
    +
    +

    Deadlines

    +

    + The backend enforces these, so a missed deadline reads as "the deadline passed" + rather than "you are not allowed". Leave one empty and that step never closes. +

    +
    + + {#if form?.message} + + {/if} + +
    +
    +

    Schedule

    + {#if form?.saved}Saved.{/if} +
    + +
    + {#each DEADLINES as d (d.name)} + + {/each} +
    + + + +
    + +
    +
    + +
    +
    +

    Extend a deadline now

    + {#if form?.overrode}Extended.{/if} +
    +

    + Adds time from this moment, not from the original deadline — when the + demos overrun you want "fifteen more minutes", not arithmetic. +

    + +
    + + + +
    + +
    +
    +
    +
    diff --git a/components/frontend/src/routes/(app)/register/[id]/+page.server.ts b/components/frontend/src/routes/(app)/register/[id]/+page.server.ts new file mode 100644 index 00000000..c6a79d14 --- /dev/null +++ b/components/frontend/src/routes/(app)/register/[id]/+page.server.ts @@ -0,0 +1,161 @@ +import { error, fail } from "@sveltejs/kit" +import type { Actions, PageServerLoad } from "./$types" +import { publicHackathonClient, requireGrpc } from "$lib/server/grpc/client" +import { Visibility } from "$lib/server/grpc/generated/hackathon/entities/visibility" +import { ClientError, Status } from "nice-grpc-common" + +// Completing a hackathon's registration form. +// +// This route deliberately does NOT live under /my/hackathon/[id]/, because +// that subtree calls HackathonService.Get, which denies waitlisted users — +// and a waitlisted user is exactly who needs to fill this in. The schema is +// read from List instead, which carries registration_form and serves public +// hackathons to anyone. + +export const load: PageServerLoad = async (event) => { + const { hackathon } = requireGrpc(event.locals.grpc) + const hackathonId = event.params.id + + // An organizer reading another participant's answers, read-only. Absent, the + // caller reads their own editable form. `?userId=` is a query param rather + // than a route segment because the destination is the same form, one person + // over — the backend's Write check on GetRegistrationResponse is what gates it. + const targetUserId = event.url.searchParams.get("userId") || undefined + const readOnly = targetUserId !== undefined + + // Prefer the member view when the caller can see it (private events, or + // confirmed members): Get carries the same schema plus their membership. + let found + try { + const res = await hackathon.get({ hackathonId }) + found = res.hackathon + } catch (e) { + if ( + !( + e instanceof ClientError && + (e.code === Status.PERMISSION_DENIED || e.code === Status.NOT_FOUND) + ) + ) { + throw e + } + // Waitlisted or not a member: fall back to the public listing. + const listed = await publicHackathonClient.list({ + visibilityFilter: Visibility.VISIBILITY_PUBLIC, + }) + found = listed.hackathons.find((h) => h.id === hackathonId) + } + + if (!found) error(404, "Hackathon not found") + if (!found.registrationForm) { + error(404, "This hackathon has no registration form") + } + + // Answers already on file, so the form opens filled in and can be corrected + // rather than re-typed from memory. Its own RPC, not part of Get: Get denies + // waitlisted users, who are exactly the people still reviewing their form. + // With a userId this reads someone else's answers; the backend refuses a + // non-organizer caller, which surfaces as the 403 below. + let existing + try { + existing = await hackathon.getRegistrationResponse({ + hackathonId, + userId: targetUserId, + }) + } catch (e) { + if (e instanceof ClientError && e.code === Status.PERMISSION_DENIED) + error(403, "You don't have permission to view this registration") + if (e instanceof ClientError && e.code === Status.NOT_FOUND) + error(404, "That participant is not registered here") + throw e + } + + // The heading names the person whose answers these are. `found.members` comes + // from Get, which only a confirmed member (the organizer here) reaches, so + // the roster is present exactly when a userId can be. + const targetName = readOnly + ? found.members.find((m) => m.user?.id === targetUserId)?.user + ?.displayName || + found.members.find((m) => m.user?.id === targetUserId)?.user?.username || + "This participant" + : undefined + + // Struct values arrive as unknown; the form only ever renders text, and a + // list field (`tags`) round-trips as a comma-separated string. + const answers: Record = {} + for (const [k, v] of Object.entries(existing.responses ?? {})) { + answers[k] = Array.isArray(v) ? v.join(", ") : v == null ? "" : String(v) + } + + return { + hackathonId, + name: found.name, + fields: found.registrationForm.fields, + consents: found.registrationForm.consents, + alreadySubmitted: existing.submitted, + answers, + consentValues: existing.consents ?? {}, + readOnly, + targetName, + } +} + +export const actions: Actions = { + default: async (event) => { + const { hackathon } = requireGrpc(event.locals.grpc) + const form = await event.request.formData() + + // Only keys the organizer defined are sent: the backend rejects unknown + // fields, and echoing back stray form data (like the CSRF-ish extras a + // browser may add) would trip that. + const responses: Record = {} + for (const [k, v] of form.entries()) { + if (k.startsWith("field:")) + responses[k.slice("field:".length)] = String(v) + } + const consents: Record = {} + for (const k of form.keys()) { + if (k.startsWith("consent:")) consents[k.slice("consent:".length)] = true + } + // An unchecked box submits nothing, so absent means "not given" — the + // backend decides whether that is acceptable for a required consent. + for (const k of String(form.get("consentKeys") ?? "").split(",")) { + if (k && !(k in consents)) consents[k] = false + } + + try { + await hackathon.submitRegistrationForm({ + hackathonId: event.params.id, + responses, + consents, + }) + } catch (e) { + if (e instanceof ClientError) { + if (e.code === Status.INVALID_ARGUMENT) + // The backend names the offending key ("missing required field + // \"affiliation\""), which is more useful than anything generic. + return fail(400, { + message: e.details || "Some answers are missing or invalid.", + }) + if (e.code === Status.PERMISSION_DENIED) + return fail(403, { + message: "You are not registered for this hackathon.", + }) + if (e.code === Status.FAILED_PRECONDITION) + return fail(409, { + message: + e.details || + "Registration is closed — this is a deadline, not a permission problem.", + }) + if (e.code === Status.ALREADY_EXISTS) + // Only reachable by losing a race with a concurrent first submit; + // normal edits are an upsert. Either way the answers are on file. + return fail(409, { + message: "Your answers were already saved — reload to see them.", + }) + } + throw e + } + + return { submitted: true } + }, +} diff --git a/components/frontend/src/routes/(app)/register/[id]/+page.svelte b/components/frontend/src/routes/(app)/register/[id]/+page.svelte new file mode 100644 index 00000000..e7379b5a --- /dev/null +++ b/components/frontend/src/routes/(app)/register/[id]/+page.svelte @@ -0,0 +1,128 @@ + + +Registration · {data.name} + +
    +

    + {#if data.readOnly}{data.targetName}'s registration{:else}{data.alreadySubmitted ? 'Your registration' : 'Registration'}{/if} +

    +

    {data.name}

    + + {#if data.readOnly} + + {#if !data.alreadySubmitted} +

    No response yet.

    + {:else} +
    + {#each data.fields as f (f.key)} +
    +
    {f.label}
    +
    + {data.answers[f.key] || '—'} +
    +
    + {/each} + {#each data.consents as c (c.key)} +
    + + {c.label} +
    + {/each} +
    + {/if} + {:else if form?.submitted} +
    +

    Thanks — your answers are in.

    +

    + The organizers review registrations and will confirm your place. You can come + back to this page and change your answers at any time. +

    + Back to my dashboard +
    + {:else} + {#if data.alreadySubmitted} +

    + You've already filled this in — your answers are below. Change anything you + like and save; the organizers see the latest version. +

    + {/if} + {#if form?.message} +

    {form.message}

    + {/if} + +
    + + + {#each data.fields as f (f.key)} + + {/each} + + {#each data.consents as c (c.key)} + + {/each} + +
    + + Cancel +
    +
    + {/if} +
    diff --git a/components/frontend/src/routes/(public)/+layout.svelte b/components/frontend/src/routes/(public)/+layout.svelte index 610ecb74..52465334 100644 --- a/components/frontend/src/routes/(public)/+layout.svelte +++ b/components/frontend/src/routes/(public)/+layout.svelte @@ -1,14 +1,13 @@ -
    - -
    - {@render children()} -
    - -
    + + {@render children()} + diff --git a/components/frontend/src/routes/(public)/+page.server.ts b/components/frontend/src/routes/(public)/+page.server.ts index 45f57aaa..0df7c29e 100644 --- a/components/frontend/src/routes/(public)/+page.server.ts +++ b/components/frontend/src/routes/(public)/+page.server.ts @@ -1,13 +1,98 @@ import type { PageServerLoad } from "./$types" -import { publicHackathonClient } from "$lib/server/grpc/client" +import { + publicHackathonClient, + publicPrizeClient, +} from "$lib/server/grpc/client" import { Visibility } from "$lib/server/grpc/generated/hackathon/entities/visibility" +import { HackathonStatus } from "$lib/server/grpc/generated/hackathon/entities/hackathon_status" + +/** How many finished events to look for winners in, newest first. */ +const AWARD_EVENTS = 4 export const load: PageServerLoad = async (event) => { - const result = await publicHackathonClient.list({ - visibilityFilter: Visibility.VISIBILITY_PUBLIC, - }) + // A backend outage must cost the LIST, not the page. + // + // This awaited bare, so any unreachable backend turned the platform's front + // page into a 500 — measured at 98 of 356 samples during one e2e run, which + // wipes and reboots Postgres and the backend by design. The rest of the page + // is static marketing copy that needs no backend at all, and a stack trace is + // not something to render at a visitor. + // + // But "no events to show" is NOT the truthful thing to render, which is what + // the comment here used to claim. It is truthful only when there are no + // events; when the list could not be FETCHED it is a different fact, and + // flattening the two cost hours on 2026-08-13 — this page and /hackathon both + // showed nothing while the database held eight public editions and `grpcurl` + // returned them (the gRPC channel was waiting out its reconnect backoff; see + // lib/server/grpc/client.ts). In a container where every test run wipes and + // reseeds the database, "empty" and "unreachable" looking identical is the + // most expensive confusion available. So the failure is carried, not hidden. + let hackathons: Awaited< + ReturnType + >["hackathons"] = [] + let listUnavailable = false + try { + const listed = await publicHackathonClient.list({ + visibilityFilter: Visibility.VISIBILITY_PUBLIC, + }) + hackathons = listed.hackathons + } catch (e) { + event.locals.logger.error( + { err: e }, + "Public hackathon list unavailable on the landing page — rendering the outage state, not an empty platform.", + ) + listUnavailable = true + } + const result = { hackathons } + + // Winners, from the events that actually finished and recorded them. + // + // This section used to be three hard-coded cards — invented projects credited + // to invented teams, each labelled "1st Place" — on the platform's front + // page. A fabricated record of who won is worse than an empty section, so it + // reads the real awards and renders nothing when there are none. + // + // Anonymous-readable because PrizeService.Get checks visibility before + // membership: a public event's awards are the result it announced. + const finished = result.hackathons + .filter((h) => h.status === HackathonStatus.HACKATHON_STATUS_FINISHED) + .sort((a, b) => (b.endsAt?.getTime() ?? 0) - (a.endsAt?.getTime() ?? 0)) + .slice(0, AWARD_EVENTS) + + const awards = ( + await Promise.all( + finished.map(async (h) => { + // One event's prizes failing must cost that event's cards, not the page. + const res = await publicPrizeClient + .get({ hackathonId: h.id }) + .catch(() => null) + if (!res || !res.finalized) return [] + + return res.awards.map((a) => ({ + hackathonId: h.id, + hackathonName: h.name, + rank: a.rank, + title: a.title, + // The event's own cover. A submission has no image of its own, and + // inventing one for a real award would be a fabricated record — the + // exact thing the hard-coded cards here were replaced for. The cover + // is the event's real picture, so an award card carries a true image + // or none at all. + hackathonLogo: h.logo, + })) + }), + ) + ) + .flat() + // Top placements first, so three cards show three winners rather than three + // runners-up from the same event. + .sort((a, b) => a.rank - b.rank) + .slice(0, 3) + return { session: event.locals.session, hackathons: result.hackathons, + listUnavailable, + awards, } } diff --git a/components/frontend/src/routes/(public)/+page.svelte b/components/frontend/src/routes/(public)/+page.svelte index aae917a3..88769cc1 100644 --- a/components/frontend/src/routes/(public)/+page.svelte +++ b/components/frontend/src/routes/(public)/+page.svelte @@ -14,15 +14,30 @@ } from 'lucide-svelte'; import HackathonRow from '$lib/components/hackathon/HackathonRow.svelte'; import CtaSection from '$lib/components/hackathon/CtaSection.svelte'; - import { statusLabel, statusBadgePreset } from '$lib/utils/hackathonStatus'; + import { statusLabel, statusBadgeVariant } from '$lib/utils/hackathonStatus'; + import { defaultHackathon } from '$lib/navigation'; + import Seo from '$lib/components/layout/Seo.svelte'; import type { PageData } from './$types'; let { data }: { data: PageData } = $props(); + // The event to put in front of a stranger: happening now, else starting + // soonest, else finished most recently — the same ranking the nav uses, so + // the platform never disagrees with itself about which event is current. + // + // The hero used to name "ORD Hackathon 2026 — Registration open" and link + // at /hackathon/ord-2026, which is not an id: the front page's primary + // action was a 404, and its badge announced an event that may not exist. + const featured = $derived(defaultHackathon(data.hackathons)); + + // See DashboardView for why these are token-derived rather than palette steps. const GRADIENTS = [ - { from: 'var(--color-primary-700)', to: 'var(--color-primary-950)' }, - { from: 'var(--color-secondary-500)', to: 'var(--color-secondary-950)' }, - { from: 'var(--color-tertiary-500)', to: 'var(--color-tertiary-950)' }, + { from: 'var(--color-accent)', to: 'color-mix(in oklab, var(--color-accent) 35%, black)' }, + { from: 'var(--color-info)', to: 'color-mix(in oklab, var(--color-info) 35%, black)' }, + { + from: 'var(--color-success)', + to: 'color-mix(in oklab, var(--color-success) 35%, black)', + }, ]; function formatMeta(h: { startsAt?: Date; endsAt?: Date }): string { @@ -37,12 +52,22 @@ return GRADIENTS[i % GRADIENTS.length]!; } + // Only organisations with a logo in static/images/logos. A "trusted by" + // row is a claim about the organisations named in it, so it lists the ones + // we can actually show rather than a placeholder grid of empty boxes. + const ORGS = [ + { name: 'SDSC', src: '/logos/sdsc.svg', darkSrc: '/logos/sdsc_white.svg' }, + { name: 'ETH Zurich', src: '/images/logos/eth-zurich.svg', invert: true }, + { name: 'EPFL', src: '/images/logos/epfl.svg', invert: true }, + { name: 'Durham University', src: '/images/logos/durham.webp' }, + ]; + let carouselIndex = $state(0); const carouselSlides = [ - { src: '/images/hackathon-ord-2024/ambiance/ambiance_1.jpg', caption: 'ORD Hackathon 2024 — Opening ceremony' }, - { src: '/images/hackathon-ord-2024/teams/teams_1.jpg', caption: 'ORD Hackathon 2024 — Team collaboration' }, - { src: '/images/hackathon-ord-2024/ambiance/ambiance_3.jpg', caption: 'ORD Hackathon 2024 — Working sessions' }, - { src: '/images/hackathon-ord-2024/winners/winners_1.jpg', caption: 'ORD Hackathon 2024 — Award ceremony' }, + { src: '/images/hackathon-ord-2024/ambiance/ambiance_1.webp', caption: 'ORD Hackathon 2024 — Opening ceremony' }, + { src: '/images/hackathon-ord-2024/teams/teams_1.webp', caption: 'ORD Hackathon 2024 — Team collaboration' }, + { src: '/images/hackathon-ord-2024/ambiance/ambiance_3.webp', caption: 'ORD Hackathon 2024 — Working sessions' }, + { src: '/images/hackathon-ord-2024/winners/winners_1.webp', caption: 'ORD Hackathon 2024 — Award ceremony' }, ]; function nextSlide() { @@ -53,41 +78,56 @@ } + + + +
    -
    +
    - - - ORD Hackathon 2026 — Registration open - + {#if featured} + + + {featured.name} — {statusLabel(featured.status) ?? 'Hackathon'} + + {/if}

    SDSC Hackathon Platform

    -

    +

    Propose projects, form teams, and build solutions together. Hosted by SDSC for the Swiss scientific community.

    - - Get Started - - + {#if featured} + + Get Started + + {/if} + + + Browse Hackathons @@ -99,28 +139,36 @@

    -

    - The Open Research Data Hackathon is a two-day event focused on building practical tools - and workflows that advance FAIR (Findable, Accessible, Interoperable, Reusable) data - practices in Swiss research. -

    + {#if h.description} + + {/if} -

    What to expect

    -
      -
    • Day 1: Keynote talks, team formation, project kickoff, and evening apero
    • -
    • Day 2: Intensive hacking sessions, project presentations, voting, and awards
    • -
    + + {#each data.pages as p (p.id)} +
    +

    {p.title}

    +
    + + {/each} -

    Who should participate

    -
      -
    • Researchers working with open data
    • -
    • Software developers interested in research infrastructure
    • -
    • Data stewards and librarians
    • -
    • Students in data science, CS, or related fields
    • -
    -`} /> +
    + {#if form?.message} + + {/if} - - - - - - - + {#if cta === 'member'} +

    You're in

    + + Open your event view + + {:else if cta === 'waiting'} +

    You're on the waitlist

    + {#if form?.joined && form.waitlisted && form.queuePosition} + +

    + You're number {form.queuePosition} in the queue. +

    + {/if} +

    + An organiser reviews registrations — the full event view opens once yours is + confirmed. +

    + {:else if cta === 'join'} +

    Ready to participate?

    + {#if h.maxParticipants} +

    + Places are limited ({h.maxParticipants}). Free places go + first-come, first-served — once the event is full, joining adds + you to the waiting list. +

    + {:else} +

    + Joining puts you on the list. Organisers confirm participants before the event + opens. +

    + {/if} +
    + +
    + {:else} +

    Ready to participate?

    +

    You need an account to join.

    + + + {/if} +
    diff --git a/components/frontend/src/routes/(public)/invite/[token]/+page.server.ts b/components/frontend/src/routes/(public)/invite/[token]/+page.server.ts new file mode 100644 index 00000000..725b9c7f --- /dev/null +++ b/components/frontend/src/routes/(public)/invite/[token]/+page.server.ts @@ -0,0 +1,97 @@ +import { error, fail, redirect } from "@sveltejs/kit" +import type { Actions, PageServerLoad } from "./$types" +import { + createAuthorizedGrpc, + publicHackathonClient, +} from "$lib/server/grpc/client" +import { ClientError, Status } from "nice-grpc-common" +import { loginUrlFor } from "$lib/utils/returnTo" +// Importing the type also pulls in the module augmentation that puts +// accessToken on Session — the same thing hooks.server.ts relies on. +import type { CustomSession } from "../../../../auth.d" + +// Redeeming an invitation link. The route is PUBLIC so someone opening the +// link from their email sees what they were invited to before being asked to +// sign in — the token itself is the credential. +// +// Redeeming grants visibility, not membership: Join still puts the user on the +// waitlist for an organizer to approve, so a forwarded link cannot insert a +// stranger into the roster. + +export const load: PageServerLoad = async (event) => { + let preview + try { + preview = await publicHackathonClient.previewInvite({ + token: event.params.token, + }) + } catch (e) { + // Unknown, malformed and revoked tokens must all look identical, so that a + // link nobody should have cannot be told apart from one that expired. + // + // INVALID_ARGUMENT belongs here too: the token is a uuid in the proto, so + // protovalidate rejects a malformed one before the handler can turn it + // into NOT_FOUND. Without this, /invite/nonsense was a 500 — an error page + // that says "something broke on our side" about a perfectly ordinary typo. + if ( + e instanceof ClientError && + (e.code === Status.NOT_FOUND || e.code === Status.INVALID_ARGUMENT) + ) + error(404, "This invitation link is not valid or has been revoked") + throw e + } + if (!preview.hackathon) error(404, "This invitation link is not valid") + + const session = await event.locals.auth() + + return { + hackathon: preview.hackathon, + alreadyParticipant: preview.alreadyParticipant, + signedIn: Boolean(session?.user), + token: event.params.token, + } +} + +export const actions: Actions = { + join: async (event) => { + const session = (await event.locals.auth()) as CustomSession | null + // Public route, so there is no locals.grpc: build a client from the + // session, or send them to sign in and come straight back here. + // + // Through the SAME interstitial as the route guards, deliberately. This + // redirect happens after someone pressed "Sign in to continue" on the invite + // page, so they are mid-flow and the page they land on has to say that the + // invitation survives — which is exactly what the interstitial says, naming + // this very link as where they are coming back to. Sending them to "/" + // instead dropped them on the marketing front page holding an invitation + // they could no longer see. + if (!session?.accessToken) { + redirect(302, loginUrlFor(`/invite/${event.params.token}`)) + } + + const form = await event.request.formData() + const hackathonId = String(form.get("hackathonId") ?? "") + if (!hackathonId) return fail(400, { message: "Missing hackathon." }) + + const grpc = createAuthorizedGrpc(session.accessToken) + try { + await grpc.hackathon.join({ + hackathonId, + inviteToken: event.params.token, + }) + } catch (e) { + if (e instanceof ClientError) { + if (e.code === Status.PERMISSION_DENIED) + return fail(403, { message: "This invitation is no longer valid." }) + if (e.code === Status.FAILED_PRECONDITION) + return fail(409, { + message: "Registration is not open for this hackathon right now.", + }) + if (e.code === Status.ALREADY_EXISTS) + return fail(409, { message: "You have already requested to join." }) + } + throw e + } + + return { joined: true } + }, +} diff --git a/components/frontend/src/routes/(public)/invite/[token]/+page.svelte b/components/frontend/src/routes/(public)/invite/[token]/+page.svelte new file mode 100644 index 00000000..e22aadd1 --- /dev/null +++ b/components/frontend/src/routes/(public)/invite/[token]/+page.svelte @@ -0,0 +1,87 @@ + + + + + +
    +

    You've been invited

    + +

    {h.name}

    + +
    + {#if dates}{dates}{/if} + {statusLabel(h.status) ?? 'Hackathon'} +
    + + {#if h.description} + + {/if} + + {#if form?.message} +

    {form.message}

    + {/if} + +
    + {#if joined} +

    You're on the list.

    +

    + The organizers review requests and will confirm your place. You'll find + this event on your dashboard. +

    + Go to my dashboard + {:else if data.signedIn} +

    + Requesting a place puts you on the organizers' list — they confirm who + takes part. +

    +
    + + +
    + {:else} +

    + Sign in to request a place. This invitation link stays valid — you'll + come straight back here. +

    + +
    + + +
    + {/if} +
    +
    diff --git a/components/frontend/src/routes/(public)/signin/+page.server.ts b/components/frontend/src/routes/(public)/signin/+page.server.ts new file mode 100644 index 00000000..dbf684d7 --- /dev/null +++ b/components/frontend/src/routes/(public)/signin/+page.server.ts @@ -0,0 +1,46 @@ +import { redirect } from "@sveltejs/kit" +import { signIn } from "../../../auth" +import type { Actions, PageServerLoad } from "./$types" +import { loginDestination } from "$lib/utils/returnTo" + +// The sign-in interstitial. +// +// GET renders a page that says why the visitor is here and where they are going +// (+page.svelte). POST is the Auth.js sign-in form action — the same one +// @auth/sveltekit's own component posts to — so the button on that page +// starts the real OIDC flow with no JavaScript involved. +// +// It lives in the (public) group for two reasons: it must be reachable while +// anonymous (a guard that bounced you to a protected page is an infinite +// redirect), and it wears the same chrome as every other page. As a top-level +// route it rendered with no header and no footer at all. + +export const load: PageServerLoad = async (event) => { + // Resolved HERE, on the server, so the page never has to decide and the + //
    's redirectTo is a value that has already been validated. An + // unvalidated one is an open redirect off the site, and it would be handed + // straight to Auth.js as the post-login destination. + const destination = loginDestination(event.url.searchParams.get("returnTo")) + + // A signed-in visitor has nothing to be told and nothing to wait for. This is + // also the landing spot when Auth.js sends someone back here (a stale link, a + // Back press out of Keycloak), so it has to move them on rather than show a + // countdown to a login they already completed. + if (event.locals.session?.user?.id) { + event.locals.logger.debug( + { destination }, + "SIGNIN: already signed in -> forwarding to the destination.", + ) + redirect(303, destination) + } + + return { + destination, + // Whether the visitor asked for a specific page or just wants in. The page + // says "we can't open X yet" in the first case and "you need to sign in" in + // the second; claiming a destination nobody named would be a fabrication. + deepLinked: destination !== loginDestination(null), + } +} + +export const actions: Actions = { default: signIn } diff --git a/components/frontend/src/routes/(public)/signin/+page.svelte b/components/frontend/src/routes/(public)/signin/+page.svelte new file mode 100644 index 00000000..1f443957 --- /dev/null +++ b/components/frontend/src/routes/(public)/signin/+page.svelte @@ -0,0 +1,143 @@ + + + + + +
    +

    Sign in to continue

    + + +
    + {#if data.deepLinked} +

    + You're not signed in, so + + {data.destination} + is not available yet. +

    + {:else} +

    You're not signed in yet.

    + {/if} + + {#if auto} +

    Taking you to the login page in {DELAY_MS / 1000} seconds…

    + {:else} +

    Continue to the login page using the button below.

    + {/if} + +

    + {#if data.deepLinked} + Once you're signed in we'll bring you straight back to + {data.destination}. + {:else} + Once you're signed in you'll land on your dashboard. + {/if} +

    +
    + + + + + + + + + + + Back to the home page + +
    diff --git a/components/frontend/src/routes/+error.svelte b/components/frontend/src/routes/+error.svelte index 2078a48d..c005ffab 100644 --- a/components/frontend/src/routes/+error.svelte +++ b/components/frontend/src/routes/+error.svelte @@ -1,33 +1,48 @@ -
    -
    -
    - -

    {status}

    -
    +
    +
    + +

    {status}

    -
    -

    - {message || 'An unexpected error occurred.'} -

    -
    +

    + {message || 'An unexpected error occurred.'} +

    -
    +
    - -
    + + + {back.label} + +
    diff --git a/components/frontend/src/routes/+layout.server.ts b/components/frontend/src/routes/+layout.server.ts index c77e194f..c0fd406e 100644 --- a/components/frontend/src/routes/+layout.server.ts +++ b/components/frontend/src/routes/+layout.server.ts @@ -1,5 +1,126 @@ import type { LayoutServerLoad } from "./$types" +import { + REPLAY_CONSENT_COOKIE, + parseReplayConsent, +} from "$lib/utils/replayConsent" + +// The origin a VISITOR reaches the app at, for the absolute URLs in link +// previews (og:url, og:image, canonical). +// +// `event.url` is the origin the app was reached at internally. Behind a +// TLS-terminating proxy — the Cloudflare tunnel in dev, an ingress in +// production — that is plain http, so og:image would advertise an insecure URL +// that crawlers refuse to fetch, and og:url would name an internal host. +// +// A comma-separated value means the request crossed several proxies; the first +// entry is the client-facing one. +function firstHeaderValue(value: string | null): string | undefined { + return value?.split(",")[0]?.trim() || undefined +} + +/** Local addresses, where plain http is the honest answer. */ +function isLocalHost(host: string): boolean { + const name = host.split(":")[0] ?? "" + + return ( + name === "localhost" || + name === "127.0.0.1" || + name === "[::1]" || + name === "::1" + ) +} export const load: LayoutServerLoad = async (event) => { - return { session: event.locals.session } + const host = + firstHeaderValue(event.request.headers.get("x-forwarded-host")) ?? + event.url.host + + // A non-local hostname is served over https, full stop — a public URL on + // plain http is not a deployment this app supports. + // + // The forwarded proto is NOT consulted for that decision: the dev tunnel's + // caddy reports its own inbound hop, which is plain http from cloudflared + // even though the visitor is on https. Trusting it would advertise insecure + // preview URLs. (It is also deliberately not forwarded to this app at all — + // Auth.js derives its cookie NAMES from the scheme it sees, and feeding it + // https on some requests and http on others silently broke login.) + const proto = isLocalHost(host) + ? (firstHeaderValue(event.request.headers.get("x-forwarded-proto")) ?? + event.url.protocol.replace(":", "")) + : "https" + + // Session replay, if it has been switched on deliberately AND this visitor + // has said yes. It is mounted on the ROOT layout because a dead control is + // just as dead on a public page as on a signed-in one. + // + // TWO INDEPENDENT SWITCHES, and both must be on: + // + // configured a deployment filled in `replay:` in config.yaml. Absent or + // incomplete ⇒ the feature does not exist here, and nobody is + // asked anything. + // consent THIS browser answered "allow". Absent ⇒ no decision has been + // made yet, which behaves exactly like "no". + // + // The gate is HERE, on the server, and not in the component — that is the + // whole point. A client-side check would mean the browser had already been + // handed an ingest endpoint and a project key and was trusted not to use + // them; withholding them makes "no consent ⇒ no recording" a property of + // what was sent rather than of what the page decided to do. It is therefore + // true on the very first paint of the very first page, before any script of + // ours has run. + const replay = event.locals.config?.replay + const configured = Boolean( + replay?.enabled && replay.ingestPoint && replay.projectKey, + ) + const consent = parseReplayConsent(event.cookies.get(REPLAY_CONSENT_COOKIE)) + + // Audience measurement, if a deployment switched it on. ONE switch, not two — + // and that difference from `replay` directly above is the deliberate part. + // + // WHY THIS IS NOT BEHIND THE REPLAY CONSENT BANNER. The banner asks one + // question, in its own words: may we RECORD YOUR SESSION. Consent is scoped + // to what was asked, so reusing that answer to authorise a second, different + // collection would be helping ourselves to permission nobody gave — the same + // reason a registration consent could not be reused for replay + // ($lib/utils/replayConsent). A second banner was the other option and is + // worse for everyone: two asks on a first visit, for one question that has a + // real answer and one that does not need asking. + // + // What makes "does not need asking" true here is a property, not a vendor + // claim: nothing is stored in or read from the visitor's browser — no + // cookie, no localStorage, no fingerprint — which is the thing consent is + // required for under ePrivacy, and it is why Plausible was picked over the + // alternatives. What remains is an IP address processed in transit to + // compute a daily-salted hash, which is a legitimate-interest processing + // decision the deployment makes and STATES (docs/frontend/analytics.md), + // not a box to trick someone into ticking. + // + // The visitor's own signal is still honoured: DNT/GPC suppresses the script + // entirely, client-side, before it is fetched (PlausibleAnalytics.svelte). + // And there is nothing here to withdraw later, because there is nothing + // stored to withdraw — which is the whole difference between the two + // features on this page. + const plausible = event.locals.config?.plausible + const plausibleConfig = + plausible?.enabled && plausible.scriptUrl && plausible.domain + ? { scriptUrl: plausible.scriptUrl, domain: plausible.domain } + : null + + return { + session: event.locals.session, + publicOrigin: `${proto}://${host}`, + plausible: plausibleConfig, + replay: { + configured, + consent, + config: + configured && consent === "granted" + ? { + ingestPoint: replay!.ingestPoint!, + projectKey: replay!.projectKey!, + allowInsecureOrigin: replay!.allowInsecureOrigin, + } + : null, + }, + } } diff --git a/components/frontend/src/routes/+layout.svelte b/components/frontend/src/routes/+layout.svelte index ab4f667e..207c38d8 100644 --- a/components/frontend/src/routes/+layout.svelte +++ b/components/frontend/src/routes/+layout.svelte @@ -1,9 +1,33 @@ + + + {@render children()} + + diff --git a/components/frontend/src/routes/consent/replay/+server.ts b/components/frontend/src/routes/consent/replay/+server.ts new file mode 100644 index 00000000..7914f17d --- /dev/null +++ b/components/frontend/src/routes/consent/replay/+server.ts @@ -0,0 +1,53 @@ +import { redirect, type RequestHandler } from "@sveltejs/kit" +import { + REPLAY_CONSENT_COOKIE, + REPLAY_CONSENT_MAX_AGE, + parseReplayConsent, +} from "$lib/utils/replayConsent" +import { safeReturnTo } from "$lib/utils/returnTo" + +// Records (or withdraws) this browser's permission for session replay. +// +// A plain POST endpoint outside both route groups, for three reasons: +// +// 1. IT MUST WORK FOR ANONYMOUS VISITORS. The tracker runs on the landing +// page and on invite links, so the person deciding may have no account. +// `hooks.server.ts` lists `/consent/` as public for exactly this. +// 2. IT MUST WORK BEFORE HYDRATION. The banner is a plain `
    ` +// with no `use:enhance`: this app has already shipped a control whose +// `onclick` did not exist yet when it was first clicked (the account menu, +// 2026-08-05), and a consent button that silently does nothing on the first +// click is the worst possible version of that bug. +// 3. THE REDIRECT IS THE MECHANISM, not a nicety. Answering with a 303 forces +// a full document load, so the next page is rendered by a server that has +// already read the new cookie. Withdrawing therefore does not merely stop +// future recordings — the recording page itself is torn down and its +// replacement is never given an ingest endpoint. An `enhance`d submit would +// have left the tracker running in a live document. +export const POST: RequestHandler = async (event) => { + const form = await event.request.formData() + const decision = parseReplayConsent(String(form.get("decision") ?? "")) + // Same validation the login flow uses: this value comes from a form field, + // so an unchecked one turns a consent button into an open redirect. + const back = safeReturnTo(String(form.get("returnTo") ?? "")) ?? "/" + + if (decision === null) { + // An unparseable answer clears the decision rather than guessing one. Back + // to "not asked", which behaves as "no". + event.cookies.delete(REPLAY_CONSENT_COOKIE, { path: "/" }) + } else { + event.cookies.set(REPLAY_CONSENT_COOKIE, decision, { + path: "/", + maxAge: REPLAY_CONSENT_MAX_AGE, + // Nothing in the browser needs to read this: the only consumer is + // `+layout.server.ts`, which decides whether to send the tracker's + // config at all. Keeping it out of `document.cookie` means a script on + // the page — ours, or one that got there — cannot flip it. + httpOnly: true, + sameSite: "lax", + secure: Boolean(event.locals.config?.cookies?.useSecure), + }) + } + + redirect(303, back) +} diff --git a/components/frontend/src/routes/objects/[...path]/+server.ts b/components/frontend/src/routes/objects/[...path]/+server.ts new file mode 100644 index 00000000..c666225d --- /dev/null +++ b/components/frontend/src/routes/objects/[...path]/+server.ts @@ -0,0 +1,79 @@ +import type { RequestHandler } from "./$types" +import { env } from "$env/dynamic/private" +import { error } from "@sveltejs/kit" + +/** + * Serve `/objects/*` from the object store when nothing in front of us does. + * + * `/objects` is deliberately same-origin: the database stores a root-relative + * path, so an uploaded image resolves wherever the app is reached from — + * localhost, the tunnel, a deployment — instead of a hostname that was only + * correct on the machine that wrote it. Serving that path was left to the edge: + * vite proxies it in dev and caddy proxies it for the tunnel. + * + * The bare adapter-node server had neither, so `node build` answered 404 for + * every uploaded image AND for every in-browser upload — the presign succeeded + * and the PUT went nowhere. This is the fallback that makes the app correct on + * its own. + * + * It is a FALLBACK, not the intended path. Whatever sits in front (caddy, an + * ingress, a CDN) should keep serving `/objects` directly, because bytes + * travelling through the app server is exactly what presigned URLs exist to + * avoid. This handler exists so the absence of that proxy is a performance + * characteristic rather than a broken feature. + */ +const STORE = env.STORAGE_ENDPOINT ?? "http://rustfs:9000" + +async function forward( + request: Request, + path: string, + search: string, +): Promise { + if (path.includes("..")) error(400, "Invalid object path") + + // The signature is computed over the STORE's host and the un-prefixed path, + // so both have to be reproduced exactly: fetch derives Host from this URL, + // and the query string carries the signature itself. + const target = `${STORE}/${path}${search}` + + const init: RequestInit = { method: request.method, redirect: "manual" } + const type = request.headers.get("content-type") + if (type) init.headers = { "content-type": type } + if (request.method !== "GET" && request.method !== "HEAD") { + // Buffered rather than streamed: undici refuses a streaming body without + // duplex, and objects here are images and attachments, not arbitrary size. + init.body = new Uint8Array(await request.arrayBuffer()) + } + + let upstream: Response + try { + upstream = await fetch(target, init) + } catch { + error(502, "The object store is not reachable") + } + + // Only the headers a browser needs to render or cache the object. Copying + // everything would forward the store's own auth and server headers. + const headers = new Headers() + for (const h of [ + "content-type", + "content-length", + "etag", + "last-modified", + "cache-control", + ]) { + const v = upstream.headers.get(h) + if (v) headers.set(h, v) + } + + return new Response(upstream.body, { status: upstream.status, headers }) +} + +export const GET: RequestHandler = ({ request, params, url }) => + forward(request, params.path, url.search) + +export const HEAD: RequestHandler = ({ request, params, url }) => + forward(request, params.path, url.search) + +export const PUT: RequestHandler = ({ request, params, url }) => + forward(request, params.path, url.search) diff --git a/components/frontend/src/routes/signin/+page.server.ts b/components/frontend/src/routes/signin/+page.server.ts deleted file mode 100644 index b675233d..00000000 --- a/components/frontend/src/routes/signin/+page.server.ts +++ /dev/null @@ -1,3 +0,0 @@ -import { signIn } from "../../auth" -import type { Actions } from "./$types" -export const actions: Actions = { default: signIn } diff --git a/components/frontend/src/themes/hackagon.css b/components/frontend/src/themes/hackagon.css new file mode 100644 index 00000000..e8f0438b --- /dev/null +++ b/components/frontend/src/themes/hackagon.css @@ -0,0 +1,639 @@ +/* Hackagon theme — the tokens and component classes the app is built from. + * + * This file has three parts, in cascade order: + * + * 1. Tokens — `--hk-*` custom properties, redefined per colour mode. + * 2. @theme — exposes those tokens to Tailwind as utility classes. + * 3. base/components — element defaults and the component class set. + * + * Dark is the default mode: `:root` holds the dark values and + * `[data-mode="light"]` overrides them. `data-mode` is set on by + * app.html and flipped by LightSwitch.svelte. + * + * Why tokens rather than a numeric palette: a token means one thing + * ("the page background"), so a component names its intent instead of picking a + * step and a mode-flipped partner. That removes the `bg-surface-100-900` pair + * machinery and, with it, the class of bug where one usage flips with the mode + * and its neighbour doesn't. + * + * The rules that keep this coherent — one solid accent per view, accent is not + * a status, mono for labels and sans for sentences — live in the + * `frontend-theme` skill, not here. + */ + +:root { + /* Ground. A cyan-slate at hue 196 rather than a neutral grey: the previous + * palette carried a 196deg hue on its three darkest steps at chroma 0, where + * hue has no effect. Giving it real chroma makes that deliberate, and puts + * 65deg between the ground and the lime so the accent reads as a chosen + * colour on tinted metal instead of a pop on black. */ + --hk-canvas: oklch(17.5% 0.009 196); + --hk-surface: oklch(21% 0.01 196); + --hk-raised: oklch(25.5% 0.011 196); + --hk-overlay: oklch(29% 0.012 196); + + --hk-line: oklch(33% 0.013 196); + --hk-line-strong: oklch(41% 0.015 196); + + --hk-ink: oklch(95% 0.005 196); + --hk-ink-2: oklch(79% 0.009 196); + --hk-ink-3: oklch(64% 0.011 196); + + /* Accent. The lime is kept but pulled from chroma 0.18 to 0.155: at full + * strength it only ever appears on small solid marks, so the extra saturation + * bought nothing except making large fills shout. */ + --hk-accent: oklch(80% 0.155 131); + --hk-accent-hover: oklch(86% 0.16 131); + --hk-accent-ink: oklch(84% 0.14 131); + --hk-on-accent: oklch(20% 0.04 131); + + /* Status. Success moved to a teal at 168deg: at its old 141deg it sat 11deg + * from the brand lime, so a status colour read as the brand colour. */ + --hk-success: oklch(72% 0.13 168); + --hk-success-ink: oklch(80% 0.12 168); + --hk-warning: oklch(75% 0.14 78); + --hk-warning-ink: oklch(82% 0.13 78); + --hk-danger: oklch(65% 0.18 20); + --hk-danger-ink: oklch(72% 0.16 20); + --hk-info: oklch(68% 0.11 225); + --hk-info-ink: oklch(78% 0.09 225); + + /* The wash behind a drawer or dialog. A token rather than `bg-black/50` at + * each call site: the two modes want different densities, and a literal + * cannot flip. Denser here, because the ground it covers is already dark. */ + --hk-scrim: oklch(0% 0 0 / 0.62); +} + +[data-mode="light"] { + --hk-canvas: oklch(99% 0.003 196); + --hk-surface: oklch(100% 0 0); + --hk-raised: oklch(97% 0.005 196); + --hk-overlay: oklch(94% 0.006 196); + + --hk-line: oklch(92% 0.007 196); + --hk-line-strong: oklch(84% 0.01 196); + + --hk-ink: oklch(21% 0.012 196); + --hk-ink-2: oklch(40% 0.013 196); + --hk-ink-3: oklch(54% 0.014 196); + + /* The solid field stays bright — it carries dark ink in both modes — while + * the accent-as-text token has to darken to stay legible on white. Keeping + * these separate is what makes the accent safe to use either way. */ + --hk-accent: oklch(76% 0.16 131); + --hk-accent-hover: oklch(70% 0.16 131); + --hk-accent-ink: oklch(47% 0.125 131); + --hk-on-accent: oklch(20% 0.04 131); + + --hk-success: oklch(55% 0.12 168); + --hk-success-ink: oklch(45% 0.1 168); + --hk-warning: oklch(62% 0.13 70); + --hk-warning-ink: oklch(50% 0.11 70); + --hk-danger: oklch(58% 0.19 22); + --hk-danger-ink: oklch(50% 0.16 22); + --hk-info: oklch(55% 0.12 235); + --hk-info-ink: oklch(46% 0.1 235); + + /* Tinted with the ground hue rather than pure black, so the wash reads as + * the page dimming instead of a grey sheet laid over it. */ + --hk-scrim: oklch(21% 0.012 196 / 0.42); +} + +/* `inline` so the generated utilities resolve the `--hk-*` var at use time + * rather than baking in a copy — that is what lets `[data-mode="light"]` + * above re-point every utility at once. Without it, mode switching silently + * stops working. */ +@theme inline { + --color-canvas: var(--hk-canvas); + --color-surface: var(--hk-surface); + --color-raised: var(--hk-raised); + --color-overlay: var(--hk-overlay); + + --color-line: var(--hk-line); + --color-line-strong: var(--hk-line-strong); + + --color-ink: var(--hk-ink); + --color-ink-2: var(--hk-ink-2); + --color-ink-3: var(--hk-ink-3); + + --color-accent: var(--hk-accent); + --color-accent-hover: var(--hk-accent-hover); + --color-accent-ink: var(--hk-accent-ink); + --color-on-accent: var(--hk-on-accent); + + --color-success: var(--hk-success); + --color-success-ink: var(--hk-success-ink); + --color-warning: var(--hk-warning); + --color-warning-ink: var(--hk-warning-ink); + --color-danger: var(--hk-danger); + --color-danger-ink: var(--hk-danger-ink); + --color-info: var(--hk-info); + --color-info-ink: var(--hk-info-ink); + + --color-scrim: var(--hk-scrim); +} + +@theme { + /* Two faces, two jobs. Mono is the app's voice — headings, labels, counts, + * IDs, anything scanned. Sans carries anything read as a sentence, because + * mono at paragraph length is tiring and leaves no contrast to build + * hierarchy from. Both are system stacks: no webfont request, no flash of + * unstyled text, and it avoids the faces every other product reaches for. */ + --font-sans: + ui-sans-serif, -apple-system, BlinkMacSystemFont, "Segoe UI", + "Helvetica Neue", Arial, sans-serif; + --font-mono: + ui-monospace, "SF Mono", SFMono-Regular, "Cascadia Code", "JetBrains Mono", + Menlo, Consolas, "DejaVu Sans Mono", monospace; + + /* Four display steps plus a meta step. Mono needs negative tracking as it + * scales up, so each step carries its own. */ + --text-display: clamp(1.6rem, 3.2vw, 2.1rem); + --text-display--line-height: 1.12; + --text-display--letter-spacing: -0.025em; + --text-display--font-weight: 600; + + --text-title: 1.3125rem; + --text-title--line-height: 1.24; + --text-title--letter-spacing: -0.018em; + --text-title--font-weight: 600; + + --text-section: 1.0625rem; + --text-section--line-height: 1.4; + --text-section--letter-spacing: -0.012em; + --text-section--font-weight: 600; + + --text-body: 0.9375rem; + --text-body--line-height: 1.62; + + --text-meta: 0.6875rem; + --text-meta--line-height: 1.4; + --text-meta--letter-spacing: 0.09em; + + /* Controls are rounded against the squarer frames they sit in, so the + * roundness reads as a deliberate contrast rather than a global softening. */ + --radius-control: 999px; + --radius-card: 0.375rem; + --radius-field: 0.375rem; + + /* Carried over from the previous theme rather than reset to Tailwind's + * 0.25rem: every `gap-*`/`p-*` in the app was authored against this, so + * changing it here would resize every screen at once. Revisit deliberately. */ + --spacing: 0.28rem; +} + +@layer base { + /* Tailwind's preflight defaults every border to currentColor, which makes a + * bare `border` invisible against text. Point it at the hairline token so + * `border` alone is always the right colour. */ + *, + ::before, + ::after { + border-color: var(--color-line); + } + + body { + background-color: var(--color-canvas); + color: var(--color-ink); + font-family: var(--font-mono); + } + + h1, + h2, + h3, + h4, + h5, + h6 { + font-family: var(--font-mono); + font-weight: 600; + letter-spacing: -0.015em; + text-wrap: balance; + } + + a { + color: var(--color-accent-ink); + text-decoration: none; + } + a:hover { + text-decoration: underline; + } + + :focus-visible { + outline: 2px solid var(--color-accent); + outline-offset: 2px; + } +} + +@layer components { + /* --------------------------------------------------------------- + * Typographic roles + * --------------------------------------------------------------- */ + + /* Running text. Switches to the sans face and caps the measure in `ch`, so + * the line length holds regardless of how wide the container gets. */ + .prose { + font-family: var(--font-sans); + font-size: var(--text-body); + line-height: 1.62; + max-width: 68ch; + } + + /* Small uppercase mono label: eyebrows, field labels, attribute names. */ + .meta { + font-family: var(--font-mono); + font-size: var(--text-meta); + letter-spacing: 0.09em; + text-transform: uppercase; + color: var(--color-ink-3); + } + + /* Any figure that stacks with another figure — counts down a list, times in + * a column. Non-aligning digits are the fastest way to make a table look + * unfinished. */ + .tnum { + font-variant-numeric: tabular-nums; + } + + /* --------------------------------------------------------------- + * Buttons. `.btn` is the shape; a variant supplies the colour. + * Exactly one `.btn-solid` per view — see the frontend-theme skill. + * --------------------------------------------------------------- */ + .btn { + display: inline-flex; + align-items: center; + justify-content: center; + gap: --spacing(1.5); + padding: --spacing(2) --spacing(4.5); + border-radius: var(--radius-control); + font-family: var(--font-mono); + font-size: 0.8125rem; + font-weight: 500; + line-height: 1.25rem; + white-space: nowrap; + border: 1px solid transparent; + cursor: pointer; + transition: + background-color 120ms ease, + border-color 120ms ease, + color 120ms ease; + } + .btn:disabled { + opacity: 0.5; + cursor: not-allowed; + } + + .btn-sm { + padding: --spacing(1.5) --spacing(3.5); + font-size: var(--text-meta); + letter-spacing: 0; + line-height: 1rem; + } + + .btn-icon { + padding: --spacing(2); + aspect-ratio: 1; + } + + .btn-solid { + background-color: var(--color-accent); + color: var(--color-on-accent); + } + .btn-solid:hover { + background-color: var(--color-accent-hover); + } + + .btn-outline { + color: var(--color-ink); + border-color: var(--color-line-strong); + } + .btn-outline:hover { + background-color: var(--color-raised); + border-color: var(--color-ink-3); + } + + .btn-outline-accent { + color: var(--color-accent-ink); + border-color: var(--color-accent); + } + .btn-outline-accent:hover { + background-color: color-mix(in oklab, var(--color-accent) 12%, transparent); + } + + .btn-ghost { + color: var(--color-ink); + background-color: var(--color-raised); + } + .btn-ghost:hover { + background-color: var(--color-overlay); + } + + .btn-quiet { + color: var(--color-ink-2); + background-color: transparent; + } + .btn-quiet:hover { + background-color: var(--color-raised); + color: var(--color-ink); + } + + .btn-accent { + color: var(--color-accent-ink); + background-color: color-mix(in oklab, var(--color-accent) 20%, transparent); + } + .btn-accent:hover { + background-color: color-mix(in oklab, var(--color-accent) 30%, transparent); + } + + .btn-success { + color: var(--color-success-ink); + background-color: color-mix( + in oklab, + var(--color-success) 20%, + transparent + ); + } + .btn-success:hover { + background-color: color-mix( + in oklab, + var(--color-success) 30%, + transparent + ); + } + + .btn-warning { + color: var(--color-warning-ink); + background-color: color-mix( + in oklab, + var(--color-warning) 20%, + transparent + ); + } + .btn-warning:hover { + background-color: color-mix( + in oklab, + var(--color-warning) 30%, + transparent + ); + } + + .btn-danger { + color: var(--color-danger-ink); + border-color: color-mix(in oklab, var(--color-danger) 45%, transparent); + } + .btn-danger:hover { + background-color: color-mix(in oklab, var(--color-danger) 12%, transparent); + } + + .btn-danger-solid { + background-color: var(--color-danger); + color: oklch(98% 0.01 20); + } + .btn-danger-solid:hover { + background-color: color-mix(in oklab, var(--color-danger) 85%, black); + } + + /* --------------------------------------------------------------- + * Badges. Status vocabulary, so the variants are named after the + * state rather than after a colour. + * --------------------------------------------------------------- */ + .badge { + display: inline-flex; + align-items: center; + gap: --spacing(1.5); + padding: --spacing(1) --spacing(3); + border-radius: var(--radius-control); + font-family: var(--font-mono); + font-size: var(--text-meta); + font-weight: 500; + letter-spacing: 0.06em; + text-transform: uppercase; + line-height: 1rem; + white-space: nowrap; + border: 1px solid transparent; + } + + .badge-icon { + display: inline-flex; + align-items: center; + justify-content: center; + padding: --spacing(1); + aspect-ratio: 1; + border-radius: var(--radius-control); + font-family: var(--font-mono); + font-variant-numeric: tabular-nums; + border: 1px solid transparent; + } + + .badge-solid { + background-color: var(--color-accent); + color: var(--color-on-accent); + } + + .badge-accent { + background-color: color-mix(in oklab, var(--color-accent) 20%, transparent); + color: var(--color-accent-ink); + border-color: color-mix(in oklab, var(--color-accent-ink) 24%, transparent); + } + + .badge-outline-accent { + border-color: var(--color-accent); + color: var(--color-accent-ink); + } + + .badge-neutral { + background-color: var(--color-raised); + color: var(--color-ink-2); + border-color: var(--color-line); + } + + .badge-success { + background-color: color-mix( + in oklab, + var(--color-success) 20%, + transparent + ); + color: var(--color-success-ink); + border-color: color-mix( + in oklab, + var(--color-success-ink) 24%, + transparent + ); + } + + .badge-warning { + background-color: color-mix( + in oklab, + var(--color-warning) 20%, + transparent + ); + color: var(--color-warning-ink); + border-color: color-mix( + in oklab, + var(--color-warning-ink) 24%, + transparent + ); + } + + .badge-danger { + background-color: color-mix(in oklab, var(--color-danger) 20%, transparent); + color: var(--color-danger-ink); + border-color: color-mix(in oklab, var(--color-danger-ink) 24%, transparent); + } + + .badge-info { + background-color: color-mix(in oklab, var(--color-info) 20%, transparent); + color: var(--color-info-ink); + border-color: color-mix(in oklab, var(--color-info-ink) 24%, transparent); + } + + /* --------------------------------------------------------------- + * Surfaces and inputs + * --------------------------------------------------------------- */ + .card { + background-color: var(--color-surface); + border: 1px solid var(--color-line); + border-radius: var(--radius-card); + } + + /* A card one lightness step up. For rows in a list that sits directly on the + * canvas, where `surface` is too close to the ground to separate the row from + * it. Depth is lightness here, as everywhere — no shadow. */ + .card-raised { + background-color: var(--color-raised); + } + + /* Absorbs the input recipe that was hand-copied across seven forms. + * + * `.field-area` shares the whole recipe rather than modifying it, because a + * class that renders nothing on its own WILL be used on its own — and was, by + * eight textareas across four routes. Tailwind's preflight makes a bare + * textarea transparent, borderless and `cols`-wide, so each of those drew as + * an invisible box the colour of the card behind it, about twenty characters + * across. Reported from /manage/pages as "the text entry box is the same + * background and doesn't extend to the size of the width"; it was equally + * true of the email templates, the vote-category descriptions and the + * submission forms. Spelling `field field-area` (which the editors do) still + * works and now means the same thing twice. */ + .field, + .field-area { + width: 100%; + border: 1px solid var(--color-line); + border-radius: var(--radius-field); + background-color: var(--color-raised); + color: var(--color-ink); + font-family: var(--font-mono); + font-size: 0.8125rem; + } + .field { + height: --spacing(9); + padding: 0 --spacing(3); + } + .field::placeholder, + .field-area::placeholder { + color: var(--color-ink-3); + } + .field:focus, + .field-area:focus { + border-color: var(--color-accent); + outline: none; + } + .field:focus-visible, + .field-area:focus-visible { + outline: 2px solid var(--color-accent); + outline-offset: -1px; + } + + /* Anything that wraps: textareas and the preview pane rendered beside them. + * The fixed control height is what makes `.field` single-line, so this drops + * it and pays the padding back vertically. Ordered AFTER `.field` so the two + * spelled together resolve to the multi-line box. */ + .field-area { + height: auto; + padding: --spacing(2) --spacing(3); + line-height: 1.6; + resize: vertical; + } + + /* The caption-above-input pair, which four forms had each spelled out as the + * same string of utilities. Deliberately not uppercase like `.meta`: + * `text-transform` inherits, and this element wraps the input, so it would + * uppercase whatever the user typed. */ + .field-label { + display: flex; + flex-direction: column; + gap: --spacing(1); + font-family: var(--font-mono); + font-size: 0.75rem; + font-weight: 600; + color: var(--color-ink-3); + } + + /* Three forms asked for `.checkbox` while nothing defined it, so every switch + * was a browser-default box that ignored the mode. Checked is a solid accent + * field rather than a native tick: the lime sits at 80% lightness, so + * `accent-color` would draw the white checkmark `on-accent` exists to rule + * out, and filled-vs-empty carries the state on its own at this size. */ + .checkbox { + appearance: none; + height: --spacing(4); + width: --spacing(4); + flex-shrink: 0; + border: 1px solid var(--color-line-strong); + border-radius: var(--radius-field); + background-color: var(--color-raised); + cursor: pointer; + } + .checkbox:checked { + border-color: var(--color-accent); + background-color: var(--color-accent); + } + + .chip { + display: inline-flex; + align-items: center; + gap: --spacing(1.5); + padding: --spacing(1.5) --spacing(3.5); + border-radius: var(--radius-control); + font-family: var(--font-mono); + font-size: 0.8125rem; + white-space: nowrap; + cursor: pointer; + background-color: transparent; + color: var(--color-ink-3); + transition: + background-color 120ms ease, + color 120ms ease; + } + .chip:hover { + background-color: var(--color-raised); + color: var(--color-ink); + } + + .chip-active { + background-color: color-mix(in oklab, var(--color-accent) 20%, transparent); + color: var(--color-accent-ink); + } + /* The selected chip, hovered. Without this the pointer ERASED the selection: + * `.chip:hover` is (0,2,0) and `.chip-active` is (0,1,0), so the tab the + * viewer is on lost its accent tint for exactly as long as they pointed at + * it — the one moment they are most likely to be reading which tab is which. + * + * A rule of its own rather than raising `.chip-active`, for two reasons. + * `.chip-active` is also worn by a `btn btn-icon btn-quiet` (the markdown + * editor's table toggle), so `.chip.chip-active` would have left that one + * untinted; and `.btn-quiet:hover` is (0,2,0) too, so the same erasure was + * happening there. This selector is (0,2,0) and covers both. + * + * ⚠ It TIES with `.chip:hover` and `.btn-quiet:hover` and wins on source + * order, which is the same way `.chip-active` already beats `.chip`. Keep it + * last: moved above either of those it stops applying and nothing but the + * computed-style check in `24-chip-states.spec.ts` would notice. + * + * 20% → 30% is the step `.btn-accent`/`.btn-accent:hover` already uses, so + * selected-and-hovered reads as both: still accent (not the neutral + * `raised` an unselected chip gets), and visibly lifted from selected-at-rest. */ + .chip-active:hover { + background-color: color-mix(in oklab, var(--color-accent) 30%, transparent); + color: var(--color-accent-ink); + } +} diff --git a/components/frontend/src/themes/hackathonsdsc.css b/components/frontend/src/themes/hackathonsdsc.css deleted file mode 100644 index eba43294..00000000 --- a/components/frontend/src/themes/hackathonsdsc.css +++ /dev/null @@ -1,207 +0,0 @@ -[data-theme="Hackathonsdsc"] { - --anchor-font-color: var(--color-primary-500); - --anchor-font-color-dark: var(--color-primary-500); - --anchor-font-family: inherit; - --anchor-font-size: inherit; - --anchor-font-style: inherit; - --anchor-font-weight: inherit; - --anchor-letter-spacing: inherit; - --anchor-line-height: inherit; - --anchor-text-decoration: none; - --anchor-text-decoration-active: none; - --anchor-text-decoration-focus: none; - --anchor-text-decoration-hover: underline; - --base-font-color: var(--color-surface-950); - --base-font-color-dark: var(--color-surface-50); - --base-font-family: - ui-monospace, "Cascadia Code", "Source Code Pro", Menlo, Consolas, - "DejaVu Sans Mono", monospace; - --base-font-size: inherit; - --base-font-style: normal; - --base-font-weight: normal; - --base-letter-spacing: 0em; - --base-line-height: inherit; - --body-background-color: var(--color-surface-50); - --body-background-color-dark: var(--color-surface-950); - --color-error-100: oklch(86.47% 0.08 357.17deg); - --color-error-200: oklch(77.96% 0.14 357.3deg); - --color-error-300: oklch(70.8% 0.2 359.87deg); - --color-error-400: oklch(66.06% 0.24 4.09deg); - --color-error-50: oklch(95.88% 0.02 10.3deg); - --color-error-500: oklch(63.88% 0.26 10.51deg); - --color-error-600: oklch(56.54% 0.23 10.73deg); - --color-error-700: oklch(48.97% 0.2 10.61deg); - --color-error-800: oklch(40.88% 0.16 10.73deg); - --color-error-900: oklch(32.6% 0.13 10.52deg); - --color-error-950: oklch(23.71% 0.09 11.16deg); - --color-error-contrast-100: var(--color-error-contrast-dark); - --color-error-contrast-200: var(--color-error-contrast-dark); - --color-error-contrast-300: var(--color-error-contrast-dark); - --color-error-contrast-400: var(--color-error-contrast-dark); - --color-error-contrast-50: var(--color-error-contrast-dark); - --color-error-contrast-500: var(--color-error-contrast-dark); - --color-error-contrast-600: var(--color-error-contrast-light); - --color-error-contrast-700: var(--color-error-contrast-light); - --color-error-contrast-800: var(--color-error-contrast-light); - --color-error-contrast-900: var(--color-error-contrast-light); - --color-error-contrast-950: var(--color-error-contrast-light); - --color-error-contrast-dark: var(--color-error-950); - --color-error-contrast-light: var(--color-error-50); - --color-primary-100: oklch(91.11% 0.15 127.77deg); - --color-primary-200: oklch(87.74% 0.15 128.44deg); - --color-primary-300: oklch(84.19% 0.16 128.54deg); - --color-primary-400: oklch(80.82% 0.17 129.29deg); - --color-primary-50: oklch(94.62% 0.14 127.69deg); - --color-primary-500: oklch(77.26% 0.18 129.66deg); - --color-primary-600: oklch(67.3% 0.15 129.98deg); - --color-primary-700: oklch(57.01% 0.13 130.07deg); - --color-primary-800: oklch(46.16% 0.11 130.55deg); - --color-primary-900: oklch(34.7% 0.08 130.93deg); - --color-primary-950: oklch(22.06% 0.06 131.28deg); - --color-primary-contrast-100: var(--color-primary-contrast-dark); - --color-primary-contrast-200: var(--color-primary-contrast-dark); - --color-primary-contrast-300: var(--color-primary-contrast-dark); - --color-primary-contrast-400: var(--color-primary-contrast-dark); - --color-primary-contrast-50: var(--color-primary-contrast-dark); - --color-primary-contrast-500: var(--color-primary-contrast-dark); - --color-primary-contrast-600: var(--color-primary-contrast-dark); - --color-primary-contrast-700: var(--color-primary-contrast-dark); - --color-primary-contrast-800: var(--color-primary-contrast-light); - --color-primary-contrast-900: var(--color-primary-contrast-light); - --color-primary-contrast-950: var(--color-primary-contrast-light); - --color-primary-contrast-dark: var(--color-primary-950); - --color-primary-contrast-light: var(--color-primary-50); - --color-secondary-100: oklch(87.34% 0.04 282.16deg); - --color-secondary-200: oklch(78.54% 0.05 280.27deg); - --color-secondary-300: oklch(69.81% 0.07 277.96deg); - --color-secondary-400: oklch(60.64% 0.09 276.72deg); - --color-secondary-50: oklch(95.76% 0.02 288.93deg); - --color-secondary-500: oklch(51.51% 0.11 274.45deg); - --color-secondary-600: oklch(45.92% 0.11 273.16deg); - --color-secondary-700: oklch(40.37% 0.12 272.22deg); - --color-secondary-800: oklch(34.61% 0.12 270.32deg); - --color-secondary-900: oklch(29.02% 0.12 268.41deg); - --color-secondary-950: oklch(23.41% 0.13 264.71deg); - --color-secondary-contrast-100: var(--color-secondary-contrast-dark); - --color-secondary-contrast-200: var(--color-secondary-contrast-dark); - --color-secondary-contrast-300: var(--color-secondary-contrast-dark); - --color-secondary-contrast-400: var(--color-secondary-contrast-dark); - --color-secondary-contrast-50: var(--color-secondary-contrast-dark); - --color-secondary-contrast-500: var(--color-secondary-contrast-light); - --color-secondary-contrast-600: var(--color-secondary-contrast-light); - --color-secondary-contrast-700: var(--color-secondary-contrast-light); - --color-secondary-contrast-800: var(--color-secondary-contrast-light); - --color-secondary-contrast-900: var(--color-secondary-contrast-light); - --color-secondary-contrast-950: var(--color-secondary-contrast-light); - --color-secondary-contrast-dark: var(--color-secondary-950); - --color-secondary-contrast-light: var(--color-secondary-50); - --color-success-100: oklch(92.36% 0.16 138.76deg); - --color-success-200: oklch(90.46% 0.2 139.6deg); - --color-success-300: oklch(88.93% 0.24 140.39deg); - --color-success-400: oklch(87.84% 0.27 140.91deg); - --color-success-50: oklch(94.64% 0.11 136.87deg); - --color-success-500: oklch(87.14% 0.29 141.53deg); - --color-success-600: oklch(75.55% 0.25 141.53deg); - --color-success-700: oklch(63.23% 0.21 141.53deg); - --color-success-800: oklch(50.55% 0.16 141.55deg); - --color-success-900: oklch(36.7% 0.12 141.46deg); - --color-success-950: oklch(21.58% 0.07 141.31deg); - --color-success-contrast-100: var(--color-success-contrast-dark); - --color-success-contrast-200: var(--color-success-contrast-dark); - --color-success-contrast-300: var(--color-success-contrast-dark); - --color-success-contrast-400: var(--color-success-contrast-dark); - --color-success-contrast-50: var(--color-success-contrast-dark); - --color-success-contrast-500: var(--color-success-contrast-dark); - --color-success-contrast-600: var(--color-success-contrast-dark); - --color-success-contrast-700: var(--color-success-contrast-dark); - --color-success-contrast-800: var(--color-success-contrast-light); - --color-success-contrast-900: var(--color-success-contrast-light); - --color-success-contrast-950: var(--color-success-contrast-light); - --color-success-contrast-dark: var(--color-success-950); - --color-success-contrast-light: var(--color-success-50); - --color-surface-100: oklch(91.28% 0 none); - --color-surface-200: oklch(82.34% 0 none); - --color-surface-300: oklch(73.48% 0 none); - --color-surface-400: oklch(64.01% 0 none); - --color-surface-50: oklch(100% 0 none); - --color-surface-500: oklch(54.17% 0 none); - --color-surface-600: oklch(48.19% 0 none); - --color-surface-700: oklch(42.3% 0 196.51deg); - --color-surface-800: oklch(36% 0 none); - --color-surface-900: oklch(29.61% 0 196.7deg); - --color-surface-950: oklch(22.54% 0 196.78deg); - --color-surface-contrast-100: var(--color-surface-contrast-dark); - --color-surface-contrast-200: var(--color-surface-contrast-dark); - --color-surface-contrast-300: var(--color-surface-contrast-dark); - --color-surface-contrast-400: var(--color-surface-contrast-dark); - --color-surface-contrast-50: var(--color-surface-contrast-dark); - --color-surface-contrast-500: var(--color-surface-contrast-light); - --color-surface-contrast-600: var(--color-surface-contrast-light); - --color-surface-contrast-700: var(--color-surface-contrast-light); - --color-surface-contrast-800: var(--color-surface-contrast-light); - --color-surface-contrast-900: var(--color-surface-contrast-light); - --color-surface-contrast-950: var(--color-surface-contrast-light); - --color-surface-contrast-dark: var(--color-surface-950); - --color-surface-contrast-light: var(--color-surface-50); - --color-tertiary-100: oklch(83.44% 0.03 293.01deg); - --color-tertiary-200: oklch(70.97% 0.05 289.31deg); - --color-tertiary-300: oklch(57.81% 0.06 288.76deg); - --color-tertiary-400: oklch(44.18% 0.08 285.46deg); - --color-tertiary-50: oklch(95.72% 0.02 296.8deg); - --color-tertiary-500: oklch(29.59% 0.1 280.86deg); - --color-tertiary-600: oklch(28.46% 0.1 280.38deg); - --color-tertiary-700: oklch(27.13% 0.1 280.34deg); - --color-tertiary-800: oklch(25.81% 0.1 279.06deg); - --color-tertiary-900: oklch(24.49% 0.1 278.79deg); - --color-tertiary-950: oklch(23.36% 0.1 277.99deg); - --color-tertiary-contrast-100: var(--color-tertiary-contrast-dark); - --color-tertiary-contrast-200: var(--color-tertiary-contrast-dark); - --color-tertiary-contrast-300: var(--color-tertiary-contrast-dark); - --color-tertiary-contrast-400: var(--color-tertiary-contrast-light); - --color-tertiary-contrast-50: var(--color-tertiary-contrast-dark); - --color-tertiary-contrast-500: var(--color-tertiary-contrast-light); - --color-tertiary-contrast-600: var(--color-tertiary-contrast-light); - --color-tertiary-contrast-700: var(--color-tertiary-contrast-light); - --color-tertiary-contrast-800: var(--color-tertiary-contrast-light); - --color-tertiary-contrast-900: var(--color-tertiary-contrast-light); - --color-tertiary-contrast-950: var(--color-tertiary-contrast-light); - --color-tertiary-contrast-dark: var(--color-tertiary-950); - --color-tertiary-contrast-light: var(--color-tertiary-50); - --color-warning-100: oklch(89.78% 0.06 53.25deg); - --color-warning-200: oklch(84.04% 0.1 54.73deg); - --color-warning-300: oklch(78.77% 0.14 54deg); - --color-warning-400: oklch(74.11% 0.17 51.01deg); - --color-warning-50: oklch(95.85% 0.02 42.62deg); - --color-warning-500: oklch(70.23% 0.2 45.09deg); - --color-warning-600: oklch(61.74% 0.18 45.09deg); - --color-warning-700: oklch(52.79% 0.15 45.3deg); - --color-warning-800: oklch(43.46% 0.12 44.75deg); - --color-warning-900: oklch(33.57% 0.1 44.99deg); - --color-warning-950: oklch(23.08% 0.07 44.92deg); - --color-warning-contrast-100: var(--color-warning-contrast-dark); - --color-warning-contrast-200: var(--color-warning-contrast-dark); - --color-warning-contrast-300: var(--color-warning-contrast-dark); - --color-warning-contrast-400: var(--color-warning-contrast-dark); - --color-warning-contrast-50: var(--color-warning-contrast-dark); - --color-warning-contrast-500: var(--color-warning-contrast-dark); - --color-warning-contrast-600: var(--color-warning-contrast-dark); - --color-warning-contrast-700: var(--color-warning-contrast-light); - --color-warning-contrast-800: var(--color-warning-contrast-light); - --color-warning-contrast-900: var(--color-warning-contrast-light); - --color-warning-contrast-950: var(--color-warning-contrast-light); - --color-warning-contrast-dark: var(--color-warning-950); - --color-warning-contrast-light: var(--color-warning-50); - --default-border-width: 1px; - --default-divide-width: 1px; - --default-ring-width: 1px; - --heading-font-color: inherit; - --heading-font-color-dark: inherit; - --heading-font-family: inherit; - --heading-font-style: normal; - --heading-font-weight: bold; - --heading-letter-spacing: inherit; - --radius-base: 0rem; - --radius-container: 0rem; - --spacing: 0.28rem; - --text-scaling: 1.2; -} diff --git a/components/frontend/static/apple-touch-icon.png b/components/frontend/static/apple-touch-icon.png new file mode 100644 index 00000000..5502aad3 --- /dev/null +++ b/components/frontend/static/apple-touch-icon.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:a412dc8336b7e02d7014048bb438f74db36cf91144347a984c6abbc22e33ef45 +size 7876 diff --git a/components/frontend/static/favicon.ico b/components/frontend/static/favicon.ico new file mode 100644 index 00000000..c01bc26b --- /dev/null +++ b/components/frontend/static/favicon.ico @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:b8fed20cd53578f67b8d0eeb0fcd2ceff34f356537edc2a939034267c7d76159 +size 15086 diff --git a/components/frontend/static/favicon.png b/components/frontend/static/favicon.png index 6eac0fe9..4b50d854 100644 --- a/components/frontend/static/favicon.png +++ b/components/frontend/static/favicon.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:5146ed79b486cb9e1cdcdd7814cd22ae78e70ceb30fa06b4cd9a16cf121bc9e6 -size 1571 +oid sha256:fb9aebdc5b5a35a56703902116658e1ea8867505fa0c6fe6b98e3555937579fe +size 1174 diff --git a/components/frontend/static/images/hackathon-ord-2024/ambiance/ambiance_1.jpg b/components/frontend/static/images/hackathon-ord-2024/ambiance/ambiance_1.jpg deleted file mode 100644 index 48c983cc..00000000 --- a/components/frontend/static/images/hackathon-ord-2024/ambiance/ambiance_1.jpg +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:64376a6f9298c2a8e53f12543e15ec5656444f0b79be5fdda4c4a52b465b47fa -size 85815 diff --git a/components/frontend/static/images/hackathon-ord-2024/ambiance/ambiance_1.webp b/components/frontend/static/images/hackathon-ord-2024/ambiance/ambiance_1.webp new file mode 100644 index 00000000..513866e5 Binary files /dev/null and b/components/frontend/static/images/hackathon-ord-2024/ambiance/ambiance_1.webp differ diff --git a/components/frontend/static/images/hackathon-ord-2024/ambiance/ambiance_3.jpg b/components/frontend/static/images/hackathon-ord-2024/ambiance/ambiance_3.jpg deleted file mode 100644 index f936e81f..00000000 --- a/components/frontend/static/images/hackathon-ord-2024/ambiance/ambiance_3.jpg +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:1c476d3f2a57e43ee4329102ee2cb74387a6f62b63fe29ddc468001b1c24746b -size 83750 diff --git a/components/frontend/static/images/hackathon-ord-2024/ambiance/ambiance_3.webp b/components/frontend/static/images/hackathon-ord-2024/ambiance/ambiance_3.webp new file mode 100644 index 00000000..02e0ff4b Binary files /dev/null and b/components/frontend/static/images/hackathon-ord-2024/ambiance/ambiance_3.webp differ diff --git a/components/frontend/static/images/hackathon-ord-2024/teams/teams_1.jpg b/components/frontend/static/images/hackathon-ord-2024/teams/teams_1.jpg deleted file mode 100644 index d1ab6269..00000000 --- a/components/frontend/static/images/hackathon-ord-2024/teams/teams_1.jpg +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:f36c23c99fd1dd4802c32923af2c4a3d1c009ae1e55446901b27c66c5d39f6f3 -size 2457702 diff --git a/components/frontend/static/images/hackathon-ord-2024/teams/teams_1.webp b/components/frontend/static/images/hackathon-ord-2024/teams/teams_1.webp new file mode 100644 index 00000000..d1a9c979 Binary files /dev/null and b/components/frontend/static/images/hackathon-ord-2024/teams/teams_1.webp differ diff --git a/components/frontend/static/images/hackathon-ord-2024/winners/winners_1.jpg b/components/frontend/static/images/hackathon-ord-2024/winners/winners_1.jpg deleted file mode 100644 index 894d9932..00000000 --- a/components/frontend/static/images/hackathon-ord-2024/winners/winners_1.jpg +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:6be72464d1d0320d9ee7186edce83c6ee0ce69a73502a300d92567985df2f6b8 -size 55132 diff --git a/components/frontend/static/images/hackathon-ord-2024/winners/winners_1.webp b/components/frontend/static/images/hackathon-ord-2024/winners/winners_1.webp new file mode 100644 index 00000000..5d7ff61d Binary files /dev/null and b/components/frontend/static/images/hackathon-ord-2024/winners/winners_1.webp differ diff --git a/components/frontend/static/images/logos/durham.webp b/components/frontend/static/images/logos/durham.webp new file mode 100644 index 00000000..953a7658 Binary files /dev/null and b/components/frontend/static/images/logos/durham.webp differ diff --git a/components/frontend/static/og-default.jpg b/components/frontend/static/og-default.jpg new file mode 100644 index 00000000..09b26cd9 --- /dev/null +++ b/components/frontend/static/og-default.jpg @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:00eb530a63ba1d0daaf1f2d2622d2d06d0cb85ab3f03cfdb86bd0c2920d58de6 +size 58130 diff --git a/components/frontend/static/social/banner.png b/components/frontend/static/social/banner.png new file mode 100644 index 00000000..9a45d749 --- /dev/null +++ b/components/frontend/static/social/banner.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:2cd93d78b3782e53cb83a9ce920edcee70405f31f6ed09865ad609577531ca09 +size 52143 diff --git a/components/frontend/static/social/square.png b/components/frontend/static/social/square.png new file mode 100644 index 00000000..ef237bdc --- /dev/null +++ b/components/frontend/static/social/square.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:f15994aaad6ac1d0727794360509db2fe142519fdd1bbea68e63d66b1b46d8d2 +size 73514 diff --git a/components/frontend/static/social/story.png b/components/frontend/static/social/story.png new file mode 100644 index 00000000..83b70e51 --- /dev/null +++ b/components/frontend/static/social/story.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:827cd4a7701c2b1183ca400c1c59bfead9b661d09d35d967bbac24154ec42e39 +size 124555 diff --git a/components/frontend/vite.config.ts b/components/frontend/vite.config.ts index e6f4fde6..4345a7f7 100644 --- a/components/frontend/vite.config.ts +++ b/components/frontend/vite.config.ts @@ -2,6 +2,9 @@ import tailwindcss from "@tailwindcss/vite" import { sveltekit } from "@sveltejs/kit/vite" import { defineConfig } from "vite" import path from "path" +import { readFileSync } from "node:fs" +import { execFileSync } from "node:child_process" +import { fileURLToPath } from "node:url" const coverageDir = path.join( process.env.QUITSH_COVERAGE_DIR || "build", @@ -9,17 +12,116 @@ const coverageDir = path.join( "data", ) +const repoRoot = path.resolve( + path.dirname(fileURLToPath(import.meta.url)), + "..", + "..", +) + +/** + * The declared product version. `VERSION` at the repo root is the single source + * of truth (`just version::bump`), so the version survives a build with no git + * available — a shallow clone or an unpacked tarball still stamps something + * truthful rather than "unknown". + */ +function declaredVersion(): string { + try { + return readFileSync(path.join(repoRoot, "VERSION"), "utf8").trim() + } catch { + return "0.0.0" + } +} + +/** Run git, treating any failure (no git, no repo, no commits) as "unknown". */ +function git(...args: string[]): string { + try { + return execFileSync("git", args, { + cwd: repoRoot, + stdio: ["ignore", "pipe", "ignore"], + encoding: "utf8", + }).trim() + } catch { + return "" + } +} + +/** + * What the footer shows. A clean checkout sitting on the tag that matches + * `VERSION` is a release and gets a bare `v0.0.1`; anything else is a build of + * some intermediate commit and says so, because a dev build claiming to be the + * release is how a bug report ends up pinned to the wrong code. Kept in step + * with `just version::show`. + */ +function buildVersion(): string { + const declared = declaredVersion() + const commit = git("rev-parse", "--short=7", "HEAD") + const dirty = git("status", "--porcelain") !== "" + const onTag = git("tag", "--points-at", "HEAD") + .split("\n") + .includes(`v${declared}`) + + if (onTag && !dirty) return `v${declared}` + return `v${declared}${commit ? `+${commit}` : ""}${dirty ? "-dirty" : ""}` +} + export default defineConfig({ plugins: [tailwindcss(), sveltekit()], + // TEST ONLY, and only because a component test cannot exist without it. + // + // Vitest resolves imports the way Node does, so `import ... from "svelte"` + // picks the package's SERVER entry and `render()` from + // @testing-library/svelte dies with `mount(...) is not available on the + // server` — the component never mounts, which reads like a broken test rather + // than a resolution setting. Preferring the `browser` condition is what + // @testing-library/svelte's own vite plugin does; it is spelled out here + // because that plugin only INSERTS `browser` into an existing conditions + // list and does nothing when there is none. + // + // Guarded by VITEST so `vite build`, `vite dev` and svelte-check are + // untouched. It does apply to the whole test run rather than to component + // files only: verified harmless — `markdown.test.ts` runs under + // `@vitest-environment node` and still asserts that isomorphic-dompurify + // sanitizes with no `window` at all, which is the one thing this could + // plausibly have broken. + resolve: process.env.VITEST ? { conditions: ["browser"] } : undefined, + optimizeDeps: { exclude: ["@sjsf/form", "@sjsf/skeleton3-theme", "@sjsf/basic-theme"], }, + // Resolved once at config load, so the stamp reflects the commit the server + // was started from and does not drift as you edit. Read through + // `$lib/version`, never as a bare global. + define: { + __APP_VERSION__: JSON.stringify(buildVersion()), + }, + // configuration for Vitest server: { port: 8081, // Port fixed also in keycloak realm allowed redirects. strictPort: true, + // Cloudflare quick tunnels (see .devcontainer/README.md) proxy the dev + // server under a random *.trycloudflare.com host. + allowedHosts: [".trycloudflare.com"], + // Inotify does not cross the devcontainer bind mount on Windows hosts; + // without polling, edits made on the host never hot-reload. + watch: { usePolling: true, interval: 500 }, + // Uploaded files are served from the app's OWN origin at /objects, not from + // the object store's host. The database therefore stores a root-relative + // path, which resolves wherever the app is reached from — localhost, the + // Cloudflare tunnel, or a deployment — instead of a hostname that is only + // correct on the machine that wrote it. + // + // Caddy has the matching route for the tunnel and the built server; this + // one covers `vite dev`. + proxy: { + "/objects": { + target: "http://rustfs:9000", + changeOrigin: true, + rewrite: (path) => path.replace(/^\/objects/, ""), + }, + }, }, test: { // Enable Vitest's global APIs (describe, it, expect, etc.) diff --git a/docs/README.md b/docs/README.md new file mode 100644 index 00000000..83e3d388 --- /dev/null +++ b/docs/README.md @@ -0,0 +1,40 @@ +# Hackagon documentation + +Hackagon is a hackathon platform: a Go + gRPC backend, a SvelteKit frontend, +Keycloak for authentication, Postgres through the ent ORM, and casbin for +per-hackathon RBAC. The whole stack runs locally under a Nix dev shell driven by +`just` and process-compose. + +## Guides + +| Document | Answers | +| ---------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------- | +| [architecture.md](architecture.md) | How do the pieces fit together — repo layout, runtime topology, request flow, codegen? | +| [getting-started.md](getting-started.md) | How do I get a working environment and run/seed/inspect the stack? | +| [architecture-model.md](architecture-model.md) | The architecture as C4 (context / containers / components), channels, and the endpoint catalogue — generated from the model. | +| [backend/services.md](backend/services.md) | Which gRPC services and RPCs exist, and what does each handler do? | +| [backend/data-model.md](backend/data-model.md) | What tables/entities exist and how do they relate? | +| [backend/rbac.md](backend/rbac.md) | How are permissions modelled and enforced (casbin roles, domains, policy rows)? | +| [backend/rpc-journal.md](backend/rpc-journal.md) | The off-by-default RPC journal: what it records, what it never reads, and how it seeds recipe actions. | +| [frontend/routes-and-auth.md](frontend/routes-and-auth.md) | Which routes exist, which are public, and how does the session/auth guard work? | +| [frontend/grpc-clients.md](frontend/grpc-clients.md) | How does the SvelteKit server talk to the backend, and how are gRPC errors translated to HTTP? | +| [frontend/session-replay.md](frontend/session-replay.md) | Session replay: what is recorded, when, on whose say-so, how consent is withdrawn, and how long recordings live. | +| [user-flows.md](user-flows.md) | What does the platform look like to a visitor, participant, organizer and admin — screen by screen, desktop and phone? | +| [lifecycle.md](lifecycle.md) | What is the end-to-end hackathon lifecycle, from publication through voting and prizes? | +| [testing.md](testing.md) | What test suites exist (Go, Vitest, Playwright e2e) and how do I run them? | +| [backend/schema.dbml](backend/schema.dbml) | The data model as DBML — paste into [dbdiagram.io](https://dbdiagram.io/d) for an interactive diagram. | +| [TODO.md](TODO.md) | Known bugs, open decisions, and the cleanup checklist (from the 2026-08-04 code audit). | +| [requirements.md](requirements.md) | Requirements summary (backend/frontend split, per-act themes) generated from the executable spec. | +| [roadmap.md](roadmap.md) | What is MVP vs Core, the designed user flow scored step by step, and what is explicitly unscheduled. | +| [glossary.md](glossary.md) | The domain vocabulary, code-grounded — including the collision-prone terms (capability, member, phase…). | +| [infrastructure.md](infrastructure.md) | What runs today vs what production needs; a paperdraw.dev build sheet with real load profiles. | +| [deployment.md](deployment.md) | The Helm chart: what an operator must supply, what will bite them, and the k3d rig that proved it (two modes, what each can prove). | + +## Generated references + +These two files are produced by codegen — do not edit them by hand. + +| Reference | Contents | Regenerate with | +| ----------------------------------------------------------------- | ------------------------------------------------------------------------------- | ------------------------- | +| [`api/proto/API.md`](../api/proto/API.md) | Every proto message, enum, service and RPC (protoc-gen-doc output). | `just codegen::proto` | +| [`components/backend/Schema.md`](../components/backend/Schema.md) | Human-readable DB reference generated from `components/backend/db/schema/*.go`. | `just codegen::db-schema` | diff --git a/docs/TODO.md b/docs/TODO.md new file mode 100644 index 00000000..d5b5661a --- /dev/null +++ b/docs/TODO.md @@ -0,0 +1,387 @@ +# TODO — known bugs, open decisions, cleanup checklist + +Compiled 2026-08-04 from a full code audit of branch `sketch/04-08-26` (done +while generating this documentation set). Line references are to that branch. +Policy-level open questions live in [lifecycle.md](lifecycle.md) ("open +decisions"); this page is the engineering list. + +**Work continued on `sketch/06-08-26`**, which is where every entry dated +2026-08-05 or later was fixed. The two branch names below are kept as written: +they record where a finding was made, not where to look now. + +**Update 2026-08-04 (later the same day):** B1, B5, B6, B8, B10, B11, B12 +(partial), B14 and F2, F3, F4, F5, F7, F8 are fixed on `sketch/04-08-26` — see +the checklist below for the per-item notes, including two deliberate non-fixes. +The tables keep the original audit text as the record of what was found; the +checklist is the live status. + +## Known bugs — backend + +| # | Severity | What | Where | +| --- | ----------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------- | +| B1 | **crash** | `Join` nil-derefs on any hackathon without an end date: `h.EndsAt.Before(...)` on a nillable `*time.Time` | `internal/service/hackathon_service.go` (~L326) | +| B2 | **access** | `AllowPublicHackathonAccess` is dead code — never called by any handler, so a public hackathon is listable anonymously but `Get` still requires membership (backend half of the F1 dead end) | `internal/middleware/rbac.go` | +| B3 | **conflict** | Two contradictory registration gates: `settings.registrations_enabled` is stored/editable but enforced nowhere; the `register` capability governs (see the MERGE NOTE re #78/#87 — opposite defaults) | `hackathon_service.go` (~L330) | +| B4 | data loss | `PhaseService.Create` accepts `starts_at`/`ends_at` in the proto but silently drops them — phases are always created undated | `phase_service.go` | +| B5 | drift | `TeamService.AssignUser`/`RemoveUser` only log casbin failures and still return success — join table and policy table can diverge | `team_service.go` | +| B6 | race | `CreateSubmission` computes `version = count+1`; concurrent creates hit the unique index and surface as `Internal` instead of a retry | `team_service.go` | +| B7 | policy | `ownTeamVoting` is persisted via `SetVotingPolicy` but never read by `SubmitVote`; `organizerVoting` is enforced but hard-coded rather than read from the policy | `vote_service.go` | +| B8 | auth gap | `ProjectService.SetPreference` is the only mutation with **no casbin check** (participant lookup + capability + window only) | `project_service.go` | +| B9 | access | Private hackathons are joinable by anyone authenticated who has the UUID — `Join` never checks visibility; privacy is discovery-only | `hackathon_service.go` | +| B10 | contract | `TeamService.Edit` / `ProjectService.Edit` (`track_id`) treat empty string as "unchanged" although the protos declare `optional` — a description can never be cleared | `team_service.go`, `project_service.go` | +| B11 | audit | `ProjectService.Edit` and `setApproval` never `SetModifier` (every other Edit handler does) | `project_service.go` | +| B12 | dx | `TeamService.List`/`Get` collapse every failure to `PermissionDenied` with message `"cann't get teams"` (typo, twice); `Delete` lacks the team-scoped fallback that `Edit` has | `team_service.go` | +| B13 | ~~api~~ FIXED | Vote proto declares `created_at`/`modified_at` but the ent schema has no timestamp columns — always zero on the wire. Columns added to `Vote` **and** `VoteCategory` (whose proto declares them too) 2026-08-07, alongside ranked/points voting | `db/schema/vote.go` vs `api/proto/vote/**` | +| B14 | minor | `PageService.List` public fallback masks `NotFound` behind the permission error; stray `"...for reordering2"` in a `SetOrder` error | `page_service.go` (~L624) | +| B15 | ~~missing~~ FIXED | `UserService.AddRole/RemoveRole` and `HackathonService.AddOwner/RemoveOwner` were proto-only → `Unimplemented`; the only Owner grant was the `Create` side effect. All four implemented 2026-08-06, each with a caller | protos vs handlers | + +## Known bugs / gaps — frontend + +| # | Severity | What | Where | +| --- | --------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ----------------------------------------- | +| F1 | **UX dead end** | Signed-in non-members opening `/hackathon/[id]` are unconditionally redirected to the member view → 403, with no Join affordance (pairs with B2) | `(public)/hackathon/[id]/+page.server.ts` | +| F2 | **stub** | Dashboard "Other hackathons" links straight into F1, and its Join button is `alert('Join: not yet implemented')` although `HackathonService.Join` exists | `DashboardView.svelte` | +| F3 | error | `/manage/users` returns **500** (untranslated `PERMISSION_DENIED`) to non-admins; also unreachable from any nav | `(app)/manage/users/+page.server.ts` | +| F4 | UX | `returnTo` is written by both guards but never consumed — deep links always land on `/dashboard` | `hooks.server.ts`, `NavBar.svelte` | +| F5 | 404 | `/my/hackathon/[id]` has a layout but no `+page.*` → 404 on the bare URL | `(app)/my/hackathon/[id]/` | +| F6 | **security** | `MarkdownSection.svelte` renders `{@html content}` with no parser and no sanitizer (no markdown dep in `package.json`). Currently fed a literal only — wiring it to `Page.content` as-is would be stored XSS | `MarkdownSection.svelte` | +| F7 | config | gRPC channel hard-codes `localhost:3000`; the validated `config.backend.hostname/port` is loaded but read by nothing | `lib/server/grpc/client.ts` | +| F8 | stale | `pnpm proto:generate` covers only health/user/hackathon — a strict subset of what the app imports; `just codegen::proto` is the real pipeline | `package.json` | +| F9 | minor | `idToken` dropped on initial sign-in (cookie size) but written back by the refresh branch | `auth.ts` | +| F10 | stubs | Static placeholders: participants page (hard-coded demo array), overview (only `description` real), webinars/photos, home "Get Started" → non-UUID `/hackathon/ord-2026` | various | + +## Checklist + +### Correctness first + +- [x] B1 — nil-check `EndsAt` in `Join` (crash on undated hackathons) — matches + `computeHackathonStatus`: no end date ⇒ never FINISHED ⇒ still joinable +- [x] F1 — the 403 dead end is gone. `/hackathon/[id]` no longer redirects + signed-in visitors into the member view (that assumed signed in ⇒ member); + it renders the event and offers the right ask: open your view, you're + waitlisted, join, or log in and come back. Joining from the event's own + page works at all now, which it never did. +- [ ] B2 — still open, and now scoped: the page reads the entity from `List`, + whose shallow entry carries name/description/dates/logo/status. That is + enough for a public event page, so `Get` stays the member view. Decide the + public-visibility matrix (see `backend/rbac.md`) only if a non-member + needs the deep tree — tracks, pages, the project list. +- [x] F2 — real Join button: `?/join` form action on the dashboard calling + `HackathonService.Join`, backend verdicts translated to messages +- [x] B3 — DECIDED: the `register` **capability** governs; + `settings.registrations_enabled` is vestigial and is deliberately not + enforced and not exposed in the UI. Reasons, in order: the capability is + the gate that is actually enforced and it is phase-aware, which is the + schedule organisers already maintain; the setting defaults to FALSE, so + enforcing it would make every existing hackathon unjoinable for no gain; + and "registration is closed" is already expressible by closing the + capability window. The proto field stays until there is a reason to make a + breaking change — nothing reads it. The voting page wires up + `EditSettings` for `voting_enabled` ONLY, with a comment saying why its + neighbour is left alone. +- [x] B4 — persist phase dates on create. Caught end-to-end by the new + `act6.phase.current` flow: the recipe created phases WITH dates, every + phase rendered undated/"Upcoming", and clearing the current phase could + never show "In progress". (`act6.ui.timeline` only asserted names, so it + stayed green for months — the assertion has to be on the state, not the + list.) +- [x] B5 — casbin write errors no longer swallowed in team membership ops. NOTE: + the two stores cannot share a transaction (casbin writes on its own + connection; an ent tx held across it deadlocks) — compensating writes are + used instead, ordered so a partial failure is always inert-not-privileged +- [x] B8 — added the missing casbin check to `SetPreference` (`project`/`read`, + chosen so waitlisted participants may still mark preferences) +- [x] B7 — `SubmitVote` reads the stored voting policy instead of hard-coding + it: `organizerVoting` was a constant and `ownTeamVoting` was enforced + nowhere. Both default to the previous behaviour (organisers do not vote, + own-team voting allowed), so an event with no policy is unaffected, and + the policy now has a UI (voting page) and a read path (on the hackathon + entity, because these are the rules the voters are bound by). +- [x] B9 — `Join` now requires a live invitation link for private hackathons, + checked BEFORE any state check so a guessed UUID cannot confirm that a + private event exists or what state it is in. See `HackathonInvite`. + +### Preferences and submissions (2026-08-05) + +Pinned policy: **a participant's project preference is final.** `SetPreference` +adds only; there is no self-service unset, because team formation reads these +choices and letting people churn them mid-allocation keeps moving the ground +under it. The UI therefore confirms before submitting ("this cannot be undone"). + +- [x] Organizer override — + `ProjectService.RemovePreference(project_id, user_id)` requires + `project:write`, so someone who picked in error asks an organizer. + Removing a preference nobody expressed is a no-op, not an error. +- [ ] Participants still cannot SEE their own preferences: the only read is the + organizer-only `ExportPreferences`. A `ListMyPreferences` (or preferences + on the project entity for the calling user) would close it. +- [x] `EditSubmission` accepts a `form` map — answers were frozen at create + time, so a mistyped repo URL could never be corrected. An absent map + leaves stored answers alone; a present one replaces them wholesale and + must satisfy the organizer's schema, since a partial map would silently + drop required fields. +- [x] **Submission answers are now persisted.** `Submission` gained a `form` + JSON column: `CreateSubmission` previously validated the map against the + schema and then discarded it, so nothing was stored to edit or judge. + +### Personal data people can change (2026-08-05) + +Pinned policy: **Keycloak owns the identity, the platform owns the profile.** +`username` and `email` arrive on every token and are re-synced from it, so the +platform must not offer to edit them — the next request would revert it. The +display name is the platform's own field and is editable. + +- [x] `UserService.EditProfile` (display name, self-only — no user id in the + request, so it cannot reach another account). The reason the account page + was read-only was not a missing form: `WhoAmI` re-synced `display_name` + from the token on every request, and hooks calls it on every protected + page load. `syncFromKeycloak` now refreshes only the IdP-owned fields and + backfills an empty display name, so a nameless profile still gets one. +- [x] `SubmitRegistrationForm` is an upsert. It inserted only, so a second + submit hit the unique (hackathon, user) index and came back + `AlreadyExists` — a typo in your affiliation was permanent. One row per + person is the current state of the answers, not an append-only log; + `submitted_by` is re-stamped so an organizer correcting a walk-in's paper + form is recorded as the author of THOSE answers. +- [x] `GetRegistrationResponse` reads the answers back. Deliberately its own RPC + rather than a field on `Get`: `Get` denies waitlisted users, who are + exactly the people who still need to review their form. Own answers need + no casbin check; someone else's needs hackathon `Write`. +- [ ] Changing email/password still means leaving for Keycloak's account console + (`/account` links to it). Proxying those through a backend Keycloak Admin + API client would keep people in the app, at the cost of giving the backend + admin credentials it does not have today. +- [ ] Consent withdrawal is only as granular as the form: unticking the photo + consent updates the row, but nothing propagates that to photos already + published. + +### Security + +- [ ] F6 — markdown pipeline: parser + sanitizer (no raw HTML, allowlisted video + embeds) **before** rendering `Page.content`/`description` +- [x] F3 — `PERMISSION_DENIED` → 403 on `/manage/users`; audit found and fixed + two more unguarded loads (dashboard, submissions fan-out) +- [ ] Votes/ballot privacy: keep individual ballots non-listable except + voter+admin (see rbac matrix) +- [ ] **Content Security Policy** — none configured today (`svelte.config.js` + has no `kit.csp`). It is the second line of defence behind F6: if the + markdown sanitizer ever has a hole, a strict `script-src 'self'` still + stops the injected script from executing. Sequence it right after F6, + since both defend the same page. Directives it must cover: `script-src` + (self only), `frame-src` (allowlisted video providers, for the pasted-URL + embeds), `img-src` (self + `data:` + the image hosts the seeded editions + use — Firebase Storage, SDSC CDN), `connect-src` (self; plus the ingest + endpoint if session replay is ever adopted), `object-src 'none'`, + `base-uri 'self'`. Roll out with `Content-Security-Policy-Report-Only` + first, watch the violations for a week, then enforce. + +### Contracts & polish + +- [x] B10/B11/B12 — Edit-optional semantics (team + project), modifier edges, + team error messages (+ typo). `Delete`'s missing team-scoped fallback was + deliberately NOT added: team members deleting their own team is a policy + change, guarded by a green test and documented in rbac.md +- [x] B14 — `PageService.List` no longer masks NotFound behind the permission + error; "reordering2" typo gone +- [x] B6 — `CreateSubmission` version race: retries once on constraint + violation, then `Aborted` instead of `Internal` +- [x] B13 — added, not dropped: `created_at`/`modified_at` on `Vote` and on + `VoteCategory` (its proto declared them too and they were equally zero). + Landed with ranked/points voting, which touched the same two schemas + +### Ranked and points ballots (2026-08-07) + +`VoteCategory.voting_method` had offered all three methods since the schema was +written, and the organiser's `