From 0d3343ab6de3459fc31247557a6b576d2d7781b6 Mon Sep 17 00:00:00 2001 From: Jannis Braun <151788261+TheZwiss@users.noreply.github.com> Date: Tue, 21 Apr 2026 18:40:39 +0200 Subject: [PATCH 01/14] feat(schema): add consecutive_auth_failures column on federation_peers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Tracks HMAC-failure count separately from consecutive_failures (network errors). Auth failures and network failures have different resolution paths; mixing them would let a single successful retry after a network blip mask real secret desync. Part of backlog #19 — outbox auth-failure recovery. Note: drizzle-kit generated the 0003 SQL with an unexpected CREATE TABLE for peer_approval_requests because the 0002 migration was authored manually without a corresponding 0002_snapshot.json (see 14041e9). The generated SQL has been trimmed to the single intended ALTER TABLE. The regenerated 0003_snapshot.json correctly reflects the full current schema, so future migrations will diff cleanly. --- packages/server/drizzle/0003_classy_loki.sql | 1 + .../server/drizzle/meta/0003_snapshot.json | 3225 +++++++++++++++++ packages/server/drizzle/meta/_journal.json | 7 + packages/server/src/db/schema.ts | 1 + 4 files changed, 3234 insertions(+) create mode 100644 packages/server/drizzle/0003_classy_loki.sql create mode 100644 packages/server/drizzle/meta/0003_snapshot.json diff --git a/packages/server/drizzle/0003_classy_loki.sql b/packages/server/drizzle/0003_classy_loki.sql new file mode 100644 index 00000000..de150deb --- /dev/null +++ b/packages/server/drizzle/0003_classy_loki.sql @@ -0,0 +1 @@ +ALTER TABLE `federation_peers` ADD `consecutive_auth_failures` integer DEFAULT 0 NOT NULL; diff --git a/packages/server/drizzle/meta/0003_snapshot.json b/packages/server/drizzle/meta/0003_snapshot.json new file mode 100644 index 00000000..d39c7df2 --- /dev/null +++ b/packages/server/drizzle/meta/0003_snapshot.json @@ -0,0 +1,3225 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "cd27f0d9-4506-4906-be9c-fee112ad7bdd", + "prevId": "2148ab61-cd98-4493-8c98-912ae17eb14b", + "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": false, + "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 + } + }, + "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 + } + }, + "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 441c9792..58ed0cac 100644 --- a/packages/server/drizzle/meta/_journal.json +++ b/packages/server/drizzle/meta/_journal.json @@ -22,6 +22,13 @@ "when": 1776689384005, "tag": "0002_peer_approval_requests", "breakpoints": true + }, + { + "idx": 3, + "version": "6", + "when": 1776789567488, + "tag": "0003_classy_loki", + "breakpoints": true } ] } \ No newline at end of file diff --git a/packages/server/src/db/schema.ts b/packages/server/src/db/schema.ts index 74f0f6f5..004ec45f 100644 --- a/packages/server/src/db/schema.ts +++ b/packages/server/src/db/schema.ts @@ -363,6 +363,7 @@ export const federationPeers = sqliteTable('federation_peers', { lastSeenAt: integer('last_seen_at'), lastFailureAt: integer('last_failure_at'), consecutiveFailures: integer('consecutive_failures').default(0), + consecutiveAuthFailures: integer('consecutive_auth_failures').notNull().default(0), lastSyncedAt: integer('last_synced_at').default(0), remoteMaxUploadSize: integer('remote_max_upload_size'), nonceSupported: integer('nonce_supported').notNull().default(0), From 1df25737b6a3e6bc4f30b84a68bf802f9d946fa8 Mon Sep 17 00:00:00 2001 From: Jannis Braun <151788261+TheZwiss@users.noreply.github.com> Date: Tue, 21 Apr 2026 20:29:14 +0200 Subject: [PATCH 02/14] types: align FederationPeer union with actual server statuses Adds 'rejected', 'awaiting_approval', and 'needs_attention' to the status union, plus the consecutiveAuthFailures / autoRotateIntervalDays / secretRotatedAt / rotationInProgress fields the UI already reads. --- packages/shared/src/types.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/packages/shared/src/types.ts b/packages/shared/src/types.ts index 0d8c123a..6d5a3903 100644 --- a/packages/shared/src/types.ts +++ b/packages/shared/src/types.ts @@ -986,10 +986,14 @@ export interface FederationPeer { id: string; origin: string; instanceName: string | null; - status: 'pending' | 'active' | 'unreachable' | 'revoked'; + status: 'pending' | 'active' | 'unreachable' | 'revoked' | 'rejected' | 'awaiting_approval' | 'needs_attention'; lastSeenAt: number | null; lastFailureAt: number | null; consecutiveFailures: number; + consecutiveAuthFailures: number; lastSyncedAt: number; + autoRotateIntervalDays: number; + secretRotatedAt: number | null; + rotationInProgress: boolean; createdAt: number; } From 617d71ab4be25bc6e666068c854dc1740ee41047 Mon Sep 17 00:00:00 2001 From: Jannis Braun <151788261+TheZwiss@users.noreply.github.com> Date: Tue, 21 Apr 2026 20:32:38 +0200 Subject: [PATCH 03/14] feat(federation): add evaluateAuthFailure decision function Pure function deciding whether the next 401/403 from an active peer triggers backoff or a transition to needs_attention. Threshold = 5, corresponding to ~21.5 min of the existing BACKOFF_SCHEDULE_MS. --- .../src/utils/federationAuthFailure.test.ts | 35 +++++++++++++++++++ .../server/src/utils/federationAuthFailure.ts | 28 +++++++++++++++ 2 files changed, 63 insertions(+) create mode 100644 packages/server/src/utils/federationAuthFailure.test.ts create mode 100644 packages/server/src/utils/federationAuthFailure.ts diff --git a/packages/server/src/utils/federationAuthFailure.test.ts b/packages/server/src/utils/federationAuthFailure.test.ts new file mode 100644 index 00000000..da2591d4 --- /dev/null +++ b/packages/server/src/utils/federationAuthFailure.test.ts @@ -0,0 +1,35 @@ +import { describe, it, expect } from 'vitest'; +import { evaluateAuthFailure, AUTH_FAILURE_THRESHOLD } from './federationAuthFailure.js'; + +describe('evaluateAuthFailure', () => { + it('returns backoff with incremented count for a first failure', () => { + const result = evaluateAuthFailure(0); + expect(result).toEqual({ kind: 'backoff', newAuthFailures: 1 }); + }); + + it('returns backoff below the threshold', () => { + for (let prev = 0; prev < AUTH_FAILURE_THRESHOLD - 1; prev++) { + const result = evaluateAuthFailure(prev); + expect(result.kind).toBe('backoff'); + expect(result.newAuthFailures).toBe(prev + 1); + } + }); + + it('returns transition at the threshold', () => { + const result = evaluateAuthFailure(AUTH_FAILURE_THRESHOLD - 1); + expect(result).toEqual({ + kind: 'transition_to_needs_attention', + newAuthFailures: AUTH_FAILURE_THRESHOLD, + }); + }); + + it('returns transition beyond the threshold', () => { + const result = evaluateAuthFailure(AUTH_FAILURE_THRESHOLD + 5); + expect(result.kind).toBe('transition_to_needs_attention'); + expect(result.newAuthFailures).toBe(AUTH_FAILURE_THRESHOLD + 6); + }); + + it('AUTH_FAILURE_THRESHOLD is 5', () => { + expect(AUTH_FAILURE_THRESHOLD).toBe(5); + }); +}); diff --git a/packages/server/src/utils/federationAuthFailure.ts b/packages/server/src/utils/federationAuthFailure.ts new file mode 100644 index 00000000..e37b7723 --- /dev/null +++ b/packages/server/src/utils/federationAuthFailure.ts @@ -0,0 +1,28 @@ +/** + * Number of consecutive 401/403 responses from an active peer before the + * outbox worker transitions that peer to `needs_attention`. + * + * Rationale (see design spec §Retry Budget): with the existing + * BACKOFF_SCHEDULE_MS = [30s, 1m, 5m, 15m, 1h], five consecutive auth + * failures span ~21.5 min — covering the 15-min rotation grace window + * with ~6.5 min margin while still giving clear signal that a persistent + * desync has occurred. + */ +export const AUTH_FAILURE_THRESHOLD = 5; + +export type AuthFailureAction = + | { kind: 'backoff'; newAuthFailures: number } + | { kind: 'transition_to_needs_attention'; newAuthFailures: number }; + +/** + * Pure decision function. Given the current consecutive-auth-failure count, + * return whether the next failure keeps the peer in retry-with-backoff or + * transitions it to the `needs_attention` terminal state. + */ +export function evaluateAuthFailure(currentAuthFailures: number): AuthFailureAction { + const newAuthFailures = currentAuthFailures + 1; + if (newAuthFailures >= AUTH_FAILURE_THRESHOLD) { + return { kind: 'transition_to_needs_attention', newAuthFailures }; + } + return { kind: 'backoff', newAuthFailures }; +} From 695ea0849d0e743953a4636537e9274f28d73366 Mon Sep 17 00:00:00 2001 From: Jannis Braun <151788261+TheZwiss@users.noreply.github.com> Date: Tue, 21 Apr 2026 20:35:51 +0200 Subject: [PATCH 04/14] refactor(federation-worker): extract buildContextMapForPeer helper MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pure refactor — will be reused by the needs_attention transition handler. No behavior change. --- packages/server/src/utils/federationWorker.ts | 45 ++++++++++++------- 1 file changed, 29 insertions(+), 16 deletions(-) diff --git a/packages/server/src/utils/federationWorker.ts b/packages/server/src/utils/federationWorker.ts index 17d7a433..b63bdf2d 100644 --- a/packages/server/src/utils/federationWorker.ts +++ b/packages/server/src/utils/federationWorker.ts @@ -396,22 +396,7 @@ async function resolvePendingPeers(): Promise { case 'rejected': { console.warn(`[federation-worker] Auto-peering rejected by ${peerOrigin}: ${result.error}`); - // Collect affected contexts before purging - const entries = db - .select({ - contextId: schema.federationOutbox.contextId, - contextType: schema.federationOutbox.contextType, - }) - .from(schema.federationOutbox) - .where(eq(schema.federationOutbox.peerId, peerId)) - .all(); - - const contextMap = new Map(); - for (const e of entries) { - if (!contextMap.has(e.contextId)) { - contextMap.set(e.contextId, e.contextType); - } - } + const contextMap = buildContextMapForPeer(db, peerId); // Purge outbox entries (NOT mutation log) db.delete(schema.federationOutbox) @@ -433,6 +418,34 @@ async function resolvePendingPeers(): Promise { } } +/** + * Build a map of contextId → contextType for all outbox entries targeting + * a specific peer. Used for surfacing "delivery impossible" via + * pushPeerRejectedEvent when a peer is rejected or transitioned to + * needs_attention. + */ +function buildContextMapForPeer( + db: ReturnType, + peerId: string, +): Map { + const entries = db + .select({ + contextId: schema.federationOutbox.contextId, + contextType: schema.federationOutbox.contextType, + }) + .from(schema.federationOutbox) + .where(eq(schema.federationOutbox.peerId, peerId)) + .all(); + + const contextMap = new Map(); + for (const e of entries) { + if (!contextMap.has(e.contextId)) { + contextMap.set(e.contextId, e.contextType); + } + } + return contextMap; +} + /** * Push a federation_peer_rejected WS event to all local users affected by * the rejection. Resolves contextLabel from the database for each context. From e5afd376d2cd22bee59cba7cd059dec0d672e41e Mon Sep 17 00:00:00 2001 From: Jannis Braun <151788261+TheZwiss@users.noreply.github.com> Date: Tue, 21 Apr 2026 20:39:37 +0200 Subject: [PATCH 05/14] fix(federation-worker): replace 401/403 wipe-and-rehandshake with bounded retry MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous handler (commit ce33ccf + its 403 extension) wiped hmac_secret and reset peer status to 'pending' on any 401/403 from an active peer. This collapsed three distinct failure modes — transient clock skew, legitimate split-brain, active MITM attempt — into "silently establish new trust immediately." The remote's /peer/accept idempotent-200-no-update safeguard then prevented the re-handshake from actually working, producing a 1-req/sec loop observed during backlog #16 verification. New behavior: increment consecutive_auth_failures, apply backoff to outbox entries. At AUTH_FAILURE_THRESHOLD (5) transition to needs_attention, preserve hmac_secret, surface delivery-impossible to affected users, notify admins. Secret is NEVER wiped in response to a network-observed 401/403. Part of backlog #19. --- packages/server/src/utils/federationWorker.ts | 65 ++++++++++++++----- 1 file changed, 47 insertions(+), 18 deletions(-) diff --git a/packages/server/src/utils/federationWorker.ts b/packages/server/src/utils/federationWorker.ts index b63bdf2d..350cccf9 100644 --- a/packages/server/src/utils/federationWorker.ts +++ b/packages/server/src/utils/federationWorker.ts @@ -5,6 +5,7 @@ import { config } from '../config.js'; import { isFederationRelayEnabled, queueOutboxEvent } from './federationOutbox.js'; import { runFederationJanitor } from './storageJanitor.js'; import { buildFederationHeaders, getOurOrigin, generateHmacSecret, ROTATION_GRACE_PERIOD_MS } from './federationAuth.js'; +import { evaluateAuthFailure } from './federationAuthFailure.js'; import { generateSnowflake } from './snowflake.js'; import { getDmMessageWithUser } from '../routes/dm.js'; import { connectionManager } from '../ws/handler.js'; @@ -265,25 +266,53 @@ async function processOutboxTick(): Promise { .where(eq(schema.federationPeers.id, peerId)) .run(); } else if (response.status === 401 || response.status === 403) { - // 401 = HMAC verification failed (remote deleted our peer record entirely) - // 403 = Peer exists but is not active (remote revoked/rejected us) - // Both mean the peer relationship is broken on the remote side. Reset to - // 'pending' so resolvePendingPeers() triggers a fresh handshake via - // ensurePeered() on the next tick. - // Outbox entries are preserved (same peer ID) and will deliver after re-peering. - console.warn( - `[federation-worker] Peer ${peerOrigin} returned ${response.status} (peer stale) — resetting to pending for re-handshake`, - ); - db.update(schema.federationPeers) - .set({ - status: 'pending', - hmacSecret: '', // Will be regenerated by ensurePeered/performHandshake - consecutiveFailures: 0, - lastFailureAt: now, - }) + // HMAC rejected or remote's peer row non-active. Do NOT re-handshake + // via the unauthenticated /peer/accept path — the remote's + // idempotent-200-no-update safeguard would loop forever and, more + // importantly, re-handshaking in response to a 401 is not how trust + // gets healed. Persistent auth failures transition to + // needs_attention; bounded retry (AUTH_FAILURE_THRESHOLD) rides out + // transient clock skew and rotation-grace edge races. + const currentRow = db + .select({ consecutiveAuthFailures: schema.federationPeers.consecutiveAuthFailures }) + .from(schema.federationPeers) .where(eq(schema.federationPeers.id, peerId)) - .run(); - connectionManager.sendToAdmins({ type: 'federation_peers_changed' as const }); + .get(); + const decision = evaluateAuthFailure(currentRow?.consecutiveAuthFailures ?? 0); + + if (decision.kind === 'transition_to_needs_attention') { + db.update(schema.federationPeers) + .set({ + status: 'needs_attention', + consecutiveAuthFailures: decision.newAuthFailures, + lastFailureAt: now, + }) + .where(eq(schema.federationPeers.id, peerId)) + .run(); + console.warn( + `[federation-worker] Peer ${peerOrigin} transitioned to needs_attention after ${decision.newAuthFailures} consecutive ${response.status} responses`, + ); + + const contextMap = buildContextMapForPeer(db, peerId); + if (contextMap.size > 0) { + pushPeerRejectedEvent( + peerOrigin, + contextMap, + 'Federation trust broken — admin must reset peering', + ); + } + connectionManager.sendToAdmins({ type: 'federation_peers_changed' as const }); + } else { + // Below threshold — preserve state, apply backoff to outbox entries + db.update(schema.federationPeers) + .set({ + consecutiveAuthFailures: decision.newAuthFailures, + lastFailureAt: now, + }) + .where(eq(schema.federationPeers.id, peerId)) + .run(); + handleOutboxDeliveryFailure(db, peerId, peerEntries, now); + } } else { console.warn( `[federation-worker] Peer ${peerOrigin} returned HTTP ${response.status}`, From 012e489bc7f8c11b3b7af21c9b48d88b5ab6de39 Mon Sep 17 00:00:00 2001 From: Jannis Braun <151788261+TheZwiss@users.noreply.github.com> Date: Tue, 21 Apr 2026 20:45:33 +0200 Subject: [PATCH 06/14] fix(federation-worker): auth failures must not increment consecutive_failures MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Code review of the previous commit found that the backoff branch of the new 401/403 handler delegated to handleOutboxDeliveryFailure, which double-dips by also incrementing consecutive_failures (the network-layer counter that drives the 'unreachable' transition at threshold 10). Per the design spec §State Machine Changes → Reset logic, auth failures must increment consecutive_auth_failures ONLY. Split handleOutboxDeliveryFailure into: - applyOutboxEntryBackoff: just the per-entry backoff update (safe to call from the auth-failure path) - handleOutboxDeliveryFailure: entry backoff + peer's consecutive_failures bump (network-error path only) Also adds a console.warn to the backoff branch so operators can diagnose clock-skew and rotation-grace incidents before the peer hits the terminal threshold. Part of backlog #19. --- packages/server/src/utils/federationWorker.ts | 29 ++++++++++++++----- 1 file changed, 22 insertions(+), 7 deletions(-) diff --git a/packages/server/src/utils/federationWorker.ts b/packages/server/src/utils/federationWorker.ts index 350cccf9..f986396a 100644 --- a/packages/server/src/utils/federationWorker.ts +++ b/packages/server/src/utils/federationWorker.ts @@ -5,7 +5,7 @@ import { config } from '../config.js'; import { isFederationRelayEnabled, queueOutboxEvent } from './federationOutbox.js'; import { runFederationJanitor } from './storageJanitor.js'; import { buildFederationHeaders, getOurOrigin, generateHmacSecret, ROTATION_GRACE_PERIOD_MS } from './federationAuth.js'; -import { evaluateAuthFailure } from './federationAuthFailure.js'; +import { evaluateAuthFailure, AUTH_FAILURE_THRESHOLD } from './federationAuthFailure.js'; import { generateSnowflake } from './snowflake.js'; import { getDmMessageWithUser } from '../routes/dm.js'; import { connectionManager } from '../ws/handler.js'; @@ -303,7 +303,14 @@ async function processOutboxTick(): Promise { } connectionManager.sendToAdmins({ type: 'federation_peers_changed' as const }); } else { - // Below threshold — preserve state, apply backoff to outbox entries + // Below threshold — preserve state, apply backoff to outbox entries. + // Do NOT call handleOutboxDeliveryFailure here: per spec, auth failures + // must NOT increment consecutive_failures (that counter drives the + // 'unreachable' transition, which is a network-layer signal, not an + // auth-layer one). + console.warn( + `[federation-worker] Peer ${peerOrigin} returned ${response.status} (auth failure ${decision.newAuthFailures}/${AUTH_FAILURE_THRESHOLD})`, + ); db.update(schema.federationPeers) .set({ consecutiveAuthFailures: decision.newAuthFailures, @@ -311,7 +318,7 @@ async function processOutboxTick(): Promise { }) .where(eq(schema.federationPeers.id, peerId)) .run(); - handleOutboxDeliveryFailure(db, peerId, peerEntries, now); + applyOutboxEntryBackoff(db, peerEntries, now); } } else { console.warn( @@ -336,13 +343,11 @@ async function processOutboxTick(): Promise { await resolvePendingPeers(); } -function handleOutboxDeliveryFailure( +function applyOutboxEntryBackoff( db: ReturnType, - peerId: string, entries: Array<{ outboxId: string; attempts: number | null }>, now: number, ): void { - // Increment attempts and compute next retry for each entry for (const entry of entries) { const newAttempts = (entry.attempts ?? 0) + 1; const backoffMs = getBackoffMs(newAttempts); @@ -355,8 +360,18 @@ function handleOutboxDeliveryFailure( .where(eq(schema.federationOutbox.id, entry.outboxId)) .run(); } +} - // Update peer failure tracking +function handleOutboxDeliveryFailure( + db: ReturnType, + peerId: string, + entries: Array<{ outboxId: string; attempts: number | null }>, + now: number, +): void { + applyOutboxEntryBackoff(db, entries, now); + + // Update peer failure tracking (network/generic-error path only — auth failures + // use consecutive_auth_failures instead). const peer = db .select({ consecutiveFailures: schema.federationPeers.consecutiveFailures }) .from(schema.federationPeers) From 48dbe32a695a279a08e851d25378c2ff5f519e46 Mon Sep 17 00:00:00 2001 From: Jannis Braun <151788261+TheZwiss@users.noreply.github.com> Date: Tue, 21 Apr 2026 20:46:52 +0200 Subject: [PATCH 07/14] fix(federation-worker): reset consecutive_auth_failures on successful delivery MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pairs with the new 401/403 handler — a 2xx relay confirms HMAC trust is healthy so the counter should clear. Mirrors the existing consecutive_failures reset for network-layer health. --- packages/server/src/utils/federationWorker.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/server/src/utils/federationWorker.ts b/packages/server/src/utils/federationWorker.ts index f986396a..92f26856 100644 --- a/packages/server/src/utils/federationWorker.ts +++ b/packages/server/src/utils/federationWorker.ts @@ -262,6 +262,7 @@ async function processOutboxTick(): Promise { .set({ lastSeenAt: now, consecutiveFailures: 0, + consecutiveAuthFailures: 0, }) .where(eq(schema.federationPeers.id, peerId)) .run(); From ceaa08b4bd7802a58974fded926212719f51d967 Mon Sep 17 00:00:00 2001 From: Jannis Braun <151788261+TheZwiss@users.noreply.github.com> Date: Tue, 21 Apr 2026 20:49:19 +0200 Subject: [PATCH 08/14] fix(federation): extend /peer/accept safeguard to cover needs_attention MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Unauthenticated /peer/accept must not overwrite hmac_secret for peers in needs_attention, same as active. needs_attention means 'auth trust broke and we don't know why' — letting an unauthenticated request flip it back would reintroduce a path for silent HMAC rotation via the outbox-401 loop the rest of #19 closes. Legitimate recovery is the admin 'Reset peering' action (next task). --- packages/server/src/routes/federation.ts | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/packages/server/src/routes/federation.ts b/packages/server/src/routes/federation.ts index e12b7857..e0a21f6b 100644 --- a/packages/server/src/routes/federation.ts +++ b/packages/server/src/routes/federation.ts @@ -491,8 +491,17 @@ export async function federationRoutes(app: FastifyInstance): Promise { .get(); if (existing) { - if (existing.status === 'active') { - // Idempotent — already peered + if (existing.status === 'active' || existing.status === 'needs_attention') { + // Idempotent — already peered (or peering is in needs_attention state). + // In both cases we refuse to overwrite hmac_secret via this + // unauthenticated endpoint. An unauthenticated caller cannot + // prove prior trust, and needs_attention means "we don't know + // why this broke" — letting an unauthenticated request flip it + // to active with a new secret defeats the purpose. + // + // Legitimate recovery path: local admin clicks "Reset peering" → + // row is deleted → remote's /peer/accept then lands on a + // non-existent row and the normal handshake path runs. return reply.code(200).send({ accepted: true }); } if (existing.status === 'revoked') { From 54f32657e54df7604ebc6baf55894cc7c6677bda Mon Sep 17 00:00:00 2001 From: Jannis Braun <151788261+TheZwiss@users.noreply.github.com> Date: Tue, 21 Apr 2026 20:52:14 +0200 Subject: [PATCH 09/14] test(federation): add route-level tests for POST /peers/:id/reset MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Four cases from the spec's testing strategy: 404 on missing peer, 400 on wrong status, 403 for non-admin, and successful delete including FK cascade of queued outbox entries. Introduces a minimal Fastify-inject harness for route testing — previously the codebase had only pure-function unit tests under utils/. Tests intentionally FAIL at this commit — Task 8 will add the handler and close the loop. --- .../src/routes/federation.reset.test.ts | 191 ++++++++++++++++++ 1 file changed, 191 insertions(+) create mode 100644 packages/server/src/routes/federation.reset.test.ts diff --git a/packages/server/src/routes/federation.reset.test.ts b/packages/server/src/routes/federation.reset.test.ts new file mode 100644 index 00000000..079d887a --- /dev/null +++ b/packages/server/src/routes/federation.reset.test.ts @@ -0,0 +1,191 @@ +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 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)); + +type TestDb = ReturnType>; +let sqlite: Database.Database; +let testDb: TestDb; +let currentUserId = 'admin-user'; +let currentUserIsAdmin = true; + +// Mock getDb BEFORE importing the route module so the module reads our test DB. +vi.mock('../db/index.js', () => ({ + getDb: () => testDb, +})); + +// Mock the auth middleware to honour test-controlled currentUserId / currentUserIsAdmin. +vi.mock('../utils/auth.js', () => ({ + authenticate: async (req: { userId?: string }) => { + if (!currentUserId) { + throw Object.assign(new Error('unauthenticated'), { statusCode: 401 }); + } + req.userId = currentUserId; + }, + requireAdmin: async (req: { userId?: string }, reply: { code: (n: number) => { send: (b: unknown) => unknown } }) => { + if (!currentUserIsAdmin) { + return reply.code(403).send({ error: 'Only instance admins can perform this action', statusCode: 403 }); + } + }, +})); + +// Mock the WS connection manager — the handler only calls sendToAdmins. +vi.mock('../ws/handler.js', () => ({ + connectionManager: { + sendToAdmins: vi.fn(), + getAllOnlineUserIds: () => [], + sendToUser: vi.fn(), + sendToDmMembers: vi.fn(), + }, +})); + +async function buildApp(): Promise { + const app = Fastify({ logger: false }); + // Import the route module AFTER the mocks above are set up. + const { federationRoutes } = await import('./federation.js'); + await app.register(federationRoutes); + await app.ready(); + return app; +} + +function applyMigrations(db: Database.Database): void { + const migrationsDir = path.resolve(__dirname, '../../drizzle'); + const files = fs + .readdirSync(migrationsDir) + .filter(f => f.endsWith('.sql')) + .sort(); + for (const f of files) { + const sql = fs.readFileSync(path.join(migrationsDir, f), 'utf8'); + // drizzle-kit uses `--> statement-breakpoint` as separator + const statements = sql.split(/-->\s*statement-breakpoint/); + for (const stmt of statements) { + const clean = stmt.trim(); + if (clean) db.exec(clean); + } + } +} + +describe('POST /api/federation/peers/:id/reset', () => { + let app: FastifyInstance; + + beforeEach(async () => { + sqlite = new Database(':memory:'); + sqlite.pragma('foreign_keys = ON'); + applyMigrations(sqlite); + testDb = drizzle(sqlite, { schema }); + currentUserId = 'admin-user'; + currentUserIsAdmin = true; + app = await buildApp(); + }); + + it('returns 404 when the peer does not exist', async () => { + const res = await app.inject({ + method: 'POST', + url: '/api/federation/peers/nonexistent/reset', + }); + expect(res.statusCode).toBe(404); + expect(res.json()).toMatchObject({ error: 'Peer not found' }); + }); + + it('returns 400 when the peer status is not needs_attention', async () => { + testDb.insert(schema.federationPeers).values({ + id: 'peer-1', + origin: 'https://example.com', + hmacSecret: 'a'.repeat(64), + status: 'active', + createdAt: Date.now(), + }).run(); + + const res = await app.inject({ + method: 'POST', + url: '/api/federation/peers/peer-1/reset', + }); + expect(res.statusCode).toBe(400); + expect(res.json().error).toContain('needs_attention'); + + // Verify the peer row was NOT deleted + const still = testDb.select().from(schema.federationPeers).all(); + expect(still).toHaveLength(1); + }); + + it('returns 403 when the caller is not an admin', async () => { + currentUserIsAdmin = false; + testDb.insert(schema.federationPeers).values({ + id: 'peer-1', + origin: 'https://example.com', + hmacSecret: 'a'.repeat(64), + status: 'needs_attention', + createdAt: Date.now(), + }).run(); + + const res = await app.inject({ + method: 'POST', + url: '/api/federation/peers/peer-1/reset', + }); + expect(res.statusCode).toBe(403); + + // Row must still exist + const still = testDb.select().from(schema.federationPeers).all(); + expect(still).toHaveLength(1); + }); + + it('deletes the peer and cascade-removes outbox entries on success', async () => { + const now = Date.now(); + testDb.insert(schema.federationPeers).values({ + id: 'peer-1', + origin: 'https://example.com', + hmacSecret: 'a'.repeat(64), + status: 'needs_attention', + createdAt: now, + }).run(); + + // Queue two outbox entries for this peer + testDb.insert(schema.federationOutbox).values([ + { + id: 'out-1', + peerId: 'peer-1', + contextId: 'dm-1', + entityId: 'msg-1', + contextType: 'dm', + eventType: 'message_create', + payload: '{}', + nextRetryAt: now, + expiresAt: now + 86_400_000, + createdAt: now, + }, + { + id: 'out-2', + peerId: 'peer-1', + contextId: 'dm-1', + entityId: 'msg-2', + contextType: 'dm', + eventType: 'message_create', + payload: '{}', + nextRetryAt: now, + expiresAt: now + 86_400_000, + createdAt: now, + }, + ]).run(); + + const res = await app.inject({ + method: 'POST', + url: '/api/federation/peers/peer-1/reset', + }); + expect(res.statusCode).toBe(200); + expect(res.json()).toEqual({ success: true }); + + // Peer deleted + const peers = testDb.select().from(schema.federationPeers).all(); + expect(peers).toHaveLength(0); + + // Outbox entries cascade-removed + const outbox = testDb.select().from(schema.federationOutbox).all(); + expect(outbox).toHaveLength(0); + }); +}); From 0c2864a3d2e46a1562e204f11b33ba18d9211165 Mon Sep 17 00:00:00 2001 From: Jannis Braun <151788261+TheZwiss@users.noreply.github.com> Date: Tue, 21 Apr 2026 20:56:26 +0200 Subject: [PATCH 10/14] feat(federation): add POST /api/federation/peers/:id/reset MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Admin-only endpoint for recovering from needs_attention. Deletes the local peer row; FK cascade removes queued outbox entries. Gated to peers in needs_attention to prevent accidental resets of healthy peerings (use /peers/:id for revoke on active peers). Also extends the Task 8.5 test mock of '../db/index.js' to re-export `schema`. federation.ts imports `schema` from the re-export alongside `getDb`; the previous mock only exposed `getDb`, causing the route handler to blow up with 500s before reaching any assertion. This is a scaffolding fix — no test assertions were changed. --- .../src/routes/federation.reset.test.ts | 2 + packages/server/src/routes/federation.ts | 39 +++++++++++++++++++ 2 files changed, 41 insertions(+) diff --git a/packages/server/src/routes/federation.reset.test.ts b/packages/server/src/routes/federation.reset.test.ts index 079d887a..d1d13aaa 100644 --- a/packages/server/src/routes/federation.reset.test.ts +++ b/packages/server/src/routes/federation.reset.test.ts @@ -16,8 +16,10 @@ let currentUserId = 'admin-user'; let currentUserIsAdmin = true; // Mock getDb BEFORE importing the route module so the module reads our test DB. +// Must also re-export `schema` because federation.ts imports it from '../db/index.js'. vi.mock('../db/index.js', () => ({ getDb: () => testDb, + schema, })); // Mock the auth middleware to honour test-controlled currentUserId / currentUserIsAdmin. diff --git a/packages/server/src/routes/federation.ts b/packages/server/src/routes/federation.ts index e0a21f6b..bccb532c 100644 --- a/packages/server/src/routes/federation.ts +++ b/packages/server/src/routes/federation.ts @@ -845,6 +845,45 @@ export async function federationRoutes(app: FastifyInstance): Promise { }, ); + // ─── POST /api/federation/peers/:id/reset ────────────────────────────────── + // Admin-only: reset a peer that has transitioned to needs_attention. + // Deletes the local peer row (cascade-deletes outbox entries via FK). + // Admin must re-initiate peering out of band after reset. + app.post<{ Params: { id: string } }>( + '/api/federation/peers/:id/reset', + { preHandler: [authenticate, requireAdmin] }, + async (request, reply) => { + const { id } = request.params; + const db = getDb(); + + const peer = db + .select() + .from(schema.federationPeers) + .where(eq(schema.federationPeers.id, id)) + .get(); + + if (!peer) { + return reply.code(404).send({ error: 'Peer not found', statusCode: 404 }); + } + + if (peer.status !== 'needs_attention') { + return reply.code(400).send({ + error: 'Reset is only available for peers in the needs_attention state. Use revoke for active peers.', + statusCode: 400, + }); + } + + // Cascade-delete handles federation_outbox entries (FK onDelete: 'cascade'). + db.delete(schema.federationPeers) + .where(eq(schema.federationPeers.id, id)) + .run(); + + connectionManager.sendToAdmins({ type: 'federation_peers_changed' as const }); + + return reply.code(200).send({ success: true }); + }, + ); + // ─── PATCH /api/federation/peers/:id ──────────────────────────────────────── // Admin-only: update peer settings (e.g. auto-rotation interval). app.patch<{ Params: { id: string }; Body: { autoRotateIntervalDays?: number } }>( From 8a084b0652b20034802dc07b205e312afd3f17e3 Mon Sep 17 00:00:00 2001 From: Jannis Braun <151788261+TheZwiss@users.noreply.github.com> Date: Tue, 21 Apr 2026 20:58:59 +0200 Subject: [PATCH 11/14] feat(api): add federation.resetPeer client method --- packages/web/src/api/client.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/packages/web/src/api/client.ts b/packages/web/src/api/client.ts index 48b72fd9..e72851af 100644 --- a/packages/web/src/api/client.ts +++ b/packages/web/src/api/client.ts @@ -235,6 +235,7 @@ export class BackspaceApiClient { ensurePeered: (data: { remoteOrigin: string }) => Promise<{ peeringStatus: string; peerId?: string; error?: string }>; peers: () => Promise<{ peers: FederationPeer[] }>; revokePeer: (id: string) => Promise<{ success: boolean }>; + resetPeer: (id: string) => Promise<{ success: boolean }>; rotatePeerSecret: (id: string) => Promise<{ success: boolean; gracePeriodMs: number }>; updatePeer: (id: string, data: { autoRotateIntervalDays: number }) => Promise<{ peer: FederationPeer }>; deletePeerPermanently: (id: string) => Promise<{ success: boolean }>; @@ -688,6 +689,8 @@ export class BackspaceApiClient { ), revokePeer: (id: string) => request<{ success: boolean }>('DELETE', `/federation/peers/${id}`), + resetPeer: (id: string) => + request<{ success: boolean }>('POST', `/federation/peers/${id}/reset`), rotatePeerSecret: (id: string) => request<{ success: boolean; gracePeriodMs: number }>('POST', `/federation/peers/${id}/rotate`), updatePeer: (id: string, data: { autoRotateIntervalDays: number }) => From 5a1e354ae1791d194512a1ee3bec7e50c1a232da Mon Sep 17 00:00:00 2001 From: Jannis Braun <151788261+TheZwiss@users.noreply.github.com> Date: Tue, 21 Apr 2026 21:02:49 +0200 Subject: [PATCH 12/14] feat(federation-ui): add needs_attention pill and Reset peering action - peerStatusLabel/Color/DotColor gain a 'needs_attention' case (rose). - StatusFilter row gains 'Needs Attention' toggle. - PeerRow hides Rotate/Revoke and shows 'Reset Peering' when status is needs_attention, plus an Auth Failures stat. - Parent panel routes 'reset' through a ConfirmDialog (danger variant) that spells out the destructive nature and the out-of-band re-peer step. - Client FederationPeer interface gains consecutiveAuthFailures (Task 2 extended the shared type but the web client's local mirror was stale). Codifies the manual 'delete both sides, re-peer' workaround as a first-class admin action. --- packages/web/src/api/client.ts | 1 + .../FederationPanel.tsx | 93 +++++++++++++------ 2 files changed, 68 insertions(+), 26 deletions(-) diff --git a/packages/web/src/api/client.ts b/packages/web/src/api/client.ts index e72851af..daad7ea0 100644 --- a/packages/web/src/api/client.ts +++ b/packages/web/src/api/client.ts @@ -71,6 +71,7 @@ export interface FederationPeer { lastSeenAt: number | null; lastFailureAt: number | null; consecutiveFailures: number | null; + consecutiveAuthFailures: number; lastSyncedAt: number | null; createdAt: number; secretRotatedAt: number | null; diff --git a/packages/web/src/components/modals/instanceSettingsPanels/FederationPanel.tsx b/packages/web/src/components/modals/instanceSettingsPanels/FederationPanel.tsx index 2c811a7a..bf313a8d 100644 --- a/packages/web/src/components/modals/instanceSettingsPanels/FederationPanel.tsx +++ b/packages/web/src/components/modals/instanceSettingsPanels/FederationPanel.tsx @@ -175,6 +175,7 @@ function peerStatusColor(status: string): string { case 'unreachable': return 'bg-accent-amber/15 text-accent-amber'; case 'rejected': return 'bg-accent-rose/15 text-accent-rose'; case 'awaiting_approval': return 'bg-accent-amber/15 text-accent-amber'; + case 'needs_attention': return 'bg-accent-rose/15 text-accent-rose'; case 'revoked': return 'bg-white/5 text-txt-tertiary'; default: return 'bg-white/5 text-txt-tertiary'; } @@ -187,6 +188,7 @@ function peerStatusDotColor(status: string): string { case 'unreachable': return 'bg-accent-amber'; case 'rejected': return 'bg-accent-rose'; case 'awaiting_approval': return 'bg-accent-amber'; + case 'needs_attention': return 'bg-accent-rose'; default: return 'bg-txt-tertiary'; } } @@ -199,13 +201,14 @@ function peerStatusLabel(status: string): string { case 'rejected': return 'Rejected (auto-peering denied)'; case 'revoked': return 'Revoked'; case 'awaiting_approval': return 'Awaiting Approval'; + case 'needs_attention': return 'Needs Attention'; default: return status; } } type PeerView = 'active' | 'revoked'; type SortBy = 'name' | 'lastSeen' | 'dateAdded' | 'failures'; -type StatusFilter = 'active' | 'unreachable' | 'pending' | 'rejected' | 'awaiting_approval'; +type StatusFilter = 'active' | 'unreachable' | 'pending' | 'rejected' | 'awaiting_approval' | 'needs_attention'; // ─── Filter Dropdown ───────────────────────────────────────────────────────── @@ -267,7 +270,7 @@ function FilterDropdown({ {view === 'active' && ( <>
Status
- {(['active', 'unreachable', 'pending', 'rejected', 'awaiting_approval'] as StatusFilter[]).map((s) => ( + {(['active', 'unreachable', 'pending', 'rejected', 'awaiting_approval', 'needs_attention'] as StatusFilter[]).map((s) => ( ))}
@@ -386,7 +393,7 @@ function PeerRow({ peer, view, expanded, onToggleExpand, onAction, defaultAutoRo view: PeerView; expanded: boolean; onToggleExpand: () => void; - onAction: (type: 'rotate' | 'revoke' | 'reinitiate' | 'delete') => void; + onAction: (type: 'rotate' | 'revoke' | 'reinitiate' | 'delete' | 'reset') => void; defaultAutoRotateIntervalDays: number; }) { const [editingInterval, setEditingInterval] = useState(false); @@ -484,6 +491,14 @@ function PeerRow({ peer, view, expanded, onToggleExpand, onAction, defaultAutoRo {peer.consecutiveFailures ?? 0}
+ {peer.status === 'needs_attention' && ( +
+
Auth Failures
+
+ {peer.consecutiveAuthFailures} +
+
+ )}
Last Failure
{formatRelativeTime(peer.lastFailureAt)}
@@ -547,30 +562,42 @@ function PeerRow({ peer, view, expanded, onToggleExpand, onAction, defaultAutoRo {/* Actions */}
- - - {!editingInterval && ( + {peer.status === 'needs_attention' ? ( + ) : ( + <> + + + {!editingInterval && ( + + )} + )}
@@ -750,13 +777,15 @@ export function FederationPanel({ onApprovalCountChange }: { onApprovalCountChan const [peersLoading, setPeersLoading] = useState(false); const [peersError, setPeersError] = useState(''); const [view, setView] = useState('active'); - const [statusFilter, setStatusFilter] = useState>(new Set(['active', 'unreachable', 'pending', 'rejected', 'awaiting_approval'])); + const [statusFilter, setStatusFilter] = useState>( + new Set(['active', 'unreachable', 'pending', 'rejected', 'awaiting_approval', 'needs_attention']), + ); const [sortBy, setSortBy] = useState('name'); const [expandedPeerId, setExpandedPeerId] = useState(null); // Confirm dialog state (used in Task 10) const [confirmAction, setConfirmAction] = useState<{ - type: 'rotate' | 'revoke' | 'reinitiate' | 'delete'; + type: 'rotate' | 'revoke' | 'reinitiate' | 'delete' | 'reset'; peer: FederationPeer; } | null>(null); const [actionLoading, setActionLoading] = useState(false); @@ -855,6 +884,12 @@ export function FederationPanel({ onApprovalCountChange }: { onApprovalCountChan addToast('Peer permanently deleted', 'success', 2000); break; } + case 'reset': { + await api.federation.resetPeer(peer.id); + setPeers((prev) => prev.filter((p) => p.id !== peer.id)); + addToast(`Peering reset for ${peer.instanceName || peer.origin}`, 'success', 3000); + break; + } } } catch (err) { addToast(err instanceof Error ? err.message : 'Action failed', 'warning', 3000); @@ -891,6 +926,12 @@ export function FederationPanel({ onApprovalCountChange }: { onApprovalCountChan confirmLabel: 'Delete', variant: 'danger' as const, }; + case 'reset': return { + title: 'Reset Peering', + description: `Reset peering with ${name}? This deletes the local peer record and all pending outbox entries. You must re-initiate peering with the remote admin out of band after reset. This cannot be undone.`, + confirmLabel: 'Reset', + variant: 'danger' as const, + }; } })() : null; From 64d231d78228a184c3639b4dd19f69129a883c65 Mon Sep 17 00:00:00 2001 From: Jannis Braun <151788261+TheZwiss@users.noreply.github.com> Date: Tue, 21 Apr 2026 21:07:24 +0200 Subject: [PATCH 13/14] audit(federation): verify no parallel hmac-wipe-on-401 paths Task 11 due-diligence audit for #19. Checked all signed-fetch sites in packages/server/src for 4xx-branch mutations of federationPeers.hmacSecret or federationPeers.status: - sendCallRelay (federationOutbox.ts): on 4xx returns post_failed; on 5xx/network returns peer_transient_failure. No peer-state mutation. - sendTypingRelay (federationOutbox.ts): delegates to sendCallRelay with peeringTimeoutMs:0 (fire-and-forget). No peer-state mutation. - cleanupExpiredApprovalRequests (storageJanitor.ts): sends denial, only deletes peerApprovalRequests row on success. No federationPeers mutation. - DELETE /identity (users.ts): per-origin cleanup; only deletes local userFederationRegistry on success, never touches federationPeers. - denyApprovalRequest (federation.ts): requires 2xx from remote before inserting/updating a rejected peer row. Admin-driven, not wipe. - POST /peers/:id/rotate (federation.ts): mutates pendingHmacSecret only on 2xx; returns 502 on 4xx without state change. - Auto-rotation in federationWorker.ts: same 2xx-gated pattern as manual rotate. - Unreachable-recovery health check: only promotes to active on 2xx. - ensurePeered/performHandshake (federationPeering.ts): on 403 with PEERING_REQUIRES_APPROVAL sets status='rejected' (explicit, not a HMAC-mismatch wipe); on other 4xx/5xx only deletes the row if it was a freshly created placeholder (existingPeerId falsy). Pre-existing peers are untouched. Only federationWorker.ts:269 mutates HMAC-related state in response to 401/403, and that path was rewritten in Task 5 to use evaluateAuthFailure and transition to needs_attention. No additional handlers require the bounded-retry refactor. From e236d0730e69702f5297e20b1aefc99c84bb3ae0 Mon Sep 17 00:00:00 2001 From: Jannis Braun <151788261+TheZwiss@users.noreply.github.com> Date: Tue, 21 Apr 2026 21:11:17 +0200 Subject: [PATCH 14/14] docs(systems): document needs_attention state and Reset peering action --- docs/systems/admin.md | 16 +++++++++---- docs/systems/database.md | 5 ++-- docs/systems/federation.md | 49 ++++++++++++++++++++++++++------------ 3 files changed, 49 insertions(+), 21 deletions(-) diff --git a/docs/systems/admin.md b/docs/systems/admin.md index 5d09e609..b820c6ba 100644 --- a/docs/systems/admin.md +++ b/docs/systems/admin.md @@ -9,6 +9,7 @@ Source files: - `packages/server/src/utils/storageJanitor.ts` -- Storage stats, orphan detection, cleanup - `packages/web/src/stores/settingsStore.ts` -- Zustand store for instance/streaming settings - `packages/web/src/components/modals/instanceSettingsPanels/GeneralPanel.tsx` -- General settings UI +- `packages/web/src/components/modals/instanceSettingsPanels/FederationPanel.tsx` -- Federation peers panel (peering, approval queue, peer status, rotation, reset) - `packages/web/src/components/modals/instanceSettingsPanels/StoragePanel.tsx` -- Storage management UI - `packages/web/src/components/modals/instanceSettingsPanels/StreamingPanel.tsx` -- Streaming config UI - `packages/web/src/components/modals/instanceSettingsPanels/UsersPanel.tsx` -- User management UI @@ -422,14 +423,21 @@ All panels live under `packages/web/src/components/modals/instanceSettingsPanels #### GeneralPanel -Manages: instance name, registration toggle, discovery toggle, GIF API key, federation relay toggle/TTL, pending approval requests, peered instances list. +Manages: instance name, registration toggle, discovery toggle, GIF API key, federation relay toggle/TTL. - Instance name input: max 32 chars, enforced client-side via `slice(0, 32)` - GIF key: password input, separate dirty tracking (`gifKeyDirty`). Only sent on save if modified. "Clear key" button sets empty string. +- Federation relay toggle and TTL input: drive `federationRelayEnabled` and `federationRelayTtlDays` instance settings. + +#### FederationPanel + +Manages: federation peers list, pending approval requests, manual peering initiation, secret rotation, peer reset. + - **Pending Approvals section:** Visible only when `pendingApprovalCount > 0` (from ready payload). Positioned above the peer list. Each row shows the requesting instance name and origin with Approve and Deny buttons. Approve calls `api.federation.approveApprovalRequest(id)` and Deny calls `api.federation.denyApprovalRequest(id)`; both remove the row from the local list on success. -- Federation peers: fetched via `api.federation.peers()`, displayed as a list with status badges (active/pending/unreachable/awaiting_approval), last-seen/synced times, revoke button -- Peers with status `'revoked'` are filtered out of the visible list -- Revoke calls `api.federation.revokePeer(peerId)` and removes from local list +- Federation peers: fetched via `api.federation.peers()`, displayed as a list with status badges (active/pending/unreachable/awaiting_approval/rejected/needs_attention), last-seen/synced times, and per-peer actions. +- Peers with status `'revoked'` are filtered out of the visible list. +- Revoke calls `api.federation.revokePeer(peerId)` and removes from local list. +- Peers in `needs_attention` status render with a rose "Needs Attention" pill and a single "Reset Peering" action. The action opens a danger-variant ConfirmDialog explaining that reset deletes the local peer record (cascade-removes outbox entries) and requires out-of-band re-peering with the remote admin. #### StoragePanel diff --git a/docs/systems/database.md b/docs/systems/database.md index dbbf70fa..e8c7a797 100644 --- a/docs/systems/database.md +++ b/docs/systems/database.md @@ -357,10 +357,11 @@ Migration flags (internal): `voice_bit_migrated`, `profile_attachments_cleaned`, | origin | text NOT NULL UNIQUE | | `https://domain.tld` | | instanceName | text | | | | hmacSecret | text NOT NULL | | 256-bit hex | -| status | text NOT NULL | `'active'` | active/pending/awaiting_approval/unreachable/revoked/rejected | +| status | text NOT NULL | `'active'` | active/pending/awaiting_approval/unreachable/revoked/rejected/needs_attention | | lastSeenAt | integer | | | | lastFailureAt | integer | | | -| consecutiveFailures | integer | 0 | >=10 → unreachable | +| consecutiveFailures | integer | 0 | >=10 → unreachable (network/5xx failures) | +| consecutiveAuthFailures | integer NOT NULL | 0 | >=5 → needs_attention. Tracked separately from `consecutiveFailures` (network) because auth (401/403) and network failures have different resolution paths. | | lastSyncedAt | integer | 0 | | | remoteMaxUploadSize | integer | | Bytes, from peer | | createdAt | integer NOT NULL | | | diff --git a/docs/systems/federation.md b/docs/systems/federation.md index 9a91b57f..de421744 100644 --- a/docs/systems/federation.md +++ b/docs/systems/federation.md @@ -73,22 +73,26 @@ Both instances store the **same** HMAC secret. The initiating instance generates ``` ensurePeered - (none) ──────────► pending ──────────► active - ▲ │ │ - │ │ remote 202 │ delivery failures - │ ▼ ▼ - │ awaiting_approval unreachable - │ │ │ - │ ┌───────────┼──────────┐ │ health check OK - │ │ │ │ ▼ - │ accept denied expired active - │ (fresh) (admin) (janitor) │ - │ │ │ │ │ admin revoke - │ ▼ ▼ ▼ ▼ - │ active rejected rejected revoked - │ - │ auto-peer rejected (403 PEERING_REQUIRES_APPROVAL) + (none) ──────────► pending ──────────► active ──────► needs_attention + ▲ │ │ ▲ │ + │ │ remote 202 │ │ │ admin Reset + │ ▼ │ │ ▼ + │ awaiting_approval │ │ (deleted) + │ │ │ │ + │ ┌───────────┼──────────┐ │ │ N consecutive + │ │ │ │ │ │ auth failures (401/403) + │ accept denied expired │ │ + │ (fresh) (admin) (janitor) │ │ delivery failures + │ │ │ │ ▼ │ + │ ▼ ▼ ▼ unreachable + │ active rejected rejected │ + │ │ health check OK + │ auto-peer rejected ▼ + │ (403 PEERING_REQUIRES_APPROVAL) active └──────────────────────────── rejected + │ admin revoke (active) + ▼ + revoked ``` | Status | Outbox delivery | Health check | Relay accepts | Re-initiation | Admin clear | @@ -97,6 +101,7 @@ Both instances store the **same** HMAC secret. The initiating instance generates | `pending` | No | No | No | No (returns 409) | N/A | | `awaiting_approval` | No | No | No | Returns pending; no re-handshake | Yes (admin deletes) | | `unreachable` | No (entries wait) | Yes (1h interval) | Yes (resets to active) | No | N/A | +| `needs_attention` | No (entries bounded by TTL) | No | Yes (200 no-update, same as active) | No (admin must Reset first) | Yes (admin Reset deletes record) | | `revoked` | No (entries purged) | No | No (returns 403) | Yes (old record deleted) | N/A | | `rejected` | No | No | No | Yes (admin deletes record, then re-initiates) | N/A | @@ -151,6 +156,7 @@ When `autoAcceptPeering` is `false` and an instance calls `POST /api/federation/ | `/api/federation/peers/:id` | PATCH | JWT + admin | Update peer settings (auto-rotation interval) | | `/api/federation/peers/:id` | DELETE | JWT + admin | Revoke peer, purge outbox | | `/api/federation/peers/:id/permanent` | DELETE | JWT + admin | Hard-delete revoked peer record | +| `/api/federation/peers/:id/reset` | POST | JWT + admin | Delete peer record (cascade-deletes outbox). Only admissible in `needs_attention` state. | | `/api/federation/peers/:id/rotate` | POST | JWT + admin | Trigger immediate secret rotation | | `/api/federation/approval-requests` | GET | JWT + admin | List pending peering approval requests | | `/api/federation/approval-requests/:id/approve` | POST | JWT + admin | Approve request, initiate handshake | @@ -480,6 +486,19 @@ Trigger (API/WS handler) | 6 | 6 hours | | 7+ | 24 hours (cap) | +### Authentication-failure handling (401 / 403) + +When a relay response is 401 (HMAC rejected) or 403 (remote's peer row is non-active or missing), the worker increments `consecutive_auth_failures` on the peer row, applies backoff to the queued outbox entries via the existing `BACKOFF_SCHEDULE_MS`, and preserves `hmac_secret`. After `AUTH_FAILURE_THRESHOLD = 5` consecutive auth failures (~21.5 min with the existing schedule), the peer transitions to `needs_attention`: + +- Outbox delivery halts (the existing `status = 'active'` filter on the delivery query excludes `needs_attention`). +- `hmac_secret` is preserved (admin can inspect; no silent rotation). +- Affected local users receive `federation_peer_rejected` WS events with reason "Federation trust broken — admin must reset peering". +- Admins receive `federation_peers_changed`. + +The worker NEVER re-handshakes via unauthenticated `/peer/accept` in response to a 401/403. The safeguard at `/peer/accept` (idempotent-200-no-update on `active` OR `needs_attention` peers) is what prevents silent HMAC rotation; the worker's job is to respect that signal and surface it to admins rather than loop. Recovery is via the admin "Reset peering" action, which deletes the local peer row and requires out-of-band re-peering. + +Network failures (timeouts, non-401/403 non-2xx responses) are tracked separately via `consecutive_failures` and lead to `unreachable` at `PEER_UNREACHABLE_THRESHOLD = 10`. A successful delivery resets both counters. + ### Relay Request/Response Format **Request:**