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

@ -20,7 +20,7 @@ import type {
*/
export const activitiesRoutes = new Hono<{ Variables: AppVariables }>();
const VALID_VIS = new Set<Visibility>(['private', 'semi', 'public']);
const VALID_VIS = new Set<Visibility>(['private', 'semi', 'public', 'friends']);
interface ActivityRow {
id: string;
@ -155,6 +155,31 @@ function serialize(row: ActivityRow, viewerId: string | null): Activity {
return a;
}
const attrib = ownerAttribution(row.owner_id);
if (row.visibility === 'friends') {
// Visibility check already happened upstream; we only get here if the
// viewer is allowed to see the row. Attribution is fine because being a
// friend implies knowing the owner.
const attribF = ownerAttribution(row.owner_id);
return {
id: row.id,
visibility: 'friends',
owner_id: row.owner_id,
owner_display: attribF.display,
owner_username: attribF.username,
title: row.title ?? '',
description: row.description,
tags,
loc_label: row.loc_label,
loc_lat: row.loc_lat,
loc_lng: row.loc_lng,
scheduled_at: row.scheduled_at,
heart_count: hearts.count,
viewer_hearted: hearts.hearted,
viewer_bookmarked: bookmarked,
created_at: row.created_at,
updated_at: row.updated_at,
};
}
const a: ActivityPublic = {
id: row.id,
visibility: 'public',
@ -201,7 +226,8 @@ function validateForVisibility(body: CreateActivityRequest): string | null {
// --- GET /api/activities ----------------------------------------------------
// Returns: all public + semi activities (visible to anyone), plus the caller's
// own private activities (if logged in).
// own private activities AND friends-only activities owned by users who have
// added the caller to their friends list (with mutual block-filtering).
activitiesRoutes.get('/', (c) => {
const viewerId = currentUserId(c);
const db = getDb();
@ -209,8 +235,28 @@ activitiesRoutes.get('/', (c) => {
const params: string[] = [];
let where = `visibility IN ('public','semi')`;
if (viewerId) {
// Own private:
where += ` OR (visibility = 'private' AND owner_id = ?)`;
params.push(viewerId);
// Own friends-only (always visible to you, even if you don't appear in
// your own friend list — which would be impossible anyway given the
// self-friending CHECK):
where += ` OR (visibility = 'friends' AND owner_id = ?)`;
params.push(viewerId);
// Friends-only owned by someone who has added me, with no block in
// either direction.
where += `
OR (
visibility = 'friends'
AND owner_id IN (SELECT owner_id FROM friends WHERE friend_id = ?)
AND NOT EXISTS (
SELECT 1 FROM user_blocks
WHERE (blocker_id = activities.owner_id AND blocked_id = ?)
OR (blocker_id = ? AND blocked_id = activities.owner_id)
)
)
`;
params.push(viewerId, viewerId, viewerId);
}
const rows = db
@ -225,9 +271,31 @@ 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;
if (!row) return c.json({ error: 'not_found' }, 404);
// Apply the same visibility rules as the list endpoint. We return 404
// (not 403) for hidden rows so the endpoint doesn't double as an existence
// oracle for activity ids.
if (row.visibility === 'private' && row.owner_id !== viewerId) {
return c.json({ error: 'not_found' }, 404);
}
if (row.visibility === 'friends') {
if (!viewerId) return c.json({ error: 'not_found' }, 404);
if (row.owner_id !== viewerId) {
const db = getDb();
const isFriend = !!db
.prepare('SELECT 1 FROM friends WHERE owner_id = ? AND friend_id = ?')
.get(row.owner_id, viewerId);
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, viewerId, viewerId, row.owner_id);
if (blocked) return c.json({ error: 'not_found' }, 404);
}
}
return c.json(serialize(row, viewerId));
});

View file

@ -54,7 +54,14 @@ const SCHEMA_STATEMENTS: readonly string[] = [
`CREATE TABLE IF NOT EXISTS activities (
id TEXT PRIMARY KEY,
owner_id TEXT NOT NULL REFERENCES users(id),
visibility TEXT NOT NULL CHECK (visibility IN ('private','semi','public')),
-- 'friends' = visible only to the owner's friend list, with blocks filtering
-- in both directions. NOT encrypted server-side (it's an access-list
-- visibility, not a cryptographic one); see /personvern for the trade-off.
-- NOTE: SQLite can't ALTER a CHECK constraint; old scaffold DBs created
-- before this commit will still have the 3-value CHECK and reject
-- friends-only inserts. Drop and recreate the DB for dev, or run a
-- table-rebuild migration for production.
visibility TEXT NOT NULL CHECK (visibility IN ('private','semi','public','friends')),
ciphertext BLOB,
nonce BLOB,
title TEXT,
@ -140,6 +147,32 @@ const SCHEMA_STATEMENTS: readonly string[] = [
claimed_by_user_id TEXT REFERENCES users(id)
)`,
`CREATE INDEX IF NOT EXISTS invites_inviter_idx ON invites(inviter_user_id)`,
// Friends: one-way edges. (owner_id, friend_id) means owner_id has added
// friend_id to their friend list. owner_id's friends-only activities are
// visible to friend_id (subject to block filtering below).
`CREATE TABLE IF NOT EXISTS friends (
owner_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
friend_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
created_at INTEGER NOT NULL,
PRIMARY KEY (owner_id, friend_id),
-- Sanity: can't friend yourself. Saves a UI check.
CHECK (owner_id <> friend_id)
)`,
// Lookup-by-friend (the incoming list) needs an index because the PK is
// (owner_id, friend_id) — leftmost column is owner_id, so queries by
// friend_id alone would full-scan without help.
`CREATE INDEX IF NOT EXISTS friends_friend_idx ON friends(friend_id)`,
// User blocks: directional. (blocker_id, blocked_id) means blocker_id has
// blocked blocked_id. Friends-only visibility filters out content in
// either direction (mutual exclusion).
`CREATE TABLE IF NOT EXISTS user_blocks (
blocker_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
blocked_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
created_at INTEGER NOT NULL,
PRIMARY KEY (blocker_id, blocked_id),
CHECK (blocker_id <> blocked_id)
)`,
`CREATE INDEX IF NOT EXISTS user_blocks_blocked_idx ON user_blocks(blocked_id)`,
];
const PRAGMAS: readonly string[] = [
@ -172,6 +205,65 @@ function ensureColumn(db: Database, table: string, column: string, type: string)
db.prepare(`ALTER TABLE ${table} ADD COLUMN ${column} ${type}`).run();
}
/**
* Rebuild the activities table if its CHECK constraint predates 'friends'.
* SQLite cannot ALTER a CHECK constraint, so the only way forward is to copy
* the table into a fresh one with the updated constraint and rename. Data
* preserved; indexes recreated below. No-op if the constraint already
* mentions 'friends'.
*/
function ensureActivitiesCheckIncludesFriends(db: Database): void {
const row = db
.prepare("SELECT sql FROM sqlite_master WHERE type='table' AND name='activities'")
.get() as { sql: string } | null;
if (!row) return; // table didn't exist; CREATE TABLE just made it with the new check
if (row.sql.includes("'friends'")) return; // already up to date
// SQLite's recommended table-rebuild dance. foreign_keys=OFF lets us
// drop+rename without cascade-failing on activity_tags / activity_hearts /
// bookmarks. After the rebuild we run foreign_key_check to confirm
// everything still points somewhere real.
db.prepare('PRAGMA foreign_keys = OFF').run();
const txn = db.transaction(() => {
db.prepare(`
CREATE TABLE activities_new (
id TEXT PRIMARY KEY,
owner_id TEXT NOT NULL REFERENCES users(id),
visibility TEXT NOT NULL CHECK (visibility IN ('private','semi','public','friends')),
ciphertext BLOB,
nonce BLOB,
title TEXT,
description TEXT,
scheduled_at INTEGER,
loc_label TEXT,
loc_lat REAL,
loc_lng REAL,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL
)
`).run();
db.prepare(`
INSERT INTO activities_new
(id, owner_id, visibility, ciphertext, nonce, title, description,
scheduled_at, loc_label, loc_lat, loc_lng, created_at, updated_at)
SELECT id, owner_id, visibility, ciphertext, nonce, title, description,
scheduled_at, loc_label, loc_lat, loc_lng, created_at, updated_at
FROM activities
`).run();
db.prepare('DROP TABLE activities').run();
db.prepare('ALTER TABLE activities_new RENAME TO activities').run();
db.prepare('CREATE INDEX IF NOT EXISTS activities_visibility_idx ON activities(visibility)').run();
db.prepare('CREATE INDEX IF NOT EXISTS activities_owner_idx ON activities(owner_id)').run();
});
txn();
// foreign_key_check returns rows for any orphaned FK; should be empty.
const orphans = db.prepare('PRAGMA foreign_key_check').all();
if (orphans.length > 0) {
throw new Error(`Migration left orphan FKs: ${JSON.stringify(orphans)}`);
}
db.prepare('PRAGMA foreign_keys = ON').run();
}
export function getDb(): Database {
if (dbInstance) return dbInstance;
@ -199,6 +291,10 @@ export function getDb(): Database {
ensureColumn(db, 'feedback', 'done_at', 'INTEGER');
ensureColumn(db, 'feedback', 'done_by', 'TEXT');
ensureColumn(db, 'activities', 'description', 'TEXT');
// Activities visibility CHECK: gained 'friends' as a fourth value. The
// ALTER must rebuild the table, which is expensive — guarded inside the
// helper.
ensureActivitiesCheckIncludesFriends(db);
ensureColumn(db, 'users', 'username', 'TEXT');
ensureColumn(db, 'users', 'public_list_enabled', 'INTEGER NOT NULL DEFAULT 0');
// UNIQUE index on username via separate CREATE INDEX so the ALTER TABLE

183
server/friends.ts Normal file
View file

@ -0,0 +1,183 @@
import { Hono } from 'hono';
import { getDb } from './db';
import { requireAuth, type AppVariables } from './session';
import type { FriendEntry, BlockEntry, AddByUsernameRequest } from '../shared/types';
/**
* Friends and blocks.
*
* Friendship is one-way. (owner, friend) means owner has added friend to
* their list; owner's friends-only activities become visible to friend
* (subject to block filtering). Friend doesn't need to reciprocate.
*
* Blocks are also one-way at the DB level, but the visibility filter checks
* blocks in *both directions* once anyone in a pair blocks the other,
* friends-only content between them stops flowing in either direction.
*
* Discovery: users are added by `username` (the URL slug). A user without a
* username can't be added; this is intentional opting into a username is
* the "I want to be findable" signal. Documented in /personvern.
*/
export const friendsRoutes = new Hono<{ Variables: AppVariables }>();
interface UserLookupRow {
id: string;
username: string | null;
display_name: string | null;
}
function lookupByUsername(username: string): UserLookupRow | null {
return getDb()
.prepare('SELECT id, username, display_name FROM users WHERE username = ?')
.get(username.trim().toLowerCase()) as UserLookupRow | null;
}
function rowToEntry(row: {
user_id: string;
username: string | null;
display_name: string | null;
since: number;
}): FriendEntry {
return {
user_id: row.user_id,
username: row.username,
display_name: row.display_name,
since: row.since,
};
}
// --- GET /api/friends ------------------------------------------------------
// My outgoing list (people I've added).
friendsRoutes.get('/', requireAuth, (c) => {
const userId = c.get('userId');
const rows = getDb()
.prepare(`
SELECT f.friend_id AS user_id, u.username, u.display_name,
f.created_at AS since
FROM friends f
JOIN users u ON u.id = f.friend_id
WHERE f.owner_id = ?
ORDER BY f.created_at DESC
`)
.all(userId) as { user_id: string; username: string | null; display_name: string | null; since: number }[];
return c.json(rows.map(rowToEntry));
});
// --- GET /api/friends/incoming ---------------------------------------------
// Who has added ME as a friend.
friendsRoutes.get('/incoming', requireAuth, (c) => {
const userId = c.get('userId');
const rows = getDb()
.prepare(`
SELECT f.owner_id AS user_id, u.username, u.display_name,
f.created_at AS since
FROM friends f
JOIN users u ON u.id = f.owner_id
WHERE f.friend_id = ?
ORDER BY f.created_at DESC
`)
.all(userId) as { user_id: string; username: string | null; display_name: string | null; since: number }[];
return c.json(rows.map(rowToEntry));
});
// --- POST /api/friends -----------------------------------------------------
// Add a friend by username. Idempotent — re-adding is a no-op rather than 409.
friendsRoutes.post('/', requireAuth, async (c) => {
const userId = c.get('userId');
const body = (await c.req.json().catch(() => null)) as AddByUsernameRequest | null;
if (!body || typeof body.username !== 'string' || !body.username.trim()) {
return c.json({ error: 'missing:username' }, 400);
}
const target = lookupByUsername(body.username);
if (!target) return c.json({ error: 'user_not_found' }, 404);
if (target.id === userId) return c.json({ error: 'cannot_friend_self' }, 400);
getDb()
.prepare(
'INSERT OR IGNORE INTO friends (owner_id, friend_id, created_at) VALUES (?, ?, ?)',
)
.run(userId, target.id, Date.now());
const since = (getDb()
.prepare('SELECT created_at FROM friends WHERE owner_id = ? AND friend_id = ?')
.get(userId, target.id) as { created_at: number }).created_at;
const entry: FriendEntry = {
user_id: target.id,
username: target.username,
display_name: target.display_name,
since,
};
return c.json(entry, 201);
});
// --- DELETE /api/friends/:userId -------------------------------------------
friendsRoutes.delete('/:userId', requireAuth, (c) => {
const userId = c.get('userId');
const targetId = c.req.param('userId');
getDb().prepare('DELETE FROM friends WHERE owner_id = ? AND friend_id = ?').run(userId, targetId);
return c.json({ ok: true });
});
// --- GET /api/friends/blocks -----------------------------------------------
// My blocked-users list. Routed under /friends so the UI surface stays in
// one place (friends + blocks are part of the same "people I deal with" page).
friendsRoutes.get('/blocks', requireAuth, (c) => {
const userId = c.get('userId');
const rows = getDb()
.prepare(`
SELECT b.blocked_id AS user_id, u.username, u.display_name,
b.created_at AS since
FROM user_blocks b
JOIN users u ON u.id = b.blocked_id
WHERE b.blocker_id = ?
ORDER BY b.created_at DESC
`)
.all(userId) as { user_id: string; username: string | null; display_name: string | null; since: number }[];
const entries: BlockEntry[] = rows.map(rowToEntry);
return c.json(entries);
});
// --- POST /api/friends/blocks ----------------------------------------------
// Block by user_id (the incoming list already gives us the id; no username
// dance needed). Block is idempotent.
friendsRoutes.post('/blocks', requireAuth, async (c) => {
const userId = c.get('userId');
const body = (await c.req.json().catch(() => null)) as { user_id?: string } | null;
if (!body || typeof body.user_id !== 'string' || !body.user_id.trim()) {
return c.json({ error: 'missing:user_id' }, 400);
}
if (body.user_id === userId) return c.json({ error: 'cannot_block_self' }, 400);
const target = getDb()
.prepare('SELECT id, username, display_name FROM users WHERE id = ?')
.get(body.user_id) as UserLookupRow | null;
if (!target) return c.json({ error: 'user_not_found' }, 404);
getDb()
.prepare(
'INSERT OR IGNORE INTO user_blocks (blocker_id, blocked_id, created_at) VALUES (?, ?, ?)',
)
.run(userId, target.id, Date.now());
const since = (getDb()
.prepare('SELECT created_at FROM user_blocks WHERE blocker_id = ? AND blocked_id = ?')
.get(userId, target.id) as { created_at: number }).created_at;
const entry: BlockEntry = {
user_id: target.id,
username: target.username,
display_name: target.display_name,
since,
};
return c.json(entry, 201);
});
// --- DELETE /api/friends/blocks/:userId ------------------------------------
friendsRoutes.delete('/blocks/:userId', requireAuth, (c) => {
const userId = c.get('userId');
const targetId = c.req.param('userId');
getDb().prepare('DELETE FROM user_blocks WHERE blocker_id = ? AND blocked_id = ?').run(userId, targetId);
return c.json({ ok: true });
});

View file

@ -9,6 +9,7 @@ import { feedbackRoutes } from './feedback';
import { adminRoutes } from './admin';
import { settingsRoutes } from './settings';
import { invitesRoutes } from './invites';
import { friendsRoutes } from './friends';
// Initialise DB up front so the server fails fast on schema problems.
getDb();
@ -33,6 +34,7 @@ app.route('/api/feedback', feedbackRoutes);
app.route('/api/admin', adminRoutes);
app.route('/api/settings', settingsRoutes);
app.route('/api/invites', invitesRoutes);
app.route('/api/friends', friendsRoutes);
// In production, serve the built Svelte SPA. The static helper is registered
// for the asset directory and for the top-level files that Vite copies from