One-shot deploy script that wraps the podman build + run dance:
./deploy.sh # build → replace container → prune (default)
./deploy.sh build # build + tag only
./deploy.sh run # restart from existing :latest, no rebuild
./deploy.sh prune # drop old timestamped images
./deploy.sh config # print resolved configuration
Each build tags the image with both :latest AND a UTC timestamp
(YYYYMMDD-HHMMSS), so rollback is a tag retag away. Prune keeps the
N most recent timestamped images (KEEP_IMAGES, default 3); :latest
is never touched. The matching regex is strict — only the exact
YYYYMMDD-HHMMSS pattern — so a stray "dev" or hand-typed tag can't
get caught.
Settings come from an optional deploy.env (gitignored; example in
deploy.env.example). Parser is allowlist-based: only recognised
keys apply, malformed lines and command-substitution forms are
ignored. Available overrides: IMAGE_NAME, CONTAINER_NAME,
VOLUME_NAME, HOST_PORT, BIND_ADDR, KEEP_IMAGES, PUBLIC_BASE_URL,
EXTRA_PODMAN_RUN_ARGS. HOST_PORT and KEEP_IMAGES are integer-
validated before use.
Uses podman run --replace per the global ops guidance (atomic,
idempotent, no stop→rm→run race). BUILDAH_FORMAT=docker so the
HEALTHCHECK directive in the Containerfile survives. shellcheck
clean.
README's Deployment section rewritten to lead with the script;
manual podman snippet kept as fallback.
Before: goHome() and goPublicHome() did pushUrl(path) + view=value.
When the user was already on /hjem (or /) both calls were no-ops —
clicking the wordmark or "Min liste" appeared to do nothing.
Add a monotonic reloadKey counter in App.svelte. Both nav handlers
bump it. Home.svelte takes reloadKey as a prop and runs load() in a
$effect when the value changes relative to what it last saw.
Skipping the first run is necessary because onMount() already kicks
off the initial load — the effect skips on init by comparing
reloadKey to lastSeenReloadKey, which both start equal. load()
doesn't touch reloadKey so the effect can't self-fire (the previous
$effect bug from the archive/hide toggles is documented inline).
Search query, edit-in-progress form, and other local state in Home
survive the refresh — only the activity fetch re-runs.
Activity descriptions now render through a small marked + DOMPurify
pipeline. Client-side only — the server keeps storing raw markdown
source, private descriptions stay inside the encrypted payload.
frontend/src/lib/markdown.ts exposes a single renderMarkdown(src)
helper. Allowlist: p / br / hr / strong / em / del / s / code / pre /
ul / ol / li / blockquote / a / h3–h6. URL scheme allowlist:
http(s) and mailto. Images are deliberately stripped — external
image URLs leak the viewer's IP to the linker's host. Raw HTML
pass-through is off. A DOMPurify afterSanitizeAttributes hook forces
target="_blank" rel="noopener noreferrer ugc" on every <a>, matching
the existing external-link pattern in PublicList. h1/h2 in the
source get downshifted to h3 via walkTokens so the description's
heading hierarchy doesn't collide with the SPA's own <h1>/<h2>.
Render sites: the two description spots in ActivityRow.svelte (one
for the decrypted private branch, one for non-private). New
.md class in styles.css gives the rendered block tight spacing,
discreet code/pre/blockquote treatment, accent-coloured links.
UX: a "Du kan bruke Markdown — **fet**, *kursiv*, [lenke](https://…),
lister med -" hint under the textarea in ActivityForm. No preview
pane to keep scope contained.
Tests: 14 cases in tests/markdown.test.ts covering the allowlist
(bold/italic/strike/lists/links/mailto), the sanitisation surface
(javascript: / data: / script / iframe / on* handlers / images),
and the h1/h2 → h3 downshift. happy-dom is registered locally in
beforeAll (and the markdown module dynamic-imported) rather than as
a project-wide preload — the latter overrides fetch/Request/Response
and breaks Bun-fetch-based API tests in other files.
Bundle impact: marked + dompurify add ~60KB to the SPA bundle.
Once an invite is claimed, the token has no functional role — claims
are one-way and the link is dead. Stop returning the literal token in
the GET /api/invites response for claimed entries (server/invites.ts
toEntry). The audit trail — claimed_at, claimed_by_display — stays.
Helps a little with data minimization: a compromised inviter account
can no longer see used-up invitation URLs.
Type: InviteEntry.token is now string | null. Callers that still need
to use the token (signup-via-invite tests, the cancel button, the
copy button) are guarded so they only run on entries where the token
is present (i.e. unclaimed). The each-key falls back to a synthetic
composite when token is null so Svelte's keyed-each stays stable.
UI: claimed entries collapse to a single muted line, no card frame,
no URL placeholder:
✓ Laget DD.MM.YYYY · godtatt av <bruker> DD.MM.YYYY
Unclaimed entries keep the existing card with copy / cancel buttons.
Heading on the invite section also renamed from "Invitasjonslenker"
to "Invitasjoner" — claimed entries don't have a link anymore so the
older label was misleading.
Tests updated to match by created_at instead of token for the
claimed-invite lookup, and to assert that token is null post-claim.
Two small UX fixes on the Profile page:
1. FriendsPanel: incoming-friends list previously had only a
"Blokker" button. Add a "Legg til som venn"-knapp alongside it
when the entry isn't already mutual. Shows "(gjensidig)" inline
for entries where I've already added them back, and hides the
add button when the incoming user doesn't have a username (rare
— addFriend goes by username).
2. Invite list: claimed entries now show
"✓ Mottatt og tatt i bruk av <bruker> · <dato>" instead of the
"Brukt" badge + separate "av X · Y" line. Clearer wording, same
data.
The sort endpoint validated existence with a bare
`SELECT 1 FROM activities WHERE id = ?`, ignoring visibility. A
logged-in attacker could PATCH /sort with any UUID and distinguish
"private id exists, owned by someone else" (200) from "id doesn't
exist" (404), letting them enumerate private activity ids.
Apply the same visibility filter as GET /:id, toggleDone, and
toggleFiling: private requires owner; friends requires mutual-friend
+ no block in either direction; hidden rows return 404, not 403.
Regression test added in tests/activities.test.ts.
Surfaced by /audit security (HIGH severity).
The "Vis arkivert" / "Vis skjult" toggles re-fetch from the server
(filtering is server-side). I had wired the re-fetch through a
$effect tracking both checkboxes — but the effect body also read
`loading` to skip re-entry, which meant the effect re-ran every
time load() flipped `loading` to true and back. Each cycle called
load(), which flipped `loading` again, ad infinitum: the list
stayed pinned on "Laster …" forever.
Replace with explicit onchange handlers on the two checkboxes that
both update state AND call load() once. Same UX, no reactive loop.
Two new per-viewer flags on activities, mirroring the heart/done shape:
- ARCHIVE: any viewer (incl. the owner) can archive a row. Out of
sight by default but the permalink still resolves. For owners,
archive also filters the row out of their /<bruker>/liste public
page — "I'm done sharing this."
- HIDE: only non-owners. "This doesn't appeal to me." Endpoint
returns 400 cannot_hide_own when the owner tries it (they should
delete or archive instead).
Both default-filtered from GET /api/activities. Opt-in via query
params with three modes each: exclude (default), include (1), only.
?archived=1&hidden=1 includes both; ?archived=only shows just the
archive. The list endpoint composes the SQL filters per-viewer
(anonymous viewers have no rows in either table → no-ops).
Schema: two new tables user_archived_activities and
user_hidden_activities, both composite-PK on (user_id, activity_id),
cascade on both refs. Same shape as the existing engagement tables.
Types: viewer_archived + viewer_hidden on every Activity variant.
viewer_hidden on private is always false (the endpoint refuses);
docstring on the type explains why.
Bulk lookups extended so the list endpoint stays at constant queries.
Single-row paths get per-row viewerArchived/viewerHidden helpers.
fetchRowForViewer keeps preserving the viewer's custom sort_position
on toggles.
UI: two new buttons in ActivityRow's action row — "📦 Arkiver" /
"📦 Arkivert" (everyone) and "🙈 Gjem" / "🙈 Skjult" (non-owners
only). Two new checkboxes near the search field on /hjem: "📦 Vis
arkivert" and "🙈 Vis skjult". Toggling either re-fetches from the
server because the filtering is server-side. When an archive/hide
flips the row out of the current view, Home.svelte triggers a
refetch so the row disappears in real time.
Public-list endpoint (/api/users/:bruker/list) also filters out the
owner's own archived rows — consistent with "archive means filed
away from active view."
Tests: 3 new in engagement.test.ts — viewer archives + per-viewer
isolation, owner's archive filters their public list, hide refuses
on own row + ?hidden=only path. Suite goes 102 → 105.
New CLI: bun run reset-password <email>
Two modes selected interactively:
- Recovery mode: if you still have the user's recovery code, unwrap
the existing DEK with it and re-wrap with the new password. No data
loss; the recovery code stays valid (mirrors /auth/recovery-complete).
- Nuke mode: if both password AND recovery code are gone, generate a
fresh DEK + new recovery code (printed once), and DELETE the user's
private activities — their ciphertext is permanently unrecoverable.
Public/semi/friends rows and engagement (hearts/bookmarks/done) are
preserved.
Both modes invalidate the user's sessions.
Password length matches the signup/recovery rule (12 chars min).
Wrong-recovery-code path aborts before any DB writes. Hand-rolled
line reader sidesteps a Bun quirk where node:readline only delivers
the first answer when stdin is piped.
Also expand README's "Deployment" section: container snippet stays,
plus new subsections for env vars, TLS termination (with a Caddyfile
example), backup/restore via sqlite3 .backup, the /api/health
healthcheck, upgrade flow, and a walkthrough of the reset CLI.
Toggling "gjort" (and heart, and bookmark, and edit, and GET /:id)
silently reset the row's effective sort_position to -created_at,
wiping any custom drag-sort the viewer had applied to that row.
The list endpoint joins user_activity_sort to get the per-viewer
position; single-row endpoints were doing plain
`SELECT * FROM activities WHERE id = ?` and serialize() was falling
back to -created_at when sort_position was missing from the row.
User-visible effect on the private list (which often has custom
ordering since it's the user's todo list): toggling a checkbox made
that row jump back to its created_at slot.
Fix: fetchRowForViewer(id, viewerId) helper that does the same
LEFT JOIN as the list query. Routed through every single-row return
path — GET /:id, POST /, PATCH /:id, POST/DELETE /:id/heart,
POST/DELETE /:id/bookmark, POST/DELETE /:id/done.
Regression test covers heart + done + GET-by-id all preserving a
custom sort_position written via PATCH /:id/sort.
Per-user "I've done this" toggle alongside hearts and bookmarks.
Hearts express approval; gjort expresses completion. Both contribute
to public statistics so readers can see what people LIKE versus what
people actually DO.
Backend:
- New activity_done table (composite PK on activity_id + user_id,
CASCADE on both refs, mirrors activity_hearts).
- POST/DELETE /api/activities/:id/done. Unlike heart/bookmark, "gjort"
works on every visibility the viewer can see — private (owner-only,
acts as a personal todo checkbox), friends-only (mutual-friend +
no-block check, mirrors GET /:id), public, semi. Non-viewers get
404 to avoid leaking existence.
- buildBulkLookups + serialize extended with done_count + viewer_done
so the list endpoint stays at constant queries per render.
- Public-list endpoint (server/users.ts) bulk-fetches done counts
alongside heart counts; viewer_done is always false (unauth view).
Types: Activity{Public,Semi,Private,Friends} all gain done_count +
viewer_done. Private's count is at most 1 (only the owner can write).
UI: new "✓ Gjort" / "☐ Gjort" button in the action row with the same
optimistic-toggle + localOverride pattern as hearts. Anonymous viewers
on public activities see a muted "✓ N" stat. Title hint clarifies
the intent: "Dette har jeg gjort" vs "Du har gjort dette."
Tests: 2 new in engagement.test.ts — toggle + idempotency on public,
owner-only access on private (non-owner gets 404).
Add a "Lagt til DD.MM.YYYY" line to ActivityRow. For public rows the
existing owner-attribution snippet folds into the same muted footer
line so it reads naturally ("Lagt til 25.05.2026 av Ole-Morten").
Private rows just get the date — they have no public attribution.
Date-only is intentional (per spec: "Det holder med dato"). created_at
is epoch milliseconds, not seconds like scheduled_at, so a separate
formatDateOnly helper avoids confusing the two.
Two related navigation fixes:
1. Back-button now uses real browser history. Sub-view "Tilbake"
buttons (permalink, tag page, personvern, public list) all used
to call goPublicHome() — which always sent the user to / —
instead of returning to wherever they came from. Replaced with
a single backToCallerOrHome() that delegates to
navigate.goBack(fallback), which calls window.history.back() if
there's a prior entry, else navigates to /hjem (or / when
anonymous) so cold-loaded permalinks still have somewhere to go.
2. Cold-load redirect: a logged-in user landing on / probably
wants their own dashboard, not the public marketing surface.
After the me-probe finishes in onMount, if the initial route
was 'public-home' and session.user exists, push to /hjem
instead of applying the public-home route. This only runs on
initial mount (not on every popstate), so browser-back from
/hjem to / still works if the user explicitly navigates there.
The wordmark in the nav also picks its destination by auth state
now — logged-in users go to /hjem, anonymous users to /. Otherwise
clicking it post-redirect would just bounce back to /hjem.
Plain <a href="/..."> links to in-app routes were causing full page
reloads. The DEK lives only in memory (session.svelte.ts), so every
reload drops it and private rows can't decrypt until the user signs
in again. Earlier fix (03ac99e) made the post-reload state usable;
this one removes the reload in the first place.
New helper lib/navigate.ts:
- navigate(path): pushState + dispatch synthetic popstate so the
existing window popstate listener in App.svelte re-parses and
applies the route. SPA state (DEK, decrypted activities, scroll
position) is preserved.
- onSpaLink(event, path): swallow plain left-click only. Modified
clicks (Cmd/Ctrl/Shift/Alt, middle-click, right-click) fall
through to the browser so "open in new tab", copy-link-address,
and screen-reader behaviour all still work.
Applied to the five internal anchors in ActivityRow.svelte:
permalink title (private + non-private), tag chips (private +
non-private), and the owner attribution link to /<user>/liste.
Verified in the browser: clicking a permalink now preserves a
window.__spaProbe marker; back-button likewise stays in-app.
User-visible SPA routes were a mix of English and Norwegian. Bring
them in line with the rest of the project's language:
/home → /hjem
/a/:id → /aktivitet/:id
/<username>/list → /<username>/liste
/tags/:tag → /etiketter/:etikett
/invite/:token → /invitasjon/:token
The English forms remain accepted by the SPA router (parsePath) and
the server OG handlers so links shared before the rename — invite
URLs in particular — still resolve. Outgoing links always use the
Norwegian forms. API paths (/api/users/:username/list etc.) stay in
English — they're internal contracts between client and server, not
user-visible URLs.
Server OG registration orders /<username>/liste before /aktivitet/:id
so a hypothetical user with the slug "aktivitet" still gets their
profile page rather than an activity-not-found. For normal activity
URLs the user-list route doesn't match (second segment must be the
literal "liste").
Profile copy referencing the URL slug also updated.
A light touch of sun and warmth without overhauling the design:
1. Decorative radial sun-wash on body::before — soft peach glow from
top-right and a warmer lemon hint from bottom-left. Pure colour
(no image), so it doesn't trip the contrast issues the earlier
snowflake-background hit. Dark mode gets a dimmer ember-toned
variant so it reads as low winter sun.
2. Wordmark gains a ☀ before the title (warm, with a small text-shadow
halo) paired with the existing ❄ after. Sun + snow makes the
"vintersol" metaphor explicit instead of implied.
3. Primary button hover adds a ~4px sun-halo on top of the existing
drop shadow — feels lit, not just lifted.
All within the existing palette. WCAG contrast unchanged.
Add DELETE /api/tags/:name (gated by isModerator(), which also passes
for admins per the admin-implies-moderator invariant in roles.ts).
The endpoint normalises the name the same way creation does so the
URL casing doesn't matter, then deletes the tag and detaches it from
every activity_tags row in one transaction.
UI: new "Etiketter" nav entry visible to moderators + admins, opens
a ModerateTags.svelte view with search-as-you-type (reusing the
/api/tags suggestion endpoint) and a Slett button per row. Private
tags are unaffected — they're encrypted in the activity payload and
never reach the server tag table.
Tests: 3 new cases on top of the admin suite — moderator can delete,
plain user gets 403 (anonymous gets 401), unknown tag gets 404.
gcSessions() existed but only ran on signup. On low-volume instances the
sessions table would grow forever as users logged in/out without ever
triggering a sweep. Schedule it hourly from server boot via
setInterval(...).unref() so it doesn't keep the process alive. Run once
on boot too, to clean up whatever accumulated while the previous instance
was down. Tests import individual route modules instead of server/index.ts,
so this only fires in a real server boot.
Surfaced by /audit simplify.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
The /api/activities list endpoint serialised each row by calling
tagsFor, heartsFor, viewerBookmarked, and ownerAttribution — that's
~5 queries per row. For a list of N activities the endpoint issued
5N queries; for a hundred-row list, hundreds of round-trips.
Add buildBulkLookups(rows, viewerId) that runs one IN-query per
concern (tags, heart counts, viewer-hearted, viewer-bookmarked,
owner attribution) and returns precomputed maps. serialize() now
accepts an optional bulk arg; single-row paths (GET /:id, POST,
PATCH, heart/bookmark toggles) pass undefined and keep their per-row
helpers — fine for one row, wrong for a list.
Also add bulkTagsFor() to server/tags.ts as a reusable helper, and
apply the same treatment to server/users.ts (public-list endpoint
had a 2N pattern on tags + heart counts).
Surfaced by /audit simplify.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Home.svelte and TagPage.svelte both pre-decrypted private rows with
the same ~15-line $derived.by block (loop + decryptPayload + try/catch).
Move it into a small lib helper so future changes (parallelism, error
reporting) live in one place. Both call sites now use a plain
$derived(decryptPrivateCleartext(activities, session.dek)).
Surfaced by /audit simplify.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
App.svelte's onMount used to call api.logout() whenever it detected an
existing server session at boot, on the theory that "we can't decrypt
without the DEK so the session is half-broken anyway." That destroyed
the user's session on every full-page load — including clicking the
plain <a href="/a/<id>"> permalink in ActivityRow, which navigates
the browser instead of routing client-side.
Symptom reported by the user: clicking a permalink for a private
activity returned "fant ikke aktiviteten" (because the now-anonymous
caller can't read private rows), and the back button left them logged
out (because session.user was never re-hydrated).
Fix: keep the server session on reload and re-hydrate session.user
from /me. The DEK is still intentionally absent (it never persists),
so private rows that the SPA can't decrypt now show a clear
"logg inn på nytt med passordet ditt for å vise det" message
instead of a stuck "Dekrypterer …" spinner. Public / semi / friends
content keeps working without re-authentication.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Four nearly-identical endpoints (heart add/remove, bookmark add/remove)
collapse into one toggleMark(c, kind, op) helper. Behaviour is unchanged
— idempotent on both sides, 404 on missing activity, 400 if private,
same serialized response. Tests pass.
Surfaced by /audit simplify.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Two bugs in the previous DnD wiring, both stemming from the
handle-gates-dragDisabled pattern:
1. **Two-step desktop drag.** The handle's pointerdown flipped
dragDisabled to false, but by then the gesture was already in
progress and the library wasn't watching. Users had to click
the handle, release, then mousedown+drag the card — two
gestures for one action.
2. **Keyboard reorder didn't work.** svelte-dnd-action's Space-to-
lift + arrow-keys-to-move handling is gated by dragDisabled. By
keeping it true except during a handle-press, the library never
processed keyboard interactions.
Fix: dragDisabled becomes a pure function of auth state. The
library's built-in distance threshold prevents accidental drags
from clicks; form controls and links inside cards aren't draggable
targets. Result:
- Desktop: mousedown on the card, move → drag starts.
- Touch: tap-and-drag on the card → drag starts (after threshold).
- Keyboard: Tab to a row, Space to lift, arrows to move, Space
to drop. Screen-reader announcements come from the library.
Plus: the drag-handle ⋮⋮ icon is gone entirely. The library
listens on the whole card, so the handle was only ever a visual
hint. `cursor: grab` on the card carries that affordance for the
~5px of weight the handle was eating.
Net diff: −12 / +18, including a small CSS adjustment to scope
`cursor: grab` to dndzone children only (so the same card style
on permalink / public-list / tag pages stays with default cursor).
Also: the public landing ("/") now sorts strictly by created_at
DESC regardless of viewer. Personal sort applies on /home but not
on the public root — the landing is the canonical newest-first
view, not the viewer's curated one.
96 tests still pass; typecheck clean; build ok.
HTML5 native drag-and-drop doesn't fire on touchscreens — mobile
users couldn't reorder the list at all. Swapped the manual DnD
wiring (dragstart/dragover/drop) for svelte-dnd-action, which uses
Pointer Events and handles mouse, touch, AND keyboard reorder
uniformly. Linear-quality reorder UX for ~11 KB gzipped.
Replacement details:
- bun add svelte-dnd-action (0.9.69)
- Home.svelte: ~70 lines of manual handler code deleted, replaced
with ~30 lines wiring up `use:dndzone` + `onconsider` +
`onfinalize`. The midpoint-position math for sort_position is
unchanged (finalize gives us the new neighbour list directly).
- ActivityRow.svelte: the drag handle's `onpointerdown` flips a
parent-owned dragDisabled flag to false — the library then
takes over. Standard "handle-only drag" recipe; clicks on the
title/buttons inside the card don't initiate drag because
dragDisabled stays true everywhere else.
- dndItems is a buffer copy of `filtered` that the library
mutates during a drag. An $effect re-syncs it from `filtered`
between drags (so new activities still float to the top, etc).
- Shadow item (the library's placeholder while dragging) is
rendered at 30% opacity so the drop target is visible without
flashing.
Accessibility wins for free:
- Keyboard reorder: focus an item, press Space to "pick up",
arrow keys to move, Space to drop. Screen readers get
polite-live announcements of each move from the library.
- Touch reorder works on iOS Safari and Android Chrome.
96 tests still pass; typecheck clean; build ok.
Bundle: 122 KB → 154 KB (gzipped 42 → 53 KB, ~+11 KB).
The home dashboard's five sections (Bokmerker / Dine private /
Venner / Anonyme / Offentlige) collapse into one ordered list.
Each row is identified by its visibility badge plus an optional
"★ Bokmerket" badge — the meaning stays clear, the layout gets
much tighter, and reordering across visibility levels becomes a
single drag.
Per-viewer ordering, sparsely stored:
Schema (additive):
- user_activity_sort(user_id, activity_id, position REAL)
composite PK; ON DELETE CASCADE both ways
Sort math: the list query LEFT JOINs the table per-viewer and
orders by COALESCE(custom_position, -activity.created_at). Rows
without a custom position sort by -created_at (very negative for
recent activity, very less-negative for old) so new and untouched
activities float to the top in newest-first order. Once dragged,
the row carries a real float position that the listing query uses
instead.
Sort endpoint:
PATCH /api/activities/:id/sort body: { position: number }
ON CONFLICT UPDATE so re-dragging the same row is cheap and
doesn't accumulate rows.
Wire: every activity variant now carries `sort_position: number`
— the effective position the server used (custom or -created_at).
The client uses it to compute midpoint positions on drop without
needing to know the formula.
Frontend:
- Home.svelte renders one list ordered by sort_position. Search
filter still works across the unified list.
- ActivityRow.svelte gains a drag-handle button (only rendered
when the parent passes draggable=true; off on the public
landing and on /a/:id, /<username>/list, /tags/:tag).
- ActivityRow.svelte gains a "★ Bokmerket" vis-badge alongside
the visibility badge so the marker is consistent with the
other status pills.
- Home computes the drop's new position as the midpoint between
neighbours (or top/bottom + 1.0 at the edges), updates the
local list optimistically, then PATCHes the server. Snapping
back on failure.
Touch DnD is currently not supported — HTML5 native DnD doesn't
work on touch. Adding a polyfill is a separate concern (the user
explicitly asked for drag-and-drop; can revisit for mobile later).
Regression test in tests/activities.test.ts covers:
- default order is newest-first
- a custom position via PATCH /sort moves a row
- ordering is per-viewer (A's drag doesn't affect B's list)
- a fresh activity created after a custom position floats above
the user's custom-positioned rows (because -created_at is much
more negative than any reasonable custom position float)
- 401 without auth
- 400 on missing or non-finite position
96 tests pass total; typecheck clean; build ok.
server/invites.ts derived the share URL from c.req.url — i.e., from
the API request's host. In production the API and SPA share an
origin so this happened to work; in dev where the SPA runs on :5173
and the API on :3000, the generated link pointed at the API
(http://localhost:3000/invite/<token>) which serves nothing.
Fix: the server no longer returns a `url` field. The token is the
canonical artefact; the SPA builds the share link itself via
`${window.location.origin}/invite/${token}`, which is always the
right origin regardless of split-process dev or single-process prod.
- shared/types.ts: InviteEntry.url removed
- server/invites.ts: drop originOf() and the URL field in toEntry()
- frontend Profile.svelte: new inviteUrl() helper; the displayed
<code> and the clipboard payload both use it
- tests/social.test.ts: assertion checks token shape instead of
the URL field
93 tests still pass.
Users can attach up to 5 labelled URLs to their profile — social
handles, blog, anything. They're shown on /<username>/list (the
opt-in public list), behind target="_blank" rel="noopener noreferrer
ugc" anchors so the destination tab can't script back into our
window.
Schema:
- user_links(id, user_id, label, url, position, created_at)
UNIQUE(user_id, position) to keep ordering stable, ON DELETE
CASCADE so user deletion sweeps links.
Wire:
- UserLink type (id/label/url)
- MeResponse.links: UserLink[]
- PublicListResponse.links: UserLink[]
- ProfileUpdateRequest.links?: { label, url }[] — bulk replace
- USER_LINK_LIMITS exported so frontend constraints match server
Validation (server/auth.ts):
- Label 1-40 chars, trimmed
- URL parseable + http:// or https:// only (no javascript:, data:,
mailto:, etc.)
- URL ≤ 500 chars
- Max 5 links per user
Bulk replace semantics with up-front validation, then DELETE +
INSERT inside the same transaction as the user UPDATE. A username
UNIQUE violation rolls back the link changes too — no half-applied
state. Empty rows in the request are silently dropped so users
can leave half-typed entries without a server rejection.
Frontend Profile gets an "Eksterne lenker" section between the
FriendsPanel and the Eksporter section. Five label+URL row pairs
with add/remove buttons, save button, error → Bokmål mapping
(link_label_required, link_url_bad_protocol, etc.).
93 tests still pass; typecheck clean; build ok.
Server-side render OG + Twitter Card meta for the routes where rich
link previews matter:
/ — homepage
/personvern — privacy + how-it-works
/a/:id — activity permalink
/tags/:tag — tag page
/<username>/list — public list (opt-in)
Everything else falls through to the unmodified SPA shell.
Approach: in production mode, register Hono handlers BEFORE the
catch-all that read the prebuilt index.html template, swap <title>
and <meta name="description"> with route-specific values, and inject
an OG/Twitter meta block before </head>. Same HTML to scrapers and
real users — no UA sniffing. The SPA still bootstraps the same way
(the <script type="module"> and <div id="app"> are untouched).
Information-leak guard: private activities and not-opted-in public
lists fall back to the SAME generic "not found" OG as truly missing
URLs. Otherwise a scraper could distinguish "exists but hidden"
from "doesn't exist," which the regular API correctly hides.
Implementation notes:
- Template read once on first request and cached. New deploys
require a server restart to pick up a rebuilt index.html, which
is the normal deploy flow anyway.
- All meta attribute values pass through escapeAttr() so user
content (activity titles, tag names, display names) can't break
out of the attribute or inject HTML.
- Description capped at 200 chars (what most scrapers actually
render).
- Base URL prefers PUBLIC_BASE_URL env var, falling back to
request URL — works behind a reverse proxy if the env var is set.
- PNG fallback for OG image is deliberately not shipped; the SVG
works on every modern scraper that matters. iOS home-screen
previews are the only place this falls back to the page
screenshot, which is fine.
Smoke-tested via curl against a production-mode server: all five
routes render the right title, description, og:url, and image, and
the "not found" + "hidden" cases collapse to the same OG shape.
The intro paragraph that names the visibility levels skipped over
"friends" — which is now a first-class visibility, not a footnote.
Added "Du kan også dele ting med vennene dine." so visitors learn
about the option as part of the welcome.
Playwright inspection of the redesigned root page surfaced three real
issues:
1. The .landing-hero element had a bottom-border that rendered as a
harsh horizontal rule between the intro and the search. Combined
with the footer's top-border, a short landing page had TWO visual
<hr> lines fighting each other. Removed the bottom-border.
2. The intro's secondary paragraph ended with "Mer om personvern og
hvordan det virker." — the persistent footer right below has the
exact same link. Two identical CTAs within ~400 vertical pixels
on a short page. Dropped the inline link.
3. **Real contrast bug**: the body's snowflake background image was
meant to be barely-visible (a body::before wash at z-index -1 was
supposed to fade it). But `z-index: -1` on a pseudo-element of
body puts it BEHIND the body's painted background — not in
front. So the wash did nothing, and on full-screen viewports
(≥1100 px) the snowflake rendered at full luminosity-blended
opacity, wrecking text contrast. The user spotted this in full
screen.
Fix: remove the body-background snowflake entirely. The ❄ glyph
next to the wordmark in nav.top h1::after already carries the
seasonal icon language and never interferes with content text.
Also: extended .gitignore to drop .playwright-mcp/ artefacts and
*.png. The previous attempt at this commit accidentally included
inspection screenshots and console logs; reset and redone.
The previous palette was technically correct (passed WCAG, worked in
both themes) but read like a generic Linear/Notion clone. The new
look leans into "doing things in winter with people" — warm orange
accent, beige borders, a snowflake glyph next to the wordmark, soft
shadows on cards that lift on hover.
Palette (all combos verified for ≥4.5:1 contrast in both themes):
Light: bg #f8f5ef, fg #1a1612, accent #c2410c (orange-700),
border #e5dfd3, card #ffffff
Dark: bg #14110d, fg #f0ede5, accent #fb923c (orange-400),
border #2d2820, card #1d1813
Visibility colours now live in their own variables (--vis-private,
--vis-semi, --vis-public, --vis-friends) decoupled from --accent, so
the brand hue can change without making "private" look like a
primary action.
Cards:
- radius bumped 10→12 px, padding +10%
- subtle shadow base (--shadow-sm), with --shadow-md on activity
row hover so the cards feel tactile
- article.card:hover gets a hint of accent in the border
Buttons:
- Primary button gains a 1 px lift + larger shadow on hover (only
on the primary — secondary buttons just tint to accent-soft)
- All buttons keep min-height: 44 px (WCAG 2.5.5)
Visibility badges:
- Each gains an emoji glyph via ::before (🔒/🎭/🌍/👥), so meaning
isn't conveyed by colour alone (a11y win, also faster scanning)
- Use color-mix() for backgrounds — cleaner than per-rgba tuples
Hero on the landing page:
- New .landing-hero block: bigger first paragraph (1.05 rem,
--fg colour), softer secondary line, bottom-border divider so
the section reads as a hero rather than two muted paragraphs
Decoration:
- On viewports ≥1100 px, the page background gets the icon SVG
fixed at low opacity (luminosity blend mode + a body::before
wash, so it's barely-visible — never competes with text contrast)
- Wordmark gets a small ❄ accent next to it
Theme-color meta + manifest + icon SVG fill all updated to match.
prefers-reduced-motion still disables transitions and the primary
button lift.
CSS grew from 3.65 → 6.38 KB (2.05 KB gzipped); no other bundle
changes. Typecheck clean.
Tag pills in ActivityRow now navigate to /tags/<tag> when clicked.
The destination shows every activity matching the tag that the
viewer would otherwise be able to see — same visibility rules as the
dashboard: anonymous gets public + semi, authenticated adds own
private + friends-only.
Design choice: client-side filter, no new server endpoint. The
dashboard's `GET /api/activities` already returns exactly the rowset
that should be filterable by tag, so TagPage.svelte loads the list
the normal way and runs `.tags.includes(needle)` on it (private rows
decrypt locally first — same pattern Home.svelte uses for search).
Avoids duplicating the 4-clause visibility filter SQL into a second
endpoint, and the private-tag case wouldn't have worked on the
server anyway since private tags are inside the encrypted payload.
Routing:
- parsePath gains `/tags/:tag` with decodeURIComponent for any
character (tags allow spaces, special chars, etc — they're
normalised server-side to lowercase + trim)
- applyRoute branch sets view='tag' and tagName from the URL
- leaveTag() returns to /home (logged in) or / (logged out)
- Nav header is hidden on the tag view (it has its own back button,
same pattern as PublicList and ActivityPermalink)
ActivityRow tag pills changed from <span> to <a href="/tags/...">,
inheriting colour and dropping text-decoration so the visual pill
stays as-is. The :focus-visible underline added in commit 6313f36
still applies, so keyboard users see focus clearly.
Also, while in profile copy: removed the stale "delen av eposten din"
hint in Profile.svelte — the email-prefix fallback was removed in
commit 43c24ec, the docstring lagged. New copy: "Er feltet tomt og du
har satt et brukernavn, brukes det i stedet. Ellers vises ingen
attribusjon."
Typecheck clean; build succeeds.
Brings the test count from 29 → 93 across 8 files. Each new file
exercises one feature area through the HTTP layer using a shared
helper that spins up a fresh Hono app on a fresh temp DB.
New test files:
- tests/helpers.ts: setupTestApp(), signupAndGetCookie(),
req()/reqJson() convenience, the ttest()
helper that wraps test() with a 30s
timeout (Argon2id signups blow the 5s
default).
- tests/activities.test.ts (17 tests): create + read per visibility,
owner_id stripping on semi, visibility
transitions (column wipes, tag-row
clearing), validation rejects, delete +
update authz (owner / moderator),
display-name fallback chain.
- tests/engagement.test.ts (10 tests): heart toggle + idempotency
+ private-refusal + auth gate; same for
bookmarks; tag normalisation + autocomplete
prefix-match + tag-store-never-sees-private
+ tag-update-deccrements-old.
- tests/admin.test.ts (9 tests): first-user-auto-admin, admin gate
(401/403/200), promote/demote, last-admin
guard, admin-implies-moderator (via
feedback-list endpoint), user-list shape,
admin-can-delete-semi crossover.
- tests/social.test.ts (11 tests): feedback submit/list/done/delete +
admin-only gates; invite create/claim/cancel,
single-use behaviour, audit-trail preservation
on claimed invites, cross-user delete blocked;
settings GET (public), closed-registry gate
with invite bypass.
- tests/profile.test.ts (17 tests): display_name + username updates,
username validation (incl. silent
lowercasing), username uniqueness via 409,
public-list opt-in / opt-out, full login
flow (challenge → login → me), wrong-verifier
vs unknown-email both 401, logout
invalidates session, password change happy
path (re-login with new + old fails),
duplicate email 409, invalid email 400.
**Real bug uncovered**: the invite signup flow hit a `FOREIGN KEY
constraint failed` because `claimInvite()` does `UPDATE invites SET
claimed_by_user_id = <new user id>` *before* the user INSERT runs.
With foreign_keys=ON (which we run with), the FK on
`invites.claimed_by_user_id → users.id` blows up immediately. No live
session had exercised invite signup end-to-end, so the bug was
invisible.
Fix in server/auth.ts: `PRAGMA defer_foreign_keys = ON` inside the
signup transaction, so FK checks happen at COMMIT after the user
INSERT has run. The PRAGMA is per-transaction and resets automatically.
93/93 tests pass; typecheck clean; build succeeds.
"venner-bare-innhold" and "venner-bare-oppføringer" are
awkward compound coinages — Norwegian prefers a postpositional
"X for venner" ("content for friends"). Two instances + one in the
"hvem får se mine ..."-liste fragment, all updated.
Surfaced by a copy review.
The friends-only visibility is one-way:
- If Anna adds Britt → Anna's friends-only posts are visible to Britt
- If Britt has NOT added Anna → Britt's friends-only posts are NOT
visible to Anna, even if Britt is in Anna's list
This matches the user's mental model and is what server/activities.ts
already implements via "owner_id IN (SELECT owner_id FROM friends
WHERE friend_id = ?)" — owner must have added viewer, not the other
way round.
Test covers three cases end-to-end through the HTTP layer:
1. Asymmetric: Anna adds Britt, but not vice versa. Anna's post
reaches Britt; Britt's post does NOT reach Anna. Permalink GET
returns 404 (not 403) for the hidden direction, matching the
"don't leak existence" pattern we use elsewhere.
2. Reciprocal: both add each other, both see each other's posts.
3. Block: mutual friends, then one blocks the other. The block
filter applies symmetrically — neither sees the other's
friends-only content from then on, even though the friendship
rows still exist.
29 total tests now pass (26 prior + 3 new).
A fourth visibility level ("Venner") with one-way friendship and
two-way block filtering, plus the table-rebuild migration that drags
older dev DBs forward.
Visibility model:
- Friendship is directional: (owner, friend) means owner has added
friend to their list. Owner's friends-only activities become
visible to friend; friend isn't automatically friends with owner.
- Blocking is also directional at the DB level (blocker, blocked)
but is checked SYMMETRICALLY at visibility-resolution time: once
either user has blocked the other, friends-only content stops
flowing in either direction. Block does NOT affect public or
anonymous content — those are open to anyone by definition.
- "Friends-only" is an access-list visibility, NOT cryptographic.
The server stores the content in plaintext and serves it only to
authorised viewers. This is documented honestly in /personvern.
Schema:
- activities.visibility CHECK gains 'friends' as a fourth value
- friends(owner_id, friend_id, created_at) — composite PK,
self-friending blocked by CHECK
- user_blocks(blocker_id, blocked_id, created_at) — same shape,
blocking-self also blocked
Migration (server/db.ts):
- SQLite can't ALTER a CHECK constraint, so the migration detects
out-of-date DBs by scanning sqlite_master for the literal
"'friends'" in the activities table's CREATE statement
- If absent, rebuilds activities via the standard SQLite
table-copy-drop-rename dance with foreign_keys briefly off
around the transaction, then runs foreign_key_check to confirm
no FKs were left orphaned (activity_tags, activity_hearts,
bookmarks all point at activities). Smoke-tested on the dev DB:
olemd's user row and moderator/admin flags survived.
Server endpoints (server/friends.ts):
GET /api/friends — my outgoing list
GET /api/friends/incoming — who has added ME
POST /api/friends — add by username (idempotent)
DELETE /api/friends/:userId — remove a friend
GET /api/friends/blocks — my blocked-users list
POST /api/friends/blocks — block by user_id (idempotent)
DELETE /api/friends/blocks/:userId — unblock
Add-by-username (not by email): users must set a username to be
findable. Email stays a private contact identifier.
Activity list filter (server/activities.ts): adds two clauses to the
WHERE — own friends-only, and friends-only owned by a user who has
added me AND there's no block in either direction. Single-activity
GET applies the same check.
Frontend:
- ActivityForm.svelte gains the "Venner" option
- ActivityRow.svelte renders a "Venner" badge with a new amber
vis-badge.friends colour (passes contrast in both themes)
- FriendsPanel.svelte: add-by-username form, outgoing, incoming
(with Block button), and blocked (with Unblock button)
- Profile.svelte mounts FriendsPanel between display fields and
Eksporter
- Home.svelte adds a "Venner" section between private and semi
Docs: Personvern.svelte gains a "Venner og blokkering" section
explaining that friends-only is access-list-not-crypto and pointing
the reader at "private" for actually-sensitive content.
26 tests still pass; typecheck clean; build succeeds. Bundle
36.8 KB → 39.1 KB gzipped (FriendsPanel + new server endpoints +
the Personvern prose).
Two layers of explanation, addressing the gap between "I just want to
use it" and "I want to understand the crypto."
Inline:
- Signup page: extends the existing one-liner with a "Les hvordan
det virker" link. Opens in a new tab so the user doesn't lose the
form (and any invite token) mid-read.
- Public landing: gains a second muted paragraph that names the
three visibility levels in plain language and links to the docs.
Detail page (/personvern):
- New Svelte component Personvern.svelte with sections for the
three visibility levels, E2E key model, password vs recovery
code, what the server stores vs doesn't, sessions, and explicit
limits ("what crypto does NOT protect against").
- Written for non-technical users; still names the primitives
(Argon2id, XChaCha20-Poly1305) and gestures at SECURITY.md for
anyone who wants the engineering depth.
App.svelte routing:
- New /personvern path handled in parsePath
- applyRoute branch sets view='personvern'
- leavePersonvern() routes back to /home or / depending on auth
- Persistent footer link on every view so the docs are always one
click away
Norwegian Bokmål throughout. No new dependencies. Bundle 34.2 KB →
36.8 KB gzipped (mostly the markdown-ish prose content).
The "Du registrerer deg med en invitasjonslenke" banner that appears
when arriving at signup via /invite/<token> was a plain <div> — a
screen reader would read it in document order with no special
treatment. role="status" turns it into a polite live region so AT
users hear it as a contextual announcement when the signup view
loads with an invite attached.
Surfaced by /audit a11y (semantic structure lens).
Without scope="col", screen readers can't reliably associate each
header cell with its column when the user is navigating cell-by-cell.
Modern AT often guesses correctly from <thead> placement, but the
explicit attribute removes the ambiguity.
One attribute per <th>, no rendering change.
Surfaced by /audit a11y (semantic structure lens).
Activity title permalinks and the "Vinterliste" nav logo are styled
with color: inherit; text-decoration: none — perfect for a clean
visual, but they relied on the generic *:focus-visible outline to
signal focus. The outline alone is easy to miss against the activity
card border, and a low-vision keyboard user has no underline cue to
fall back on.
Added an a:focus-visible rule with a slightly larger outline-offset
and an underline. Standard anchors (the OSM links, the public-list
attribution links) already had their accent colour AND now get the
underline on focus too — all wins, no regressions.
Surfaced by /audit a11y (keyboard & focus lens).
App.svelte already renders <h1>Vinterliste</h1> in the nav, so the
PublicList page's <h1>{displayName || username}</h1> produced a
two-h1 document — screen-reader users navigating by headings would
see two top-level entries that aren't actually peers.
Switched to <h2>, kept the larger font size inline so the visual
hierarchy is unchanged. Every other view (Home/Profile/Admin/Feedback)
already follows the "nav h1, view h2" pattern; this brings PublicList
in line.
Surfaced by /audit a11y (semantic structure lens).
The previous text ("Offentlige og halv-offentlige aktiviteter. Logg
inn for å se og legge til dine egne.") read like a sysadmin status
banner — it described the data model rather than the purpose.
New text explains what the app is actually for: making and sharing
winter activity lists to keep the winter blues at bay, with a nod to
the fact that some users will use it for entirely different lists.
Login is still one click away in the nav, so the dropped "Logg inn
for å se …" instruction isn't needed.
A new "Eksporter" section on the Profile page generates a markdown
file containing all activities visible to the caller — including
their own private ones — and triggers a download.
Why pure client-side:
- Private rows are E2E-encrypted; the server doesn't have the
cleartext. Decryption MUST happen in the browser.
- Avoiding a server endpoint also means there's no "give me my
plaintext" target for anyone who steals a session cookie.
Mechanics (frontend/src/lib/export.ts):
- Fetch /api/activities (same as the dashboard does)
- For each row, normalise: decrypt private payload via session.dek,
pass semi/public through as-is
- Build a markdown doc with sections "Private (N)" and
"Offentlige og anonyme (N)" plus per-row title/description/tags/
location/scheduled blocks
- Wrap in a Blob, create a temporary <a download>, click it, revoke
the object URL
The Profile section reports the resulting size as a quick confirmation
and surfaces errors inline. No server changes required.
Bundle: 32.7 KB → 34.2 KB gzipped (mostly the export helper itself,
which is plain string concatenation — no new deps).
Logged-in users can star a public or semi activity to save it for
later. Bookmarked rows float to the top of the user's dashboard in a
dedicated "Bokmerker" section. The same row still appears in its
visibility section below — bookmarking doesn't remove anything; it
just adds a fast lane.
Schema:
- bookmarks(user_id, activity_id, created_at) with composite PK
- Both FKs CASCADE so user deletion or activity deletion sweeps
bookmarks automatically
Wire/types: ActivityPublic/Semi/Private all gain `viewer_bookmarked:
boolean` for type uniformity. Private rows always carry false (the
owner already has direct access; bookmarking own private items would
be redundant), and the bookmark endpoints reject visibility='private'
with cannot_bookmark_private. Anonymous viewers (public-list endpoint)
get false too.
Server:
- viewerBookmarked() helper next to heartsFor() — same shape
- serialize() includes the field
- POST/DELETE /api/activities/:id/bookmark, idempotent via
INSERT OR IGNORE / DELETE; mirrors the heart endpoints
Frontend:
- ActivityRow gets an "☆ Bokmerk" / "★ Bokmerket" toggle next to
the heart button. Uses the same optimistic local-override pattern
so the UI feels instant.
- Home renders a "Bokmerker" section at the top when bookmarked
rows exist. publicOnly mode (the "/" landing) skips it — the
field is always false there.
26 tests still pass; typecheck clean.
A free-text body alongside title/tags/location/scheduled. Plain text
for now; markdown rendering is a deliberate non-goal (the user noted
it was nice-to-have but not essential).
Schema (additive, idempotent via ensureColumn):
- activities.description TEXT NULL
- For private rows the column stays NULL; the description lives
inside the encrypted payload alongside title.
Wire/types:
- PrivatePayload.description?: string (in shared/crypto.ts)
- ActivityPublic.description / ActivitySemi.description: string | null
- CreateActivityRequest.description?: string | null
Server:
- INSERT and UPDATE handlers now write description for semi/public
- Private→semi/public transition: description column populated
- Semi/public→private transition: description column wiped (now in
the encrypted blob)
- serialize() includes the column on public and semi rows
- server/users.ts public-list endpoint surfaces it too
Frontend:
- ActivityForm.svelte: textarea after the title field; round-trips
through the existing private-encrypt / plaintext-PATCH paths
- ActivityRow.svelte: renders the description as a `white-space:
pre-wrap` <p> so line breaks survive without enabling markdown
- Home.svelte: search now matches against the description text
(decrypted client-side for private rows, just like the title)
The owner attribution helper used to fall back from display_name to
the part of the email before "@" when no display name was set. That
defeats the point of letting users pick a name: anyone who hadn't
explicitly chosen one had their email prefix surfaced publicly.
New fallback chain, applied uniformly:
- display_name (the user's chosen name) — if set, use it
- username (also chosen by the user as a URL slug) — if set, use it
- null — render nothing; the client hides the attribution line
Wire type ActivityPublic.owner_display is now `string | null`.
ActivityRow renders the "Lagt til av X" line only when display is
non-null.
Same idea applied to the user's own surfaces (nav + greeting):
- Nav button shows "Profil" (a label, not a name) when display_name
is empty, instead of falling back to the email.
- Home greeting drops "Velkommen, <name>." entirely when the user
has no display name, leaving just "Her er aktivitetene dine ...".
The feedback list (moderator view) and admin user table keep showing
the email — moderators and admins legitimately need it to identify
users for triage and role management.
Problem: the profile-update handler pre-checked username uniqueness
with a SELECT followed by an UPDATE outside any transaction. Two
concurrent PATCHes setting the same slug would both pass the SELECT
(no conflict yet), then one of the UPDATEs would hit the underlying
UNIQUE constraint and surface as an unhandled SqliteError → 500.
Fix: drop the racy pre-check entirely. The UNIQUE constraint on
users.username (column-level on fresh DBs, partial unique index on
migrated DBs) is the source of truth. Wrap the UPDATE in try/catch
and convert SQLITE_CONSTRAINT_UNIQUE into a clean 409 username_taken
response. Same "push the invariant down to the database" pattern as
the recent first-user-auto-admin race fix.
Surfaced by the username-uniqueness review.
Problem: GET /api/feedback (moderator-readable) returned the user id of
the admin who marked an entry done. Moderators don't need to triangulate
"which admin closed which ticket" — done_at alone is sufficient signal
that the entry has been triaged. Keeping the user id in the response
made it possible to cross-reference admins with the user list via a
second authenticated call.
Fix: the `feedback.done_by` column stays in the schema (server-side
audit trail is preserved) but the column is no longer SELECTed by the
list or update endpoints, and is no longer in the FeedbackEntry wire
type. Moderators see only the `done_at` timestamp.
Surfaced by /audit security (data exposure lens).
Problem: the signup handler counted users with a SELECT issued BEFORE the
transaction opened. Two parallel signups against an empty DB could both
observe count == 0 and both be promoted to admin — a violation of the
"exactly one first user" invariant. Not a confidentiality breach (both
rows passed the same signup checks), but real.
Fix: drop the JS-side count entirely. The is_admin column in the INSERT
is now populated via a subquery:
(SELECT CASE WHEN COUNT(*) = 0 THEN 1 ELSE 0 END FROM users)
SQLite's WAL serializes writers, so the second concurrent INSERT runs
after the first has committed. Its subquery sees count = 1 and returns
0; the new row is not admin. No transaction-mode tweak required.
Surfaced by /audit security (auth & session lens).
Three pieces of a single registration story.
1. **Self-registry toggle.** New generic `settings` key/value table.
Initial key: `self_registry_enabled` (default `1`). Admin-only PATCH
/api/settings flips it. GET /api/settings is public so the login
screen can hide the "Opprett konto" CTA when registration is closed.
2. **Invite links.** New `invites(token, inviter_user_id, created_at,
claimed_at, claimed_by_user_id)` table; tokens are 22-char base64url
(~128 bits of entropy). Endpoints:
POST /api/invites — create (any logged-in user)
GET /api/invites — list mine
DELETE /api/invites/:token — cancel an unclaimed invite
Claimed invites are kept in the DB (the audit trail of who-invited-
whom survives) — only unclaimed ones can be cancelled.
The signup endpoint accepts an optional `invite_token`. The signup
handler does the claim + user-insert in a single SQLite transaction
so we can't end up with a claimed invite pointing at a missing user.
A concurrent claim race is closed by `UPDATE … WHERE claimed_at IS
NULL` — only one transaction's UPDATE actually flips the column.
New `users.invited_by` column records the inviter id so accounts have
a traceable origin. Profile page shows the user's invites with
"Kopier lenke" / "Avbryt" buttons; the SPA serves /invite/<token>
into the Signup view with the token prefilled.
3. **First-user auto-admin.** The signup handler counts users *before*
the insert; if it's the first one, `is_admin` is set on the row.
This solves the bootstrap chicken-and-egg without an env var or
sqlite3 step. Documented in README.
When self-registry is **off**:
- The login screen hides "Opprett konto" and shows a "stengt" notice
- /api/auth/signup with no invite returns 403 signup_closed
- /api/auth/signup with a valid invite still works (and attributes)
- /api/auth/signup with an *invalid* invite returns 403 invalid_invite
When self-registry is **on**:
- Anyone can sign up (no invite required)
- An invite that comes along is still consumed for attribution
- An invalid invite is ignored — signup proceeds without attribution
26 tests still pass; typecheck clean; bundle 31.2 KB → 32.7 KB gzipped.