Files
Levyraati26_go/docs/spec.md
T
Esa Kataja 0f15ae0bfc Release 2026.08.02-1
SQLite replaces Postgres, and two fixes from using the thing.

- The database is a file under ./storage instead of a second container. Ten
  members never needed a database server, and the driver is pure Go, so the
  build stays CGO_ENABLED=0 and the dependency count is unchanged. One bind
  mount is now the whole backup: no pgdata, no healthcheck-gated depends_on,
  no startup retry loop. Timestamps are UTC text, idle_ttl is seconds, the
  divisive/unified boards carry their own stddev, and foreign keys are on by
  pragma. Tests get a database file each and run without any setup
- Invites are copied, not clicked. An invite is something to send, and the
  anchor opened the join form in the admin's own browser
- Feedback asks for more than faults: the footer reads "Ongelmia? Ideoita?
  Palautetta?" and the page behind it invites ideas rather than only bugs
- Kuuntele YouTubessa opens in a new tab, so a half-typed review survives it
2026-08-02 20:58:52 +03:00

742 lines
36 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Levyraati — specification
What the app does. This file and the code must never disagree; when behaviour changes, this changes
with it. Terms are defined in [CONTEXT.md](../CONTEXT.md), decisions and their reasons in
[decisions.md](./decisions.md), and anything explicitly not in v1 in [later.md](./later.md).
Stack, configuration, and operations are in the [README](../README.md).
---
## 1. Access
- **Invite-only.** No public registration, no self-service password reset. The admin resets passwords.
- Registration requires a valid, unused invite code. Codes are one-time and are spent **only after a
registration actually succeeds** — a failed signup must leave the code usable.
- **No roles.** Every account is a member. The admin is not an account (§5).
- **Email is the login identifier.** The app never sends mail in v1 and cannot verify addresses.
- **Names are not unique** — they are display only, and changing yours must never affect logging in.
- Banned members are rejected at login.
### 1.1 Sessions
- Random token (32 bytes, hex) in `sessions`, presented in an `HttpOnly; Secure; SameSite=Lax`
cookie **or** an `Authorization: Bearer` header.
- **Idle timeout.** A session dies after a period with no requests: **24 hours**, or **30 days** if
"remember me" was ticked at login. The per-session `idle_ttl` is stored on the row, so extending is
`expires_at = now() + idle_ttl` with no branch. Skip the write unless at least a minute has passed,
so this is not a write per request.
- Logging out deletes the row. Changing your password deletes your other sessions. Banning deletes
all of a member's sessions.
- Expired rows are swept at startup (§4.6). They are harmless until then — `expires_at` is checked on
every read.
```go
func sessionToken(r *http.Request) string {
if h := r.Header.Get("Authorization"); strings.HasPrefix(h, "Bearer ") {
return strings.TrimPrefix(h, "Bearer ")
}
if c, err := r.Cookie("session"); err == nil {
return c.Value
}
return ""
}
```
Reading the token from a header does not weaken CSRF: `SameSite=Lax` still guards the cookie path,
and a cross-origin page cannot set `Authorization` without CORS, which is not enabled.
### 1.2 Security behaviours
- Changing your own password requires the current password.
- Passwords are bcrypt. **There is no minimum length** — only non-empty. Invite-only registration,
ten members, and an admin-only reset path leave a length policy nothing to protect.
- **Login attempts are rate limited**: 10 failures for one email address within 15 minutes lock
*that address's login* for 15 minutes, and a correct password clears the counter. Keyed by email
rather than IP, because behind a proxy the address requires trusting `X-Forwarded-For`. Held in
memory, so a restart clears it. Registration is not limited — an invite code is 128 bits.
- Invite codes carry 128 bits of entropy (`crypto/rand`, 16 bytes hex).
- Avatar upload: 5 MB max, normalised through ffmpeg to a 256 px JPEG. The re-encode **is** the
validation, and it caps what lands on disk. ffmpeg handles webp and avif; stdlib `image` does not.
- Audio is auth-gated. Avatars are public — they are not secret, and gating them buys nothing.
- Never log secrets: no passwords, no session tokens, no invite codes, no admin password.
---
## 2. Songs
A song is a published piece of music with converted audio. A row in `songs` is a real song with no
qualifier — in-flight work lives in `submissions` (§4).
- Any member may submit: title (≤100), artist (≤100), genre (fixed list), optional description
("short introduction"), and either an audio file or a YouTube URL.
- **Max 5 submissions per rolling 24 hours** per member. Failed and discarded submissions never
count — no audio, no submission — so a yt-dlp outage costs nobody their quota. Checked before
anything is written to disk.
- Audio is Opus, 96 kbps, stereo, `.ogg`:
`ffmpeg -i IN -c:a libopus -b:a 96k -ac 2 -vn OUT.ogg`. The original is discarded.
- Stored at `storage/audio/<song_id>.ogg`; the DB stores the filename.
- `duration_seconds` is captured from `ffprobe` during conversion and stored on the row.
- **Tracks longer than 15 minutes are rejected** at submit, from metadata, before any download.
- `source_url` is kept on the song row for YouTube submissions — "listen on YouTube", and
re-downloading if a conversion turns out bad.
### 2.1 Locked
A song with at least one review is **locked**. While it is unlocked, its submitter may change
**title, artist, genre, and description**, and may delete it. Audio is never editable.
Locked is a live state, not a one-way door: if a review is deleted (§3) and the song drops back to
zero reviews, it is editable again.
- Submitter deletes: `POST /songs/{id}/delete`, unlocked only.
- Admin deletes: `POST /admin/songs/{id}/delete`, unconditional.
- Two routes rather than one route with a branch.
- **Deleting a song deletes its `.ogg`.** The row and the file always go together.
### 2.2 Genres
Fixed list, `text` column, validated app-side. The **stored value is the English code** and the
Finnish label is display only — the same split the statuses use, so rewording a genre never touches
a song row:
| Code | Label | | Code | Label |
|---|---|---|---|---|
| Rock | Rock | | Soundtrack | Elokuvamusiikki |
| Metal | Metal | | Experimental | Kokeellinen |
| Punk | Punk | | Classical | Klassinen |
| Blues | Blues | | Electronic | Elektroninen |
| Jazz | Jazz | | Hip Hop | Hip hop |
| Pop | Pop | | Finnish | Kotimainen |
| Folk / Country | Folk / Country | | Just Plain Weird | Ihan outoa |
| | | | Other | Muu |
---
## 3. Reviews
- Score: integer 1100. Text: required, ≤5000 chars.
- One review per member per song. Rely on the DB unique constraint on `(song_id, reviewer_id)` and
map error `23505` to a 409.
- You cannot review your own song.
- Reviews are never anonymous.
- **30-minute window**, measured from `updated_at`, during which the reviewer may **edit or delete**
their own review. An edit extends the window. After it closes the review is permanent.
- Nobody else deletes a review. The admin's remedy for a bad one is deleting the song or talking to
the person.
### 3.1 Reveal
Other members' opinions of a song are hidden until you have reviewed it, or you are its submitter.
- Covers **the reviews and the song's average score**, everywhere a song is named: the queue, the
browse list, the song page, the API.
- Enforced **server-side in the query**. Hidden reviews are never sent and hidden with CSS.
- Does **not** apply to `/stats` — leaderboards are always public.
- Profiles show counts and history-wide averages, never how someone reviewed a particular song.
The principle, which settles future "can this page show X" questions: *per-song opinion is gated;
whole-history aggregate is public.*
---
## 4. Submission pipeline
Both paths — file upload and YouTube URL — converge on one pipeline. A YouTube song is not a
different kind of song; it has an extra download step and a `source_url`.
Two principles, and the rest follows:
1. **Nothing enters `songs` until the audio converts and the submitter confirms it.** So `songs` has
no `status` column and no query anywhere filters on readiness.
2. **Metadata is read synchronously; conversion runs in the background.** Metadata arriving later
would land in a form the submitter is already typing into, and every prefill would race their
keystrokes.
### 4.1 Submit — `POST /submit`
- **File upload:** cap the request body at 50 MB, write to `storage/tmp/<submission_id>`, then read
tags with `ffprobe` (local, effectively instant).
- **URL:** validate the host against an allowlist (`youtube.com`, `youtu.be`, `music.youtube.com`)
*before* touching yt-dlp. Never pass an unvalidated string into a subprocess argument list, and
never through a shell. Then `yt-dlp -J` for metadata only — no download yet.
- Reject over-long tracks here, from `duration`.
- Insert the `submissions` row with any prefilled title/artist, then redirect to `GET /submit/{id}`.
`yt-dlp -J` is a 13 s network call, acceptable in the handler with a 15 s timeout. On timeout,
render blank fields rather than failing the submission.
**Prefill is a suggestion, not an answer:**
| Source | Title | Artist |
|---|---|---|
| Uploaded file (`ffprobe`) | `title` tag | `artist``album_artist` |
| YouTube (`yt-dlp -J`) | `track``title` | `artist``creator``uploader` |
- `ffprobe` tag keys vary in case by container (`title`, `TITLE`, `Title`) — lowercase the map first.
- YouTube's `track`/`artist` exist only for Topic channels, YouTube Music entries, and videos with a
"Music in this video" panel. `testdata/ytdlp-noose.json` is a real dump of an ordinary upload:
`track`, `artist`, `creator` and `album` are all null, leaving `uploader` and a title with double
spaces in it.
- If a field resolves to empty, **leave it blank.** A blank field prompts the submitter; a plausible
`"Unknown"` does not.
- **Never prefill the description from YouTube's `description`** — it is tracklists, socials, and
affiliate links. The introduction wants the submitter's own words.
- Tag text is attacker-controlled. `html/template` escapes on render, but truncate to the column
limit and strip control characters and newlines before the insert: a title with an embedded
newline wrecks every list layout it appears in.
### 4.2 Convert — background worker
```go
// ponytail: in-process goroutines, 2 at a time. A real queue is the upgrade if this ever
// needs to survive a restart mid-conversion or run on another box.
var slots = make(chan struct{}, 2)
```
Unbounded goroutines shelling out to ffmpeg is how one enthusiastic evening fork-bombs a small VPS.
Everything past the cap waits in `queued`.
States: `queued` → (`downloading`, URL only) → `converting``ready` | `failed`.
- Download: `yt-dlp -f bestaudio --no-playlist --max-filesize 100M -o storage/tmp/<id>.%(ext)s <url>`
- Convert: the ffmpeg command in §2. Its success **is** the validation — no container sniffing, no
magic-byte library. If ffmpeg produced an Opus stream, it was audio.
- On failure, store the stderr tail in `status_msg`. "Video unavailable in this region" is worth
showing; "submission failed" is not.
- Output lands at `storage/tmp/<submission_id>.ogg` — still not in `storage/audio/`.
### 4.3 The waiting page — `GET /submit/{id}`
Submitter-only. Live status plus the editable metadata form, so the wait is spent writing the
introduction rather than watching a spinner.
**One button, at the bottom of the form: Julkaise**, disabled until `status = 'ready'`. Publishing
is always an explicit click — firing it automatically would race the submitter mid-sentence.
There is no separate save button: two buttons made it unclear which one committed the text.
- The metadata form **autosaves**`hx-post` on `input changed delay:1.2s` and on `change`,
answering with a quiet "Tallennettu 21.37" line and nothing else.
- Julkaise lives outside the form and is bound to it with the HTML `form=` attribute, so pressing
it submits the metadata *and* publishes in one request. The last keystrokes therefore arrive with
the click even if the autosave never fired — which is also what makes the page work with no JS at
all.
The live part is HTMX polling a fragment:
```html
<!-- {{define "submission-status"}} — included by the page, returned alone by the poll -->
<div hx-get="/submit/{{.ID}}/status" hx-trigger="every 2s" hx-swap="outerHTML">
<p>{{.Label}}</p>
<button type="submit" form="meta" {{if not .Ready}}disabled{{end}}>Julkaise</button>
</div>
```
The page includes the partial on first paint and `GET /submit/{id}/status` returns the same partial
alone, so the markup exists once and arrives populated — no flash, no empty state, no client-side
templating. HTMX stops polling when the fragment drops `hx-trigger`, which it does on a terminal
status. Finnish strings never leave Go.
With JS disabled the page degrades to "saving the form shows the current status", which is what a
`<meta http-equiv="refresh">` would have given at the cost of wiping whatever is being typed.
### 4.4 Publish — `POST /submit/{id}/publish`
**Title, artist and genre are required to publish**; description is optional. Validated here, not at
submit time — the form is meant to be filled during the wait. A blank field comes back as a field
error on the same page with the audio still converted and waiting.
```go
// Rename inside the transaction: if the file move fails, the song row never existed.
// A crash between rename and commit leaves an orphan .ogg — the startup sweep gets it.
tx.QueryRow(ctx, `INSERT INTO songs (...) VALUES (...) RETURNING id`).Scan(&songID)
os.Rename(sub.TmpPath, filepath.Join("storage/audio", fmt.Sprint(songID)+".ogg"))
tx.Exec(ctx, `DELETE FROM submissions WHERE id = $1`, sub.ID)
tx.Commit(ctx)
```
### 4.5 Failure means denied
A failed submission is invisible to everyone but its submitter and never reaches `songs`. The row
survives only to carry the error message and offer:
- **Discard** — delete the row and unlink the temp file.
- **Retry** — URL submissions only, re-queued with the title and introduction already typed. An
upload cannot retry, its temp file is gone; offer re-upload.
Neither costs quota (§2).
### 4.6 Startup sweeps
Next to the migrations, before serving:
```sql
UPDATE submissions SET status='failed', status_msg='interrupted by restart'
WHERE status IN ('queued','downloading','converting');
DELETE FROM submissions WHERE created_at < now() - interval '7 days';
DELETE FROM sessions WHERE expires_at < now();
```
An in-process goroutine dies with the process; without the first statement those rows say
`converting` forever. The second covers failures *and* conversions nobody came back to publish —
unlink `tmp_path` as you delete. Also unlink any `.ogg` in `storage/audio/` with no matching song
row. No cron, no partitioning.
Log a count of `failed` submissions at startup: an unpublished submission is invisible to everyone
but its submitter, so a broken pipeline has no other way of announcing itself.
### 4.7 Operational notes
- **yt-dlp rots.** Installed with `apk add yt-dlp` from Alpine's active branch, which tracks
upstream closely; rebuild monthly. If the packaged version ever lags at a bad moment,
`pip install -U yt-dlp` is the fallback — at the cost of python3 and pip in the image.
- Downloading YouTube audio is against YouTube's ToS. This is a private app among friends; the
decision is deliberate rather than accidental.
---
## 5. Pages
Nav: **Jono** (`/`), **Kappaleet** (`/songs`), **Tilastot** (`/stats`), **Oma profiili**
(`/profile`).
### 5.1 Jono — the queue, `GET /`
The work list, not a catalogue:
- Songs you have **not** reviewed, **oldest first**.
- **Excludes your own songs** — you can never review them, so they would sit at the front forever.
- No average score, no review text (§3.1). Review count is fine.
- Drains to empty, and the empty state is a real state worth rendering well.
- Cursor pagination, 20 per page.
### 5.2 Kappaleet — browse, `GET /songs`
Everything, newest first, cursor-paginated, with badges (`arvosteltu`, `oma kappale`).
`average_score` shows only for songs you have revealed. This is where a song lives after it leaves
the queue, and the only way back to something you have already reviewed.
### 5.3 Song — `GET /songs/{id}`
Player, metadata, your own review or the review form, and — once revealed — everyone else's reviews
and the average.
### 5.4 Tilastot — `GET /stats`
Always public, ignores the reveal rule. Minimum **3 reviews** for a song to qualify for any ranking;
`min_reviews` is published, not hardcoded in a client.
- Songs: Top 10 all-time, Bottom 10, Most Divisive (highest score spread), Most Unified (lowest),
Most Reviewed.
- Reviewers: Harshest Critic (lowest average given), Most Generous, Most Active, Most Prolific
Submitter.
- SQL does all of it: `avg()`, `count()`, `HAVING count(*) >= 3`. Order and limit in SQL, never in
Go. SQLite has no `stddev_pop`, so the divisive/unified boards spell the population formula out —
see `stddevPop` in `stats.go`.
- **Every leaderboard needs a deterministic tie-break** — `ORDER BY value DESC, review_count DESC,
id ASC`. Ties are common in a ten-person club, and without one the list reshuffles between reloads
for no reason.
- The count-based lists (Most Reviewed, Most Active) go stale once everyone has worked the queue.
Prune them when they stop being interesting.
### 5.5 Profiles — `GET /profile`, `GET /profile/{id}`
The member's songs, and counts and averages over their whole history: songs submitted, reviews
written, average score given, average score received. **Never a list of their reviews** (§3.1).
`POST /profile` edits name, email, password, avatar. No avatar → `avatar_url` is `null` and the
template renders a circle with the member's initials, in CSS. No default image on disk.
### 5.6 Palaute — `GET /report?from=…`, `POST /report`
- Any member, free text (≤2000 chars), nothing else. No category, no priority, no severity — with
ten users a sentence and a page URL beat a taxonomy nobody fills in honestly.
- A footer link on every page carries the current path into a hidden field. The server already knows
where they were, so no JS.
- `user_agent` is captured from the header. "Only on my phone" is the most common bug report and
this answers it without asking.
- Submitting redirects back to `from` with a flash toast. The reporter sees their own past reports
listed, which is what stops the same bug arriving four times.
---
## 6. Admin
**The admin is not a user.** They never submit, never review, and never see the member-facing site.
No `role` column, no admin row, no admin session, no admin login page, no first-launch seeding — and
no query anywhere has to exclude the admin from a list, a leaderboard, or an aggregate. The admin UI
is Finnish, like everything else.
```go
// ponytail: Basic Auth, no admin session, no admin row. Ceiling: one admin, no logout
// (close the browser). Add a cookie session if a second admin ever needs one.
func requireAdmin(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
u, p, ok := r.BasicAuth()
if !ok || subtle.ConstantTimeCompare([]byte(u), []byte(adminUser)) != 1 ||
subtle.ConstantTimeCompare([]byte(p), []byte(adminPass)) != 1 {
w.Header().Set("WWW-Authenticate", `Basic realm="levyraati admin"`)
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
next.ServeHTTP(w, r)
})
}
```
No bcrypt here: hashing protects *stored* passwords against a database leak, and this one lives in
the env file already. The constant-time compare is the part that
matters. **Fatal at startup if `ADMIN_PASSWORD` is unset** — an admin panel that silently opens is
worse than one that will not boot.
```go
// ponytail: two listeners, one process. Admin is loopback-only — reach it over an SSH tunnel
// or the reverse proxy. A separate binary would need its own deploy and would race the
// startup migrations; it buys nothing else.
go func() { log.Fatal(http.ListenAndServe("127.0.0.1:8081", requireAdmin(adminMux))) }()
log.Fatal(http.ListenAndServe(":8080", memberMux))
```
**Bootstrap:** the admin logs in with the env credentials and mints the first invite. That is the
entire first-launch story. Losing the password is an edit to `.env` and a restart.
**Routes** (all on the loopback listener): `GET /admin` dashboard, `POST /admin/invites`,
`POST /admin/users/{id}/password`, `POST /admin/users/{id}/ban`, `POST /admin/songs/{id}/delete`,
`GET /admin/reports`, `POST /admin/reports/{id}/resolve`, and `GET /admin/audio/{id}` — moderating a
complaint means listening to the song, and a separate audio route avoids branching auth inside the
member handler.
**Ban** is a reversible toggle. It refuses login and deletes the member's sessions immediately.
Their songs and reviews stay, keep counting in the stats, and keep their name on them: a ban ends
participation, it does not rewrite history.
**The admin surface has no API.** Basic Auth on loopback with no client but a browser — JSON would
be contract surface with no consumer.
---
## 7. Two surfaces
**One binary, one origin.** HTML at `/`, JSON at `/api/…`. No separate frontend, no static bundle,
no second domain, and therefore no CORS. A reverse proxy in front is TLS termination, not a second
application.
**The page surface returns HTML, including fragments for HTMX. The API surface returns JSON and
never HTML.** Which you get is decided by the route, not by an `Accept` header — one URL returning
two shapes is a contract a client has to hope is honored, and it drags `Vary: Accept` along to stay
cache-correct.
```go
mux.HandleFunc("GET /songs/{id}", page(a.songData, "song.html")) // HTML, browser
mux.HandleFunc("GET /api/songs/{id}", jsonOf(a.songData)) // JSON, always
```
**Every route is two thin adapters over one data function.** `songData` owns the reveal rule, the
edit window, the locked rule — everything. The surfaces are physically incapable of diverging, which
is the only real cost an API has. Templates receive the same struct the API marshals.
The discipline that keeps it honest:
- `json` tags on everything exposed; the struct **is** the contract.
- **No display formatting in the data.** Dates stay timestamps, statuses stay codes with a `label`
alongside. Formatting is a template func.
- **Additive changes only** once a client is installed somewhere you cannot update.
- **CORS** is a one-line middleware added the day a client fetches from another origin. Not before.
- **Design the contract now; implement each endpoint when something calls it.** Nothing consumes
`/api` while the browser talks HTML, and each route is one line over a data function that already
exists.
`GET /audio/{id}` stays a plain, Range-capable, auth-gated URL — not under `/api`, because it serves
bytes rather than JSON. `http.ServeContent` handles 206/416/If-Range correctly for free. Parse
`{id}` as an integer before it touches a path; that **is** the traversal check.
### 7.1 Page routes
| Route | Purpose |
|---|---|
| `POST /login`, `POST /register`, `POST /logout` | Form posts, redirect on success |
| `GET /` | Jono — the queue (§5.1) |
| `GET /songs` | Kappaleet — browse (§5.2) |
| `GET /songs/{id}` | Song detail, reveal rule applied |
| `POST /songs/{id}` | Submitter edits title/artist/genre/description, unlocked only |
| `POST /songs/{id}/delete` | Submitter deletes, unlocked only |
| `POST /submit` | Upload or URL (§4.1) |
| `GET /submit/{id}` | Waiting page, submitter-only |
| `GET /submit/{id}/status` | HTML partial for the HTMX poll |
| `POST /submit/{id}/publish`, `/retry`, `/discard` | (§4.4, §4.5) |
| `POST /songs/{id}/review` | Create a review |
| `POST /reviews/{id}`, `POST /reviews/{id}/delete` | Edit or delete, inside the 30-minute window |
| `GET /stats` | Leaderboards |
| `GET /profile`, `GET /profile/{id}`, `POST /profile` | Profiles |
| `GET /audio/{id}` | Auth-gated, Range-capable |
| `GET /avatars/{id}` | Public; 404 when there is no upload |
| `GET /report`, `POST /report` | Palaute (§5.6) |
HTML forms cannot send `PATCH` or `DELETE`, which is exactly why the browser uses `POST` here and
the API uses the proper methods. The two-surface split earns its keep rather than contorting one
surface to serve both.
### 7.2 Frontend
Hand-written `style.css` (~200 lines, CSS custom properties at the top for the dark rock/metal
theme, Oswald headings, orange/red accents) plus **vendored** HTMX and Alpine — downloaded files, not
a CDN, ~30 KB together. Two script tags, no build step, no npm.
- **HTMX** when the answer must come from the server: it swaps a rendered partial in, so there is no
JSON tier and no duplicated markup.
- **Alpine** when the page reacts only to itself.
- **Neither** when a native element does the job:
| Need | Use |
|---|---|
| Audio player | `<audio controls>` |
| Mobile nav toggle | `<details>` |
| Toasts | flash cookie, rendered server-side, cleared on read |
| Score slider | `<input type=range>` + `<output>` |
Today HTMX drives exactly one thing (the status poll) and Alpine has no job at all. Both are present
so the next dynamic feature needs no decision, not because v1 leans on them. **A page with no server
round trip and no local state gets neither `hx-` nor `x-` attributes.**
The ceiling: this pairing is for server-owned state. An interface where a lot of state lives on the
client and changes constantly is what a real client framework is for; nothing here is close.
**The audio player lives in one template partial** (`{{template "player" .}}`) — not an abstraction,
just not copy-pasting five lines into four pages. Replacing the native controls later then edits one
file.
---
## 8. API contract
**Not built.** Nothing consumes `/api` — the browser talks HTML to the page surface — so this
section is a design, not a description of running code (decision 43). It stays here because the
shape is the expensive thing to change once a client is installed somewhere you cannot update, and
the first endpoint is one line over a data function that already exists.
**Conventions**
- `snake_case` field names, matching the SQL columns.
- Timestamps are RFC 3339 UTC strings (`2026-08-01T10:00:00Z`). Never preformatted, never a locale
string, never a unix int.
- Ids are JSON numbers (SQLite rowids, safely under 2⁵³).
- Nullable fields are present and `null`. **No `omitempty`** — a stable key set is worth more than a
few bytes, and "missing" versus "null" is a distinction clients get wrong.
- Scores are integers, averages are floats.
- **Every enum ships with a `label`**: `{"status":"converting","label":"Muunnetaan…"}`. Clients
render the label and never map codes to strings, so adding a status later breaks nothing and the
Finnish stays in Go.
- **Derived booleans accompany state**: `done`, `ready`, `failed`, `can_edit`, `can_delete`. A client
asking "can I edit this?" must never reimplement the 30-minute rule.
- Collections are `{"items": [...], "next_cursor": "…"|null}`. The cursor is **opaque** — today it is
the last id, but naming it a cursor means changing that is not a breaking change.
**Client contract:** ignore unknown fields, tolerate unknown enum values by falling back to `label`,
never derive permissions locally.
**Object shapes**
```jsonc
// song_summary — queue, browse, stats entries
{ "id": 42, "title": "…", "artist": "…", "genre": "Metal", "duration_seconds": 213,
"audio_url": "/audio/42", "submitted_by": <user_summary>, "created_at": "…",
"review_count": 5,
"reviews_revealed": false, // whether the viewer has unlocked this song
"average_score": null, // null when not revealed — see below
"own": false, "viewer_reviewed": false }
// song — detail, adds:
{ …song_summary,
"description": "…"|null, "source_url": null,
"reviews": [<review>]|null, // null when not revealed — never a lie-by-empty-array
"viewer_review": <review>|null,
"can_review": true, "can_edit": false, "can_delete": false }
// user_summary
{ "id": 3, "name": "Esa", "avatar_url": null } // null → client renders initials
// user — profile, adds created_at and:
{ "stats": { "songs_submitted": 4, "reviews_written": 12,
"average_score_given": 63.2, "average_score_received": 70.1 } }
// review
{ "id": 9, "song_id": 42, "reviewer": <user_summary>, "score": 88, "text": "…",
"created_at": "…", "updated_at": "…",
"editable_until": "…"|null, "can_edit": true, "can_delete": true }
// submission
{ "id": 7, "status": "converting", "label": "Muunnetaan…",
"done": false, "ready": false, "failed": false, "status_msg": null,
"title": "…"|null, "artist": "…"|null, "genre": null, "description": null,
"source_url": null, "can_retry": false, "created_at": "…" }
// report
{ "id": 2, "body": "…", "page": "/songs/42", "created_at": "…", "resolved_at": null }
```
`reviews: null` versus `[]` is deliberate: null means the reveal rule is withholding them, `[]` means
the song genuinely has none. Collapsing them would show "no reviews yet" on a song with twenty.
`average_score` is `null` for both reasons — `reviews_revealed` and `review_count` disambiguate, and
a client must not infer a score from either.
**Endpoints**
| Method | Path | Body / Query | Returns |
|---|---|---|---|
| `POST` | `/api/login` | `{email, password, remember?}` | `{token, expires_at, user}` |
| `POST` | `/api/logout` | — | `204` |
| `GET` | `/api/me` | — | `user` + `email` |
| `PATCH` | `/api/me` | `{name?, email?, current_password?, new_password?}` | `user` |
| `POST` | `/api/me/avatar` | multipart | `user` |
| `GET` | `/api/songs` | `?cursor=&limit=20&unreviewed=&order=` | `{items: [song_summary], next_cursor}` |
| `GET` | `/api/songs/{id}` | — | `song` |
| `PATCH` | `/api/songs/{id}` | `{title?, artist?, genre?, description?}` | `song` |
| `DELETE` | `/api/songs/{id}` | — | `204` |
| `POST` | `/api/songs/{id}/reviews` | `{score, text}` | `review` (`409` if one exists) |
| `PATCH` | `/api/reviews/{id}` | `{score?, text?}` | `review` (`403` past the window) |
| `DELETE` | `/api/reviews/{id}` | — | `204` (`403` past the window) |
| `POST` | `/api/submissions` | multipart, or `{source_url}` | `submission` (`429` over quota) |
| `GET` | `/api/submissions/{id}` | — | `submission` |
| `PATCH` | `/api/submissions/{id}` | `{title?, artist?, genre?, description?}` | `submission` |
| `POST` | `/api/submissions/{id}/publish` | — | `song` |
| `POST` | `/api/submissions/{id}/retry` | — | `submission` |
| `DELETE` | `/api/submissions/{id}` | — | `204` |
| `GET` | `/api/users/{id}` | — | `user` |
| `GET` | `/api/stats` | — | see below |
| `POST` | `/api/reports` | `{body, page?}` | `report` |
| `GET` | `/api/reports` | — | `{items: [report]}` (own only) |
`GET /api/songs` is the browse list by default: all songs, newest first. The queue is the same
endpoint with `?unreviewed=1&order=oldest`. Parameters rather than a second route, because the queue
is a *view* of songs and a future filter is another param, not another shape.
`GET /api/stats` returns one object of named leaderboards, every entry the same shape so a client
renders them all with one component:
```jsonc
{ "min_reviews": 3,
"top_songs": [{ "song": <song_summary>, "value": 88.2, "review_count": 6 }],
"bottom_songs": [ … ], "most_divisive": [ … ], "most_unified": [ … ],
"most_reviewed": [ … ],
"harshest_critics": [{ "user": <user_summary>, "value": 41.3, "review_count": 9 }],
"most_generous": [ … ], "most_active": [ … ], "most_prolific": [ … ] }
```
Adding a leaderboard later is a new key, which is additive. Removing one is not, which is one more
reason the count-based lists stay until they are proven useless.
**Errors** — one shape, always:
```jsonc
{ "error": { "code": "already_reviewed",
"message": "Olet jo arvostellut tämän kappaleen.",
"fields": { "score": "Pisteiden tulee olla 1100." } } }
```
`code` is machine-stable and never changes; `message` is Finnish and may. `fields` appears only for
`validation_failed`. Codes: `unauthorized` (401), `forbidden` (403), `not_found` (404),
`validation_failed` (422), `already_reviewed` (409), `edit_window_expired` (403), `invite_invalid`
(422), `banned` (403), `payload_too_large` (413), `quota_exceeded` (429).
---
## 9. Data model
Ids are `integer primary key autoincrement` — never reused, because `storage/audio/<song_id>.ogg` is
named after one. Session tokens stay random — those are secrets, ids are not, and enumerable ids are
not a threat model for a login-walled app for ten friends.
Timestamps are declared `timestamp` and hold UTC `YYYY-MM-DD HH:MM:SS`: the declared type is what
makes the driver return `time.Time`, and the fixed-width UTC string is what makes ordering and
comparison against `datetime('now')` mean what they say. Booleans are `integer`, 0 or 1.
```sql
users (id pk, name, email unique, password_hash, avatar, banned, created_at)
sessions (token pk, user_id fk not null, idle_ttl integer not null, -- seconds
expires_at, created_at) -- token: 32 random bytes, hex
songs (id pk, title, artist, genre, description, lyrics, audio_file,
duration_seconds integer,
source_url, -- nullable, for YouTube submissions
submitted_by fk users, created_at)
submissions (id pk, user_id fk not null,
status text not null default 'queued', -- queued|downloading|converting|ready|failed
status_msg text,
source_url, tmp_path,
title, artist, genre, description, lyrics,
created_at)
reviews (id pk, song_id fk on delete cascade, reviewer_id fk users,
score integer, text, created_at, updated_at,
unique (song_id, reviewer_id))
invites (id pk, code unique, is_valid integer, created_at)
reports (id pk, user_id fk not null, body text not null,
page text, user_agent text,
resolved_at timestamp, -- null = open
created_at)
```
`foreign_keys` is off by default in SQLite, so the cascades above only exist because the pragma is
set on every connection — see `openDB` in `main.go`.
No `role` column (§6). No `status` on `songs` (§4).
Also: `CHECK (score BETWEEN 1 AND 100)`, `NOT NULL` on everything required, an index on
`reviews(song_id)`, and an index supporting the queue's "songs this member has not reviewed".
Files on disk: `storage/tmp/<submission_id>[.ogg]` in flight, `storage/audio/<song_id>.ogg` once
published. A row and its file are always created and deleted together.
Migrations are numbered `.sql` files in an `embed.FS`, applied at startup before serving.
---
## 10. Logging
`slog.NewJSONHandler(os.Stdout, …)` and nothing else. `docker compose logs` is the log viewer — there
is no log table and no in-app viewer.
Group with `slog.With("ctx", …)`: `songs`, `reviews`, `auth`, `invites`, `submissions`, `startup`.
---
## 11. Tests
Each of these is a place where a plausible-looking implementation is silently wrong:
- **Invite burning** — a failed registration leaves the code usable; a successful one does not.
- **The 30-minute window** — measured from `updated_at`, so an edit extends it. Check the boundary,
the expired case, and that it gates delete as well as edit.
- **The reveal query** — a member who has not reviewed a song must not receive other reviews *in the
result set*, and must not receive the average. Assert on the JSON: `reviews` is `null`, not `[]`,
and `average_score` is `null`.
- **The queue** — excludes your own songs and songs you have reviewed, orders oldest first, and
re-includes nothing when a review is deleted by someone else.
- **Unlock** — deleting the only review makes the song editable by its submitter again.
- **The submission quota** — five in 24 hours, and a failed submission does not consume one.
- **Publish** — the song row and its `.ogg` appear together, or neither does. Force the rename to
fail and assert no orphan row.
- **Restart recovery** — a submission left `converting` becomes `failed` at the next startup.
- **The API contract** — one golden file per object shape. It fails the moment a field is renamed or
dropped, which is the breakage that is expensive later and invisible in review.
Everything else is forms and `INSERT`s. No framework, no fixtures beyond a test database.
---
## 12. Build order
Each step leaves something runnable. Registration needs an invite and invites come from the admin
panel, so the admin surface comes first — before a single member can exist.
1. **Skeleton** — `main.go`, embedded migrations at startup, `database/sql`, slog, Docker Compose,
the two listeners.
2. **Admin, invites, auth** — Basic Auth listener, mint an invite, register, log in, sessions, ban.
3. **Submission pipeline, upload path only** — submit, convert, waiting page, publish. No yt-dlp yet,
so the hard parts (worker, publish transaction, restart recovery) are proven without a network
dependency.
4. **Queue, song page, reviews, reveal** — the product loop. **Usable by ten people at the end of
this step.**
5. **YouTube path** — yt-dlp metadata and download, slotted into a pipeline that already works.
6. **Stats, profiles, avatars, palaute.**
7. **API endpoints and golden tests** — deferred until something wants them (decision 43).