Skip to content

Develop - #189

Closed
sabinem wants to merge 294 commits into
mainfrom
develop
Closed

Develop#189
sabinem wants to merge 294 commits into
mainfrom
develop

Conversation

@sabinem

@sabinem sabinem commented Aug 18, 2026

Copy link
Copy Markdown
Collaborator

No description provided.

caviri added 30 commits August 4, 2026 07:22
Profile-gated cloudflared sidecar exposes the running frontend on a
random trycloudflare.com URL for sharing; vite allows the tunnel
host. Anonymous browsing works fully; the OIDC login flow stays
local-only (Keycloak lives on localhost) — documented.
The 'Trusted by' section rendered six empty placeholder squares in a
non-wrapping flex row (~576px), the main horizontal-overflow culprit
on phone widths and the 'broken logos' report. Renders actual logo
assets where they exist (SDSC light/dark, ETH, EPFL), plain names for
the rest, and wraps.
The notifications sidebar was a fixed w-80 shrink-0 column in a
non-wrapping flex row (377px overflow at 390px width); it now stacks
below the main column until lg. The users table scrolls in its own
container instead of stretching the page.
Inotify events do not cross the Windows bind mount, so Vite served
stale modules until a manual restart. Polling keeps hot reload
honest inside the container.
Hero CTAs stack on phones; award cards and feature cards go
single-column; trending tabs wrap; the footer wraps instead of
overlapping its three groups at narrow widths. Partner-strip
ETH/EPFL logos follow the footer's invert convention (white-native
svgs: invert in light mode, none in dark).
…h-mux

One public trycloudflare hostname now serves the whole stack: a caddy
sidecar (compose profile tunnel) routes /realms/* and /resources/* to
Keycloak and everything else to the frontend, and the tunnel targets
caddy instead of vite. Keycloak trusts X-Forwarded-* (proxy-headers=
xforwarded) with dynamic hostname resolution (devenv's pinned
hostname=localhost blanked, hostname-strict=false), so tokens minted
through the proxy carry the public https issuer while direct localhost
use is byte-for-byte unchanged.

The per-tunnel issuer rewiring (realm client allowlist, frontend/backend
issuer configs, process restarts, /etc/hosts DNS pin for fresh
trycloudflare hostnames) is scripted in the cloudflare-tunnel skill's
up.sh --with-auth / down.sh; the .pretunnel config backups it keeps are
gitignored. The Keycloak admin console is deliberately not routed
through the tunnel.
A repo-shipped theme (tools/configs/keycloak/themes/hackagon) extends
keycloak.v2: SDSC logo + HACKAGON wordmark in the brand slot, the app's
green and mono font from hackathonsdsc.css, surface-50/surface-950 page
backgrounds following prefers-color-scheme, a centered layout with
mobile-safe 100svh height, and green focus/accent overrides where
PatternFly pins blue at the component level. Wired via the theme-folder
SPI with caching off so CSS edits show on refresh; the realm selects it
through loginTheme (plus a Hackagon displayName for the wordmark).
From the 2026-08-04 code audit (docs/TODO.md):

- B1  Join nil-derefs on hackathons without an end date. An absent EndsAt
      means "never finished" — the same rule computeHackathonStatus applies —
      so the hackathon stays joinable instead of crashing.
- B5  AssignUser/RemoveUser logged casbin failures and returned success,
      letting the join table and the policy table drift. Both stores are now
      written with compensating rollback. They cannot share a transaction:
      casbin writes on its own connection and an ent tx held across that write
      deadlocks. Order is chosen so a partial failure leaves the user inert
      (row without role) rather than privileged (role without row).
- B6  CreateSubmission's count+1 version raced into the unique index and
      surfaced as Internal; it now recounts once and returns Aborted.
- B8  SetPreference was the only mutation with no casbin check. Enforces
      project/read, which waitlisted participants hold — they may still mark
      preferences, as the lifecycle recipe requires.
- B10 Team.Edit and Project.Edit treated empty string as "unchanged" although
      the protos declare optional: nil now means unchanged, so a description
      can be cleared and a track unset.
- B11 Project.Edit and setApproval never recorded the modifier edge.
- B12 Team List/Get collapsed every failure into PermissionDenied (with a
      typo); genuine denials keep their code, other errors surface as
      themselves. Delete's missing team-scoped fallback is deliberately left
      alone — allowing members to delete their team is a policy change,
      guarded by a green test and documented in rbac.md.
- B14 PageService.List masked NotFound behind the permission error; stray
      "reordering2" in a SetOrder message.

Two test expectations predated a pinned policy: hackathon members read all
submissions hackathon-wide (voting requires reviewing other teams' work, and
rbac.go grants it), so the GetSubmission/ListSubmissions denial specs now
assert access.
- F2  The dashboard Join button was an alert stub. It is now a ?/join form
      action calling HackathonService.Join, with the backend's verdicts
      translated into readable messages (closed window, already joined,
      denied, gone). The backend stays authoritative; the route only maps
      codes, per the frontend/backend contract in CLAUDE.md.
- F3  /manage/users returned an untranslated 500 to non-admins. Auditing the
      other (app) loads found two more unguarded gRPC calls — the dashboard
      pair and the submissions fan-out — now mapped the same way.
- F4  returnTo was written by both guards and never read, so deep links always
      landed on /dashboard. It is now consumed after login, validated against
      open-redirect vectors. The old presence check was doubling as the
      anti-ping-pong guard for dead sessions; that role moves to an explicit
      locals.sessionUsable flag.
- F5  /my/hackathon/[id] had a layout but no page: it now redirects to
      /overview instead of 404ing.
- F7  The gRPC channel hard-coded localhost:3000 while the validated
      config.backend address was read by nothing. The channel is built lazily
      from config; the public clients become accessors so they resolve after
      config load.
- F8  Deleted the stale proto:generate script — it covered a subset of the
      protos the app imports; just codegen::proto is the real pipeline.
The workspace bind mount is 9p on Windows (osxfs/virtiofs on macOS) while
container volumes are native ext4, and node_modules is ~275 MB of small
files, so every stat() paid the crossing. Measured in this container:

  require("isomorphic-dompurify")   52,821 ms  ->  331 ms
  pnpm install                        34 s      ->  5 s
  vitest run src/lib/utils            104 s     ->  1.06 s

The first number was not merely slow, it was fatal: it exceeded vite's
60 s SSR module-transport timeout, so every route returned 500 with a
fetchModule timeout on /src/app.css. Any dependency with a large file
count could trigger that again — this removes the whole class of failure
rather than the one package that surfaced it.

node_modules, .svelte-kit and .pnpm-store now live in named volumes.
They mask the host directories, so they start empty and are invisible
from Windows: post-create.sh chowns the (root-owned) mountpoints and
bootstrap.sh repopulates them with pnpm install. All three are
reproducible from lockfiles, so losing them costs one install.
MarkdownSection rendered {@html content} with no parser and no
sanitizer, carrying a TODO about XSS. It only ever received a literal,
but SitePage content is admin-authored and lands in the same component,
so the hole had to close before that shipped.

renderMarkdown() parses with marked (GFM) and sanitizes with DOMPurify,
which filters a parsed DOM rather than a string. Policy: tag and
attribute allowlists (so every on* handler and style is dropped),
http(s)/mailto/relative URLs only, rel=noopener noreferrer forced on
anchors, and iframes kept only for youtube/vimeo embed URLs.

Two findings worth keeping: DOMPurify's DATA_URI_TAGS exception let
<img src="data:..."> through despite the URI allowlist, so a hook
strips data: hrefs and srcs explicitly; and arbitrary class attributes
are dropped because Tailwind is global and a free class is enough to
build a full-viewport overlay.

23 tests cover the defences in both a Node (SSR) and a jsdom
environment — sanitization has to happen server-side, before any HTML
reaches a client.
The footer linked /about, /privacy and /terms with a comment calling
them "external static pages". They did not exist — every one 404'd —
and PageService could not host them: Page.hackathon is Required() and
its casbin domain is /hackathon/<uuid>, but these belong to the
platform, not to any event.

SitePage is the platform-level counterpart, addressed by a unique slug
so the router resolves /about without a deploy per page. There is no
hackathon to scope authorization to, so reads are open (a visitor
follows the footer link before ever logging in) and writes require the
global Admin role via the new enforcer.RequireGlobalAdmin. Unpublished
pages return NotFound rather than PermissionDenied for non-admins:
telling a stranger a draft exists leaks the roadmap.

Frontend: (public)/[slug=sitepage] renders them through the sanitizing
markdown pipeline, and admins get CRUD at /manage/pages. The slug set
lives in a param matcher so a bare [slug] route cannot swallow typos of
real routes — and PUBLIC_ROUTE_PATTERNS must list the same slugs, or
the auth guard redirects visitors to login (it did; that is why the
routes are documented together in CLAUDE.md).

NavBar's About now points at the real page. "Challenges" is gone
rather than left pointing at "/" — no such entity exists anywhere in
the codebase.
Almost nothing on it did anything. The hero CTA pointed at a
hard-coded /hackathon/ord-2026 — a non-UUID slug that 404s (audit F10);
"Browse all" and "See all" both linked to "/", the page you are
already on; and the three trending chips were buttons with no handler,
one of them ("Challenges") naming an entity that does not exist.

The CTA now resolves to a real hackathon picked from the loaded data
(live, else soonest upcoming, else most recent) and falls back to an
anchor when there is none, so it cannot dead-end. The chips became
status filters with live counts and aria-pressed state, plus an empty
state that offers a reset. "Browse all" expands the capped list and
only renders when there is more to show.

The showcase carousel had handlers but broken markup: four slides at
25% width were all visible at once, so "next" scrolled into blank
space and dots 3 and 4 could never activate. It is now a real scrollable
track with page-based dots measured at runtime, disabled arrows at the
ends, and a touch-sized hit area.
Healthchecks and real dependency conditions. caddy now waits for dev to
be serving and tunnel waits for caddy to be healthy — cloudflared
resolves its target once at startup, so starting it against a
not-yet-listening caddy left the tunnel pointing at nothing until it was
recreated by hand. That happened today; plain depends_on only waits for
"started".

dev's healthcheck deliberately reports the APP, not the container: an
idle dev container with no `just up` is unhealthy by design, because
that is the only signal the sidecars can usefully gate on. Nothing
restarts on it. It probes both loopback families since vite binds ::1.

Postgres and Keycloak also exist as real containers now, behind the
"services" profile, with healthchecks (pg_isready; Keycloak's
/health/ready over bash /dev/tcp — that image ships no curl), named
volumes and the same checked-in realm export the Nix service imports.
Ports are not published: dev reaches them by name, and publishing would
collide with the ports dev already maps. service-bridge.sh socat-maps
localhost:5432/8180 inside dev onto them so every checked-in config
keeps working unchanged.

They are opt-in: `just up` still starts devenv's own Postgres and
Keycloak, and the Nix shell has withPostgres but no withKeycloak, so
making these the default needs a toolchain.nix change first.

postgres:18 wants ONE mount at /var/lib/postgresql; the 17-and-earlier
/var/lib/postgresql/data path makes the entrypoint abort.

Resource limits are set for the small sidecars and left unset for dev,
where a wrong ceiling shows up as an OOM kill mid-build.
Two regressions from the healthcheck work, both hit within minutes:

Adding a healthcheck changed `dev`'s compose config, so the next
`compose up` of any sidecar RECREATED dev — killing the
process-compose stack running inside it. The new
`depends_on: condition: service_healthy` then blocked waiting for the
stack that had just been destroyed, and the tunnel failed with
"dependency failed to start: container dev is unhealthy".

Gating on `dev` was the wrong design: its health depends on `just up`,
which compose does not manage, so compose can neither wait for it
correctly nor bring it about. caddy goes back to a plain dependency and
the readiness check moves into the cloudflare-tunnel skill's up.sh,
which now fails fast with an actionable message when nothing is serving
on :8081. The dev healthcheck stays — it is useful as a status signal —
but nothing depends on it. tunnel -> caddy keeps service_healthy, which
is the race that actually needed fixing and which compose fully owns.

Recreating the container also wiped the apt packages that
`playwright install --with-deps` had added at runtime, so the e2e suite
died with "libgtk-3.so.0: cannot open shared object file". Those
libraries are now baked into the image, where recreation cannot take
them.
Two things a visitor could not trust.

The public event page was a mock. The title was the literal
"Open Research Data Hackathon 2026" — shown for EVERY hackathon — with
hard-coded dates, venue "ETH Zurich, Zurich", capacity 42/100, two
invented webinars with fake speakers, fabricated highlights and a video
captioned as last year's event. Only the News & Pages section was real.

It now renders the backend's name, dates, status and description. Venue
and capacity are not in the schema, so the hero omits them: HeroSection's
props became optional and the fields render only when supplied, because
an absent field is honest where a placeholder is not. The invented
sections are gone rather than genericised — they were factual claims
about events that never happened. Description and page content go
through the sanitizing markdown renderer.

Anonymous callers cannot call Get (it still requires membership — audit
B2), so the page is built from List, which already serves public
hackathons to everyone. Private and nonexistent both 404, which is
correct: confirming a private event exists is a leak.

Separately, the CMS could publish pages nobody could reach. The
[slug=sitepage] matcher hard-coded about|privacy|terms, so a page an
admin created showed as Published with a "View page" link that bounced
to the login screen. Both the matcher and the auth guard now derive from
one rule in lib/utils/sitePageSlug.ts: any lowercase-kebab segment that
is not a reserved route. Deriving both from one place is the point — the
previous arrangement required remembering to edit two files, and I had
already documented that trap before falling into it.
Two related holes. Privacy was discovery-only: List hid private events but
Join never checked visibility, so anyone who learned a UUID could join one
outright (B9). And signed-in non-members opening any hackathon were
redirected into the member view, which answered 403 with no way forward
(F1).

HackathonInvite is a revocable, multi-use link an organizer generates on
demand. The token is a v4 (random) UUID rather than the v7 used for ids:
a link secret must not encode its creation time or be guessable from a
neighbouring one. Revoking sets a timestamp instead of deleting, so the
audit trail survives.

Redeeming grants VISIBILITY, not membership. /invite/<token> renders the
event for anyone holding the link — signed in or not, since the token is
the credential — and Join still puts them on the waitlist for the
organizer to approve. A forwarded link therefore cannot insert a stranger
into the roster; it only gets them as far as asking.

Join now requires a live invite for private hackathons, checked BEFORE
any state check: answering "already finished" to a caller holding
nothing but a guessed UUID would confirm both that the event exists and
what state it is in. Every rejection returns the same PermissionDenied,
so a wrong token, a revoked one and one belonging to another event are
indistinguishable.

PreviewInvite takes ONLY the token — never a hackathon id — so it cannot
be used to probe which events exist, and unknown/malformed/revoked all
yield the same NotFound.

The 403 dead end becomes a page that tells an invitee what to do without
confirming anything: private and nonexistent look identical, and it
explains that private events are joined through an emailed link while
public ones can be registered for directly. Anonymous visitors still get
a plain 404 — only signed-in users see the explanation.
…unt deletion

The four capabilities the lifecycle recipe referenced but nothing
implemented, so six of its actions could only self-skip.

SetEmailTemplates stores organizer-authored notification copy against a
CLOSED key set (registrationConfirmed, teamAssigned, deadlineReminder,
results) — an unknown key is rejected rather than stored silently, which
would leave the intended message unwritten. Nothing sends these yet; the
copy is what organizers author and what the recipe pins.

SetBranding does partial updates and validates colours as real hex
server-side: they are interpolated into CSS, so trusting the frontend to
escape them would be the wrong place for that check.

CreateSubmission gains a structured  map validated against the
organizer submission schema, sharing one validator with the registration
form. Validation is opt-in: with no schema configured anything is
accepted, so this cannot break events that never defined one.

DeleteAccount pins the semantics the recipe asked for. It takes NO user
id — self-service GDPR, not an admin removal tool. Participation rows and
every casbin role (g and g2) go; the Keycloak identity does not, so the
person can sign in again and start fresh rather than being locked out of
an account they cannot recreate. Authored content (pages, projects,
submissions) is Restrict-guarded and refuses with FailedPrecondition
instead of cascading — removing an event page because its author left
would damage other people records. Roles are purged LAST: a stale
grouping row would otherwise re-grant access to whoever next registers
that Keycloak subject.
Until now exactly two routes in the app could write anything. Every
organizer action — approving a participant, publishing an event page,
handing out an invitation, even creating the event — was grpcurl-only.

/my/hackathon/[id]/manage is the cockpit, shown only to owners (the
backend denies everyone else anyway; hiding the tab avoids showing a door
that will not open):
  - participants: approve, decline, remove, with the waitlist separated
    from the confirmed roster
  - invitation links: generate with a note, copy the full URL, revoke
  - event pages: create, edit, show/hide, delete

/hackathon/create is the missing front door — the sidebar already linked
to it. It mirrors the proto rules in human wording (name >= 3 chars,
both dates or neither) so people get the message inline instead of a raw
validation error, and it explains at the point of choosing that a private
event is invitation-only. Creating makes the caller Owner, so it lands on
the cockpit where the next steps live.

Also drops the sidebar's /admin link, which never existed.
SetBranding was write-only storage: nothing read it back, so the colours
and banner an organizer set had no effect anywhere.

Branding now rides on the Hackathon entity, mapped from the forms row.
brandingEntryFromEnt returns nil when nothing usable is set, so "no
branding" is an absent field rather than a message of empty strings —
clients can treat presence as "this event has a look of its own".

Eager-loaded on BOTH Get and List. List matters more than it looks: the
public event page is built from List because Get requires membership, so
loading it only on Get would have left branding invisible in exactly the
place visitors see.
… mock data

Every organizer API now has a UI, branding actually shows, and the last
fabricated content is gone.

Cockpit gains phases (with AdvancePhase to mark the current one), tracks,
prizes (Finalize behind a confirmation — it is the admin's final voice
and cannot be undone), deadline windows (the override is now-anchored, so
the copy says the extension runs from when it is applied, not from the
original deadline), event settings, and per-capability toggles. Prizes and
windows are write-first panels: no read RPC exists for either, so they
render what the server returns after a save rather than pretending to
show current state. datetime-local submits a zoneless wall-clock string,
so an enhance helper converts to ISO in the browser — otherwise the
server would read the organizer's 18:00 in its own timezone.

Branding renders through a wrapper that declares --brand-* on ITSELF,
never :root, so one event's colours cannot repaint the platform; it is
display:contents so an unbranded event gains no box and shifts nothing.
Colours are re-validated as hex before reaching a style attribute even
though the backend validates on write — proven load-bearing by writing a
hostile value straight into Postgres, which the frontend then dropped.

Participants, webinars and photos read real data (audit F10). The 12-person
demo roster is gone, and with it affiliation/skills/LinkedIn — those fields
do not exist on User, so showing them meant inventing them. Webinars and
photos read PageService.List rather than the layout's embedded pages,
because Get embeds unpublished drafts while List applies the visibility
rule server-side: using layout data would have leaked organizer drafts to
ordinary members.
Two read-path gaps that made working UI impossible.

Hackathon.Get now carries registration_form and submission_form. The
schemas existed as a FormSchema proto and were validated against on
write, but no RPC returned them — so a client had to GUESS the
organizer's field keys to submit anything, and the participant UI fell
back to a raw key/value editor. Get-only on purpose: you need them to
render a form you are about to fill in.

ListVotes declared voter_id and submission_id as strict UUIDs while the
handler treats empty as "no filter", so protovalidate rejected the empty
case before the handler ran and EVERY partially-filtered call was
impossible — including the obvious "all votes in this category". Now
allowed via the same CEL escape hatch page_id uses in phase_svc. Verified:
the error moved from InvalidArgument to a correct NotFound.

category_id keeps the strict rule; listing votes without one is not a
supported query.
Closes the bulk of the API-to-UI gap: 38 RPCs had no caller, now 9, and
four of those are proto-only stubs that return Unimplemented (audit B15).

Participants can now do the things a hackathon is made of. Propose,
edit and withdraw a project; mark preferences; start, edit and finalize
their team's submission with version history behind a two-step confirm.
Closed deadline windows return FAILED_PRECONDITION, which reads as a
permission failure — they are translated as "this is a deadline, not a
permission problem", because a participant who is late must not think
they were excluded.

The voting phase existed only as backend. Organizers get categories,
a live tally, placements and exports but NO ballot, with the reason
stated — organizer status is derived from backend data, not guessed:
Get only admits confirmed members, owners and admins, so no membership
row means global admin. A duplicate vote shows the voter their existing
ballot (id kept in an httpOnly cookie, re-read through GetVote and
discarded unless voterId matches) rather than a raw ALREADY_EXISTS.

Organizer config gains registration/submission form builders, voting
policy, email templates, branding with a colour picker, team membership
and page reordering. Two traps avoided there: repeating rows use selects
rather than checkboxes, because an unchecked checkbox submits nothing
and would shift every later row's flags onto the wrong field; and
replace-semantics APIs (SetEmailTemplates, SetOrder) always post the
complete set, since a partial submission deletes what it omits.
Completes the API-to-UI work: of 62 RPCs, 55 now have a frontend caller.
The remaining 7 are four proto-only stubs that return Unimplemented
(audit B15) and three readers whose List equivalents already drive the UI.

/register/[id] deliberately sits OUTSIDE /my/hackathon/[id]/, because that
subtree calls Get, which denies waitlisted users — and a waitlisted person
is exactly who still has a form to fill in. The schema is read from List,
which carries registration_form and serves public hackathons to anyone.
Consent keys are posted explicitly: an unchecked box submits nothing, so
"refused" and "never asked" would otherwise be indistinguishable.

/account shows what the platform holds and offers deletion behind typing
your username.

DeleteAccount now draws the erasure line explicitly, which the journey
forced: a registration response is personal data (affiliation, dietary
needs, consents) and cannot outlive the profile, so responses, ballots,
preferences, seats and jury assignments go with the person. Content they
authored for others — pages, projects, submissions, tracks — still blocks
with FailedPrecondition, because deleting an event's content when a
contributor leaves damages other people's records. Deleting ballots does
change tallies; the alternative is retaining an identified vote for
someone who asked to be erased.
Email templates were inert twice over: they stored a body but no subject,
and nothing could read them back — the cockpit could not even show what
was saved. Each moment now stores subject + body, and email_templates
rides on Get.

EmailComposer turns that copy into something sendable, since Hackagon has
no notification service: a mailto: link plus separate copy buttons for
recipients, subject and body, with an audience picker. Recipients go in
BCC — a roster is personal data and forty addresses in To: would disclose
every participant to every other one. {event} is substituted; {team},
{project} and {window} differ per recipient and are flagged rather than
sent as literal braces. Past ~1800 characters the mailto link is withheld
in favour of the copy buttons, because clients truncate long URLs and a
silently truncated recipient list is worse than no link.

Preferences are now explicitly final: participants confirm before marking
one and cannot unset it, because team formation reads these choices.
ProjectService.RemovePreference gives organizers the override, so a
misclick is fixed by asking rather than by letting choices churn.

EditSubmission accepts a form map — and adding it exposed that Submission
had no column for answers at all: CreateSubmission validated the map
against the schema and then discarded it, so nothing was stored to edit or
judge. The entity now persists it, Create writes it, Edit replaces it under
the same validation.
The registration form was unusable on a phone and cramped on desktop.

PHONE KEYBOARD. Measured at 390x844: the page had ZERO scroll range, so
when iOS shrank the visual viewport for the keyboard, Safari had nothing
to scroll and the password field (y=524) and Sign In (bottom=628) sat
permanently under the ~460px left above the keyboard. Cause was mine:
min-height:100svh + align-items:center pins the card to the middle of a
page exactly one viewport tall. Now centred with auto margins, which
yield instead of pinning, plus a keyboard's worth of padding that appears
only while a control is focused — no dead space at rest. Sign In now sits
at 376px, fully visible without scrolling. Inputs stay >= 16px so iOS
never auto-zooms.

SLIM COLUMN, two causes. PatternFly adds 98px inline padding per side at
>= 1200px, leaving 284px of a 30rem card, so six registration fields lived
in a 188px box. And flipping the container to flex-direction:column
changed what align-self:start means — on the inline axis it made the card
shrink-wrap instead of fill. Staying in grid fixes both; desktop is now a
brand panel beside a 32rem card, collapsing to one column below 64rem.

OVERFLOW WAS REAL AND I WAS HIDING IT. The previous overflow-x:hidden
masked keycloak.v2's "Restart login" tooltip, parked at left:160% and only
visibility:hidden so it stays in layout: +103px at 390px. Mask removed,
tooltip re-anchored, and the screenshot spec now asserts
scrollWidth - innerWidth === 0. The background seam was the same family:
the background shorthand reset background-repeat, so gradients tiled once
the page outgrew one viewport.

CONTRAST, measured. White on green-600 was 2.5:1 — now green-950 on green
at 4.93:1, the pairing the app itself uses. Links were 2.5:1, now 5.15:1.

CSS-only: keycloak.v2 already emits the two blocks the split needs, and
copying .ftl templates out of the jar would freeze markup that gains
features every release with no signal when the copy goes stale.
The brand block now renders the SDSC mark and the tagline only.

Two things had to change together. Keycloak fills the header text from the
realm's displayName and, when that is blank, falls back to the realm NAME --
so clearing displayName alone brought "hackagon" straight back.

Suppressing it in CSS then has to happen at id specificity: the stock
keycloak.v2 rule is `#kc-header-wrapper { font-size: 29px; text-transform:
uppercase }`, which outranks a class-only override. The class rule looked
like it worked because the desktop panel override happens to be an id
selector -- so the wordmark vanished at >=64rem and stayed on phones.
Clicking the avatar now opens a menu: who you are signed in as, My
hackathons, Your account, and -- for the roles that have them -- Create a
hackathon and the platform Pages/Users admin. Before this there was no
navigation to /manage/* at all; you had to know the URL.

Entries are gated by the backend's own answer (casbin g2 via WhoAmI, passed
down from the (app) layout load), not by a client-side guess. Every page
behind them still enforces on its own -- the gating only avoids offering
doors that will not open.

The button deliberately has no aria-label: it would replace the visible
initial as the accessible name, and that initial is how a signed-in user
(and the e2e suite) tells whose session this is.
The row had a fixed height and laid its title, meta and badges out in one
line at every width. On a phone a three-line title either overflowed the
box or collided with the status chip.

The row is now a column below sm: title and meta stack, badges move under
them (indented to the avatar's text column so they read as part of the row),
and the fixed height becomes a min-height so a long title can grow the row
instead of spilling out of it.

The dashboard's membership badge used to be a sibling floated beside the
row; it now passes through the row as extraBadge so it groups with the
status chip rather than floating next to a wrapped title.
/hackathon/create lives in the (app) group and needs locals.grpc, but the
public-route pattern /^\/hackathon(\/|$)/ matched it. hooks.server.ts
therefore skipped the auth setup, the group's own guard found no grpc and
redirected to login, and the signed-in user was sent straight back: an
infinite loop on the one page that creates a hackathon.

Public event pages keep their pattern; create is carved out with a negative
lookahead.
caviri and others added 28 commits August 15, 2026 16:28
…d it

The premise needed correcting first: og-default.jpg was already a branded
typographic card, not the stock photo I assumed. But dbbc8c8's own message says
it was "rendered once with satori + resvg-js in a throwaway script, not a project
dependency" — so the real gap was reproducibility, not design. The template and
build script now live in the repo and print dimensions, size, alpha, headline
fill and grid contrast on every run.

Kept the established design language rather than restyling: static/social/
{banner,square,story}.png are the same design at three other sizes, and changing
only the OG card would desync the set. The SDSC mark is placed and scaled, never
restyled — it is a real organisation's identity.

What changed is the words and one repetition. The mark carried a "Hackathons"
lockup that the headline then repeated, so it is the bare mark now, as the app's
own footer uses it. The subline was a feature list — "Propose projects, form
teams, and build solutions together" — and is now an invitation: "Bring an idea,
or join one. Then build it together." Set in sans, per the theme's rule that mono
carries labels and sans carries sentences; an invitation in a typewriter face
reads as a terminal banner. Event pages pass NO image to <Seo>, so this card is
literally what appears when a hackathon link is shared — the title carries the
event, so the image carries the platform.

## Two bugs found while building it

My own headline check was VACUOUS: it measured 1056px, which is exactly
1200 − 144 of padding, because #headline is a block. It was asserting on the
padding and would have passed in any font. It measures the text itself through a
Range over the element's contents now (882px, 83% of the content box).

And `oklch()` colour stops render as NOTHING inside repeating-linear-gradient in
Playwright's Firefox — no error, no console message, and getComputedStyle
happily reports the fully-resolved gradient. The grid was invisible in the first
two renders and was found only by sampling pixels out of the PNG. The template
uses each token's measured sRGB with the oklch source in a comment, and the
renderer asserts the grid's 60px period IN THE FINISHED JPEG, because a wash can
render and still be destroyed by the downscale or the quantiser.

1200×630 exactly, opaque, 56.8 KiB, identical md5 across runs. The lfs clean
filter's pointer oid and size match the file's sha256, and the file starts ff d8
— a real content change, not a pointer mismatch. og:image resolves absolute at
runtime, which matters because most clients ignore a relative one.

Playwright and sharp are borrowed from the e2e skill's node_modules at render
time rather than added to the frontend's package.json — no dependency or
lockfile change for a build-time-only tool.

Note the running :8081 server still serves the old card: build/ snapshots static/
at build time, and it was left alone deliberately because two servers have been
running off it for hours and a concurrent agent depends on them. It needs a
rebuild and restart to serve this.
…tion record

`embed-run-report.mjs` had the same bug `build-quality-report.mjs` was fixed for
in 226d201: it wrote `recipe-player.html` and only then ran its read-back,
spec-count and close-tag checks. A bad splice corrupted the player and the
validator reported it afterwards — the corrupt file existed either way.

Same treatment, reusing that file's approach rather than inventing a second one:
assemble in memory, run every check against the string, and only on a clean pass
write a same-directory temp, fsync, rename (retrying EPERM — 9p, container trap
5), then re-read the destination and throw unless it matches. Note the invariant
differs by file: the player asserts THREE literal close-script tags, the quality
report asserts one. Both now say "checked first, then written" on success.

## The mutation record is current again

`verify.json` held 23 EXACT / 11 GAP from 2026-08-13, two days behind a manifest
that declares 37 EXACT / 1 GAP — so the quality report was printing a derived
callout about its own stale input. Re-run: **38 run, 36 exact, 1 known gap,
0 untested properties, 1 other failure.**

That confirms from a fresh run what the manifest had only claimed: the eleven
gaps closed by the 28 backend specs are genuinely closed, and no property is
untested. `EXIT=1` is the runner working — any non-EXACT verdict fails the run,
which is what stops a quiet regression sliding past.

The single other failure is the known shape rather than a new one:
`capacity.oversell-by-one`'s witness is a declared-flaky concurrency spec, so it
can come back MISMATCH when the flake fires rather than when anything is wrong.
A second run was started to separate those two; the record here is the first
run's, and the entry stays listed as flaky-witnessed rather than quietly excused.

Journal empty and `components/` clean afterwards, verified — a mutation left
applied and then committed is the worst thing this tooling can do, and it is why
nothing was staged while the sweep was running.
Loose ends: a social card that can be rebuilt, validate-before-write, and a current mutation record
`capacity.oversell-by-one` was judged by a spec that hammers concurrent
joins — declared flaky in the mutation runner, and ambiguous in both
directions: a MISMATCH could be the flake firing, and the flake once
stayed GREEN under the very mutation it exists to catch.

The half an off-by-one cap actually breaks needs no concurrency: it is
where the last seat IS, not whether two arrivals can share it. This
fills an event to exactly its capacity one join at a time, reading the
roster back from the DB after each, and asserts the next arrival queues
with the confirmed count unmoved. Three capacities, because a rule that
is off by one is off by one at every cap; the seated joins at capacity
2 and 3 are the positive control against an event that simply queues
everybody.

The concurrency spec stays — it pins a real race that once oversold.
…lake

The entry's expectReds named the concurrency spec — the runner's one
KNOWN_FLAKY entry — which made the verdict a coin flip in both
directions. A MISMATCH could be the flake firing rather than a
regression, and the flake has stayed GREEN under this very mutation,
which is an expected red that never arrived and no excuse list can
reach: expectReds is by design where the filter does not apply.

Recorded rather than predicted (--record, then read). The observed set
under the mutation is 7 reds; 6 are frozen and the race spec is left
out, so its red now lands in the extras column, is excused with its
reason, and is PRINTED on every run of this entry. The evidence stays
on screen without being on the ballot.

The rule that survived is written down in both places that used to
teach the opposite: a verdict may only rest on witnesses that cannot
flake. The runner's own "put it in expectReds" hint said the thing
that caused this, so it now says what to do instead.

The flake itself is untouched and still reds CI — a test-side race,
tracked separately.
Both were hit while getting the running :8081 server onto the current
static/og-default.jpg, and both were previously written down as
something else.

The 9p EPERM on the atomic swap is not only the filesystem. With the
:8081 adapter-node server up, `frontend-build.sh build` burned all five
rename attempts and gave up; one `prod-frontend.sh stop` later the very
next attempt succeeded, with the :8082 server still running. When every
retry fails, ask who is serving the tree. The order is stop, build,
start — which is what prod-frontend.sh's `start` already does, so the
case that hits the wall is calling the helper directly.

And swapping the tree in does not reach a server that booted against
the old one. adapter-node serves the client dir through sirv, which
builds its manifest once at boot, so afterwards it streams the CURRENT
file while advertising the PREVIOUS length and ETag: the tunnel's :8082
server answered every request with Content-Length 85099, an ETag to
match, and 58130 correct bytes. Hashing the download says MATCH — the
body really is the new card — while every client sees a truncated image
and every cache honours a false ETag. Compare headers, and read curl's
exit code. Restarted both servers; verified byte-for-byte and
header-for-header against the static file.
The two traps recorded in e93f1c1 were hand-wrapped; prettier owns .claude now
(the exemption came off during the develop merge), and CI runs the formatter
with --fail-on-change. Layout only.
A capacity witness that cannot flake, and the social card actually served
Both icon links pointed at static/favicon.png, which is 32x32. That file
is right for a browser tab, but `apple-touch-icon` is what iMessage,
Safari, Slack and most other link-unfurl clients read for the small site
icon on a preview card, and Apple asks for at least 180x180 there. At
32x32 those clients either upscale it to a blur or drop it outright,
which is what "the favicon is not showing up properly in social
previews" was.

Adds a 180x180 apple-touch-icon.png rendered from the vector mark in
static/logos/sdsc.svg (square viewBox centred on the mark's bounding box,
so the glyph keeps its aspect ratio and gains a small inset that iOS's
rounded-corner mask will not crowd), and points the link at it with an
explicit `sizes`. Transparent background, matching favicon.png. The
32x32 `rel="icon"` tag is unchanged.
Shared links rendered a broken-image glyph where the site icon belongs in
Slack's preview card. Slack's unfurler — like several other crawlers and
some browsers — requests /favicon.ico at the site root by hard-coded
convention and never reads <link rel="icon"> at all, and static/ had no
.ico, so that request 404'd. This is separate from the apple-touch-icon
sizing in the previous commit: Slack does not consume apple-touch-icon.

Not an auth-guard problem, despite /favicon.ico being absent from
PUBLIC_ROUTE_PATTERNS: static assets are served ahead of hooks.server.ts,
which favicon.png and og-default.jpg already demonstrate by loading on
unauthenticated pages. It was a plain missing file.

Adds a multi-resolution favicon.ico (16, 32 and 48 px sub-images, 32bpp
with alpha) rendered from the same vector mark and the same square crop as
apple-touch-icon.png, so every icon surface shows identical framing. Also
declares it in app.html for completeness, without a type or sizes so the
existing, more specific 32x32 PNG still wins in browsers that support it.
…con-size

fix(frontend): repair the site icon in link previews
…king

The refresh path re-added the Keycloak id_token to the JWT that Auth.js
encrypts into the session cookie. Access + refresh tokens alone encrypt to
~3.8 kB; the ~1.2 kB id_token pushed the value past @auth/core's 3936-byte
chunk threshold, splitting it into two ~4 kB Set-Cookie headers that overflow
a reverse proxy's default 4 kB response-header buffer -- so every response
became a 502, roughly 4.5 minutes into each session when the first refresh
fired.

Strip idToken ahead of every return path, including the still-valid branch so
sessions minted before this fix recover without re-login, and stop storing the
refreshed id_token. Nothing reads it. Ports the tested fix from main.
hooks.server.ts leaves locals.platformUser undefined when WhoAmI returns
UNAVAILABLE, but the auto-Register that runs on NOT_FOUND had no such rescue,
and the dashboard loader dereferenced platformUser!.id unconditionally. A
backend that dropped between the two calls, or during the first page after
login, surfaced as a bare TypeError 500 that named nothing.

Wrap the auto-Register in the same UNAVAILABLE rescue WhoAmI already has, and
guard the dashboard loader with a 503 that names the symptom.
CapabilitiesPanel, PageForm and PhaseForm all render class="checkbox", but
nothing defined the rule, so every switch fell back to a browser-default box
that ignored the theme. Adds the appearance:none / accent-fill style from main.
…allers

ListVoteCategories, GetVoteCategory and GetVote used RequireSubject, which
admits the anonymous subject. The vote-category entry mapper embeds jury
members' emails, so anyone who could name a private event's id could read its
jury roster; GetVote returned any voter's ballot by id, breaking ballot
secrecy.

Require a real user and enforce hackathon:read on the category reads
(member-scoped, matching who needs to vote) and hackathon:write on GetVote
(organizer/admin, matching its sibling ListVotes).
…0818

fix: QA cluster — id_token 502, first-login 500, .checkbox theme, vote-read authz
…team

The Rules form used a bare `use:enhance`, whose default success handler calls
form.reset(). `checked={data.policy.ownTeamVoting}` sets the checkbox PROPERTY,
never the defaultChecked ATTRIBUTE, so reset reverts every box to unchecked.
`ownTeamVoting` defaults to checked and round-trips to the same value, so
Svelte's fine-grained reactivity never re-asserts the property — the reset's
uncheck sticks and the save looks like it turned the rule off. The data was
saved correctly; only the display lied, and a second save from that state would
have stored the false the organiser never chose.

Uses the file's existing `keepValues` handler (update({ reset: false })), which
keeps the invalidation that reflects genuine changes while dropping the
destructive reset.
castBallot mapped every FAILED_PRECONDITION to one string, "Voting is not open
for this hackathon." SubmitVote answers that code for two distinct states — the
votingEnabled master switch being off, and the VOTE capability being closed by
the current phase — so an organiser was told voting was closed with no way to
know which of the two separate controls to fix. Surface the server's own detail
(the same `e.details || "…"` pattern this file already uses for other codes).
TeamCard resolves its moreInfoHref through SvelteKit's resolve(), which takes a
route id. The teams list passed "#team-{id}" — a bare fragment, cast to `any`
to get past the type checker — and resolve() throws on it, so clicking "More
Information" errored. There is also no element with that id to scroll to: main's
teams/[teamId] detail page was not carried onto this branch, so the fragment
never had a target.

Guard the link on a real in-app route (moreInfoHref.startsWith("/")) and stop
the teams page passing a fragment, so the dead link is not offered until a team
detail page exists to point at. ProjectCard's callers pass real routes and are
unaffected.
…ng-20260819

fix: voting-rules save, ballot-refusal message, teams More-Information error
The event header derived participantCount from hackathon.members.length, which
includes waitlisted members. A waitlisted person has not joined yet, so the
headline "N participants" over-counted for every viewer, admins included (issue
#184). Filter out is_waiting; the roster still lists them — this is the count,
not the list.
…h3-20260819

fix: count only confirmed participants in the event hero (issue #184)
resolveRegistrationTarget checked Hackathon:Write for the on_behalf_of path but
returned the caller unconditionally on the self path. So any authenticated user
could POST SubmitRegistrationForm for ANY hackathon — a private one they were
never invited to included — which both wrote a FormResponse row into that event
and turned the validation errors into a form-schema oracle (they name missing
and unknown fields).

Require a Participant row on the self path. Join is what writes it, and a
private event's Join requires an invite, so a participant row is the proof the
caller belongs in this form. Waitlisted participants pass — they are exactly who
still needs to submit or correct their answers. The check runs before the form
is loaded, so a non-participant is refused before any schema detail leaks.

Pins it: registration_gate_test.go asserts a non-participant gets
PermissionDenied and a waitlisted joiner passes the gate (reaching the
form-not-defined FailedPrecondition — a different code is the proof).
…indow (D6)

The SUBMISSION_ATTACHMENT presign path checked casbin Submission:Write but not
the submissions window or the create_project_submissions capability — the two
gates every other submission write goes through (CreateSubmission,
EditSubmission, FinalizeSubmission in team_service.go). So a team could presign
and upload an attachment after the deadline closed, or while the submit
capability was off, slipping work in past the window the submission handlers
enforce.

Add the same requireWindowOpen(windowSubmissions) + requireCapability(
CreateProjectSubmissions) calls the submission handlers use, on the same
hackathon id already resolved from the submission's team.
…rity-d3-d6-20260819

fix(backend): close D3 (registration-form self-path) and D6 (attachment window bypass)
ListVoteResults used RequireSubject, which admits the anonymous subject, so an
unauthenticated caller could read placements the moment VIEW_RESULTS was open —
the handler's own comment says "any signed-in user". Switch to RequireUser. The
capability gate still lets organisers review a tally before publishing, and the
only caller is the authenticated /my/hackathon/[id]/voting route, so nothing
public breaks.
…ission read (D5)

GetSubmission/ListSubmissions let a team member read their own team's submission
(team-scoped) and fell back to a hackathon-wide Submission:Read so participants
can read every team's work to vote on it. But Join grants the Member role even
to waitlisted registrants (so they can propose), and Member carries that
hackathon-wide read — so an unapproved registrant could read every team's
submissions, a composition nobody chose.

Extract a shared authorizeSubmissionRead helper: team-scoped read is unchanged,
and the hackathon-wide path now excludes callers holding a waitlisted
participant row. Confirmed participants (no waiting row) and organizers/admins
(who reach it via Owner/admin, not a participant row) are unaffected.

Pinned by submission_read_gate_test.go: a waitlisted joiner gets PermissionDenied
reading another team's submission, and approval lifts it to NotFound (past the
gate, no submission) — a different code proving the gate opened.
…rity-d4-d5-20260819

fix(backend): close D4 (anonymous vote results) and D5 (waitlisted submission read)
@caviri caviri closed this Aug 20, 2026
@caviri
caviri deleted the develop branch August 20, 2026 11:20
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants