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.