diff --git a/README.md b/README.md
index f713b40..7fa82a7 100644
--- a/README.md
+++ b/README.md
@@ -101,9 +101,7 @@ 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.
-## Deployment
-
-### Container (podman)
+## Container (podman)
The provided `Containerfile` builds a single image that serves API + frontend
and persists the SQLite database in `/app/data` (one volume).
@@ -126,107 +124,7 @@ 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`. 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.
+git revision into both OCI labels and `/etc/build-info`.
## Registration: open, invite-only, or both
diff --git a/frontend/src/App.svelte b/frontend/src/App.svelte
index 324a90b..3534856 100644
--- a/frontend/src/App.svelte
+++ b/frontend/src/App.svelte
@@ -3,7 +3,6 @@
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';
@@ -139,18 +138,7 @@
// No session — fine.
}
- // 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);
- }
+ applyRoute(route);
});
function applyRoute(route: Route) {
@@ -176,20 +164,22 @@
}
}
+ function leaveTag() {
+ // Same logic as leavePersonvern — back to wherever they were.
+ if (session.user) goHome();
+ else goPublicHome();
+ }
+
function goPersonvern() {
pushUrl('/personvern');
view = 'personvern';
}
- /**
- * 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 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();
}
function onAuthed() {
@@ -217,8 +207,7 @@
{#if view !== 'public-list' && view !== 'permalink' && view !== 'tag'}
@@ -257,9 +246,9 @@
{#if view === 'loading'}
Laster …
{:else if view === 'public-list'}
-
+
{:else if view === 'permalink'}
-
+
{:else if view === 'public-home'}
{:else if view === 'login'}
@@ -287,9 +276,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 34420f0..2aa0acd 100644
--- a/frontend/src/components/ActivityRow.svelte
+++ b/frontend/src/components/ActivityRow.svelte
@@ -51,15 +51,6 @@
});
}
- // 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)
@@ -152,77 +143,6 @@
}
}
- // --- "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() {
@@ -309,7 +229,6 @@
{@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
@@ -357,18 +276,17 @@
{@render locationLine(activity.loc_label, activity.loc_lat, activity.loc_lng)}
{/if}
{#if activity.scheduled_at} 🕒 {formatDate(activity.scheduled_at)}
{/if}
-
- Lagt til {formatDateOnly(activity.created_at)}
- {#if activity.visibility === 'public' && activity.owner_display}
- av
+ {#if activity.visibility === 'public' && activity.owner_display}
+
+ Lagt til av
{#if activity.owner_username}
onSpaLink(e, `/${activity.owner_username}/liste`)}>{activity.owner_display}
{:else}
{activity.owner_display}
{/if}
- {/if}
-
+
+ {/if}
{/if}
@@ -398,49 +316,9 @@
♡ {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 1f1cd53..7985f03 100644
--- a/frontend/src/components/FriendsPanel.svelte
+++ b/frontend/src/components/FriendsPanel.svelte
@@ -88,21 +88,6 @@
}
}
- // "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 {
@@ -170,22 +155,13 @@
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}
-
- {#if !alreadyFriend && f.username}
- addBack(f)}>
- Legg til som venn
-
- {/if}
- block(f)}>Blokker
-
+
block(f)}>Blokker
{/each}
diff --git a/frontend/src/components/Home.svelte b/frontend/src/components/Home.svelte
index db20e2c..7985fd7 100644
--- a/frontend/src/components/Home.svelte
+++ b/frontend/src/components/Home.svelte
@@ -22,28 +22,12 @@
let error: string | null = $state(null);
let query = $state('');
- // 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());
+ onMount(load);
async function load() {
loading = true;
try {
- activities = await api.listActivities(
- publicOnly
- ? undefined
- : {
- ...(showArchived ? { archived: '1' as const } : {}),
- ...(showHidden ? { hidden: '1' as const } : {}),
- },
- );
+ activities = await api.listActivities();
} catch (e) {
error = 'Kunne ikke laste oppføringer.';
} finally {
@@ -51,21 +35,6 @@
}
}
- // 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;
@@ -77,13 +46,7 @@
}
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) {
@@ -238,19 +201,6 @@
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 447a2a3..30a7f5b 100644
--- a/frontend/src/components/Profile.svelte
+++ b/frontend/src/components/Profile.svelte
@@ -170,11 +170,8 @@
/** 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. 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;
+ * the URL is just a presentation concern the SPA owns. */
+ function inviteUrl(inv: InviteEntry): string {
return `${window.location.origin}/invitasjon/${inv.token}`;
}
@@ -364,7 +361,7 @@
- Invitasjoner
+ Invitasjonslenker
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.
@@ -381,34 +378,28 @@
{#if invites.length === 0}
Ingen invitasjoner ennå.
{/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)}
+ {#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}
{: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 56d7442..c5fde72 100644
--- a/frontend/src/lib/api.ts
+++ b/frontend/src/lib/api.ts
@@ -64,13 +64,7 @@ export const api = {
}),
// --- 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}` : ''}`);
- },
+ listActivities: () => http('/activities'),
getActivity: (id: string) =>
http(`/activities/${encodeURIComponent(id)}`),
createActivity: (body: CreateActivityRequest) =>
@@ -85,18 +79,6 @@ 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 e54bf79..929c8c1 100644
--- a/frontend/src/lib/navigate.ts
+++ b/frontend/src/lib/navigate.ts
@@ -34,21 +34,3 @@ 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 28bb896..6cbb1a7 100644
--- a/package.json
+++ b/package.json
@@ -10,8 +10,7 @@
"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",
- "reset-password": "bun run server/reset-password.ts"
+ "typecheck": "tsc --noEmit && tsc --noEmit -p frontend/tsconfig.json"
},
"dependencies": {
"hono": "^4.6.0",
diff --git a/server/activities.ts b/server/activities.ts
index bd47834..985ba7d 100644
--- a/server/activities.ts
+++ b/server/activities.ts
@@ -79,36 +79,6 @@ 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);
@@ -141,31 +111,6 @@ 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');
}
@@ -180,10 +125,7 @@ function b64ToBuf(s: string): Buffer {
interface BulkLookups {
tags: Map;
hearts: Map;
- done: Map;
bookmarked: Set;
- archived: Set;
- hidden: Set;
attribution: Map;
}
@@ -192,13 +134,10 @@ 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, done, bookmarked, archived, hidden, attribution };
+ if (ids.length === 0) return { tags, hearts, bookmarked, attribution };
const ph = ids.map(() => '?').join(',');
const heartCounts = db
@@ -222,27 +161,6 @@ 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(`
@@ -251,22 +169,6 @@ 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))];
@@ -292,7 +194,7 @@ function buildBulkLookups(rows: ActivityRow[], viewerId: string | null): BulkLoo
}
}
- return { tags, hearts, done, bookmarked, archived, hidden, attribution };
+ return { tags, hearts, bookmarked, attribution };
}
function serialize(row: ActivityRow, viewerId: string | null, bulk?: BulkLookups): Activity {
@@ -300,16 +202,6 @@ 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,
@@ -321,10 +213,6 @@ 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,
@@ -355,10 +243,6 @@ 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,
@@ -390,10 +274,6 @@ 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,
@@ -415,10 +295,6 @@ 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,
@@ -456,20 +332,8 @@ activitiesRoutes.get('/', (c) => {
const viewerId = currentUserId(c);
const db = getDb();
- // 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')`;
+ const params: string[] = [];
+ let where = `visibility IN ('public','semi')`;
if (viewerId) {
// Own private:
where += ` OR (visibility = 'private' AND owner_id = ?)`;
@@ -494,28 +358,6 @@ 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)
@@ -556,32 +398,13 @@ activitiesRoutes.patch('/:id/sort', requireAuth, async (c) => {
return c.json({ error: 'missing:position' }, 400);
}
const db = getDb();
- // 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);
- }
+ // 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);
db.prepare(`
INSERT INTO user_activity_sort (user_id, activity_id, position) VALUES (?, ?, ?)
@@ -593,7 +416,7 @@ activitiesRoutes.patch('/:id/sort', requireAuth, async (c) => {
// --- GET /api/activities/:id ------------------------------------------------
activitiesRoutes.get('/:id', (c) => {
const viewerId = currentUserId(c);
- const row = fetchRowForViewer(c.req.param('id'), viewerId);
+ const row = getDb().prepare('SELECT * FROM activities WHERE id = ?').get(c.req.param('id')) as ActivityRow | null;
if (!row) return c.json({ error: 'not_found' }, 404);
// Apply the same visibility rules as the list endpoint. We return 404
@@ -660,10 +483,7 @@ activitiesRoutes.post('/', requireAuth, async (c) => {
setActivityTags(id, body.tags ?? []);
}
- // 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;
+ const row = db.prepare('SELECT * FROM activities WHERE id = ?').get(id) as ActivityRow;
return c.json(serialize(row, userId), 201);
});
@@ -731,7 +551,7 @@ activitiesRoutes.patch('/:id', requireAuth, async (c) => {
setActivityTags(id, body.tags ?? []);
}
- const row = fetchRowForViewer(id, userId) as ActivityRow;
+ const row = db.prepare('SELECT * FROM activities WHERE id = ?').get(id) as ActivityRow;
return c.json(serialize(row, userId));
});
@@ -773,7 +593,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 = fetchRowForViewer(id, userId) as ActivityRow;
+ const refreshed = db.prepare('SELECT * FROM activities WHERE id = ?').get(id) as ActivityRow;
return c.json(serialize(refreshed, userId));
}
@@ -782,127 +602,6 @@ 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 3acd0cd..5fdaac0 100644
--- a/server/db.ts
+++ b/server/db.ts
@@ -119,36 +119,6 @@ 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 939cf68..c3b1e32 100644
--- a/server/invites.ts
+++ b/server/invites.ts
@@ -50,12 +50,7 @@ function toEntry(row: InviteRow): InviteEntry {
}
}
return {
- // 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,
+ token: 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
deleted file mode 100644
index 112d976..0000000
--- a/server/reset-password.ts
+++ /dev/null
@@ -1,282 +0,0 @@
-/**
- * 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 71478dc..17ac796 100644
--- a/server/users.ts
+++ b/server/users.ts
@@ -41,42 +41,32 @@ 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 3 queries instead of 3N. The endpoint is public,
- // so viewer_hearted/bookmarked/done are always false — no per-viewer queries.
+ // 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.
const ids = rows.map((r) => r.id);
const tags = bulkTagsFor(ids);
- 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 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 activities: ActivityPublic[] = rows.map((r) => ({
id: r.id,
@@ -97,14 +87,9 @@ 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/done truthfully. Always false.
+ // viewer is to fill viewer_hearted/bookmarked 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 280f98b..9d79426 100644
--- a/shared/types.ts
+++ b/shared/types.ts
@@ -45,9 +45,8 @@ 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. 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;
+ * environments. */
+ token: string;
created_at: number;
claimed_at: number | null;
/** Display name (or username) of the user who claimed it, if any. */
@@ -220,17 +219,6 @@ 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. */
@@ -256,10 +244,6 @@ 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. */
@@ -280,16 +264,6 @@ 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. */
@@ -320,10 +294,6 @@ 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 3c1b1c9..402727e 100644
--- a/tests/activities.test.ts
+++ b/tests/activities.test.ts
@@ -333,42 +333,6 @@ 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);
@@ -384,41 +348,6 @@ 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 632ed8e..150c0ff 100644
--- a/tests/engagement.test.ts
+++ b/tests/engagement.test.ts
@@ -124,163 +124,6 @@ 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 54ea8b8..991f157 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,16 +133,13 @@ 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 server stops
- // returning the literal token once the invite is claimed, so match by
- // created_at instead.
+ // The invite is now claimed in the inviter's list.
const myInvites = await reqJson(ctx, 'GET', '/api/invites', {
cookie: inviter.cookie,
});
- const claimed = myInvites.find((i) => i.created_at === inv.created_at)!;
+ const claimed = myInvites.find((i) => i.token === inv.token)!;
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 () => {
@@ -152,12 +149,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();
@@ -179,7 +176,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,
});
@@ -235,7 +232,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.