Dåre's website. TanStack Start + Tailwind v4 + shadcn/ui (Base UI, Lyra, Mist)
- Drizzle + Postgres, with login through Datasektionen SSO. Runs on Bun and deploys to Datasektionen's Nomad cluster.
bun install
cp .env.example .env # then set SESSION_SECRET (openssl rand -base64 32)
bun run services:up # Postgres + SSO mock (docker compose)
bun run db:migrate
bun run dev # http://localhost:2027docker-compose.yml runs
nyckeln-under-dorrmattan,
a mock of SSO on http://localhost:7003. Clicking Logga in sends
you to its login page. Enter one of the users from dev/nyckeln.yaml:
| kth_id | Who |
|---|---|
turetek |
admin (dÅrestaben, which has $dare:admin) |
nollan |
regular chapter member |
gaest |
KTH user outside the chapter (guest) |
To add users, group members or permissions, edit dev/nyckeln.yaml and run
docker compose restart sso.
/auth/loginstarts an OIDC authorization code flow againstOIDC_ISSUER, without PKCE (SSO requires PKCE to be off for the client)./loginis a friendlier address for it that lands on?redirect=(default/dashboard), or goes straight there if already signed in./auth/callbackfetches userinfo and stores the user in an encrypted HttpOnly cookie (dare_session, 7 days).POST /auth/logoutclears it. No auth tables live in the database.- Anyone with an SSO account can log in. Admins are those with the Hive
permission
$dare:admin(permissionadminin the Hive systemdare). SSO includes it in userinfo through thepermissionsscope, so the app never calls Hive itself. It's read at login, so changes in Hive apply at the next login. The user is{ kthid, name, email, isAdmin, isJudge }. - Judges may score Jäger vs Minttu and nothing else. Admins add their KTH
ids on the Jäger vs Minttu page (
battle_judges, changes logged inbattle_judge_changes);isJudgeis looked up on every request, so it applies right away. Judges can log in with/loginlike everyone else (the overview sends them on to scoring), or with/domare, which goes straight there. - Pages: put protected routes under
src/routes/_authed/.context.useris non-null there. Gate admin-only content oncontext.user.isAdmin(see_authed/dashboard.tsx). - Server functions: use
.middleware([authMiddleware])or.middleware([adminMiddleware])from@/lib/auth/functions(orjudgeMiddlewarefrom@/lib/battle/accessfor scoring). Always check on the server; route guards only affect the UI.
/dashboard has a sidebar (a slide-out menu on phones) with one page per
area. Pages under src/routes/_authed/dashboard/_admin/ are for admins only.
| Page | Who | What |
|---|---|---|
| Översikt | all | Summary tiles, quick scoring and recent activity |
| Profil | all | Account, permission, theme, log out |
| Jäger vs Minttu | admins, judges | Score the battle, who has scored most; admins also manage judges and reset |
| Biljettsläpp | admins | The ticket release time the landing page counts down to |
| Puckopist | admins | The /game leaderboard: today's numbers, runs to review, hide/approve/remove runs, bans, saving on/off, reset |
| Aktivitet | admins | Everything admins have changed, filterable with tabs |
| Inställningar | admins | Switch optional features on and off (below) |
Parts of the site that are only needed for a while can be switched off under
Inställningar. Switched-off features are hidden from the dashboard and the
activity log, and the server refuses changes to them. Nothing is deleted.
Switches are stored in site_settings and logged in feature_changes.
| Feature | Default | When off |
|---|---|---|
| Biljettsläpp | on | The landing page stays, but without the countdown numbers |
| Jäger vs Minttu | off | /battle redirects to the start page |
| Puckopist | on | /game redirects to the start page |
Profile pictures (avatars) come from /api/avatar/<kthid>, which looks them
up in SSO's internal API (SSO_API_URL; SSO gets them from rfinger) and
redirects to them. The links expire, so they're only cached in memory for a
few hours. Without SSO_API_URL, avatars show initials.
Puckopist (/game) never takes a score from the browser. Before each run the
server hands out a single-use ticket (game_runs) with the piste's seed; the
game records when jump is pressed and let go, and after the crash sends those
presses with an HMAC made with the ticket's key. The server replays them on the
same seed (the simulation is deterministic, see src/lib/game/dmath.ts) and
saves its own score in game_scores. Runs finished faster than they could be
played are refused; runs that don't match their replay, or were played with
developer tools open, swapped-out timers or in slow motion, are flagged and
(while Granska flaggade åk is on) wait for an admin to approve them. See
src/lib/game/anticheat.ts.
Players don't log in, so bans (game_bans) go by an encrypted device cookie,
optionally a browser fingerprint or IP (rarely useful, as a whole party shares
the same wifi), or words in the name. Shadow bans let the player think their
runs are saved. Under Puckopist, admins can also remove runs (logged in
game_score_removals), close saving, and reset the leaderboard (only newer
runs count; nothing is deleted). Everything else they do there is logged in
game_admin_events. All of it shows under Aktivitet.
The ticket release time is stored in site_settings (a single row), and every
change is logged in ticket_release_changes. Until an admin sets a time,
2 October 2026 21:00 is used. Times are entered and shown in Swedish time
(src/lib/time.ts).
Off by default. It's only used at the ticket release pub, so switch it on
under Inställningar before the pub and off afterwards. While it's off,
/battle redirects to the start page, it's hidden from the dashboard and the
activity log, and the server refuses scoring. The scores are kept, so start a
new round with Nollställ next time.
A full-screen battle for the big screen, based on
haaker1/haaker1.github.io
(images from there, in public/battle/). The fighters are pushed towards the
side that's behind, and every point triggers sparks, a shockwave, screen shake
and an impact word. The page has no controls: press F for fullscreen.
- Admins score only from the Jäger vs Minttu card on
/dashboard(made for phones)./battleitself is read-only. - The score is in the
battletable, and every hit, undo and reset is logged inbattle_events. - Updates reach every open screen instantly through server-sent events
(
/api/battle/events), fanned out between app instances with PostgresLISTEN/NOTIFY. Screens also poll every 15 s as a fallback. - The screens look after themselves, so nobody has to reload them during
the pub: the stream sends the current score on every (re)connect and pings
every 15 s. The browser reopens it when pings stop or after an HTTP error
(like a 502 during a deploy), backing off up to 15 s. If the page itself
couldn't load, it retries every 5 s. While the connection is down, a small
Återansluter… shows in a corner. See
src/lib/battle/use-battle-stream.ts.
| Script | What it does |
|---|---|
dev |
Vite dev server on port 2027 (fails if taken) |
build / start |
Production build to .output/, serve with Bun |
test / test:watch |
Vitest |
check |
Biome lint + format (writes fixes) |
typecheck |
tsc --noEmit |
services:up / services:down |
Start / stop Postgres and the SSO mock |
db:generate |
Generate a SQL migration from schema changes |
db:migrate |
Apply pending migrations |
db:push / db:studio |
Push schema directly / open Drizzle Studio |
Add tables in src/db/schema/ and export them from index.ts. Then run
bun run db:generate and bun run db:migrate, and commit the files in drizzle/.
In production, the container applies pending migrations on startup
(scripts/migrate.ts).
Pushing to main runs .github/workflows/deploy.yml, which uses
datasektionen/nomad-deploy
to build the Dockerfile, push it to ghcr.io/datasektionen/dare-website and
run job.nomad.hcl (job dare, namespace default, host
dåre.datasektionen.se).
One-time setup, done by D-Sys:
- Deploy token: add
"dare-website"to thedefaultlist indeploy-tokensin infra/github.tf. This creates the repo'sNOMAD_TOKENsecret. - Database: create user and database
dareonpostgres.dsekt.internal. - Hive: create the system
darewith a permissionadmin, and assign it to the groupdarestaben@datasektionen.se. Members of its subgroups get it too, and only while their membership is active. - SSO client: in SSO's admin panel, create client
darewith redirect URIhttps://xn--dre-ula.datasektionen.se/auth/callbackand Hive systemdare. Without the Hive system, SSO rejects logins that requestpermissions. Allow guests if people outside the chapter should be able to log in. - Nomad variables at
nomad/jobs/dare:db_password,session_secret(openssl rand -base64 32),oidc_client_secret. - DNS: point
dåre.datasektionen.se(xn--dre-ula.datasektionen.se) at the cluster.