Commit graph

12 commits

Author SHA1 Message Date
fbdb441427 Warmer welcome copy on the public landing
The previous text ("Offentlige og halv-offentlige aktiviteter. Logg
inn for å se og legge til dine egne.") read like a sysadmin status
banner — it described the data model rather than the purpose.

New text explains what the app is actually for: making and sharing
winter activity lists to keep the winter blues at bay, with a nod to
the fact that some users will use it for entirely different lists.

Login is still one click away in the nav, so the dropped "Logg inn
for å se …" instruction isn't needed.
2026-05-25 14:20:16 +02:00
12d16c0835 Activity export to Markdown file (client-side, decrypts privates)
A new "Eksporter" section on the Profile page generates a markdown
file containing all activities visible to the caller — including
their own private ones — and triggers a download.

Why pure client-side:
  - Private rows are E2E-encrypted; the server doesn't have the
    cleartext. Decryption MUST happen in the browser.
  - Avoiding a server endpoint also means there's no "give me my
    plaintext" target for anyone who steals a session cookie.

Mechanics (frontend/src/lib/export.ts):
  - Fetch /api/activities (same as the dashboard does)
  - For each row, normalise: decrypt private payload via session.dek,
    pass semi/public through as-is
  - Build a markdown doc with sections "Private (N)" and
    "Offentlige og anonyme (N)" plus per-row title/description/tags/
    location/scheduled blocks
  - Wrap in a Blob, create a temporary <a download>, click it, revoke
    the object URL

The Profile section reports the resulting size as a quick confirmation
and surfaces errors inline. No server changes required.

Bundle: 32.7 KB → 34.2 KB gzipped (mostly the export helper itself,
which is plain string concatenation — no new deps).
2026-05-25 14:13:42 +02:00
f0ce5e9680 Bookmarks on public/semi activities, surfaced on /home
Logged-in users can star a public or semi activity to save it for
later. Bookmarked rows float to the top of the user's dashboard in a
dedicated "Bokmerker" section. The same row still appears in its
visibility section below — bookmarking doesn't remove anything; it
just adds a fast lane.

Schema:
  - bookmarks(user_id, activity_id, created_at) with composite PK
  - Both FKs CASCADE so user deletion or activity deletion sweeps
    bookmarks automatically

Wire/types: ActivityPublic/Semi/Private all gain `viewer_bookmarked:
boolean` for type uniformity. Private rows always carry false (the
owner already has direct access; bookmarking own private items would
be redundant), and the bookmark endpoints reject visibility='private'
with cannot_bookmark_private. Anonymous viewers (public-list endpoint)
get false too.

Server:
  - viewerBookmarked() helper next to heartsFor() — same shape
  - serialize() includes the field
  - POST/DELETE /api/activities/:id/bookmark, idempotent via
    INSERT OR IGNORE / DELETE; mirrors the heart endpoints

Frontend:
  - ActivityRow gets an "☆ Bokmerk" / "★ Bokmerket" toggle next to
    the heart button. Uses the same optimistic local-override pattern
    so the UI feels instant.
  - Home renders a "Bokmerker" section at the top when bookmarked
    rows exist. publicOnly mode (the "/" landing) skips it — the
    field is always false there.

26 tests still pass; typecheck clean.
2026-05-25 14:11:58 +02:00
3215917b7a Optional description field on activities
A free-text body alongside title/tags/location/scheduled. Plain text
for now; markdown rendering is a deliberate non-goal (the user noted
it was nice-to-have but not essential).

Schema (additive, idempotent via ensureColumn):
  - activities.description TEXT NULL
  - For private rows the column stays NULL; the description lives
    inside the encrypted payload alongside title.

Wire/types:
  - PrivatePayload.description?: string  (in shared/crypto.ts)
  - ActivityPublic.description / ActivitySemi.description: string | null
  - CreateActivityRequest.description?: string | null

Server:
  - INSERT and UPDATE handlers now write description for semi/public
  - Private→semi/public transition: description column populated
  - Semi/public→private transition: description column wiped (now in
    the encrypted blob)
  - serialize() includes the column on public and semi rows
  - server/users.ts public-list endpoint surfaces it too

Frontend:
  - ActivityForm.svelte: textarea after the title field; round-trips
    through the existing private-encrypt / plaintext-PATCH paths
  - ActivityRow.svelte: renders the description as a `white-space:
    pre-wrap` <p> so line breaks survive without enabling markdown
  - Home.svelte: search now matches against the description text
    (decrypted client-side for private rows, just like the title)
2026-05-25 14:08:55 +02:00
43c24ec16b fix(profile): stop falling back to email when display_name is empty
The owner attribution helper used to fall back from display_name to
the part of the email before "@" when no display name was set. That
defeats the point of letting users pick a name: anyone who hadn't
explicitly chosen one had their email prefix surfaced publicly.

New fallback chain, applied uniformly:
  - display_name (the user's chosen name) — if set, use it
  - username (also chosen by the user as a URL slug) — if set, use it
  - null — render nothing; the client hides the attribution line

Wire type ActivityPublic.owner_display is now `string | null`.
ActivityRow renders the "Lagt til av X" line only when display is
non-null.

Same idea applied to the user's own surfaces (nav + greeting):
  - Nav button shows "Profil" (a label, not a name) when display_name
    is empty, instead of falling back to the email.
  - Home greeting drops "Velkommen, <name>." entirely when the user
    has no display name, leaving just "Her er aktivitetene dine ...".

The feedback list (moderator view) and admin user table keep showing
the email — moderators and admins legitimately need it to identify
users for triage and role management.
2026-05-25 14:00:39 +02:00
755a615f61 Self-registry toggle, invite links with attribution, first-user-admin
Three pieces of a single registration story.

1. **Self-registry toggle.** New generic `settings` key/value table.
   Initial key: `self_registry_enabled` (default `1`). Admin-only PATCH
   /api/settings flips it. GET /api/settings is public so the login
   screen can hide the "Opprett konto" CTA when registration is closed.

2. **Invite links.** New `invites(token, inviter_user_id, created_at,
   claimed_at, claimed_by_user_id)` table; tokens are 22-char base64url
   (~128 bits of entropy). Endpoints:
     POST   /api/invites             — create (any logged-in user)
     GET    /api/invites             — list mine
     DELETE /api/invites/:token      — cancel an unclaimed invite
   Claimed invites are kept in the DB (the audit trail of who-invited-
   whom survives) — only unclaimed ones can be cancelled.

   The signup endpoint accepts an optional `invite_token`. The signup
   handler does the claim + user-insert in a single SQLite transaction
   so we can't end up with a claimed invite pointing at a missing user.
   A concurrent claim race is closed by `UPDATE … WHERE claimed_at IS
   NULL` — only one transaction's UPDATE actually flips the column.

   New `users.invited_by` column records the inviter id so accounts have
   a traceable origin. Profile page shows the user's invites with
   "Kopier lenke" / "Avbryt" buttons; the SPA serves /invite/<token>
   into the Signup view with the token prefilled.

3. **First-user auto-admin.** The signup handler counts users *before*
   the insert; if it's the first one, `is_admin` is set on the row.
   This solves the bootstrap chicken-and-egg without an env var or
   sqlite3 step. Documented in README.

When self-registry is **off**:
  - The login screen hides "Opprett konto" and shows a "stengt" notice
  - /api/auth/signup with no invite returns 403 signup_closed
  - /api/auth/signup with a valid invite still works (and attributes)
  - /api/auth/signup with an *invalid* invite returns 403 invalid_invite

When self-registry is **on**:
  - Anyone can sign up (no invite required)
  - An invite that comes along is still consumed for attribution
  - An invalid invite is ignored — signup proceeds without attribution

26 tests still pass; typecheck clean; bundle 31.2 KB → 32.7 KB gzipped.
2026-05-25 13:45:32 +02:00
5c9455c3f3 Hearts on activities, feedback triage by admins, click-to-permalink
Three small features and one UX bug.

1. **Hearts.** New `activity_hearts(activity_id, user_id, created_at)`
   table with composite PK. POST/DELETE /api/activities/:id/heart for
   logged-in users on any non-private activity. Idempotent — re-posting
   is a no-op rather than a 409.

   Activity serialisation now includes `heart_count` and
   `viewer_hearted` on all three visibility variants (private is always
   0/false; hearts make no sense there). ActivityRow renders a toggle
   button with optimistic updates and a local override that snaps back
   on network error, then propagates the server's authoritative state
   to the parent list via a new `onChanged` callback.

2. **Admin can triage feedback.** Added `done_at` + `done_by` columns
   to feedback (migrated via ensureColumn for older scaffold DBs). New
   admin-only endpoints:
     PATCH  /api/feedback/:id    — { done: boolean }; sets/clears done_at
     DELETE /api/feedback/:id    — drops the entry
   The Feedback list view sorts open requests above done ones, and
   admins see "Marker som ferdig" / "Marker som åpen" / "Slett" buttons
   per entry. Open/done badges visible to everyone with read access
   (i.e., moderators).

3. **Clicking an activity opens its permalink.** Activity titles in
   ActivityRow are now anchor links to /a/:id, so clicking the title
   navigates to the permalink view. Action buttons (heart, edit,
   delete, del/copy-link) stay inside the card; the anchor only wraps
   the title text, so taps elsewhere don't navigate.

Bundle gained 1 KB gzipped (30.2 → 31.2 KB). 26 tests still pass,
typecheck clean.
2026-05-25 13:33:51 +02:00
bd82f71a01 Admin role, root/home URL split, activity permalinks
Three related changes.

1. **Admin role.** New `is_admin INTEGER NOT NULL DEFAULT 0` column on
   users; added to MeResponse. Admin strictly implies moderator —
   shared/roles.ts has a single isModerator()/isAdmin() pair so the
   implication can't drift between callers. The duplicated isModerator()
   helpers in server/activities.ts and server/feedback.ts now import
   from there.

   /api/admin endpoints (admin-only):
     GET   /admin/users           — list users with their roles
     PATCH /admin/users/:id/role  — set is_moderator and/or is_admin

   Last-admin guard: the role-update endpoint refuses to demote the only
   remaining admin (409 cannot_demote_last_admin). Bootstrap is via
   `sqlite3 ... UPDATE users SET is_admin=1` — documented in README.

   Frontend Admin.svelte: table of users with toggles for moderator and
   admin. Visible from the nav only when the current user is admin.
   Toggling our own role refreshes session.user so the nav adapts
   immediately.

2. **Root/home split.** The URL `/` always shows the public landing
   (public + semi activities), even when the user is logged in. `/home`
   is the authenticated dashboard. After login or signup the SPA pushes
   `/home`; after logout it pushes `/`. popstate is wired so the
   back/forward buttons work. Unknown paths fall through to the public
   landing, not a 404.

3. **Activity permalinks at /a/:id.** New SPA route renders a single
   activity via the existing GET /api/activities/:id endpoint (private
   rows still require the owner's session to decrypt). A "Del" button
   on each ActivityRow copies the absolute permalink to the clipboard.
   Clipboard API has a prompt() fallback for environments where it's
   blocked.

Server changes minimal: server/admin.ts is the new file; server/roles.ts
is the lifted helper; server/index.ts wires the admin routes; server/db.ts
gets one more ensureColumn() line.

26 tests still pass; typecheck clean; Vite build succeeds. Bundle grew
from 28.6 KB gzipped to 30.2 KB reflecting the Admin + permalink views.
2026-05-25 13:23:13 +02:00
f0b4d735b5 Public landing, owner-list links, owner-conditional semi, PWA + mobile
Four related UX/privacy/install changes.

1. **Logged-out lands on the public list.** The root route now shows the
   same Home view as a logged-in user, minus their own private rows and
   the "Ny aktivitet" button. The nav exposes a "Logg inn" button when
   no session is present. Login becomes one click away, not the forced
   landing — anyone can browse the public + semi list anonymously.

2. **Public activities link to /<owner_username>/list.** When a public
   activity's owner has opted into a public list, the "Lagt til av X"
   line renders X as a link to /<username>/list. Server populates
   `owner_username` on every public-row serialisation (null when the
   owner hasn't opted in, so the client just renders plain text).

3. **Conditional owner_id on semi rows.** The server now serialises
   `owner_id` on a semi row ONLY when the viewer IS the owner. The
   wire type's `ActivitySemi.owner_id` is therefore optional. This
   solves the semi-delete UX without leaking attribution: owners see
   Edit/Delete buttons on their own semi rows; non-owners get the same
   bare row they got before. The privacy property is enforced at the
   API boundary, not in client-side render logic.

4. **Mobile-friendly + installable PWA.**
   - `manifest.webmanifest` with name, theme color, standalone display,
     and a maskable SVG icon (icon.svg).
   - Service worker (sw.js): cache-first for the bundled shell;
     network-only for /api/* (we never cache session-dependent or
     ciphertext data — see the comment in sw.js for the rationale).
     Falls back to the SPA shell for navigation requests when offline.
   - SW registered in main.ts only in production builds (import.meta.env.PROD).
   - viewport-fit=cover + env(safe-area-inset-*) padding so content
     doesn't slip under iOS notches when installed.
   - WCAG 2.5.5 touch-target sizing: min-height: 44px on buttons,
     with an explicit opt-out for tag-close buttons (24×24 still meets
     the 2.5.8 minimum).
   - 16px font on form inputs below 480px so iOS doesn't auto-zoom.

Server-side: server/index.ts now serves manifest, icon, and sw.js
from frontend/dist alongside /assets/*. The catch-all still serves
index.html so the SPA's /<username>/list path routing keeps working.

Smoke-tested with a production-mode server: manifest returns the
correct application/manifest+json MIME, SVG renders, sw.js is
loadable, and unknown paths fall through to index.html as expected.

26 tests still pass; both tsconfigs typecheck (frontend now pulls
vite/client types for import.meta.env.PROD); Vite build succeeds.
2026-05-25 12:57:59 +02:00
6f4c11c7a6 User profile, activity editing, search, OSM links, moderator role,
opt-in /<username>/list, and a feedback channel

Six related features that touch the user model and activity UX:

1. **User profile** (display_name, username, public_list_enabled).
   New `display_name`, `username` (UNIQUE, slug-shaped), and
   `public_list_enabled` columns. PATCH /api/auth/profile is a partial
   update — pass only the fields you want to change, null to clear.
   MeResponse exposes all three. Display name is shown on public
   activities and in the nav; falls back to the email prefix when
   unset.

2. **Change password from the profile editor.** Existing
   /api/auth/password endpoint surfaced in the new Profile.svelte;
   the local-decrypt failure path on a wrong current password is
   mapped to a clean error.

3. **Edit existing activities.** ActivityForm becomes dual-purpose
   (create or edit). Title, tags, date/time, location, and
   visibility are all editable. Visibility transitions decrypt or
   re-encrypt client-side as needed before PATCH, and the IndexedDB
   private-tag index is kept in sync diff-style.

4. **Search.** A search input on Home filters across visible
   activities. Private rows are searched against their decrypted
   cleartext (decrypted once and memoised via $derived, so the work
   is amortised across keystrokes). Matches across title, tags,
   location label, and (for public) author display name.

5. **OpenStreetMap links.** Each row with a location renders the
   label as an OSM link. Smart: coords if present
   (?mlat=&mlon=&map=15/lat/lng → pinned view), else
   /search?query=. Built with the WHATWG URL constructor so
   Norwegian characters and commas survive.

6. **Moderator role + semi-delete by owner.** New is_moderator
   column on users. Owners always delete their own rows; moderators
   can additionally delete any semi or public activity (private is
   excluded — it's invisible to others, so there's no moderation
   case). README documents the manual promotion via sqlite3.

7. **Opt-in /<username>/list.** New server route
   /api/users/:username/list returns the user's public activities
   when both `username` is set AND `public_list_enabled = 1`. 404
   when either condition fails — same response in both cases so the
   route doesn't leak username existence for users who haven't opted
   in. SPA-side, App.svelte parses window.location.pathname on
   mount; falls back to "/" via history.replaceState after
   authenticating from a deep link.

8. **Feedback channel.** New `feedback` table. POST /api/feedback
   for any authenticated user; GET /api/feedback gated to
   moderators. The Feedback.svelte component is dual-mode — the
   form is universal; the list view auto-loads only for
   moderators. Submitter identity (email + display name) is shown
   to moderators so they can follow up; not exposed to the
   submitter themselves.

Schema migrations land via the existing ensureColumn() helper so
scaffold DBs upgrade cleanly. The username UNIQUE constraint is
applied as a partial unique index (WHERE username IS NOT NULL) so
multiple users with NULL usernames don't collide.

All 26 existing tests still pass; typecheck clean for both
tsconfigs; Vite build succeeds.
2026-05-25 12:44:33 +02:00
add76be486 Close the recovery lockout-DoS hole on /auth/recovery-complete
The original spec stored only `kek_salt`, `wrapped_dek_pw`+nonce,
`rec_salt`, and `wrapped_dek_rec`+nonce. Under that model, anyone who
knew a user's email could POST to /auth/recovery-complete with junk
material and overwrite the password-side wrap, locking the legitimate
user out. The data stayed safe (the attacker couldn't decrypt
anything) but the account was effectively DoS'd until the user dug up
their recovery code.

Fix: add a recovery-side verifier mirroring the password-side one.

Storage: two new columns on `users`:
  - rec_auth_salt           BLOB NOT NULL — independent of rec_salt
  - rec_auth_verifier_hash  TEXT NOT NULL — Bun.password.hash output

The migration adds them via ensureColumn() for forward-compat with
scaffold DBs that pre-date this commit; new tables get them via the
CREATE TABLE statement.

Wire protocol:
  - SignupRequest gains rec_auth_salt + rec_auth_verifier
  - RecoveryChallengeResponse gains rec_auth_salt
  - RecoveryCompleteRequest gains rec_auth_verifier

Server (server/auth.ts):
  - signup hashes the recovery verifier alongside the auth verifier
    and stores both
  - recovery-challenge returns rec_auth_salt so the client can derive
    the verifier; refuses with 409 for pre-fix accounts that have a
    NULL rec_auth_salt
  - recovery-complete calls Bun.password.verify against the stored
    hash BEFORE touching any state. Always runs verify even for
    unknown emails (against a dummy hash) so timing doesn't leak
    existence — same pattern we already used for /auth/login.

Client (frontend/src/lib/auth.ts):
  - signup() generates a fourth salt and derives the recovery
    verifier from the recovery code
  - recover() fetches the new rec_auth_salt and submits the derived
    verifier as part of recovery-complete

Recovery.svelte distinguishes the new 401 ("Feil gjenopprettingskode")
and 409 ("Denne kontoen mangler gjenopprettingsverifikator") cases.

Regression test (tests/auth.test.ts) asserts the gate is real:
  - junk recovery verifier → 401, no state changes
  - unknown email → 401 (constant-time)
  - challenge response includes rec_auth_salt
  - correctly-derived verifier passes the gate

SECURITY.md is updated to describe four salts instead of three, the
new key-model storage, and the closed lockout DoS. CLAUDE.md flags
the rec_auth_* columns as load-bearing — removing them re-opens the
hole.

This is the only deviation from the spec's stated storage model;
documented as such in both SECURITY.md and CLAUDE.md.
2026-05-25 12:28:26 +02:00
47963c9225 Scaffold Vinterliste — end-to-end encrypted winter activity list
Foundation for an E2E-encrypted activity list per
winter-list-claude-code-prompt.md.

Server (Bun + Hono):
- bun:sqlite with WAL and the spec's schema (idempotent migration)
- opaque server-stored sessions, httpOnly cookie
- signup / challenge / login / logout / me / password / recovery-challenge /
  recovery-complete
- activity CRUD with strict visibility rules: private uses ciphertext+nonce,
  semi never serializes owner_id, public attributes the owner
- tag store with normalisation + autocomplete (semi/public only)

Frontend (Svelte 5 + Vite):
- libsodium-wrappers-sumo for client crypto (Argon2id + XChaCha20-Poly1305).
  SUMO is required because the standard build omits crypto_pwhash.
- IndexedDB-backed private tag index (never leaves the browser)
- in-memory DEK (no localStorage); page reload re-prompts for password
- signup shows the recovery code once; tag input merges server + private
  sources with clear labelling
- Bokmål UI

Crypto module (shared/crypto.ts):
- pure, runs in both Bun and the browser via a runtime-conditional loader
  that papers over libsodium-wrappers-sumo's broken ESM entry (createRequire
  on server, Vite alias in the browser)
- DEK wrap/unwrap, AEAD payload encryption, recovery code generation with
  a visually-unambiguous alphabet

Verification:
- 22 crypto round-trip tests (wrap/unwrap, AEAD tamper rejection, password
  change preserves ciphertexts, recovery still works after rotation)
- typecheck passes for server and frontend
- Vite production build succeeds; libsodium SUMO chunk is ~315 KB gzipped

Single-image Containerfile for podman: builds frontend in a builder stage,
runs Bun in a slim runtime; one volume for the SQLite file; BUILD_DATE /
GIT_REVISION baked into OCI labels and /etc/build-info.

Known limitation deferred for this commit: the recovery endpoint has no
server-side proof of the recovery code (anyone who knows an email can lock
out the legitimate user, though they can't read any data). Closed in the
next commit.
2026-05-25 12:27:14 +02:00