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.
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.
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).
The original spec stored only `kek_salt`, `wrapped_dek_pw`+nonce,
`rec_salt`, and `wrapped_dek_rec`+nonce. Under that model, anyone who
knew a user's email could POST to /auth/recovery-complete with junk
material and overwrite the password-side wrap, locking the legitimate
user out. The data stayed safe (the attacker couldn't decrypt
anything) but the account was effectively DoS'd until the user dug up
their recovery code.
Fix: add a recovery-side verifier mirroring the password-side one.
Storage: two new columns on `users`:
- rec_auth_salt BLOB NOT NULL — independent of rec_salt
- rec_auth_verifier_hash TEXT NOT NULL — Bun.password.hash output
The migration adds them via ensureColumn() for forward-compat with
scaffold DBs that pre-date this commit; new tables get them via the
CREATE TABLE statement.
Wire protocol:
- SignupRequest gains rec_auth_salt + rec_auth_verifier
- RecoveryChallengeResponse gains rec_auth_salt
- RecoveryCompleteRequest gains rec_auth_verifier
Server (server/auth.ts):
- signup hashes the recovery verifier alongside the auth verifier
and stores both
- recovery-challenge returns rec_auth_salt so the client can derive
the verifier; refuses with 409 for pre-fix accounts that have a
NULL rec_auth_salt
- recovery-complete calls Bun.password.verify against the stored
hash BEFORE touching any state. Always runs verify even for
unknown emails (against a dummy hash) so timing doesn't leak
existence — same pattern we already used for /auth/login.
Client (frontend/src/lib/auth.ts):
- signup() generates a fourth salt and derives the recovery
verifier from the recovery code
- recover() fetches the new rec_auth_salt and submits the derived
verifier as part of recovery-complete
Recovery.svelte distinguishes the new 401 ("Feil gjenopprettingskode")
and 409 ("Denne kontoen mangler gjenopprettingsverifikator") cases.
Regression test (tests/auth.test.ts) asserts the gate is real:
- junk recovery verifier → 401, no state changes
- unknown email → 401 (constant-time)
- challenge response includes rec_auth_salt
- correctly-derived verifier passes the gate
SECURITY.md is updated to describe four salts instead of three, the
new key-model storage, and the closed lockout DoS. CLAUDE.md flags
the rec_auth_* columns as load-bearing — removing them re-opens the
hole.
This is the only deviation from the spec's stated storage model;
documented as such in both SECURITY.md and CLAUDE.md.
Foundation for an E2E-encrypted activity list per
winter-list-claude-code-prompt.md.
Server (Bun + Hono):
- bun:sqlite with WAL and the spec's schema (idempotent migration)
- opaque server-stored sessions, httpOnly cookie
- signup / challenge / login / logout / me / password / recovery-challenge /
recovery-complete
- activity CRUD with strict visibility rules: private uses ciphertext+nonce,
semi never serializes owner_id, public attributes the owner
- tag store with normalisation + autocomplete (semi/public only)
Frontend (Svelte 5 + Vite):
- libsodium-wrappers-sumo for client crypto (Argon2id + XChaCha20-Poly1305).
SUMO is required because the standard build omits crypto_pwhash.
- IndexedDB-backed private tag index (never leaves the browser)
- in-memory DEK (no localStorage); page reload re-prompts for password
- signup shows the recovery code once; tag input merges server + private
sources with clear labelling
- Bokmål UI
Crypto module (shared/crypto.ts):
- pure, runs in both Bun and the browser via a runtime-conditional loader
that papers over libsodium-wrappers-sumo's broken ESM entry (createRequire
on server, Vite alias in the browser)
- DEK wrap/unwrap, AEAD payload encryption, recovery code generation with
a visually-unambiguous alphabet
Verification:
- 22 crypto round-trip tests (wrap/unwrap, AEAD tamper rejection, password
change preserves ciphertexts, recovery still works after rotation)
- typecheck passes for server and frontend
- Vite production build succeeds; libsodium SUMO chunk is ~315 KB gzipped
Single-image Containerfile for podman: builds frontend in a builder stage,
runs Bun in a slim runtime; one volume for the SQLite file; BUILD_DATE /
GIT_REVISION baked into OCI labels and /etc/build-info.
Known limitation deferred for this commit: the recovery endpoint has no
server-side proof of the recovery code (anyone who knows an email can lock
out the legitimate user, though they can't read any data). Closed in the
next commit.