Compare commits

..

No commits in common. "95f989639d7afa2ee99072beec6791f25ec6cad3" and "834abfdfb0dd71da842687b1bf4998eee717a7a7" have entirely different histories.

18 changed files with 88 additions and 1337 deletions

106
README.md
View file

@ -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

View file

@ -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 @@
<main>
<nav class="top">
<h1 style="margin: 0;">
<a href={session.user ? '/hjem' : '/'}
onclick={(e) => { e.preventDefault(); session.user ? goHome() : goPublicHome(); }}
<a href="/" onclick={(e) => { e.preventDefault(); goPublicHome(); }}
style="color: inherit; text-decoration: none;">Vinterliste</a>
</h1>
{#if view !== 'public-list' && view !== 'permalink' && view !== 'tag'}
@ -257,9 +246,9 @@
{#if view === 'loading'}
<p class="muted">Laster …</p>
{:else if view === 'public-list'}
<PublicList username={publicListUsername} onBack={backToCallerOrHome} />
<PublicList username={publicListUsername} onBack={goPublicHome} />
{:else if view === 'permalink'}
<ActivityPermalink id={activityId} onBack={backToCallerOrHome} />
<ActivityPermalink id={activityId} onBack={goPublicHome} />
{:else if view === 'public-home'}
<Home publicOnly={true} />
{:else if view === 'login'}
@ -287,9 +276,9 @@
{:else if view === 'moderate-tags'}
<ModerateTags onDone={goHome} />
{:else if view === 'personvern'}
<Personvern onBack={backToCallerOrHome} />
<Personvern onBack={leavePersonvern} />
{:else if view === 'tag'}
<TagPage tag={tagName} onBack={backToCallerOrHome} />
<TagPage tag={tagName} onBack={leaveTag} />
{:else}
<Home publicOnly={false} />
{/if}

View file

@ -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}<p class="muted">🕒 {formatDate(decrypted.scheduled_at)}</p>{/if}
<p class="muted" style="font-size: 0.8rem;">Lagt til {formatDateOnly(activity.created_at)}</p>
{:else if !session.dek}
<h3 id={`act-${activity.id}-h`} style="display: flex; align-items: center;">
<span class="vis-badge private">Privat</span>
@ -357,18 +276,17 @@
{@render locationLine(activity.loc_label, activity.loc_lat, activity.loc_lng)}
{/if}
{#if activity.scheduled_at}<p class="muted">🕒 {formatDate(activity.scheduled_at)}</p>{/if}
<p class="muted" style="font-size: 0.8rem;">
Lagt til {formatDateOnly(activity.created_at)}
{#if activity.visibility === 'public' && activity.owner_display}
av
{#if activity.visibility === 'public' && activity.owner_display}
<p class="muted" style="font-size: 0.8rem;">
Lagt til av
{#if activity.owner_username}
<a href={`/${activity.owner_username}/liste`}
onclick={(e) => onSpaLink(e, `/${activity.owner_username}/liste`)}>{activity.owner_display}</a>
{:else}
{activity.owner_display}
{/if}
{/if}
</p>
</p>
{/if}
{/if}
<div class="row" style="margin-top: 0.5rem;">
@ -398,49 +316,9 @@
<span class="muted" aria-label="Antall hjerter">{view.heart_count}</span>
{/if}
{/if}
<!-- "Gjort" works on every visibility — including the owner's private
rows where it's a personal todo checkbox. -->
{#if session.user}
<button
type="button"
onclick={toggleDone}
disabled={doneBusy}
aria-pressed={view.viewer_done}
aria-label={view.viewer_done ? 'Marker som ikke gjort' : 'Marker som gjort'}
title={view.viewer_done ? 'Du har gjort dette' : 'Dette har jeg gjort'}
class={view.viewer_done ? 'primary' : ''}
>
{view.viewer_done ? '✓ Gjort' : '☐ Gjort'} {view.done_count > 0 ? view.done_count : ''}
</button>
{:else if view.visibility !== 'private' && view.done_count > 0}
<span class="muted" aria-label="Antall som har gjort dette">{view.done_count}</span>
{/if}
{#if canEdit && onEdit}
<button type="button" onclick={startEdit}>Rediger</button>
{/if}
{#if session.user}
<button
type="button"
onclick={toggleArchive}
disabled={archiveBusy}
aria-pressed={view.viewer_archived}
title={view.viewer_archived ? 'Hent ut av arkivet' : 'Arkiver — gjem fra hovedlisten, behold for historikk'}
>
{view.viewer_archived ? '📦 Arkivert' : '📦 Arkiver'}
</button>
{/if}
{#if session.user && !isOwner}
<!-- Hide is non-owner only — owners can delete or archive instead. -->
<button
type="button"
onclick={toggleHide}
disabled={hideBusy}
aria-pressed={view.viewer_hidden}
title={view.viewer_hidden ? 'Vis igjen' : 'Gjem — denne appellerer ikke til meg'}
>
{view.viewer_hidden ? '🙈 Skjult' : '🙈 Gjem'}
</button>
{/if}
{#if canDelete}
<button class="danger" type="button" onclick={del}>Slett</button>
{/if}

View file

@ -88,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 @@
<p class="muted">Ingen har lagt deg til ennå.</p>
{/if}
{#each incoming as f (f.user_id)}
{@const alreadyFriend = outgoing.some((o) => o.user_id === f.user_id)}
<article class="card" style="margin: 0.4rem 0; padding: 0.5rem 0.75rem;">
<div class="row" style="justify-content: space-between; gap: 0.5rem;">
<div class="row" style="justify-content: space-between;">
<span>
{displayName(f)}
{#if f.username}<span class="muted">· @{f.username}</span>{/if}
{#if alreadyFriend}<span class="muted" style="font-size: 0.85rem;">(gjensidig)</span>{/if}
</span>
<div class="row" style="gap: 0.4rem;">
{#if !alreadyFriend && f.username}
<button class="primary" type="button" onclick={() => addBack(f)}>
Legg til som venn
</button>
{/if}
<button class="danger" type="button" onclick={() => block(f)}>Blokker</button>
</div>
<button class="danger" type="button" onclick={() => block(f)}>Blokker</button>
</div>
</article>
{/each}

View file

@ -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}
<div class="row" style="gap: 1rem; margin-top: 0.5rem; font-size: 0.9rem;">
<label class="row" style="gap: 0.35rem;">
<input type="checkbox" checked={showArchived} onchange={onToggleArchive} />
<span class="muted">📦 Vis arkivert</span>
</label>
<label class="row" style="gap: 0.35rem;">
<input type="checkbox" checked={showHidden} onchange={onToggleHidden} />
<span class="muted">🙈 Vis skjult</span>
</label>
</div>
{/if}
{#if showForm}
<div style="margin-top: 1rem;">
<ActivityForm onCreated={onCreated} onCancel={() => (showForm = false)} />

View file

@ -170,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 @@
</section>
<section class="card" aria-labelledby="inv-h">
<h3 id="inv-h">Invitasjoner</h3>
<h3 id="inv-h">Invitasjonslenker</h3>
<p class="muted">
Generer en lenke du kan dele. Den som registrerer seg via lenken blir
knyttet til deg som invitør. Hver lenke kan bare brukes én gang.
@ -381,34 +378,28 @@
{#if invites.length === 0}
<p class="muted">Ingen invitasjoner ennå.</p>
{/if}
{#each invites as inv (inv.token ?? `c-${inv.created_at}-${inv.claimed_at}`)}
{#if inv.claimed_at}
<!-- Claimed: one-line summary. The token isn't returned anymore,
so there's nothing to copy or cancel — just the audit trail. -->
<p class="muted" style="margin: 0.4rem 0; font-size: 0.9rem;">
✓ Laget {formatDate(inv.created_at)} ·
{#if inv.claimed_by_display}
godtatt av {inv.claimed_by_display} {formatDate(inv.claimed_at)}
{#each invites as inv (inv.token)}
<article class="card" style="margin-top: 0.5rem; {inv.claimed_at ? 'opacity: 0.7;' : ''}">
<div class="row" style="justify-content: space-between;">
<code style="font-size: 0.8rem; word-break: break-all;">{inviteUrl(inv)}</code>
<span class="muted" style="white-space: nowrap;">{formatDate(inv.created_at)}</span>
</div>
<div class="row" style="margin-top: 0.5rem;">
{#if inv.claimed_at}
<span class="vis-badge semi">Brukt</span>
{#if inv.claimed_by_display}
<span class="muted">av {inv.claimed_by_display} · {formatDate(inv.claimed_at)}</span>
{/if}
{:else}
godtatt {formatDate(inv.claimed_at)}
{/if}
</p>
{:else}
<article class="card" style="margin-top: 0.5rem;">
<div class="row" style="justify-content: space-between;">
<code style="font-size: 0.8rem; word-break: break-all;">{inviteUrl(inv)}</code>
<span class="muted" style="white-space: nowrap;">{formatDate(inv.created_at)}</span>
</div>
<div class="row" style="margin-top: 0.5rem;">
<button type="button" onclick={() => copyInviteUrl(inv)}>
{copiedToken === inv.token ? 'Kopiert!' : 'Kopier lenke'}
</button>
<button class="danger" type="button" onclick={() => cancelInvite(inv)}>
Avbryt
</button>
</div>
</article>
{/if}
{/if}
</div>
</article>
{/each}
</section>

View file

@ -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<Activity[]>(`/activities${q ? `?${q}` : ''}`);
},
listActivities: () => http<Activity[]>('/activities'),
getActivity: (id: string) =>
http<Activity>(`/activities/${encodeURIComponent(id)}`),
createActivity: (body: CreateActivityRequest) =>
@ -85,18 +79,6 @@ export const api = {
http<Activity>(`/activities/${encodeURIComponent(id)}/heart`, { method: 'POST' }),
unheartActivity: (id: string) =>
http<Activity>(`/activities/${encodeURIComponent(id)}/heart`, { method: 'DELETE' }),
doneActivity: (id: string) =>
http<Activity>(`/activities/${encodeURIComponent(id)}/done`, { method: 'POST' }),
undoneActivity: (id: string) =>
http<Activity>(`/activities/${encodeURIComponent(id)}/done`, { method: 'DELETE' }),
archiveActivity: (id: string) =>
http<Activity>(`/activities/${encodeURIComponent(id)}/archive`, { method: 'POST' }),
unarchiveActivity: (id: string) =>
http<Activity>(`/activities/${encodeURIComponent(id)}/archive`, { method: 'DELETE' }),
hideActivity: (id: string) =>
http<Activity>(`/activities/${encodeURIComponent(id)}/hide`, { method: 'POST' }),
unhideActivity: (id: string) =>
http<Activity>(`/activities/${encodeURIComponent(id)}/hide`, { method: 'DELETE' }),
bookmarkActivity: (id: string) =>
http<Activity>(`/activities/${encodeURIComponent(id)}/bookmark`, { method: 'POST' }),
unbookmarkActivity: (id: string) =>

View file

@ -34,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);
}
}

View file

@ -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",

View file

@ -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<string, string[]>;
hearts: Map<string, { count: number; hearted: boolean }>;
done: Map<string, { count: number; done: boolean }>;
bookmarked: Set<string>;
archived: Set<string>;
hidden: Set<string>;
attribution: Map<string, { display: string | null; username: string | null }>;
}
@ -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<string, { count: number; hearted: boolean }>();
const done = new Map<string, { count: number; done: boolean }>();
const bookmarked = new Set<string>();
const archived = new Set<string>();
const hidden = new Set<string>();
const attribution = new Map<string, { display: string | null; username: string | null }>();
if (ids.length === 0) return { tags, hearts, 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<string>();
if (viewerId) {
const vd = db
.prepare(`
SELECT activity_id FROM activity_done
WHERE activity_id IN (${ph}) AND user_id = ?
`)
.all(...ids, viewerId) as { activity_id: string }[];
for (const r of vd) viewerDoneSet.add(r.activity_id);
}
for (const r of doneCounts) {
done.set(r.activity_id, { count: r.n, done: viewerDoneSet.has(r.activity_id) });
}
if (viewerId) {
const bm = db
.prepare(`
@ -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<Filing, string> = {
archive: 'user_archived_activities',
hide: 'user_hidden_activities',
};
function toggleFiling(c: AppContext, kind: Filing, op: 'add' | 'remove') {
const userId = c.get('userId');
const id = c.req.param('id');
if (!id) return c.json({ error: 'not_found' }, 404);
const db = getDb();
const row = db
.prepare('SELECT visibility, owner_id FROM activities WHERE id = ?')
.get(id) as { visibility: Visibility; owner_id: string } | null;
if (!row) return c.json({ error: 'not_found' }, 404);
// Same visibility check as toggleDone — hidden rows return 404, not 403.
if (row.visibility === 'private' && row.owner_id !== userId) {
return c.json({ error: 'not_found' }, 404);
}
if (row.visibility === 'friends' && row.owner_id !== userId) {
const isFriend = !!db
.prepare('SELECT 1 FROM friends WHERE owner_id = ? AND friend_id = ?')
.get(row.owner_id, userId);
if (!isFriend) return c.json({ error: 'not_found' }, 404);
const blocked = !!db
.prepare(`
SELECT 1 FROM user_blocks
WHERE (blocker_id = ? AND blocked_id = ?)
OR (blocker_id = ? AND blocked_id = ?)
`)
.get(row.owner_id, userId, userId, row.owner_id);
if (blocked) return c.json({ error: 'not_found' }, 404);
}
// Hiding your own row makes no sense — you can delete or archive instead.
if (kind === 'hide' && row.owner_id === userId) {
return c.json({ error: 'cannot_hide_own' }, 400);
}
const table = FILING_TABLES[kind];
if (op === 'add') {
db.prepare(
`INSERT OR IGNORE INTO ${table} (user_id, activity_id, created_at) VALUES (?, ?, ?)`,
).run(userId, id, Date.now());
} else {
db.prepare(`DELETE FROM ${table} WHERE user_id = ? AND activity_id = ?`).run(userId, id);
}
const refreshed = fetchRowForViewer(id, userId) as ActivityRow;
return c.json(serialize(refreshed, userId));
}
activitiesRoutes.post('/:id/archive', requireAuth, (c) => toggleFiling(c, 'archive', 'add'));
activitiesRoutes.delete('/:id/archive', requireAuth, (c) => toggleFiling(c, 'archive', 'remove'));
activitiesRoutes.post('/:id/hide', requireAuth, (c) => toggleFiling(c, 'hide', 'add'));
activitiesRoutes.delete('/:id/hide', requireAuth, (c) => toggleFiling(c, 'hide', 'remove'));
// --- DELETE /api/activities/:id ---------------------------------------------
// Authz:
// - private: owner only. Other users can't even see private rows, so

View file

@ -119,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.

View file

@ -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,

View file

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

View file

@ -41,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<string, number> => {
if (ids.length === 0) return new Map();
const ph = ids.map(() => '?').join(',');
const rows = db
.prepare(`
SELECT activity_id, COUNT(*) AS n FROM ${table}
WHERE activity_id IN (${ph})
GROUP BY activity_id
`)
.all(...ids) as { activity_id: string; n: number }[];
return new Map(rows.map((r) => [r.activity_id, r.n]));
};
const heartCounts = bulkCounts('activity_hearts');
const doneCounts = bulkCounts('activity_done');
const heartCounts = ids.length === 0
? new Map<string, number>()
: new Map<string, number>(
(db
.prepare(`
SELECT activity_id, COUNT(*) AS n FROM activity_hearts
WHERE activity_id IN (${ids.map(() => '?').join(',')})
GROUP BY activity_id
`)
.all(...ids) as { activity_id: string; n: number }[]
).map((r) => [r.activity_id, r.n]),
);
const 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,

View file

@ -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. */

View file

@ -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<Activity>(ctx, 'POST', `/api/activities/${a1.id}/heart`, {
cookie: user.cookie,
});
expect(hearted.sort_position).toBe(customPos);
// Same for done.
const doneRes = await reqJson<Activity>(ctx, 'POST', `/api/activities/${a1.id}/done`, {
cookie: user.cookie,
});
expect(doneRes.sort_position).toBe(customPos);
// And for GET /:id.
const single = await reqJson<Activity>(ctx, 'GET', `/api/activities/${a1.id}`, {
cookie: user.cookie,
});
expect(single.sort_position).toBe(customPos);
});
test('PATCH /sort requires auth', async () => {
const res = await req(ctx, 'PATCH', '/api/activities/whatever/sort', { body: { position: 1 } });
expect(res.status).toBe(401);
@ -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)', () => {

View file

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

View file

@ -125,7 +125,7 @@ describe('invites', () => {
expect(inv.claimed_at).toBeNull();
// A new user signs up with that invite token.
const claimer = await signupAndGetCookie(ctx, 'inv-claimer@test.invalid', undefined, inv.token!);
const claimer = await signupAndGetCookie(ctx, 'inv-claimer@test.invalid', undefined, inv.token);
expect(claimer.me.id).toBeTruthy();
// The invited_by column on the new user row points at the inviter.
@ -133,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<InviteEntry[]>(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<InviteEntry>(ctx, 'POST', '/api/invites', {
cookie: inviter.cookie, expect: 201,
});
await signupAndGetCookie(ctx, 'inv-already-c@test.invalid', undefined, inv.token!);
await signupAndGetCookie(ctx, 'inv-already-c@test.invalid', undefined, inv.token);
const cancelRes = await req(ctx, 'DELETE', `/api/invites/${inv.token}`, {
cookie: inviter.cookie,
});
@ -235,7 +232,7 @@ describe('settings + signup gating', () => {
const inv = await reqJson<InviteEntry>(ctx, 'POST', '/api/invites', {
cookie: admin.cookie, expect: 201,
});
const claimer = await signupAndGetCookie(ctx, 'gate-claimer@test.invalid', undefined, inv.token!);
const claimer = await signupAndGetCookie(ctx, 'gate-claimer@test.invalid', undefined, inv.token);
expect(claimer.me.id).toBeTruthy();
} finally {
// Re-open self-registry so subsequent tests can still sign up.