Compare commits
10 commits
834abfdfb0
...
95f989639d
| Author | SHA1 | Date | |
|---|---|---|---|
| 95f989639d | |||
| 2ac73c3515 | |||
| 0e5bf0a035 | |||
| 09a9e3742c | |||
| 6e005fc2d7 | |||
| ef02b3f585 | |||
| fb193b4914 | |||
| bbb5ad2bdd | |||
| 38db772b4f | |||
| 443702d222 |
18 changed files with 1337 additions and 88 deletions
106
README.md
106
README.md
|
|
@ -101,7 +101,9 @@ The server serves the SPA from `frontend/dist` in production. All non-`/api/*`,
|
|||
non-`/assets/*` requests fall through to `index.html` so client-side routing
|
||||
still works.
|
||||
|
||||
## Container (podman)
|
||||
## Deployment
|
||||
|
||||
### Container (podman)
|
||||
|
||||
The provided `Containerfile` builds a single image that serves API + frontend
|
||||
and persists the SQLite database in `/app/data` (one volume).
|
||||
|
|
@ -124,7 +126,107 @@ podman run --replace --name vinterliste \
|
|||
```
|
||||
|
||||
The container exposes `/api/health` for healthchecks and bakes the build date /
|
||||
git revision into both OCI labels and `/etc/build-info`.
|
||||
git revision into both OCI labels and `/etc/build-info`. Use `podman run
|
||||
--replace ...` for redeploys — it's atomic and avoids the "container exists"
|
||||
race.
|
||||
|
||||
### Environment variables
|
||||
|
||||
| Variable | Default | Notes |
|
||||
|--------------------|------------------------|---------------------------------------------------------------|
|
||||
| `PORT` | `3000` | TCP port the server listens on. |
|
||||
| `NODE_ENV` | (unset) | Set to `production` to serve `frontend/dist` from the API. |
|
||||
| `VINTERLISTE_DB` | `data/vinterliste.db` | Path to the SQLite file. Override for an external volume. |
|
||||
| `PUBLIC_BASE_URL` | (derived from request) | Override the absolute URL used in OpenGraph `og:url` tags. |
|
||||
|
||||
There are no secrets to set. Auth verifiers and DEK wraps live in the SQLite
|
||||
file; session tokens are generated per process and stored server-side, not
|
||||
signed.
|
||||
|
||||
### TLS termination
|
||||
|
||||
The app speaks plain HTTP — terminate TLS at a reverse proxy (Caddy, nginx,
|
||||
Traefik). The session cookie is marked `Secure` when the request was HTTPS
|
||||
(`X-Forwarded-Proto: https`), so make sure the proxy sets that header.
|
||||
|
||||
Sample Caddyfile:
|
||||
|
||||
```caddyfile
|
||||
vinterliste.example.org {
|
||||
encode zstd gzip
|
||||
reverse_proxy localhost:3000
|
||||
}
|
||||
```
|
||||
|
||||
Caddy auto-provisions a Let's Encrypt cert. Other proxies need the cert
|
||||
configured manually.
|
||||
|
||||
### Backup and restore
|
||||
|
||||
The SQLite database is the entire app state — user accounts, DEK wraps,
|
||||
activity ciphertexts, sessions, the lot. Backing it up while the server is
|
||||
running is safe because of WAL mode:
|
||||
|
||||
```bash
|
||||
# Atomic backup using SQLite's built-in copy
|
||||
sqlite3 data/vinterliste.db ".backup '/path/to/backup/vinterliste-$(date +%F).db'"
|
||||
|
||||
# Or via the container's volume
|
||||
podman exec vinterliste sqlite3 /app/data/vinterliste.db \
|
||||
".backup '/app/data/backup-$(date +%F).db'"
|
||||
```
|
||||
|
||||
Plain file copy of the `.db` works too if the server is stopped first. With WAL
|
||||
files (`.db-wal`, `.db-shm`) present, copy all three or use `.backup`.
|
||||
|
||||
To restore: replace the file on disk and restart the server. There are no
|
||||
out-of-band caches.
|
||||
|
||||
### Healthcheck
|
||||
|
||||
`GET /api/health` returns `{ ok: true, build: { revision, built_at } }` with
|
||||
HTTP 200. Hook your monitoring or `HEALTHCHECK` directive at this endpoint.
|
||||
|
||||
### Upgrading
|
||||
|
||||
1. Build a new image with current `BUILD_DATE` and `GIT_REVISION` args.
|
||||
2. `podman run --replace` — schema migrations are idempotent
|
||||
(`CREATE TABLE IF NOT EXISTS …` and `ensureColumn(...)` add new columns
|
||||
without touching existing data).
|
||||
3. Verify `/api/health` returns the new `revision`.
|
||||
4. The `activities` table's CHECK constraint includes all visibility values;
|
||||
the `friends` visibility added later is migrated in via
|
||||
`ensureActivitiesCheckIncludesFriends()` (table copy-drop-rename) on
|
||||
first boot if needed. Take a backup beforehand the first time you upgrade
|
||||
past a CHECK-constraint change.
|
||||
|
||||
### Emergency password reset (CLI)
|
||||
|
||||
If an admin has lost access (forgotten password, lost recovery code, etc.) and
|
||||
can't recover via the UI, the server box has a CLI tool:
|
||||
|
||||
```bash
|
||||
# Inside the container:
|
||||
podman exec -it vinterliste bun run reset-password admin@example.org
|
||||
|
||||
# Or on the host if you're running the server directly:
|
||||
bun run reset-password admin@example.org
|
||||
```
|
||||
|
||||
It asks one question first: **do you still have this user's recovery code?**
|
||||
|
||||
- **Yes → recovery mode.** Behaves exactly like the in-app recovery flow:
|
||||
unwraps the existing DEK with the recovery code, re-wraps it with the new
|
||||
password. No data is lost. The recovery code stays valid afterwards.
|
||||
- **No → nuke mode.** Generates a brand-new DEK + new recovery code and
|
||||
prints the new code to stdout (write it down — it's shown once). The
|
||||
user's **private activities are deleted** because their ciphertext was
|
||||
encrypted with the now-unrecoverable old DEK. Public, semi, friends-only
|
||||
activities, plus hearts / bookmarks / "gjort" marks, are kept.
|
||||
|
||||
Both modes invalidate every existing session for the user, matching the
|
||||
hygiene of the in-app `/auth/recovery-complete` endpoint. The CLI requires
|
||||
direct DB access — there is no network exposure of this code path.
|
||||
|
||||
## Registration: open, invite-only, or both
|
||||
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@
|
|||
import { ready } from './lib/crypto';
|
||||
import { api, ApiError } from './lib/api';
|
||||
import { session, setSessionUserOnly } from './lib/session.svelte';
|
||||
import { goBack } from './lib/navigate';
|
||||
import { logout } from './lib/auth';
|
||||
import Login from './components/Login.svelte';
|
||||
import Signup from './components/Signup.svelte';
|
||||
|
|
@ -138,7 +139,18 @@
|
|||
// No session — fine.
|
||||
}
|
||||
|
||||
applyRoute(route);
|
||||
// Cold-load redirect: a logged-in user landing on the public landing
|
||||
// probably wants their own dashboard, not the marketing-y "what is this"
|
||||
// page. We only redirect on this initial mount — not on every
|
||||
// applyRoute call — so browser-back from /hjem to / still lets the
|
||||
// explicit navigation through (no loop, and the wordmark intentionally
|
||||
// sends logged-in users to /hjem instead of / anyway).
|
||||
if (route.view === 'public-home' && session.user) {
|
||||
pushUrl('/hjem');
|
||||
view = 'home';
|
||||
} else {
|
||||
applyRoute(route);
|
||||
}
|
||||
});
|
||||
|
||||
function applyRoute(route: Route) {
|
||||
|
|
@ -164,22 +176,20 @@
|
|||
}
|
||||
}
|
||||
|
||||
function leaveTag() {
|
||||
// Same logic as leavePersonvern — back to wherever they were.
|
||||
if (session.user) goHome();
|
||||
else goPublicHome();
|
||||
}
|
||||
|
||||
function goPersonvern() {
|
||||
pushUrl('/personvern');
|
||||
view = 'personvern';
|
||||
}
|
||||
|
||||
function leavePersonvern() {
|
||||
// Send the visitor wherever they "would have been" — landing if logged out,
|
||||
// dashboard if logged in. Either is more useful than staying on the doc page.
|
||||
if (session.user) goHome();
|
||||
else goPublicHome();
|
||||
/**
|
||||
* Back-button handler for sub-views (permalink, tag page, personvern,
|
||||
* public list). Uses real browser history so the user returns to
|
||||
* wherever they came from in the SPA — /hjem, /etiketter/foo,
|
||||
* /aktivitet/bar, anywhere. Falls back to /hjem (or / when anonymous)
|
||||
* on cold-loads where there's no prior history entry.
|
||||
*/
|
||||
function backToCallerOrHome() {
|
||||
goBack(session.user ? '/hjem' : '/');
|
||||
}
|
||||
|
||||
function onAuthed() {
|
||||
|
|
@ -207,7 +217,8 @@
|
|||
<main>
|
||||
<nav class="top">
|
||||
<h1 style="margin: 0;">
|
||||
<a href="/" onclick={(e) => { e.preventDefault(); goPublicHome(); }}
|
||||
<a href={session.user ? '/hjem' : '/'}
|
||||
onclick={(e) => { e.preventDefault(); session.user ? goHome() : goPublicHome(); }}
|
||||
style="color: inherit; text-decoration: none;">Vinterliste</a>
|
||||
</h1>
|
||||
{#if view !== 'public-list' && view !== 'permalink' && view !== 'tag'}
|
||||
|
|
@ -246,9 +257,9 @@
|
|||
{#if view === 'loading'}
|
||||
<p class="muted">Laster …</p>
|
||||
{:else if view === 'public-list'}
|
||||
<PublicList username={publicListUsername} onBack={goPublicHome} />
|
||||
<PublicList username={publicListUsername} onBack={backToCallerOrHome} />
|
||||
{:else if view === 'permalink'}
|
||||
<ActivityPermalink id={activityId} onBack={goPublicHome} />
|
||||
<ActivityPermalink id={activityId} onBack={backToCallerOrHome} />
|
||||
{:else if view === 'public-home'}
|
||||
<Home publicOnly={true} />
|
||||
{:else if view === 'login'}
|
||||
|
|
@ -276,9 +287,9 @@
|
|||
{:else if view === 'moderate-tags'}
|
||||
<ModerateTags onDone={goHome} />
|
||||
{:else if view === 'personvern'}
|
||||
<Personvern onBack={leavePersonvern} />
|
||||
<Personvern onBack={backToCallerOrHome} />
|
||||
{:else if view === 'tag'}
|
||||
<TagPage tag={tagName} onBack={leaveTag} />
|
||||
<TagPage tag={tagName} onBack={backToCallerOrHome} />
|
||||
{:else}
|
||||
<Home publicOnly={false} />
|
||||
{/if}
|
||||
|
|
|
|||
|
|
@ -51,6 +51,15 @@
|
|||
});
|
||||
}
|
||||
|
||||
// created_at is epoch *milliseconds* (Date.now() on the server). The
|
||||
// existing formatDate above takes seconds because scheduled_at is stored
|
||||
// that way. Date-only is enough for "added on" — time of day is noise.
|
||||
function formatDateOnly(epochMs: number): string {
|
||||
return new Date(epochMs).toLocaleDateString('nb-NO', {
|
||||
year: 'numeric', month: '2-digit', day: '2-digit',
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* OpenStreetMap link for a location.
|
||||
* - If coordinates are present → /?mlat=…&mlon=…&zoom=15 (shows a pin)
|
||||
|
|
@ -143,6 +152,77 @@
|
|||
}
|
||||
}
|
||||
|
||||
// --- "Gjort" (completion mark) ------------------------------------------
|
||||
// Works on EVERY visibility the viewer can see. For private rows it acts
|
||||
// as a personal checkbox; for public/semi/friends it contributes to the
|
||||
// visible done_count statistic.
|
||||
let doneBusy = $state(false);
|
||||
async function toggleDone() {
|
||||
if (!session.user || doneBusy) return;
|
||||
doneBusy = true;
|
||||
const wasDone = view.viewer_done;
|
||||
localOverride = {
|
||||
...view,
|
||||
viewer_done: !wasDone,
|
||||
done_count: view.done_count + (wasDone ? -1 : 1),
|
||||
};
|
||||
try {
|
||||
const updated = wasDone
|
||||
? await api.undoneActivity(view.id)
|
||||
: await api.doneActivity(view.id);
|
||||
localOverride = updated;
|
||||
onChanged?.(updated);
|
||||
} catch {
|
||||
localOverride = null;
|
||||
} finally {
|
||||
doneBusy = false;
|
||||
}
|
||||
}
|
||||
|
||||
// --- Archive / Hide -----------------------------------------------------
|
||||
// Two per-viewer flags. Archive applies to anyone (including the owner);
|
||||
// hide applies only to non-owners. Both opt the row out of the default
|
||||
// listing — the user has to toggle "Vis arkiv" / "Vis skjulte" to see
|
||||
// them again.
|
||||
let archiveBusy = $state(false);
|
||||
let hideBusy = $state(false);
|
||||
|
||||
async function toggleArchive() {
|
||||
if (!session.user || archiveBusy) return;
|
||||
archiveBusy = true;
|
||||
const wasArchived = view.viewer_archived;
|
||||
localOverride = { ...view, viewer_archived: !wasArchived };
|
||||
try {
|
||||
const updated = wasArchived
|
||||
? await api.unarchiveActivity(view.id)
|
||||
: await api.archiveActivity(view.id);
|
||||
localOverride = updated;
|
||||
onChanged?.(updated);
|
||||
} catch {
|
||||
localOverride = null;
|
||||
} finally {
|
||||
archiveBusy = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function toggleHide() {
|
||||
if (!session.user || hideBusy) return;
|
||||
hideBusy = true;
|
||||
const wasHidden = view.viewer_hidden;
|
||||
localOverride = { ...view, viewer_hidden: !wasHidden };
|
||||
try {
|
||||
const updated = wasHidden
|
||||
? await api.unhideActivity(view.id)
|
||||
: await api.hideActivity(view.id);
|
||||
localOverride = updated;
|
||||
onChanged?.(updated);
|
||||
} catch {
|
||||
localOverride = null;
|
||||
} finally {
|
||||
hideBusy = false;
|
||||
}
|
||||
}
|
||||
|
||||
// --- Bookmarks -----------------------------------------------------------
|
||||
let bookmarkBusy = $state(false);
|
||||
async function toggleBookmark() {
|
||||
|
|
@ -229,6 +309,7 @@
|
|||
{@render locationLine(decrypted.loc_label, null, null)}
|
||||
{/if}
|
||||
{#if decrypted.scheduled_at}<p class="muted">🕒 {formatDate(decrypted.scheduled_at)}</p>{/if}
|
||||
<p class="muted" style="font-size: 0.8rem;">Lagt til {formatDateOnly(activity.created_at)}</p>
|
||||
{:else if !session.dek}
|
||||
<h3 id={`act-${activity.id}-h`} style="display: flex; align-items: center;">
|
||||
<span class="vis-badge private">Privat</span>
|
||||
|
|
@ -276,17 +357,18 @@
|
|||
{@render locationLine(activity.loc_label, activity.loc_lat, activity.loc_lng)}
|
||||
{/if}
|
||||
{#if activity.scheduled_at}<p class="muted">🕒 {formatDate(activity.scheduled_at)}</p>{/if}
|
||||
{#if activity.visibility === 'public' && activity.owner_display}
|
||||
<p class="muted" style="font-size: 0.8rem;">
|
||||
Lagt til av
|
||||
<p class="muted" style="font-size: 0.8rem;">
|
||||
Lagt til {formatDateOnly(activity.created_at)}
|
||||
{#if activity.visibility === 'public' && activity.owner_display}
|
||||
av
|
||||
{#if activity.owner_username}
|
||||
<a href={`/${activity.owner_username}/liste`}
|
||||
onclick={(e) => onSpaLink(e, `/${activity.owner_username}/liste`)}>{activity.owner_display}</a>
|
||||
{:else}
|
||||
{activity.owner_display}
|
||||
{/if}
|
||||
</p>
|
||||
{/if}
|
||||
{/if}
|
||||
</p>
|
||||
{/if}
|
||||
|
||||
<div class="row" style="margin-top: 0.5rem;">
|
||||
|
|
@ -316,9 +398,49 @@
|
|||
<span class="muted" aria-label="Antall hjerter">♡ {view.heart_count}</span>
|
||||
{/if}
|
||||
{/if}
|
||||
<!-- "Gjort" works on every visibility — including the owner's private
|
||||
rows where it's a personal todo checkbox. -->
|
||||
{#if session.user}
|
||||
<button
|
||||
type="button"
|
||||
onclick={toggleDone}
|
||||
disabled={doneBusy}
|
||||
aria-pressed={view.viewer_done}
|
||||
aria-label={view.viewer_done ? 'Marker som ikke gjort' : 'Marker som gjort'}
|
||||
title={view.viewer_done ? 'Du har gjort dette' : 'Dette har jeg gjort'}
|
||||
class={view.viewer_done ? 'primary' : ''}
|
||||
>
|
||||
{view.viewer_done ? '✓ Gjort' : '☐ Gjort'} {view.done_count > 0 ? view.done_count : ''}
|
||||
</button>
|
||||
{:else if view.visibility !== 'private' && view.done_count > 0}
|
||||
<span class="muted" aria-label="Antall som har gjort dette">✓ {view.done_count}</span>
|
||||
{/if}
|
||||
{#if canEdit && onEdit}
|
||||
<button type="button" onclick={startEdit}>Rediger</button>
|
||||
{/if}
|
||||
{#if session.user}
|
||||
<button
|
||||
type="button"
|
||||
onclick={toggleArchive}
|
||||
disabled={archiveBusy}
|
||||
aria-pressed={view.viewer_archived}
|
||||
title={view.viewer_archived ? 'Hent ut av arkivet' : 'Arkiver — gjem fra hovedlisten, behold for historikk'}
|
||||
>
|
||||
{view.viewer_archived ? '📦 Arkivert' : '📦 Arkiver'}
|
||||
</button>
|
||||
{/if}
|
||||
{#if session.user && !isOwner}
|
||||
<!-- Hide is non-owner only — owners can delete or archive instead. -->
|
||||
<button
|
||||
type="button"
|
||||
onclick={toggleHide}
|
||||
disabled={hideBusy}
|
||||
aria-pressed={view.viewer_hidden}
|
||||
title={view.viewer_hidden ? 'Vis igjen' : 'Gjem — denne appellerer ikke til meg'}
|
||||
>
|
||||
{view.viewer_hidden ? '🙈 Skjult' : '🙈 Gjem'}
|
||||
</button>
|
||||
{/if}
|
||||
{#if canDelete}
|
||||
<button class="danger" type="button" onclick={del}>Slett</button>
|
||||
{/if}
|
||||
|
|
|
|||
|
|
@ -88,6 +88,21 @@
|
|||
}
|
||||
}
|
||||
|
||||
// "Legg til som venn tilbake" — for an incoming entry where I haven't
|
||||
// already added them. Goes through the same addFriend API as the form
|
||||
// above. If the user hasn't set a username (rare — incoming implies they
|
||||
// added someone, which requires THE OTHER PARTY's username, not theirs)
|
||||
// we hide the button instead of erroring.
|
||||
async function addBack(f: FriendEntry) {
|
||||
if (!f.username) return;
|
||||
try {
|
||||
const added = await api.addFriend({ username: f.username });
|
||||
outgoing = [added, ...outgoing.filter((o) => o.user_id !== added.user_id)];
|
||||
} catch {
|
||||
loadError = 'Klarte ikke å legge til venn.';
|
||||
}
|
||||
}
|
||||
|
||||
async function block(e: FriendEntry) {
|
||||
if (!confirm(`Blokkere ${displayName(e)}? De vil ikke lenger se aktiviteter du deler med venner.`)) return;
|
||||
try {
|
||||
|
|
@ -155,13 +170,22 @@
|
|||
<p class="muted">Ingen har lagt deg til ennå.</p>
|
||||
{/if}
|
||||
{#each incoming as f (f.user_id)}
|
||||
{@const alreadyFriend = outgoing.some((o) => o.user_id === f.user_id)}
|
||||
<article class="card" style="margin: 0.4rem 0; padding: 0.5rem 0.75rem;">
|
||||
<div class="row" style="justify-content: space-between;">
|
||||
<div class="row" style="justify-content: space-between; gap: 0.5rem;">
|
||||
<span>
|
||||
{displayName(f)}
|
||||
{#if f.username}<span class="muted">· @{f.username}</span>{/if}
|
||||
{#if alreadyFriend}<span class="muted" style="font-size: 0.85rem;">(gjensidig)</span>{/if}
|
||||
</span>
|
||||
<button class="danger" type="button" onclick={() => block(f)}>Blokker</button>
|
||||
<div class="row" style="gap: 0.4rem;">
|
||||
{#if !alreadyFriend && f.username}
|
||||
<button class="primary" type="button" onclick={() => addBack(f)}>
|
||||
Legg til som venn
|
||||
</button>
|
||||
{/if}
|
||||
<button class="danger" type="button" onclick={() => block(f)}>Blokker</button>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
{/each}
|
||||
|
|
|
|||
|
|
@ -22,12 +22,28 @@
|
|||
let error: string | null = $state(null);
|
||||
let query = $state('');
|
||||
|
||||
onMount(load);
|
||||
// Two toggles control what shape of list we ask the server for:
|
||||
// - default: active rows only (excludes archived AND hidden)
|
||||
// - showArchived: include archived rows mixed in with active
|
||||
// - showHidden: include hidden rows mixed in
|
||||
// The user can flip these independently. Anonymous (publicOnly) viewers
|
||||
// never see the toggles since they have no archive/hide state.
|
||||
let showArchived = $state(false);
|
||||
let showHidden = $state(false);
|
||||
|
||||
onMount(() => load());
|
||||
|
||||
async function load() {
|
||||
loading = true;
|
||||
try {
|
||||
activities = await api.listActivities();
|
||||
activities = await api.listActivities(
|
||||
publicOnly
|
||||
? undefined
|
||||
: {
|
||||
...(showArchived ? { archived: '1' as const } : {}),
|
||||
...(showHidden ? { hidden: '1' as const } : {}),
|
||||
},
|
||||
);
|
||||
} catch (e) {
|
||||
error = 'Kunne ikke laste oppføringer.';
|
||||
} finally {
|
||||
|
|
@ -35,6 +51,21 @@
|
|||
}
|
||||
}
|
||||
|
||||
// Re-fetch from the server when the toggles flip — the WHERE clause is
|
||||
// server-side so we can't filter the existing array client-side. Triggered
|
||||
// from the checkbox onchange handlers below, NOT from a $effect — the
|
||||
// effect form had a self-fire loop (each load() toggles `loading`, the
|
||||
// effect tracked `loading`, every cycle re-triggered itself, list stayed
|
||||
// stuck on "Laster …").
|
||||
function onToggleArchive(e: Event) {
|
||||
showArchived = (e.currentTarget as HTMLInputElement).checked;
|
||||
load();
|
||||
}
|
||||
function onToggleHidden(e: Event) {
|
||||
showHidden = (e.currentTarget as HTMLInputElement).checked;
|
||||
load();
|
||||
}
|
||||
|
||||
function onCreated(a: Activity) {
|
||||
activities = [a, ...activities];
|
||||
showForm = false;
|
||||
|
|
@ -46,7 +77,13 @@
|
|||
}
|
||||
|
||||
function onChanged(a: Activity) {
|
||||
// When a row's archived/hidden state flips, it may no longer belong in
|
||||
// the current view (toggles are off → archived/hidden rows disappear).
|
||||
// Reload to get the authoritative list from the server.
|
||||
const needsRefetch =
|
||||
(a.viewer_archived && !showArchived) || (a.viewer_hidden && !showHidden);
|
||||
activities = activities.map((x) => (x.id === a.id ? a : x));
|
||||
if (needsRefetch) load();
|
||||
}
|
||||
|
||||
function onDeleted(id: string) {
|
||||
|
|
@ -201,6 +238,19 @@
|
|||
aria-label="Søk i aktiviteter"
|
||||
/>
|
||||
|
||||
{#if !publicOnly && session.user}
|
||||
<div class="row" style="gap: 1rem; margin-top: 0.5rem; font-size: 0.9rem;">
|
||||
<label class="row" style="gap: 0.35rem;">
|
||||
<input type="checkbox" checked={showArchived} onchange={onToggleArchive} />
|
||||
<span class="muted">📦 Vis arkivert</span>
|
||||
</label>
|
||||
<label class="row" style="gap: 0.35rem;">
|
||||
<input type="checkbox" checked={showHidden} onchange={onToggleHidden} />
|
||||
<span class="muted">🙈 Vis skjult</span>
|
||||
</label>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if showForm}
|
||||
<div style="margin-top: 1rem;">
|
||||
<ActivityForm onCreated={onCreated} onCancel={() => (showForm = false)} />
|
||||
|
|
|
|||
|
|
@ -170,8 +170,11 @@
|
|||
/** Build the shareable invite URL using the SPA's own origin. The server
|
||||
* used to compute this but in dev that yielded the API host (port 3000),
|
||||
* not the SPA host (port 5173). The token is the canonical artefact;
|
||||
* the URL is just a presentation concern the SPA owns. */
|
||||
function inviteUrl(inv: InviteEntry): string {
|
||||
* the URL is just a presentation concern the SPA owns. Returns null
|
||||
* when the invite has been claimed — the server stops returning the
|
||||
* token at that point and there's nothing left to share. */
|
||||
function inviteUrl(inv: InviteEntry): string | null {
|
||||
if (!inv.token) return null;
|
||||
return `${window.location.origin}/invitasjon/${inv.token}`;
|
||||
}
|
||||
|
||||
|
|
@ -361,7 +364,7 @@
|
|||
</section>
|
||||
|
||||
<section class="card" aria-labelledby="inv-h">
|
||||
<h3 id="inv-h">Invitasjonslenker</h3>
|
||||
<h3 id="inv-h">Invitasjoner</h3>
|
||||
<p class="muted">
|
||||
Generer en lenke du kan dele. Den som registrerer seg via lenken blir
|
||||
knyttet til deg som invitør. Hver lenke kan bare brukes én gang.
|
||||
|
|
@ -378,28 +381,34 @@
|
|||
{#if invites.length === 0}
|
||||
<p class="muted">Ingen invitasjoner ennå.</p>
|
||||
{/if}
|
||||
{#each invites as inv (inv.token)}
|
||||
<article class="card" style="margin-top: 0.5rem; {inv.claimed_at ? 'opacity: 0.7;' : ''}">
|
||||
<div class="row" style="justify-content: space-between;">
|
||||
<code style="font-size: 0.8rem; word-break: break-all;">{inviteUrl(inv)}</code>
|
||||
<span class="muted" style="white-space: nowrap;">{formatDate(inv.created_at)}</span>
|
||||
</div>
|
||||
<div class="row" style="margin-top: 0.5rem;">
|
||||
{#if inv.claimed_at}
|
||||
<span class="vis-badge semi">Brukt</span>
|
||||
{#if inv.claimed_by_display}
|
||||
<span class="muted">av {inv.claimed_by_display} · {formatDate(inv.claimed_at)}</span>
|
||||
{/if}
|
||||
{#each invites as inv (inv.token ?? `c-${inv.created_at}-${inv.claimed_at}`)}
|
||||
{#if inv.claimed_at}
|
||||
<!-- Claimed: one-line summary. The token isn't returned anymore,
|
||||
so there's nothing to copy or cancel — just the audit trail. -->
|
||||
<p class="muted" style="margin: 0.4rem 0; font-size: 0.9rem;">
|
||||
✓ Laget {formatDate(inv.created_at)} ·
|
||||
{#if inv.claimed_by_display}
|
||||
godtatt av {inv.claimed_by_display} {formatDate(inv.claimed_at)}
|
||||
{:else}
|
||||
godtatt {formatDate(inv.claimed_at)}
|
||||
{/if}
|
||||
</p>
|
||||
{:else}
|
||||
<article class="card" style="margin-top: 0.5rem;">
|
||||
<div class="row" style="justify-content: space-between;">
|
||||
<code style="font-size: 0.8rem; word-break: break-all;">{inviteUrl(inv)}</code>
|
||||
<span class="muted" style="white-space: nowrap;">{formatDate(inv.created_at)}</span>
|
||||
</div>
|
||||
<div class="row" style="margin-top: 0.5rem;">
|
||||
<button type="button" onclick={() => copyInviteUrl(inv)}>
|
||||
{copiedToken === inv.token ? 'Kopiert!' : 'Kopier lenke'}
|
||||
</button>
|
||||
<button class="danger" type="button" onclick={() => cancelInvite(inv)}>
|
||||
Avbryt
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
</article>
|
||||
{/if}
|
||||
{/each}
|
||||
</section>
|
||||
|
||||
|
|
|
|||
|
|
@ -64,7 +64,13 @@ export const api = {
|
|||
}),
|
||||
|
||||
// --- activities -----------------------------------------------------------
|
||||
listActivities: () => http<Activity[]>('/activities'),
|
||||
listActivities: (opts?: { archived?: '1' | 'only'; hidden?: '1' | 'only' }) => {
|
||||
const qs = new URLSearchParams();
|
||||
if (opts?.archived) qs.set('archived', opts.archived);
|
||||
if (opts?.hidden) qs.set('hidden', opts.hidden);
|
||||
const q = qs.toString();
|
||||
return http<Activity[]>(`/activities${q ? `?${q}` : ''}`);
|
||||
},
|
||||
getActivity: (id: string) =>
|
||||
http<Activity>(`/activities/${encodeURIComponent(id)}`),
|
||||
createActivity: (body: CreateActivityRequest) =>
|
||||
|
|
@ -79,6 +85,18 @@ export const api = {
|
|||
http<Activity>(`/activities/${encodeURIComponent(id)}/heart`, { method: 'POST' }),
|
||||
unheartActivity: (id: string) =>
|
||||
http<Activity>(`/activities/${encodeURIComponent(id)}/heart`, { method: 'DELETE' }),
|
||||
doneActivity: (id: string) =>
|
||||
http<Activity>(`/activities/${encodeURIComponent(id)}/done`, { method: 'POST' }),
|
||||
undoneActivity: (id: string) =>
|
||||
http<Activity>(`/activities/${encodeURIComponent(id)}/done`, { method: 'DELETE' }),
|
||||
archiveActivity: (id: string) =>
|
||||
http<Activity>(`/activities/${encodeURIComponent(id)}/archive`, { method: 'POST' }),
|
||||
unarchiveActivity: (id: string) =>
|
||||
http<Activity>(`/activities/${encodeURIComponent(id)}/archive`, { method: 'DELETE' }),
|
||||
hideActivity: (id: string) =>
|
||||
http<Activity>(`/activities/${encodeURIComponent(id)}/hide`, { method: 'POST' }),
|
||||
unhideActivity: (id: string) =>
|
||||
http<Activity>(`/activities/${encodeURIComponent(id)}/hide`, { method: 'DELETE' }),
|
||||
bookmarkActivity: (id: string) =>
|
||||
http<Activity>(`/activities/${encodeURIComponent(id)}/bookmark`, { method: 'POST' }),
|
||||
unbookmarkActivity: (id: string) =>
|
||||
|
|
|
|||
|
|
@ -34,3 +34,21 @@ export function onSpaLink(e: MouseEvent, path: string): void {
|
|||
e.preventDefault();
|
||||
navigate(path);
|
||||
}
|
||||
|
||||
/**
|
||||
* "Back" navigation for in-app back buttons. Uses real browser history
|
||||
* when there's prior history to return to (preserves the user's actual
|
||||
* path through the SPA). Falls back to navigate(fallback) on cold-loads
|
||||
* — when the user opened the URL directly in a new tab and there's no
|
||||
* prior entry to back into.
|
||||
*
|
||||
* `window.history.length` is browser-dependent but length === 1 is a
|
||||
* reliable cold-load signal: every browser counts the current entry.
|
||||
*/
|
||||
export function goBack(fallback: string): void {
|
||||
if (window.history.length > 1) {
|
||||
window.history.back();
|
||||
} else {
|
||||
navigate(fallback);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -10,7 +10,8 @@
|
|||
"build:frontend": "vite build --config frontend/vite.config.ts",
|
||||
"start": "NODE_ENV=production bun run server/index.ts",
|
||||
"test": "bun test",
|
||||
"typecheck": "tsc --noEmit && tsc --noEmit -p frontend/tsconfig.json"
|
||||
"typecheck": "tsc --noEmit && tsc --noEmit -p frontend/tsconfig.json",
|
||||
"reset-password": "bun run server/reset-password.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"hono": "^4.6.0",
|
||||
|
|
|
|||
|
|
@ -79,6 +79,36 @@ function viewerBookmarked(activityId: string, viewerId: string | null): boolean
|
|||
.get(activityId, viewerId);
|
||||
}
|
||||
|
||||
/** Does the viewer have an archive entry on this activity? */
|
||||
function viewerArchived(activityId: string, viewerId: string | null): boolean {
|
||||
if (!viewerId) return false;
|
||||
return !!getDb()
|
||||
.prepare('SELECT 1 FROM user_archived_activities WHERE activity_id = ? AND user_id = ?')
|
||||
.get(activityId, viewerId);
|
||||
}
|
||||
|
||||
/** Does the viewer have a hide entry on this activity? */
|
||||
function viewerHidden(activityId: string, viewerId: string | null): boolean {
|
||||
if (!viewerId) return false;
|
||||
return !!getDb()
|
||||
.prepare('SELECT 1 FROM user_hidden_activities WHERE activity_id = ? AND user_id = ?')
|
||||
.get(activityId, viewerId);
|
||||
}
|
||||
|
||||
/** Done-count + viewer-done lookup for a single activity. */
|
||||
function doneFor(activityId: string, viewerId: string | null): { count: number; done: boolean } {
|
||||
const db = getDb();
|
||||
const count = (db
|
||||
.prepare('SELECT COUNT(*) AS n FROM activity_done WHERE activity_id = ?')
|
||||
.get(activityId) as { n: number }).n;
|
||||
const done = viewerId
|
||||
? !!db
|
||||
.prepare('SELECT 1 FROM activity_done WHERE activity_id = ? AND user_id = ?')
|
||||
.get(activityId, viewerId)
|
||||
: false;
|
||||
return { count, done };
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the public-facing attribution for an owner. Prefer the user's chosen
|
||||
* `display_name`; fall back to their `username` slug if set (also user-chosen);
|
||||
|
|
@ -111,6 +141,31 @@ function b64(b: Uint8Array | null): string | null {
|
|||
return b === null ? null : Buffer.from(b).toString('base64');
|
||||
}
|
||||
|
||||
/**
|
||||
* Single-row fetch that includes the viewer's custom sort_position via the
|
||||
* same LEFT JOIN as the list endpoint. Single-row endpoints (POST, PATCH,
|
||||
* GET /:id, heart/bookmark/done toggles) used to call plain
|
||||
* `SELECT * FROM activities WHERE id = ?` and let serialize() fall back to
|
||||
* -created_at, which silently overwrote any custom drag-sort the viewer
|
||||
* had on that row. Use this helper instead so toggles preserve the user's
|
||||
* ordering.
|
||||
*/
|
||||
function fetchRowForViewer(id: string, viewerId: string | null): ActivityRow | null {
|
||||
const db = getDb();
|
||||
if (viewerId) {
|
||||
return db
|
||||
.prepare(`
|
||||
SELECT activities.*, s.position AS sort_position
|
||||
FROM activities
|
||||
LEFT JOIN user_activity_sort s
|
||||
ON s.activity_id = activities.id AND s.user_id = ?
|
||||
WHERE activities.id = ?
|
||||
`)
|
||||
.get(viewerId, id) as ActivityRow | null;
|
||||
}
|
||||
return db.prepare('SELECT * FROM activities WHERE id = ?').get(id) as ActivityRow | null;
|
||||
}
|
||||
|
||||
function b64ToBuf(s: string): Buffer {
|
||||
return Buffer.from(s, 'base64');
|
||||
}
|
||||
|
|
@ -125,7 +180,10 @@ function b64ToBuf(s: string): Buffer {
|
|||
interface BulkLookups {
|
||||
tags: Map<string, string[]>;
|
||||
hearts: Map<string, { count: number; hearted: boolean }>;
|
||||
done: Map<string, { count: number; done: boolean }>;
|
||||
bookmarked: Set<string>;
|
||||
archived: Set<string>;
|
||||
hidden: Set<string>;
|
||||
attribution: Map<string, { display: string | null; username: string | null }>;
|
||||
}
|
||||
|
||||
|
|
@ -134,10 +192,13 @@ function buildBulkLookups(rows: ActivityRow[], viewerId: string | null): BulkLoo
|
|||
const ids = rows.map((r) => r.id);
|
||||
const tags = bulkTagsFor(ids);
|
||||
const hearts = new Map<string, { count: number; hearted: boolean }>();
|
||||
const done = new Map<string, { count: number; done: boolean }>();
|
||||
const bookmarked = new Set<string>();
|
||||
const archived = new Set<string>();
|
||||
const hidden = new Set<string>();
|
||||
const attribution = new Map<string, { display: string | null; username: string | null }>();
|
||||
|
||||
if (ids.length === 0) return { tags, hearts, bookmarked, attribution };
|
||||
if (ids.length === 0) return { tags, hearts, done, bookmarked, archived, hidden, attribution };
|
||||
const ph = ids.map(() => '?').join(',');
|
||||
|
||||
const heartCounts = db
|
||||
|
|
@ -161,6 +222,27 @@ function buildBulkLookups(rows: ActivityRow[], viewerId: string | null): BulkLoo
|
|||
hearts.set(r.activity_id, { count: r.n, hearted: viewerHeartSet.has(r.activity_id) });
|
||||
}
|
||||
|
||||
const doneCounts = db
|
||||
.prepare(`
|
||||
SELECT activity_id, COUNT(*) AS n FROM activity_done
|
||||
WHERE activity_id IN (${ph})
|
||||
GROUP BY activity_id
|
||||
`)
|
||||
.all(...ids) as { activity_id: string; n: number }[];
|
||||
const viewerDoneSet = new Set<string>();
|
||||
if (viewerId) {
|
||||
const vd = db
|
||||
.prepare(`
|
||||
SELECT activity_id FROM activity_done
|
||||
WHERE activity_id IN (${ph}) AND user_id = ?
|
||||
`)
|
||||
.all(...ids, viewerId) as { activity_id: string }[];
|
||||
for (const r of vd) viewerDoneSet.add(r.activity_id);
|
||||
}
|
||||
for (const r of doneCounts) {
|
||||
done.set(r.activity_id, { count: r.n, done: viewerDoneSet.has(r.activity_id) });
|
||||
}
|
||||
|
||||
if (viewerId) {
|
||||
const bm = db
|
||||
.prepare(`
|
||||
|
|
@ -169,6 +251,22 @@ function buildBulkLookups(rows: ActivityRow[], viewerId: string | null): BulkLoo
|
|||
`)
|
||||
.all(...ids, viewerId) as { activity_id: string }[];
|
||||
for (const r of bm) bookmarked.add(r.activity_id);
|
||||
|
||||
const ar = db
|
||||
.prepare(`
|
||||
SELECT activity_id FROM user_archived_activities
|
||||
WHERE activity_id IN (${ph}) AND user_id = ?
|
||||
`)
|
||||
.all(...ids, viewerId) as { activity_id: string }[];
|
||||
for (const r of ar) archived.add(r.activity_id);
|
||||
|
||||
const hd = db
|
||||
.prepare(`
|
||||
SELECT activity_id FROM user_hidden_activities
|
||||
WHERE activity_id IN (${ph}) AND user_id = ?
|
||||
`)
|
||||
.all(...ids, viewerId) as { activity_id: string }[];
|
||||
for (const r of hd) hidden.add(r.activity_id);
|
||||
}
|
||||
|
||||
const ownerIds = [...new Set(rows.map((r) => r.owner_id))];
|
||||
|
|
@ -194,7 +292,7 @@ function buildBulkLookups(rows: ActivityRow[], viewerId: string | null): BulkLoo
|
|||
}
|
||||
}
|
||||
|
||||
return { tags, hearts, bookmarked, attribution };
|
||||
return { tags, hearts, done, bookmarked, archived, hidden, attribution };
|
||||
}
|
||||
|
||||
function serialize(row: ActivityRow, viewerId: string | null, bulk?: BulkLookups): Activity {
|
||||
|
|
@ -202,6 +300,16 @@ function serialize(row: ActivityRow, viewerId: string | null, bulk?: BulkLookups
|
|||
// otherwise -created_at so unsorted rows float to the top by recency.
|
||||
// Matches the SQL ORDER BY in the list query.
|
||||
const sortPos = row.sort_position ?? -row.created_at;
|
||||
// Done state applies to all visibilities. For private rows the count is
|
||||
// at most 1 (only the owner can mark it) and acts as a personal checkbox.
|
||||
const done = bulk
|
||||
? (bulk.done.get(row.id) ?? { count: 0, done: false })
|
||||
: doneFor(row.id, viewerId);
|
||||
// Archive applies to every viewer; hide applies only to non-owners. For
|
||||
// a private row that's only ever visible to its owner, hide is always
|
||||
// false (the endpoint refuses it anyway).
|
||||
const archived = bulk ? bulk.archived.has(row.id) : viewerArchived(row.id, viewerId);
|
||||
const hidden = bulk ? bulk.hidden.has(row.id) : viewerHidden(row.id, viewerId);
|
||||
if (row.visibility === 'private') {
|
||||
const a: ActivityPrivate = {
|
||||
id: row.id,
|
||||
|
|
@ -213,6 +321,10 @@ function serialize(row: ActivityRow, viewerId: string | null, bulk?: BulkLookups
|
|||
heart_count: 0,
|
||||
viewer_hearted: false,
|
||||
viewer_bookmarked: false,
|
||||
done_count: done.count,
|
||||
viewer_done: done.done,
|
||||
viewer_archived: archived,
|
||||
viewer_hidden: false,
|
||||
sort_position: sortPos,
|
||||
created_at: row.created_at,
|
||||
updated_at: row.updated_at,
|
||||
|
|
@ -243,6 +355,10 @@ function serialize(row: ActivityRow, viewerId: string | null, bulk?: BulkLookups
|
|||
heart_count: hearts.count,
|
||||
viewer_hearted: hearts.hearted,
|
||||
viewer_bookmarked: bookmarked,
|
||||
done_count: done.count,
|
||||
viewer_done: done.done,
|
||||
viewer_archived: archived,
|
||||
viewer_hidden: hidden,
|
||||
sort_position: sortPos,
|
||||
created_at: row.created_at,
|
||||
updated_at: row.updated_at,
|
||||
|
|
@ -274,6 +390,10 @@ function serialize(row: ActivityRow, viewerId: string | null, bulk?: BulkLookups
|
|||
heart_count: hearts.count,
|
||||
viewer_hearted: hearts.hearted,
|
||||
viewer_bookmarked: bookmarked,
|
||||
done_count: done.count,
|
||||
viewer_done: done.done,
|
||||
viewer_archived: archived,
|
||||
viewer_hidden: hidden,
|
||||
sort_position: sortPos,
|
||||
created_at: row.created_at,
|
||||
updated_at: row.updated_at,
|
||||
|
|
@ -295,6 +415,10 @@ function serialize(row: ActivityRow, viewerId: string | null, bulk?: BulkLookups
|
|||
heart_count: hearts.count,
|
||||
viewer_hearted: hearts.hearted,
|
||||
viewer_bookmarked: bookmarked,
|
||||
done_count: done.count,
|
||||
viewer_done: done.done,
|
||||
viewer_archived: archived,
|
||||
viewer_hidden: hidden,
|
||||
sort_position: sortPos,
|
||||
created_at: row.created_at,
|
||||
updated_at: row.updated_at,
|
||||
|
|
@ -332,8 +456,20 @@ activitiesRoutes.get('/', (c) => {
|
|||
const viewerId = currentUserId(c);
|
||||
const db = getDb();
|
||||
|
||||
const params: string[] = [];
|
||||
let where = `visibility IN ('public','semi')`;
|
||||
// Inclusion flags for archived / hidden rows. Modes:
|
||||
// ?archived=0 (default) -- exclude archived
|
||||
// ?archived=1 -- include archived (still mixed with the rest)
|
||||
// ?archived=only -- ONLY archived
|
||||
// Same scheme for ?hidden=...
|
||||
// Anonymous viewers don't have either flag stored so these are no-ops for them.
|
||||
type Mode = 'exclude' | 'include' | 'only';
|
||||
const parseMode = (q?: string): Mode =>
|
||||
q === '1' ? 'include' : q === 'only' ? 'only' : 'exclude';
|
||||
const archivedMode = parseMode(c.req.query('archived'));
|
||||
const hiddenMode = parseMode(c.req.query('hidden'));
|
||||
|
||||
const params: (string | number)[] = [];
|
||||
let where = `(visibility IN ('public','semi')`;
|
||||
if (viewerId) {
|
||||
// Own private:
|
||||
where += ` OR (visibility = 'private' AND owner_id = ?)`;
|
||||
|
|
@ -358,6 +494,28 @@ activitiesRoutes.get('/', (c) => {
|
|||
`;
|
||||
params.push(viewerId, viewerId, viewerId);
|
||||
}
|
||||
where += `)`;
|
||||
|
||||
// Per-viewer archive / hide filters. Only meaningful for an authenticated
|
||||
// viewer (anonymous viewers have no rows in either table).
|
||||
if (viewerId) {
|
||||
const archivedExists = `EXISTS (SELECT 1 FROM user_archived_activities WHERE activity_id = activities.id AND user_id = ?)`;
|
||||
const hiddenExists = `EXISTS (SELECT 1 FROM user_hidden_activities WHERE activity_id = activities.id AND user_id = ?)`;
|
||||
if (archivedMode === 'exclude') {
|
||||
where += ` AND NOT ${archivedExists}`;
|
||||
params.push(viewerId);
|
||||
} else if (archivedMode === 'only') {
|
||||
where += ` AND ${archivedExists}`;
|
||||
params.push(viewerId);
|
||||
}
|
||||
if (hiddenMode === 'exclude') {
|
||||
where += ` AND NOT ${hiddenExists}`;
|
||||
params.push(viewerId);
|
||||
} else if (hiddenMode === 'only') {
|
||||
where += ` AND ${hiddenExists}`;
|
||||
params.push(viewerId);
|
||||
}
|
||||
}
|
||||
|
||||
// Effective ordering: if the viewer has a per-row sort position, use it;
|
||||
// otherwise fall back to -created_at so new activities (no sort row yet)
|
||||
|
|
@ -398,13 +556,32 @@ activitiesRoutes.patch('/:id/sort', requireAuth, async (c) => {
|
|||
return c.json({ error: 'missing:position' }, 400);
|
||||
}
|
||||
const db = getDb();
|
||||
// Confirm the activity exists AND the viewer can see it. Anything else is
|
||||
// a 404 — we don't want callers persisting positions for activities they
|
||||
// can't see, even though it wouldn't surface anywhere visible.
|
||||
const visible = db
|
||||
.prepare('SELECT 1 FROM activities WHERE id = ?')
|
||||
.get(id);
|
||||
if (!visible) return c.json({ error: 'not_found' }, 404);
|
||||
// Apply the same visibility filter as GET /:id and the list endpoint so
|
||||
// sort doesn't double as an existence oracle for private / friends-only
|
||||
// activity ids. Hidden rows return 404 (not 403). Earlier this endpoint
|
||||
// only did a bare `SELECT 1 FROM activities WHERE id = ?` — surfaced by
|
||||
// /audit security as a HIGH severity finding.
|
||||
const row = db
|
||||
.prepare('SELECT visibility, owner_id FROM activities WHERE id = ?')
|
||||
.get(id) as { visibility: Visibility; owner_id: string } | null;
|
||||
if (!row) return c.json({ error: 'not_found' }, 404);
|
||||
if (row.visibility === 'private' && row.owner_id !== userId) {
|
||||
return c.json({ error: 'not_found' }, 404);
|
||||
}
|
||||
if (row.visibility === 'friends' && row.owner_id !== userId) {
|
||||
const isFriend = !!db
|
||||
.prepare('SELECT 1 FROM friends WHERE owner_id = ? AND friend_id = ?')
|
||||
.get(row.owner_id, userId);
|
||||
if (!isFriend) return c.json({ error: 'not_found' }, 404);
|
||||
const blocked = !!db
|
||||
.prepare(`
|
||||
SELECT 1 FROM user_blocks
|
||||
WHERE (blocker_id = ? AND blocked_id = ?)
|
||||
OR (blocker_id = ? AND blocked_id = ?)
|
||||
`)
|
||||
.get(row.owner_id, userId, userId, row.owner_id);
|
||||
if (blocked) return c.json({ error: 'not_found' }, 404);
|
||||
}
|
||||
|
||||
db.prepare(`
|
||||
INSERT INTO user_activity_sort (user_id, activity_id, position) VALUES (?, ?, ?)
|
||||
|
|
@ -416,7 +593,7 @@ activitiesRoutes.patch('/:id/sort', requireAuth, async (c) => {
|
|||
// --- GET /api/activities/:id ------------------------------------------------
|
||||
activitiesRoutes.get('/:id', (c) => {
|
||||
const viewerId = currentUserId(c);
|
||||
const row = getDb().prepare('SELECT * FROM activities WHERE id = ?').get(c.req.param('id')) as ActivityRow | null;
|
||||
const row = fetchRowForViewer(c.req.param('id'), viewerId);
|
||||
if (!row) return c.json({ error: 'not_found' }, 404);
|
||||
|
||||
// Apply the same visibility rules as the list endpoint. We return 404
|
||||
|
|
@ -483,7 +660,10 @@ activitiesRoutes.post('/', requireAuth, async (c) => {
|
|||
setActivityTags(id, body.tags ?? []);
|
||||
}
|
||||
|
||||
const row = db.prepare('SELECT * FROM activities WHERE id = ?').get(id) as ActivityRow;
|
||||
// No custom sort_position can exist for a row this user just created, so
|
||||
// the LEFT JOIN is a strict no-op here — but using the helper keeps the
|
||||
// single return path uniform.
|
||||
const row = fetchRowForViewer(id, userId) as ActivityRow;
|
||||
return c.json(serialize(row, userId), 201);
|
||||
});
|
||||
|
||||
|
|
@ -551,7 +731,7 @@ activitiesRoutes.patch('/:id', requireAuth, async (c) => {
|
|||
setActivityTags(id, body.tags ?? []);
|
||||
}
|
||||
|
||||
const row = db.prepare('SELECT * FROM activities WHERE id = ?').get(id) as ActivityRow;
|
||||
const row = fetchRowForViewer(id, userId) as ActivityRow;
|
||||
return c.json(serialize(row, userId));
|
||||
});
|
||||
|
||||
|
|
@ -593,7 +773,7 @@ function toggleMark(c: AppContext, kind: Mark, op: 'add' | 'remove') {
|
|||
db.prepare(`DELETE FROM ${table} WHERE user_id = ? AND activity_id = ?`).run(userId, id);
|
||||
}
|
||||
|
||||
const refreshed = db.prepare('SELECT * FROM activities WHERE id = ?').get(id) as ActivityRow;
|
||||
const refreshed = fetchRowForViewer(id, userId) as ActivityRow;
|
||||
return c.json(serialize(refreshed, userId));
|
||||
}
|
||||
|
||||
|
|
@ -602,6 +782,127 @@ activitiesRoutes.delete('/:id/heart', requireAuth, (c) => toggleMark(c, 'heart'
|
|||
activitiesRoutes.post('/:id/bookmark', requireAuth, (c) => toggleMark(c, 'bookmark', 'add'));
|
||||
activitiesRoutes.delete('/:id/bookmark', requireAuth, (c) => toggleMark(c, 'bookmark', 'remove'));
|
||||
|
||||
/**
|
||||
* "Gjort" / done toggle. Differs from heart/bookmark:
|
||||
* - Private rows are allowed (owner-only, acts as a personal checkbox).
|
||||
* - Friends-only rows require the viewer to be the owner OR a mutual
|
||||
* friend with no block in either direction.
|
||||
* Same visibility rules as GET /api/activities/:id — we treat
|
||||
* "can't see it → 404" the same way.
|
||||
*/
|
||||
function toggleDone(c: AppContext, op: 'add' | 'remove') {
|
||||
const userId = c.get('userId');
|
||||
const id = c.req.param('id');
|
||||
if (!id) return c.json({ error: 'not_found' }, 404);
|
||||
const db = getDb();
|
||||
|
||||
const row = db
|
||||
.prepare('SELECT visibility, owner_id FROM activities WHERE id = ?')
|
||||
.get(id) as { visibility: Visibility; owner_id: string } | null;
|
||||
if (!row) return c.json({ error: 'not_found' }, 404);
|
||||
|
||||
// Apply the same visibility filter as the list and single-fetch endpoints.
|
||||
// Hidden rows return 404 (not 403) so the endpoint doesn't double as an
|
||||
// existence oracle.
|
||||
if (row.visibility === 'private' && row.owner_id !== userId) {
|
||||
return c.json({ error: 'not_found' }, 404);
|
||||
}
|
||||
if (row.visibility === 'friends' && row.owner_id !== userId) {
|
||||
const isFriend = !!db
|
||||
.prepare('SELECT 1 FROM friends WHERE owner_id = ? AND friend_id = ?')
|
||||
.get(row.owner_id, userId);
|
||||
if (!isFriend) return c.json({ error: 'not_found' }, 404);
|
||||
const blocked = !!db
|
||||
.prepare(`
|
||||
SELECT 1 FROM user_blocks
|
||||
WHERE (blocker_id = ? AND blocked_id = ?)
|
||||
OR (blocker_id = ? AND blocked_id = ?)
|
||||
`)
|
||||
.get(row.owner_id, userId, userId, row.owner_id);
|
||||
if (blocked) return c.json({ error: 'not_found' }, 404);
|
||||
}
|
||||
|
||||
if (op === 'add') {
|
||||
db.prepare(
|
||||
'INSERT OR IGNORE INTO activity_done (activity_id, user_id, created_at) VALUES (?, ?, ?)',
|
||||
).run(id, userId, Date.now());
|
||||
} else {
|
||||
db.prepare('DELETE FROM activity_done WHERE activity_id = ? AND user_id = ?').run(id, userId);
|
||||
}
|
||||
|
||||
const refreshed = fetchRowForViewer(id, userId) as ActivityRow;
|
||||
return c.json(serialize(refreshed, userId));
|
||||
}
|
||||
|
||||
activitiesRoutes.post('/:id/done', requireAuth, (c) => toggleDone(c, 'add'));
|
||||
activitiesRoutes.delete('/:id/done', requireAuth, (c) => toggleDone(c, 'remove'));
|
||||
|
||||
/**
|
||||
* Archive (anyone) / hide (non-owner only). Same skeleton as toggleDone:
|
||||
* - Visibility-aware existence check (404 for rows the viewer can't see).
|
||||
* - For hide, the owner gets 400 cannot_hide_own — they should delete or
|
||||
* archive instead.
|
||||
* - INSERT OR IGNORE / DELETE for idempotency.
|
||||
*/
|
||||
type Filing = 'archive' | 'hide';
|
||||
const FILING_TABLES: Record<Filing, string> = {
|
||||
archive: 'user_archived_activities',
|
||||
hide: 'user_hidden_activities',
|
||||
};
|
||||
|
||||
function toggleFiling(c: AppContext, kind: Filing, op: 'add' | 'remove') {
|
||||
const userId = c.get('userId');
|
||||
const id = c.req.param('id');
|
||||
if (!id) return c.json({ error: 'not_found' }, 404);
|
||||
const db = getDb();
|
||||
|
||||
const row = db
|
||||
.prepare('SELECT visibility, owner_id FROM activities WHERE id = ?')
|
||||
.get(id) as { visibility: Visibility; owner_id: string } | null;
|
||||
if (!row) return c.json({ error: 'not_found' }, 404);
|
||||
|
||||
// Same visibility check as toggleDone — hidden rows return 404, not 403.
|
||||
if (row.visibility === 'private' && row.owner_id !== userId) {
|
||||
return c.json({ error: 'not_found' }, 404);
|
||||
}
|
||||
if (row.visibility === 'friends' && row.owner_id !== userId) {
|
||||
const isFriend = !!db
|
||||
.prepare('SELECT 1 FROM friends WHERE owner_id = ? AND friend_id = ?')
|
||||
.get(row.owner_id, userId);
|
||||
if (!isFriend) return c.json({ error: 'not_found' }, 404);
|
||||
const blocked = !!db
|
||||
.prepare(`
|
||||
SELECT 1 FROM user_blocks
|
||||
WHERE (blocker_id = ? AND blocked_id = ?)
|
||||
OR (blocker_id = ? AND blocked_id = ?)
|
||||
`)
|
||||
.get(row.owner_id, userId, userId, row.owner_id);
|
||||
if (blocked) return c.json({ error: 'not_found' }, 404);
|
||||
}
|
||||
|
||||
// Hiding your own row makes no sense — you can delete or archive instead.
|
||||
if (kind === 'hide' && row.owner_id === userId) {
|
||||
return c.json({ error: 'cannot_hide_own' }, 400);
|
||||
}
|
||||
|
||||
const table = FILING_TABLES[kind];
|
||||
if (op === 'add') {
|
||||
db.prepare(
|
||||
`INSERT OR IGNORE INTO ${table} (user_id, activity_id, created_at) VALUES (?, ?, ?)`,
|
||||
).run(userId, id, Date.now());
|
||||
} else {
|
||||
db.prepare(`DELETE FROM ${table} WHERE user_id = ? AND activity_id = ?`).run(userId, id);
|
||||
}
|
||||
|
||||
const refreshed = fetchRowForViewer(id, userId) as ActivityRow;
|
||||
return c.json(serialize(refreshed, userId));
|
||||
}
|
||||
|
||||
activitiesRoutes.post('/:id/archive', requireAuth, (c) => toggleFiling(c, 'archive', 'add'));
|
||||
activitiesRoutes.delete('/:id/archive', requireAuth, (c) => toggleFiling(c, 'archive', 'remove'));
|
||||
activitiesRoutes.post('/:id/hide', requireAuth, (c) => toggleFiling(c, 'hide', 'add'));
|
||||
activitiesRoutes.delete('/:id/hide', requireAuth, (c) => toggleFiling(c, 'hide', 'remove'));
|
||||
|
||||
// --- DELETE /api/activities/:id ---------------------------------------------
|
||||
// Authz:
|
||||
// - private: owner only. Other users can't even see private rows, so
|
||||
|
|
|
|||
30
server/db.ts
30
server/db.ts
|
|
@ -119,6 +119,36 @@ const SCHEMA_STATEMENTS: readonly string[] = [
|
|||
PRIMARY KEY (activity_id, user_id)
|
||||
)`,
|
||||
`CREATE INDEX IF NOT EXISTS activity_hearts_user_idx ON activity_hearts(user_id)`,
|
||||
// "Gjort": per-user completion mark. Same shape as hearts but a different
|
||||
// meaning — hearts express approval, gjort expresses "I actually did
|
||||
// this." Unlike hearts/bookmarks, gjort applies to ANY visibility the
|
||||
// viewer can see, including their own private activities (where it
|
||||
// doubles as a todo-list checkbox).
|
||||
`CREATE TABLE IF NOT EXISTS activity_done (
|
||||
activity_id TEXT NOT NULL REFERENCES activities(id) ON DELETE CASCADE,
|
||||
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
created_at INTEGER NOT NULL,
|
||||
PRIMARY KEY (activity_id, user_id)
|
||||
)`,
|
||||
`CREATE INDEX IF NOT EXISTS activity_done_user_idx ON activity_done(user_id)`,
|
||||
// Per-viewer "archived" flag. Any viewer (incl. the owner) can archive an
|
||||
// activity to remove it from their default list while keeping the row
|
||||
// intact for history. Archived rows are still permalinked.
|
||||
`CREATE TABLE IF NOT EXISTS user_archived_activities (
|
||||
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
activity_id TEXT NOT NULL REFERENCES activities(id) ON DELETE CASCADE,
|
||||
created_at INTEGER NOT NULL,
|
||||
PRIMARY KEY (user_id, activity_id)
|
||||
)`,
|
||||
// Per-viewer "hidden" flag. Only non-owners can hide a row — the owner
|
||||
// already has delete. Semantics: "this doesn't appeal to me, get it out
|
||||
// of my feed." Default-filtered like archived.
|
||||
`CREATE TABLE IF NOT EXISTS user_hidden_activities (
|
||||
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
activity_id TEXT NOT NULL REFERENCES activities(id) ON DELETE CASCADE,
|
||||
created_at INTEGER NOT NULL,
|
||||
PRIMARY KEY (user_id, activity_id)
|
||||
)`,
|
||||
// Bookmarks: logged-in users can save public/semi activities to their own
|
||||
// dashboard. Same shape as hearts: composite PK on (user, activity), one
|
||||
// row per bookmark. CASCADE on the activity so deletes clean up.
|
||||
|
|
|
|||
|
|
@ -50,7 +50,12 @@ function toEntry(row: InviteRow): InviteEntry {
|
|||
}
|
||||
}
|
||||
return {
|
||||
token: row.token,
|
||||
// Once an invite is claimed the token has no functional role — claim
|
||||
// is one-way, you can't re-claim — and we don't need the inviter to
|
||||
// be able to re-share a now-dead link. Stripping it from the response
|
||||
// also means a compromised inviter account doesn't leak used-up
|
||||
// links. The audit trail (claimed_at, claimed_by_display) stays.
|
||||
token: row.claimed_at ? null : row.token,
|
||||
created_at: row.created_at,
|
||||
claimed_at: row.claimed_at,
|
||||
claimed_by_display: claimedByDisplay,
|
||||
|
|
|
|||
282
server/reset-password.ts
Normal file
282
server/reset-password.ts
Normal file
|
|
@ -0,0 +1,282 @@
|
|||
/**
|
||||
* CLI: emergency password reset.
|
||||
*
|
||||
* bun run reset-password <email>
|
||||
*
|
||||
* Run on the server box (requires direct DB access). Two modes:
|
||||
*
|
||||
* 1. RECOVERY mode (no data loss). If you still have the user's recovery
|
||||
* code, supply it interactively. This mirrors the regular recovery
|
||||
* flow: we unwrap the existing DEK with the recovery code, then
|
||||
* re-wrap it with the new password's KEK. Private activities stay
|
||||
* readable. wrapped_dek_rec/rec_salt/rec_auth_* are NOT touched, so
|
||||
* the recovery code remains valid afterwards (same as
|
||||
* /auth/recovery-complete).
|
||||
*
|
||||
* 2. NUKE mode (last resort). If both the password AND recovery code are
|
||||
* gone, the user's DEK is permanently unrecoverable. This mode
|
||||
* generates a fresh DEK + new recovery code, writes new auth/KEK
|
||||
* material, and DELETES the user's private activities (their
|
||||
* ciphertext can never be opened again). Public/semi/friends rows,
|
||||
* hearts, bookmarks, "gjort" marks all stay intact.
|
||||
*
|
||||
* Both modes invalidate all existing sessions for the user (a logged-in
|
||||
* tab might have been compromised — same hygiene as /auth/recovery-complete).
|
||||
*
|
||||
* For routine "I forgot my password" flow, use the in-app recovery page —
|
||||
* this CLI is for cases where the user can't reach the UI at all (admin
|
||||
* lockout on a fresh deployment, etc.).
|
||||
*/
|
||||
import { getDb } from './db';
|
||||
import {
|
||||
ready,
|
||||
generateDek,
|
||||
generateSalt,
|
||||
generateRecoveryCode,
|
||||
normalizeRecoveryCode,
|
||||
deriveKey,
|
||||
deriveAuthVerifier,
|
||||
wrapDek,
|
||||
unwrapDek,
|
||||
zero,
|
||||
} from '../shared/crypto';
|
||||
|
||||
function usage(): never {
|
||||
console.error('Usage: bun run reset-password <email>');
|
||||
console.error('');
|
||||
console.error('Interactive flow. Asks whether you have a recovery code:');
|
||||
console.error(' - Yes → preserves all data; just rewires the password.');
|
||||
console.error(' - No → wipes the user\'s private activities (their DEK');
|
||||
console.error(' is unrecoverable without the recovery code).');
|
||||
process.exit(2);
|
||||
}
|
||||
|
||||
// Hand-rolled line reader. Bun's node:readline (both promises and callback
|
||||
// forms) only delivers the first answer when stdin is piped/heredoc — the
|
||||
// subsequent question() never resolves. We read the stdin stream directly
|
||||
// and pull lines from a growing buffer. Works the same way for piped input
|
||||
// and an interactive TTY (user types, hits Enter, line is delivered).
|
||||
const decoder = new TextDecoder();
|
||||
const reader: ReadableStreamDefaultReader<Uint8Array> = Bun.stdin.stream().getReader();
|
||||
let lineBuf = '';
|
||||
let stdinEnded = false;
|
||||
|
||||
async function readLine(prompt: string): Promise<string> {
|
||||
process.stdout.write(prompt);
|
||||
while (true) {
|
||||
const nl = lineBuf.indexOf('\n');
|
||||
if (nl >= 0) {
|
||||
const line = lineBuf.slice(0, nl).replace(/\r$/, '');
|
||||
lineBuf = lineBuf.slice(nl + 1);
|
||||
return line.trim();
|
||||
}
|
||||
if (stdinEnded) return lineBuf.trim();
|
||||
const { value, done } = await reader.read();
|
||||
if (done) { stdinEnded = true; continue; }
|
||||
lineBuf += decoder.decode(value);
|
||||
}
|
||||
}
|
||||
|
||||
interface UserRow {
|
||||
id: string;
|
||||
email: string;
|
||||
is_admin: number;
|
||||
rec_salt: Buffer;
|
||||
wrapped_dek_rec: Buffer;
|
||||
dek_rec_nonce: Buffer;
|
||||
}
|
||||
|
||||
function loadUser(email: string): UserRow | null {
|
||||
return getDb()
|
||||
.prepare(`
|
||||
SELECT id, email, is_admin, rec_salt, wrapped_dek_rec, dek_rec_nonce
|
||||
FROM users WHERE email = ?
|
||||
`)
|
||||
.get(email) as UserRow | null;
|
||||
}
|
||||
|
||||
async function recoveryReset(user: UserRow, recoveryCode: string, newPassword: string): Promise<void> {
|
||||
const db = getDb();
|
||||
|
||||
// Unwrap the existing DEK using the recovery code's KEK.
|
||||
const kekRec = deriveKey(normalizeRecoveryCode(recoveryCode), new Uint8Array(user.rec_salt));
|
||||
let dek: Uint8Array;
|
||||
try {
|
||||
dek = unwrapDek(
|
||||
{ ciphertext: new Uint8Array(user.wrapped_dek_rec), nonce: new Uint8Array(user.dek_rec_nonce) },
|
||||
kekRec,
|
||||
);
|
||||
} catch {
|
||||
zero(kekRec);
|
||||
console.error('Recovery code did not unwrap the DEK. Wrong code? Aborting (no DB changes).');
|
||||
process.exit(1);
|
||||
}
|
||||
zero(kekRec);
|
||||
|
||||
// Build fresh password-side material; recovery side stays put so the
|
||||
// existing recovery code keeps working.
|
||||
const authSalt = generateSalt();
|
||||
const kekSalt = generateSalt();
|
||||
const kekPw = deriveKey(newPassword, kekSalt);
|
||||
const authVerifier = deriveAuthVerifier(newPassword, authSalt);
|
||||
const wrappedPw = wrapDek(dek, kekPw);
|
||||
zero(kekPw);
|
||||
zero(dek);
|
||||
|
||||
const authVerifierHash = await Bun.password.hash(authVerifier, { algorithm: 'argon2id' });
|
||||
|
||||
const txn = db.transaction(() => {
|
||||
db.prepare(`
|
||||
UPDATE users SET
|
||||
auth_salt = ?,
|
||||
auth_verifier_hash = ?,
|
||||
kek_salt = ?,
|
||||
wrapped_dek_pw = ?,
|
||||
dek_pw_nonce = ?
|
||||
WHERE id = ?
|
||||
`).run(
|
||||
Buffer.from(authSalt),
|
||||
authVerifierHash,
|
||||
Buffer.from(kekSalt),
|
||||
Buffer.from(wrappedPw.ciphertext),
|
||||
Buffer.from(wrappedPw.nonce),
|
||||
user.id,
|
||||
);
|
||||
return db.prepare('DELETE FROM sessions WHERE user_id = ?').run(user.id).changes;
|
||||
});
|
||||
|
||||
const sessDeleted = txn();
|
||||
console.log('');
|
||||
console.log('Password reset complete (recovery mode — no data loss).');
|
||||
console.log(` - Sessions invalidated: ${sessDeleted}`);
|
||||
console.log(' - Private activities: preserved');
|
||||
console.log(' - Recovery code: unchanged (still valid)');
|
||||
}
|
||||
|
||||
async function nukeReset(user: UserRow, newPassword: string): Promise<void> {
|
||||
const db = getDb();
|
||||
|
||||
// Brand-new DEK + recovery code. The old wraps are now garbage.
|
||||
const dek = generateDek();
|
||||
const recoveryCode = generateRecoveryCode();
|
||||
const normCode = normalizeRecoveryCode(recoveryCode);
|
||||
|
||||
const authSalt = generateSalt();
|
||||
const kekSalt = generateSalt();
|
||||
const recSalt = generateSalt();
|
||||
const recAuthSalt = generateSalt();
|
||||
|
||||
const kekPw = deriveKey(newPassword, kekSalt);
|
||||
const kekRec = deriveKey(normCode, recSalt);
|
||||
const authVerifier = deriveAuthVerifier(newPassword, authSalt);
|
||||
const recAuthVerifier = deriveAuthVerifier(normCode, recAuthSalt);
|
||||
const wrappedPw = wrapDek(dek, kekPw);
|
||||
const wrappedRec = wrapDek(dek, kekRec);
|
||||
|
||||
zero(kekPw);
|
||||
zero(kekRec);
|
||||
zero(dek);
|
||||
|
||||
const authVerifierHash = await Bun.password.hash(authVerifier, { algorithm: 'argon2id' });
|
||||
const recAuthVerifierHash = await Bun.password.hash(recAuthVerifier, { algorithm: 'argon2id' });
|
||||
|
||||
const txn = db.transaction(() => {
|
||||
db.prepare(`
|
||||
UPDATE users SET
|
||||
auth_salt = ?,
|
||||
auth_verifier_hash = ?,
|
||||
kek_salt = ?,
|
||||
wrapped_dek_pw = ?,
|
||||
dek_pw_nonce = ?,
|
||||
rec_salt = ?,
|
||||
wrapped_dek_rec = ?,
|
||||
dek_rec_nonce = ?,
|
||||
rec_auth_salt = ?,
|
||||
rec_auth_verifier_hash = ?
|
||||
WHERE id = ?
|
||||
`).run(
|
||||
Buffer.from(authSalt),
|
||||
authVerifierHash,
|
||||
Buffer.from(kekSalt),
|
||||
Buffer.from(wrappedPw.ciphertext),
|
||||
Buffer.from(wrappedPw.nonce),
|
||||
Buffer.from(recSalt),
|
||||
Buffer.from(wrappedRec.ciphertext),
|
||||
Buffer.from(wrappedRec.nonce),
|
||||
Buffer.from(recAuthSalt),
|
||||
recAuthVerifierHash,
|
||||
user.id,
|
||||
);
|
||||
const privDeleted = db
|
||||
.prepare(`DELETE FROM activities WHERE owner_id = ? AND visibility = 'private'`)
|
||||
.run(user.id).changes;
|
||||
const sessDeleted = db
|
||||
.prepare('DELETE FROM sessions WHERE user_id = ?')
|
||||
.run(user.id).changes;
|
||||
return { privDeleted, sessDeleted };
|
||||
});
|
||||
|
||||
const { privDeleted, sessDeleted } = txn();
|
||||
|
||||
console.log('');
|
||||
console.log('Password reset complete (nuke mode — private data lost).');
|
||||
console.log(` - Private activities deleted: ${privDeleted}`);
|
||||
console.log(` - Sessions invalidated: ${sessDeleted}`);
|
||||
console.log('');
|
||||
console.log('=== NEW RECOVERY CODE — write this down NOW. It will never be shown again. ===');
|
||||
console.log(recoveryCode);
|
||||
console.log('=== END RECOVERY CODE ===');
|
||||
}
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const email = process.argv[2]?.trim().toLowerCase();
|
||||
if (!email) usage();
|
||||
|
||||
await ready();
|
||||
const user = loadUser(email);
|
||||
if (!user) {
|
||||
console.error(`No user found with email "${email}".`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log(`User: ${user.email} (is_admin=${user.is_admin})`);
|
||||
console.log('');
|
||||
|
||||
const hasCode = (await readLine('Do you still have this user\'s recovery code? [y/N] ')).toLowerCase();
|
||||
const useRecovery = hasCode === 'y' || hasCode === 'yes';
|
||||
|
||||
if (useRecovery) {
|
||||
console.log('Recovery mode selected. Private activities will be preserved.');
|
||||
const code = await readLine('Recovery code: ');
|
||||
const password = await readLine('New password (visible while typing): ');
|
||||
if (password.length < 12) {
|
||||
console.error('Password must be at least 12 characters (matches the signup/recovery rule).');
|
||||
process.exit(1);
|
||||
}
|
||||
await recoveryReset(user, code, password);
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('No recovery code → nuke mode. This will:');
|
||||
console.log(' - Generate a brand-new recovery code (printed once below)');
|
||||
console.log(' - DELETE all private activities owned by this user');
|
||||
console.log(' (their ciphertext is unrecoverable without the old code)');
|
||||
console.log(' - Invalidate all existing sessions for this user');
|
||||
console.log('');
|
||||
const confirm = await readLine('Type DELETE to confirm: ');
|
||||
if (confirm !== 'DELETE') {
|
||||
console.error('Confirmation did not match. Aborting (no DB changes).');
|
||||
process.exit(1);
|
||||
}
|
||||
const password = await readLine('New password (visible while typing): ');
|
||||
if (password.length < 8) {
|
||||
console.error('Password must be at least 8 characters.');
|
||||
process.exit(1);
|
||||
}
|
||||
await nukeReset(user, password);
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error('Reset failed:', err);
|
||||
process.exit(1);
|
||||
});
|
||||
|
|
@ -41,32 +41,42 @@ usersRoutes.get('/:username/list', (c) => {
|
|||
return c.json({ error: 'not_found' }, 404);
|
||||
}
|
||||
|
||||
// The owner's archive intent extends to their public list — archived
|
||||
// rows disappear from "their published winter list" too. There's no
|
||||
// logged-in viewer here (this endpoint is for the anonymous public),
|
||||
// so we only need to filter rows the OWNER has archived.
|
||||
const rows = db
|
||||
.prepare(`
|
||||
SELECT id, owner_id, title, description, scheduled_at, loc_label,
|
||||
loc_lat, loc_lng, created_at, updated_at
|
||||
FROM activities
|
||||
WHERE owner_id = ? AND visibility = 'public'
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM user_archived_activities
|
||||
WHERE activity_id = activities.id AND user_id = activities.owner_id
|
||||
)
|
||||
ORDER BY created_at DESC
|
||||
`)
|
||||
.all(user.id) as ActivityRow[];
|
||||
|
||||
// Bulk lookups so we make 2 queries instead of 2N. The endpoint is public,
|
||||
// so viewer_hearted/bookmarked are always false — no per-viewer queries.
|
||||
// Bulk lookups so we make 3 queries instead of 3N. The endpoint is public,
|
||||
// so viewer_hearted/bookmarked/done are always false — no per-viewer queries.
|
||||
const ids = rows.map((r) => r.id);
|
||||
const tags = bulkTagsFor(ids);
|
||||
const heartCounts = ids.length === 0
|
||||
? new Map<string, number>()
|
||||
: new Map<string, number>(
|
||||
(db
|
||||
.prepare(`
|
||||
SELECT activity_id, COUNT(*) AS n FROM activity_hearts
|
||||
WHERE activity_id IN (${ids.map(() => '?').join(',')})
|
||||
GROUP BY activity_id
|
||||
`)
|
||||
.all(...ids) as { activity_id: string; n: number }[]
|
||||
).map((r) => [r.activity_id, r.n]),
|
||||
);
|
||||
const bulkCounts = (table: string): Map<string, number> => {
|
||||
if (ids.length === 0) return new Map();
|
||||
const ph = ids.map(() => '?').join(',');
|
||||
const rows = db
|
||||
.prepare(`
|
||||
SELECT activity_id, COUNT(*) AS n FROM ${table}
|
||||
WHERE activity_id IN (${ph})
|
||||
GROUP BY activity_id
|
||||
`)
|
||||
.all(...ids) as { activity_id: string; n: number }[];
|
||||
return new Map(rows.map((r) => [r.activity_id, r.n]));
|
||||
};
|
||||
const heartCounts = bulkCounts('activity_hearts');
|
||||
const doneCounts = bulkCounts('activity_done');
|
||||
|
||||
const activities: ActivityPublic[] = rows.map((r) => ({
|
||||
id: r.id,
|
||||
|
|
@ -87,9 +97,14 @@ usersRoutes.get('/:username/list', (c) => {
|
|||
scheduled_at: r.scheduled_at,
|
||||
heart_count: heartCounts.get(r.id) ?? 0,
|
||||
// The public-list endpoint is unauthenticated; we don't know who the
|
||||
// viewer is to fill viewer_hearted/bookmarked truthfully. Always false.
|
||||
// viewer is to fill viewer_hearted/bookmarked/done truthfully. Always false.
|
||||
viewer_hearted: false,
|
||||
viewer_bookmarked: false,
|
||||
done_count: doneCounts.get(r.id) ?? 0,
|
||||
viewer_done: false,
|
||||
// No logged-in viewer → can't have personal archive/hide state.
|
||||
viewer_archived: false,
|
||||
viewer_hidden: false,
|
||||
// No personal sort here — anonymous view always sorts by recency.
|
||||
sort_position: -r.created_at,
|
||||
created_at: r.created_at,
|
||||
|
|
|
|||
|
|
@ -45,8 +45,9 @@ export interface InviteEntry {
|
|||
/** The token itself. The shareable URL is built client-side as
|
||||
* `${window.location.origin}/invitasjon/${token}` — server-side URL
|
||||
* construction would point at the API host in split-process dev
|
||||
* environments. */
|
||||
token: string;
|
||||
* environments. NULL when the invite has been claimed; the token has
|
||||
* no functional role after that and we don't keep it in the response. */
|
||||
token: string | null;
|
||||
created_at: number;
|
||||
claimed_at: number | null;
|
||||
/** Display name (or username) of the user who claimed it, if any. */
|
||||
|
|
@ -219,6 +220,17 @@ export interface ActivityPublic {
|
|||
viewer_hearted: boolean;
|
||||
/** True when the authenticated viewer has bookmarked this activity. */
|
||||
viewer_bookmarked: boolean;
|
||||
/** Total "done" marks (people who have actually completed the activity). */
|
||||
done_count: number;
|
||||
/** True when the authenticated viewer has marked this activity done. */
|
||||
viewer_done: boolean;
|
||||
/** True when the authenticated viewer has archived this activity for
|
||||
* themselves. Default-filtered from the main and public lists; the
|
||||
* client opts in via ?archived=1 to see them. Owner-archiving works too. */
|
||||
viewer_archived: boolean;
|
||||
/** True when the authenticated viewer has hidden this activity. Only
|
||||
* non-owners can hide. Default-filtered from lists; opt-in via ?hidden=1. */
|
||||
viewer_hidden: boolean;
|
||||
/** Effective sort position for THIS viewer: a custom value if they've
|
||||
* dragged this row, otherwise -created_at so unsorted/new rows float
|
||||
* to the top. Used by the client to compute drop-target midpoints. */
|
||||
|
|
@ -244,6 +256,10 @@ export interface ActivitySemi {
|
|||
heart_count: number;
|
||||
viewer_hearted: boolean;
|
||||
viewer_bookmarked: boolean;
|
||||
done_count: number;
|
||||
viewer_done: boolean;
|
||||
viewer_archived: boolean;
|
||||
viewer_hidden: boolean;
|
||||
/** Effective sort position for THIS viewer: a custom value if they've
|
||||
* dragged this row, otherwise -created_at so unsorted/new rows float
|
||||
* to the top. Used by the client to compute drop-target midpoints. */
|
||||
|
|
@ -264,6 +280,16 @@ export interface ActivityPrivate {
|
|||
heart_count: number;
|
||||
viewer_hearted: boolean;
|
||||
viewer_bookmarked: boolean;
|
||||
// "Done" DOES apply to private rows: the owner can use it as a personal
|
||||
// todo checkbox. done_count is therefore always 0 or 1 (just the owner)
|
||||
// for private rows. viewer_done reflects the owner's own state.
|
||||
done_count: number;
|
||||
viewer_done: boolean;
|
||||
// Private rows are owner-only, so viewer_hidden is always false (the
|
||||
// hide endpoint refuses on your own activities). viewer_archived is the
|
||||
// owner archiving their own private todo.
|
||||
viewer_archived: boolean;
|
||||
viewer_hidden: boolean;
|
||||
/** Effective sort position for THIS viewer: a custom value if they've
|
||||
* dragged this row, otherwise -created_at so unsorted/new rows float
|
||||
* to the top. Used by the client to compute drop-target midpoints. */
|
||||
|
|
@ -294,6 +320,10 @@ export interface ActivityFriends {
|
|||
heart_count: number;
|
||||
viewer_hearted: boolean;
|
||||
viewer_bookmarked: boolean;
|
||||
done_count: number;
|
||||
viewer_done: boolean;
|
||||
viewer_archived: boolean;
|
||||
viewer_hidden: boolean;
|
||||
/** Effective sort position for THIS viewer: a custom value if they've
|
||||
* dragged this row, otherwise -created_at so unsorted/new rows float
|
||||
* to the top. Used by the client to compute drop-target midpoints. */
|
||||
|
|
|
|||
|
|
@ -333,6 +333,42 @@ describe('per-user sort order', () => {
|
|||
expect(finalIds[0]).toBe(fresh.id);
|
||||
});
|
||||
|
||||
ttest('single-row endpoints preserve the viewer\'s custom sort_position', async () => {
|
||||
// Regression: heart / bookmark / done toggles (and PATCH/GET-by-id) used
|
||||
// to do plain `SELECT * FROM activities` without the user_activity_sort
|
||||
// LEFT JOIN, so serialize() silently fell back to -created_at and
|
||||
// overwrote any custom position. Now they go through fetchRowForViewer.
|
||||
const user = await signupAndGetCookie(ctx, 'sort-toggle@test.invalid');
|
||||
const a1 = await createActivity(ctx, user.cookie, { visibility: 'public', title: 'one', tags: [] });
|
||||
await new Promise((r) => setTimeout(r, 5));
|
||||
const a2 = await createActivity(ctx, user.cookie, { visibility: 'public', title: 'two', tags: [] });
|
||||
|
||||
// Drag a1 below a2 (more positive sort_position → later in the ASC sort).
|
||||
const customPos = -a2.created_at + 100;
|
||||
await reqJson(ctx, 'PATCH', `/api/activities/${a1.id}/sort`, {
|
||||
cookie: user.cookie, body: { position: customPos },
|
||||
});
|
||||
|
||||
// Heart toggle should return a1 with the SAME custom sort_position,
|
||||
// not the default -created_at.
|
||||
const hearted = await reqJson<Activity>(ctx, 'POST', `/api/activities/${a1.id}/heart`, {
|
||||
cookie: user.cookie,
|
||||
});
|
||||
expect(hearted.sort_position).toBe(customPos);
|
||||
|
||||
// Same for done.
|
||||
const doneRes = await reqJson<Activity>(ctx, 'POST', `/api/activities/${a1.id}/done`, {
|
||||
cookie: user.cookie,
|
||||
});
|
||||
expect(doneRes.sort_position).toBe(customPos);
|
||||
|
||||
// And for GET /:id.
|
||||
const single = await reqJson<Activity>(ctx, 'GET', `/api/activities/${a1.id}`, {
|
||||
cookie: user.cookie,
|
||||
});
|
||||
expect(single.sort_position).toBe(customPos);
|
||||
});
|
||||
|
||||
test('PATCH /sort requires auth', async () => {
|
||||
const res = await req(ctx, 'PATCH', '/api/activities/whatever/sort', { body: { position: 1 } });
|
||||
expect(res.status).toBe(401);
|
||||
|
|
@ -348,6 +384,41 @@ describe('per-user sort order', () => {
|
|||
expect(res.status).toBe(400);
|
||||
}
|
||||
});
|
||||
|
||||
ttest('PATCH /sort does not double as an existence oracle for hidden rows', async () => {
|
||||
// Regression: the endpoint previously did a bare `SELECT 1 FROM activities`
|
||||
// without visibility scoping, so a logged-in attacker could distinguish
|
||||
// "private id exists, owned by someone else" (200 ok) from "id doesn't
|
||||
// exist" (404). /audit security flagged this as HIGH.
|
||||
const [owner, attacker] = await Promise.all([
|
||||
signupAndGetCookie(ctx, 'sort-oracle-owner@test.invalid'),
|
||||
signupAndGetCookie(ctx, 'sort-oracle-att@test.invalid'),
|
||||
]);
|
||||
const priv = await createActivity(ctx, owner.cookie, {
|
||||
visibility: 'private',
|
||||
ciphertext: 'AAAA',
|
||||
nonce: 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA',
|
||||
} as never);
|
||||
|
||||
// Attacker tries to sort someone else's private row → must get 404
|
||||
// (same status as for a truly nonexistent id).
|
||||
const sortOther = await req(ctx, 'PATCH', `/api/activities/${priv.id}/sort`, {
|
||||
cookie: attacker.cookie, body: { position: 0 },
|
||||
});
|
||||
expect(sortOther.status).toBe(404);
|
||||
|
||||
// And for a truly nonexistent id.
|
||||
const sortMissing = await req(ctx, 'PATCH', '/api/activities/nope-nope-nope/sort', {
|
||||
cookie: attacker.cookie, body: { position: 0 },
|
||||
});
|
||||
expect(sortMissing.status).toBe(404);
|
||||
|
||||
// Owner can still sort their own private row.
|
||||
const sortOwn = await req(ctx, 'PATCH', `/api/activities/${priv.id}/sort`, {
|
||||
cookie: owner.cookie, body: { position: 0 },
|
||||
});
|
||||
expect(sortOwn.status).toBe(200);
|
||||
});
|
||||
});
|
||||
|
||||
describe('owner_display fallback chain (no email leak)', () => {
|
||||
|
|
|
|||
|
|
@ -124,6 +124,163 @@ describe('hearts', () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe('"gjort" (done) marks', () => {
|
||||
ttest('toggle on a public activity, idempotent both ways', async () => {
|
||||
const [owner, viewer] = await Promise.all([
|
||||
signupAndGetCookie(ctx, 'done-owner@test.invalid'),
|
||||
signupAndGetCookie(ctx, 'done-viewer@test.invalid'),
|
||||
]);
|
||||
const pub = await createActivity(ctx, owner.cookie, {
|
||||
visibility: 'public', title: 'Gjort-test', tags: [],
|
||||
});
|
||||
|
||||
const after = await reqJson<Activity>(ctx, 'POST', `/api/activities/${pub.id}/done`, {
|
||||
cookie: viewer.cookie,
|
||||
});
|
||||
expect(after.done_count).toBe(1);
|
||||
expect(after.viewer_done).toBe(true);
|
||||
|
||||
// Re-mark is idempotent (INSERT OR IGNORE).
|
||||
const again = await reqJson<Activity>(ctx, 'POST', `/api/activities/${pub.id}/done`, {
|
||||
cookie: viewer.cookie,
|
||||
});
|
||||
expect(again.done_count).toBe(1);
|
||||
|
||||
// Owner's own view shows the count too but their viewer_done is still false.
|
||||
const list = await listActivities(ctx, owner.cookie);
|
||||
const ownerView = list.find((a) => a.id === pub.id);
|
||||
expect(ownerView?.done_count).toBe(1);
|
||||
expect(ownerView?.viewer_done).toBe(false);
|
||||
|
||||
// Undo.
|
||||
const undone = await reqJson<Activity>(ctx, 'DELETE', `/api/activities/${pub.id}/done`, {
|
||||
cookie: viewer.cookie,
|
||||
});
|
||||
expect(undone.done_count).toBe(0);
|
||||
expect(undone.viewer_done).toBe(false);
|
||||
});
|
||||
|
||||
ttest('owner can mark their own private activity done; non-owners get 404', async () => {
|
||||
const [owner, other] = await Promise.all([
|
||||
signupAndGetCookie(ctx, 'priv-done-owner@test.invalid'),
|
||||
signupAndGetCookie(ctx, 'priv-done-other@test.invalid'),
|
||||
]);
|
||||
// Private rows need a ciphertext/nonce payload — we don't actually
|
||||
// decrypt in this test, just need the row to exist.
|
||||
const priv = await createActivity(ctx, owner.cookie, {
|
||||
visibility: 'private',
|
||||
ciphertext: 'AAAA',
|
||||
nonce: 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA', // 24 bytes base64
|
||||
} as never);
|
||||
|
||||
// Owner can mark it done.
|
||||
const after = await reqJson<Activity>(ctx, 'POST', `/api/activities/${priv.id}/done`, {
|
||||
cookie: owner.cookie,
|
||||
});
|
||||
expect(after.done_count).toBe(1);
|
||||
expect(after.viewer_done).toBe(true);
|
||||
|
||||
// Non-owner gets 404 (same as GET /:id behaviour — doesn't leak existence).
|
||||
const denied = await req(ctx, 'POST', `/api/activities/${priv.id}/done`, {
|
||||
cookie: other.cookie,
|
||||
});
|
||||
expect(denied.status).toBe(404);
|
||||
});
|
||||
});
|
||||
|
||||
describe('archive + hide (per-viewer)', () => {
|
||||
ttest('viewer can archive any visibility they see; default list excludes archived', async () => {
|
||||
const [owner, viewer] = await Promise.all([
|
||||
signupAndGetCookie(ctx, 'arch-owner@test.invalid'),
|
||||
signupAndGetCookie(ctx, 'arch-viewer@test.invalid'),
|
||||
]);
|
||||
const pub = await createActivity(ctx, owner.cookie, {
|
||||
visibility: 'public', title: 'arch-pub', tags: [],
|
||||
});
|
||||
|
||||
// Default list includes it.
|
||||
let list = await listActivities(ctx, viewer.cookie);
|
||||
expect(list.find((a) => a.id === pub.id)).toBeTruthy();
|
||||
|
||||
// Archive it.
|
||||
const archived = await reqJson<Activity>(ctx, 'POST', `/api/activities/${pub.id}/archive`, {
|
||||
cookie: viewer.cookie,
|
||||
});
|
||||
expect(archived.viewer_archived).toBe(true);
|
||||
|
||||
// Disappears from the default list.
|
||||
list = await listActivities(ctx, viewer.cookie);
|
||||
expect(list.find((a) => a.id === pub.id)).toBeUndefined();
|
||||
|
||||
// ?archived=1 brings it back.
|
||||
const withArch = await reqJson<Activity[]>(ctx, 'GET', '/api/activities?archived=1', {
|
||||
cookie: viewer.cookie,
|
||||
});
|
||||
expect(withArch.find((a) => a.id === pub.id)).toBeTruthy();
|
||||
|
||||
// Other viewer is unaffected — archive is per-viewer.
|
||||
const otherList = await listActivities(ctx, owner.cookie);
|
||||
expect(otherList.find((a) => a.id === pub.id)?.viewer_archived).toBe(false);
|
||||
});
|
||||
|
||||
ttest('owner can archive their own row (own public list filters it too)', async () => {
|
||||
const owner = await signupAndGetCookie(ctx, 'arch-self@test.invalid');
|
||||
// Owner needs a username + public_list_enabled to test the public-list filter.
|
||||
await reqJson(ctx, 'PATCH', '/api/auth/profile', {
|
||||
cookie: owner.cookie,
|
||||
body: { username: 'archself', public_list_enabled: true, display_name: 'Arch Self' },
|
||||
});
|
||||
const pub = await createActivity(ctx, owner.cookie, {
|
||||
visibility: 'public', title: 'arch-own-pub', tags: [],
|
||||
});
|
||||
|
||||
// Visible on the owner's public list initially.
|
||||
const before = await reqJson<{ activities: Activity[] }>(ctx, 'GET', '/api/users/archself/list');
|
||||
expect(before.activities.find((a) => a.id === pub.id)).toBeTruthy();
|
||||
|
||||
// Owner archives → drops off their public list.
|
||||
await req(ctx, 'POST', `/api/activities/${pub.id}/archive`, { cookie: owner.cookie });
|
||||
const after = await reqJson<{ activities: Activity[] }>(ctx, 'GET', '/api/users/archself/list');
|
||||
expect(after.activities.find((a) => a.id === pub.id)).toBeUndefined();
|
||||
});
|
||||
|
||||
ttest('hide refuses on owner\'s own row; works on others\'; per-viewer', async () => {
|
||||
const [owner, viewer] = await Promise.all([
|
||||
signupAndGetCookie(ctx, 'hide-owner@test.invalid'),
|
||||
signupAndGetCookie(ctx, 'hide-viewer@test.invalid'),
|
||||
]);
|
||||
const pub = await createActivity(ctx, owner.cookie, {
|
||||
visibility: 'public', title: 'hide-test', tags: [],
|
||||
});
|
||||
|
||||
// Owner trying to hide their own row → 400.
|
||||
const ownerHide = await req(ctx, 'POST', `/api/activities/${pub.id}/hide`, {
|
||||
cookie: owner.cookie,
|
||||
});
|
||||
expect(ownerHide.status).toBe(400);
|
||||
|
||||
// Non-owner viewer can hide it.
|
||||
const hidden = await reqJson<Activity>(ctx, 'POST', `/api/activities/${pub.id}/hide`, {
|
||||
cookie: viewer.cookie,
|
||||
});
|
||||
expect(hidden.viewer_hidden).toBe(true);
|
||||
|
||||
// Disappears from the viewer's default list…
|
||||
const list = await listActivities(ctx, viewer.cookie);
|
||||
expect(list.find((a) => a.id === pub.id)).toBeUndefined();
|
||||
|
||||
// …but ?hidden=only surfaces just the hidden ones.
|
||||
const only = await reqJson<Activity[]>(ctx, 'GET', '/api/activities?hidden=only', {
|
||||
cookie: viewer.cookie,
|
||||
});
|
||||
expect(only.find((a) => a.id === pub.id)).toBeTruthy();
|
||||
|
||||
// Owner's view is unaffected.
|
||||
const ownerList = await listActivities(ctx, owner.cookie);
|
||||
expect(ownerList.find((a) => a.id === pub.id)).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
describe('bookmarks', () => {
|
||||
ttest('toggle, idempotent, refused on private', async () => {
|
||||
const [owner, viewer, otherViewer] = await Promise.all([
|
||||
|
|
|
|||
|
|
@ -125,7 +125,7 @@ describe('invites', () => {
|
|||
expect(inv.claimed_at).toBeNull();
|
||||
|
||||
// A new user signs up with that invite token.
|
||||
const claimer = await signupAndGetCookie(ctx, 'inv-claimer@test.invalid', undefined, inv.token);
|
||||
const claimer = await signupAndGetCookie(ctx, 'inv-claimer@test.invalid', undefined, inv.token!);
|
||||
expect(claimer.me.id).toBeTruthy();
|
||||
|
||||
// The invited_by column on the new user row points at the inviter.
|
||||
|
|
@ -133,13 +133,16 @@ describe('invites', () => {
|
|||
.get(claimer.me.id) as { invited_by: string | null };
|
||||
expect(row.invited_by).toBe(inviter.me.id);
|
||||
|
||||
// The invite is now claimed in the inviter's list.
|
||||
// The invite is now claimed in the inviter's list. The server stops
|
||||
// returning the literal token once the invite is claimed, so match by
|
||||
// created_at instead.
|
||||
const myInvites = await reqJson<InviteEntry[]>(ctx, 'GET', '/api/invites', {
|
||||
cookie: inviter.cookie,
|
||||
});
|
||||
const claimed = myInvites.find((i) => i.token === inv.token)!;
|
||||
const claimed = myInvites.find((i) => i.created_at === inv.created_at)!;
|
||||
expect(claimed.claimed_at).not.toBeNull();
|
||||
expect(claimed.claimed_by_display).toBe('inv-claimer');
|
||||
expect(claimed.token).toBeNull();
|
||||
});
|
||||
|
||||
ttest('invite is single-use: re-claim is rejected', async () => {
|
||||
|
|
@ -149,12 +152,12 @@ describe('invites', () => {
|
|||
});
|
||||
|
||||
// First claim succeeds.
|
||||
await signupAndGetCookie(ctx, 'inv-single-1@test.invalid', undefined, inv.token);
|
||||
await signupAndGetCookie(ctx, 'inv-single-1@test.invalid', undefined, inv.token!);
|
||||
|
||||
// Second signup with the same token: with self-registry OPEN (the
|
||||
// default), the bad token is silently dropped and signup proceeds
|
||||
// WITHOUT attribution. Confirm: signup ok, invited_by is null.
|
||||
const second = await signupAndGetCookie(ctx, 'inv-single-2@test.invalid', undefined, inv.token);
|
||||
const second = await signupAndGetCookie(ctx, 'inv-single-2@test.invalid', undefined, inv.token!);
|
||||
const row = getDb().prepare('SELECT invited_by FROM users WHERE id = ?')
|
||||
.get(second.me.id) as { invited_by: string | null };
|
||||
expect(row.invited_by).toBeNull();
|
||||
|
|
@ -176,7 +179,7 @@ describe('invites', () => {
|
|||
const inv = await reqJson<InviteEntry>(ctx, 'POST', '/api/invites', {
|
||||
cookie: inviter.cookie, expect: 201,
|
||||
});
|
||||
await signupAndGetCookie(ctx, 'inv-already-c@test.invalid', undefined, inv.token);
|
||||
await signupAndGetCookie(ctx, 'inv-already-c@test.invalid', undefined, inv.token!);
|
||||
const cancelRes = await req(ctx, 'DELETE', `/api/invites/${inv.token}`, {
|
||||
cookie: inviter.cookie,
|
||||
});
|
||||
|
|
@ -232,7 +235,7 @@ describe('settings + signup gating', () => {
|
|||
const inv = await reqJson<InviteEntry>(ctx, 'POST', '/api/invites', {
|
||||
cookie: admin.cookie, expect: 201,
|
||||
});
|
||||
const claimer = await signupAndGetCookie(ctx, 'gate-claimer@test.invalid', undefined, inv.token);
|
||||
const claimer = await signupAndGetCookie(ctx, 'gate-claimer@test.invalid', undefined, inv.token!);
|
||||
expect(claimer.me.id).toBeTruthy();
|
||||
} finally {
|
||||
// Re-open self-registry so subsequent tests can still sign up.
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue