Et verktøy for å lage lister over ting å gjøre om vinteren for å holde vinterdepresjonen unna. Og selvfølgelig kan du også lage lister for andre formål — noen liker jo ikke sommeren heller.
  • TypeScript 66.4%
  • Svelte 27.7%
  • CSS 2.9%
  • Shell 1.6%
  • JavaScript 0.7%
  • Other 0.7%
Find a file
Ole-Morten Duesund f39fe9ed65 Friends + friends-only visibility + blocking
A fourth visibility level ("Venner") with one-way friendship and
two-way block filtering, plus the table-rebuild migration that drags
older dev DBs forward.

Visibility model:
  - Friendship is directional: (owner, friend) means owner has added
    friend to their list. Owner's friends-only activities become
    visible to friend; friend isn't automatically friends with owner.
  - Blocking is also directional at the DB level (blocker, blocked)
    but is checked SYMMETRICALLY at visibility-resolution time: once
    either user has blocked the other, friends-only content stops
    flowing in either direction. Block does NOT affect public or
    anonymous content — those are open to anyone by definition.
  - "Friends-only" is an access-list visibility, NOT cryptographic.
    The server stores the content in plaintext and serves it only to
    authorised viewers. This is documented honestly in /personvern.

Schema:
  - activities.visibility CHECK gains 'friends' as a fourth value
  - friends(owner_id, friend_id, created_at) — composite PK,
    self-friending blocked by CHECK
  - user_blocks(blocker_id, blocked_id, created_at) — same shape,
    blocking-self also blocked

Migration (server/db.ts):
  - SQLite can't ALTER a CHECK constraint, so the migration detects
    out-of-date DBs by scanning sqlite_master for the literal
    "'friends'" in the activities table's CREATE statement
  - If absent, rebuilds activities via the standard SQLite
    table-copy-drop-rename dance with foreign_keys briefly off
    around the transaction, then runs foreign_key_check to confirm
    no FKs were left orphaned (activity_tags, activity_hearts,
    bookmarks all point at activities). Smoke-tested on the dev DB:
    olemd's user row and moderator/admin flags survived.

Server endpoints (server/friends.ts):
  GET    /api/friends           — my outgoing list
  GET    /api/friends/incoming  — who has added ME
  POST   /api/friends           — add by username (idempotent)
  DELETE /api/friends/:userId   — remove a friend
  GET    /api/friends/blocks    — my blocked-users list
  POST   /api/friends/blocks    — block by user_id (idempotent)
  DELETE /api/friends/blocks/:userId — unblock

Add-by-username (not by email): users must set a username to be
findable. Email stays a private contact identifier.

Activity list filter (server/activities.ts): adds two clauses to the
WHERE — own friends-only, and friends-only owned by a user who has
added me AND there's no block in either direction. Single-activity
GET applies the same check.

Frontend:
  - ActivityForm.svelte gains the "Venner" option
  - ActivityRow.svelte renders a "Venner" badge with a new amber
    vis-badge.friends colour (passes contrast in both themes)
  - FriendsPanel.svelte: add-by-username form, outgoing, incoming
    (with Block button), and blocked (with Unblock button)
  - Profile.svelte mounts FriendsPanel between display fields and
    Eksporter
  - Home.svelte adds a "Venner" section between private and semi

Docs: Personvern.svelte gains a "Venner og blokkering" section
explaining that friends-only is access-list-not-crypto and pointing
the reader at "private" for actually-sensitive content.

26 tests still pass; typecheck clean; build succeeds. Bundle
36.8 KB → 39.1 KB gzipped (FriendsPanel + new server endpoints +
the Personvern prose).
2026-05-25 14:47:20 +02:00
frontend Friends + friends-only visibility + blocking 2026-05-25 14:47:20 +02:00
server Friends + friends-only visibility + blocking 2026-05-25 14:47:20 +02:00
shared Friends + friends-only visibility + blocking 2026-05-25 14:47:20 +02:00
tests Close the recovery lockout-DoS hole on /auth/recovery-complete 2026-05-25 12:28:26 +02:00
.dockerignore Scaffold Vinterliste — end-to-end encrypted winter activity list 2026-05-25 12:27:14 +02:00
.gitignore Scaffold Vinterliste — end-to-end encrypted winter activity list 2026-05-25 12:27:14 +02:00
bun.lock Scaffold Vinterliste — end-to-end encrypted winter activity list 2026-05-25 12:27:14 +02:00
CLAUDE.md Admin role, root/home URL split, activity permalinks 2026-05-25 13:23:13 +02:00
Containerfile Scaffold Vinterliste — end-to-end encrypted winter activity list 2026-05-25 12:27:14 +02:00
package.json Scaffold Vinterliste — end-to-end encrypted winter activity list 2026-05-25 12:27:14 +02:00
README.md Self-registry toggle, invite links with attribution, first-user-admin 2026-05-25 13:45:32 +02:00
SECURITY.md Close the recovery lockout-DoS hole on /auth/recovery-complete 2026-05-25 12:28:26 +02:00
tsconfig.json Scaffold Vinterliste — end-to-end encrypted winter activity list 2026-05-25 12:27:14 +02:00
winter-list-claude-code-prompt.md Scaffold Vinterliste — end-to-end encrypted winter activity list 2026-05-25 12:27:14 +02:00

Vinterliste

A small end-to-end-encrypted app for collecting winter activities — things to do when winter feels long. Activities can be:

  • private — encrypted client-side; the server only ever sees ciphertext;
  • semi-public — visible to everyone, but the creator is not shown;
  • public — visible to everyone, attributed to the creator.

See SECURITY.md for the cryptographic model. It's load-bearing — read it before changing anything in shared/crypto.ts or the auth flow.

Stack

  • Runtime: Bun 1.3+. TypeScript everywhere.
  • HTTP: Hono on Bun.
  • DB: bun:sqlite (built-in), WAL mode.
  • Server password hashing: Bun.password (argon2id) — auth verifier only.
  • Client crypto: libsodium-wrappers-sumo (WASM — the SUMO build is needed because the standard libsodium-wrappers doesn't ship crypto_pwhash). Argon2id via crypto_pwhash; AEAD via XChaCha20-Poly1305-IETF.
  • Frontend: Svelte 5 + Vite. Private tag index in IndexedDB.
  • Container: single oven/bun image, one volume for the SQLite file.

Layout

shared/      pure modules used by both server and frontend
  crypto.ts  libsodium-backed key derivation, AEAD, wrap/unwrap, helpers
  types.ts   wire-level types shared across the network boundary

server/      Bun + Hono backend
  db.ts            bun:sqlite, WAL, idempotent schema migration
  session.ts       opaque, server-stored sessions (httpOnly cookie)
  auth.ts          signup, challenge, login, logout, password change, recovery
  activities.ts    CRUD with visibility rules
  tags.ts          server-side (public/semi) tag store + autocomplete
  index.ts         Hono app + static frontend in production

frontend/    Vite + Svelte 5 SPA
  src/lib/crypto.ts      re-exports shared/crypto for bundling
  src/lib/api.ts         fetch wrapper for the JSON API
  src/lib/auth.ts        signup/login/recovery orchestration
  src/lib/tagIndex.ts    IndexedDB store for private tags
  src/lib/session.svelte.ts  in-memory DEK + current user
  src/components/        Svelte 5 components (Login, Signup, Recovery, Home, …)

tests/       Bun tests
  crypto.test.ts   round-trip wrap/unwrap, AEAD, password change, recovery

Containerfile  single-image build for podman

Running locally

You need Bun 1.3+ installed.

bun install

# 1. In one terminal — start the API on http://localhost:3000
bun run dev:server

# 2. In another terminal — start the Vite dev server on http://localhost:5173
#    (it proxies /api to :3000)
bun run dev:frontend

The dev server writes the SQLite file to data/vinterliste.db. Set VINTERLISTE_DB=/some/other/path to override.

Tests

bun test

The crypto tests cover:

  • DEK wrap/unwrap via both the password and recovery paths;
  • AEAD encrypt/decrypt round-trip, plus tamper and wrong-key rejection;
  • password change preserves activity ciphertexts (DEK is the same);
  • recovery unlocks even after multiple password changes;
  • recovery-code normalisation handles dashes and casing;
  • the safe alphabet excludes visually ambiguous characters.

Typecheck

bun run typecheck

Production build

bun run build:frontend    # produces frontend/dist
NODE_ENV=production bun run start

The server serves the SPA from frontend/dist in production. All non-/api/*, non-/assets/* requests fall through to index.html so client-side routing still works.

Container (podman)

The provided Containerfile builds a single image that serves API + frontend and persists the SQLite database in /app/data (one volume).

BUILDAH_FORMAT=docker podman build \
  --build-arg BUILD_DATE="$(date -u +%Y-%m-%dT%H:%M:%SZ)" \
  --build-arg GIT_REVISION="$(git describe --always --dirty 2>/dev/null || echo dev)" \
  -t vinterliste:latest .

# Create a named volume for the SQLite file
podman volume create vinterliste-data

podman run --replace --name vinterliste \
  -p 3000:3000 \
  -v vinterliste-data:/app/data:Z \
  vinterliste:latest

# Visit http://localhost:3000

The container exposes /api/health for healthchecks and bakes the build date / git revision into both OCI labels and /etc/build-info.

Registration: open, invite-only, or both

Admins can toggle self-registration from the Admin UI. Two modes:

  • Open (default): anyone can hit / → "Logg inn" → "Opprett konto".
  • Closed: the "Opprett konto" button is hidden; new accounts can only be created through invite links.

Any logged-in user can generate invite links from their Profile page. Each link is single-use; the link URL is /<origin>/invite/<token> and the recipient is dropped straight into the signup form with the token attached.

Successful invite-signups record users.invited_by = <inviter_id> so the account has a traceable origin. Invites that have been claimed are kept in the DB (they can no longer be cancelled) so the audit trail survives.

Installable (PWA) + mobile

The SPA ships with a web app manifest (/manifest.webmanifest), an SVG icon (/icon.svg), and a small service worker (/sw.js) that caches the bundled shell for offline reads. The API itself is never cached — sessions and ciphertexts must come fresh from the server. On supported browsers (Chrome/Edge on Android and desktop, Firefox with the flag) you'll see an "Install" prompt; on iOS you can Add to Home Screen but iOS doesn't render SVG icons, so the home-screen icon will fall back to the page screenshot.

Layout adapts to small screens via:

  • viewport set to width=device-width, initial-scale=1, viewport-fit=cover
  • safe-area insets in padding so content doesn't slip under iOS notches
  • min-height: 44px on buttons (WCAG 2.5.5 enhanced touch target)
  • font-size: 16px on inputs below 480px so iOS doesn't auto-zoom

Roles: moderator and admin

There are three privilege levels:

Role What it grants
Anonymous Browse public + semi activities, view opt-in /<username>/list pages
User + manage own activities, edit own profile, submit feedback
Moderator + delete any semi/public activity, read the feedback list
Admin + grant/revoke moderator and admin on other users (via /api/admin/users)

Admin implies moderator — admins automatically pass any is_moderator check.

The first user to sign up is auto-promoted to admin so a fresh deployment is never stranded without one. After that, admins can grant moderator/admin to others through the Admin UI. If for any reason that didn't happen (e.g., you imported a DB), you can still bootstrap manually:

# Manually promote (e.g. for imported DBs):
sqlite3 data/vinterliste.db \
  "UPDATE users SET is_admin = 1 WHERE email = 'you@example.org';"

# Promote a plain moderator (admins can also do this from the UI):
sqlite3 data/vinterliste.db \
  "UPDATE users SET is_moderator = 1 WHERE email = 'them@example.org';"

The user has to log out and back in for the in-memory session.user to refresh — server-side authz updates on the next request regardless.

A last-admin safety net is wired into the role-update endpoint: an admin trying to demote themselves while they're the only remaining admin gets a 409 cannot_demote_last_admin. If you really want to strand the deployment with no admin, you have to use sqlite3 directly.

Manual verification

After signing up an account, the spec asks you to inspect a private row directly in the DB and confirm only ciphertext is stored:

sqlite3 data/vinterliste.db \
  "SELECT id, visibility, title, loc_label, scheduled_at,
          length(ciphertext) AS ct_len, length(nonce) AS nc_len
   FROM activities WHERE visibility = 'private';"

You should see title, loc_label, and scheduled_at all NULL, and the ciphertext / nonce columns populated.

Status / scope

This is the scaffold from winter-list-claude-code-prompt.md. In scope:

  • repo structure, schema, single-image container
  • crypto module + tests
  • signup / login / password change / recovery
  • activity CRUD with strict visibility handling
  • tag autocomplete (server tags table + client IndexedDB)

Explicitly out of scope for now:

  • sharing/permissions beyond the three visibility levels
  • comments, notifications, other social features
  • native/mobile apps
  • server-side full-text search over private data
  • rate limiting on auth/recovery endpoints (defense-in-depth — the recovery verifier already closes the lockout-DoS hole; rate limiting reduces online brute-force surface)