Friends + friends-only visibility + blocking

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).
This commit is contained in:
Ole-Morten Duesund 2026-05-25 14:47:20 +02:00
commit f39fe9ed65
14 changed files with 657 additions and 8 deletions

View file

@ -2,7 +2,7 @@
// the SQL schema in server/db.ts and the request/response handlers in
// server/{auth,activities,tags}.ts.
export type Visibility = 'private' | 'semi' | 'public';
export type Visibility = 'private' | 'semi' | 'public' | 'friends';
// --- Auth --------------------------------------------------------------------
export interface SignupRequest {
@ -238,7 +238,53 @@ export interface ActivityPrivate {
updated_at: number;
}
export type Activity = ActivityPublic | ActivitySemi | ActivityPrivate;
/**
* Friends-only activity. Shape mirrors ActivityPublic the viewer is
* definitionally a friend of the owner, so attribution is fine. NOT
* encrypted; this is an access-list visibility, not a cryptographic one.
* See /personvern for the trade-off.
*/
export interface ActivityFriends {
id: string;
visibility: 'friends';
owner_id: string;
owner_display: string | null;
owner_username: string | null;
title: string;
description: string | null;
tags: string[];
loc_label: string | null;
loc_lat: number | null;
loc_lng: number | null;
scheduled_at: number | null;
heart_count: number;
viewer_hearted: boolean;
viewer_bookmarked: boolean;
created_at: number;
updated_at: number;
}
export type Activity = ActivityPublic | ActivitySemi | ActivityPrivate | ActivityFriends;
// --- Friends & blocks ------------------------------------------------------
/** Both the outgoing-friends list and the incoming-friends list. */
export interface FriendEntry {
user_id: string;
username: string | null;
display_name: string | null;
since: number;
}
export interface BlockEntry {
user_id: string;
username: string | null;
display_name: string | null;
since: number;
}
export interface AddByUsernameRequest {
username: string;
}
export interface CreateActivityRequest {
visibility: Visibility;