From 1b63ca538ecc196f280672a6106eb7b9a9744bb9 Mon Sep 17 00:00:00 2001 From: Jannis Braun <151788261+TheZwiss@users.noreply.github.com> Date: Sat, 25 Apr 2026 21:14:58 +0200 Subject: [PATCH 01/25] feat(federation): normalizeOriginForCompare helper --- .../server/src/utils/federationAuth.test.ts | 30 +++++++++++++++++++ packages/server/src/utils/federationAuth.ts | 28 +++++++++++++++++ 2 files changed, 58 insertions(+) diff --git a/packages/server/src/utils/federationAuth.test.ts b/packages/server/src/utils/federationAuth.test.ts index 4b0a55b7..af7151d3 100644 --- a/packages/server/src/utils/federationAuth.test.ts +++ b/packages/server/src/utils/federationAuth.test.ts @@ -78,3 +78,33 @@ describe('verifyPeerSignature', () => { expect(verifyPeerSignature(body, sig, timestamp, nonce, peer)).toBe(false); }); }); + +import { normalizeOriginForCompare } from './federationAuth.js'; + +describe('normalizeOriginForCompare', () => { + it('canonicalizes a bare host', () => { + expect(normalizeOriginForCompare('nova.ddns.net')).toBe('nova.ddns.net'); + }); + it('strips https:// scheme', () => { + expect(normalizeOriginForCompare('https://nova.ddns.net')).toBe('nova.ddns.net'); + }); + it('strips http:// scheme', () => { + expect(normalizeOriginForCompare('http://localhost:3005')).toBe('localhost:3005'); + }); + it('strips trailing slash', () => { + expect(normalizeOriginForCompare('https://nova.ddns.net/')).toBe('nova.ddns.net'); + }); + it('lowercases the host', () => { + expect(normalizeOriginForCompare('HTTPS://Nova.DDNS.net')).toBe('nova.ddns.net'); + }); + it('returns null for null input', () => { + expect(normalizeOriginForCompare(null)).toBeNull(); + }); + it('returns null for empty string', () => { + expect(normalizeOriginForCompare('')).toBeNull(); + }); + it('treats bare and full-URL forms as equal', () => { + expect(normalizeOriginForCompare('nova.ddns.net')) + .toBe(normalizeOriginForCompare('https://nova.ddns.net')); + }); +}); diff --git a/packages/server/src/utils/federationAuth.ts b/packages/server/src/utils/federationAuth.ts index 7e47eb2a..c80d05e1 100644 --- a/packages/server/src/utils/federationAuth.ts +++ b/packages/server/src/utils/federationAuth.ts @@ -182,3 +182,31 @@ export function getOurOrigin(): string { } return `http://localhost:${config.port}`; } + +/** + * Canonicalize a homeInstance / origin value for comparison. + * + * The homeInstance column is stored in two shapes depending on the code path + * that wrote it: + * - `auth.ts` registration writes the bare host the client sent (e.g. `nova.ddns.net`). + * - `resolveOrCreateReplicatedUser` writes the bare host (`extractDomain(...)`). + * - `getOurOrigin()` returns the full URL (`https://nova.ddns.net`). + * + * All federation authority / self-friend comparisons must route through this + * helper to avoid false-fires across the dual storage convention. Returns the + * lowercased host (with optional :port), no scheme, no trailing slash. + * + * NOTE: A federation-wide audit + canonical-storage migration is tracked + * separately. This helper papers over the inconsistency at comparison sites. + */ +export function normalizeOriginForCompare(value: string | null | undefined): string | null { + if (!value) return null; + let s = value.trim(); + if (!s) return null; + // Strip scheme if present + s = s.replace(/^https?:\/\//i, ''); + // Strip trailing slashes + s = s.replace(/\/+$/, ''); + if (!s) return null; + return s.toLowerCase(); +} From 1b98b052a157ba0221e1d2768dd66790cc3a5599 Mon Sep 17 00:00:00 2001 From: Jannis Braun <151788261+TheZwiss@users.noreply.github.com> Date: Sat, 25 Apr 2026 21:18:11 +0200 Subject: [PATCH 02/25] =?UTF-8?q?chore(federation):=20T1=20review=20polish?= =?UTF-8?q?=20=E2=80=94=20top-of-file=20import=20+=20undefined=20test=20ca?= =?UTF-8?q?se?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/server/src/utils/federationAuth.test.ts | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/packages/server/src/utils/federationAuth.test.ts b/packages/server/src/utils/federationAuth.test.ts index af7151d3..83601e16 100644 --- a/packages/server/src/utils/federationAuth.test.ts +++ b/packages/server/src/utils/federationAuth.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from 'vitest'; -import { verifyPeerSignature, signRequest, ROTATION_GRACE_PERIOD_MS } from './federationAuth.js'; +import { verifyPeerSignature, signRequest, ROTATION_GRACE_PERIOD_MS, normalizeOriginForCompare } from './federationAuth.js'; describe('verifyPeerSignature', () => { const primarySecret = 'a'.repeat(64); @@ -79,8 +79,6 @@ describe('verifyPeerSignature', () => { }); }); -import { normalizeOriginForCompare } from './federationAuth.js'; - describe('normalizeOriginForCompare', () => { it('canonicalizes a bare host', () => { expect(normalizeOriginForCompare('nova.ddns.net')).toBe('nova.ddns.net'); @@ -100,6 +98,9 @@ describe('normalizeOriginForCompare', () => { it('returns null for null input', () => { expect(normalizeOriginForCompare(null)).toBeNull(); }); + it('returns null for undefined input', () => { + expect(normalizeOriginForCompare(undefined)).toBeNull(); + }); it('returns null for empty string', () => { expect(normalizeOriginForCompare('')).toBeNull(); }); From 1a9dc547c43116eea93d5bd63117e8aa3a44f487 Mon Sep 17 00:00:00 2001 From: Jannis Braun <151788261+TheZwiss@users.noreply.github.com> Date: Sat, 25 Apr 2026 21:19:40 +0200 Subject: [PATCH 03/25] feat(shared): types for federation user lookup + WS events Add FederationUserLookupRequest, FederationUserLookupProfile, and FederationUserLookupResponse for the peer lookup endpoint contract. Add friend_request_sent and friend_request_relay_failed ServerEvent variants alongside existing friend_request_* cases. --- packages/shared/src/types.ts | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/packages/shared/src/types.ts b/packages/shared/src/types.ts index 29faa8ec..678c9121 100644 --- a/packages/shared/src/types.ts +++ b/packages/shared/src/types.ts @@ -438,6 +438,8 @@ export type ServerEvent = | { type: 'friend_removed'; userId: string } | { type: 'friend_request_cancelled'; requestId: string; userId: string } | { type: 'friend_request_declined'; requestId: string; userId: string } + | { type: 'friend_request_sent'; request: FriendRequest } + | { type: 'friend_request_relay_failed'; requestId: string; reason: 'user_not_found' | 'peer_rejected'; message: string; targetHandle: string } | { type: 'channel_created'; channel: Channel; spaceId: string } | { type: 'channel_updated'; channel: Channel; spaceId: string } | { type: 'channel_deleted'; channelId: string; spaceId: string } @@ -993,6 +995,22 @@ export interface FederationSyncResponse { checkpoint: number; } +export interface FederationUserLookupRequest { + username: string; +} + +export interface FederationUserLookupProfile { + displayName: string | null; + avatar: string | null; + avatarColor: AvatarColor | null; + banner: string | null; + bio: string | null; +} + +export type FederationUserLookupResponse = + | { found: true; user: { homeUserId: string; username: string; profile: FederationUserLookupProfile } } + | { found: false; code: 'user_not_found' }; + export interface FederationPeer { id: string; origin: string; From e4086fecfff05184c8eb4f7a06b8385d58d4d6af Mon Sep 17 00:00:00 2001 From: Jannis Braun <151788261+TheZwiss@users.noreply.github.com> Date: Sat, 25 Apr 2026 21:22:25 +0200 Subject: [PATCH 04/25] feat(db): add relay_message_id column to friend_requests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Nullable indexed column tracking the entityId of the originating relay event for federated friend requests. Used by the rollback hook in federationRollback.ts to locate and delete rows when a federated friend_request_create is permanently rejected (spec §5). --- packages/server/drizzle/0001_complex_screwball.sql | 2 ++ packages/server/src/db/schema.ts | 2 ++ 2 files changed, 4 insertions(+) create mode 100644 packages/server/drizzle/0001_complex_screwball.sql diff --git a/packages/server/drizzle/0001_complex_screwball.sql b/packages/server/drizzle/0001_complex_screwball.sql new file mode 100644 index 00000000..52805603 --- /dev/null +++ b/packages/server/drizzle/0001_complex_screwball.sql @@ -0,0 +1,2 @@ +ALTER TABLE `friend_requests` ADD `relay_message_id` text;--> statement-breakpoint +CREATE INDEX `idx_friend_requests_relay_message_id` ON `friend_requests` (`relay_message_id`); \ No newline at end of file diff --git a/packages/server/src/db/schema.ts b/packages/server/src/db/schema.ts index 18d92291..b2f18154 100644 --- a/packages/server/src/db/schema.ts +++ b/packages/server/src/db/schema.ts @@ -194,9 +194,11 @@ export const friendRequests = sqliteTable('friend_requests', { toId: text('to_id').notNull().references(() => users.id, { onDelete: 'cascade' }), status: text('status').default('pending'), // 'pending', 'accepted', 'declined' createdAt: integer('created_at').notNull(), + relayMessageId: text('relay_message_id'), // entityId of the originating relay event; null for local-only requests. Used by the rollback hook in federationRollback.ts to find rows for deletion when a federated friend_request_create is permanently rejected. }, (table) => ({ toIdx: index('idx_friend_requests_to_id').on(table.toId), fromIdx: index('idx_friend_requests_from_id').on(table.fromId), + relayMessageIdx: index('idx_friend_requests_relay_message_id').on(table.relayMessageId), })); export const reactions = sqliteTable('reactions', { From bd9657c5d2d15b046b058aeedb515974ce820363 Mon Sep 17 00:00:00 2001 From: Jannis Braun <151788261+TheZwiss@users.noreply.github.com> Date: Sat, 25 Apr 2026 21:22:56 +0200 Subject: [PATCH 05/25] fix(db): commit drizzle meta journal/snapshot for 0001 migration The T3 commit (e4086fe) generated the SQL migration but missed the drizzle meta files that track migration state. Without these, future db:generate runs would re-emit or skew migration ordering. --- .../server/drizzle/meta/0001_snapshot.json | 3239 +++++++++++++++++ packages/server/drizzle/meta/_journal.json | 7 + 2 files changed, 3246 insertions(+) create mode 100644 packages/server/drizzle/meta/0001_snapshot.json diff --git a/packages/server/drizzle/meta/0001_snapshot.json b/packages/server/drizzle/meta/0001_snapshot.json new file mode 100644 index 00000000..8e9d03ee --- /dev/null +++ b/packages/server/drizzle/meta/0001_snapshot.json @@ -0,0 +1,3239 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "48befb5f-efdc-4356-b8b0-8d8a06339790", + "prevId": "b5b7d74f-4158-4798-9f01-2cbab126f9da", + "tables": { + "attachments": { + "name": "attachments", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "message_id": { + "name": "message_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "dm_message_id": { + "name": "dm_message_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "uploader_id": { + "name": "uploader_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "filename": { + "name": "filename", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "original_name": { + "name": "original_name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "mimetype": { + "name": "mimetype", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "size": { + "name": "size", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "thumbnail_filename": { + "name": "thumbnail_filename", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "width": { + "name": "width", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "height": { + "name": "height", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "duration": { + "name": "duration", + "type": "real", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "source_url": { + "name": "source_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "federation_status": { + "name": "federation_status", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "federation_meta": { + "name": "federation_meta", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "idx_attachments_message_id": { + "name": "idx_attachments_message_id", + "columns": [ + "message_id" + ], + "isUnique": false + }, + "idx_attachments_dm_message_id": { + "name": "idx_attachments_dm_message_id", + "columns": [ + "dm_message_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "attachments_message_id_messages_id_fk": { + "name": "attachments_message_id_messages_id_fk", + "tableFrom": "attachments", + "tableTo": "messages", + "columnsFrom": [ + "message_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "attachments_dm_message_id_dm_messages_id_fk": { + "name": "attachments_dm_message_id_dm_messages_id_fk", + "tableFrom": "attachments", + "tableTo": "dm_messages", + "columnsFrom": [ + "dm_message_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "bans": { + "name": "bans", + "columns": { + "space_id": { + "name": "space_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "reason": { + "name": "reason", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "banned_by": { + "name": "banned_by", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "idx_bans_space_id": { + "name": "idx_bans_space_id", + "columns": [ + "space_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "bans_space_id_spaces_id_fk": { + "name": "bans_space_id_spaces_id_fk", + "tableFrom": "bans", + "tableTo": "spaces", + "columnsFrom": [ + "space_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "bans_user_id_users_id_fk": { + "name": "bans_user_id_users_id_fk", + "tableFrom": "bans", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "bans_banned_by_users_id_fk": { + "name": "bans_banned_by_users_id_fk", + "tableFrom": "bans", + "tableTo": "users", + "columnsFrom": [ + "banned_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "bans_space_id_user_id_pk": { + "columns": [ + "space_id", + "user_id" + ], + "name": "bans_space_id_user_id_pk" + } + }, + "uniqueConstraints": {} + }, + "category_overrides": { + "name": "category_overrides", + "columns": { + "category_id": { + "name": "category_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "target_type": { + "name": "target_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "target_id": { + "name": "target_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "allow": { + "name": "allow", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'0'" + }, + "deny": { + "name": "deny", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'0'" + } + }, + "indexes": { + "idx_category_overrides_category_id": { + "name": "idx_category_overrides_category_id", + "columns": [ + "category_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "category_overrides_category_id_channel_categories_id_fk": { + "name": "category_overrides_category_id_channel_categories_id_fk", + "tableFrom": "category_overrides", + "tableTo": "channel_categories", + "columnsFrom": [ + "category_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "category_overrides_category_id_target_type_target_id_pk": { + "columns": [ + "category_id", + "target_type", + "target_id" + ], + "name": "category_overrides_category_id_target_type_target_id_pk" + } + }, + "uniqueConstraints": {} + }, + "channel_categories": { + "name": "channel_categories", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "space_id": { + "name": "space_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "position": { + "name": "position", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "idx_channel_categories_space_id": { + "name": "idx_channel_categories_space_id", + "columns": [ + "space_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "channel_categories_space_id_spaces_id_fk": { + "name": "channel_categories_space_id_spaces_id_fk", + "tableFrom": "channel_categories", + "tableTo": "spaces", + "columnsFrom": [ + "space_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "channel_overrides": { + "name": "channel_overrides", + "columns": { + "channel_id": { + "name": "channel_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "target_type": { + "name": "target_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "target_id": { + "name": "target_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "allow": { + "name": "allow", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'0'" + }, + "deny": { + "name": "deny", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'0'" + } + }, + "indexes": { + "idx_channel_overrides_channel_id": { + "name": "idx_channel_overrides_channel_id", + "columns": [ + "channel_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "channel_overrides_channel_id_channels_id_fk": { + "name": "channel_overrides_channel_id_channels_id_fk", + "tableFrom": "channel_overrides", + "tableTo": "channels", + "columnsFrom": [ + "channel_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "channel_overrides_channel_id_target_type_target_id_pk": { + "columns": [ + "channel_id", + "target_type", + "target_id" + ], + "name": "channel_overrides_channel_id_target_type_target_id_pk" + } + }, + "uniqueConstraints": {} + }, + "channels": { + "name": "channels", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "space_id": { + "name": "space_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "topic": { + "name": "topic", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "position": { + "name": "position", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 0 + }, + "category_id": { + "name": "category_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "idx_channels_space_id": { + "name": "idx_channels_space_id", + "columns": [ + "space_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "channels_space_id_spaces_id_fk": { + "name": "channels_space_id_spaces_id_fk", + "tableFrom": "channels", + "tableTo": "spaces", + "columnsFrom": [ + "space_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "dm_channels": { + "name": "dm_channels", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "owner_id": { + "name": "owner_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "federated_id": { + "name": "federated_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "owner_home_user_id": { + "name": "owner_home_user_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "owner_home_instance": { + "name": "owner_home_instance", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "idx_dm_federated": { + "name": "idx_dm_federated", + "columns": [ + "federated_id" + ], + "isUnique": true, + "where": "federated_id IS NOT NULL" + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "dm_members": { + "name": "dm_members", + "columns": { + "dm_channel_id": { + "name": "dm_channel_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "closed": { + "name": "closed", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 0 + } + }, + "indexes": { + "idx_dm_members_user_id": { + "name": "idx_dm_members_user_id", + "columns": [ + "user_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "dm_members_dm_channel_id_dm_channels_id_fk": { + "name": "dm_members_dm_channel_id_dm_channels_id_fk", + "tableFrom": "dm_members", + "tableTo": "dm_channels", + "columnsFrom": [ + "dm_channel_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "dm_members_user_id_users_id_fk": { + "name": "dm_members_user_id_users_id_fk", + "tableFrom": "dm_members", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "dm_members_dm_channel_id_user_id_pk": { + "columns": [ + "dm_channel_id", + "user_id" + ], + "name": "dm_members_dm_channel_id_user_id_pk" + } + }, + "uniqueConstraints": {} + }, + "dm_messages": { + "name": "dm_messages", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "dm_channel_id": { + "name": "dm_channel_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "reply_to_id": { + "name": "reply_to_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'user'" + }, + "edited_at": { + "name": "edited_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "source_instance": { + "name": "source_instance", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "source_message_id": { + "name": "source_message_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "encryption_version": { + "name": "encryption_version", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "idx_dm_messages_dm_channel_id": { + "name": "idx_dm_messages_dm_channel_id", + "columns": [ + "dm_channel_id" + ], + "isUnique": false + }, + "idx_dm_messages_user_id": { + "name": "idx_dm_messages_user_id", + "columns": [ + "user_id" + ], + "isUnique": false + }, + "idx_dm_messages_source_unique": { + "name": "idx_dm_messages_source_unique", + "columns": [ + "source_instance", + "source_message_id" + ], + "isUnique": true, + "where": "source_instance IS NOT NULL" + } + }, + "foreignKeys": { + "dm_messages_dm_channel_id_dm_channels_id_fk": { + "name": "dm_messages_dm_channel_id_dm_channels_id_fk", + "tableFrom": "dm_messages", + "tableTo": "dm_channels", + "columnsFrom": [ + "dm_channel_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "dm_messages_user_id_users_id_fk": { + "name": "dm_messages_user_id_users_id_fk", + "tableFrom": "dm_messages", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "dm_messages_reply_to_id_dm_messages_id_fk": { + "name": "dm_messages_reply_to_id_dm_messages_id_fk", + "tableFrom": "dm_messages", + "tableTo": "dm_messages", + "columnsFrom": [ + "reply_to_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "dm_reactions": { + "name": "dm_reactions", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "dm_message_id": { + "name": "dm_message_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "emoji": { + "name": "emoji", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "idx_dm_reactions_dm_message_id": { + "name": "idx_dm_reactions_dm_message_id", + "columns": [ + "dm_message_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "dm_reactions_dm_message_id_dm_messages_id_fk": { + "name": "dm_reactions_dm_message_id_dm_messages_id_fk", + "tableFrom": "dm_reactions", + "tableTo": "dm_messages", + "columnsFrom": [ + "dm_message_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "dm_reactions_user_id_users_id_fk": { + "name": "dm_reactions_user_id_users_id_fk", + "tableFrom": "dm_reactions", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "embeds": { + "name": "embeds", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "message_id": { + "name": "message_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "dm_message_id": { + "name": "dm_message_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "embed_type": { + "name": "embed_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "embed_url": { + "name": "embed_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "width": { + "name": "width", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "height": { + "name": "height", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "color": { + "name": "color", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "idx_embeds_message_id": { + "name": "idx_embeds_message_id", + "columns": [ + "message_id" + ], + "isUnique": false + }, + "idx_embeds_dm_message_id": { + "name": "idx_embeds_dm_message_id", + "columns": [ + "dm_message_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "embeds_message_id_messages_id_fk": { + "name": "embeds_message_id_messages_id_fk", + "tableFrom": "embeds", + "tableTo": "messages", + "columnsFrom": [ + "message_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "embeds_dm_message_id_dm_messages_id_fk": { + "name": "embeds_dm_message_id_dm_messages_id_fk", + "tableFrom": "embeds", + "tableTo": "dm_messages", + "columnsFrom": [ + "dm_message_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "federation_file_queue": { + "name": "federation_file_queue", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "peer_origin": { + "name": "peer_origin", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "dm_message_id": { + "name": "dm_message_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "source_url": { + "name": "source_url", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "target_filename": { + "name": "target_filename", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "original_name": { + "name": "original_name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "mimetype": { + "name": "mimetype", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "size": { + "name": "size", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'pending'" + }, + "rejection_reason": { + "name": "rejection_reason", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "attempts": { + "name": "attempts", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 0 + }, + "next_retry_at": { + "name": "next_retry_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "federation_mutation_log": { + "name": "federation_mutation_log", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "entity_id": { + "name": "entity_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "context_id": { + "name": "context_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "context_type": { + "name": "context_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'dm'" + }, + "mutation_type": { + "name": "mutation_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "mutated_at": { + "name": "mutated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "payload": { + "name": "payload", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "idx_mutation_log_time": { + "name": "idx_mutation_log_time", + "columns": [ + "mutated_at" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "federation_outbox": { + "name": "federation_outbox", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "peer_id": { + "name": "peer_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "context_id": { + "name": "context_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "entity_id": { + "name": "entity_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "context_type": { + "name": "context_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'dm'" + }, + "event_type": { + "name": "event_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "payload": { + "name": "payload", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "encryption_version": { + "name": "encryption_version", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 0 + }, + "attempts": { + "name": "attempts", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 0 + }, + "next_retry_at": { + "name": "next_retry_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "idx_outbox_retry": { + "name": "idx_outbox_retry", + "columns": [ + "next_retry_at" + ], + "isUnique": false + }, + "federation_outbox_peer_id_entity_id_unique": { + "name": "federation_outbox_peer_id_entity_id_unique", + "columns": [ + "peer_id", + "entity_id" + ], + "isUnique": true + } + }, + "foreignKeys": { + "federation_outbox_peer_id_federation_peers_id_fk": { + "name": "federation_outbox_peer_id_federation_peers_id_fk", + "tableFrom": "federation_outbox", + "tableTo": "federation_peers", + "columnsFrom": [ + "peer_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "federation_peers": { + "name": "federation_peers", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "origin": { + "name": "origin", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "instance_name": { + "name": "instance_name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "hmac_secret": { + "name": "hmac_secret", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'active'" + }, + "last_seen_at": { + "name": "last_seen_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_failure_at": { + "name": "last_failure_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "consecutive_failures": { + "name": "consecutive_failures", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "consecutive_auth_failures": { + "name": "consecutive_auth_failures", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "last_synced_at": { + "name": "last_synced_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 0 + }, + "remote_max_upload_size": { + "name": "remote_max_upload_size", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "nonce_supported": { + "name": "nonce_supported", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "pending_hmac_secret": { + "name": "pending_hmac_secret", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "secret_rotation_at": { + "name": "secret_rotation_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "secret_rotated_at": { + "name": "secret_rotated_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "auto_rotate_interval_days": { + "name": "auto_rotate_interval_days", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 90 + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "federation_peers_origin_unique": { + "name": "federation_peers_origin_unique", + "columns": [ + "origin" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "friend_requests": { + "name": "friend_requests", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "from_id": { + "name": "from_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "to_id": { + "name": "to_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "'pending'" + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "relay_message_id": { + "name": "relay_message_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "idx_friend_requests_to_id": { + "name": "idx_friend_requests_to_id", + "columns": [ + "to_id" + ], + "isUnique": false + }, + "idx_friend_requests_from_id": { + "name": "idx_friend_requests_from_id", + "columns": [ + "from_id" + ], + "isUnique": false + }, + "idx_friend_requests_relay_message_id": { + "name": "idx_friend_requests_relay_message_id", + "columns": [ + "relay_message_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "friend_requests_from_id_users_id_fk": { + "name": "friend_requests_from_id_users_id_fk", + "tableFrom": "friend_requests", + "tableTo": "users", + "columnsFrom": [ + "from_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "friend_requests_to_id_users_id_fk": { + "name": "friend_requests_to_id_users_id_fk", + "tableFrom": "friend_requests", + "tableTo": "users", + "columnsFrom": [ + "to_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "friends": { + "name": "friends", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "friend_id": { + "name": "friend_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "idx_friends_user_id": { + "name": "idx_friends_user_id", + "columns": [ + "user_id" + ], + "isUnique": false + }, + "idx_friends_friend_id": { + "name": "idx_friends_friend_id", + "columns": [ + "friend_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "friends_user_id_users_id_fk": { + "name": "friends_user_id_users_id_fk", + "tableFrom": "friends", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "friends_friend_id_users_id_fk": { + "name": "friends_friend_id_users_id_fk", + "tableFrom": "friends", + "tableTo": "users", + "columnsFrom": [ + "friend_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "friends_user_id_friend_id_pk": { + "columns": [ + "user_id", + "friend_id" + ], + "name": "friends_user_id_friend_id_pk" + } + }, + "uniqueConstraints": {} + }, + "instance_settings": { + "name": "instance_settings", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": false, + "default": 1 + }, + "instance_name": { + "name": "instance_name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "'Backspace'" + }, + "worker_id": { + "name": "worker_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "discovery_enabled": { + "name": "discovery_enabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 1 + }, + "max_bitrate_kbps": { + "name": "max_bitrate_kbps", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 20000 + }, + "min_bitrate_kbps": { + "name": "min_bitrate_kbps", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 500 + }, + "bitrate_step_kbps": { + "name": "bitrate_step_kbps", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 500 + }, + "allowed_resolutions": { + "name": "allowed_resolutions", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'540,720,1080'" + }, + "allowed_framerates": { + "name": "allowed_framerates", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'30,45,60'" + }, + "max_resolution": { + "name": "max_resolution", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 1080 + }, + "max_framerate": { + "name": "max_framerate", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 60 + }, + "registration_open": { + "name": "registration_open", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "gif_api_key": { + "name": "gif_api_key", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "bitrate_matrix_overrides": { + "name": "bitrate_matrix_overrides", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "allow_custom_bitrate": { + "name": "allow_custom_bitrate", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 1 + }, + "max_upload_size_bytes": { + "name": "max_upload_size_bytes", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "federation_relay_enabled": { + "name": "federation_relay_enabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 1 + }, + "federation_relay_ttl_days": { + "name": "federation_relay_ttl_days", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 30 + }, + "default_auto_rotate_interval_days": { + "name": "default_auto_rotate_interval_days", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 90 + }, + "auto_accept_peering": { + "name": "auto_accept_peering", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 1 + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "join_requests": { + "name": "join_requests", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "space_id": { + "name": "space_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "message": { + "name": "message", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'pending'" + }, + "decided_by": { + "name": "decided_by", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "decided_at": { + "name": "decided_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "idx_join_requests_space_id_status": { + "name": "idx_join_requests_space_id_status", + "columns": [ + "space_id", + "status" + ], + "isUnique": false + } + }, + "foreignKeys": { + "join_requests_space_id_spaces_id_fk": { + "name": "join_requests_space_id_spaces_id_fk", + "tableFrom": "join_requests", + "tableTo": "spaces", + "columnsFrom": [ + "space_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "join_requests_user_id_users_id_fk": { + "name": "join_requests_user_id_users_id_fk", + "tableFrom": "join_requests", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "join_requests_decided_by_users_id_fk": { + "name": "join_requests_decided_by_users_id_fk", + "tableFrom": "join_requests", + "tableTo": "users", + "columnsFrom": [ + "decided_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "member_roles": { + "name": "member_roles", + "columns": { + "space_id": { + "name": "space_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "role_id": { + "name": "role_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "idx_member_roles_user_id_space_id": { + "name": "idx_member_roles_user_id_space_id", + "columns": [ + "user_id", + "space_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "member_roles_space_id_spaces_id_fk": { + "name": "member_roles_space_id_spaces_id_fk", + "tableFrom": "member_roles", + "tableTo": "spaces", + "columnsFrom": [ + "space_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "member_roles_user_id_users_id_fk": { + "name": "member_roles_user_id_users_id_fk", + "tableFrom": "member_roles", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "member_roles_role_id_roles_id_fk": { + "name": "member_roles_role_id_roles_id_fk", + "tableFrom": "member_roles", + "tableTo": "roles", + "columnsFrom": [ + "role_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "member_roles_space_id_user_id_role_id_pk": { + "columns": [ + "space_id", + "user_id", + "role_id" + ], + "name": "member_roles_space_id_user_id_role_id_pk" + } + }, + "uniqueConstraints": {} + }, + "messages": { + "name": "messages", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "channel_id": { + "name": "channel_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "reply_to_id": { + "name": "reply_to_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "edited_at": { + "name": "edited_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "idx_messages_channel_id": { + "name": "idx_messages_channel_id", + "columns": [ + "channel_id" + ], + "isUnique": false + }, + "idx_messages_user_id": { + "name": "idx_messages_user_id", + "columns": [ + "user_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "messages_channel_id_channels_id_fk": { + "name": "messages_channel_id_channels_id_fk", + "tableFrom": "messages", + "tableTo": "channels", + "columnsFrom": [ + "channel_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "messages_user_id_users_id_fk": { + "name": "messages_user_id_users_id_fk", + "tableFrom": "messages", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "messages_reply_to_id_messages_id_fk": { + "name": "messages_reply_to_id_messages_id_fk", + "tableFrom": "messages", + "tableTo": "messages", + "columnsFrom": [ + "reply_to_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "peer_approval_requests": { + "name": "peer_approval_requests", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "origin": { + "name": "origin", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "instance_name": { + "name": "instance_name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "hmac_secret": { + "name": "hmac_secret", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "requested_at": { + "name": "requested_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "peer_approval_requests_origin_unique": { + "name": "peer_approval_requests_origin_unique", + "columns": [ + "origin" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "reactions": { + "name": "reactions", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "message_id": { + "name": "message_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "emoji": { + "name": "emoji", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "idx_reactions_message_id": { + "name": "idx_reactions_message_id", + "columns": [ + "message_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "reactions_message_id_messages_id_fk": { + "name": "reactions_message_id_messages_id_fk", + "tableFrom": "reactions", + "tableTo": "messages", + "columnsFrom": [ + "message_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "reactions_user_id_users_id_fk": { + "name": "reactions_user_id_users_id_fk", + "tableFrom": "reactions", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "read_states": { + "name": "read_states", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "channel_id": { + "name": "channel_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "last_read_message_id": { + "name": "last_read_message_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "idx_read_states_user_id": { + "name": "idx_read_states_user_id", + "columns": [ + "user_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "read_states_user_id_users_id_fk": { + "name": "read_states_user_id_users_id_fk", + "tableFrom": "read_states", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "read_states_user_id_channel_id_pk": { + "columns": [ + "user_id", + "channel_id" + ], + "name": "read_states_user_id_channel_id_pk" + } + }, + "uniqueConstraints": {} + }, + "roles": { + "name": "roles", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "space_id": { + "name": "space_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "color": { + "name": "color", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "'#b9bbbe'" + }, + "position": { + "name": "position", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 0 + }, + "permissions": { + "name": "permissions", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "idx_roles_space_id": { + "name": "idx_roles_space_id", + "columns": [ + "space_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "roles_space_id_spaces_id_fk": { + "name": "roles_space_id_spaces_id_fk", + "tableFrom": "roles", + "tableTo": "spaces", + "columnsFrom": [ + "space_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "space_folder_members": { + "name": "space_folder_members", + "columns": { + "folder_id": { + "name": "folder_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "space_id": { + "name": "space_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "position": { + "name": "position", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 0 + } + }, + "indexes": {}, + "foreignKeys": { + "space_folder_members_folder_id_space_folders_id_fk": { + "name": "space_folder_members_folder_id_space_folders_id_fk", + "tableFrom": "space_folder_members", + "tableTo": "space_folders", + "columnsFrom": [ + "folder_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "space_folder_members_folder_id_space_id_pk": { + "columns": [ + "folder_id", + "space_id" + ], + "name": "space_folder_members_folder_id_space_id_pk" + } + }, + "uniqueConstraints": {} + }, + "space_folders": { + "name": "space_folders", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "color": { + "name": "color", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "position": { + "name": "position", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "space_folders_user_id_users_id_fk": { + "name": "space_folders_user_id_users_id_fk", + "tableFrom": "space_folders", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "space_members": { + "name": "space_members", + "columns": { + "space_id": { + "name": "space_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "nickname": { + "name": "nickname", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "joined_at": { + "name": "joined_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "idx_space_members_user_id": { + "name": "idx_space_members_user_id", + "columns": [ + "user_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "space_members_space_id_spaces_id_fk": { + "name": "space_members_space_id_spaces_id_fk", + "tableFrom": "space_members", + "tableTo": "spaces", + "columnsFrom": [ + "space_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "space_members_user_id_users_id_fk": { + "name": "space_members_user_id_users_id_fk", + "tableFrom": "space_members", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "space_members_space_id_user_id_pk": { + "columns": [ + "space_id", + "user_id" + ], + "name": "space_members_space_id_user_id_pk" + } + }, + "uniqueConstraints": {} + }, + "spaces": { + "name": "spaces", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "icon": { + "name": "icon", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "banner": { + "name": "banner", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "avatar_color": { + "name": "avatar_color", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "owner_id": { + "name": "owner_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "invite_code": { + "name": "invite_code", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "visibility": { + "name": "visibility", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "'private'" + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "spaces_invite_code_unique": { + "name": "spaces_invite_code_unique", + "columns": [ + "invite_code" + ], + "isUnique": true + } + }, + "foreignKeys": { + "spaces_owner_id_users_id_fk": { + "name": "spaces_owner_id_users_id_fk", + "tableFrom": "spaces", + "tableTo": "users", + "columnsFrom": [ + "owner_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "user_federation_registry": { + "name": "user_federation_registry", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "origin": { + "name": "origin", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "label": { + "name": "label", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "''" + }, + "username": { + "name": "username", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "''" + }, + "remote_user_id": { + "name": "remote_user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "''" + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'connected'" + }, + "added_at": { + "name": "added_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "last_connected_at": { + "name": "last_connected_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "disconnected_at": { + "name": "disconnected_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "user_federation_registry_user_id_users_id_fk": { + "name": "user_federation_registry_user_id_users_id_fk", + "tableFrom": "user_federation_registry", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "user_federation_registry_user_id_origin_pk": { + "columns": [ + "user_id", + "origin" + ], + "name": "user_federation_registry_user_id_origin_pk" + } + }, + "uniqueConstraints": {} + }, + "user_space_layout": { + "name": "user_space_layout", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "layout": { + "name": "layout", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'[]'" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "user_space_layout_user_id_users_id_fk": { + "name": "user_space_layout_user_id_users_id_fk", + "tableFrom": "user_space_layout", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "users": { + "name": "users", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "username": { + "name": "username", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "password_hash": { + "name": "password_hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "avatar": { + "name": "avatar", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "'offline'" + }, + "custom_status": { + "name": "custom_status", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "is_admin": { + "name": "is_admin", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 0 + }, + "home_instance": { + "name": "home_instance", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "home_user_id": { + "name": "home_user_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "replicated_instances": { + "name": "replicated_instances", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "'[]'" + }, + "banner": { + "name": "banner", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "accent_color": { + "name": "accent_color", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "avatar_color": { + "name": "avatar_color", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "bio": { + "name": "bio", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "is_deleted": { + "name": "is_deleted", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 0 + }, + "discoverable": { + "name": "discoverable", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 1 + }, + "profile_updated_at": { + "name": "profile_updated_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "password_changed_at": { + "name": "password_changed_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "show_activity": { + "name": "show_activity", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 1 + }, + "federation_registry_updated_at": { + "name": "federation_registry_updated_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "users_username_unique": { + "name": "users_username_unique", + "columns": [ + "username" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "voice_restrictions": { + "name": "voice_restrictions", + "columns": { + "space_id": { + "name": "space_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "restriction_type": { + "name": "restriction_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "moderator_id": { + "name": "moderator_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "idx_voice_restrictions_space_id": { + "name": "idx_voice_restrictions_space_id", + "columns": [ + "space_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "voice_restrictions_space_id_spaces_id_fk": { + "name": "voice_restrictions_space_id_spaces_id_fk", + "tableFrom": "voice_restrictions", + "tableTo": "spaces", + "columnsFrom": [ + "space_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "voice_restrictions_user_id_users_id_fk": { + "name": "voice_restrictions_user_id_users_id_fk", + "tableFrom": "voice_restrictions", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "voice_restrictions_moderator_id_users_id_fk": { + "name": "voice_restrictions_moderator_id_users_id_fk", + "tableFrom": "voice_restrictions", + "tableTo": "users", + "columnsFrom": [ + "moderator_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "voice_restrictions_space_id_user_id_restriction_type_pk": { + "columns": [ + "space_id", + "user_id", + "restriction_type" + ], + "name": "voice_restrictions_space_id_user_id_restriction_type_pk" + } + }, + "uniqueConstraints": {} + } + }, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "indexes": {} + } +} \ No newline at end of file diff --git a/packages/server/drizzle/meta/_journal.json b/packages/server/drizzle/meta/_journal.json index d3c40e33..a69c0cff 100644 --- a/packages/server/drizzle/meta/_journal.json +++ b/packages/server/drizzle/meta/_journal.json @@ -8,6 +8,13 @@ "when": 1777065939127, "tag": "0000_lethal_wildside", "breakpoints": true + }, + { + "idx": 1, + "version": "6", + "when": 1777144927946, + "tag": "0001_complex_screwball", + "breakpoints": true } ] } \ No newline at end of file From 069a1525ea19ffc666bc2f9468a42607a64960a8 Mon Sep 17 00:00:00 2001 From: Jannis Braun <151788261+TheZwiss@users.noreply.github.com> Date: Sat, 25 Apr 2026 21:28:44 +0200 Subject: [PATCH 06/25] feat(federation): per-peer rate limiter for user lookup (60/min) --- packages/server/src/routes/federation.ts | 26 ++++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/packages/server/src/routes/federation.ts b/packages/server/src/routes/federation.ts index 11136582..ed07a5a1 100644 --- a/packages/server/src/routes/federation.ts +++ b/packages/server/src/routes/federation.ts @@ -136,6 +136,32 @@ function isRelayRateLimited(peerOrigin: string): boolean { return false; } +// ─── In-memory rate limiter for the user-lookup endpoint (per-peer) ────────── +const lookupRateBuckets = new Map(); +const LOOKUP_RATE_WINDOW_MS = 60_000; +const LOOKUP_RATE_MAX = 60; + +function isLookupRateLimited(peerOrigin: string): boolean { + const now = Date.now(); + let timestamps = lookupRateBuckets.get(peerOrigin); + if (!timestamps) { + timestamps = []; + lookupRateBuckets.set(peerOrigin, timestamps); + } + const cutoff = now - LOOKUP_RATE_WINDOW_MS; + while (timestamps.length > 0 && (timestamps[0] ?? Infinity) < cutoff) { + timestamps.shift(); + } + if (timestamps.length >= LOOKUP_RATE_MAX) return true; + timestamps.push(now); + return false; +} + +// Test-only export — used by federation.userLookup.test.ts to reset between cases. +export function _resetLookupRateBuckets(): void { + lookupRateBuckets.clear(); +} + // ─── In-memory rate limiter for the ensure endpoint (per-user) ───────────── const ensureRateBuckets = new Map(); const ENSURE_RATE_WINDOW_MS = 15 * 60_000; // 15 minutes From 03cef0e6b32a043b5c23c8d96a726c56d2749e6a Mon Sep 17 00:00:00 2001 From: Jannis Braun <151788261+TheZwiss@users.noreply.github.com> Date: Sat, 25 Apr 2026 21:32:49 +0200 Subject: [PATCH 07/25] feat(federation): POST /api/federation/users/lookup endpoint --- packages/server/src/routes/federation.ts | 92 +++++++ .../src/routes/federation.userLookup.test.ts | 251 ++++++++++++++++++ 2 files changed, 343 insertions(+) create mode 100644 packages/server/src/routes/federation.userLookup.test.ts diff --git a/packages/server/src/routes/federation.ts b/packages/server/src/routes/federation.ts index ed07a5a1..18ab811a 100644 --- a/packages/server/src/routes/federation.ts +++ b/packages/server/src/routes/federation.ts @@ -1622,6 +1622,98 @@ export async function federationRoutes(app: FastifyInstance): Promise { }, ); + // ─── POST /api/federation/users/lookup ───────────────────────────────────── + // Server-to-server: resolve a username on this instance to its canonical + // (homeUserId, profile snapshot). Used by another instance to construct a + // friend_request_create event without requiring a federated user account. + // + // Returns 200 with profile snapshot for native users (regardless of the + // user's `discoverable` setting — exact-handle resolution). + // Returns 404 for tombstoned users, replicated stubs, or unknown usernames. + app.post<{ Body: { username?: unknown } }>( + '/api/federation/users/lookup', + { bodyLimit: 4 * 1024 }, + async (request, reply) => { + const db = getDb(); + + // 1. Verify HMAC (mirror relay endpoint) + const fedHeaders = parseFederationHeaders(request.headers as Record); + if (!fedHeaders) { + return reply.code(401).send({ error: 'Missing or malformed federation headers', statusCode: 401 }); + } + + const peer = db + .select() + .from(schema.federationPeers) + .where(eq(schema.federationPeers.origin, fedHeaders.origin)) + .get(); + + if (!peer || peer.status !== 'active') { + return reply.code(403).send({ error: 'Unknown or inactive peer', statusCode: 403 }); + } + + if (isLookupRateLimited(peer.origin)) { + return reply.code(429).header('Retry-After', '60').send({ error: 'Rate limit exceeded', statusCode: 429 }); + } + + const bodyString = JSON.stringify(request.body); + if (!verifyPeerSignature(bodyString, fedHeaders.signature, fedHeaders.timestamp, fedHeaders.nonce, peer)) { + return reply.code(401).send({ error: 'Invalid signature', statusCode: 401 }); + } + + // 1b. Nonce-based replay protection + if (fedHeaders.nonce) { + if (isNonceDuplicate(peer.origin, fedHeaders.nonce)) { + return reply.code(409).send({ error: 'Duplicate nonce — possible replay', statusCode: 409 }); + } + } else if (peer.nonceSupported) { + return reply.code(401).send({ error: 'Nonce required — peer previously supported nonces', statusCode: 401 }); + } + + // 2. Validate body + const rawUsername = (request.body as { username?: unknown } | null)?.username; + if (typeof rawUsername !== 'string') { + return reply.code(400).send({ error: 'username is required (string)', statusCode: 400 }); + } + const username = rawUsername.trim().toLowerCase(); + if (!username) { + return reply.code(400).send({ error: 'username is required', statusCode: 400 }); + } + + // 3. Native-only lookup with isDeleted filter; discoverable is NOT consulted. + const user = db + .select() + .from(schema.users) + .where( + and( + eq(schema.users.username, username), + eq(schema.users.isDeleted, 0), + isNull(schema.users.homeInstance), + ), + ) + .get(); + + if (!user) { + return reply.code(404).send({ found: false, code: 'user_not_found' }); + } + + return reply.code(200).send({ + found: true, + user: { + homeUserId: user.id, + username: user.username, + profile: { + displayName: user.displayName, + avatar: user.avatar, + avatarColor: user.avatarColor, + banner: user.banner, + bio: user.bio, + }, + }, + }); + }, + ); + // ─── POST /api/federation/sync ────────────────────────────────────────────── // Server-to-server: checkpoint catch-up sync. A peer calls this after downtime // to retrieve missed DM mutations from the mutation log. diff --git a/packages/server/src/routes/federation.userLookup.test.ts b/packages/server/src/routes/federation.userLookup.test.ts new file mode 100644 index 00000000..2826af4a --- /dev/null +++ b/packages/server/src/routes/federation.userLookup.test.ts @@ -0,0 +1,251 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import Fastify, { type FastifyInstance } from 'fastify'; +import Database from 'better-sqlite3'; +import { drizzle } from 'drizzle-orm/better-sqlite3'; +import { eq } from 'drizzle-orm'; +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import * as schema from '../db/schema.js'; +import { setWorkerId } from '../utils/snowflake.js'; +import { signRequest } from '../utils/federationAuth.js'; +import { randomUUID } from 'node:crypto'; + +setWorkerId(1); +const __dirname = path.dirname(fileURLToPath(import.meta.url)); + +// Module-level mutable state. Each beforeEach reassigns sqlite/testDb; +// the getDb getter in the mock closes over the current binding. +type TestDb = ReturnType>; +let sqlite: Database.Database; +let testDb: TestDb; + +const PEER_ORIGIN = 'https://orbit.test'; +const PEER_SECRET = 'a'.repeat(64); + +vi.mock('../db/index.js', () => ({ + getDb: () => testDb, + getRawDb: () => sqlite, + schema, +})); + +vi.mock('../utils/federationAuth.js', async (importActual) => { + const actual = await importActual(); + return { ...actual, getOurOrigin: () => 'https://home.test' }; +}); + +function applyMigrations(db: Database.Database): void { + const dir = path.resolve(__dirname, '../../drizzle'); + for (const f of fs.readdirSync(dir).filter(f => f.endsWith('.sql')).sort()) { + const sqlText = fs.readFileSync(path.join(dir, f), 'utf8'); + for (const stmt of sqlText.split(/-->\s*statement-breakpoint/)) { + const clean = stmt.trim(); + if (clean) db.exec(clean); + } + } +} + +function seedActivePeer(): void { + testDb.insert(schema.federationPeers).values({ + id: 'peer-1', + origin: PEER_ORIGIN, + hmacSecret: PEER_SECRET, + status: 'active', + nonceSupported: 1, + createdAt: Date.now(), + lastSeenAt: Date.now(), + consecutiveFailures: 0, + consecutiveAuthFailures: 0, + } as typeof schema.federationPeers.$inferInsert).run(); +} + +function seedUser(opts: { + id: string; + username: string; + isDeleted?: 0 | 1; + discoverable?: 0 | 1; + homeInstance?: string | null; + homeUserId?: string | null; + displayName?: string | null; + avatar?: string | null; + avatarColor?: string | null; + banner?: string | null; + bio?: string | null; +}): void { + testDb.insert(schema.users).values({ + id: opts.id, + username: opts.username, + displayName: opts.displayName ?? null, + passwordHash: 'x', + status: 'offline', + isAdmin: 0, + isDeleted: opts.isDeleted ?? 0, + discoverable: opts.discoverable ?? 1, + homeInstance: opts.homeInstance ?? null, + homeUserId: opts.homeUserId ?? null, + avatar: opts.avatar ?? null, + avatarColor: opts.avatarColor ?? null, + banner: opts.banner ?? null, + bio: opts.bio ?? null, + createdAt: Date.now(), + }).run(); +} + +async function buildApp(): Promise { + const app = Fastify({ logger: false }); + const { _resetLookupRateBuckets, federationRoutes } = await import('./federation.js'); + _resetLookupRateBuckets(); + await app.register(federationRoutes); + await app.ready(); + return app; +} + +function signedHeaders(body: string): Record { + const timestamp = Date.now(); + const nonce = randomUUID(); + const sig = signRequest(body, PEER_SECRET, timestamp, nonce); + return { + 'X-Federation-Origin': PEER_ORIGIN, + 'X-Federation-Timestamp': String(timestamp), + 'X-Federation-Nonce': nonce, + 'X-Federation-Signature': `sha256=${sig}`, + 'Content-Type': 'application/json', + }; +} + +async function lookup( + app: FastifyInstance, + body: object, + headersOverride?: Record, +) { + const bodyStr = JSON.stringify(body); + return app.inject({ + method: 'POST', + url: '/api/federation/users/lookup', + headers: headersOverride ?? signedHeaders(bodyStr), + payload: bodyStr, + }); +} + +describe('POST /api/federation/users/lookup', () => { + let app: FastifyInstance; + + beforeEach(async () => { + sqlite = new Database(':memory:'); + testDb = drizzle(sqlite, { schema }); + applyMigrations(sqlite); + seedActivePeer(); + app = await buildApp(); + }); + + it('returns 200 + full profile snapshot for a native user', async () => { + seedUser({ id: 'u1', username: 'alice', displayName: 'Alice', bio: 'hi', avatarColor: '#ff0000' }); + const res = await lookup(app, { username: 'alice' }); + expect(res.statusCode).toBe(200); + const body = JSON.parse(res.body); + expect(body.found).toBe(true); + expect(body.user.homeUserId).toBe('u1'); + expect(body.user.username).toBe('alice'); + expect(body.user.profile.displayName).toBe('Alice'); + expect(body.user.profile.bio).toBe('hi'); + expect(body.user.profile.avatarColor).toBe('#ff0000'); + }); + + it('returns 200 for a discoverable=0 user (exact-handle resolution must not regress)', async () => { + seedUser({ id: 'u1', username: 'alice', discoverable: 0 }); + const res = await lookup(app, { username: 'alice' }); + expect(res.statusCode).toBe(200); + expect(JSON.parse(res.body).found).toBe(true); + }); + + it('returns 404 for a tombstoned user', async () => { + seedUser({ id: 'u1', username: 'alice', isDeleted: 1 }); + const res = await lookup(app, { username: 'alice' }); + expect(res.statusCode).toBe(404); + expect(JSON.parse(res.body)).toEqual({ found: false, code: 'user_not_found' }); + }); + + it('returns 404 for a replicated stub (homeInstance set)', async () => { + seedUser({ + id: 'stub1', + username: 'remote-id@orbit.test', + homeInstance: 'orbit.test', + homeUserId: 'remote-id', + }); + const res = await lookup(app, { username: 'remote-id@orbit.test' }); + expect(res.statusCode).toBe(404); + expect(JSON.parse(res.body)).toEqual({ found: false, code: 'user_not_found' }); + }); + + it('returns 404 for an unknown username', async () => { + const res = await lookup(app, { username: 'nope' }); + expect(res.statusCode).toBe(404); + expect(JSON.parse(res.body)).toEqual({ found: false, code: 'user_not_found' }); + }); + + it('matches lowercase storage from a mixed-case lookup', async () => { + seedUser({ id: 'u1', username: 'alice' }); + const res = await lookup(app, { username: 'ALICE' }); + expect(res.statusCode).toBe(200); + expect(JSON.parse(res.body).found).toBe(true); + }); + + it('trims whitespace from the lookup username', async () => { + seedUser({ id: 'u1', username: 'alice' }); + const res = await lookup(app, { username: ' alice ' }); + expect(res.statusCode).toBe(200); + expect(JSON.parse(res.body).found).toBe(true); + }); + + it('returns 400 for a missing username field', async () => { + const res = await lookup(app, {}); + expect(res.statusCode).toBe(400); + expect(JSON.parse(res.body).error).toContain('username'); + }); + + it('returns 400 for a non-string username', async () => { + const res = await lookup(app, { username: 42 }); + expect(res.statusCode).toBe(400); + expect(JSON.parse(res.body).error).toContain('username'); + }); + + it('returns 400 for a whitespace-only username', async () => { + const res = await lookup(app, { username: ' ' }); + expect(res.statusCode).toBe(400); + expect(JSON.parse(res.body).error).toContain('username'); + }); + + it('returns 401 when HMAC headers are missing', async () => { + const res = await lookup(app, { username: 'alice' }, { 'Content-Type': 'application/json' }); + expect(res.statusCode).toBe(401); + }); + + it('returns 401 for a bad signature', async () => { + seedUser({ id: 'u1', username: 'alice' }); + // Sign a different body — mismatch will fail HMAC verification + const bad = signedHeaders('{"different":"body"}'); + const res = await lookup(app, { username: 'alice' }, bad); + expect(res.statusCode).toBe(401); + }); + + it('returns 403 if the peer is not active', async () => { + testDb + .update(schema.federationPeers) + .set({ status: 'rejected' }) + .where(eq(schema.federationPeers.id, 'peer-1')) + .run(); + const res = await lookup(app, { username: 'alice' }); + expect(res.statusCode).toBe(403); + }); + + it('returns 429 after 60 lookups in a minute from the same peer', async () => { + seedUser({ id: 'u1', username: 'alice' }); + for (let i = 0; i < 60; i++) { + const ok = await lookup(app, { username: 'alice' }); + expect(ok.statusCode).toBe(200); + } + const blocked = await lookup(app, { username: 'alice' }); + expect(blocked.statusCode).toBe(429); + expect(blocked.headers['retry-after']).toBe('60'); + }); +}); From fbe2fb370bc9717b08329d38bdfe9bf50797af6d Mon Sep 17 00:00:00 2001 From: Jannis Braun <151788261+TheZwiss@users.noreply.github.com> Date: Sat, 25 Apr 2026 21:37:41 +0200 Subject: [PATCH 08/25] feat(federation): lookupRemoteUser outbound HMAC helper --- .../server/src/utils/federationLookup.test.ts | 218 ++++++++++++++++++ packages/server/src/utils/federationLookup.ts | 77 +++++++ 2 files changed, 295 insertions(+) create mode 100644 packages/server/src/utils/federationLookup.test.ts create mode 100644 packages/server/src/utils/federationLookup.ts diff --git a/packages/server/src/utils/federationLookup.test.ts b/packages/server/src/utils/federationLookup.test.ts new file mode 100644 index 00000000..ddf2b712 --- /dev/null +++ b/packages/server/src/utils/federationLookup.test.ts @@ -0,0 +1,218 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import Database from 'better-sqlite3'; +import { drizzle } from 'drizzle-orm/better-sqlite3'; +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import * as schema from '../db/schema.js'; +import { setWorkerId } from '../utils/snowflake.js'; + +setWorkerId(1); +const __dirname = path.dirname(fileURLToPath(import.meta.url)); + +type TestDb = ReturnType>; +let sqlite: Database.Database; +let testDb: TestDb; + +const PEER_ORIGIN = 'https://orbit.test'; +const PEER_SECRET = 'a'.repeat(64); + +vi.mock('../db/index.js', () => ({ + getDb: () => testDb, + getRawDb: () => sqlite, + schema, +})); + +vi.mock('./federationAuth.js', async (importActual) => { + const actual = await importActual(); + return { ...actual, getOurOrigin: () => 'https://home.test' }; +}); + +function applyMigrations(db: Database.Database): void { + const dir = path.resolve(__dirname, '../../drizzle'); + for (const f of fs.readdirSync(dir).filter((f) => f.endsWith('.sql')).sort()) { + const sqlText = fs.readFileSync(path.join(dir, f), 'utf8'); + for (const stmt of sqlText.split(/-->\s*statement-breakpoint/)) { + const clean = stmt.trim(); + if (clean) db.exec(clean); + } + } +} + +function seedActivePeer(): void { + testDb + .insert(schema.federationPeers) + .values({ + id: 'peer-1', + origin: PEER_ORIGIN, + hmacSecret: PEER_SECRET, + status: 'active', + nonceSupported: 1, + createdAt: Date.now(), + lastSeenAt: Date.now(), + consecutiveFailures: 0, + consecutiveAuthFailures: 0, + } as typeof schema.federationPeers.$inferInsert) + .run(); +} + +const VALID_RESPONSE_BODY = { + found: true, + user: { + homeUserId: 'remote-uid-1', + username: 'bob', + profile: { + displayName: 'Bob', + avatar: null, + avatarColor: null, + banner: null, + bio: 'hello from orbit', + }, + }, +}; + +beforeEach(() => { + sqlite = new Database(':memory:'); + testDb = drizzle(sqlite, { schema }); + applyMigrations(sqlite); + seedActivePeer(); +}); + +afterEach(() => { + vi.unstubAllGlobals(); +}); + +describe('lookupRemoteUser', () => { + it('1. returns ok:true with homeUserId/username/profile on HTTP 200 + valid body', async () => { + vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ + ok: true, + status: 200, + headers: { get: () => null }, + json: async () => VALID_RESPONSE_BODY, + })); + + const { lookupRemoteUser } = await import('./federationLookup.js'); + const result = await lookupRemoteUser(PEER_ORIGIN, 'bob'); + + expect(result).toEqual({ + ok: true, + homeUserId: 'remote-uid-1', + username: 'bob', + profile: { + displayName: 'Bob', + avatar: null, + avatarColor: null, + banner: null, + bio: 'hello from orbit', + }, + }); + }); + + it('2. returns ok:false reason:not_found on HTTP 404', async () => { + vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ + ok: false, + status: 404, + headers: { get: () => null }, + })); + + const { lookupRemoteUser } = await import('./federationLookup.js'); + const result = await lookupRemoteUser(PEER_ORIGIN, 'nobody'); + + expect(result).toEqual({ ok: false, reason: 'not_found' }); + }); + + it('3. returns ok:false reason:rate_limited with retryAfter on HTTP 429', async () => { + vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ + ok: false, + status: 429, + headers: { get: (name: string) => (name === 'Retry-After' ? '60' : null) }, + })); + + const { lookupRemoteUser } = await import('./federationLookup.js'); + const result = await lookupRemoteUser(PEER_ORIGIN, 'bob'); + + expect(result).toEqual({ ok: false, reason: 'rate_limited', retryAfter: 60 }); + }); + + it('4. returns ok:false reason:unreachable on network error (TypeError)', async () => { + vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new TypeError('fetch failed'))); + + const { lookupRemoteUser } = await import('./federationLookup.js'); + const result = await lookupRemoteUser(PEER_ORIGIN, 'bob'); + + expect(result).toEqual({ ok: false, reason: 'unreachable' }); + }); + + it('5. returns ok:false reason:unreachable on AbortError (timeout)', async () => { + const abortError = new DOMException('The operation was aborted.', 'AbortError'); + vi.stubGlobal('fetch', vi.fn().mockRejectedValue(abortError)); + + const { lookupRemoteUser } = await import('./federationLookup.js'); + const result = await lookupRemoteUser(PEER_ORIGIN, 'bob'); + + expect(result).toEqual({ ok: false, reason: 'unreachable' }); + }); + + it('6. throws when no peer record exists in federation_peers', async () => { + vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ + ok: true, + status: 200, + headers: { get: () => null }, + json: async () => VALID_RESPONSE_BODY, + })); + + const { lookupRemoteUser } = await import('./federationLookup.js'); + await expect(lookupRemoteUser('https://unknown.test', 'bob')).rejects.toThrow( + 'lookupRemoteUser: no peer record for https://unknown.test', + ); + }); + + it('7. signs the request with correct HMAC headers', async () => { + let capturedInit: RequestInit | undefined; + vi.stubGlobal( + 'fetch', + vi.fn().mockImplementation((_url: string, init: RequestInit) => { + capturedInit = init; + return Promise.resolve({ + ok: true, + status: 200, + headers: { get: () => null }, + json: async () => VALID_RESPONSE_BODY, + }); + }), + ); + + const { lookupRemoteUser } = await import('./federationLookup.js'); + await lookupRemoteUser(PEER_ORIGIN, 'bob'); + + expect(capturedInit).toBeDefined(); + const headers = capturedInit!.headers as Record; + + // URL + const fetchMock = (globalThis.fetch as ReturnType); + expect(fetchMock).toHaveBeenCalledWith( + `${PEER_ORIGIN}/api/federation/users/lookup`, + expect.anything(), + ); + + // Required HMAC headers + expect(headers['X-Federation-Origin']).toBe('https://home.test'); + expect(headers['X-Federation-Signature']).toMatch(/^sha256=[0-9a-f]{64}$/); + expect(headers['X-Federation-Nonce']).toBeTruthy(); + expect(headers['X-Federation-Timestamp']).toMatch(/^\d+$/); + }); + + it('8. throws on malformed 200 body', async () => { + vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ + ok: true, + status: 200, + headers: { get: () => null }, + json: async () => ({ found: false, code: 'user_not_found' }), + })); + + const { lookupRemoteUser } = await import('./federationLookup.js'); + await expect(lookupRemoteUser(PEER_ORIGIN, 'bob')).rejects.toThrow( + `lookupRemoteUser: peer ${PEER_ORIGIN} returned malformed body`, + ); + }); +}); diff --git a/packages/server/src/utils/federationLookup.ts b/packages/server/src/utils/federationLookup.ts new file mode 100644 index 00000000..d8b81476 --- /dev/null +++ b/packages/server/src/utils/federationLookup.ts @@ -0,0 +1,77 @@ +import { eq } from 'drizzle-orm'; +import { getDb } from '../db/index.js'; +import * as schema from '../db/schema.js'; +import { buildFederationHeaders, getOurOrigin } from './federationAuth.js'; +import type { FederationUserLookupProfile, FederationUserLookupResponse } from '@backspace/shared'; + +const LOOKUP_TIMEOUT_MS = 10_000; + +export type LookupResult = + | { ok: true; homeUserId: string; username: string; profile: FederationUserLookupProfile } + | { ok: false; reason: 'not_found' } + | { ok: false; reason: 'unreachable' } + | { ok: false; reason: 'rate_limited'; retryAfter?: number }; + +/** + * Look up a username on a remote peer instance. + * + * - Looks up the peer's HMAC secret from the local federation_peers table. + * - Throws if the peer record is missing — caller must ensurePeered first. + * - Wraps fetch in a 10s timeout; network/timeout/AbortError → unreachable. + * - HTTP 404 → not_found; 429 → rate_limited (with retryAfter); 200 + valid body → ok; + * anything else (5xx, unexpected 4xx, malformed body) → throws (logged at call site). + */ +export async function lookupRemoteUser(peerOrigin: string, username: string): Promise { + const db = getDb(); + const peer = db + .select() + .from(schema.federationPeers) + .where(eq(schema.federationPeers.origin, peerOrigin)) + .get(); + + if (!peer) { + throw new Error(`lookupRemoteUser: no peer record for ${peerOrigin}`); + } + + const body = JSON.stringify({ username }); + const headers = buildFederationHeaders(body, peer.hmacSecret, getOurOrigin()); + + let response: Response; + try { + response = await fetch(`${peerOrigin}/api/federation/users/lookup`, { + method: 'POST', + headers, + body, + signal: AbortSignal.timeout(LOOKUP_TIMEOUT_MS), + }); + } catch { + // Network error, timeout, AbortError — all unreachable. + return { ok: false, reason: 'unreachable' }; + } + + if (response.status === 404) { + return { ok: false, reason: 'not_found' }; + } + + if (response.status === 429) { + const raw = Number(response.headers.get('Retry-After') ?? '60'); + const retryAfter = Number.isFinite(raw) ? raw : 60; + return { ok: false, reason: 'rate_limited', retryAfter }; + } + + if (!response.ok) { + throw new Error(`lookupRemoteUser: peer ${peerOrigin} returned HTTP ${response.status}`); + } + + const json = (await response.json()) as FederationUserLookupResponse; + if (!json || json.found !== true || !json.user || typeof json.user.homeUserId !== 'string') { + throw new Error(`lookupRemoteUser: peer ${peerOrigin} returned malformed body`); + } + + return { + ok: true, + homeUserId: json.user.homeUserId, + username: json.user.username, + profile: json.user.profile, + }; +} From 03737c0955b3749ac45b72db9ddf407c843e7296 Mon Sep 17 00:00:00 2001 From: Jannis Braun <151788261+TheZwiss@users.noreply.github.com> Date: Sat, 25 Apr 2026 21:41:25 +0200 Subject: [PATCH 09/25] feat(federation): resolveOriginFromHostname helper --- .../src/utils/federationOriginResolve.test.ts | 91 +++++++++++++++++++ .../src/utils/federationOriginResolve.ts | 51 +++++++++++ 2 files changed, 142 insertions(+) create mode 100644 packages/server/src/utils/federationOriginResolve.test.ts create mode 100644 packages/server/src/utils/federationOriginResolve.ts diff --git a/packages/server/src/utils/federationOriginResolve.test.ts b/packages/server/src/utils/federationOriginResolve.test.ts new file mode 100644 index 00000000..998ab1d8 --- /dev/null +++ b/packages/server/src/utils/federationOriginResolve.test.ts @@ -0,0 +1,91 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import Database from 'better-sqlite3'; +import { drizzle } from 'drizzle-orm/better-sqlite3'; +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import * as schema from '../db/schema.js'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +let sqlite: Database.Database; +let testDb: ReturnType>; +let mockOurOrigin = 'https://home.test'; + +vi.mock('../db/index.js', () => ({ + getDb: () => testDb, + getRawDb: () => sqlite, + schema, +})); + +vi.mock('./federationAuth.js', async (importActual) => { + const actual = await importActual(); + return { ...actual, getOurOrigin: () => mockOurOrigin }; +}); + +function applyMigrations(db: Database.Database): void { + const dir = path.resolve(__dirname, '../../drizzle'); + for (const f of fs.readdirSync(dir).filter(f => f.endsWith('.sql')).sort()) { + const sqlText = fs.readFileSync(path.join(dir, f), 'utf8'); + for (const stmt of sqlText.split(/-->\s*statement-breakpoint/)) { + const clean = stmt.trim(); + if (clean) db.exec(clean); + } + } +} + +function seedPeer(origin: string): void { + testDb.insert(schema.federationPeers).values({ + id: `peer-${origin}`, + origin, + hmacSecret: 'a'.repeat(64), + status: 'active', + nonceSupported: 1, + createdAt: Date.now(), + consecutiveFailures: 0, + consecutiveAuthFailures: 0, + } as typeof schema.federationPeers.$inferInsert).run(); +} + +beforeEach(() => { + sqlite = new Database(':memory:'); + testDb = drizzle(sqlite, { schema }); + applyMigrations(sqlite); + mockOurOrigin = 'https://home.test'; +}); + +describe('resolveOriginFromHostname', () => { + it('returns stored peer origin on exact host match', async () => { + seedPeer('https://orbit.test'); + const { resolveOriginFromHostname } = await import('./federationOriginResolve.js'); + expect(resolveOriginFromHostname('orbit.test')).toBe('https://orbit.test'); + }); + + it('matches peer origin case-insensitively', async () => { + seedPeer('https://orbit.test'); + const { resolveOriginFromHostname } = await import('./federationOriginResolve.js'); + expect(resolveOriginFromHostname('ORBIT.TEST')).toBe('https://orbit.test'); + }); + + it('mirrors https scheme when no peer matches', async () => { + mockOurOrigin = 'https://home.test'; + const { resolveOriginFromHostname } = await import('./federationOriginResolve.js'); + expect(resolveOriginFromHostname('newpeer.example')).toBe('https://newpeer.example'); + }); + + it('mirrors http scheme for localhost targets', async () => { + mockOurOrigin = 'http://localhost:3005'; + const { resolveOriginFromHostname } = await import('./federationOriginResolve.js'); + expect(resolveOriginFromHostname('localhost:3006')).toBe('http://localhost:3006'); + }); + + it('returns null when validateOrigin rejects http for non-localhost', async () => { + mockOurOrigin = 'http://localhost:3005'; + const { resolveOriginFromHostname } = await import('./federationOriginResolve.js'); + expect(resolveOriginFromHostname('newpeer.example')).toBeNull(); + }); + + it('returns null for empty input', async () => { + const { resolveOriginFromHostname } = await import('./federationOriginResolve.js'); + expect(resolveOriginFromHostname('')).toBeNull(); + }); +}); diff --git a/packages/server/src/utils/federationOriginResolve.ts b/packages/server/src/utils/federationOriginResolve.ts new file mode 100644 index 00000000..e04713f3 --- /dev/null +++ b/packages/server/src/utils/federationOriginResolve.ts @@ -0,0 +1,51 @@ +import { getDb } from '../db/index.js'; +import * as schema from '../db/schema.js'; +import { getOurOrigin } from './federationAuth.js'; +import { validateOrigin } from '../routes/federation.js'; + +/** + * Resolve a typed hostname (e.g., the part after `@` in `alice@orbit.test`) + * into a full peer origin URL suitable for ensurePeered() / fetch(). + * + * Resolution order: + * 1. If a federation_peers row exists whose URL host matches (case-insensitive), + * return that peer's stored origin verbatim. (Authoritative for any peer the + * admin has explicitly configured.) + * 2. Otherwise, mirror getOurOrigin()'s scheme: + * - https://... → https://${hostname} + * - http://... → http://${hostname} (covers dev: localhost:3006) + * Validate via validateOrigin (which rejects http for non-localhost). + * + * Returns null if the result fails validation (e.g., http for a public domain + * when our scheme is http — caller should surface as 'invalid target'). + * + * Stale-scheme edge case: if a stored peer row points at the wrong scheme + * (peer migrated http↔https since the row was written), ensurePeered will + * surface a connectivity failure via the standard 'unreachable' path. Scheme + * migration of an existing peer is an admin operation outside this code's + * scope (delete + re-peer). + */ +export function resolveOriginFromHostname(hostnameOrHostPort: string): string | null { + if (!hostnameOrHostPort) return null; + const target = hostnameOrHostPort.trim().toLowerCase(); + if (!target) return null; + + const db = getDb(); + const peers = db + .select({ origin: schema.federationPeers.origin }) + .from(schema.federationPeers) + .all(); + + for (const p of peers) { + try { + const u = new URL(p.origin); + if (u.host.toLowerCase() === target) return p.origin; + } catch { + // skip malformed origin + } + } + + const ourScheme = getOurOrigin().startsWith('https://') ? 'https://' : 'http://'; + const candidate = `${ourScheme}${target}`; + return validateOrigin(candidate); +} From be3eb5284e6fa4ec61aa6716792f7b2fefd495d9 Mon Sep 17 00:00:00 2001 From: Jannis Braun <151788261+TheZwiss@users.noreply.github.com> Date: Sat, 25 Apr 2026 21:42:57 +0200 Subject: [PATCH 10/25] feat(federation): permanent-failure callback registry --- .../src/utils/federationRollback.test.ts | 41 +++++++++++++++++++ .../server/src/utils/federationRollback.ts | 39 ++++++++++++++++++ 2 files changed, 80 insertions(+) create mode 100644 packages/server/src/utils/federationRollback.test.ts create mode 100644 packages/server/src/utils/federationRollback.ts diff --git a/packages/server/src/utils/federationRollback.test.ts b/packages/server/src/utils/federationRollback.test.ts new file mode 100644 index 00000000..0e5bcb10 --- /dev/null +++ b/packages/server/src/utils/federationRollback.test.ts @@ -0,0 +1,41 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { + registerPermanentFailureCallback, + invokePermanentFailureCallback, + _resetCallbacks, +} from './federationRollback.js'; + +describe('permanent-failure callback registry', () => { + beforeEach(() => _resetCallbacks()); + + it('invokes the registered callback when called by eventType', () => { + const cb = vi.fn(); + registerPermanentFailureCallback('friend_request_create', cb); + invokePermanentFailureCallback('friend_request_create', 'msg-123', 'recipient_not_found'); + expect(cb).toHaveBeenCalledWith('msg-123', 'recipient_not_found'); + }); + + it('is a no-op for unregistered event types', () => { + expect(() => + invokePermanentFailureCallback('unknown_event_type', 'msg-1', 'whatever') + ).not.toThrow(); + }); + + it('replaces a previously-registered callback for the same eventType', () => { + const old = vi.fn(); + const fresh = vi.fn(); + registerPermanentFailureCallback('e1', old); + registerPermanentFailureCallback('e1', fresh); + invokePermanentFailureCallback('e1', 'msg-9', 'reason'); + expect(old).not.toHaveBeenCalled(); + expect(fresh).toHaveBeenCalledWith('msg-9', 'reason'); + }); + + it('swallows errors thrown by the callback (logs but does not throw)', () => { + registerPermanentFailureCallback('boom', () => { throw new Error('test'); }); + const errSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + expect(() => invokePermanentFailureCallback('boom', 'msg-x', 'r')).not.toThrow(); + expect(errSpy).toHaveBeenCalled(); + errSpy.mockRestore(); + }); +}); diff --git a/packages/server/src/utils/federationRollback.ts b/packages/server/src/utils/federationRollback.ts new file mode 100644 index 00000000..8512e3d0 --- /dev/null +++ b/packages/server/src/utils/federationRollback.ts @@ -0,0 +1,39 @@ +/** + * Permanent-failure callback registry for outbox events. + * + * When the federation worker observes a receiver-acknowledged terminal + * rejection (4xx with a recognized reason like 'recipient_not_found'), + * it invokes the registered callback for the eventType so the originating + * instance can roll back any local state created at queue time. + * + * Callbacks are NEVER invoked on transient failures (5xx, network errors, + * retry exhaustion). Only on receiver-acknowledged terminal rejections. + * + * Errors thrown by callbacks are logged but not re-thrown — rollback failure + * must not prevent the outbox entry from being deleted. + */ +type PermanentFailureCallback = (messageId: string, reason: string) => void; + +const callbacks = new Map(); + +export function registerPermanentFailureCallback(eventType: string, cb: PermanentFailureCallback): void { + callbacks.set(eventType, cb); +} + +export function invokePermanentFailureCallback(eventType: string, messageId: string, reason: string): void { + const cb = callbacks.get(eventType); + if (!cb) return; + try { + cb(messageId, reason); + } catch (err) { + console.error( + `[federation-rollback] callback for ${eventType} (msg=${messageId}, reason=${reason}) threw:`, + err, + ); + } +} + +/** Test-only: clear the registry between tests. */ +export function _resetCallbacks(): void { + callbacks.clear(); +} From 9e3417485c2752b2d5075f598a2f72abd9fa4aba Mon Sep 17 00:00:00 2001 From: Jannis Braun <151788261+TheZwiss@users.noreply.github.com> Date: Sat, 25 Apr 2026 21:46:28 +0200 Subject: [PATCH 11/25] feat(federation-worker): treat receiver-ack 4xx reasons as terminal + invoke rollback --- .../server/src/utils/federationWorker.test.ts | 141 +++++++++++++++++- packages/server/src/utils/federationWorker.ts | 60 ++++++-- 2 files changed, 188 insertions(+), 13 deletions(-) diff --git a/packages/server/src/utils/federationWorker.test.ts b/packages/server/src/utils/federationWorker.test.ts index 11d58850..94e01777 100644 --- a/packages/server/src/utils/federationWorker.test.ts +++ b/packages/server/src/utils/federationWorker.test.ts @@ -64,6 +64,13 @@ vi.mock('../utils/federationAuthFailure.js', () => ({ AUTH_FAILURE_THRESHOLD: 5, })); +const invokeRollbackMock = vi.fn(); +vi.mock('./federationRollback.js', () => ({ + invokePermanentFailureCallback: invokeRollbackMock, + registerPermanentFailureCallback: vi.fn(), + _resetCallbacks: vi.fn(), +})); + function applyMigrations(db: Database.Database): void { const migrationsDir = path.resolve(__dirname, '../../drizzle'); const files = fs.readdirSync(migrationsDir).filter(f => f.endsWith('.sql')).sort(); @@ -84,10 +91,10 @@ function seedPeer(id: string): void { }).run(); } -function seedOutboxEntry(id: string, peerId: string, entityId: string): void { +function seedOutboxEntry(id: string, peerId: string, entityId: string, eventType = 'create'): void { testDb.insert(schema.federationOutbox).values({ id, peerId, contextId: 'ch-1', entityId, - contextType: 'dm', eventType: 'create', payload: JSON.stringify({ + contextType: 'dm', eventType, payload: JSON.stringify({ message: { userId: 'u', homeUserId: 'u', homeInstance: 'test.example', content: 'hi', replyToId: null, editedAt: null, createdAt: Date.now() }, }), encryptionVersion: 0, attempts: 0, nextRetryAt: Date.now() - 1000, @@ -288,3 +295,133 @@ describe('federatedCallSentinel', () => { ); }); }); + +// ─── Terminal rejection reasons + rollback invocation ──────────────────────── + +describe('outbox worker — terminal rejection reasons + rollback invocation', () => { + beforeEach(() => { + sqlite = new Database(':memory:'); + testDb = drizzle(sqlite, { schema }); + applyMigrations(sqlite); + vi.restoreAllMocks(); + invokeRollbackMock.mockReset(); + }); + + afterEach(() => { + sqlite.close(); + }); + + it('recipient_not_found for friend_request_create is terminal AND invokes rollback', async () => { + seedPeer('peer-r1'); + seedOutboxEntry('entry-r1', 'peer-r1', 'msg-1', 'friend_request_create'); + + vi.spyOn(globalThis, 'fetch').mockImplementation(async () => + new Response(JSON.stringify({ + accepted: [], + rejected: [{ messageId: 'msg-1', reason: 'recipient_not_found' }], + }), { status: 200, headers: { 'Content-Type': 'application/json' } }), + ); + + const workerModule = await import('./federationWorker.js'); + const processOutboxTick = (workerModule as { processOutboxTick?: () => Promise }).processOutboxTick!; + await processOutboxTick(); + + const remaining = testDb.select().from(schema.federationOutbox) + .where(eq(schema.federationOutbox.id, 'entry-r1')).get(); + expect(remaining).toBeUndefined(); + + expect(invokeRollbackMock).toHaveBeenCalledTimes(1); + expect(invokeRollbackMock).toHaveBeenCalledWith('friend_request_create', 'msg-1', 'recipient_not_found'); + }); + + it('recipient_not_found for dm_message_create: row deleted, rollback still invoked (no-op inside registry)', async () => { + seedPeer('peer-r2'); + seedOutboxEntry('entry-r2', 'peer-r2', 'msg-2', 'dm_message_create'); + + vi.spyOn(globalThis, 'fetch').mockImplementation(async () => + new Response(JSON.stringify({ + accepted: [], + rejected: [{ messageId: 'msg-2', reason: 'recipient_not_found' }], + }), { status: 200, headers: { 'Content-Type': 'application/json' } }), + ); + + const workerModule = await import('./federationWorker.js'); + const processOutboxTick = (workerModule as { processOutboxTick?: () => Promise }).processOutboxTick!; + await processOutboxTick(); + + const remaining = testDb.select().from(schema.federationOutbox) + .where(eq(schema.federationOutbox.id, 'entry-r2')).get(); + expect(remaining).toBeUndefined(); + + // Worker always calls invoke; whether a callback is registered is the registry's concern + expect(invokeRollbackMock).toHaveBeenCalledTimes(1); + expect(invokeRollbackMock).toHaveBeenCalledWith('dm_message_create', 'msg-2', 'recipient_not_found'); + }); + + it('duplicate rejection is terminal but does NOT invoke rollback callback', async () => { + seedPeer('peer-r3'); + seedOutboxEntry('entry-r3', 'peer-r3', 'msg-3', 'friend_request_create'); + + vi.spyOn(globalThis, 'fetch').mockImplementation(async () => + new Response(JSON.stringify({ + accepted: [], + rejected: [{ messageId: 'msg-3', reason: 'duplicate' }], + }), { status: 200, headers: { 'Content-Type': 'application/json' } }), + ); + + const workerModule = await import('./federationWorker.js'); + const processOutboxTick = (workerModule as { processOutboxTick?: () => Promise }).processOutboxTick!; + await processOutboxTick(); + + const remaining = testDb.select().from(schema.federationOutbox) + .where(eq(schema.federationOutbox.id, 'entry-r3')).get(); + expect(remaining).toBeUndefined(); + + expect(invokeRollbackMock).not.toHaveBeenCalled(); + }); + + it('attribution_mismatch is terminal AND invokes rollback', async () => { + seedPeer('peer-r4'); + seedOutboxEntry('entry-r4', 'peer-r4', 'msg-4', 'friend_request_create'); + + vi.spyOn(globalThis, 'fetch').mockImplementation(async () => + new Response(JSON.stringify({ + accepted: [], + rejected: [{ messageId: 'msg-4', reason: 'attribution_mismatch' }], + }), { status: 200, headers: { 'Content-Type': 'application/json' } }), + ); + + const workerModule = await import('./federationWorker.js'); + const processOutboxTick = (workerModule as { processOutboxTick?: () => Promise }).processOutboxTick!; + await processOutboxTick(); + + const remaining = testDb.select().from(schema.federationOutbox) + .where(eq(schema.federationOutbox.id, 'entry-r4')).get(); + expect(remaining).toBeUndefined(); + + expect(invokeRollbackMock).toHaveBeenCalledTimes(1); + expect(invokeRollbackMock).toHaveBeenCalledWith('friend_request_create', 'msg-4', 'attribution_mismatch'); + }); + + it('non-terminal rejection (processing_error) does NOT invoke rollback and retains the outbox row', async () => { + seedPeer('peer-r5'); + seedOutboxEntry('entry-r5', 'peer-r5', 'msg-5', 'friend_request_create'); + + vi.spyOn(globalThis, 'fetch').mockImplementation(async () => + new Response(JSON.stringify({ + accepted: [], + rejected: [{ messageId: 'msg-5', reason: 'processing_error' }], + }), { status: 200, headers: { 'Content-Type': 'application/json' } }), + ); + + const workerModule = await import('./federationWorker.js'); + const processOutboxTick = (workerModule as { processOutboxTick?: () => Promise }).processOutboxTick!; + await processOutboxTick(); + + const remaining = testDb.select().from(schema.federationOutbox) + .where(eq(schema.federationOutbox.id, 'entry-r5')).get(); + expect(remaining).toBeDefined(); + + expect(invokeRollbackMock).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/server/src/utils/federationWorker.ts b/packages/server/src/utils/federationWorker.ts index 3ddbf3bf..e2c89507 100644 --- a/packages/server/src/utils/federationWorker.ts +++ b/packages/server/src/utils/federationWorker.ts @@ -12,6 +12,7 @@ import { connectionManager } from '../ws/handler.js'; import { generateThumbnail } from './thumbnail.js'; import type { FederationRelayRequest, FederationRelayResponse, FederationRelayEvent } from '@backspace/shared'; import { onPeerActivated, startupBootstrapSync, onPeerDeactivated } from './federationPeerActivation.js'; +import { invokePermanentFailureCallback } from './federationRollback.js'; import fs from 'node:fs'; import path from 'node:path'; import crypto from 'node:crypto'; @@ -49,6 +50,24 @@ const BACKOFF_SCHEDULE_MS: readonly number[] = [ const MAX_FILE_ATTEMPTS = 10; const PEER_UNREACHABLE_THRESHOLD = 10; +/** + * Outbox rejection reasons that the receiver has acknowledged as permanently + * undeliverable. These cause the outbox entry to be deleted (no retry) and + * trigger the registered permanent-failure callback for the eventType. + * + * 'duplicate' is treated as terminal-but-no-rollback (the receiver already has + * the event; nothing to roll back locally). + * + * 5xx responses, network errors, and timeouts are NOT in this set — they are + * transient and retried via the existing backoff schedule. + */ +const TERMINAL_REJECTION_REASONS = new Set([ + 'duplicate', // peer already has it (existing behavior) + 'recipient_not_found', // receiver doesn't know the target user + 'attribution_mismatch', // payload claims a homeInstance the source can't authoritatively speak for + 'unknown_event_type', // peer doesn't understand this eventType — never will +]); + // ─── Worker State ─────────────────────────────────────────────────────────── let outboxTimer: ReturnType | null = null; @@ -230,15 +249,25 @@ export async function processOutboxTick(): Promise { if (response.ok) { const result = await response.json() as FederationRelayResponse; - // Terminal outcomes = accepted + duplicate-rejected. - // `duplicate` means the peer already has the message (e.g., delivered - // earlier via outbox or pulled via sync). Retrying will fail with - // `duplicate` forever until TTL expires — treat it as effectively- - // accepted and remove the outbox entry. + // Terminal rejection reasons: receiver acknowledged the event is permanently + // undeliverable. Retrying will fail forever — remove from outbox. + // Non-`duplicate` terminals additionally invoke any registered permanent- + // failure callback for the eventType so the originator can roll back local + // state (e.g., friend_request_create deletes the local friend_requests row). const terminalEntityIds = new Set(result.accepted); + const terminalForRollback: Array<{ messageId: string; reason: string; eventType: string | null }> = []; + for (const rejection of result.rejected) { - if (rejection.reason === 'duplicate') { + if (TERMINAL_REJECTION_REASONS.has(rejection.reason)) { terminalEntityIds.add(rejection.messageId); + if (rejection.reason !== 'duplicate') { + const entry = peerEntries.find(e => e.entityId === rejection.messageId); + terminalForRollback.push({ + messageId: rejection.messageId, + reason: rejection.reason, + eventType: entry?.eventType ?? null, + }); + } } } @@ -254,13 +283,22 @@ export async function processOutboxTick(): Promise { } } - // Log rejected entries. Duplicate is terminal (outbox entry already - // removed above) — log at info level. Other reasons are transient / - // retained for retry — log at warn level. + // Invoke registered rollback callbacks AFTER deleting the outbox row, + // so the rollback runs in a clean state. The registry catches and logs + // callback errors — they cannot prevent outbox cleanup. + for (const { messageId, reason, eventType } of terminalForRollback) { + if (eventType) { + invokePermanentFailureCallback(eventType, messageId, reason); + } + } + + // Log rejected entries. Terminal reasons (incl. 'duplicate') are logged + // at info level — outbox entry already removed. Non-terminals stay in + // outbox for retry and log at warn level. for (const rejection of result.rejected) { - if (rejection.reason === 'duplicate') { + if (TERMINAL_REJECTION_REASONS.has(rejection.reason)) { console.log( - `[federation-worker] Peer ${peerOrigin} rejected message ${rejection.messageId} as duplicate — outbox entry removed (terminal)`, + `[federation-worker] Peer ${peerOrigin} terminal rejection ${rejection.messageId}: ${rejection.reason} — outbox entry removed`, ); } else { console.warn( From 08872f1e084d9d0a48644dcf3255235f2aadfe12 Mon Sep 17 00:00:00 2001 From: Jannis Braun <151788261+TheZwiss@users.noreply.github.com> Date: Sat, 25 Apr 2026 21:51:04 +0200 Subject: [PATCH 12/25] feat(federation): rollbackFriendRequestCreate + side-effect registration --- packages/server/src/index.ts | 1 + .../federationRollback.friendRequest.test.ts | 107 ++++++++++++++++++ .../server/src/utils/federationRollback.ts | 56 +++++++++ 3 files changed, 164 insertions(+) create mode 100644 packages/server/src/utils/federationRollback.friendRequest.test.ts diff --git a/packages/server/src/index.ts b/packages/server/src/index.ts index 511c3c41..9a50d082 100644 --- a/packages/server/src/index.ts +++ b/packages/server/src/index.ts @@ -26,6 +26,7 @@ import { adminRoutes } from './routes/admin.js'; import { gifRoutes } from './routes/gif.js'; import { federationRoutes } from './routes/federation.js'; import { startFederationWorkers, stopFederationWorkers } from './utils/federationWorker.js'; +import './utils/federationRollback.js'; // Side-effect: registers rollback callbacks for outbox terminal failures. import { registerCallRelayHooks } from './ws/events.js'; import { registerWebSocket } from './ws/handler.js'; diff --git a/packages/server/src/utils/federationRollback.friendRequest.test.ts b/packages/server/src/utils/federationRollback.friendRequest.test.ts new file mode 100644 index 00000000..6ef90dbc --- /dev/null +++ b/packages/server/src/utils/federationRollback.friendRequest.test.ts @@ -0,0 +1,107 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import Database from 'better-sqlite3'; +import { drizzle } from 'drizzle-orm/better-sqlite3'; +import { eq } from 'drizzle-orm'; +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import * as schema from '../db/schema.js'; +import { setWorkerId } from '../utils/snowflake.js'; + +setWorkerId(1); +const __dirname = path.dirname(fileURLToPath(import.meta.url)); + +let sqlite: Database.Database; +let testDb: ReturnType>; +const sendToUser = vi.fn(); + +vi.mock('../db/index.js', () => ({ + getDb: () => testDb, + getRawDb: () => sqlite, + schema, +})); + +vi.mock('../ws/handler.js', () => ({ + connectionManager: { sendToUser }, +})); + +function applyMigrations(db: Database.Database): void { + const dir = path.resolve(__dirname, '../../drizzle'); + for (const f of fs.readdirSync(dir).filter(f => f.endsWith('.sql')).sort()) { + const sqlText = fs.readFileSync(path.join(dir, f), 'utf8'); + for (const stmt of sqlText.split(/-->\s*statement-breakpoint/)) { + const clean = stmt.trim(); + if (clean) db.exec(clean); + } + } +} + +beforeEach(() => { + sqlite = new Database(':memory:'); + testDb = drizzle(sqlite, { schema }); + applyMigrations(sqlite); + sendToUser.mockReset(); +}); + +describe('rollbackFriendRequestCreate', () => { + it('deletes the matching friend_requests row and emits friend_request_relay_failed to sender', async () => { + testDb.insert(schema.users).values([ + { id: 'sender', username: 'bob', passwordHash: 'x', status: 'offline', isAdmin: 0, createdAt: Date.now() }, + { id: 'stub', username: 'alice@orbit.test', passwordHash: '!federation-replicated', + status: 'offline', isAdmin: 0, homeInstance: 'orbit.test', homeUserId: 'remote-1', createdAt: Date.now() }, + ] as typeof schema.users.$inferInsert[]).run(); + + testDb.insert(schema.friendRequests).values({ + id: 'req-1', + fromId: 'sender', + toId: 'stub', + status: 'pending', + createdAt: Date.now(), + relayMessageId: 'friend_req:remote-1:sender:1234', + } as typeof schema.friendRequests.$inferInsert).run(); + + const { rollbackFriendRequestCreate } = await import('./federationRollback.js'); + rollbackFriendRequestCreate('friend_req:remote-1:sender:1234', 'recipient_not_found'); + + const remaining = testDb.select().from(schema.friendRequests).where(eq(schema.friendRequests.id, 'req-1')).get(); + expect(remaining).toBeUndefined(); + + expect(sendToUser).toHaveBeenCalledOnce(); + const [userId, event] = sendToUser.mock.calls[0]!; + expect(userId).toBe('sender'); + expect(event.type).toBe('friend_request_relay_failed'); + expect(event.requestId).toBe('req-1'); + expect(event.reason).toBe('user_not_found'); + expect(event.targetHandle).toBe('alice@orbit.test'); + expect(typeof event.message).toBe('string'); + }); + + it('is idempotent: no-op if no row matches the messageId', async () => { + const { rollbackFriendRequestCreate } = await import('./federationRollback.js'); + expect(() => rollbackFriendRequestCreate('no-such-msg', 'recipient_not_found')).not.toThrow(); + expect(sendToUser).not.toHaveBeenCalled(); + }); + + it('maps unknown reasons to peer_rejected', async () => { + testDb.insert(schema.users).values([ + { id: 'sender', username: 'bob', passwordHash: 'x', status: 'offline', isAdmin: 0, createdAt: Date.now() }, + { id: 'stub', username: 'alice@orbit.test', passwordHash: '!federation-replicated', + status: 'offline', isAdmin: 0, homeInstance: 'orbit.test', homeUserId: 'remote-1', createdAt: Date.now() }, + ] as typeof schema.users.$inferInsert[]).run(); + testDb.insert(schema.friendRequests).values({ + id: 'req-2', + fromId: 'sender', + toId: 'stub', + status: 'pending', + createdAt: Date.now(), + relayMessageId: 'msg-x', + } as typeof schema.friendRequests.$inferInsert).run(); + + const { rollbackFriendRequestCreate } = await import('./federationRollback.js'); + rollbackFriendRequestCreate('msg-x', 'attribution_mismatch'); + + expect(sendToUser).toHaveBeenCalledOnce(); + const event = sendToUser.mock.calls[0]![1]; + expect(event.reason).toBe('peer_rejected'); + }); +}); diff --git a/packages/server/src/utils/federationRollback.ts b/packages/server/src/utils/federationRollback.ts index 8512e3d0..c011c6db 100644 --- a/packages/server/src/utils/federationRollback.ts +++ b/packages/server/src/utils/federationRollback.ts @@ -12,6 +12,11 @@ * Errors thrown by callbacks are logged but not re-thrown — rollback failure * must not prevent the outbox entry from being deleted. */ +import { getDb } from '../db/index.js'; +import * as schema from '../db/schema.js'; +import { eq } from 'drizzle-orm'; +import { connectionManager } from '../ws/handler.js'; + type PermanentFailureCallback = (messageId: string, reason: string) => void; const callbacks = new Map(); @@ -37,3 +42,54 @@ export function invokePermanentFailureCallback(eventType: string, messageId: str export function _resetCallbacks(): void { callbacks.clear(); } + +/** + * Rollback handler for 'friend_request_create' outbox events. + * + * Deletes the pending friend request row whose relayMessageId matches the + * failed outbox message, then notifies the sender via WebSocket so the client + * can surface an appropriate error toast. + */ +export function rollbackFriendRequestCreate(messageId: string, receiverReason: string): void { + const db = getDb(); + + const row = db + .select() + .from(schema.friendRequests) + .where(eq(schema.friendRequests.relayMessageId, messageId)) + .get(); + + if (!row) return; // Idempotent — no row to roll back. + + // Look up recipient handle for the toast text BEFORE deleting. + const recipient = db + .select({ username: schema.users.username }) + .from(schema.users) + .where(eq(schema.users.id, row.toId)) + .get(); + const targetHandle = recipient?.username ?? 'unknown'; + + db.delete(schema.friendRequests).where(eq(schema.friendRequests.id, row.id)).run(); + + // Reason mapping: receiver-side reason → client-facing reason. + // 'recipient_not_found' → 'user_not_found' (the looked-up identity vanished) + // anything else → 'peer_rejected' (catch-all) + const reason: 'user_not_found' | 'peer_rejected' = + receiverReason === 'recipient_not_found' ? 'user_not_found' : 'peer_rejected'; + + const message = + reason === 'user_not_found' + ? `User ${targetHandle} no longer exists on the remote instance.` + : `Friend request to ${targetHandle} was rejected by the remote instance.`; + + connectionManager.sendToUser(row.fromId, { + type: 'friend_request_relay_failed', + requestId: row.id, + reason, + message, + targetHandle, + }); +} + +// Register the callback at module-load time so the worker invokes it. +registerPermanentFailureCallback('friend_request_create', rollbackFriendRequestCreate); From 0574a10ff3fa912a9aa0b186b6b2189c71ad268f Mon Sep 17 00:00:00 2001 From: Jannis Braun <151788261+TheZwiss@users.noreply.github.com> Date: Sat, 25 Apr 2026 22:04:36 +0200 Subject: [PATCH 13/25] feat(social): federated branch for POST /api/social/requests (happy path) Refactors the POST handler into handleLocalFriendRequest + handleFederatedFriendRequest helpers. The federated branch resolves the target domain, ensures peering, looks up the remote user, creates/hydrates a replicated stub, and writes a transactional (friend_requests + mutation_log + outbox) event with relayMessageId set. Also exports hydrateReplicatedUserProfile from federation.ts and adds the T11 happy-path test. --- packages/server/src/routes/federation.ts | 2 +- .../src/routes/social.federated.test.ts | 173 ++++++++ packages/server/src/routes/social.test.ts | 7 +- packages/server/src/routes/social.ts | 396 +++++++++++++----- 4 files changed, 465 insertions(+), 113 deletions(-) create mode 100644 packages/server/src/routes/social.federated.test.ts diff --git a/packages/server/src/routes/federation.ts b/packages/server/src/routes/federation.ts index 18ab811a..6a30e081 100644 --- a/packages/server/src/routes/federation.ts +++ b/packages/server/src/routes/federation.ts @@ -3845,7 +3845,7 @@ function processOwnershipTransferEvent( * Only updates fields that are currently null/empty on the local row, * so manually-set local values are preserved. */ -function hydrateReplicatedUserProfile( +export function hydrateReplicatedUserProfile( user: typeof schema.users.$inferSelect, profile: FederationRelayProfileSnapshot | undefined, db: ReturnType, diff --git a/packages/server/src/routes/social.federated.test.ts b/packages/server/src/routes/social.federated.test.ts new file mode 100644 index 00000000..6a259df6 --- /dev/null +++ b/packages/server/src/routes/social.federated.test.ts @@ -0,0 +1,173 @@ +import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest'; +import Fastify, { type FastifyInstance } from 'fastify'; +import Database from 'better-sqlite3'; +import { drizzle } from 'drizzle-orm/better-sqlite3'; +import { eq } from 'drizzle-orm'; +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import * as schema from '../db/schema.js'; +import { setWorkerId } from '../utils/snowflake.js'; + +setWorkerId(1); +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const CALLER_ID = 'caller-id'; + +let sqlite: Database.Database; +let testDb: ReturnType>; +const sendToUser = vi.fn(); +const ensurePeeredMock = vi.fn(); +const lookupRemoteUserMock = vi.fn(); +const resolveOriginFromHostnameMock = vi.fn(); + +vi.mock('../db/index.js', () => ({ getDb: () => testDb, getRawDb: () => sqlite, schema })); +vi.mock('../utils/auth.js', () => ({ + authenticate: async (req: { userId?: string }) => { req.userId = CALLER_ID; }, +})); +vi.mock('../ws/handler.js', () => ({ + connectionManager: { sendToUser, sendToAdmins: vi.fn(), sendToDmMembers: vi.fn(), getAllOnlineUserIds: () => [] }, +})); +vi.mock('../utils/federationAuth.js', async (importActual) => { + const actual = await importActual(); + return { ...actual, getOurOrigin: () => 'https://home.test' }; +}); +vi.mock('../utils/federationPeering.js', () => ({ + ensurePeered: (...args: unknown[]) => ensurePeeredMock(...args), + racePeering: vi.fn(), +})); +vi.mock('../utils/federationLookup.js', () => ({ + lookupRemoteUser: (...args: unknown[]) => lookupRemoteUserMock(...args), +})); +vi.mock('../utils/federationOriginResolve.js', () => ({ + resolveOriginFromHostname: (...args: unknown[]) => resolveOriginFromHostnameMock(...args), +})); + +function applyMigrations(db: Database.Database): void { + const dir = path.resolve(__dirname, '../../drizzle'); + for (const f of fs.readdirSync(dir).filter(f => f.endsWith('.sql')).sort()) { + const sqlText = fs.readFileSync(path.join(dir, f), 'utf8'); + for (const stmt of sqlText.split(/-->\s*statement-breakpoint/)) { + const clean = stmt.trim(); + if (clean) db.exec(clean); + } + } +} + +function seedSelf(opts: { homeInstance?: string | null; homeUserId?: string | null } = {}): void { + testDb.insert(schema.users).values({ + id: CALLER_ID, + username: 'caller', + displayName: 'Caller', + passwordHash: 'x', + status: 'online', + isAdmin: 0, + homeInstance: opts.homeInstance ?? null, + homeUserId: opts.homeUserId ?? null, + createdAt: Date.now(), + }).run(); + // Seed instance_settings with relay enabled so queue/log writes are not silently skipped. + // Use raw exec to avoid the updatedAt NOT NULL constraint (no default in schema). + sqlite.exec(`INSERT OR IGNORE INTO instance_settings (id, federation_relay_enabled, updated_at) VALUES (1, 1, ${Date.now()})`); +} + +async function buildApp(): Promise { + const app = Fastify({ logger: false }); + const { socialRoutes } = await import('./social.js'); + await app.register(socialRoutes); + await app.ready(); + return app; +} + +beforeEach(() => { + sqlite = new Database(':memory:'); + testDb = drizzle(sqlite, { schema }); + applyMigrations(sqlite); + sendToUser.mockReset(); + ensurePeeredMock.mockReset(); + lookupRemoteUserMock.mockReset(); + resolveOriginFromHostnameMock.mockReset(); +}); + +afterEach(() => { + vi.clearAllMocks(); +}); + +describe('POST /api/social/requests — federated branch (happy path)', () => { + it('creates a stub, inserts the request with relayMessageId, queues a relay event', async () => { + seedSelf(); + + resolveOriginFromHostnameMock.mockReturnValue('https://orbit.test'); + ensurePeeredMock.mockResolvedValue({ status: 'active', peerId: 'peer-1' }); + lookupRemoteUserMock.mockResolvedValue({ + ok: true, + homeUserId: 'remote-alice', + username: 'alice', + profile: { + displayName: 'Alice', + avatar: null, + avatarColor: 'mint', + banner: null, + bio: 'hi', + }, + }); + + const app = await buildApp(); + const res = await app.inject({ + method: 'POST', + url: '/api/social/requests', + payload: { username: 'alice@orbit.test' }, + }); + + // Status and body + expect(res.statusCode).toBe(201); + const body = JSON.parse(res.body) as { success: boolean; requestId: string }; + expect(body.success).toBe(true); + expect(typeof body.requestId).toBe('string'); + + // friend_requests row + const reqRow = testDb.select().from(schema.friendRequests) + .where(eq(schema.friendRequests.id, body.requestId)) + .get(); + expect(reqRow).toBeTruthy(); + expect(reqRow!.fromId).toBe(CALLER_ID); + expect(reqRow!.relayMessageId).toMatch(/^friend_req:/); + + // Stub user exists with correct federated identity + const stub = testDb.select().from(schema.users) + .where(eq(schema.users.homeUserId, 'remote-alice')) + .get(); + expect(stub).toBeTruthy(); + expect(stub!.homeInstance).toBe('orbit.test'); + expect(stub!.displayName).toBe('Alice'); + expect(stub!.bio).toBe('hi'); + + // federation_outbox row + const peer = testDb.select({ id: schema.federationPeers.id, origin: schema.federationPeers.origin }) + .from(schema.federationPeers) + .where(eq(schema.federationPeers.origin, 'https://orbit.test')) + .get(); + expect(peer).toBeTruthy(); + + const outboxRow = testDb.select().from(schema.federationOutbox) + .where(eq(schema.federationOutbox.peerId, peer!.id)) + .get(); + expect(outboxRow).toBeTruthy(); + expect(outboxRow!.eventType).toBe('friend_request_create'); + expect(outboxRow!.entityId).toBe(reqRow!.relayMessageId); + + // federation_mutation_log row + const mutationRow = testDb.select().from(schema.federationMutationLog) + .where(eq(schema.federationMutationLog.entityId, reqRow!.relayMessageId!)) + .get(); + expect(mutationRow).toBeTruthy(); + + // WS broadcast to sender + expect(sendToUser).toHaveBeenCalledWith( + CALLER_ID, + expect.objectContaining({ + type: 'friend_request_sent', + request: expect.objectContaining({ id: body.requestId }), + }), + ); + }); +}); diff --git a/packages/server/src/routes/social.test.ts b/packages/server/src/routes/social.test.ts index 7efe6790..df0f0850 100644 --- a/packages/server/src/routes/social.test.ts +++ b/packages/server/src/routes/social.test.ts @@ -53,9 +53,10 @@ vi.mock('../utils/federationOutbox.js', () => ({ getFriendEventTargets: () => [], })); -vi.mock('../utils/federationAuth.js', () => ({ - getOurOrigin: () => 'https://local.test', -})); +vi.mock('../utils/federationAuth.js', async (importActual) => { + const actual = await importActual(); + return { ...actual, getOurOrigin: () => 'https://local.test' }; +}); function applyMigrations(db: Database.Database): void { const migrationsDir = path.resolve(__dirname, '../../drizzle'); diff --git a/packages/server/src/routes/social.ts b/packages/server/src/routes/social.ts index bb8f769f..946f5a1f 100644 --- a/packages/server/src/routes/social.ts +++ b/packages/server/src/routes/social.ts @@ -1,11 +1,15 @@ -import type { FastifyInstance } from 'fastify'; +import type { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify'; import { eq, and, or, ne, like, sql, inArray } from 'drizzle-orm'; -import { getDb, schema } from '../db/index.js'; +import { getDb, getRawDb, schema } from '../db/index.js'; import { authenticate } from '../utils/auth.js'; import { generateSnowflake } from '../utils/snowflake.js'; import { connectionManager } from '../ws/handler.js'; import { appendMutationLog, queueOutboxEvent, buildFriendContextId, getFriendEventTargets } from '../utils/federationOutbox.js'; -import { getOurOrigin } from '../utils/federationAuth.js'; +import { getOurOrigin, normalizeOriginForCompare } from '../utils/federationAuth.js'; +import { ensurePeered } from '../utils/federationPeering.js'; +import { lookupRemoteUser } from '../utils/federationLookup.js'; +import { resolveOriginFromHostname } from '../utils/federationOriginResolve.js'; +import { resolveOrCreateReplicatedUser, hydrateReplicatedUserProfile } from './federation.js'; import type { FederationRelayEvent, FederationRelayProfileSnapshot } from '@backspace/shared'; import type { Friend, @@ -27,6 +31,260 @@ function buildProfileSnapshot(user: typeof schema.users.$inferSelect): Federatio }; } +// ─── Local friend request helper ───────────────────────────────────────────── + +async function handleLocalFriendRequest( + db: ReturnType, + request: FastifyRequest, + reply: FastifyReply, + localUsername: string, + sender: typeof schema.users.$inferSelect, + ourOrigin: string, +): Promise { + // Match the canonical-lowercase form used by auth (auth.ts:32, 211, 256). + const lookupUsername = localUsername.toLowerCase(); + + // Find the target user + const targetUser = db.select().from(schema.users).where(eq(schema.users.username, lookupUsername)).get(); + if (!targetUser) { + return reply.code(404).send({ error: 'User not found', statusCode: 404 }); + } + + if (targetUser.id === request.userId) { + return reply.code(400).send({ error: 'You cannot add yourself as a friend', statusCode: 400 }); + } + + // Check if already friends + const existingFriend = db.select().from(schema.friends).where(or( + and(eq(schema.friends.userId, request.userId), eq(schema.friends.friendId, targetUser.id)), + and(eq(schema.friends.userId, targetUser.id), eq(schema.friends.friendId, request.userId)) + )).get(); + + if (existingFriend) { + return reply.code(400).send({ error: 'You are already friends with this user', statusCode: 400 }); + } + + // Check for existing pending request + const existingRequest = db.select().from(schema.friendRequests).where(and( + or( + and(eq(schema.friendRequests.fromId, request.userId), eq(schema.friendRequests.toId, targetUser.id)), + and(eq(schema.friendRequests.fromId, targetUser.id), eq(schema.friendRequests.toId, request.userId)) + ), + eq(schema.friendRequests.status, 'pending') + )).get(); + + if (existingRequest) { + return reply.code(400).send({ error: 'A friend request is already pending', statusCode: 400 }); + } + + // Create the request + const id = generateSnowflake(); + const now = Date.now(); + db.insert(schema.friendRequests).values({ + id, + fromId: request.userId, + toId: targetUser.id, + status: 'pending', + createdAt: now, + }).run(); + + // Broadcast friend_request_received to the target user + const friendRequestPayload: FriendRequest = { + id, + fromId: request.userId, + toId: targetUser.id, + status: 'pending', + createdAt: now, + user: sanitizeUser(sender), + }; + + connectionManager.sendToUser(targetUser.id, { + type: 'friend_request_received', + request: friendRequestPayload, + }); + + // Federation relay: notify the target user's home instance + const fromIdentity = { + homeUserId: sender.homeUserId || request.userId, + homeInstance: sender.homeInstance || ourOrigin, + }; + const toIdentity = { + homeUserId: targetUser.homeUserId || targetUser.id, + homeInstance: targetUser.homeInstance || ourOrigin, + }; + + const targets = getFriendEventTargets(fromIdentity.homeInstance, toIdentity.homeInstance); + if (targets.length > 0) { + const contextId = buildFriendContextId(fromIdentity.homeUserId, toIdentity.homeUserId); + const entityId = `friend_req:${[fromIdentity.homeUserId, toIdentity.homeUserId].sort().join(':')}:${now}`; + + const payload: FederationRelayEvent = { + eventType: 'friend_request_create', + contextType: 'friend', + messageId: entityId, + encryptionVersion: 0, + timestamp: now, + friendship: { + from: fromIdentity, + to: toIdentity, + fromProfile: buildProfileSnapshot(sender), + toProfile: buildProfileSnapshot(targetUser), + status: 'pending', + createdAt: now, + }, + }; + + const payloadStr = JSON.stringify(payload); + appendMutationLog(entityId, contextId, 'friend_request_create', payloadStr, 'friend'); + queueOutboxEvent(entityId, contextId, 'friend_request_create', payloadStr, targets, 'friend'); + } + + return reply.code(201).send({ success: true, requestId: id }); +} + +// ─── Federated friend request helper ───────────────────────────────────────── + +async function handleFederatedFriendRequest( + db: ReturnType, + request: FastifyRequest, + reply: FastifyReply, + raw: string, + atIndex: number, + sender: typeof schema.users.$inferSelect, + ourOrigin: string, +): Promise { + const baseName = raw.slice(0, atIndex).toLowerCase(); + const targetDomain = raw.slice(atIndex + 1).toLowerCase(); + + // 1. Resolve scheme + const peerOrigin = resolveOriginFromHostname(targetDomain); + if (!peerOrigin) { + return reply.code(400).send({ error: 'invalid_target_domain', statusCode: 400, domain: targetDomain }); + } + + // 2. ensurePeered — block until 'active', or surface peer status as error + const peering = await ensurePeered(peerOrigin); + if (peering.status === 'rejected') { + return reply.code(403).send({ error: 'peer_rejected', statusCode: 403, domain: targetDomain }); + } + if (peering.status === 'failed') { + return reply.code(503).send({ error: 'peer_unreachable', statusCode: 503, domain: targetDomain }); + } + if (peering.status === 'pending') { + const peerRow = db.select({ status: schema.federationPeers.status }) + .from(schema.federationPeers) + .where(eq(schema.federationPeers.origin, peerOrigin)) + .get(); + if (peerRow?.status === 'awaiting_approval') { + return reply.code(409).send({ error: 'peer_pending_approval', statusCode: 409, domain: targetDomain }); + } + return reply.code(409).send({ error: 'peer_pending', statusCode: 409, domain: targetDomain }); + } + // peering.status === 'active' — continue + + // 3. Lookup + const lookup = await lookupRemoteUser(peerOrigin, baseName); + if (!lookup.ok) { + if (lookup.reason === 'not_found') { + return reply.code(404).send({ error: 'user_not_found', statusCode: 404, domain: targetDomain, handle: baseName }); + } + if (lookup.reason === 'unreachable') { + return reply.code(503).send({ error: 'peer_unreachable', statusCode: 503, domain: targetDomain }); + } + if (lookup.reason === 'rate_limited') { + const headers: Record = {}; + if (lookup.retryAfter) headers['Retry-After'] = String(lookup.retryAfter); + return reply.code(429).headers(headers).send({ error: 'lookup_rate_limited', statusCode: 429 }); + } + // Exhaustive — should be unreachable. + return reply.code(500).send({ error: 'unknown_lookup_failure', statusCode: 500 }); + } + + // 4. Self-friend pre-check + const senderCanonicalId = sender.homeUserId || sender.id; + if ( + lookup.homeUserId === senderCanonicalId && + normalizeOriginForCompare(peerOrigin) === normalizeOriginForCompare(ourOrigin) + ) { + return reply.code(400).send({ error: 'cannot_friend_self', statusCode: 400 }); + } + + // 5. Resolve / hydrate stub + const stub = resolveOrCreateReplicatedUser(lookup.homeUserId, targetDomain, db, { username: lookup.username }); + if (!stub) { + // Tombstoned identity — refuse to resurrect. + return reply.code(404).send({ error: 'user_not_found', statusCode: 404, domain: targetDomain, handle: baseName }); + } + const stubHydrated = hydrateReplicatedUserProfile(stub, lookup.profile, db); + + // 6. Transaction: insert + log + queue outbox + const now = Date.now(); + const fromIdentity = { + homeUserId: sender.homeUserId || sender.id, + homeInstance: ourOrigin, + }; + const toIdentity = { + homeUserId: stubHydrated.homeUserId!, + homeInstance: peerOrigin, + }; + const contextId = buildFriendContextId(fromIdentity.homeUserId, toIdentity.homeUserId); + const entityId = `friend_req:${[fromIdentity.homeUserId, toIdentity.homeUserId].sort().join(':')}:${now}`; + const requestId = generateSnowflake(); + + const payload: FederationRelayEvent = { + eventType: 'friend_request_create', + contextType: 'friend', + messageId: entityId, + encryptionVersion: 0, + timestamp: now, + friendship: { + from: fromIdentity, + to: toIdentity, + fromProfile: buildProfileSnapshot(sender), + toProfile: { + username: stubHydrated.username, + displayName: lookup.profile.displayName, + avatar: lookup.profile.avatar, + avatarColor: lookup.profile.avatarColor, + banner: lookup.profile.banner, + bio: lookup.profile.bio, + }, + status: 'pending', + createdAt: now, + }, + }; + const payloadStr = JSON.stringify(payload); + + const rawDb = getRawDb(); + rawDb.transaction(() => { + db.insert(schema.friendRequests).values({ + id: requestId, + fromId: sender.id, + toId: stubHydrated.id, + status: 'pending', + createdAt: now, + relayMessageId: entityId, + }).run(); + appendMutationLog(entityId, contextId, 'friend_request_create', payloadStr, 'friend'); + queueOutboxEvent(entityId, contextId, 'friend_request_create', payloadStr, [peerOrigin], 'friend'); + })(); + + // 7. WS broadcast to sender's other tabs/devices + const requestSnapshot: FriendRequest = { + id: requestId, + fromId: sender.id, + toId: stubHydrated.id, + status: 'pending', + createdAt: now, + user: sanitizeUser(stubHydrated), + }; + connectionManager.sendToUser(sender.id, { type: 'friend_request_sent', request: requestSnapshot }); + + return reply.code(201).send({ success: true, requestId }); +} + +// ─── Route registration ─────────────────────────────────────────────────────── + export async function socialRoutes(app: FastifyInstance): Promise { // GET /api/social/friends - List all friends app.get('/api/social/friends', { @@ -112,7 +370,7 @@ export async function socialRoutes(app: FastifyInstance): Promise { return reply.code(200).send(result); }); - // POST /api/social/requests - Send a friend request + // POST /api/social/requests - Send a friend request (local or federated) app.post<{ Body: SendFriendRequest }>('/api/social/requests', { preHandler: authenticate, }, async (request, reply) => { @@ -120,116 +378,36 @@ export async function socialRoutes(app: FastifyInstance): Promise { const db = getDb(); if (!username || typeof username !== 'string') { - return reply.code(400).send({ error: 'Username is required', statusCode: 400 }); + return reply.code(400).send({ error: 'username_required', statusCode: 400 }); } - // Match the canonical-lowercase form used by auth (auth.ts:32, 211, 256). - const lookupUsername = username.trim().toLowerCase(); - if (!lookupUsername) { - return reply.code(400).send({ error: 'Username is required', statusCode: 400 }); + const raw = username.trim(); + if (!raw) return reply.code(400).send({ error: 'username_required', statusCode: 400 }); + + const sender = db.select().from(schema.users).where(eq(schema.users.id, request.userId)).get(); + if (!sender) return reply.code(401).send({ error: 'authenticated user not found', statusCode: 401 }); + + const ourOrigin = getOurOrigin(); + const ourHost = normalizeOriginForCompare(ourOrigin); + + // Authority defense — only native users may originate friend_request_create + // outbox events from this instance (spec §5.6). Done before any branching. + const senderHomeNorm = normalizeOriginForCompare(sender.homeInstance); + if (senderHomeNorm && senderHomeNorm !== ourHost) { + return reply.code(403).send({ error: 'not_authoritative_for_sender', statusCode: 403 }); } - // Find the target user - const targetUser = db.select().from(schema.users).where(eq(schema.users.username, lookupUsername)).get(); - if (!targetUser) { - return reply.code(404).send({ error: 'User not found', statusCode: 404 }); + const atIndex = raw.lastIndexOf('@'); + const isFederated = + atIndex > 0 && + atIndex < raw.length - 1 && + normalizeOriginForCompare(raw.slice(atIndex + 1)) !== ourHost; + + if (!isFederated) { + const localUsername = atIndex > 0 ? raw.slice(0, atIndex) : raw; + return handleLocalFriendRequest(db, request, reply, localUsername, sender, ourOrigin); } - - if (targetUser.id === request.userId) { - return reply.code(400).send({ error: 'You cannot add yourself as a friend', statusCode: 400 }); - } - - // Check if already friends - const existingFriend = db.select().from(schema.friends).where(or( - and(eq(schema.friends.userId, request.userId), eq(schema.friends.friendId, targetUser.id)), - and(eq(schema.friends.userId, targetUser.id), eq(schema.friends.friendId, request.userId)) - )).get(); - - if (existingFriend) { - return reply.code(400).send({ error: 'You are already friends with this user', statusCode: 400 }); - } - - // Check for existing pending request - const existingRequest = db.select().from(schema.friendRequests).where(and( - or( - and(eq(schema.friendRequests.fromId, request.userId), eq(schema.friendRequests.toId, targetUser.id)), - and(eq(schema.friendRequests.fromId, targetUser.id), eq(schema.friendRequests.toId, request.userId)) - ), - eq(schema.friendRequests.status, 'pending') - )).get(); - - if (existingRequest) { - return reply.code(400).send({ error: 'A friend request is already pending', statusCode: 400 }); - } - - // Create the request - const id = generateSnowflake(); - const now = Date.now(); - db.insert(schema.friendRequests).values({ - id, - fromId: request.userId, - toId: targetUser.id, - status: 'pending', - createdAt: now, - }).run(); - - // Get the sender user for the WS event - const senderUser = db.select().from(schema.users).where(eq(schema.users.id, request.userId)).get(); - - // Broadcast friend_request_received to the target user - const friendRequestPayload: FriendRequest = { - id, - fromId: request.userId, - toId: targetUser.id, - status: 'pending', - createdAt: now, - user: senderUser ? sanitizeUser(senderUser) : undefined, - }; - - connectionManager.sendToUser(targetUser.id, { - type: 'friend_request_received', - request: friendRequestPayload, - }); - - // Federation relay: notify the target user's home instance - const domainOrigin = getOurOrigin(); - - const fromIdentity = { - homeUserId: senderUser?.homeUserId || request.userId, - homeInstance: senderUser?.homeInstance || domainOrigin, - }; - const toIdentity = { - homeUserId: targetUser.homeUserId || targetUser.id, - homeInstance: targetUser.homeInstance || domainOrigin, - }; - - const targets = getFriendEventTargets(fromIdentity.homeInstance, toIdentity.homeInstance); - if (targets.length > 0) { - const contextId = buildFriendContextId(fromIdentity.homeUserId, toIdentity.homeUserId); - const entityId = `friend_req:${[fromIdentity.homeUserId, toIdentity.homeUserId].sort().join(':')}:${now}`; - - const payload: FederationRelayEvent = { - eventType: 'friend_request_create', - contextType: 'friend', - messageId: entityId, - encryptionVersion: 0, - timestamp: now, - friendship: { - from: fromIdentity, - to: toIdentity, - fromProfile: senderUser ? buildProfileSnapshot(senderUser) : undefined, - toProfile: buildProfileSnapshot(targetUser), - status: 'pending', - createdAt: now, - }, - }; - - const payloadStr = JSON.stringify(payload); - appendMutationLog(entityId, contextId, 'friend_request_create', payloadStr, 'friend'); - queueOutboxEvent(entityId, contextId, 'friend_request_create', payloadStr, targets, 'friend'); - } - - return reply.code(201).send({ success: true, requestId: id }); + return handleFederatedFriendRequest(db, request, reply, raw, atIndex, sender, ourOrigin); }); // PATCH /api/social/requests/:id - Accept/Decline a friend request From 72f4b170f6f6a45514a65e8f08fb7fc118ceef62 Mon Sep 17 00:00:00 2001 From: Jannis Braun <151788261+TheZwiss@users.noreply.github.com> Date: Sat, 25 Apr 2026 22:09:42 +0200 Subject: [PATCH 14/25] test(social): federated branch peer-status + lookup-failure mappings --- .../src/routes/social.federated.test.ts | 122 ++++++++++++++++++ 1 file changed, 122 insertions(+) diff --git a/packages/server/src/routes/social.federated.test.ts b/packages/server/src/routes/social.federated.test.ts index 6a259df6..1016d938 100644 --- a/packages/server/src/routes/social.federated.test.ts +++ b/packages/server/src/routes/social.federated.test.ts @@ -171,3 +171,125 @@ describe('POST /api/social/requests — federated branch (happy path)', () => { ); }); }); + +describe('POST /api/social/requests — federated branch (peer status)', () => { + beforeEach(() => { + seedSelf(); + resolveOriginFromHostnameMock.mockReturnValue('https://orbit.test'); + }); + + it('returns 403 peer_rejected when ensurePeered returns rejected', async () => { + ensurePeeredMock.mockResolvedValue({ status: 'rejected', error: 'admin must approve' }); + const app = await buildApp(); + const res = await app.inject({ + method: 'POST', + url: '/api/social/requests', + payload: { username: 'alice@orbit.test' }, + }); + expect(res.statusCode).toBe(403); + expect(JSON.parse(res.body).error).toBe('peer_rejected'); + expect(testDb.select().from(schema.friendRequests).all()).toHaveLength(0); + }); + + it('returns 503 peer_unreachable when ensurePeered returns failed', async () => { + ensurePeeredMock.mockResolvedValue({ status: 'failed', error: 'network' }); + const app = await buildApp(); + const res = await app.inject({ + method: 'POST', + url: '/api/social/requests', + payload: { username: 'alice@orbit.test' }, + }); + expect(res.statusCode).toBe(503); + expect(JSON.parse(res.body).error).toBe('peer_unreachable'); + expect(testDb.select().from(schema.friendRequests).all()).toHaveLength(0); + }); + + it('returns 409 peer_pending_approval when peering is pending and peer row is awaiting_approval', async () => { + ensurePeeredMock.mockResolvedValue({ status: 'pending', error: 'awaiting' }); + testDb.insert(schema.federationPeers).values({ + id: 'peer-pending-1', + origin: 'https://orbit.test', + hmacSecret: 'secret', + status: 'awaiting_approval', + createdAt: Date.now(), + }).run(); + const app = await buildApp(); + const res = await app.inject({ + method: 'POST', + url: '/api/social/requests', + payload: { username: 'alice@orbit.test' }, + }); + expect(res.statusCode).toBe(409); + expect(JSON.parse(res.body).error).toBe('peer_pending_approval'); + }); + + it('returns 409 peer_pending when peering is pending and no peer row exists (handshake in flight)', async () => { + ensurePeeredMock.mockResolvedValue({ status: 'pending', error: 'in flight' }); + const app = await buildApp(); + const res = await app.inject({ + method: 'POST', + url: '/api/social/requests', + payload: { username: 'alice@orbit.test' }, + }); + expect(res.statusCode).toBe(409); + expect(JSON.parse(res.body).error).toBe('peer_pending'); + }); +}); + +describe('POST /api/social/requests — federated branch (lookup failures)', () => { + beforeEach(() => { + seedSelf(); + resolveOriginFromHostnameMock.mockReturnValue('https://orbit.test'); + ensurePeeredMock.mockResolvedValue({ status: 'active', peerId: 'p1' }); + }); + + it('returns 404 user_not_found when lookup returns not_found', async () => { + lookupRemoteUserMock.mockResolvedValue({ ok: false, reason: 'not_found' }); + const app = await buildApp(); + const res = await app.inject({ + method: 'POST', + url: '/api/social/requests', + payload: { username: 'alice@orbit.test' }, + }); + expect(res.statusCode).toBe(404); + expect(JSON.parse(res.body).error).toBe('user_not_found'); + expect(testDb.select().from(schema.friendRequests).all()).toHaveLength(0); + }); + + it('returns 503 peer_unreachable when lookup returns unreachable', async () => { + lookupRemoteUserMock.mockResolvedValue({ ok: false, reason: 'unreachable' }); + const app = await buildApp(); + const res = await app.inject({ + method: 'POST', + url: '/api/social/requests', + payload: { username: 'alice@orbit.test' }, + }); + expect(res.statusCode).toBe(503); + expect(JSON.parse(res.body).error).toBe('peer_unreachable'); + }); + + it('returns 429 lookup_rate_limited with Retry-After header when lookup returns rate_limited', async () => { + lookupRemoteUserMock.mockResolvedValue({ ok: false, reason: 'rate_limited', retryAfter: 30 }); + const app = await buildApp(); + const res = await app.inject({ + method: 'POST', + url: '/api/social/requests', + payload: { username: 'alice@orbit.test' }, + }); + expect(res.statusCode).toBe(429); + expect(JSON.parse(res.body).error).toBe('lookup_rate_limited'); + expect(res.headers['retry-after']).toBe('30'); + }); + + it('returns 400 invalid_target_domain when resolveOriginFromHostname returns null', async () => { + resolveOriginFromHostnameMock.mockReturnValue(null); + const app = await buildApp(); + const res = await app.inject({ + method: 'POST', + url: '/api/social/requests', + payload: { username: 'alice@orbit.test' }, + }); + expect(res.statusCode).toBe(400); + expect(JSON.parse(res.body).error).toBe('invalid_target_domain'); + }); +}); From bf13e2a2236c3bb5fae84aeab57696e94bda8064 Mon Sep 17 00:00:00 2001 From: Jannis Braun <151788261+TheZwiss@users.noreply.github.com> Date: Sat, 25 Apr 2026 22:12:08 +0200 Subject: [PATCH 15/25] =?UTF-8?q?feat(social):=20federated=20branch=20?= =?UTF-8?q?=E2=80=94=20authority=20+=20self-friend=20+=20idempotency?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds direction-aware idempotency and already-friends checks to handleFederatedFriendRequest (after stub hydration, before transaction), plus 6 tests covering authority defense, bare-host normalization, cannot_friend_self, already_friends, same-direction idempotent 200, and opposite-direction 409. --- .../src/routes/social.federated.test.ts | 158 ++++++++++++++++++ packages/server/src/routes/social.ts | 33 ++++ 2 files changed, 191 insertions(+) diff --git a/packages/server/src/routes/social.federated.test.ts b/packages/server/src/routes/social.federated.test.ts index 1016d938..f9fe3ad4 100644 --- a/packages/server/src/routes/social.federated.test.ts +++ b/packages/server/src/routes/social.federated.test.ts @@ -293,3 +293,161 @@ describe('POST /api/social/requests — federated branch (lookup failures)', () expect(JSON.parse(res.body).error).toBe('invalid_target_domain'); }); }); + +describe('POST /api/social/requests — federated branch (authority + self-friend + idempotency)', () => { + beforeEach(() => { + resolveOriginFromHostnameMock.mockReturnValue('https://orbit.test'); + ensurePeeredMock.mockResolvedValue({ status: 'active', peerId: 'p1' }); + lookupRemoteUserMock.mockResolvedValue({ + ok: true, homeUserId: 'remote-alice', username: 'alice', + profile: { displayName: 'Alice', avatar: null, avatarColor: 'mint', banner: null, bio: null }, + }); + }); + + it('returns 403 not_authoritative_for_sender when caller is a federated user', async () => { + seedSelf({ homeInstance: 'other.test', homeUserId: 'me-elsewhere' }); + const app = await buildApp(); + const res = await app.inject({ + method: 'POST', + url: '/api/social/requests', + payload: { username: 'alice@orbit.test' }, + }); + expect(res.statusCode).toBe(403); + expect(JSON.parse(res.body).error).toBe('not_authoritative_for_sender'); + }); + + it('passes authority check when sender homeInstance is stored as bare host', async () => { + // homeInstance stored without scheme — normalizeOriginForCompare('home.test') must + // equal normalizeOriginForCompare('https://home.test') (T1 invariant). + seedSelf({ homeInstance: 'home.test', homeUserId: CALLER_ID }); + const app = await buildApp(); + const res = await app.inject({ + method: 'POST', + url: '/api/social/requests', + payload: { username: 'alice@orbit.test' }, + }); + expect(res.statusCode).toBe(201); + }); + + it('returns 400 cannot_friend_self when looked-up user is canonical-self', async () => { + // The dispatcher routes this as federated because targetDomain ('otherhost') is not + // normalized to our host. resolveOriginFromHostname maps it to our own origin anyway + // (defense-in-depth: misconfigured or spoofed domain). The lookup returns our own + // canonical userId, triggering the self-friend check. + seedSelf(); + resolveOriginFromHostnameMock.mockReturnValue('https://home.test'); + lookupRemoteUserMock.mockResolvedValue({ + ok: true, homeUserId: CALLER_ID, username: 'caller', + profile: { displayName: 'Caller', avatar: null, avatarColor: 'mint', banner: null, bio: null }, + }); + const app = await buildApp(); + const res = await app.inject({ + method: 'POST', + url: '/api/social/requests', + payload: { username: 'caller@otherhost' }, + }); + expect(res.statusCode).toBe(400); + expect(JSON.parse(res.body).error).toBe('cannot_friend_self'); + }); + + it('returns 409 already_friends if friendship row exists', async () => { + seedSelf(); + // Pre-seed stub and friendship + testDb.insert(schema.users).values({ + id: 'stub-alice', + username: 'remote-alice@orbit.test', + displayName: 'Alice', + passwordHash: '', + status: 'offline', + isAdmin: 0, + homeInstance: 'orbit.test', + homeUserId: 'remote-alice', + createdAt: Date.now(), + }).run(); + testDb.insert(schema.friends).values({ + userId: CALLER_ID, + friendId: 'stub-alice', + createdAt: Date.now(), + }).run(); + + const app = await buildApp(); + const res = await app.inject({ + method: 'POST', + url: '/api/social/requests', + payload: { username: 'alice@orbit.test' }, + }); + expect(res.statusCode).toBe(409); + expect(JSON.parse(res.body).error).toBe('already_friends'); + }); + + it('returns 200 + existing requestId when same-direction request already pending (idempotent)', async () => { + seedSelf(); + // Pre-seed stub and same-direction pending request + testDb.insert(schema.users).values({ + id: 'stub-alice', + username: 'remote-alice@orbit.test', + displayName: 'Alice', + passwordHash: '', + status: 'offline', + isAdmin: 0, + homeInstance: 'orbit.test', + homeUserId: 'remote-alice', + createdAt: Date.now(), + }).run(); + testDb.insert(schema.friendRequests).values({ + id: 'existing-req', + fromId: CALLER_ID, + toId: 'stub-alice', + status: 'pending', + createdAt: Date.now(), + }).run(); + + const app = await buildApp(); + const res = await app.inject({ + method: 'POST', + url: '/api/social/requests', + payload: { username: 'alice@orbit.test' }, + }); + expect(res.statusCode).toBe(200); + const body = JSON.parse(res.body) as { success: boolean; requestId: string }; + expect(body.requestId).toBe('existing-req'); + + // No duplicate row created + const allRequests = testDb.select().from(schema.friendRequests).all(); + expect(allRequests).toHaveLength(1); + }); + + it('returns 409 incoming_request_exists when opposite-direction request already pending', async () => { + seedSelf(); + // Pre-seed stub and opposite-direction pending request + testDb.insert(schema.users).values({ + id: 'stub-alice', + username: 'remote-alice@orbit.test', + displayName: 'Alice', + passwordHash: '', + status: 'offline', + isAdmin: 0, + homeInstance: 'orbit.test', + homeUserId: 'remote-alice', + createdAt: Date.now(), + }).run(); + testDb.insert(schema.friendRequests).values({ + id: 'incoming-req', + fromId: 'stub-alice', + toId: CALLER_ID, + status: 'pending', + createdAt: Date.now(), + }).run(); + + const app = await buildApp(); + const res = await app.inject({ + method: 'POST', + url: '/api/social/requests', + payload: { username: 'alice@orbit.test' }, + }); + expect(res.statusCode).toBe(409); + const body = JSON.parse(res.body) as { error: string; requestId: string }; + expect(body.error).toBe('incoming_request_exists'); + expect(body.requestId).toBe('incoming-req'); + }); +}); diff --git a/packages/server/src/routes/social.ts b/packages/server/src/routes/social.ts index 946f5a1f..f457191b 100644 --- a/packages/server/src/routes/social.ts +++ b/packages/server/src/routes/social.ts @@ -217,6 +217,39 @@ async function handleFederatedFriendRequest( } const stubHydrated = hydrateReplicatedUserProfile(stub, lookup.profile, db); + // 5a. Direction-aware idempotency + const existingRequest = db.select().from(schema.friendRequests).where(and( + or( + and(eq(schema.friendRequests.fromId, sender.id), eq(schema.friendRequests.toId, stubHydrated.id)), + and(eq(schema.friendRequests.fromId, stubHydrated.id), eq(schema.friendRequests.toId, sender.id)), + ), + eq(schema.friendRequests.status, 'pending'), + )).get(); + + if (existingRequest) { + if (existingRequest.fromId === sender.id) { + // Same direction — idempotent return + return reply.code(200).send({ success: true, requestId: existingRequest.id }); + } else { + // Opposite direction — incoming request already exists + return reply.code(409).send({ + error: 'incoming_request_exists', + statusCode: 409, + requestId: existingRequest.id, + }); + } + } + + // 5b. Already-friends check + const existingFriend = db.select().from(schema.friends).where(or( + and(eq(schema.friends.userId, sender.id), eq(schema.friends.friendId, stubHydrated.id)), + and(eq(schema.friends.userId, stubHydrated.id), eq(schema.friends.friendId, sender.id)), + )).get(); + + if (existingFriend) { + return reply.code(409).send({ error: 'already_friends', statusCode: 409 }); + } + // 6. Transaction: insert + log + queue outbox const now = Date.now(); const fromIdentity = { From 1ad1fd1b0c65a31c86603d3ebe563c27c9147fc1 Mon Sep 17 00:00:00 2001 From: Jannis Braun <151788261+TheZwiss@users.noreply.github.com> Date: Sat, 25 Apr 2026 22:15:57 +0200 Subject: [PATCH 16/25] feat(social): broadcast friend_request_sent on local request creation --- packages/server/src/routes/social.test.ts | 18 ++++++++++++++++++ packages/server/src/routes/social.ts | 5 +++++ 2 files changed, 23 insertions(+) diff --git a/packages/server/src/routes/social.test.ts b/packages/server/src/routes/social.test.ts index df0f0850..4a8e1c0b 100644 --- a/packages/server/src/routes/social.test.ts +++ b/packages/server/src/routes/social.test.ts @@ -219,4 +219,22 @@ describe('POST /api/social/requests — case-insensitive username lookup', () => expect(res.statusCode).toBe(404); expect(JSON.parse(res.body).error).toBe('User not found'); }); + + it('broadcasts friend_request_sent to the sender on local request creation', async () => { + seedUser({ id: 'u1', username: 'alice' }); + const { connectionManager } = await import('../ws/handler.js'); + const sendToUser = connectionManager.sendToUser as unknown as ReturnType; + sendToUser.mockClear(); + + const res = await app.inject({ + method: 'POST', + url: '/api/social/requests', + payload: { username: 'alice' }, + }); + expect(res.statusCode).toBe(201); + + const sent = sendToUser.mock.calls.find(c => c[1]?.type === 'friend_request_sent'); + expect(sent).toBeDefined(); + expect(sent![0]).toBe(CALLER_ID); + }); }); diff --git a/packages/server/src/routes/social.ts b/packages/server/src/routes/social.ts index f457191b..61f62f80 100644 --- a/packages/server/src/routes/social.ts +++ b/packages/server/src/routes/social.ts @@ -103,6 +103,11 @@ async function handleLocalFriendRequest( request: friendRequestPayload, }); + connectionManager.sendToUser(request.userId, { + type: 'friend_request_sent', + request: friendRequestPayload, + }); + // Federation relay: notify the target user's home instance const fromIdentity = { homeUserId: sender.homeUserId || request.userId, From d88d369a82893cf2c7ace38f12f4b0e50126d60d Mon Sep 17 00:00:00 2001 From: Jannis Braun <151788261+TheZwiss@users.noreply.github.com> Date: Sat, 25 Apr 2026 22:18:47 +0200 Subject: [PATCH 17/25] fix(social): friend_request_sent local broadcast must carry target profile MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The local branch was reusing friendRequestPayload (built for friend_request_received with user=sender) for the sender-side friend_request_sent broadcast. Tab B on the sender would render the sender's own avatar where the target's should appear. Match the federated branch — sent broadcast carries target. --- .../server/src/routes/social.federated.test.ts | 17 ++++++++++------- packages/server/src/routes/social.test.ts | 4 ++++ packages/server/src/routes/social.ts | 18 ++++++++++++++---- 3 files changed, 28 insertions(+), 11 deletions(-) diff --git a/packages/server/src/routes/social.federated.test.ts b/packages/server/src/routes/social.federated.test.ts index f9fe3ad4..82643963 100644 --- a/packages/server/src/routes/social.federated.test.ts +++ b/packages/server/src/routes/social.federated.test.ts @@ -161,14 +161,17 @@ describe('POST /api/social/requests — federated branch (happy path)', () => { .get(); expect(mutationRow).toBeTruthy(); - // WS broadcast to sender - expect(sendToUser).toHaveBeenCalledWith( - CALLER_ID, - expect.objectContaining({ - type: 'friend_request_sent', - request: expect.objectContaining({ id: body.requestId }), - }), + // WS broadcast to sender — the 'user' field must carry the TARGET's profile (alice), + // not the sender. This is the federated analogue of the local-branch invariant. + const sentEvent = sendToUser.mock.calls.find( + (c: unknown[]) => (c[1] as { type?: string })?.type === 'friend_request_sent', ); + expect(sentEvent).toBeDefined(); + expect(sentEvent![0]).toBe(CALLER_ID); + expect(sentEvent![1].request.id).toBe(body.requestId); + // homeUserId identifies the target; username is the canonical stub form (@). + expect(sentEvent![1].request.user.homeUserId).toBe('remote-alice'); + expect(sentEvent![1].request.user.username).toBe('remote-alice@orbit.test'); }); }); diff --git a/packages/server/src/routes/social.test.ts b/packages/server/src/routes/social.test.ts index 4a8e1c0b..d123c2e7 100644 --- a/packages/server/src/routes/social.test.ts +++ b/packages/server/src/routes/social.test.ts @@ -236,5 +236,9 @@ describe('POST /api/social/requests — case-insensitive username lookup', () => const sent = sendToUser.mock.calls.find(c => c[1]?.type === 'friend_request_sent'); expect(sent).toBeDefined(); expect(sent![0]).toBe(CALLER_ID); + // The 'user' field on the sent payload must be the TARGET (alice), + // not the sender — symmetric with how the federated branch builds it. + expect(sent![1].request.user.id).toBe('u1'); + expect(sent![1].request.user.username).toBe('alice'); }); }); diff --git a/packages/server/src/routes/social.ts b/packages/server/src/routes/social.ts index 61f62f80..2db95d7a 100644 --- a/packages/server/src/routes/social.ts +++ b/packages/server/src/routes/social.ts @@ -88,8 +88,8 @@ async function handleLocalFriendRequest( createdAt: now, }).run(); - // Broadcast friend_request_received to the target user - const friendRequestPayload: FriendRequest = { + // Broadcast to the target: they need to know who sent the request (sender profile). + const receivedPayload: FriendRequest = { id, fromId: request.userId, toId: targetUser.id, @@ -98,14 +98,24 @@ async function handleLocalFriendRequest( user: sanitizeUser(sender), }; + // Broadcast to the sender's other tabs: they need to know who they added (target profile). + const sentPayload: FriendRequest = { + id, + fromId: request.userId, + toId: targetUser.id, + status: 'pending', + createdAt: now, + user: sanitizeUser(targetUser), + }; + connectionManager.sendToUser(targetUser.id, { type: 'friend_request_received', - request: friendRequestPayload, + request: receivedPayload, }); connectionManager.sendToUser(request.userId, { type: 'friend_request_sent', - request: friendRequestPayload, + request: sentPayload, }); // Federation relay: notify the target user's home instance From bd70950de754bb5a356a7b785093d61525675108 Mon Sep 17 00:00:00 2001 From: Jannis Braun <151788261+TheZwiss@users.noreply.github.com> Date: Sat, 25 Apr 2026 22:23:04 +0200 Subject: [PATCH 18/25] test(federation): receiver accepts home-queued friend_request_create --- .../federation.friendRequestCreate.test.ts | 116 ++++++++++++++++++ 1 file changed, 116 insertions(+) create mode 100644 packages/server/src/routes/federation.friendRequestCreate.test.ts diff --git a/packages/server/src/routes/federation.friendRequestCreate.test.ts b/packages/server/src/routes/federation.friendRequestCreate.test.ts new file mode 100644 index 00000000..e7bfffb3 --- /dev/null +++ b/packages/server/src/routes/federation.friendRequestCreate.test.ts @@ -0,0 +1,116 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import Database from 'better-sqlite3'; +import { drizzle } from 'drizzle-orm/better-sqlite3'; +import { eq } from 'drizzle-orm'; +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import * as schema from '../db/schema.js'; +import { setWorkerId } from '../utils/snowflake.js'; +import type { FederationRelayEvent } from '@backspace/shared'; + +setWorkerId(1); +const __dirname = path.dirname(fileURLToPath(import.meta.url)); + +let sqlite: Database.Database; +let testDb: ReturnType>; +const sendToUser = vi.fn(); + +vi.mock('../db/index.js', () => ({ getDb: () => testDb, getRawDb: () => sqlite, schema })); +vi.mock('../ws/handler.js', () => ({ + connectionManager: { sendToUser, sendToAdmins: vi.fn(), sendToDmMembers: vi.fn(), getAllOnlineUserIds: () => [] }, +})); +vi.mock('../utils/federationAuth.js', async (importActual) => { + const actual = await importActual(); + return { ...actual, getOurOrigin: () => 'https://home.test' }; +}); + +function applyMigrations(db: Database.Database): void { + const dir = path.resolve(__dirname, '../../drizzle'); + for (const f of fs.readdirSync(dir).filter(f => f.endsWith('.sql')).sort()) { + const sqlText = fs.readFileSync(path.join(dir, f), 'utf8'); + for (const stmt of sqlText.split(/-->\s*statement-breakpoint/)) { + const clean = stmt.trim(); + if (clean) db.exec(clean); + } + } +} + +beforeEach(() => { + sqlite = new Database(':memory:'); + testDb = drizzle(sqlite, { schema }); + applyMigrations(sqlite); + sendToUser.mockReset(); +}); + +describe('processRelayEvents — friend_request_create from sender home (S2S friend-add wire format)', () => { + it('accepts the event, creates the local request, hydrates the sender stub, broadcasts to recipient', async () => { + // Seed local recipient + testDb.insert(schema.users).values({ + id: 'alice-id', + username: 'alice', + passwordHash: 'x', + status: 'online', + isAdmin: 0, + createdAt: Date.now(), + } as typeof schema.users.$inferInsert).run(); + + // Seed active peer for the source instance + testDb.insert(schema.federationPeers).values({ + id: 'peer-orbit', + origin: 'https://orbit.test', + hmacSecret: 'a'.repeat(64), + status: 'active', + nonceSupported: 1, + createdAt: Date.now(), + consecutiveFailures: 0, + consecutiveAuthFailures: 0, + } as typeof schema.federationPeers.$inferInsert).run(); + + const event: FederationRelayEvent = { + eventType: 'friend_request_create', + contextType: 'friend', + messageId: 'friend_req:alice-id:remote-bob:12345', + encryptionVersion: 0, + timestamp: 12345, + friendship: { + from: { homeUserId: 'remote-bob', homeInstance: 'https://orbit.test' }, + to: { homeUserId: 'alice-id', homeInstance: 'https://home.test' }, + fromProfile: { username: 'bob', displayName: 'Bob', avatar: null, avatarColor: 'mint', banner: null, bio: null }, + toProfile: { username: 'alice', displayName: 'Alice', avatar: null, avatarColor: 'rose', banner: null, bio: null }, + status: 'pending', + createdAt: 12345, + }, + }; + + const { processRelayEvents } = await import('./federation.js'); + const result = await processRelayEvents([event], 'https://orbit.test', 'https://orbit.test', testDb); + + // Wire-format invariant: accepted, no rejection. + expect(result.accepted).toEqual(['friend_req:alice-id:remote-bob:12345']); + expect(result.rejected).toEqual([]); + + // Stub for sender was auto-created. Note: resolveOrCreateReplicatedUser + // stores homeInstance as bare host (extractDomain), not full URL. + const bobStub = testDb.select().from(schema.users) + .where(eq(schema.users.homeUserId, 'remote-bob')).get(); + expect(bobStub).toBeDefined(); + expect(bobStub!.homeInstance).toBe('orbit.test'); + + // Friend request row inserted: from=stub, to=alice. + const reqs = testDb.select().from(schema.friendRequests).all(); + expect(reqs).toHaveLength(1); + expect(reqs[0]!.toId).toBe('alice-id'); + expect(reqs[0]!.fromId).toBe(bobStub!.id); + expect(reqs[0]!.status).toBe('pending'); + + // WS broadcast to alice with friend_request_received and the bob profile. + expect(sendToUser).toHaveBeenCalledOnce(); + const [userId, evt] = sendToUser.mock.calls[0]!; + expect(userId).toBe('alice-id'); + expect(evt.type).toBe('friend_request_received'); + expect(evt.request.fromId).toBe(bobStub!.id); + expect(evt.request.toId).toBe('alice-id'); + expect(evt.request.user?.displayName).toBe('Bob'); + }); +}); From 0677c21ab98184b81742b3ebd459c1eb63e549d5 Mon Sep 17 00:00:00 2001 From: Jannis Braun <151788261+TheZwiss@users.noreply.github.com> Date: Sat, 25 Apr 2026 22:24:04 +0200 Subject: [PATCH 19/25] test(federation): buildFriendContextId determinism (cross-instance invariant) The friend contextId must be byte-identical on both peers for a given canonical pair regardless of argument order. Initial-sync backfill keys on contextId; if the two sides computed different values for the same friendship, missed events would never reconcile. --- .../utils/federationOutbox.mutationlog.test.ts | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/packages/server/src/utils/federationOutbox.mutationlog.test.ts b/packages/server/src/utils/federationOutbox.mutationlog.test.ts index 19c5242a..85ab57c4 100644 --- a/packages/server/src/utils/federationOutbox.mutationlog.test.ts +++ b/packages/server/src/utils/federationOutbox.mutationlog.test.ts @@ -133,3 +133,21 @@ describe('queueReadStateRelay — mutation log capture', () => { expect(rows[0]?.contextType).toBe('dm'); }); }); + +describe('buildFriendContextId — cross-instance determinism', () => { + it('produces the same value regardless of argument order', async () => { + const { buildFriendContextId } = await import('./federationOutbox.js'); + expect(buildFriendContextId('alice', 'bob')).toBe(buildFriendContextId('bob', 'alice')); + }); + + it('produces the same value on home and on orbit for the same canonical pair', async () => { + // home computes: buildFriendContextId(myHomeUserId, theirHomeUserId) + // orbit computes: buildFriendContextId(theirHomeUserId, myHomeUserId) + // Both must equal — initial-sync backfill depends on this invariant. + const { buildFriendContextId } = await import('./federationOutbox.js'); + const home = buildFriendContextId('home-id-1', 'orbit-id-2'); + const orbit = buildFriendContextId('orbit-id-2', 'home-id-1'); + expect(home).toBe(orbit); + expect(home).toBe('friend:home-id-1:orbit-id-2'); + }); +}); From b28bf6646d88e67f568483ab4aab98e481d97101 Mon Sep 17 00:00:00 2001 From: Jannis Braun <151788261+TheZwiss@users.noreply.github.com> Date: Sat, 25 Apr 2026 22:26:58 +0200 Subject: [PATCH 20/25] refactor(socialStore): collapse sendFriendRequest; delete federation error classes Server now handles all parsing/routing/peering/lookup (T11-T14). Client sends the trimmed username verbatim to /api/social/requests and surfaces server errors via toast (added in T18-T20). Note: FriendsPage.tsx and UserProfileModal.tsx will fail to compile until T19 and T20 remove their now-dead try/catch blocks for the deleted error classes. TypeScript catches it; pnpm dev will not start until those tasks land. --- .../stores/socialStore.sendRequest.test.ts | 91 +++++++------------ packages/web/src/stores/socialStore.ts | 61 +------------ 2 files changed, 38 insertions(+), 114 deletions(-) diff --git a/packages/web/src/stores/socialStore.sendRequest.test.ts b/packages/web/src/stores/socialStore.sendRequest.test.ts index 367dddd1..a975c44b 100644 --- a/packages/web/src/stores/socialStore.sendRequest.test.ts +++ b/packages/web/src/stores/socialStore.sendRequest.test.ts @@ -1,9 +1,7 @@ import { describe, it, expect, beforeEach, vi } from 'vitest'; -const homeSendRequest = vi.fn(async () => ({ success: true, requestId: 'req-home' })); -const remoteSendRequest = vi.fn(async () => ({ success: true, requestId: 'req-remote' })); +const homeSendRequest = vi.fn(async () => ({ success: true, requestId: 'req-1' })); const homeRequests = vi.fn(async () => []); -const remoteRequests = vi.fn(async () => []); vi.mock('../api/client', () => ({ api: { @@ -14,75 +12,52 @@ vi.mock('../api/client', () => ({ }, })); -const remoteApi = { - social: { - sendRequest: (...args: unknown[]) => remoteSendRequest(...args), - requests: () => remoteRequests(), - }, -}; - -vi.mock('./instanceStore', () => ({ - useInstanceStore: { - getState: () => ({ - instances: [ - { - origin: 'https://orbit.ddns.net', - status: 'connected', - api: remoteApi, - }, - ], - }), - }, -})); - vi.mock('../utils/assetUrls', () => ({ normalizeUserAssets: (u: unknown) => u, })); +// instanceStore is still imported by other socialStore methods (loadFriends, loadRequests) +// — provide an empty-instances stub so those calls don't crash. +vi.mock('./instanceStore', () => ({ + useInstanceStore: { + getState: () => ({ instances: [], _autoConnectDone: true }), + subscribe: () => () => {}, + }, +})); + import { useSocialStore } from './socialStore'; -describe('socialStore.sendFriendRequest — case-insensitive domain routing', () => { +describe('socialStore.sendFriendRequest — server-side routing (post-S2S)', () => { beforeEach(() => { homeSendRequest.mockClear(); - remoteSendRequest.mockClear(); homeRequests.mockClear(); - remoteRequests.mockClear(); - // window.location.host in jsdom defaults to 'localhost:3000' or similar. - // Override it for routing tests. - Object.defineProperty(window, 'location', { - configurable: true, - writable: true, - value: { ...window.location, host: 'local.test', hostname: 'local.test' }, - }); }); - it('sends to the home API when the typed domain matches window.location.host exactly', async () => { + it('sends bare handle to home API as-is', async () => { + const id = await useSocialStore.getState().sendFriendRequest('bob'); + expect(homeSendRequest).toHaveBeenCalledOnce(); + expect(homeSendRequest).toHaveBeenCalledWith('bob'); + expect(id).toBe('req-1'); + }); + + it('sends @-handle to home API verbatim (server handles routing)', async () => { + await useSocialStore.getState().sendFriendRequest('bob@orbit.tld'); + expect(homeSendRequest).toHaveBeenCalledWith('bob@orbit.tld'); + }); + + it('sends @-handle for own host to home API verbatim', async () => { await useSocialStore.getState().sendFriendRequest('bob@local.test'); + expect(homeSendRequest).toHaveBeenCalledWith('bob@local.test'); + }); + + it('trims whitespace before sending', async () => { + await useSocialStore.getState().sendFriendRequest(' bob '); expect(homeSendRequest).toHaveBeenCalledWith('bob'); - expect(remoteSendRequest).not.toHaveBeenCalled(); }); - it('sends to the home API when the typed domain matches with mixed case', async () => { - await useSocialStore.getState().sendFriendRequest('bob@LOCAL.TEST'); - expect(homeSendRequest).toHaveBeenCalledWith('bob'); - expect(remoteSendRequest).not.toHaveBeenCalled(); - }); - - it('routes to a connected remote instance when the typed domain matches its origin host', async () => { - await useSocialStore.getState().sendFriendRequest('bob@orbit.ddns.net'); - expect(remoteSendRequest).toHaveBeenCalledWith('bob'); - expect(homeSendRequest).not.toHaveBeenCalled(); - }); - - it('routes to a connected remote instance when the typed domain has mixed case', async () => { - await useSocialStore.getState().sendFriendRequest('bob@ORBIT.ddns.net'); - expect(remoteSendRequest).toHaveBeenCalledWith('bob'); - expect(homeSendRequest).not.toHaveBeenCalled(); - }); - - it('sends bare handle (no @) directly to the home API', async () => { - await useSocialStore.getState().sendFriendRequest('bob'); - expect(homeSendRequest).toHaveBeenCalledWith('bob'); - expect(remoteSendRequest).not.toHaveBeenCalled(); + it('propagates server errors and sets store.error', async () => { + homeSendRequest.mockRejectedValueOnce(new Error('user_not_found')); + await expect(useSocialStore.getState().sendFriendRequest('nope')).rejects.toThrow('user_not_found'); + expect(useSocialStore.getState().error).toBe('user_not_found'); }); }); diff --git a/packages/web/src/stores/socialStore.ts b/packages/web/src/stores/socialStore.ts index 9a0878e6..22eda4f6 100644 --- a/packages/web/src/stores/socialStore.ts +++ b/packages/web/src/stores/socialStore.ts @@ -4,24 +4,6 @@ import { api } from '../api/client'; import { useInstanceStore } from './instanceStore'; import { normalizeUserAssets } from '../utils/assetUrls'; -// ─── Federation errors ──────────────────────────────────────────────────── - -/** Thrown when the target domain has never been connected. */ -export class InstanceNotConnectedError extends Error { - constructor(public domain: string) { - super(`Not connected to ${domain}`); - this.name = 'InstanceNotConnectedError'; - } -} - -/** Thrown when the instance entry exists but the session is disconnected/errored. */ -export class InstanceDisconnectedError extends Error { - constructor(public domain: string) { - super(`Instance ${domain} is not currently connected`); - this.name = 'InstanceDisconnectedError'; - } -} - // ─── Tagged types (origin tracking for federation) ─────────────────────────── export type TaggedFriend = Friend & { _instanceOrigin: string }; @@ -210,44 +192,11 @@ export const useSocialStore = create((set, get) => ({ sendFriendRequest: async (username: string) => { set({ isLoading: true, error: null }); try { - const atIndex = username.lastIndexOf('@'); - let res: { success: boolean; requestId?: string }; - - if (atIndex === -1) { - // No @ → local user on home instance - res = await api.social.sendRequest(username); - } else { - const baseName = username.slice(0, atIndex); - const domain = username.slice(atIndex + 1).toLowerCase(); - - // Check if domain matches home instance - if (domain === window.location.host) { - // Strip domain, send to home API - res = await api.social.sendRequest(baseName); - } else { - // Find a connected instance matching this domain - const instances = useInstanceStore.getState().instances; - const match = instances.find(inst => { - try { - return new URL(inst.origin).host === domain; - } catch { - return false; - } - }); - - if (!match) { - throw new InstanceNotConnectedError(domain); - } - - if (match.status !== 'connected') { - throw new InstanceDisconnectedError(domain); - } - - // On the remote instance, the user is just "alice", not "alice@orbit" - res = await match.api.social.sendRequest(baseName); - } - } - + const res = await api.social.sendRequest(username.trim()); + set({ isLoading: false }); + // Server emits friend_request_sent over WS; useWebSocket appends the row + // optimistically. As a safety net for tabs that race the WS event, refresh + // from server too. await get().loadRequests(); return res.requestId; } catch (err) { From 9d3f75b33c1122d12af75fcfc44f672f2a77d038 Mon Sep 17 00:00:00 2001 From: Jannis Braun <151788261+TheZwiss@users.noreply.github.com> Date: Sat, 25 Apr 2026 22:29:45 +0200 Subject: [PATCH 21/25] feat(web): WS handlers for friend_request_sent + friend_request_relay_failed Multi-tab sync: friend_request_sent appends the new outbound request to socialStore (deduped by id+origin), no toast. Async rollback: friend_request_relay_failed removes the row by id and surfaces a warning toast with the target handle and reason. Wires the client side of the rollback hook from T10. --- packages/web/src/hooks/useWebSocket.ts | 21 +++++++++++++++++++++ packages/web/src/stores/socialStore.ts | 12 ++++++++++++ 2 files changed, 33 insertions(+) diff --git a/packages/web/src/hooks/useWebSocket.ts b/packages/web/src/hooks/useWebSocket.ts index 3a5b537c..760f2279 100644 --- a/packages/web/src/hooks/useWebSocket.ts +++ b/packages/web/src/hooks/useWebSocket.ts @@ -862,6 +862,27 @@ function handleEvent(origin: string, event: ServerEvent): void { break; } + case 'friend_request_sent': { + // Multi-tab sync: another tab/device of the same user just created an outbound request. + if (!isHome && event.request.user) normalizeUserAssets(event.request.user, origin); + const { addOutboundRequest } = useSocialStore.getState(); + addOutboundRequest(event.request, origin); + break; + } + + case 'friend_request_relay_failed': { + // Async rollback notification: the federated friend_request_create was permanently rejected. + // Drop the optimistic row and surface a warning toast. + const { removeRequestById } = useSocialStore.getState(); + removeRequestById(event.requestId, origin); + const { addToast } = useUIStore.getState(); + addToast( + `Friend request to ${event.targetHandle} could not be delivered: ${event.message}`, + 'warning', + ); + break; + } + case 'friend_request_accepted': { if (!isHome) normalizeUserAssets(event.friend, origin); const { addFriendFromAccepted } = useSocialStore.getState(); diff --git a/packages/web/src/stores/socialStore.ts b/packages/web/src/stores/socialStore.ts index 22eda4f6..2553cd4e 100644 --- a/packages/web/src/stores/socialStore.ts +++ b/packages/web/src/stores/socialStore.ts @@ -57,6 +57,7 @@ interface SocialState { removeFriend: (id: string) => Promise; searchUsers: (query: string) => Promise; addIncomingRequest: (request: FriendRequest, origin: string) => void; + addOutboundRequest: (request: FriendRequest, origin: string) => void; addFriendFromAccepted: (friend: Friend, requestId: string, origin: string) => void; updateFriendPresence: (userId: string, status: string) => void; updateFriendProfile: (user: User) => void; @@ -343,6 +344,17 @@ export const useSocialStore = create((set, get) => ({ }); }, + // Called from WS handler for multi-tab sync when this user creates an outbound request + addOutboundRequest: (request: FriendRequest, origin: string) => { + set((state) => { + const canonicalId = request.user?.homeUserId ?? request.user?.id; + if (canonicalId && state.requests.some(r => (r.user?.homeUserId ?? r.user?.id) === canonicalId)) { + return state; + } + return { requests: [...state.requests, { ...request, _instanceOrigin: origin }] }; + }); + }, + // Called from WS handler when someone accepts your friend request addFriendFromAccepted: (friend: Friend, requestId: string, origin: string) => { set((state) => { From 7309f44de563659e039d8da6e04793778e9a6b7d Mon Sep 17 00:00:00 2001 From: Jannis Braun <151788261+TheZwiss@users.noreply.github.com> Date: Sat, 25 Apr 2026 22:33:26 +0200 Subject: [PATCH 22/25] =?UTF-8?q?refactor(web):=20FriendsPage=20=E2=80=94?= =?UTF-8?q?=20toast=20on=20server=20errors,=20drop=20ConnectInstanceModal?= =?UTF-8?q?=20triggers?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Removes try/catch on the deleted InstanceNotConnectedError/Disconnected classes (T17). Server now returns structured error codes; client maps them to human-readable toasts via the new mapServerErrorToMessage helper. The friend-add flow no longer triggers ConnectInstanceModal — the server handles all routing/peering/lookup. The modal itself stays for Connections settings and space-join flows. --- .../web/src/components/chat/FriendsPage.tsx | 97 ++++--------------- packages/web/src/utils/friendErrors.ts | 35 +++++++ 2 files changed, 54 insertions(+), 78 deletions(-) create mode 100644 packages/web/src/utils/friendErrors.ts diff --git a/packages/web/src/components/chat/FriendsPage.tsx b/packages/web/src/components/chat/FriendsPage.tsx index a1978597..b3d78fc3 100644 --- a/packages/web/src/components/chat/FriendsPage.tsx +++ b/packages/web/src/components/chat/FriendsPage.tsx @@ -1,9 +1,9 @@ import React, { useEffect, useState, useCallback, useMemo } from 'react'; import { useNavigate } from 'react-router-dom'; -import { useSocialStore, type TaggedFriend, type TaggedFriendRequest, type TaggedUser, InstanceNotConnectedError, InstanceDisconnectedError } from '../../stores/socialStore'; +import { useSocialStore, type TaggedFriend, type TaggedFriendRequest, type TaggedUser } from '../../stores/socialStore'; import { useAuthStore } from '../../stores/authStore'; -import { ConnectInstanceModal } from '../modals/ConnectInstanceModal'; import { useDiscoverStore, type TaggedDiscoverUser } from '../../stores/discoverStore'; +import { mapServerErrorToMessage } from '../../utils/friendErrors'; import { useSpaceStore } from '../../stores/spaceStore'; import { useInstanceStore } from '../../stores/instanceStore'; import { useUIStore } from '../../stores/uiStore'; @@ -398,11 +398,6 @@ function AddFriendTab({ const [rawSearchResults, setRawSearchResults] = useState([]); const [searchLoading, setSearchLoading] = useState(false); const [directAddLoading, setDirectAddLoading] = useState(false); - const [connectModal, setConnectModal] = useState<{ - domain: string; - isReconnect: boolean; - username: string; - } | null>(null); // Fetch discover on mount useEffect(() => { @@ -481,34 +476,21 @@ function AddFriendTab({ addToast('Friend request sent!', 'success'); setQuery(''); } catch (err) { - if (err instanceof InstanceNotConnectedError) { - setConnectModal({ domain: err.domain, isReconnect: false, username: query.trim() }); - } else if (err instanceof InstanceDisconnectedError) { - setConnectModal({ domain: err.domain, isReconnect: true, username: query.trim() }); - } else { - addToast((err as Error).message, 'warning'); + const errorBody = (err as { body?: unknown })?.body; + let code: string | undefined; + let message: string | undefined; + if (errorBody && typeof errorBody === 'object') { + code = (errorBody as { error?: string }).error; + message = (errorBody as { message?: string }).message; } + // Fall back to the Error message if the server didn't send a structured body. + const fallback = message ?? (err instanceof Error ? err.message : undefined); + addToast(mapServerErrorToMessage(code, fallback, query.trim()), 'warning'); } finally { setDirectAddLoading(false); } }; - // Connect modal handler - const handleConnected = async (result: 'new' | 'reconnect') => { - const username = connectModal?.username; - const domain = connectModal?.domain; - setConnectModal(null); - if (!username) return; - try { - await sendFriendRequest(username); - const verb = result === 'reconnect' ? 'Reconnected to' : 'Connected to'; - addToast(`${verb} ${domain} — friend request sent!`, 'success'); - setQuery(''); - } catch (err) { - addToast((err as Error).message, 'warning'); - } - }; - // No-op relationship change for search mode cards (useMemo re-derives from store) const noopRelationshipChange = useCallback(() => {}, []); @@ -618,15 +600,6 @@ function AddFriendTab({ )} - {connectModal && ( - setConnectModal(null)} - /> - )} ); } @@ -647,12 +620,6 @@ function UserDiscoverCard({ const openModal = useUIStore((s) => s.openModal); const [actionLoading, setActionLoading] = useState(false); const [error, setError] = useState(''); - const addToast = useUIStore((s) => s.addToast); - const [connectModal, setConnectModal] = useState<{ - domain: string; - isReconnect: boolean; - username: string; - } | null>(null); const baseName = user.username.includes('@') ? user.username.split('@')[0]! : user.username; const displayName = user.displayName ?? baseName; @@ -676,32 +643,15 @@ function UserDiscoverCard({ const requestId = await sendFriendRequest(username); onRelationshipChange(user.id, user._instanceOrigin, 'outbound_pending', requestId); } catch (err) { - if (err instanceof InstanceNotConnectedError) { - setConnectModal({ domain: err.domain, isReconnect: false, username }); - } else if (err instanceof InstanceDisconnectedError) { - setConnectModal({ domain: err.domain, isReconnect: true, username }); - } else { - setError(err instanceof Error ? err.message : 'Failed to send request'); + const errorBody = (err as { body?: unknown })?.body; + let code: string | undefined; + let message: string | undefined; + if (errorBody && typeof errorBody === 'object') { + code = (errorBody as { error?: string }).error; + message = (errorBody as { message?: string }).message; } - } finally { - setActionLoading(false); - } - }; - - const handleDiscoverConnected = async (result: 'new' | 'reconnect') => { - const username = connectModal?.username; - const domain = connectModal?.domain; - setConnectModal(null); - if (!username) return; - - setActionLoading(true); - try { - const requestId = await sendFriendRequest(username); - onRelationshipChange(user.id, user._instanceOrigin, 'outbound_pending', requestId); - const verb = result === 'reconnect' ? 'Reconnected to' : 'Connected to'; - addToast(`${verb} ${domain} — friend request sent!`, 'success'); - } catch (err) { - setError(err instanceof Error ? err.message : 'Failed to send request'); + const fallback = message ?? (err instanceof Error ? err.message : 'Failed to send request'); + setError(mapServerErrorToMessage(code, fallback, username)); } finally { setActionLoading(false); } @@ -885,15 +835,6 @@ function UserDiscoverCard({ )} - {connectModal && ( - setConnectModal(null)} - /> - )} ); } diff --git a/packages/web/src/utils/friendErrors.ts b/packages/web/src/utils/friendErrors.ts new file mode 100644 index 00000000..ec1531a5 --- /dev/null +++ b/packages/web/src/utils/friendErrors.ts @@ -0,0 +1,35 @@ +/** + * Map a server error code (from POST /api/social/requests) to a human-readable + * toast message. The server emits these codes; the client renders them. + * + * Used by FriendsPage and UserProfileModal when the server returns an error + * from the friend-add flow. + */ +export function mapServerErrorToMessage( + code: string | undefined, + fallback: string | undefined, + handle: string, +): string { + switch (code) { + case 'username_required': return 'Enter a username.'; + case 'cannot_friend_self': return "You can't friend yourself."; + case 'peer_rejected': + return `Instance has rejected federation. Contact your admin.`; + case 'user_not_found': + return `No user "${handle}" on the remote instance.`; + case 'already_friends': return "You're already friends with this user."; + case 'peer_pending_approval': + return "The remote instance's admin needs to approve federation. Try again later."; + case 'peer_pending': + return 'Connecting to the remote instance — try again in a moment.'; + case 'incoming_request_exists': + return `${handle} has already sent you a request — open the Pending tab.`; + case 'lookup_rate_limited': return 'Too many lookups; try again in a minute.'; + case 'peer_unreachable': return 'The remote instance is currently unreachable.'; + case 'invalid_target_domain': return 'Invalid target domain.'; + case 'not_authoritative_for_sender': + // Should not happen in normal client usage — internal protocol violation. + return 'Could not send friend request (authority error).'; + default: return fallback ?? 'Could not send friend request.'; + } +} From 4a939b743fec0653c4318ad2b10190490ad3c018 Mon Sep 17 00:00:00 2001 From: Jannis Braun <151788261+TheZwiss@users.noreply.github.com> Date: Sat, 25 Apr 2026 22:35:52 +0200 Subject: [PATCH 23/25] =?UTF-8?q?refactor(web):=20UserProfileModal=20?= =?UTF-8?q?=E2=80=94=20toast=20on=20server=20errors,=20drop=20ConnectInsta?= =?UTF-8?q?nceModal=20triggers?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Same pattern as FriendsPage cleanup (T19): the friend-add catch no longer branches on the deleted InstanceNotConnectedError / InstanceDisconnectedError; the ConnectInstanceModal trigger is removed since the server handles all routing/peering. Errors surface via toast using mapServerErrorToMessage. This restores the web package to a compileable state. --- .../components/modals/UserProfileModal.tsx | 47 ++++--------------- 1 file changed, 10 insertions(+), 37 deletions(-) diff --git a/packages/web/src/components/modals/UserProfileModal.tsx b/packages/web/src/components/modals/UserProfileModal.tsx index 039fabf7..09f0d26b 100644 --- a/packages/web/src/components/modals/UserProfileModal.tsx +++ b/packages/web/src/components/modals/UserProfileModal.tsx @@ -7,8 +7,8 @@ import { Username } from '../ui/Username'; import { useUIStore } from '../../stores/uiStore'; import { useSpaceStore, getApiForOrigin, resolveUserOrigin } from '../../stores/spaceStore'; import { api } from '../../api/client'; -import { useSocialStore, type TaggedFriend, type TaggedFriendRequest, InstanceNotConnectedError, InstanceDisconnectedError } from '../../stores/socialStore'; -import { ConnectInstanceModal } from './ConnectInstanceModal'; +import { useSocialStore, type TaggedFriend, type TaggedFriendRequest } from '../../stores/socialStore'; +import { mapServerErrorToMessage } from '../../utils/friendErrors'; import { useAuthStore } from '../../stores/authStore'; import { getAvatarGradient, getSpaceGradient, adjustColor, mutedGradient } from '../../utils/gradients'; import { parseFederatedUsername, isSelf, canonicalUserMatch } from '../../utils/identity'; @@ -71,10 +71,6 @@ export function UserProfileModal() { const [mutualSpaces, setMutualSpaces] = useState([]); const [loadingMutuals, setLoadingMutuals] = useState(false); const [friendActionLoading, setFriendActionLoading] = useState(false); - const [connectModal, setConnectModal] = useState<{ - domain: string; - isReconnect: boolean; - } | null>(null); const isOpen = activeModal === 'userProfile'; const userId = modalData?.userId as string | undefined; @@ -190,29 +186,15 @@ export function UserProfileModal() { try { await sendFriendRequest(user.username); } catch (err) { - if (err instanceof InstanceNotConnectedError) { - setConnectModal({ domain: err.domain, isReconnect: false }); - } else if (err instanceof InstanceDisconnectedError) { - setConnectModal({ domain: err.domain, isReconnect: true }); + const errorBody = (err as { body?: unknown })?.body; + let code: string | undefined; + let message: string | undefined; + if (errorBody && typeof errorBody === 'object') { + code = (errorBody as { error?: string }).error; + message = (errorBody as { message?: string }).message; } - // Other errors: socialStore already sets its own error state - } finally { - setFriendActionLoading(false); - } - }; - - const handleConnected = async (result: 'new' | 'reconnect') => { - const domain = connectModal?.domain; // capture before clearing - setConnectModal(null); - // Retry the friend request now that we're connected - setFriendActionLoading(true); - try { - await sendFriendRequest(user.username); - const verb = result === 'reconnect' ? 'Reconnected to' : 'Connected to'; - addToast(`${verb} ${domain} — friend request sent to ${user.displayName ?? parseFederatedUsername(user.username).baseName}`, 'success'); - } catch (err) { - // Connection succeeded but friend request failed — still valuable - addToast((err as Error).message, 'warning'); + const fallback = message ?? (err instanceof Error ? err.message : undefined); + addToast(mapServerErrorToMessage(code, fallback, user.username), 'warning'); } finally { setFriendActionLoading(false); } @@ -559,15 +541,6 @@ export function UserProfileModal() { - {connectModal && user && ( - setConnectModal(null)} - /> - )} ); } From fe969a7d9673371e909676cb680afd7916891cbd Mon Sep 17 00:00:00 2001 From: Jannis Braun <151788261+TheZwiss@users.noreply.github.com> Date: Sat, 25 Apr 2026 23:17:19 +0200 Subject: [PATCH 24/25] =?UTF-8?q?fix(web):=20friend-add=20error=20toasts?= =?UTF-8?q?=20surface=20raw=20codes=20=E2=80=94=20read=20err.message,=20no?= =?UTF-8?q?t=20err.body?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The T19/T20 catch blocks looked for an `.body` property on thrown errors to extract the structured error code. The shared API client (api/client.ts:298) actually throws `new Error(body.error)` — the code lives in `err.message`, and there's no `.body` attached. Live E2E (T22 scenario 2) caught this: typing alice@orbit against an awaiting_approval peer surfaced the raw code 'peer_pending_approval' as the toast text instead of the human-readable mapServerErrorToMessage output. Same defect would have hit every server-error toast on both FriendsPage (AddFriend + UserDiscoverCard) and UserProfileModal. Catch blocks now use err.message as both the code and the fallback text; the inline comment points at the API client throw site so the contract is documented at the consumer. --- .../web/src/components/chat/FriendsPage.tsx | 28 ++++++------------- .../components/modals/UserProfileModal.tsx | 14 ++++------ 2 files changed, 14 insertions(+), 28 deletions(-) diff --git a/packages/web/src/components/chat/FriendsPage.tsx b/packages/web/src/components/chat/FriendsPage.tsx index b3d78fc3..39df158b 100644 --- a/packages/web/src/components/chat/FriendsPage.tsx +++ b/packages/web/src/components/chat/FriendsPage.tsx @@ -476,16 +476,12 @@ function AddFriendTab({ addToast('Friend request sent!', 'success'); setQuery(''); } catch (err) { - const errorBody = (err as { body?: unknown })?.body; - let code: string | undefined; - let message: string | undefined; - if (errorBody && typeof errorBody === 'object') { - code = (errorBody as { error?: string }).error; - message = (errorBody as { message?: string }).message; - } - // Fall back to the Error message if the server didn't send a structured body. - const fallback = message ?? (err instanceof Error ? err.message : undefined); - addToast(mapServerErrorToMessage(code, fallback, query.trim()), 'warning'); + // The shared API client throws `new Error(body.error)` for non-2xx + // responses (api/client.ts:298), so `err.message` carries the server's + // error code (e.g. 'peer_pending_approval'). It also doubles as fallback + // text if the code is unrecognized by mapServerErrorToMessage. + const code = err instanceof Error ? err.message : undefined; + addToast(mapServerErrorToMessage(code, code, query.trim()), 'warning'); } finally { setDirectAddLoading(false); } @@ -643,15 +639,9 @@ function UserDiscoverCard({ const requestId = await sendFriendRequest(username); onRelationshipChange(user.id, user._instanceOrigin, 'outbound_pending', requestId); } catch (err) { - const errorBody = (err as { body?: unknown })?.body; - let code: string | undefined; - let message: string | undefined; - if (errorBody && typeof errorBody === 'object') { - code = (errorBody as { error?: string }).error; - message = (errorBody as { message?: string }).message; - } - const fallback = message ?? (err instanceof Error ? err.message : 'Failed to send request'); - setError(mapServerErrorToMessage(code, fallback, username)); + // See handleDirectAdd above — err.message is the server error code. + const code = err instanceof Error ? err.message : undefined; + setError(mapServerErrorToMessage(code, code ?? 'Failed to send request', username)); } finally { setActionLoading(false); } diff --git a/packages/web/src/components/modals/UserProfileModal.tsx b/packages/web/src/components/modals/UserProfileModal.tsx index 09f0d26b..1e3a6f53 100644 --- a/packages/web/src/components/modals/UserProfileModal.tsx +++ b/packages/web/src/components/modals/UserProfileModal.tsx @@ -186,15 +186,11 @@ export function UserProfileModal() { try { await sendFriendRequest(user.username); } catch (err) { - const errorBody = (err as { body?: unknown })?.body; - let code: string | undefined; - let message: string | undefined; - if (errorBody && typeof errorBody === 'object') { - code = (errorBody as { error?: string }).error; - message = (errorBody as { message?: string }).message; - } - const fallback = message ?? (err instanceof Error ? err.message : undefined); - addToast(mapServerErrorToMessage(code, fallback, user.username), 'warning'); + // The shared API client throws `new Error(body.error)` for non-2xx + // responses (api/client.ts:298), so err.message carries the server's + // error code (e.g. 'peer_pending_approval'). + const code = err instanceof Error ? err.message : undefined; + addToast(mapServerErrorToMessage(code, code, user.username), 'warning'); } finally { setFriendActionLoading(false); } From e15661e1bc62e93fd25c74a94109f250f3aa65b9 Mon Sep 17 00:00:00 2001 From: Jannis Braun <151788261+TheZwiss@users.noreply.github.com> Date: Sat, 25 Apr 2026 23:33:13 +0200 Subject: [PATCH 25/25] =?UTF-8?q?docs(systems):=20S2S=20friend-add=20?= =?UTF-8?q?=E2=80=94=20social/client-federation/federation/api?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reflects what shipped on feat/s2s-friend-add (T1-T22 verified live): - social.md: rewrite §6 outbound friend_request_create flow (sender's home is now the queueing instance for native users); §8 sendFriendRequest collapsed to single home-API call; §12 drops ConnectInstanceModal trigger; new "Failure Handling" subsection covers rollback path; relayMessageId schema note added. - client-federation.md: split paragraph clarifying friend/DM = S2S, spaces = client-federated; §1 clarifies federated accounts are now spaces-only; new API-client error contract subsection (err.message carries the code, not err.body — caught + fixed in fe969a7). - federation.md: endpoints table + S2S User Lookup subsection; TERMINAL_REJECTION_REASONS + permanent-failure callback registry; ghost-row note (rollback errors are best-effort). - api.md: POST /api/social/requests new error-code table; new federation lookup route entry. --- docs/systems/api.md | 26 ++++++++++- docs/systems/client-federation.md | 14 +++++- docs/systems/federation.md | 32 +++++++++++-- docs/systems/social.md | 75 ++++++++++++++++++++++--------- 4 files changed, 120 insertions(+), 27 deletions(-) diff --git a/docs/systems/api.md b/docs/systems/api.md index 8ee8f998..b18a5df6 100644 --- a/docs/systems/api.md +++ b/docs/systems/api.md @@ -118,7 +118,7 @@ DELETE /dm/messages/:id → { succes ``` GET /social/friends → { friends[] } GET /social/requests → { requests[] } -POST /social/requests { username } → { request } +POST /social/requests { username } → { success, requestId } PATCH /social/requests/:id { status: 'accepted'|'declined' } → { request } DELETE /social/requests/:id → { success } (cancel, sender-only) DELETE /social/friends/:id → { success } @@ -126,6 +126,27 @@ GET /social/discover ?q=&limit=&offset= → { users[], total } GET /social/search ?q= → { users[] } ``` +### POST /api/social/requests — routing & error codes + +`body.username` may be `bare` (local), `bare@` (also routed local — server normalizes), or `bare@` (federated branch). The client sends the trimmed handle verbatim; all parsing, routing, peering, and remote lookup are server-side. + +| HTTP | error code | When | +|---|---|---| +| 200 | (success, idempotent) | Same-direction pending request already exists; returns existing `requestId` | +| 201 | (success, created) | New friend request created | +| 400 | `username_required` | Missing/empty/non-string username | +| 400 | `cannot_friend_self` | Looked-up identity matches sender | +| 400 | `invalid_target_domain` | Scheme resolution failed (e.g., non-localhost HTTP target when our scheme is HTTPS) | +| 403 | `peer_rejected` | Remote instance has rejected federation; admin must intervene | +| 403 | `not_authoritative_for_sender` | Caller is a federated (replicated) user; should not have reached here | +| 404 | `user_not_found` | Remote lookup returned 404 (no such user, or tombstoned) | +| 409 | `already_friends` | Friendship row already exists | +| 409 | `peer_pending_approval` | Remote admin needs to approve the peering relationship | +| 409 | `peer_pending` | Peer handshake in flight | +| 409 | `incoming_request_exists` | Opposite-direction pending request exists; response includes `requestId` for deep-link | +| 429 | `lookup_rate_limited` | Remote `/users/lookup` returned 429; `Retry-After` header forwarded | +| 503 | `peer_unreachable` | Remote instance unreachable (network/timeout/lookup-unreachable) | + ## Search (`routes/search.ts`) — auth required ``` GET /channels/:id/search ?q=&from=&has=&before=&after=&offset=&limit= → { results[], totalCount } [VIEW_CHANNEL] @@ -201,8 +222,11 @@ GET /federation/peers (admin) DELETE /federation/peers/:id (admin) → { success } + outbox cleanup POST /federation/relay (HMAC-signed S2S) FederationRelayRequest → { accepted[], rejected[] } POST /federation/sync (HMAC-signed S2S) { sinceTimestamp, limit?, dmChannelId?, federatedId?, contextType? } → { events[], hasMore, checkpoint } +POST /federation/users/lookup (HMAC-signed S2S, rate-limited 60/min/peer) { username } → { found, user? } ``` +**`POST /api/federation/users/lookup`** — HMAC-authenticated S2S endpoint. Resolves a username on this instance to its canonical `(homeUserId, profile snapshot)`. Used by the cross-instance friend-add flow on the sender's home server before queuing a `friend_request_create` event. Responds to native, non-deleted users only; ignores `discoverable`. Returns `{ found: false, code: 'user_not_found' }` for stubs, tombstoned users, or unknown handles. See `federation.md` §1 "S2S User Lookup" for the full contract. + ## Utilities (`routes/utils.ts`) — auth required ``` GET /utils/metadata ?url= → { title?, description?, image?, siteName? } diff --git a/docs/systems/client-federation.md b/docs/systems/client-federation.md index cc9b34b3..76fcc69a 100644 --- a/docs/systems/client-federation.md +++ b/docs/systems/client-federation.md @@ -15,6 +15,10 @@ Source files: ## Architecture Overview +**Client-side vs S2S federation by feature:** +- **Friend & DM relay are S2S.** Sending a friend request to `alice@orbit.tld` does not require having a federated account on `orbit.tld`; the sender's home server queues the relay (see `social.md` §6 outbound flow). DM messages are similarly relayed server-to-server once the initial channel exists. +- **Spaces are client-federated.** Joining a remote space still requires creating a federated account on that instance via the Connections UI. + Backspace supports **client-side federation**: a single app session (web or desktop — both are feature-identical) can connect to multiple Backspace instances simultaneously. The user has a **home instance** (their primary identity) and zero or more **connected remote instances**. ``` @@ -50,7 +54,9 @@ Backspace supports **client-side federation**: a single app session (web or desk ## 1. Federated Account Creation -When a user connects to a remote instance, the client creates (or logs into) an account on that instance. This is a **real account with a real bcrypt password** — not a replicated stub. +When a user adds a remote instance via the Connections settings to **join a space there**, the client creates (or logs into) an account on that instance. As of 2026-04-25, this is no longer required for friending or messaging users on a remote instance — those flows are fully S2S (see `social.md` §6 and `federation.md` §4 respectively). Federated accounts remain real loginable accounts and retain all capabilities (login, space membership, password sync, deletion). + +This is a **real account with a real bcrypt password** — not a replicated stub. ### Username Format @@ -80,6 +86,12 @@ When a user adds a remote instance via the Connections settings: The same password is used across all instances. Password changes on the home instance are synced to remote instances automatically. +### API Client Error Contract + +The shared API client (`packages/web/src/api/client.ts:298`) throws `new Error(body.error)` for non-2xx responses. The server's structured error code is on `err.message`; there is **no** `err.body` or `err.code` property. Catch handlers that need to map codes to UI messages should read `err.message` and pass it as both the code and the fallback to `mapServerErrorToMessage` (see `packages/web/src/utils/friendErrors.ts`). + +This was documented after T19/T20 catch blocks initially read the wrong shape and surfaced raw codes as toast text — fixed in commit `d207af4`. The same pattern applies to any new client-side code that catches API errors from the home or remote instances. + --- ## 2. Instance Store (`instanceStore.ts`) diff --git a/docs/systems/federation.md b/docs/systems/federation.md index e489f9c7..9ccfdc49 100644 --- a/docs/systems/federation.md +++ b/docs/systems/federation.md @@ -183,6 +183,7 @@ When `autoAcceptPeering` is `false` and an instance calls `POST /api/federation/ | `/api/federation/peer/rotate` | POST | HMAC | Accept secret rotation from peer | | `/api/federation/peer/denied` | POST | HMAC | Receive denial notification for awaiting_approval peer | | `/api/federation/identity` | DELETE | HMAC | Delete federated user identity (soft/full mode) | +| `/api/federation/users/lookup` | POST | HMAC, rate-limited 60/min/peer | Resolve a username on this instance to (homeUserId, profile snapshot) for cross-instance friend-request originators | ### S2S Identity Deletion (`DELETE /api/federation/identity`) @@ -202,6 +203,22 @@ Allows a home instance to remove a user's replicated identity from a remote inst - **Mode `"full"`:** Calls `tombstoneUser(uid, { purgeContent: true })` — full tombstone including reactions and orphaned DM cleanup. - **Post-deletion:** Broadcasts `member_left` WS events for all spaces the user belonged to before removal. +### S2S User Lookup (`POST /api/federation/users/lookup`) + +HMAC-authenticated. Rate-limited to 60 requests/minute per peer. Used by the cross-instance friend-add flow to resolve a username on this instance before the sender's home server queues a `friend_request_create` event (see `social.md` §6 outbound flow). + +**Request body:** `{ username: string }` — server trims and lowercases before lookup. + +**Response 200:** `{ found: true, user: { homeUserId, username, profile: { displayName, avatar, avatarColor, banner, bio } } }` — returned for native, non-deleted users regardless of their `discoverable` setting. + +**Response 404:** `{ found: false, code: 'user_not_found' }` — returned for tombstoned users (`isDeleted=1`), replicated stubs (`homeInstance IS NOT NULL`), or unknown usernames. + +**Response 400:** Malformed input (missing, non-string, or empty-after-trim `username`). + +**Response 429:** Per-peer rate limit (60/min) exceeded; `Retry-After` header set. + +**Filter invariant:** `discoverable` is NOT consulted — exact-handle resolution must work for opted-out users. This mirrors the Direct-Add invariant from social.md commits 69d430b/c60ecc5/189cac4: discovery surfaces only opted-in users, but once you have a handle you can always send a request. + ### WebSocket Events (Peering) These S→C events are pushed to the acting user's connected clients by the federation subsystem. @@ -487,14 +504,21 @@ Trigger (API/WS handler) - Increment peer `consecutiveFailures`, set `lastFailureAt` - If `consecutiveFailures >= PEER_UNREACHABLE_THRESHOLD (10)` -> mark peer `unreachable` -#### Terminal rejection: `duplicate` +#### Terminal rejection reasons -The outbox delivery worker treats a relay response of `{ rejected: [{ reason: 'duplicate', ... }] }` as effectively-accepted — the outbox entry is deleted rather than retained for retry. The `duplicate` reason is emitted by the receiving instance's inbound processors when a row with the same `(sourceInstance, sourceMessageId)` already exists; retrying will fail identically until TTL (30 days). Since the peer already has the message, terminal removal is the correct outcome. +As of 2026-04-25, `processOutboxTick` recognizes a configurable set of receiver-acknowledged **terminal rejection reasons** (constant `TERMINAL_REJECTION_REASONS` in `federationWorker.ts`): `duplicate`, `recipient_not_found`, `attribution_mismatch`, `unknown_event_type`. Outbox entries with these reasons are deleted with no retry. + +- `duplicate` — the receiving instance already has the row (same `(sourceInstance, sourceMessageId)`); retrying will fail identically until TTL. +- `recipient_not_found`, `attribution_mismatch`, `unknown_event_type` — structural mismatches that cannot be resolved by retrying. + +For non-`duplicate` terminals, the worker invokes a registered permanent-failure callback via `invokePermanentFailureCallback(eventType, messageId, reason)` from `utils/federationRollback.ts`. Currently registered: `friend_request_create` → `rollbackFriendRequestCreate` (deletes the local `friend_requests` row by `relay_message_id` and emits WS `friend_request_relay_failed` to the sender). Other event types may register their own callbacks. See `social.md` §6 "Failure Handling" for the friend-specific rollback contract. + +**Ghost-row failure mode.** Rollback callbacks are best-effort: the registry catches and logs callback errors but does NOT re-throw. A botched rollback (e.g., DB write fails mid-rollback due to disk full or FK violation) can leave a ghost row that no longer corresponds to any in-flight relay. Acceptable vs. retry-forever blocking the outbox, but worth knowing when debugging stuck pending states. + +**5xx and network failures are NOT terminal** — those use the existing exponential-backoff retry path. Pending operations stay pending indefinitely under sustained connectivity loss, matching the pre-existing DM relay behavior. Logged at `console.log` ("outbox entry removed (terminal)") to distinguish from retained-for-retry `console.warn` messages. -Other rejection reasons (`attribution_mismatch`, `missing_*_payload`, `unknown_event_type`, `unauthorized_source`, `channel_not_found`, `participant_not_found`, `processing_error`, …) remain on the retry path. Some are arguably terminal too; treating them as such is deferred until they are observed accumulating in practice. - ### Retry Backoff Schedule | Attempt | Delay | diff --git a/docs/systems/social.md b/docs/systems/social.md index 18c49469..516c5bca 100644 --- a/docs/systems/social.md +++ b/docs/systems/social.md @@ -287,13 +287,36 @@ The sorted join ensures the same pair always produces the same prefix regardless ### End-to-End Relay Flow: Friend Request Create -**Outbound (origin instance -- `social.ts:POST /api/social/requests`):** -1. Friend request created locally (DB insert + WS broadcast to local recipient) -2. Build identity objects for sender and recipient using `homeUserId || id` / `homeInstance || getOurOrigin()` -3. Call `getFriendEventTargets(from.homeInstance, to.homeInstance)` -- if both local, returns `[]` (no relay) -4. Build `FederationRelayEvent` with `eventType: 'friend_request_create'`, `contextType: 'friend'`, friendship payload including both identities and profile snapshots -5. Call `appendMutationLog()` -- records in `federation_mutation_log` for sync protocol -6. Call `queueOutboxEvent()` -- inserts into `federation_outbox` for each target peer +**Outbound (sender's home instance -- `social.ts:POST /api/social/requests`):** + +As of 2026-04-25, the sender's home server owns the entire federated friend-add flow. The client sends `{ username }` verbatim; all parsing, peering, remote lookup, and queueing happen server-side in this strict order: + +1. **Parse target.** If `body.username` contains no `@`, or the domain after `@` normalizes to this server's own host, fall through to the local-only path (unchanged). +2. **resolveOriginFromHostname(targetDomain)** — resolves the target peer's full origin URL. Prefers a stored `federation_peers` row matching the typed host; falls back to mirroring `getOurOrigin()`'s scheme. Returns null → 400 `invalid_target_domain`. +3. **Authority defense.** If the calling user's `homeInstance` is set and does not normalize to this server's own host (checked via `normalizeOriginForCompare`), return 403 `not_authoritative_for_sender`. Prevents replicated/federated users from queueing relay events the home server isn't authoritative for. Runs before peering to fail fast. +4. **ensurePeered(peerOrigin)** — blocks on the result. Status → HTTP mapping: + - `'active'` → continue + - `'pending'` (handshake in flight) → 409 `peer_pending` + - `'pending'` + peer row `awaiting_approval` (re-queried after the call) → 409 `peer_pending_approval` + - `'rejected'` → 403 `peer_rejected` + - `'failed'` → 503 `peer_unreachable` +5. **lookupRemoteUser(peerOrigin, baseName)** — POSTs HMAC-signed `{ username }` to `peerOrigin/api/federation/users/lookup`. Result mapping: + - `not_found` → 404 `user_not_found` + - `unreachable` → 503 `peer_unreachable` + - `rate_limited` → 429 `lookup_rate_limited` (with `Retry-After` header) +6. **Self-friend pre-check.** If the looked-up `(homeUserId, peerOrigin)` matches the sender's canonical identity (using `normalizeOriginForCompare` for host comparison) → 400 `cannot_friend_self`. +7. **resolveOrCreateReplicatedUser + hydrateReplicatedUserProfile** — creates or refreshes the local stub for the remote user. Tombstoned identities (resolveOrCreateReplicatedUser returns null) → 404 `user_not_found`. +8. **Direction-aware idempotency:** + - Same-direction pending request exists → 200 with existing `requestId` (idempotent). + - Opposite-direction pending request exists → 409 `incoming_request_exists` with existing `requestId` for client deep-link. + - Already friends → 409 `already_friends`. +9. **db.transaction(...)** (synchronous): inserts the `friend_requests` row with `relayMessageId = entityId`, calls `appendMutationLog`, calls `queueOutboxEvent` targeting `[peerOrigin]`. +10. **WS broadcast** `friend_request_sent` to the sender's other tabs/devices (multi-tab sync). +11. Returns `201 { success: true, requestId }`. + +The wire format of the queued event is identical to the pre-2026-04-25 flow; only the queueing instance has changed. The receiver's `processFriendRequestCreateEvent` is unchanged. Both peers' authority checks (`from.homeInstance === sourceInstance`) continue to pass because the sender's instance is now both source and queueing instance. + +> **Schema note:** The `friend_requests` table gained a `relayMessageId TEXT` column (added 2026-04-25, drizzle migration `0001_complex_screwball.sql`). It is `NULL` for local-only requests; for federated ones it carries the `entityId` of the originating relay event so the rollback hook can locate the row by message ID. **Delivery (federation worker -- `federationWorker.ts:processOutboxTick`):** 1. Worker polls outbox every 10 seconds @@ -311,6 +334,22 @@ The sorted join ensures the same pair always produces the same prefix regardless 7. **WS broadcast:** `friend_request_received` sent to local recipient with sender's sanitized profile 8. Push `event.messageId` to accepted array +### Failure Handling: Async Rollback + +When the outbox worker receives a relay response from the remote instance, it classifies each rejected entry. As of 2026-04-25, a configurable set of **terminal rejection reasons** (`TERMINAL_REJECTION_REASONS` in `federationWorker.ts`) causes an outbox entry to be deleted with no retry: `duplicate`, `recipient_not_found`, `attribution_mismatch`, `unknown_event_type`. + +For non-`duplicate` terminals, the worker invokes the registered permanent-failure callback via `invokePermanentFailureCallback(eventType, messageId, reason)` from `utils/federationRollback.ts`. For `friend_request_create`, this is **`rollbackFriendRequestCreate`**: + +1. Looks up the `friend_requests` row by `relayMessageId` (the stored `entityId`). +2. Deletes the row. +3. Emits WS `friend_request_relay_failed` to the sender's connections, with a client-facing reason: receiver `recipient_not_found` → `user_not_found`; everything else → `peer_rejected`. + +The client handler in `useWebSocket.ts` removes the row from `socialStore` and shows a warning toast. + +**5xx responses, network errors, and retry exhaustion are NOT terminal** — the outbox retries with exponential backoff. The sender sees indefinite "pending" under sustained connectivity loss, matching DM relay's behavior under the same conditions. + +**Ghost-row risk.** Rollback callbacks are best-effort: the registry catches and logs callback errors but does not re-throw. A failed rollback (e.g., DB write fails mid-rollback) leaves a ghost `friend_requests` row with no corresponding in-flight relay. Acceptable vs. retry-forever blocking the outbox, but worth knowing when debugging stuck pending requests. + ### End-to-End Relay Flow: Friend Request Update (Accept/Decline) **Outbound (`social.ts:PATCH /api/social/requests/:id`):** @@ -411,18 +450,11 @@ type TaggedUser = User & { _instanceOrigin: string }; Same `Promise.allSettled()` fan-out pattern as `loadFriends`. **Dedup by the other party's canonical identity** (`request.user.homeUserId ?? request.user.id`), preferring the record from the instance where the other party is native (`!request.user.homeInstance`). This is critical: a cross-instance request exists as two rows -- one on each instance -- and both sides return it, but only the record from the target's home instance has the canonical (non-stub) user ids and the correct `_instanceOrigin` tag. Matching those is what lets the Add Friend search card flip to "Request Pending" after sending. Normalizes assets for remote request user profiles. -### Sending Friend Requests (Federation Routing) +### Sending Friend Requests -`sendFriendRequest(username: string)` handles the `user@domain` routing: +`sendFriendRequest(username: string)` sends the trimmed handle verbatim to the home instance API (`POST /api/social/requests`). As of 2026-04-25, all routing, peering, and remote lookup happen server-side — the client no longer resolves the domain to a connected instance or throws `InstanceNotConnectedError`/`InstanceDisconnectedError`. The server returns a structured error code on any failure; the catch block in `socialStore` maps it via `mapServerErrorToMessage` from `packages/web/src/utils/friendErrors.ts` and surfaces it as a toast. -1. **No `@` in username:** Send to home instance API directly -2. **`@` present:** Extract `baseName` and `domain` via `lastIndexOf('@')` - - If domain matches `window.location.host`: strip domain, send to home API - - Otherwise: find matching connected instance by comparing `new URL(inst.origin).host === domain` - - **Not found:** Throw `InstanceNotConnectedError(domain)` (UI prompts to connect) - - **Found but disconnected:** Throw `InstanceDisconnectedError(domain)` (UI prompts to reconnect) - - **Found and connected:** Send to remote instance API with just the `baseName` (no domain suffix) -3. After success, reloads requests via `loadRequests()` +After success, reloads requests via `loadRequests()`. ### Cross-Instance Search (`searchUsers`) @@ -592,7 +624,9 @@ The Add Friend tab merges search and discovery into a single UI: 1. **Empty query:** Shows discover grid (from `discoverStore.fetchUsers()`, loaded on mount) 2. **Query entered:** Switches to search mode (debounced 300ms, uses `socialStore.searchUsers()`) -3. **Direct-Add row:** Shown whenever the search input is non-empty and resolves to a well-formed handle (`trimmed.length > 0 && (no @ || @ at non-edge position)`). Displays the resolved form: when the typed query has no `@`, the row shows `@` so the user sees which instance the request will hit; when `@` is present, displays the typed query verbatim. The submit button calls `sendFriendRequest(query.trim())` — the resolved form is display-only; routing to the correct API client (home-only for no-`@`, federation-aware for `@host` form) happens inside `sendFriendRequest`, which lowercases the parsed `@domain` before comparison so mixed-case hostnames route correctly. Server-side `POST /api/social/requests` lowercases the lookup input before matching, so mixed-case bare handles resolve too. A 404 from the server (`"User not found"`) surfaces as a warning toast via the catch-all in `handleDirectAdd`. +3. **Direct-Add row:** Shown whenever the search input is non-empty and resolves to a well-formed handle (`trimmed.length > 0 && (no @ || @ at non-edge position)`). Displays the resolved form: when the typed query has no `@`, the row shows `@` so the user sees which instance the request will hit; when `@` is present, displays the typed query verbatim. The submit button calls `sendFriendRequest(query.trim())` — the resolved form is display-only. All routing, peering, and remote lookup happen server-side on `POST /api/social/requests` (see §6 outbound flow). Server-side `POST /api/social/requests` lowercases the lookup input before matching, so mixed-case bare handles resolve too. + +**Error handling:** Server errors surface as toasts via `mapServerErrorToMessage` in `packages/web/src/utils/friendErrors.ts`. All structured error codes returned by the federated branch (`user_not_found`, `peer_pending`, `peer_rejected`, `incoming_request_exists`, etc.) are mapped to human-readable messages there. The `ConnectInstanceModal` component still exists in the codebase but is no longer triggered by friend-add — it is used only by the Connections settings panel and space-join flows. ### Search Result Enrichment @@ -612,7 +646,7 @@ Renders a card with banner, avatar, display name, username, bio, mutual counts, - `inbound_pending`: "Accept" / "Decline" buttons - `friends`: "Message" button -When sending a request to a remote user, constructs `baseName@originHost` format for the username. Handles `InstanceNotConnectedError` / `InstanceDisconnectedError` by showing `ConnectInstanceModal`. +When sending a request to a remote user, constructs `baseName@originHost` format for the username. Errors from the server are surfaced as toasts via `mapServerErrorToMessage` (see `packages/web/src/utils/friendErrors.ts`). --- @@ -657,8 +691,7 @@ All actions route through `socialStore` methods, which handle instance routing v - User profile is loaded via `getApiForOrigin(origin)` to fetch from the correct instance - Banner/avatar URLs resolved through the correct API client for remote users - Mutuals loaded via `loadFederatedMutuals()` with cross-instance fan-out -- Friend actions use `sendFriendRequest(user.username)` which handles `user@domain` routing -- `ConnectInstanceModal` shown when trying to add a friend on an unconnected instance +- Friend actions use `sendFriendRequest(user.username)` — all routing is server-side; errors surface as toasts via `mapServerErrorToMessage` ---