Compare commits

...

10 commits

Author SHA1 Message Date
95f989639d feat(invites): drop literal token after claim; cleaner UI
Once an invite is claimed, the token has no functional role — claims
are one-way and the link is dead. Stop returning the literal token in
the GET /api/invites response for claimed entries (server/invites.ts
toEntry). The audit trail — claimed_at, claimed_by_display — stays.
Helps a little with data minimization: a compromised inviter account
can no longer see used-up invitation URLs.

Type: InviteEntry.token is now string | null. Callers that still need
to use the token (signup-via-invite tests, the cancel button, the
copy button) are guarded so they only run on entries where the token
is present (i.e. unclaimed). The each-key falls back to a synthetic
composite when token is null so Svelte's keyed-each stays stable.

UI: claimed entries collapse to a single muted line, no card frame,
no URL placeholder:
    ✓ Laget DD.MM.YYYY · godtatt av <bruker> DD.MM.YYYY

Unclaimed entries keep the existing card with copy / cancel buttons.
Heading on the invite section also renamed from "Invitasjonslenker"
to "Invitasjoner" — claimed entries don't have a link anymore so the
older label was misleading.

Tests updated to match by created_at instead of token for the
claimed-invite lookup, and to assert that token is null post-claim.
2026-05-25 20:47:33 +02:00
2ac73c3515 feat(social): "Legg til som venn"-knapp + clearer used-invite copy
Two small UX fixes on the Profile page:

1. FriendsPanel: incoming-friends list previously had only a
   "Blokker" button. Add a "Legg til som venn"-knapp alongside it
   when the entry isn't already mutual. Shows "(gjensidig)" inline
   for entries where I've already added them back, and hides the
   add button when the incoming user doesn't have a username (rare
   — addFriend goes by username).

2. Invite list: claimed entries now show
   "✓ Mottatt og tatt i bruk av <bruker> · <dato>" instead of the
   "Brukt" badge + separate "av X · Y" line. Clearer wording, same
   data.
2026-05-25 20:34:50 +02:00
0e5bf0a035 fix(activities): close existence oracle on PATCH /:id/sort
The sort endpoint validated existence with a bare
`SELECT 1 FROM activities WHERE id = ?`, ignoring visibility. A
logged-in attacker could PATCH /sort with any UUID and distinguish
"private id exists, owned by someone else" (200) from "id doesn't
exist" (404), letting them enumerate private activity ids.

Apply the same visibility filter as GET /:id, toggleDone, and
toggleFiling: private requires owner; friends requires mutual-friend
+ no block in either direction; hidden rows return 404, not 403.

Regression test added in tests/activities.test.ts.

Surfaced by /audit security (HIGH severity).
2026-05-25 20:34:50 +02:00
09a9e3742c fix(home): stop self-firing $effect that pinned list on "Laster …"
The "Vis arkivert" / "Vis skjult" toggles re-fetch from the server
(filtering is server-side). I had wired the re-fetch through a
$effect tracking both checkboxes — but the effect body also read
`loading` to skip re-entry, which meant the effect re-ran every
time load() flipped `loading` to true and back. Each cycle called
load(), which flipped `loading` again, ad infinitum: the list
stayed pinned on "Laster …" forever.

Replace with explicit onchange handlers on the two checkboxes that
both update state AND call load() once. Same UX, no reactive loop.
2026-05-25 20:26:53 +02:00
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
38db772b4f feat(activity): show visible creation date on each row
Add a "Lagt til DD.MM.YYYY" line to ActivityRow. For public rows the
existing owner-attribution snippet folds into the same muted footer
line so it reads naturally ("Lagt til 25.05.2026 av Ole-Morten").
Private rows just get the date — they have no public attribution.

Date-only is intentional (per spec: "Det holder med dato"). created_at
is epoch milliseconds, not seconds like scheduled_at, so a separate
formatDateOnly helper avoids confusing the two.
2026-05-25 18:50:57 +02:00
443702d222 fix(spa): back button respects history; auto-redirect / → /hjem when logged in
Two related navigation fixes:

1. Back-button now uses real browser history. Sub-view "Tilbake"
   buttons (permalink, tag page, personvern, public list) all used
   to call goPublicHome() — which always sent the user to / —
   instead of returning to wherever they came from. Replaced with
   a single backToCallerOrHome() that delegates to
   navigate.goBack(fallback), which calls window.history.back() if
   there's a prior entry, else navigates to /hjem (or / when
   anonymous) so cold-loaded permalinks still have somewhere to go.

2. Cold-load redirect: a logged-in user landing on / probably
   wants their own dashboard, not the public marketing surface.
   After the me-probe finishes in onMount, if the initial route
   was 'public-home' and session.user exists, push to /hjem
   instead of applying the public-home route. This only runs on
   initial mount (not on every popstate), so browser-back from
   /hjem to / still works if the user explicitly navigates there.

The wordmark in the nav also picks its destination by auth state
now — logged-in users go to /hjem, anonymous users to /. Otherwise
clicking it post-redirect would just bounce back to /hjem.
2026-05-25 18:48:34 +02:00
18 changed files with 1337 additions and 88 deletions

106
README.md
View file

@ -101,7 +101,9 @@ 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)
## Deployment
### Container (podman)
The provided `Containerfile` builds a single image that serves API + frontend
and persists the SQLite database in `/app/data` (one volume).
@ -124,7 +126,107 @@ podman run --replace --name vinterliste \
```
The container exposes `/api/health` for healthchecks and bakes the build date /
git revision into both OCI labels and `/etc/build-info`.
git revision into both OCI labels and `/etc/build-info`. Use `podman run
--replace ...` for redeploys — it's atomic and avoids the "container exists"
race.
### Environment variables
| Variable | Default | Notes |
|--------------------|------------------------|---------------------------------------------------------------|
| `PORT` | `3000` | TCP port the server listens on. |
| `NODE_ENV` | (unset) | Set to `production` to serve `frontend/dist` from the API. |
| `VINTERLISTE_DB` | `data/vinterliste.db` | Path to the SQLite file. Override for an external volume. |
| `PUBLIC_BASE_URL` | (derived from request) | Override the absolute URL used in OpenGraph `og:url` tags. |
There are no secrets to set. Auth verifiers and DEK wraps live in the SQLite
file; session tokens are generated per process and stored server-side, not
signed.
### TLS termination
The app speaks plain HTTP — terminate TLS at a reverse proxy (Caddy, nginx,
Traefik). The session cookie is marked `Secure` when the request was HTTPS
(`X-Forwarded-Proto: https`), so make sure the proxy sets that header.
Sample Caddyfile:
```caddyfile
vinterliste.example.org {
encode zstd gzip
reverse_proxy localhost:3000
}
```
Caddy auto-provisions a Let's Encrypt cert. Other proxies need the cert
configured manually.
### Backup and restore
The SQLite database is the entire app state — user accounts, DEK wraps,
activity ciphertexts, sessions, the lot. Backing it up while the server is
running is safe because of WAL mode:
```bash
# Atomic backup using SQLite's built-in copy
sqlite3 data/vinterliste.db ".backup '/path/to/backup/vinterliste-$(date +%F).db'"
# Or via the container's volume
podman exec vinterliste sqlite3 /app/data/vinterliste.db \
".backup '/app/data/backup-$(date +%F).db'"
```
Plain file copy of the `.db` works too if the server is stopped first. With WAL
files (`.db-wal`, `.db-shm`) present, copy all three or use `.backup`.
To restore: replace the file on disk and restart the server. There are no
out-of-band caches.
### Healthcheck
`GET /api/health` returns `{ ok: true, build: { revision, built_at } }` with
HTTP 200. Hook your monitoring or `HEALTHCHECK` directive at this endpoint.
### Upgrading
1. Build a new image with current `BUILD_DATE` and `GIT_REVISION` args.
2. `podman run --replace` — schema migrations are idempotent
(`CREATE TABLE IF NOT EXISTS …` and `ensureColumn(...)` add new columns
without touching existing data).
3. Verify `/api/health` returns the new `revision`.
4. The `activities` table's CHECK constraint includes all visibility values;
the `friends` visibility added later is migrated in via
`ensureActivitiesCheckIncludesFriends()` (table copy-drop-rename) on
first boot if needed. Take a backup beforehand the first time you upgrade
past a CHECK-constraint change.
### Emergency password reset (CLI)
If an admin has lost access (forgotten password, lost recovery code, etc.) and
can't recover via the UI, the server box has a CLI tool:
```bash
# Inside the container:
podman exec -it vinterliste bun run reset-password admin@example.org
# Or on the host if you're running the server directly:
bun run reset-password admin@example.org
```
It asks one question first: **do you still have this user's recovery code?**
- **Yes → recovery mode.** Behaves exactly like the in-app recovery flow:
unwraps the existing DEK with the recovery code, re-wraps it with the new
password. No data is lost. The recovery code stays valid afterwards.
- **No → nuke mode.** Generates a brand-new DEK + new recovery code and
prints the new code to stdout (write it down — it's shown once). The
user's **private activities are deleted** because their ciphertext was
encrypted with the now-unrecoverable old DEK. Public, semi, friends-only
activities, plus hearts / bookmarks / "gjort" marks, are kept.
Both modes invalidate every existing session for the user, matching the
hygiene of the in-app `/auth/recovery-complete` endpoint. The CLI requires
direct DB access — there is no network exposure of this code path.
## Registration: open, invite-only, or both

View file

@ -3,6 +3,7 @@
import { ready } from './lib/crypto';
import { api, ApiError } from './lib/api';
import { session, setSessionUserOnly } from './lib/session.svelte';
import { goBack } from './lib/navigate';
import { logout } from './lib/auth';
import Login from './components/Login.svelte';
import Signup from './components/Signup.svelte';
@ -138,7 +139,18 @@
// No session — fine.
}
applyRoute(route);
// Cold-load redirect: a logged-in user landing on the public landing
// probably wants their own dashboard, not the marketing-y "what is this"
// page. We only redirect on this initial mount — not on every
// applyRoute call — so browser-back from /hjem to / still lets the
// explicit navigation through (no loop, and the wordmark intentionally
// sends logged-in users to /hjem instead of / anyway).
if (route.view === 'public-home' && session.user) {
pushUrl('/hjem');
view = 'home';
} else {
applyRoute(route);
}
});
function applyRoute(route: Route) {
@ -164,22 +176,20 @@
}
}
function leaveTag() {
// Same logic as leavePersonvern — back to wherever they were.
if (session.user) goHome();
else goPublicHome();
}
function goPersonvern() {
pushUrl('/personvern');
view = 'personvern';
}
function leavePersonvern() {
// Send the visitor wherever they "would have been" — landing if logged out,
// dashboard if logged in. Either is more useful than staying on the doc page.
if (session.user) goHome();
else goPublicHome();
/**
* Back-button handler for sub-views (permalink, tag page, personvern,
* public list). Uses real browser history so the user returns to
* wherever they came from in the SPA — /hjem, /etiketter/foo,
* /aktivitet/bar, anywhere. Falls back to /hjem (or / when anonymous)
* on cold-loads where there's no prior history entry.
*/
function backToCallerOrHome() {
goBack(session.user ? '/hjem' : '/');
}
function onAuthed() {
@ -207,7 +217,8 @@
<main>
<nav class="top">
<h1 style="margin: 0;">
<a href="/" onclick={(e) => { e.preventDefault(); goPublicHome(); }}
<a href={session.user ? '/hjem' : '/'}
onclick={(e) => { e.preventDefault(); session.user ? goHome() : goPublicHome(); }}
style="color: inherit; text-decoration: none;">Vinterliste</a>
</h1>
{#if view !== 'public-list' && view !== 'permalink' && view !== 'tag'}
@ -246,9 +257,9 @@
{#if view === 'loading'}
<p class="muted">Laster …</p>
{:else if view === 'public-list'}
<PublicList username={publicListUsername} onBack={goPublicHome} />
<PublicList username={publicListUsername} onBack={backToCallerOrHome} />
{:else if view === 'permalink'}
<ActivityPermalink id={activityId} onBack={goPublicHome} />
<ActivityPermalink id={activityId} onBack={backToCallerOrHome} />
{:else if view === 'public-home'}
<Home publicOnly={true} />
{:else if view === 'login'}
@ -276,9 +287,9 @@
{:else if view === 'moderate-tags'}
<ModerateTags onDone={goHome} />
{:else if view === 'personvern'}
<Personvern onBack={leavePersonvern} />
<Personvern onBack={backToCallerOrHome} />
{:else if view === 'tag'}
<TagPage tag={tagName} onBack={leaveTag} />
<TagPage tag={tagName} onBack={backToCallerOrHome} />
{:else}
<Home publicOnly={false} />
{/if}

View file

@ -51,6 +51,15 @@
});
}
// created_at is epoch *milliseconds* (Date.now() on the server). The
// existing formatDate above takes seconds because scheduled_at is stored
// that way. Date-only is enough for "added on" — time of day is noise.
function formatDateOnly(epochMs: number): string {
return new Date(epochMs).toLocaleDateString('nb-NO', {
year: 'numeric', month: '2-digit', day: '2-digit',
});
}
/**
* OpenStreetMap link for a location.
* - If coordinates are present → /?mlat=…&mlon=…&zoom=15 (shows a pin)
@ -143,6 +152,77 @@
}
}
// --- "Gjort" (completion mark) ------------------------------------------
// Works on EVERY visibility the viewer can see. For private rows it acts
// as a personal checkbox; for public/semi/friends it contributes to the
// visible done_count statistic.
let doneBusy = $state(false);
async function toggleDone() {
if (!session.user || doneBusy) return;
doneBusy = true;
const wasDone = view.viewer_done;
localOverride = {
...view,
viewer_done: !wasDone,
done_count: view.done_count + (wasDone ? -1 : 1),
};
try {
const updated = wasDone
? await api.undoneActivity(view.id)
: await api.doneActivity(view.id);
localOverride = updated;
onChanged?.(updated);
} catch {
localOverride = null;
} finally {
doneBusy = false;
}
}
// --- Archive / Hide -----------------------------------------------------
// Two per-viewer flags. Archive applies to anyone (including the owner);
// hide applies only to non-owners. Both opt the row out of the default
// listing — the user has to toggle "Vis arkiv" / "Vis skjulte" to see
// them again.
let archiveBusy = $state(false);
let hideBusy = $state(false);
async function toggleArchive() {
if (!session.user || archiveBusy) return;
archiveBusy = true;
const wasArchived = view.viewer_archived;
localOverride = { ...view, viewer_archived: !wasArchived };
try {
const updated = wasArchived
? await api.unarchiveActivity(view.id)
: await api.archiveActivity(view.id);
localOverride = updated;
onChanged?.(updated);
} catch {
localOverride = null;
} finally {
archiveBusy = false;
}
}
async function toggleHide() {
if (!session.user || hideBusy) return;
hideBusy = true;
const wasHidden = view.viewer_hidden;
localOverride = { ...view, viewer_hidden: !wasHidden };
try {
const updated = wasHidden
? await api.unhideActivity(view.id)
: await api.hideActivity(view.id);
localOverride = updated;
onChanged?.(updated);
} catch {
localOverride = null;
} finally {
hideBusy = false;
}
}
// --- Bookmarks -----------------------------------------------------------
let bookmarkBusy = $state(false);
async function toggleBookmark() {
@ -229,6 +309,7 @@
{@render locationLine(decrypted.loc_label, null, null)}
{/if}
{#if decrypted.scheduled_at}<p class="muted">🕒 {formatDate(decrypted.scheduled_at)}</p>{/if}
<p class="muted" style="font-size: 0.8rem;">Lagt til {formatDateOnly(activity.created_at)}</p>
{:else if !session.dek}
<h3 id={`act-${activity.id}-h`} style="display: flex; align-items: center;">
<span class="vis-badge private">Privat</span>
@ -276,17 +357,18 @@
{@render locationLine(activity.loc_label, activity.loc_lat, activity.loc_lng)}
{/if}
{#if activity.scheduled_at}<p class="muted">🕒 {formatDate(activity.scheduled_at)}</p>{/if}
{#if activity.visibility === 'public' && activity.owner_display}
<p class="muted" style="font-size: 0.8rem;">
Lagt til av
<p class="muted" style="font-size: 0.8rem;">
Lagt til {formatDateOnly(activity.created_at)}
{#if activity.visibility === 'public' && activity.owner_display}
av
{#if activity.owner_username}
<a href={`/${activity.owner_username}/liste`}
onclick={(e) => onSpaLink(e, `/${activity.owner_username}/liste`)}>{activity.owner_display}</a>
{:else}
{activity.owner_display}
{/if}
</p>
{/if}
{/if}
</p>
{/if}
<div class="row" style="margin-top: 0.5rem;">
@ -316,9 +398,49 @@
<span class="muted" aria-label="Antall hjerter">{view.heart_count}</span>
{/if}
{/if}
<!-- "Gjort" works on every visibility — including the owner's private
rows where it's a personal todo checkbox. -->
{#if session.user}
<button
type="button"
onclick={toggleDone}
disabled={doneBusy}
aria-pressed={view.viewer_done}
aria-label={view.viewer_done ? 'Marker som ikke gjort' : 'Marker som gjort'}
title={view.viewer_done ? 'Du har gjort dette' : 'Dette har jeg gjort'}
class={view.viewer_done ? 'primary' : ''}
>
{view.viewer_done ? '✓ Gjort' : '☐ Gjort'} {view.done_count > 0 ? view.done_count : ''}
</button>
{:else if view.visibility !== 'private' && view.done_count > 0}
<span class="muted" aria-label="Antall som har gjort dette">{view.done_count}</span>
{/if}
{#if canEdit && onEdit}
<button type="button" onclick={startEdit}>Rediger</button>
{/if}
{#if session.user}
<button
type="button"
onclick={toggleArchive}
disabled={archiveBusy}
aria-pressed={view.viewer_archived}
title={view.viewer_archived ? 'Hent ut av arkivet' : 'Arkiver — gjem fra hovedlisten, behold for historikk'}
>
{view.viewer_archived ? '📦 Arkivert' : '📦 Arkiver'}
</button>
{/if}
{#if session.user && !isOwner}
<!-- Hide is non-owner only — owners can delete or archive instead. -->
<button
type="button"
onclick={toggleHide}
disabled={hideBusy}
aria-pressed={view.viewer_hidden}
title={view.viewer_hidden ? 'Vis igjen' : 'Gjem — denne appellerer ikke til meg'}
>
{view.viewer_hidden ? '🙈 Skjult' : '🙈 Gjem'}
</button>
{/if}
{#if canDelete}
<button class="danger" type="button" onclick={del}>Slett</button>
{/if}

View file

@ -88,6 +88,21 @@
}
}
// "Legg til som venn tilbake" — for an incoming entry where I haven't
// already added them. Goes through the same addFriend API as the form
// above. If the user hasn't set a username (rare — incoming implies they
// added someone, which requires THE OTHER PARTY's username, not theirs)
// we hide the button instead of erroring.
async function addBack(f: FriendEntry) {
if (!f.username) return;
try {
const added = await api.addFriend({ username: f.username });
outgoing = [added, ...outgoing.filter((o) => o.user_id !== added.user_id)];
} catch {
loadError = 'Klarte ikke å legge til venn.';
}
}
async function block(e: FriendEntry) {
if (!confirm(`Blokkere ${displayName(e)}? De vil ikke lenger se aktiviteter du deler med venner.`)) return;
try {
@ -155,13 +170,22 @@
<p class="muted">Ingen har lagt deg til ennå.</p>
{/if}
{#each incoming as f (f.user_id)}
{@const alreadyFriend = outgoing.some((o) => o.user_id === f.user_id)}
<article class="card" style="margin: 0.4rem 0; padding: 0.5rem 0.75rem;">
<div class="row" style="justify-content: space-between;">
<div class="row" style="justify-content: space-between; gap: 0.5rem;">
<span>
{displayName(f)}
{#if f.username}<span class="muted">· @{f.username}</span>{/if}
{#if alreadyFriend}<span class="muted" style="font-size: 0.85rem;">(gjensidig)</span>{/if}
</span>
<button class="danger" type="button" onclick={() => block(f)}>Blokker</button>
<div class="row" style="gap: 0.4rem;">
{#if !alreadyFriend && f.username}
<button class="primary" type="button" onclick={() => addBack(f)}>
Legg til som venn
</button>
{/if}
<button class="danger" type="button" onclick={() => block(f)}>Blokker</button>
</div>
</div>
</article>
{/each}

View file

@ -22,12 +22,28 @@
let error: string | null = $state(null);
let query = $state('');
onMount(load);
// Two toggles control what shape of list we ask the server for:
// - default: active rows only (excludes archived AND hidden)
// - showArchived: include archived rows mixed in with active
// - showHidden: include hidden rows mixed in
// The user can flip these independently. Anonymous (publicOnly) viewers
// never see the toggles since they have no archive/hide state.
let showArchived = $state(false);
let showHidden = $state(false);
onMount(() => load());
async function load() {
loading = true;
try {
activities = await api.listActivities();
activities = await api.listActivities(
publicOnly
? undefined
: {
...(showArchived ? { archived: '1' as const } : {}),
...(showHidden ? { hidden: '1' as const } : {}),
},
);
} catch (e) {
error = 'Kunne ikke laste oppføringer.';
} finally {
@ -35,6 +51,21 @@
}
}
// Re-fetch from the server when the toggles flip — the WHERE clause is
// server-side so we can't filter the existing array client-side. Triggered
// from the checkbox onchange handlers below, NOT from a $effect — the
// effect form had a self-fire loop (each load() toggles `loading`, the
// effect tracked `loading`, every cycle re-triggered itself, list stayed
// stuck on "Laster …").
function onToggleArchive(e: Event) {
showArchived = (e.currentTarget as HTMLInputElement).checked;
load();
}
function onToggleHidden(e: Event) {
showHidden = (e.currentTarget as HTMLInputElement).checked;
load();
}
function onCreated(a: Activity) {
activities = [a, ...activities];
showForm = false;
@ -46,7 +77,13 @@
}
function onChanged(a: Activity) {
// When a row's archived/hidden state flips, it may no longer belong in
// the current view (toggles are off → archived/hidden rows disappear).
// Reload to get the authoritative list from the server.
const needsRefetch =
(a.viewer_archived && !showArchived) || (a.viewer_hidden && !showHidden);
activities = activities.map((x) => (x.id === a.id ? a : x));
if (needsRefetch) load();
}
function onDeleted(id: string) {
@ -201,6 +238,19 @@
aria-label="Søk i aktiviteter"
/>
{#if !publicOnly && session.user}
<div class="row" style="gap: 1rem; margin-top: 0.5rem; font-size: 0.9rem;">
<label class="row" style="gap: 0.35rem;">
<input type="checkbox" checked={showArchived} onchange={onToggleArchive} />
<span class="muted">📦 Vis arkivert</span>
</label>
<label class="row" style="gap: 0.35rem;">
<input type="checkbox" checked={showHidden} onchange={onToggleHidden} />
<span class="muted">🙈 Vis skjult</span>
</label>
</div>
{/if}
{#if showForm}
<div style="margin-top: 1rem;">
<ActivityForm onCreated={onCreated} onCancel={() => (showForm = false)} />

View file

@ -170,8 +170,11 @@
/** Build the shareable invite URL using the SPA's own origin. The server
* used to compute this but in dev that yielded the API host (port 3000),
* not the SPA host (port 5173). The token is the canonical artefact;
* the URL is just a presentation concern the SPA owns. */
function inviteUrl(inv: InviteEntry): string {
* the URL is just a presentation concern the SPA owns. Returns null
* when the invite has been claimed — the server stops returning the
* token at that point and there's nothing left to share. */
function inviteUrl(inv: InviteEntry): string | null {
if (!inv.token) return null;
return `${window.location.origin}/invitasjon/${inv.token}`;
}
@ -361,7 +364,7 @@
</section>
<section class="card" aria-labelledby="inv-h">
<h3 id="inv-h">Invitasjonslenker</h3>
<h3 id="inv-h">Invitasjoner</h3>
<p class="muted">
Generer en lenke du kan dele. Den som registrerer seg via lenken blir
knyttet til deg som invitør. Hver lenke kan bare brukes én gang.
@ -378,28 +381,34 @@
{#if invites.length === 0}
<p class="muted">Ingen invitasjoner ennå.</p>
{/if}
{#each invites as inv (inv.token)}
<article class="card" style="margin-top: 0.5rem; {inv.claimed_at ? 'opacity: 0.7;' : ''}">
<div class="row" style="justify-content: space-between;">
<code style="font-size: 0.8rem; word-break: break-all;">{inviteUrl(inv)}</code>
<span class="muted" style="white-space: nowrap;">{formatDate(inv.created_at)}</span>
</div>
<div class="row" style="margin-top: 0.5rem;">
{#if inv.claimed_at}
<span class="vis-badge semi">Brukt</span>
{#if inv.claimed_by_display}
<span class="muted">av {inv.claimed_by_display} · {formatDate(inv.claimed_at)}</span>
{/if}
{#each invites as inv (inv.token ?? `c-${inv.created_at}-${inv.claimed_at}`)}
{#if inv.claimed_at}
<!-- Claimed: one-line summary. The token isn't returned anymore,
so there's nothing to copy or cancel — just the audit trail. -->
<p class="muted" style="margin: 0.4rem 0; font-size: 0.9rem;">
✓ Laget {formatDate(inv.created_at)} ·
{#if inv.claimed_by_display}
godtatt av {inv.claimed_by_display} {formatDate(inv.claimed_at)}
{:else}
godtatt {formatDate(inv.claimed_at)}
{/if}
</p>
{:else}
<article class="card" style="margin-top: 0.5rem;">
<div class="row" style="justify-content: space-between;">
<code style="font-size: 0.8rem; word-break: break-all;">{inviteUrl(inv)}</code>
<span class="muted" style="white-space: nowrap;">{formatDate(inv.created_at)}</span>
</div>
<div class="row" style="margin-top: 0.5rem;">
<button type="button" onclick={() => copyInviteUrl(inv)}>
{copiedToken === inv.token ? 'Kopiert!' : 'Kopier lenke'}
</button>
<button class="danger" type="button" onclick={() => cancelInvite(inv)}>
Avbryt
</button>
{/if}
</div>
</article>
</div>
</article>
{/if}
{/each}
</section>

View file

@ -64,7 +64,13 @@ export const api = {
}),
// --- activities -----------------------------------------------------------
listActivities: () => http<Activity[]>('/activities'),
listActivities: (opts?: { archived?: '1' | 'only'; hidden?: '1' | 'only' }) => {
const qs = new URLSearchParams();
if (opts?.archived) qs.set('archived', opts.archived);
if (opts?.hidden) qs.set('hidden', opts.hidden);
const q = qs.toString();
return http<Activity[]>(`/activities${q ? `?${q}` : ''}`);
},
getActivity: (id: string) =>
http<Activity>(`/activities/${encodeURIComponent(id)}`),
createActivity: (body: CreateActivityRequest) =>
@ -79,6 +85,18 @@ export const api = {
http<Activity>(`/activities/${encodeURIComponent(id)}/heart`, { method: 'POST' }),
unheartActivity: (id: string) =>
http<Activity>(`/activities/${encodeURIComponent(id)}/heart`, { method: 'DELETE' }),
doneActivity: (id: string) =>
http<Activity>(`/activities/${encodeURIComponent(id)}/done`, { method: 'POST' }),
undoneActivity: (id: string) =>
http<Activity>(`/activities/${encodeURIComponent(id)}/done`, { method: 'DELETE' }),
archiveActivity: (id: string) =>
http<Activity>(`/activities/${encodeURIComponent(id)}/archive`, { method: 'POST' }),
unarchiveActivity: (id: string) =>
http<Activity>(`/activities/${encodeURIComponent(id)}/archive`, { method: 'DELETE' }),
hideActivity: (id: string) =>
http<Activity>(`/activities/${encodeURIComponent(id)}/hide`, { method: 'POST' }),
unhideActivity: (id: string) =>
http<Activity>(`/activities/${encodeURIComponent(id)}/hide`, { method: 'DELETE' }),
bookmarkActivity: (id: string) =>
http<Activity>(`/activities/${encodeURIComponent(id)}/bookmark`, { method: 'POST' }),
unbookmarkActivity: (id: string) =>

View file

@ -34,3 +34,21 @@ export function onSpaLink(e: MouseEvent, path: string): void {
e.preventDefault();
navigate(path);
}
/**
* "Back" navigation for in-app back buttons. Uses real browser history
* when there's prior history to return to (preserves the user's actual
* path through the SPA). Falls back to navigate(fallback) on cold-loads
* when the user opened the URL directly in a new tab and there's no
* prior entry to back into.
*
* `window.history.length` is browser-dependent but length === 1 is a
* reliable cold-load signal: every browser counts the current entry.
*/
export function goBack(fallback: string): void {
if (window.history.length > 1) {
window.history.back();
} else {
navigate(fallback);
}
}

View file

@ -10,7 +10,8 @@
"build:frontend": "vite build --config frontend/vite.config.ts",
"start": "NODE_ENV=production bun run server/index.ts",
"test": "bun test",
"typecheck": "tsc --noEmit && tsc --noEmit -p frontend/tsconfig.json"
"typecheck": "tsc --noEmit && tsc --noEmit -p frontend/tsconfig.json",
"reset-password": "bun run server/reset-password.ts"
},
"dependencies": {
"hono": "^4.6.0",

View file

@ -79,6 +79,36 @@ function viewerBookmarked(activityId: string, viewerId: string | null): boolean
.get(activityId, viewerId);
}
/** Does the viewer have an archive entry on this activity? */
function viewerArchived(activityId: string, viewerId: string | null): boolean {
if (!viewerId) return false;
return !!getDb()
.prepare('SELECT 1 FROM user_archived_activities WHERE activity_id = ? AND user_id = ?')
.get(activityId, viewerId);
}
/** Does the viewer have a hide entry on this activity? */
function viewerHidden(activityId: string, viewerId: string | null): boolean {
if (!viewerId) return false;
return !!getDb()
.prepare('SELECT 1 FROM user_hidden_activities WHERE activity_id = ? AND user_id = ?')
.get(activityId, viewerId);
}
/** Done-count + viewer-done lookup for a single activity. */
function doneFor(activityId: string, viewerId: string | null): { count: number; done: boolean } {
const db = getDb();
const count = (db
.prepare('SELECT COUNT(*) AS n FROM activity_done WHERE activity_id = ?')
.get(activityId) as { n: number }).n;
const done = viewerId
? !!db
.prepare('SELECT 1 FROM activity_done WHERE activity_id = ? AND user_id = ?')
.get(activityId, viewerId)
: false;
return { count, done };
}
/**
* Build the public-facing attribution for an owner. Prefer the user's chosen
* `display_name`; fall back to their `username` slug if set (also user-chosen);
@ -111,6 +141,31 @@ function b64(b: Uint8Array | null): string | null {
return b === null ? null : Buffer.from(b).toString('base64');
}
/**
* Single-row fetch that includes the viewer's custom sort_position via the
* same LEFT JOIN as the list endpoint. Single-row endpoints (POST, PATCH,
* GET /:id, heart/bookmark/done toggles) used to call plain
* `SELECT * FROM activities WHERE id = ?` and let serialize() fall back to
* -created_at, which silently overwrote any custom drag-sort the viewer
* had on that row. Use this helper instead so toggles preserve the user's
* ordering.
*/
function fetchRowForViewer(id: string, viewerId: string | null): ActivityRow | null {
const db = getDb();
if (viewerId) {
return db
.prepare(`
SELECT activities.*, s.position AS sort_position
FROM activities
LEFT JOIN user_activity_sort s
ON s.activity_id = activities.id AND s.user_id = ?
WHERE activities.id = ?
`)
.get(viewerId, id) as ActivityRow | null;
}
return db.prepare('SELECT * FROM activities WHERE id = ?').get(id) as ActivityRow | null;
}
function b64ToBuf(s: string): Buffer {
return Buffer.from(s, 'base64');
}
@ -125,7 +180,10 @@ function b64ToBuf(s: string): Buffer {
interface BulkLookups {
tags: Map<string, string[]>;
hearts: Map<string, { count: number; hearted: boolean }>;
done: Map<string, { count: number; done: boolean }>;
bookmarked: Set<string>;
archived: Set<string>;
hidden: Set<string>;
attribution: Map<string, { display: string | null; username: string | null }>;
}
@ -134,10 +192,13 @@ function buildBulkLookups(rows: ActivityRow[], viewerId: string | null): BulkLoo
const ids = rows.map((r) => r.id);
const tags = bulkTagsFor(ids);
const hearts = new Map<string, { count: number; hearted: boolean }>();
const done = new Map<string, { count: number; done: boolean }>();
const bookmarked = new Set<string>();
const archived = new Set<string>();
const hidden = new Set<string>();
const attribution = new Map<string, { display: string | null; username: string | null }>();
if (ids.length === 0) return { tags, hearts, bookmarked, attribution };
if (ids.length === 0) return { tags, hearts, done, bookmarked, archived, hidden, attribution };
const ph = ids.map(() => '?').join(',');
const heartCounts = db
@ -161,6 +222,27 @@ function buildBulkLookups(rows: ActivityRow[], viewerId: string | null): BulkLoo
hearts.set(r.activity_id, { count: r.n, hearted: viewerHeartSet.has(r.activity_id) });
}
const doneCounts = db
.prepare(`
SELECT activity_id, COUNT(*) AS n FROM activity_done
WHERE activity_id IN (${ph})
GROUP BY activity_id
`)
.all(...ids) as { activity_id: string; n: number }[];
const viewerDoneSet = new Set<string>();
if (viewerId) {
const vd = db
.prepare(`
SELECT activity_id FROM activity_done
WHERE activity_id IN (${ph}) AND user_id = ?
`)
.all(...ids, viewerId) as { activity_id: string }[];
for (const r of vd) viewerDoneSet.add(r.activity_id);
}
for (const r of doneCounts) {
done.set(r.activity_id, { count: r.n, done: viewerDoneSet.has(r.activity_id) });
}
if (viewerId) {
const bm = db
.prepare(`
@ -169,6 +251,22 @@ function buildBulkLookups(rows: ActivityRow[], viewerId: string | null): BulkLoo
`)
.all(...ids, viewerId) as { activity_id: string }[];
for (const r of bm) bookmarked.add(r.activity_id);
const ar = db
.prepare(`
SELECT activity_id FROM user_archived_activities
WHERE activity_id IN (${ph}) AND user_id = ?
`)
.all(...ids, viewerId) as { activity_id: string }[];
for (const r of ar) archived.add(r.activity_id);
const hd = db
.prepare(`
SELECT activity_id FROM user_hidden_activities
WHERE activity_id IN (${ph}) AND user_id = ?
`)
.all(...ids, viewerId) as { activity_id: string }[];
for (const r of hd) hidden.add(r.activity_id);
}
const ownerIds = [...new Set(rows.map((r) => r.owner_id))];
@ -194,7 +292,7 @@ function buildBulkLookups(rows: ActivityRow[], viewerId: string | null): BulkLoo
}
}
return { tags, hearts, bookmarked, attribution };
return { tags, hearts, done, bookmarked, archived, hidden, attribution };
}
function serialize(row: ActivityRow, viewerId: string | null, bulk?: BulkLookups): Activity {
@ -202,6 +300,16 @@ function serialize(row: ActivityRow, viewerId: string | null, bulk?: BulkLookups
// otherwise -created_at so unsorted rows float to the top by recency.
// Matches the SQL ORDER BY in the list query.
const sortPos = row.sort_position ?? -row.created_at;
// Done state applies to all visibilities. For private rows the count is
// at most 1 (only the owner can mark it) and acts as a personal checkbox.
const done = bulk
? (bulk.done.get(row.id) ?? { count: 0, done: false })
: doneFor(row.id, viewerId);
// Archive applies to every viewer; hide applies only to non-owners. For
// a private row that's only ever visible to its owner, hide is always
// false (the endpoint refuses it anyway).
const archived = bulk ? bulk.archived.has(row.id) : viewerArchived(row.id, viewerId);
const hidden = bulk ? bulk.hidden.has(row.id) : viewerHidden(row.id, viewerId);
if (row.visibility === 'private') {
const a: ActivityPrivate = {
id: row.id,
@ -213,6 +321,10 @@ function serialize(row: ActivityRow, viewerId: string | null, bulk?: BulkLookups
heart_count: 0,
viewer_hearted: false,
viewer_bookmarked: false,
done_count: done.count,
viewer_done: done.done,
viewer_archived: archived,
viewer_hidden: false,
sort_position: sortPos,
created_at: row.created_at,
updated_at: row.updated_at,
@ -243,6 +355,10 @@ function serialize(row: ActivityRow, viewerId: string | null, bulk?: BulkLookups
heart_count: hearts.count,
viewer_hearted: hearts.hearted,
viewer_bookmarked: bookmarked,
done_count: done.count,
viewer_done: done.done,
viewer_archived: archived,
viewer_hidden: hidden,
sort_position: sortPos,
created_at: row.created_at,
updated_at: row.updated_at,
@ -274,6 +390,10 @@ function serialize(row: ActivityRow, viewerId: string | null, bulk?: BulkLookups
heart_count: hearts.count,
viewer_hearted: hearts.hearted,
viewer_bookmarked: bookmarked,
done_count: done.count,
viewer_done: done.done,
viewer_archived: archived,
viewer_hidden: hidden,
sort_position: sortPos,
created_at: row.created_at,
updated_at: row.updated_at,
@ -295,6 +415,10 @@ function serialize(row: ActivityRow, viewerId: string | null, bulk?: BulkLookups
heart_count: hearts.count,
viewer_hearted: hearts.hearted,
viewer_bookmarked: bookmarked,
done_count: done.count,
viewer_done: done.done,
viewer_archived: archived,
viewer_hidden: hidden,
sort_position: sortPos,
created_at: row.created_at,
updated_at: row.updated_at,
@ -332,8 +456,20 @@ activitiesRoutes.get('/', (c) => {
const viewerId = currentUserId(c);
const db = getDb();
const params: string[] = [];
let where = `visibility IN ('public','semi')`;
// Inclusion flags for archived / hidden rows. Modes:
// ?archived=0 (default) -- exclude archived
// ?archived=1 -- include archived (still mixed with the rest)
// ?archived=only -- ONLY archived
// Same scheme for ?hidden=...
// Anonymous viewers don't have either flag stored so these are no-ops for them.
type Mode = 'exclude' | 'include' | 'only';
const parseMode = (q?: string): Mode =>
q === '1' ? 'include' : q === 'only' ? 'only' : 'exclude';
const archivedMode = parseMode(c.req.query('archived'));
const hiddenMode = parseMode(c.req.query('hidden'));
const params: (string | number)[] = [];
let where = `(visibility IN ('public','semi')`;
if (viewerId) {
// Own private:
where += ` OR (visibility = 'private' AND owner_id = ?)`;
@ -358,6 +494,28 @@ activitiesRoutes.get('/', (c) => {
`;
params.push(viewerId, viewerId, viewerId);
}
where += `)`;
// Per-viewer archive / hide filters. Only meaningful for an authenticated
// viewer (anonymous viewers have no rows in either table).
if (viewerId) {
const archivedExists = `EXISTS (SELECT 1 FROM user_archived_activities WHERE activity_id = activities.id AND user_id = ?)`;
const hiddenExists = `EXISTS (SELECT 1 FROM user_hidden_activities WHERE activity_id = activities.id AND user_id = ?)`;
if (archivedMode === 'exclude') {
where += ` AND NOT ${archivedExists}`;
params.push(viewerId);
} else if (archivedMode === 'only') {
where += ` AND ${archivedExists}`;
params.push(viewerId);
}
if (hiddenMode === 'exclude') {
where += ` AND NOT ${hiddenExists}`;
params.push(viewerId);
} else if (hiddenMode === 'only') {
where += ` AND ${hiddenExists}`;
params.push(viewerId);
}
}
// Effective ordering: if the viewer has a per-row sort position, use it;
// otherwise fall back to -created_at so new activities (no sort row yet)
@ -398,13 +556,32 @@ activitiesRoutes.patch('/:id/sort', requireAuth, async (c) => {
return c.json({ error: 'missing:position' }, 400);
}
const db = getDb();
// Confirm the activity exists AND the viewer can see it. Anything else is
// a 404 — we don't want callers persisting positions for activities they
// can't see, even though it wouldn't surface anywhere visible.
const visible = db
.prepare('SELECT 1 FROM activities WHERE id = ?')
.get(id);
if (!visible) return c.json({ error: 'not_found' }, 404);
// Apply the same visibility filter as GET /:id and the list endpoint so
// sort doesn't double as an existence oracle for private / friends-only
// activity ids. Hidden rows return 404 (not 403). Earlier this endpoint
// only did a bare `SELECT 1 FROM activities WHERE id = ?` — surfaced by
// /audit security as a HIGH severity finding.
const row = db
.prepare('SELECT visibility, owner_id FROM activities WHERE id = ?')
.get(id) as { visibility: Visibility; owner_id: string } | null;
if (!row) return c.json({ error: 'not_found' }, 404);
if (row.visibility === 'private' && row.owner_id !== userId) {
return c.json({ error: 'not_found' }, 404);
}
if (row.visibility === 'friends' && row.owner_id !== userId) {
const isFriend = !!db
.prepare('SELECT 1 FROM friends WHERE owner_id = ? AND friend_id = ?')
.get(row.owner_id, userId);
if (!isFriend) return c.json({ error: 'not_found' }, 404);
const blocked = !!db
.prepare(`
SELECT 1 FROM user_blocks
WHERE (blocker_id = ? AND blocked_id = ?)
OR (blocker_id = ? AND blocked_id = ?)
`)
.get(row.owner_id, userId, userId, row.owner_id);
if (blocked) return c.json({ error: 'not_found' }, 404);
}
db.prepare(`
INSERT INTO user_activity_sort (user_id, activity_id, position) VALUES (?, ?, ?)
@ -416,7 +593,7 @@ activitiesRoutes.patch('/:id/sort', requireAuth, async (c) => {
// --- GET /api/activities/:id ------------------------------------------------
activitiesRoutes.get('/:id', (c) => {
const viewerId = currentUserId(c);
const row = getDb().prepare('SELECT * FROM activities WHERE id = ?').get(c.req.param('id')) as ActivityRow | null;
const row = fetchRowForViewer(c.req.param('id'), viewerId);
if (!row) return c.json({ error: 'not_found' }, 404);
// Apply the same visibility rules as the list endpoint. We return 404
@ -483,7 +660,10 @@ activitiesRoutes.post('/', requireAuth, async (c) => {
setActivityTags(id, body.tags ?? []);
}
const row = db.prepare('SELECT * FROM activities WHERE id = ?').get(id) as ActivityRow;
// No custom sort_position can exist for a row this user just created, so
// the LEFT JOIN is a strict no-op here — but using the helper keeps the
// single return path uniform.
const row = fetchRowForViewer(id, userId) as ActivityRow;
return c.json(serialize(row, userId), 201);
});
@ -551,7 +731,7 @@ activitiesRoutes.patch('/:id', requireAuth, async (c) => {
setActivityTags(id, body.tags ?? []);
}
const row = db.prepare('SELECT * FROM activities WHERE id = ?').get(id) as ActivityRow;
const row = fetchRowForViewer(id, userId) as ActivityRow;
return c.json(serialize(row, userId));
});
@ -593,7 +773,7 @@ function toggleMark(c: AppContext, kind: Mark, op: 'add' | 'remove') {
db.prepare(`DELETE FROM ${table} WHERE user_id = ? AND activity_id = ?`).run(userId, id);
}
const refreshed = db.prepare('SELECT * FROM activities WHERE id = ?').get(id) as ActivityRow;
const refreshed = fetchRowForViewer(id, userId) as ActivityRow;
return c.json(serialize(refreshed, userId));
}
@ -602,6 +782,127 @@ activitiesRoutes.delete('/:id/heart', requireAuth, (c) => toggleMark(c, 'heart'
activitiesRoutes.post('/:id/bookmark', requireAuth, (c) => toggleMark(c, 'bookmark', 'add'));
activitiesRoutes.delete('/:id/bookmark', requireAuth, (c) => toggleMark(c, 'bookmark', 'remove'));
/**
* "Gjort" / done toggle. Differs from heart/bookmark:
* - Private rows are allowed (owner-only, acts as a personal checkbox).
* - Friends-only rows require the viewer to be the owner OR a mutual
* friend with no block in either direction.
* Same visibility rules as GET /api/activities/:id we treat
* "can't see it → 404" the same way.
*/
function toggleDone(c: AppContext, op: 'add' | 'remove') {
const userId = c.get('userId');
const id = c.req.param('id');
if (!id) return c.json({ error: 'not_found' }, 404);
const db = getDb();
const row = db
.prepare('SELECT visibility, owner_id FROM activities WHERE id = ?')
.get(id) as { visibility: Visibility; owner_id: string } | null;
if (!row) return c.json({ error: 'not_found' }, 404);
// Apply the same visibility filter as the list and single-fetch endpoints.
// Hidden rows return 404 (not 403) so the endpoint doesn't double as an
// existence oracle.
if (row.visibility === 'private' && row.owner_id !== userId) {
return c.json({ error: 'not_found' }, 404);
}
if (row.visibility === 'friends' && row.owner_id !== userId) {
const isFriend = !!db
.prepare('SELECT 1 FROM friends WHERE owner_id = ? AND friend_id = ?')
.get(row.owner_id, userId);
if (!isFriend) return c.json({ error: 'not_found' }, 404);
const blocked = !!db
.prepare(`
SELECT 1 FROM user_blocks
WHERE (blocker_id = ? AND blocked_id = ?)
OR (blocker_id = ? AND blocked_id = ?)
`)
.get(row.owner_id, userId, userId, row.owner_id);
if (blocked) return c.json({ error: 'not_found' }, 404);
}
if (op === 'add') {
db.prepare(
'INSERT OR IGNORE INTO activity_done (activity_id, user_id, created_at) VALUES (?, ?, ?)',
).run(id, userId, Date.now());
} else {
db.prepare('DELETE FROM activity_done WHERE activity_id = ? AND user_id = ?').run(id, userId);
}
const refreshed = fetchRowForViewer(id, userId) as ActivityRow;
return c.json(serialize(refreshed, userId));
}
activitiesRoutes.post('/:id/done', requireAuth, (c) => toggleDone(c, 'add'));
activitiesRoutes.delete('/:id/done', requireAuth, (c) => toggleDone(c, 'remove'));
/**
* Archive (anyone) / hide (non-owner only). Same skeleton as toggleDone:
* - Visibility-aware existence check (404 for rows the viewer can't see).
* - For hide, the owner gets 400 cannot_hide_own they should delete or
* archive instead.
* - INSERT OR IGNORE / DELETE for idempotency.
*/
type Filing = 'archive' | 'hide';
const FILING_TABLES: Record<Filing, string> = {
archive: 'user_archived_activities',
hide: 'user_hidden_activities',
};
function toggleFiling(c: AppContext, kind: Filing, op: 'add' | 'remove') {
const userId = c.get('userId');
const id = c.req.param('id');
if (!id) return c.json({ error: 'not_found' }, 404);
const db = getDb();
const row = db
.prepare('SELECT visibility, owner_id FROM activities WHERE id = ?')
.get(id) as { visibility: Visibility; owner_id: string } | null;
if (!row) return c.json({ error: 'not_found' }, 404);
// Same visibility check as toggleDone — hidden rows return 404, not 403.
if (row.visibility === 'private' && row.owner_id !== userId) {
return c.json({ error: 'not_found' }, 404);
}
if (row.visibility === 'friends' && row.owner_id !== userId) {
const isFriend = !!db
.prepare('SELECT 1 FROM friends WHERE owner_id = ? AND friend_id = ?')
.get(row.owner_id, userId);
if (!isFriend) return c.json({ error: 'not_found' }, 404);
const blocked = !!db
.prepare(`
SELECT 1 FROM user_blocks
WHERE (blocker_id = ? AND blocked_id = ?)
OR (blocker_id = ? AND blocked_id = ?)
`)
.get(row.owner_id, userId, userId, row.owner_id);
if (blocked) return c.json({ error: 'not_found' }, 404);
}
// Hiding your own row makes no sense — you can delete or archive instead.
if (kind === 'hide' && row.owner_id === userId) {
return c.json({ error: 'cannot_hide_own' }, 400);
}
const table = FILING_TABLES[kind];
if (op === 'add') {
db.prepare(
`INSERT OR IGNORE INTO ${table} (user_id, activity_id, created_at) VALUES (?, ?, ?)`,
).run(userId, id, Date.now());
} else {
db.prepare(`DELETE FROM ${table} WHERE user_id = ? AND activity_id = ?`).run(userId, id);
}
const refreshed = fetchRowForViewer(id, userId) as ActivityRow;
return c.json(serialize(refreshed, userId));
}
activitiesRoutes.post('/:id/archive', requireAuth, (c) => toggleFiling(c, 'archive', 'add'));
activitiesRoutes.delete('/:id/archive', requireAuth, (c) => toggleFiling(c, 'archive', 'remove'));
activitiesRoutes.post('/:id/hide', requireAuth, (c) => toggleFiling(c, 'hide', 'add'));
activitiesRoutes.delete('/:id/hide', requireAuth, (c) => toggleFiling(c, 'hide', 'remove'));
// --- DELETE /api/activities/:id ---------------------------------------------
// Authz:
// - private: owner only. Other users can't even see private rows, so

View file

@ -119,6 +119,36 @@ const SCHEMA_STATEMENTS: readonly string[] = [
PRIMARY KEY (activity_id, user_id)
)`,
`CREATE INDEX IF NOT EXISTS activity_hearts_user_idx ON activity_hearts(user_id)`,
// "Gjort": per-user completion mark. Same shape as hearts but a different
// meaning — hearts express approval, gjort expresses "I actually did
// this." Unlike hearts/bookmarks, gjort applies to ANY visibility the
// viewer can see, including their own private activities (where it
// doubles as a todo-list checkbox).
`CREATE TABLE IF NOT EXISTS activity_done (
activity_id TEXT NOT NULL REFERENCES activities(id) ON DELETE CASCADE,
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
created_at INTEGER NOT NULL,
PRIMARY KEY (activity_id, user_id)
)`,
`CREATE INDEX IF NOT EXISTS activity_done_user_idx ON activity_done(user_id)`,
// Per-viewer "archived" flag. Any viewer (incl. the owner) can archive an
// activity to remove it from their default list while keeping the row
// intact for history. Archived rows are still permalinked.
`CREATE TABLE IF NOT EXISTS user_archived_activities (
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
activity_id TEXT NOT NULL REFERENCES activities(id) ON DELETE CASCADE,
created_at INTEGER NOT NULL,
PRIMARY KEY (user_id, activity_id)
)`,
// Per-viewer "hidden" flag. Only non-owners can hide a row — the owner
// already has delete. Semantics: "this doesn't appeal to me, get it out
// of my feed." Default-filtered like archived.
`CREATE TABLE IF NOT EXISTS user_hidden_activities (
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
activity_id TEXT NOT NULL REFERENCES activities(id) ON DELETE CASCADE,
created_at INTEGER NOT NULL,
PRIMARY KEY (user_id, activity_id)
)`,
// Bookmarks: logged-in users can save public/semi activities to their own
// dashboard. Same shape as hearts: composite PK on (user, activity), one
// row per bookmark. CASCADE on the activity so deletes clean up.

View file

@ -50,7 +50,12 @@ function toEntry(row: InviteRow): InviteEntry {
}
}
return {
token: row.token,
// Once an invite is claimed the token has no functional role — claim
// is one-way, you can't re-claim — and we don't need the inviter to
// be able to re-share a now-dead link. Stripping it from the response
// also means a compromised inviter account doesn't leak used-up
// links. The audit trail (claimed_at, claimed_by_display) stays.
token: row.claimed_at ? null : row.token,
created_at: row.created_at,
claimed_at: row.claimed_at,
claimed_by_display: claimedByDisplay,

282
server/reset-password.ts Normal file
View file

@ -0,0 +1,282 @@
/**
* CLI: emergency password reset.
*
* bun run reset-password <email>
*
* Run on the server box (requires direct DB access). Two modes:
*
* 1. RECOVERY mode (no data loss). If you still have the user's recovery
* code, supply it interactively. This mirrors the regular recovery
* flow: we unwrap the existing DEK with the recovery code, then
* re-wrap it with the new password's KEK. Private activities stay
* readable. wrapped_dek_rec/rec_salt/rec_auth_* are NOT touched, so
* the recovery code remains valid afterwards (same as
* /auth/recovery-complete).
*
* 2. NUKE mode (last resort). If both the password AND recovery code are
* gone, the user's DEK is permanently unrecoverable. This mode
* generates a fresh DEK + new recovery code, writes new auth/KEK
* material, and DELETES the user's private activities (their
* ciphertext can never be opened again). Public/semi/friends rows,
* hearts, bookmarks, "gjort" marks all stay intact.
*
* Both modes invalidate all existing sessions for the user (a logged-in
* tab might have been compromised same hygiene as /auth/recovery-complete).
*
* For routine "I forgot my password" flow, use the in-app recovery page
* this CLI is for cases where the user can't reach the UI at all (admin
* lockout on a fresh deployment, etc.).
*/
import { getDb } from './db';
import {
ready,
generateDek,
generateSalt,
generateRecoveryCode,
normalizeRecoveryCode,
deriveKey,
deriveAuthVerifier,
wrapDek,
unwrapDek,
zero,
} from '../shared/crypto';
function usage(): never {
console.error('Usage: bun run reset-password <email>');
console.error('');
console.error('Interactive flow. Asks whether you have a recovery code:');
console.error(' - Yes → preserves all data; just rewires the password.');
console.error(' - No → wipes the user\'s private activities (their DEK');
console.error(' is unrecoverable without the recovery code).');
process.exit(2);
}
// Hand-rolled line reader. Bun's node:readline (both promises and callback
// forms) only delivers the first answer when stdin is piped/heredoc — the
// subsequent question() never resolves. We read the stdin stream directly
// and pull lines from a growing buffer. Works the same way for piped input
// and an interactive TTY (user types, hits Enter, line is delivered).
const decoder = new TextDecoder();
const reader: ReadableStreamDefaultReader<Uint8Array> = Bun.stdin.stream().getReader();
let lineBuf = '';
let stdinEnded = false;
async function readLine(prompt: string): Promise<string> {
process.stdout.write(prompt);
while (true) {
const nl = lineBuf.indexOf('\n');
if (nl >= 0) {
const line = lineBuf.slice(0, nl).replace(/\r$/, '');
lineBuf = lineBuf.slice(nl + 1);
return line.trim();
}
if (stdinEnded) return lineBuf.trim();
const { value, done } = await reader.read();
if (done) { stdinEnded = true; continue; }
lineBuf += decoder.decode(value);
}
}
interface UserRow {
id: string;
email: string;
is_admin: number;
rec_salt: Buffer;
wrapped_dek_rec: Buffer;
dek_rec_nonce: Buffer;
}
function loadUser(email: string): UserRow | null {
return getDb()
.prepare(`
SELECT id, email, is_admin, rec_salt, wrapped_dek_rec, dek_rec_nonce
FROM users WHERE email = ?
`)
.get(email) as UserRow | null;
}
async function recoveryReset(user: UserRow, recoveryCode: string, newPassword: string): Promise<void> {
const db = getDb();
// Unwrap the existing DEK using the recovery code's KEK.
const kekRec = deriveKey(normalizeRecoveryCode(recoveryCode), new Uint8Array(user.rec_salt));
let dek: Uint8Array;
try {
dek = unwrapDek(
{ ciphertext: new Uint8Array(user.wrapped_dek_rec), nonce: new Uint8Array(user.dek_rec_nonce) },
kekRec,
);
} catch {
zero(kekRec);
console.error('Recovery code did not unwrap the DEK. Wrong code? Aborting (no DB changes).');
process.exit(1);
}
zero(kekRec);
// Build fresh password-side material; recovery side stays put so the
// existing recovery code keeps working.
const authSalt = generateSalt();
const kekSalt = generateSalt();
const kekPw = deriveKey(newPassword, kekSalt);
const authVerifier = deriveAuthVerifier(newPassword, authSalt);
const wrappedPw = wrapDek(dek, kekPw);
zero(kekPw);
zero(dek);
const authVerifierHash = await Bun.password.hash(authVerifier, { algorithm: 'argon2id' });
const txn = db.transaction(() => {
db.prepare(`
UPDATE users SET
auth_salt = ?,
auth_verifier_hash = ?,
kek_salt = ?,
wrapped_dek_pw = ?,
dek_pw_nonce = ?
WHERE id = ?
`).run(
Buffer.from(authSalt),
authVerifierHash,
Buffer.from(kekSalt),
Buffer.from(wrappedPw.ciphertext),
Buffer.from(wrappedPw.nonce),
user.id,
);
return db.prepare('DELETE FROM sessions WHERE user_id = ?').run(user.id).changes;
});
const sessDeleted = txn();
console.log('');
console.log('Password reset complete (recovery mode — no data loss).');
console.log(` - Sessions invalidated: ${sessDeleted}`);
console.log(' - Private activities: preserved');
console.log(' - Recovery code: unchanged (still valid)');
}
async function nukeReset(user: UserRow, newPassword: string): Promise<void> {
const db = getDb();
// Brand-new DEK + recovery code. The old wraps are now garbage.
const dek = generateDek();
const recoveryCode = generateRecoveryCode();
const normCode = normalizeRecoveryCode(recoveryCode);
const authSalt = generateSalt();
const kekSalt = generateSalt();
const recSalt = generateSalt();
const recAuthSalt = generateSalt();
const kekPw = deriveKey(newPassword, kekSalt);
const kekRec = deriveKey(normCode, recSalt);
const authVerifier = deriveAuthVerifier(newPassword, authSalt);
const recAuthVerifier = deriveAuthVerifier(normCode, recAuthSalt);
const wrappedPw = wrapDek(dek, kekPw);
const wrappedRec = wrapDek(dek, kekRec);
zero(kekPw);
zero(kekRec);
zero(dek);
const authVerifierHash = await Bun.password.hash(authVerifier, { algorithm: 'argon2id' });
const recAuthVerifierHash = await Bun.password.hash(recAuthVerifier, { algorithm: 'argon2id' });
const txn = db.transaction(() => {
db.prepare(`
UPDATE users SET
auth_salt = ?,
auth_verifier_hash = ?,
kek_salt = ?,
wrapped_dek_pw = ?,
dek_pw_nonce = ?,
rec_salt = ?,
wrapped_dek_rec = ?,
dek_rec_nonce = ?,
rec_auth_salt = ?,
rec_auth_verifier_hash = ?
WHERE id = ?
`).run(
Buffer.from(authSalt),
authVerifierHash,
Buffer.from(kekSalt),
Buffer.from(wrappedPw.ciphertext),
Buffer.from(wrappedPw.nonce),
Buffer.from(recSalt),
Buffer.from(wrappedRec.ciphertext),
Buffer.from(wrappedRec.nonce),
Buffer.from(recAuthSalt),
recAuthVerifierHash,
user.id,
);
const privDeleted = db
.prepare(`DELETE FROM activities WHERE owner_id = ? AND visibility = 'private'`)
.run(user.id).changes;
const sessDeleted = db
.prepare('DELETE FROM sessions WHERE user_id = ?')
.run(user.id).changes;
return { privDeleted, sessDeleted };
});
const { privDeleted, sessDeleted } = txn();
console.log('');
console.log('Password reset complete (nuke mode — private data lost).');
console.log(` - Private activities deleted: ${privDeleted}`);
console.log(` - Sessions invalidated: ${sessDeleted}`);
console.log('');
console.log('=== NEW RECOVERY CODE — write this down NOW. It will never be shown again. ===');
console.log(recoveryCode);
console.log('=== END RECOVERY CODE ===');
}
async function main(): Promise<void> {
const email = process.argv[2]?.trim().toLowerCase();
if (!email) usage();
await ready();
const user = loadUser(email);
if (!user) {
console.error(`No user found with email "${email}".`);
process.exit(1);
}
console.log(`User: ${user.email} (is_admin=${user.is_admin})`);
console.log('');
const hasCode = (await readLine('Do you still have this user\'s recovery code? [y/N] ')).toLowerCase();
const useRecovery = hasCode === 'y' || hasCode === 'yes';
if (useRecovery) {
console.log('Recovery mode selected. Private activities will be preserved.');
const code = await readLine('Recovery code: ');
const password = await readLine('New password (visible while typing): ');
if (password.length < 12) {
console.error('Password must be at least 12 characters (matches the signup/recovery rule).');
process.exit(1);
}
await recoveryReset(user, code, password);
return;
}
console.log('No recovery code → nuke mode. This will:');
console.log(' - Generate a brand-new recovery code (printed once below)');
console.log(' - DELETE all private activities owned by this user');
console.log(' (their ciphertext is unrecoverable without the old code)');
console.log(' - Invalidate all existing sessions for this user');
console.log('');
const confirm = await readLine('Type DELETE to confirm: ');
if (confirm !== 'DELETE') {
console.error('Confirmation did not match. Aborting (no DB changes).');
process.exit(1);
}
const password = await readLine('New password (visible while typing): ');
if (password.length < 8) {
console.error('Password must be at least 8 characters.');
process.exit(1);
}
await nukeReset(user, password);
}
main().catch((err) => {
console.error('Reset failed:', err);
process.exit(1);
});

View file

@ -41,32 +41,42 @@ usersRoutes.get('/:username/list', (c) => {
return c.json({ error: 'not_found' }, 404);
}
// The owner's archive intent extends to their public list — archived
// rows disappear from "their published winter list" too. There's no
// logged-in viewer here (this endpoint is for the anonymous public),
// so we only need to filter rows the OWNER has archived.
const rows = db
.prepare(`
SELECT id, owner_id, title, description, scheduled_at, loc_label,
loc_lat, loc_lng, created_at, updated_at
FROM activities
WHERE owner_id = ? AND visibility = 'public'
AND NOT EXISTS (
SELECT 1 FROM user_archived_activities
WHERE activity_id = activities.id AND user_id = activities.owner_id
)
ORDER BY created_at DESC
`)
.all(user.id) as ActivityRow[];
// Bulk lookups so we make 2 queries instead of 2N. The endpoint is public,
// so viewer_hearted/bookmarked are always false — no per-viewer queries.
// Bulk lookups so we make 3 queries instead of 3N. The endpoint is public,
// so viewer_hearted/bookmarked/done are always false — no per-viewer queries.
const ids = rows.map((r) => r.id);
const tags = bulkTagsFor(ids);
const heartCounts = ids.length === 0
? new Map<string, number>()
: new Map<string, number>(
(db
.prepare(`
SELECT activity_id, COUNT(*) AS n FROM activity_hearts
WHERE activity_id IN (${ids.map(() => '?').join(',')})
GROUP BY activity_id
`)
.all(...ids) as { activity_id: string; n: number }[]
).map((r) => [r.activity_id, r.n]),
);
const bulkCounts = (table: string): Map<string, number> => {
if (ids.length === 0) return new Map();
const ph = ids.map(() => '?').join(',');
const rows = db
.prepare(`
SELECT activity_id, COUNT(*) AS n FROM ${table}
WHERE activity_id IN (${ph})
GROUP BY activity_id
`)
.all(...ids) as { activity_id: string; n: number }[];
return new Map(rows.map((r) => [r.activity_id, r.n]));
};
const heartCounts = bulkCounts('activity_hearts');
const doneCounts = bulkCounts('activity_done');
const activities: ActivityPublic[] = rows.map((r) => ({
id: r.id,
@ -87,9 +97,14 @@ usersRoutes.get('/:username/list', (c) => {
scheduled_at: r.scheduled_at,
heart_count: heartCounts.get(r.id) ?? 0,
// The public-list endpoint is unauthenticated; we don't know who the
// viewer is to fill viewer_hearted/bookmarked truthfully. Always false.
// viewer is to fill viewer_hearted/bookmarked/done truthfully. Always false.
viewer_hearted: false,
viewer_bookmarked: false,
done_count: doneCounts.get(r.id) ?? 0,
viewer_done: false,
// No logged-in viewer → can't have personal archive/hide state.
viewer_archived: false,
viewer_hidden: false,
// No personal sort here — anonymous view always sorts by recency.
sort_position: -r.created_at,
created_at: r.created_at,

View file

@ -45,8 +45,9 @@ export interface InviteEntry {
/** The token itself. The shareable URL is built client-side as
* `${window.location.origin}/invitasjon/${token}` server-side URL
* construction would point at the API host in split-process dev
* environments. */
token: string;
* environments. NULL when the invite has been claimed; the token has
* no functional role after that and we don't keep it in the response. */
token: string | null;
created_at: number;
claimed_at: number | null;
/** Display name (or username) of the user who claimed it, if any. */
@ -219,6 +220,17 @@ export interface ActivityPublic {
viewer_hearted: boolean;
/** True when the authenticated viewer has bookmarked this activity. */
viewer_bookmarked: boolean;
/** Total "done" marks (people who have actually completed the activity). */
done_count: number;
/** True when the authenticated viewer has marked this activity done. */
viewer_done: boolean;
/** True when the authenticated viewer has archived this activity for
* themselves. Default-filtered from the main and public lists; the
* client opts in via ?archived=1 to see them. Owner-archiving works too. */
viewer_archived: boolean;
/** True when the authenticated viewer has hidden this activity. Only
* non-owners can hide. Default-filtered from lists; opt-in via ?hidden=1. */
viewer_hidden: boolean;
/** Effective sort position for THIS viewer: a custom value if they've
* dragged this row, otherwise -created_at so unsorted/new rows float
* to the top. Used by the client to compute drop-target midpoints. */
@ -244,6 +256,10 @@ export interface ActivitySemi {
heart_count: number;
viewer_hearted: boolean;
viewer_bookmarked: boolean;
done_count: number;
viewer_done: boolean;
viewer_archived: boolean;
viewer_hidden: boolean;
/** Effective sort position for THIS viewer: a custom value if they've
* dragged this row, otherwise -created_at so unsorted/new rows float
* to the top. Used by the client to compute drop-target midpoints. */
@ -264,6 +280,16 @@ export interface ActivityPrivate {
heart_count: number;
viewer_hearted: boolean;
viewer_bookmarked: boolean;
// "Done" DOES apply to private rows: the owner can use it as a personal
// todo checkbox. done_count is therefore always 0 or 1 (just the owner)
// for private rows. viewer_done reflects the owner's own state.
done_count: number;
viewer_done: boolean;
// Private rows are owner-only, so viewer_hidden is always false (the
// hide endpoint refuses on your own activities). viewer_archived is the
// owner archiving their own private todo.
viewer_archived: boolean;
viewer_hidden: boolean;
/** Effective sort position for THIS viewer: a custom value if they've
* dragged this row, otherwise -created_at so unsorted/new rows float
* to the top. Used by the client to compute drop-target midpoints. */
@ -294,6 +320,10 @@ export interface ActivityFriends {
heart_count: number;
viewer_hearted: boolean;
viewer_bookmarked: boolean;
done_count: number;
viewer_done: boolean;
viewer_archived: boolean;
viewer_hidden: boolean;
/** Effective sort position for THIS viewer: a custom value if they've
* dragged this row, otherwise -created_at so unsorted/new rows float
* to the top. Used by the client to compute drop-target midpoints. */

View file

@ -333,6 +333,42 @@ describe('per-user sort order', () => {
expect(finalIds[0]).toBe(fresh.id);
});
ttest('single-row endpoints preserve the viewer\'s custom sort_position', async () => {
// Regression: heart / bookmark / done toggles (and PATCH/GET-by-id) used
// to do plain `SELECT * FROM activities` without the user_activity_sort
// LEFT JOIN, so serialize() silently fell back to -created_at and
// overwrote any custom position. Now they go through fetchRowForViewer.
const user = await signupAndGetCookie(ctx, 'sort-toggle@test.invalid');
const a1 = await createActivity(ctx, user.cookie, { visibility: 'public', title: 'one', tags: [] });
await new Promise((r) => setTimeout(r, 5));
const a2 = await createActivity(ctx, user.cookie, { visibility: 'public', title: 'two', tags: [] });
// Drag a1 below a2 (more positive sort_position → later in the ASC sort).
const customPos = -a2.created_at + 100;
await reqJson(ctx, 'PATCH', `/api/activities/${a1.id}/sort`, {
cookie: user.cookie, body: { position: customPos },
});
// Heart toggle should return a1 with the SAME custom sort_position,
// not the default -created_at.
const hearted = await reqJson<Activity>(ctx, 'POST', `/api/activities/${a1.id}/heart`, {
cookie: user.cookie,
});
expect(hearted.sort_position).toBe(customPos);
// Same for done.
const doneRes = await reqJson<Activity>(ctx, 'POST', `/api/activities/${a1.id}/done`, {
cookie: user.cookie,
});
expect(doneRes.sort_position).toBe(customPos);
// And for GET /:id.
const single = await reqJson<Activity>(ctx, 'GET', `/api/activities/${a1.id}`, {
cookie: user.cookie,
});
expect(single.sort_position).toBe(customPos);
});
test('PATCH /sort requires auth', async () => {
const res = await req(ctx, 'PATCH', '/api/activities/whatever/sort', { body: { position: 1 } });
expect(res.status).toBe(401);
@ -348,6 +384,41 @@ describe('per-user sort order', () => {
expect(res.status).toBe(400);
}
});
ttest('PATCH /sort does not double as an existence oracle for hidden rows', async () => {
// Regression: the endpoint previously did a bare `SELECT 1 FROM activities`
// without visibility scoping, so a logged-in attacker could distinguish
// "private id exists, owned by someone else" (200 ok) from "id doesn't
// exist" (404). /audit security flagged this as HIGH.
const [owner, attacker] = await Promise.all([
signupAndGetCookie(ctx, 'sort-oracle-owner@test.invalid'),
signupAndGetCookie(ctx, 'sort-oracle-att@test.invalid'),
]);
const priv = await createActivity(ctx, owner.cookie, {
visibility: 'private',
ciphertext: 'AAAA',
nonce: 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA',
} as never);
// Attacker tries to sort someone else's private row → must get 404
// (same status as for a truly nonexistent id).
const sortOther = await req(ctx, 'PATCH', `/api/activities/${priv.id}/sort`, {
cookie: attacker.cookie, body: { position: 0 },
});
expect(sortOther.status).toBe(404);
// And for a truly nonexistent id.
const sortMissing = await req(ctx, 'PATCH', '/api/activities/nope-nope-nope/sort', {
cookie: attacker.cookie, body: { position: 0 },
});
expect(sortMissing.status).toBe(404);
// Owner can still sort their own private row.
const sortOwn = await req(ctx, 'PATCH', `/api/activities/${priv.id}/sort`, {
cookie: owner.cookie, body: { position: 0 },
});
expect(sortOwn.status).toBe(200);
});
});
describe('owner_display fallback chain (no email leak)', () => {

View file

@ -124,6 +124,163 @@ describe('hearts', () => {
});
});
describe('"gjort" (done) marks', () => {
ttest('toggle on a public activity, idempotent both ways', async () => {
const [owner, viewer] = await Promise.all([
signupAndGetCookie(ctx, 'done-owner@test.invalid'),
signupAndGetCookie(ctx, 'done-viewer@test.invalid'),
]);
const pub = await createActivity(ctx, owner.cookie, {
visibility: 'public', title: 'Gjort-test', tags: [],
});
const after = await reqJson<Activity>(ctx, 'POST', `/api/activities/${pub.id}/done`, {
cookie: viewer.cookie,
});
expect(after.done_count).toBe(1);
expect(after.viewer_done).toBe(true);
// Re-mark is idempotent (INSERT OR IGNORE).
const again = await reqJson<Activity>(ctx, 'POST', `/api/activities/${pub.id}/done`, {
cookie: viewer.cookie,
});
expect(again.done_count).toBe(1);
// Owner's own view shows the count too but their viewer_done is still false.
const list = await listActivities(ctx, owner.cookie);
const ownerView = list.find((a) => a.id === pub.id);
expect(ownerView?.done_count).toBe(1);
expect(ownerView?.viewer_done).toBe(false);
// Undo.
const undone = await reqJson<Activity>(ctx, 'DELETE', `/api/activities/${pub.id}/done`, {
cookie: viewer.cookie,
});
expect(undone.done_count).toBe(0);
expect(undone.viewer_done).toBe(false);
});
ttest('owner can mark their own private activity done; non-owners get 404', async () => {
const [owner, other] = await Promise.all([
signupAndGetCookie(ctx, 'priv-done-owner@test.invalid'),
signupAndGetCookie(ctx, 'priv-done-other@test.invalid'),
]);
// Private rows need a ciphertext/nonce payload — we don't actually
// decrypt in this test, just need the row to exist.
const priv = await createActivity(ctx, owner.cookie, {
visibility: 'private',
ciphertext: 'AAAA',
nonce: 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA', // 24 bytes base64
} as never);
// Owner can mark it done.
const after = await reqJson<Activity>(ctx, 'POST', `/api/activities/${priv.id}/done`, {
cookie: owner.cookie,
});
expect(after.done_count).toBe(1);
expect(after.viewer_done).toBe(true);
// Non-owner gets 404 (same as GET /:id behaviour — doesn't leak existence).
const denied = await req(ctx, 'POST', `/api/activities/${priv.id}/done`, {
cookie: other.cookie,
});
expect(denied.status).toBe(404);
});
});
describe('archive + hide (per-viewer)', () => {
ttest('viewer can archive any visibility they see; default list excludes archived', async () => {
const [owner, viewer] = await Promise.all([
signupAndGetCookie(ctx, 'arch-owner@test.invalid'),
signupAndGetCookie(ctx, 'arch-viewer@test.invalid'),
]);
const pub = await createActivity(ctx, owner.cookie, {
visibility: 'public', title: 'arch-pub', tags: [],
});
// Default list includes it.
let list = await listActivities(ctx, viewer.cookie);
expect(list.find((a) => a.id === pub.id)).toBeTruthy();
// Archive it.
const archived = await reqJson<Activity>(ctx, 'POST', `/api/activities/${pub.id}/archive`, {
cookie: viewer.cookie,
});
expect(archived.viewer_archived).toBe(true);
// Disappears from the default list.
list = await listActivities(ctx, viewer.cookie);
expect(list.find((a) => a.id === pub.id)).toBeUndefined();
// ?archived=1 brings it back.
const withArch = await reqJson<Activity[]>(ctx, 'GET', '/api/activities?archived=1', {
cookie: viewer.cookie,
});
expect(withArch.find((a) => a.id === pub.id)).toBeTruthy();
// Other viewer is unaffected — archive is per-viewer.
const otherList = await listActivities(ctx, owner.cookie);
expect(otherList.find((a) => a.id === pub.id)?.viewer_archived).toBe(false);
});
ttest('owner can archive their own row (own public list filters it too)', async () => {
const owner = await signupAndGetCookie(ctx, 'arch-self@test.invalid');
// Owner needs a username + public_list_enabled to test the public-list filter.
await reqJson(ctx, 'PATCH', '/api/auth/profile', {
cookie: owner.cookie,
body: { username: 'archself', public_list_enabled: true, display_name: 'Arch Self' },
});
const pub = await createActivity(ctx, owner.cookie, {
visibility: 'public', title: 'arch-own-pub', tags: [],
});
// Visible on the owner's public list initially.
const before = await reqJson<{ activities: Activity[] }>(ctx, 'GET', '/api/users/archself/list');
expect(before.activities.find((a) => a.id === pub.id)).toBeTruthy();
// Owner archives → drops off their public list.
await req(ctx, 'POST', `/api/activities/${pub.id}/archive`, { cookie: owner.cookie });
const after = await reqJson<{ activities: Activity[] }>(ctx, 'GET', '/api/users/archself/list');
expect(after.activities.find((a) => a.id === pub.id)).toBeUndefined();
});
ttest('hide refuses on owner\'s own row; works on others\'; per-viewer', async () => {
const [owner, viewer] = await Promise.all([
signupAndGetCookie(ctx, 'hide-owner@test.invalid'),
signupAndGetCookie(ctx, 'hide-viewer@test.invalid'),
]);
const pub = await createActivity(ctx, owner.cookie, {
visibility: 'public', title: 'hide-test', tags: [],
});
// Owner trying to hide their own row → 400.
const ownerHide = await req(ctx, 'POST', `/api/activities/${pub.id}/hide`, {
cookie: owner.cookie,
});
expect(ownerHide.status).toBe(400);
// Non-owner viewer can hide it.
const hidden = await reqJson<Activity>(ctx, 'POST', `/api/activities/${pub.id}/hide`, {
cookie: viewer.cookie,
});
expect(hidden.viewer_hidden).toBe(true);
// Disappears from the viewer's default list…
const list = await listActivities(ctx, viewer.cookie);
expect(list.find((a) => a.id === pub.id)).toBeUndefined();
// …but ?hidden=only surfaces just the hidden ones.
const only = await reqJson<Activity[]>(ctx, 'GET', '/api/activities?hidden=only', {
cookie: viewer.cookie,
});
expect(only.find((a) => a.id === pub.id)).toBeTruthy();
// Owner's view is unaffected.
const ownerList = await listActivities(ctx, owner.cookie);
expect(ownerList.find((a) => a.id === pub.id)).toBeTruthy();
});
});
describe('bookmarks', () => {
ttest('toggle, idempotent, refused on private', async () => {
const [owner, viewer, otherViewer] = await Promise.all([

View file

@ -125,7 +125,7 @@ describe('invites', () => {
expect(inv.claimed_at).toBeNull();
// A new user signs up with that invite token.
const claimer = await signupAndGetCookie(ctx, 'inv-claimer@test.invalid', undefined, inv.token);
const claimer = await signupAndGetCookie(ctx, 'inv-claimer@test.invalid', undefined, inv.token!);
expect(claimer.me.id).toBeTruthy();
// The invited_by column on the new user row points at the inviter.
@ -133,13 +133,16 @@ describe('invites', () => {
.get(claimer.me.id) as { invited_by: string | null };
expect(row.invited_by).toBe(inviter.me.id);
// The invite is now claimed in the inviter's list.
// The invite is now claimed in the inviter's list. The server stops
// returning the literal token once the invite is claimed, so match by
// created_at instead.
const myInvites = await reqJson<InviteEntry[]>(ctx, 'GET', '/api/invites', {
cookie: inviter.cookie,
});
const claimed = myInvites.find((i) => i.token === inv.token)!;
const claimed = myInvites.find((i) => i.created_at === inv.created_at)!;
expect(claimed.claimed_at).not.toBeNull();
expect(claimed.claimed_by_display).toBe('inv-claimer');
expect(claimed.token).toBeNull();
});
ttest('invite is single-use: re-claim is rejected', async () => {
@ -149,12 +152,12 @@ describe('invites', () => {
});
// First claim succeeds.
await signupAndGetCookie(ctx, 'inv-single-1@test.invalid', undefined, inv.token);
await signupAndGetCookie(ctx, 'inv-single-1@test.invalid', undefined, inv.token!);
// Second signup with the same token: with self-registry OPEN (the
// default), the bad token is silently dropped and signup proceeds
// WITHOUT attribution. Confirm: signup ok, invited_by is null.
const second = await signupAndGetCookie(ctx, 'inv-single-2@test.invalid', undefined, inv.token);
const second = await signupAndGetCookie(ctx, 'inv-single-2@test.invalid', undefined, inv.token!);
const row = getDb().prepare('SELECT invited_by FROM users WHERE id = ?')
.get(second.me.id) as { invited_by: string | null };
expect(row.invited_by).toBeNull();
@ -176,7 +179,7 @@ describe('invites', () => {
const inv = await reqJson<InviteEntry>(ctx, 'POST', '/api/invites', {
cookie: inviter.cookie, expect: 201,
});
await signupAndGetCookie(ctx, 'inv-already-c@test.invalid', undefined, inv.token);
await signupAndGetCookie(ctx, 'inv-already-c@test.invalid', undefined, inv.token!);
const cancelRes = await req(ctx, 'DELETE', `/api/invites/${inv.token}`, {
cookie: inviter.cookie,
});
@ -232,7 +235,7 @@ describe('settings + signup gating', () => {
const inv = await reqJson<InviteEntry>(ctx, 'POST', '/api/invites', {
cookie: admin.cookie, expect: 201,
});
const claimer = await signupAndGetCookie(ctx, 'gate-claimer@test.invalid', undefined, inv.token);
const claimer = await signupAndGetCookie(ctx, 'gate-claimer@test.invalid', undefined, inv.token!);
expect(claimer.me.id).toBeTruthy();
} finally {
// Re-open self-registry so subsequent tests can still sign up.