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/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 view !== 'public-list' && view !== 'permalink' && view !== 'tag'}
@@ -246,9 +257,9 @@
{#if view === 'loading'}
Laster …
{:else if view === 'public-list'}
-
+
{:else if view === 'permalink'}
-
+
{:else if view === 'public-home'}
{:else if view === 'login'}
@@ -276,9 +287,9 @@
{:else if view === 'moderate-tags'}
{:else if view === 'personvern'}
-
+
{:else if view === 'tag'}
-
+
{:else}
{/if}
diff --git a/frontend/src/components/ActivityRow.svelte b/frontend/src/components/ActivityRow.svelte
index 2aa0acd..34420f0 100644
--- a/frontend/src/components/ActivityRow.svelte
+++ b/frontend/src/components/ActivityRow.svelte
@@ -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}🕒 {formatDate(decrypted.scheduled_at)}
{/if}
+ Lagt til {formatDateOnly(activity.created_at)}
{:else if !session.dek}
Privat
@@ -276,17 +357,18 @@
{@render locationLine(activity.loc_label, activity.loc_lat, activity.loc_lng)}
{/if}
{#if activity.scheduled_at} 🕒 {formatDate(activity.scheduled_at)}
{/if}
- {#if activity.visibility === 'public' && activity.owner_display}
-
- Lagt til av
+
+ Lagt til {formatDateOnly(activity.created_at)}
+ {#if activity.visibility === 'public' && activity.owner_display}
+ av
{#if activity.owner_username}
onSpaLink(e, `/${activity.owner_username}/liste`)}>{activity.owner_display}
{:else}
{activity.owner_display}
{/if}
-
- {/if}
+ {/if}
+
{/if}
@@ -316,9 +398,49 @@
♡ {view.heart_count}
{/if}
{/if}
+
+ {#if session.user}
+
+ {view.viewer_done ? '✓ Gjort' : '☐ Gjort'} {view.done_count > 0 ? view.done_count : ''}
+
+ {:else if view.visibility !== 'private' && view.done_count > 0}
+
✓ {view.done_count}
+ {/if}
{#if canEdit && onEdit}
Rediger
{/if}
+ {#if session.user}
+
+ {view.viewer_archived ? '📦 Arkivert' : '📦 Arkiver'}
+
+ {/if}
+ {#if session.user && !isOwner}
+
+
+ {view.viewer_hidden ? '🙈 Skjult' : '🙈 Gjem'}
+
+ {/if}
{#if canDelete}
Slett
{/if}
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)}
-
+
{displayName(f)}
{#if f.username}· @{f.username} {/if}
+ {#if alreadyFriend}(gjensidig) {/if}
-
block(f)}>Blokker
+
+ {#if !alreadyFriend && f.username}
+ addBack(f)}>
+ Legg til som venn
+
+ {/if}
+ block(f)}>Blokker
+
{/each}
diff --git a/frontend/src/components/Home.svelte b/frontend/src/components/Home.svelte
index 7985fd7..db20e2c 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,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}
+
+
+
+ 📦 Vis arkivert
+
+
+
+ 🙈 Vis skjult
+
+
+ {/if}
+
{#if showForm}
(showForm = false)} />
diff --git a/frontend/src/components/Profile.svelte b/frontend/src/components/Profile.svelte
index 30a7f5b..447a2a3 100644
--- a/frontend/src/components/Profile.svelte
+++ b/frontend/src/components/Profile.svelte
@@ -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 @@
- Invitasjonslenker
+ Invitasjoner
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}
Ingen invitasjoner ennå.
{/if}
- {#each invites as inv (inv.token)}
-
-
- {inviteUrl(inv)}
- {formatDate(inv.created_at)}
-
-
- {#if inv.claimed_at}
-
Brukt
- {#if inv.claimed_by_display}
-
av {inv.claimed_by_display} · {formatDate(inv.claimed_at)}
- {/if}
+ {#each invites as inv (inv.token ?? `c-${inv.created_at}-${inv.claimed_at}`)}
+ {#if inv.claimed_at}
+
+
+ ✓ 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}
+
+ {:else}
+
+
+ {inviteUrl(inv)}
+ {formatDate(inv.created_at)}
+
+
copyInviteUrl(inv)}>
{copiedToken === inv.token ? 'Kopiert!' : 'Kopier lenke'}
cancelInvite(inv)}>
Avbryt
- {/if}
-
-
+
+
+ {/if}
{/each}
diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts
index c5fde72..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) =>
@@ -79,6 +85,18 @@ 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' }),
+ 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/frontend/src/lib/navigate.ts b/frontend/src/lib/navigate.ts
index 929c8c1..e54bf79 100644
--- a/frontend/src/lib/navigate.ts
+++ b/frontend/src/lib/navigate.ts
@@ -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);
+ }
+}
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/activities.ts b/server/activities.ts
index 985ba7d..bd47834 100644
--- a/server/activities.ts
+++ b/server/activities.ts
@@ -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;
hearts: Map;
+ done: Map;
bookmarked: Set;
+ archived: Set;
+ hidden: Set;
attribution: Map;
}
@@ -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();
+ 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, 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();
+ 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 = {
+ 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 5fdaac0..3acd0cd 100644
--- a/server/db.ts
+++ b/server/db.ts
@@ -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.
diff --git a/server/invites.ts b/server/invites.ts
index c3b1e32..939cf68 100644
--- a/server/invites.ts
+++ b/server/invites.ts
@@ -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,
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);
+});
diff --git a/server/users.ts b/server/users.ts
index 17ac796..71478dc 100644
--- a/server/users.ts
+++ b/server/users.ts
@@ -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()
- : 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 +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,
diff --git a/shared/types.ts b/shared/types.ts
index 9d79426..280f98b 100644
--- a/shared/types.ts
+++ b/shared/types.ts
@@ -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. */
diff --git a/tests/activities.test.ts b/tests/activities.test.ts
index 402727e..3c1b1c9 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);
@@ -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)', () => {
diff --git a/tests/engagement.test.ts b/tests/engagement.test.ts
index 150c0ff..632ed8e 100644
--- a/tests/engagement.test.ts
+++ b/tests/engagement.test.ts
@@ -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(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('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([
diff --git a/tests/social.test.ts b/tests/social.test.ts
index 991f157..54ea8b8 100644
--- a/tests/social.test.ts
+++ b/tests/social.test.ts
@@ -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(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(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(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.