From b687bfe721b6594b12d84c625ea10e42ead67482 Mon Sep 17 00:00:00 2001 From: Jannis Braun <151788261+TheZwiss@users.noreply.github.com> Date: Sun, 26 Apr 2026 21:02:27 +0200 Subject: [PATCH 01/20] feat(federation): schema for outbound peering gate (direction column, subscribers, notifications) --- .../server/drizzle/0003_brave_inhumans.sql | 50 + .../server/drizzle/meta/0003_snapshot.json | 3445 +++++++++++++++++ packages/server/drizzle/meta/_journal.json | 7 + packages/server/src/db/schema.ts | 34 +- packages/server/src/routes/federation.ts | 9 + packages/server/src/utils/storageJanitor.ts | 7 + 6 files changed, 3549 insertions(+), 3 deletions(-) create mode 100644 packages/server/drizzle/0003_brave_inhumans.sql create mode 100644 packages/server/drizzle/meta/0003_snapshot.json diff --git a/packages/server/drizzle/0003_brave_inhumans.sql b/packages/server/drizzle/0003_brave_inhumans.sql new file mode 100644 index 00000000..f4f436e5 --- /dev/null +++ b/packages/server/drizzle/0003_brave_inhumans.sql @@ -0,0 +1,50 @@ +CREATE TABLE `peer_approval_notifications` ( + `id` text PRIMARY KEY NOT NULL, + `user_id` text NOT NULL, + `kind` text NOT NULL, + `peer_origin` text NOT NULL, + `trigger_reason` text NOT NULL, + `trigger_target` text NOT NULL, + `created_at` integer NOT NULL, + `read_at` integer, + FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE cascade +); +--> statement-breakpoint +CREATE TABLE `peer_approval_subscribers` ( + `id` text PRIMARY KEY NOT NULL, + `request_id` text NOT NULL, + `user_id` text NOT NULL, + `trigger_reason` text NOT NULL, + `trigger_target` text NOT NULL, + `created_at` integer NOT NULL, + FOREIGN KEY (`request_id`) REFERENCES `peer_approval_requests`(`id`) ON UPDATE no action ON DELETE cascade, + FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE cascade +); +--> statement-breakpoint +CREATE INDEX `idx_peer_approval_notifications_user_id` ON `peer_approval_notifications` (`user_id`);--> statement-breakpoint +CREATE INDEX `idx_peer_approval_subscribers_user_id` ON `peer_approval_subscribers` (`user_id`);--> statement-breakpoint +CREATE UNIQUE INDEX `peer_approval_subscribers_request_id_user_id_trigger_reason_trigger_target_unique` ON `peer_approval_subscribers` (`request_id`,`user_id`,`trigger_reason`,`trigger_target`);--> statement-breakpoint +-- Recreate peer_approval_requests: +-- - add direction column (default 'inbound') so existing rows classify correctly +-- - relax hmac_secret to nullable (outbound rows generate fresh on approval) +-- - drop UNIQUE(origin); add UNIQUE(origin, direction) +-- - add CHECK enforcing inbound rows always carry hmac_secret +CREATE TABLE `__new_peer_approval_requests` ( + `id` text PRIMARY KEY NOT NULL, + `origin` text NOT NULL, + `direction` text DEFAULT 'inbound' NOT NULL, + `instance_name` text, + `hmac_secret` text, + `requested_at` integer NOT NULL, + `expires_at` integer NOT NULL, + `approval_token` text, + CHECK ( + (direction = 'inbound' AND hmac_secret IS NOT NULL) + OR (direction = 'outbound') + ) +);--> statement-breakpoint +INSERT INTO `__new_peer_approval_requests` (`id`, `origin`, `direction`, `instance_name`, `hmac_secret`, `requested_at`, `expires_at`, `approval_token`) +SELECT `id`, `origin`, 'inbound', `instance_name`, `hmac_secret`, `requested_at`, `expires_at`, `approval_token` FROM `peer_approval_requests`;--> statement-breakpoint +DROP TABLE `peer_approval_requests`;--> statement-breakpoint +ALTER TABLE `__new_peer_approval_requests` RENAME TO `peer_approval_requests`;--> statement-breakpoint +CREATE UNIQUE INDEX `peer_approval_requests_origin_direction_unique` ON `peer_approval_requests` (`origin`,`direction`); diff --git a/packages/server/drizzle/meta/0003_snapshot.json b/packages/server/drizzle/meta/0003_snapshot.json new file mode 100644 index 00000000..684da74d --- /dev/null +++ b/packages/server/drizzle/meta/0003_snapshot.json @@ -0,0 +1,3445 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "5ae0d7c7-5409-464d-b8d2-8af70cf6601f", + "prevId": "a603a29a-c462-46c0-b559-5fcfb2453b27", + "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_notifications": { + "name": "peer_approval_notifications", + "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 + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "peer_origin": { + "name": "peer_origin", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "trigger_reason": { + "name": "trigger_reason", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "trigger_target": { + "name": "trigger_target", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "read_at": { + "name": "read_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "idx_peer_approval_notifications_user_id": { + "name": "idx_peer_approval_notifications_user_id", + "columns": [ + "user_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "peer_approval_notifications_user_id_users_id_fk": { + "name": "peer_approval_notifications_user_id_users_id_fk", + "tableFrom": "peer_approval_notifications", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "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 + }, + "direction": { + "name": "direction", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'inbound'" + }, + "instance_name": { + "name": "instance_name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "hmac_secret": { + "name": "hmac_secret", + "type": "text", + "primaryKey": false, + "notNull": false, + "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_direction_unique": { + "name": "peer_approval_requests_origin_direction_unique", + "columns": [ + "origin", + "direction" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "peer_approval_subscribers": { + "name": "peer_approval_subscribers", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "request_id": { + "name": "request_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "trigger_reason": { + "name": "trigger_reason", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "trigger_target": { + "name": "trigger_target", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "idx_peer_approval_subscribers_user_id": { + "name": "idx_peer_approval_subscribers_user_id", + "columns": [ + "user_id" + ], + "isUnique": false + }, + "peer_approval_subscribers_request_id_user_id_trigger_reason_trigger_target_unique": { + "name": "peer_approval_subscribers_request_id_user_id_trigger_reason_trigger_target_unique", + "columns": [ + "request_id", + "user_id", + "trigger_reason", + "trigger_target" + ], + "isUnique": true + } + }, + "foreignKeys": { + "peer_approval_subscribers_request_id_peer_approval_requests_id_fk": { + "name": "peer_approval_subscribers_request_id_peer_approval_requests_id_fk", + "tableFrom": "peer_approval_subscribers", + "tableTo": "peer_approval_requests", + "columnsFrom": [ + "request_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "peer_approval_subscribers_user_id_users_id_fk": { + "name": "peer_approval_subscribers_user_id_users_id_fk", + "tableFrom": "peer_approval_subscribers", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "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 72a4d99a..3880987d 100644 --- a/packages/server/drizzle/meta/_journal.json +++ b/packages/server/drizzle/meta/_journal.json @@ -22,6 +22,13 @@ "when": 1777196627239, "tag": "0002_peer_approval_token", "breakpoints": true + }, + { + "idx": 3, + "version": "6", + "when": 1777229997210, + "tag": "0003_brave_inhumans", + "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 17c3a696..5f877c4d 100644 --- a/packages/server/src/db/schema.ts +++ b/packages/server/src/db/schema.ts @@ -379,13 +379,41 @@ export const federationPeers = sqliteTable('federation_peers', { export const peerApprovalRequests = sqliteTable('peer_approval_requests', { id: text('id').primaryKey(), - origin: text('origin').notNull().unique(), + origin: text('origin').notNull(), + direction: text('direction').notNull().default('inbound'), instanceName: text('instance_name'), - hmacSecret: text('hmac_secret').notNull(), + hmacSecret: text('hmac_secret'), requestedAt: integer('requested_at').notNull(), expiresAt: integer('expires_at').notNull(), approvalToken: text('approval_token'), -}); +}, (table) => ({ + uniqOriginDirection: unique().on(table.origin, table.direction), +})); + +export const peerApprovalSubscribers = sqliteTable('peer_approval_subscribers', { + id: text('id').primaryKey(), + requestId: text('request_id').notNull().references(() => peerApprovalRequests.id, { onDelete: 'cascade' }), + userId: text('user_id').notNull().references(() => users.id, { onDelete: 'cascade' }), + triggerReason: text('trigger_reason').notNull(), + triggerTarget: text('trigger_target').notNull(), + createdAt: integer('created_at').notNull(), +}, (table) => ({ + uniqSubscription: unique().on(table.requestId, table.userId, table.triggerReason, table.triggerTarget), + userIdx: index('idx_peer_approval_subscribers_user_id').on(table.userId), +})); + +export const peerApprovalNotifications = sqliteTable('peer_approval_notifications', { + id: text('id').primaryKey(), + userId: text('user_id').notNull().references(() => users.id, { onDelete: 'cascade' }), + kind: text('kind').notNull(), + peerOrigin: text('peer_origin').notNull(), + triggerReason: text('trigger_reason').notNull(), + triggerTarget: text('trigger_target').notNull(), + createdAt: integer('created_at').notNull(), + readAt: integer('read_at'), +}, (table) => ({ + userIdx: index('idx_peer_approval_notifications_user_id').on(table.userId), +})); export const federationOutbox = sqliteTable('federation_outbox', { id: text('id').primaryKey(), diff --git a/packages/server/src/routes/federation.ts b/packages/server/src/routes/federation.ts index 34e18bdf..62c784df 100644 --- a/packages/server/src/routes/federation.ts +++ b/packages/server/src/routes/federation.ts @@ -1370,6 +1370,15 @@ export async function federationRoutes(app: FastifyInstance): Promise { return reply.code(404).send({ error: 'Approval request not found', statusCode: 404 }); } + // Outbound rows have no hmac_secret and no remote /peer/denied endpoint to call. + // Direction-branched handling lands in Task 7; until then, inbound is the only path here. + if (!approvalReq.hmacSecret) { + return reply.code(400).send({ + error: 'Cannot deny an outbound peering request via this endpoint yet — outbound denial handling is not implemented.', + statusCode: 400, + }); + } + const ourOrigin = getOurOrigin(); const denialBody = JSON.stringify({ origin: ourOrigin, diff --git a/packages/server/src/utils/storageJanitor.ts b/packages/server/src/utils/storageJanitor.ts index d1ac81bb..73f0ebd2 100644 --- a/packages/server/src/utils/storageJanitor.ts +++ b/packages/server/src/utils/storageJanitor.ts @@ -452,6 +452,13 @@ export async function cleanupExpiredApprovalRequests(): Promise { let cleaned = 0; for (const req of expired) { + // Outbound rows have no hmac_secret and no remote /peer/denied endpoint; + // their expiry handling (subscriber notifications) lands in Task 9. + // Until then, skip outbound rows here so this loop only processes inbound expirations. + if (!req.hmacSecret) { + continue; + } + const denialBody = JSON.stringify({ origin: ourOrigin, reason: 'expired' as const, From 57462e69dff9a32b80f4042dd4ac4a98c4435f4c Mon Sep 17 00:00:00 2001 From: Jannis Braun <151788261+TheZwiss@users.noreply.github.com> Date: Sun, 26 Apr 2026 21:07:08 +0200 Subject: [PATCH 02/20] docs(schema): comment CHECK invariant on peer_approval_requests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit drizzle's snapshot does not encode SQL-level CHECK constraints. Without this comment, a future migration that recreates the table for unrelated reasons would silently drop the (direction='inbound' → hmac_secret NOT NULL) invariant. --- packages/server/src/db/schema.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/packages/server/src/db/schema.ts b/packages/server/src/db/schema.ts index 5f877c4d..08448e40 100644 --- a/packages/server/src/db/schema.ts +++ b/packages/server/src/db/schema.ts @@ -377,6 +377,11 @@ export const federationPeers = sqliteTable('federation_peers', { approvalToken: text('approval_token'), }); +// SQL-level CHECK constraint enforces (direction='inbound' → hmac_secret NOT NULL). +// See packages/server/drizzle/0003_brave_inhumans.sql. drizzle-kit cannot represent +// CHECK constraints in its snapshot, so any future migration that recreates this +// table MUST re-add the CHECK clause by hand. The snapshot WILL silently drop it +// otherwise. export const peerApprovalRequests = sqliteTable('peer_approval_requests', { id: text('id').primaryKey(), origin: text('origin').notNull(), From 55af76f8b43e10202f15b244eeed2d1430e6c8e4 Mon Sep 17 00:00:00 2001 From: Jannis Braun <151788261+TheZwiss@users.noreply.github.com> Date: Sun, 26 Apr 2026 21:10:32 +0200 Subject: [PATCH 03/20] feat(shared): types for outbound peering gate (intent, subscriptions, notifications) --- packages/shared/src/types.ts | 97 ++++++++++++++++++++++++++++++++++++ 1 file changed, 97 insertions(+) diff --git a/packages/shared/src/types.ts b/packages/shared/src/types.ts index 678c9121..9cf6af5a 100644 --- a/packages/shared/src/types.ts +++ b/packages/shared/src/types.ts @@ -467,6 +467,8 @@ export type ServerEvent = | { type: 'federation_peer_active'; peerOrigin: string } | { type: 'federation_peers_changed' } | { type: 'federation_approval_request_received'; origin: string; instanceName?: string } + | { type: 'peering_subscription_changed' } + | { type: 'peering_notification_received'; kind: PeeringNotificationKind } | { type: 'dm_owner_updated'; dmChannelId: string; newOwnerId: string } | { type: 'pong' } | { type: 'error'; message: string }; @@ -1026,3 +1028,98 @@ export interface FederationPeer { rotationInProgress: boolean; createdAt: number; } + +// ─── Outbound peering gate ────────────────────────────────────────────────── + +/** + * Why a user-initiated federation action triggered the outbound peering gate. + * Recorded on `peer_approval_subscribers.trigger_reason` so admins can see + * the human-readable cause and the user can recover their original action + * after approval. Persisted as a string column with this exact set of values. + */ +export type TriggerReason = 'friend_add' | 'space_join' | 'direct_message'; + +/** + * Caller intent passed into `ensurePeered()`. The gate (when + * `autoAcceptPeering=0` and no peer row exists) branches on `kind`: + * - 'user_action': queue an outbound approval request and surface + * `admin_required` to the caller so the user sees a clear pending state. + * - 'system': skip queueing; surface `admin_required` so the calling + * subsystem (e.g. background relay) can fail loudly without spamming + * admin queues with rows nobody asked for. + * + * `target` is the human-readable target identifier the user acted on + * (e.g. `username@instance.example` for friend_add, the space invite code + * for space_join, the federated DM channel id for direct_message). + */ +export type EnsurePeeredCallerIntent = + | { kind: 'user_action'; userId: string; reason: TriggerReason; target: string } + | { kind: 'system' }; + +/** + * Terminal-state notification kinds delivered to subscribers when the + * outbound queue resolves. 'expired' is delivered by the storage janitor + * before it deletes an unresolved outbound queue row past `expiresAt`. + */ +export type PeeringNotificationKind = 'approved' | 'denied' | 'expired'; + +/** + * Per-user pending row joined from `peer_approval_subscribers` to its parent + * `peer_approval_requests`. Returned from + * `GET /api/federation/peering-subscriptions`. Used to render the user's own + * "waiting on admin" surface so they remember which actions are blocked. + */ +export interface PeeringSubscription { + id: string; + requestId: string; + peerOrigin: string; + peerInstanceName: string | null; + triggerReason: TriggerReason; + triggerTarget: string; + createdAt: number; +} + +/** + * Terminal-state notification row returned from + * `GET /api/federation/peering-notifications`. Persists until the user + * explicitly reads (sets `readAt`) or the janitor cleans up read rows + * older than the retention window. + */ +export interface PeeringNotification { + id: string; + kind: PeeringNotificationKind; + peerOrigin: string; + triggerReason: TriggerReason; + triggerTarget: string; + createdAt: number; + readAt: number | null; +} + +/** + * Subscriber summary embedded in the admin-facing approval request response + * for outbound rows. Lets the admin see which users are waiting on each + * outbound request without a separate fetch. + */ +export interface ApprovalRequestSubscriberSummary { + userId: string; + username: string; + triggerReason: TriggerReason; + triggerTarget: string; +} + +/** + * Admin-facing approval request row returned from + * `GET /api/federation/approval-requests`. Inbound rows are remote + * instances asking to peer with us; outbound rows are local users asking + * us to peer with a remote instance. Outbound rows include `subscribers` + * so the admin can see who is waiting. + */ +export interface ApprovalRequest { + id: string; + direction: 'inbound' | 'outbound'; + origin: string; + instanceName: string | null; + requestedAt: number; + expiresAt: number; + subscribers?: ApprovalRequestSubscriberSummary[]; +} From 94c291c472ecbc2849ab59515f94f973423ec9d8 Mon Sep 17 00:00:00 2001 From: Jannis Braun <151788261+TheZwiss@users.noreply.github.com> Date: Sun, 26 Apr 2026 21:14:51 +0200 Subject: [PATCH 04/20] =?UTF-8?q?refactor(shared):=20rename=20TriggerReaso?= =?UTF-8?q?n=20=E2=86=92=20PeeringTriggerReason,=20pin=20subscribers=20sem?= =?UTF-8?q?antics?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Symmetric with PeeringNotificationKind at module scope; avoids future collision with unrelated trigger systems. - subscribers field doc now explicit: undefined for inbound, present (possibly []) for outbound. Pins the response contract before Task 7 implements the GET endpoint response shape. --- packages/shared/src/types.ts | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/packages/shared/src/types.ts b/packages/shared/src/types.ts index 9cf6af5a..bf7325e1 100644 --- a/packages/shared/src/types.ts +++ b/packages/shared/src/types.ts @@ -1037,7 +1037,7 @@ export interface FederationPeer { * the human-readable cause and the user can recover their original action * after approval. Persisted as a string column with this exact set of values. */ -export type TriggerReason = 'friend_add' | 'space_join' | 'direct_message'; +export type PeeringTriggerReason = 'friend_add' | 'space_join' | 'direct_message'; /** * Caller intent passed into `ensurePeered()`. The gate (when @@ -1053,7 +1053,7 @@ export type TriggerReason = 'friend_add' | 'space_join' | 'direct_message'; * for space_join, the federated DM channel id for direct_message). */ export type EnsurePeeredCallerIntent = - | { kind: 'user_action'; userId: string; reason: TriggerReason; target: string } + | { kind: 'user_action'; userId: string; reason: PeeringTriggerReason; target: string } | { kind: 'system' }; /** @@ -1074,7 +1074,7 @@ export interface PeeringSubscription { requestId: string; peerOrigin: string; peerInstanceName: string | null; - triggerReason: TriggerReason; + triggerReason: PeeringTriggerReason; triggerTarget: string; createdAt: number; } @@ -1089,7 +1089,7 @@ export interface PeeringNotification { id: string; kind: PeeringNotificationKind; peerOrigin: string; - triggerReason: TriggerReason; + triggerReason: PeeringTriggerReason; triggerTarget: string; createdAt: number; readAt: number | null; @@ -1103,7 +1103,7 @@ export interface PeeringNotification { export interface ApprovalRequestSubscriberSummary { userId: string; username: string; - triggerReason: TriggerReason; + triggerReason: PeeringTriggerReason; triggerTarget: string; } @@ -1121,5 +1121,11 @@ export interface ApprovalRequest { instanceName: string | null; requestedAt: number; expiresAt: number; + /** + * Subscriber summaries — present (and possibly empty array) only when + * `direction === 'outbound'`. ABSENT (`undefined`) when `direction === 'inbound'`. + * Inbound rows have no per-action context; the field is omitted from the server + * response, not set to `[]`. + */ subscribers?: ApprovalRequestSubscriberSummary[]; } From 50bc510e8d8dbf542058366852cabdeffe414a15 Mon Sep 17 00:00:00 2001 From: Jannis Braun <151788261+TheZwiss@users.noreply.github.com> Date: Sun, 26 Apr 2026 21:18:43 +0200 Subject: [PATCH 05/20] feat(federation): outbound gate in ensurePeered with required intent argument MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - New 'admin_required' EnsurePeeredResult variant. - Required intent argument (no optional default) prevents future user-initiated callers from silently getting system behavior. - Gate runs only when no peer row exists; toggling autoAccept later does not retroactively gate established peer rows. - queueOutboundApproval helper upserts parent + subscriber rows and broadcasts to admins + user. - Schema enum-narrows direction ('inbound' | 'outbound') and notifications.kind ('approved' | 'denied' | 'expired') at the column level; drizzle generate confirmed no migration delta. - Existing call sites now fail typecheck — fixed in Task 5. --- packages/server/src/db/schema.ts | 4 +- .../server/src/utils/federationPeering.ts | 147 +++++++++++++++++- 2 files changed, 144 insertions(+), 7 deletions(-) diff --git a/packages/server/src/db/schema.ts b/packages/server/src/db/schema.ts index 08448e40..a97ab2ec 100644 --- a/packages/server/src/db/schema.ts +++ b/packages/server/src/db/schema.ts @@ -385,7 +385,7 @@ export const federationPeers = sqliteTable('federation_peers', { export const peerApprovalRequests = sqliteTable('peer_approval_requests', { id: text('id').primaryKey(), origin: text('origin').notNull(), - direction: text('direction').notNull().default('inbound'), + direction: text('direction', { enum: ['inbound', 'outbound'] }).notNull().default('inbound'), instanceName: text('instance_name'), hmacSecret: text('hmac_secret'), requestedAt: integer('requested_at').notNull(), @@ -410,7 +410,7 @@ export const peerApprovalSubscribers = sqliteTable('peer_approval_subscribers', export const peerApprovalNotifications = sqliteTable('peer_approval_notifications', { id: text('id').primaryKey(), userId: text('user_id').notNull().references(() => users.id, { onDelete: 'cascade' }), - kind: text('kind').notNull(), + kind: text('kind', { enum: ['approved', 'denied', 'expired'] }).notNull(), peerOrigin: text('peer_origin').notNull(), triggerReason: text('trigger_reason').notNull(), triggerTarget: text('trigger_target').notNull(), diff --git a/packages/server/src/utils/federationPeering.ts b/packages/server/src/utils/federationPeering.ts index 48ce5769..b2ff562a 100644 --- a/packages/server/src/utils/federationPeering.ts +++ b/packages/server/src/utils/federationPeering.ts @@ -1,10 +1,11 @@ import { getDb } from '../db/index.js'; import * as schema from '../db/schema.js'; -import { eq } from 'drizzle-orm'; +import { and, eq } from 'drizzle-orm'; import { generateSnowflake } from './snowflake.js'; import { getOurOrigin, generateHmacSecret } from './federationAuth.js'; import { validateOrigin } from '../routes/federation.js'; import { onPeerActivated, onPeerDeactivated } from './federationPeerActivation.js'; +import type { EnsurePeeredCallerIntent } from '@backspace/shared'; // ─── Types ─────────────────────────────────────────────────────────────────── @@ -12,7 +13,8 @@ export type EnsurePeeredResult = | { status: 'active'; peerId: string } | { status: 'rejected'; error: string } | { status: 'failed'; error: string } - | { status: 'pending'; error: string }; + | { status: 'pending'; error: string } + | { status: 'admin_required'; error: string }; // ─── Helpers ───────────────────────────────────────────────────────────────── @@ -26,6 +28,106 @@ function getInstanceName(): string | undefined { return row?.name ?? undefined; } +const THIRTY_DAYS_MS = 30 * 24 * 60 * 60 * 1000; + +/** + * When the outbound gate fires for a `user_action` intent, upsert the + * `peer_approval_requests` (parent, keyed on origin+direction='outbound') + * and `peer_approval_subscribers` (per-user, keyed on parent+user+reason+target) + * rows, then broadcast WS events so the admin queue and the user's pending + * list refresh. Idempotent: repeated calls for the same (origin, user, reason, + * target) refresh `created_at` rather than creating duplicate rows. + * + * NOTE: parent row is created with `hmac_secret = NULL`. The CHECK constraint + * permits this for `direction='outbound'`. The approve handler generates fresh + * HMAC at the moment it actually sends `/peer/accept` to the remote. + */ +async function queueOutboundApproval( + origin: string, + intent: Extract, +): Promise { + const db = getDb(); + const now = Date.now(); + + // Upsert parent row keyed on (origin, direction='outbound'). + let parent = db + .select() + .from(schema.peerApprovalRequests) + .where( + and( + eq(schema.peerApprovalRequests.origin, origin), + eq(schema.peerApprovalRequests.direction, 'outbound'), + ), + ) + .get(); + + if (!parent) { + const id = generateSnowflake(); + db.insert(schema.peerApprovalRequests) + .values({ + id, + origin, + direction: 'outbound', + instanceName: null, + hmacSecret: null, + requestedAt: now, + expiresAt: now + THIRTY_DAYS_MS, + approvalToken: null, + }) + .run(); + parent = db + .select() + .from(schema.peerApprovalRequests) + .where(eq(schema.peerApprovalRequests.id, id)) + .get()!; + } + + // Upsert subscriber row. + const existingSub = db + .select({ id: schema.peerApprovalSubscribers.id }) + .from(schema.peerApprovalSubscribers) + .where( + and( + eq(schema.peerApprovalSubscribers.requestId, parent.id), + eq(schema.peerApprovalSubscribers.userId, intent.userId), + eq(schema.peerApprovalSubscribers.triggerReason, intent.reason), + eq(schema.peerApprovalSubscribers.triggerTarget, intent.target), + ), + ) + .get(); + + if (existingSub) { + db.update(schema.peerApprovalSubscribers) + .set({ createdAt: now }) + .where(eq(schema.peerApprovalSubscribers.id, existingSub.id)) + .run(); + } else { + db.insert(schema.peerApprovalSubscribers) + .values({ + id: generateSnowflake(), + requestId: parent.id, + userId: intent.userId, + triggerReason: intent.reason, + triggerTarget: intent.target, + createdAt: now, + }) + .run(); + } + + // Broadcast: admins refresh queue; the calling user refreshes pending list. + // Dynamic import is the existing circular-dep workaround in this file + // (see ws/handler.js imports later). Keep it consistent. + const { connectionManager } = await import('../ws/handler.js'); + connectionManager.sendToAdmins({ + type: 'federation_approval_request_received' as const, + origin, + instanceName: undefined, + }); + connectionManager.sendToUser(intent.userId, { + type: 'peering_subscription_changed' as const, + }); +} + // ─── In-flight deduplication ───────────────────────────────────────────────── const inFlightPeering = new Map>(); @@ -40,7 +142,10 @@ const inFlightPeering = new Map>(); * - { status: 'rejected', error } — remote rejected auto-peering, or peer was revoked * - { status: 'failed', error } — transient error (network, timeout), will retry */ -export async function ensurePeered(origin: string): Promise { +export async function ensurePeered( + origin: string, + intent: EnsurePeeredCallerIntent, +): Promise { // Validate origin format const normalized = validateOrigin(origin); if (!normalized) { @@ -106,6 +211,34 @@ export async function ensurePeered(origin: string): Promise }; } + // Outbound gate: when autoAcceptPeering=0, regular-user-initiated outbound + // becomes admin-approvable; system-initiated outbound is refused outright. + // Runs only when no peer row exists (existing rows already passed the gate + // when first created — toggling autoAccept later doesn't retroactively gate). + if (!existing) { + const settings = db + .select({ autoAcceptPeering: schema.instanceSettings.autoAcceptPeering }) + .from(schema.instanceSettings) + .where(eq(schema.instanceSettings.id, 1)) + .get(); + const autoAccept = settings?.autoAcceptPeering ?? 1; + + if (autoAccept === 0) { + if (intent.kind === 'user_action') { + await queueOutboundApproval(normalized, intent); + return { + status: 'admin_required', + error: 'Awaiting your admin\'s approval to initiate peering', + }; + } + // system intent — refuse without queue + return { + status: 'admin_required', + error: 'Outbound peering requires admin approval on this instance', + }; + } + } + // Deduplicate: if a handshake is already in flight, share the promise const inflight = inFlightPeering.get(normalized); if (inflight) { @@ -280,9 +413,13 @@ export function _clearInFlightPeering(): void { export async function racePeering( origin: string, timeoutMs: number, - ensurePeeredFn: (origin: string) => Promise = ensurePeered, + intent: EnsurePeeredCallerIntent, + ensurePeeredFn: ( + origin: string, + intent: EnsurePeeredCallerIntent, + ) => Promise = ensurePeered, ): Promise { - const handshake = ensurePeeredFn(origin); + const handshake = ensurePeeredFn(origin, intent); let timeoutHandle: ReturnType | undefined; const timeoutPromise = new Promise<{ status: 'timeout' }>(resolve => { From 6ee8663e9e2d8083661688f8747990e079423506 Mon Sep 17 00:00:00 2001 From: Jannis Braun <151788261+TheZwiss@users.noreply.github.com> Date: Sun, 26 Apr 2026 21:23:55 +0200 Subject: [PATCH 06/20] fix(federation): narrow trust-guard query to inbound rows only MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The pre-existing trust-guard's query filtered peer_approval_requests by origin only. After Task 3 added outbound rows to the same table, the guard started matching the user's own queued outbound row on retry, returning 'rejected' with a misleading 'admin must resolve pending approval' copy instead of the intended 'admin_required'. The variable name (pendingInbound) and comment block already described the intent as inbound-only — the query just didn't match. Adding direction='inbound' to the where clause restores the intended behavior. --- packages/server/src/utils/federationPeering.ts | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/packages/server/src/utils/federationPeering.ts b/packages/server/src/utils/federationPeering.ts index b2ff562a..4fdc8527 100644 --- a/packages/server/src/utils/federationPeering.ts +++ b/packages/server/src/utils/federationPeering.ts @@ -198,10 +198,20 @@ export async function ensurePeered( // The legitimate approval flow (routes/federation.ts /approval-requests/:id/ // approve) does NOT call ensurePeered — it deletes the approval-request first // and does its own fetch — so this guard does not block legitimate approvals. + // + // direction='inbound' filter: the table is bidirectional as of the outbound + // peering gate (Task 3); outbound rows live in the same table and must NOT + // trigger this guard. The outbound gate below (`if (!existing)` block) is + // responsible for outbound row state. const pendingInbound = db .select({ id: schema.peerApprovalRequests.id }) .from(schema.peerApprovalRequests) - .where(eq(schema.peerApprovalRequests.origin, normalized)) + .where( + and( + eq(schema.peerApprovalRequests.origin, normalized), + eq(schema.peerApprovalRequests.direction, 'inbound'), + ), + ) .get(); if (pendingInbound) { From ef80e3b41684a70b49d0e5b3680ce3f083c485b2 Mon Sep 17 00:00:00 2001 From: Jannis Braun <151788261+TheZwiss@users.noreply.github.com> Date: Sun, 26 Apr 2026 21:28:06 +0200 Subject: [PATCH 07/20] test(federation): outbound gate behavior across autoAccept settings and intents --- .../federationPeering.outboundGate.test.ts | 455 ++++++++++++++++++ 1 file changed, 455 insertions(+) create mode 100644 packages/server/src/utils/federationPeering.outboundGate.test.ts diff --git a/packages/server/src/utils/federationPeering.outboundGate.test.ts b/packages/server/src/utils/federationPeering.outboundGate.test.ts new file mode 100644 index 00000000..fcaac048 --- /dev/null +++ b/packages/server/src/utils/federationPeering.outboundGate.test.ts @@ -0,0 +1,455 @@ +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(autoAcceptPeering: 0 | 1): void { + testDb.insert(schema.instanceSettings).values({ + id: 1, + instanceName: 'Local Backspace', + autoAcceptPeering, + registrationOpen: 1, + updatedAt: Date.now(), + }).run(); +} + +function seedUser(id: string, username: string): void { + testDb.insert(schema.users).values({ + id, + username, + passwordHash: 'x', + createdAt: Date.now(), + }).run(); +} + +describe('ensurePeered — outbound gate behavior across (autoAccept × peer-row × intent)', () => { + beforeEach(async () => { + sqlite = new Database(':memory:'); + testDb = drizzle(sqlite, { schema }); + applyMigrations(sqlite); + const { _clearInFlightPeering } = await import('./federationPeering.js'); + _clearInFlightPeering(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + vi.unstubAllGlobals(); + sqlite.close(); + }); + + // ─── Test 1 ───────────────────────────────────────────────────────────── + // autoAccept=1 + no peer + user_action → gate does NOT fire; pending peer + // row created; handshake runs as today. + it('autoAccept=1 + no peer + user_action → no gate; pending peer row created; handshake runs', async () => { + seedInstanceSettings(1); + seedUser('user1', 'alice'); + + // Stub fetch with a network failure so the handshake resolves as 'failed' + // (transient) without us needing a complete 200/202 response. The peer row + // is created BEFORE fetch in performHandshake, then deleted on transient + // failure. So we assert the row existed mid-flight by spying on fetch and + // capturing DB state at that point. + let peerRowsDuringHandshake: Array<{ status: string; origin: string }> = []; + vi.stubGlobal('fetch', vi.fn(async () => { + peerRowsDuringHandshake = testDb + .select({ status: schema.federationPeers.status, origin: schema.federationPeers.origin }) + .from(schema.federationPeers) + .all(); + throw new TypeError('fetch failed'); + })); + + const { ensurePeered } = await import('./federationPeering.js'); + const result = await ensurePeered('https://orbit.example', { + kind: 'user_action', + userId: 'user1', + reason: 'friend_add', + target: 'bob@orbit.example', + }); + + // Handshake reached fetch (gate did not fire). Network failure → 'failed'. + expect(result.status).toBe('failed'); + + // Mid-handshake, the pending peer row existed. + expect(peerRowsDuringHandshake).toHaveLength(1); + expect(peerRowsDuringHandshake[0].status).toBe('pending'); + expect(peerRowsDuringHandshake[0].origin).toBe('https://orbit.example'); + + // No outbound queue rows were created. + const parents = testDb.select().from(schema.peerApprovalRequests).all(); + expect(parents).toHaveLength(0); + const subs = testDb.select().from(schema.peerApprovalSubscribers).all(); + expect(subs).toHaveLength(0); + }); + + // ─── Test 2 ───────────────────────────────────────────────────────────── + // autoAccept=0 + no peer + user_action → gate fires; outbound queue parent + // + subscriber created; returns 'admin_required'; no peer row created. + it('autoAccept=0 + no peer + user_action → queues outbound row + subscriber; no peer row; admin_required', async () => { + seedInstanceSettings(0); + seedUser('user1', 'alice'); + + const fetchSpy = vi.spyOn(globalThis, 'fetch'); + + const { ensurePeered } = await import('./federationPeering.js'); + const result = await ensurePeered('https://orbit.example', { + kind: 'user_action', + userId: 'user1', + reason: 'friend_add', + target: 'bob@orbit.example', + }); + + expect(result.status).toBe('admin_required'); + + // Parent row created. + const parents = testDb.select().from(schema.peerApprovalRequests).all(); + expect(parents).toHaveLength(1); + expect(parents[0].direction).toBe('outbound'); + expect(parents[0].origin).toBe('https://orbit.example'); + expect(parents[0].hmacSecret).toBeNull(); + + // Subscriber row created. + const subs = testDb.select().from(schema.peerApprovalSubscribers).all(); + expect(subs).toHaveLength(1); + expect(subs[0].userId).toBe('user1'); + expect(subs[0].triggerReason).toBe('friend_add'); + expect(subs[0].triggerTarget).toBe('bob@orbit.example'); + expect(subs[0].requestId).toBe(parents[0].id); + + // No federation_peers row created; no outbound POST attempted. + const peers = testDb.select().from(schema.federationPeers).all(); + expect(peers).toHaveLength(0); + expect(fetchSpy).not.toHaveBeenCalled(); + }); + + // ─── Test 3 ───────────────────────────────────────────────────────────── + // autoAccept=0 + no peer + system intent → gate fires; no queue row created; + // zero subscribers; returns 'admin_required'. + it('autoAccept=0 + no peer + system intent → admin_required; no queue, no subscribers', async () => { + seedInstanceSettings(0); + + const fetchSpy = vi.spyOn(globalThis, 'fetch'); + + const { ensurePeered } = await import('./federationPeering.js'); + const result = await ensurePeered('https://orbit.example', { kind: 'system' }); + + expect(result.status).toBe('admin_required'); + + const parents = testDb.select().from(schema.peerApprovalRequests).all(); + expect(parents).toHaveLength(0); + const subs = testDb.select().from(schema.peerApprovalSubscribers).all(); + expect(subs).toHaveLength(0); + const peers = testDb.select().from(schema.federationPeers).all(); + expect(peers).toHaveLength(0); + expect(fetchSpy).not.toHaveBeenCalled(); + }); + + // ─── Test 4 ───────────────────────────────────────────────────────────── + // autoAccept=0 + existing active peer row + any intent → gate skipped; + // returns 'active' with existing peerId. + it('autoAccept=0 + existing active peer row → returns active; gate skipped', async () => { + seedInstanceSettings(0); + seedUser('user1', 'alice'); + + testDb.insert(schema.federationPeers).values({ + id: 'peer-active', + origin: 'https://orbit.example', + hmacSecret: 'secret', + status: 'active', + createdAt: Date.now(), + }).run(); + + const fetchSpy = vi.spyOn(globalThis, 'fetch'); + + const { ensurePeered } = await import('./federationPeering.js'); + const result = await ensurePeered('https://orbit.example', { + kind: 'user_action', + userId: 'user1', + reason: 'friend_add', + target: 'bob@orbit.example', + }); + + expect(result.status).toBe('active'); + if (result.status === 'active') { + expect(result.peerId).toBe('peer-active'); + } + + // No queue rows; no outbound POST. + const parents = testDb.select().from(schema.peerApprovalRequests).all(); + expect(parents).toHaveLength(0); + const subs = testDb.select().from(schema.peerApprovalSubscribers).all(); + expect(subs).toHaveLength(0); + expect(fetchSpy).not.toHaveBeenCalled(); + }); + + // ─── Test 5 ───────────────────────────────────────────────────────────── + // autoAccept=0 + existing pending peer row + user_action → gate skipped + // (existing path handles); existing dedup/inflight logic runs. + it('autoAccept=0 + existing pending peer row + user_action → gate skipped; reaches handshake (dedup path)', async () => { + seedInstanceSettings(0); + seedUser('user1', 'alice'); + + testDb.insert(schema.federationPeers).values({ + id: 'peer-pending', + origin: 'https://orbit.example', + hmacSecret: 'old-secret', + status: 'pending', + createdAt: Date.now(), + }).run(); + + // The pending branch falls through to performHandshake which calls fetch. + // Stub it to fail transiently — the handshake will keep the existing peer + // row (existingPeerId is set, so it isn't deleted on failure). + const fetchSpy = vi.fn(async () => { + throw new TypeError('fetch failed'); + }); + vi.stubGlobal('fetch', fetchSpy); + + const { ensurePeered } = await import('./federationPeering.js'); + const result = await ensurePeered('https://orbit.example', { + kind: 'user_action', + userId: 'user1', + reason: 'friend_add', + target: 'bob@orbit.example', + }); + + // Reached performHandshake — failure mode is 'failed', NOT 'admin_required'. + expect(result.status).toBe('failed'); + expect(fetchSpy).toHaveBeenCalled(); + + // Critically: the gate did NOT queue an outbound approval row. + const parents = testDb.select().from(schema.peerApprovalRequests).all(); + expect(parents).toHaveLength(0); + const subs = testDb.select().from(schema.peerApprovalSubscribers).all(); + expect(subs).toHaveLength(0); + + // Existing pending peer row still present (existingPeerId path doesn't delete on failure). + const peers = testDb.select().from(schema.federationPeers).all(); + expect(peers).toHaveLength(1); + expect(peers[0].id).toBe('peer-pending'); + }); + + // ─── Test 6 ───────────────────────────────────────────────────────────── + // autoAccept=0 + existing rejected peer row + user_action → gate skipped; + // returns 'rejected' per existing branch (no queue creation). + it('autoAccept=0 + existing rejected peer row + user_action → returns rejected; gate does NOT override', async () => { + seedInstanceSettings(0); + seedUser('user1', 'alice'); + + testDb.insert(schema.federationPeers).values({ + id: 'peer-rejected', + origin: 'https://orbit.example', + hmacSecret: 'secret', + status: 'rejected', + createdAt: Date.now(), + }).run(); + + const fetchSpy = vi.spyOn(globalThis, 'fetch'); + + const { ensurePeered } = await import('./federationPeering.js'); + const result = await ensurePeered('https://orbit.example', { + kind: 'user_action', + userId: 'user1', + reason: 'friend_add', + target: 'bob@orbit.example', + }); + + expect(result.status).toBe('rejected'); + + // CRITICAL: no queue row created — the gate did NOT override the rejected branch. + const parents = testDb.select().from(schema.peerApprovalRequests).all(); + expect(parents).toHaveLength(0); + const subs = testDb.select().from(schema.peerApprovalSubscribers).all(); + expect(subs).toHaveLength(0); + expect(fetchSpy).not.toHaveBeenCalled(); + + // Rejected peer row unchanged. + const peer = testDb.select().from(schema.federationPeers) + .where(eq(schema.federationPeers.id, 'peer-rejected')).get(); + expect(peer?.status).toBe('rejected'); + }); + + // ─── Test 7 ───────────────────────────────────────────────────────────── + // autoAccept=0 + same user calls twice with same target → upsert path; one + // parent row, one subscriber row, refreshed created_at. + it('autoAccept=0 + same user calls twice with same target → idempotent: 1 parent, 1 subscriber, refreshed createdAt', async () => { + seedInstanceSettings(0); + seedUser('user1', 'alice'); + + const { ensurePeered } = await import('./federationPeering.js'); + + const result1 = await ensurePeered('https://orbit.example', { + kind: 'user_action', + userId: 'user1', + reason: 'friend_add', + target: 'bob@orbit.example', + }); + expect(result1.status).toBe('admin_required'); + + const subsAfterFirst = testDb.select().from(schema.peerApprovalSubscribers).all(); + expect(subsAfterFirst).toHaveLength(1); + const firstCreatedAt = subsAfterFirst[0].createdAt; + + // Wait a moment so the refreshed createdAt would differ. + await new Promise(r => setTimeout(r, 5)); + + const result2 = await ensurePeered('https://orbit.example', { + kind: 'user_action', + userId: 'user1', + reason: 'friend_add', + target: 'bob@orbit.example', + }); + expect(result2.status).toBe('admin_required'); + + // Still exactly one parent row keyed on (origin, direction='outbound'). + const parents = testDb.select().from(schema.peerApprovalRequests).all(); + expect(parents).toHaveLength(1); + expect(parents[0].direction).toBe('outbound'); + expect(parents[0].origin).toBe('https://orbit.example'); + + // Still exactly one subscriber keyed on (request_id, user_id, reason, target). + const subs = testDb.select().from(schema.peerApprovalSubscribers).all(); + expect(subs).toHaveLength(1); + expect(subs[0].userId).toBe('user1'); + expect(subs[0].triggerReason).toBe('friend_add'); + expect(subs[0].triggerTarget).toBe('bob@orbit.example'); + + // createdAt was refreshed on the second call. + expect(subs[0].createdAt).toBeGreaterThan(firstCreatedAt); + }); + + // ─── Test 8 ───────────────────────────────────────────────────────────── + // autoAccept=0 + two users call with same target → one parent row, two + // subscriber rows. + it('autoAccept=0 + two users call with same target → 1 parent, 2 subscribers (m:n fanout)', async () => { + seedInstanceSettings(0); + seedUser('user1', 'alice'); + seedUser('user2', 'carol'); + + const { ensurePeered } = await import('./federationPeering.js'); + + const result1 = await ensurePeered('https://orbit.example', { + kind: 'user_action', + userId: 'user1', + reason: 'friend_add', + target: 'bob@orbit.example', + }); + expect(result1.status).toBe('admin_required'); + + const result2 = await ensurePeered('https://orbit.example', { + kind: 'user_action', + userId: 'user2', + reason: 'friend_add', + target: 'bob@orbit.example', + }); + expect(result2.status).toBe('admin_required'); + + // Exactly one parent row. + const parents = testDb.select().from(schema.peerApprovalRequests).all(); + expect(parents).toHaveLength(1); + expect(parents[0].origin).toBe('https://orbit.example'); + expect(parents[0].direction).toBe('outbound'); + + // Two subscribers, both pointing at the same parent. + const subs = testDb.select().from(schema.peerApprovalSubscribers).all(); + expect(subs).toHaveLength(2); + const userIds = subs.map(s => s.userId).sort(); + expect(userIds).toEqual(['user1', 'user2']); + for (const sub of subs) { + expect(sub.requestId).toBe(parents[0].id); + expect(sub.triggerReason).toBe('friend_add'); + expect(sub.triggerTarget).toBe('bob@orbit.example'); + } + }); + + // ─── Test 9 ───────────────────────────────────────────────────────────── + // autoAccept=0 + user_action with reason='space_join' → subscriber row + // records correct reason and target. + it('autoAccept=0 + user_action reason=space_join → subscriber records correct reason/target', async () => { + seedInstanceSettings(0); + seedUser('user1', 'alice'); + + const { ensurePeered } = await import('./federationPeering.js'); + const result = await ensurePeered('https://orbit.example', { + kind: 'user_action', + userId: 'user1', + reason: 'space_join', + target: 'space-abc-123', + }); + + expect(result.status).toBe('admin_required'); + + const subs = testDb.select().from(schema.peerApprovalSubscribers).all(); + expect(subs).toHaveLength(1); + expect(subs[0].userId).toBe('user1'); + expect(subs[0].triggerReason).toBe('space_join'); + expect(subs[0].triggerTarget).toBe('space-abc-123'); + + const parents = testDb.select().from(schema.peerApprovalRequests).all(); + expect(parents).toHaveLength(1); + expect(parents[0].direction).toBe('outbound'); + expect(parents[0].origin).toBe('https://orbit.example'); + }); +}); From 4d4dc383d73eb6c751df482b77a508fcb831f658 Mon Sep 17 00:00:00 2001 From: Jannis Braun <151788261+TheZwiss@users.noreply.github.com> Date: Sun, 26 Apr 2026 21:37:19 +0200 Subject: [PATCH 08/20] feat(federation): pass explicit intent at every ensurePeered call site MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - social.ts friend-add: user_action, with 409 peer_pending_local_admin when gate fires - /peer/ensure: user_action, surfaces peeringStatus: 'admin_required' - sendCallRelay (typing warm-up + call relay): system intent - federationWorker resolvePendingPeers: system intent (defensive — gate is unreachable from here since pending rows already exist) - CallRelayFailureReason: peer_admin_required added (mapped to peer_transient_failure on the user-facing event surface, since system intent should never legitimately surface admin_required) - Test files: thread intent arg through racePeering and ensurePeered calls (positional shift from racePeering signature change) - outboundGate.test.ts: tighten noUncheckedIndexedAccess access via non-null assertions after toHaveLength() - docs/systems/social.md: peer_pending_local_admin error code documented --- docs/systems/social.md | 1 + packages/server/src/routes/federation.ts | 24 +++++++-- packages/server/src/routes/social.ts | 14 +++++- packages/server/src/utils/federationOutbox.ts | 8 ++- .../federationPeering.approvalToken.test.ts | 8 +-- .../federationPeering.instanceName.test.ts | 6 +-- .../federationPeering.outboundGate.test.ts | 50 +++++++++---------- .../src/utils/federationPeering.test.ts | 14 +++--- .../federationPeering.trustGuard.test.ts | 4 +- packages/server/src/utils/federationWorker.ts | 2 +- 10 files changed, 81 insertions(+), 50 deletions(-) diff --git a/docs/systems/social.md b/docs/systems/social.md index 516c5bca..bcf52093 100644 --- a/docs/systems/social.md +++ b/docs/systems/social.md @@ -300,6 +300,7 @@ As of 2026-04-25, the sender's home server owns the entire federated friend-add - `'pending'` + peer row `awaiting_approval` (re-queried after the call) → 409 `peer_pending_approval` - `'rejected'` → 403 `peer_rejected` - `'failed'` → 503 `peer_unreachable` + - `'admin_required'` (gate fired locally) → 409 `peer_pending_local_admin` — your own admin must approve before we reach out 5. **lookupRemoteUser(peerOrigin, baseName)** — POSTs HMAC-signed `{ username }` to `peerOrigin/api/federation/users/lookup`. Result mapping: - `not_found` → 404 `user_not_found` - `unreachable` → 503 `peer_unreachable` diff --git a/packages/server/src/routes/federation.ts b/packages/server/src/routes/federation.ts index 62c784df..bf24102d 100644 --- a/packages/server/src/routes/federation.ts +++ b/packages/server/src/routes/federation.ts @@ -798,14 +798,26 @@ export async function federationRoutes(app: FastifyInstance): Promise { } const { ensurePeered } = await import('../utils/federationPeering.js'); - const result = await ensurePeered(remoteOrigin); + // NOTE: /peer/ensure is currently only invoked from friend-add client paths + // (see packages/web/src/stores/instanceStore.ts ensurePeered references). + // The hardcoded reason here is correct TODAY but will become wrong when + // DM-to-stranger or space-join grow into the gate. When that happens, + // surface the reason and target through the request body instead. Do NOT + // silently leave the hardcoding in place when adding a new caller. + const result = await ensurePeered(remoteOrigin, { + kind: 'user_action', + userId: request.userId, + reason: 'friend_add', + target: remoteOrigin, + }); // NOTE: The internal EnsurePeeredResult status names differ from the client-facing // peeringStatus values. The mapping: - // 'active' → 'active' (peer is live) - // 'rejected' → 'rejected' (permanently blocked) - // 'pending' → 'awaiting_approval' (queued on remote, waiting for admin) - // 'failed' → 'pending' (transient error, will retry automatically) + // 'active' → 'active' (peer is live) + // 'rejected' → 'rejected' (permanently blocked) + // 'pending' → 'awaiting_approval' (queued on remote, waiting for admin) + // 'failed' → 'pending' (transient error, will retry automatically) + // 'admin_required' → 'admin_required' (local outbound gate fired — our admin must approve) // The internal 'pending' means "we got a 202 from the remote — admin hasn't acted yet", // while 'failed' means "network/timeout — the outbox worker will retry next tick". // The client sees 'awaiting_approval' (actionable info) vs 'pending' (transient, will resolve). @@ -818,6 +830,8 @@ export async function federationRoutes(app: FastifyInstance): Promise { return reply.code(200).send({ peeringStatus: 'awaiting_approval', error: result.error }); case 'failed': return reply.code(200).send({ peeringStatus: 'pending', error: result.error }); + case 'admin_required': + return reply.code(200).send({ peeringStatus: 'admin_required' }); default: return reply.code(200).send({ peeringStatus: 'pending', error: 'Unknown peering result' }); } diff --git a/packages/server/src/routes/social.ts b/packages/server/src/routes/social.ts index 2db95d7a..4bc66d59 100644 --- a/packages/server/src/routes/social.ts +++ b/packages/server/src/routes/social.ts @@ -178,7 +178,12 @@ async function handleFederatedFriendRequest( } // 2. ensurePeered — block until 'active', or surface peer status as error - const peering = await ensurePeered(peerOrigin); + const peering = await ensurePeered(peerOrigin, { + kind: 'user_action', + userId: sender.id, + reason: 'friend_add', + target: `${baseName}@${targetDomain}`, + }); if (peering.status === 'rejected') { return reply.code(403).send({ error: 'peer_rejected', statusCode: 403, domain: targetDomain }); } @@ -195,6 +200,13 @@ async function handleFederatedFriendRequest( } return reply.code(409).send({ error: 'peer_pending', statusCode: 409, domain: targetDomain }); } + if (peering.status === 'admin_required') { + return reply.code(409).send({ + error: 'peer_pending_local_admin', + statusCode: 409, + domain: targetDomain, + }); + } // peering.status === 'active' — continue // 3. Lookup diff --git a/packages/server/src/utils/federationOutbox.ts b/packages/server/src/utils/federationOutbox.ts index b078f438..08e8d33f 100644 --- a/packages/server/src/utils/federationOutbox.ts +++ b/packages/server/src/utils/federationOutbox.ts @@ -520,6 +520,7 @@ export const CALL_PEERING_TIMEOUT_MS = 3_000; export type CallRelayFailureReason = | 'peer_rejected' | 'peer_awaiting_approval' + | 'peer_admin_required' | 'peer_transient_failure' | 'post_failed'; @@ -547,6 +548,7 @@ export function mapCallReasonToEventReason(reason: CallRelayFailureReason): DmCa switch (reason) { case 'peer_rejected': return 'peer_rejected'; case 'peer_awaiting_approval': return 'peer_awaiting_approval'; + case 'peer_admin_required': return 'peer_transient_failure'; // gate-unreachable from system intent; defensive map case 'peer_transient_failure': return 'peer_transient_failure'; case 'post_failed': return 'peer_transient_failure'; // 4xx looks transient to users } @@ -583,14 +585,14 @@ export async function sendCallRelay( if (!peer) { // ─── Non-blocking mode (typing): warm up in background, do not POST ── if (timeoutMs === 0) { - ensurePeered(targetPeerOrigin).catch(err => { + ensurePeered(targetPeerOrigin, { kind: 'system' }).catch(err => { console.warn('[federation] typing-triggered background handshake:', targetPeerOrigin, err); }); return { ok: false, reason: 'peer_transient_failure', error: 'peer not active' }; } // ─── Race ensurePeered against the deadline ── - const raced = await racePeering(targetPeerOrigin, timeoutMs); + const raced = await racePeering(targetPeerOrigin, timeoutMs, { kind: 'system' }); switch (raced.status) { case 'active': @@ -607,6 +609,8 @@ export async function sendCallRelay( return { ok: false, reason: 'peer_rejected', error: raced.error }; case 'pending': return { ok: false, reason: 'peer_awaiting_approval', error: raced.error }; + case 'admin_required': + return { ok: false, reason: 'peer_admin_required', error: raced.error }; case 'failed': return { ok: false, reason: 'peer_transient_failure', error: raced.error }; case 'timeout': diff --git a/packages/server/src/utils/federationPeering.approvalToken.test.ts b/packages/server/src/utils/federationPeering.approvalToken.test.ts index a337738f..385fe076 100644 --- a/packages/server/src/utils/federationPeering.approvalToken.test.ts +++ b/packages/server/src/utils/federationPeering.approvalToken.test.ts @@ -101,7 +101,7 @@ describe('performHandshake — approval token capture & clear', () => { ); const { ensurePeered } = await import('./federationPeering.js'); - const result = await ensurePeered('https://remote.example'); + const result = await ensurePeered('https://remote.example', { kind: 'system' }); expect(result.status).toBe('pending'); @@ -120,7 +120,7 @@ describe('performHandshake — approval token capture & clear', () => { ); const { ensurePeered } = await import('./federationPeering.js'); - const result = await ensurePeered('https://legacy.example'); + const result = await ensurePeered('https://legacy.example', { kind: 'system' }); expect(result.status).toBe('pending'); const peer = testDb.select().from(schema.federationPeers) @@ -135,7 +135,7 @@ describe('performHandshake — approval token capture & clear', () => { ); const { ensurePeered } = await import('./federationPeering.js'); - const result = await ensurePeered('https://empty.example'); + const result = await ensurePeered('https://empty.example', { kind: 'system' }); expect(result.status).toBe('pending'); const peer = testDb.select().from(schema.federationPeers) @@ -161,7 +161,7 @@ describe('performHandshake — approval token capture & clear', () => { ); const { ensurePeered } = await import('./federationPeering.js'); - const result = await ensurePeered('https://remote.example'); + const result = await ensurePeered('https://remote.example', { kind: 'system' }); expect(result.status).toBe('active'); const peer = testDb.select().from(schema.federationPeers) diff --git a/packages/server/src/utils/federationPeering.instanceName.test.ts b/packages/server/src/utils/federationPeering.instanceName.test.ts index c19402d5..ca48bd33 100644 --- a/packages/server/src/utils/federationPeering.instanceName.test.ts +++ b/packages/server/src/utils/federationPeering.instanceName.test.ts @@ -98,7 +98,7 @@ describe('performHandshake — persist remote instanceName', () => { const { ensurePeered, _clearInFlightPeering } = await import('./federationPeering.js'); _clearInFlightPeering(); - const result = await ensurePeered('https://remote.example'); + const result = await ensurePeered('https://remote.example', { kind: 'system' }); expect(result.status).toBe('active'); const row = testDb.select().from(schema.federationPeers) @@ -117,7 +117,7 @@ describe('performHandshake — persist remote instanceName', () => { const { ensurePeered, _clearInFlightPeering } = await import('./federationPeering.js'); _clearInFlightPeering(); - const result = await ensurePeered('https://remote.example'); + const result = await ensurePeered('https://remote.example', { kind: 'system' }); expect(result.status).toBe('active'); const row = testDb.select().from(schema.federationPeers) @@ -136,7 +136,7 @@ describe('performHandshake — persist remote instanceName', () => { const { ensurePeered, _clearInFlightPeering } = await import('./federationPeering.js'); _clearInFlightPeering(); - const result = await ensurePeered('https://remote.example'); + const result = await ensurePeered('https://remote.example', { kind: 'system' }); expect(result.status).toBe('active'); const row = testDb.select().from(schema.federationPeers) diff --git a/packages/server/src/utils/federationPeering.outboundGate.test.ts b/packages/server/src/utils/federationPeering.outboundGate.test.ts index fcaac048..a189b190 100644 --- a/packages/server/src/utils/federationPeering.outboundGate.test.ts +++ b/packages/server/src/utils/federationPeering.outboundGate.test.ts @@ -134,8 +134,8 @@ describe('ensurePeered — outbound gate behavior across (autoAccept × peer-row // Mid-handshake, the pending peer row existed. expect(peerRowsDuringHandshake).toHaveLength(1); - expect(peerRowsDuringHandshake[0].status).toBe('pending'); - expect(peerRowsDuringHandshake[0].origin).toBe('https://orbit.example'); + expect(peerRowsDuringHandshake[0]!.status).toBe('pending'); + expect(peerRowsDuringHandshake[0]!.origin).toBe('https://orbit.example'); // No outbound queue rows were created. const parents = testDb.select().from(schema.peerApprovalRequests).all(); @@ -166,17 +166,17 @@ describe('ensurePeered — outbound gate behavior across (autoAccept × peer-row // Parent row created. const parents = testDb.select().from(schema.peerApprovalRequests).all(); expect(parents).toHaveLength(1); - expect(parents[0].direction).toBe('outbound'); - expect(parents[0].origin).toBe('https://orbit.example'); - expect(parents[0].hmacSecret).toBeNull(); + expect(parents[0]!.direction).toBe('outbound'); + expect(parents[0]!.origin).toBe('https://orbit.example'); + expect(parents[0]!.hmacSecret).toBeNull(); // Subscriber row created. const subs = testDb.select().from(schema.peerApprovalSubscribers).all(); expect(subs).toHaveLength(1); - expect(subs[0].userId).toBe('user1'); - expect(subs[0].triggerReason).toBe('friend_add'); - expect(subs[0].triggerTarget).toBe('bob@orbit.example'); - expect(subs[0].requestId).toBe(parents[0].id); + expect(subs[0]!.userId).toBe('user1'); + expect(subs[0]!.triggerReason).toBe('friend_add'); + expect(subs[0]!.triggerTarget).toBe('bob@orbit.example'); + expect(subs[0]!.requestId).toBe(parents[0]!.id); // No federation_peers row created; no outbound POST attempted. const peers = testDb.select().from(schema.federationPeers).all(); @@ -288,7 +288,7 @@ describe('ensurePeered — outbound gate behavior across (autoAccept × peer-row // Existing pending peer row still present (existingPeerId path doesn't delete on failure). const peers = testDb.select().from(schema.federationPeers).all(); expect(peers).toHaveLength(1); - expect(peers[0].id).toBe('peer-pending'); + expect(peers[0]!.id).toBe('peer-pending'); }); // ─── Test 6 ───────────────────────────────────────────────────────────── @@ -350,7 +350,7 @@ describe('ensurePeered — outbound gate behavior across (autoAccept × peer-row const subsAfterFirst = testDb.select().from(schema.peerApprovalSubscribers).all(); expect(subsAfterFirst).toHaveLength(1); - const firstCreatedAt = subsAfterFirst[0].createdAt; + const firstCreatedAt = subsAfterFirst[0]!.createdAt; // Wait a moment so the refreshed createdAt would differ. await new Promise(r => setTimeout(r, 5)); @@ -366,18 +366,18 @@ describe('ensurePeered — outbound gate behavior across (autoAccept × peer-row // Still exactly one parent row keyed on (origin, direction='outbound'). const parents = testDb.select().from(schema.peerApprovalRequests).all(); expect(parents).toHaveLength(1); - expect(parents[0].direction).toBe('outbound'); - expect(parents[0].origin).toBe('https://orbit.example'); + expect(parents[0]!.direction).toBe('outbound'); + expect(parents[0]!.origin).toBe('https://orbit.example'); // Still exactly one subscriber keyed on (request_id, user_id, reason, target). const subs = testDb.select().from(schema.peerApprovalSubscribers).all(); expect(subs).toHaveLength(1); - expect(subs[0].userId).toBe('user1'); - expect(subs[0].triggerReason).toBe('friend_add'); - expect(subs[0].triggerTarget).toBe('bob@orbit.example'); + expect(subs[0]!.userId).toBe('user1'); + expect(subs[0]!.triggerReason).toBe('friend_add'); + expect(subs[0]!.triggerTarget).toBe('bob@orbit.example'); // createdAt was refreshed on the second call. - expect(subs[0].createdAt).toBeGreaterThan(firstCreatedAt); + expect(subs[0]!.createdAt).toBeGreaterThan(firstCreatedAt); }); // ─── Test 8 ───────────────────────────────────────────────────────────── @@ -409,8 +409,8 @@ describe('ensurePeered — outbound gate behavior across (autoAccept × peer-row // Exactly one parent row. const parents = testDb.select().from(schema.peerApprovalRequests).all(); expect(parents).toHaveLength(1); - expect(parents[0].origin).toBe('https://orbit.example'); - expect(parents[0].direction).toBe('outbound'); + expect(parents[0]!.origin).toBe('https://orbit.example'); + expect(parents[0]!.direction).toBe('outbound'); // Two subscribers, both pointing at the same parent. const subs = testDb.select().from(schema.peerApprovalSubscribers).all(); @@ -418,7 +418,7 @@ describe('ensurePeered — outbound gate behavior across (autoAccept × peer-row const userIds = subs.map(s => s.userId).sort(); expect(userIds).toEqual(['user1', 'user2']); for (const sub of subs) { - expect(sub.requestId).toBe(parents[0].id); + expect(sub.requestId).toBe(parents[0]!.id); expect(sub.triggerReason).toBe('friend_add'); expect(sub.triggerTarget).toBe('bob@orbit.example'); } @@ -443,13 +443,13 @@ describe('ensurePeered — outbound gate behavior across (autoAccept × peer-row const subs = testDb.select().from(schema.peerApprovalSubscribers).all(); expect(subs).toHaveLength(1); - expect(subs[0].userId).toBe('user1'); - expect(subs[0].triggerReason).toBe('space_join'); - expect(subs[0].triggerTarget).toBe('space-abc-123'); + expect(subs[0]!.userId).toBe('user1'); + expect(subs[0]!.triggerReason).toBe('space_join'); + expect(subs[0]!.triggerTarget).toBe('space-abc-123'); const parents = testDb.select().from(schema.peerApprovalRequests).all(); expect(parents).toHaveLength(1); - expect(parents[0].direction).toBe('outbound'); - expect(parents[0].origin).toBe('https://orbit.example'); + expect(parents[0]!.direction).toBe('outbound'); + expect(parents[0]!.origin).toBe('https://orbit.example'); }); }); diff --git a/packages/server/src/utils/federationPeering.test.ts b/packages/server/src/utils/federationPeering.test.ts index 2b044795..1a438cf7 100644 --- a/packages/server/src/utils/federationPeering.test.ts +++ b/packages/server/src/utils/federationPeering.test.ts @@ -51,9 +51,9 @@ describe('racePeering', () => { status: 'active', peerId: 'peer-1', })); - const result = await racePeering('https://example.com', 1_000, stub); + const result = await racePeering('https://example.com', 1_000, { kind: 'system' }, stub); expect(result).toEqual({ status: 'active', peerId: 'peer-1' }); - expect(stub).toHaveBeenCalledWith('https://example.com'); + expect(stub).toHaveBeenCalledWith('https://example.com', { kind: 'system' }); }); it('returns timeout when ensurePeered takes longer than the deadline', async () => { @@ -61,7 +61,7 @@ describe('racePeering', () => { const stub = vi.fn((): Promise => new Promise(() => { // Never resolves — simulates a slow handshake. })); - const racePromise = racePeering('https://example.com', 50, stub); + const racePromise = racePeering('https://example.com', 50, { kind: 'system' }, stub); await vi.advanceTimersByTimeAsync(50); const result = await racePromise; expect(result).toEqual({ status: 'timeout' }); @@ -73,7 +73,7 @@ describe('racePeering', () => { status: 'rejected', error: 'peer denied', })); - const result = await racePeering('https://example.com', 1_000, stub); + const result = await racePeering('https://example.com', 1_000, { kind: 'system' }, stub); expect(result).toEqual({ status: 'rejected', error: 'peer denied' }); }); @@ -83,7 +83,7 @@ describe('racePeering', () => { const stub = vi.fn(() => new Promise((_, reject) => { setTimeout(() => reject(new Error('late failure')), 30); })); - const racePromise = racePeering('https://example.com', 10, stub); + const racePromise = racePeering('https://example.com', 10, { kind: 'system' }, stub); await vi.advanceTimersByTimeAsync(10); const result = await racePromise; expect(result).toEqual({ status: 'timeout' }); @@ -104,7 +104,7 @@ describe('racePeering', () => { const stub = vi.fn(async (): Promise => { throw new Error('immediate handshake failure'); }); - const result = await racePeering('https://example.com', 1_000, stub); + const result = await racePeering('https://example.com', 1_000, { kind: 'system' }, stub); expect(result).toEqual({ status: 'failed', error: 'immediate handshake failure' }); // The handshake rejection was the race winner — no background warn should fire. await Promise.resolve(); @@ -156,7 +156,7 @@ describe('ensurePeered needs_attention handling', () => { const { ensurePeered } = await import('./federationPeering.js'); const fetchSpy = vi.spyOn(globalThis, 'fetch'); - const result = await ensurePeered('https://remote.example'); + const result = await ensurePeered('https://remote.example', { kind: 'system' }); expect(result.status).toBe('rejected'); if (result.status === 'rejected') { diff --git a/packages/server/src/utils/federationPeering.trustGuard.test.ts b/packages/server/src/utils/federationPeering.trustGuard.test.ts index 56b5cfe9..4d1c4fa9 100644 --- a/packages/server/src/utils/federationPeering.trustGuard.test.ts +++ b/packages/server/src/utils/federationPeering.trustGuard.test.ts @@ -103,7 +103,7 @@ describe('ensurePeered — refuses when unresolved inbound approval-request exis const { ensurePeered, _clearInFlightPeering } = await import('./federationPeering.js'); _clearInFlightPeering(); - const result = await ensurePeered('https://orbit.test'); + const result = await ensurePeered('https://orbit.test', { kind: 'system' }); expect(result.status).toBe('rejected'); if (result.status === 'rejected') { @@ -126,7 +126,7 @@ describe('ensurePeered — refuses when unresolved inbound approval-request exis const { ensurePeered, _clearInFlightPeering } = await import('./federationPeering.js'); _clearInFlightPeering(); - const result = await ensurePeered('https://nopeer.test'); + const result = await ensurePeered('https://nopeer.test', { kind: 'system' }); // Reached performHandshake — failure mode is 'failed' (network), NOT // the pre-handshake 'rejected' from the new guard. diff --git a/packages/server/src/utils/federationWorker.ts b/packages/server/src/utils/federationWorker.ts index e2c89507..dc7cb3c7 100644 --- a/packages/server/src/utils/federationWorker.ts +++ b/packages/server/src/utils/federationWorker.ts @@ -496,7 +496,7 @@ async function resolvePendingPeers(): Promise { for (const { peerId, peerOrigin } of pendingWithEntries) { console.log(`[federation-worker] Attempting auto-peer with ${peerOrigin}...`); - const result = await ensurePeered(peerOrigin); + const result = await ensurePeered(peerOrigin, { kind: 'system' }); switch (result.status) { case 'active': From 7cc360eb11fc0eda7be6dd00696cab989f499b73 Mon Sep 17 00:00:00 2001 From: Jannis Braun <151788261+TheZwiss@users.noreply.github.com> Date: Sun, 26 Apr 2026 21:46:32 +0200 Subject: [PATCH 09/20] feat(federation): fanout outbound subscribers on peer activation (any path) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The fanoutOutboundSubscribers helper is called from onPeerActivated, the single point all peer activations flow through (queue approval, /peer/initiate, autoAccept=1 remote, mutual-approval token verification). Subscribers see kind='approved' notification, parent + subscriber rows cascade-delete. Critical: cleanup triggers on status->active, not on the local approve action — when the remote also gates, the peer goes to awaiting_approval first and subscribers must remain queued. --- ...ationPeerActivation.outboundFanout.test.ts | 275 ++++++++++++++++++ .../src/utils/federationPeerActivation.ts | 87 ++++++ 2 files changed, 362 insertions(+) create mode 100644 packages/server/src/utils/federationPeerActivation.outboundFanout.test.ts diff --git a/packages/server/src/utils/federationPeerActivation.outboundFanout.test.ts b/packages/server/src/utils/federationPeerActivation.outboundFanout.test.ts new file mode 100644 index 00000000..9284075d --- /dev/null +++ b/packages/server/src/utils/federationPeerActivation.outboundFanout.test.ts @@ -0,0 +1,275 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import Database from 'better-sqlite3'; +import { drizzle } from 'drizzle-orm/better-sqlite3'; +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import * as schema from '../db/schema.js'; +import { generateSnowflake, 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, +})); + +// Disable the relay so syncPeerMutationLog is a no-op (no fetches). +vi.mock('./federationOutbox.js', () => ({ + isFederationRelayEnabled: () => false, +})); + +vi.mock('./federationAuth.js', () => ({ + getOurOrigin: () => 'https://local.example', + buildFederationHeaders: (_body: string, _secret: string, _origin: string) => ({ + 'Content-Type': 'application/json', + 'X-Federation-Origin': _origin, + }), + generateHmacSecret: () => 'mock-hmac-secret', +})); + +vi.mock('../routes/federation.js', () => ({ + processRelayEvents: vi.fn().mockResolvedValue({ accepted: [], rejected: [], undeliverable: [] }), + 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: () => [], + sendToDmMembers: vi.fn(), + evictFederatedCallsForHost: vi.fn().mockReturnValue(0), + }, +})); + +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 seedUser(id: string, username: string): void { + testDb.insert(schema.users).values({ + id, + username, + passwordHash: 'x', + createdAt: Date.now(), + }).run(); +} + +function seedActivePeer(id: string, origin: string): void { + testDb.insert(schema.federationPeers).values({ + id, + origin, + hmacSecret: 'secret', + status: 'active', + lastSyncedAt: Date.now(), // non-zero so startupBootstrapSync wouldn't pick it up + createdAt: Date.now(), + }).run(); +} + +describe('onPeerActivated — outbound subscriber fanout', () => { + beforeEach(() => { + sqlite = new Database(':memory:'); + testDb = drizzle(sqlite, { schema }); + applyMigrations(sqlite); + vi.clearAllMocks(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + sqlite.close(); + }); + + // ─── Test 10 ──────────────────────────────────────────────────────────── + // onPeerActivated with outbound queue + 2 subscribers → fans out approved + // notifications to each subscriber, deletes parent (cascade clears subs), + // and sends peering_notification_received WS to each subscriber. + it('fans out approved notifications to all subscribers and deletes parent', async () => { + seedUser('user1', 'alice'); + seedUser('user2', 'bob'); + seedActivePeer('peer-active', 'https://orbit.example'); + + const parentId = generateSnowflake(); + const now = Date.now(); + testDb.insert(schema.peerApprovalRequests).values({ + id: parentId, + origin: 'https://orbit.example', + direction: 'outbound', + instanceName: null, + hmacSecret: null, + requestedAt: now, + expiresAt: now + 30 * 24 * 60 * 60 * 1000, + approvalToken: null, + }).run(); + + testDb.insert(schema.peerApprovalSubscribers).values([ + { + id: generateSnowflake(), + requestId: parentId, + userId: 'user1', + triggerReason: 'friend_add', + triggerTarget: 'someone@orbit.example', + createdAt: now, + }, + { + id: generateSnowflake(), + requestId: parentId, + userId: 'user2', + triggerReason: 'space_join', + triggerTarget: 'space-x', + createdAt: now, + }, + ]).run(); + + const { onPeerActivated } = await import('./federationPeerActivation.js'); + await onPeerActivated('peer-active', 'accept_new'); + + // 2 approved notifications written. + const notifs = testDb.select().from(schema.peerApprovalNotifications).all(); + expect(notifs).toHaveLength(2); + expect(notifs.every(n => n.kind === 'approved')).toBe(true); + expect(notifs.every(n => n.peerOrigin === 'https://orbit.example')).toBe(true); + expect(notifs.every(n => n.readAt === null)).toBe(true); + + const userIds = notifs.map(n => n.userId).sort(); + expect(userIds).toEqual(['user1', 'user2']); + + const u1Notif = notifs.find(n => n.userId === 'user1'); + expect(u1Notif?.triggerReason).toBe('friend_add'); + expect(u1Notif?.triggerTarget).toBe('someone@orbit.example'); + const u2Notif = notifs.find(n => n.userId === 'user2'); + expect(u2Notif?.triggerReason).toBe('space_join'); + expect(u2Notif?.triggerTarget).toBe('space-x'); + + // Parent deleted, subscribers cascade-deleted. + const remainingParents = testDb.select().from(schema.peerApprovalRequests).all(); + expect(remainingParents).toHaveLength(0); + const remainingSubs = testDb.select().from(schema.peerApprovalSubscribers).all(); + expect(remainingSubs).toHaveLength(0); + + // peering_notification_received WS sent to each subscriber. + const { connectionManager } = await import('../ws/handler.js'); + expect(connectionManager.sendToUser).toHaveBeenCalledWith('user1', { + type: 'peering_notification_received', + kind: 'approved', + }); + expect(connectionManager.sendToUser).toHaveBeenCalledWith('user2', { + type: 'peering_notification_received', + kind: 'approved', + }); + // Exactly two sendToUser calls (one per subscriber). + const sendToUserCalls = vi.mocked(connectionManager.sendToUser).mock.calls; + const peeringCalls = sendToUserCalls.filter( + c => (c[1] as { type?: string }).type === 'peering_notification_received', + ); + expect(peeringCalls).toHaveLength(2); + + // sendToAdmins fired (the standard onPeerActivated broadcast). + expect(connectionManager.sendToAdmins).toHaveBeenCalledWith({ + type: 'federation_peers_changed', + }); + }); + + // ─── Test 11 ──────────────────────────────────────────────────────────── + // onPeerActivated with no outbound queue → no-op fanout (no notifications, + // no sendToUser calls, no errors). The standard sendToAdmins broadcast + // still fires (that's onPeerActivated's own concern, not the fanout's). + it('is a no-op when no outbound queue row exists for the activated origin', async () => { + seedActivePeer('peer-no-queue', 'https://orphan.example'); + + const { onPeerActivated } = await import('./federationPeerActivation.js'); + await expect(onPeerActivated('peer-no-queue', 'health_check_recovery')).resolves.toBeUndefined(); + + const notifs = testDb.select().from(schema.peerApprovalNotifications).all(); + expect(notifs).toHaveLength(0); + + const { connectionManager } = await import('../ws/handler.js'); + const sendToUserCalls = vi.mocked(connectionManager.sendToUser).mock.calls; + const peeringCalls = sendToUserCalls.filter( + c => (c[1] as { type?: string }).type === 'peering_notification_received', + ); + expect(peeringCalls).toHaveLength(0); + }); + + // ─── Test 12 ──────────────────────────────────────────────────────────── + // CRITICAL CORRECTNESS PROPERTY: fanout runs regardless of activation + // path. Here we use reason='initiate_accepted' (admin-initiated via + // /peer/initiate, NOT via the outbound queue approval handler) and assert + // the fanout still clears subscribers and notifies them. This proves the + // centralized cleanup works for any activation path — not just the + // outbound-approve flow. + it('fans out and clears even when activated via admin-initiated path (initiate_accepted reason)', async () => { + seedUser('user1', 'alice'); + seedActivePeer('peer-admin-initiated', 'https://orbit.example'); + + const parentId = generateSnowflake(); + const now = Date.now(); + testDb.insert(schema.peerApprovalRequests).values({ + id: parentId, + origin: 'https://orbit.example', + direction: 'outbound', + instanceName: null, + hmacSecret: null, + requestedAt: now, + expiresAt: now + 30 * 24 * 60 * 60 * 1000, + approvalToken: null, + }).run(); + + testDb.insert(schema.peerApprovalSubscribers).values({ + id: generateSnowflake(), + requestId: parentId, + userId: 'user1', + triggerReason: 'friend_add', + triggerTarget: 'someone@orbit.example', + createdAt: now, + }).run(); + + const { onPeerActivated } = await import('./federationPeerActivation.js'); + // 'initiate_accepted' = admin used /peer/initiate, bypassing the queue + // approval handler entirely. The fanout MUST still run. + await onPeerActivated('peer-admin-initiated', 'initiate_accepted'); + + // Subscriber was notified. + const notifs = testDb.select().from(schema.peerApprovalNotifications).all(); + expect(notifs).toHaveLength(1); + expect(notifs[0]!.kind).toBe('approved'); + expect(notifs[0]!.userId).toBe('user1'); + expect(notifs[0]!.peerOrigin).toBe('https://orbit.example'); + expect(notifs[0]!.triggerReason).toBe('friend_add'); + expect(notifs[0]!.triggerTarget).toBe('someone@orbit.example'); + + // Parent + subscriber cascade-cleared. + const remainingParents = testDb.select().from(schema.peerApprovalRequests).all(); + expect(remainingParents).toHaveLength(0); + const remainingSubs = testDb.select().from(schema.peerApprovalSubscribers).all(); + expect(remainingSubs).toHaveLength(0); + + // WS event sent to the subscriber. + const { connectionManager } = await import('../ws/handler.js'); + expect(connectionManager.sendToUser).toHaveBeenCalledWith('user1', { + type: 'peering_notification_received', + kind: 'approved', + }); + }); +}); diff --git a/packages/server/src/utils/federationPeerActivation.ts b/packages/server/src/utils/federationPeerActivation.ts index c68f8d80..04dd71eb 100644 --- a/packages/server/src/utils/federationPeerActivation.ts +++ b/packages/server/src/utils/federationPeerActivation.ts @@ -3,6 +3,7 @@ import * as schema from '../db/schema.js'; import { and, eq } from 'drizzle-orm'; import { isFederationRelayEnabled } from './federationOutbox.js'; import { buildFederationHeaders, getOurOrigin } from './federationAuth.js'; +import { generateSnowflake } from './snowflake.js'; import type { FederationRelayEvent } from '@backspace/shared'; export type PeerActivationReason = @@ -50,6 +51,7 @@ export async function onPeerActivated( try { resetOutboxBackoff(peerId); await syncPeerMutationLog(peerId, reason); + await fanoutOutboundSubscribers(peerId); const { connectionManager } = await import('../ws/handler.js'); connectionManager.sendToAdmins({ type: 'federation_peers_changed' as const }); } catch (err) { @@ -173,6 +175,91 @@ export async function syncPeerMutationLog( } } +/** + * Fan out approved-notifications to all subscribers of any outbound + * peer_approval_requests row matching this activated peer's origin, then + * cascade-delete the parent row (which clears subscriber rows via the + * schema's onDelete: 'cascade'). + * + * Single-source-of-truth cleanup hook for outbound subscribers. Runs from + * inside onPeerActivated so EVERY activation path triggers it, regardless + * of how the peer became active (queue approval, /peer/initiate, + * autoAccept=1 remote, mutual-approval token verification). + * + * Critical correctness invariant: cleanup hangs off status→active, NOT off + * the local admin's approve action. When the remote also gates, our peer + * row goes to awaiting_approval first; subscribers must remain queued + * until the remote also approves and the peer fully activates. + * + * No-op when no outbound queue row exists for the origin (the common case + * for non-gated peerings). + * + * Does NOT broadcast federation_peers_changed itself — onPeerActivated + * does that after this returns, so the queue-change signal is unified + * with the peer-state-change signal admins already receive. + */ +async function fanoutOutboundSubscribers(peerId: string): Promise { + const db = getDb(); + const peer = db + .select({ origin: schema.federationPeers.origin }) + .from(schema.federationPeers) + .where(eq(schema.federationPeers.id, peerId)) + .get(); + if (!peer) return; + + const parent = db + .select() + .from(schema.peerApprovalRequests) + .where( + and( + eq(schema.peerApprovalRequests.origin, peer.origin), + eq(schema.peerApprovalRequests.direction, 'outbound'), + ), + ) + .get(); + if (!parent) return; + + const subscribers = db + .select() + .from(schema.peerApprovalSubscribers) + .where(eq(schema.peerApprovalSubscribers.requestId, parent.id)) + .all(); + + const now = Date.now(); + const { connectionManager } = await import('../ws/handler.js'); + + for (const sub of subscribers) { + db.insert(schema.peerApprovalNotifications) + .values({ + id: generateSnowflake(), + userId: sub.userId, + kind: 'approved', + peerOrigin: peer.origin, + triggerReason: sub.triggerReason, + triggerTarget: sub.triggerTarget, + createdAt: now, + readAt: null, + }) + .run(); + + connectionManager.sendToUser(sub.userId, { + type: 'peering_notification_received' as const, + kind: 'approved', + }); + } + + // Cascade-deletes subscriber rows via onDelete: 'cascade'. + db.delete(schema.peerApprovalRequests) + .where(eq(schema.peerApprovalRequests.id, parent.id)) + .run(); + + if (subscribers.length > 0) { + console.log( + `[federation] fanoutOutboundSubscribers(${peerId}) approved ${subscribers.length} subscriber notification${subscribers.length === 1 ? '' : 's'} for ${peer.origin}`, + ); + } +} + /** * Startup bootstrap — scan for freshly-peered rows (status='active', lastSyncedAt=0) * and run onPeerActivated for each. Replaces runInitialSyncForNewPeers. From 42355ee8891cfe25ba34f4e2b727611fe3a4f5d8 Mon Sep 17 00:00:00 2001 From: Jannis Braun <151788261+TheZwiss@users.noreply.github.com> Date: Sun, 26 Apr 2026 22:01:14 +0200 Subject: [PATCH 10/20] feat(federation): direction-branched approve and deny for outbound queue - /approve on outbound: generates HMAC, sends /peer/accept to remote. 200 -> activate peer + onPeerActivated cleanup. 202 -> awaiting_approval, capture token, queue row + subscribers REMAIN. 4xx/5xx/network -> clean up peer row, leave queue for admin retry. - /deny on outbound: fans out kind='denied' notifications, cascade-deletes parent + subscribers, broadcasts admin event. No remote network call. - /approve and /deny on inbound: existing behavior preserved verbatim. - GET /approval-requests: response includes direction; outbound rows carry subscribers[] (joined with users.username, possibly empty). - Removes Task 1's temporary /deny scaffolding guard now that the direction-branched dispatcher handles outbound rows correctly. - Updates docs/systems/federation.md to describe direction-branched flow. --- docs/systems/federation.md | 34 +- .../routes/federation.outboundApprove.test.ts | 471 +++++++++++ .../routes/federation.outboundDeny.test.ts | 387 +++++++++ packages/server/src/routes/federation.ts | 741 +++++++++++++----- 4 files changed, 1412 insertions(+), 221 deletions(-) create mode 100644 packages/server/src/routes/federation.outboundApprove.test.ts create mode 100644 packages/server/src/routes/federation.outboundDeny.test.ts diff --git a/docs/systems/federation.md b/docs/systems/federation.md index ca4d2310..a418213c 100644 --- a/docs/systems/federation.md +++ b/docs/systems/federation.md @@ -145,17 +145,35 @@ When `autoAcceptPeering` is `false` and an instance calls `POST /api/federation/ - `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` -2. A standard `peer/accept` handshake is sent to the requesting origin -3. On success the local peer becomes `active`; the `peer_approval_requests` row is deleted +**Approval flow** — Admin approves via `POST /api/federation/approval-requests/:id/approve`. The handler dispatches on `peer_approval_requests.direction`: -**Denial flow** — Admin denies via `POST /api/federation/approval-requests/:id/deny`: +*Inbound* (remote asked to peer with us): +1. A fresh `federationPeer` record is created (or existing `rejected`/`awaiting_approval` record is upserted) with status `pending` +2. A standard `peer/accept` handshake is sent to the requesting origin (forwarding the stored `approvalToken` per [Approval Token Verification](#approval-token-verification)) +3. On 200 the local peer becomes `active`; on 202 the peer transitions to `awaiting_approval` (mutual-gate); the `peer_approval_requests` row is deleted in both cases + +*Outbound* (local users asked us to peer with a remote — created by the gate in `ensurePeered`): +1. A fresh `federationPeer` row is created with status `pending` (HMAC generated at this moment — outbound queue rows store `hmac_secret = NULL`) +2. `peer/accept` is sent to the remote with no `approvalToken` (we are the initiator with no prior token from this remote) +3. On 200 the peer is promoted to `active`. `onPeerActivated` then fires `fanoutOutboundSubscribers` (see [Outbound peering gate](#outbound-peering-gate)) which writes `kind='approved'` notifications for each subscriber and cascade-deletes the parent `peer_approval_requests` row. The handler does NOT duplicate this cleanup. +4. On 202 the peer transitions to `awaiting_approval`, captures the returned `approvalToken`, and the queue row + subscribers are LEFT INTACT — they wait for the eventual remote-admin approval. `onPeerActivated` is NOT called on this path. +5. On 4xx/5xx/network error the peer row is deleted; the queue row is left intact so the admin can retry. Response status is `502`/`503`/`504` accordingly. +6. Response body shape: `{ success, peerStatus: 'active' | 'awaiting_approval', peer? }`. The `peerStatus` field is the outbound-only signal. + +**Denial flow** — Admin denies via `POST /api/federation/approval-requests/:id/deny`. The handler dispatches on direction: + +*Inbound*: 1. Server sends `POST {origin}/api/federation/peer/denied` signed with the requester's `hmac_secret` (from the approval request row) 2. Receiving instance transitions its local peer record from `awaiting_approval` → `rejected` 3. A local `federationPeer` record is upserted with status `rejected` to block future unsolicited requests from the same origin 4. The `peer_approval_requests` row is deleted +*Outbound*: +1. For each row in `peer_approval_subscribers`, a `kind='denied'` notification is inserted into `peer_approval_notifications` and a `peering_notification_received` WS event is sent to the user +2. The parent `peer_approval_requests` row is deleted (cascade clears subscribers) +3. No remote network call — the remote never knew we were considering this peer +4. `federation_peers_changed` is broadcast to admins so the queue UI refreshes + **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 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. @@ -193,9 +211,9 @@ The pre-handshake guard above closes the most reliable trigger but cannot preven | `/api/federation/peers/:id/permanent` | DELETE | JWT + admin | Hard-delete revoked peer record | | `/api/federation/peers/:id/reset` | POST | JWT + admin | Delete peer record (cascade-deletes outbox). Only admissible in `needs_attention` state. | | `/api/federation/peers/:id/rotate` | POST | JWT + admin | Trigger immediate secret rotation | -| `/api/federation/approval-requests` | GET | JWT + admin | List pending peering approval requests | -| `/api/federation/approval-requests/:id/approve` | POST | JWT + admin | Approve request, initiate handshake | -| `/api/federation/approval-requests/:id/deny` | POST | JWT + admin | Deny request, notify requester | +| `/api/federation/approval-requests` | GET | JWT + admin | List pending peering approval requests (inbound + outbound). Outbound rows include `subscribers: ApprovalRequestSubscriberSummary[]` (possibly empty). Inbound rows omit `subscribers`. Each row carries `direction: 'inbound' \| 'outbound'`. | +| `/api/federation/approval-requests/:id/approve` | POST | JWT + admin | Approve request — direction-branched (see Approval flow above) | +| `/api/federation/approval-requests/:id/deny` | POST | JWT + admin | Deny request — direction-branched (see Denial flow above) | **`POST /api/federation/peer/ensure`** — Wraps `ensurePeered()`. Accepts `{ remoteOrigin: string }` in body. Returns `{ peeringStatus, peerId?, error? }` where `peeringStatus` is one of `active`, `pending`, `awaiting_approval`, `rejected`, `unreachable`, or `revoked`. diff --git a/packages/server/src/routes/federation.outboundApprove.test.ts b/packages/server/src/routes/federation.outboundApprove.test.ts new file mode 100644 index 00000000..80e38b63 --- /dev/null +++ b/packages/server/src/routes/federation.outboundApprove.test.ts @@ -0,0 +1,471 @@ +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', + }; +}); + +const sentToUser = vi.fn(); +const sentToAdmins = vi.fn(); + +vi.mock('../ws/handler.js', () => ({ + connectionManager: { + sendToAdmins: sentToAdmins, + getAllOnlineUserIds: () => [], + sendToUser: sentToUser, + sendToDmMembers: vi.fn(), + }, +})); + +// Mock the activation module entirely. The route handler is responsible for +// CALLING onPeerActivated on the 200 path; we assert that here. The fanout +// behavior itself is covered by federationPeerActivation.outboundFanout.test.ts +// (Task 6) — keeping that separation avoids running real network sync from +// inside a route test. +const onPeerActivatedMock = vi.fn(async () => undefined); +vi.mock('../utils/federationPeerActivation.js', () => ({ + onPeerActivated: onPeerActivatedMock, + 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(); +} + +function seedUser(id: string, username: string): void { + testDb.insert(schema.users).values({ + id, + username, + passwordHash: 'x', + displayName: username, + createdAt: Date.now(), + }).run(); +} + +function seedOutboundRequest(opts: { + id: string; + origin: string; + instanceName?: string | null; + subscribers: Array<{ userId: string; reason: 'friend_add' | 'space_join' | 'direct_message'; target: string }>; +}): void { + const now = Date.now(); + testDb.insert(schema.peerApprovalRequests).values({ + id: opts.id, + origin: opts.origin, + direction: 'outbound', + instanceName: opts.instanceName ?? null, + hmacSecret: null, + requestedAt: now, + expiresAt: now + 30 * 24 * 60 * 60 * 1000, + approvalToken: null, + }).run(); + for (const sub of opts.subscribers) { + testDb.insert(schema.peerApprovalSubscribers).values({ + id: `sub-${opts.id}-${sub.userId}`, + requestId: opts.id, + userId: sub.userId, + triggerReason: sub.reason, + triggerTarget: sub.target, + createdAt: 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 direction', () => { + let app: FastifyInstance; + + beforeEach(async () => { + sqlite = new Database(':memory:'); + testDb = drizzle(sqlite, { schema }); + applyMigrations(sqlite); + seedInstanceSettings(); + seedUser('alice', 'alice'); + seedUser('bob', 'bob'); + sentToUser.mockClear(); + sentToAdmins.mockClear(); + onPeerActivatedMock.mockClear(); + app = await buildApp(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + sqlite.close(); + }); + + // Test 13: 200 outbound activates peer + delegates fanout to onPeerActivated. + it('200 from remote → peer becomes active; delegates fanout to onPeerActivated; handler does NOT manually clean subscribers', async () => { + seedOutboundRequest({ + id: 'req-out-200', + origin: 'https://remote.example', + instanceName: 'Remote Inst', + subscribers: [ + { userId: 'alice', reason: 'friend_add', target: 'someone@remote.example' }, + { userId: 'bob', reason: 'friend_add', target: 'other@remote.example' }, + ], + }); + + vi.spyOn(globalThis, 'fetch').mockResolvedValue( + new Response( + JSON.stringify({ accepted: true, instanceName: 'Remote Backspace' }), + { status: 200, headers: { 'Content-Type': 'application/json' } }, + ), + ); + + const response = await app.inject({ + method: 'POST', + url: '/api/federation/approval-requests/req-out-200/approve', + }); + + expect(response.statusCode).toBe(200); + const body = response.json() as { success: boolean; peerStatus: string }; + expect(body.success).toBe(true); + expect(body.peerStatus).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?.instanceName).toBe('Remote Backspace'); + expect(peer?.approvalToken).toBeNull(); + + // Handler delegates fanout: onPeerActivated MUST be called with the new peer's id. + expect(onPeerActivatedMock).toHaveBeenCalledTimes(1); + expect(onPeerActivatedMock).toHaveBeenCalledWith(peer!.id, 'approval_handshake'); + + // The handler must NOT pre-emptively clean up subscribers — that's + // onPeerActivated's job. Since we mocked onPeerActivated, the parent + + // subscribers should still be present here. (In production, the real + // onPeerActivated then cascades them; covered by Task 6's fanout test.) + expect(testDb.select().from(schema.peerApprovalRequests) + .where(eq(schema.peerApprovalRequests.id, 'req-out-200')).get()).toBeDefined(); + expect(testDb.select().from(schema.peerApprovalSubscribers) + .where(eq(schema.peerApprovalSubscribers.requestId, 'req-out-200')).all()).toHaveLength(2); + + // No notifications written by the handler directly. + expect(testDb.select().from(schema.peerApprovalNotifications).all()).toHaveLength(0); + + // No peering_notification_received fired by the handler. + const peeringEvents = sentToUser.mock.calls.filter(call => { + const ev = call[1] as { type?: string }; + return ev?.type === 'peering_notification_received'; + }); + expect(peeringEvents).toHaveLength(0); + }); + + // Test 14: 202 outbound transitions to awaiting_approval and leaves queue intact. + it('202 from remote → peer becomes awaiting_approval, captures approvalToken, queue + subscribers REMAIN, onPeerActivated NOT called', async () => { + seedOutboundRequest({ + id: 'req-out-202', + origin: 'https://remote.example', + subscribers: [ + { userId: 'alice', reason: 'friend_add', target: 'someone@remote.example' }, + ], + }); + + const remoteToken = 'a'.repeat(64); + vi.spyOn(globalThis, 'fetch').mockResolvedValue( + new Response( + JSON.stringify({ queued: true, approvalToken: remoteToken }), + { status: 202, headers: { 'Content-Type': 'application/json' } }, + ), + ); + + const response = await app.inject({ + method: 'POST', + url: '/api/federation/approval-requests/req-out-202/approve', + }); + + expect(response.statusCode).toBe(200); + const body = response.json() as { success: boolean; peerStatus: string }; + expect(body.success).toBe(true); + expect(body.peerStatus).toBe('awaiting_approval'); + + 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); + + // Outbound queue row + subscribers REMAIN — they wait for full activation. + const stillQueued = testDb.select().from(schema.peerApprovalRequests) + .where(eq(schema.peerApprovalRequests.id, 'req-out-202')).get(); + expect(stillQueued).toBeDefined(); + expect(stillQueued?.direction).toBe('outbound'); + const stillSubscribed = testDb.select().from(schema.peerApprovalSubscribers) + .where(eq(schema.peerApprovalSubscribers.requestId, 'req-out-202')).all(); + expect(stillSubscribed).toHaveLength(1); + + // onPeerActivated must NOT be called — peer is not yet active. + expect(onPeerActivatedMock).not.toHaveBeenCalled(); + + // No notifications written. + expect(testDb.select().from(schema.peerApprovalNotifications).all()).toHaveLength(0); + + // No peering_notification_received fired. + const peeringEvents = sentToUser.mock.calls.filter(call => { + const ev = call[1] as { type?: string }; + return ev?.type === 'peering_notification_received'; + }); + expect(peeringEvents).toHaveLength(0); + }); + + // Test 15: network error returns 503; peer cleaned up; queue intact. + it('network error from remote → 503; peer row cleaned up; queue + subscribers REMAIN; onPeerActivated NOT called', async () => { + seedOutboundRequest({ + id: 'req-out-net', + origin: 'https://remote.example', + subscribers: [ + { userId: 'alice', reason: 'friend_add', target: 'someone@remote.example' }, + ], + }); + + vi.spyOn(globalThis, 'fetch').mockRejectedValue(new TypeError('fetch failed: connection refused')); + + const response = await app.inject({ + method: 'POST', + url: '/api/federation/approval-requests/req-out-net/approve', + }); + + expect(response.statusCode).toBe(503); + + // Peer row cleaned up. + expect(testDb.select().from(schema.federationPeers) + .where(eq(schema.federationPeers.origin, 'https://remote.example')).get()).toBeUndefined(); + + // Queue + subscribers UNTOUCHED for admin retry. + expect(testDb.select().from(schema.peerApprovalRequests) + .where(eq(schema.peerApprovalRequests.id, 'req-out-net')).get()).toBeDefined(); + expect(testDb.select().from(schema.peerApprovalSubscribers) + .where(eq(schema.peerApprovalSubscribers.requestId, 'req-out-net')).all()).toHaveLength(1); + + expect(onPeerActivatedMock).not.toHaveBeenCalled(); + expect(testDb.select().from(schema.peerApprovalNotifications).all()).toHaveLength(0); + }); + + it('5xx from remote → 502; peer row cleaned up; queue + subscribers REMAIN', async () => { + seedOutboundRequest({ + id: 'req-out-500', + origin: 'https://remote.example', + subscribers: [ + { userId: 'alice', reason: 'friend_add', target: 'someone@remote.example' }, + ], + }); + + vi.spyOn(globalThis, 'fetch').mockResolvedValue( + new Response(JSON.stringify({ error: 'remote boom' }), { + status: 500, + headers: { 'Content-Type': 'application/json' }, + }), + ); + + const response = await app.inject({ + method: 'POST', + url: '/api/federation/approval-requests/req-out-500/approve', + }); + + expect(response.statusCode).toBe(502); + const body = response.json() as { remoteStatus?: number }; + expect(body.remoteStatus).toBe(500); + + expect(testDb.select().from(schema.federationPeers) + .where(eq(schema.federationPeers.origin, 'https://remote.example')).get()).toBeUndefined(); + expect(testDb.select().from(schema.peerApprovalRequests) + .where(eq(schema.peerApprovalRequests.id, 'req-out-500')).get()).toBeDefined(); + expect(testDb.select().from(schema.peerApprovalSubscribers) + .where(eq(schema.peerApprovalSubscribers.requestId, 'req-out-500')).all()).toHaveLength(1); + }); + + it('does NOT forward approvalToken in outbound /peer/accept body (we hold no remote token)', async () => { + seedOutboundRequest({ + id: 'req-out-noforward', + origin: 'https://remote.example', + subscribers: [ + { userId: 'alice', reason: 'friend_add', target: 'x@remote.example' }, + ], + }); + + const fetchSpy = vi.spyOn(globalThis, 'fetch').mockResolvedValue( + new Response(JSON.stringify({ accepted: true, instanceName: 'R' }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }), + ); + + await app.inject({ + method: 'POST', + url: '/api/federation/approval-requests/req-out-noforward/approve', + }); + + const init = fetchSpy.mock.calls[0]?.[1]; + const body = JSON.parse(init?.body as string) as { approvalToken?: string; sourceOrigin?: string; hmacSecret?: string }; + expect(body.approvalToken).toBeUndefined(); + expect(body.sourceOrigin).toBe('https://local.example'); + expect(body.hmacSecret).toBe('mock-generated-secret'); + }); +}); + +describe('GET /api/federation/approval-requests — direction + outbound subscribers in response shape', () => { + let app: FastifyInstance; + + beforeEach(async () => { + sqlite = new Database(':memory:'); + testDb = drizzle(sqlite, { schema }); + applyMigrations(sqlite); + seedInstanceSettings(); + seedUser('alice', 'alice'); + seedUser('bob', 'bob'); + app = await buildApp(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + sqlite.close(); + }); + + it('inbound rows have no subscribers field; outbound rows include subscriber summaries with username', async () => { + const now = Date.now(); + testDb.insert(schema.peerApprovalRequests).values({ + id: 'req-inbound', + origin: 'https://inbound.example', + direction: 'inbound', + instanceName: 'Inbound', + hmacSecret: 'their-secret', + requestedAt: now, + expiresAt: now + 30 * 24 * 60 * 60 * 1000, + }).run(); + seedOutboundRequest({ + id: 'req-outbound', + origin: 'https://outbound.example', + instanceName: null, + subscribers: [ + { userId: 'alice', reason: 'friend_add', target: 'a@outbound.example' }, + { userId: 'bob', reason: 'space_join', target: 'invite-code-xyz' }, + ], + }); + + const response = await app.inject({ + method: 'GET', + url: '/api/federation/approval-requests', + }); + + expect(response.statusCode).toBe(200); + const body = response.json() as { + requests: Array<{ + id: string; + direction: string; + subscribers?: Array<{ userId: string; username: string; triggerReason: string; triggerTarget: string }>; + }>; + }; + const inbound = body.requests.find(r => r.id === 'req-inbound'); + const outbound = body.requests.find(r => r.id === 'req-outbound'); + expect(inbound).toBeDefined(); + expect(inbound?.direction).toBe('inbound'); + expect(inbound?.subscribers).toBeUndefined(); + + expect(outbound).toBeDefined(); + expect(outbound?.direction).toBe('outbound'); + expect(outbound?.subscribers).toBeDefined(); + expect(outbound?.subscribers).toHaveLength(2); + const usernames = new Set(outbound!.subscribers!.map(s => s.username)); + expect(usernames).toEqual(new Set(['alice', 'bob'])); + const reasons = new Set(outbound!.subscribers!.map(s => s.triggerReason)); + expect(reasons).toEqual(new Set(['friend_add', 'space_join'])); + }); + + it('outbound row with zero subscribers returns subscribers: [] (not undefined)', async () => { + const now = Date.now(); + testDb.insert(schema.peerApprovalRequests).values({ + id: 'req-empty-out', + origin: 'https://lonely.example', + direction: 'outbound', + instanceName: null, + hmacSecret: null, + requestedAt: now, + expiresAt: now + 30 * 24 * 60 * 60 * 1000, + }).run(); + + const response = await app.inject({ + method: 'GET', + url: '/api/federation/approval-requests', + }); + + expect(response.statusCode).toBe(200); + const body = response.json() as { + requests: Array<{ id: string; direction: string; subscribers?: unknown }>; + }; + const row = body.requests.find(r => r.id === 'req-empty-out'); + expect(row?.direction).toBe('outbound'); + expect(Array.isArray(row?.subscribers)).toBe(true); + expect(row?.subscribers).toEqual([]); + }); +}); diff --git a/packages/server/src/routes/federation.outboundDeny.test.ts b/packages/server/src/routes/federation.outboundDeny.test.ts new file mode 100644 index 00000000..27f66bc1 --- /dev/null +++ b/packages/server/src/routes/federation.outboundDeny.test.ts @@ -0,0 +1,387 @@ +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', + }; +}); + +const sentToUser = vi.fn(); +const sentToAdmins = vi.fn(); + +vi.mock('../ws/handler.js', () => ({ + connectionManager: { + sendToAdmins: sentToAdmins, + getAllOnlineUserIds: () => [], + sendToUser: sentToUser, + sendToDmMembers: vi.fn(), + }, +})); + +const onPeerActivatedMock = vi.fn(async () => undefined); +vi.mock('../utils/federationPeerActivation.js', () => ({ + onPeerActivated: onPeerActivatedMock, + 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(); +} + +function seedUser(id: string, username: string): void { + testDb.insert(schema.users).values({ + id, + username, + passwordHash: 'x', + displayName: username, + createdAt: Date.now(), + }).run(); +} + +function seedOutboundRequest(opts: { + id: string; + origin: string; + subscribers: Array<{ userId: string; reason: 'friend_add' | 'space_join' | 'direct_message'; target: string }>; +}): void { + const now = Date.now(); + testDb.insert(schema.peerApprovalRequests).values({ + id: opts.id, + origin: opts.origin, + direction: 'outbound', + instanceName: null, + hmacSecret: null, + requestedAt: now, + expiresAt: now + 30 * 24 * 60 * 60 * 1000, + approvalToken: null, + }).run(); + for (const sub of opts.subscribers) { + testDb.insert(schema.peerApprovalSubscribers).values({ + id: `sub-${opts.id}-${sub.userId}`, + requestId: opts.id, + userId: sub.userId, + triggerReason: sub.reason, + triggerTarget: sub.target, + createdAt: 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/deny — outbound direction', () => { + let app: FastifyInstance; + + beforeEach(async () => { + sqlite = new Database(':memory:'); + testDb = drizzle(sqlite, { schema }); + applyMigrations(sqlite); + seedInstanceSettings(); + seedUser('alice', 'alice'); + seedUser('bob', 'bob'); + sentToUser.mockClear(); + sentToAdmins.mockClear(); + onPeerActivatedMock.mockClear(); + app = await buildApp(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + sqlite.close(); + }); + + // Test 16: outbound deny fans out denied notifications + cascade-deletes. + it('outbound deny → writes denied notifications for each subscriber, sends WS to each, cascade-deletes parent + subscribers, broadcasts admin event', async () => { + seedOutboundRequest({ + id: 'req-out-deny', + origin: 'https://remote.example', + subscribers: [ + { userId: 'alice', reason: 'friend_add', target: 'someone@remote.example' }, + { userId: 'bob', reason: 'space_join', target: 'invite-xyz' }, + ], + }); + + // No fetch should be invoked — outbound deny has no remote network call. + const fetchSpy = vi.spyOn(globalThis, 'fetch'); + + const response = await app.inject({ + method: 'POST', + url: '/api/federation/approval-requests/req-out-deny/deny', + }); + + expect(response.statusCode).toBe(200); + expect(response.json()).toEqual({ success: true }); + + // No remote network call. + expect(fetchSpy).not.toHaveBeenCalled(); + + // Two denied notifications written. + const notifications = testDb.select().from(schema.peerApprovalNotifications).all(); + expect(notifications).toHaveLength(2); + expect(notifications.every(n => n.kind === 'denied')).toBe(true); + expect(notifications.every(n => n.peerOrigin === 'https://remote.example')).toBe(true); + expect(notifications.every(n => n.readAt === null)).toBe(true); + const userIds = new Set(notifications.map(n => n.userId)); + expect(userIds).toEqual(new Set(['alice', 'bob'])); + // Trigger reason + target captured per row. + const aliceN = notifications.find(n => n.userId === 'alice'); + expect(aliceN?.triggerReason).toBe('friend_add'); + expect(aliceN?.triggerTarget).toBe('someone@remote.example'); + const bobN = notifications.find(n => n.userId === 'bob'); + expect(bobN?.triggerReason).toBe('space_join'); + expect(bobN?.triggerTarget).toBe('invite-xyz'); + + // Parent + subscribers gone (cascade). + expect(testDb.select().from(schema.peerApprovalRequests) + .where(eq(schema.peerApprovalRequests.id, 'req-out-deny')).get()).toBeUndefined(); + expect(testDb.select().from(schema.peerApprovalSubscribers) + .where(eq(schema.peerApprovalSubscribers.requestId, 'req-out-deny')).all()).toHaveLength(0); + + // Each subscriber received a peering_notification_received WS. + const peeringEvents = sentToUser.mock.calls.filter(call => { + const ev = call[1] as { type?: string; kind?: string }; + return ev?.type === 'peering_notification_received' && ev?.kind === 'denied'; + }); + expect(peeringEvents).toHaveLength(2); + + // Admins notified that queue changed. + const adminEvents = sentToAdmins.mock.calls.filter(call => { + const ev = call[0] as { type?: string }; + return ev?.type === 'federation_peers_changed'; + }); + expect(adminEvents.length).toBeGreaterThanOrEqual(1); + + // No federation_peers row created — outbound deny has no peer to mark rejected. + expect(testDb.select().from(schema.federationPeers).all()).toHaveLength(0); + }); + + it('outbound deny with zero subscribers still cascade-deletes parent and broadcasts admin event', async () => { + const now = Date.now(); + testDb.insert(schema.peerApprovalRequests).values({ + id: 'req-out-empty', + origin: 'https://lonely.example', + direction: 'outbound', + instanceName: null, + hmacSecret: null, + requestedAt: now, + expiresAt: now + 30 * 24 * 60 * 60 * 1000, + approvalToken: null, + }).run(); + + const response = await app.inject({ + method: 'POST', + url: '/api/federation/approval-requests/req-out-empty/deny', + }); + + expect(response.statusCode).toBe(200); + expect(testDb.select().from(schema.peerApprovalRequests) + .where(eq(schema.peerApprovalRequests.id, 'req-out-empty')).get()).toBeUndefined(); + expect(testDb.select().from(schema.peerApprovalNotifications).all()).toHaveLength(0); + + const adminEvents = sentToAdmins.mock.calls.filter(call => { + const ev = call[0] as { type?: string }; + return ev?.type === 'federation_peers_changed'; + }); + expect(adminEvents.length).toBeGreaterThanOrEqual(1); + }); + + it('non-existent id returns 404', async () => { + const response = await app.inject({ + method: 'POST', + url: '/api/federation/approval-requests/does-not-exist/deny', + }); + expect(response.statusCode).toBe(404); + }); +}); + +// Test 17: regression — inbound paths still behave as before the direction split. +describe('Inbound regression after direction split', () => { + let app: FastifyInstance; + + beforeEach(async () => { + sqlite = new Database(':memory:'); + testDb = drizzle(sqlite, { schema }); + applyMigrations(sqlite); + seedInstanceSettings(); + seedUser('alice', 'alice'); + sentToUser.mockClear(); + sentToAdmins.mockClear(); + onPeerActivatedMock.mockClear(); + app = await buildApp(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + sqlite.close(); + }); + + function seedInboundRequest(id: string): void { + const now = Date.now(); + testDb.insert(schema.peerApprovalRequests).values({ + id, + origin: 'https://inbound.example', + direction: 'inbound', + instanceName: 'Inbound', + hmacSecret: 'their-secret', + requestedAt: now, + expiresAt: now + 30 * 24 * 60 * 60 * 1000, + }).run(); + } + + it('/approve on inbound row → existing 200 path activates peer (preserved verbatim)', async () => { + seedInboundRequest('req-in-approve'); + vi.spyOn(globalThis, 'fetch').mockResolvedValue( + new Response(JSON.stringify({ accepted: true, instanceName: 'Inbound Backspace' }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }), + ); + + const response = await app.inject({ + method: 'POST', + url: '/api/federation/approval-requests/req-in-approve/approve', + }); + + expect(response.statusCode).toBe(200); + const body = response.json() as { success: boolean; peer?: { status?: string } }; + expect(body.success).toBe(true); + // Inbound preserves the existing response shape: `peer` is included, + // `peerStatus` is NOT (that's outbound's signal). + expect(body.peer).toBeDefined(); + + const peer = testDb.select().from(schema.federationPeers) + .where(eq(schema.federationPeers.origin, 'https://inbound.example')).get(); + expect(peer?.status).toBe('active'); + expect(peer?.instanceName).toBe('Inbound Backspace'); + + // Inbound 200 path deletes the queue row directly (not via onPeerActivated). + expect(testDb.select().from(schema.peerApprovalRequests) + .where(eq(schema.peerApprovalRequests.id, 'req-in-approve')).get()).toBeUndefined(); + + // Still calls onPeerActivated for sync-pull / outbox reset. + expect(onPeerActivatedMock).toHaveBeenCalledWith(peer!.id, 'approval_handshake'); + }); + + it('/deny on inbound row → calls remote /peer/denied, marks peer rejected, deletes queue row', async () => { + seedInboundRequest('req-in-deny'); + + const fetchSpy = vi.spyOn(globalThis, 'fetch').mockResolvedValue( + new Response(JSON.stringify({ ok: true }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }), + ); + + const response = await app.inject({ + method: 'POST', + url: '/api/federation/approval-requests/req-in-deny/deny', + }); + + expect(response.statusCode).toBe(200); + expect(response.json()).toEqual({ success: true }); + + // Remote /peer/denied invoked. + expect(fetchSpy).toHaveBeenCalledTimes(1); + const url = fetchSpy.mock.calls[0]?.[0] as string; + expect(url).toBe('https://inbound.example/api/federation/peer/denied'); + + // Local peer row inserted as 'rejected'. + const peer = testDb.select().from(schema.federationPeers) + .where(eq(schema.federationPeers.origin, 'https://inbound.example')).get(); + expect(peer?.status).toBe('rejected'); + + // Queue row deleted. + expect(testDb.select().from(schema.peerApprovalRequests) + .where(eq(schema.peerApprovalRequests.id, 'req-in-deny')).get()).toBeUndefined(); + + // No subscriber notifications (those are outbound-only). + expect(testDb.select().from(schema.peerApprovalNotifications).all()).toHaveLength(0); + }); + + it('/deny on inbound row with unreachable remote returns 502 and leaves queue row pending', async () => { + seedInboundRequest('req-in-deny-fail'); + + vi.spyOn(globalThis, 'fetch').mockRejectedValue(new TypeError('connection refused')); + + const response = await app.inject({ + method: 'POST', + url: '/api/federation/approval-requests/req-in-deny-fail/deny', + }); + + expect(response.statusCode).toBe(502); + + // Queue row still pending — admin can retry. + expect(testDb.select().from(schema.peerApprovalRequests) + .where(eq(schema.peerApprovalRequests.id, 'req-in-deny-fail')).get()).toBeDefined(); + // No federation_peers row created because we couldn't deliver the denial. + expect(testDb.select().from(schema.federationPeers).all()).toHaveLength(0); + }); +}); diff --git a/packages/server/src/routes/federation.ts b/packages/server/src/routes/federation.ts index bf24102d..82ab9c48 100644 --- a/packages/server/src/routes/federation.ts +++ b/packages/server/src/routes/federation.ts @@ -20,7 +20,7 @@ import { tombstoneUser, collectDeletionBroadcastTargets, collectProfileBroadcast import { computeFederatedId, getDmParticipants, sendCallRelay } from '../utils/federationOutbox.js'; import { onPeerActivated, onPeerDeactivated } from '../utils/federationPeerActivation.js'; import { getDmMessageWithUser } from './dm.js'; -import type { FederationRelayRequest, FederationRelayResponse, FederationRelayEvent, FederationRelayAttachment, FederationSyncRequest, FederationSyncResponse, DmMessageWithUser, DmChannel, FederationRelayProfileSnapshot, FederationIdentityDeleteS2SRequest, FederationProfileUpdatePayload, ServerEvent } from '@backspace/shared'; +import type { FederationRelayRequest, FederationRelayResponse, FederationRelayEvent, FederationRelayAttachment, FederationSyncRequest, FederationSyncResponse, DmMessageWithUser, DmChannel, FederationRelayProfileSnapshot, FederationIdentityDeleteS2SRequest, FederationProfileUpdatePayload, ServerEvent, ApprovalRequestSubscriberSummary, PeeringTriggerReason } from '@backspace/shared'; /** Fields safe to expose to admin callers (everything except hmacSecret). */ interface SanitizedPeer { @@ -317,6 +317,483 @@ function queueApprovalRequest( }); } +/** + * Inbound approve — admin accepts a remote instance's peering request. + * Generates fresh HMAC, sends `/peer/accept` to the remote, and on success + * activates the peer locally. Preserves the historical behavior verbatim; + * extracted from the route handler so the dispatcher can branch on direction. + */ +async function handleInboundApprove( + approvalReq: typeof schema.peerApprovalRequests.$inferSelect, + localOrigin: string, + reply: FastifyReply, +): Promise { + const db = getDb(); + const id = approvalReq.id; + + const existingPeer = db + .select() + .from(schema.federationPeers) + .where(eq(schema.federationPeers.origin, approvalReq.origin)) + .get(); + + if (existingPeer && existingPeer.status === 'active') { + db.delete(schema.peerApprovalRequests) + .where(eq(schema.peerApprovalRequests.id, id)) + .run(); + return reply.code(200).send({ success: true, peer: sanitizePeer(existingPeer) }); + } + + if (existingPeer) { + db.delete(schema.federationPeers) + .where(eq(schema.federationPeers.id, existingPeer.id)) + .run(); + } + + const hmacSecret = generateHmacSecret(); + const peerId = generateSnowflake(); + const now = Date.now(); + + db.insert(schema.federationPeers).values({ + id: peerId, + origin: approvalReq.origin, + instanceName: approvalReq.instanceName, + hmacSecret, + status: 'pending', + createdAt: now, + }).run(); + + try { + const instanceName = db + .select({ name: schema.instanceSettings.instanceName }) + .from(schema.instanceSettings) + .where(eq(schema.instanceSettings.id, 1)) + .get()?.name ?? undefined; + + const response = await fetch(`${approvalReq.origin}/api/federation/peer/accept`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + 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), + }); + + 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', approvalToken: returnedToken }) + .where(eq(schema.federationPeers.id, peerId)) + .run(); + // Delete the approval request since we already acted on it + db.delete(schema.peerApprovalRequests) + .where(eq(schema.peerApprovalRequests.id, id)) + .run(); + return reply.code(200).send({ + success: true, + awaitingRemoteApproval: true, + message: 'Remote instance also requires admin approval. Your request has been queued on their side.', + }); + } + + if (!response.ok) { + let errorMessage = `Remote instance rejected handshake (HTTP ${response.status})`; + try { + const body = await response.json() as { error?: string }; + if (body.error) errorMessage = body.error; + } catch { /* ignore */ } + + db.delete(schema.federationPeers) + .where(eq(schema.federationPeers.id, peerId)) + .run(); + return reply.code(502).send({ error: errorMessage, statusCode: 502 }); + } + + // Parse the remote's instanceName from the response body so the + // federation panel renders a friendly label. Tolerate omission and + // non-JSON bodies — same pattern as performHandshake and /peer/initiate. + let remoteInstanceName: string | null = null; + try { + const body = (await response.json()) as { instanceName?: string | null }; + if (typeof body?.instanceName === 'string' && body.instanceName.length > 0) { + remoteInstanceName = body.instanceName; + } + } catch { + // Non-JSON body — leave null. + } + + db.update(schema.federationPeers) + .set({ status: 'active', lastSeenAt: now, instanceName: remoteInstanceName, approvalToken: null }) + .where(eq(schema.federationPeers.id, peerId)) + .run(); + + db.delete(schema.peerApprovalRequests) + .where(eq(schema.peerApprovalRequests.id, id)) + .run(); + + connectionManager.sendToAdmins({ type: 'federation_peers_changed' as const }); + onPeerActivated(peerId, 'approval_handshake').catch(err => + console.error('[federation] onPeerActivated from /approval-requests/:id/approve failed:', err) + ); + + const peer = db + .select() + .from(schema.federationPeers) + .where(eq(schema.federationPeers.id, peerId)) + .get(); + + return reply.code(200).send({ success: true, peer: peer ? sanitizePeer(peer) : undefined }); + } catch (err: unknown) { + db.delete(schema.federationPeers) + .where(eq(schema.federationPeers.id, peerId)) + .run(); + + const message = err instanceof Error ? err.message : 'Unknown error'; + if (err instanceof DOMException && err.name === 'TimeoutError') { + return reply.code(504).send({ + error: 'Remote instance did not respond within 10 seconds', + statusCode: 504, + }); + } + return reply.code(502).send({ + error: `Failed to reach remote instance: ${message}`, + statusCode: 502, + }); + } +} + +/** + * Outbound approve — admin authorizes the local instance to peer with a + * remote that one or more of its users have requested. Generates fresh HMAC, + * sends `/peer/accept` to the remote, and: + * - 200 → activate peer; `onPeerActivated` runs and (per Task 6) fans out + * approved-notifications to outbound subscribers and cascade-deletes the + * queue row. The handler MUST NOT duplicate that cleanup. + * - 202 → remote also gates; transition to `awaiting_approval`, capture + * the returned token, leave the queue row + subscribers untouched (they + * wait for the remote admin to approve and the eventual full activation + * to fan out via `onPeerActivated`). + * - 4xx/5xx/network → clean up the peer row we created; leave the queue + * row alone so the admin can retry. + */ +async function handleOutboundApprove( + approvalReq: typeof schema.peerApprovalRequests.$inferSelect, + localOrigin: string, + reply: FastifyReply, +): Promise { + const db = getDb(); + const hmacSecret = generateHmacSecret(); + const peerId = generateSnowflake(); + const now = Date.now(); + + // Insert the peer row in 'pending' so failure paths roll back cleanly. + db.insert(schema.federationPeers).values({ + id: peerId, + origin: approvalReq.origin, + instanceName: approvalReq.instanceName, + hmacSecret, + status: 'pending', + createdAt: now, + }).run(); + + const instanceName = db + .select({ name: schema.instanceSettings.instanceName }) + .from(schema.instanceSettings) + .where(eq(schema.instanceSettings.id, 1)) + .get()?.name ?? undefined; + + let response: Response; + try { + response = await fetch(`${approvalReq.origin}/api/federation/peer/accept`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + sourceOrigin: localOrigin, + hmacSecret, + instanceName, + // No approvalToken — outbound rows are admin-initiated locally; we + // hold no prior token from the remote and rely on the remote's own + // autoAcceptPeering setting to decide 200 vs 202. + }), + signal: AbortSignal.timeout(10_000), + }); + } catch (err: unknown) { + db.delete(schema.federationPeers) + .where(eq(schema.federationPeers.id, peerId)) + .run(); + const message = err instanceof Error ? err.message : 'Unknown error'; + if (err instanceof DOMException && err.name === 'TimeoutError') { + return reply.code(504).send({ + error: 'Remote instance did not respond within 10 seconds', + statusCode: 504, + }); + } + return reply.code(503).send({ + error: `Remote instance unreachable: ${message}`, + statusCode: 503, + }); + } + + if (response.status === 202) { + // Remote also gates new peers. Capture the approval token they returned + // so the next inbound /peer/accept (when the remote admin approves) can + // verify mutual admin approval. The outbound queue row and its + // subscribers REMAIN — `onPeerActivated` is NOT called here; subscribers + // wait for the eventual activation (fanout happens then). + 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 with no token to capture. + } + + db.update(schema.federationPeers) + .set({ status: 'awaiting_approval', approvalToken: returnedToken }) + .where(eq(schema.federationPeers.id, peerId)) + .run(); + + connectionManager.sendToAdmins({ type: 'federation_peers_changed' as const }); + + const peer = db + .select() + .from(schema.federationPeers) + .where(eq(schema.federationPeers.id, peerId)) + .get(); + + return reply.code(200).send({ + success: true, + peerStatus: 'awaiting_approval' as const, + awaitingRemoteApproval: true, + message: 'Remote instance also requires admin approval. Your request has been queued on their side.', + peer: peer ? sanitizePeer(peer) : undefined, + }); + } + + if (!response.ok) { + let errorMessage = `Remote instance rejected handshake (HTTP ${response.status})`; + try { + const body = await response.json() as { error?: string }; + if (body.error) errorMessage = body.error; + } catch { /* ignore */ } + + // Clean up the peer row we created. Leave the outbound queue row alone + // so the admin can retry without re-collecting subscribers. + db.delete(schema.federationPeers) + .where(eq(schema.federationPeers.id, peerId)) + .run(); + return reply.code(502).send({ + error: errorMessage, + statusCode: 502, + remoteStatus: response.status, + }); + } + + // 200 — peer activated. Capture remote's instanceName for the friendly label. + let remoteInstanceName: string | null = approvalReq.instanceName; + try { + const body = (await response.json()) as { instanceName?: string | null }; + if (typeof body?.instanceName === 'string' && body.instanceName.length > 0) { + remoteInstanceName = body.instanceName; + } + } catch { + // Non-JSON body — keep approvalReq.instanceName (may be null). + } + + db.update(schema.federationPeers) + .set({ + status: 'active', + lastSeenAt: now, + instanceName: remoteInstanceName, + approvalToken: null, + }) + .where(eq(schema.federationPeers.id, peerId)) + .run(); + + connectionManager.sendToAdmins({ type: 'federation_peers_changed' as const }); + + // onPeerActivated runs fanoutOutboundSubscribers (Task 6) which: + // - inserts kind='approved' notifications for each subscriber, + // - sends `peering_notification_received` WS to each subscriber, + // - cascade-deletes the parent + subscriber rows. + // Do NOT duplicate any of that here — it would double-notify and corrupt + // the queue. + onPeerActivated(peerId, 'approval_handshake').catch(err => + console.error('[federation] onPeerActivated from outbound /approval-requests/:id/approve failed:', err) + ); + + const peer = db + .select() + .from(schema.federationPeers) + .where(eq(schema.federationPeers.id, peerId)) + .get(); + + return reply.code(200).send({ + success: true, + peerStatus: 'active' as const, + peer: peer ? sanitizePeer(peer) : undefined, + }); +} + +/** + * Inbound deny — admin rejects a remote instance's peering request. Fires + * the existing /peer/denied notification to the remote, marks any local + * peer row as `rejected`, and clears the queue row. Preserves historical + * behavior verbatim. + */ +async function handleInboundDeny( + approvalReq: typeof schema.peerApprovalRequests.$inferSelect, + reply: FastifyReply, +): Promise { + const db = getDb(); + const id = approvalReq.id; + + // Inbound rows always carry hmacSecret (CHECK constraint enforces this). + // If it's somehow null, we cannot sign /peer/denied — surface clearly. + if (!approvalReq.hmacSecret) { + return reply.code(500).send({ + error: 'Inbound approval request is missing hmacSecret — cannot deliver /peer/denied notification.', + statusCode: 500, + }); + } + + const ourOrigin = getOurOrigin(); + const denialBody = JSON.stringify({ + origin: ourOrigin, + reason: 'denied_by_admin' as const, + message: 'Request denied by admin', + }); + + const headers = buildFederationHeaders(denialBody, approvalReq.hmacSecret, ourOrigin); + + let notificationSent = false; + try { + const response = await fetch(`${approvalReq.origin}/api/federation/peer/denied`, { + method: 'POST', + headers, + body: denialBody, + signal: AbortSignal.timeout(10_000), + }); + notificationSent = response.ok; + } catch { + // Network error + } + + if (!notificationSent) { + return reply.code(502).send({ + error: 'Denial notification could not be delivered to the remote instance. The request is still pending — you can retry or wait for it to expire.', + statusCode: 502, + }); + } + + const existingPeer = db + .select() + .from(schema.federationPeers) + .where(eq(schema.federationPeers.origin, approvalReq.origin)) + .get(); + + if (!existingPeer) { + db.insert(schema.federationPeers).values({ + id: generateSnowflake(), + origin: approvalReq.origin, + instanceName: approvalReq.instanceName, + hmacSecret: approvalReq.hmacSecret, + status: 'rejected', + createdAt: Date.now(), + }).run(); + } else if (existingPeer.status !== 'active') { + db.update(schema.federationPeers) + .set({ status: 'rejected' }) + .where(eq(schema.federationPeers.id, existingPeer.id)) + .run(); + } + + connectionManager.sendToAdmins({ type: 'federation_peers_changed' as const }); + + db.delete(schema.peerApprovalRequests) + .where(eq(schema.peerApprovalRequests.id, id)) + .run(); + + return reply.code(200).send({ success: true }); +} + +/** + * Outbound deny — admin refuses local users' peering request. Fans out + * `kind='denied'` notifications to each subscriber and cascade-deletes the + * parent (which clears subscribers via FK cascade). No remote network call + * — outbound rows have no /peer/denied counterpart on the wire (the remote + * never knew we were considering this). + */ +async function handleOutboundDeny( + approvalReq: typeof schema.peerApprovalRequests.$inferSelect, + reply: FastifyReply, +): Promise { + const db = getDb(); + const subscribers = db + .select() + .from(schema.peerApprovalSubscribers) + .where(eq(schema.peerApprovalSubscribers.requestId, approvalReq.id)) + .all(); + + const now = Date.now(); + for (const sub of subscribers) { + db.insert(schema.peerApprovalNotifications) + .values({ + id: generateSnowflake(), + userId: sub.userId, + kind: 'denied', + peerOrigin: approvalReq.origin, + triggerReason: sub.triggerReason, + triggerTarget: sub.triggerTarget, + createdAt: now, + readAt: null, + }) + .run(); + + connectionManager.sendToUser(sub.userId, { + type: 'peering_notification_received' as const, + kind: 'denied', + }); + } + + // Cascade-delete clears subscribers via onDelete: 'cascade'. + db.delete(schema.peerApprovalRequests) + .where(eq(schema.peerApprovalRequests.id, approvalReq.id)) + .run(); + + // Tell admins the queue changed. + connectionManager.sendToAdmins({ type: 'federation_peers_changed' as const }); + + if (subscribers.length > 0) { + console.log( + `[federation] handleOutboundDeny denied ${subscribers.length} subscriber notification${subscribers.length === 1 ? '' : 's'} for ${approvalReq.origin}`, + ); + } + + return reply.code(200).send({ success: true }); +} + export async function federationRoutes(app: FastifyInstance): Promise { // ─── POST /api/federation/peer/initiate ──────────────────────────────────── // Admin-only: start a peering handshake with a remote instance. @@ -1175,6 +1652,7 @@ export async function federationRoutes(app: FastifyInstance): Promise { .select({ id: schema.peerApprovalRequests.id, origin: schema.peerApprovalRequests.origin, + direction: schema.peerApprovalRequests.direction, instanceName: schema.peerApprovalRequests.instanceName, requestedAt: schema.peerApprovalRequests.requestedAt, expiresAt: schema.peerApprovalRequests.expiresAt, @@ -1183,11 +1661,50 @@ export async function federationRoutes(app: FastifyInstance): Promise { .orderBy(desc(schema.peerApprovalRequests.requestedAt)) .all(); - return reply.code(200).send({ requests }); + // For outbound rows, fetch subscriber summaries (joined with users for username). + // Inbound rows have no subscriber concept; field is omitted in their response. + const outboundIds = requests.filter(r => r.direction === 'outbound').map(r => r.id); + const subscribersByRequestId = new Map(); + if (outboundIds.length > 0) { + const rows = db + .select({ + requestId: schema.peerApprovalSubscribers.requestId, + userId: schema.peerApprovalSubscribers.userId, + username: schema.users.username, + triggerReason: schema.peerApprovalSubscribers.triggerReason, + triggerTarget: schema.peerApprovalSubscribers.triggerTarget, + }) + .from(schema.peerApprovalSubscribers) + .innerJoin(schema.users, eq(schema.users.id, schema.peerApprovalSubscribers.userId)) + .where(inArray(schema.peerApprovalSubscribers.requestId, outboundIds)) + .all(); + for (const row of rows) { + const arr = subscribersByRequestId.get(row.requestId) ?? []; + arr.push({ + userId: row.userId, + username: row.username, + triggerReason: row.triggerReason as PeeringTriggerReason, + triggerTarget: row.triggerTarget, + }); + subscribersByRequestId.set(row.requestId, arr); + } + } + + return reply.code(200).send({ + requests: requests.map(r => + r.direction === 'outbound' + ? { ...r, subscribers: subscribersByRequestId.get(r.id) ?? [] } + : r, + ), + }); }, ); // ─── POST /api/federation/approval-requests/:id/approve ─────────────────── + // Direction-branched: inbound rows complete the existing accept-handshake + // path (preserved verbatim); outbound rows initiate /peer/accept against + // the remote, capturing 200/202 outcomes and leaving the queue intact on + // failure so the admin can retry. app.post<{ Params: { id: string } }>( '/api/federation/approval-requests/:id/approve', { preHandler: [authenticate, requireAdmin] }, @@ -1215,158 +1732,18 @@ export async function federationRoutes(app: FastifyInstance): Promise { }); } - const existingPeer = db - .select() - .from(schema.federationPeers) - .where(eq(schema.federationPeers.origin, approvalReq.origin)) - .get(); - - if (existingPeer && existingPeer.status === 'active') { - db.delete(schema.peerApprovalRequests) - .where(eq(schema.peerApprovalRequests.id, id)) - .run(); - return reply.code(200).send({ success: true, peer: sanitizePeer(existingPeer) }); + if (approvalReq.direction === 'outbound') { + return await handleOutboundApprove(approvalReq, localOrigin, reply); } - if (existingPeer) { - db.delete(schema.federationPeers) - .where(eq(schema.federationPeers.id, existingPeer.id)) - .run(); - } - - const hmacSecret = generateHmacSecret(); - const peerId = generateSnowflake(); - const now = Date.now(); - - db.insert(schema.federationPeers).values({ - id: peerId, - origin: approvalReq.origin, - instanceName: approvalReq.instanceName, - hmacSecret, - status: 'pending', - createdAt: now, - }).run(); - - try { - const instanceName = db - .select({ name: schema.instanceSettings.instanceName }) - .from(schema.instanceSettings) - .where(eq(schema.instanceSettings.id, 1)) - .get()?.name ?? undefined; - - const response = await fetch(`${approvalReq.origin}/api/federation/peer/accept`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - 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), - }); - - 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', approvalToken: returnedToken }) - .where(eq(schema.federationPeers.id, peerId)) - .run(); - // Delete the approval request since we already acted on it - db.delete(schema.peerApprovalRequests) - .where(eq(schema.peerApprovalRequests.id, id)) - .run(); - return reply.code(200).send({ - success: true, - awaitingRemoteApproval: true, - message: 'Remote instance also requires admin approval. Your request has been queued on their side.', - }); - } - - if (!response.ok) { - let errorMessage = `Remote instance rejected handshake (HTTP ${response.status})`; - try { - const body = await response.json() as { error?: string }; - if (body.error) errorMessage = body.error; - } catch { /* ignore */ } - - db.delete(schema.federationPeers) - .where(eq(schema.federationPeers.id, peerId)) - .run(); - return reply.code(502).send({ error: errorMessage, statusCode: 502 }); - } - - // Parse the remote's instanceName from the response body so the - // federation panel renders a friendly label. Tolerate omission and - // non-JSON bodies — same pattern as performHandshake and /peer/initiate. - let remoteInstanceName: string | null = null; - try { - const body = (await response.json()) as { instanceName?: string | null }; - if (typeof body?.instanceName === 'string' && body.instanceName.length > 0) { - remoteInstanceName = body.instanceName; - } - } catch { - // Non-JSON body — leave null. - } - - db.update(schema.federationPeers) - .set({ status: 'active', lastSeenAt: now, instanceName: remoteInstanceName, approvalToken: null }) - .where(eq(schema.federationPeers.id, peerId)) - .run(); - - db.delete(schema.peerApprovalRequests) - .where(eq(schema.peerApprovalRequests.id, id)) - .run(); - - connectionManager.sendToAdmins({ type: 'federation_peers_changed' as const }); - onPeerActivated(peerId, 'approval_handshake').catch(err => - console.error('[federation] onPeerActivated from /approval-requests/:id/approve failed:', err) - ); - - const peer = db - .select() - .from(schema.federationPeers) - .where(eq(schema.federationPeers.id, peerId)) - .get(); - - return reply.code(200).send({ success: true, peer: peer ? sanitizePeer(peer) : undefined }); - } catch (err: unknown) { - db.delete(schema.federationPeers) - .where(eq(schema.federationPeers.id, peerId)) - .run(); - - const message = err instanceof Error ? err.message : 'Unknown error'; - if (err instanceof DOMException && err.name === 'TimeoutError') { - return reply.code(504).send({ - error: 'Remote instance did not respond within 10 seconds', - statusCode: 504, - }); - } - return reply.code(502).send({ - error: `Failed to reach remote instance: ${message}`, - statusCode: 502, - }); - } + return await handleInboundApprove(approvalReq, localOrigin, reply); }, ); // ─── POST /api/federation/approval-requests/:id/deny ─────────────────────── + // Direction-branched: inbound rows hit the remote's /peer/denied endpoint + // (existing behavior preserved); outbound rows fan out denied notifications + // to subscribers and cascade-delete the queue row. app.post<{ Params: { id: string } }>( '/api/federation/approval-requests/:id/deny', { preHandler: [authenticate, requireAdmin] }, @@ -1384,73 +1761,11 @@ export async function federationRoutes(app: FastifyInstance): Promise { return reply.code(404).send({ error: 'Approval request not found', statusCode: 404 }); } - // Outbound rows have no hmac_secret and no remote /peer/denied endpoint to call. - // Direction-branched handling lands in Task 7; until then, inbound is the only path here. - if (!approvalReq.hmacSecret) { - return reply.code(400).send({ - error: 'Cannot deny an outbound peering request via this endpoint yet — outbound denial handling is not implemented.', - statusCode: 400, - }); + if (approvalReq.direction === 'outbound') { + return await handleOutboundDeny(approvalReq, reply); } - const ourOrigin = getOurOrigin(); - const denialBody = JSON.stringify({ - origin: ourOrigin, - reason: 'denied_by_admin' as const, - message: 'Request denied by admin', - }); - - const headers = buildFederationHeaders(denialBody, approvalReq.hmacSecret, ourOrigin); - - let notificationSent = false; - try { - const response = await fetch(`${approvalReq.origin}/api/federation/peer/denied`, { - method: 'POST', - headers, - body: denialBody, - signal: AbortSignal.timeout(10_000), - }); - notificationSent = response.ok; - } catch { - // Network error - } - - if (!notificationSent) { - return reply.code(502).send({ - error: 'Denial notification could not be delivered to the remote instance. The request is still pending — you can retry or wait for it to expire.', - statusCode: 502, - }); - } - - const existingPeer = db - .select() - .from(schema.federationPeers) - .where(eq(schema.federationPeers.origin, approvalReq.origin)) - .get(); - - if (!existingPeer) { - db.insert(schema.federationPeers).values({ - id: generateSnowflake(), - origin: approvalReq.origin, - instanceName: approvalReq.instanceName, - hmacSecret: approvalReq.hmacSecret, - status: 'rejected', - createdAt: Date.now(), - }).run(); - } else if (existingPeer.status !== 'active') { - db.update(schema.federationPeers) - .set({ status: 'rejected' }) - .where(eq(schema.federationPeers.id, existingPeer.id)) - .run(); - } - - connectionManager.sendToAdmins({ type: 'federation_peers_changed' as const }); - - db.delete(schema.peerApprovalRequests) - .where(eq(schema.peerApprovalRequests.id, id)) - .run(); - - return reply.code(200).send({ success: true }); + return await handleInboundDeny(approvalReq, reply); }, ); From 45ab0ea2c84bcdcec1b9ab3ca983059a6e482ad5 Mon Sep 17 00:00:00 2001 From: Jannis Braun <151788261+TheZwiss@users.noreply.github.com> Date: Sun, 26 Apr 2026 22:09:41 +0200 Subject: [PATCH 11/20] feat(federation): peering-subscriptions GET and DELETE endpoints User-facing endpoints for the pending-peering-approvals UI surface. GET lists the user's own pending subscriber rows joined to parent peering requests. DELETE cancels a single subscription; if it was the last subscriber, cascade-deletes the parent (admin queue updates via federation_peers_changed). Authorization: subscriber.userId must match request.userId. --- .../federation.peeringSubscriptions.test.ts | 433 ++++++++++++++++++ packages/server/src/routes/federation.ts | 83 ++++ 2 files changed, 516 insertions(+) create mode 100644 packages/server/src/routes/federation.peeringSubscriptions.test.ts diff --git a/packages/server/src/routes/federation.peeringSubscriptions.test.ts b/packages/server/src/routes/federation.peeringSubscriptions.test.ts new file mode 100644 index 00000000..5db4cee9 --- /dev/null +++ b/packages/server/src/routes/federation.peeringSubscriptions.test.ts @@ -0,0 +1,433 @@ +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; + +// The userId set onto request by the mocked authenticate preHandler. Tests +// override this per-case to simulate different authenticated users. +let currentUserId = 'alice'; + +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 = currentUserId; + }, + 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', + }; +}); + +const sentToUser = vi.fn(); +const sentToAdmins = vi.fn(); + +vi.mock('../ws/handler.js', () => ({ + connectionManager: { + sendToAdmins: sentToAdmins, + getAllOnlineUserIds: () => [], + sendToUser: sentToUser, + 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(); +} + +function seedUser(id: string, username: string): void { + testDb.insert(schema.users).values({ + id, + username, + passwordHash: 'x', + displayName: username, + createdAt: Date.now(), + }).run(); +} + +interface SeedSub { + id: string; + userId: string; + reason: 'friend_add' | 'space_join' | 'direct_message'; + target: string; + createdAt: number; +} + +function seedOutboundRequest(opts: { + id: string; + origin: string; + instanceName?: string | null; + subscribers: SeedSub[]; +}): void { + const now = Date.now(); + testDb.insert(schema.peerApprovalRequests).values({ + id: opts.id, + origin: opts.origin, + direction: 'outbound', + instanceName: opts.instanceName ?? null, + hmacSecret: null, + requestedAt: now, + expiresAt: now + 30 * 24 * 60 * 60 * 1000, + approvalToken: null, + }).run(); + for (const sub of opts.subscribers) { + testDb.insert(schema.peerApprovalSubscribers).values({ + id: sub.id, + requestId: opts.id, + userId: sub.userId, + triggerReason: sub.reason, + triggerTarget: sub.target, + createdAt: sub.createdAt, + }).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('GET /api/federation/peering-subscriptions', () => { + let app: FastifyInstance; + + beforeEach(async () => { + sqlite = new Database(':memory:'); + testDb = drizzle(sqlite, { schema }); + applyMigrations(sqlite); + seedInstanceSettings(); + seedUser('alice', 'alice'); + seedUser('bob', 'bob'); + sentToUser.mockClear(); + sentToAdmins.mockClear(); + currentUserId = 'alice'; + app = await buildApp(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + sqlite.close(); + }); + + // Test 18: GET own subscriptions → returns user rows joined to parent, + // ordered DESC by createdAt, scoped to the requesting user only. + it("returns the user's subscriber rows joined to parent requests, ordered DESC by createdAt, scoped to the user", async () => { + const t0 = 1_700_000_000_000; + seedOutboundRequest({ + id: 'req-A', + origin: 'https://orbit.example', + instanceName: 'Orbit', + subscribers: [ + { id: 'sub-A-alice-old', userId: 'alice', reason: 'friend_add', target: 'someone@orbit.example', createdAt: t0 }, + { id: 'sub-A-bob', userId: 'bob', reason: 'space_join', target: 'invite-xyz', createdAt: t0 + 100 }, + ], + }); + seedOutboundRequest({ + id: 'req-B', + origin: 'https://other.example', + instanceName: null, + subscribers: [ + { id: 'sub-B-alice-new', userId: 'alice', reason: 'direct_message', target: 'pal@other.example', createdAt: t0 + 1_000 }, + ], + }); + + const response = await app.inject({ + method: 'GET', + url: '/api/federation/peering-subscriptions', + }); + + expect(response.statusCode).toBe(200); + const body = response.json() as { + subscriptions: Array<{ + id: string; + requestId: string; + peerOrigin: string; + peerInstanceName: string | null; + triggerReason: string; + triggerTarget: string; + createdAt: number; + }>; + }; + + // Only alice's two subscriptions returned (bob's not included). + expect(body.subscriptions).toHaveLength(2); + expect(body.subscriptions.map(s => s.id)).toEqual(['sub-B-alice-new', 'sub-A-alice-old']); + + // DESC-by-createdAt ordering (newest first). + const newest = body.subscriptions[0]!; + const older = body.subscriptions[1]!; + expect(newest.createdAt).toBe(t0 + 1_000); + expect(older.createdAt).toBe(t0); + + // Shape: every documented field is present and joined correctly. + expect(newest).toEqual({ + id: 'sub-B-alice-new', + requestId: 'req-B', + peerOrigin: 'https://other.example', + peerInstanceName: null, + triggerReason: 'direct_message', + triggerTarget: 'pal@other.example', + createdAt: t0 + 1_000, + }); + + expect(older).toEqual({ + id: 'sub-A-alice-old', + requestId: 'req-A', + peerOrigin: 'https://orbit.example', + peerInstanceName: 'Orbit', + triggerReason: 'friend_add', + triggerTarget: 'someone@orbit.example', + createdAt: t0, + }); + }); + + it('returns an empty array when the user has no subscriptions', async () => { + seedOutboundRequest({ + id: 'req-only-bob', + origin: 'https://x.example', + subscribers: [ + { id: 'sub-only-bob', userId: 'bob', reason: 'friend_add', target: 'b@x.example', createdAt: Date.now() }, + ], + }); + + const response = await app.inject({ + method: 'GET', + url: '/api/federation/peering-subscriptions', + }); + + expect(response.statusCode).toBe(200); + const body = response.json() as { subscriptions: unknown[] }; + expect(body.subscriptions).toEqual([]); + }); +}); + +describe('DELETE /api/federation/peering-subscriptions/:id', () => { + let app: FastifyInstance; + + beforeEach(async () => { + sqlite = new Database(':memory:'); + testDb = drizzle(sqlite, { schema }); + applyMigrations(sqlite); + seedInstanceSettings(); + seedUser('alice', 'alice'); + seedUser('bob', 'bob'); + sentToUser.mockClear(); + sentToAdmins.mockClear(); + currentUserId = 'alice'; + app = await buildApp(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + sqlite.close(); + }); + + // Test 19a: cascade-on-last — alice cancels the SOLE subscriber → parent + // is cascade-deleted; admin gets federation_peers_changed; user gets + // peering_subscription_changed. + it('cascade-deletes parent when the canceller was the last subscriber; fires both WS events', async () => { + seedOutboundRequest({ + id: 'req-solo', + origin: 'https://solo.example', + subscribers: [ + { id: 'sub-solo-alice', userId: 'alice', reason: 'friend_add', target: 'x@solo.example', createdAt: Date.now() }, + ], + }); + + const response = await app.inject({ + method: 'DELETE', + url: '/api/federation/peering-subscriptions/sub-solo-alice', + }); + + expect(response.statusCode).toBe(200); + expect(response.json()).toEqual({ success: true }); + + // Subscriber row removed. + expect(testDb.select().from(schema.peerApprovalSubscribers) + .where(eq(schema.peerApprovalSubscribers.id, 'sub-solo-alice')).get()).toBeUndefined(); + + // Parent cascade-deleted. + expect(testDb.select().from(schema.peerApprovalRequests) + .where(eq(schema.peerApprovalRequests.id, 'req-solo')).get()).toBeUndefined(); + + // Admin queue refresh broadcast. + const adminEvents = sentToAdmins.mock.calls.filter(c => { + const ev = c[0] as { type?: string }; + return ev?.type === 'federation_peers_changed'; + }); + expect(adminEvents).toHaveLength(1); + + // User-facing list refresh broadcast. + const userEvents = sentToUser.mock.calls.filter(c => { + const uid = c[0] as string; + const ev = c[1] as { type?: string }; + return uid === 'alice' && ev?.type === 'peering_subscription_changed'; + }); + expect(userEvents).toHaveLength(1); + + // No notification written for the canceller. + expect(testDb.select().from(schema.peerApprovalNotifications).all()).toHaveLength(0); + }); + + // Test 19b: no-cascade-when-others-remain — alice cancels her row but bob + // still subscribes → parent stays; admin event NOT fired; user event fired. + it('does NOT cascade-delete parent when other subscribers remain; only fires user WS event', async () => { + seedOutboundRequest({ + id: 'req-shared', + origin: 'https://shared.example', + instanceName: 'Shared', + subscribers: [ + { id: 'sub-shared-alice', userId: 'alice', reason: 'friend_add', target: 'a@shared.example', createdAt: Date.now() }, + { id: 'sub-shared-bob', userId: 'bob', reason: 'space_join', target: 'invite-yz', createdAt: Date.now() }, + ], + }); + + const response = await app.inject({ + method: 'DELETE', + url: '/api/federation/peering-subscriptions/sub-shared-alice', + }); + + expect(response.statusCode).toBe(200); + expect(response.json()).toEqual({ success: true }); + + // Alice's row removed. + expect(testDb.select().from(schema.peerApprovalSubscribers) + .where(eq(schema.peerApprovalSubscribers.id, 'sub-shared-alice')).get()).toBeUndefined(); + + // Bob's row still present. + expect(testDb.select().from(schema.peerApprovalSubscribers) + .where(eq(schema.peerApprovalSubscribers.id, 'sub-shared-bob')).get()).toBeDefined(); + + // Parent NOT deleted. + expect(testDb.select().from(schema.peerApprovalRequests) + .where(eq(schema.peerApprovalRequests.id, 'req-shared')).get()).toBeDefined(); + + // Admin event NOT fired (queue did not change). + const adminEvents = sentToAdmins.mock.calls.filter(c => { + const ev = c[0] as { type?: string }; + return ev?.type === 'federation_peers_changed'; + }); + expect(adminEvents).toHaveLength(0); + + // User event still fired. + const userEvents = sentToUser.mock.calls.filter(c => { + const uid = c[0] as string; + const ev = c[1] as { type?: string }; + return uid === 'alice' && ev?.type === 'peering_subscription_changed'; + }); + expect(userEvents).toHaveLength(1); + + // No notification written. + expect(testDb.select().from(schema.peerApprovalNotifications).all()).toHaveLength(0); + }); + + // Test 20: foreign-user delete → 403, no DB mutation, no broadcasts. + it('returns 403 when the row belongs to another user; no DB mutation; no WS broadcasts', async () => { + seedOutboundRequest({ + id: 'req-foreign', + origin: 'https://foreign.example', + subscribers: [ + { id: 'sub-foreign-bob', userId: 'bob', reason: 'friend_add', target: 'b@foreign.example', createdAt: Date.now() }, + ], + }); + + // currentUserId is alice, but the row is bob's. + const response = await app.inject({ + method: 'DELETE', + url: '/api/federation/peering-subscriptions/sub-foreign-bob', + }); + + expect(response.statusCode).toBe(403); + const body = response.json() as { error: string }; + expect(body.error).toBe('forbidden'); + + // Row UNTOUCHED. + expect(testDb.select().from(schema.peerApprovalSubscribers) + .where(eq(schema.peerApprovalSubscribers.id, 'sub-foreign-bob')).get()).toBeDefined(); + + // Parent UNTOUCHED. + expect(testDb.select().from(schema.peerApprovalRequests) + .where(eq(schema.peerApprovalRequests.id, 'req-foreign')).get()).toBeDefined(); + + // No broadcasts. + expect(sentToAdmins).not.toHaveBeenCalled(); + expect(sentToUser).not.toHaveBeenCalled(); + }); + + // Test 21: nonexistent row → 404. + it('returns 404 when the subscription id does not exist', async () => { + const response = await app.inject({ + method: 'DELETE', + url: '/api/federation/peering-subscriptions/sub-does-not-exist', + }); + + expect(response.statusCode).toBe(404); + const body = response.json() as { error: string }; + expect(body.error).toBe('subscription_not_found'); + + // No broadcasts. + expect(sentToAdmins).not.toHaveBeenCalled(); + expect(sentToUser).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/server/src/routes/federation.ts b/packages/server/src/routes/federation.ts index 82ab9c48..c26fe11c 100644 --- a/packages/server/src/routes/federation.ts +++ b/packages/server/src/routes/federation.ts @@ -1769,6 +1769,89 @@ export async function federationRoutes(app: FastifyInstance): Promise { }, ); + // ─── GET /api/federation/peering-subscriptions ───────────────────────────── + // User-facing: list the requesting user's pending outbound peering + // subscriber rows joined to their parent peer_approval_requests. Used by the + // pending-peering UI surface to show "you have a peering with X waiting on + // your admin's approval" rows. + app.get( + '/api/federation/peering-subscriptions', + { preHandler: [authenticate] }, + async (request, reply) => { + const db = getDb(); + const userId = request.userId; + const rows = db + .select({ + id: schema.peerApprovalSubscribers.id, + requestId: schema.peerApprovalSubscribers.requestId, + peerOrigin: schema.peerApprovalRequests.origin, + peerInstanceName: schema.peerApprovalRequests.instanceName, + triggerReason: schema.peerApprovalSubscribers.triggerReason, + triggerTarget: schema.peerApprovalSubscribers.triggerTarget, + createdAt: schema.peerApprovalSubscribers.createdAt, + }) + .from(schema.peerApprovalSubscribers) + .innerJoin( + schema.peerApprovalRequests, + eq(schema.peerApprovalRequests.id, schema.peerApprovalSubscribers.requestId), + ) + .where(eq(schema.peerApprovalSubscribers.userId, userId)) + .orderBy(desc(schema.peerApprovalSubscribers.createdAt)) + .all(); + return reply.send({ subscriptions: rows }); + }, + ); + + // ─── DELETE /api/federation/peering-subscriptions/:id ────────────────────── + // User-facing: cancel one of the requesting user's pending peering + // subscriptions. Authorization: subscriber.userId must match request.userId. + // If this was the last subscriber for its parent request, the parent + // cascade-deletes too (avoids zombie outbound rows in the admin queue). + // No notification is created for the canceller (per spec §4.3 (iii)). + app.delete<{ Params: { id: string } }>( + '/api/federation/peering-subscriptions/:id', + { preHandler: [authenticate] }, + async (request, reply) => { + const db = getDb(); + const { id } = request.params; + const userId = request.userId; + + const sub = db + .select() + .from(schema.peerApprovalSubscribers) + .where(eq(schema.peerApprovalSubscribers.id, id)) + .get(); + if (!sub) { + return reply.code(404).send({ error: 'subscription_not_found', statusCode: 404 }); + } + if (sub.userId !== userId) { + return reply.code(403).send({ error: 'forbidden', statusCode: 403 }); + } + + db.delete(schema.peerApprovalSubscribers) + .where(eq(schema.peerApprovalSubscribers.id, id)) + .run(); + + // If the row we just removed was the last subscriber on its parent + // peer_approval_request, cascade-delete the parent. The admin queue + // refreshes via federation_peers_changed. + const remaining = db + .select({ id: schema.peerApprovalSubscribers.id }) + .from(schema.peerApprovalSubscribers) + .where(eq(schema.peerApprovalSubscribers.requestId, sub.requestId)) + .all(); + if (remaining.length === 0) { + db.delete(schema.peerApprovalRequests) + .where(eq(schema.peerApprovalRequests.id, sub.requestId)) + .run(); + connectionManager.sendToAdmins({ type: 'federation_peers_changed' as const }); + } + + connectionManager.sendToUser(userId, { type: 'peering_subscription_changed' as const }); + return reply.send({ success: true }); + }, + ); + // ─── POST /api/federation/peers/:id/rotate ────────────────────────────────── // Admin-only: trigger immediate secret rotation for a peer. app.post<{ Params: { id: string } }>( From 86ac54b91364fc9ae2deb6a44a69426dac4b2532 Mon Sep 17 00:00:00 2001 From: Jannis Braun <151788261+TheZwiss@users.noreply.github.com> Date: Sun, 26 Apr 2026 22:18:44 +0200 Subject: [PATCH 12/20] feat(federation): peering-notifications endpoints + janitor expiry fanout MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - GET /api/federation/peering-notifications (?unread=1 filter) - POST /api/federation/peering-notifications/:id/read (per-row mark-read) - POST /api/federation/peering-notifications/read-all (bulk mark-read, preserves already-read readAt; returns affected count) - janitor: outbound expired rows fan out kind='expired' notifications to each subscriber before cascade-deleting parent (replaces Task 1's scaffolding 'continue' guard); inbound expired rows are deleted outright (both sides expire independently — no cross-instance network call) - janitor: 30-day cleanup pass for read notifications (cleanupReadPeeringNotifications); unread rows persist indefinitely - cleanupExpiredApprovalRequests is now sync (no async network IO) Notes: - No WS broadcast on janitor expiry — offline users see notifications on next GET, matching the persistence guarantee of the notifications table. - The previous /peer/denied network call on inbound expiry is removed; symmetric per-side expiry now handles termination on both sides. Tests: 12 new (3 + 4 endpoint cases on 3 endpoints; 2 outbound/inbound janitor expiry cases + 1 not-yet-expired guard + 1 retention sweep); 247 server tests passing total. --- .../federation.peeringNotifications.test.ts | 526 ++++++++++++++++++ packages/server/src/routes/federation.ts | 86 +++ .../storageJanitor.peeringExpiry.test.ts | 289 ++++++++++ packages/server/src/utils/storageJanitor.ts | 136 +++-- 4 files changed, 985 insertions(+), 52 deletions(-) create mode 100644 packages/server/src/routes/federation.peeringNotifications.test.ts create mode 100644 packages/server/src/utils/storageJanitor.peeringExpiry.test.ts diff --git a/packages/server/src/routes/federation.peeringNotifications.test.ts b/packages/server/src/routes/federation.peeringNotifications.test.ts new file mode 100644 index 00000000..9938de33 --- /dev/null +++ b/packages/server/src/routes/federation.peeringNotifications.test.ts @@ -0,0 +1,526 @@ +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; + +// The userId set onto request by the mocked authenticate preHandler. Tests +// override this per-case to simulate different authenticated users. +let currentUserId = 'alice'; + +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 = currentUserId; + }, + 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(); +} + +function seedUser(id: string, username: string): void { + testDb.insert(schema.users).values({ + id, + username, + passwordHash: 'x', + displayName: username, + createdAt: Date.now(), + }).run(); +} + +interface SeedNotif { + id: string; + userId: string; + kind: 'approved' | 'denied' | 'expired'; + peerOrigin: string; + triggerReason: string; + triggerTarget: string; + createdAt: number; + readAt: number | null; +} + +function seedNotification(n: SeedNotif): void { + testDb.insert(schema.peerApprovalNotifications).values({ + id: n.id, + userId: n.userId, + kind: n.kind, + peerOrigin: n.peerOrigin, + triggerReason: n.triggerReason, + triggerTarget: n.triggerTarget, + createdAt: n.createdAt, + readAt: n.readAt, + }).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('GET /api/federation/peering-notifications', () => { + let app: FastifyInstance; + + beforeEach(async () => { + sqlite = new Database(':memory:'); + testDb = drizzle(sqlite, { schema }); + applyMigrations(sqlite); + seedInstanceSettings(); + seedUser('alice', 'alice'); + seedUser('bob', 'bob'); + currentUserId = 'alice'; + app = await buildApp(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + sqlite.close(); + }); + + // Test 22: GET → user's rows ordered DESC by createdAt. + it("returns the user's notifications ordered DESC by createdAt", async () => { + const t0 = 1_700_000_000_000; + seedNotification({ + id: 'notif-old', + userId: 'alice', + kind: 'approved', + peerOrigin: 'https://orbit.example', + triggerReason: 'friend_add', + triggerTarget: 'someone@orbit.example', + createdAt: t0, + readAt: null, + }); + seedNotification({ + id: 'notif-new', + userId: 'alice', + kind: 'denied', + peerOrigin: 'https://other.example', + triggerReason: 'space_join', + triggerTarget: 'invite-xyz', + createdAt: t0 + 1_000, + readAt: null, + }); + seedNotification({ + id: 'notif-mid', + userId: 'alice', + kind: 'expired', + peerOrigin: 'https://third.example', + triggerReason: 'direct_message', + triggerTarget: 'pal@third.example', + createdAt: t0 + 500, + readAt: t0 + 800, + }); + + const response = await app.inject({ + method: 'GET', + url: '/api/federation/peering-notifications', + }); + + expect(response.statusCode).toBe(200); + const body = response.json() as { + notifications: Array<{ + id: string; + userId: string; + kind: string; + peerOrigin: string; + triggerReason: string; + triggerTarget: string; + createdAt: number; + readAt: number | null; + }>; + }; + + expect(body.notifications).toHaveLength(3); + expect(body.notifications.map(n => n.id)).toEqual(['notif-new', 'notif-mid', 'notif-old']); + + // Shape check on the newest row. + expect(body.notifications[0]).toEqual({ + id: 'notif-new', + userId: 'alice', + kind: 'denied', + peerOrigin: 'https://other.example', + triggerReason: 'space_join', + triggerTarget: 'invite-xyz', + createdAt: t0 + 1_000, + readAt: null, + }); + }); + + // Test 23: GET ?unread=1 → only readAt IS NULL rows. + it('filters to only unread notifications when ?unread=1', async () => { + const t0 = 1_700_000_000_000; + seedNotification({ + id: 'notif-unread-1', + userId: 'alice', + kind: 'approved', + peerOrigin: 'https://a.example', + triggerReason: 'friend_add', + triggerTarget: 'a@a.example', + createdAt: t0, + readAt: null, + }); + seedNotification({ + id: 'notif-read', + userId: 'alice', + kind: 'denied', + peerOrigin: 'https://b.example', + triggerReason: 'space_join', + triggerTarget: 'invite-y', + createdAt: t0 + 100, + readAt: t0 + 200, + }); + seedNotification({ + id: 'notif-unread-2', + userId: 'alice', + kind: 'expired', + peerOrigin: 'https://c.example', + triggerReason: 'direct_message', + triggerTarget: 'c@c.example', + createdAt: t0 + 300, + readAt: null, + }); + + const response = await app.inject({ + method: 'GET', + url: '/api/federation/peering-notifications?unread=1', + }); + + expect(response.statusCode).toBe(200); + const body = response.json() as { notifications: Array<{ id: string; readAt: number | null }> }; + expect(body.notifications).toHaveLength(2); + // DESC ordering: unread-2 (newer) before unread-1 (older). + expect(body.notifications.map(n => n.id)).toEqual(['notif-unread-2', 'notif-unread-1']); + for (const n of body.notifications) { + expect(n.readAt).toBeNull(); + } + }); + + // Test 26 (cross-user): GET shows only the requesting user's notifications. + it('does NOT return other users\' notifications', async () => { + seedNotification({ + id: 'alice-notif', + userId: 'alice', + kind: 'approved', + peerOrigin: 'https://a.example', + triggerReason: 'friend_add', + triggerTarget: 'a@a.example', + createdAt: 1, + readAt: null, + }); + seedNotification({ + id: 'bob-notif', + userId: 'bob', + kind: 'denied', + peerOrigin: 'https://b.example', + triggerReason: 'friend_add', + triggerTarget: 'b@b.example', + createdAt: 2, + readAt: null, + }); + + const response = await app.inject({ + method: 'GET', + url: '/api/federation/peering-notifications', + }); + + expect(response.statusCode).toBe(200); + const body = response.json() as { notifications: Array<{ id: string; userId: string }> }; + expect(body.notifications).toHaveLength(1); + expect(body.notifications[0]!.id).toBe('alice-notif'); + expect(body.notifications[0]!.userId).toBe('alice'); + }); +}); + +describe('POST /api/federation/peering-notifications/:id/read', () => { + let app: FastifyInstance; + + beforeEach(async () => { + sqlite = new Database(':memory:'); + testDb = drizzle(sqlite, { schema }); + applyMigrations(sqlite); + seedInstanceSettings(); + seedUser('alice', 'alice'); + seedUser('bob', 'bob'); + currentUserId = 'alice'; + app = await buildApp(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + sqlite.close(); + }); + + // Test 24: POST :id/read → sets readAt. + it('marks the notification as read by setting readAt', async () => { + const t0 = 1_700_000_000_000; + seedNotification({ + id: 'notif-1', + userId: 'alice', + kind: 'approved', + peerOrigin: 'https://a.example', + triggerReason: 'friend_add', + triggerTarget: 'a@a.example', + createdAt: t0, + readAt: null, + }); + + const before = Date.now(); + const response = await app.inject({ + method: 'POST', + url: '/api/federation/peering-notifications/notif-1/read', + }); + const after = Date.now(); + + expect(response.statusCode).toBe(200); + expect(response.json()).toEqual({ success: true }); + + const row = testDb.select() + .from(schema.peerApprovalNotifications) + .where(eq(schema.peerApprovalNotifications.id, 'notif-1')) + .get(); + expect(row).toBeDefined(); + expect(row!.readAt).not.toBeNull(); + expect(row!.readAt!).toBeGreaterThanOrEqual(before); + expect(row!.readAt!).toBeLessThanOrEqual(after); + }); + + // Test 26: POST :id/read on another user's notif → 403. + it("returns 403 when the notification belongs to another user; row UNTOUCHED", async () => { + seedNotification({ + id: 'bob-notif', + userId: 'bob', + kind: 'denied', + peerOrigin: 'https://b.example', + triggerReason: 'friend_add', + triggerTarget: 'b@b.example', + createdAt: 1, + readAt: null, + }); + + // currentUserId is alice; row belongs to bob. + const response = await app.inject({ + method: 'POST', + url: '/api/federation/peering-notifications/bob-notif/read', + }); + + expect(response.statusCode).toBe(403); + expect((response.json() as { error: string }).error).toBe('forbidden'); + + const row = testDb.select() + .from(schema.peerApprovalNotifications) + .where(eq(schema.peerApprovalNotifications.id, 'bob-notif')) + .get(); + expect(row!.readAt).toBeNull(); + }); + + // Test 26: POST :id/read on non-existent → 404. + it('returns 404 when the notification does not exist', async () => { + const response = await app.inject({ + method: 'POST', + url: '/api/federation/peering-notifications/does-not-exist/read', + }); + + expect(response.statusCode).toBe(404); + expect((response.json() as { error: string }).error).toBe('notification_not_found'); + }); +}); + +describe('POST /api/federation/peering-notifications/read-all', () => { + let app: FastifyInstance; + + beforeEach(async () => { + sqlite = new Database(':memory:'); + testDb = drizzle(sqlite, { schema }); + applyMigrations(sqlite); + seedInstanceSettings(); + seedUser('alice', 'alice'); + seedUser('bob', 'bob'); + currentUserId = 'alice'; + app = await buildApp(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + sqlite.close(); + }); + + // Test 25: POST read-all → marks all unread as read; returns count. + // Already-read rows are NOT touched (their readAt is preserved). + it('marks the user\'s unread rows as read; preserves already-read readAt; returns affected count', async () => { + const t0 = 1_700_000_000_000; + const ALREADY_READ_AT = t0 + 50; + + seedNotification({ + id: 'unread-1', + userId: 'alice', + kind: 'approved', + peerOrigin: 'https://a.example', + triggerReason: 'friend_add', + triggerTarget: 'a@a.example', + createdAt: t0, + readAt: null, + }); + seedNotification({ + id: 'unread-2', + userId: 'alice', + kind: 'denied', + peerOrigin: 'https://b.example', + triggerReason: 'space_join', + triggerTarget: 'invite-y', + createdAt: t0 + 100, + readAt: null, + }); + seedNotification({ + id: 'already-read', + userId: 'alice', + kind: 'expired', + peerOrigin: 'https://c.example', + triggerReason: 'direct_message', + triggerTarget: 'c@c.example', + createdAt: t0 + 200, + readAt: ALREADY_READ_AT, + }); + + const before = Date.now(); + const response = await app.inject({ + method: 'POST', + url: '/api/federation/peering-notifications/read-all', + }); + const after = Date.now(); + + expect(response.statusCode).toBe(200); + expect(response.json()).toEqual({ success: true, count: 2 }); + + const u1 = testDb.select().from(schema.peerApprovalNotifications) + .where(eq(schema.peerApprovalNotifications.id, 'unread-1')).get(); + const u2 = testDb.select().from(schema.peerApprovalNotifications) + .where(eq(schema.peerApprovalNotifications.id, 'unread-2')).get(); + const ar = testDb.select().from(schema.peerApprovalNotifications) + .where(eq(schema.peerApprovalNotifications.id, 'already-read')).get(); + + expect(u1!.readAt).not.toBeNull(); + expect(u1!.readAt!).toBeGreaterThanOrEqual(before); + expect(u1!.readAt!).toBeLessThanOrEqual(after); + expect(u2!.readAt).not.toBeNull(); + expect(u2!.readAt!).toBeGreaterThanOrEqual(before); + expect(u2!.readAt!).toBeLessThanOrEqual(after); + + // already-read row's readAt is preserved (unchanged). + expect(ar!.readAt).toBe(ALREADY_READ_AT); + }); + + // Test 26: POST read-all only marks the requesting user's rows. + it('does NOT touch other users\' notifications', async () => { + seedNotification({ + id: 'alice-unread', + userId: 'alice', + kind: 'approved', + peerOrigin: 'https://a.example', + triggerReason: 'friend_add', + triggerTarget: 'a@a.example', + createdAt: 1, + readAt: null, + }); + seedNotification({ + id: 'bob-unread', + userId: 'bob', + kind: 'denied', + peerOrigin: 'https://b.example', + triggerReason: 'friend_add', + triggerTarget: 'b@b.example', + createdAt: 2, + readAt: null, + }); + + const response = await app.inject({ + method: 'POST', + url: '/api/federation/peering-notifications/read-all', + }); + + expect(response.statusCode).toBe(200); + expect(response.json()).toEqual({ success: true, count: 1 }); + + const aliceRow = testDb.select().from(schema.peerApprovalNotifications) + .where(eq(schema.peerApprovalNotifications.id, 'alice-unread')).get(); + const bobRow = testDb.select().from(schema.peerApprovalNotifications) + .where(eq(schema.peerApprovalNotifications.id, 'bob-unread')).get(); + + expect(aliceRow!.readAt).not.toBeNull(); + expect(bobRow!.readAt).toBeNull(); + }); +}); diff --git a/packages/server/src/routes/federation.ts b/packages/server/src/routes/federation.ts index c26fe11c..8acf51d2 100644 --- a/packages/server/src/routes/federation.ts +++ b/packages/server/src/routes/federation.ts @@ -1852,6 +1852,92 @@ export async function federationRoutes(app: FastifyInstance): Promise { }, ); + // ─── GET /api/federation/peering-notifications ───────────────────────────── + // User-facing: list the requesting user's terminal-state peering + // notifications (kind='approved'|'denied'|'expired'). Optional ?unread=1 + // filter narrows to rows where readAt IS NULL. Ordered DESC by createdAt + // (newest first). + app.get<{ Querystring: { unread?: string } }>( + '/api/federation/peering-notifications', + { preHandler: [authenticate] }, + async (request, reply) => { + const db = getDb(); + const userId = request.userId; + const unread = request.query?.unread === '1'; + + const whereClause = unread + ? and( + eq(schema.peerApprovalNotifications.userId, userId), + isNull(schema.peerApprovalNotifications.readAt), + ) + : eq(schema.peerApprovalNotifications.userId, userId); + + const notifications = db + .select() + .from(schema.peerApprovalNotifications) + .where(whereClause) + .orderBy(desc(schema.peerApprovalNotifications.createdAt)) + .all(); + + return reply.send({ notifications }); + }, + ); + + // ─── POST /api/federation/peering-notifications/:id/read ─────────────────── + // User-facing: mark a single peering notification as read. Authorization: + // notification.userId must match request.userId. + app.post<{ Params: { id: string } }>( + '/api/federation/peering-notifications/:id/read', + { preHandler: [authenticate] }, + async (request, reply) => { + const db = getDb(); + const { id } = request.params; + const userId = request.userId; + + const notif = db + .select() + .from(schema.peerApprovalNotifications) + .where(eq(schema.peerApprovalNotifications.id, id)) + .get(); + if (!notif) { + return reply.code(404).send({ error: 'notification_not_found', statusCode: 404 }); + } + if (notif.userId !== userId) { + return reply.code(403).send({ error: 'forbidden', statusCode: 403 }); + } + + db.update(schema.peerApprovalNotifications) + .set({ readAt: Date.now() }) + .where(eq(schema.peerApprovalNotifications.id, id)) + .run(); + return reply.send({ success: true }); + }, + ); + + // ─── POST /api/federation/peering-notifications/read-all ─────────────────── + // User-facing: mark all the requesting user's unread peering notifications + // as read. Already-read rows are NOT touched (their readAt is preserved). + // Returns the count of rows affected for UI feedback. + app.post( + '/api/federation/peering-notifications/read-all', + { preHandler: [authenticate] }, + async (request, reply) => { + const db = getDb(); + const userId = request.userId; + const result = db + .update(schema.peerApprovalNotifications) + .set({ readAt: Date.now() }) + .where( + and( + eq(schema.peerApprovalNotifications.userId, userId), + isNull(schema.peerApprovalNotifications.readAt), + ), + ) + .run(); + return reply.send({ success: true, count: result.changes }); + }, + ); + // ─── POST /api/federation/peers/:id/rotate ────────────────────────────────── // Admin-only: trigger immediate secret rotation for a peer. app.post<{ Params: { id: string } }>( diff --git a/packages/server/src/utils/storageJanitor.peeringExpiry.test.ts b/packages/server/src/utils/storageJanitor.peeringExpiry.test.ts new file mode 100644 index 00000000..a8e9488f --- /dev/null +++ b/packages/server/src/utils/storageJanitor.peeringExpiry.test.ts @@ -0,0 +1,289 @@ +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(2); + +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, + uploadDir: '/tmp/backspace-test-uploads', + }, +})); + +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 seedUser(id: string, username: string): void { + testDb.insert(schema.users).values({ + id, + username, + passwordHash: 'x', + displayName: username, + createdAt: Date.now(), + }).run(); +} + +describe('cleanupExpiredApprovalRequests', () => { + beforeEach(() => { + sqlite = new Database(':memory:'); + testDb = drizzle(sqlite, { schema }); + applyMigrations(sqlite); + seedUser('alice', 'alice'); + seedUser('bob', 'bob'); + }); + + afterEach(() => { + vi.restoreAllMocks(); + sqlite.close(); + }); + + // Janitor expiry test A: outbound row with subscribers + past expiresAt → + // janitor writes kind='expired' notifications to each subscriber, cascade-deletes parent. + it('outbound expired row: fans out kind=expired notifications to each subscriber, then cascade-deletes parent', async () => { + const past = Date.now() - 1_000; + testDb.insert(schema.peerApprovalRequests).values({ + id: 'req-out', + origin: 'https://orbit.example', + direction: 'outbound', + instanceName: 'Orbit', + hmacSecret: null, + requestedAt: past - 1000, + expiresAt: past, + approvalToken: null, + }).run(); + testDb.insert(schema.peerApprovalSubscribers).values({ + id: 'sub-alice', + requestId: 'req-out', + userId: 'alice', + triggerReason: 'friend_add', + triggerTarget: 'someone@orbit.example', + createdAt: past - 1000, + }).run(); + testDb.insert(schema.peerApprovalSubscribers).values({ + id: 'sub-bob', + requestId: 'req-out', + userId: 'bob', + triggerReason: 'space_join', + triggerTarget: 'invite-xyz', + createdAt: past - 500, + }).run(); + + const { cleanupExpiredApprovalRequests } = await import('./storageJanitor.js'); + const before = Date.now(); + const cleaned = cleanupExpiredApprovalRequests(); + const after = Date.now(); + + expect(cleaned).toBe(1); + + // Parent deleted. + expect(testDb.select().from(schema.peerApprovalRequests) + .where(eq(schema.peerApprovalRequests.id, 'req-out')).get()).toBeUndefined(); + + // Subscribers cascade-deleted. + expect(testDb.select().from(schema.peerApprovalSubscribers) + .where(eq(schema.peerApprovalSubscribers.requestId, 'req-out')).all()).toHaveLength(0); + + // Each subscriber received a kind='expired' notification preserving their + // trigger_reason / trigger_target. + const allNotifs = testDb.select().from(schema.peerApprovalNotifications).all(); + expect(allNotifs).toHaveLength(2); + + const aliceNotif = allNotifs.find(n => n.userId === 'alice'); + const bobNotif = allNotifs.find(n => n.userId === 'bob'); + + expect(aliceNotif).toBeDefined(); + expect(aliceNotif!.kind).toBe('expired'); + expect(aliceNotif!.peerOrigin).toBe('https://orbit.example'); + expect(aliceNotif!.triggerReason).toBe('friend_add'); + expect(aliceNotif!.triggerTarget).toBe('someone@orbit.example'); + expect(aliceNotif!.readAt).toBeNull(); + expect(aliceNotif!.createdAt).toBeGreaterThanOrEqual(before); + expect(aliceNotif!.createdAt).toBeLessThanOrEqual(after); + + expect(bobNotif).toBeDefined(); + expect(bobNotif!.kind).toBe('expired'); + expect(bobNotif!.peerOrigin).toBe('https://orbit.example'); + expect(bobNotif!.triggerReason).toBe('space_join'); + expect(bobNotif!.triggerTarget).toBe('invite-xyz'); + expect(bobNotif!.readAt).toBeNull(); + }); + + // Janitor expiry test B: inbound row with past expiresAt → janitor deletes + // it without writing any notifications. + it('inbound expired row: deletes outright with NO notifications written', async () => { + const past = Date.now() - 1_000; + testDb.insert(schema.peerApprovalRequests).values({ + id: 'req-in', + origin: 'https://orbit.example', + direction: 'inbound', + instanceName: 'Orbit', + hmacSecret: 'shared-secret', + requestedAt: past - 1000, + expiresAt: past, + approvalToken: null, + }).run(); + + const { cleanupExpiredApprovalRequests } = await import('./storageJanitor.js'); + const cleaned = cleanupExpiredApprovalRequests(); + + expect(cleaned).toBe(1); + + // Parent deleted. + expect(testDb.select().from(schema.peerApprovalRequests) + .where(eq(schema.peerApprovalRequests.id, 'req-in')).get()).toBeUndefined(); + + // No notifications written for inbound. + expect(testDb.select().from(schema.peerApprovalNotifications).all()).toHaveLength(0); + }); + + // Mixed: not-yet-expired rows are not touched, regardless of direction. + it('does not touch rows whose expiresAt is in the future', async () => { + const future = Date.now() + 60_000; + testDb.insert(schema.peerApprovalRequests).values({ + id: 'req-future-out', + origin: 'https://a.example', + direction: 'outbound', + instanceName: null, + hmacSecret: null, + requestedAt: Date.now(), + expiresAt: future, + approvalToken: null, + }).run(); + testDb.insert(schema.peerApprovalSubscribers).values({ + id: 'sub-future', + requestId: 'req-future-out', + userId: 'alice', + triggerReason: 'friend_add', + triggerTarget: 'a@a.example', + createdAt: Date.now(), + }).run(); + + const { cleanupExpiredApprovalRequests } = await import('./storageJanitor.js'); + const cleaned = cleanupExpiredApprovalRequests(); + + expect(cleaned).toBe(0); + + // Row still present. + expect(testDb.select().from(schema.peerApprovalRequests) + .where(eq(schema.peerApprovalRequests.id, 'req-future-out')).get()).toBeDefined(); + expect(testDb.select().from(schema.peerApprovalSubscribers) + .where(eq(schema.peerApprovalSubscribers.id, 'sub-future')).get()).toBeDefined(); + expect(testDb.select().from(schema.peerApprovalNotifications).all()).toHaveLength(0); + }); +}); + +describe('cleanupReadPeeringNotifications', () => { + beforeEach(() => { + sqlite = new Database(':memory:'); + testDb = drizzle(sqlite, { schema }); + applyMigrations(sqlite); + seedUser('alice', 'alice'); + }); + + afterEach(() => { + vi.restoreAllMocks(); + sqlite.close(); + }); + + // Cleanup test: read notifications older than 30 days are deleted; unread + // and recent-read notifications are NOT deleted. + it('deletes only read-AND-old notifications; unread and recent-read survive', async () => { + const now = Date.now(); + const THIRTY_DAYS = 30 * 24 * 60 * 60 * 1000; + const cutoffMargin = 60_000; + + // (a) Read & older than 30 days → DELETE + testDb.insert(schema.peerApprovalNotifications).values({ + id: 'old-read', + userId: 'alice', + kind: 'approved', + peerOrigin: 'https://a.example', + triggerReason: 'friend_add', + triggerTarget: 'a@a.example', + createdAt: now - THIRTY_DAYS - 5 * 60_000, + readAt: now - THIRTY_DAYS - cutoffMargin, + }).run(); + + // (b) Read & recent (< 30 days old) → KEEP + testDb.insert(schema.peerApprovalNotifications).values({ + id: 'recent-read', + userId: 'alice', + kind: 'denied', + peerOrigin: 'https://b.example', + triggerReason: 'space_join', + triggerTarget: 'invite-y', + createdAt: now - 5 * 60_000, + readAt: now - 60_000, + }).run(); + + // (c) Unread (regardless of age) → KEEP + testDb.insert(schema.peerApprovalNotifications).values({ + id: 'old-unread', + userId: 'alice', + kind: 'expired', + peerOrigin: 'https://c.example', + triggerReason: 'direct_message', + triggerTarget: 'c@c.example', + createdAt: now - THIRTY_DAYS - 10 * 60_000, + readAt: null, + }).run(); + testDb.insert(schema.peerApprovalNotifications).values({ + id: 'fresh-unread', + userId: 'alice', + kind: 'approved', + peerOrigin: 'https://d.example', + triggerReason: 'friend_add', + triggerTarget: 'd@d.example', + createdAt: now - 60_000, + readAt: null, + }).run(); + + const { cleanupReadPeeringNotifications } = await import('./storageJanitor.js'); + const deleted = cleanupReadPeeringNotifications(); + + expect(deleted).toBe(1); + + expect(testDb.select().from(schema.peerApprovalNotifications) + .where(eq(schema.peerApprovalNotifications.id, 'old-read')).get()).toBeUndefined(); + expect(testDb.select().from(schema.peerApprovalNotifications) + .where(eq(schema.peerApprovalNotifications.id, 'recent-read')).get()).toBeDefined(); + expect(testDb.select().from(schema.peerApprovalNotifications) + .where(eq(schema.peerApprovalNotifications.id, 'old-unread')).get()).toBeDefined(); + expect(testDb.select().from(schema.peerApprovalNotifications) + .where(eq(schema.peerApprovalNotifications.id, 'fresh-unread')).get()).toBeDefined(); + }); +}); diff --git a/packages/server/src/utils/storageJanitor.ts b/packages/server/src/utils/storageJanitor.ts index 73f0ebd2..78f1a562 100644 --- a/packages/server/src/utils/storageJanitor.ts +++ b/packages/server/src/utils/storageJanitor.ts @@ -4,6 +4,7 @@ import { and, eq, inArray, isNotNull, isNull, lt, lte } from 'drizzle-orm'; import { config } from '../config.js'; import { getDb, getRawDb, schema } from '../db/index.js'; import { deleteUploadFile, deleteAttachmentFiles } from './fileCleanup.js'; +import { generateSnowflake } from './snowflake.js'; import type { StorageStats, StorageBreakdown, OrphanedFile, CleanupResult } from '@backspace/shared'; const IMAGE_EXTS = new Set(['.jpg', '.jpeg', '.png', '.gif', '.webp', '.svg', '.ico', '.bmp', '.avif']); @@ -433,65 +434,92 @@ export function cleanupFederationFileQueue(): number { /** * Expire peer approval requests older than their expiresAt timestamp. - * For each expired request, attempt to send a signed denial notification - * to the requesting instance before deleting. If notification fails, - * leave the record for the next janitor cycle. + * + * Outbound rows: before deletion, fan out kind='expired' notifications to + * each subscriber so the original requester(s) can see the terminal state on + * next page load. Subscriber rows cascade-delete via FK when the parent row + * is removed. + * + * Inbound rows: deleted outright. Both sides expire independently (their + * own janitor will fan out kind='expired' notifications to their subscribers + * on their schedule); no cross-instance network notification is required. + * + * No WebSocket broadcast is emitted on expiry — offline users see the + * notification on next GET /api/federation/peering-notifications, matching + * the persistence guarantee of the notifications table. */ -export async function cleanupExpiredApprovalRequests(): Promise { +export function cleanupExpiredApprovalRequests(): number { const db = getDb(); - const expired = db + const expiredRequests = db .select() .from(schema.peerApprovalRequests) .where(lte(schema.peerApprovalRequests.expiresAt, Date.now())) .all(); - if (expired.length === 0) return 0; + if (expiredRequests.length === 0) return 0; - const { getOurOrigin, buildFederationHeaders } = await import('./federationAuth.js'); - const ourOrigin = getOurOrigin(); - let cleaned = 0; - - for (const req of expired) { - // Outbound rows have no hmac_secret and no remote /peer/denied endpoint; - // their expiry handling (subscriber notifications) lands in Task 9. - // Until then, skip outbound rows here so this loop only processes inbound expirations. - if (!req.hmacSecret) { - continue; - } - - const denialBody = JSON.stringify({ - origin: ourOrigin, - reason: 'expired' as const, - message: 'Request expired — no response from admin within 30 days', - }); - - const headers = buildFederationHeaders(denialBody, req.hmacSecret, ourOrigin); - - let sent = false; - try { - const response = await fetch(`${req.origin}/api/federation/peer/denied`, { - method: 'POST', - headers, - body: denialBody, - signal: AbortSignal.timeout(10_000), - }); - sent = response.ok; - } catch { - // Network error — will retry next cycle - } - - if (sent) { - // No rejected peer record for expiry — origin can re-request - db.delete(schema.peerApprovalRequests) - .where(eq(schema.peerApprovalRequests.id, req.id)) - .run(); - cleaned++; - } else { - console.warn(`[storage-janitor] Failed to send expiry denial to ${req.origin} — will retry next cycle`); + const now = Date.now(); + let deletedCount = 0; + for (const req of expiredRequests) { + if (req.direction === 'outbound') { + // Fan out kind='expired' notifications to subscribers before delete. + const subs = db + .select() + .from(schema.peerApprovalSubscribers) + .where(eq(schema.peerApprovalSubscribers.requestId, req.id)) + .all(); + for (const sub of subs) { + db.insert(schema.peerApprovalNotifications) + .values({ + id: generateSnowflake(), + userId: sub.userId, + kind: 'expired', + peerOrigin: req.origin, + triggerReason: sub.triggerReason, + triggerTarget: sub.triggerTarget, + createdAt: now, + readAt: null, + }) + .run(); + } } + // Cascade-delete the parent (subscribers go via FK cascade for outbound; + // inbound has no subscribers to clear). + db.delete(schema.peerApprovalRequests) + .where(eq(schema.peerApprovalRequests.id, req.id)) + .run(); + deletedCount++; } - return cleaned; + if (deletedCount > 0) { + console.log(`[storage-janitor] Deleted ${deletedCount} expired peer_approval_requests rows`); + } + + return deletedCount; +} + +/** + * Delete read peering notifications older than 30 days. Unread notifications + * are never auto-cleaned — the user must explicitly read or dismiss them. + */ +const NOTIFICATION_RETENTION_MS = 30 * 24 * 60 * 60 * 1000; + +export function cleanupReadPeeringNotifications(): number { + const db = getDb(); + const cutoff = Date.now() - NOTIFICATION_RETENTION_MS; + const result = db + .delete(schema.peerApprovalNotifications) + .where( + and( + isNotNull(schema.peerApprovalNotifications.readAt), + lte(schema.peerApprovalNotifications.readAt, cutoff), + ), + ) + .run(); + if (result.changes > 0) { + console.log(`[storage-janitor] Deleted ${result.changes} read peering notifications older than 30 days`); + } + return result.changes; } /** @@ -629,14 +657,18 @@ export function runFederationJanitor(): void { ); } - // Async: expire approval requests (sends network notifications) - cleanupExpiredApprovalRequests().then((approvalExpired) => { + // Expire approval requests (outbound rows fan out kind='expired' + // notifications to subscribers before delete; inbound rows are deleted + // outright). Then sweep read peering notifications older than 30 days. + try { + const approvalExpired = cleanupExpiredApprovalRequests(); if (approvalExpired > 0) { console.log(`[storage-janitor] Expired ${approvalExpired} peer approval request(s)`); } - }).catch((err) => { + cleanupReadPeeringNotifications(); + } catch (err) { console.error('[storage-janitor] Approval request expiry error:', err); - }); + } } catch (err) { console.error('[storage-janitor] Federation GC sweep error:', err); } From 4064d822cdebb813b9960a1829ee9d0822bc613b Mon Sep 17 00:00:00 2001 From: Jannis Braun <151788261+TheZwiss@users.noreply.github.com> Date: Sun, 26 Apr 2026 22:22:34 +0200 Subject: [PATCH 13/20] fix(janitor): restore inbound /peer/denied expiry notification MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Task 9's first pass replaced inbound expiry's signed /peer/denied POST with a plain delete, citing symmetric independent expiry as the design. The spec at §4.10 explicitly said 'inbound row cleanup unchanged' — that was scope creep, not a fix. Inbound expiry now preserves the pre-branch behavior verbatim: signed POST to remote, delete only on success, retry on failure. Outbound expiry's fanout logic (the actual Task 9 scope) is unchanged. --- .../storageJanitor.peeringExpiry.test.ts | 108 ++++++++++++++++-- packages/server/src/utils/storageJanitor.ts | 85 +++++++++++--- 2 files changed, 168 insertions(+), 25 deletions(-) diff --git a/packages/server/src/utils/storageJanitor.peeringExpiry.test.ts b/packages/server/src/utils/storageJanitor.peeringExpiry.test.ts index a8e9488f..ce99ba95 100644 --- a/packages/server/src/utils/storageJanitor.peeringExpiry.test.ts +++ b/packages/server/src/utils/storageJanitor.peeringExpiry.test.ts @@ -102,11 +102,18 @@ describe('cleanupExpiredApprovalRequests', () => { createdAt: past - 500, }).run(); + // Outbound expiry must NOT make any network call. Spy on fetch to assert. + const fetchSpy = vi.spyOn(globalThis, 'fetch').mockResolvedValue( + new Response(null, { status: 200 }), + ); + const { cleanupExpiredApprovalRequests } = await import('./storageJanitor.js'); const before = Date.now(); - const cleaned = cleanupExpiredApprovalRequests(); + const cleaned = await cleanupExpiredApprovalRequests(); const after = Date.now(); + expect(fetchSpy).not.toHaveBeenCalled(); + expect(cleaned).toBe(1); // Parent deleted. @@ -142,9 +149,10 @@ describe('cleanupExpiredApprovalRequests', () => { expect(bobNotif!.readAt).toBeNull(); }); - // Janitor expiry test B: inbound row with past expiresAt → janitor deletes - // it without writing any notifications. - it('inbound expired row: deletes outright with NO notifications written', async () => { + // Janitor expiry test B1: inbound row with past expiresAt + successful + // /peer/denied POST → janitor sends signed denial, then deletes the row. + // No local notifications are written for inbound. + it('inbound expired row: sends signed /peer/denied to origin and deletes on success', async () => { const past = Date.now() - 1_000; testDb.insert(schema.peerApprovalRequests).values({ id: 'req-in', @@ -157,16 +165,102 @@ describe('cleanupExpiredApprovalRequests', () => { approvalToken: null, }).run(); + const fetchSpy = vi.spyOn(globalThis, 'fetch').mockResolvedValue( + new Response(null, { status: 200 }), + ); + const { cleanupExpiredApprovalRequests } = await import('./storageJanitor.js'); - const cleaned = cleanupExpiredApprovalRequests(); + const cleaned = await cleanupExpiredApprovalRequests(); expect(cleaned).toBe(1); + // Exactly one signed POST to the origin's /peer/denied endpoint. + expect(fetchSpy).toHaveBeenCalledTimes(1); + const [url, init] = fetchSpy.mock.calls[0]!; + expect(url).toBe('https://orbit.example/api/federation/peer/denied'); + expect(init?.method).toBe('POST'); + + const headers = init?.headers as Record; + expect(headers['X-Federation-Signature']).toMatch(/^sha256=[0-9a-f]+$/); + expect(headers['X-Federation-Origin']).toBe('https://local.example'); + expect(headers['X-Federation-Timestamp']).toMatch(/^\d+$/); + expect(headers['X-Federation-Nonce']).toBeDefined(); + expect(headers['Content-Type']).toBe('application/json'); + + const body = JSON.parse(init?.body as string); + expect(body.origin).toBe('https://local.example'); + expect(body.reason).toBe('expired'); + expect(typeof body.message).toBe('string'); + expect(body.message.length).toBeGreaterThan(0); + // Parent deleted. expect(testDb.select().from(schema.peerApprovalRequests) .where(eq(schema.peerApprovalRequests.id, 'req-in')).get()).toBeUndefined(); - // No notifications written for inbound. + // No local notifications written for inbound. + expect(testDb.select().from(schema.peerApprovalNotifications).all()).toHaveLength(0); + }); + + // Janitor expiry test B2: inbound row with past expiresAt + failed POST + // (network error or non-2xx) → janitor keeps the row for retry on the + // next cycle. No local notifications written. + it('inbound expired row: keeps row for retry when /peer/denied POST fails', async () => { + const past = Date.now() - 1_000; + testDb.insert(schema.peerApprovalRequests).values({ + id: 'req-in-fail', + origin: 'https://orbit.example', + direction: 'inbound', + instanceName: 'Orbit', + hmacSecret: 'shared-secret', + requestedAt: past - 1000, + expiresAt: past, + approvalToken: null, + }).run(); + + // Simulate a 503 (non-ok response) — the row must remain. + const fetchSpy = vi.spyOn(globalThis, 'fetch').mockResolvedValue( + new Response(null, { status: 503 }), + ); + + const { cleanupExpiredApprovalRequests } = await import('./storageJanitor.js'); + const cleaned = await cleanupExpiredApprovalRequests(); + + expect(cleaned).toBe(0); + expect(fetchSpy).toHaveBeenCalledTimes(1); + + // Row still present — eligible for retry next cycle. + expect(testDb.select().from(schema.peerApprovalRequests) + .where(eq(schema.peerApprovalRequests.id, 'req-in-fail')).get()).toBeDefined(); + + // No local notifications written. + expect(testDb.select().from(schema.peerApprovalNotifications).all()).toHaveLength(0); + }); + + // Network-error variant of B2: thrown error (e.g. AbortError, DNS failure) + // is caught and treated as not-sent. Row must remain for retry. + it('inbound expired row: keeps row when fetch throws a network error', async () => { + const past = Date.now() - 1_000; + testDb.insert(schema.peerApprovalRequests).values({ + id: 'req-in-throw', + origin: 'https://orbit.example', + direction: 'inbound', + instanceName: 'Orbit', + hmacSecret: 'shared-secret', + requestedAt: past - 1000, + expiresAt: past, + approvalToken: null, + }).run(); + + const fetchSpy = vi.spyOn(globalThis, 'fetch').mockRejectedValue(new Error('ECONNREFUSED')); + + const { cleanupExpiredApprovalRequests } = await import('./storageJanitor.js'); + const cleaned = await cleanupExpiredApprovalRequests(); + + expect(cleaned).toBe(0); + expect(fetchSpy).toHaveBeenCalledTimes(1); + + expect(testDb.select().from(schema.peerApprovalRequests) + .where(eq(schema.peerApprovalRequests.id, 'req-in-throw')).get()).toBeDefined(); expect(testDb.select().from(schema.peerApprovalNotifications).all()).toHaveLength(0); }); @@ -193,7 +287,7 @@ describe('cleanupExpiredApprovalRequests', () => { }).run(); const { cleanupExpiredApprovalRequests } = await import('./storageJanitor.js'); - const cleaned = cleanupExpiredApprovalRequests(); + const cleaned = await cleanupExpiredApprovalRequests(); expect(cleaned).toBe(0); diff --git a/packages/server/src/utils/storageJanitor.ts b/packages/server/src/utils/storageJanitor.ts index 78f1a562..164bafe1 100644 --- a/packages/server/src/utils/storageJanitor.ts +++ b/packages/server/src/utils/storageJanitor.ts @@ -438,17 +438,18 @@ export function cleanupFederationFileQueue(): number { * Outbound rows: before deletion, fan out kind='expired' notifications to * each subscriber so the original requester(s) can see the terminal state on * next page load. Subscriber rows cascade-delete via FK when the parent row - * is removed. + * is removed. No network call — the remote was never told about our queued + * outbound request to begin with. * - * Inbound rows: deleted outright. Both sides expire independently (their - * own janitor will fan out kind='expired' notifications to their subscribers - * on their schedule); no cross-instance network notification is required. + * Inbound rows: send a signed POST /peer/denied with reason='expired' to + * the requesting origin so it can update its UI. Only delete the row if the + * POST succeeded; otherwise keep it and retry on the next janitor cycle. * - * No WebSocket broadcast is emitted on expiry — offline users see the + * No WebSocket broadcast is emitted on local expiry — offline users see the * notification on next GET /api/federation/peering-notifications, matching * the persistence guarantee of the notifications table. */ -export function cleanupExpiredApprovalRequests(): number { +export async function cleanupExpiredApprovalRequests(): Promise { const db = getDb(); const expiredRequests = db .select() @@ -458,11 +459,15 @@ export function cleanupExpiredApprovalRequests(): number { if (expiredRequests.length === 0) return 0; + const { getOurOrigin, buildFederationHeaders } = await import('./federationAuth.js'); + const ourOrigin = getOurOrigin(); const now = Date.now(); let deletedCount = 0; + for (const req of expiredRequests) { if (req.direction === 'outbound') { - // Fan out kind='expired' notifications to subscribers before delete. + // Outbound expiry: fan out kind='expired' notifications to subscribers, + // then cascade-delete. No network call. const subs = db .select() .from(schema.peerApprovalSubscribers) @@ -482,13 +487,54 @@ export function cleanupExpiredApprovalRequests(): number { }) .run(); } + db.delete(schema.peerApprovalRequests) + .where(eq(schema.peerApprovalRequests.id, req.id)) + .run(); + deletedCount++; + continue; + } + + // Inbound expiry: signed /peer/denied POST to origin; only delete on + // success. Preserved verbatim from pre-branch behavior. + if (!req.hmacSecret) { + // CHECK constraint guarantees inbound rows have hmac_secret; defensive guard. + console.warn( + `[storage-janitor] Inbound approval-request ${req.id} missing hmac_secret — skipping expiry notification`, + ); + continue; + } + + const denialBody = JSON.stringify({ + origin: ourOrigin, + reason: 'expired' as const, + message: 'Request expired — no response from admin within 30 days', + }); + const headers = buildFederationHeaders(denialBody, req.hmacSecret, ourOrigin); + + let sent = false; + try { + const response = await fetch(`${req.origin}/api/federation/peer/denied`, { + method: 'POST', + headers, + body: denialBody, + signal: AbortSignal.timeout(10_000), + }); + sent = response.ok; + } catch { + // Network error — will retry next cycle + } + + if (sent) { + // No rejected peer record for expiry — origin can re-request + db.delete(schema.peerApprovalRequests) + .where(eq(schema.peerApprovalRequests.id, req.id)) + .run(); + deletedCount++; + } else { + console.warn( + `[storage-janitor] Failed to send expiry denial to ${req.origin} — will retry next cycle`, + ); } - // Cascade-delete the parent (subscribers go via FK cascade for outbound; - // inbound has no subscribers to clear). - db.delete(schema.peerApprovalRequests) - .where(eq(schema.peerApprovalRequests.id, req.id)) - .run(); - deletedCount++; } if (deletedCount > 0) { @@ -643,7 +689,7 @@ export function cleanupSoftDeletedDmChannels(): number { * - Stale file queue entries * - Soft-deleted DM channels past grace period */ -export function runFederationJanitor(): void { +export async function runFederationJanitor(): Promise { try { const outbox = cleanupFederationOutbox(); const mutLog = cleanupFederationMutationLog(); @@ -657,11 +703,14 @@ export function runFederationJanitor(): void { ); } - // Expire approval requests (outbound rows fan out kind='expired' - // notifications to subscribers before delete; inbound rows are deleted - // outright). Then sweep read peering notifications older than 30 days. + // Expire approval requests: + // - Outbound rows fan out kind='expired' notifications to subscribers + // before cascade-delete (local DB only). + // - Inbound rows send a signed /peer/denied POST to the requesting + // origin; only delete on success, otherwise retry next cycle. + // Then sweep read peering notifications older than 30 days. try { - const approvalExpired = cleanupExpiredApprovalRequests(); + const approvalExpired = await cleanupExpiredApprovalRequests(); if (approvalExpired > 0) { console.log(`[storage-janitor] Expired ${approvalExpired} peer approval request(s)`); } From e82ccbde62c7997779ac7ef561e004f2d92d0ee4 Mon Sep 17 00:00:00 2001 From: Jannis Braun <151788261+TheZwiss@users.noreply.github.com> Date: Sun, 26 Apr 2026 22:27:43 +0200 Subject: [PATCH 14/20] fix(federation): tighten peering-notifications GET response to spec shape MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit db.select() returned every column including userId; spec §4.9 defined the response row WITHOUT userId. The leak is harmless today (user queries their own rows) but expands the public API surface beyond the spec, and would become part of the contract once Task 10 generates client types. Switching to explicit column projection. --- .../src/routes/federation.peeringNotifications.test.ts | 7 +++---- packages/server/src/routes/federation.ts | 10 +++++++++- 2 files changed, 12 insertions(+), 5 deletions(-) diff --git a/packages/server/src/routes/federation.peeringNotifications.test.ts b/packages/server/src/routes/federation.peeringNotifications.test.ts index 9938de33..02ac0027 100644 --- a/packages/server/src/routes/federation.peeringNotifications.test.ts +++ b/packages/server/src/routes/federation.peeringNotifications.test.ts @@ -195,7 +195,6 @@ describe('GET /api/federation/peering-notifications', () => { const body = response.json() as { notifications: Array<{ id: string; - userId: string; kind: string; peerOrigin: string; triggerReason: string; @@ -211,7 +210,6 @@ describe('GET /api/federation/peering-notifications', () => { // Shape check on the newest row. expect(body.notifications[0]).toEqual({ id: 'notif-new', - userId: 'alice', kind: 'denied', peerOrigin: 'https://other.example', triggerReason: 'space_join', @@ -299,10 +297,11 @@ describe('GET /api/federation/peering-notifications', () => { }); expect(response.statusCode).toBe(200); - const body = response.json() as { notifications: Array<{ id: string; userId: string }> }; + const body = response.json() as { notifications: Array<{ id: string }> }; expect(body.notifications).toHaveLength(1); + // Alice's row comes back (isolation: bob-notif is excluded by userId filter + // in the WHERE clause; the response shape itself no longer surfaces userId). expect(body.notifications[0]!.id).toBe('alice-notif'); - expect(body.notifications[0]!.userId).toBe('alice'); }); }); diff --git a/packages/server/src/routes/federation.ts b/packages/server/src/routes/federation.ts index 8acf51d2..e77fa108 100644 --- a/packages/server/src/routes/federation.ts +++ b/packages/server/src/routes/federation.ts @@ -1873,7 +1873,15 @@ export async function federationRoutes(app: FastifyInstance): Promise { : eq(schema.peerApprovalNotifications.userId, userId); const notifications = db - .select() + .select({ + id: schema.peerApprovalNotifications.id, + kind: schema.peerApprovalNotifications.kind, + peerOrigin: schema.peerApprovalNotifications.peerOrigin, + triggerReason: schema.peerApprovalNotifications.triggerReason, + triggerTarget: schema.peerApprovalNotifications.triggerTarget, + createdAt: schema.peerApprovalNotifications.createdAt, + readAt: schema.peerApprovalNotifications.readAt, + }) .from(schema.peerApprovalNotifications) .where(whereClause) .orderBy(desc(schema.peerApprovalNotifications.createdAt)) From 51da827089c48f31b98dc91fb9282a572c636b27 Mon Sep 17 00:00:00 2001 From: Jannis Braun <151788261+TheZwiss@users.noreply.github.com> Date: Sun, 26 Apr 2026 22:32:36 +0200 Subject: [PATCH 15/20] feat(web): API client methods for peering subscriptions and notifications - Drops the web-local ApprovalRequest interface in favor of the canonical shared type (Task 2 already established this; Task 10 reconciles the consumer side). - Adds five new federation methods wrapping the REST endpoints added in Tasks 8 and 9: peeringSubscriptions (GET), cancelPeering Subscription (DELETE), peeringNotifications (GET +/- unreadOnly filter), markPeeringNotificationRead (POST :id/read), markAll PeeringNotificationsRead (POST read-all). --- packages/web/src/api/client.ts | 39 ++++++++++++++++++++++++++-------- 1 file changed, 30 insertions(+), 9 deletions(-) diff --git a/packages/web/src/api/client.ts b/packages/web/src/api/client.ts index e2360f9f..d80d49e0 100644 --- a/packages/web/src/api/client.ts +++ b/packages/web/src/api/client.ts @@ -53,9 +53,12 @@ import type { FederationIdentityDeleteRequest, FederationIdentityDeleteResponse, FederationPeer, + ApprovalRequest, + PeeringSubscription, + PeeringNotification, } from '@backspace/shared'; -export type { FederationPeer }; +export type { FederationPeer, ApprovalRequest, PeeringSubscription, PeeringNotification }; export class RateLimitError extends Error { readonly retryAfter: number; @@ -66,14 +69,6 @@ export class RateLimitError extends Error { } } -export interface ApprovalRequest { - id: string; - origin: string; - instanceName: string | null; - requestedAt: number; - expiresAt: number; -} - export class BackspaceApiClient { readonly auth: { register: (data: RegisterRequest) => Promise; @@ -230,6 +225,11 @@ export class BackspaceApiClient { approvalRequests: () => Promise<{ requests: ApprovalRequest[] }>; approveRequest: (id: string) => Promise<{ success: boolean; peer?: FederationPeer }>; denyRequest: (id: string) => Promise<{ success: boolean }>; + peeringSubscriptions: () => Promise<{ subscriptions: PeeringSubscription[] }>; + cancelPeeringSubscription: (id: string) => Promise<{ success: boolean }>; + peeringNotifications: (unreadOnly?: boolean) => Promise<{ notifications: PeeringNotification[] }>; + markPeeringNotificationRead: (id: string) => Promise<{ success: boolean }>; + markAllPeeringNotificationsRead: () => Promise<{ success: boolean; count: number }>; }; readonly admin: { @@ -697,6 +697,27 @@ export class BackspaceApiClient { request<{ success: boolean }>( 'POST', `/federation/approval-requests/${id}/deny` ), + peeringSubscriptions: () => + request<{ subscriptions: PeeringSubscription[] }>( + 'GET', '/federation/peering-subscriptions' + ), + cancelPeeringSubscription: (id: string) => + request<{ success: boolean }>( + 'DELETE', `/federation/peering-subscriptions/${id}` + ), + peeringNotifications: (unreadOnly = false) => + request<{ notifications: PeeringNotification[] }>( + 'GET', + `/federation/peering-notifications${unreadOnly ? '?unread=1' : ''}`, + ), + markPeeringNotificationRead: (id: string) => + request<{ success: boolean }>( + 'POST', `/federation/peering-notifications/${id}/read` + ), + markAllPeeringNotificationsRead: () => + request<{ success: boolean; count: number }>( + 'POST', '/federation/peering-notifications/read-all' + ), }; this.admin = { From eddf2254cc9e29ef9071d706cae8a90c455877fc Mon Sep 17 00:00:00 2001 From: Jannis Braun <151788261+TheZwiss@users.noreply.github.com> Date: Sun, 26 Apr 2026 22:35:16 +0200 Subject: [PATCH 16/20] feat(web): render outbound peering requests with subscriber list in admin panel MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PendingApprovals now branches row rendering on direction. Inbound rows render exactly as before. Outbound rows show ' — N users want us to peer' with an inline subscriber list (username + reason + target). ConfirmDialog descriptions branch on direction so admins see appropriate copy for outbound approve (initiate handshake on behalf of N users) vs outbound deny (notify requesting users). --- .../FederationPanel.tsx | 77 ++++++++++++++----- 1 file changed, 59 insertions(+), 18 deletions(-) diff --git a/packages/web/src/components/modals/instanceSettingsPanels/FederationPanel.tsx b/packages/web/src/components/modals/instanceSettingsPanels/FederationPanel.tsx index bf313a8d..1edfdaf4 100644 --- a/packages/web/src/components/modals/instanceSettingsPanels/FederationPanel.tsx +++ b/packages/web/src/components/modals/instanceSettingsPanels/FederationPanel.tsx @@ -693,16 +693,44 @@ function PendingApprovals({ onCountChange }: { onCountChange?: (count: number) =
Loading...
)} {requests.map((req) => { - const name = req.instanceName || new URL(req.origin).host; + const isOutbound = req.direction === 'outbound'; + let name = req.instanceName || ''; + if (!name) { + try { + name = new URL(req.origin).host; + } catch { + name = req.origin; + } + } + const subCount = req.subscribers?.length ?? 0; + const titleText = isOutbound + ? `${name} — ${subCount} ${subCount === 1 ? 'user wants' : 'users want'} us to peer` + : name; return (
-
{name}
+
{titleText}
{req.origin}
Requested {formatRelativeTime(req.requestedAt)}
+ {isOutbound && req.subscribers && req.subscribers.length > 0 && ( +
+ {req.subscribers.map((sub) => ( +
+ {sub.username} + {' — '} + {sub.triggerReason === 'friend_add' && `friend-add to ${sub.triggerTarget}`} + {sub.triggerReason === 'space_join' && `wants to join ${sub.triggerTarget}`} + {sub.triggerReason === 'direct_message' && `wants to DM ${sub.triggerTarget}`} +
+ ))} +
+ )}
); } From cebbd5c859a9faeca510a5997dae72406ca1e1c0 Mon Sep 17 00:00:00 2001 From: Jannis Braun <151788261+TheZwiss@users.noreply.github.com> Date: Sun, 26 Apr 2026 22:42:53 +0200 Subject: [PATCH 17/20] feat(web): user-facing pending peering subscriptions and outcome notifications MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two new inline sections in the user-facing federation/connections settings panel: 'Recent peering outcomes' (terminal-state notifications with Retry-for-approved + Dismiss) and 'Pending peering approvals' (active subscriber rows the user is waiting on, with Cancel). New WS handlers for peering_subscription_changed and peering_notification_received refresh the lists in real-time and surface a transient toast for online users. Retry deep-link for friend_add prefills the friend-add input with the original target handle (other reasons get Dismiss only — the gate doesn't wire those paths yet). --- .../web/src/components/chat/FriendsPage.tsx | 20 +- .../components/modals/ConnectedInstances.tsx | 339 +++++++++++++++++- packages/web/src/hooks/useWebSocket.ts | 28 ++ packages/web/src/stores/federationStore.ts | 96 +++++ 4 files changed, 479 insertions(+), 4 deletions(-) create mode 100644 packages/web/src/stores/federationStore.ts diff --git a/packages/web/src/components/chat/FriendsPage.tsx b/packages/web/src/components/chat/FriendsPage.tsx index 39df158b..14cfc717 100644 --- a/packages/web/src/components/chat/FriendsPage.tsx +++ b/packages/web/src/components/chat/FriendsPage.tsx @@ -7,6 +7,7 @@ import { mapServerErrorToMessage } from '../../utils/friendErrors'; import { useSpaceStore } from '../../stores/spaceStore'; import { useInstanceStore } from '../../stores/instanceStore'; import { useUIStore } from '../../stores/uiStore'; +import { useFederationStore } from '../../stores/federationStore'; import { Avatar } from '../ui/Avatar'; import { MemberListToggleButton } from '../layout/MemberListToggleButton'; import { LoadingSpinner } from '../ui/LoadingSpinner'; @@ -34,6 +35,17 @@ export function FriendsPage({ mobile }: FriendsPageProps) { const navigate = useNavigate(); const addDmChannel = useSpaceStore((s) => s.addDmChannel); + // If the user clicked "Retry your friend request" in the Connections panel + // and we just navigated here, the federation store carries the original + // target. Switching to the Add tab makes the AddFriendTab mount, which then + // consumes the prefill into its query input. We only check on mount — the + // store value is one-shot (cleared by AddFriendTab on consume). + useEffect(() => { + if (useFederationStore.getState().pendingFriendAddPrefill) { + setActiveTab('add'); + } + }, []); + const { friends, requests, @@ -394,7 +406,13 @@ function AddFriendTab({ const fetchDiscoverUsers = useDiscoverStore((s) => s.fetchUsers); const updateRelationship = useDiscoverStore((s) => s.updateRelationship); - const [query, setQuery] = useState(''); + const [query, setQuery] = useState(() => { + // Consume the federation store's pending friend-add prefill at mount so + // a Retry click in the Connections panel lands here with the original + // target already in the input. The consume call clears the store value + // so subsequent mounts (e.g. tab switching) start empty. + return useFederationStore.getState().consumePendingFriendAddPrefill() ?? ''; + }); const [rawSearchResults, setRawSearchResults] = useState([]); const [searchLoading, setSearchLoading] = useState(false); const [directAddLoading, setDirectAddLoading] = useState(false); diff --git a/packages/web/src/components/modals/ConnectedInstances.tsx b/packages/web/src/components/modals/ConnectedInstances.tsx index 103dd92d..4d60c178 100644 --- a/packages/web/src/components/modals/ConnectedInstances.tsx +++ b/packages/web/src/components/modals/ConnectedInstances.tsx @@ -1,9 +1,17 @@ -import React, { useState } from 'react'; +import React, { useEffect, useState } from 'react'; import ReactDOM from 'react-dom'; -import type { InstanceInfoResponse, FederationRegistryEntry } from '@backspace/shared'; +import { useNavigate } from 'react-router-dom'; +import type { + InstanceInfoResponse, + FederationRegistryEntry, + PeeringSubscription, + PeeringNotification, + PeeringTriggerReason, +} from '@backspace/shared'; import { useInstanceStore, DifferentPasswordError, isSelfOrigin } from '../../stores/instanceStore'; import { useAuthStore } from '../../stores/authStore'; import { useUIStore } from '../../stores/uiStore'; +import { useFederationStore } from '../../stores/federationStore'; import { isElectron } from '../../platform/platform'; import { ConfirmDialog } from '../ui/ConfirmDialog'; @@ -967,6 +975,311 @@ function sortEntries(entries: FederationRegistryEntry[], sortBy: SortBy): Federa }); } +// ─── Outbound peering gate helpers ────────────────────────────────────────── + +function actionLabel(reason: PeeringTriggerReason): string { + switch (reason) { + case 'friend_add': return 'friend request'; + case 'space_join': return 'space join'; + case 'direct_message': return 'direct message'; + } +} + +function actionVerbPhrase(reason: PeeringTriggerReason, target: string): string { + switch (reason) { + case 'friend_add': return `Friend request to ${target}`; + case 'space_join': return `Join ${target}`; + case 'direct_message': return `Direct message to ${target}`; + } +} + +// ─── Pending peering subscriptions section ────────────────────────────────── + +function PendingSubscriptionRow({ subscription }: { subscription: PeeringSubscription }) { + const cancelPeeringSubscription = useFederationStore((s) => s.cancelPeeringSubscription); + const addToast = useUIStore((s) => s.addToast); + const [busy, setBusy] = useState(false); + + const host = safeHost(subscription.peerOrigin); + const peerLabel = subscription.peerInstanceName || host; + + const handleCancel = async () => { + setBusy(true); + try { + await cancelPeeringSubscription(subscription.id); + addToast('Peering request cancelled', 'success', 3000); + } catch (err) { + addToast( + `Failed to cancel: ${err instanceof Error ? err.message : 'Unknown error'}`, + 'warning', + 5000, + ); + setBusy(false); + } + }; + + return ( +
+
+
+ {actionVerbPhrase(subscription.triggerReason, subscription.triggerTarget)} +
+
+ on {peerLabel} + {subscription.peerInstanceName && ( + ({host}) + )} +
+
+ +
+ ); +} + +function PendingPeeringSubscriptionsSection() { + const subscriptions = useFederationStore((s) => s.peeringSubscriptions); + + if (subscriptions.length === 0) return null; + + return ( +
+
+ Pending Peering Approvals +
+

+ Your admin must approve before these requests can proceed. +

+
+ {subscriptions.map((s) => ( + + ))} +
+
+ ); +} + +// ─── Recent peering outcomes section ──────────────────────────────────────── + +function notificationAccentClasses(kind: PeeringNotification['kind']): { + surface: string; + iconBg: string; + iconColor: string; +} { + switch (kind) { + case 'approved': + return { + surface: 'bg-status-online/[0.06] border border-status-online/15', + iconBg: 'bg-status-online/15', + iconColor: 'text-status-online', + }; + case 'denied': + return { + surface: 'bg-accent-rose/[0.06] border border-accent-rose/15', + iconBg: 'bg-accent-rose/15', + iconColor: 'text-txt-danger', + }; + case 'expired': + return { + surface: 'bg-accent-amber/[0.06] border border-accent-amber/15', + iconBg: 'bg-accent-amber/15', + iconColor: 'text-accent-amber', + }; + } +} + +function NotificationIcon({ kind, className }: { kind: PeeringNotification['kind']; className: string }) { + // approved: check, denied: cross, expired: clock + if (kind === 'approved') { + return ( + + + + ); + } + if (kind === 'denied') { + return ( + + + + ); + } + return ( + + + + ); +} + +function PeeringNotificationCard({ + notification, + onRetry, +}: { + notification: PeeringNotification; + onRetry: (notification: PeeringNotification) => void; +}) { + const markPeeringNotificationRead = useFederationStore((s) => s.markPeeringNotificationRead); + const addToast = useUIStore((s) => s.addToast); + const [busy, setBusy] = useState(false); + + const host = safeHost(notification.peerOrigin); + const accent = notificationAccentClasses(notification.kind); + + const handleDismiss = async () => { + setBusy(true); + try { + await markPeeringNotificationRead(notification.id); + } catch (err) { + addToast( + `Failed to dismiss: ${err instanceof Error ? err.message : 'Unknown error'}`, + 'warning', + 5000, + ); + setBusy(false); + } + }; + + // Retry is only meaningful on approved notifications, and the gate currently + // only wires friend_add. space_join and direct_message reach the gate via + // backend paths that aren't user-initiated end-to-end yet, so we hide Retry + // for those rather than promise an action we cannot deliver. + const showRetry = + notification.kind === 'approved' && notification.triggerReason === 'friend_add'; + + let primaryText: string; + if (notification.kind === 'approved') { + primaryText = `Your peering request to ${host} was approved by your admin.`; + } else if (notification.kind === 'denied') { + primaryText = `Your peering request to ${host} was denied by your admin.`; + } else { + primaryText = `Your peering request to ${host} expired without admin action.`; + } + + const contextText = + notification.kind === 'approved' && notification.triggerReason !== 'friend_add' + ? `Original action: ${actionVerbPhrase(notification.triggerReason, notification.triggerTarget)}.` + : `Original action: ${actionVerbPhrase(notification.triggerReason, notification.triggerTarget)}`; + + return ( +
+
+
+ +
+
+
{primaryText}
+
{contextText}
+
+ {showRetry && ( + + )} + +
+
+
+
+ ); +} + +function RecentPeeringOutcomesSection() { + const notifications = useFederationStore((s) => s.peeringNotifications); + const markAllPeeringNotificationsRead = useFederationStore((s) => s.markAllPeeringNotificationsRead); + const setPendingFriendAddPrefill = useFederationStore((s) => s.setPendingFriendAddPrefill); + const closeModal = useUIStore((s) => s.closeModal); + const setShowDms = useUIStore((s) => s.setShowDms); + const setMobileTab = useUIStore((s) => s.setMobileTab); + const isMobile = useUIStore((s) => s.isMobile); + const addToast = useUIStore((s) => s.addToast); + const navigate = useNavigate(); + const [bulkBusy, setBulkBusy] = useState(false); + + if (notifications.length === 0) return null; + + const handleRetry = (notification: PeeringNotification) => { + if (notification.triggerReason !== 'friend_add') { + // Defensive — Retry button is only rendered for friend_add. Bail + // silently if a future change widens this without updating the handler. + return; + } + // Set the prefill side-channel before navigating so AddFriendTab finds + // it on its initial render. + setPendingFriendAddPrefill(notification.triggerTarget); + // Mark this notification read in the background — the user has acted on + // it. Use the per-id endpoint so other unread notifications stay visible. + void useFederationStore.getState().markPeeringNotificationRead(notification.id); + // Close the settings modal that hosts this panel. + closeModal(); + if (isMobile) { + // Mobile: jump to the DMs/Friends tab so MobileShell renders FriendsPage. + setMobileTab('dms'); + } else { + // Desktop: route to /channels/@me — AppLayout's effect calls + // setShowDms(true) for the @me path, and MainContent renders FriendsPage + // when no DM channel is selected. + setShowDms(true); + navigate('/channels/@me'); + } + }; + + const handleDismissAll = async () => { + setBulkBusy(true); + try { + await markAllPeeringNotificationsRead(); + } catch (err) { + addToast( + `Failed to dismiss all: ${err instanceof Error ? err.message : 'Unknown error'}`, + 'warning', + 5000, + ); + setBulkBusy(false); + } + }; + + return ( +
+
+
+ Recent Peering Outcomes +
+ {notifications.length > 1 && ( + + )} +
+
+ {notifications.map((n) => ( + + ))} +
+
+ ); +} + // ─── Main component ────────────────────────────────────────────────────────── export function ConnectedInstances() { @@ -974,6 +1287,17 @@ export function ConnectedInstances() { const registry = useInstanceStore((s) => s.registry); const user = useAuthStore((s) => s.user); + const refetchPeeringSubscriptions = useFederationStore((s) => s.refetchPeeringSubscriptions); + const refetchPeeringNotifications = useFederationStore((s) => s.refetchPeeringNotifications); + + // Hydrate the outbound-peering-gate user surfaces on mount. WS events + // (peering_subscription_changed / peering_notification_received) will keep + // them fresh while the panel stays mounted. + useEffect(() => { + void refetchPeeringSubscriptions(); + void refetchPeeringNotifications(); + }, [refetchPeeringSubscriptions, refetchPeeringNotifications]); + const [showAddForm, setShowAddForm] = useState(false); const [filter, setFilter] = useState('all'); const [sortBy, setSortBy] = useState('dateAdded'); @@ -1025,7 +1349,15 @@ export function ConnectedInstances() { : null; return ( -
+
+ {/* Terminal-state outcomes first — newly resolved requests warrant the + user's attention (especially approvals they can now retry). */} + + + {/* Active waiting state. */} + + +
Connected Instances
@@ -1109,6 +1441,7 @@ export function ConnectedInstances() { )}
+
); } diff --git a/packages/web/src/hooks/useWebSocket.ts b/packages/web/src/hooks/useWebSocket.ts index 760f2279..f86a1131 100644 --- a/packages/web/src/hooks/useWebSocket.ts +++ b/packages/web/src/hooks/useWebSocket.ts @@ -14,6 +14,7 @@ import { getActiveRoom } from './useLiveKit'; import { useUIStore } from '../stores/uiStore'; import { useActivityStore } from '../stores/activityStore'; import { useDiscoverStore } from '../stores/discoverStore'; +import { useFederationStore } from '../stores/federationStore'; // ─── Rejected peer origins (for unreachable member indicators) ─────────────── const rejectedPeerOrigins = new Set(); @@ -781,6 +782,33 @@ function handleEvent(origin: string, event: ServerEvent): void { break; } + case 'peering_subscription_changed': { + // The user's pending peering-subscription set changed (admin approved/ + // denied/expired the parent request, or the user cancelled a row from + // another tab). Refetch — the server is the source of truth. + void useFederationStore.getState().refetchPeeringSubscriptions(); + break; + } + + case 'peering_notification_received': { + // Terminal-state outcome arrived for one of the user's outbound peering + // requests. Refetch the notifications list and surface a transient + // toast — the inline list in the Connections panel is the persistent + // surface; the toast is opportunistic for online users. + void useFederationStore.getState().refetchPeeringNotifications(); + const message = + event.kind === 'approved' + ? 'Your peering request was approved' + : event.kind === 'denied' + ? 'Your peering request was denied' + : 'Your peering request expired'; + // uiStore exposes 'info' | 'warning' | 'success' — use 'success' for + // approved, 'warning' for denied/expired (no error severity exists). + const severity: 'success' | 'warning' = event.kind === 'approved' ? 'success' : 'warning'; + useUIStore.getState().addToast(message, severity, 4500); + break; + } + case 'dm_message_deleted': if (!isHome && !activePeerOrigins.has(origin)) break; removeMessage(event.messageId, event.dmChannelId); diff --git a/packages/web/src/stores/federationStore.ts b/packages/web/src/stores/federationStore.ts new file mode 100644 index 00000000..31efb581 --- /dev/null +++ b/packages/web/src/stores/federationStore.ts @@ -0,0 +1,96 @@ +import { create } from 'zustand'; +import type { PeeringSubscription, PeeringNotification } from '@backspace/shared'; +import { api } from '../api/client'; + +/** + * Outbound peering gate user-facing state. + * + * Holds the current user's pending peering subscriptions (rows they own in + * `peer_approval_subscribers` joined to their parent peering request) and + * their unread terminal-state notifications. Both lists are scoped to the + * home instance API client — federation gating is a home-instance concern; + * remote instances do not surface their own outbound queues to this user. + * + * The retry deep-link side-channel (`pendingFriendAddPrefill`) carries the + * trigger target from a Retry click in the Connections panel into the + * `AddFriendTab` on the Friends page. The consuming component reads and + * clears the value on mount. + */ +interface FederationState { + peeringSubscriptions: PeeringSubscription[]; + peeringNotifications: PeeringNotification[]; + + /** + * Side-channel for the friend-add Retry deep-link. The Connections panel + * sets this to the original `triggerTarget` (e.g. `alice@orbit.tld`), + * navigates to the Friends page, and the AddFriendTab consumes + clears + * it on mount to prefill its query input. + */ + pendingFriendAddPrefill: string | null; + + refetchPeeringSubscriptions: () => Promise; + refetchPeeringNotifications: () => Promise; + cancelPeeringSubscription: (id: string) => Promise; + markPeeringNotificationRead: (id: string) => Promise; + markAllPeeringNotificationsRead: () => Promise; + + setPendingFriendAddPrefill: (value: string | null) => void; + consumePendingFriendAddPrefill: () => string | null; +} + +export const useFederationStore = create((set, get) => ({ + peeringSubscriptions: [], + peeringNotifications: [], + pendingFriendAddPrefill: null, + + refetchPeeringSubscriptions: async () => { + try { + const { subscriptions } = await api.federation.peeringSubscriptions(); + set({ peeringSubscriptions: subscriptions }); + } catch (err) { + console.error('Failed to load peering subscriptions:', err); + } + }, + + refetchPeeringNotifications: async () => { + try { + // unreadOnly=true — UI only ever shows unread terminal notifications. + const { notifications } = await api.federation.peeringNotifications(true); + set({ peeringNotifications: notifications }); + } catch (err) { + console.error('Failed to load peering notifications:', err); + } + }, + + cancelPeeringSubscription: async (id) => { + await api.federation.cancelPeeringSubscription(id); + // Optimistic local update — the WS `peering_subscription_changed` event + // will arrive shortly and reconcile, but we drop the row immediately so + // the UI feels responsive. + set((state) => ({ + peeringSubscriptions: state.peeringSubscriptions.filter((s) => s.id !== id), + })); + }, + + markPeeringNotificationRead: async (id) => { + await api.federation.markPeeringNotificationRead(id); + set((state) => ({ + peeringNotifications: state.peeringNotifications.filter((n) => n.id !== id), + })); + }, + + markAllPeeringNotificationsRead: async () => { + await api.federation.markAllPeeringNotificationsRead(); + set({ peeringNotifications: [] }); + }, + + setPendingFriendAddPrefill: (value) => set({ pendingFriendAddPrefill: value }), + + consumePendingFriendAddPrefill: () => { + const value = get().pendingFriendAddPrefill; + if (value !== null) { + set({ pendingFriendAddPrefill: null }); + } + return value; + }, +})); From 4fd67fa7dd9c05d5f412e0b752d6b03971fe9193 Mon Sep 17 00:00:00 2001 From: Jannis Braun <151788261+TheZwiss@users.noreply.github.com> Date: Sun, 26 Apr 2026 22:43:52 +0200 Subject: [PATCH 18/20] fix(web): map peer_pending_local_admin to user-facing message Without this, when the local admin gate fires on friend-add, the user got a generic 'Could not send friend request' fallback. Now they see 'Your admin needs to approve federation with this instance' with a pointer to Connections settings where Task 12's pending-approvals section shows their queued request. --- packages/web/src/utils/friendErrors.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/web/src/utils/friendErrors.ts b/packages/web/src/utils/friendErrors.ts index ec1531a5..12fddf4f 100644 --- a/packages/web/src/utils/friendErrors.ts +++ b/packages/web/src/utils/friendErrors.ts @@ -20,6 +20,8 @@ export function mapServerErrorToMessage( case 'already_friends': return "You're already friends with this user."; case 'peer_pending_approval': return "The remote instance's admin needs to approve federation. Try again later."; + case 'peer_pending_local_admin': + return "Your admin needs to approve federation with this instance. You'll see your request in Connections settings."; case 'peer_pending': return 'Connecting to the remote instance — try again in a moment.'; case 'incoming_request_exists': From e35b44c05f84d634d7d493ad505557ab45a97867 Mon Sep 17 00:00:00 2001 From: Jannis Braun <151788261+TheZwiss@users.noreply.github.com> Date: Sun, 26 Apr 2026 22:59:27 +0200 Subject: [PATCH 19/20] docs(systems): outbound peering gate documentation across federation, db, api, ws, admin, client-federation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - federation.md: Outbound Peering Gate subsection (gate, intent contract, gate-but-don't-queue split, lifecycle invariant on onPeerActivated) - database.md: peer_approval_requests direction + nullable hmac_secret + UNIQUE relaxation + CHECK; new peer_approval_subscribers + peer_approval_ notifications tables - api.md: /approval-requests direction-branched approve/deny + extended GET response; new peering-subscriptions and peering-notifications endpoints - websocket.md: peering_subscription_changed, peering_notification_received events; federation_approval_request_received fires for outbound too - admin.md: outbound row rendering with subscriber list - client-federation.md: 'admin_required' status, peer_pending_local_admin error code, new Connections surfaces, federationStore slice Spec §11 closes the Approve-button investigation finding. --- docs/systems/admin.md | 33 +++++++---- docs/systems/api.md | 99 +++++++++++++++++++++++++++++++ docs/systems/client-federation.md | 53 ++++++++++++++++- docs/systems/database.md | 55 +++++++++++++++-- docs/systems/federation.md | 70 +++++++++++++++++++++- docs/systems/websocket.md | 3 + 6 files changed, 293 insertions(+), 20 deletions(-) diff --git a/docs/systems/admin.md b/docs/systems/admin.md index b820c6ba..8e4f6160 100644 --- a/docs/systems/admin.md +++ b/docs/systems/admin.md @@ -361,26 +361,31 @@ The temporary password is shown exactly once in the admin UI -- the UsersPanel d ### Peering Approval Requests -All admin-only. Only present when `autoAcceptPeering` is `false` and incoming peering requests are queued. +All admin-only. Present when `autoAcceptPeering` is `false`. The queue holds **both directions**: +- **Inbound** rows — remote instances asking to peer with us. +- **Outbound** rows — local users who initiated peering (friend-add, etc.) that the local [Outbound Peering Gate](federation.md#outbound-peering-gate) intercepted because no `federation_peers` row exists yet for the target. ``` -GET /api/federation/approval-requests → PeerApprovalRequest[] -POST /api/federation/approval-requests/:id/approve → { success: boolean } -POST /api/federation/approval-requests/:id/deny → { success: boolean } +GET /api/federation/approval-requests → { requests: ApprovalRequestSummary[] } +POST /api/federation/approval-requests/:id/approve → { success, peerStatus?, peer? } +POST /api/federation/approval-requests/:id/deny → { success } ``` -`PeerApprovalRequest` shape: +`ApprovalRequestSummary` (see [api.md → Federation Peering Approval Queue](api.md#federation-peering-approval-queue) for the complete TypeScript shape): ```typescript { - id: string; // Snowflake - origin: string; // Requesting instance URL + id: string; + direction: 'inbound' | 'outbound'; + origin: string; instanceName: string | null; - requestedAt: number; // Epoch ms - expiresAt: number; // Epoch ms (requestedAt + 30 days) + requestedAt: number; + expiresAt: number; + // Outbound rows ONLY — inbound rows omit this field entirely (absent, not null and not []). + subscribers?: Array<{ id, userId, username, triggerReason, triggerTarget, createdAt }>; } ``` -See [federation.md](federation.md) — Peer Approval Queue section for the full approval/denial/expiry flow. +See [federation.md](federation.md) — Peer Approval Queue and Outbound Peering Gate sections for the full approval/denial/expiry flow (including the direction-branched approve/deny semantics, `onPeerActivated` cleanup invariant, and the inbound-expiry `/peer/denied` notification preserved unchanged). --- @@ -431,9 +436,13 @@ Manages: instance name, registration toggle, discovery toggle, GIF API key, fede #### FederationPanel -Manages: federation peers list, pending approval requests, manual peering initiation, secret rotation, peer reset. +Manages: federation peers list, pending approval requests (inbound + outbound), manual peering initiation, secret rotation, peer reset. -- **Pending Approvals section:** Visible only when `pendingApprovalCount > 0` (from ready payload). Positioned above the peer list. Each row shows the requesting instance name and origin with Approve and Deny buttons. Approve calls `api.federation.approveApprovalRequest(id)` and Deny calls `api.federation.denyApprovalRequest(id)`; both remove the row from the local list on success. +- **Pending Approvals section:** Visible only when `pendingApprovalCount > 0` (from ready payload — the count sums inbound + outbound rows). Positioned above the peer list. Both directions render as rows in the same unified queue, branched on `direction`: + - **Inbound rows** — "{instanceName} ({origin}) — wants to peer with us." Approve / Deny buttons. + - **Outbound rows** — "{instanceName} ({origin}) — N user(s) want us to peer with them. Triggered by: friend-add (etc.)." Inline expansion reveals the subscriber list (`username — friend_add → alice@orbit`, ...). Approve / Deny buttons same as inbound; backend branches on direction. + - Approve calls `api.federation.approveApprovalRequest(id)` and Deny calls `api.federation.denyApprovalRequest(id)`. Both remove the row from the local list on success and refresh on `federation_approval_request_received` (which now also fires for outbound queue creation) and `federation_peers_changed`. + - **ConfirmDialog copy variants:** the dialog branches on direction. Outbound approve confirms "send `/peer/accept` to {origin} on behalf of N user(s)"; outbound deny confirms "fan out denied notifications to N user(s) and discard the queued request" (no remote network call). - Federation peers: fetched via `api.federation.peers()`, displayed as a list with status badges (active/pending/unreachable/awaiting_approval/rejected/needs_attention), last-seen/synced times, and per-peer actions. - Peers with status `'revoked'` are filtered out of the visible list. - Revoke calls `api.federation.revokePeer(peerId)` and removes from local list. diff --git a/docs/systems/api.md b/docs/systems/api.md index e3099526..f84412e9 100644 --- a/docs/systems/api.md +++ b/docs/systems/api.md @@ -142,6 +142,7 @@ GET /social/search ?q= → { users[] } | 404 | `user_not_found` | Remote lookup returned 404 (no such user, or tombstoned) | | 409 | `already_friends` | Friendship row already exists | | 409 | `peer_pending_approval` | Remote admin needs to approve the peering relationship | +| 409 | `peer_pending_local_admin` | Local instance has `autoAcceptPeering=0` and the user attempted to friend-add a never-peered remote target. The user's own admin must approve before any traffic reaches the wire. Distinct from `peer_pending_approval` (remote admin must approve). See [federation.md → Outbound Peering Gate](federation.md#outbound-peering-gate). | | 409 | `peer_pending` | Peer handshake in flight | | 409 | `incoming_request_exists` | Opposite-direction pending request exists; response includes `requestId` for deep-link | | 429 | `lookup_rate_limited` | Remote `/users/lookup` returned 429; `Retry-After` header forwarded | @@ -229,6 +230,104 @@ POST /federation/users/lookup (HMAC-signed S2S, rate-limited 60/min/peer) **`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. +### Federation Peering Approval Queue + +Inbound + outbound peering approval queue (`autoAcceptPeering=0`). See [federation.md → Peer Approval Queue](federation.md#peer-approval-queue) and [federation.md → Outbound Peering Gate](federation.md#outbound-peering-gate). + +``` +GET /federation/approval-requests (admin) → { requests: ApprovalRequestSummary[] } +POST /federation/approval-requests/:id/approve (admin) → { success, peerStatus?, peer? } +POST /federation/approval-requests/:id/deny (admin) → { success } +``` + +**`ApprovalRequestSummary` shape:** + +```typescript +type ApprovalRequestSummary = { + id: string; + direction: 'inbound' | 'outbound'; + origin: string; + instanceName: string | null; + requestedAt: number; + expiresAt: number; + // Outbound rows ONLY — inbound rows OMIT this field entirely (it is absent, not null and not []). + subscribers?: ApprovalRequestSubscriberSummary[]; +}; + +type ApprovalRequestSubscriberSummary = { + id: string; + userId: string; + username: string; + triggerReason: 'friend_add' | 'space_join' | 'direct_message'; + triggerTarget: string; + createdAt: number; +}; +``` + +**`POST /approval-requests/:id/approve`** — direction-branched. +- **Inbound** — existing behavior preserved verbatim (creates / upserts a local `federation_peers` row with status `pending`, sends `/peer/accept` to origin forwarding the stored `approvalToken`, deletes the queue row regardless of whether the result is `active` (200) or `awaiting_approval` (202)). +- **Outbound** — generates a fresh HMAC, sends `/peer/accept` to the origin (no token; we are the initiator). + - On 200 → peer becomes `active`. `onPeerActivated` runs: fans out `kind='approved'` notifications to subscribers and cascade-deletes the queue row. The handler does NOT duplicate this cleanup. + - On 202 → peer transitions to `awaiting_approval`, captures the returned `approvalToken`, and the queue row + subscribers are LEFT INTACT for the eventual remote-admin approval. `onPeerActivated` is NOT called yet. + - On 4xx/5xx/network → the peer row is cleaned up; the queue row is LEFT INTACT so the admin can retry. Response status mirrors the wire failure (`502`/`503`/`504`). +- **Response body:** `{ success, peerStatus?: 'active' | 'awaiting_approval', peer? }` for outbound; `{ success }` for inbound. + +**`POST /approval-requests/:id/deny`** — direction-branched. +- **Inbound** — existing behavior preserved (sends signed `/peer/denied` to origin, upserts a local `rejected` `federation_peers` row, deletes the queue row). +- **Outbound** — fans out `kind='denied'` notifications to all `peer_approval_subscribers` of the queue row, then cascade-deletes the parent (no remote network call). Broadcasts `federation_peers_changed` to admins so the queue UI refreshes. + +### Federation Peering Subscriptions (user-facing) + +``` +GET /federation/peering-subscriptions (auth) → { subscriptions: PeeringSubscriptionSummary[] } +DELETE /federation/peering-subscriptions/:id (auth) → { success } +``` + +User-facing surface for the rows in `peer_approval_subscribers` belonging to the calling user. GET joins the parent `peer_approval_requests` row to include peer origin/instance metadata. + +```typescript +type PeeringSubscriptionSummary = { + id: string; + requestId: string; + peerOrigin: string; + peerInstanceName: string | null; + triggerReason: 'friend_add' | 'space_join' | 'direct_message'; + triggerTarget: string; + createdAt: number; +}; +``` + +**`DELETE /peering-subscriptions/:id`:** +- 404 if the subscriber row doesn't exist. +- 403 if the row belongs to a different user. +- On success: deletes the subscriber row; if it was the last subscriber for the parent, cascade-deletes the parent (admin's queue row disappears too). No `peer_approval_notifications` row is created (the user took the action; they know). +- Broadcasts `peering_subscription_changed` to the calling user (multi-tab refresh) and `federation_peers_changed` to admins if the parent was deleted. + +### Federation Peering Notifications (user-facing) + +``` +GET /federation/peering-notifications (auth) ?unread=1? → { notifications: PeeringNotificationSummary[] } +POST /federation/peering-notifications/:id/read (auth) → { success } +POST /federation/peering-notifications/read-all (auth) → { success, count } +``` + +User-facing terminal-state notifications for peering events. GET orders DESC by `createdAt`; `?unread=1` filters to `readAt IS NULL`. + +```typescript +type PeeringNotificationSummary = { + id: string; + kind: 'approved' | 'denied' | 'expired'; + peerOrigin: string; + triggerReason: 'friend_add' | 'space_join' | 'direct_message'; + triggerTarget: string; + createdAt: number; + readAt: number | null; +}; +``` + +**`POST /:id/read`** — sets `readAt = Date.now()` for the calling user's notification (404 / 403 on miss / mismatch). +**`POST /read-all`** — marks all of the calling user's unread notifications as read; returns `{ success, count }` where `count` is the number of rows updated. + ## Utilities (`routes/utils.ts`) — auth required ``` GET /utils/metadata ?url= → { title?, description?, image?, siteName? } diff --git a/docs/systems/client-federation.md b/docs/systems/client-federation.md index 76fcc69a..7a40f1ca 100644 --- a/docs/systems/client-federation.md +++ b/docs/systems/client-federation.md @@ -340,7 +340,58 @@ Client-driven LWW whole-registry push (same pattern as `profileSync.ts`): --- -## 8. Relationship to S2S Federation +## 8. Outbound Peering Gate (client surfaces) + +When the local instance has `autoAcceptPeering=0`, every outbound new-peer attempt funnels through the centralized [Outbound Peering Gate](federation.md#outbound-peering-gate) on the server. The client surfaces three things: a new error code on the friend-add path, a new peering-status value on `/peer/ensure`, and two new Connections-settings panels (pending and outcomes). + +### Peering-status taxonomy (`/peer/ensure` response) + +`peeringStatus` returned from `POST /api/federation/peer/ensure` now includes `'admin_required'` alongside the existing `'active' | 'pending' | 'awaiting_approval' | 'rejected' | 'unreachable' | 'revoked'`. `'admin_required'` means: gate fired locally, your own admin must approve before any traffic reaches the wire. The user's request becomes admin-approvable rather than auto-firing. + +### Friend-add error mapping (`peer_pending_local_admin`) + +`POST /api/social/requests` returns 409 `peer_pending_local_admin` when the gate fires for a never-peered remote target (the user's request is queued + subscriber-tracked on the server; admin must approve). + +`packages/web/src/utils/friendErrors.ts` maps `peer_pending_local_admin` to: + +> "Your admin needs to approve federation with this instance. You'll see your request in Connections settings." + +The catch handler reads `err.message` (per the API client error contract documented in §1) and passes it to `mapServerErrorToMessage`. Distinct from `peer_pending_approval` ("the *remote* admin must approve") — this one is local-admin gating. + +### Connections settings — Pending peering approvals + +A new section in the Connections settings UI (alongside the federation registry) lists the calling user's rows from `peer_approval_subscribers`, joined to parent `peer_approval_requests`. Each row renders: + +- "Awaiting your admin's approval to federate with `{peerOrigin}` so you can `friend_add → alice@orbit`." +- A Cancel button. Cancel calls `DELETE /api/federation/peering-subscriptions/:id`. If the cancelled row was the last subscriber for the parent, the parent cascades and disappears from the admin's queue too. + +Live updates: a `peering_subscription_changed` WebSocket event refetches the list. + +### Connections settings — Recent peering outcomes + +A second new section above the pending list shows unread `peer_approval_notifications` rows ordered by `createdAt DESC`. Each row's copy branches on `kind`: + +- **`approved`** — "Your peering request to `{peerOrigin}` was approved — retry your friend-add to `{triggerTarget}`?" `[Retry]` `[Dismiss]`. Retry deep-links to the friend-add UI prefilled with the original target. Today only the `friend_add` reason produces a retry deep-link; future trigger reasons add their own deep-link flows. Dismiss POSTs to `/peering-notifications/:id/read`. +- **`denied`** — "Your peering request to `{peerOrigin}` was denied by your admin." `[Dismiss]`. +- **`expired`** — "Your peering request to `{peerOrigin}` expired without admin action." `[Dismiss]`. + +A "Mark all as read" action POSTs to `/peering-notifications/read-all`. Read rows hide from view (soft-delete preserves audit; the storage janitor cleans up read rows older than 30 days). + +Live updates: a `peering_notification_received` WebSocket event refetches the list and may surface a transient toast for the matching `kind` (online users only). + +### Federation store slice + +A new `federationStore.ts` slice (separate from `instanceStore`) holds: + +- `peeringSubscriptions: PeeringSubscriptionSummary[]` +- `peeringNotifications: PeeringNotificationSummary[]` +- `pendingFriendAddPrefill?: { username: string }` — side-channel populated by the Retry button on `kind='approved'` notifications, consumed by the friend-add modal on next open. + +This slice is intentionally separate from `instanceStore` because the data is per-user (not per-instance) and lives on the home server only. WebSocket handlers route `peering_subscription_changed` and `peering_notification_received` events into this slice's refetch actions. + +--- + +## 9. Relationship to S2S Federation Client-side and S2S federation serve different purposes: diff --git a/docs/systems/database.md b/docs/systems/database.md index e14bd9a5..c64b5ea6 100644 --- a/docs/systems/database.md +++ b/docs/systems/database.md @@ -366,17 +366,62 @@ PK: (spaceId, userId, restrictionType) | 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. +Queue of peering requests pending admin review when `autoAcceptPeering` is `false`. Holds **both directions**: inbound rows (remote asked to peer with us) and outbound rows (a local user-initiated `ensurePeered` call gated on this side; see [federation.md → Outbound Peering Gate](federation.md#outbound-peering-gate)). UNIQUE on `(origin, direction)` so the same origin may have at most one row per direction simultaneously. Rows expire after 30 days via janitor cleanup. | Column | Type | Default | Notes | |--------|------|---------|-------| | id | text PK | | Snowflake | -| origin | text NOT NULL UNIQUE | | Requesting instance's origin URL | -| instanceName | text | | Instance name sent by requester | -| hmacSecret | text NOT NULL | | Requester's HMAC secret; used to sign denial notification | +| origin | text NOT NULL | | Requesting / target instance's origin URL. UNIQUE per `direction` (composite UNIQUE `(origin, direction)`). | +| direction | text NOT NULL | `'inbound'` | `'inbound'` (remote → us) or `'outbound'` (us → remote, gate-created on user_action). Migration backfills existing rows to `'inbound'`. | +| instanceName | text | | Instance name (sent by requester for inbound; null for outbound until populated by future enrichment). | +| hmacSecret | text | | Requester's HMAC secret for inbound (used to sign the `/peer/denied` notification). **Nullable** — outbound rows have `hmac_secret = NULL` and the `/approve` handler generates fresh HMAC at the moment it sends `/peer/accept`. CHECK enforced (see below). | | 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). | +| approvalToken | text | | Single-use 64-hex-char token issued by the receiver in the 202 response when an inbound 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. Inbound-only meaning preserved (outbound rows always have `approval_token = NULL`). See [federation.md → Approval Token Verification](federation.md#approval-token-verification). | + +**CHECK constraint** (direction-specific shape): + +```sql +CHECK ( + (direction = 'inbound' AND hmac_secret IS NOT NULL) + OR (direction = 'outbound') +) +``` + +> **drizzle-kit limitation:** drizzle-kit does NOT represent SQLite CHECK constraints in its snapshot/diff. The constraint is created by the original baseline migration (or, for the post-baseline ALTER, hand-written SQL) and is preserved in `schema.ts` as a comment so a future table recreate (drizzle generates a recreate when UNIQUE/PK changes again) re-adds the CHECK by hand. If you regenerate a migration that recreates this table, audit the generated SQL and re-add the CHECK clause manually before applying. + +### peer_approval_subscribers +Per-user "I want this peering relationship" subscriber rows attached to outbound `peer_approval_requests`. Logically outbound-only (inbound rows have no subscribers). Existence = waiting; deletion = resolved (resolution recorded in `peer_approval_notifications` at deletion time, except for the canceller path). One subscriber may have multiple rows on the same parent if they triggered the gate from different actions/targets. + +| Column | Type | Notes | +|--------|------|-------| +| id | text PK | Snowflake | +| requestId | text NOT NULL | FK → peer_approval_requests.id CASCADE — parent deletion (admin approve→active fanout, admin deny, last-subscriber cancel, expiry) automatically clears subscriber rows. | +| userId | text NOT NULL | FK → users.id CASCADE | +| triggerReason | text NOT NULL | `'friend_add'` \| `'space_join'` \| `'direct_message'` (`PeeringTriggerReason` enum in `packages/shared/src/types.ts`). | +| triggerTarget | text NOT NULL | Action target — for `friend_add` this is `username@instance`; for `space_join` an invite code or space ID; for `direct_message` a recipient handle. Never stores message bodies, attachments, or user content. | +| createdAt | integer NOT NULL | Epoch ms | + +**UNIQUE:** `(request_id, user_id, trigger_reason, trigger_target)` — same user retriggering the gate with the same reason+target updates rather than duplicates. +**Index:** `idx_peer_approval_subscribers_user_id` on `(user_id)` — supports the user-facing pending list query. + +### peer_approval_notifications +Terminal-state notifications for peering events (approved / denied / expired). Scoped to peering — NOT a generalized in-app notification system. When a generalized system is built later, this table either migrates into it or stays as a peering-specific artifact (decision deferred to that spec). + +| Column | Type | Notes | +|--------|------|-------| +| id | text PK | Snowflake | +| userId | text NOT NULL | FK → users.id CASCADE | +| kind | text NOT NULL | `'approved'` \| `'denied'` \| `'expired'`. | +| peerOrigin | text NOT NULL | Origin URL the notification refers to. | +| triggerReason | text NOT NULL | Mirrors the originating subscriber row's `trigger_reason`. | +| triggerTarget | text NOT NULL | Mirrors the originating subscriber row's `trigger_target`. | +| createdAt | integer NOT NULL | Epoch ms | +| readAt | integer | Nullable. NULL = unread; epoch ms once dismissed/marked read. | + +**Index:** `idx_peer_approval_notifications_user_id` on `(user_id)` — supports the user-facing list and unread-filter queries. + +Inserted by `onPeerActivated` (`'approved'`), the outbound `/deny` handler (`'denied'`), and the storage janitor outbound expiry pass (`'expired'`). Read rows older than 30 days are auto-cleaned by the janitor; unread rows are never auto-cleaned. ### federation_outbox UNIQUE: (peerId, entityId) diff --git a/docs/systems/federation.md b/docs/systems/federation.md index a418213c..12192163 100644 --- a/docs/systems/federation.md +++ b/docs/systems/federation.md @@ -176,7 +176,7 @@ 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 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. +**Pre-handshake guard (`ensurePeered`)** — Before any outbound handshake, `ensurePeered(origin)` in `federationPeering.ts` refuses with `{ status: 'rejected', error: 'Local admin must resolve…' }` if an **inbound** `peer_approval_requests` row exists for that origin. The guard query is narrowed to `direction='inbound'` (commit `0d3d087`) so the gate's own outbound queue rows do not falsely block their own approval path. 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 @@ -196,7 +196,73 @@ The pre-handshake guard above closes the most reliable trigger but cannot preven **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. +**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` was tracked as the "Direction C" follow-up and is now closed by the [Outbound Peering Gate](#outbound-peering-gate) below (spec `docs/superpowers/specs/2026-04-26-outbound-peering-gate-design.md`, commits `ac2565f..c795d72`). + +### Outbound Peering Gate + +The receiver-side trust class is closed by the approval-token mechanism above. The sender-side trust class — *bug-prone or incidental code paths on the initiator that drag the local instance into peering relationships without local admin consent* — is closed by a centralized gate inside `ensurePeered`. After this change, `autoAcceptPeering=0` is symmetric: BOTH inbound and outbound new-peer establishment require local admin approval. Existing active peers keep working unchanged. + +Spec: `docs/superpowers/specs/2026-04-26-outbound-peering-gate-design.md`. + +**Gate location.** `ensurePeered` (`utils/federationPeering.ts`) is the single chokepoint every outbound new-peer attempt funnels through (friend-add, `/peer/ensure`, `sendCallRelay`'s no-active-peer branch, future callers). The gate runs ONLY when **no `federation_peers` row exists for the origin**. If any peer row exists in any status (`active`, `pending`, `awaiting_approval`, `rejected`, `revoked`, `unreachable`, `needs_attention`), the gate is a no-op — the existing branches in `ensurePeered` handle the row as today. This matches the threat model: the worry is automated paths creating *new* peerings without admin consent. + +**Required caller intent.** Every call site MUST pass an explicit `EnsurePeeredCallerIntent` (declared in `packages/shared/src/types.ts`). The argument is required at the type level so a future caller cannot silently fall through to system behavior: + +```ts +export type PeeringTriggerReason = 'friend_add' | 'space_join' | 'direct_message'; + +export type EnsurePeeredCallerIntent = + | { kind: 'user_action'; userId: string; reason: PeeringTriggerReason; target: string } + | { kind: 'system' }; + +export type EnsurePeeredResult = + | { status: 'active'; peerId: string } + | { status: 'rejected'; error: string } + | { status: 'failed'; error: string } + | { status: 'pending'; error: string } + | { status: 'admin_required'; error: string }; // ← new variant +``` + +(Type was originally drafted as `TriggerReason` and renamed to `PeeringTriggerReason` in commit `f6487a2` to disambiguate from unrelated outbox/voice trigger enums.) + +**Gate-but-don't-queue split.** When the gate fires (no peer row + `autoAcceptPeering=0`), behavior diverges on intent: + +- **`intent.kind === 'user_action'`** — upsert an outbound `peer_approval_requests` row keyed on `(origin, direction='outbound')`, upsert a `peer_approval_subscribers` row keyed on `(request_id, user_id, trigger_reason, trigger_target)`, broadcast `federation_approval_request_received` to admins, broadcast `peering_subscription_changed` to the requesting user, return `{ status: 'admin_required', error: 'Awaiting your admin\'s approval to initiate peering' }`. The user's request is admin-approvable but no traffic reaches the wire yet. +- **`intent.kind === 'system'`** — no DB writes, no admin broadcast, no admin-visible queue clutter; return `{ status: 'admin_required', error: 'Outbound peering requires admin approval on this instance' }`. A stale outbox event or dead voice-call has no admin remedy worth surfacing. + +**`'admin_required'` result semantics.** Each user-action caller maps the new variant to its own surface (e.g. friend-add returns `409 peer_pending_local_admin`; `sendCallRelay` returns `peer_admin_required` and threads through the existing exhaustive `CallRelayFailureReason` switch). See `social.md` §6 outbound flow for the friend-add error mapping. + +**Lifecycle (centralized cleanup on `onPeerActivated`).** The most important correctness invariant: outbound subscriber cleanup hangs off **status transition to `active` (`onPeerActivated`)**, NOT off the local admin's approve-action handler. This holds across every activation path: + +- queue approval (`/api/federation/approval-requests/:id/approve` 200 branch) +- admin-direct (`/peer/initiate`) +- autoAccept=1 remote (`/peer/accept` 200 from a remote that auto-accepts) +- mutual-token approval (the receiver's `/peer/accept` verifying `approvalToken` and promoting `awaiting_approval → active`) + +When `federation_peers.status` transitions to `active`, `onPeerActivated` (`utils/federationPeerActivation.ts`) calls `fanoutOutboundSubscribers(origin)`: it queries the outbound `peer_approval_requests` row for the activated origin, inserts a `kind='approved'` row in `peer_approval_notifications` for each subscriber, broadcasts `peering_notification_received` per subscriber, then deletes the parent `peer_approval_requests` row (cascade clears subscribers). No per-action code knows about subscribers; the queue-approval handler does NOT duplicate this cleanup — it just performs the handshake and lets the resulting status transition trigger fanout. + +The reason cleanup must NOT live in the approve-action handler: when the remote also has `autoAcceptPeering=0`, local admin approval transitions our peer row to `awaiting_approval`, not `active`. Subscribers must remain queued until full activation completes (which may be days later when the remote admin approves). Wiring cleanup to the approve-action handler instead would clear subscribers prematurely; users would retry against an `awaiting_approval` peer and hit `peer_pending_approval` 409, confused. + +The other lifecycle exits write notifications and cascade-delete the parent at the trigger site: + +- **Admin denies an outbound request** (`POST /api/federation/approval-requests/:id/deny`, `direction='outbound'` branch) — fans out `kind='denied'` notifications to subscribers, then deletes the parent (cascade clears subscribers). No remote network call — the remote never knew we were considering this peer. `federation_peers_changed` is broadcast to admins so the queue UI refreshes. +- **Last-subscriber cancel** (`DELETE /api/federation/peering-subscriptions/:id`) — deletes the subscriber row; if it was the last subscriber for the parent, cascade-deletes the parent. No notification created (the user took the action; they know). +- **Parent-row expiry** (`storageJanitor.ts` outbound branch) — fans out `kind='expired'` notifications to subscribers before deleting the parent. **Inbound expiry behavior is preserved unchanged** from pre-branch: the janitor still sends a signed `/peer/denied` to the inbound origin and only deletes the row on success (the Task 9 first-pass mistakenly removed this; commit `c4e7438` restored it). + +**No status column on subscribers.** Existence = waiting; deletion = resolved (with the resolution mode encoded in the notification row created at deletion time, except for the canceller path). + +**Gate composition with the trust-guard.** The pre-handshake trust-guard ("inbound `peer_approval_requests` exists → refuse outbound with `'rejected'`") is preserved unchanged and runs after the gate. The trust-guard query was narrowed to `direction='inbound'` so the gate's own outbound queue rows don't false-positive block their own approval path. The two layers compose cleanly: the gate fires earlier when there's no peer row at all; the trust-guard fires later when an inbound row exists. + +**Caller summary** (intent each call site declares): + +| Site | Intent | On `'admin_required'` | +|---|---|---| +| `routes/social.ts` (friend-add) | `user_action` reason `friend_add` target `name@domain` | 409 `peer_pending_local_admin` | +| `routes/federation.ts` (`/peer/ensure`) | `user_action` reason `friend_add` (default; client-supplied reason is ignored today) | response `peeringStatus: 'admin_required'` | +| `utils/federationOutbox.ts` (`sendCallRelay` no-active-peer) | `system` | `CallRelayFailureReason.peer_admin_required` | +| `utils/federationWorker.ts` (`resolvePendingPeers`) | n/a | operates only on already-existing pending rows; gate is unreachable from this path | + +Admin-initiated paths (`/peer/initiate`, `/approve`) do NOT call `ensurePeered`. They issue their own `fetch` and run their own activation logic. The gate does not affect admin power; admins retain full ability to pre-peer or approve outbound regardless of the setting. ### Admin Endpoints diff --git a/docs/systems/websocket.md b/docs/systems/websocket.md index 51dee891..fa32e8a3 100644 --- a/docs/systems/websocket.md +++ b/docs/systems/websocket.md @@ -192,6 +192,9 @@ reason: `'displaced'` (new tab) | `'session_closed'` | type | fields | scope | |------|--------|-------| | `federation_file_rejected` | messageId, dmChannelId, attachmentId, affectedUsers[] | DM members | +| `federation_approval_request_received` | — (refetch trigger; payload: `{ type }`) | admins. Fires for **both** inbound peering requests (remote → us) AND outbound queue creation when the [Outbound Peering Gate](federation.md#outbound-peering-gate) creates a `peer_approval_requests` row in response to a user_action. Payload shape unchanged from the inbound-only behavior; only the firing surface widened. | +| `peering_subscription_changed` | — (refetch trigger; payload: `{ type }`) | the subscribing user (all of their connected sessions). Fires when a `peer_approval_subscribers` row belonging to the user is created, modified, or deleted (gate fan-in, user cancel, parent cascade). Client refetches `GET /api/federation/peering-subscriptions`. | +| `peering_notification_received` | `{ type, kind: 'approved' \| 'denied' \| 'expired' }` | the user the notification belongs to. Fires when a `peer_approval_notifications` row is created (`onPeerActivated` outbound fanout, outbound `/deny` fanout, janitor outbound expiry). Client refetches `GET /api/federation/peering-notifications` and may surface a transient toast for online users. | **S2S relay-only event (not a direct client WS event):** From 6ba8b9256b0541bac61e8a94d8b1feeaa1dc3021 Mon Sep 17 00:00:00 2001 From: Jannis Braun <151788261+TheZwiss@users.noreply.github.com> Date: Sun, 26 Apr 2026 23:20:31 +0200 Subject: [PATCH 20/20] fix(federation): broadcast peering_subscription_changed on terminal state Live verification caught a stale-UI bug: when admin denial / approval fanout / janitor expiry cascaded subscriber rows away, only the peering_notification_received WS event fired (which only refetches the notification list). The user's pending-subscriptions section stayed stale until manual refresh. Each terminal-state path now also fires peering_subscription_changed to affected users so their pending list refreshes alongside the new notification. Fixed in: - onPeerActivated.fanoutOutboundSubscribers (approval path) - handleOutboundDeny (admin denial) - cleanupExpiredApprovalRequests (janitor expiry; also adds the notification-received broadcast that was previously deferred to next-page-load only) --- packages/server/src/routes/federation.ts | 4 ++++ packages/server/src/utils/federationPeerActivation.ts | 5 +++++ packages/server/src/utils/storageJanitor.ts | 11 +++++++++++ 3 files changed, 20 insertions(+) diff --git a/packages/server/src/routes/federation.ts b/packages/server/src/routes/federation.ts index e77fa108..4c1f6435 100644 --- a/packages/server/src/routes/federation.ts +++ b/packages/server/src/routes/federation.ts @@ -775,6 +775,10 @@ async function handleOutboundDeny( type: 'peering_notification_received' as const, kind: 'denied', }); + // Subscriber row is about to cascade-delete; refresh the user's pending list. + connectionManager.sendToUser(sub.userId, { + type: 'peering_subscription_changed' as const, + }); } // Cascade-delete clears subscribers via onDelete: 'cascade'. diff --git a/packages/server/src/utils/federationPeerActivation.ts b/packages/server/src/utils/federationPeerActivation.ts index 04dd71eb..afe32f78 100644 --- a/packages/server/src/utils/federationPeerActivation.ts +++ b/packages/server/src/utils/federationPeerActivation.ts @@ -246,6 +246,11 @@ async function fanoutOutboundSubscribers(peerId: string): Promise { type: 'peering_notification_received' as const, kind: 'approved', }); + // The subscriber row is about to cascade-delete; tell the user's UI to + // refetch its pending list so the now-stale row disappears. + connectionManager.sendToUser(sub.userId, { + type: 'peering_subscription_changed' as const, + }); } // Cascade-deletes subscriber rows via onDelete: 'cascade'. diff --git a/packages/server/src/utils/storageJanitor.ts b/packages/server/src/utils/storageJanitor.ts index 164bafe1..df636113 100644 --- a/packages/server/src/utils/storageJanitor.ts +++ b/packages/server/src/utils/storageJanitor.ts @@ -460,6 +460,7 @@ export async function cleanupExpiredApprovalRequests(): Promise { if (expiredRequests.length === 0) return 0; const { getOurOrigin, buildFederationHeaders } = await import('./federationAuth.js'); + const { connectionManager } = await import('../ws/handler.js'); const ourOrigin = getOurOrigin(); const now = Date.now(); let deletedCount = 0; @@ -486,6 +487,16 @@ export async function cleanupExpiredApprovalRequests(): Promise { readAt: null, }) .run(); + // Subscriber row is about to cascade-delete; if the user is online, + // refresh their pending list AND notification list. Offline users see + // both on next page load via the GET endpoints (persistence guarantee). + connectionManager.sendToUser(sub.userId, { + type: 'peering_notification_received' as const, + kind: 'expired', + }); + connectionManager.sendToUser(sub.userId, { + type: 'peering_subscription_changed' as const, + }); } db.delete(schema.peerApprovalRequests) .where(eq(schema.peerApprovalRequests.id, req.id))