From 2d8ecd0131adf51a81668608a138469eab9017df Mon Sep 17 00:00:00 2001 From: Jannis Braun <151788261+TheZwiss@users.noreply.github.com> Date: Sun, 26 Apr 2026 11:44:06 +0200 Subject: [PATCH 1/5] feat(federation): add approval_token columns to peer schema MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Nullable text columns on federation_peers and peer_approval_requests. Existing rows degrade gracefully (NULL token) per spec §5; the verification logic landing in subsequent commits routes legacy null-token state through the existing autoAccept gate. --- .../drizzle/0002_peer_approval_token.sql | 2 + .../server/drizzle/meta/0002_snapshot.json | 3253 +++++++++++++++++ packages/server/drizzle/meta/_journal.json | 7 + packages/server/src/db/schema.ts | 2 + 4 files changed, 3264 insertions(+) create mode 100644 packages/server/drizzle/0002_peer_approval_token.sql create mode 100644 packages/server/drizzle/meta/0002_snapshot.json diff --git a/packages/server/drizzle/0002_peer_approval_token.sql b/packages/server/drizzle/0002_peer_approval_token.sql new file mode 100644 index 00000000..00fac8bb --- /dev/null +++ b/packages/server/drizzle/0002_peer_approval_token.sql @@ -0,0 +1,2 @@ +ALTER TABLE `federation_peers` ADD `approval_token` text;--> statement-breakpoint +ALTER TABLE `peer_approval_requests` ADD `approval_token` text; \ No newline at end of file diff --git a/packages/server/drizzle/meta/0002_snapshot.json b/packages/server/drizzle/meta/0002_snapshot.json new file mode 100644 index 00000000..a6b584bd --- /dev/null +++ b/packages/server/drizzle/meta/0002_snapshot.json @@ -0,0 +1,3253 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "a603a29a-c462-46c0-b559-5fcfb2453b27", + "prevId": "48befb5f-efdc-4356-b8b0-8d8a06339790", + "tables": { + "attachments": { + "name": "attachments", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "message_id": { + "name": "message_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "dm_message_id": { + "name": "dm_message_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "uploader_id": { + "name": "uploader_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "filename": { + "name": "filename", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "original_name": { + "name": "original_name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "mimetype": { + "name": "mimetype", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "size": { + "name": "size", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "thumbnail_filename": { + "name": "thumbnail_filename", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "width": { + "name": "width", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "height": { + "name": "height", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "duration": { + "name": "duration", + "type": "real", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "source_url": { + "name": "source_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "federation_status": { + "name": "federation_status", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "federation_meta": { + "name": "federation_meta", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "idx_attachments_message_id": { + "name": "idx_attachments_message_id", + "columns": [ + "message_id" + ], + "isUnique": false + }, + "idx_attachments_dm_message_id": { + "name": "idx_attachments_dm_message_id", + "columns": [ + "dm_message_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "attachments_message_id_messages_id_fk": { + "name": "attachments_message_id_messages_id_fk", + "tableFrom": "attachments", + "tableTo": "messages", + "columnsFrom": [ + "message_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "attachments_dm_message_id_dm_messages_id_fk": { + "name": "attachments_dm_message_id_dm_messages_id_fk", + "tableFrom": "attachments", + "tableTo": "dm_messages", + "columnsFrom": [ + "dm_message_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "bans": { + "name": "bans", + "columns": { + "space_id": { + "name": "space_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "reason": { + "name": "reason", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "banned_by": { + "name": "banned_by", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "idx_bans_space_id": { + "name": "idx_bans_space_id", + "columns": [ + "space_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "bans_space_id_spaces_id_fk": { + "name": "bans_space_id_spaces_id_fk", + "tableFrom": "bans", + "tableTo": "spaces", + "columnsFrom": [ + "space_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "bans_user_id_users_id_fk": { + "name": "bans_user_id_users_id_fk", + "tableFrom": "bans", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "bans_banned_by_users_id_fk": { + "name": "bans_banned_by_users_id_fk", + "tableFrom": "bans", + "tableTo": "users", + "columnsFrom": [ + "banned_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "bans_space_id_user_id_pk": { + "columns": [ + "space_id", + "user_id" + ], + "name": "bans_space_id_user_id_pk" + } + }, + "uniqueConstraints": {} + }, + "category_overrides": { + "name": "category_overrides", + "columns": { + "category_id": { + "name": "category_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "target_type": { + "name": "target_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "target_id": { + "name": "target_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "allow": { + "name": "allow", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'0'" + }, + "deny": { + "name": "deny", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'0'" + } + }, + "indexes": { + "idx_category_overrides_category_id": { + "name": "idx_category_overrides_category_id", + "columns": [ + "category_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "category_overrides_category_id_channel_categories_id_fk": { + "name": "category_overrides_category_id_channel_categories_id_fk", + "tableFrom": "category_overrides", + "tableTo": "channel_categories", + "columnsFrom": [ + "category_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "category_overrides_category_id_target_type_target_id_pk": { + "columns": [ + "category_id", + "target_type", + "target_id" + ], + "name": "category_overrides_category_id_target_type_target_id_pk" + } + }, + "uniqueConstraints": {} + }, + "channel_categories": { + "name": "channel_categories", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "space_id": { + "name": "space_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "position": { + "name": "position", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "idx_channel_categories_space_id": { + "name": "idx_channel_categories_space_id", + "columns": [ + "space_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "channel_categories_space_id_spaces_id_fk": { + "name": "channel_categories_space_id_spaces_id_fk", + "tableFrom": "channel_categories", + "tableTo": "spaces", + "columnsFrom": [ + "space_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "channel_overrides": { + "name": "channel_overrides", + "columns": { + "channel_id": { + "name": "channel_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "target_type": { + "name": "target_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "target_id": { + "name": "target_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "allow": { + "name": "allow", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'0'" + }, + "deny": { + "name": "deny", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'0'" + } + }, + "indexes": { + "idx_channel_overrides_channel_id": { + "name": "idx_channel_overrides_channel_id", + "columns": [ + "channel_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "channel_overrides_channel_id_channels_id_fk": { + "name": "channel_overrides_channel_id_channels_id_fk", + "tableFrom": "channel_overrides", + "tableTo": "channels", + "columnsFrom": [ + "channel_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "channel_overrides_channel_id_target_type_target_id_pk": { + "columns": [ + "channel_id", + "target_type", + "target_id" + ], + "name": "channel_overrides_channel_id_target_type_target_id_pk" + } + }, + "uniqueConstraints": {} + }, + "channels": { + "name": "channels", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "space_id": { + "name": "space_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "topic": { + "name": "topic", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "position": { + "name": "position", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 0 + }, + "category_id": { + "name": "category_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "idx_channels_space_id": { + "name": "idx_channels_space_id", + "columns": [ + "space_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "channels_space_id_spaces_id_fk": { + "name": "channels_space_id_spaces_id_fk", + "tableFrom": "channels", + "tableTo": "spaces", + "columnsFrom": [ + "space_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "dm_channels": { + "name": "dm_channels", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "owner_id": { + "name": "owner_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "federated_id": { + "name": "federated_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "owner_home_user_id": { + "name": "owner_home_user_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "owner_home_instance": { + "name": "owner_home_instance", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "idx_dm_federated": { + "name": "idx_dm_federated", + "columns": [ + "federated_id" + ], + "isUnique": true, + "where": "federated_id IS NOT NULL" + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "dm_members": { + "name": "dm_members", + "columns": { + "dm_channel_id": { + "name": "dm_channel_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "closed": { + "name": "closed", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 0 + } + }, + "indexes": { + "idx_dm_members_user_id": { + "name": "idx_dm_members_user_id", + "columns": [ + "user_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "dm_members_dm_channel_id_dm_channels_id_fk": { + "name": "dm_members_dm_channel_id_dm_channels_id_fk", + "tableFrom": "dm_members", + "tableTo": "dm_channels", + "columnsFrom": [ + "dm_channel_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "dm_members_user_id_users_id_fk": { + "name": "dm_members_user_id_users_id_fk", + "tableFrom": "dm_members", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "dm_members_dm_channel_id_user_id_pk": { + "columns": [ + "dm_channel_id", + "user_id" + ], + "name": "dm_members_dm_channel_id_user_id_pk" + } + }, + "uniqueConstraints": {} + }, + "dm_messages": { + "name": "dm_messages", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "dm_channel_id": { + "name": "dm_channel_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "reply_to_id": { + "name": "reply_to_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'user'" + }, + "edited_at": { + "name": "edited_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "source_instance": { + "name": "source_instance", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "source_message_id": { + "name": "source_message_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "encryption_version": { + "name": "encryption_version", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "idx_dm_messages_dm_channel_id": { + "name": "idx_dm_messages_dm_channel_id", + "columns": [ + "dm_channel_id" + ], + "isUnique": false + }, + "idx_dm_messages_user_id": { + "name": "idx_dm_messages_user_id", + "columns": [ + "user_id" + ], + "isUnique": false + }, + "idx_dm_messages_source_unique": { + "name": "idx_dm_messages_source_unique", + "columns": [ + "source_instance", + "source_message_id" + ], + "isUnique": true, + "where": "source_instance IS NOT NULL" + } + }, + "foreignKeys": { + "dm_messages_dm_channel_id_dm_channels_id_fk": { + "name": "dm_messages_dm_channel_id_dm_channels_id_fk", + "tableFrom": "dm_messages", + "tableTo": "dm_channels", + "columnsFrom": [ + "dm_channel_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "dm_messages_user_id_users_id_fk": { + "name": "dm_messages_user_id_users_id_fk", + "tableFrom": "dm_messages", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "dm_messages_reply_to_id_dm_messages_id_fk": { + "name": "dm_messages_reply_to_id_dm_messages_id_fk", + "tableFrom": "dm_messages", + "tableTo": "dm_messages", + "columnsFrom": [ + "reply_to_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "dm_reactions": { + "name": "dm_reactions", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "dm_message_id": { + "name": "dm_message_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "emoji": { + "name": "emoji", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "idx_dm_reactions_dm_message_id": { + "name": "idx_dm_reactions_dm_message_id", + "columns": [ + "dm_message_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "dm_reactions_dm_message_id_dm_messages_id_fk": { + "name": "dm_reactions_dm_message_id_dm_messages_id_fk", + "tableFrom": "dm_reactions", + "tableTo": "dm_messages", + "columnsFrom": [ + "dm_message_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "dm_reactions_user_id_users_id_fk": { + "name": "dm_reactions_user_id_users_id_fk", + "tableFrom": "dm_reactions", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "embeds": { + "name": "embeds", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "message_id": { + "name": "message_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "dm_message_id": { + "name": "dm_message_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "embed_type": { + "name": "embed_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "embed_url": { + "name": "embed_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "width": { + "name": "width", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "height": { + "name": "height", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "color": { + "name": "color", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "idx_embeds_message_id": { + "name": "idx_embeds_message_id", + "columns": [ + "message_id" + ], + "isUnique": false + }, + "idx_embeds_dm_message_id": { + "name": "idx_embeds_dm_message_id", + "columns": [ + "dm_message_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "embeds_message_id_messages_id_fk": { + "name": "embeds_message_id_messages_id_fk", + "tableFrom": "embeds", + "tableTo": "messages", + "columnsFrom": [ + "message_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "embeds_dm_message_id_dm_messages_id_fk": { + "name": "embeds_dm_message_id_dm_messages_id_fk", + "tableFrom": "embeds", + "tableTo": "dm_messages", + "columnsFrom": [ + "dm_message_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "federation_file_queue": { + "name": "federation_file_queue", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "peer_origin": { + "name": "peer_origin", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "dm_message_id": { + "name": "dm_message_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "source_url": { + "name": "source_url", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "target_filename": { + "name": "target_filename", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "original_name": { + "name": "original_name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "mimetype": { + "name": "mimetype", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "size": { + "name": "size", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'pending'" + }, + "rejection_reason": { + "name": "rejection_reason", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "attempts": { + "name": "attempts", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 0 + }, + "next_retry_at": { + "name": "next_retry_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "federation_mutation_log": { + "name": "federation_mutation_log", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "entity_id": { + "name": "entity_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "context_id": { + "name": "context_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "context_type": { + "name": "context_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'dm'" + }, + "mutation_type": { + "name": "mutation_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "mutated_at": { + "name": "mutated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "payload": { + "name": "payload", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "idx_mutation_log_time": { + "name": "idx_mutation_log_time", + "columns": [ + "mutated_at" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "federation_outbox": { + "name": "federation_outbox", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "peer_id": { + "name": "peer_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "context_id": { + "name": "context_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "entity_id": { + "name": "entity_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "context_type": { + "name": "context_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'dm'" + }, + "event_type": { + "name": "event_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "payload": { + "name": "payload", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "encryption_version": { + "name": "encryption_version", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 0 + }, + "attempts": { + "name": "attempts", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 0 + }, + "next_retry_at": { + "name": "next_retry_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "idx_outbox_retry": { + "name": "idx_outbox_retry", + "columns": [ + "next_retry_at" + ], + "isUnique": false + }, + "federation_outbox_peer_id_entity_id_unique": { + "name": "federation_outbox_peer_id_entity_id_unique", + "columns": [ + "peer_id", + "entity_id" + ], + "isUnique": true + } + }, + "foreignKeys": { + "federation_outbox_peer_id_federation_peers_id_fk": { + "name": "federation_outbox_peer_id_federation_peers_id_fk", + "tableFrom": "federation_outbox", + "tableTo": "federation_peers", + "columnsFrom": [ + "peer_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "federation_peers": { + "name": "federation_peers", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "origin": { + "name": "origin", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "instance_name": { + "name": "instance_name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "hmac_secret": { + "name": "hmac_secret", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'active'" + }, + "last_seen_at": { + "name": "last_seen_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_failure_at": { + "name": "last_failure_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "consecutive_failures": { + "name": "consecutive_failures", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "consecutive_auth_failures": { + "name": "consecutive_auth_failures", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "last_synced_at": { + "name": "last_synced_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 0 + }, + "remote_max_upload_size": { + "name": "remote_max_upload_size", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "nonce_supported": { + "name": "nonce_supported", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "pending_hmac_secret": { + "name": "pending_hmac_secret", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "secret_rotation_at": { + "name": "secret_rotation_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "secret_rotated_at": { + "name": "secret_rotated_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "auto_rotate_interval_days": { + "name": "auto_rotate_interval_days", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 90 + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "approval_token": { + "name": "approval_token", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "federation_peers_origin_unique": { + "name": "federation_peers_origin_unique", + "columns": [ + "origin" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "friend_requests": { + "name": "friend_requests", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "from_id": { + "name": "from_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "to_id": { + "name": "to_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "'pending'" + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "relay_message_id": { + "name": "relay_message_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "idx_friend_requests_to_id": { + "name": "idx_friend_requests_to_id", + "columns": [ + "to_id" + ], + "isUnique": false + }, + "idx_friend_requests_from_id": { + "name": "idx_friend_requests_from_id", + "columns": [ + "from_id" + ], + "isUnique": false + }, + "idx_friend_requests_relay_message_id": { + "name": "idx_friend_requests_relay_message_id", + "columns": [ + "relay_message_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "friend_requests_from_id_users_id_fk": { + "name": "friend_requests_from_id_users_id_fk", + "tableFrom": "friend_requests", + "tableTo": "users", + "columnsFrom": [ + "from_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "friend_requests_to_id_users_id_fk": { + "name": "friend_requests_to_id_users_id_fk", + "tableFrom": "friend_requests", + "tableTo": "users", + "columnsFrom": [ + "to_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "friends": { + "name": "friends", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "friend_id": { + "name": "friend_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "idx_friends_user_id": { + "name": "idx_friends_user_id", + "columns": [ + "user_id" + ], + "isUnique": false + }, + "idx_friends_friend_id": { + "name": "idx_friends_friend_id", + "columns": [ + "friend_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "friends_user_id_users_id_fk": { + "name": "friends_user_id_users_id_fk", + "tableFrom": "friends", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "friends_friend_id_users_id_fk": { + "name": "friends_friend_id_users_id_fk", + "tableFrom": "friends", + "tableTo": "users", + "columnsFrom": [ + "friend_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "friends_user_id_friend_id_pk": { + "columns": [ + "user_id", + "friend_id" + ], + "name": "friends_user_id_friend_id_pk" + } + }, + "uniqueConstraints": {} + }, + "instance_settings": { + "name": "instance_settings", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": false, + "default": 1 + }, + "instance_name": { + "name": "instance_name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "'Backspace'" + }, + "worker_id": { + "name": "worker_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "discovery_enabled": { + "name": "discovery_enabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 1 + }, + "max_bitrate_kbps": { + "name": "max_bitrate_kbps", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 20000 + }, + "min_bitrate_kbps": { + "name": "min_bitrate_kbps", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 500 + }, + "bitrate_step_kbps": { + "name": "bitrate_step_kbps", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 500 + }, + "allowed_resolutions": { + "name": "allowed_resolutions", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'540,720,1080'" + }, + "allowed_framerates": { + "name": "allowed_framerates", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'30,45,60'" + }, + "max_resolution": { + "name": "max_resolution", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 1080 + }, + "max_framerate": { + "name": "max_framerate", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 60 + }, + "registration_open": { + "name": "registration_open", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "gif_api_key": { + "name": "gif_api_key", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "bitrate_matrix_overrides": { + "name": "bitrate_matrix_overrides", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "allow_custom_bitrate": { + "name": "allow_custom_bitrate", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 1 + }, + "max_upload_size_bytes": { + "name": "max_upload_size_bytes", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "federation_relay_enabled": { + "name": "federation_relay_enabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 1 + }, + "federation_relay_ttl_days": { + "name": "federation_relay_ttl_days", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 30 + }, + "default_auto_rotate_interval_days": { + "name": "default_auto_rotate_interval_days", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 90 + }, + "auto_accept_peering": { + "name": "auto_accept_peering", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 1 + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "join_requests": { + "name": "join_requests", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "space_id": { + "name": "space_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "message": { + "name": "message", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'pending'" + }, + "decided_by": { + "name": "decided_by", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "decided_at": { + "name": "decided_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "idx_join_requests_space_id_status": { + "name": "idx_join_requests_space_id_status", + "columns": [ + "space_id", + "status" + ], + "isUnique": false + } + }, + "foreignKeys": { + "join_requests_space_id_spaces_id_fk": { + "name": "join_requests_space_id_spaces_id_fk", + "tableFrom": "join_requests", + "tableTo": "spaces", + "columnsFrom": [ + "space_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "join_requests_user_id_users_id_fk": { + "name": "join_requests_user_id_users_id_fk", + "tableFrom": "join_requests", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "join_requests_decided_by_users_id_fk": { + "name": "join_requests_decided_by_users_id_fk", + "tableFrom": "join_requests", + "tableTo": "users", + "columnsFrom": [ + "decided_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "member_roles": { + "name": "member_roles", + "columns": { + "space_id": { + "name": "space_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "role_id": { + "name": "role_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "idx_member_roles_user_id_space_id": { + "name": "idx_member_roles_user_id_space_id", + "columns": [ + "user_id", + "space_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "member_roles_space_id_spaces_id_fk": { + "name": "member_roles_space_id_spaces_id_fk", + "tableFrom": "member_roles", + "tableTo": "spaces", + "columnsFrom": [ + "space_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "member_roles_user_id_users_id_fk": { + "name": "member_roles_user_id_users_id_fk", + "tableFrom": "member_roles", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "member_roles_role_id_roles_id_fk": { + "name": "member_roles_role_id_roles_id_fk", + "tableFrom": "member_roles", + "tableTo": "roles", + "columnsFrom": [ + "role_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "member_roles_space_id_user_id_role_id_pk": { + "columns": [ + "space_id", + "user_id", + "role_id" + ], + "name": "member_roles_space_id_user_id_role_id_pk" + } + }, + "uniqueConstraints": {} + }, + "messages": { + "name": "messages", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "channel_id": { + "name": "channel_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "reply_to_id": { + "name": "reply_to_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "edited_at": { + "name": "edited_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "idx_messages_channel_id": { + "name": "idx_messages_channel_id", + "columns": [ + "channel_id" + ], + "isUnique": false + }, + "idx_messages_user_id": { + "name": "idx_messages_user_id", + "columns": [ + "user_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "messages_channel_id_channels_id_fk": { + "name": "messages_channel_id_channels_id_fk", + "tableFrom": "messages", + "tableTo": "channels", + "columnsFrom": [ + "channel_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "messages_user_id_users_id_fk": { + "name": "messages_user_id_users_id_fk", + "tableFrom": "messages", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "messages_reply_to_id_messages_id_fk": { + "name": "messages_reply_to_id_messages_id_fk", + "tableFrom": "messages", + "tableTo": "messages", + "columnsFrom": [ + "reply_to_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "peer_approval_requests": { + "name": "peer_approval_requests", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "origin": { + "name": "origin", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "instance_name": { + "name": "instance_name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "hmac_secret": { + "name": "hmac_secret", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "requested_at": { + "name": "requested_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "approval_token": { + "name": "approval_token", + "type": "text", + "primaryKey": false, + "notNull": false, + "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 a69c0cff..72a4d99a 100644 --- a/packages/server/drizzle/meta/_journal.json +++ b/packages/server/drizzle/meta/_journal.json @@ -15,6 +15,13 @@ "when": 1777144927946, "tag": "0001_complex_screwball", "breakpoints": true + }, + { + "idx": 2, + "version": "6", + "when": 1777196627239, + "tag": "0002_peer_approval_token", + "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 b2f18154..17c3a696 100644 --- a/packages/server/src/db/schema.ts +++ b/packages/server/src/db/schema.ts @@ -374,6 +374,7 @@ export const federationPeers = sqliteTable('federation_peers', { secretRotatedAt: integer('secret_rotated_at'), autoRotateIntervalDays: integer('auto_rotate_interval_days').notNull().default(90), createdAt: integer('created_at').notNull(), + approvalToken: text('approval_token'), }); export const peerApprovalRequests = sqliteTable('peer_approval_requests', { @@ -383,6 +384,7 @@ export const peerApprovalRequests = sqliteTable('peer_approval_requests', { hmacSecret: text('hmac_secret').notNull(), requestedAt: integer('requested_at').notNull(), expiresAt: integer('expires_at').notNull(), + approvalToken: text('approval_token'), }); export const federationOutbox = sqliteTable('federation_outbox', { From 9e078c44ba007a12e6c95ed59fd503dd78f62c22 Mon Sep 17 00:00:00 2001 From: Jannis Braun <151788261+TheZwiss@users.noreply.github.com> Date: Sun, 26 Apr 2026 11:45:16 +0200 Subject: [PATCH 2/5] feat(federation): performHandshake captures + clears approval token MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 202 response: parse approvalToken from body and store on the local federation_peers row alongside status='awaiting_approval'. Legacy receivers that omit the field result in null stored token, handled gracefully by the verification logic landing in subsequent commits. - 200 response: clear approvalToken in the same UPDATE that sets status='active' (single-use consumption per spec §3.2). Test coverage: 4 tests in federationPeering.approvalToken.test.ts covering JSON token, missing token (legacy), non-JSON body, and 200 clear after prior token storage. --- .../federationPeering.approvalToken.test.ts | 172 ++++++++++++++++++ .../server/src/utils/federationPeering.ts | 19 +- 2 files changed, 188 insertions(+), 3 deletions(-) create mode 100644 packages/server/src/utils/federationPeering.approvalToken.test.ts diff --git a/packages/server/src/utils/federationPeering.approvalToken.test.ts b/packages/server/src/utils/federationPeering.approvalToken.test.ts new file mode 100644 index 00000000..a337738f --- /dev/null +++ b/packages/server/src/utils/federationPeering.approvalToken.test.ts @@ -0,0 +1,172 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import Database from 'better-sqlite3'; +import { drizzle } from 'drizzle-orm/better-sqlite3'; +import { eq } from 'drizzle-orm'; +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import * as schema from '../db/schema.js'; +import { setWorkerId } from './snowflake.js'; + +setWorkerId(1); + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +type TestDb = ReturnType>; +let sqlite: Database.Database; +let testDb: TestDb; + +vi.mock('../db/index.js', () => ({ + getDb: () => testDb, + schema, +})); + +vi.mock('./federationAuth.js', async () => { + const actual = await vi.importActual('./federationAuth.js'); + return { + ...actual, + getOurOrigin: () => 'https://local.example', + generateHmacSecret: () => 'mock-hmac-secret', + }; +}); + +vi.mock('../routes/federation.js', () => ({ + validateOrigin: (raw: string) => { + try { + const url = new URL(raw); + return url.origin; + } catch { + return null; + } + }, +})); + +vi.mock('../ws/handler.js', () => ({ + connectionManager: { + sendToAdmins: vi.fn(), + sendToUser: vi.fn(), + getAllOnlineUserIds: () => [], + }, +})); + +vi.mock('./federationPeerActivation.js', () => ({ + onPeerActivated: vi.fn(async () => undefined), + onPeerDeactivated: vi.fn(async () => undefined), +})); + +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 sqlText = fs.readFileSync(path.join(migrationsDir, f), 'utf8'); + const statements = sqlText.split(/-->\s*statement-breakpoint/); + for (const stmt of statements) { + const clean = stmt.trim(); + if (clean) db.exec(clean); + } + } +} + +function seedInstanceSettings(): void { + testDb.insert(schema.instanceSettings).values({ + id: 1, + instanceName: 'Local Backspace', + autoAcceptPeering: 1, + registrationOpen: 1, + updatedAt: Date.now(), + }).run(); +} + +describe('performHandshake — approval token capture & clear', () => { + beforeEach(async () => { + sqlite = new Database(':memory:'); + testDb = drizzle(sqlite, { schema }); + applyMigrations(sqlite); + seedInstanceSettings(); + const { _clearInFlightPeering } = await import('./federationPeering.js'); + _clearInFlightPeering(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + sqlite.close(); + }); + + it('stores approvalToken on the peer row when remote returns 202 with token', async () => { + const fakeToken = 'a'.repeat(64); + vi.spyOn(globalThis, 'fetch').mockResolvedValue( + new Response( + JSON.stringify({ queued: true, message: 'queued', approvalToken: fakeToken }), + { status: 202, headers: { 'Content-Type': 'application/json' } }, + ), + ); + + const { ensurePeered } = await import('./federationPeering.js'); + const result = await ensurePeered('https://remote.example'); + + expect(result.status).toBe('pending'); + + const peer = testDb.select().from(schema.federationPeers) + .where(eq(schema.federationPeers.origin, 'https://remote.example')).get(); + expect(peer?.status).toBe('awaiting_approval'); + expect(peer?.approvalToken).toBe(fakeToken); + }); + + it('stores null approvalToken when 202 omits the field (legacy receiver)', async () => { + vi.spyOn(globalThis, 'fetch').mockResolvedValue( + new Response( + JSON.stringify({ queued: true, message: 'queued' }), + { status: 202, headers: { 'Content-Type': 'application/json' } }, + ), + ); + + const { ensurePeered } = await import('./federationPeering.js'); + const result = await ensurePeered('https://legacy.example'); + + expect(result.status).toBe('pending'); + const peer = testDb.select().from(schema.federationPeers) + .where(eq(schema.federationPeers.origin, 'https://legacy.example')).get(); + expect(peer?.status).toBe('awaiting_approval'); + expect(peer?.approvalToken).toBeNull(); + }); + + it('handles non-JSON 202 body gracefully (token stays null)', async () => { + vi.spyOn(globalThis, 'fetch').mockResolvedValue( + new Response('', { status: 202 }), + ); + + const { ensurePeered } = await import('./federationPeering.js'); + const result = await ensurePeered('https://empty.example'); + + expect(result.status).toBe('pending'); + const peer = testDb.select().from(schema.federationPeers) + .where(eq(schema.federationPeers.origin, 'https://empty.example')).get(); + expect(peer?.approvalToken).toBeNull(); + }); + + it('clears approvalToken on 200 activation even if previously stored', async () => { + testDb.insert(schema.federationPeers).values({ + id: 'peer-existing', + origin: 'https://remote.example', + hmacSecret: 'old-secret', + status: 'pending', + approvalToken: 'old-token', + createdAt: Date.now(), + }).run(); + + vi.spyOn(globalThis, 'fetch').mockResolvedValue( + new Response( + JSON.stringify({ accepted: true, instanceName: 'Remote' }), + { status: 200, headers: { 'Content-Type': 'application/json' } }, + ), + ); + + const { ensurePeered } = await import('./federationPeering.js'); + const result = await ensurePeered('https://remote.example'); + expect(result.status).toBe('active'); + + const peer = testDb.select().from(schema.federationPeers) + .where(eq(schema.federationPeers.origin, 'https://remote.example')).get(); + expect(peer?.status).toBe('active'); + expect(peer?.approvalToken).toBeNull(); + }); +}); diff --git a/packages/server/src/utils/federationPeering.ts b/packages/server/src/utils/federationPeering.ts index 2fbda0a5..48ce5769 100644 --- a/packages/server/src/utils/federationPeering.ts +++ b/packages/server/src/utils/federationPeering.ts @@ -166,9 +166,22 @@ async function performHandshake( }); if (response.status === 202) { - // Request queued for admin approval on the remote side + // Capture the approval token from the 202 body if present. Stored on + // our federation_peers row so a subsequent inbound /peer/accept (from + // the remote's /approve flow) can be cryptographically verified before + // we promote awaiting_approval → active. Spec §3.7. + let approvalToken: string | null = null; + try { + const body = (await response.json()) as { approvalToken?: string }; + if (typeof body?.approvalToken === 'string' && body.approvalToken.length > 0) { + approvalToken = body.approvalToken; + } + } catch { + // Non-JSON or empty body — legacy receiver, leave null. + } + db.update(schema.federationPeers) - .set({ status: 'awaiting_approval' }) + .set({ status: 'awaiting_approval', approvalToken }) .where(eq(schema.federationPeers.id, peerId)) .run(); const { connectionManager } = await import('../ws/handler.js'); @@ -191,7 +204,7 @@ async function performHandshake( } db.update(schema.federationPeers) - .set({ status: 'active', lastSeenAt: Date.now(), instanceName: remoteInstanceName }) + .set({ status: 'active', lastSeenAt: Date.now(), instanceName: remoteInstanceName, approvalToken: null }) .where(eq(schema.federationPeers.id, peerId)) .run(); const { connectionManager } = await import('../ws/handler.js'); From 058eb992e485f2ee8b0fc8c63d0d7628ce2ac7b1 Mon Sep 17 00:00:00 2001 From: Jannis Braun <151788261+TheZwiss@users.noreply.github.com> Date: Sun, 26 Apr 2026 11:48:33 +0200 Subject: [PATCH 3/5] =?UTF-8?q?feat(federation):=20verify=20approval=20tok?= =?UTF-8?q?en=20before=20awaiting=5Fapproval=20=E2=86=92=20active?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Receiver-side defense — closes the trust-bypass class the cheap fix (4533e36) cannot cover. The /peer/accept handler's awaiting_approval branch now requires an approvalToken matching the one stored on the local peer row before promoting to active. Without a match: - autoAccept=0 falls through to queueApprovalRequest (no bypass; new approval-request queued, existing awaiting_approval row untouched). - autoAccept=1 falls back to permissive promotion (no regression vs prior behavior, since autoAccept=1 would accept any inbound regardless). Successful match also deletes any stale approval-request row for the origin to prevent debris accumulation from prior bypass attempts. Refactor: queueing path extracted to a top-level queueApprovalRequest() helper so both the no-existing-peer case and the awaiting_approval mismatch fallback share one implementation. Helper generates a fresh single-use token on every call. Adds 'accept_awaiting_approval_fallback' variant to PeerActivationReason to distinguish the autoAccept=1 fallback path from the verified path in onPeerActivated audit logs. Spec §3.5, §3.6. Test counts: 289 → 301 (+12). --- ...federation.peerAcceptApprovalToken.test.ts | 345 ++++++++++++++++++ packages/server/src/routes/federation.ts | 215 +++++++---- .../src/utils/federationPeerActivation.ts | 1 + 3 files changed, 493 insertions(+), 68 deletions(-) create mode 100644 packages/server/src/routes/federation.peerAcceptApprovalToken.test.ts diff --git a/packages/server/src/routes/federation.peerAcceptApprovalToken.test.ts b/packages/server/src/routes/federation.peerAcceptApprovalToken.test.ts new file mode 100644 index 00000000..933741f4 --- /dev/null +++ b/packages/server/src/routes/federation.peerAcceptApprovalToken.test.ts @@ -0,0 +1,345 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import Fastify, { type FastifyInstance } from 'fastify'; +import Database from 'better-sqlite3'; +import { drizzle } from 'drizzle-orm/better-sqlite3'; +import { eq } from 'drizzle-orm'; +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import * as schema from '../db/schema.js'; +import { setWorkerId } from '../utils/snowflake.js'; + +setWorkerId(1); + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); + +type TestDb = ReturnType>; +let sqlite: Database.Database; +let testDb: TestDb; + +vi.mock('../db/index.js', () => ({ + getDb: () => testDb, + getRawDb: () => sqlite, + schema, +})); + +vi.mock('../utils/auth.js', () => ({ + authenticate: async (req: { userId?: string }) => { + req.userId = 'admin-user'; + }, + requireAdmin: async () => {}, +})); + +vi.mock('../ws/handler.js', () => ({ + connectionManager: { + sendToAdmins: vi.fn(), + getAllOnlineUserIds: () => [], + sendToUser: vi.fn(), + sendToDmMembers: vi.fn(), + }, +})); + +vi.mock('../utils/federationPeerActivation.js', () => ({ + onPeerActivated: vi.fn(async () => undefined), + onPeerDeactivated: vi.fn(async () => undefined), +})); + +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 sqlText = fs.readFileSync(path.join(migrationsDir, f), 'utf8'); + const statements = sqlText.split(/-->\s*statement-breakpoint/); + for (const stmt of statements) { + const clean = stmt.trim(); + if (clean) db.exec(clean); + } + } +} + +function seedInstanceSettings(autoAccept: 0 | 1): void { + testDb.insert(schema.instanceSettings).values({ + id: 1, + instanceName: 'Local Backspace', + autoAcceptPeering: autoAccept, + registrationOpen: 1, + updatedAt: Date.now(), + }).run(); +} + +async function buildApp(): Promise { + const app = Fastify({ logger: false }); + const { federationRoutes } = await import('./federation.js'); + await app.register(federationRoutes); + await app.ready(); + return app; +} + +describe('POST /api/federation/peer/accept — approval token verification', () => { + let app: FastifyInstance; + + beforeEach(async () => { + sqlite = new Database(':memory:'); + testDb = drizzle(sqlite, { schema }); + applyMigrations(sqlite); + }); + + async function setupAutoAccept(value: 0 | 1): Promise { + seedInstanceSettings(value); + app = await buildApp(); + } + + it('queueing path stores token on peer_approval_requests and returns it in the 202 body (autoAccept=0, no existing peer)', async () => { + await setupAutoAccept(0); + + const response = await app.inject({ + method: 'POST', + url: '/api/federation/peer/accept', + payload: { + sourceOrigin: 'https://remote.example', + hmacSecret: 'remote-secret', + instanceName: 'Remote', + }, + }); + + expect(response.statusCode).toBe(202); + const body = response.json() as { queued: boolean; approvalToken?: string }; + expect(body.queued).toBe(true); + expect(typeof body.approvalToken).toBe('string'); + expect(body.approvalToken).toMatch(/^[0-9a-f]{64}$/); + + const row = testDb.select().from(schema.peerApprovalRequests) + .where(eq(schema.peerApprovalRequests.origin, 'https://remote.example')).get(); + expect(row?.approvalToken).toBe(body.approvalToken); + }); + + it('regenerates token on re-handshake (existing approval-request row gets new token)', async () => { + await setupAutoAccept(0); + + const now = Date.now(); + const oldToken = 'old-token-' + 'a'.repeat(53); + testDb.insert(schema.peerApprovalRequests).values({ + id: 'approval-old', + origin: 'https://remote.example', + instanceName: 'Remote', + hmacSecret: 'old-secret', + requestedAt: now - 1000, + expiresAt: now + 30 * 24 * 60 * 60 * 1000, + approvalToken: oldToken, + }).run(); + + const response = await app.inject({ + method: 'POST', + url: '/api/federation/peer/accept', + payload: { + sourceOrigin: 'https://remote.example', + hmacSecret: 'fresh-secret', + instanceName: 'Remote', + }, + }); + + expect(response.statusCode).toBe(202); + const body = response.json() as { queued: boolean; approvalToken?: string }; + expect(body.approvalToken).toMatch(/^[0-9a-f]{64}$/); + expect(body.approvalToken).not.toBe(oldToken); + + const row = testDb.select().from(schema.peerApprovalRequests) + .where(eq(schema.peerApprovalRequests.origin, 'https://remote.example')).get(); + expect(row?.approvalToken).toBe(body.approvalToken); + expect(row?.hmacSecret).toBe('fresh-secret'); + }); + + it('promotes awaiting_approval → active when token matches (autoAccept=0)', async () => { + await setupAutoAccept(0); + + const token = 'a'.repeat(64); + testDb.insert(schema.federationPeers).values({ + id: 'peer-pending', + origin: 'https://remote.example', + hmacSecret: 'old-secret', + status: 'awaiting_approval', + approvalToken: token, + createdAt: Date.now(), + }).run(); + + const response = await app.inject({ + method: 'POST', + url: '/api/federation/peer/accept', + payload: { + sourceOrigin: 'https://remote.example', + hmacSecret: 'fresh-secret', + instanceName: 'Remote', + approvalToken: token, + }, + }); + + expect(response.statusCode).toBe(200); + const peer = testDb.select().from(schema.federationPeers) + .where(eq(schema.federationPeers.origin, 'https://remote.example')).get(); + expect(peer?.status).toBe('active'); + expect(peer?.hmacSecret).toBe('fresh-secret'); + expect(peer?.approvalToken).toBeNull(); + }); + + it('on successful match, deletes any stale approval-request row for the same origin', async () => { + await setupAutoAccept(0); + const token = 'b'.repeat(64); + const now = Date.now(); + testDb.insert(schema.federationPeers).values({ + id: 'peer-pending', + origin: 'https://remote.example', + hmacSecret: 'old-secret', + status: 'awaiting_approval', + approvalToken: token, + createdAt: now, + }).run(); + // Stale approval-request from a prior bypass attempt (e.g., bug-prone code path). + testDb.insert(schema.peerApprovalRequests).values({ + id: 'stale-request', + origin: 'https://remote.example', + instanceName: 'Remote', + hmacSecret: 'bypass-secret', + requestedAt: now, + expiresAt: now + 30 * 24 * 60 * 60 * 1000, + approvalToken: 'stale-token', + }).run(); + + const response = await app.inject({ + method: 'POST', + url: '/api/federation/peer/accept', + payload: { + sourceOrigin: 'https://remote.example', + hmacSecret: 'fresh-secret', + approvalToken: token, + }, + }); + + expect(response.statusCode).toBe(200); + const stale = testDb.select().from(schema.peerApprovalRequests) + .where(eq(schema.peerApprovalRequests.origin, 'https://remote.example')).get(); + expect(stale).toBeUndefined(); + }); + + it('autoAccept=0 + missing token → does NOT promote, queues new approval-request', async () => { + await setupAutoAccept(0); + const token = 'c'.repeat(64); + testDb.insert(schema.federationPeers).values({ + id: 'peer-pending', + origin: 'https://remote.example', + hmacSecret: 'old-secret', + status: 'awaiting_approval', + approvalToken: token, + createdAt: Date.now(), + }).run(); + + const response = await app.inject({ + method: 'POST', + url: '/api/federation/peer/accept', + payload: { + sourceOrigin: 'https://remote.example', + hmacSecret: 'bypass-secret', + // no approvalToken + }, + }); + + expect(response.statusCode).toBe(202); + const body = response.json() as { queued: boolean; approvalToken?: string }; + expect(body.queued).toBe(true); + expect(typeof body.approvalToken).toBe('string'); + + // Existing awaiting_approval peer row UNCHANGED. + const peer = testDb.select().from(schema.federationPeers) + .where(eq(schema.federationPeers.origin, 'https://remote.example')).get(); + expect(peer?.status).toBe('awaiting_approval'); + expect(peer?.hmacSecret).toBe('old-secret'); + expect(peer?.approvalToken).toBe(token); + + // New approval-request row exists with a fresh token (different from the existing peer's). + const req = testDb.select().from(schema.peerApprovalRequests) + .where(eq(schema.peerApprovalRequests.origin, 'https://remote.example')).get(); + expect(req?.approvalToken).toBe(body.approvalToken); + expect(req?.approvalToken).not.toBe(token); + }); + + it('autoAccept=0 + mismatched token → behaves the same as missing token (queues)', async () => { + await setupAutoAccept(0); + testDb.insert(schema.federationPeers).values({ + id: 'peer-pending', + origin: 'https://remote.example', + hmacSecret: 'old-secret', + status: 'awaiting_approval', + approvalToken: 'd'.repeat(64), + createdAt: Date.now(), + }).run(); + + const response = await app.inject({ + method: 'POST', + url: '/api/federation/peer/accept', + payload: { + sourceOrigin: 'https://remote.example', + hmacSecret: 'bypass-secret', + approvalToken: 'e'.repeat(64), + }, + }); + + expect(response.statusCode).toBe(202); + const peer = testDb.select().from(schema.federationPeers) + .where(eq(schema.federationPeers.origin, 'https://remote.example')).get(); + expect(peer?.status).toBe('awaiting_approval'); + }); + + it('autoAccept=0 + null stored token (legacy) + no inbound token → queues (does not promote)', async () => { + await setupAutoAccept(0); + testDb.insert(schema.federationPeers).values({ + id: 'peer-legacy', + origin: 'https://remote.example', + hmacSecret: 'old-secret', + status: 'awaiting_approval', + approvalToken: null, + createdAt: Date.now(), + }).run(); + + const response = await app.inject({ + method: 'POST', + url: '/api/federation/peer/accept', + payload: { + sourceOrigin: 'https://remote.example', + hmacSecret: 'fresh-secret', + }, + }); + + expect(response.statusCode).toBe(202); + const peer = testDb.select().from(schema.federationPeers) + .where(eq(schema.federationPeers.origin, 'https://remote.example')).get(); + expect(peer?.status).toBe('awaiting_approval'); + }); + + it('autoAccept=1 + missing/mismatched token → fallback promotes (no security regression)', async () => { + await setupAutoAccept(1); + testDb.insert(schema.federationPeers).values({ + id: 'peer-pending', + origin: 'https://remote.example', + hmacSecret: 'old-secret', + status: 'awaiting_approval', + approvalToken: 'f'.repeat(64), + createdAt: Date.now(), + }).run(); + + const response = await app.inject({ + method: 'POST', + url: '/api/federation/peer/accept', + payload: { + sourceOrigin: 'https://remote.example', + hmacSecret: 'fresh-secret', + // no approvalToken + }, + }); + + expect(response.statusCode).toBe(200); + const peer = testDb.select().from(schema.federationPeers) + .where(eq(schema.federationPeers.origin, 'https://remote.example')).get(); + expect(peer?.status).toBe('active'); + expect(peer?.hmacSecret).toBe('fresh-secret'); + expect(peer?.approvalToken).toBeNull(); + }); +}); diff --git a/packages/server/src/routes/federation.ts b/packages/server/src/routes/federation.ts index 6a30e081..c23697e6 100644 --- a/packages/server/src/routes/federation.ts +++ b/packages/server/src/routes/federation.ts @@ -1,4 +1,4 @@ -import type { FastifyInstance } from 'fastify'; +import type { FastifyInstance, FastifyReply } from 'fastify'; import { randomBytes } from 'node:crypto'; import fs from 'node:fs'; import path from 'node:path'; @@ -246,6 +246,77 @@ setInterval(() => { } }, ACCEPT_RATE_WINDOW_MS).unref(); +/** + * Queue an inbound peer/accept request for local-admin approval. + * + * Called from `/peer/accept` when: + * (a) `autoAcceptPeering=0` and no `pending`/`awaiting_approval` peer row + * exists for the source origin (first-contact request from remote), OR + * (b) the receiver is in `awaiting_approval` for this origin but the + * inbound `/peer/accept` cannot be cryptographically verified + * (token absent or mismatched) — see spec §3.5. + * + * Generates a fresh single-use approval token, upserts the + * `peer_approval_requests` row, notifies admins, and returns 202 with the + * token in the body. The initiator stores the token alongside its + * `awaiting_approval` row so a future `/peer/accept` from this side's + * `/approve` endpoint can verify mutual admin approval. + */ +function queueApprovalRequest( + db: ReturnType, + reply: FastifyReply, + sourceOrigin: string, + hmacSecret: string, + reqInstanceName: string | null, +): FastifyReply { + const now = Date.now(); + const THIRTY_DAYS_MS = 30 * 24 * 60 * 60 * 1000; + const approvalToken = randomBytes(32).toString('hex'); + + const existingRequest = db + .select({ id: schema.peerApprovalRequests.id }) + .from(schema.peerApprovalRequests) + .where(eq(schema.peerApprovalRequests.origin, sourceOrigin)) + .get(); + + if (existingRequest) { + db.update(schema.peerApprovalRequests) + .set({ + instanceName: reqInstanceName, + hmacSecret, + requestedAt: now, + expiresAt: now + THIRTY_DAYS_MS, + approvalToken, + }) + .where(eq(schema.peerApprovalRequests.id, existingRequest.id)) + .run(); + } else { + db.insert(schema.peerApprovalRequests) + .values({ + id: generateSnowflake(), + origin: sourceOrigin, + instanceName: reqInstanceName, + hmacSecret, + requestedAt: now, + expiresAt: now + THIRTY_DAYS_MS, + approvalToken, + }) + .run(); + } + + connectionManager.sendToAdmins({ + type: 'federation_approval_request_received' as const, + origin: sourceOrigin, + instanceName: reqInstanceName ?? undefined, + }); + + return reply.code(202).send({ + queued: true, + message: 'Request queued for admin approval', + approvalToken, + }); +} + export async function federationRoutes(app: FastifyInstance): Promise { // ─── POST /api/federation/peer/initiate ──────────────────────────────────── // Admin-only: start a peering handshake with a remote instance. @@ -432,7 +503,7 @@ export async function federationRoutes(app: FastifyInstance): Promise { // ─── POST /api/federation/peer/accept ────────────────────────────────────── // Server-to-server: accept a peering request from a remote instance. // No JWT auth — this is first contact. Rate-limited by IP. - app.post<{ Body: { sourceOrigin: string; challenge?: string; hmacSecret: string; instanceName?: string } }>( + app.post<{ Body: { sourceOrigin: string; challenge?: string; hmacSecret: string; instanceName?: string; approvalToken?: string } }>( '/api/federation/peer/accept', async (request, reply) => { const clientIp = request.ip; @@ -443,7 +514,7 @@ export async function federationRoutes(app: FastifyInstance): Promise { }); } - const { sourceOrigin: rawOrigin, hmacSecret, instanceName: reqInstanceName } = request.body ?? {}; + const { sourceOrigin: rawOrigin, hmacSecret, instanceName: reqInstanceName, approvalToken: inboundToken } = request.body ?? {}; if (!rawOrigin || typeof rawOrigin !== 'string') { return reply.code(400).send({ error: 'sourceOrigin is required', statusCode: 400 }); @@ -514,50 +585,7 @@ export async function federationRoutes(app: FastifyInstance): Promise { }); } - // Queue for admin approval — upsert into peer_approval_requests - const now = Date.now(); - const THIRTY_DAYS_MS = 30 * 24 * 60 * 60 * 1000; - - const existingRequest = db - .select({ id: schema.peerApprovalRequests.id }) - .from(schema.peerApprovalRequests) - .where(eq(schema.peerApprovalRequests.origin, sourceOrigin)) - .get(); - - if (existingRequest) { - db.update(schema.peerApprovalRequests) - .set({ - instanceName: reqInstanceName ?? null, - hmacSecret, - requestedAt: now, - expiresAt: now + THIRTY_DAYS_MS, - }) - .where(eq(schema.peerApprovalRequests.id, existingRequest.id)) - .run(); - } else { - db.insert(schema.peerApprovalRequests) - .values({ - id: generateSnowflake(), - origin: sourceOrigin, - instanceName: reqInstanceName ?? null, - hmacSecret, - requestedAt: now, - expiresAt: now + THIRTY_DAYS_MS, - }) - .run(); - } - - // Notify admin users that a new approval request arrived - connectionManager.sendToAdmins({ - type: 'federation_approval_request_received' as const, - origin: sourceOrigin, - instanceName: reqInstanceName ?? undefined, - }); - - return reply.code(202).send({ - queued: true, - message: 'Request queued for admin approval', - }); + return queueApprovalRequest(db, reply, sourceOrigin, hmacSecret, reqInstanceName ?? null); } } @@ -617,31 +645,82 @@ export async function federationRoutes(app: FastifyInstance): Promise { return reply.code(200).send({ accepted: true, instanceName: ourInstanceName }); } if (existing.status === 'awaiting_approval') { - // Remote admin approved — this is a fresh handshake from them. - db.update(schema.federationPeers) - .set({ - hmacSecret, - instanceName: reqInstanceName ?? null, - status: 'active', - lastSeenAt: Date.now(), - }) - .where(eq(schema.federationPeers.id, existing.id)) - .run(); + // Spec §3.5: token verification gates the awaiting_approval → active + // promotion. Without proof the inbound came from the remote's + // /approve endpoint, an adversarial timing-knowledge attack or a + // bug-prone background code path could falsely flip this row to + // active. The token is single-use entropy issued in the 202 we + // returned when the remote's outbound /peer/accept first hit our + // queue — only their /approve endpoint forwards it. + const tokenValid = + typeof existing.approvalToken === 'string' && + existing.approvalToken.length > 0 && + existing.approvalToken === inboundToken; - // Broadcast activation - for (const uid of connectionManager.getAllOnlineUserIds()) { - connectionManager.sendToUser(uid, { - type: 'federation_peer_active' as const, - peerOrigin: sourceOrigin, - }); + if (tokenValid) { + db.update(schema.federationPeers) + .set({ + hmacSecret, + instanceName: reqInstanceName ?? null, + status: 'active', + lastSeenAt: Date.now(), + approvalToken: null, + }) + .where(eq(schema.federationPeers.id, existing.id)) + .run(); + + // Clean up any stale approval-request row for this origin (e.g., + // queued debris from a prior bypass attempt that did not promote). + db.delete(schema.peerApprovalRequests) + .where(eq(schema.peerApprovalRequests.origin, sourceOrigin)) + .run(); + + for (const uid of connectionManager.getAllOnlineUserIds()) { + connectionManager.sendToUser(uid, { + type: 'federation_peer_active' as const, + peerOrigin: sourceOrigin, + }); + } + connectionManager.sendToAdmins({ type: 'federation_peers_changed' as const }); + onPeerActivated(existing.id, 'accept_awaiting_approval').catch(err => + console.error('[federation] onPeerActivated from /peer/accept (awaiting_approval) failed:', err) + ); + return reply.code(200).send({ accepted: true, instanceName: ourInstanceName }); } - connectionManager.sendToAdmins({ type: 'federation_peers_changed' as const }); - onPeerActivated(existing.id, 'accept_awaiting_approval').catch(err => - console.error('[federation] onPeerActivated from /peer/accept (awaiting_approval) failed:', err) - ); + // Token absent or mismatched. Cannot prove mutual approval. + if (autoAccept === 1) { + // We accept any inbound anyway — promoting here is no weaker than + // accepting a fresh handshake from a new peer. Clear the stored + // token (moot now) and proceed. + db.update(schema.federationPeers) + .set({ + hmacSecret, + instanceName: reqInstanceName ?? null, + status: 'active', + lastSeenAt: Date.now(), + approvalToken: null, + }) + .where(eq(schema.federationPeers.id, existing.id)) + .run(); - return reply.code(200).send({ accepted: true, instanceName: ourInstanceName }); + for (const uid of connectionManager.getAllOnlineUserIds()) { + connectionManager.sendToUser(uid, { + type: 'federation_peer_active' as const, + peerOrigin: sourceOrigin, + }); + } + connectionManager.sendToAdmins({ type: 'federation_peers_changed' as const }); + onPeerActivated(existing.id, 'accept_awaiting_approval_fallback').catch(err => + console.error('[federation] onPeerActivated from /peer/accept (awaiting_approval fallback) failed:', err) + ); + return reply.code(200).send({ accepted: true, instanceName: ourInstanceName }); + } + + // autoAccept=0 + unverifiable inbound → queue as new approval-request. + // Existing awaiting_approval row stays untouched; the new approval- + // request lets the local admin decide whether to honor this inbound. + return queueApprovalRequest(db, reply, sourceOrigin, hmacSecret, reqInstanceName ?? null); } // Pending — update with new secret and activate db.update(schema.federationPeers) diff --git a/packages/server/src/utils/federationPeerActivation.ts b/packages/server/src/utils/federationPeerActivation.ts index a290bc0d..c68f8d80 100644 --- a/packages/server/src/utils/federationPeerActivation.ts +++ b/packages/server/src/utils/federationPeerActivation.ts @@ -9,6 +9,7 @@ export type PeerActivationReason = | 'initiate_accepted' | 'accept_rejected_override' | 'accept_awaiting_approval' + | 'accept_awaiting_approval_fallback' | 'accept_pending' | 'accept_new' | 'approval_handshake' From fa7a5f831dba56916ae5e55c4c24bdb0f1110e08 Mon Sep 17 00:00:00 2001 From: Jannis Braun <151788261+TheZwiss@users.noreply.github.com> Date: Sun, 26 Apr 2026 11:52:04 +0200 Subject: [PATCH 4/5] feat(federation): /approve forwards token + /approve & /peer/initiate capture on 202 + 200 clear MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three changes to keep the outbound /peer/accept call sites consistent with the new approval-token mechanism: 1. /approve outbound body now forwards approvalToken from the queued peer_approval_requests row when present. Receiver's awaiting_approval branch verifies it and promotes mutual approval. Legacy null-token rows omit the field; receiver falls through autoAccept gate. Spec §3.7. 2. /approve and /peer/initiate 202 paths now capture the approvalToken returned by the remote and store it on the local federation_peers row. Without this, the symmetric autoAccept=0 mutual-approval flow could not verify on the eventual return /peer/accept. Spec §3.7. 3. /approve and /peer/initiate 200 paths now include approvalToken=null in the activation UPDATE — single-use lifecycle hygiene per §3.2. Test coverage: +6 tests (4 in approveOutbound, 2 in peerInitiateOutbound). Total: 301 → 307. Web tsc clean. --- .../routes/federation.approveOutbound.test.ts | 239 ++++++++++++++++++ .../federation.peerInitiateOutbound.test.ts | 156 ++++++++++++ packages/server/src/routes/federation.ts | 40 ++- 3 files changed, 427 insertions(+), 8 deletions(-) create mode 100644 packages/server/src/routes/federation.approveOutbound.test.ts create mode 100644 packages/server/src/routes/federation.peerInitiateOutbound.test.ts diff --git a/packages/server/src/routes/federation.approveOutbound.test.ts b/packages/server/src/routes/federation.approveOutbound.test.ts new file mode 100644 index 00000000..5683fb57 --- /dev/null +++ b/packages/server/src/routes/federation.approveOutbound.test.ts @@ -0,0 +1,239 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import Fastify, { type FastifyInstance } from 'fastify'; +import Database from 'better-sqlite3'; +import { drizzle } from 'drizzle-orm/better-sqlite3'; +import { eq } from 'drizzle-orm'; +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import * as schema from '../db/schema.js'; +import { setWorkerId } from '../utils/snowflake.js'; + +setWorkerId(1); + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); + +type TestDb = ReturnType>; +let sqlite: Database.Database; +let testDb: TestDb; + +vi.mock('../db/index.js', () => ({ + getDb: () => testDb, + getRawDb: () => sqlite, + schema, +})); + +vi.mock('../config.js', () => ({ + config: { + domain: 'local.example', + port: 3000, + host: '0.0.0.0', + jwtSecret: 'test-secret-12345678901234567890123456789012', + maxUploadSize: 100 * 1024 * 1024, + registrationOpen: true, + }, +})); + +vi.mock('../utils/auth.js', () => ({ + authenticate: async (req: { userId?: string }) => { + req.userId = 'admin-user'; + }, + requireAdmin: async () => {}, +})); + +vi.mock('../utils/federationAuth.js', async () => { + const actual = await vi.importActual('../utils/federationAuth.js'); + return { + ...actual, + getOurOrigin: () => 'https://local.example', + generateHmacSecret: () => 'mock-generated-secret', + }; +}); + +vi.mock('../ws/handler.js', () => ({ + connectionManager: { + sendToAdmins: vi.fn(), + getAllOnlineUserIds: () => [], + sendToUser: vi.fn(), + sendToDmMembers: vi.fn(), + }, +})); + +vi.mock('../utils/federationPeerActivation.js', () => ({ + onPeerActivated: vi.fn(async () => undefined), + onPeerDeactivated: vi.fn(async () => undefined), +})); + +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 sqlText = fs.readFileSync(path.join(migrationsDir, f), 'utf8'); + const statements = sqlText.split(/-->\s*statement-breakpoint/); + for (const stmt of statements) { + const clean = stmt.trim(); + if (clean) db.exec(clean); + } + } +} + +function seedInstanceSettings(): void { + testDb.insert(schema.instanceSettings).values({ + id: 1, + instanceName: 'Local Backspace', + autoAcceptPeering: 0, + registrationOpen: 1, + updatedAt: Date.now(), + }).run(); +} + +async function buildApp(): Promise { + const app = Fastify({ logger: false }); + const { federationRoutes } = await import('./federation.js'); + await app.register(federationRoutes); + await app.ready(); + return app; +} + +describe('POST /api/federation/approval-requests/:id/approve — outbound token forwarding & 202 capture', () => { + let app: FastifyInstance; + + beforeEach(async () => { + sqlite = new Database(':memory:'); + testDb = drizzle(sqlite, { schema }); + applyMigrations(sqlite); + seedInstanceSettings(); + app = await buildApp(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + sqlite.close(); + }); + + it('forwards approvalToken from peer_approval_requests in the outbound body', async () => { + const now = Date.now(); + const token = 'a'.repeat(64); + testDb.insert(schema.peerApprovalRequests).values({ + id: 'req-1', + origin: 'https://remote.example', + instanceName: 'Remote', + hmacSecret: 'remote-secret', + requestedAt: now, + expiresAt: now + 30 * 24 * 60 * 60 * 1000, + approvalToken: token, + }).run(); + + const fetchSpy = vi.spyOn(globalThis, 'fetch').mockResolvedValue( + new Response( + JSON.stringify({ accepted: true, instanceName: 'Remote' }), + { status: 200, headers: { 'Content-Type': 'application/json' } }, + ), + ); + + const response = await app.inject({ + method: 'POST', + url: '/api/federation/approval-requests/req-1/approve', + }); + + expect(response.statusCode).toBe(200); + expect(fetchSpy).toHaveBeenCalledTimes(1); + const init = fetchSpy.mock.calls[0]?.[1]; + const body = JSON.parse(init?.body as string) as { approvalToken?: string }; + expect(body.approvalToken).toBe(token); + }); + + it('omits approvalToken from outbound body when approval-request has null token (legacy)', async () => { + const now = Date.now(); + testDb.insert(schema.peerApprovalRequests).values({ + id: 'req-2', + origin: 'https://remote.example', + instanceName: 'Remote', + hmacSecret: 'remote-secret', + requestedAt: now, + expiresAt: now + 30 * 24 * 60 * 60 * 1000, + approvalToken: null, + }).run(); + + const fetchSpy = vi.spyOn(globalThis, 'fetch').mockResolvedValue( + new Response( + JSON.stringify({ accepted: true, instanceName: 'Remote' }), + { status: 200, headers: { 'Content-Type': 'application/json' } }, + ), + ); + + const response = await app.inject({ + method: 'POST', + url: '/api/federation/approval-requests/req-2/approve', + }); + + expect(response.statusCode).toBe(200); + const init = fetchSpy.mock.calls[0]?.[1]; + const body = JSON.parse(init?.body as string) as { approvalToken?: string }; + expect(body.approvalToken).toBeUndefined(); + }); + + it('on 200 success, clears approvalToken on the new federation_peers row', async () => { + const now = Date.now(); + testDb.insert(schema.peerApprovalRequests).values({ + id: 'req-200', + origin: 'https://remote.example', + instanceName: 'Remote', + hmacSecret: 'remote-secret', + requestedAt: now, + expiresAt: now + 30 * 24 * 60 * 60 * 1000, + approvalToken: 'a'.repeat(64), + }).run(); + + vi.spyOn(globalThis, 'fetch').mockResolvedValue( + new Response( + JSON.stringify({ accepted: true, instanceName: 'Remote' }), + { status: 200, headers: { 'Content-Type': 'application/json' } }, + ), + ); + + const response = await app.inject({ + method: 'POST', + url: '/api/federation/approval-requests/req-200/approve', + }); + + expect(response.statusCode).toBe(200); + const peer = testDb.select().from(schema.federationPeers) + .where(eq(schema.federationPeers.origin, 'https://remote.example')).get(); + expect(peer?.status).toBe('active'); + expect(peer?.approvalToken).toBeNull(); + }); + + it('on 202 from remote, transitions local peer to awaiting_approval and stores returned approvalToken', async () => { + const now = Date.now(); + const inboundToken = 'a'.repeat(64); + testDb.insert(schema.peerApprovalRequests).values({ + id: 'req-3', + origin: 'https://remote.example', + instanceName: 'Remote', + hmacSecret: 'remote-secret', + requestedAt: now, + expiresAt: now + 30 * 24 * 60 * 60 * 1000, + approvalToken: inboundToken, + }).run(); + + const remoteToken = 'b'.repeat(64); + vi.spyOn(globalThis, 'fetch').mockResolvedValue( + new Response( + JSON.stringify({ queued: true, message: 'queued', approvalToken: remoteToken }), + { status: 202, headers: { 'Content-Type': 'application/json' } }, + ), + ); + + const response = await app.inject({ + method: 'POST', + url: '/api/federation/approval-requests/req-3/approve', + }); + + expect(response.statusCode).toBe(200); + const peer = testDb.select().from(schema.federationPeers) + .where(eq(schema.federationPeers.origin, 'https://remote.example')).get(); + expect(peer?.status).toBe('awaiting_approval'); + expect(peer?.approvalToken).toBe(remoteToken); + }); +}); diff --git a/packages/server/src/routes/federation.peerInitiateOutbound.test.ts b/packages/server/src/routes/federation.peerInitiateOutbound.test.ts new file mode 100644 index 00000000..daf5e9a7 --- /dev/null +++ b/packages/server/src/routes/federation.peerInitiateOutbound.test.ts @@ -0,0 +1,156 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import Fastify, { type FastifyInstance } from 'fastify'; +import Database from 'better-sqlite3'; +import { drizzle } from 'drizzle-orm/better-sqlite3'; +import { eq } from 'drizzle-orm'; +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import * as schema from '../db/schema.js'; +import { setWorkerId } from '../utils/snowflake.js'; + +setWorkerId(1); + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); + +type TestDb = ReturnType>; +let sqlite: Database.Database; +let testDb: TestDb; + +vi.mock('../db/index.js', () => ({ + getDb: () => testDb, + getRawDb: () => sqlite, + schema, +})); + +vi.mock('../config.js', () => ({ + config: { + domain: 'local.example', + port: 3000, + host: '0.0.0.0', + jwtSecret: 'test-secret-12345678901234567890123456789012', + maxUploadSize: 100 * 1024 * 1024, + registrationOpen: true, + }, +})); + +vi.mock('../utils/auth.js', () => ({ + authenticate: async (req: { userId?: string }) => { + req.userId = 'admin-user'; + }, + requireAdmin: async () => {}, +})); + +vi.mock('../utils/federationAuth.js', async () => { + const actual = await vi.importActual('../utils/federationAuth.js'); + return { + ...actual, + getOurOrigin: () => 'https://local.example', + generateHmacSecret: () => 'mock-generated-secret', + }; +}); + +vi.mock('../ws/handler.js', () => ({ + connectionManager: { + sendToAdmins: vi.fn(), + getAllOnlineUserIds: () => [], + sendToUser: vi.fn(), + sendToDmMembers: vi.fn(), + }, +})); + +vi.mock('../utils/federationPeerActivation.js', () => ({ + onPeerActivated: vi.fn(async () => undefined), + onPeerDeactivated: vi.fn(async () => undefined), +})); + +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 sqlText = fs.readFileSync(path.join(migrationsDir, f), 'utf8'); + const statements = sqlText.split(/-->\s*statement-breakpoint/); + for (const stmt of statements) { + const clean = stmt.trim(); + if (clean) db.exec(clean); + } + } +} + +function seedInstanceSettings(): void { + testDb.insert(schema.instanceSettings).values({ + id: 1, + instanceName: 'Local Backspace', + autoAcceptPeering: 0, + registrationOpen: 1, + updatedAt: Date.now(), + }).run(); +} + +async function buildApp(): Promise { + const app = Fastify({ logger: false }); + const { federationRoutes } = await import('./federation.js'); + await app.register(federationRoutes); + await app.ready(); + return app; +} + +describe('POST /api/federation/peer/initiate — 202 token capture & 200 clear', () => { + let app: FastifyInstance; + + beforeEach(async () => { + sqlite = new Database(':memory:'); + testDb = drizzle(sqlite, { schema }); + applyMigrations(sqlite); + seedInstanceSettings(); + app = await buildApp(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + sqlite.close(); + }); + + it('on 202 from remote, transitions local peer to awaiting_approval and stores returned approvalToken', async () => { + const remoteToken = 'c'.repeat(64); + vi.spyOn(globalThis, 'fetch').mockResolvedValue( + new Response( + JSON.stringify({ queued: true, message: 'queued', approvalToken: remoteToken }), + { status: 202, headers: { 'Content-Type': 'application/json' } }, + ), + ); + + const response = await app.inject({ + method: 'POST', + url: '/api/federation/peer/initiate', + payload: { remoteOrigin: 'https://remote.example' }, + }); + + expect(response.statusCode).toBe(202); + const peer = testDb.select().from(schema.federationPeers) + .where(eq(schema.federationPeers.origin, 'https://remote.example')).get(); + expect(peer?.status).toBe('awaiting_approval'); + expect(peer?.approvalToken).toBe(remoteToken); + }); + + it('on 200 from remote, activates and clears approvalToken', async () => { + vi.spyOn(globalThis, 'fetch').mockResolvedValue( + new Response( + JSON.stringify({ accepted: true, instanceName: 'Remote' }), + { status: 200, headers: { 'Content-Type': 'application/json' } }, + ), + ); + + const response = await app.inject({ + method: 'POST', + url: '/api/federation/peer/initiate', + payload: { remoteOrigin: 'https://remote.example' }, + }); + + expect(response.statusCode).toBe(200); + const peer = testDb.select().from(schema.federationPeers) + .where(eq(schema.federationPeers.origin, 'https://remote.example')).get(); + expect(peer?.status).toBe('active'); + expect(peer?.approvalToken).toBeNull(); + }); +}); diff --git a/packages/server/src/routes/federation.ts b/packages/server/src/routes/federation.ts index c23697e6..34e18bdf 100644 --- a/packages/server/src/routes/federation.ts +++ b/packages/server/src/routes/federation.ts @@ -409,12 +409,20 @@ export async function federationRoutes(app: FastifyInstance): Promise { // (autoAcceptPeering is off on their side). Do NOT activate the // local peer — mirror the auto-peer flow in federationPeering.ts // by transitioning the pending record to awaiting_approval. - // Without this branch the local peer would flip to `active` - // (because response.ok is true for 202) while the remote had us - // pending, producing a local-active / remote-pending split that - // only self-heals when the remote admin approves. + // Capture the approval token they returned so the next inbound + // /peer/accept (when their admin approves) can be verified. §3.7. + let returnedToken: string | null = null; + try { + const body = (await response.json()) as { approvalToken?: string }; + if (typeof body?.approvalToken === 'string' && body.approvalToken.length > 0) { + returnedToken = body.approvalToken; + } + } catch { + // Non-JSON / empty body — legacy peer. + } + db.update(schema.federationPeers) - .set({ status: 'awaiting_approval' }) + .set({ status: 'awaiting_approval', approvalToken: returnedToken }) .where(eq(schema.federationPeers.id, peerId)) .run(); connectionManager.sendToAdmins({ type: 'federation_peers_changed' as const }); @@ -462,7 +470,7 @@ export async function federationRoutes(app: FastifyInstance): Promise { } db.update(schema.federationPeers) - .set({ status: 'active', lastSeenAt: Date.now(), instanceName: remoteInstanceName }) + .set({ status: 'active', lastSeenAt: Date.now(), instanceName: remoteInstanceName, approvalToken: null }) .where(eq(schema.federationPeers.id, peerId)) .run(); connectionManager.sendToAdmins({ type: 'federation_peers_changed' as const }); @@ -1239,6 +1247,10 @@ export async function federationRoutes(app: FastifyInstance): Promise { sourceOrigin: localOrigin, hmacSecret, instanceName, + // Forward the stored token (issued in our 202 response when the + // remote first sent /peer/accept). Lets the remote verify mutual + // admin approval. Spec §3.7. + ...(approvalReq.approvalToken ? { approvalToken: approvalReq.approvalToken } : {}), }), signal: AbortSignal.timeout(10_000), }); @@ -1246,8 +1258,20 @@ export async function federationRoutes(app: FastifyInstance): Promise { if (response.status === 202) { // Remote instance also has autoAcceptPeering off — they queued our request. // Don't activate our peer. Set to awaiting_approval until their admin also approves. + // Capture the approval token they returned so the next inbound + // /peer/accept (when their admin approves) can be verified. §3.7. + let returnedToken: string | null = null; + try { + const body = (await response.json()) as { approvalToken?: string }; + if (typeof body?.approvalToken === 'string' && body.approvalToken.length > 0) { + returnedToken = body.approvalToken; + } + } catch { + // Non-JSON / empty body — legacy peer. + } + db.update(schema.federationPeers) - .set({ status: 'awaiting_approval' }) + .set({ status: 'awaiting_approval', approvalToken: returnedToken }) .where(eq(schema.federationPeers.id, peerId)) .run(); // Delete the approval request since we already acted on it @@ -1288,7 +1312,7 @@ export async function federationRoutes(app: FastifyInstance): Promise { } db.update(schema.federationPeers) - .set({ status: 'active', lastSeenAt: now, instanceName: remoteInstanceName }) + .set({ status: 'active', lastSeenAt: now, instanceName: remoteInstanceName, approvalToken: null }) .where(eq(schema.federationPeers.id, peerId)) .run(); From 94294c64b990c66427135ef1e5ac2aa335d3c8b9 Mon Sep 17 00:00:00 2001 From: Jannis Braun <151788261+TheZwiss@users.noreply.github.com> Date: Sun, 26 Apr 2026 11:53:54 +0200 Subject: [PATCH 5/5] docs(systems): document approval token mechanism federation.md: new 'Approval Token Verification' subsection covering issuance (queue path generates token, returns in 202), storage on initiator (federation_peers.approval_token), forwarding from /approve, verification on receiver's awaiting_approval branch, single-use lifecycle, backward compatibility, and threat-model boundary (sender-side outbound gating tracked separately). database.md: approval_token column documented on both federation_peers and peer_approval_requests with cross-references to federation.md. api.md: /peer/accept request + 202 response now show optional approvalToken field with pointer to the federation spec. --- docs/systems/api.md | 4 +++- docs/systems/database.md | 2 ++ docs/systems/federation.md | 23 ++++++++++++++++++++++- 3 files changed, 27 insertions(+), 2 deletions(-) diff --git a/docs/systems/api.md b/docs/systems/api.md index b18a5df6..e3099526 100644 --- a/docs/systems/api.md +++ b/docs/systems/api.md @@ -217,7 +217,7 @@ DELETE /admin/users/:id → { success } ## Federation (`routes/federation.ts`) ``` POST /federation/peer/initiate (admin) { remoteOrigin } → peer created -POST /federation/peer/accept (public, IP rate-limited 10/min) { sourceOrigin, challenge, hmacSecret } → accepted +POST /federation/peer/accept (public, IP rate-limited 10/min) { sourceOrigin, challenge, hmacSecret, instanceName?, approvalToken? } → accepted (200) | queued (202 + { approvalToken }) GET /federation/peers (admin) → { peers[] } (no secrets) DELETE /federation/peers/:id (admin) → { success } + outbox cleanup POST /federation/relay (HMAC-signed S2S) FederationRelayRequest → { accepted[], rejected[] } @@ -225,6 +225,8 @@ POST /federation/sync (HMAC-signed S2S) { sinceTimestamp, limit?, POST /federation/users/lookup (HMAC-signed S2S, rate-limited 60/min/peer) { username } → { found, user? } ``` +**`POST /api/federation/peer/accept`** — public, IP-rate-limited. Optional `approvalToken` (64-hex) on the request body proves mutual admin approval; required to promote an `awaiting_approval` row to `active` when the receiver has `autoAcceptPeering=0`. The receiver returns it in the 202 body when queueing the request for admin review (`{ queued: true, message, approvalToken }`); the initiator stores it and the receiver's `/approve` later forwards it back. See `federation.md` §1 "Approval Token Verification" for the full lifecycle and threat model. + **`POST /api/federation/users/lookup`** — HMAC-authenticated S2S endpoint. Resolves a username on this instance to its canonical `(homeUserId, profile snapshot)`. Used by the cross-instance friend-add flow on the sender's home server before queuing a `friend_request_create` event. Responds to native, non-deleted users only; ignores `discoverable`. Returns `{ found: false, code: 'user_not_found' }` for stubs, tombstoned users, or unknown handles. See `federation.md` §1 "S2S User Lookup" for the full contract. ## Utilities (`routes/utils.ts`) — auth required diff --git a/docs/systems/database.md b/docs/systems/database.md index 29e880de..e14bd9a5 100644 --- a/docs/systems/database.md +++ b/docs/systems/database.md @@ -363,6 +363,7 @@ PK: (spaceId, userId, restrictionType) | lastSyncedAt | integer | 0 | | | remoteMaxUploadSize | integer | | Bytes, from peer | | createdAt | integer NOT NULL | | | +| approvalToken | text | | Single-use 64-hex-char token stored when this row is in `awaiting_approval` (received from remote's 202 response). Verified against the inbound `/peer/accept` `approvalToken` field before promoting to `active`. Cleared (`NULL`) on promotion. See [federation.md → Approval Token Verification](federation.md#approval-token-verification). | ### peer_approval_requests Holds incoming peering requests queued for admin review when `autoAcceptPeering` is `false`. One row per requesting origin (UNIQUE constraint). Rows expire after 30 days via janitor cleanup. @@ -375,6 +376,7 @@ Holds incoming peering requests queued for admin review when `autoAcceptPeering` | hmacSecret | text NOT NULL | | Requester's HMAC secret; used to sign denial notification | | requestedAt | integer NOT NULL | | Epoch ms | | expiresAt | integer NOT NULL | | Epoch ms; requestedAt + 30 days | +| approvalToken | text | | Single-use 64-hex-char token issued in the 202 response when this row is created. Forwarded by `/approve` in its outbound `/peer/accept` so the remote initiator can verify mutual admin approval. Deleted along with this row when `/approve` runs. See [federation.md → Approval Token Verification](federation.md#approval-token-verification). | ### federation_outbox UNIQUE: (peerId, entityId) diff --git a/docs/systems/federation.md b/docs/systems/federation.md index f8deb935..ca4d2310 100644 --- a/docs/systems/federation.md +++ b/docs/systems/federation.md @@ -143,6 +143,7 @@ When `autoAcceptPeering` is `false` and an instance calls `POST /api/federation/ - `instance_name` — Instance name sent by requester - `hmac_secret` — Requester's HMAC secret; used to sign the denial notification - `requested_at` / `expires_at` — Epoch ms; expiry is `requested_at + 30 days` +- `approval_token` — Single-use 64-hex-char random token issued in the 202 response and forwarded by `/approve`. See [Approval Token Verification](#approval-token-verification). **Approval flow** — Admin approves via `POST /api/federation/approval-requests/:id/approve`: 1. A fresh `federationPeer` record is created (or existing `rejected`/`awaiting_approval` record is upserted) with status `pending` @@ -157,7 +158,27 @@ When `autoAcceptPeering` is `false` and an instance calls `POST /api/federation/ **Expiry** — The janitor (`federationJanitor.ts`) runs on its scheduled interval and deletes rows where `expires_at < now`. Expired requests do NOT create a `rejected` peer — the requesting instance can re-submit. Admin denial, by contrast, does create a `rejected` peer record, blocking re-requests until an admin clears it. -**Pre-handshake guard (`ensurePeered`)** — Before any outbound handshake, `ensurePeered(origin)` in `federationPeering.ts` refuses with `{ status: 'rejected', error: 'Local admin must resolve…' }` if a `peer_approval_requests` row exists for that origin. This blocks the auto-reconnect bypass: without the guard, any code path calling `ensurePeered` (e.g., the silent reconnect in `stores/instanceStore.ts`) could initiate a fresh outbound handshake to a peer that has a pending inbound approval request, and the receiver's `/peer/accept` `awaiting_approval` branch would activate the relationship without admin involvement. The legitimate approve flow (`POST /api/federation/approval-requests/:id/approve`) does NOT call `ensurePeered` — it deletes the approval-request row and does its own direct `fetch` to `/peer/accept` — so the guard does not block legitimate approvals. Note: the guard closes the trigger only; the receiver-side trust assumption in the `/peer/accept` `awaiting_approval` branch is still permissive and is tracked separately at `docs/superpowers/problems/2026-04-26-peer-handshake-trust-model.md`. +**Pre-handshake guard (`ensurePeered`)** — Before any outbound handshake, `ensurePeered(origin)` in `federationPeering.ts` refuses with `{ status: 'rejected', error: 'Local admin must resolve…' }` if a `peer_approval_requests` row exists for that origin. This blocks the auto-reconnect trigger: without the guard, any code path calling `ensurePeered` (e.g., the silent reconnect in `stores/instanceStore.ts`) could initiate a fresh outbound handshake to a peer that has a pending inbound approval request. The legitimate approve flow (`POST /api/federation/approval-requests/:id/approve`) does NOT call `ensurePeered` — it deletes the approval-request row and does its own direct `fetch` to `/peer/accept` — so the guard does not block legitimate approvals. + +### Approval Token Verification + +The pre-handshake guard above closes the most reliable trigger but cannot prevent every adversarial code path on a remote side from sending an inbound `/peer/accept` against a row in `awaiting_approval`. To close that broader class, the protocol adds a cryptographic single-use **approval token** verified on the receiver before promoting `awaiting_approval → active`. Spec: `docs/superpowers/specs/2026-04-26-peer-approval-token.md`. + +**Issuance.** When `/peer/accept` returns 202 (queue-as-approval-request, `autoAcceptPeering=0`), the server generates a 32-byte random hex token via `crypto.randomBytes(32).toString('hex')`, stores it on the new `peer_approval_requests.approval_token` column, and returns it in the 202 body as `{ queued: true, message, approvalToken }`. + +**Storage on the initiator.** When the initiator's outbound `/peer/accept` (from `performHandshake`, `/peer/initiate`, or `/approve`) receives 202, it parses `approvalToken` from the response body and stores it on the local `federation_peers.approval_token` column alongside `status='awaiting_approval'`. + +**Forwarding from `/approve`.** When the local admin approves an inbound queued request, the `/approve` endpoint reads `approvalToken` from the queued `peer_approval_requests` row and includes it in its outbound `/peer/accept` body. No other code path forwards a token — the security property is "only `/approve` reads the stored token from the DB." + +**Verification on the initiator's `/peer/accept` handler.** When an inbound request matches an existing `awaiting_approval` peer row, the handler verifies `existing.approval_token === request.body.approvalToken` (length-checked, plain `===` — see spec §3.1 on why constant-time comparison isn't required). On match, the row is promoted to `active`, the stored token is cleared (`approval_token=NULL`), and any stale `peer_approval_requests` row for the origin is deleted. On mismatch (or missing token), behavior depends on the receiver's `autoAcceptPeering`: +- `autoAcceptPeering=0` → `queueApprovalRequest()` is invoked: a new `peer_approval_requests` row is upserted with a fresh token, returns 202. The existing `awaiting_approval` row is left untouched. **No bypass.** +- `autoAcceptPeering=1` → fallback promote to `active` (no security regression vs. prior behavior — `autoAccept=1` would accept any inbound `/peer/accept` regardless). + +**Single-use lifecycle.** The token is consumed on first successful match: cleared from `federation_peers.approval_token` at the receiver's promote step, cleared from the initiator's `federation_peers.approval_token` when its own outbound returns 200, and deleted along with the approval-request row when `/approve` completes. This bounds replay of a leaked token (DB snapshot, log capture) to the period before the legitimate `/approve` runs. + +**Backward compatibility.** Both schema columns are nullable. Older peers that don't include `approvalToken` in the request body or 202 response result in `null` storage; the receiver's verification then falls through the `autoAcceptPeering` gate. Existing `active` peers and existing `awaiting_approval` rows in production at upgrade time are unaffected — the verification only runs on the receiver's `awaiting_approval` branch. Stalled legacy `awaiting_approval` rows (no stored token) cannot complete via inbound `/peer/accept` from a legacy initiator unless the receiver is `autoAccept=1`; admins should re-initiate them through the standard flow if needed. + +**What this does NOT defend against** — a remote operator running custom code with full DB access can read their stored token and forge `/peer/accept`. That is the inherent trust radius of federation peering. The threat model is bug-prone code paths (auto-reconnect, voice-call peering races, future `ensurePeered` callers) on otherwise-honest peers, not adversarial operators. Sender-side outbound gating for `autoAcceptPeering=0` is a separate concern tracked as a follow-up. ### Admin Endpoints