From f39fe9ed65c13dafb4d81bfb6624e8c7fb6a723e Mon Sep 17 00:00:00 2001
From: Ole-Morten Duesund
Date: Mon, 25 May 2026 14:47:20 +0200
Subject: [PATCH] Friends + friends-only visibility + blocking
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
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).
---
frontend/src/components/ActivityForm.svelte | 1 +
frontend/src/components/ActivityRow.svelte | 8 +-
frontend/src/components/FriendsPanel.svelte | 184 ++++++++++++++++++++
frontend/src/components/Home.svelte | 14 ++
frontend/src/components/Personvern.svelte | 30 +++-
frontend/src/components/Profile.svelte | 3 +
frontend/src/lib/api.ts | 14 ++
frontend/src/lib/export.ts | 2 +-
frontend/src/styles.css | 4 +
server/activities.ts | 72 +++++++-
server/db.ts | 98 ++++++++++-
server/friends.ts | 183 +++++++++++++++++++
server/index.ts | 2 +
shared/types.ts | 50 +++++-
14 files changed, 657 insertions(+), 8 deletions(-)
create mode 100644 frontend/src/components/FriendsPanel.svelte
create mode 100644 server/friends.ts
diff --git a/frontend/src/components/ActivityForm.svelte b/frontend/src/components/ActivityForm.svelte
index ef0ad95..986df24 100644
--- a/frontend/src/components/ActivityForm.svelte
+++ b/frontend/src/components/ActivityForm.svelte
@@ -188,6 +188,7 @@
diff --git a/frontend/src/components/ActivityRow.svelte b/frontend/src/components/ActivityRow.svelte
index 47f8a61..c0f9276 100644
--- a/frontend/src/components/ActivityRow.svelte
+++ b/frontend/src/components/ActivityRow.svelte
@@ -222,7 +222,13 @@
{activity.title}
- {activity.visibility === 'semi' ? 'Anonym' : 'Offentlig'}
+ {#if activity.visibility === 'semi'}
+ Anonym
+ {:else if activity.visibility === 'friends'}
+ Venner
+ {:else}
+ Offentlig
+ {/if}
{#if activity.description}
diff --git a/frontend/src/components/FriendsPanel.svelte b/frontend/src/components/FriendsPanel.svelte
new file mode 100644
index 0000000..7985f03
--- /dev/null
+++ b/frontend/src/components/FriendsPanel.svelte
@@ -0,0 +1,184 @@
+
+
+
+
Venner
+
+ Vennskap er enveis. Du kan se aktiviteter merket «Venner» fra noen som
+ har lagt deg til, og motsatt. Brukere må ha satt et brukernavn for å
+ være søkbare.
+
{#each semi as a (a.id)}
diff --git a/frontend/src/components/Personvern.svelte b/frontend/src/components/Personvern.svelte
index 8b6dcb7..d6efa48 100644
--- a/frontend/src/components/Personvern.svelte
+++ b/frontend/src/components/Personvern.svelte
@@ -21,7 +21,7 @@
serveren faktisk ser av det du skriver.
-
De tre synlighetsnivåene
+
De fire synlighetsnivåene
Privat: oppføringen krypteres i nettleseren din før den
@@ -29,6 +29,13 @@
databasen, kan lese innholdet. Bare du kan låse den opp — med passordet
ditt eller gjenopprettingskoden.
+
+ Venner: kun synlig for brukere du har lagt til som
+ venner. Dette er en tilgangsliste, ikke kryptografi — innholdet
+ lagres i klartekst hos oss, men vi serverer det bare til de du har valgt.
+ Det betyr at vi (eller noen med databasetilgang) i prinsippet kan lese
+ det, til forskjell fra «privat». Velg «privat» om innholdet er sensitivt.
+
Anonym (halv-offentlig): innholdet er synlig for alle,
men navnet ditt vises ikke ved siden av. Vi lagrer en intern referanse
@@ -42,6 +49,27 @@
+
Venner og blokkering
+
+ Vennskap i Vinterliste er enveis. Du kan legge til en bruker som venn
+ uten at de må godkjenne — det er din egen «hvem får se mine venner-bare-
+ oppføringer»-liste. Den du legger til, ser i sin tur at du har lagt dem
+ til, og kan velge å gjøre det samme andre veien om de vil.
+
+
+ Brukere må ha satt opp et brukernavn (URL-vennlig kortform) for å være
+ søkbare. Vi bruker brukernavnet for å finne hverandre — ikke eposten, som
+ er en kontaktidentifikator vi ikke deler.
+
+
+ Hvis noen har lagt deg til som venn, og du heller vil slippe det, kan du
+ blokkere dem. Blokkering virker symmetrisk på
+ venner-bare-innhold: når én av dere har blokkert den andre, ser ingen av
+ dere lenger venner-bare-oppføringer fra den andre. Blokkering påvirker
+ ikke offentlige eller anonyme oppføringer — det som er åpent for alle, er
+ åpent for alle.
+
+
Ende-til-ende-kryptering, kort forklart
For private oppføringer skjer all kryptering i nettleseren din. Når du
diff --git a/frontend/src/components/Profile.svelte b/frontend/src/components/Profile.svelte
index 7d584b1..b2f87da 100644
--- a/frontend/src/components/Profile.svelte
+++ b/frontend/src/components/Profile.svelte
@@ -4,6 +4,7 @@
import { changePassword } from '../lib/auth';
import { session } from '../lib/session.svelte';
import { downloadExport } from '../lib/export';
+ import FriendsPanel from './FriendsPanel.svelte';
import type { InviteEntry } from '../../../shared/types';
interface Props {
@@ -216,6 +217,8 @@
+
+
Eksporter
diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts
index 3fb4e0a..47d9421 100644
--- a/frontend/src/lib/api.ts
+++ b/frontend/src/lib/api.ts
@@ -6,6 +6,7 @@ import type {
PublicListResponse, FeedbackSubmitRequest, FeedbackEntry, FeedbackUpdateRequest,
AdminUser, AdminRoleUpdate,
PublicSettings, SettingsUpdateRequest, InviteEntry,
+ FriendEntry, BlockEntry, AddByUsernameRequest,
} from '../../../shared/types';
const BASE = '/api';
@@ -119,4 +120,17 @@ export const api = {
createInvite: () => http('/invites', { method: 'POST' }),
cancelInvite: (token: string) =>
http<{ ok: true }>(`/invites/${encodeURIComponent(token)}`, { method: 'DELETE' }),
+
+ // --- friends & blocks -----------------------------------------------------
+ listFriends: () => http('/friends'),
+ listIncomingFriends: () => http('/friends/incoming'),
+ addFriend: (body: AddByUsernameRequest) =>
+ http('/friends', { method: 'POST', body: JSON.stringify(body) }),
+ removeFriend: (userId: string) =>
+ http<{ ok: true }>(`/friends/${encodeURIComponent(userId)}`, { method: 'DELETE' }),
+ listBlocks: () => http('/friends/blocks'),
+ blockUser: (userId: string) =>
+ http('/friends/blocks', { method: 'POST', body: JSON.stringify({ user_id: userId }) }),
+ unblockUser: (userId: string) =>
+ http<{ ok: true }>(`/friends/blocks/${encodeURIComponent(userId)}`, { method: 'DELETE' }),
};
diff --git a/frontend/src/lib/export.ts b/frontend/src/lib/export.ts
index 6c0f9e3..8e7cd79 100644
--- a/frontend/src/lib/export.ts
+++ b/frontend/src/lib/export.ts
@@ -33,7 +33,7 @@ interface NormalisedRow {
tags: string[];
loc_label: string | null;
scheduled_at: number | null;
- visibility: 'private' | 'semi' | 'public';
+ visibility: 'private' | 'semi' | 'public' | 'friends';
}
/** Decrypt a private activity into the same shape as semi/public rows. */
diff --git a/frontend/src/styles.css b/frontend/src/styles.css
index eba111e..e28ea99 100644
--- a/frontend/src/styles.css
+++ b/frontend/src/styles.css
@@ -192,6 +192,10 @@ nav.top > .row {
.vis-badge.private { background: rgba(31,111,235,0.15); color: var(--accent); }
.vis-badge.semi { background: rgba(127,127,127,0.18); color: var(--muted); }
.vis-badge.public { background: rgba(46,160,67,0.18); color: #2ea043; }
+.vis-badge.friends { background: rgba(241,165,40,0.20); color: #b67100; }
+@media (prefers-color-scheme: dark) {
+ .vis-badge.friends { color: #f1a528; }
+}
.error { color: var(--danger); margin-top: 0.5rem; }
diff --git a/server/activities.ts b/server/activities.ts
index bf41b7b..f5b79be 100644
--- a/server/activities.ts
+++ b/server/activities.ts
@@ -20,7 +20,7 @@ import type {
*/
export const activitiesRoutes = new Hono<{ Variables: AppVariables }>();
-const VALID_VIS = new Set(['private', 'semi', 'public']);
+const VALID_VIS = new Set(['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));
});
diff --git a/server/db.ts b/server/db.ts
index 100aa86..97b30ca 100644
--- a/server/db.ts
+++ b/server/db.ts
@@ -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
diff --git a/server/friends.ts b/server/friends.ts
new file mode 100644
index 0000000..35338ee
--- /dev/null
+++ b/server/friends.ts
@@ -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 });
+});
diff --git a/server/index.ts b/server/index.ts
index ccf3cc4..f34203b 100644
--- a/server/index.ts
+++ b/server/index.ts
@@ -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
diff --git a/shared/types.ts b/shared/types.ts
index e402b0f..c048884 100644
--- a/shared/types.ts
+++ b/shared/types.ts
@@ -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;