Commit graph

10 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
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
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
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
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
eafc216d9b test(friends): lock in directional visibility semantics
The friends-only visibility is one-way:
  - If Anna adds Britt → Anna's friends-only posts are visible to Britt
  - If Britt has NOT added Anna → Britt's friends-only posts are NOT
    visible to Anna, even if Britt is in Anna's list

This matches the user's mental model and is what server/activities.ts
already implements via "owner_id IN (SELECT owner_id FROM friends
WHERE friend_id = ?)" — owner must have added viewer, not the other
way round.

Test covers three cases end-to-end through the HTTP layer:
  1. Asymmetric: Anna adds Britt, but not vice versa. Anna's post
     reaches Britt; Britt's post does NOT reach Anna. Permalink GET
     returns 404 (not 403) for the hidden direction, matching the
     "don't leak existence" pattern we use elsewhere.
  2. Reciprocal: both add each other, both see each other's posts.
  3. Block: mutual friends, then one blocks the other. The block
     filter applies symmetrically — neither sees the other's
     friends-only content from then on, even though the friendship
     rows still exist.

29 total tests now pass (26 prior + 3 new).
2026-05-25 15:02:57 +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