From 443702d222fa73c30ea50d5047a3fe2381d0a061 Mon Sep 17 00:00:00 2001
From: Ole-Morten Duesund
Date: Mon, 25 May 2026 18:48:34 +0200
Subject: [PATCH 01/10] =?UTF-8?q?fix(spa):=20back=20button=20respects=20hi?=
=?UTF-8?q?story;=20auto-redirect=20/=20=E2=86=92=20/hjem=20when=20logged?=
=?UTF-8?q?=20in?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
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.
---
frontend/src/App.svelte | 45 ++++++++++++++++++++++--------------
frontend/src/lib/navigate.ts | 18 +++++++++++++++
2 files changed, 46 insertions(+), 17 deletions(-)
diff --git a/frontend/src/App.svelte b/frontend/src/App.svelte
index 3534856..324a90b 100644
--- a/frontend/src/App.svelte
+++ b/frontend/src/App.svelte
@@ -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 @@
{/if}
From bbb5ad2bddd67bd35fce25b7fd72a6cd96bdf2f9 Mon Sep 17 00:00:00 2001
From: Ole-Morten Duesund
Date: Mon, 25 May 2026 19:00:26 +0200
Subject: [PATCH 03/10] feat(activity): "Gjort" mark with statistics
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
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).
---
frontend/src/components/ActivityRow.svelte | 44 +++++++++
frontend/src/lib/api.ts | 4 +
server/activities.ts | 109 ++++++++++++++++++++-
server/db.ts | 12 +++
server/users.ts | 34 ++++---
shared/types.ts | 13 +++
tests/engagement.test.ts | 64 ++++++++++++
7 files changed, 263 insertions(+), 17 deletions(-)
diff --git a/frontend/src/components/ActivityRow.svelte b/frontend/src/components/ActivityRow.svelte
index 1ba08f6..a550dce 100644
--- a/frontend/src/components/ActivityRow.svelte
+++ b/frontend/src/components/ActivityRow.svelte
@@ -152,6 +152,33 @@
}
}
+ // --- "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;
+ }
+ }
+
// --- Bookmarks -----------------------------------------------------------
let bookmarkBusy = $state(false);
async function toggleBookmark() {
@@ -327,6 +354,23 @@
♡ {view.heart_count}
{/if}
{/if}
+
+ {#if session.user}
+
+ {:else if view.visibility !== 'private' && view.done_count > 0}
+ ✓ {view.done_count}
+ {/if}
{#if canEdit && onEdit}
{/if}
diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts
index c5fde72..523e1ba 100644
--- a/frontend/src/lib/api.ts
+++ b/frontend/src/lib/api.ts
@@ -79,6 +79,10 @@ export const api = {
http(`/activities/${encodeURIComponent(id)}/heart`, { method: 'POST' }),
unheartActivity: (id: string) =>
http(`/activities/${encodeURIComponent(id)}/heart`, { method: 'DELETE' }),
+ doneActivity: (id: string) =>
+ http(`/activities/${encodeURIComponent(id)}/done`, { method: 'POST' }),
+ undoneActivity: (id: string) =>
+ http(`/activities/${encodeURIComponent(id)}/done`, { method: 'DELETE' }),
bookmarkActivity: (id: string) =>
http(`/activities/${encodeURIComponent(id)}/bookmark`, { method: 'POST' }),
unbookmarkActivity: (id: string) =>
diff --git a/server/activities.ts b/server/activities.ts
index 985ba7d..b9d7aa5 100644
--- a/server/activities.ts
+++ b/server/activities.ts
@@ -79,6 +79,20 @@ function viewerBookmarked(activityId: string, viewerId: string | null): boolean
.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);
@@ -125,6 +139,7 @@ function b64ToBuf(s: string): Buffer {
interface BulkLookups {
tags: Map;
hearts: Map;
+ done: Map;
bookmarked: Set;
attribution: Map;
}
@@ -134,10 +149,11 @@ function buildBulkLookups(rows: ActivityRow[], viewerId: string | null): BulkLoo
const ids = rows.map((r) => r.id);
const tags = bulkTagsFor(ids);
const hearts = new Map();
+ const done = new Map();
const bookmarked = new Set();
const attribution = new Map();
- if (ids.length === 0) return { tags, hearts, bookmarked, attribution };
+ if (ids.length === 0) return { tags, hearts, done, bookmarked, attribution };
const ph = ids.map(() => '?').join(',');
const heartCounts = db
@@ -161,6 +177,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();
+ 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(`
@@ -194,7 +231,7 @@ function buildBulkLookups(rows: ActivityRow[], viewerId: string | null): BulkLoo
}
}
- return { tags, hearts, bookmarked, attribution };
+ return { tags, hearts, done, bookmarked, attribution };
}
function serialize(row: ActivityRow, viewerId: string | null, bulk?: BulkLookups): Activity {
@@ -202,6 +239,11 @@ 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);
if (row.visibility === 'private') {
const a: ActivityPrivate = {
id: row.id,
@@ -213,6 +255,8 @@ 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,
sort_position: sortPos,
created_at: row.created_at,
updated_at: row.updated_at,
@@ -243,6 +287,8 @@ 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,
sort_position: sortPos,
created_at: row.created_at,
updated_at: row.updated_at,
@@ -274,6 +320,8 @@ 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,
sort_position: sortPos,
created_at: row.created_at,
updated_at: row.updated_at,
@@ -295,6 +343,8 @@ 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,
sort_position: sortPos,
created_at: row.created_at,
updated_at: row.updated_at,
@@ -602,6 +652,61 @@ 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 = db.prepare('SELECT * FROM activities WHERE id = ?').get(id) 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'));
+
// --- DELETE /api/activities/:id ---------------------------------------------
// Authz:
// - private: owner only. Other users can't even see private rows, so
diff --git a/server/db.ts b/server/db.ts
index 5fdaac0..614ceb1 100644
--- a/server/db.ts
+++ b/server/db.ts
@@ -119,6 +119,18 @@ 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)`,
// 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.
diff --git a/server/users.ts b/server/users.ts
index 17ac796..a327f87 100644
--- a/server/users.ts
+++ b/server/users.ts
@@ -51,22 +51,24 @@ usersRoutes.get('/:username/list', (c) => {
`)
.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()
- : new Map(
- (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 => {
+ 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 +89,11 @@ 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 personal sort here — anonymous view always sorts by recency.
sort_position: -r.created_at,
created_at: r.created_at,
diff --git a/shared/types.ts b/shared/types.ts
index 9d79426..21634d7 100644
--- a/shared/types.ts
+++ b/shared/types.ts
@@ -219,6 +219,10 @@ 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;
/** 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 +248,8 @@ export interface ActivitySemi {
heart_count: number;
viewer_hearted: boolean;
viewer_bookmarked: boolean;
+ done_count: number;
+ viewer_done: 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 +270,11 @@ 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;
/** 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 +305,8 @@ export interface ActivityFriends {
heart_count: number;
viewer_hearted: boolean;
viewer_bookmarked: boolean;
+ done_count: number;
+ viewer_done: 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. */
diff --git a/tests/engagement.test.ts b/tests/engagement.test.ts
index 150c0ff..fc2a1d1 100644
--- a/tests/engagement.test.ts
+++ b/tests/engagement.test.ts
@@ -124,6 +124,70 @@ 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(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(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(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(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('bookmarks', () => {
ttest('toggle, idempotent, refused on private', async () => {
const [owner, viewer, otherViewer] = await Promise.all([
From fb193b491431f4e34111d74963a9d046890b1a0b Mon Sep 17 00:00:00 2001
From: Ole-Morten Duesund
Date: Mon, 25 May 2026 19:46:24 +0200
Subject: [PATCH 04/10] fix(activities): preserve viewer's sort_position on
single-row fetches
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
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.
---
server/activities.ts | 38 +++++++++++++++++++++++++++++++++-----
tests/activities.test.ts | 36 ++++++++++++++++++++++++++++++++++++
2 files changed, 69 insertions(+), 5 deletions(-)
diff --git a/server/activities.ts b/server/activities.ts
index b9d7aa5..2d8238b 100644
--- a/server/activities.ts
+++ b/server/activities.ts
@@ -125,6 +125,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');
}
@@ -466,7 +491,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
@@ -533,7 +558,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);
});
@@ -601,7 +629,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));
});
@@ -643,7 +671,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));
}
@@ -700,7 +728,7 @@ function toggleDone(c: AppContext, op: 'add' | 'remove') {
db.prepare('DELETE FROM activity_done WHERE activity_id = ? AND user_id = ?').run(id, userId);
}
- 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));
}
diff --git a/tests/activities.test.ts b/tests/activities.test.ts
index 402727e..a27dcad 100644
--- a/tests/activities.test.ts
+++ b/tests/activities.test.ts
@@ -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(ctx, 'POST', `/api/activities/${a1.id}/heart`, {
+ cookie: user.cookie,
+ });
+ expect(hearted.sort_position).toBe(customPos);
+
+ // Same for done.
+ const doneRes = await reqJson(ctx, 'POST', `/api/activities/${a1.id}/done`, {
+ cookie: user.cookie,
+ });
+ expect(doneRes.sort_position).toBe(customPos);
+
+ // And for GET /:id.
+ const single = await reqJson(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);
From ef02b3f5856e8c1e9384b072e14eca4e0636202e Mon Sep 17 00:00:00 2001
From: Ole-Morten Duesund
Date: Mon, 25 May 2026 20:04:57 +0200
Subject: [PATCH 05/10] feat(ops): emergency password-reset CLI + deployment
docs
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
New CLI: bun run reset-password
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.
---
README.md | 106 ++++++++++++++-
package.json | 3 +-
server/reset-password.ts | 282 +++++++++++++++++++++++++++++++++++++++
3 files changed, 388 insertions(+), 3 deletions(-)
create mode 100644 server/reset-password.ts
diff --git a/README.md b/README.md
index 7fa82a7..f713b40 100644
--- a/README.md
+++ b/README.md
@@ -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
diff --git a/package.json b/package.json
index 6cbb1a7..28bb896 100644
--- a/package.json
+++ b/package.json
@@ -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",
diff --git a/server/reset-password.ts b/server/reset-password.ts
new file mode 100644
index 0000000..112d976
--- /dev/null
+++ b/server/reset-password.ts
@@ -0,0 +1,282 @@
+/**
+ * CLI: emergency password reset.
+ *
+ * bun run reset-password
+ *
+ * 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 ');
+ 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 = Bun.stdin.stream().getReader();
+let lineBuf = '';
+let stdinEnded = false;
+
+async function readLine(prompt: string): Promise {
+ 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 {
+ 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 {
+ 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 {
+ 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);
+});
From 6e005fc2d795648cd859663853ce1f277b74c4b1 Mon Sep 17 00:00:00 2001
From: Ole-Morten Duesund
Date: Mon, 25 May 2026 20:19:44 +0200
Subject: [PATCH 06/10] feat(activity): per-viewer archive and hide
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
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 //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.
---
frontend/src/components/ActivityRow.svelte | 67 +++++++++
frontend/src/components/Home.svelte | 47 +++++-
frontend/src/lib/api.ts | 16 ++-
server/activities.ts | 157 ++++++++++++++++++++-
server/db.ts | 18 +++
server/users.ts | 11 ++
shared/types.ts | 16 +++
tests/engagement.test.ts | 93 ++++++++++++
8 files changed, 418 insertions(+), 7 deletions(-)
diff --git a/frontend/src/components/ActivityRow.svelte b/frontend/src/components/ActivityRow.svelte
index a550dce..34420f0 100644
--- a/frontend/src/components/ActivityRow.svelte
+++ b/frontend/src/components/ActivityRow.svelte
@@ -179,6 +179,50 @@
}
}
+ // --- 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() {
@@ -374,6 +418,29 @@
{#if canEdit && onEdit}
{/if}
+ {#if session.user}
+
+ {/if}
+ {#if session.user && !isOwner}
+
+
+ {/if}
{#if canDelete}
{/if}
diff --git a/frontend/src/components/Home.svelte b/frontend/src/components/Home.svelte
index 7985fd7..afcf990 100644
--- a/frontend/src/components/Home.svelte
+++ b/frontend/src/components/Home.svelte
@@ -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,14 @@
}
}
+ // 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.
+ $effect(() => {
+ void showArchived;
+ void showHidden;
+ if (!loading) load();
+ });
+
function onCreated(a: Activity) {
activities = [a, ...activities];
showForm = false;
@@ -46,7 +70,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 +231,19 @@
aria-label="Søk i aktiviteter"
/>
+ {#if !publicOnly && session.user}
+
+
+
+
+ {/if}
+
{#if showForm}
(showForm = false)} />
diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts
index 523e1ba..56d7442 100644
--- a/frontend/src/lib/api.ts
+++ b/frontend/src/lib/api.ts
@@ -64,7 +64,13 @@ export const api = {
}),
// --- activities -----------------------------------------------------------
- listActivities: () => http('/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(`/activities${q ? `?${q}` : ''}`);
+ },
getActivity: (id: string) =>
http(`/activities/${encodeURIComponent(id)}`),
createActivity: (body: CreateActivityRequest) =>
@@ -83,6 +89,14 @@ export const api = {
http(`/activities/${encodeURIComponent(id)}/done`, { method: 'POST' }),
undoneActivity: (id: string) =>
http(`/activities/${encodeURIComponent(id)}/done`, { method: 'DELETE' }),
+ archiveActivity: (id: string) =>
+ http(`/activities/${encodeURIComponent(id)}/archive`, { method: 'POST' }),
+ unarchiveActivity: (id: string) =>
+ http(`/activities/${encodeURIComponent(id)}/archive`, { method: 'DELETE' }),
+ hideActivity: (id: string) =>
+ http(`/activities/${encodeURIComponent(id)}/hide`, { method: 'POST' }),
+ unhideActivity: (id: string) =>
+ http(`/activities/${encodeURIComponent(id)}/hide`, { method: 'DELETE' }),
bookmarkActivity: (id: string) =>
http(`/activities/${encodeURIComponent(id)}/bookmark`, { method: 'POST' }),
unbookmarkActivity: (id: string) =>
diff --git a/server/activities.ts b/server/activities.ts
index 2d8238b..892e8ce 100644
--- a/server/activities.ts
+++ b/server/activities.ts
@@ -79,6 +79,22 @@ 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();
@@ -166,6 +182,8 @@ interface BulkLookups {
hearts: Map;
done: Map;
bookmarked: Set;
+ archived: Set;
+ hidden: Set;
attribution: Map;
}
@@ -176,9 +194,11 @@ function buildBulkLookups(rows: ActivityRow[], viewerId: string | null): BulkLoo
const hearts = new Map();
const done = new Map();
const bookmarked = new Set();
+ const archived = new Set();
+ const hidden = new Set();
const attribution = new Map();
- if (ids.length === 0) return { tags, hearts, done, bookmarked, attribution };
+ if (ids.length === 0) return { tags, hearts, done, bookmarked, archived, hidden, attribution };
const ph = ids.map(() => '?').join(',');
const heartCounts = db
@@ -231,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))];
@@ -256,7 +292,7 @@ function buildBulkLookups(rows: ActivityRow[], viewerId: string | null): BulkLoo
}
}
- return { tags, hearts, done, bookmarked, attribution };
+ return { tags, hearts, done, bookmarked, archived, hidden, attribution };
}
function serialize(row: ActivityRow, viewerId: string | null, bulk?: BulkLookups): Activity {
@@ -269,6 +305,11 @@ function serialize(row: ActivityRow, viewerId: string | null, bulk?: BulkLookups
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,
@@ -282,6 +323,8 @@ function serialize(row: ActivityRow, viewerId: string | null, bulk?: BulkLookups
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,
@@ -314,6 +357,8 @@ function serialize(row: ActivityRow, viewerId: string | null, bulk?: BulkLookups
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,
@@ -347,6 +392,8 @@ function serialize(row: ActivityRow, viewerId: string | null, bulk?: BulkLookups
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,
@@ -370,6 +417,8 @@ function serialize(row: ActivityRow, viewerId: string | null, bulk?: BulkLookups
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,
@@ -407,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 = ?)`;
@@ -433,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)
@@ -735,6 +818,72 @@ function toggleDone(c: AppContext, op: 'add' | 'remove') {
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 = {
+ 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
diff --git a/server/db.ts b/server/db.ts
index 614ceb1..3acd0cd 100644
--- a/server/db.ts
+++ b/server/db.ts
@@ -131,6 +131,24 @@ const SCHEMA_STATEMENTS: readonly string[] = [
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.
diff --git a/server/users.ts b/server/users.ts
index a327f87..71478dc 100644
--- a/server/users.ts
+++ b/server/users.ts
@@ -41,12 +41,20 @@ 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[];
@@ -94,6 +102,9 @@ usersRoutes.get('/:username/list', (c) => {
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,
diff --git a/shared/types.ts b/shared/types.ts
index 21634d7..e83fd61 100644
--- a/shared/types.ts
+++ b/shared/types.ts
@@ -223,6 +223,13 @@ export interface ActivityPublic {
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. */
@@ -250,6 +257,8 @@ export interface ActivitySemi {
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. */
@@ -275,6 +284,11 @@ export interface ActivityPrivate {
// 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. */
@@ -307,6 +321,8 @@ export interface ActivityFriends {
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. */
diff --git a/tests/engagement.test.ts b/tests/engagement.test.ts
index fc2a1d1..632ed8e 100644
--- a/tests/engagement.test.ts
+++ b/tests/engagement.test.ts
@@ -188,6 +188,99 @@ describe('"gjort" (done) marks', () => {
});
});
+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(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(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(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(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([
From 09a9e3742cc1c577358ba5fff99b4a13db645928 Mon Sep 17 00:00:00 2001
From: Ole-Morten Duesund
Date: Mon, 25 May 2026 20:26:53 +0200
Subject: [PATCH 07/10] =?UTF-8?q?fix(home):=20stop=20self-firing=20$effect?=
=?UTF-8?q?=20that=20pinned=20list=20on=20"Laster=20=E2=80=A6"?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
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.
---
frontend/src/components/Home.svelte | 23 +++++++++++++++--------
1 file changed, 15 insertions(+), 8 deletions(-)
diff --git a/frontend/src/components/Home.svelte b/frontend/src/components/Home.svelte
index afcf990..db20e2c 100644
--- a/frontend/src/components/Home.svelte
+++ b/frontend/src/components/Home.svelte
@@ -52,12 +52,19 @@
}
// 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.
- $effect(() => {
- void showArchived;
- void showHidden;
- if (!loading) load();
- });
+ // 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];
@@ -234,11 +241,11 @@
{#if !publicOnly && session.user}
From 0e5bf0a03572c7efdad09a987c736022f07b28fc Mon Sep 17 00:00:00 2001
From: Ole-Morten Duesund
Date: Mon, 25 May 2026 20:34:50 +0200
Subject: [PATCH 08/10] 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).
---
server/activities.ts | 33 ++++++++++++++++++++++++++-------
tests/activities.test.ts | 35 +++++++++++++++++++++++++++++++++++
2 files changed, 61 insertions(+), 7 deletions(-)
diff --git a/server/activities.ts b/server/activities.ts
index 892e8ce..bd47834 100644
--- a/server/activities.ts
+++ b/server/activities.ts
@@ -556,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 (?, ?, ?)
diff --git a/tests/activities.test.ts b/tests/activities.test.ts
index a27dcad..3c1b1c9 100644
--- a/tests/activities.test.ts
+++ b/tests/activities.test.ts
@@ -384,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)', () => {
From 2ac73c35151207f967c9af4d389e5a352d46de79 Mon Sep 17 00:00:00 2001
From: Ole-Morten Duesund
Date: Mon, 25 May 2026 20:34:50 +0200
Subject: [PATCH 09/10] feat(social): "Legg til som venn"-knapp + clearer
used-invite copy
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
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 · " instead of the
"Brukt" badge + separate "av X · Y" line. Clearer wording, same
data.
---
frontend/src/components/FriendsPanel.svelte | 28 +++++++++++++++++++--
frontend/src/components/Profile.svelte | 11 +++++---
2 files changed, 33 insertions(+), 6 deletions(-)
diff --git a/frontend/src/components/FriendsPanel.svelte b/frontend/src/components/FriendsPanel.svelte
index 7985f03..1f1cd53 100644
--- a/frontend/src/components/FriendsPanel.svelte
+++ b/frontend/src/components/FriendsPanel.svelte
@@ -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 @@
Ingen har lagt deg til ennå.
{/if}
{#each incoming as f (f.user_id)}
+ {@const alreadyFriend = outgoing.some((o) => o.user_id === f.user_id)}
-
{#if inv.claimed_at}
- Brukt
- {#if inv.claimed_by_display}
- av {inv.claimed_by_display} · {formatDate(inv.claimed_at)}
- {/if}
+
+ {#if inv.claimed_by_display}
+ ✓ Mottatt og tatt i bruk av {inv.claimed_by_display} · {formatDate(inv.claimed_at)}
+ {:else}
+ ✓ Mottatt og tatt i bruk · {formatDate(inv.claimed_at)}
+ {/if}
+
{:else}