Commit graph

28 commits

Author SHA1 Message Date
6e005fc2d7 feat(activity): per-viewer archive and hide
Two new per-viewer flags on activities, mirroring the heart/done shape:

- ARCHIVE: any viewer (incl. the owner) can archive a row. Out of
  sight by default but the permalink still resolves. For owners,
  archive also filters the row out of their /<bruker>/liste public
  page — "I'm done sharing this."
- HIDE: only non-owners. "This doesn't appeal to me." Endpoint
  returns 400 cannot_hide_own when the owner tries it (they should
  delete or archive instead).

Both default-filtered from GET /api/activities. Opt-in via query
params with three modes each: exclude (default), include (1), only.
?archived=1&hidden=1 includes both; ?archived=only shows just the
archive. The list endpoint composes the SQL filters per-viewer
(anonymous viewers have no rows in either table → no-ops).

Schema: two new tables user_archived_activities and
user_hidden_activities, both composite-PK on (user_id, activity_id),
cascade on both refs. Same shape as the existing engagement tables.

Types: viewer_archived + viewer_hidden on every Activity variant.
viewer_hidden on private is always false (the endpoint refuses);
docstring on the type explains why.

Bulk lookups extended so the list endpoint stays at constant queries.
Single-row paths get per-row viewerArchived/viewerHidden helpers.
fetchRowForViewer keeps preserving the viewer's custom sort_position
on toggles.

UI: two new buttons in ActivityRow's action row — "📦 Arkiver" /
"📦 Arkivert" (everyone) and "🙈 Gjem" / "🙈 Skjult" (non-owners
only). Two new checkboxes near the search field on /hjem: "📦 Vis
arkivert" and "🙈 Vis skjult". Toggling either re-fetches from the
server because the filtering is server-side. When an archive/hide
flips the row out of the current view, Home.svelte triggers a
refetch so the row disappears in real time.

Public-list endpoint (/api/users/:bruker/list) also filters out the
owner's own archived rows — consistent with "archive means filed
away from active view."

Tests: 3 new in engagement.test.ts — viewer archives + per-viewer
isolation, owner's archive filters their public list, hide refuses
on own row + ?hidden=only path. Suite goes 102 → 105.
2026-05-25 20:19:44 +02:00
ef02b3f585 feat(ops): emergency password-reset CLI + deployment docs
New CLI: bun run reset-password <email>

Two modes selected interactively:

- Recovery mode: if you still have the user's recovery code, unwrap
  the existing DEK with it and re-wrap with the new password. No data
  loss; the recovery code stays valid (mirrors /auth/recovery-complete).
- Nuke mode: if both password AND recovery code are gone, generate a
  fresh DEK + new recovery code (printed once), and DELETE the user's
  private activities — their ciphertext is permanently unrecoverable.
  Public/semi/friends rows and engagement (hearts/bookmarks/done) are
  preserved.

Both modes invalidate the user's sessions.

Password length matches the signup/recovery rule (12 chars min).
Wrong-recovery-code path aborts before any DB writes. Hand-rolled
line reader sidesteps a Bun quirk where node:readline only delivers
the first answer when stdin is piped.

Also expand README's "Deployment" section: container snippet stays,
plus new subsections for env vars, TLS termination (with a Caddyfile
example), backup/restore via sqlite3 .backup, the /api/health
healthcheck, upgrade flow, and a walkthrough of the reset CLI.
2026-05-25 20:04:57 +02:00
fb193b4914 fix(activities): preserve viewer's sort_position on single-row fetches
Toggling "gjort" (and heart, and bookmark, and edit, and GET /:id)
silently reset the row's effective sort_position to -created_at,
wiping any custom drag-sort the viewer had applied to that row.
The list endpoint joins user_activity_sort to get the per-viewer
position; single-row endpoints were doing plain
`SELECT * FROM activities WHERE id = ?` and serialize() was falling
back to -created_at when sort_position was missing from the row.

User-visible effect on the private list (which often has custom
ordering since it's the user's todo list): toggling a checkbox made
that row jump back to its created_at slot.

Fix: fetchRowForViewer(id, viewerId) helper that does the same
LEFT JOIN as the list query. Routed through every single-row return
path — GET /:id, POST /, PATCH /:id, POST/DELETE /:id/heart,
POST/DELETE /:id/bookmark, POST/DELETE /:id/done.

Regression test covers heart + done + GET-by-id all preserving a
custom sort_position written via PATCH /:id/sort.
2026-05-25 19:46:24 +02:00
bbb5ad2bdd feat(activity): "Gjort" mark with statistics
Per-user "I've done this" toggle alongside hearts and bookmarks.
Hearts express approval; gjort expresses completion. Both contribute
to public statistics so readers can see what people LIKE versus what
people actually DO.

Backend:
- New activity_done table (composite PK on activity_id + user_id,
  CASCADE on both refs, mirrors activity_hearts).
- POST/DELETE /api/activities/:id/done. Unlike heart/bookmark, "gjort"
  works on every visibility the viewer can see — private (owner-only,
  acts as a personal todo checkbox), friends-only (mutual-friend +
  no-block check, mirrors GET /:id), public, semi. Non-viewers get
  404 to avoid leaking existence.
- buildBulkLookups + serialize extended with done_count + viewer_done
  so the list endpoint stays at constant queries per render.
- Public-list endpoint (server/users.ts) bulk-fetches done counts
  alongside heart counts; viewer_done is always false (unauth view).

Types: Activity{Public,Semi,Private,Friends} all gain done_count +
viewer_done. Private's count is at most 1 (only the owner can write).

UI: new "✓ Gjort" / "☐ Gjort" button in the action row with the same
optimistic-toggle + localOverride pattern as hearts. Anonymous viewers
on public activities see a muted "✓ N" stat. Title hint clarifies
the intent: "Dette har jeg gjort" vs "Du har gjort dette."

Tests: 2 new in engagement.test.ts — toggle + idempotency on public,
owner-only access on private (non-owner gets 404).
2026-05-25 19:00:26 +02:00
f4816502ed refactor: Norwegian URL paths
User-visible SPA routes were a mix of English and Norwegian. Bring
them in line with the rest of the project's language:

  /home              → /hjem
  /a/:id             → /aktivitet/:id
  /<username>/list   → /<username>/liste
  /tags/:tag         → /etiketter/:etikett
  /invite/:token     → /invitasjon/:token

The English forms remain accepted by the SPA router (parsePath) and
the server OG handlers so links shared before the rename — invite
URLs in particular — still resolve. Outgoing links always use the
Norwegian forms. API paths (/api/users/:username/list etc.) stay in
English — they're internal contracts between client and server, not
user-visible URLs.

Server OG registration orders /<username>/liste before /aktivitet/:id
so a hypothetical user with the slug "aktivitet" still gets their
profile page rather than an activity-not-found. For normal activity
URLs the user-list route doesn't match (second segment must be the
literal "liste").

Profile copy referencing the URL slug also updated.
2026-05-25 18:20:50 +02:00
54d8ed22f4 feat(tags): moderators and admins can delete public tags
Add DELETE /api/tags/:name (gated by isModerator(), which also passes
for admins per the admin-implies-moderator invariant in roles.ts).
The endpoint normalises the name the same way creation does so the
URL casing doesn't matter, then deletes the tag and detaches it from
every activity_tags row in one transaction.

UI: new "Etiketter" nav entry visible to moderators + admins, opens
a ModerateTags.svelte view with search-as-you-type (reusing the
/api/tags suggestion endpoint) and a Slett button per row. Private
tags are unaffected — they're encrypted in the activity payload and
never reach the server tag table.

Tests: 3 new cases on top of the admin suite — moderator can delete,
plain user gets 403 (anonymous gets 401), unknown tag gets 404.
2026-05-25 17:57:33 +02:00
dc349a9373 chore(sessions): schedule periodic expired-session GC
gcSessions() existed but only ran on signup. On low-volume instances the
sessions table would grow forever as users logged in/out without ever
triggering a sweep. Schedule it hourly from server boot via
setInterval(...).unref() so it doesn't keep the process alive. Run once
on boot too, to clean up whatever accumulated while the previous instance
was down. Tests import individual route modules instead of server/index.ts,
so this only fires in a real server boot.

Surfaced by /audit simplify.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-25 17:50:06 +02:00
c7365b46ed perf(activities): bulk-fetch list serialisation (N+1 → constant)
The /api/activities list endpoint serialised each row by calling
tagsFor, heartsFor, viewerBookmarked, and ownerAttribution — that's
~5 queries per row. For a list of N activities the endpoint issued
5N queries; for a hundred-row list, hundreds of round-trips.

Add buildBulkLookups(rows, viewerId) that runs one IN-query per
concern (tags, heart counts, viewer-hearted, viewer-bookmarked,
owner attribution) and returns precomputed maps. serialize() now
accepts an optional bulk arg; single-row paths (GET /:id, POST,
PATCH, heart/bookmark toggles) pass undefined and keep their per-row
helpers — fine for one row, wrong for a list.

Also add bulkTagsFor() to server/tags.ts as a reusable helper, and
apply the same treatment to server/users.ts (public-list endpoint
had a 2N pattern on tags + heart counts).

Surfaced by /audit simplify.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-25 17:45:46 +02:00
9bcdf53d0b refactor(activities): dedupe heart/bookmark POST+DELETE endpoints
Four nearly-identical endpoints (heart add/remove, bookmark add/remove)
collapse into one toggleMark(c, kind, op) helper. Behaviour is unchanged
— idempotent on both sides, 404 on missing activity, 400 if private,
same serialized response. Tests pass.

Surfaced by /audit simplify.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-25 17:35:14 +02:00
8295a35c94 Drag-and-drop unified activity list with per-user sort order
The home dashboard's five sections (Bokmerker / Dine private /
Venner / Anonyme / Offentlige) collapse into one ordered list.
Each row is identified by its visibility badge plus an optional
"★ Bokmerket" badge — the meaning stays clear, the layout gets
much tighter, and reordering across visibility levels becomes a
single drag.

Per-viewer ordering, sparsely stored:

Schema (additive):
  - user_activity_sort(user_id, activity_id, position REAL)
    composite PK; ON DELETE CASCADE both ways

Sort math: the list query LEFT JOINs the table per-viewer and
orders by COALESCE(custom_position, -activity.created_at). Rows
without a custom position sort by -created_at (very negative for
recent activity, very less-negative for old) so new and untouched
activities float to the top in newest-first order. Once dragged,
the row carries a real float position that the listing query uses
instead.

Sort endpoint:
  PATCH /api/activities/:id/sort  body: { position: number }
  ON CONFLICT UPDATE so re-dragging the same row is cheap and
  doesn't accumulate rows.

Wire: every activity variant now carries `sort_position: number`
— the effective position the server used (custom or -created_at).
The client uses it to compute midpoint positions on drop without
needing to know the formula.

Frontend:
  - Home.svelte renders one list ordered by sort_position. Search
    filter still works across the unified list.
  - ActivityRow.svelte gains a drag-handle button (only rendered
    when the parent passes draggable=true; off on the public
    landing and on /a/:id, /<username>/list, /tags/:tag).
  - ActivityRow.svelte gains a "★ Bokmerket" vis-badge alongside
    the visibility badge so the marker is consistent with the
    other status pills.
  - Home computes the drop's new position as the midpoint between
    neighbours (or top/bottom + 1.0 at the edges), updates the
    local list optimistically, then PATCHes the server. Snapping
    back on failure.

Touch DnD is currently not supported — HTML5 native DnD doesn't
work on touch. Adding a polyfill is a separate concern (the user
explicitly asked for drag-and-drop; can revisit for mobile later).

Regression test in tests/activities.test.ts covers:
  - default order is newest-first
  - a custom position via PATCH /sort moves a row
  - ordering is per-viewer (A's drag doesn't affect B's list)
  - a fresh activity created after a custom position floats above
    the user's custom-positioned rows (because -created_at is much
    more negative than any reasonable custom position float)
  - 401 without auth
  - 400 on missing or non-finite position

96 tests pass total; typecheck clean; build ok.
2026-05-25 16:47:55 +02:00
e64d5450f8 fix(invites): build share URL on the client, not the server
server/invites.ts derived the share URL from c.req.url — i.e., from
the API request's host. In production the API and SPA share an
origin so this happened to work; in dev where the SPA runs on :5173
and the API on :3000, the generated link pointed at the API
(http://localhost:3000/invite/<token>) which serves nothing.

Fix: the server no longer returns a `url` field. The token is the
canonical artefact; the SPA builds the share link itself via
`${window.location.origin}/invite/${token}`, which is always the
right origin regardless of split-process dev or single-process prod.

  - shared/types.ts: InviteEntry.url removed
  - server/invites.ts: drop originOf() and the URL field in toEntry()
  - frontend Profile.svelte: new inviteUrl() helper; the displayed
    <code> and the clipboard payload both use it
  - tests/social.test.ts: assertion checks token shape instead of
    the URL field

93 tests still pass.
2026-05-25 16:25:55 +02:00
9b825bfe1d External profile links (max 5 per user)
Users can attach up to 5 labelled URLs to their profile — social
handles, blog, anything. They're shown on /<username>/list (the
opt-in public list), behind target="_blank" rel="noopener noreferrer
ugc" anchors so the destination tab can't script back into our
window.

Schema:
  - user_links(id, user_id, label, url, position, created_at)
    UNIQUE(user_id, position) to keep ordering stable, ON DELETE
    CASCADE so user deletion sweeps links.

Wire:
  - UserLink type (id/label/url)
  - MeResponse.links: UserLink[]
  - PublicListResponse.links: UserLink[]
  - ProfileUpdateRequest.links?: { label, url }[]  — bulk replace
  - USER_LINK_LIMITS exported so frontend constraints match server

Validation (server/auth.ts):
  - Label 1-40 chars, trimmed
  - URL parseable + http:// or https:// only (no javascript:, data:,
    mailto:, etc.)
  - URL ≤ 500 chars
  - Max 5 links per user

Bulk replace semantics with up-front validation, then DELETE +
INSERT inside the same transaction as the user UPDATE. A username
UNIQUE violation rolls back the link changes too — no half-applied
state. Empty rows in the request are silently dropped so users
can leave half-typed entries without a server rejection.

Frontend Profile gets an "Eksterne lenker" section between the
FriendsPanel and the Eksporter section. Five label+URL row pairs
with add/remove buttons, save button, error → Bokmål mapping
(link_label_required, link_url_bad_protocol, etc.).

93 tests still pass; typecheck clean; build ok.
2026-05-25 16:20:04 +02:00
7964d499e2 OpenGraph meta on the SPA's shareable routes
Server-side render OG + Twitter Card meta for the routes where rich
link previews matter:

  /                — homepage
  /personvern      — privacy + how-it-works
  /a/:id           — activity permalink
  /tags/:tag       — tag page
  /<username>/list — public list (opt-in)

Everything else falls through to the unmodified SPA shell.

Approach: in production mode, register Hono handlers BEFORE the
catch-all that read the prebuilt index.html template, swap <title>
and <meta name="description"> with route-specific values, and inject
an OG/Twitter meta block before </head>. Same HTML to scrapers and
real users — no UA sniffing. The SPA still bootstraps the same way
(the <script type="module"> and <div id="app"> are untouched).

Information-leak guard: private activities and not-opted-in public
lists fall back to the SAME generic "not found" OG as truly missing
URLs. Otherwise a scraper could distinguish "exists but hidden"
from "doesn't exist," which the regular API correctly hides.

Implementation notes:
  - Template read once on first request and cached. New deploys
    require a server restart to pick up a rebuilt index.html, which
    is the normal deploy flow anyway.
  - All meta attribute values pass through escapeAttr() so user
    content (activity titles, tag names, display names) can't break
    out of the attribute or inject HTML.
  - Description capped at 200 chars (what most scrapers actually
    render).
  - Base URL prefers PUBLIC_BASE_URL env var, falling back to
    request URL — works behind a reverse proxy if the env var is set.
  - PNG fallback for OG image is deliberately not shipped; the SVG
    works on every modern scraper that matters. iOS home-screen
    previews are the only place this falls back to the page
    screenshot, which is fine.

Smoke-tested via curl against a production-mode server: all five
routes render the right title, description, og:url, and image, and
the "not found" + "hidden" cases collapse to the same OG shape.
2026-05-25 16:05:43 +02:00
7d9b4a3599 test: coverage for all major server features
Brings the test count from 29 → 93 across 8 files. Each new file
exercises one feature area through the HTTP layer using a shared
helper that spins up a fresh Hono app on a fresh temp DB.

New test files:
  - tests/helpers.ts:        setupTestApp(), signupAndGetCookie(),
                             req()/reqJson() convenience, the ttest()
                             helper that wraps test() with a 30s
                             timeout (Argon2id signups blow the 5s
                             default).
  - tests/activities.test.ts (17 tests): create + read per visibility,
                             owner_id stripping on semi, visibility
                             transitions (column wipes, tag-row
                             clearing), validation rejects, delete +
                             update authz (owner / moderator),
                             display-name fallback chain.
  - tests/engagement.test.ts (10 tests): heart toggle + idempotency
                             + private-refusal + auth gate; same for
                             bookmarks; tag normalisation + autocomplete
                             prefix-match + tag-store-never-sees-private
                             + tag-update-deccrements-old.
  - tests/admin.test.ts (9 tests): first-user-auto-admin, admin gate
                             (401/403/200), promote/demote, last-admin
                             guard, admin-implies-moderator (via
                             feedback-list endpoint), user-list shape,
                             admin-can-delete-semi crossover.
  - tests/social.test.ts (11 tests): feedback submit/list/done/delete +
                             admin-only gates; invite create/claim/cancel,
                             single-use behaviour, audit-trail preservation
                             on claimed invites, cross-user delete blocked;
                             settings GET (public), closed-registry gate
                             with invite bypass.
  - tests/profile.test.ts (17 tests): display_name + username updates,
                             username validation (incl. silent
                             lowercasing), username uniqueness via 409,
                             public-list opt-in / opt-out, full login
                             flow (challenge → login → me), wrong-verifier
                             vs unknown-email both 401, logout
                             invalidates session, password change happy
                             path (re-login with new + old fails),
                             duplicate email 409, invalid email 400.

**Real bug uncovered**: the invite signup flow hit a `FOREIGN KEY
constraint failed` because `claimInvite()` does `UPDATE invites SET
claimed_by_user_id = <new user id>` *before* the user INSERT runs.
With foreign_keys=ON (which we run with), the FK on
`invites.claimed_by_user_id → users.id` blows up immediately. No live
session had exercised invite signup end-to-end, so the bug was
invisible.

Fix in server/auth.ts: `PRAGMA defer_foreign_keys = ON` inside the
signup transaction, so FK checks happen at COMMIT after the user
INSERT has run. The PRAGMA is per-transaction and resets automatically.

93/93 tests pass; typecheck clean; build succeeds.
2026-05-25 15:37:53 +02:00
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
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
d68859d68b fix(auth): race-proof username uniqueness in PATCH /profile
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.
2026-05-25 14:00:26 +02:00
fbe37109a4 fix(feedback): stop exposing done_by user id in API responses
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).
2026-05-25 13:54:07 +02:00
b16d06e651 fix(auth): close first-user-auto-admin race
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).
2026-05-25 13:52:57 +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