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).
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.
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)
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.
Problem: the profile-update handler pre-checked username uniqueness
with a SELECT followed by an UPDATE outside any transaction. Two
concurrent PATCHes setting the same slug would both pass the SELECT
(no conflict yet), then one of the UPDATEs would hit the underlying
UNIQUE constraint and surface as an unhandled SqliteError → 500.
Fix: drop the racy pre-check entirely. The UNIQUE constraint on
users.username (column-level on fresh DBs, partial unique index on
migrated DBs) is the source of truth. Wrap the UPDATE in try/catch
and convert SQLITE_CONSTRAINT_UNIQUE into a clean 409 username_taken
response. Same "push the invariant down to the database" pattern as
the recent first-user-auto-admin race fix.
Surfaced by the username-uniqueness review.
Problem: GET /api/feedback (moderator-readable) returned the user id of
the admin who marked an entry done. Moderators don't need to triangulate
"which admin closed which ticket" — done_at alone is sufficient signal
that the entry has been triaged. Keeping the user id in the response
made it possible to cross-reference admins with the user list via a
second authenticated call.
Fix: the `feedback.done_by` column stays in the schema (server-side
audit trail is preserved) but the column is no longer SELECTed by the
list or update endpoints, and is no longer in the FeedbackEntry wire
type. Moderators see only the `done_at` timestamp.
Surfaced by /audit security (data exposure lens).
Problem: the signup handler counted users with a SELECT issued BEFORE the
transaction opened. Two parallel signups against an empty DB could both
observe count == 0 and both be promoted to admin — a violation of the
"exactly one first user" invariant. Not a confidentiality breach (both
rows passed the same signup checks), but real.
Fix: drop the JS-side count entirely. The is_admin column in the INSERT
is now populated via a subquery:
(SELECT CASE WHEN COUNT(*) = 0 THEN 1 ELSE 0 END FROM users)
SQLite's WAL serializes writers, so the second concurrent INSERT runs
after the first has committed. Its subquery sees count = 1 and returns
0; the new row is not admin. No transaction-mode tweak required.
Surfaced by /audit security (auth & session lens).
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.
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.
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.
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.
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.
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.
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.