From 22788890f368d2162dfce12388bef70ca8ace0fd Mon Sep 17 00:00:00 2001 From: Jannis Braun <151788261+TheZwiss@users.noreply.github.com> Date: Tue, 28 Apr 2026 18:52:15 +0200 Subject: [PATCH 01/41] feat(db): add invite_links + invite_redemptions tables + federatedRegistrationOpen --- .../server/drizzle/0004_slim_mentallo.sql | 28 + .../server/drizzle/meta/0004_snapshot.json | 3641 +++++++++++++++++ packages/server/drizzle/meta/_journal.json | 7 + packages/server/src/db/schema.ts | 26 + 4 files changed, 3702 insertions(+) create mode 100644 packages/server/drizzle/0004_slim_mentallo.sql create mode 100644 packages/server/drizzle/meta/0004_snapshot.json diff --git a/packages/server/drizzle/0004_slim_mentallo.sql b/packages/server/drizzle/0004_slim_mentallo.sql new file mode 100644 index 00000000..911dca59 --- /dev/null +++ b/packages/server/drizzle/0004_slim_mentallo.sql @@ -0,0 +1,28 @@ +CREATE TABLE `invite_links` ( + `id` text PRIMARY KEY NOT NULL, + `token` text NOT NULL, + `name` text NOT NULL, + `created_by` text NOT NULL, + `created_at` integer NOT NULL, + `max_uses` integer, + `used_count` integer DEFAULT 0 NOT NULL, + `expires_at` integer, + `revoked_at` integer, + FOREIGN KEY (`created_by`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE no action +); +--> statement-breakpoint +CREATE TABLE `invite_redemptions` ( + `id` text PRIMARY KEY NOT NULL, + `invite_id` text NOT NULL, + `user_id` text, + `registrant_username` text NOT NULL, + `redeemed_at` integer NOT NULL, + FOREIGN KEY (`invite_id`) REFERENCES `invite_links`(`id`) ON UPDATE no action ON DELETE cascade, + FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE set null +); +--> statement-breakpoint +ALTER TABLE `instance_settings` ADD `federated_registration_open` integer DEFAULT 1 NOT NULL;--> statement-breakpoint +CREATE UNIQUE INDEX `invite_links_token_unique` ON `invite_links` (`token`);--> statement-breakpoint +CREATE INDEX `idx_invite_links_created_at` ON `invite_links` (`created_at`);--> statement-breakpoint +CREATE INDEX `idx_invite_redemptions_invite_id` ON `invite_redemptions` (`invite_id`);--> statement-breakpoint +CREATE INDEX `idx_invite_redemptions_user_id` ON `invite_redemptions` (`user_id`); \ No newline at end of file diff --git a/packages/server/drizzle/meta/0004_snapshot.json b/packages/server/drizzle/meta/0004_snapshot.json new file mode 100644 index 00000000..87eeea02 --- /dev/null +++ b/packages/server/drizzle/meta/0004_snapshot.json @@ -0,0 +1,3641 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "f0f55202-ecbe-499c-8269-8cf1222c085b", + "prevId": "5ae0d7c7-5409-464d-b8d2-8af70cf6601f", + "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 + }, + "federated_registration_open": { + "name": "federated_registration_open", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 1 + }, + "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": {} + }, + "invite_links": { + "name": "invite_links", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "max_uses": { + "name": "max_uses", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "used_count": { + "name": "used_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "revoked_at": { + "name": "revoked_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "invite_links_token_unique": { + "name": "invite_links_token_unique", + "columns": [ + "token" + ], + "isUnique": true + }, + "idx_invite_links_created_at": { + "name": "idx_invite_links_created_at", + "columns": [ + "created_at" + ], + "isUnique": false + } + }, + "foreignKeys": { + "invite_links_created_by_users_id_fk": { + "name": "invite_links_created_by_users_id_fk", + "tableFrom": "invite_links", + "tableTo": "users", + "columnsFrom": [ + "created_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "invite_redemptions": { + "name": "invite_redemptions", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "invite_id": { + "name": "invite_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "registrant_username": { + "name": "registrant_username", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "redeemed_at": { + "name": "redeemed_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "idx_invite_redemptions_invite_id": { + "name": "idx_invite_redemptions_invite_id", + "columns": [ + "invite_id" + ], + "isUnique": false + }, + "idx_invite_redemptions_user_id": { + "name": "idx_invite_redemptions_user_id", + "columns": [ + "user_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "invite_redemptions_invite_id_invite_links_id_fk": { + "name": "invite_redemptions_invite_id_invite_links_id_fk", + "tableFrom": "invite_redemptions", + "tableTo": "invite_links", + "columnsFrom": [ + "invite_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "invite_redemptions_user_id_users_id_fk": { + "name": "invite_redemptions_user_id_users_id_fk", + "tableFrom": "invite_redemptions", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "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 3880987d..8b4b7ab2 100644 --- a/packages/server/drizzle/meta/_journal.json +++ b/packages/server/drizzle/meta/_journal.json @@ -29,6 +29,13 @@ "when": 1777229997210, "tag": "0003_brave_inhumans", "breakpoints": true + }, + { + "idx": 4, + "version": "6", + "when": 1777395066272, + "tag": "0004_slim_mentallo", + "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 a97ab2ec..f06c78cb 100644 --- a/packages/server/src/db/schema.ts +++ b/packages/server/src/db/schema.ts @@ -310,6 +310,7 @@ export const instanceSettings = sqliteTable('instance_settings', { maxResolution: integer('max_resolution').notNull().default(1080), maxFramerate: integer('max_framerate').notNull().default(60), registrationOpen: integer('registration_open'), // null = use env var default, 0/1 = explicit + federatedRegistrationOpen: integer('federated_registration_open').notNull().default(1), gifApiKey: text('gif_api_key'), bitrateMatrixOverrides: text('bitrate_matrix_overrides'), allowCustomBitrate: integer('allow_custom_bitrate').notNull().default(1), @@ -481,3 +482,28 @@ export const userFederationRegistry = sqliteTable('user_federation_registry', { }, (table) => ({ pk: primaryKey({ columns: [table.userId, table.origin] }), })); + +export const inviteLinks = sqliteTable('invite_links', { + id: text('id').primaryKey(), + token: text('token').notNull().unique(), + name: text('name').notNull(), + createdBy: text('created_by').notNull().references(() => users.id), + createdAt: integer('created_at').notNull(), + maxUses: integer('max_uses'), + usedCount: integer('used_count').notNull().default(0), + expiresAt: integer('expires_at'), + revokedAt: integer('revoked_at'), +}, (table) => ({ + createdAtIdx: index('idx_invite_links_created_at').on(table.createdAt), +})); + +export const inviteRedemptions = sqliteTable('invite_redemptions', { + id: text('id').primaryKey(), + inviteId: text('invite_id').notNull().references(() => inviteLinks.id, { onDelete: 'cascade' }), + userId: text('user_id').references(() => users.id, { onDelete: 'set null' }), + registrantUsername: text('registrant_username').notNull(), + redeemedAt: integer('redeemed_at').notNull(), +}, (table) => ({ + inviteIdx: index('idx_invite_redemptions_invite_id').on(table.inviteId), + userIdx: index('idx_invite_redemptions_user_id').on(table.userId), +})); From 884b3d9fe9ceb2edb5af9880f32838716aa65114 Mon Sep 17 00:00:00 2001 From: Jannis Braun <151788261+TheZwiss@users.noreply.github.com> Date: Tue, 28 Apr 2026 19:45:25 +0200 Subject: [PATCH 02/41] feat(shared): add invite link types + federatedRegistrationOpen --- packages/shared/src/types.ts | 65 ++++++++++++++++++++++++++++++++++++ 1 file changed, 65 insertions(+) diff --git a/packages/shared/src/types.ts b/packages/shared/src/types.ts index bf7325e1..5be15488 100644 --- a/packages/shared/src/types.ts +++ b/packages/shared/src/types.ts @@ -482,6 +482,7 @@ export interface RegisterRequest { avatarColor?: string; homeInstance?: string; homeUserId?: string; + inviteToken?: string; } export interface LoginRequest { @@ -681,6 +682,7 @@ export interface GifResult { export interface InstanceAdminSettings { instanceName: string; registrationOpen: boolean; + federatedRegistrationOpen: boolean; discoveryEnabled: boolean; gifApiKey?: string; gifEnabled?: boolean; @@ -710,6 +712,7 @@ export interface InstanceInfoResponse { name: string; version: string; registrationOpen: boolean; + federatedRegistrationOpen: boolean; } export interface VerifyPasswordRequest { @@ -1129,3 +1132,65 @@ export interface ApprovalRequest { */ subscribers?: ApprovalRequestSubscriberSummary[]; } + +// ─── Invite Links ────────────────────────────────────────────────────────── + +export type InviteStatus = 'active' | 'expired' | 'exhausted' | 'revoked'; + +export interface InviteLinkSummary { + id: string; + token: string; + name: string; + status: InviteStatus; + maxUses: number | null; + usedCount: number; + expiresAt: number | null; + revokedAt: number | null; + createdBy: string; + createdByUsername: string | null; + createdAt: number; + url: string; +} + +export interface InviteRedemption { + id: string; + userId: string | null; + registrantUsername: string; + currentUsername: string | null; + isDeleted: boolean; + redeemedAt: number; +} + +export interface CreateInviteRequest { + name: string; + maxUses: number | null; + expiresAt: number | null; +} + +export interface PatchInviteRequest { + name?: string; + maxUses?: number | null; + expiresAt?: number | null; +} + +export interface ReinstateInviteRequest { + maxUses?: number | null; + expiresAt?: number | null; +} + +export interface ReinstateInviteResponse { + invite: InviteLinkSummary; + tokenRotated: boolean; +} + +export interface CheckInviteValidResponse { + valid: true; + name: string; +} + +export interface CheckInviteInvalidResponse { + valid: false; + reason: 'expired' | 'exhausted' | 'invalid'; +} + +export type CheckInviteResponse = CheckInviteValidResponse | CheckInviteInvalidResponse; From e984ce910c66b7022e3e7347177323fb863549f4 Mon Sep 17 00:00:00 2001 From: Jannis Braun <151788261+TheZwiss@users.noreply.github.com> Date: Tue, 28 Apr 2026 19:50:05 +0200 Subject: [PATCH 03/41] =?UTF-8?q?refactor(shared):=20rename=20PatchInviteR?= =?UTF-8?q?equest=20=E2=86=92=20UpdateInviteRequest=20+=20JSDoc?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Matches existing codebase convention (UpdateChannelRequest, UpdateSpaceRequest, etc.). Adds JSDoc on InviteStatus and InviteLinkSummary.url / createdByUsername fields with server-side semantics. Plan updated for downstream task naming consistency. --- packages/shared/src/types.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/packages/shared/src/types.ts b/packages/shared/src/types.ts index 5be15488..e392a576 100644 --- a/packages/shared/src/types.ts +++ b/packages/shared/src/types.ts @@ -1135,6 +1135,7 @@ export interface ApprovalRequest { // ─── Invite Links ────────────────────────────────────────────────────────── +/** Derived status of an invite link. Active = usable; expired/exhausted/revoked = archived. */ export type InviteStatus = 'active' | 'expired' | 'exhausted' | 'revoked'; export interface InviteLinkSummary { @@ -1147,8 +1148,10 @@ export interface InviteLinkSummary { expiresAt: number | null; revokedAt: number | null; createdBy: string; + /** Joined from users.username at read time. `'Deleted User'` when the creator's account is tombstoned. `null` only if the FK is somehow unresolvable (defensive). */ createdByUsername: string | null; createdAt: number; + /** Server-constructed full URL, e.g. `https://host.example/register?invite=`. Clients must NOT assemble this themselves. */ url: string; } @@ -1167,7 +1170,7 @@ export interface CreateInviteRequest { expiresAt: number | null; } -export interface PatchInviteRequest { +export interface UpdateInviteRequest { name?: string; maxUses?: number | null; expiresAt?: number | null; From 967c62d488518177c6ba8c9bb46feebe70f5c3c8 Mon Sep 17 00:00:00 2001 From: Jannis Braun <151788261+TheZwiss@users.noreply.github.com> Date: Tue, 28 Apr 2026 19:52:16 +0200 Subject: [PATCH 04/41] feat(invites): inviteStatus derivation + token generator --- .../server/src/utils/inviteService.test.ts | 56 +++++++++++++++++++ packages/server/src/utils/inviteService.ts | 41 ++++++++++++++ 2 files changed, 97 insertions(+) create mode 100644 packages/server/src/utils/inviteService.test.ts create mode 100644 packages/server/src/utils/inviteService.ts diff --git a/packages/server/src/utils/inviteService.test.ts b/packages/server/src/utils/inviteService.test.ts new file mode 100644 index 00000000..5f94b4cf --- /dev/null +++ b/packages/server/src/utils/inviteService.test.ts @@ -0,0 +1,56 @@ +import { describe, it, expect } from 'vitest'; +import { inviteStatus, generateInviteToken } from './inviteService.js'; + +describe('inviteStatus', () => { + const base = { revokedAt: null, expiresAt: null, maxUses: null, usedCount: 0 }; + + it('returns active for a fresh invite', () => { + expect(inviteStatus(base)).toBe('active'); + }); + + it('returns revoked when revokedAt is set', () => { + expect(inviteStatus({ ...base, revokedAt: 100 })).toBe('revoked'); + }); + + it('returns expired when expiresAt is past', () => { + expect(inviteStatus({ ...base, expiresAt: Date.now() - 1000 })).toBe('expired'); + }); + + it('returns active when expiresAt is future', () => { + expect(inviteStatus({ ...base, expiresAt: Date.now() + 100_000 })).toBe('active'); + }); + + it('returns exhausted when usedCount >= maxUses', () => { + expect(inviteStatus({ ...base, maxUses: 5, usedCount: 5 })).toBe('exhausted'); + expect(inviteStatus({ ...base, maxUses: 5, usedCount: 6 })).toBe('exhausted'); + }); + + it('returns active when usedCount < maxUses', () => { + expect(inviteStatus({ ...base, maxUses: 5, usedCount: 4 })).toBe('active'); + }); + + it('revoked beats expired', () => { + expect(inviteStatus({ ...base, revokedAt: 100, expiresAt: Date.now() - 1000 })).toBe('revoked'); + }); + + it('expired beats exhausted', () => { + expect(inviteStatus({ ...base, expiresAt: Date.now() - 1000, maxUses: 5, usedCount: 5 })).toBe('expired'); + }); + + it('treats maxUses null as unlimited', () => { + expect(inviteStatus({ ...base, maxUses: null, usedCount: 1_000_000 })).toBe('active'); + }); +}); + +describe('generateInviteToken', () => { + it('returns 22-char base64url string', () => { + const t = generateInviteToken(); + expect(t).toMatch(/^[A-Za-z0-9_-]{22}$/); + }); + + it('returns different tokens on subsequent calls', () => { + const a = generateInviteToken(); + const b = generateInviteToken(); + expect(a).not.toBe(b); + }); +}); diff --git a/packages/server/src/utils/inviteService.ts b/packages/server/src/utils/inviteService.ts new file mode 100644 index 00000000..f1de9245 --- /dev/null +++ b/packages/server/src/utils/inviteService.ts @@ -0,0 +1,41 @@ +import crypto from 'node:crypto'; + +/** + * Derived status of an invite link. Mirrors the `InviteStatus` union exported + * from `@backspace/shared` (kept side-by-side intentionally — the shared union + * defines the API contract, this local one drives internal service logic, and + * keeping them independent lets a drift surface as a real type error). + */ +export type InviteStatus = 'active' | 'expired' | 'exhausted' | 'revoked'; + +/** + * Minimal row shape needed to derive an invite's status. Matches the relevant + * columns of `invite_links` (revokedAt, expiresAt, maxUses, usedCount). + */ +export interface InviteStatusInput { + revokedAt: number | null; + expiresAt: number | null; + maxUses: number | null; + usedCount: number; +} + +/** + * Derive the current status of an invite from its row. Precedence order: + * revoked > expired > exhausted > active. A `maxUses` of `null` means + * unlimited; an `expiresAt` of `null` means no expiry. + */ +export function inviteStatus(row: InviteStatusInput): InviteStatus { + if (row.revokedAt !== null) return 'revoked'; + if (row.expiresAt !== null && row.expiresAt < Date.now()) return 'expired'; + if (row.maxUses !== null && row.usedCount >= row.maxUses) return 'exhausted'; + return 'active'; +} + +/** + * Generate a fresh invite token: 16 random bytes encoded as base64url, which + * yields a 22-character URL-safe string (16 * 8 / 6 = 21.33, rounded up; no + * `=` padding because base64url omits it). + */ +export function generateInviteToken(): string { + return crypto.randomBytes(16).toString('base64url'); +} From daf2e958f7a1222c02d8941702a17b56a1ace3d2 Mon Sep 17 00:00:00 2001 From: Jannis Braun <151788261+TheZwiss@users.noreply.github.com> Date: Tue, 28 Apr 2026 19:58:03 +0200 Subject: [PATCH 05/41] feat(invites): createInvite + getInviteByToken with validation --- .../server/src/utils/inviteService.test.ts | 122 +++++++++++++- packages/server/src/utils/inviteService.ts | 158 ++++++++++++++++++ 2 files changed, 278 insertions(+), 2 deletions(-) diff --git a/packages/server/src/utils/inviteService.test.ts b/packages/server/src/utils/inviteService.test.ts index 5f94b4cf..05fa529d 100644 --- a/packages/server/src/utils/inviteService.test.ts +++ b/packages/server/src/utils/inviteService.test.ts @@ -1,5 +1,52 @@ -import { describe, it, expect } from 'vitest'; -import { inviteStatus, generateInviteToken } from './inviteService.js'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import Database from 'better-sqlite3'; +import { drizzle } from 'drizzle-orm/better-sqlite3'; +import * as schema from '../db/schema.js'; +import { setWorkerId } from './snowflake.js'; +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +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, +})); + +import { inviteStatus, generateInviteToken, createInvite, getInviteByToken } from './inviteService.js'; + +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 seedAdmin(): string { + const adminId = 'admin-user-1'; + testDb.insert(schema.users).values({ + id: adminId, + username: 'admin', + passwordHash: 'x', + isAdmin: 1, + createdAt: Date.now(), + }).run(); + return adminId; +} describe('inviteStatus', () => { const base = { revokedAt: null, expiresAt: null, maxUses: null, usedCount: 0 }; @@ -54,3 +101,74 @@ describe('generateInviteToken', () => { expect(a).not.toBe(b); }); }); + +describe('createInvite', () => { + beforeEach(() => { + sqlite = new Database(':memory:'); + sqlite.pragma('foreign_keys = ON'); + applyMigrations(sqlite); + testDb = drizzle(sqlite, { schema }); + }); + + it('creates an invite with required fields', () => { + const adminId = seedAdmin(); + const invite = createInvite({ name: 'Friends batch 1', maxUses: 10, expiresAt: Date.now() + 86_400_000 }, adminId); + expect(invite.name).toBe('Friends batch 1'); + expect(invite.maxUses).toBe(10); + expect(invite.usedCount).toBe(0); + expect(invite.revokedAt).toBeNull(); + expect(invite.token).toMatch(/^[A-Za-z0-9_-]{22}$/); + expect(invite.status).toBe('active'); + expect(invite.createdBy).toBe(adminId); + expect(invite.createdByUsername).toBe('admin'); + }); + + it('accepts null maxUses (unlimited)', () => { + const adminId = seedAdmin(); + const invite = createInvite({ name: 'unlimited', maxUses: null, expiresAt: null }, adminId); + expect(invite.maxUses).toBeNull(); + expect(invite.expiresAt).toBeNull(); + expect(invite.status).toBe('active'); + }); + + it('rejects empty name', () => { + const adminId = seedAdmin(); + expect(() => createInvite({ name: '', maxUses: null, expiresAt: null }, adminId)).toThrow(); + }); + + it('rejects name longer than 64 chars', () => { + const adminId = seedAdmin(); + expect(() => createInvite({ name: 'x'.repeat(65), maxUses: null, expiresAt: null }, adminId)).toThrow(); + }); + + it('rejects non-positive maxUses', () => { + const adminId = seedAdmin(); + expect(() => createInvite({ name: 'a', maxUses: 0, expiresAt: null }, adminId)).toThrow(); + expect(() => createInvite({ name: 'a', maxUses: -1, expiresAt: null }, adminId)).toThrow(); + }); + + it('rejects past expiresAt', () => { + const adminId = seedAdmin(); + expect(() => createInvite({ name: 'a', maxUses: null, expiresAt: Date.now() - 1000 }, adminId)).toThrow(); + }); +}); + +describe('getInviteByToken', () => { + beforeEach(() => { + sqlite = new Database(':memory:'); + sqlite.pragma('foreign_keys = ON'); + applyMigrations(sqlite); + testDb = drizzle(sqlite, { schema }); + }); + + it('returns the invite when token matches', () => { + const adminId = seedAdmin(); + const created = createInvite({ name: 'a', maxUses: null, expiresAt: null }, adminId); + const found = getInviteByToken(created.token); + expect(found?.id).toBe(created.id); + }); + + it('returns null when token not found', () => { + expect(getInviteByToken('nonexistent_token_aaaaaa')).toBeNull(); + }); +}); diff --git a/packages/server/src/utils/inviteService.ts b/packages/server/src/utils/inviteService.ts index f1de9245..97efde7b 100644 --- a/packages/server/src/utils/inviteService.ts +++ b/packages/server/src/utils/inviteService.ts @@ -1,4 +1,9 @@ import crypto from 'node:crypto'; +import { eq } from 'drizzle-orm'; +import { getDb, schema } from '../db/index.js'; +import { generateSnowflake } from './snowflake.js'; +import { config } from '../config.js'; +import type { InviteLinkSummary, CreateInviteRequest } from '@backspace/shared'; /** * Derived status of an invite link. Mirrors the `InviteStatus` union exported @@ -39,3 +44,156 @@ export function inviteStatus(row: InviteStatusInput): InviteStatus { export function generateInviteToken(): string { return crypto.randomBytes(16).toString('base64url'); } + +/** + * Thrown by invite-service mutations when caller-supplied input violates a + * field rule (length, sign, ordering, etc.). Caller (HTTP route) maps this to + * a 400 Bad Request with the message as `error`. + */ +export class InviteValidationError extends Error { + constructor(message: string) { + super(message); + this.name = 'InviteValidationError'; + } +} + +/** + * Build the public-facing invite URL embedded in API responses. Production + * deployments always set `DOMAIN`; the localhost fallback is only used in + * local dev (where `config.host` is typically `0.0.0.0` and unusable as a + * URL host). Per spec §1.2 + §5.4 the server owns URL construction so + * clients never have to assemble it. + */ +function buildInviteUrl(token: string): string { + if (config.domain) return `https://${config.domain}/register?invite=${token}`; + return `http://localhost:${config.port}/register?invite=${token}`; +} + +/** + * Validate the invite name (1–64 chars after trim). Trimming is part of + * normalization so `" foo "` is stored as `"foo"`. + */ +function validateName(name: string): string { + const trimmed = (name ?? '').trim(); + if (trimmed.length < 1 || trimmed.length > 64) { + throw new InviteValidationError('Name must be 1-64 characters'); + } + return trimmed; +} + +/** + * Validate `maxUses`: `null` means unlimited; otherwise a positive integer. + * Zero is rejected because an invite that can never be used is meaningless + * (use revoke for that). + */ +function validateMaxUses(maxUses: number | null): number | null { + if (maxUses === null) return null; + if (!Number.isInteger(maxUses) || maxUses < 1) { + throw new InviteValidationError('maxUses must be a positive integer or null'); + } + return maxUses; +} + +/** + * Validate `expiresAt` (epoch ms). On create, must be in the future; on + * patch/reinstate, `allowPast` lets admins keep an unchanged past value or + * deliberately set a past expiry to soft-shut. `Date.now()` exactly is not + * "in the future" and is rejected when `allowPast` is false. + */ +function validateExpiresAt(expiresAt: number | null, allowPast: boolean): number | null { + if (expiresAt === null) return null; + if (!Number.isInteger(expiresAt)) { + throw new InviteValidationError('expiresAt must be an integer epoch ms or null'); + } + if (!allowPast && expiresAt <= Date.now()) { + throw new InviteValidationError('expiresAt must be in the future'); + } + return expiresAt; +} + +/** + * Project an `invite_links` row plus the resolved creator-username into the + * shared `InviteLinkSummary` shape. Centralized so list/create/patch/reinstate + * all return identically-shaped rows. Status is derived (never stored) per + * spec §2.1. + */ +function rowToSummary( + row: typeof schema.inviteLinks.$inferSelect, + createdByUsername: string | null, +): InviteLinkSummary { + return { + id: row.id, + token: row.token, + name: row.name, + status: inviteStatus(row), + maxUses: row.maxUses, + usedCount: row.usedCount, + expiresAt: row.expiresAt, + revokedAt: row.revokedAt, + createdBy: row.createdBy, + createdByUsername, + createdAt: row.createdAt, + url: buildInviteUrl(row.token), + }; +} + +/** + * Resolve the username to display for an invite's creator. Returns the live + * username, `'Deleted User'` for tombstoned accounts (spec §3.1, §4.1), or + * `null` if the FK is unresolvable (defensive — should not happen in practice). + */ +function resolveCreatorUsername(creatorId: string): string | null { + const db = getDb(); + const u = db.select({ username: schema.users.username, isDeleted: schema.users.isDeleted }) + .from(schema.users) + .where(eq(schema.users.id, creatorId)) + .get(); + if (!u) return null; + if (u.isDeleted === 1) return 'Deleted User'; + return u.username; +} + +/** + * Create a new invite link. Validates input, generates id + token, inserts the + * row, and returns the projected summary. Throws `InviteValidationError` on + * bad input (caller maps to 400). + */ +export function createInvite(req: CreateInviteRequest, creatorId: string): InviteLinkSummary { + const name = validateName(req.name); + const maxUses = validateMaxUses(req.maxUses); + const expiresAt = validateExpiresAt(req.expiresAt, false); + + const db = getDb(); + const id = generateSnowflake(); + const token = generateInviteToken(); + const now = Date.now(); + + db.insert(schema.inviteLinks).values({ + id, + token, + name, + createdBy: creatorId, + createdAt: now, + maxUses, + usedCount: 0, + expiresAt, + revokedAt: null, + }).run(); + + const row = db.select().from(schema.inviteLinks).where(eq(schema.inviteLinks.id, id)).get(); + if (!row) throw new Error('Failed to insert invite'); + return rowToSummary(row, resolveCreatorUsername(creatorId)); +} + +/** + * Look up the raw `invite_links` row by token. Used by the registration flow + * (check-invite, register) — those sites do their own derived-status checks. + * The format guard short-circuits before hitting the DB to keep malformed + * tokens cheap. + */ +export function getInviteByToken(token: string): typeof schema.inviteLinks.$inferSelect | null { + if (typeof token !== 'string' || !/^[A-Za-z0-9_-]{22}$/.test(token)) return null; + const db = getDb(); + const row = db.select().from(schema.inviteLinks).where(eq(schema.inviteLinks.token, token)).get(); + return row ?? null; +} From ce5d4c10c86085fc93bb5f036b2d377005f2a21c Mon Sep 17 00:00:00 2001 From: Jannis Braun <151788261+TheZwiss@users.noreply.github.com> Date: Tue, 28 Apr 2026 20:04:18 +0200 Subject: [PATCH 06/41] feat(invites): listInvites + listRedemptions with creator/current JOINs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds two query helpers to inviteService: - listInvites(filter): single-query LEFT JOIN against users to surface createdByUsername, with status filtered in TS via the canonical inviteStatus() derivation. Avoids N+1 the spec calls out (§3.1). 'archived' = expired | exhausted | revoked. Sort: createdAt DESC. - listRedemptions(inviteId): LEFT JOIN against users via userId to expose currentUsername alongside the registrantUsername snapshot. Three null-handling branches per spec §3.1: live (username), tombstoned ('Deleted User', isDeleted=true), and hard-deleted (userId null, currentUsername null, isDeleted false). Sort: redeemedAt DESC. Also fixes a mistitled DB-miss test in getInviteByToken: the original 'returns null when token not found' used a 24-char string that fails the format regex *before* the DB lookup. Split into two tests covering both the format-reject path and the well-formed-but-missing path. 40 files / 302 tests passing. --- .../server/src/utils/inviteService.test.ts | 129 +++++++++++++++++- packages/server/src/utils/inviteService.ts | 79 ++++++++++- 2 files changed, 203 insertions(+), 5 deletions(-) diff --git a/packages/server/src/utils/inviteService.test.ts b/packages/server/src/utils/inviteService.test.ts index 05fa529d..719f5fa1 100644 --- a/packages/server/src/utils/inviteService.test.ts +++ b/packages/server/src/utils/inviteService.test.ts @@ -21,7 +21,8 @@ vi.mock('../db/index.js', () => ({ schema, })); -import { inviteStatus, generateInviteToken, createInvite, getInviteByToken } from './inviteService.js'; +import { inviteStatus, generateInviteToken, createInvite, getInviteByToken, listInvites, listRedemptions } from './inviteService.js'; +import { eq } from 'drizzle-orm'; function applyMigrations(db: Database.Database): void { const migrationsDir = path.resolve(__dirname, '../../drizzle'); @@ -168,7 +169,129 @@ describe('getInviteByToken', () => { expect(found?.id).toBe(created.id); }); - it('returns null when token not found', () => { - expect(getInviteByToken('nonexistent_token_aaaaaa')).toBeNull(); + it('returns null when token has invalid format', () => { + expect(getInviteByToken('tooshort')).toBeNull(); + expect(getInviteByToken('nonexistent_token_aaaaaa')).toBeNull(); // 24 chars + }); + + it('returns null when token is well-formed but not in DB', () => { + expect(getInviteByToken('aaaaaaaaaaaaaaaaaaaaaa')).toBeNull(); // 22 chars, valid format + }); +}); + +describe('listInvites', () => { + beforeEach(() => { + sqlite = new Database(':memory:'); + sqlite.pragma('foreign_keys = ON'); + applyMigrations(sqlite); + testDb = drizzle(sqlite, { schema }); + }); + + it('returns active invites only with status=active', () => { + const adminId = seedAdmin(); + const a = createInvite({ name: 'active1', maxUses: null, expiresAt: null }, adminId); + const b = createInvite({ name: 'active2', maxUses: 1, expiresAt: null }, adminId); + // Manually exhaust b + testDb.update(schema.inviteLinks).set({ usedCount: 1 }).where(eq(schema.inviteLinks.id, b.id)).run(); + + const list = listInvites('active'); + expect(list.map(i => i.id)).toEqual([a.id]); + }); + + it('returns archived invites only with status=archived', () => { + const adminId = seedAdmin(); + createInvite({ name: 'active', maxUses: null, expiresAt: null }, adminId); + const exhausted = createInvite({ name: 'exhausted', maxUses: 1, expiresAt: null }, adminId); + testDb.update(schema.inviteLinks).set({ usedCount: 1 }).where(eq(schema.inviteLinks.id, exhausted.id)).run(); + const revoked = createInvite({ name: 'revoked', maxUses: null, expiresAt: null }, adminId); + testDb.update(schema.inviteLinks).set({ revokedAt: Date.now() }).where(eq(schema.inviteLinks.id, revoked.id)).run(); + + const list = listInvites('archived'); + expect(list.map(i => i.id).sort()).toEqual([exhausted.id, revoked.id].sort()); + expect(list.find(i => i.id === exhausted.id)?.status).toBe('exhausted'); + expect(list.find(i => i.id === revoked.id)?.status).toBe('revoked'); + }); + + it('JOIN surfaces createdByUsername; tombstoned creator -> "Deleted User"', () => { + const adminId = seedAdmin(); + createInvite({ name: 'i1', maxUses: null, expiresAt: null }, adminId); + // Tombstone admin + testDb.update(schema.users).set({ isDeleted: 1, username: '!deleted:' + adminId }).where(eq(schema.users.id, adminId)).run(); + + const list = listInvites('active'); + expect(list[0]?.createdByUsername).toBe('Deleted User'); + }); + + it('sorts by createdAt DESC', async () => { + const adminId = seedAdmin(); + const a = createInvite({ name: 'first', maxUses: null, expiresAt: null }, adminId); + await new Promise(r => setTimeout(r, 5)); + const b = createInvite({ name: 'second', maxUses: null, expiresAt: null }, adminId); + const list = listInvites('active'); + expect(list.map(i => i.id)).toEqual([b.id, a.id]); + }); +}); + +describe('listRedemptions', () => { + beforeEach(() => { + sqlite = new Database(':memory:'); + sqlite.pragma('foreign_keys = ON'); + applyMigrations(sqlite); + testDb = drizzle(sqlite, { schema }); + }); + + it('returns redemption rows with currentUsername joined', () => { + const adminId = seedAdmin(); + const invite = createInvite({ name: 'i', maxUses: null, expiresAt: null }, adminId); + const userId = 'user-1'; + testDb.insert(schema.users).values({ id: userId, username: 'alice', passwordHash: 'x', createdAt: Date.now() }).run(); + testDb.insert(schema.inviteRedemptions).values({ + id: 'red-1', + inviteId: invite.id, + userId, + registrantUsername: 'alice', + redeemedAt: Date.now(), + }).run(); + + const list = listRedemptions(invite.id); + expect(list).toHaveLength(1); + expect(list[0]?.registrantUsername).toBe('alice'); + expect(list[0]?.currentUsername).toBe('alice'); + expect(list[0]?.isDeleted).toBe(false); + }); + + it('marks tombstoned users with currentUsername="Deleted User" and isDeleted=true', () => { + const adminId = seedAdmin(); + const invite = createInvite({ name: 'i', maxUses: null, expiresAt: null }, adminId); + const userId = 'user-2'; + testDb.insert(schema.users).values({ id: userId, username: 'bob', passwordHash: 'x', isDeleted: 1, createdAt: Date.now() }).run(); + testDb.insert(schema.inviteRedemptions).values({ + id: 'red-2', + inviteId: invite.id, + userId, + registrantUsername: 'bob', + redeemedAt: Date.now(), + }).run(); + + const list = listRedemptions(invite.id); + expect(list[0]?.currentUsername).toBe('Deleted User'); + expect(list[0]?.isDeleted).toBe(true); + }); + + it('handles null userId (hard-deleted user)', () => { + const adminId = seedAdmin(); + const invite = createInvite({ name: 'i', maxUses: null, expiresAt: null }, adminId); + testDb.insert(schema.inviteRedemptions).values({ + id: 'red-3', + inviteId: invite.id, + userId: null, + registrantUsername: 'ghost', + redeemedAt: Date.now(), + }).run(); + + const list = listRedemptions(invite.id); + expect(list[0]?.userId).toBeNull(); + expect(list[0]?.currentUsername).toBeNull(); + expect(list[0]?.isDeleted).toBe(false); }); }); diff --git a/packages/server/src/utils/inviteService.ts b/packages/server/src/utils/inviteService.ts index 97efde7b..cae2a80f 100644 --- a/packages/server/src/utils/inviteService.ts +++ b/packages/server/src/utils/inviteService.ts @@ -1,9 +1,9 @@ import crypto from 'node:crypto'; -import { eq } from 'drizzle-orm'; +import { eq, desc } from 'drizzle-orm'; import { getDb, schema } from '../db/index.js'; import { generateSnowflake } from './snowflake.js'; import { config } from '../config.js'; -import type { InviteLinkSummary, CreateInviteRequest } from '@backspace/shared'; +import type { InviteLinkSummary, CreateInviteRequest, InviteRedemption } from '@backspace/shared'; /** * Derived status of an invite link. Mirrors the `InviteStatus` union exported @@ -197,3 +197,78 @@ export function getInviteByToken(token: string): typeof schema.inviteLinks.$infe const row = db.select().from(schema.inviteLinks).where(eq(schema.inviteLinks.token, token)).get(); return row ?? null; } + +/** + * List invites filtered by lifecycle state. `'active'` returns only rows whose + * derived status is `active`; `'archived'` returns rows in `expired`, + * `exhausted`, or `revoked`. The status is derived in TS (single source of + * truth: `inviteStatus()`), so we fetch all rows then filter — see spec §6.3 + * (no per-instance invite policy / janitor) for why this is acceptable at v1 + * scale; switch to a SQL-side filter only if instances accumulate thousands of + * invites. The LEFT JOIN against `users` resolves `createdByUsername` in a + * single query, avoiding the N+1 the spec calls out (§3.1). + */ +export function listInvites(filter: 'active' | 'archived'): InviteLinkSummary[] { + const db = getDb(); + const rows = db.select({ + invite: schema.inviteLinks, + creatorUsername: schema.users.username, + creatorIsDeleted: schema.users.isDeleted, + }) + .from(schema.inviteLinks) + .leftJoin(schema.users, eq(schema.inviteLinks.createdBy, schema.users.id)) + .orderBy(desc(schema.inviteLinks.createdAt)) + .all(); + + const summaries = rows.map(({ invite, creatorUsername, creatorIsDeleted }) => { + const username = creatorUsername === null + ? null + : creatorIsDeleted === 1 + ? 'Deleted User' + : creatorUsername; + return rowToSummary(invite, username); + }); + + if (filter === 'active') return summaries.filter(s => s.status === 'active'); + return summaries.filter(s => s.status !== 'active'); +} + +/** + * List redemptions for one invite, newest first. The LEFT JOIN against `users` + * via `userId` surfaces the live username so the UI can render + * "registered as alice (now Anastasia)" — the snapshot in `registrantUsername` + * stays forensically stable while `currentUsername` reflects the live state. + * + * Three null-handling branches per spec §3.1: + * - live user → `currentUsername = users.username`, `isDeleted = false` + * - tombstoned user → `currentUsername = 'Deleted User'`, `isDeleted = true` + * - hard-deleted user → `userId = null`, `currentUsername = null`, + * `isDeleted = false` (the row is genuinely gone, not + * soft-deleted; "Deleted User" would be misleading) + */ +export function listRedemptions(inviteId: string): InviteRedemption[] { + const db = getDb(); + const rows = db.select({ + redemption: schema.inviteRedemptions, + currentUsername: schema.users.username, + currentIsDeleted: schema.users.isDeleted, + }) + .from(schema.inviteRedemptions) + .leftJoin(schema.users, eq(schema.inviteRedemptions.userId, schema.users.id)) + .where(eq(schema.inviteRedemptions.inviteId, inviteId)) + .orderBy(desc(schema.inviteRedemptions.redeemedAt)) + .all(); + + return rows.map(({ redemption, currentUsername, currentIsDeleted }) => ({ + id: redemption.id, + userId: redemption.userId, + registrantUsername: redemption.registrantUsername, + currentUsername: redemption.userId === null + ? null + : currentIsDeleted === 1 + ? 'Deleted User' + : currentUsername, + isDeleted: currentIsDeleted === 1, + redeemedAt: redemption.redeemedAt, + })); +} From 23338419df7b0578f2ecfb994f06d3c5f0f57142 Mon Sep 17 00:00:00 2001 From: Jannis Braun <151788261+TheZwiss@users.noreply.github.com> Date: Tue, 28 Apr 2026 20:11:47 +0200 Subject: [PATCH 07/41] feat(invites): patchInvite + revokeInvite with txn re-derive + foldUsername helper Both functions wrap their read-modify-write in a Drizzle better-sqlite3 db.transaction((tx) => ...) with in-txn re-fetch so concurrent admin mutations are serialized by SQLite's writer lock. - patchInvite: 404 on missing, 409 on revoked, 400 on maxUses < usedCount, allows expiresAt to be moved into the past (effective soft-shut). - revokeInvite: 404 on missing, 409 on already-revoked (explicit reject, not silent no-op). Also extracts foldUsername() to collapse the duplicated (username, isDeleted) -> display string fold across resolveCreatorUsername, listInvites, and listRedemptions (deferred refactor from Task 5 review). Note: the plan's example used db.transaction(cb)() with an IIFE, which is the raw better-sqlite3 signature. Drizzle's wrapper returns the callback's return value directly, so we use the (tx) => ... form consistent with the rest of the codebase (userDeletion, federation, channels, etc.). Tests: 36 invite-service tests pass (27 prior + 9 new). Full server suite: 40 files / 311 tests pass. --- .../server/src/utils/inviteService.test.ts | 81 ++++++++++- packages/server/src/utils/inviteService.ts | 131 ++++++++++++++++-- 2 files changed, 197 insertions(+), 15 deletions(-) diff --git a/packages/server/src/utils/inviteService.test.ts b/packages/server/src/utils/inviteService.test.ts index 719f5fa1..63713514 100644 --- a/packages/server/src/utils/inviteService.test.ts +++ b/packages/server/src/utils/inviteService.test.ts @@ -21,7 +21,8 @@ vi.mock('../db/index.js', () => ({ schema, })); -import { inviteStatus, generateInviteToken, createInvite, getInviteByToken, listInvites, listRedemptions } from './inviteService.js'; +import { inviteStatus, generateInviteToken, createInvite, getInviteByToken, listInvites, listRedemptions, InviteValidationError } from './inviteService.js'; +import { patchInvite, revokeInvite, InviteStateConflictError, InviteNotFoundError } from './inviteService.js'; import { eq } from 'drizzle-orm'; function applyMigrations(db: Database.Database): void { @@ -295,3 +296,81 @@ describe('listRedemptions', () => { expect(list[0]?.isDeleted).toBe(false); }); }); + +describe('patchInvite', () => { + beforeEach(() => { + sqlite = new Database(':memory:'); + sqlite.pragma('foreign_keys = ON'); + applyMigrations(sqlite); + testDb = drizzle(sqlite, { schema }); + }); + + it('updates name', () => { + const adminId = seedAdmin(); + const inv = createInvite({ name: 'old', maxUses: null, expiresAt: null }, adminId); + const updated = patchInvite(inv.id, { name: 'new' }); + expect(updated.name).toBe('new'); + }); + + it('updates maxUses', () => { + const adminId = seedAdmin(); + const inv = createInvite({ name: 'a', maxUses: 5, expiresAt: null }, adminId); + const updated = patchInvite(inv.id, { maxUses: 20 }); + expect(updated.maxUses).toBe(20); + }); + + it('rejects maxUses below current usedCount', () => { + const adminId = seedAdmin(); + const inv = createInvite({ name: 'a', maxUses: 10, expiresAt: null }, adminId); + testDb.update(schema.inviteLinks).set({ usedCount: 7 }).where(eq(schema.inviteLinks.id, inv.id)).run(); + expect(() => patchInvite(inv.id, { maxUses: 5 })).toThrow(InviteValidationError); + }); + + it('throws InviteNotFoundError when id not found', () => { + expect(() => patchInvite('nonexistent', { name: 'x' })).toThrow(InviteNotFoundError); + }); + + it('throws InviteStateConflictError when invite is revoked', () => { + const adminId = seedAdmin(); + const inv = createInvite({ name: 'a', maxUses: null, expiresAt: null }, adminId); + testDb.update(schema.inviteLinks).set({ revokedAt: Date.now() }).where(eq(schema.inviteLinks.id, inv.id)).run(); + expect(() => patchInvite(inv.id, { name: 'x' })).toThrow(InviteStateConflictError); + }); + + it('allows expiresAt to be moved to past (effective soft-shut)', () => { + const adminId = seedAdmin(); + const inv = createInvite({ name: 'a', maxUses: null, expiresAt: Date.now() + 100_000 }, adminId); + const past = Date.now() - 1000; + const updated = patchInvite(inv.id, { expiresAt: past }); + expect(updated.expiresAt).toBe(past); + expect(updated.status).toBe('expired'); + }); +}); + +describe('revokeInvite', () => { + beforeEach(() => { + sqlite = new Database(':memory:'); + sqlite.pragma('foreign_keys = ON'); + applyMigrations(sqlite); + testDb = drizzle(sqlite, { schema }); + }); + + it('sets revokedAt and returns revoked summary', () => { + const adminId = seedAdmin(); + const inv = createInvite({ name: 'a', maxUses: null, expiresAt: null }, adminId); + const revoked = revokeInvite(inv.id); + expect(revoked.status).toBe('revoked'); + expect(revoked.revokedAt).toBeGreaterThan(0); + }); + + it('throws InviteStateConflictError on already-revoked', () => { + const adminId = seedAdmin(); + const inv = createInvite({ name: 'a', maxUses: null, expiresAt: null }, adminId); + revokeInvite(inv.id); + expect(() => revokeInvite(inv.id)).toThrow(InviteStateConflictError); + }); + + it('throws InviteNotFoundError on missing id', () => { + expect(() => revokeInvite('nonexistent')).toThrow(InviteNotFoundError); + }); +}); diff --git a/packages/server/src/utils/inviteService.ts b/packages/server/src/utils/inviteService.ts index cae2a80f..b4a8bea5 100644 --- a/packages/server/src/utils/inviteService.ts +++ b/packages/server/src/utils/inviteService.ts @@ -3,7 +3,7 @@ import { eq, desc } from 'drizzle-orm'; import { getDb, schema } from '../db/index.js'; import { generateSnowflake } from './snowflake.js'; import { config } from '../config.js'; -import type { InviteLinkSummary, CreateInviteRequest, InviteRedemption } from '@backspace/shared'; +import type { InviteLinkSummary, CreateInviteRequest, InviteRedemption, UpdateInviteRequest } from '@backspace/shared'; /** * Derived status of an invite link. Mirrors the `InviteStatus` union exported @@ -111,6 +111,19 @@ function validateExpiresAt(expiresAt: number | null, allowPast: boolean): number return expiresAt; } +/** + * Folds a (username, isDeleted) pair into the display string used by + * InviteLinkSummary.createdByUsername / InviteRedemption.currentUsername. + * + * - null username → null (FK unresolvable; should be rare, defensive) + * - isDeleted=1 → 'Deleted User' (matches sanitizeUser convention) + * - else → username + */ +function foldUsername(username: string | null, isDeleted: number | null): string | null { + if (username === null) return null; + return isDeleted === 1 ? 'Deleted User' : username; +} + /** * Project an `invite_links` row plus the resolved creator-username into the * shared `InviteLinkSummary` shape. Centralized so list/create/patch/reinstate @@ -148,9 +161,7 @@ function resolveCreatorUsername(creatorId: string): string | null { .from(schema.users) .where(eq(schema.users.id, creatorId)) .get(); - if (!u) return null; - if (u.isDeleted === 1) return 'Deleted User'; - return u.username; + return foldUsername(u?.username ?? null, u?.isDeleted ?? null); } /** @@ -221,11 +232,7 @@ export function listInvites(filter: 'active' | 'archived'): InviteLinkSummary[] .all(); const summaries = rows.map(({ invite, creatorUsername, creatorIsDeleted }) => { - const username = creatorUsername === null - ? null - : creatorIsDeleted === 1 - ? 'Deleted User' - : creatorUsername; + const username = foldUsername(creatorUsername, creatorIsDeleted); return rowToSummary(invite, username); }); @@ -263,12 +270,108 @@ export function listRedemptions(inviteId: string): InviteRedemption[] { id: redemption.id, userId: redemption.userId, registrantUsername: redemption.registrantUsername, - currentUsername: redemption.userId === null - ? null - : currentIsDeleted === 1 - ? 'Deleted User' - : currentUsername, + currentUsername: redemption.userId === null ? null : foldUsername(currentUsername, currentIsDeleted), isDeleted: currentIsDeleted === 1, redeemedAt: redemption.redeemedAt, })); } + +/** + * Thrown when a mutation targets an invite id that does not exist. Caller + * (HTTP route) maps this to 404 Not Found. + */ +export class InviteNotFoundError extends Error { + constructor() { + super('Invite not found'); + this.name = 'InviteNotFoundError'; + } +} + +/** + * Thrown when a mutation is rejected because the invite's current state + * forbids it (e.g. patching a revoked invite, double-revoking). Caller + * (HTTP route) maps this to 409 Conflict; the message is the user-facing + * copy that surfaces in the toast. + */ +export class InviteStateConflictError extends Error { + constructor(message: string) { + super(message); + this.name = 'InviteStateConflictError'; + } +} + +/** + * Patch an existing invite's mutable fields. Wrapped in a SQLite transaction + * with an in-txn re-fetch so concurrent admin edits are serialized: the + * second writer sees the first writer's committed state and either applies + * its own delta on top or rejects (e.g. observed-revoked). + * + * Validation rules per spec §3.1: + * - 404 if id not found. + * - 409 if invite is currently revoked (must reinstate first to modify). + * - 400 if maxUses would drop below current usedCount (would retroactively + * exhaust — confusing; admin should use revoke instead). + * - expiresAt may be moved into the past (effective soft-shut → status + * flips to 'expired' on next read). + * + * An empty patch body is a no-op that returns the current summary unchanged. + */ +export function patchInvite(id: string, req: UpdateInviteRequest): InviteLinkSummary { + const db = getDb(); + return db.transaction((tx) => { + const row = tx.select().from(schema.inviteLinks).where(eq(schema.inviteLinks.id, id)).get(); + if (!row) throw new InviteNotFoundError(); + if (row.revokedAt !== null) { + throw new InviteStateConflictError('Invite is revoked. Reinstate first to modify.'); + } + + const updates: Partial = {}; + if (req.name !== undefined) updates.name = validateName(req.name); + if (req.maxUses !== undefined) { + const v = validateMaxUses(req.maxUses); + if (v !== null && v < row.usedCount) { + throw new InviteValidationError( + `maxUses (${v}) cannot be less than current usedCount (${row.usedCount})`, + ); + } + updates.maxUses = v; + } + if (req.expiresAt !== undefined) { + updates.expiresAt = validateExpiresAt(req.expiresAt, true); + } + + if (Object.keys(updates).length === 0) { + // No-op: just return current summary + return rowToSummary(row, resolveCreatorUsername(row.createdBy)); + } + + tx.update(schema.inviteLinks).set(updates).where(eq(schema.inviteLinks.id, id)).run(); + const updated = tx.select().from(schema.inviteLinks).where(eq(schema.inviteLinks.id, id)).get(); + if (!updated) throw new Error('Failed to read updated invite'); + return rowToSummary(updated, resolveCreatorUsername(updated.createdBy)); + }); +} + +/** + * Revoke an invite. Wrapped in a SQLite transaction with an in-txn re-fetch + * so concurrent revokes are serialized: the first wins, the second sees + * `revokedAt !== null` and throws `InviteStateConflictError` (mapped to 409 + * by the route — explicit rejection rather than silent no-op, per spec §3.1). + * + * Token is preserved on revoke; reinstate-from-revoked rotates the token as + * a security boundary (handled in `reinstateInvite`, not here). + */ +export function revokeInvite(id: string): InviteLinkSummary { + const db = getDb(); + return db.transaction((tx) => { + const row = tx.select().from(schema.inviteLinks).where(eq(schema.inviteLinks.id, id)).get(); + if (!row) throw new InviteNotFoundError(); + if (row.revokedAt !== null) { + throw new InviteStateConflictError('Invite is already revoked'); + } + tx.update(schema.inviteLinks).set({ revokedAt: Date.now() }).where(eq(schema.inviteLinks.id, id)).run(); + const updated = tx.select().from(schema.inviteLinks).where(eq(schema.inviteLinks.id, id)).get(); + if (!updated) throw new Error('Failed to read updated invite'); + return rowToSummary(updated, resolveCreatorUsername(updated.createdBy)); + }); +} From 45b2eabed34f2d38228a12028a1e1ad7c7e96a48 Mon Sep 17 00:00:00 2001 From: Jannis Braun <151788261+TheZwiss@users.noreply.github.com> Date: Tue, 28 Apr 2026 20:17:41 +0200 Subject: [PATCH 08/41] refactor(invites): thread tx through resolveCreatorUsername + tighten test assertions Inside patchInvite/revokeInvite txn bodies, all reads now go through the tx proxy. Pre-Task-7 hygiene: locks in the consistent pattern that reinstateInvite (Task 7) and redeemInvite (Task 8) will copy. Createinvite test failures now pin to InviteValidationError, catching regressions where the wrong error class would otherwise pass silently. --- .../server/src/utils/inviteService.test.ts | 10 +++++----- packages/server/src/utils/inviteService.ts | 18 ++++++++++++------ 2 files changed, 17 insertions(+), 11 deletions(-) diff --git a/packages/server/src/utils/inviteService.test.ts b/packages/server/src/utils/inviteService.test.ts index 63713514..56f39865 100644 --- a/packages/server/src/utils/inviteService.test.ts +++ b/packages/server/src/utils/inviteService.test.ts @@ -135,23 +135,23 @@ describe('createInvite', () => { it('rejects empty name', () => { const adminId = seedAdmin(); - expect(() => createInvite({ name: '', maxUses: null, expiresAt: null }, adminId)).toThrow(); + expect(() => createInvite({ name: '', maxUses: null, expiresAt: null }, adminId)).toThrow(InviteValidationError); }); it('rejects name longer than 64 chars', () => { const adminId = seedAdmin(); - expect(() => createInvite({ name: 'x'.repeat(65), maxUses: null, expiresAt: null }, adminId)).toThrow(); + expect(() => createInvite({ name: 'x'.repeat(65), maxUses: null, expiresAt: null }, adminId)).toThrow(InviteValidationError); }); it('rejects non-positive maxUses', () => { const adminId = seedAdmin(); - expect(() => createInvite({ name: 'a', maxUses: 0, expiresAt: null }, adminId)).toThrow(); - expect(() => createInvite({ name: 'a', maxUses: -1, expiresAt: null }, adminId)).toThrow(); + expect(() => createInvite({ name: 'a', maxUses: 0, expiresAt: null }, adminId)).toThrow(InviteValidationError); + expect(() => createInvite({ name: 'a', maxUses: -1, expiresAt: null }, adminId)).toThrow(InviteValidationError); }); it('rejects past expiresAt', () => { const adminId = seedAdmin(); - expect(() => createInvite({ name: 'a', maxUses: null, expiresAt: Date.now() - 1000 }, adminId)).toThrow(); + expect(() => createInvite({ name: 'a', maxUses: null, expiresAt: Date.now() - 1000 }, adminId)).toThrow(InviteValidationError); }); }); diff --git a/packages/server/src/utils/inviteService.ts b/packages/server/src/utils/inviteService.ts index b4a8bea5..fe3190e8 100644 --- a/packages/server/src/utils/inviteService.ts +++ b/packages/server/src/utils/inviteService.ts @@ -154,10 +154,16 @@ function rowToSummary( * Resolve the username to display for an invite's creator. Returns the live * username, `'Deleted User'` for tombstoned accounts (spec §3.1, §4.1), or * `null` if the FK is unresolvable (defensive — should not happen in practice). + * + * Accepts an optional Drizzle handle so callers inside a `db.transaction` + * body can pass the `tx` proxy and keep the read on the same logical txn as + * surrounding writes. Defaults to the outer `getDb()` for non-txn callers. */ -function resolveCreatorUsername(creatorId: string): string | null { - const db = getDb(); - const u = db.select({ username: schema.users.username, isDeleted: schema.users.isDeleted }) +function resolveCreatorUsername( + creatorId: string, + dbHandle: ReturnType = getDb(), +): string | null { + const u = dbHandle.select({ username: schema.users.username, isDeleted: schema.users.isDeleted }) .from(schema.users) .where(eq(schema.users.id, creatorId)) .get(); @@ -342,13 +348,13 @@ export function patchInvite(id: string, req: UpdateInviteRequest): InviteLinkSum if (Object.keys(updates).length === 0) { // No-op: just return current summary - return rowToSummary(row, resolveCreatorUsername(row.createdBy)); + return rowToSummary(row, resolveCreatorUsername(row.createdBy, tx)); } tx.update(schema.inviteLinks).set(updates).where(eq(schema.inviteLinks.id, id)).run(); const updated = tx.select().from(schema.inviteLinks).where(eq(schema.inviteLinks.id, id)).get(); if (!updated) throw new Error('Failed to read updated invite'); - return rowToSummary(updated, resolveCreatorUsername(updated.createdBy)); + return rowToSummary(updated, resolveCreatorUsername(updated.createdBy, tx)); }); } @@ -372,6 +378,6 @@ export function revokeInvite(id: string): InviteLinkSummary { tx.update(schema.inviteLinks).set({ revokedAt: Date.now() }).where(eq(schema.inviteLinks.id, id)).run(); const updated = tx.select().from(schema.inviteLinks).where(eq(schema.inviteLinks.id, id)).get(); if (!updated) throw new Error('Failed to read updated invite'); - return rowToSummary(updated, resolveCreatorUsername(updated.createdBy)); + return rowToSummary(updated, resolveCreatorUsername(updated.createdBy, tx)); }); } From ee405cba7c05de260012ac70e44be8f35073c788 Mon Sep 17 00:00:00 2001 From: Jannis Braun <151788261+TheZwiss@users.noreply.github.com> Date: Tue, 28 Apr 2026 20:20:13 +0200 Subject: [PATCH 09/41] feat(invites): reinstateInvite with revoked-vs-archived branching --- .../server/src/utils/inviteService.test.ts | 72 ++++++++++++++++ packages/server/src/utils/inviteService.ts | 82 ++++++++++++++++++- 2 files changed, 153 insertions(+), 1 deletion(-) diff --git a/packages/server/src/utils/inviteService.test.ts b/packages/server/src/utils/inviteService.test.ts index 56f39865..4cd0e501 100644 --- a/packages/server/src/utils/inviteService.test.ts +++ b/packages/server/src/utils/inviteService.test.ts @@ -23,6 +23,7 @@ vi.mock('../db/index.js', () => ({ import { inviteStatus, generateInviteToken, createInvite, getInviteByToken, listInvites, listRedemptions, InviteValidationError } from './inviteService.js'; import { patchInvite, revokeInvite, InviteStateConflictError, InviteNotFoundError } from './inviteService.js'; +import { reinstateInvite } from './inviteService.js'; import { eq } from 'drizzle-orm'; function applyMigrations(db: Database.Database): void { @@ -374,3 +375,74 @@ describe('revokeInvite', () => { expect(() => revokeInvite('nonexistent')).toThrow(InviteNotFoundError); }); }); + +describe('reinstateInvite', () => { + beforeEach(() => { + sqlite = new Database(':memory:'); + sqlite.pragma('foreign_keys = ON'); + applyMigrations(sqlite); + testDb = drizzle(sqlite, { schema }); + }); + + it('Path A — was revoked: rotates token, clears revokedAt, applies bumps', () => { + const adminId = seedAdmin(); + const inv = createInvite({ name: 'a', maxUses: 5, expiresAt: null }, adminId); + const originalToken = inv.token; + revokeInvite(inv.id); + + const result = reinstateInvite(inv.id, { maxUses: 10 }); + expect(result.tokenRotated).toBe(true); + expect(result.invite.token).not.toBe(originalToken); + expect(result.invite.token).toMatch(/^[A-Za-z0-9_-]{22}$/); + expect(result.invite.revokedAt).toBeNull(); + expect(result.invite.maxUses).toBe(10); + expect(result.invite.status).toBe('active'); + }); + + it('Path B — exhausted: keeps same token, applies maxUses bump', () => { + const adminId = seedAdmin(); + const inv = createInvite({ name: 'a', maxUses: 1, expiresAt: null }, adminId); + // Exhaust it + testDb.update(schema.inviteLinks).set({ usedCount: 1 }).where(eq(schema.inviteLinks.id, inv.id)).run(); + + const result = reinstateInvite(inv.id, { maxUses: 5 }); + expect(result.tokenRotated).toBe(false); + expect(result.invite.token).toBe(inv.token); + expect(result.invite.maxUses).toBe(5); + expect(result.invite.status).toBe('active'); + }); + + it('Path B — expired: keeps same token, bumps expiresAt', () => { + const adminId = seedAdmin(); + const inv = createInvite({ name: 'a', maxUses: null, expiresAt: Date.now() + 100_000 }, adminId); + // Move to past to expire + testDb.update(schema.inviteLinks).set({ expiresAt: Date.now() - 1000 }).where(eq(schema.inviteLinks.id, inv.id)).run(); + + const future = Date.now() + 86_400_000; + const result = reinstateInvite(inv.id, { expiresAt: future }); + expect(result.tokenRotated).toBe(false); + expect(result.invite.token).toBe(inv.token); + expect(result.invite.expiresAt).toBe(future); + expect(result.invite.status).toBe('active'); + }); + + it('Path C — already-active: throws InviteStateConflictError', () => { + const adminId = seedAdmin(); + const inv = createInvite({ name: 'a', maxUses: null, expiresAt: null }, adminId); + expect(() => reinstateInvite(inv.id, {})).toThrow(InviteStateConflictError); + }); + + it('throws InviteValidationError when result is still non-active (caller did not bump enough)', () => { + const adminId = seedAdmin(); + const inv = createInvite({ name: 'a', maxUses: 1, expiresAt: null }, adminId); + // Exhaust it + testDb.update(schema.inviteLinks).set({ usedCount: 1 }).where(eq(schema.inviteLinks.id, inv.id)).run(); + + // Caller did not bump maxUses → still exhausted → must throw + expect(() => reinstateInvite(inv.id, {})).toThrow(InviteValidationError); + + // Verify the txn rolled back: row still has maxUses=1 (no partial update) + const row = testDb.select().from(schema.inviteLinks).where(eq(schema.inviteLinks.id, inv.id)).get(); + expect(row?.maxUses).toBe(1); + }); +}); diff --git a/packages/server/src/utils/inviteService.ts b/packages/server/src/utils/inviteService.ts index fe3190e8..d318227f 100644 --- a/packages/server/src/utils/inviteService.ts +++ b/packages/server/src/utils/inviteService.ts @@ -3,7 +3,14 @@ import { eq, desc } from 'drizzle-orm'; import { getDb, schema } from '../db/index.js'; import { generateSnowflake } from './snowflake.js'; import { config } from '../config.js'; -import type { InviteLinkSummary, CreateInviteRequest, InviteRedemption, UpdateInviteRequest } from '@backspace/shared'; +import type { + InviteLinkSummary, + CreateInviteRequest, + InviteRedemption, + UpdateInviteRequest, + ReinstateInviteRequest, + ReinstateInviteResponse, +} from '@backspace/shared'; /** * Derived status of an invite link. Mirrors the `InviteStatus` union exported @@ -381,3 +388,76 @@ export function revokeInvite(id: string): InviteLinkSummary { return rowToSummary(updated, resolveCreatorUsername(updated.createdBy, tx)); }); } + +/** + * Reinstate a non-active invite back to `active`. Three branches per spec §3.1: + * + * - **Path A (revoked)**: rotates the token (security boundary — old shared + * links must stop working) and clears `revokedAt`. Caller may also bump + * `maxUses` / `expiresAt` in the same call. + * - **Path B (expired/exhausted)**: preserves the token. Caller MUST supply + * bumps that push the row back into derived `active` state, otherwise the + * txn rolls back with `InviteValidationError` (we never leave an invite + * half-reinstated, e.g. exhausted-and-still-exhausted with no token rotation + * and no state change). + * - **Path C (already active)**: rejected with `InviteStateConflictError` + * (mapped to 409). Reinstating an active invite is meaningless and would + * surprise an admin who clicked the wrong row. + * + * Wrapped in a SQLite transaction with an in-txn re-read so the post-update + * status check sees the row as the next reader would. If the post-state isn't + * `active`, the throw aborts the txn and the row reverts. + */ +export function reinstateInvite(id: string, req: ReinstateInviteRequest): ReinstateInviteResponse { + const db = getDb(); + return db.transaction((tx) => { + const row = tx.select().from(schema.inviteLinks).where(eq(schema.inviteLinks.id, id)).get(); + if (!row) throw new InviteNotFoundError(); + + const currentStatus = inviteStatus(row); + if (currentStatus === 'active') { + throw new InviteStateConflictError('Invite is already active'); + } + + const updates: Partial = {}; + let tokenRotated = false; + + if (currentStatus === 'revoked') { + updates.revokedAt = null; + updates.token = generateInviteToken(); + tokenRotated = true; + } + + if (req.maxUses !== undefined) { + const v = validateMaxUses(req.maxUses); + if (v !== null && v < row.usedCount) { + throw new InviteValidationError( + `maxUses (${v}) cannot be less than current usedCount (${row.usedCount})`, + ); + } + updates.maxUses = v; + } + if (req.expiresAt !== undefined) { + updates.expiresAt = validateExpiresAt(req.expiresAt, true); + } + + if (Object.keys(updates).length > 0) { + tx.update(schema.inviteLinks).set(updates).where(eq(schema.inviteLinks.id, id)).run(); + } + + const updated = tx.select().from(schema.inviteLinks).where(eq(schema.inviteLinks.id, id)).get(); + if (!updated) throw new Error('Failed to read updated invite'); + + if (inviteStatus(updated) !== 'active') { + // Caller did not bump enough — abort the txn so nothing is half-applied + throw new InviteValidationError( + 'Reinstate would leave invite in non-active state. Bump maxUses and/or expiresAt.', + ); + } + + return { + invite: rowToSummary(updated, resolveCreatorUsername(updated.createdBy, tx)), + tokenRotated, + }; + }); +} From b7557aa6fa2bf02f4422076ce6e4886a2ef22261 Mon Sep 17 00:00:00 2001 From: Jannis Braun <151788261+TheZwiss@users.noreply.github.com> Date: Tue, 28 Apr 2026 20:24:15 +0200 Subject: [PATCH 10/41] test(invites): document empty-updates guard + Path A rollback test Quality-review polish: a one-line comment over the empty-updates guard in reinstateInvite explains why removing it would re-leak a confusing Drizzle error. A new test verifies that when Path A (revoked->active) fails its post-state check, the original token is preserved by the SQLite transaction rollback (not replaced by the would-be new token). --- .../server/src/utils/inviteService.test.ts | 18 ++++++++++++++++++ packages/server/src/utils/inviteService.ts | 6 ++++++ 2 files changed, 24 insertions(+) diff --git a/packages/server/src/utils/inviteService.test.ts b/packages/server/src/utils/inviteService.test.ts index 4cd0e501..4ec5e7da 100644 --- a/packages/server/src/utils/inviteService.test.ts +++ b/packages/server/src/utils/inviteService.test.ts @@ -445,4 +445,22 @@ describe('reinstateInvite', () => { const row = testDb.select().from(schema.inviteLinks).where(eq(schema.inviteLinks.id, inv.id)).get(); expect(row?.maxUses).toBe(1); }); + + it('Path A — rolls back token rotation when caller did not bump enough', () => { + const adminId = seedAdmin(); + const inv = createInvite({ name: 'a', maxUses: 1, expiresAt: null }, adminId); + const originalToken = inv.token; + // Exhaust then revoke + testDb.update(schema.inviteLinks).set({ usedCount: 1 }).where(eq(schema.inviteLinks.id, inv.id)).run(); + revokeInvite(inv.id); + + // Try to reinstate without bumping maxUses — would-be Path A but post-state check rejects + expect(() => reinstateInvite(inv.id, {})).toThrow(InviteValidationError); + + // Verify rollback: original token preserved, revokedAt still set, usedCount still at limit + const row = testDb.select().from(schema.inviteLinks).where(eq(schema.inviteLinks.id, inv.id)).get(); + expect(row?.token).toBe(originalToken); + expect(row?.revokedAt).not.toBeNull(); + expect(row?.usedCount).toBe(1); + }); }); diff --git a/packages/server/src/utils/inviteService.ts b/packages/server/src/utils/inviteService.ts index d318227f..c068f795 100644 --- a/packages/server/src/utils/inviteService.ts +++ b/packages/server/src/utils/inviteService.ts @@ -441,6 +441,12 @@ export function reinstateInvite(id: string, req: ReinstateInviteRequest): Reinst updates.expiresAt = validateExpiresAt(req.expiresAt, true); } + // Skip the UPDATE entirely when there's nothing to set — Drizzle throws + // 'No values to set' before our post-state validator can produce the + // user-facing InviteValidationError. Path C (already active) handles its + // rejection above, so an empty updates map only reaches here when the + // caller didn't provide bumps for an expired/exhausted invite — the + // post-state check below will throw the correct error in that case. if (Object.keys(updates).length > 0) { tx.update(schema.inviteLinks).set(updates).where(eq(schema.inviteLinks.id, id)).run(); } From 95737ba405484e6efb3911e5beab7c12083586a2 Mon Sep 17 00:00:00 2001 From: Jannis Braun <151788261+TheZwiss@users.noreply.github.com> Date: Tue, 28 Apr 2026 20:26:57 +0200 Subject: [PATCH 11/41] feat(invites): redeemInvite (atomic txn) + deleteInvite --- .../server/src/utils/inviteService.test.ts | 143 ++++++++++++++++++ packages/server/src/utils/inviteService.ts | 90 +++++++++++ 2 files changed, 233 insertions(+) diff --git a/packages/server/src/utils/inviteService.test.ts b/packages/server/src/utils/inviteService.test.ts index 4ec5e7da..569a98b6 100644 --- a/packages/server/src/utils/inviteService.test.ts +++ b/packages/server/src/utils/inviteService.test.ts @@ -24,6 +24,7 @@ vi.mock('../db/index.js', () => ({ import { inviteStatus, generateInviteToken, createInvite, getInviteByToken, listInvites, listRedemptions, InviteValidationError } from './inviteService.js'; import { patchInvite, revokeInvite, InviteStateConflictError, InviteNotFoundError } from './inviteService.js'; import { reinstateInvite } from './inviteService.js'; +import { redeemInvite, deleteInvite, InviteUnavailableError } from './inviteService.js'; import { eq } from 'drizzle-orm'; function applyMigrations(db: Database.Database): void { @@ -464,3 +465,145 @@ describe('reinstateInvite', () => { expect(row?.usedCount).toBe(1); }); }); + +describe('redeemInvite', () => { + beforeEach(() => { + sqlite = new Database(':memory:'); + sqlite.pragma('foreign_keys = ON'); + applyMigrations(sqlite); + testDb = drizzle(sqlite, { schema }); + }); + + it('happy path: increments usedCount, writes redemption row, calls insertUser callback', () => { + const adminId = seedAdmin(); + const inv = createInvite({ name: 'a', maxUses: 5, expiresAt: null }, adminId); + const newUserId = 'new-user-1'; + const newUsername = 'alice'; + + let insertCalled = false; + const result = redeemInvite(inv.token, () => { + insertCalled = true; + // Caller's INSERT — uses the outer testDb handle (mocked getDb), which + // is correctly serialized into the same logical txn by better-sqlite3. + testDb.insert(schema.users).values({ + id: newUserId, + username: newUsername, + passwordHash: 'x', + createdAt: Date.now(), + }).run(); + return { id: newUserId, username: newUsername }; + }); + + expect(insertCalled).toBe(true); + expect(result.id).toBe(newUserId); + expect(result.username).toBe(newUsername); + + // user row exists + const user = testDb.select().from(schema.users).where(eq(schema.users.id, newUserId)).get(); + expect(user?.username).toBe(newUsername); + + // usedCount incremented + const row = testDb.select().from(schema.inviteLinks).where(eq(schema.inviteLinks.id, inv.id)).get(); + expect(row?.usedCount).toBe(1); + + // redemption row written + const redemptions = testDb.select().from(schema.inviteRedemptions).where(eq(schema.inviteRedemptions.inviteId, inv.id)).all(); + expect(redemptions).toHaveLength(1); + expect(redemptions[0]?.userId).toBe(newUserId); + expect(redemptions[0]?.registrantUsername).toBe(newUsername); + }); + + it('throws InviteUnavailableError when status is not active under txn (exhausted)', () => { + const adminId = seedAdmin(); + const inv = createInvite({ name: 'a', maxUses: 1, expiresAt: null }, adminId); + // Simulate "another concurrent registration consumed the last slot just before this call" + testDb.update(schema.inviteLinks).set({ usedCount: 1 }).where(eq(schema.inviteLinks.id, inv.id)).run(); + + let insertCalled = false; + expect(() => redeemInvite(inv.token, () => { + insertCalled = true; + return { id: 'x', username: 'x' }; + })).toThrow(InviteUnavailableError); + + expect(insertCalled).toBe(false); + // No redemption row + const redemptions = testDb.select().from(schema.inviteRedemptions).where(eq(schema.inviteRedemptions.inviteId, inv.id)).all(); + expect(redemptions).toHaveLength(0); + }); + + it('aborts transaction (no usedCount increment, no redemption row) if insertUser throws', () => { + const adminId = seedAdmin(); + const inv = createInvite({ name: 'a', maxUses: 5, expiresAt: null }, adminId); + + expect(() => redeemInvite(inv.token, () => { + throw new Error('username collision'); + })).toThrow('username collision'); + + // usedCount unchanged + const row = testDb.select().from(schema.inviteLinks).where(eq(schema.inviteLinks.id, inv.id)).get(); + expect(row?.usedCount).toBe(0); + + // No redemption row + const redemptions = testDb.select().from(schema.inviteRedemptions).where(eq(schema.inviteRedemptions.inviteId, inv.id)).all(); + expect(redemptions).toHaveLength(0); + }); + + it('throws InviteUnavailableError when token not found', () => { + expect(() => redeemInvite('aaaaaaaaaaaaaaaaaaaaaa', () => ({ id: 'x', username: 'x' }))).toThrow(InviteUnavailableError); + }); + + it('throws InviteUnavailableError on revoked invite', () => { + const adminId = seedAdmin(); + const inv = createInvite({ name: 'a', maxUses: null, expiresAt: null }, adminId); + revokeInvite(inv.id); + + let insertCalled = false; + expect(() => redeemInvite(inv.token, () => { + insertCalled = true; + return { id: 'x', username: 'x' }; + })).toThrow(InviteUnavailableError); + + expect(insertCalled).toBe(false); + }); +}); + +describe('deleteInvite', () => { + beforeEach(() => { + sqlite = new Database(':memory:'); + sqlite.pragma('foreign_keys = ON'); + applyMigrations(sqlite); + testDb = drizzle(sqlite, { schema }); + }); + + it('deletes the invite and CASCADE-removes redemption rows', () => { + const adminId = seedAdmin(); + const inv = createInvite({ name: 'a', maxUses: null, expiresAt: null }, adminId); + + // Seed a redemption referencing this invite + const userId = 'redeemer-1'; + testDb.insert(schema.users).values({ + id: userId, username: 'redeemer', passwordHash: 'x', createdAt: Date.now(), + }).run(); + testDb.insert(schema.inviteRedemptions).values({ + id: 'red-del-1', + inviteId: inv.id, + userId, + registrantUsername: 'redeemer', + redeemedAt: Date.now(), + }).run(); + + deleteInvite(inv.id); + + // Invite row gone + const inviteRow = testDb.select().from(schema.inviteLinks).where(eq(schema.inviteLinks.id, inv.id)).get(); + expect(inviteRow).toBeUndefined(); + + // Redemption row CASCADE-deleted + const redemptions = testDb.select().from(schema.inviteRedemptions).where(eq(schema.inviteRedemptions.inviteId, inv.id)).all(); + expect(redemptions).toHaveLength(0); + }); + + it('throws InviteNotFoundError when id not found', () => { + expect(() => deleteInvite('nonexistent')).toThrow(InviteNotFoundError); + }); +}); diff --git a/packages/server/src/utils/inviteService.ts b/packages/server/src/utils/inviteService.ts index c068f795..3ef0541c 100644 --- a/packages/server/src/utils/inviteService.ts +++ b/packages/server/src/utils/inviteService.ts @@ -467,3 +467,93 @@ export function reinstateInvite(id: string, req: ReinstateInviteRequest): Reinst }; }); } + +/** + * Thrown when an invite cannot be redeemed because its current state forbids + * it (token not found, revoked, expired, exhausted). Caller (HTTP register + * route) maps this to 403 Forbidden. The reason string is preserved in the + * message for debugging — the user-facing copy is constructed by the route. + */ +export class InviteUnavailableError extends Error { + constructor(reason: string) { + super(`Invite unavailable: ${reason}`); + this.name = 'InviteUnavailableError'; + } +} + +/** + * Result returned by the `insertUser` callback to `redeemInvite`. Captures + * just the fields needed to write the redemption row (id for the FK, + * username for the forensic snapshot in `registrant_username`). + */ +export interface RedemptionUserResult { + id: string; + username: string; +} + +/** + * Atomically redeem an invite token. The caller-supplied `insertUser` callback + * runs inside the same SQLite transaction as the usedCount increment + redemption + * insert. If insertUser throws, the entire transaction rolls back — the invite + * is NOT consumed for failed registrations (e.g. username uniqueness collisions). + * + * Re-derives status under the transaction to close the TOCTOU window between + * `/api/auth/check-invite` (which the client may call seconds before submit) + * and the actual register POST: another user could have consumed the last slot + * in between. Re-checking inside the txn ensures the slot we increment is the + * one we observed available. + */ +export function redeemInvite( + token: string, + insertUser: () => RedemptionUserResult, +): RedemptionUserResult { + const db = getDb(); + return db.transaction((tx) => { + const row = tx.select().from(schema.inviteLinks).where(eq(schema.inviteLinks.token, token)).get(); + if (!row) throw new InviteUnavailableError('not found'); + const status = inviteStatus(row); + if (status !== 'active') { + throw new InviteUnavailableError(status); + } + + // The insertUser callback runs inside the transaction. The caller's INSERT + // statement uses the outer `db` connection, but better-sqlite3 serializes + // all writes regardless of which Drizzle handle issued them, so the user + // insert joins the same atomic unit. If insertUser throws, the entire + // transaction rolls back including the usedCount bump and redemption row. + const userResult = insertUser(); + + tx.update(schema.inviteLinks) + .set({ usedCount: row.usedCount + 1 }) + .where(eq(schema.inviteLinks.id, row.id)) + .run(); + + tx.insert(schema.inviteRedemptions).values({ + id: generateSnowflake(), + inviteId: row.id, + userId: userResult.id, + registrantUsername: userResult.username, + redeemedAt: Date.now(), + }).run(); + + return userResult; + }); +} + +/** + * Permanently delete an invite. Redemption rows for this invite are removed + * via `ON DELETE CASCADE` on `invite_redemptions.invite_id` — this is the + * documented destructive intent of "delete the invite and its history". + * + * No transaction needed: deleteInvite has no read-modify-write state semantics + * that other concurrent mutators would race against. The existence check is + * for the 404 response only; if a concurrent process deletes the row between + * the SELECT and the DELETE, the DELETE is a harmless no-op and the caller + * still observes the row gone afterwards. + */ +export function deleteInvite(id: string): void { + const db = getDb(); + const row = db.select().from(schema.inviteLinks).where(eq(schema.inviteLinks.id, id)).get(); + if (!row) throw new InviteNotFoundError(); + db.delete(schema.inviteLinks).where(eq(schema.inviteLinks.id, id)).run(); +} From b775e3bcc9d59a33d37ddeeefb0eba704824374d Mon Sep 17 00:00:00 2001 From: Jannis Braun <151788261+TheZwiss@users.noreply.github.com> Date: Tue, 28 Apr 2026 20:32:42 +0200 Subject: [PATCH 12/41] refactor(invites): typed InviteUnavailableReason + real rollback test MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Quality-review polish on Task 8: 1. InviteUnavailableError gains a typed public readonly `reason` field (the union 'not found' | 'revoked' | 'expired' | 'exhausted'). Task 11 route handler can switch on the discriminant to produce user-facing copy without parsing the message string. 2. The original "aborts transaction if insertUser throws" test was vacuous — the callback threw before any DB write, so SQLite ROLLBACK never fired and the post-conditions were trivially true. Replaced with two tests: one that explicitly validates the synchronous short-circuit (no DB work happens at all), and a second that writes a real users row inside the callback then throws AFTER the write, proving the SQLite ROLLBACK actually reverts the in-callback write. --- .../server/src/utils/inviteService.test.ts | 34 +++++++++++++++++-- packages/server/src/utils/inviteService.ts | 18 ++++++++-- 2 files changed, 46 insertions(+), 6 deletions(-) diff --git a/packages/server/src/utils/inviteService.test.ts b/packages/server/src/utils/inviteService.test.ts index 569a98b6..b121bf16 100644 --- a/packages/server/src/utils/inviteService.test.ts +++ b/packages/server/src/utils/inviteService.test.ts @@ -531,13 +531,13 @@ describe('redeemInvite', () => { expect(redemptions).toHaveLength(0); }); - it('aborts transaction (no usedCount increment, no redemption row) if insertUser throws', () => { + it('does not bump usedCount or write a redemption when insertUser throws synchronously', () => { const adminId = seedAdmin(); const inv = createInvite({ name: 'a', maxUses: 5, expiresAt: null }, adminId); expect(() => redeemInvite(inv.token, () => { - throw new Error('username collision'); - })).toThrow('username collision'); + throw new Error('username taken'); + })).toThrow('username taken'); // usedCount unchanged const row = testDb.select().from(schema.inviteLinks).where(eq(schema.inviteLinks.id, inv.id)).get(); @@ -548,6 +548,34 @@ describe('redeemInvite', () => { expect(redemptions).toHaveLength(0); }); + it('rolls back insertUser writes when a later step throws (true SQLite ROLLBACK)', () => { + const adminId = seedAdmin(); + const inv = createInvite({ name: 'a', maxUses: 5, expiresAt: null }, adminId); + const newUserId = 'rollback-user-1'; + + // The callback writes a real users row, then throws AFTER the write. + // If the txn truly rolls back, the users row must not exist after the call. + expect(() => redeemInvite(inv.token, () => { + testDb.insert(schema.users).values({ + id: newUserId, + username: 'will-be-rolled-back', + passwordHash: 'x', + createdAt: Date.now(), + }).run(); + throw new Error('post-insert failure'); + })).toThrow('post-insert failure'); + + // Proves SQLite ROLLBACK reverted the user insert AND the would-be usedCount bump + const u = testDb.select().from(schema.users).where(eq(schema.users.id, newUserId)).get(); + expect(u).toBeUndefined(); + + const after = testDb.select().from(schema.inviteLinks).where(eq(schema.inviteLinks.id, inv.id)).get(); + expect(after?.usedCount).toBe(0); + + const redemptions = testDb.select().from(schema.inviteRedemptions).where(eq(schema.inviteRedemptions.inviteId, inv.id)).all(); + expect(redemptions).toHaveLength(0); + }); + it('throws InviteUnavailableError when token not found', () => { expect(() => redeemInvite('aaaaaaaaaaaaaaaaaaaaaa', () => ({ id: 'x', username: 'x' }))).toThrow(InviteUnavailableError); }); diff --git a/packages/server/src/utils/inviteService.ts b/packages/server/src/utils/inviteService.ts index 3ef0541c..6130a800 100644 --- a/packages/server/src/utils/inviteService.ts +++ b/packages/server/src/utils/inviteService.ts @@ -468,14 +468,23 @@ export function reinstateInvite(id: string, req: ReinstateInviteRequest): Reinst }); } +/** + * Discriminant union of reasons an invite cannot be redeemed. Surfaced as a + * typed public field on `InviteUnavailableError` so the HTTP register route + * can switch on it to produce user-facing copy without parsing the message + * string. Mirrors the non-active subset of `InviteStatus` plus `'not found'` + * for the missing-token case. + */ +export type InviteUnavailableReason = 'not found' | 'revoked' | 'expired' | 'exhausted'; + /** * Thrown when an invite cannot be redeemed because its current state forbids * it (token not found, revoked, expired, exhausted). Caller (HTTP register - * route) maps this to 403 Forbidden. The reason string is preserved in the - * message for debugging — the user-facing copy is constructed by the route. + * route) maps this to 403 Forbidden. The `reason` field is the structured + * discriminant; the message string is preserved for debugging/logging. */ export class InviteUnavailableError extends Error { - constructor(reason: string) { + constructor(public readonly reason: InviteUnavailableReason) { super(`Invite unavailable: ${reason}`); this.name = 'InviteUnavailableError'; } @@ -513,6 +522,9 @@ export function redeemInvite( if (!row) throw new InviteUnavailableError('not found'); const status = inviteStatus(row); if (status !== 'active') { + // 'active' is excluded by the guard above, so `status` is necessarily + // one of 'revoked' | 'expired' | 'exhausted' — all valid + // InviteUnavailableReason values. TS narrows the union here. throw new InviteUnavailableError(status); } From 0c9865ad90874007579e53485fe617237ac78645 Mon Sep 17 00:00:00 2001 From: Jannis Braun <151788261+TheZwiss@users.noreply.github.com> Date: Tue, 28 Apr 2026 20:36:09 +0200 Subject: [PATCH 13/41] feat(invites): /api/admin/invites CRUD endpoints --- packages/server/src/index.ts | 2 + packages/server/src/routes/invites.test.ts | 357 +++++++++++++++++++++ packages/server/src/routes/invites.ts | 137 ++++++++ 3 files changed, 496 insertions(+) create mode 100644 packages/server/src/routes/invites.test.ts create mode 100644 packages/server/src/routes/invites.ts diff --git a/packages/server/src/index.ts b/packages/server/src/index.ts index 2105840a..2c1d566a 100644 --- a/packages/server/src/index.ts +++ b/packages/server/src/index.ts @@ -20,6 +20,7 @@ import { socialRoutes } from './routes/social.js'; import { settingsRoutes } from './routes/settings.js'; import { utilRoutes } from './routes/utils.js'; import { instanceRoutes } from './routes/instance.js'; +import { invitesRoutes } from './routes/invites.js'; import { exploreRoutes } from './routes/explore.js'; import { searchRoutes } from './routes/search.js'; import { adminRoutes } from './routes/admin.js'; @@ -103,6 +104,7 @@ async function main(): Promise { await app.register(settingsRoutes); await app.register(utilRoutes); await app.register(instanceRoutes); + await app.register(invitesRoutes); await app.register(exploreRoutes); await app.register(searchRoutes); await app.register(adminRoutes); diff --git a/packages/server/src/routes/invites.test.ts b/packages/server/src/routes/invites.test.ts new file mode 100644 index 00000000..cf2c6b5b --- /dev/null +++ b/packages/server/src/routes/invites.test.ts @@ -0,0 +1,357 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import Fastify, { type FastifyInstance } from 'fastify'; +import Database from 'better-sqlite3'; +import { drizzle } from 'drizzle-orm/better-sqlite3'; +import { eq } from 'drizzle-orm'; +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import * as schema from '../db/schema.js'; +import { setWorkerId } from '../utils/snowflake.js'; + +setWorkerId(1); +const __dirname = path.dirname(fileURLToPath(import.meta.url)); + +// Module-level mutable state. Each describe's beforeEach reassigns +// `sqlite`/`testDb`/`app`; the `getDb: () => testDb` getter in the mock +// closes over the current binding, so reassignment is observed. The +// `callerIsAdmin` flag lets a test flip the admin guard to verify 403s. +type TestDb = ReturnType>; +let sqlite: Database.Database; +let testDb: TestDb; +let app: FastifyInstance; +let callerIsAdmin = true; +const CALLER_ID = 'admin-1'; + +vi.mock('../db/index.js', () => ({ + getDb: () => testDb, + getRawDb: () => sqlite, + schema, +})); + +vi.mock('../utils/auth.js', () => ({ + authenticate: async (req: { userId?: string }) => { + req.userId = CALLER_ID; + }, + requireAdmin: async ( + _req: { userId?: string }, + reply: { code: (n: number) => { send: (b: unknown) => void } }, + ) => { + if (!callerIsAdmin) { + reply.code(403).send({ error: 'Admin required', statusCode: 403 }); + } + }, +})); + +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); + } + } +} + +async function buildApp(): Promise { + const { invitesRoutes } = await import('./invites.js'); + const f = Fastify(); + await f.register(invitesRoutes); + return f; +} + +beforeEach(async () => { + sqlite = new Database(':memory:'); + sqlite.pragma('foreign_keys = ON'); + applyMigrations(sqlite); + testDb = drizzle(sqlite, { schema }); + callerIsAdmin = true; + + testDb.insert(schema.users).values({ + id: CALLER_ID, + username: 'admin', + passwordHash: 'x', + isAdmin: 1, + createdAt: Date.now(), + }).run(); + + app = await buildApp(); +}); + +describe('POST /api/admin/invites', () => { + it('creates an invite and returns summary', async () => { + const res = await app.inject({ + method: 'POST', + url: '/api/admin/invites', + payload: { name: 'Friends', maxUses: 10, expiresAt: Date.now() + 86_400_000 }, + }); + expect(res.statusCode).toBe(201); + const body = res.json(); + expect(body.name).toBe('Friends'); + expect(body.token).toMatch(/^[A-Za-z0-9_-]{22}$/); + expect(body.status).toBe('active'); + expect(body.url).toContain('/register?invite='); + }); + + it('rejects empty name with 400', async () => { + const res = await app.inject({ + method: 'POST', + url: '/api/admin/invites', + payload: { name: '', maxUses: null, expiresAt: null }, + }); + expect(res.statusCode).toBe(400); + }); + + it('returns 403 for non-admin', async () => { + callerIsAdmin = false; + const res = await app.inject({ + method: 'POST', + url: '/api/admin/invites', + payload: { name: 'x', maxUses: null, expiresAt: null }, + }); + expect(res.statusCode).toBe(403); + }); +}); + +describe('GET /api/admin/invites', () => { + it('returns active invites by default', async () => { + await app.inject({ + method: 'POST', + url: '/api/admin/invites', + payload: { name: 'a', maxUses: null, expiresAt: null }, + }); + const res = await app.inject({ method: 'GET', url: '/api/admin/invites' }); + expect(res.statusCode).toBe(200); + const body = res.json(); + expect(body.invites).toHaveLength(1); + expect(body.invites[0].name).toBe('a'); + }); + + it('returns archived invites with status=archived', async () => { + const created = await app.inject({ + method: 'POST', + url: '/api/admin/invites', + payload: { name: 'a', maxUses: null, expiresAt: null }, + }); + const id = created.json().id; + await app.inject({ method: 'POST', url: `/api/admin/invites/${id}/revoke` }); + + const active = await app.inject({ method: 'GET', url: '/api/admin/invites?status=active' }); + expect(active.json().invites).toHaveLength(0); + + const archived = await app.inject({ method: 'GET', url: '/api/admin/invites?status=archived' }); + expect(archived.json().invites).toHaveLength(1); + expect(archived.json().invites[0].status).toBe('revoked'); + }); + + it('createdByUsername surfaces "Deleted User" after admin tombstone', async () => { + await app.inject({ + method: 'POST', + url: '/api/admin/invites', + payload: { name: 'a', maxUses: null, expiresAt: null }, + }); + // Tombstone the admin who created the invite. The list endpoint uses a + // LEFT JOIN against users; foldUsername should map isDeleted=1 to + // 'Deleted User'. This exercises the JOIN-based code path, not the + // per-row resolveCreatorUsername fallback. + testDb.update(schema.users) + .set({ isDeleted: 1, username: '!deleted:' + CALLER_ID }) + .where(eq(schema.users.id, CALLER_ID)) + .run(); + + const res = await app.inject({ method: 'GET', url: '/api/admin/invites' }); + expect(res.json().invites[0].createdByUsername).toBe('Deleted User'); + }); +}); + +describe('PATCH /api/admin/invites/:id', () => { + it('updates name', async () => { + const created = await app.inject({ + method: 'POST', + url: '/api/admin/invites', + payload: { name: 'old', maxUses: null, expiresAt: null }, + }); + const id = created.json().id; + const res = await app.inject({ + method: 'PATCH', + url: `/api/admin/invites/${id}`, + payload: { name: 'new' }, + }); + expect(res.statusCode).toBe(200); + expect(res.json().name).toBe('new'); + }); + + it('returns 409 for revoked invite', async () => { + const created = await app.inject({ + method: 'POST', + url: '/api/admin/invites', + payload: { name: 'a', maxUses: null, expiresAt: null }, + }); + const id = created.json().id; + await app.inject({ method: 'POST', url: `/api/admin/invites/${id}/revoke` }); + const res = await app.inject({ + method: 'PATCH', + url: `/api/admin/invites/${id}`, + payload: { name: 'x' }, + }); + expect(res.statusCode).toBe(409); + }); + + it('returns 404 for missing id', async () => { + const res = await app.inject({ + method: 'PATCH', + url: '/api/admin/invites/nope', + payload: { name: 'x' }, + }); + expect(res.statusCode).toBe(404); + }); +}); + +describe('POST /api/admin/invites/:id/revoke', () => { + it('revokes and returns summary', async () => { + const created = await app.inject({ + method: 'POST', + url: '/api/admin/invites', + payload: { name: 'a', maxUses: null, expiresAt: null }, + }); + const id = created.json().id; + const res = await app.inject({ method: 'POST', url: `/api/admin/invites/${id}/revoke` }); + expect(res.statusCode).toBe(200); + expect(res.json().invite.status).toBe('revoked'); + }); + + it('returns 409 on already-revoked', async () => { + const created = await app.inject({ + method: 'POST', + url: '/api/admin/invites', + payload: { name: 'a', maxUses: null, expiresAt: null }, + }); + const id = created.json().id; + await app.inject({ method: 'POST', url: `/api/admin/invites/${id}/revoke` }); + const res = await app.inject({ method: 'POST', url: `/api/admin/invites/${id}/revoke` }); + expect(res.statusCode).toBe(409); + }); +}); + +describe('POST /api/admin/invites/:id/reinstate', () => { + it('rotates token on revoked path', async () => { + const created = await app.inject({ + method: 'POST', + url: '/api/admin/invites', + payload: { name: 'a', maxUses: 1, expiresAt: null }, + }); + const id = created.json().id; + const originalToken = created.json().token; + await app.inject({ method: 'POST', url: `/api/admin/invites/${id}/revoke` }); + + const res = await app.inject({ + method: 'POST', + url: `/api/admin/invites/${id}/reinstate`, + payload: { maxUses: 5 }, + }); + expect(res.statusCode).toBe(200); + const body = res.json(); + expect(body.tokenRotated).toBe(true); + expect(body.invite.token).not.toBe(originalToken); + expect(body.invite.status).toBe('active'); + }); + + it('preserves token on exhausted path', async () => { + const created = await app.inject({ + method: 'POST', + url: '/api/admin/invites', + payload: { name: 'a', maxUses: 1, expiresAt: null }, + }); + const id = created.json().id; + const originalToken = created.json().token; + // Force the row to exhausted by bumping usedCount to maxUses. + testDb.update(schema.inviteLinks) + .set({ usedCount: 1 }) + .where(eq(schema.inviteLinks.id, id)) + .run(); + + const res = await app.inject({ + method: 'POST', + url: `/api/admin/invites/${id}/reinstate`, + payload: { maxUses: 5 }, + }); + expect(res.statusCode).toBe(200); + expect(res.json().tokenRotated).toBe(false); + expect(res.json().invite.token).toBe(originalToken); + }); + + it('returns 409 on already-active', async () => { + const created = await app.inject({ + method: 'POST', + url: '/api/admin/invites', + payload: { name: 'a', maxUses: null, expiresAt: null }, + }); + const res = await app.inject({ + method: 'POST', + url: `/api/admin/invites/${created.json().id}/reinstate`, + payload: { maxUses: 100 }, + }); + expect(res.statusCode).toBe(409); + }); +}); + +describe('DELETE /api/admin/invites/:id', () => { + it('hard-deletes and cascades redemptions', async () => { + const created = await app.inject({ + method: 'POST', + url: '/api/admin/invites', + payload: { name: 'a', maxUses: null, expiresAt: null }, + }); + const id = created.json().id; + testDb.insert(schema.inviteRedemptions).values({ + id: 'r1', + inviteId: id, + userId: null, + registrantUsername: 'g', + redeemedAt: Date.now(), + }).run(); + + const res = await app.inject({ method: 'DELETE', url: `/api/admin/invites/${id}` }); + expect(res.statusCode).toBe(200); + expect(res.json().success).toBe(true); + expect( + testDb.select().from(schema.inviteLinks).where(eq(schema.inviteLinks.id, id)).get(), + ).toBeUndefined(); + expect( + testDb.select().from(schema.inviteRedemptions).where(eq(schema.inviteRedemptions.inviteId, id)).all(), + ).toHaveLength(0); + }); +}); + +describe('GET /api/admin/invites/:id/redemptions', () => { + it('returns redemption list', async () => { + const created = await app.inject({ + method: 'POST', + url: '/api/admin/invites', + payload: { name: 'a', maxUses: null, expiresAt: null }, + }); + const id = created.json().id; + testDb.insert(schema.users).values({ + id: 'u1', + username: 'alice', + passwordHash: 'x', + createdAt: Date.now(), + }).run(); + testDb.insert(schema.inviteRedemptions).values({ + id: 'r1', + inviteId: id, + userId: 'u1', + registrantUsername: 'alice', + redeemedAt: Date.now(), + }).run(); + + const res = await app.inject({ method: 'GET', url: `/api/admin/invites/${id}/redemptions` }); + expect(res.statusCode).toBe(200); + expect(res.json().redemptions).toHaveLength(1); + expect(res.json().redemptions[0].registrantUsername).toBe('alice'); + expect(res.json().redemptions[0].currentUsername).toBe('alice'); + }); +}); diff --git a/packages/server/src/routes/invites.ts b/packages/server/src/routes/invites.ts new file mode 100644 index 00000000..0c47b9ea --- /dev/null +++ b/packages/server/src/routes/invites.ts @@ -0,0 +1,137 @@ +import type { FastifyInstance } from 'fastify'; +import { authenticate, requireAdmin } from '../utils/auth.js'; +import { + createInvite, + listInvites, + patchInvite, + revokeInvite, + reinstateInvite, + deleteInvite, + listRedemptions, + InviteNotFoundError, + InviteStateConflictError, + InviteValidationError, +} from '../utils/inviteService.js'; +import type { + CreateInviteRequest, + UpdateInviteRequest, + ReinstateInviteRequest, +} from '@backspace/shared'; + +/** + * Admin CRUD routes for invite links. All endpoints sit behind the + * `[authenticate, requireAdmin]` preHandler chain — invites are an + * instance-local moderation surface and never reach federation. + * + * The handlers are thin wrappers: they parse the request shape, delegate + * to `inviteService` (which owns validation, transactions, and status + * derivation), and translate the typed service errors into HTTP statuses: + * + * - InviteValidationError → 400 Bad Request + * - InviteNotFoundError → 404 Not Found + * - InviteStateConflictError → 409 Conflict + * - anything else → propagates to Fastify's 500 handler + * + * Response shapes mirror spec §3.1 — see `docs/superpowers/specs/2026-04-28-registration-invites-design.md`. + */ +export async function invitesRoutes(app: FastifyInstance): Promise { + app.post<{ Body: CreateInviteRequest }>('/api/admin/invites', { + preHandler: [authenticate, requireAdmin], + }, async (request, reply) => { + try { + const invite = createInvite(request.body, request.userId); + return reply.code(201).send(invite); + } catch (err) { + if (err instanceof InviteValidationError) { + return reply.code(400).send({ error: err.message, statusCode: 400 }); + } + throw err; + } + }); + + app.get<{ Querystring: { status?: string } }>('/api/admin/invites', { + preHandler: [authenticate, requireAdmin], + }, async (request, reply) => { + const status = request.query.status === 'archived' ? 'archived' : 'active'; + const invites = listInvites(status); + return reply.code(200).send({ invites }); + }); + + app.patch<{ Params: { id: string }; Body: UpdateInviteRequest }>('/api/admin/invites/:id', { + preHandler: [authenticate, requireAdmin], + }, async (request, reply) => { + try { + const invite = patchInvite(request.params.id, request.body ?? {}); + return reply.code(200).send(invite); + } catch (err) { + if (err instanceof InviteNotFoundError) { + return reply.code(404).send({ error: err.message, statusCode: 404 }); + } + if (err instanceof InviteStateConflictError) { + return reply.code(409).send({ error: err.message, statusCode: 409 }); + } + if (err instanceof InviteValidationError) { + return reply.code(400).send({ error: err.message, statusCode: 400 }); + } + throw err; + } + }); + + app.post<{ Params: { id: string } }>('/api/admin/invites/:id/revoke', { + preHandler: [authenticate, requireAdmin], + }, async (request, reply) => { + try { + const invite = revokeInvite(request.params.id); + return reply.code(200).send({ invite }); + } catch (err) { + if (err instanceof InviteNotFoundError) { + return reply.code(404).send({ error: err.message, statusCode: 404 }); + } + if (err instanceof InviteStateConflictError) { + return reply.code(409).send({ error: err.message, statusCode: 409 }); + } + throw err; + } + }); + + app.post<{ Params: { id: string }; Body: ReinstateInviteRequest }>('/api/admin/invites/:id/reinstate', { + preHandler: [authenticate, requireAdmin], + }, async (request, reply) => { + try { + const result = reinstateInvite(request.params.id, request.body ?? {}); + return reply.code(200).send(result); + } catch (err) { + if (err instanceof InviteNotFoundError) { + return reply.code(404).send({ error: err.message, statusCode: 404 }); + } + if (err instanceof InviteStateConflictError) { + return reply.code(409).send({ error: err.message, statusCode: 409 }); + } + if (err instanceof InviteValidationError) { + return reply.code(400).send({ error: err.message, statusCode: 400 }); + } + throw err; + } + }); + + app.delete<{ Params: { id: string } }>('/api/admin/invites/:id', { + preHandler: [authenticate, requireAdmin], + }, async (request, reply) => { + try { + deleteInvite(request.params.id); + return reply.code(200).send({ success: true }); + } catch (err) { + if (err instanceof InviteNotFoundError) { + return reply.code(404).send({ error: err.message, statusCode: 404 }); + } + throw err; + } + }); + + app.get<{ Params: { id: string } }>('/api/admin/invites/:id/redemptions', { + preHandler: [authenticate, requireAdmin], + }, async (request, reply) => { + const redemptions = listRedemptions(request.params.id); + return reply.code(200).send({ redemptions }); + }); +} From ade5c0d99834ac49c30499c12955f72251d46c5c Mon Sep 17 00:00:00 2001 From: Jannis Braun <151788261+TheZwiss@users.noreply.github.com> Date: Tue, 28 Apr 2026 20:40:24 +0200 Subject: [PATCH 14/41] test(invites): tighten requireAdmin mock to match real Fastify contract --- packages/server/src/routes/invites.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/server/src/routes/invites.test.ts b/packages/server/src/routes/invites.test.ts index cf2c6b5b..4e5a086a 100644 --- a/packages/server/src/routes/invites.test.ts +++ b/packages/server/src/routes/invites.test.ts @@ -35,10 +35,10 @@ vi.mock('../utils/auth.js', () => ({ }, requireAdmin: async ( _req: { userId?: string }, - reply: { code: (n: number) => { send: (b: unknown) => void } }, + reply: { code: (n: number) => { send: (b: unknown) => unknown } }, ) => { if (!callerIsAdmin) { - reply.code(403).send({ error: 'Admin required', statusCode: 403 }); + return reply.code(403).send({ error: 'Admin required', statusCode: 403 }); } }, })); From effb36c7f4bf6975e37f0dfdcdd6c250f9b5f4f0 Mon Sep 17 00:00:00 2001 From: Jannis Braun <151788261+TheZwiss@users.noreply.github.com> Date: Tue, 28 Apr 2026 20:42:46 +0200 Subject: [PATCH 15/41] feat(auth): GET /api/auth/check-invite with collapsed enumeration shield --- packages/server/src/routes/auth.test.ts | 212 ++++++++++++++++++++++++ packages/server/src/routes/auth.ts | 43 +++++ 2 files changed, 255 insertions(+) create mode 100644 packages/server/src/routes/auth.test.ts diff --git a/packages/server/src/routes/auth.test.ts b/packages/server/src/routes/auth.test.ts new file mode 100644 index 00000000..05f31bd5 --- /dev/null +++ b/packages/server/src/routes/auth.test.ts @@ -0,0 +1,212 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import Fastify, { type FastifyInstance } from 'fastify'; +import Database from 'better-sqlite3'; +import { drizzle } from 'drizzle-orm/better-sqlite3'; +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import * as schema from '../db/schema.js'; +import { setWorkerId } from '../utils/snowflake.js'; + +setWorkerId(2); +const __dirname = path.dirname(fileURLToPath(import.meta.url)); + +// Module-level mutable state — see invites.test.ts for the rationale on why +// the `getDb` mock closes over a getter rather than the binding directly. +type TestDb = ReturnType>; +let sqlite: Database.Database; +let testDb: TestDb; +let app: FastifyInstance; + +vi.mock('../db/index.js', () => ({ + getDb: () => testDb, + getRawDb: () => sqlite, + schema, +})); + +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); + } + } +} + +async function buildApp(): Promise { + const { authRoutes } = await import('./auth.js'); + const f = Fastify(); + await f.register(authRoutes); + return f; +} + +const ADMIN_ID = 'admin-1'; + +beforeEach(async () => { + sqlite = new Database(':memory:'); + sqlite.pragma('foreign_keys = ON'); + applyMigrations(sqlite); + testDb = drizzle(sqlite, { schema }); + + // The invite_links rows we'll create reference users(id) via createdBy. Seed + // a single admin to act as creator across all tests. + testDb.insert(schema.users).values({ + id: ADMIN_ID, + username: 'admin', + passwordHash: 'x', + isAdmin: 1, + createdAt: Date.now(), + }).run(); + + app = await buildApp(); +}); + +describe('GET /api/auth/check-invite', () => { + it('returns valid: true with name for active token', async () => { + const token = 'a'.repeat(22); + testDb.insert(schema.inviteLinks).values({ + id: 'inv-1', + token, + name: 'Friends batch 1', + createdBy: ADMIN_ID, + createdAt: Date.now(), + maxUses: 10, + usedCount: 0, + expiresAt: null, + revokedAt: null, + }).run(); + + const res = await app.inject({ + method: 'GET', + url: `/api/auth/check-invite?token=${token}`, + }); + expect(res.statusCode).toBe(200); + const body = res.json(); + expect(body.valid).toBe(true); + expect(body.name).toBe('Friends batch 1'); + expect(body.reason).toBeUndefined(); + }); + + it("returns valid: false, reason: 'expired' for past expiresAt", async () => { + const token = 'b'.repeat(22); + testDb.insert(schema.inviteLinks).values({ + id: 'inv-2', + token, + name: 'Old link', + createdBy: ADMIN_ID, + createdAt: Date.now() - 10_000, + maxUses: 10, + usedCount: 0, + expiresAt: Date.now() - 1_000, + revokedAt: null, + }).run(); + + const res = await app.inject({ + method: 'GET', + url: `/api/auth/check-invite?token=${token}`, + }); + expect(res.statusCode).toBe(200); + const body = res.json(); + expect(body.valid).toBe(false); + expect(body.reason).toBe('expired'); + expect(body.name).toBeUndefined(); + }); + + it("returns valid: false, reason: 'exhausted' for used-up invite", async () => { + const token = 'c'.repeat(22); + testDb.insert(schema.inviteLinks).values({ + id: 'inv-3', + token, + name: 'Burned', + createdBy: ADMIN_ID, + createdAt: Date.now(), + maxUses: 1, + usedCount: 1, + expiresAt: null, + revokedAt: null, + }).run(); + + const res = await app.inject({ + method: 'GET', + url: `/api/auth/check-invite?token=${token}`, + }); + expect(res.statusCode).toBe(200); + const body = res.json(); + expect(body.valid).toBe(false); + expect(body.reason).toBe('exhausted'); + expect(body.name).toBeUndefined(); + }); + + it("returns valid: false, reason: 'invalid' for revoked invite (collapsed shield)", async () => { + const token = 'd'.repeat(22); + testDb.insert(schema.inviteLinks).values({ + id: 'inv-4', + token, + name: 'Revoked', + createdBy: ADMIN_ID, + createdAt: Date.now(), + maxUses: 10, + usedCount: 0, + expiresAt: null, + revokedAt: Date.now(), + }).run(); + + const res = await app.inject({ + method: 'GET', + url: `/api/auth/check-invite?token=${token}`, + }); + expect(res.statusCode).toBe(200); + const body = res.json(); + expect(body.valid).toBe(false); + expect(body.reason).toBe('invalid'); + expect(body.name).toBeUndefined(); + }); + + it("returns valid: false, reason: 'invalid' for unknown token", async () => { + // 22-char base64url string that is not in the DB. + const token = 'Z'.repeat(22); + const res = await app.inject({ + method: 'GET', + url: `/api/auth/check-invite?token=${token}`, + }); + expect(res.statusCode).toBe(200); + const body = res.json(); + expect(body.valid).toBe(false); + expect(body.reason).toBe('invalid'); + expect(body.name).toBeUndefined(); + }); + + it("returns valid: false, reason: 'invalid' for malformed token", async () => { + const res = await app.inject({ + method: 'GET', + url: '/api/auth/check-invite?token=tooshort', + }); + expect(res.statusCode).toBe(200); + const body = res.json(); + expect(body.valid).toBe(false); + expect(body.reason).toBe('invalid'); + expect(body.name).toBeUndefined(); + }); + + it('does not include name field on invalid responses', async () => { + // Sweep across all invalid permutations to assert the absence-of-leak + // contract once for the whole endpoint, not just per-status. + const cases = [ + { url: '/api/auth/check-invite' }, + { url: '/api/auth/check-invite?token=' }, + { url: '/api/auth/check-invite?token=tooshort' }, + { url: `/api/auth/check-invite?token=${'Z'.repeat(22)}` }, + ]; + for (const c of cases) { + const res = await app.inject({ method: 'GET', url: c.url }); + expect(res.statusCode).toBe(200); + const body = res.json(); + expect(body.valid).toBe(false); + expect(body).not.toHaveProperty('name'); + } + }); +}); diff --git a/packages/server/src/routes/auth.ts b/packages/server/src/routes/auth.ts index be717e38..9f42517d 100644 --- a/packages/server/src/routes/auth.ts +++ b/packages/server/src/routes/auth.ts @@ -8,6 +8,7 @@ import type { RegisterRequest, LoginRequest, AuthResponse } from '@backspace/sha import { AVATAR_COLORS } from '@backspace/shared'; import { sanitizeUser } from '../utils/sanitize.js'; import { findFederatedUser } from './federation.js'; +import { getInviteByToken, inviteStatus } from '../utils/inviteService.js'; export async function authRoutes(app: FastifyInstance): Promise { app.post<{ Body: RegisterRequest }>('/api/auth/register', { @@ -239,6 +240,48 @@ export async function authRoutes(app: FastifyInstance): Promise { return reply.code(200).send({ available: !existing }); }); + // Public — used by RegisterPage to debounce-validate invite tokens during + // typing. The status -> response mapping enforces a "collapsed enumeration + // shield": revoked, not-found, and malformed tokens all collapse to + // `'invalid'` so this endpoint can't be used to distinguish them. Only + // `expired` and `exhausted` surface as themselves because those are + // legitimate UX hints ("ask the admin to extend it") rather than + // existence/state leaks. The `name` field is returned ONLY in the valid + // case — invalid responses must not leak any invite metadata. + // Status code is always 200; the response body discriminates. + app.get<{ Querystring: { token?: string } }>('/api/auth/check-invite', { + config: { + rateLimit: { + max: 30, + timeWindow: '1 minute', + keyGenerator: (request: any) => request.ip, + }, + }, + }, async (request, reply) => { + const token = request.query.token; + if (!token || typeof token !== 'string') { + return reply.code(200).send({ valid: false, reason: 'invalid' }); + } + + // getInviteByToken pre-validates the 22-char base64url shape before + // hitting the DB; malformed inputs return null here, so the same branch + // covers both "wrong shape" and "shape ok, not in DB". + const row = getInviteByToken(token); + if (!row) { + return reply.code(200).send({ valid: false, reason: 'invalid' }); + } + + const status = inviteStatus(row); + if (status === 'active') { + return reply.code(200).send({ valid: true, name: row.name }); + } + if (status === 'expired' || status === 'exhausted') { + return reply.code(200).send({ valid: false, reason: status }); + } + // status === 'revoked' — collapsed to 'invalid' (no enumeration leak) + return reply.code(200).send({ valid: false, reason: 'invalid' }); + }); + app.post<{ Body: LoginRequest }>('/api/auth/login', { config: { rateLimit: { From 1b76b8bf2a002f7074da34856bc2609e2a7dd700 Mon Sep 17 00:00:00 2001 From: Jannis Braun <151788261+TheZwiss@users.noreply.github.com> Date: Tue, 28 Apr 2026 20:46:24 +0200 Subject: [PATCH 16/41] test(invites): tighten check-invite assertions to byte-identical responses MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per quality review: replace per-property assertions with toEqual() object-equality on the invalid-response bodies. Locks the enumeration- shield contract — revoked/unknown/malformed/missing must all return the SAME body, not just bodies that happen to satisfy individual assertions. --- packages/server/src/routes/auth.test.ts | 41 ++++++++++--------------- 1 file changed, 17 insertions(+), 24 deletions(-) diff --git a/packages/server/src/routes/auth.test.ts b/packages/server/src/routes/auth.test.ts index 05f31bd5..99adbdab 100644 --- a/packages/server/src/routes/auth.test.ts +++ b/packages/server/src/routes/auth.test.ts @@ -160,10 +160,10 @@ describe('GET /api/auth/check-invite', () => { url: `/api/auth/check-invite?token=${token}`, }); expect(res.statusCode).toBe(200); - const body = res.json(); - expect(body.valid).toBe(false); - expect(body.reason).toBe('invalid'); - expect(body.name).toBeUndefined(); + // toEqual locks the byte-identical-response contract: the enumeration + // shield depends on revoked/unknown/malformed all returning the SAME + // body, not just bodies that happen to satisfy individual assertions. + expect(res.json()).toEqual({ valid: false, reason: 'invalid' }); }); it("returns valid: false, reason: 'invalid' for unknown token", async () => { @@ -174,10 +174,7 @@ describe('GET /api/auth/check-invite', () => { url: `/api/auth/check-invite?token=${token}`, }); expect(res.statusCode).toBe(200); - const body = res.json(); - expect(body.valid).toBe(false); - expect(body.reason).toBe('invalid'); - expect(body.name).toBeUndefined(); + expect(res.json()).toEqual({ valid: false, reason: 'invalid' }); }); it("returns valid: false, reason: 'invalid' for malformed token", async () => { @@ -186,27 +183,23 @@ describe('GET /api/auth/check-invite', () => { url: '/api/auth/check-invite?token=tooshort', }); expect(res.statusCode).toBe(200); - const body = res.json(); - expect(body.valid).toBe(false); - expect(body.reason).toBe('invalid'); - expect(body.name).toBeUndefined(); + expect(res.json()).toEqual({ valid: false, reason: 'invalid' }); }); - it('does not include name field on invalid responses', async () => { - // Sweep across all invalid permutations to assert the absence-of-leak - // contract once for the whole endpoint, not just per-status. + it('returns byte-identical bodies across all invalid permutations', async () => { + // The enumeration shield depends on revoked/unknown/malformed/missing + // all returning the SAME body. Object equality (toEqual) catches any + // future code path that adds an extra field on one branch but not others. const cases = [ - { url: '/api/auth/check-invite' }, - { url: '/api/auth/check-invite?token=' }, - { url: '/api/auth/check-invite?token=tooshort' }, - { url: `/api/auth/check-invite?token=${'Z'.repeat(22)}` }, + '/api/auth/check-invite', + '/api/auth/check-invite?token=', + '/api/auth/check-invite?token=tooshort', + `/api/auth/check-invite?token=${'Z'.repeat(22)}`, ]; - for (const c of cases) { - const res = await app.inject({ method: 'GET', url: c.url }); + for (const url of cases) { + const res = await app.inject({ method: 'GET', url }); expect(res.statusCode).toBe(200); - const body = res.json(); - expect(body.valid).toBe(false); - expect(body).not.toHaveProperty('name'); + expect(res.json()).toEqual({ valid: false, reason: 'invalid' }); } }); }); From 0559ea369b02269c634df740ad5694259ec2c28c Mon Sep 17 00:00:00 2001 From: Jannis Braun <151788261+TheZwiss@users.noreply.github.com> Date: Tue, 28 Apr 2026 20:51:49 +0200 Subject: [PATCH 17/41] feat(auth): split registration gate by homeInstance + atomic invite redemption MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The /api/auth/register handler now branches on homeInstance: - Local path (no homeInstance): gated by registrationOpen. When closed, a valid inviteToken bypasses the gate and is consumed atomically inside redeemInvite()'s transaction (user insert + usedCount bump + redemption row all commit together, or all roll back). When open, inviteToken is silently ignored. - Federated path (homeInstance set): gated by federatedRegistrationOpen. Token is ignored entirely on this path -- tokens never unlock federated creation. Closed → 403 with "Federated registration is closed". InviteUnavailableError thrown by redeemInvite() (concurrent revoke, last-slot race, expiry between check-invite and submit) is mapped to 403 "Invalid or expired invite". The in-txn re-derive closes the TOCTOU window. 9 new tests cover the toggle matrix from spec §5.6 + invite consumption semantics + federated-gate independence + last-slot race rejection. Updates docs/systems/auth.md: rewrites the Registration Gate section to describe the three-path model (open / invite / federated), adds the toggle matrix, adds an Invite Tokens subsection with the atomic-redemption shape, notes that the federated stub upgrade is always gated by federatedRegistrationOpen, never by an invite token. --- docs/systems/auth.md | 68 +++++-- packages/server/src/routes/auth.test.ts | 252 ++++++++++++++++++++++++ packages/server/src/routes/auth.ts | 74 ++++++- 3 files changed, 377 insertions(+), 17 deletions(-) diff --git a/docs/systems/auth.md b/docs/systems/auth.md index 79dad638..f3642804 100644 --- a/docs/systems/auth.md +++ b/docs/systems/auth.md @@ -118,11 +118,54 @@ There is **no token blocklist**. The only revocation mechanism is the `passwordC ### Registration Gate -Registration open/closed is determined by: +The `/api/auth/register` route splits its gate by request shape (spec §1.2). There are **two independent toggles** plus an **invite-token bypass** for the local path. + +**Local anonymous signup** (no `homeInstance` in body): + 1. `instanceSettings.registrationOpen` (DB, id=1) -- if not null, this takes priority 2. `config.registrationOpen` (env `REGISTRATION_OPEN`, default `true`) -- fallback -Both are checked. DB value overrides env when explicitly set by admin. +DB value overrides env when explicitly set by admin. When closed, a valid `inviteToken` bypasses the gate and is **atomically consumed** alongside the user insert (see "Invite Tokens" below). When open, an `inviteToken` field is **silently ignored** (no validation, no consumption). + +**Federated identity replication** (request body has `homeInstance`): + +- Gated solely by `instanceSettings.federatedRegistrationOpen` (NOT NULL DEFAULT 1). +- `inviteToken` is **ignored entirely** on this path -- tokens never unlock federated creation, even when supplied. +- Closed → 403 `"Federated registration is closed on this instance"`. + +**Invariants** (spec §1.3): + +- **Login is never gated** by either toggle. Both gates affect *creation only*. Existing accounts remain loginable regardless of policy. +- The federated stub upgrade flow (below) is gated by `federatedRegistrationOpen`, never by an invite token. + +**Toggle matrix** (spec §5.6): + +| `registrationOpen` | `federatedRegistrationOpen` | Local register | Federated register | +|---|---|---|---| +| true | true | open | allowed | +| true | false | open | 403 | +| false | true | invite-required | allowed | +| false | false | invite-required | 403 | + +### Invite Tokens + +When `registrationOpen` is false, the local-signup path accepts an `inviteToken` field on the register body. Token format: 22-char base64url (16 random bytes). Lifecycle and admin CRUD live in `inviteService.ts` and `routes/invites.ts` -- see `docs/systems/admin.md` for the panel UX and the full status state machine. + +Atomic redemption (spec §2.4): + +``` +db.transaction(() => { + // 1. Re-fetch invite by token under txn (closes TOCTOU vs /check-invite) + // 2. Reject if status !== 'active' → throw InviteUnavailableError → 403 + // 3. INSERT user row + // 4. UPDATE invite_links SET usedCount = usedCount + 1 + // 5. INSERT invite_redemptions row (forensic audit, snapshots username) +}) +``` + +If any step throws (concurrent revoke, last-slot race, username collision against the unique index), the entire transaction rolls back -- `usedCount` is never incremented on a failed registration. The route catches `InviteUnavailableError` from `redeemInvite()` and surfaces it as 403 `"Invalid or expired invite"`. + +The `/api/auth/check-invite` debounced UX endpoint pre-validates a token from the register page; the in-txn re-derive inside `redeemInvite()` is the authoritative enforcement point. ### First-User Admin Promotion @@ -144,18 +187,21 @@ If `requestedAvatarColor` is provided and is in `AVATAR_COLORS`, use it. Otherwi ### Registration Steps 1. Validate inputs (username format, password length) -2. Check registration is open -3. **Federated stub upgrade check** (if `homeInstance` is set): call `findFederatedUser` to look for an existing relay-created stub. If found and upgradeable, upgrade it instead of creating a new record (see below). -4. Check username uniqueness (exact match on lowercased username) -5. Hash password (bcrypt, 12 rounds) -6. Generate Snowflake ID -7. Insert user row (status defaults to `'offline'` at the schema level; it is set to `'online'` only when the client establishes a WebSocket via the WS auth path in `ws/handler.ts`). Admin flag set if first user. -8. Sign JWT with `{ userId, username }` -9. Return `{ token, user }` (user sanitized via `sanitizeUser(user, true)`) +2. Read both registration gates from `instance_settings` +3. **Branch by request shape** (spec §1.2): + - If `homeInstance` set → reject with 403 unless `federatedRegistrationOpen === true`. `inviteToken` ignored on this path. + - Else (local) → if `registrationOpen` is false, require a valid `inviteToken`; otherwise reject with 403. Pre-flight token check rejects obvious-invalid tokens before bcrypt. +4. **Federated stub upgrade check** (if `homeInstance` is set): call `findFederatedUser` to look for an existing relay-created stub. If found and upgradeable, upgrade it instead of creating a new record (see below). +5. Check username uniqueness (exact match on lowercased username) +6. Hash password (bcrypt, 12 rounds) +7. Generate Snowflake ID +8. Insert user row (status defaults to `'offline'` at the schema level; it is set to `'online'` only when the client establishes a WebSocket via the WS auth path in `ws/handler.ts`). Admin flag set if first user. **When the local-closed-with-token path is in play**, the insert runs inside `redeemInvite()`'s transaction so the user row, the `usedCount` bump, and the `invite_redemptions` row commit atomically (or all roll back). +9. Sign JWT with `{ userId, username }` +10. Return `{ token, user }` (user sanitized via `sanitizeUser(user, true)`) ### Federated Stub Upgrade -When a user registers with `homeInstance` set (federated registration via friend-connect), the registration path checks for an existing relay-created stub using `findFederatedUser`. If found and the stub has `passwordHash = '!federation-replicated'` (not a real account), the stub is upgraded: +When a user registers with `homeInstance` set (federated registration via friend-connect), the registration path checks for an existing relay-created stub using `findFederatedUser`. The stub-upgrade flow is **always gated by `federatedRegistrationOpen`**, never by an invite token (spec §1.3). If found and the stub has `passwordHash = '!federation-replicated'` (not a real account), the stub is upgraded: - `passwordHash` is set to the new bcrypt hash (enables login) - `username` is updated to the registration's chosen username (replaces placeholder like `291255103060533248@nova.ddns.net` with `nova@nova.ddns.net`) diff --git a/packages/server/src/routes/auth.test.ts b/packages/server/src/routes/auth.test.ts index 99adbdab..57f41432 100644 --- a/packages/server/src/routes/auth.test.ts +++ b/packages/server/src/routes/auth.test.ts @@ -2,6 +2,7 @@ import { describe, it, expect, beforeEach, vi } from 'vitest'; import Fastify, { type FastifyInstance } from 'fastify'; import Database from 'better-sqlite3'; import { drizzle } from 'drizzle-orm/better-sqlite3'; +import { eq } from 'drizzle-orm'; import fs from 'node:fs'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; @@ -203,3 +204,254 @@ describe('GET /api/auth/check-invite', () => { } }); }); + +describe('POST /api/auth/register — federation gate split', () => { + beforeEach(() => { + // Ensure a fresh instance_settings singleton row with both gates default-true. + // The test harness's applyMigrations creates the table but does not seed the + // id=1 row (production does so via migrate.ts:ensureDefaults on first boot). + // Each test then mutates the toggles it cares about. + testDb.delete(schema.instanceSettings).run(); + testDb.insert(schema.instanceSettings).values({ + id: 1, + registrationOpen: 1, + federatedRegistrationOpen: 1, + updatedAt: Date.now(), + }).run(); + }); + + it('open registration: register without token succeeds; token field ignored if present', async () => { + const res = await app.inject({ + method: 'POST', + url: '/api/auth/register', + payload: { username: 'alice', password: 'password123' }, + }); + expect(res.statusCode).toBe(201); + + // Try with bogus token — still succeeds, token ignored + const res2 = await app.inject({ + method: 'POST', + url: '/api/auth/register', + payload: { username: 'bob', password: 'password123', inviteToken: 'fakefakefakefakefakeXX' }, + }); + expect(res2.statusCode).toBe(201); + }); + + it('closed registration without token: 403 "An invite is required"', async () => { + testDb.update(schema.instanceSettings) + .set({ registrationOpen: 0 }) + .where(eq(schema.instanceSettings.id, 1)) + .run(); + + const res = await app.inject({ + method: 'POST', + url: '/api/auth/register', + payload: { username: 'newalice', password: 'password123' }, + }); + expect(res.statusCode).toBe(403); + expect(res.json().error).toContain('invite is required'); + }); + + it('closed registration with valid token: succeeds, usedCount incremented, redemption written', async () => { + testDb.update(schema.instanceSettings) + .set({ registrationOpen: 0 }) + .where(eq(schema.instanceSettings.id, 1)) + .run(); + const token = 'abcdefghijklmnopqrstuv'; + testDb.insert(schema.inviteLinks).values({ + id: 'inv-redeem-1', + token, + name: 'F', + createdBy: ADMIN_ID, + createdAt: Date.now(), + maxUses: 5, + usedCount: 0, + expiresAt: null, + revokedAt: null, + }).run(); + + const res = await app.inject({ + method: 'POST', + url: '/api/auth/register', + payload: { username: 'newalice', password: 'password123', inviteToken: token }, + }); + expect(res.statusCode).toBe(201); + + const inv = testDb.select().from(schema.inviteLinks) + .where(eq(schema.inviteLinks.id, 'inv-redeem-1')).get(); + expect(inv?.usedCount).toBe(1); + const redemptions = testDb.select().from(schema.inviteRedemptions) + .where(eq(schema.inviteRedemptions.inviteId, 'inv-redeem-1')).all(); + expect(redemptions).toHaveLength(1); + expect(redemptions[0]?.registrantUsername).toBe('newalice'); + }); + + it('closed registration with invalid token: 403 "Invalid or expired invite"', async () => { + testDb.update(schema.instanceSettings) + .set({ registrationOpen: 0 }) + .where(eq(schema.instanceSettings.id, 1)) + .run(); + + const res = await app.inject({ + method: 'POST', + url: '/api/auth/register', + payload: { username: 'newalice', password: 'password123', inviteToken: 'fakefakefakefakefakeXX' }, + }); + expect(res.statusCode).toBe(403); + expect(res.json().error).toContain('Invalid or expired'); + }); + + it('open registration with token: usedCount NOT incremented', async () => { + const token = 'abcdefghijklmnopqrstuv'; + testDb.insert(schema.inviteLinks).values({ + id: 'inv-ignore-1', + token, + name: 'F', + createdBy: ADMIN_ID, + createdAt: Date.now(), + maxUses: 5, + usedCount: 0, + expiresAt: null, + revokedAt: null, + }).run(); + + const res = await app.inject({ + method: 'POST', + url: '/api/auth/register', + payload: { username: 'newalice', password: 'password123', inviteToken: token }, + }); + expect(res.statusCode).toBe(201); + + const inv = testDb.select().from(schema.inviteLinks) + .where(eq(schema.inviteLinks.id, 'inv-ignore-1')).get(); + expect(inv?.usedCount).toBe(0); + + const redemptions = testDb.select().from(schema.inviteRedemptions) + .where(eq(schema.inviteRedemptions.inviteId, 'inv-ignore-1')).all(); + expect(redemptions).toHaveLength(0); + }); + + it('federated registration: blocked when federatedRegistrationOpen=false', async () => { + testDb.update(schema.instanceSettings) + .set({ federatedRegistrationOpen: 0 }) + .where(eq(schema.instanceSettings.id, 1)) + .run(); + + const res = await app.inject({ + method: 'POST', + url: '/api/auth/register', + payload: { + username: 'alice@otherhost', + password: 'password123', + homeInstance: 'otherhost', + homeUserId: 'remote-id', + }, + }); + expect(res.statusCode).toBe(403); + expect(res.json().error).toContain('Federated registration'); + }); + + it('federated registration: blocked even with valid token when federatedRegistrationOpen=false', async () => { + testDb.update(schema.instanceSettings) + .set({ registrationOpen: 0, federatedRegistrationOpen: 0 }) + .where(eq(schema.instanceSettings.id, 1)) + .run(); + const token = 'abcdefghijklmnopqrstuv'; + testDb.insert(schema.inviteLinks).values({ + id: 'inv-fed-blocked', + token, + name: 'F', + createdBy: ADMIN_ID, + createdAt: Date.now(), + maxUses: 5, + usedCount: 0, + expiresAt: null, + revokedAt: null, + }).run(); + + const res = await app.inject({ + method: 'POST', + url: '/api/auth/register', + payload: { + username: 'alice@otherhost', + password: 'password123', + homeInstance: 'otherhost', + homeUserId: 'remote-id', + inviteToken: token, + }, + }); + expect(res.statusCode).toBe(403); + expect(res.json().error).toContain('Federated registration'); + + // Token MUST NOT be consumed + const inv = testDb.select().from(schema.inviteLinks) + .where(eq(schema.inviteLinks.id, 'inv-fed-blocked')).get(); + expect(inv?.usedCount).toBe(0); + }); + + it('federated registration: token IGNORED even if provided (no usedCount increment)', async () => { + testDb.update(schema.instanceSettings) + .set({ registrationOpen: 0, federatedRegistrationOpen: 1 }) + .where(eq(schema.instanceSettings.id, 1)) + .run(); + const token = 'abcdefghijklmnopqrstuv'; + testDb.insert(schema.inviteLinks).values({ + id: 'inv-fed-ignore', + token, + name: 'F', + createdBy: ADMIN_ID, + createdAt: Date.now(), + maxUses: 5, + usedCount: 0, + expiresAt: null, + revokedAt: null, + }).run(); + + const res = await app.inject({ + method: 'POST', + url: '/api/auth/register', + payload: { + username: 'alice@otherhost', + password: 'password123', + homeInstance: 'otherhost', + homeUserId: 'remote-id', + inviteToken: token, + }, + }); + expect(res.statusCode).toBe(201); + + const inv = testDb.select().from(schema.inviteLinks) + .where(eq(schema.inviteLinks.id, 'inv-fed-ignore')).get(); + expect(inv?.usedCount).toBe(0); + + const redemptions = testDb.select().from(schema.inviteRedemptions) + .where(eq(schema.inviteRedemptions.inviteId, 'inv-fed-ignore')).all(); + expect(redemptions).toHaveLength(0); + }); + + it('closed registration: token last-slot race → 403 (in-txn re-derive)', async () => { + testDb.update(schema.instanceSettings) + .set({ registrationOpen: 0 }) + .where(eq(schema.instanceSettings.id, 1)) + .run(); + const token = 'abcdefghijklmnopqrstuv'; + testDb.insert(schema.inviteLinks).values({ + id: 'inv-exhausted', + token, + name: 'F', + createdBy: ADMIN_ID, + createdAt: Date.now(), + maxUses: 1, + usedCount: 1, // already at the cap + expiresAt: null, + revokedAt: null, + }).run(); + + const res = await app.inject({ + method: 'POST', + url: '/api/auth/register', + payload: { username: 'newalice', password: 'password123', inviteToken: token }, + }); + expect(res.statusCode).toBe(403); + }); +}); diff --git a/packages/server/src/routes/auth.ts b/packages/server/src/routes/auth.ts index 9f42517d..157bbb4e 100644 --- a/packages/server/src/routes/auth.ts +++ b/packages/server/src/routes/auth.ts @@ -8,7 +8,7 @@ import type { RegisterRequest, LoginRequest, AuthResponse } from '@backspace/sha import { AVATAR_COLORS } from '@backspace/shared'; import { sanitizeUser } from '../utils/sanitize.js'; import { findFederatedUser } from './federation.js'; -import { getInviteByToken, inviteStatus } from '../utils/inviteService.js'; +import { getInviteByToken, inviteStatus, redeemInvite, InviteUnavailableError } from '../utils/inviteService.js'; export async function authRoutes(app: FastifyInstance): Promise { app.post<{ Body: RegisterRequest }>('/api/auth/register', { @@ -76,13 +76,47 @@ export async function authRoutes(app: FastifyInstance): Promise { const db = getDb(); - // Check registration: DB setting overrides env var if explicitly set by admin + // Read both gates from instance_settings. + // - registrationOpen: nullable column; null falls back to env var (config.registrationOpen). + // Admin-explicit 0/1 overrides env. Gates LOCAL anonymous signup. + // - federatedRegistrationOpen: NOT NULL DEFAULT 1 column. Gates FEDERATED identity + // replication (homeInstance set). Independent of registrationOpen by spec §1.2. const instanceRow = db.select().from(schema.instanceSettings).where(eq(schema.instanceSettings.id, 1)).get(); const registrationOpen = instanceRow?.registrationOpen !== null && instanceRow?.registrationOpen !== undefined ? instanceRow.registrationOpen === 1 : config.registrationOpen; - if (!registrationOpen) { - return reply.code(403).send({ error: 'Registration is currently closed', statusCode: 403 }); + const federatedRegistrationOpen = instanceRow?.federatedRegistrationOpen === 1; + + // Optional invite token. Only meaningful for the local-closed path; ignored + // entirely on the federated path (spec §1.3, §5.6) and on the local-open path + // (spec §5.7). + const inviteToken = typeof request.body.inviteToken === 'string' + ? request.body.inviteToken + : undefined; + + if (homeInstance) { + // Federated path: token IGNORED entirely. Gate is federatedRegistrationOpen. + if (!federatedRegistrationOpen) { + return reply.code(403).send({ error: 'Federated registration is closed on this instance', statusCode: 403 }); + } + // Fall through to existing federated stub upgrade / new federated user logic below. + } else { + // Local path: registrationOpen is the primary gate. A valid invite token + // bypasses it when closed. When open, the token is silently ignored. + if (!registrationOpen) { + if (!inviteToken) { + return reply.code(403).send({ error: 'Registration is closed. An invite is required.', statusCode: 403 }); + } + // Pre-flight check: reject obviously-invalid tokens before any expensive + // work (bcrypt). The final enforcement still happens inside the redemption + // transaction below — this only short-circuits the easy reject path. + const inviteRow = getInviteByToken(inviteToken); + if (!inviteRow || inviteStatus(inviteRow) !== 'active') { + return reply.code(403).send({ error: 'Invalid or expired invite', statusCode: 403 }); + } + } + // If registrationOpen is true: inviteToken is silently ignored — no validation, + // no consumption (spec §5.7). } const passwordHash = await hashPassword(password); @@ -175,7 +209,7 @@ export async function authRoutes(app: FastifyInstance): Promise { // which would otherwise produce a permanently stuck-online row that no // disconnect timer can clean up. The WS handshake will flip it to // 'online' once a real socket attaches. - db.insert(schema.users).values({ + const userRow = { id: userId, username: trimmedUsername, displayName: displayName?.trim() || null, @@ -185,7 +219,35 @@ export async function authRoutes(app: FastifyInstance): Promise { homeUserId: (homeInstance && homeUserId && typeof homeUserId === 'string') ? homeUserId : null, avatarColor, createdAt: now, - }).run(); + }; + + // Only the LOCAL-CLOSED-WITH-VALID-TOKEN path consumes an invite. The federated + // paths (handled above and in the stub-upgrade block) and the local-open path + // never touch the invite_links table. + const consumesInvite = !homeInstance && !registrationOpen && !!inviteToken; + + if (consumesInvite) { + // Atomic redemption: the user INSERT, the usedCount bump, and the + // invite_redemptions row all run inside one SQLite transaction. If any + // step throws (token consumed by a concurrent request, username collision + // bumping into the unique index, etc.) the entire transaction rolls back — + // we never burn a redemption on a failed registration. + try { + redeemInvite(inviteToken!, () => { + db.insert(schema.users).values(userRow).run(); + return { id: userId, username: trimmedUsername }; + }); + } catch (err) { + if (err instanceof InviteUnavailableError) { + // Concurrent revoke / last-slot race / expiry-while-typing all surface here. + return reply.code(403).send({ error: 'Invalid or expired invite', statusCode: 403 }); + } + throw err; + } + } else { + // Standard local-open or federated-new-user path: plain user insert. + db.insert(schema.users).values(userRow).run(); + } const user = db.select().from(schema.users).where(eq(schema.users.id, userId)).get(); if (!user) { From 87301bd4d2bafaa844f94446877a747f12aa9971 Mon Sep 17 00:00:00 2001 From: Jannis Braun <151788261+TheZwiss@users.noreply.github.com> Date: Tue, 28 Apr 2026 20:58:16 +0200 Subject: [PATCH 18/41] =?UTF-8?q?test(auth):=20polish=20register=20handler?= =?UTF-8?q?=20=E2=80=94=20comment,=20test=20isolation,=20+1=20coverage?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Quality-review polish on Task 11: 1. One-line comment near the federatedRegistrationOpen default behavior noting that the missing-row case is unreachable post-migration but falls federation-closed defensively (asymmetric with registrationOpen which falls back to env config — by design). 2. The "federated gate blocks token registration" test now sets ONLY federatedRegistrationOpen=0, isolating the federated-gate-alone effect rather than a both-gates-closed compound. 3. New test: open registration + revoked token → 201 (silently ignored). Locks the spec §5.7 invariant "no validation when registration is open" against future "let's just validate it for safety" regressions. 4. auth.md prose explicitly notes that federated stub upgrade and new- account paths do NOT enter redeemInvite — surfacing the structural enforcement of spec §1.3 "tokens never unlock federated creation". --- docs/systems/auth.md | 2 ++ packages/server/src/routes/auth.test.ts | 35 ++++++++++++++++++++++++- packages/server/src/routes/auth.ts | 5 ++++ 3 files changed, 41 insertions(+), 1 deletion(-) diff --git a/docs/systems/auth.md b/docs/systems/auth.md index f3642804..be71f6e3 100644 --- a/docs/systems/auth.md +++ b/docs/systems/auth.md @@ -165,6 +165,8 @@ db.transaction(() => { If any step throws (concurrent revoke, last-slot race, username collision against the unique index), the entire transaction rolls back -- `usedCount` is never incremented on a failed registration. The route catches `InviteUnavailableError` from `redeemInvite()` and surfaces it as 403 `"Invalid or expired invite"`. +- **Federated stub upgrade and federated new-account paths do NOT enter `redeemInvite`.** They are gated only by `federatedRegistrationOpen` and never consume tokens, even if a token is provided in the request body. This is the structural enforcement of the spec §1.3 invariant "tokens never unlock federated creation". + The `/api/auth/check-invite` debounced UX endpoint pre-validates a token from the register page; the in-txn re-derive inside `redeemInvite()` is the authoritative enforcement point. ### First-User Admin Promotion diff --git a/packages/server/src/routes/auth.test.ts b/packages/server/src/routes/auth.test.ts index 57f41432..c51f17a0 100644 --- a/packages/server/src/routes/auth.test.ts +++ b/packages/server/src/routes/auth.test.ts @@ -353,7 +353,7 @@ describe('POST /api/auth/register — federation gate split', () => { it('federated registration: blocked even with valid token when federatedRegistrationOpen=false', async () => { testDb.update(schema.instanceSettings) - .set({ registrationOpen: 0, federatedRegistrationOpen: 0 }) + .set({ federatedRegistrationOpen: 0 }) .where(eq(schema.instanceSettings.id, 1)) .run(); const token = 'abcdefghijklmnopqrstuv'; @@ -454,4 +454,37 @@ describe('POST /api/auth/register — federation gate split', () => { }); expect(res.statusCode).toBe(403); }); + + it('open registration: revoked/expired token is silently ignored, registration still succeeds', async () => { + // Spec §5.7: when registration is open, the token field is not even + // validated. A revoked token in the request body must NOT block signup + // and must NOT be consumed. + const adminId = ADMIN_ID; + const token = 'r'.repeat(22); + testDb.insert(schema.inviteLinks).values({ + id: 'inv-revoked', + token, + name: 'revoked', + createdBy: adminId, + createdAt: Date.now(), + maxUses: 10, + usedCount: 0, + expiresAt: null, + revokedAt: Date.now(), // revoked! + }).run(); + + const res = await app.inject({ + method: 'POST', + url: '/api/auth/register', + payload: { username: 'eve', password: 'password123', inviteToken: token }, + }); + expect(res.statusCode).toBe(201); + + // Revoked invite still revoked — usedCount unchanged, no redemption row. + const inv = testDb.select().from(schema.inviteLinks).where(eq(schema.inviteLinks.id, 'inv-revoked')).get(); + expect(inv?.usedCount).toBe(0); + expect(inv?.revokedAt).not.toBeNull(); + const reds = testDb.select().from(schema.inviteRedemptions).where(eq(schema.inviteRedemptions.inviteId, 'inv-revoked')).all(); + expect(reds).toHaveLength(0); + }); }); diff --git a/packages/server/src/routes/auth.ts b/packages/server/src/routes/auth.ts index 157bbb4e..da3f6473 100644 --- a/packages/server/src/routes/auth.ts +++ b/packages/server/src/routes/auth.ts @@ -85,6 +85,11 @@ export async function authRoutes(app: FastifyInstance): Promise { const registrationOpen = instanceRow?.registrationOpen !== null && instanceRow?.registrationOpen !== undefined ? instanceRow.registrationOpen === 1 : config.registrationOpen; + // instanceRow is guaranteed by ensureDefaults() (migrate.ts) to have id=1 + // post-boot, with federatedRegistrationOpen NOT NULL DEFAULT 1. The optional + // chain is defensive against the impossible-in-production case of a missing + // row (e.g., a hand-cleared DB) — falls open-closed rather than open-open + // for federation, which is the safer default. const federatedRegistrationOpen = instanceRow?.federatedRegistrationOpen === 1; // Optional invite token. Only meaningful for the local-closed path; ignored From 8d7ba33c218cfe141a15fdbd626bdc59e2a0b56a Mon Sep 17 00:00:00 2001 From: Jannis Braun <151788261+TheZwiss@users.noreply.github.com> Date: Tue, 28 Apr 2026 21:01:50 +0200 Subject: [PATCH 19/41] feat(settings): expose federatedRegistrationOpen in /settings/instance + /instance/info Surfaces the federatedRegistrationOpen flag (Task 1 schema column) on the admin settings GET/PATCH endpoints and the public /api/instance/info endpoint. Closes the 3 deferred TypeScript errors from Task 2 by populating the now-required InstanceAdminSettings/InstanceInfoResponse field. Adds smoke tests (routes/instance.test.ts, routes/settings.test.ts) that lock in the JSON contract the Connections UI (Task 21) and admin RegistrationPanel (Task 15) consume, plus boolean-validation coverage for the PATCH path. Updates docs/systems/admin.md with the new field in both InstanceAdminSettings and the public info response schema. --- docs/systems/admin.md | 6 +- packages/server/src/routes/instance.test.ts | 94 ++++++++++++ packages/server/src/routes/instance.ts | 1 + packages/server/src/routes/settings.test.ts | 158 ++++++++++++++++++++ packages/server/src/routes/settings.ts | 9 ++ 5 files changed, 267 insertions(+), 1 deletion(-) create mode 100644 packages/server/src/routes/instance.test.ts create mode 100644 packages/server/src/routes/settings.test.ts diff --git a/docs/systems/admin.md b/docs/systems/admin.md index c551687c..925cd2bb 100644 --- a/docs/systems/admin.md +++ b/docs/systems/admin.md @@ -53,7 +53,8 @@ Settings are split into two API surfaces: | Field | Type | DB Column | Validation | Notes | |-------|------|-----------|------------|-------| | instanceName | string | instanceName | 1-32 chars, trimmed | Default: `'Backspace'` | -| registrationOpen | boolean | registrationOpen | boolean | DB null = use env `REGISTRATION_OPEN` (default true) | +| registrationOpen | boolean | registrationOpen | boolean | Local-account registration. DB null = use env `REGISTRATION_OPEN` (default true) | +| federatedRegistrationOpen | boolean | federatedRegistrationOpen | boolean | Federated-account creation against this instance. NOT NULL DEFAULT 1. Controls whether remote users can create `username@thisInstance` accounts via Connections (see auth.md + client-federation.md) | | discoveryEnabled | boolean | discoveryEnabled | boolean | Controls space Explore page | | gifApiKey | string? | gifApiKey | string or empty to clear | Returned masked as `****{last4}` for security | | gifEnabled | boolean? | (derived) | -- | Read-only; true when gifApiKey is non-null | @@ -164,11 +165,14 @@ No authentication. Returns: name: string; // instanceSettings.instanceName ?? 'Backspace' version: string; // Hardcoded '1.0.0' in instance.ts registrationOpen: boolean; // DB setting overrides env if non-null + federatedRegistrationOpen: boolean; // NOT NULL DEFAULT 1; gates federated-account creation } ``` Registration resolution order: `instance_settings.registrationOpen` (if not null) > `config.registrationOpen` (from `REGISTRATION_OPEN` env, default true). +`federatedRegistrationOpen` is consumed by the Connections UI (client-federation.md) to decide whether to surface the "create federated account on this instance" affordance. + ### General Instance Settings ``` diff --git a/packages/server/src/routes/instance.test.ts b/packages/server/src/routes/instance.test.ts new file mode 100644 index 00000000..d951413e --- /dev/null +++ b/packages/server/src/routes/instance.test.ts @@ -0,0 +1,94 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import Fastify, { type FastifyInstance } from 'fastify'; +import Database from 'better-sqlite3'; +import { drizzle } from 'drizzle-orm/better-sqlite3'; +import { eq } from 'drizzle-orm'; +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import * as schema from '../db/schema.js'; +import { setWorkerId } from '../utils/snowflake.js'; + +setWorkerId(3); +const __dirname = path.dirname(fileURLToPath(import.meta.url)); + +// Module-level mutable state — see invites.test.ts for the rationale on why +// the `getDb` mock closes over a getter rather than the binding directly. +type TestDb = ReturnType>; +let sqlite: Database.Database; +let testDb: TestDb; +let app: FastifyInstance; + +vi.mock('../db/index.js', () => ({ + getDb: () => testDb, + getRawDb: () => sqlite, + schema, +})); + +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); + } + } +} + +async function buildApp(): Promise { + const { instanceRoutes } = await import('./instance.js'); + const f = Fastify(); + await f.register(instanceRoutes); + return f; +} + +beforeEach(async () => { + sqlite = new Database(':memory:'); + sqlite.pragma('foreign_keys = ON'); + applyMigrations(sqlite); + testDb = drizzle(sqlite, { schema }); + + // Seed the singleton instance_settings row mirroring ensureDefaults() — + // tests don't run the boot-time helper, so we insert manually with the + // schema-default values for the new federatedRegistrationOpen column. + testDb.insert(schema.instanceSettings).values({ + id: 1, + updatedAt: Date.now(), + }).run(); + + app = await buildApp(); +}); + +describe('GET /api/instance/info', () => { + it('includes federatedRegistrationOpen (default true)', async () => { + const res = await app.inject({ method: 'GET', url: '/api/instance/info' }); + expect(res.statusCode).toBe(200); + const body = res.json(); + expect(body.federatedRegistrationOpen).toBe(true); + }); + + it('reflects federatedRegistrationOpen=false when toggled off', async () => { + testDb.update(schema.instanceSettings) + .set({ federatedRegistrationOpen: 0 }) + .where(eq(schema.instanceSettings.id, 1)) + .run(); + + const res = await app.inject({ method: 'GET', url: '/api/instance/info' }); + expect(res.statusCode).toBe(200); + const body = res.json(); + expect(body.federatedRegistrationOpen).toBe(false); + }); + + it('returns the full contract: name, version, registrationOpen, federatedRegistrationOpen', async () => { + const res = await app.inject({ method: 'GET', url: '/api/instance/info' }); + expect(res.statusCode).toBe(200); + const body = res.json(); + expect(typeof body.name).toBe('string'); + expect(typeof body.version).toBe('string'); + expect(typeof body.registrationOpen).toBe('boolean'); + expect(typeof body.federatedRegistrationOpen).toBe('boolean'); + }); +}); diff --git a/packages/server/src/routes/instance.ts b/packages/server/src/routes/instance.ts index 8a235b94..11f09a39 100644 --- a/packages/server/src/routes/instance.ts +++ b/packages/server/src/routes/instance.ts @@ -22,6 +22,7 @@ export async function instanceRoutes(app: FastifyInstance): Promise { name: instanceName, version: BACKSPACE_VERSION, registrationOpen, + federatedRegistrationOpen: settings?.federatedRegistrationOpen === 1, }; return reply.code(200).send(response); diff --git a/packages/server/src/routes/settings.test.ts b/packages/server/src/routes/settings.test.ts new file mode 100644 index 00000000..eeb9c5c6 --- /dev/null +++ b/packages/server/src/routes/settings.test.ts @@ -0,0 +1,158 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import Fastify, { type FastifyInstance } from 'fastify'; +import Database from 'better-sqlite3'; +import { drizzle } from 'drizzle-orm/better-sqlite3'; +import { eq } from 'drizzle-orm'; +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import * as schema from '../db/schema.js'; +import { setWorkerId } from '../utils/snowflake.js'; + +setWorkerId(4); +const __dirname = path.dirname(fileURLToPath(import.meta.url)); + +// Module-level mutable state — see invites.test.ts for the rationale on why +// the `getDb` mock closes over a getter rather than the binding directly. +type TestDb = ReturnType>; +let sqlite: Database.Database; +let testDb: TestDb; +let app: FastifyInstance; +const ADMIN_ID = 'admin-1'; + +vi.mock('../db/index.js', () => ({ + getDb: () => testDb, + getRawDb: () => sqlite, + schema, +})); + +vi.mock('../utils/auth.js', () => ({ + authenticate: async (req: { userId?: string }) => { + req.userId = ADMIN_ID; + }, + requireAdmin: async () => { + // tests run as admin + }, +})); + +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); + } + } +} + +async function buildApp(): Promise { + const { settingsRoutes } = await import('./settings.js'); + const f = Fastify(); + await f.register(settingsRoutes); + return f; +} + +beforeEach(async () => { + sqlite = new Database(':memory:'); + sqlite.pragma('foreign_keys = ON'); + applyMigrations(sqlite); + testDb = drizzle(sqlite, { schema }); + + // Seed the singleton instance_settings row (mirrors ensureDefaults at boot). + testDb.insert(schema.instanceSettings).values({ + id: 1, + updatedAt: Date.now(), + }).run(); + + // Seed the admin user — settings routes require an authenticated admin. + testDb.insert(schema.users).values({ + id: ADMIN_ID, + username: 'admin', + passwordHash: 'x', + isAdmin: 1, + createdAt: Date.now(), + }).run(); + + app = await buildApp(); +}); + +describe('GET /api/settings/instance', () => { + it('surfaces federatedRegistrationOpen (default true)', async () => { + const res = await app.inject({ method: 'GET', url: '/api/settings/instance' }); + expect(res.statusCode).toBe(200); + const body = res.json(); + expect(body.federatedRegistrationOpen).toBe(true); + }); + + it('reflects federatedRegistrationOpen=false when toggled off in DB', async () => { + testDb.update(schema.instanceSettings) + .set({ federatedRegistrationOpen: 0 }) + .where(eq(schema.instanceSettings.id, 1)) + .run(); + + const res = await app.inject({ method: 'GET', url: '/api/settings/instance' }); + expect(res.statusCode).toBe(200); + expect(res.json().federatedRegistrationOpen).toBe(false); + }); +}); + +describe('PATCH /api/settings/instance — federatedRegistrationOpen', () => { + it('accepts federatedRegistrationOpen=false and persists it', async () => { + const res = await app.inject({ + method: 'PATCH', + url: '/api/settings/instance', + payload: { federatedRegistrationOpen: false }, + }); + expect(res.statusCode).toBe(200); + expect(res.json().federatedRegistrationOpen).toBe(false); + + // Verify persistence + const row = testDb.select().from(schema.instanceSettings) + .where(eq(schema.instanceSettings.id, 1)).get(); + expect(row?.federatedRegistrationOpen).toBe(0); + }); + + it('accepts federatedRegistrationOpen=true (re-enable)', async () => { + testDb.update(schema.instanceSettings) + .set({ federatedRegistrationOpen: 0 }) + .where(eq(schema.instanceSettings.id, 1)) + .run(); + + const res = await app.inject({ + method: 'PATCH', + url: '/api/settings/instance', + payload: { federatedRegistrationOpen: true }, + }); + expect(res.statusCode).toBe(200); + expect(res.json().federatedRegistrationOpen).toBe(true); + + const row = testDb.select().from(schema.instanceSettings) + .where(eq(schema.instanceSettings.id, 1)).get(); + expect(row?.federatedRegistrationOpen).toBe(1); + }); + + it('rejects non-boolean federatedRegistrationOpen with 400', async () => { + const res = await app.inject({ + method: 'PATCH', + url: '/api/settings/instance', + payload: { federatedRegistrationOpen: 'yes' }, + }); + expect(res.statusCode).toBe(400); + expect(res.json().error).toMatch(/federatedRegistrationOpen/); + }); + + it('leaves federatedRegistrationOpen unchanged when omitted from payload', async () => { + const res = await app.inject({ + method: 'PATCH', + url: '/api/settings/instance', + payload: { instanceName: 'NewName' }, + }); + expect(res.statusCode).toBe(200); + // Schema default is 1 → response should still report true + expect(res.json().federatedRegistrationOpen).toBe(true); + expect(res.json().instanceName).toBe('NewName'); + }); +}); diff --git a/packages/server/src/routes/settings.ts b/packages/server/src/routes/settings.ts index 74e0caa7..1db29c7c 100644 --- a/packages/server/src/routes/settings.ts +++ b/packages/server/src/routes/settings.ts @@ -185,6 +185,7 @@ export async function settingsRoutes(app: FastifyInstance): Promise { const response: InstanceAdminSettings = { instanceName: row.instanceName ?? 'Backspace', registrationOpen: row.registrationOpen !== null ? row.registrationOpen === 1 : config.registrationOpen, + federatedRegistrationOpen: row.federatedRegistrationOpen === 1, discoveryEnabled: row.discoveryEnabled === 1, gifApiKey: gifKey ? `****${gifKey.slice(-4)}` : undefined, gifEnabled: !!gifKey, @@ -216,6 +217,13 @@ export async function settingsRoutes(app: FastifyInstance): Promise { updateData.registrationOpen = body.registrationOpen ? 1 : 0; } + if (body.federatedRegistrationOpen !== undefined) { + if (typeof body.federatedRegistrationOpen !== 'boolean') { + return reply.code(400).send({ error: 'federatedRegistrationOpen must be boolean', statusCode: 400 }); + } + updateData.federatedRegistrationOpen = body.federatedRegistrationOpen ? 1 : 0; + } + if (body.discoveryEnabled !== undefined) { updateData.discoveryEnabled = body.discoveryEnabled ? 1 : 0; } @@ -276,6 +284,7 @@ export async function settingsRoutes(app: FastifyInstance): Promise { const response: InstanceAdminSettings = { instanceName: updatedRow.instanceName ?? 'Backspace', registrationOpen: updatedRow.registrationOpen !== null ? updatedRow.registrationOpen === 1 : config.registrationOpen, + federatedRegistrationOpen: updatedRow.federatedRegistrationOpen === 1, discoveryEnabled: updatedRow.discoveryEnabled === 1, gifApiKey: updatedGifKey ? `****${updatedGifKey.slice(-4)}` : undefined, gifEnabled: !!updatedGifKey, From dbcc399ebbf1ed5799d3e7ee07eec6068172890f Mon Sep 17 00:00:00 2001 From: Jannis Braun <151788261+TheZwiss@users.noreply.github.com> Date: Tue, 28 Apr 2026 21:06:05 +0200 Subject: [PATCH 20/41] test(settings): tighten 'preserves field when omitted' to actually prove preservation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The original test seeded the DB with the schema default (1) and asserted the response was true after a partial PATCH. That passes both for 'untouched' and 'reset to default' — doesn't distinguish them. Now the test toggles the DB column to false BEFORE the PATCH, then asserts the false value survives both in the response AND in the DB row directly. --- packages/server/src/routes/settings.test.ts | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/packages/server/src/routes/settings.test.ts b/packages/server/src/routes/settings.test.ts index eeb9c5c6..010ff532 100644 --- a/packages/server/src/routes/settings.test.ts +++ b/packages/server/src/routes/settings.test.ts @@ -145,14 +145,27 @@ describe('PATCH /api/settings/instance — federatedRegistrationOpen', () => { }); it('leaves federatedRegistrationOpen unchanged when omitted from payload', async () => { + // First toggle the DB column to false. If the field's value comes from the + // schema default (1) instead of the actual DB row, this test would still + // pass for the wrong reason. Toggling to non-default then asserting the + // non-default survives a partial PATCH proves real preservation. + testDb.update(schema.instanceSettings) + .set({ federatedRegistrationOpen: 0 }) + .where(eq(schema.instanceSettings.id, 1)) + .run(); + const res = await app.inject({ method: 'PATCH', url: '/api/settings/instance', payload: { instanceName: 'NewName' }, }); expect(res.statusCode).toBe(200); - // Schema default is 1 → response should still report true - expect(res.json().federatedRegistrationOpen).toBe(true); + // Field still false (the partial PATCH did not touch it) + expect(res.json().federatedRegistrationOpen).toBe(false); expect(res.json().instanceName).toBe('NewName'); + + // Verify against the DB directly to rule out a response-shape-only fix + const row = testDb.select().from(schema.instanceSettings).where(eq(schema.instanceSettings.id, 1)).get(); + expect(row?.federatedRegistrationOpen).toBe(0); }); }); From 50756b3e65d465edd58a48cd33c590cf6cfa8c8e Mon Sep 17 00:00:00 2001 From: Jannis Braun <151788261+TheZwiss@users.noreply.github.com> Date: Tue, 28 Apr 2026 21:08:12 +0200 Subject: [PATCH 21/41] feat(web): add invites + checkInvite to api client --- packages/web/src/api/client.ts | 37 ++++++++++++++++++++++++++++++++++ 1 file changed, 37 insertions(+) diff --git a/packages/web/src/api/client.ts b/packages/web/src/api/client.ts index d80d49e0..e5b9b117 100644 --- a/packages/web/src/api/client.ts +++ b/packages/web/src/api/client.ts @@ -56,6 +56,13 @@ import type { ApprovalRequest, PeeringSubscription, PeeringNotification, + InviteLinkSummary, + InviteRedemption, + CreateInviteRequest, + UpdateInviteRequest, + ReinstateInviteRequest, + ReinstateInviteResponse, + CheckInviteResponse, } from '@backspace/shared'; export type { FederationPeer, ApprovalRequest, PeeringSubscription, PeeringNotification }; @@ -74,6 +81,7 @@ export class BackspaceApiClient { register: (data: RegisterRequest) => Promise; login: (data: LoginRequest) => Promise; checkUsername: (username: string) => Promise<{ available: boolean; reason?: string }>; + checkInvite: (token: string) => Promise; }; readonly users: { @@ -232,6 +240,16 @@ export class BackspaceApiClient { markAllPeeringNotificationsRead: () => Promise<{ success: boolean; count: number }>; }; + readonly invites: { + list: (status?: 'active' | 'archived') => Promise<{ invites: InviteLinkSummary[] }>; + create: (body: CreateInviteRequest) => Promise; + patch: (id: string, body: UpdateInviteRequest) => Promise; + revoke: (id: string) => Promise<{ invite: InviteLinkSummary }>; + reinstate: (id: string, body: ReinstateInviteRequest) => Promise; + delete: (id: string) => Promise<{ success: boolean }>; + redemptions: (id: string) => Promise<{ redemptions: InviteRedemption[] }>; + }; + readonly admin: { storageStats: () => Promise; storageOrphans: () => Promise<{ orphans: OrphanedFile[] }>; @@ -393,6 +411,8 @@ export class BackspaceApiClient { request('POST', '/auth/login', data, false), checkUsername: (username: string) => request<{ available: boolean; reason?: string }>('GET', `/auth/check-username?username=${encodeURIComponent(username)}`, undefined, false), + checkInvite: (token: string) => + request('GET', `/auth/check-invite?token=${encodeURIComponent(token)}`, undefined, false), }; this.users = { @@ -720,6 +740,23 @@ export class BackspaceApiClient { ), }; + this.invites = { + list: (status: 'active' | 'archived' = 'active') => + request<{ invites: InviteLinkSummary[] }>('GET', `/admin/invites?status=${status}`), + create: (body: CreateInviteRequest) => + request('POST', '/admin/invites', body), + patch: (id: string, body: UpdateInviteRequest) => + request('PATCH', `/admin/invites/${id}`, body), + revoke: (id: string) => + request<{ invite: InviteLinkSummary }>('POST', `/admin/invites/${id}/revoke`), + reinstate: (id: string, body: ReinstateInviteRequest) => + request('POST', `/admin/invites/${id}/reinstate`, body), + delete: (id: string) => + request<{ success: boolean }>('DELETE', `/admin/invites/${id}`), + redemptions: (id: string) => + request<{ redemptions: InviteRedemption[] }>('GET', `/admin/invites/${id}/redemptions`), + }; + this.admin = { storageStats: () => request('GET', '/admin/storage/stats'), storageOrphans: () => request<{ orphans: OrphanedFile[] }>('GET', '/admin/storage/orphans'), From 7edfd8ae26ba340fab287c632bc3af639b8ad8ac Mon Sep 17 00:00:00 2001 From: Jannis Braun <151788261+TheZwiss@users.noreply.github.com> Date: Tue, 28 Apr 2026 21:11:43 +0200 Subject: [PATCH 22/41] =?UTF-8?q?refactor(web):=20rename=20invites.patch?= =?UTF-8?q?=20=E2=86=92=20invites.update=20for=20codebase=20consistency?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Other namespaces use 'update' (users.update, spaces.update, channels.update, roles.update); the new 'invites.patch' breaks the pattern. Renamed before Tasks 17-19 import it. Plan updated to match. --- packages/web/src/api/client.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/web/src/api/client.ts b/packages/web/src/api/client.ts index e5b9b117..d6f2927f 100644 --- a/packages/web/src/api/client.ts +++ b/packages/web/src/api/client.ts @@ -243,7 +243,7 @@ export class BackspaceApiClient { readonly invites: { list: (status?: 'active' | 'archived') => Promise<{ invites: InviteLinkSummary[] }>; create: (body: CreateInviteRequest) => Promise; - patch: (id: string, body: UpdateInviteRequest) => Promise; + update: (id: string, body: UpdateInviteRequest) => Promise; revoke: (id: string) => Promise<{ invite: InviteLinkSummary }>; reinstate: (id: string, body: ReinstateInviteRequest) => Promise; delete: (id: string) => Promise<{ success: boolean }>; @@ -745,7 +745,7 @@ export class BackspaceApiClient { request<{ invites: InviteLinkSummary[] }>('GET', `/admin/invites?status=${status}`), create: (body: CreateInviteRequest) => request('POST', '/admin/invites', body), - patch: (id: string, body: UpdateInviteRequest) => + update: (id: string, body: UpdateInviteRequest) => request('PATCH', `/admin/invites/${id}`, body), revoke: (id: string) => request<{ invite: InviteLinkSummary }>('POST', `/admin/invites/${id}/revoke`), From cad6c50bff0d03975783224495c493f8d5fdc237 Mon Sep 17 00:00:00 2001 From: Jannis Braun <151788261+TheZwiss@users.noreply.github.com> Date: Wed, 29 Apr 2026 00:40:04 +0200 Subject: [PATCH 23/41] feat(web): RegistrationPanel scaffold with both registration toggles --- .../RegistrationPanel.tsx | 149 ++++++++++++++++++ .../modals/settingsPanels/InstancePanel.tsx | 5 +- 2 files changed, 153 insertions(+), 1 deletion(-) create mode 100644 packages/web/src/components/modals/instanceSettingsPanels/RegistrationPanel.tsx diff --git a/packages/web/src/components/modals/instanceSettingsPanels/RegistrationPanel.tsx b/packages/web/src/components/modals/instanceSettingsPanels/RegistrationPanel.tsx new file mode 100644 index 00000000..36f999d9 --- /dev/null +++ b/packages/web/src/components/modals/instanceSettingsPanels/RegistrationPanel.tsx @@ -0,0 +1,149 @@ +import { useEffect, useState } from 'react'; +import { useSettingsStore } from '../../../stores/settingsStore'; +import { useUIStore } from '../../../stores/uiStore'; +import { Toggle } from '../../ui/Toggle'; + +interface RegistrationDraft { + registrationOpen: boolean; + federatedRegistrationOpen: boolean; +} + +export function RegistrationPanel() { + const instanceSettings = useSettingsStore((s) => s.instanceSettings); + const updateInstanceSettings = useSettingsStore((s) => s.updateInstanceSettings); + const addToast = useUIStore((s) => s.addToast); + + const [draft, setDraft] = useState(null); + const [saving, setSaving] = useState(false); + const [saveError, setSaveError] = useState(''); + + useEffect(() => { + if (instanceSettings) { + setDraft({ + registrationOpen: instanceSettings.registrationOpen, + federatedRegistrationOpen: instanceSettings.federatedRegistrationOpen, + }); + setSaveError(''); + } + }, [instanceSettings]); + + if (!draft) return
Loading settings...
; + + const hasChanges = !!instanceSettings && ( + draft.registrationOpen !== instanceSettings.registrationOpen || + draft.federatedRegistrationOpen !== instanceSettings.federatedRegistrationOpen + ); + + const handleSave = async () => { + setSaving(true); + setSaveError(''); + try { + await updateInstanceSettings({ + registrationOpen: draft.registrationOpen, + federatedRegistrationOpen: draft.federatedRegistrationOpen, + }); + addToast('Registration settings saved', 'success', 2000); + } catch (err) { + const message = err instanceof Error ? err.message : 'Failed to save'; + setSaveError(message); + addToast('Failed to update registration settings', 'warning'); + } finally { + setSaving(false); + } + }; + + const handleReset = () => { + if (instanceSettings) { + setDraft({ + registrationOpen: instanceSettings.registrationOpen, + federatedRegistrationOpen: instanceSettings.federatedRegistrationOpen, + }); + } + setSaveError(''); + }; + + return ( +
e.preventDefault()}> +

Registration

+
+ Control who can create accounts on this instance. Public registration covers local + sign-ups; federated registration covers users from peered instances creating an account here. +
+ + {/* Public registration */} +
+
Public Registration
+
+ +
+
+ + {/* Federated registration */} +
+
Federated Registration
+
+ +
+
+ + {/* Invite Links — populated in subsequent tasks */} +
+
Invite Links
+
+ Invite-link UI will appear here. +
+
+ + {/* Status messages */} + {saveError && ( +
{saveError}
+ )} + {/* Save / Reset bar */} + {hasChanges && ( +
+
+
+ + +
+
+
+ )} +
+ ); +} diff --git a/packages/web/src/components/modals/settingsPanels/InstancePanel.tsx b/packages/web/src/components/modals/settingsPanels/InstancePanel.tsx index 16e5ffc2..d9d4f23b 100644 --- a/packages/web/src/components/modals/settingsPanels/InstancePanel.tsx +++ b/packages/web/src/components/modals/settingsPanels/InstancePanel.tsx @@ -4,12 +4,13 @@ import { useSettingsSections } from '../../../hooks/useSettingsSections'; import type { SettingsSection } from '../SettingsSectionsContext'; import { SettingsTabBar } from '../SettingsTabBar'; import { GeneralPanel } from '../instanceSettingsPanels/GeneralPanel'; +import { RegistrationPanel } from '../instanceSettingsPanels/RegistrationPanel'; import { FederationPanel } from '../instanceSettingsPanels/FederationPanel'; import { StreamingPanel } from '../instanceSettingsPanels/StreamingPanel'; import { StoragePanel } from '../instanceSettingsPanels/StoragePanel'; import { UsersPanel } from '../instanceSettingsPanels/UsersPanel'; -type SubTab = 'general' | 'federation' | 'streaming' | 'storage' | 'users'; +type SubTab = 'general' | 'registration' | 'federation' | 'streaming' | 'storage' | 'users'; export function InstancePanel() { const fetchInstanceSettings = useSettingsStore((s) => s.fetchInstanceSettings); @@ -20,6 +21,7 @@ export function InstancePanel() { const sections = useMemo(() => [ { id: 'general', label: 'General' }, + { id: 'registration', label: 'Registration' }, { id: 'federation', label: 'Federation', badgeCount: approvalCount }, { id: 'streaming', label: 'Streaming' }, { id: 'storage', label: 'Storage' }, @@ -43,6 +45,7 @@ export function InstancePanel() { {subTab === 'general' && } + {subTab === 'registration' && } {subTab === 'federation' && } {subTab === 'streaming' && } {subTab === 'storage' && } From 7dd6bd6263ec09925800aca0e4c537147998f57e Mon Sep 17 00:00:00 2001 From: Jannis Braun <151788261+TheZwiss@users.noreply.github.com> Date: Wed, 29 Apr 2026 00:44:35 +0200 Subject: [PATCH 24/41] refactor(web): move registrationOpen toggle out of GeneralPanel --- .../instanceSettingsPanels/GeneralPanel.tsx | 16 ---------------- 1 file changed, 16 deletions(-) diff --git a/packages/web/src/components/modals/instanceSettingsPanels/GeneralPanel.tsx b/packages/web/src/components/modals/instanceSettingsPanels/GeneralPanel.tsx index 0c1d9b60..c8661997 100644 --- a/packages/web/src/components/modals/instanceSettingsPanels/GeneralPanel.tsx +++ b/packages/web/src/components/modals/instanceSettingsPanels/GeneralPanel.tsx @@ -28,7 +28,6 @@ export function GeneralPanel() { const baseChanges = instanceSettings && draft ? draft.instanceName !== instanceSettings.instanceName || - draft.registrationOpen !== instanceSettings.registrationOpen || draft.discoveryEnabled !== instanceSettings.discoveryEnabled : false; const hasChanges = baseChanges || gifKeyDirty; @@ -39,7 +38,6 @@ export function GeneralPanel() { try { const payload: Partial = { instanceName: draft!.instanceName, - registrationOpen: draft!.registrationOpen, discoveryEnabled: draft!.discoveryEnabled, }; if (gifKeyDirty) { @@ -86,20 +84,6 @@ export function GeneralPanel() { - {/* Registration */} -
-
Registration
-
- -
-
- {/* Discovery */}
Discovery
From dcd3daf3902a5d5d7517abd567c82c02142ed52c Mon Sep 17 00:00:00 2001 From: Jannis Braun <151788261+TheZwiss@users.noreply.github.com> Date: Wed, 29 Apr 2026 00:53:39 +0200 Subject: [PATCH 25/41] feat(web): RegistrationPanel invite-list with Active/Archived tabs --- .../RegistrationPanel.tsx | 173 +++++++++++++++++- 1 file changed, 168 insertions(+), 5 deletions(-) diff --git a/packages/web/src/components/modals/instanceSettingsPanels/RegistrationPanel.tsx b/packages/web/src/components/modals/instanceSettingsPanels/RegistrationPanel.tsx index 36f999d9..3bada018 100644 --- a/packages/web/src/components/modals/instanceSettingsPanels/RegistrationPanel.tsx +++ b/packages/web/src/components/modals/instanceSettingsPanels/RegistrationPanel.tsx @@ -1,4 +1,6 @@ -import { useEffect, useState } from 'react'; +import { useCallback, useEffect, useState } from 'react'; +import type { InviteLinkSummary } from '@backspace/shared'; +import { api } from '../../../api/client'; import { useSettingsStore } from '../../../stores/settingsStore'; import { useUIStore } from '../../../stores/uiStore'; import { Toggle } from '../../ui/Toggle'; @@ -8,6 +10,108 @@ interface RegistrationDraft { federatedRegistrationOpen: boolean; } +function formatRelative(ms: number): string { + const diff = Date.now() - ms; + const days = Math.floor(diff / 86_400_000); + if (days >= 1) return `${days}d ago`; + const hours = Math.floor(diff / 3_600_000); + if (hours >= 1) return `${hours}h ago`; + const mins = Math.floor(diff / 60_000); + if (mins >= 1) return `${mins}m ago`; + return 'just now'; +} + +function formatExpiry(invite: InviteLinkSummary): string { + if (invite.status === 'revoked' && invite.revokedAt) { + return `Revoked ${new Date(invite.revokedAt).toLocaleDateString()}`; + } + if (invite.status === 'expired' && invite.expiresAt) { + return `Expired ${new Date(invite.expiresAt).toLocaleDateString()}`; + } + if (invite.status === 'exhausted') { + return 'Exhausted'; + } + if (invite.expiresAt === null) return 'No expiration'; + const remaining = invite.expiresAt - Date.now(); + if (remaining <= 0) return `Expired ${new Date(invite.expiresAt).toLocaleDateString()}`; + const days = Math.floor(remaining / 86_400_000); + if (days >= 1) return `Expires in ${days} day${days === 1 ? '' : 's'}`; + const hours = Math.floor(remaining / 3_600_000); + return `Expires in ${hours}h`; +} + +interface InviteRowProps { + invite: InviteLinkSummary; + onMutate: () => void; +} + +function InviteRow({ invite }: InviteRowProps) { + const addToast = useUIStore((s) => s.addToast); + + const usageLabel = + invite.maxUses === null + ? `${invite.usedCount} use${invite.usedCount === 1 ? '' : 's'} · unlimited` + : `${invite.usedCount} / ${invite.maxUses} uses`; + + const usageNearLimit = + invite.maxUses !== null && invite.maxUses > 0 && invite.usedCount / invite.maxUses >= 0.8; + + const handleCopy = async () => { + try { + await navigator.clipboard.writeText(invite.url); + addToast('Invite link copied', 'success', 2000); + } catch { + addToast('Failed to copy link', 'warning'); + } + }; + + const statusPillClass = + invite.status === 'expired' + ? 'bg-rose-500/20 text-rose-400' + : invite.status === 'exhausted' + ? 'bg-amber-500/20 text-amber-400' + : 'bg-white/10 text-txt-tertiary'; + + return ( +
+
+
+ + {invite.name} + + + · {usageLabel} + + {invite.status !== 'active' && ( + + {invite.status} + + )} +
+
+ {formatExpiry(invite)} · Created by {invite.createdByUsername ?? 'Unknown'} ·{' '} + {formatRelative(invite.createdAt)} +
+
+
+ {invite.status === 'active' && ( + + )} +
+
+ ); +} + export function RegistrationPanel() { const instanceSettings = useSettingsStore((s) => s.instanceSettings); const updateInstanceSettings = useSettingsStore((s) => s.updateInstanceSettings); @@ -17,6 +121,25 @@ export function RegistrationPanel() { const [saving, setSaving] = useState(false); const [saveError, setSaveError] = useState(''); + const [tab, setTab] = useState<'active' | 'archived'>('active'); + const [invites, setInvites] = useState([]); + const [invitesLoading, setInvitesLoading] = useState(false); + + const fetchInvites = useCallback( + async (which: 'active' | 'archived') => { + setInvitesLoading(true); + try { + const res = await api.invites.list(which); + setInvites(res.invites); + } catch { + addToast('Failed to load invites', 'warning'); + } finally { + setInvitesLoading(false); + } + }, + [addToast], + ); + useEffect(() => { if (instanceSettings) { setDraft({ @@ -27,6 +150,10 @@ export function RegistrationPanel() { } }, [instanceSettings]); + useEffect(() => { + fetchInvites(tab); + }, [tab, fetchInvites]); + if (!draft) return
Loading settings...
; const hasChanges = !!instanceSettings && ( @@ -110,12 +237,48 @@ export function RegistrationPanel() {
- {/* Invite Links — populated in subsequent tasks */} + {/* Invite Links */}
-
Invite Links
-
- Invite-link UI will appear here. +
+
Invite Links
+
+ +
+ {(['active', 'archived'] as const).map((t) => ( + + ))} +
+ + {invitesLoading ? ( +
Loading...
+ ) : invites.length === 0 ? ( +
+ {tab === 'active' ? 'No active invite links.' : 'No archived invite links.'} +
+ ) : ( +
+ {invites.map((inv) => ( + fetchInvites(tab)} /> + ))} +
+ )}
{/* Status messages */} From 42d1be54fae0ff534a2b7a069a918e176698e832 Mon Sep 17 00:00:00 2001 From: Jannis Braun <151788261+TheZwiss@users.noreply.github.com> Date: Wed, 29 Apr 2026 00:57:57 +0200 Subject: [PATCH 26/41] fix(web): guard RegistrationPanel invite-list against stale-fetch race --- .../RegistrationPanel.tsx | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) diff --git a/packages/web/src/components/modals/instanceSettingsPanels/RegistrationPanel.tsx b/packages/web/src/components/modals/instanceSettingsPanels/RegistrationPanel.tsx index 3bada018..829d0284 100644 --- a/packages/web/src/components/modals/instanceSettingsPanels/RegistrationPanel.tsx +++ b/packages/web/src/components/modals/instanceSettingsPanels/RegistrationPanel.tsx @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useState } from 'react'; +import { useCallback, useEffect, useRef, useState } from 'react'; import type { InviteLinkSummary } from '@backspace/shared'; import { api } from '../../../api/client'; import { useSettingsStore } from '../../../stores/settingsStore'; @@ -125,16 +125,27 @@ export function RegistrationPanel() { const [invites, setInvites] = useState([]); const [invitesLoading, setInvitesLoading] = useState(false); + // Tracks the currently displayed tab so in-flight fetches can detect when + // the user has switched tabs and discard their stale response. Without this + // guard, a slower 'archived' response can resolve after a newer 'active' + // response and clobber the visible list. Used by both the auto-load effect + // and manual fetchInvites() callers (e.g. post-mutation refresh). + const tabRef = useRef<'active' | 'archived'>(tab); + useEffect(() => { + tabRef.current = tab; + }, [tab]); + const fetchInvites = useCallback( async (which: 'active' | 'archived') => { setInvitesLoading(true); try { const res = await api.invites.list(which); + if (tabRef.current !== which) return; setInvites(res.invites); } catch { - addToast('Failed to load invites', 'warning'); + if (tabRef.current === which) addToast('Failed to load invites', 'warning'); } finally { - setInvitesLoading(false); + if (tabRef.current === which) setInvitesLoading(false); } }, [addToast], From 234ec8e70d78f7a1780f91a9cb1db30106beba44 Mon Sep 17 00:00:00 2001 From: Jannis Braun <151788261+TheZwiss@users.noreply.github.com> Date: Wed, 29 Apr 2026 01:06:48 +0200 Subject: [PATCH 27/41] feat(web): RegistrationPanel Create-invite modal --- .../RegistrationPanel.tsx | 211 ++++++++++++++++++ 1 file changed, 211 insertions(+) diff --git a/packages/web/src/components/modals/instanceSettingsPanels/RegistrationPanel.tsx b/packages/web/src/components/modals/instanceSettingsPanels/RegistrationPanel.tsx index 829d0284..e544a82e 100644 --- a/packages/web/src/components/modals/instanceSettingsPanels/RegistrationPanel.tsx +++ b/packages/web/src/components/modals/instanceSettingsPanels/RegistrationPanel.tsx @@ -1,3 +1,4 @@ +import { createPortal } from 'react-dom'; import { useCallback, useEffect, useRef, useState } from 'react'; import type { InviteLinkSummary } from '@backspace/shared'; import { api } from '../../../api/client'; @@ -112,6 +113,205 @@ function InviteRow({ invite }: InviteRowProps) { ); } +const EXPIRY_PRESETS: ReadonlyArray<{ label: string; ms: number | null }> = [ + { label: '1 hour', ms: 3_600_000 }, + { label: '24 hours', ms: 86_400_000 }, + { label: '7 days', ms: 7 * 86_400_000 }, + { label: '30 days', ms: 30 * 86_400_000 }, + { label: 'Never', ms: null }, +]; + +interface CreateInviteModalProps { + onClose: () => void; + onCreated: (created: InviteLinkSummary) => void; +} + +function CreateInviteModal({ onClose, onCreated }: CreateInviteModalProps) { + const addToast = useUIStore((s) => s.addToast); + const [name, setName] = useState(''); + const [unlimited, setUnlimited] = useState(true); + const [maxUses, setMaxUses] = useState('1'); + const [expiryPreset, setExpiryPreset] = useState(7 * 86_400_000); + const [submitting, setSubmitting] = useState(false); + const nameInputRef = useRef(null); + + // Auto-focus name input on mount + useEffect(() => { + nameInputRef.current?.focus(); + }, []); + + // Escape closes the modal (capture phase so it fires before parent handlers) + useEffect(() => { + const handleKey = (e: KeyboardEvent) => { + if (e.key === 'Escape' && !submitting) { + e.stopPropagation(); + onClose(); + } + }; + document.addEventListener('keydown', handleKey, true); + return () => document.removeEventListener('keydown', handleKey, true); + }, [onClose, submitting]); + + const handleCreate = async () => { + const trimmed = name.trim(); + if (trimmed.length === 0 || trimmed.length > 64) { + addToast('Name must be 1–64 characters', 'warning'); + return; + } + let maxUsesNum: number | null = null; + if (!unlimited) { + const parsed = Number(maxUses); + if (!Number.isInteger(parsed) || parsed < 1) { + addToast('Max uses must be a positive integer', 'warning'); + return; + } + maxUsesNum = parsed; + } + const expiresAt = expiryPreset === null ? null : Date.now() + expiryPreset; + + setSubmitting(true); + try { + const created = await api.invites.create({ name: trimmed, maxUses: maxUsesNum, expiresAt }); + try { + await navigator.clipboard.writeText(created.url); + addToast('Link created. Copied to clipboard.', 'success', 2000); + } catch { + addToast('Link created. Copy manually from the row.', 'success', 2000); + } + onCreated(created); + onClose(); + } catch (err) { + addToast(`Failed to create invite: ${(err as Error).message}`, 'warning'); + } finally { + setSubmitting(false); + } + }; + + return createPortal( +
+ {/* Backdrop */} +
+ + {/* Modal panel — stop propagation so backdrop click doesn't fire inside */} +
e.stopPropagation()} + > +
{ + e.preventDefault(); + handleCreate(); + }} + className="p-6 space-y-5" + > +

Create invite link

+ + {/* Name */} +
+ + setName(e.target.value)} + maxLength={64} + placeholder="e.g. Friends batch 1" + className="input-standard w-full" + disabled={submitting} + /> +
+ + {/* Max uses */} +
+ +
+ + +
+
+ + {/* Expiry */} +
+ +
+ {EXPIRY_PRESETS.map((p) => ( + + ))} +
+
+ + {/* Actions */} +
+ + +
+
+
+
, + document.body, + ); +} + export function RegistrationPanel() { const instanceSettings = useSettingsStore((s) => s.instanceSettings); const updateInstanceSettings = useSettingsStore((s) => s.updateInstanceSettings); @@ -120,6 +320,7 @@ export function RegistrationPanel() { const [draft, setDraft] = useState(null); const [saving, setSaving] = useState(false); const [saveError, setSaveError] = useState(''); + const [showCreate, setShowCreate] = useState(false); const [tab, setTab] = useState<'active' | 'archived'>('active'); const [invites, setInvites] = useState([]); @@ -201,6 +402,7 @@ export function RegistrationPanel() { }; return ( + <>
e.preventDefault()}>

Registration

@@ -254,6 +456,7 @@ export function RegistrationPanel() {
Invite Links
)}
+ + {showCreate && ( + setShowCreate(false)} + onCreated={() => fetchInvites('active')} + /> + )} + ); } From ba5c84fc6b3ad25c16febf9cabd9a19191210b5c Mon Sep 17 00:00:00 2001 From: Jannis Braun <151788261+TheZwiss@users.noreply.github.com> Date: Wed, 29 Apr 2026 01:13:22 +0200 Subject: [PATCH 28/41] =?UTF-8?q?fix(web):=20add=20Custom=E2=80=A6=20expir?= =?UTF-8?q?y=20preset=20to=20CreateInviteModal=20per=20spec=20=C2=A74.2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../RegistrationPanel.tsx | 74 ++++++++++++++++--- 1 file changed, 63 insertions(+), 11 deletions(-) diff --git a/packages/web/src/components/modals/instanceSettingsPanels/RegistrationPanel.tsx b/packages/web/src/components/modals/instanceSettingsPanels/RegistrationPanel.tsx index e544a82e..5a2306c3 100644 --- a/packages/web/src/components/modals/instanceSettingsPanels/RegistrationPanel.tsx +++ b/packages/web/src/components/modals/instanceSettingsPanels/RegistrationPanel.tsx @@ -113,14 +113,30 @@ function InviteRow({ invite }: InviteRowProps) { ); } -const EXPIRY_PRESETS: ReadonlyArray<{ label: string; ms: number | null }> = [ - { label: '1 hour', ms: 3_600_000 }, - { label: '24 hours', ms: 86_400_000 }, - { label: '7 days', ms: 7 * 86_400_000 }, - { label: '30 days', ms: 30 * 86_400_000 }, - { label: 'Never', ms: null }, +type ExpiryPresetId = '1h' | '24h' | '7d' | '30d' | 'never' | 'custom'; + +const EXPIRY_PRESETS: ReadonlyArray<{ id: ExpiryPresetId; label: string; ms: number | null }> = [ + { id: '1h', label: '1 hour', ms: 3_600_000 }, + { id: '24h', label: '24 hours', ms: 86_400_000 }, + { id: '7d', label: '7 days', ms: 7 * 86_400_000 }, + { id: '30d', label: '30 days', ms: 30 * 86_400_000 }, + { id: 'never', label: 'Never', ms: null }, + // Custom uses a free-form datetime input rendered below the preset row; + // ms is intentionally null and ignored for this id. + { id: 'custom', label: 'Custom…', ms: null }, ]; +/** + * Format a millisecond timestamp as a value suitable for ``. + * The input expects local-wall-clock time in `YYYY-MM-DDTHH:mm` format (no timezone suffix); + * the browser then interprets it in the user's local timezone on read-back via `new Date(value)`. + */ +function toDatetimeLocalValue(ms: number): string { + const d = new Date(ms); + const pad = (n: number) => String(n).padStart(2, '0'); + return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`; +} + interface CreateInviteModalProps { onClose: () => void; onCreated: (created: InviteLinkSummary) => void; @@ -131,7 +147,8 @@ function CreateInviteModal({ onClose, onCreated }: CreateInviteModalProps) { const [name, setName] = useState(''); const [unlimited, setUnlimited] = useState(true); const [maxUses, setMaxUses] = useState('1'); - const [expiryPreset, setExpiryPreset] = useState(7 * 86_400_000); + const [expiryId, setExpiryId] = useState('7d'); + const [customDateTime, setCustomDateTime] = useState(''); const [submitting, setSubmitting] = useState(false); const nameInputRef = useRef(null); @@ -167,7 +184,32 @@ function CreateInviteModal({ onClose, onCreated }: CreateInviteModalProps) { } maxUsesNum = parsed; } - const expiresAt = expiryPreset === null ? null : Date.now() + expiryPreset; + + // Resolve expiresAt from the preset selection. Custom requires a non-empty, + // strictly-future datetime; everything else is derived from the preset's ms offset. + let expiresAt: number | null; + if (expiryId === 'never') { + expiresAt = null; + } else if (expiryId === 'custom') { + if (customDateTime === '') { + addToast('Pick a future date & time', 'warning'); + return; + } + const ts = new Date(customDateTime).getTime(); + if (!Number.isFinite(ts) || ts <= Date.now()) { + addToast('Pick a future date & time', 'warning'); + return; + } + expiresAt = ts; + } else { + const preset = EXPIRY_PRESETS.find((p) => p.id === expiryId); + if (!preset || preset.ms === null) { + // Unreachable: only 'never'/'custom' have null ms, and both are handled above. + addToast('Invalid expiry selection', 'warning'); + return; + } + expiresAt = Date.now() + preset.ms; + } setSubmitting(true); try { @@ -271,12 +313,12 @@ function CreateInviteModal({ onClose, onCreated }: CreateInviteModalProps) {
{EXPIRY_PRESETS.map((p) => ( ))}
+ {expiryId === 'custom' && ( + setCustomDateTime(e.target.value)} + min={toDatetimeLocalValue(Date.now() + 60_000)} + disabled={submitting} + className="input-standard w-full px-3 py-2 text-sm mt-2" + /> + )}
{/* Actions */} From bde4d829ef78917ff4109242fec519b007ab81cd Mon Sep 17 00:00:00 2001 From: Jannis Braun <151788261+TheZwiss@users.noreply.github.com> Date: Wed, 29 Apr 2026 01:21:14 +0200 Subject: [PATCH 29/41] feat(web): RegistrationPanel Edit/Revoke/Reinstate/Delete + Redemptions --- .../RegistrationPanel.tsx | 1095 +++++++++++++++-- 1 file changed, 968 insertions(+), 127 deletions(-) diff --git a/packages/web/src/components/modals/instanceSettingsPanels/RegistrationPanel.tsx b/packages/web/src/components/modals/instanceSettingsPanels/RegistrationPanel.tsx index 5a2306c3..b8c48f19 100644 --- a/packages/web/src/components/modals/instanceSettingsPanels/RegistrationPanel.tsx +++ b/packages/web/src/components/modals/instanceSettingsPanels/RegistrationPanel.tsx @@ -1,10 +1,11 @@ import { createPortal } from 'react-dom'; import { useCallback, useEffect, useRef, useState } from 'react'; -import type { InviteLinkSummary } from '@backspace/shared'; +import type { InviteLinkSummary, InviteRedemption } from '@backspace/shared'; import { api } from '../../../api/client'; import { useSettingsStore } from '../../../stores/settingsStore'; import { useUIStore } from '../../../stores/uiStore'; import { Toggle } from '../../ui/Toggle'; +import { ConfirmDialog } from '../../ui/ConfirmDialog'; interface RegistrationDraft { registrationOpen: boolean; @@ -41,80 +42,11 @@ function formatExpiry(invite: InviteLinkSummary): string { return `Expires in ${hours}h`; } -interface InviteRowProps { - invite: InviteLinkSummary; - onMutate: () => void; -} - -function InviteRow({ invite }: InviteRowProps) { - const addToast = useUIStore((s) => s.addToast); - - const usageLabel = - invite.maxUses === null - ? `${invite.usedCount} use${invite.usedCount === 1 ? '' : 's'} · unlimited` - : `${invite.usedCount} / ${invite.maxUses} uses`; - - const usageNearLimit = - invite.maxUses !== null && invite.maxUses > 0 && invite.usedCount / invite.maxUses >= 0.8; - - const handleCopy = async () => { - try { - await navigator.clipboard.writeText(invite.url); - addToast('Invite link copied', 'success', 2000); - } catch { - addToast('Failed to copy link', 'warning'); - } - }; - - const statusPillClass = - invite.status === 'expired' - ? 'bg-rose-500/20 text-rose-400' - : invite.status === 'exhausted' - ? 'bg-amber-500/20 text-amber-400' - : 'bg-white/10 text-txt-tertiary'; - - return ( -
-
-
- - {invite.name} - - - · {usageLabel} - - {invite.status !== 'active' && ( - - {invite.status} - - )} -
-
- {formatExpiry(invite)} · Created by {invite.createdByUsername ?? 'Unknown'} ·{' '} - {formatRelative(invite.createdAt)} -
-
-
- {invite.status === 'active' && ( - - )} -
-
- ); -} - type ExpiryPresetId = '1h' | '24h' | '7d' | '30d' | 'never' | 'custom'; +/** Edit modal additionally supports a 'keep' option (don't change expiry on PATCH). */ +type EditExpiryId = 'keep' | ExpiryPresetId; + const EXPIRY_PRESETS: ReadonlyArray<{ id: ExpiryPresetId; label: string; ms: number | null }> = [ { id: '1h', label: '1 hour', ms: 3_600_000 }, { id: '24h', label: '24 hours', ms: 86_400_000 }, @@ -137,6 +69,109 @@ function toDatetimeLocalValue(ms: number): string { return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`; } +interface ExpirySelectorProps { + value: EditExpiryId; + customDateTime: string; + onChange: (value: EditExpiryId, customDateTime: string) => void; + /** When true, prepends a "Keep current" pill (used by Edit). Create/Reinstate omit it. */ + showKeep: boolean; + disabled?: boolean; +} + +/** + * Shared expiry preset row + custom datetime input. Used by Create, Edit, and Reinstate + * modals so the picker UX stays consistent across all three flows. + * + * Resolution rules (applied by callers via `resolveExpiryFromSelector` below): + * 'keep' → omit `expiresAt` from request body (Edit only) + * 'never' → expiresAt: null + * 'custom' → expiresAt: new Date(customDateTime).getTime() (validated by caller) + * preset → expiresAt: Date.now() + preset.ms + */ +function ExpirySelector({ value, customDateTime, onChange, showKeep, disabled }: ExpirySelectorProps) { + return ( +
+ +
+ {showKeep && ( + + )} + {EXPIRY_PRESETS.map((p) => ( + + ))} +
+ {value === 'custom' && ( + onChange('custom', e.target.value)} + min={toDatetimeLocalValue(Date.now() + 60_000)} + disabled={disabled} + className="input-standard w-full px-3 py-2 text-sm mt-2" + /> + )} +
+ ); +} + +/** + * Resolve an `ExpirySelector` selection into a `expiresAt` timestamp for API bodies. + * + * Returns one of: + * - `{ kind: 'omit' }` — caller should NOT include `expiresAt` in the body (Edit "Keep current") + * - `{ kind: 'value', expiresAt: number | null }` — caller sets `body.expiresAt = expiresAt` + * - `{ kind: 'invalid', message: string }` — caller should toast the message and abort + */ +function resolveExpiryFromSelector( + value: EditExpiryId, + customDateTime: string, +): + | { kind: 'omit' } + | { kind: 'value'; expiresAt: number | null } + | { kind: 'invalid'; message: string } { + if (value === 'keep') return { kind: 'omit' }; + if (value === 'never') return { kind: 'value', expiresAt: null }; + if (value === 'custom') { + if (customDateTime === '') { + return { kind: 'invalid', message: 'Pick a future date & time' }; + } + const ts = new Date(customDateTime).getTime(); + if (!Number.isFinite(ts) || ts <= Date.now()) { + return { kind: 'invalid', message: 'Pick a future date & time' }; + } + return { kind: 'value', expiresAt: ts }; + } + const preset = EXPIRY_PRESETS.find((p) => p.id === value); + if (!preset || preset.ms === null) { + // Unreachable: 'never' and 'custom' are handled above; remaining ids all carry an ms. + return { kind: 'invalid', message: 'Invalid expiry selection' }; + } + return { kind: 'value', expiresAt: Date.now() + preset.ms }; +} + interface CreateInviteModalProps { onClose: () => void; onCreated: (created: InviteLinkSummary) => void; @@ -185,31 +220,19 @@ function CreateInviteModal({ onClose, onCreated }: CreateInviteModalProps) { maxUsesNum = parsed; } - // Resolve expiresAt from the preset selection. Custom requires a non-empty, - // strictly-future datetime; everything else is derived from the preset's ms offset. - let expiresAt: number | null; - if (expiryId === 'never') { - expiresAt = null; - } else if (expiryId === 'custom') { - if (customDateTime === '') { - addToast('Pick a future date & time', 'warning'); - return; - } - const ts = new Date(customDateTime).getTime(); - if (!Number.isFinite(ts) || ts <= Date.now()) { - addToast('Pick a future date & time', 'warning'); - return; - } - expiresAt = ts; - } else { - const preset = EXPIRY_PRESETS.find((p) => p.id === expiryId); - if (!preset || preset.ms === null) { - // Unreachable: only 'never'/'custom' have null ms, and both are handled above. - addToast('Invalid expiry selection', 'warning'); - return; - } - expiresAt = Date.now() + preset.ms; + // Create never uses the 'keep' option — it always sets a concrete expiry. + const resolved = resolveExpiryFromSelector(expiryId, customDateTime); + if (resolved.kind === 'invalid') { + addToast(resolved.message, 'warning'); + return; } + if (resolved.kind === 'omit') { + // Unreachable: ExpirySelector for Create is rendered with showKeep={false}, so + // 'keep' cannot be selected. Defensive guard so future refactors fail loudly. + addToast('Invalid expiry selection', 'warning'); + return; + } + const expiresAt = resolved.expiresAt; setSubmitting(true); try { @@ -308,36 +331,19 @@ function CreateInviteModal({ onClose, onCreated }: CreateInviteModalProps) {
{/* Expiry */} -
- -
- {EXPIRY_PRESETS.map((p) => ( - - ))} -
- {expiryId === 'custom' && ( - setCustomDateTime(e.target.value)} - min={toDatetimeLocalValue(Date.now() + 60_000)} - disabled={submitting} - className="input-standard w-full px-3 py-2 text-sm mt-2" - /> - )} -
+ { + // Create's expiryId state is the narrower ExpiryPresetId; 'keep' cannot + // be returned because never renders it. + if (v === 'keep') return; + setExpiryId(v); + setCustomDateTime(dt); + }} + showKeep={false} + disabled={submitting} + /> {/* Actions */}
@@ -364,6 +370,841 @@ function CreateInviteModal({ onClose, onCreated }: CreateInviteModalProps) { ); } +interface EditInviteModalProps { + invite: InviteLinkSummary; + onClose: () => void; + onUpdated: () => void; +} + +/** + * Edit modal — same shape as Create, pre-filled. Only sends fields the user actually changed + * (no-op churn avoidance). Disallowed for revoked rows (the row hides the Edit button entirely + * — Reinstate is the only path back from revoked). + */ +function EditInviteModal({ invite, onClose, onUpdated }: EditInviteModalProps) { + const addToast = useUIStore((s) => s.addToast); + const [name, setName] = useState(invite.name); + const [unlimited, setUnlimited] = useState(invite.maxUses === null); + const [maxUses, setMaxUses] = useState(invite.maxUses?.toString() ?? '1'); + const [expiryId, setExpiryId] = useState('keep'); + const [customDateTime, setCustomDateTime] = useState(''); + const [submitting, setSubmitting] = useState(false); + const nameInputRef = useRef(null); + + // Auto-focus name input on mount + useEffect(() => { + nameInputRef.current?.focus(); + }, []); + + // Escape closes the modal (capture phase so it fires before the parent settings modal handler) + useEffect(() => { + const handleKey = (e: KeyboardEvent) => { + if (e.key === 'Escape' && !submitting) { + e.stopPropagation(); + onClose(); + } + }; + document.addEventListener('keydown', handleKey, true); + return () => document.removeEventListener('keydown', handleKey, true); + }, [onClose, submitting]); + + const handleSave = async () => { + const trimmed = name.trim(); + if (trimmed.length === 0 || trimmed.length > 64) { + addToast('Name must be 1–64 characters', 'warning'); + return; + } + + // Validate maxUses input only when the user has selected limited mode. + // Server requires `maxUses >= usedCount` (a server-side floor of 1 still applies). + let newMax: number | null = null; + if (!unlimited) { + const parsed = Number(maxUses); + if (!Number.isInteger(parsed) || parsed < 1) { + addToast('Max uses must be a positive integer', 'warning'); + return; + } + if (parsed < invite.usedCount) { + addToast( + `Max uses cannot be less than current uses (${invite.usedCount})`, + 'warning', + ); + return; + } + newMax = parsed; + } + + // Build a partial body — only include fields the user actually changed. + const body: { name?: string; maxUses?: number | null; expiresAt?: number | null } = {}; + if (trimmed !== invite.name) body.name = trimmed; + if (newMax !== invite.maxUses) body.maxUses = newMax; + + const resolved = resolveExpiryFromSelector(expiryId, customDateTime); + if (resolved.kind === 'invalid') { + addToast(resolved.message, 'warning'); + return; + } + if (resolved.kind === 'value') { + body.expiresAt = resolved.expiresAt; + } + // 'omit' (Keep current) → leave expiresAt off the body entirely. + + if (Object.keys(body).length === 0) { + addToast('No changes to save', 'warning'); + return; + } + + setSubmitting(true); + try { + await api.invites.update(invite.id, body); + addToast('Invite updated', 'success', 2000); + onUpdated(); + onClose(); + } catch (err) { + addToast(`Failed to update invite: ${(err as Error).message}`, 'warning'); + } finally { + setSubmitting(false); + } + }; + + // Floor for the maxUses input — at least 1, but also at least usedCount so the + // browser native validation matches the server's constraint. + const maxUsesMin = Math.max(1, invite.usedCount); + + return createPortal( +
+
+ +
e.stopPropagation()} + > +
{ + e.preventDefault(); + handleSave(); + }} + className="p-6 space-y-5" + > +

Edit "{invite.name}"

+ + {/* Name */} +
+ + setName(e.target.value)} + maxLength={64} + className="input-standard w-full" + disabled={submitting} + /> +
+ + {/* Max uses */} +
+ +
+ + +
+
+ + {/* Expiry */} + { + setExpiryId(v); + setCustomDateTime(dt); + }} + showKeep={true} + disabled={submitting} + /> + + {/* Actions */} +
+ + +
+ +
+
, + document.body, + ); +} + +interface ReinstateInviteModalProps { + invite: InviteLinkSummary; + onClose: () => void; + onReinstated: () => void; +} + +/** + * Reinstate modal. Two visual variants share one component: + * - Variant A (revoked): warns that a NEW link will be generated; old URL stays dead. + * - Variant B (expired/exhausted): same URL becomes active again. + * + * Both variants always set a fresh expiry (the user is reactivating something whose + * expiry has, by definition, lapsed or is being re-set). Server's reinstate handler + * requires `maxUses > usedCount` for exhausted invites, hence the input min of usedCount+1. + * + * Toast and submit-label copy are derived from the response's `tokenRotated` flag and + * the invite's pre-action status, respectively, per spec §4.2. + */ +function ReinstateInviteModal({ invite, onClose, onReinstated }: ReinstateInviteModalProps) { + const addToast = useUIStore((s) => s.addToast); + const isRevoked = invite.status === 'revoked'; + + const [unlimited, setUnlimited] = useState(invite.maxUses === null); + // Default to a value strictly greater than usedCount — for exhausted invites, that's + // the minimum the server will accept; for others, it's a sensible bump. + const [maxUses, setMaxUses] = useState(() => { + const baseline = invite.maxUses ?? invite.usedCount + 1; + return Math.max(baseline, invite.usedCount + 1).toString(); + }); + const [expiryId, setExpiryId] = useState('7d'); + const [customDateTime, setCustomDateTime] = useState(''); + const [submitting, setSubmitting] = useState(false); + + // Escape closes (capture phase to avoid bubbling into parent settings modal) + useEffect(() => { + const handleKey = (e: KeyboardEvent) => { + if (e.key === 'Escape' && !submitting) { + e.stopPropagation(); + onClose(); + } + }; + document.addEventListener('keydown', handleKey, true); + return () => document.removeEventListener('keydown', handleKey, true); + }, [onClose, submitting]); + + const maxUsesMin = invite.usedCount + 1; + + const handleReinstate = async () => { + // Validate maxUses when limited. + let newMax: number | null = null; + if (!unlimited) { + const parsed = Number(maxUses); + if (!Number.isInteger(parsed) || parsed < maxUsesMin) { + addToast( + `Max uses must be at least ${maxUsesMin} (current uses: ${invite.usedCount})`, + 'warning', + ); + return; + } + newMax = parsed; + } + + // Reinstate always sets a new expiry — no 'keep' option in this flow. + const resolved = resolveExpiryFromSelector(expiryId, customDateTime); + if (resolved.kind === 'invalid') { + addToast(resolved.message, 'warning'); + return; + } + if (resolved.kind === 'omit') { + // Unreachable: ExpirySelector for Reinstate is rendered with showKeep={false}. + addToast('Invalid expiry selection', 'warning'); + return; + } + + const body: { maxUses?: number | null; expiresAt?: number | null } = { + maxUses: newMax, + expiresAt: resolved.expiresAt, + }; + + setSubmitting(true); + try { + const result = await api.invites.reinstate(invite.id, body); + if (result.tokenRotated) { + try { + await navigator.clipboard.writeText(result.invite.url); + addToast('Reinstated with new link. Copied to clipboard.', 'success', 2500); + } catch { + addToast('Reinstated with new link. Copy manually from the row.', 'success', 2500); + } + } else { + addToast('Reinstated. The same link is active again.', 'success', 2500); + } + onReinstated(); + onClose(); + } catch (err) { + addToast(`Failed to reinstate: ${(err as Error).message}`, 'warning'); + } finally { + setSubmitting(false); + } + }; + + return createPortal( +
+
+ +
e.stopPropagation()} + > +
{ + e.preventDefault(); + handleReinstate(); + }} + className="p-6 space-y-5" + > +

Reinstate "{invite.name}"

+ + {isRevoked ? ( +

+ This will generate a new link.{' '} + The previously revoked URL stays inactive — anyone who had the old link will + not be able to use it. +

+ ) : ( +

+ The same link will start working again.{' '} + Anyone who saved the URL will be able to use it. +

+ )} + + {/* Max uses */} +
+ +
+ + +
+
+ + {/* Expiry */} + { + if (v === 'keep') return; // unreachable: showKeep={false} + setExpiryId(v); + setCustomDateTime(dt); + }} + showKeep={false} + disabled={submitting} + /> + + {/* Actions */} +
+ + +
+ +
+
, + document.body, + ); +} + +interface RedemptionsModalProps { + invite: InviteLinkSummary; + onClose: () => void; +} + +/** + * Read-only redemption viewer. Each row shows the registrant's username at sign-up + * time. When the live state has diverged (rename or account deletion), the row shows + * the original name with the live state in parens — `alice (now Anastasia)` or + * `bob (now Deleted User)` per spec §4.3. + * + * Note: the spec mentions opening the user's profile (UserPopover pattern) on row + * click. That popover is not currently wired into a generic trigger callable from + * outside its existing call sites, so this modal renders rows as non-interactive. + * Adding click-through is a later polish pass — see Task 19 report. + */ +function RedemptionsModal({ invite, onClose }: RedemptionsModalProps) { + const addToast = useUIStore((s) => s.addToast); + const [redemptions, setRedemptions] = useState(null); + const [error, setError] = useState(false); + + // Escape closes (capture phase to avoid bubbling into parent settings modal) + useEffect(() => { + const handleKey = (e: KeyboardEvent) => { + if (e.key === 'Escape') { + e.stopPropagation(); + onClose(); + } + }; + document.addEventListener('keydown', handleKey, true); + return () => document.removeEventListener('keydown', handleKey, true); + }, [onClose]); + + useEffect(() => { + let cancelled = false; + api.invites + .redemptions(invite.id) + .then((r) => { + if (!cancelled) setRedemptions(r.redemptions); + }) + .catch(() => { + if (cancelled) return; + setError(true); + setRedemptions([]); + addToast('Failed to load redemptions', 'warning'); + }); + return () => { + cancelled = true; + }; + }, [invite.id, addToast]); + + return createPortal( +
+
+ +
e.stopPropagation()} + > +
+

+ Redemptions for "{invite.name}" +

+ +
+ +
+ {invite.status === 'revoked' && ( +
+ This invite was revoked + {invite.revokedAt + ? ` ${new Date(invite.revokedAt).toLocaleDateString()}` + : ''} + . The redemptions below represent users who registered before revocation. +
+ )} + +
+ {invite.usedCount} + {invite.maxUses !== null ? ` of ${invite.maxUses}` : ''} use + {invite.usedCount === 1 ? '' : 's'} +
+
+ +
+ {redemptions === null ? ( +
Loading…
+ ) : redemptions.length === 0 ? ( +
+ {error ? 'Could not load redemptions.' : 'No redemptions yet.'} +
+ ) : ( +
+ {redemptions.map((r) => { + const showCurrent = + !r.isDeleted && + r.currentUsername !== null && + r.currentUsername !== r.registrantUsername; + return ( +
+ + {r.registrantUsername} + {(r.isDeleted || showCurrent) && ( + + {' '} + (now {r.isDeleted ? 'Deleted User' : r.currentUsername}) + + )} + + + {new Date(r.redeemedAt).toLocaleString()} + +
+ ); + })} +
+ )} +
+
+
, + document.body, + ); +} + +interface InviteRowProps { + invite: InviteLinkSummary; + onMutate: () => void; +} + +/** + * One row in the invite list. Owns its own modal/popover/confirm-dialog state so the + * parent panel only deals with the list-level fetch + tab state. + * + * Action surface depends on `invite.status`: + * - active → Copy link · Edit · Revoke · ⋯ (View redemptions, Delete) + * - non-active → Reinstate · ⋯ (View redemptions, Delete) + * + * The kebab popover dismisses on outside click and Escape; clicks inside its items + * already call `setShowKebab(false)` before triggering the action. + */ +function InviteRow({ invite, onMutate }: InviteRowProps) { + const addToast = useUIStore((s) => s.addToast); + const [showEdit, setShowEdit] = useState(false); + const [showReinstate, setShowReinstate] = useState(false); + const [showRedemptions, setShowRedemptions] = useState(false); + const [showKebab, setShowKebab] = useState(false); + const [confirmRevoke, setConfirmRevoke] = useState(false); + const [confirmDelete, setConfirmDelete] = useState(false); + const [actionLoading, setActionLoading] = useState(false); + const kebabContainerRef = useRef(null); + + // Dismiss kebab popover on outside click + Escape. Mirrors the pattern used in + // TransferOwnershipModal — listen on document, check containment via ref. + useEffect(() => { + if (!showKebab) return; + const handleMouseDown = (e: MouseEvent) => { + if ( + kebabContainerRef.current && + !kebabContainerRef.current.contains(e.target as Node) + ) { + setShowKebab(false); + } + }; + const handleKey = (e: KeyboardEvent) => { + if (e.key === 'Escape') { + // Stop propagation so the parent settings modal doesn't ALSO close. + e.stopPropagation(); + setShowKebab(false); + } + }; + document.addEventListener('mousedown', handleMouseDown); + document.addEventListener('keydown', handleKey, true); + return () => { + document.removeEventListener('mousedown', handleMouseDown); + document.removeEventListener('keydown', handleKey, true); + }; + }, [showKebab]); + + const usageLabel = + invite.maxUses === null + ? `${invite.usedCount} use${invite.usedCount === 1 ? '' : 's'} · unlimited` + : `${invite.usedCount} / ${invite.maxUses} uses`; + + const usageNearLimit = + invite.maxUses !== null && invite.maxUses > 0 && invite.usedCount / invite.maxUses >= 0.8; + + const handleCopy = async () => { + try { + await navigator.clipboard.writeText(invite.url); + addToast('Invite link copied', 'success', 2000); + } catch { + addToast('Failed to copy link', 'warning'); + } + }; + + const performRevoke = async () => { + setActionLoading(true); + try { + await api.invites.revoke(invite.id); + addToast('Invite revoked', 'success', 2000); + setConfirmRevoke(false); + onMutate(); + } catch (err) { + addToast(`Failed to revoke: ${(err as Error).message}`, 'warning'); + } finally { + setActionLoading(false); + } + }; + + const performDelete = async () => { + setActionLoading(true); + try { + await api.invites.delete(invite.id); + addToast('Invite deleted', 'success', 2000); + setConfirmDelete(false); + onMutate(); + } catch (err) { + addToast(`Failed to delete: ${(err as Error).message}`, 'warning'); + } finally { + setActionLoading(false); + } + }; + + const statusPillClass = + invite.status === 'expired' + ? 'bg-rose-500/20 text-rose-400' + : invite.status === 'exhausted' + ? 'bg-amber-500/20 text-amber-400' + : 'bg-white/10 text-txt-tertiary'; + + return ( + <> +
+
+
+ + {invite.name} + + + · {usageLabel} + + {invite.status !== 'active' && ( + + {invite.status} + + )} +
+
+ {formatExpiry(invite)} · Created by {invite.createdByUsername ?? 'Unknown'} ·{' '} + {formatRelative(invite.createdAt)} +
+
+ +
+ {invite.status === 'active' ? ( + <> + + + + + ) : ( + + )} + + {showKebab && ( +
+ + +
+ )} +
+
+ + {showEdit && ( + setShowEdit(false)} + onUpdated={onMutate} + /> + )} + {showReinstate && ( + setShowReinstate(false)} + onReinstated={onMutate} + /> + )} + {showRedemptions && ( + setShowRedemptions(false)} /> + )} + + setConfirmRevoke(false)} + onConfirm={performRevoke} + title={`Revoke "${invite.name}"?`} + description={ + <> + The link will stop working immediately. Anyone who has the URL will not be able + to use it. You can reinstate the invite later — that will generate a new link + with a different URL. + + } + confirmLabel="Revoke link" + variant="danger" + loading={actionLoading} + /> + + setConfirmDelete(false)} + onConfirm={performDelete} + title={`Delete "${invite.name}" permanently?`} + description={ + <> + This cannot be undone. Redemption history for this link will also be removed. + If you only want to stop the link from working, use Revoke{' '} + instead — that preserves the redemption record. + + } + confirmLabel="Delete permanently" + variant="danger" + loading={actionLoading} + /> + + ); +} + export function RegistrationPanel() { const instanceSettings = useSettingsStore((s) => s.instanceSettings); const updateInstanceSettings = useSettingsStore((s) => s.updateInstanceSettings); From 5b51980278e136151e874624855b52bbf12d2dad Mon Sep 17 00:00:00 2001 From: Jannis Braun <151788261+TheZwiss@users.noreply.github.com> Date: Wed, 29 Apr 2026 01:26:38 +0200 Subject: [PATCH 30/41] fix(web): make ConfirmDialog Escape capture-phase to prevent leaking to ancestor modals --- packages/web/src/components/ui/ConfirmDialog.tsx | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/packages/web/src/components/ui/ConfirmDialog.tsx b/packages/web/src/components/ui/ConfirmDialog.tsx index 27a34ab2..6d141a9f 100644 --- a/packages/web/src/components/ui/ConfirmDialog.tsx +++ b/packages/web/src/components/ui/ConfirmDialog.tsx @@ -25,13 +25,16 @@ export function ConfirmDialog({ loading = false, }: ConfirmDialogProps) { const handleKeyDown = useCallback((e: KeyboardEvent) => { - if (e.key === 'Escape' && !loading) onClose(); + if (e.key === 'Escape' && !loading) { + e.stopPropagation(); + onClose(); + } }, [onClose, loading]); useEffect(() => { if (isOpen) { - document.addEventListener('keydown', handleKeyDown); - return () => document.removeEventListener('keydown', handleKeyDown); + document.addEventListener('keydown', handleKeyDown, true); + return () => document.removeEventListener('keydown', handleKeyDown, true); } }, [isOpen, handleKeyDown]); From 0dae2177878c4bbd38e3dc79ca5584d31d3d49e0 Mon Sep 17 00:00:00 2001 From: Jannis Braun <151788261+TheZwiss@users.noreply.github.com> Date: Wed, 29 Apr 2026 01:38:52 +0200 Subject: [PATCH 31/41] feat(web): RegisterPage invite-token URL pickup + manual entry + closed-state UX --- .../web/src/components/auth/RegisterPage.tsx | 186 +++++++++++++++++- 1 file changed, 183 insertions(+), 3 deletions(-) diff --git a/packages/web/src/components/auth/RegisterPage.tsx b/packages/web/src/components/auth/RegisterPage.tsx index 9b0fc9ce..074457b0 100644 --- a/packages/web/src/components/auth/RegisterPage.tsx +++ b/packages/web/src/components/auth/RegisterPage.tsx @@ -5,7 +5,7 @@ import { Avatar } from '../ui/Avatar'; import { ImageCropModal } from '../ui/ImageCropModal'; import { AVATAR_GRADIENT_MAP } from '../../utils/gradients'; import { AVATAR_COLORS } from '@backspace/shared'; -import type { AvatarColor } from '@backspace/shared'; +import type { AvatarColor, CheckInviteResponse, InstanceInfoResponse } from '@backspace/shared'; import { api, RateLimitError } from '../../api/client'; type UsernameStatus = 'idle' | 'checking' | 'available' | 'taken' | 'invalid'; @@ -26,6 +26,15 @@ export function RegisterPage() { const usernameCheckTimerRef = useRef | null>(null); const usernameCheckAbortRef = useRef(null); + // Instance info (for registration policy) + const [instanceInfo, setInstanceInfo] = useState(null); + + // Invite token state + const [manualInviteToken, setManualInviteToken] = useState(''); + const [inviteCheck, setInviteCheck] = useState(null); + const [inviteChecking, setInviteChecking] = useState(false); + const inviteCheckTimerRef = useRef | null>(null); + // Step 2 fields const [displayName, setDisplayName] = useState(''); const [avatarColor, setAvatarColor] = useState( @@ -44,6 +53,7 @@ export function RegisterPage() { const navigate = useNavigate(); const [searchParams] = useSearchParams(); const redirect = searchParams.get('redirect'); + const urlInviteToken = searchParams.get('invite'); // Cleanup blob URL on unmount useEffect(() => { @@ -52,6 +62,70 @@ export function RegisterPage() { }; }, [avatarPreview]); + // Fetch instance info to determine registration policy + useEffect(() => { + let cancelled = false; + api.instance.info() + .then((info) => { if (!cancelled) setInstanceInfo(info); }) + .catch(() => { + // Leave null — treat as open by default to avoid soft-locking the page. + // Server-side validation (Task 11) is the real gate. + }); + return () => { cancelled = true; }; + }, []); + + // Validate URL-supplied invite token on mount + useEffect(() => { + if (!urlInviteToken) return; + let cancelled = false; + setInviteChecking(true); + api.auth.checkInvite(urlInviteToken) + .then((res) => { if (!cancelled) setInviteCheck(res); }) + .catch(() => { if (!cancelled) setInviteCheck({ valid: false, reason: 'invalid' }); }) + .finally(() => { if (!cancelled) setInviteChecking(false); }); + return () => { cancelled = true; }; + }, [urlInviteToken]); + + // Debounced manual-entry invite validation + useEffect(() => { + if (urlInviteToken) return; // URL token takes precedence while it is present + + const trimmed = manualInviteToken.trim(); + if (!trimmed) { + setInviteCheck(null); + setInviteChecking(false); + return; + } + + // Extract bare token if user pasted a full URL + let token = trimmed; + const urlMatch = trimmed.match(/[?&]invite=([A-Za-z0-9_-]{22})/); + if (urlMatch) token = urlMatch[1]!; + + let cancelled = false; + + if (inviteCheckTimerRef.current) clearTimeout(inviteCheckTimerRef.current); + + inviteCheckTimerRef.current = setTimeout(async () => { + setInviteChecking(true); + try { + const res = await api.auth.checkInvite(token); + if (cancelled) return; + setInviteCheck(res); + } catch { + if (cancelled) return; + setInviteCheck({ valid: false, reason: 'invalid' }); + } finally { + if (!cancelled) setInviteChecking(false); + } + }, 500); + + return () => { + cancelled = true; + if (inviteCheckTimerRef.current) clearTimeout(inviteCheckTimerRef.current); + }; + }, [manualInviteToken, urlInviteToken]); + // Debounced username availability check useEffect(() => { // Clear previous timer and abort @@ -128,6 +202,17 @@ export function RegisterPage() { return () => clearInterval(timer); }, [retryAfter]); + // ── Invite requirements ── + const inviteRequired = instanceInfo !== null && !instanceInfo.registrationOpen; + const inviteValid = inviteRequired ? inviteCheck?.valid === true : true; + + // Show the manual-entry container when: + // - Registration is closed AND + // - There is no URL token, OR the URL token has already been validated as invalid + const showManualEntry = instanceInfo !== null + && !instanceInfo.registrationOpen + && (!urlInviteToken || inviteCheck?.valid === false); + // ── Step 1 validation ── const handleContinue = (e: React.FormEvent) => { e.preventDefault(); @@ -192,10 +277,23 @@ export function RegisterPage() { const dn = skip ? undefined : displayName.trim() || undefined; const ac = skip ? undefined : avatarColor; + // Resolve the token to send: prefer URL token, fall back to manual entry + const tokenForRegister = (() => { + if (urlInviteToken) return urlInviteToken; + const trimmed = manualInviteToken.trim(); + if (!trimmed) return undefined; + const urlMatch = trimmed.match(/[?&]invite=([A-Za-z0-9_-]{22})/); + return urlMatch ? urlMatch[1] : trimmed; + })(); + // Step 1: Register via API — store token in localStorage for API auth, // but NOT in Zustand yet so AuthRedirect doesn't fire prematurely const response = await api.auth.register({ - username: username.trim(), password, displayName: dn, avatarColor: ac, + username: username.trim(), + password, + displayName: dn, + avatarColor: ac, + ...(tokenForRegister ? { inviteToken: tokenForRegister } : {}), }); localStorage.setItem('backspace_token', response.token); @@ -235,6 +333,13 @@ export function RegisterPage() { const isDisabled = isRegistering || retryAfter > 0; + // Continue button is blocked while username is invalid/taken OR when an invite is required + // but not yet validated as valid + const continueDisabled = + usernameStatus === 'taken' || + usernameStatus === 'invalid' || + (inviteRequired && !inviteValid); + return (
@@ -258,6 +363,74 @@ export function RegisterPage() {
)} + {/* Closed-registration invite entry — shown when registration is closed and: + (a) no URL token is present, or (b) the URL token has already failed validation */} + {showManualEntry && ( +
+
+ Registration is invite-only on this instance. Paste your invite link or enter the code below. +
+ setManualInviteToken(e.target.value)} + placeholder="Invite code or link" + className="input-standard w-full px-3 py-2 text-sm" + aria-label="Invite code or link" + autoComplete="off" + /> + {inviteChecking && ( +
Checking...
+ )} + {!inviteChecking && inviteCheck?.valid === true && ( +
+ + + + Valid invite: {inviteCheck.name} +
+ )} + {!inviteChecking && inviteCheck?.valid === false && ( +
+ {inviteCheck.reason === 'expired' && 'This invite link has expired. Ask the admin for a new one.'} + {inviteCheck.reason === 'exhausted' && 'This invite has reached its usage limit. Ask the admin to extend it.'} + {inviteCheck.reason === 'invalid' && 'Invalid invite code.'} +
+ )} +
+ )} + + {/* URL-token chip — shown whenever a token was provided in the URL */} + {urlInviteToken && ( +
+ {inviteChecking ? ( + <> + + + + + Validating invite... + + ) : inviteCheck?.valid === true ? ( + <> + + + + Using invite: {inviteCheck.name} + + ) : inviteCheck?.valid === false ? ( + <> + + + + Invalid invite link — please request a new one + + ) : ( + <>Validating invite... + )} +
+ )} +
)} - {/* URL-token chip — shown whenever a token was provided in the URL */} - {urlInviteToken && ( + {/* URL-token chip — shown only when registration is closed AND a URL token was provided. + Per spec §4.4: open-registration instances silently ignore the ?invite= param. */} + {urlInviteToken && instanceInfo && !instanceInfo.registrationOpen && (
{inviteChecking ? ( <> From a7c321a9d41d5311cdd446672c1756a01a0f126d Mon Sep 17 00:00:00 2001 From: Jannis Braun <151788261+TheZwiss@users.noreply.github.com> Date: Wed, 29 Apr 2026 01:49:32 +0200 Subject: [PATCH 33/41] feat(web): show federated-registration warning banner in Connections add-instance form --- packages/web/src/components/modals/ConnectedInstances.tsx | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/packages/web/src/components/modals/ConnectedInstances.tsx b/packages/web/src/components/modals/ConnectedInstances.tsx index 4d60c178..f92b2db4 100644 --- a/packages/web/src/components/modals/ConnectedInstances.tsx +++ b/packages/web/src/components/modals/ConnectedInstances.tsx @@ -203,6 +203,12 @@ function AddInstanceFlow({ onDone }: { onDone: () => void }) {
+ {!probeResult.federatedRegistrationOpen && ( +
+ This instance has disabled new federated registrations. Existing accounts can still sign in. +
+ )} +
{ e.preventDefault(); handleConnect(); }} className="space-y-2">
From 5abe13166a8ddaeeae85b351129462118274bcf2 Mon Sep 17 00:00:00 2001 From: Jannis Braun <151788261+TheZwiss@users.noreply.github.com> Date: Wed, 29 Apr 2026 01:57:58 +0200 Subject: [PATCH 34/41] docs(systems): document invite-links + split registration gate --- docs/systems/admin.md | 68 +++++++++++++++++++++++- docs/systems/api.md | 88 ++++++++++++++++++++++++++++--- docs/systems/auth.md | 52 ++++++++++++++---- docs/systems/client-federation.md | 20 +++++++ docs/systems/database.md | 39 +++++++++++++- 5 files changed, 248 insertions(+), 19 deletions(-) diff --git a/docs/systems/admin.md b/docs/systems/admin.md index 925cd2bb..d1b3b939 100644 --- a/docs/systems/admin.md +++ b/docs/systems/admin.md @@ -9,10 +9,13 @@ Source files: - `packages/server/src/utils/storageJanitor.ts` -- Storage stats, orphan detection, cleanup - `packages/web/src/stores/settingsStore.ts` -- Zustand store for instance/streaming settings - `packages/web/src/components/modals/instanceSettingsPanels/GeneralPanel.tsx` -- General settings UI +- `packages/web/src/components/modals/instanceSettingsPanels/RegistrationPanel.tsx` -- Registration toggles + invite-link CRUD UI - `packages/web/src/components/modals/instanceSettingsPanels/FederationPanel.tsx` -- Federation peers panel (peering, approval queue, peer status, rotation, reset) - `packages/web/src/components/modals/instanceSettingsPanels/StoragePanel.tsx` -- Storage management UI - `packages/web/src/components/modals/instanceSettingsPanels/StreamingPanel.tsx` -- Streaming config UI - `packages/web/src/components/modals/instanceSettingsPanels/UsersPanel.tsx` -- User management UI +- `packages/server/src/routes/invites.ts` -- Admin invite-link CRUD endpoints +- `packages/server/src/utils/inviteService.ts` -- Token generation, derived status, atomic redemption transaction - `packages/shared/src/types.ts` -- Shared type interfaces - `packages/shared/src/constants.ts` -- Streaming constants (resolutions, framerates, bitrate matrix) @@ -432,12 +435,75 @@ All panels live under `packages/web/src/components/modals/instanceSettingsPanels #### GeneralPanel -Manages: instance name, registration toggle, discovery toggle, GIF API key, federation relay toggle/TTL. +Manages: instance name, discovery toggle, GIF API key, federation relay toggle/TTL. - Instance name input: max 32 chars, enforced client-side via `slice(0, 32)` - GIF key: password input, separate dirty tracking (`gifKeyDirty`). Only sent on save if modified. "Clear key" button sets empty string. - Federation relay toggle and TTL input: drive `federationRelayEnabled` and `federationRelayTtlDays` instance settings. +The registration toggles (`registrationOpen` / `federatedRegistrationOpen`) and the invite-link manager live in [RegistrationPanel](#registrationpanel). + +#### RegistrationPanel + +Owns the two independent registration gates and the admin invite-link CRUD surface. Mounted in the instance settings sidebar between **General** and **Users**. + +**Toggles** (top of panel) — bound to `settingsStore.instanceSettings.registrationOpen` and `.federatedRegistrationOpen`. Save bar appears at the bottom of the panel when either toggle differs from the synced value (existing pattern from `GeneralPanel`): + +- **Public registration** (`registrationOpen`) — "Allow anyone to create a local account from `/register`. When off, only invite links work for new local accounts." +- **Federated registration** (`federatedRegistrationOpen`) — "Allow users from other instances to create a federated account here via their Connections settings. Existing federated accounts log in normally." + +**Invite Links** (below the toggles) — segmented `[Active] [Archived]` tabs (local component state, default `active`). Tab switch refetches via `GET /api/admin/invites?status=...`. A `tabRef` discards stale in-flight fetches when the admin switches tabs mid-load. `[+ Create link]` button opens the Create modal. + +**Invite row** — name (left, primary text), usage indicator (`usedCount / maxUses` or `usedCount uses · unlimited`, color-coded amber at >=80%), status pill on archived tab (`Expired` rose / `Exhausted` amber / `Revoked` txt-tertiary), expiry summary subline (active: `Expires in 4 days` / `No expiration`; archived: `Expired Apr 25` / `Exhausted Apr 27` / `Revoked Apr 28`), creator + relative-time created. + +**Row actions** branch on derived status: +- **Active rows:** `Copy link`, `Edit`, `Revoke`, kebab → `Delete permanently`, `View redemptions`. +- **Archived rows:** `Reinstate`, kebab → `Delete permanently`, `View redemptions`. + +**Modals:** + +- **Create Invite** — Name (1-64 chars), Max uses (radio: Unlimited / `[N >= 1]`), Expires (preset: `1 hour` / `24 hours` / `7 days` / `30 days` / `Never` / `Custom…`). Defaults: `maxUses: null`, `expiresAt: now + 7 days`. On success the URL is auto-copied to clipboard and the new row animates in at the top of the active list. +- **Edit Invite** — same shape as Create, pre-filled. Hidden for `revoked` rows (Reinstate is the only path back). +- **Reinstate — Variant A** (was `revoked`): rotates the token (`tokenRotated: true`). Modal copy: "This will generate a new link. The previously revoked URL stays inactive." Required to bump `maxUses` and/or `expiresAt` so the resulting row derives status `active` (server returns 400 otherwise). +- **Reinstate — Variant B** (was `expired` or `exhausted`): preserves the same token (`tokenRotated: false`). Modal copy: "The same link will start working again. Anyone who saved the URL will be able to use it." Same bump-to-active validation. +- **Delete confirmation** — uses the existing `ConfirmDialog` with `variant="danger"`, copy: "Delete `` permanently? This cannot be undone. Redemption history for this link will also be removed. If you only want to stop the link from working, use Revoke instead — that preserves the redemption record." (Spec §4.2 originally proposed type-to-confirm, but the codebase uses the existing `ConfirmDialog` precedent for high-blast-radius admin actions; type-to-confirm was not introduced as a one-off pattern.) + +**Redemption viewer** — opens via `View redemptions` action. Shows `usedCount of maxUses` header (or `usedCount uses · unlimited`), then one row per redemption: `registrantUsername` (left) + `redeemedAt` formatted (right). When `currentUsername !== registrantUsername` (post-rename) or `isDeleted === true`, the registrant name is annotated `alice (now Anastasia)` / `alice (now Deleted User)` — snapshot stable, live state visible. Clicking a non-deleted row opens the user's profile (`UserPopover`). If the invite is revoked, a banner at the top notes "The redemptions above represent users who registered before revocation." + +**Invites outlive their creator's account.** `invite_links.createdBy` has no CASCADE — when an admin is tombstoned, their invites stay live and any current admin can manage them. The list joins `users` to surface `createdByUsername`, which resolves to `'Deleted User'` when the creator has `isDeleted = 1` (matching the `sanitizeUser` convention). + +**State ownership.** Invite CRUD is **not** in `settingsStore` — it's transient panel state owned by `RegistrationPanel` (`useState` for `tab`, `invites`, `invitesLoading`, modal flags). Pattern matches `UsersPanel`. Reasoning: invites are page-scoped, not session-scoped — caching them globally would just create staleness bugs when the panel reopens. + +**Type shapes** (from `packages/shared/src/types.ts`): + +```typescript +type InviteLinkSummary = { + id: string; + token: string; + name: string; + status: 'active' | 'expired' | 'exhausted' | 'revoked'; // derived; never stored + maxUses: number | null; + usedCount: number; + expiresAt: number | null; + revokedAt: number | null; + createdBy: string; + createdByUsername: string | null; // 'Deleted User' when creator's isDeleted = 1 + createdAt: number; + url: string; // server-built `https:///register?invite=` — clients MUST NOT assemble +}; + +type InviteRedemption = { + id: string; + userId: string | null; // null only on hard-delete (defensive — tombstone keeps row populated) + registrantUsername: string; // snapshot at registration moment + currentUsername: string | null; + isDeleted: boolean; + redeemedAt: number; +}; +``` + +See [api.md → Admin: Invite Management](api.md#admin-invite-management-routesinvitests) for full endpoint signatures and the [auth.md → Invite Tokens](auth.md#invite-tokens) section for the redemption transaction + audit trail. + #### FederationPanel Manages: federation peers list, pending approval requests (inbound + outbound), manual peering initiation, secret rotation, peer reset. diff --git a/docs/systems/api.md b/docs/systems/api.md index f84412e9..7d9f0792 100644 --- a/docs/systems/api.md +++ b/docs/systems/api.md @@ -7,11 +7,28 @@ Source files: `packages/server/src/routes/*.ts` ## Auth (`routes/auth.ts`) — public, rate-limited ``` -POST /auth/register { username, password, displayName?, avatarColor?, homeInstance?, homeUserId? } → { token, user } +POST /auth/register { username, password, displayName?, avatarColor?, homeInstance?, homeUserId?, inviteToken? } → { token, user } GET /auth/check-username ?username= → { available, reason? } +GET /auth/check-invite ?token= → CheckInviteResponse POST /auth/login { username, password } → { token, user } ``` +**`POST /auth/register` gating** — branches on whether `homeInstance` is set: +- **Federated path** (`homeInstance` set): gated solely by `instance_settings.federatedRegistrationOpen`. `inviteToken` is ignored entirely (not validated, not consumed). 403 `Federated registration is closed on this instance` when closed. Existing federated stubs (relay-created, `passwordHash = '!federation-replicated'`) upgrade in place — login is never blocked by this gate. +- **Local path** (no `homeInstance`): + - When `registrationOpen` is true: `inviteToken` is silently ignored (no row touched, no `usedCount` increment). + - When `registrationOpen` is false: `inviteToken` is required. The token is pre-validated, then the user INSERT + `usedCount` increment + `invite_redemptions` row INSERT all run in a single transaction (`inviteService.redeemInvite`). 403 `Registration is closed. An invite is required.` (no token) or `Invalid or expired invite` (token rejected at any stage, including a concurrent-redemption race re-check inside the transaction). + +**`GET /auth/check-invite`** — public, rate-limited 30/min/IP. Always returns 200; the body discriminates: + +```typescript +type CheckInviteResponse = + | { valid: true; name: string } // active token; name surfaces for UX + | { valid: false; reason: 'expired' | 'exhausted' | 'invalid' }; +``` + +`revoked`, malformed (non-22-char-base64url), and not-in-DB tokens all collapse to `'invalid'` (enumeration shield). `name` is returned **only** in the valid case. + ## Users (`routes/users.ts`) — auth required ``` GET /users/@me → { user } @@ -189,18 +206,20 @@ Permissions checked: CONNECT, SPEAK, STREAM (space channels). DM calls: always f ## Instance (`routes/instance.ts`) — public ``` -GET /instance/info → { name, version, registrationOpen } +GET /instance/info → { name, version, registrationOpen, federatedRegistrationOpen } ``` +`federatedRegistrationOpen` is a UX hint consumed by the Connections add-instance pre-flight (see `client-federation.md`). The 403 from `POST /auth/register` remains the security boundary. ## Settings (`routes/settings.ts`) ``` GET /settings/streaming (auth) → { streamingLimits } PATCH /settings/streaming (admin) → { streamingLimits } -GET /settings/instance (admin) → { instanceName, registrationOpen, discoveryEnabled, ... } -PATCH /settings/instance (admin) { instanceName?, registrationOpen?, discoveryEnabled?, - gifApiKey?, maxUploadSizeMb?, federationRelayEnabled?, - federationRelayTtlDays? } → { settings } +GET /settings/instance (admin) → { instanceName, registrationOpen, federatedRegistrationOpen, discoveryEnabled, ... } +PATCH /settings/instance (admin) { instanceName?, registrationOpen?, federatedRegistrationOpen?, + discoveryEnabled?, gifApiKey?, maxUploadSizeMb?, + federationRelayEnabled?, federationRelayTtlDays? } → { settings } ``` +`registrationOpen` and `federatedRegistrationOpen` are **independent** toggles. PATCH validates `federatedRegistrationOpen` is `boolean` if provided; rejects 400 otherwise. `registrationOpen` is stored as a nullable column (null = fall back to `config.registrationOpen` env default); `federatedRegistrationOpen` is NOT NULL with default 1. ## Admin (`routes/admin.ts`) — admin required ``` @@ -215,6 +234,63 @@ POST /admin/users/:id/reset-password → { temporaryPasswo DELETE /admin/users/:id → { success } ``` +## Admin: Invite Management (`routes/invites.ts`) — admin required + +All endpoints sit behind `[authenticate, requireAdmin]`. Mutating endpoints wrap their read-modify-write in a SQLite transaction with an in-txn re-fetch + status re-derive; any state mismatch returns 409. Service layer: `packages/server/src/utils/inviteService.ts` (`InviteValidationError` → 400, `InviteNotFoundError` → 404, `InviteStateConflictError` → 409). + +``` +POST /admin/invites { name, maxUses, expiresAt } → InviteLinkSummary (201) +GET /admin/invites ?status=active|archived (default: active) → { invites: InviteLinkSummary[] } +PATCH /admin/invites/:id { name?, maxUses?, expiresAt? } → InviteLinkSummary +POST /admin/invites/:id/revoke → { invite: InviteLinkSummary } +POST /admin/invites/:id/reinstate { maxUses?, expiresAt? } → { invite: InviteLinkSummary, tokenRotated: boolean } +DELETE /admin/invites/:id → { success: true } +GET /admin/invites/:id/redemptions → { redemptions: InviteRedemption[] } +``` + +**`POST /admin/invites`** — `name` 1-64 chars trimmed; `maxUses` null (unlimited) or positive integer; `expiresAt` null (never) or epoch ms strictly greater than `Date.now()`. 400 on shape violation. + +**`GET /admin/invites?status=`** — `active` returns rows whose derived status is `active`; `archived` returns `expired | exhausted | revoked`. Sort `createdAt DESC`. Joins `users` to surface `createdByUsername` (`'Deleted User'` if creator's `isDeleted = 1`). + +**`PATCH /admin/invites/:id`** — partial body. 400 if `maxUses` is a positive integer less than the current `usedCount` (would retroactively exhaust — admin should revoke instead). 409 if the invite is currently `revoked` (status conflict — reinstate first). + +**`POST /admin/invites/:id/revoke`** — sets `revokedAt = Date.now()`. 409 if already revoked. + +**`POST /admin/invites/:id/reinstate`** — branches on the row's pre-reinstate derived status: +- **Path A — was `revoked`**: rotates the token (`crypto.randomBytes(16).toString('base64url')`), clears `revokedAt`, applies any provided `maxUses`/`expiresAt` overrides. Response includes `tokenRotated: true`. 400 if the resulting row would still derive non-`active` (caller must bump enough). +- **Path B — was `expired` or `exhausted`**: token preserved. Applies overrides. Response `tokenRotated: false`. 400 same rule. +- **Path C — already `active`**: 409 "Invite is already active." Pure no-op rejection. + +**`DELETE /admin/invites/:id`** — hard-delete. CASCADE removes all `invite_redemptions` rows for this invite. Allowed in any status. 404 if not found. + +**`GET /admin/invites/:id/redemptions`** — sort `redeemedAt DESC`. Each row includes the registration-moment snapshot (`registrantUsername`) plus the live joined state (`currentUsername` / `isDeleted`) so the UI can render `alice (now Deleted User)` for renamed/tombstoned users. + +```typescript +type InviteLinkSummary = { + id: string; + token: string; + name: string; + status: 'active' | 'expired' | 'exhausted' | 'revoked'; // derived + maxUses: number | null; + usedCount: number; + expiresAt: number | null; + revokedAt: number | null; + createdBy: string; + createdByUsername: string | null; // 'Deleted User' if creator tombstoned + createdAt: number; + url: string; // server-built `https:///register?invite=` — clients MUST NOT assemble +}; + +type InviteRedemption = { + id: string; + userId: string | null; // null only on hard-delete (defensive — tombstone keeps row) + registrantUsername: string; // snapshot at registration moment + currentUsername: string | null; + isDeleted: boolean; + redeemedAt: number; +}; +``` + ## Federation (`routes/federation.ts`) ``` POST /federation/peer/initiate (admin) { remoteOrigin } → peer created diff --git a/docs/systems/auth.md b/docs/systems/auth.md index be71f6e3..810ece90 100644 --- a/docs/systems/auth.md +++ b/docs/systems/auth.md @@ -1,7 +1,9 @@ # Authentication & Session System Source files: -- `packages/server/src/routes/auth.ts` -- Registration, login, username availability endpoints +- `packages/server/src/routes/auth.ts` -- Registration, login, username availability, invite-token check endpoints +- `packages/server/src/routes/invites.ts` -- Admin invite-link CRUD (create / list / patch / revoke / reinstate / delete / redemptions) +- `packages/server/src/utils/inviteService.ts` -- Invite token generation, status derivation, atomic `redeemInvite()` transaction - `packages/server/src/routes/users.ts` -- Password change, account deletion endpoints (lines 58-156) - `packages/server/src/routes/admin.ts` -- Admin password reset endpoint (lines 232-265) - `packages/server/src/utils/auth.ts` -- Password hashing, JWT sign/verify, `authenticate` preHandler, `requireAdmin` @@ -135,21 +137,47 @@ DB value overrides env when explicitly set by admin. When closed, a valid `invit **Invariants** (spec §1.3): -- **Login is never gated** by either toggle. Both gates affect *creation only*. Existing accounts remain loginable regardless of policy. -- The federated stub upgrade flow (below) is gated by `federatedRegistrationOpen`, never by an invite token. +- **Login-unaffected invariant.** Neither toggle gates `POST /api/auth/login` for any user. Existing federated accounts always log in regardless of `federatedRegistrationOpen`; existing local accounts always log in regardless of `registrationOpen`. Both gates affect **creation only**. This is why the Connections add-instance form keeps its submit button enabled even when the target instance has `federatedRegistrationOpen = false` (see `client-federation.md`): the request runs through `instanceStore`'s register-then-login fall-through, and the login leg succeeds for users who already have a federated account on that instance. +- The federated stub upgrade flow (below) is gated by `federatedRegistrationOpen`, never by an invite token. Tokens only unlock the local anonymous-signup path. **Toggle matrix** (spec §5.6): -| `registrationOpen` | `federatedRegistrationOpen` | Local register | Federated register | -|---|---|---|---| -| true | true | open | allowed | -| true | false | open | 403 | -| false | true | invite-required | allowed | -| false | false | invite-required | 403 | +| `registrationOpen` | `federatedRegistrationOpen` | Local register | Federated register | Connections UI behavior | +|---|---|---|---|---| +| true | true | open | allowed | normal | +| true | false | open | 403 | warning banner; submit enabled (login fall-through) | +| false | true | invite-required | allowed | normal | +| false | false | invite-required | 403 | warning banner; submit enabled (login fall-through) | +| false (any) | (any) | invite bypasses | NOT bypassable by token | — | + +S2S DM stub creation (relay path, never `/register`) is gated only by federation peering settings — neither toggle affects it. ### Invite Tokens -When `registrationOpen` is false, the local-signup path accepts an `inviteToken` field on the register body. Token format: 22-char base64url (16 random bytes). Lifecycle and admin CRUD live in `inviteService.ts` and `routes/invites.ts` -- see `docs/systems/admin.md` for the panel UX and the full status state machine. +When `registrationOpen` is false, the local-signup path accepts an `inviteToken` field on the register body. Token format: 22-char base64url (`crypto.randomBytes(16).toString('base64url')` — 128 bits of entropy; collision probability against the existing space is `~2^-122`, with the DB UNIQUE on `invite_links.token` as the safety net + retry-up-to-3 in the create handler). Admin CRUD lives in `inviteService.ts` and `routes/invites.ts` -- see `docs/systems/admin.md` for the panel UX and the full status state machine. + +**Lifecycle:** + +``` +create → active ──(usedCount = maxUses)─→ exhausted ┐ + │ │ + │ ┌──(expiresAt < now)──→ expired ──────┤ + │ │ │ + ↓ ↓ │ + revoke ──(revokedAt set)──→ revoked │ reinstate + │ ←┘ (Path A: token rotates; + │ Path B: same token) + └──→ active again + │ + DELETE /admin/invites/:id + │ + ↓ + hard-delete (CASCADE redemptions) +``` + +Status is **derived** at read time from `(revokedAt, expiresAt, usedCount, maxUses)` — no stored column. Reinstate branches on the pre-reinstate status: revoked → token rotates (`tokenRotated: true`); expired/exhausted → token preserved (`tokenRotated: false`); already-active → 409. + +**Audit trail (`invite_redemptions`):** every successful redemption inserts one row with `inviteId` (FK CASCADE — admin hard-delete drops the audit), `userId` (FK SET NULL — defensive against future hard-delete; tombstone keeps it populated), `registrantUsername` (snapshot at registration moment, preserves forensic value when the user is later renamed or tombstoned `!deleted:{uid}`), and `redeemedAt`. Atomic redemption (spec §2.4): @@ -580,6 +608,7 @@ If `user` is a replicated alias of `homeUser` (via `isSelf`), returns `homeUser` |----------|-----|--------| | `POST /api/auth/register` | 10 | 2 min | | `GET /api/auth/check-username` | 30 | 1 min | +| `GET /api/auth/check-invite` | 30 | 1 min | | `POST /api/auth/login` | 15 | 2 min | | `POST /api/users/@me/change-password` | 5 | 15 min | | `DELETE /api/users/@me` | 3 | 15 min | @@ -594,7 +623,8 @@ All keyed by `request.ip`. |------------|---------|---------|-------| | `jwtSecret` | `JWT_SECRET` | (required) | Min 32 chars, startup crash if shorter | | `jwtExpiresIn` | `JWT_EXPIRES_IN` | `'30d'` | Passed to `jsonwebtoken` `expiresIn` option | -| `registrationOpen` | `REGISTRATION_OPEN` | `true` | Overridden by `instanceSettings.registrationOpen` in DB | +| `registrationOpen` | `REGISTRATION_OPEN` | `true` | Overridden by `instanceSettings.registrationOpen` in DB (null DB row = env fallback). Local anonymous signup gate. | +| (no env) | -- | `true` | `instanceSettings.federatedRegistrationOpen` is DB-only (NOT NULL DEFAULT 1) — no env override. Federated identity replication gate. | --- diff --git a/docs/systems/client-federation.md b/docs/systems/client-federation.md index 7a40f1ca..08a50082 100644 --- a/docs/systems/client-federation.md +++ b/docs/systems/client-federation.md @@ -286,6 +286,26 @@ The **Connections** panel (in user settings) allows managing remote instance con - **Remote Instances** — each shows status (connected/disconnected/error), hostname, username. Actions: Reconnect, Re-authenticate, Sync Password, Disconnect. - **Add Instance** — multi-step form: enter hostname → verify password → register/login → connected. +### Add-Instance Pre-Flight: `federatedRegistrationOpen` + +The hostname-probe step calls `GET /api/instance/info` on the target. The response carries two registration fields: + +```typescript +{ name, version, registrationOpen: boolean, federatedRegistrationOpen: boolean } +``` + +`federatedRegistrationOpen` is the gate for **creating a federated `username@thisInstance` account** via the Connections flow. When the probe returns `federatedRegistrationOpen === false`, `ConnectedInstances.tsx` (the AddInstanceFlow's password step) renders an amber-tinted banner above the password input: + +> "This instance has disabled new federated registrations. Existing accounts can still sign in." + +**The submit button stays enabled.** This is the [login-unaffected invariant](auth.md#3-registration-flow) made operational on the client. The flow runs through `instanceStore`'s register-then-login fall-through: +- A user **without** an existing federated account on the target — register attempts 403 with `Federated registration is closed on this instance`; login attempts then fail with the existing "no account" error; the user sees the post-error toast. +- A user **with** an existing federated account on the target — register 403s, then login succeeds against their existing credentials. Working path preserved for legitimate re-login. + +Disabling submit would extend the gate into login territory and soft-lock users with existing accounts on a closed instance — exactly the failure mode the invariant prevents. The 403 server-side stays as the security boundary; the banner is a UX hint. + +The probe response is not cached client-side beyond the in-flight request, so toggle flips on the target are observed on the next add-instance attempt without explicit invalidation. + ### Identity Deletion Each remote instance row exposes an identity deletion flow with three modes: diff --git a/docs/systems/database.md b/docs/systems/database.md index c64b5ea6..59461d7f 100644 --- a/docs/systems/database.md +++ b/docs/systems/database.md @@ -319,6 +319,42 @@ PK: (spaceId, userId, restrictionType) --- +## Registration Invites + +### invite_links +Admin-managed registration invite tokens. Status (`active` / `expired` / `exhausted` / `revoked`) is **derived** at read time from `revokedAt` + `expiresAt` + `usedCount`/`maxUses`; there is no stored status column. See `packages/server/src/utils/inviteService.ts` (`inviteStatus()`). + +| Column | Type | Default | Notes | +|--------|------|---------|-------| +| id | text PK | | Snowflake | +| token | text UNIQUE NOT NULL | | 22-char base64url (`crypto.randomBytes(16).toString('base64url')`). Rotated on revoked → reinstate. UNIQUE constraint provides the lookup index. | +| name | text NOT NULL | | Admin-facing label, 1-64 chars trimmed. | +| createdBy | text NOT NULL | | FK → users.id (no CASCADE — admin tombstone keeps the row resolvable). | +| createdAt | integer NOT NULL | | Epoch ms | +| maxUses | integer | | NULL = unlimited; positive integer otherwise. | +| usedCount | integer NOT NULL | 0 | Incremented atomically inside the redemption transaction. | +| expiresAt | integer | | Epoch ms; NULL = never expires. | +| revokedAt | integer | | Epoch ms; NULL = not revoked. Set by revoke endpoint, cleared by reinstate. | + +**Index:** `idx_invite_links_created_at` on `(createdAt)`. + +### invite_redemptions +One row per successful invite-token consumption. Surrogate ID supports future per-redemption metadata without schema churn. + +| Column | Type | Notes | +|--------|------|-------| +| id | text PK | Snowflake | +| inviteId | text NOT NULL | FK → invite_links.id ON DELETE CASCADE — hard-deleting an invite drops its redemption history with it. | +| userId | text | FK → users.id ON DELETE SET NULL — defensive against future hard-delete paths; tombstone (soft-delete) keeps the row populated. | +| registrantUsername | text NOT NULL | Snapshot of username at the registration moment. Preserves forensic value when the user is later renamed or tombstoned (`!deleted:{uid}`). | +| redeemedAt | integer NOT NULL | Epoch ms | + +**Indexes:** `idx_invite_redemptions_invite_id` on `(inviteId)`, `idx_invite_redemptions_user_id` on `(userId)`. + +The user INSERT, `usedCount` increment, and redemption row INSERT all run in a single SQLite transaction (`inviteService.redeemInvite()`), which re-derives status under the transaction to close the TOCTOU window between `/check-invite` and `/register`. + +--- + ## Instance Settings (singleton, id=1) | Column | Type | Default | Notes | @@ -334,7 +370,8 @@ PK: (spaceId, userId, restrictionType) | allowedFramerates | text NOT NULL | `'30,45,60'` | CSV | | maxResolution | integer NOT NULL | 1080 | | | maxFramerate | integer NOT NULL | 60 | | -| registrationOpen | integer | | null = use env | +| registrationOpen | integer | | Local-anonymous-signup gate. null = use env (`config.registrationOpen`); 0/1 = explicit admin override. | +| federatedRegistrationOpen | integer NOT NULL | 1 | Independent gate for federated identity replication via Connections (`POST /api/auth/register` with `homeInstance` set). Existing federated accounts always log in regardless of this value. | | gifApiKey | text | | Klipy API key | | bitrateMatrixOverrides | text | | JSON sparse overrides | | allowCustomBitrate | integer NOT NULL | 1 | | From 33547038f4a6a11fa93b9d3450d7e09c0e6969ef Mon Sep 17 00:00:00 2001 From: Jannis Braun <151788261+TheZwiss@users.noreply.github.com> Date: Wed, 29 Apr 2026 02:33:49 +0200 Subject: [PATCH 35/41] feat(invites): expose lastRedeemedAt on InviteLinkSummary --- docs/systems/admin.md | 1 + docs/systems/api.md | 1 + .../server/src/utils/inviteService.test.ts | 95 +++++++++++++++++++ packages/server/src/utils/inviteService.ts | 53 ++++++++--- packages/shared/src/types.ts | 2 + 5 files changed, 140 insertions(+), 12 deletions(-) diff --git a/docs/systems/admin.md b/docs/systems/admin.md index d1b3b939..46d62734 100644 --- a/docs/systems/admin.md +++ b/docs/systems/admin.md @@ -489,6 +489,7 @@ type InviteLinkSummary = { createdBy: string; createdByUsername: string | null; // 'Deleted User' when creator's isDeleted = 1 createdAt: number; + lastRedeemedAt: number | null; // epoch ms of most recent redemption; null when usedCount = 0 url: string; // server-built `https:///register?invite=` — clients MUST NOT assemble }; diff --git a/docs/systems/api.md b/docs/systems/api.md index 7d9f0792..b6eeb73f 100644 --- a/docs/systems/api.md +++ b/docs/systems/api.md @@ -278,6 +278,7 @@ type InviteLinkSummary = { createdBy: string; createdByUsername: string | null; // 'Deleted User' if creator tombstoned createdAt: number; + lastRedeemedAt: number | null; // epoch ms of most recent redemption; null when usedCount = 0 url: string; // server-built `https:///register?invite=` — clients MUST NOT assemble }; diff --git a/packages/server/src/utils/inviteService.test.ts b/packages/server/src/utils/inviteService.test.ts index b121bf16..be1dac35 100644 --- a/packages/server/src/utils/inviteService.test.ts +++ b/packages/server/src/utils/inviteService.test.ts @@ -595,6 +595,101 @@ describe('redeemInvite', () => { }); }); +describe('lastRedeemedAt', () => { + beforeEach(() => { + sqlite = new Database(':memory:'); + sqlite.pragma('foreign_keys = ON'); + applyMigrations(sqlite); + testDb = drizzle(sqlite, { schema }); + }); + + it('listInvites returns null lastRedeemedAt when invite has zero redemptions', () => { + const adminId = seedAdmin(); + createInvite({ name: 'no-redeems', maxUses: null, expiresAt: null }, adminId); + const list = listInvites('active'); + expect(list).toHaveLength(1); + expect(list[0]?.lastRedeemedAt).toBeNull(); + }); + + it('listInvites returns the max redeemed_at when invite has multiple redemptions', () => { + const adminId = seedAdmin(); + const invite = createInvite({ name: 'multi', maxUses: null, expiresAt: null }, adminId); + + const t1 = Date.now() - 2000; + const t2 = Date.now() - 1000; + const t3 = Date.now(); + testDb.insert(schema.users).values({ id: 'u1', username: 'alice', passwordHash: 'x', createdAt: t1 }).run(); + testDb.insert(schema.users).values({ id: 'u2', username: 'bob', passwordHash: 'x', createdAt: t2 }).run(); + testDb.insert(schema.users).values({ id: 'u3', username: 'carol', passwordHash: 'x', createdAt: t3 }).run(); + testDb.insert(schema.inviteRedemptions).values({ id: 'r1', inviteId: invite.id, userId: 'u1', registrantUsername: 'alice', redeemedAt: t1 }).run(); + testDb.insert(schema.inviteRedemptions).values({ id: 'r2', inviteId: invite.id, userId: 'u2', registrantUsername: 'bob', redeemedAt: t3 }).run(); + testDb.insert(schema.inviteRedemptions).values({ id: 'r3', inviteId: invite.id, userId: 'u3', registrantUsername: 'carol', redeemedAt: t2 }).run(); + + const list = listInvites('active'); + expect(list).toHaveLength(1); + expect(list[0]?.lastRedeemedAt).toBe(t3); + }); + + it('listInvites correctly attributes lastRedeemedAt to the right invite when multiple invites exist', () => { + const adminId = seedAdmin(); + const invA = createInvite({ name: 'A', maxUses: null, expiresAt: null }, adminId); + const invB = createInvite({ name: 'B', maxUses: null, expiresAt: null }, adminId); + + const tA = Date.now() - 5000; + const tB = Date.now() - 1000; + testDb.insert(schema.users).values({ id: 'uA', username: 'alice', passwordHash: 'x', createdAt: tA }).run(); + testDb.insert(schema.users).values({ id: 'uB', username: 'bob', passwordHash: 'x', createdAt: tB }).run(); + testDb.insert(schema.inviteRedemptions).values({ id: 'rA', inviteId: invA.id, userId: 'uA', registrantUsername: 'alice', redeemedAt: tA }).run(); + testDb.insert(schema.inviteRedemptions).values({ id: 'rB', inviteId: invB.id, userId: 'uB', registrantUsername: 'bob', redeemedAt: tB }).run(); + + const list = listInvites('active'); + const summaryA = list.find(i => i.id === invA.id); + const summaryB = list.find(i => i.id === invB.id); + expect(summaryA?.lastRedeemedAt).toBe(tA); + expect(summaryB?.lastRedeemedAt).toBe(tB); + }); + + it('getInviteByToken returns the raw row (not InviteLinkSummary) — lastRedeemedAt is not on the raw row type', () => { + // getInviteByToken intentionally returns the raw $inferSelect row for use + // by the registration flow, not an InviteLinkSummary. Confirm the raw row + // exists and can be fetched — the absence of lastRedeemedAt on the type is + // enforced by TypeScript at compile time. + const adminId = seedAdmin(); + const created = createInvite({ name: 'a', maxUses: null, expiresAt: null }, adminId); + const rawRow = getInviteByToken(created.token); + expect(rawRow).not.toBeNull(); + expect(rawRow?.id).toBe(created.id); + }); + + it('after redeemInvite, a subsequent listInvites reflects the updated lastRedeemedAt', () => { + const adminId = seedAdmin(); + const invite = createInvite({ name: 'redeem-test', maxUses: 5, expiresAt: null }, adminId); + + // Confirm null before any redemption + const before = listInvites('active'); + expect(before.find(i => i.id === invite.id)?.lastRedeemedAt).toBeNull(); + + const newUserId = 'redeemer-2'; + const tBefore = Date.now(); + redeemInvite(invite.token, () => { + testDb.insert(schema.users).values({ + id: newUserId, + username: 'redeemer2', + passwordHash: 'x', + createdAt: Date.now(), + }).run(); + return { id: newUserId, username: 'redeemer2' }; + }); + const tAfter = Date.now(); + + const after = listInvites('active'); + const summary = after.find(i => i.id === invite.id); + expect(summary?.lastRedeemedAt).not.toBeNull(); + expect(summary?.lastRedeemedAt).toBeGreaterThanOrEqual(tBefore); + expect(summary?.lastRedeemedAt).toBeLessThanOrEqual(tAfter); + }); +}); + describe('deleteInvite', () => { beforeEach(() => { sqlite = new Database(':memory:'); diff --git a/packages/server/src/utils/inviteService.ts b/packages/server/src/utils/inviteService.ts index 6130a800..57cb1b1e 100644 --- a/packages/server/src/utils/inviteService.ts +++ b/packages/server/src/utils/inviteService.ts @@ -1,5 +1,5 @@ import crypto from 'node:crypto'; -import { eq, desc } from 'drizzle-orm'; +import { eq, desc, sql } from 'drizzle-orm'; import { getDb, schema } from '../db/index.js'; import { generateSnowflake } from './snowflake.js'; import { config } from '../config.js'; @@ -132,14 +132,20 @@ function foldUsername(username: string | null, isDeleted: number | null): string } /** - * Project an `invite_links` row plus the resolved creator-username into the - * shared `InviteLinkSummary` shape. Centralized so list/create/patch/reinstate - * all return identically-shaped rows. Status is derived (never stored) per - * spec §2.1. + * Project an `invite_links` row plus the resolved creator-username and the + * most-recent redemption timestamp into the shared `InviteLinkSummary` shape. + * Centralized so list/create/patch/reinstate all return identically-shaped + * rows. Status is derived (never stored) per spec §2.1. + * + * `lastRedeemedAt` is `null` when the invite has zero redemptions. Callers + * that run inside a transaction pass the `MAX(redeemed_at)` they read inside + * the same transaction handle; `listInvites` bakes this into the SELECT + * projection as a correlated subquery. */ function rowToSummary( row: typeof schema.inviteLinks.$inferSelect, createdByUsername: string | null, + lastRedeemedAt: number | null, ): InviteLinkSummary { return { id: row.id, @@ -153,10 +159,31 @@ function rowToSummary( createdBy: row.createdBy, createdByUsername, createdAt: row.createdAt, + lastRedeemedAt, url: buildInviteUrl(row.token), }; } +/** + * Query `MAX(redeemed_at)` for a single invite from the `invite_redemptions` + * table. Returns `null` when there are no redemption rows for this invite. + * + * Accepts an optional Drizzle handle so mutation callers inside a transaction + * body can read the max from the same logical transaction as their surrounding + * writes. Defaults to the outer `getDb()` for non-txn callers. + */ +function resolveLastRedeemedAt( + inviteId: string, + dbHandle: ReturnType = getDb(), +): number | null { + const result = dbHandle + .select({ maxAt: sql`MAX(${schema.inviteRedemptions.redeemedAt})` }) + .from(schema.inviteRedemptions) + .where(eq(schema.inviteRedemptions.inviteId, inviteId)) + .get(); + return result?.maxAt ?? null; +} + /** * Resolve the username to display for an invite's creator. Returns the live * username, `'Deleted User'` for tombstoned accounts (spec §3.1, §4.1), or @@ -206,7 +233,8 @@ export function createInvite(req: CreateInviteRequest, creatorId: string): Invit const row = db.select().from(schema.inviteLinks).where(eq(schema.inviteLinks.id, id)).get(); if (!row) throw new Error('Failed to insert invite'); - return rowToSummary(row, resolveCreatorUsername(creatorId)); + // Freshly created invite: zero redemptions, so lastRedeemedAt is always null. + return rowToSummary(row, resolveCreatorUsername(creatorId), null); } /** @@ -238,15 +266,16 @@ export function listInvites(filter: 'active' | 'archived'): InviteLinkSummary[] invite: schema.inviteLinks, creatorUsername: schema.users.username, creatorIsDeleted: schema.users.isDeleted, + lastRedeemedAt: sql`(SELECT MAX(${schema.inviteRedemptions.redeemedAt}) FROM ${schema.inviteRedemptions} WHERE ${schema.inviteRedemptions.inviteId} = ${schema.inviteLinks.id})`, }) .from(schema.inviteLinks) .leftJoin(schema.users, eq(schema.inviteLinks.createdBy, schema.users.id)) .orderBy(desc(schema.inviteLinks.createdAt)) .all(); - const summaries = rows.map(({ invite, creatorUsername, creatorIsDeleted }) => { + const summaries = rows.map(({ invite, creatorUsername, creatorIsDeleted, lastRedeemedAt }) => { const username = foldUsername(creatorUsername, creatorIsDeleted); - return rowToSummary(invite, username); + return rowToSummary(invite, username, lastRedeemedAt); }); if (filter === 'active') return summaries.filter(s => s.status === 'active'); @@ -355,13 +384,13 @@ export function patchInvite(id: string, req: UpdateInviteRequest): InviteLinkSum if (Object.keys(updates).length === 0) { // No-op: just return current summary - return rowToSummary(row, resolveCreatorUsername(row.createdBy, tx)); + return rowToSummary(row, resolveCreatorUsername(row.createdBy, tx), resolveLastRedeemedAt(row.id, tx)); } tx.update(schema.inviteLinks).set(updates).where(eq(schema.inviteLinks.id, id)).run(); const updated = tx.select().from(schema.inviteLinks).where(eq(schema.inviteLinks.id, id)).get(); if (!updated) throw new Error('Failed to read updated invite'); - return rowToSummary(updated, resolveCreatorUsername(updated.createdBy, tx)); + return rowToSummary(updated, resolveCreatorUsername(updated.createdBy, tx), resolveLastRedeemedAt(updated.id, tx)); }); } @@ -385,7 +414,7 @@ export function revokeInvite(id: string): InviteLinkSummary { tx.update(schema.inviteLinks).set({ revokedAt: Date.now() }).where(eq(schema.inviteLinks.id, id)).run(); const updated = tx.select().from(schema.inviteLinks).where(eq(schema.inviteLinks.id, id)).get(); if (!updated) throw new Error('Failed to read updated invite'); - return rowToSummary(updated, resolveCreatorUsername(updated.createdBy, tx)); + return rowToSummary(updated, resolveCreatorUsername(updated.createdBy, tx), resolveLastRedeemedAt(updated.id, tx)); }); } @@ -462,7 +491,7 @@ export function reinstateInvite(id: string, req: ReinstateInviteRequest): Reinst } return { - invite: rowToSummary(updated, resolveCreatorUsername(updated.createdBy, tx)), + invite: rowToSummary(updated, resolveCreatorUsername(updated.createdBy, tx), resolveLastRedeemedAt(updated.id, tx)), tokenRotated, }; }); diff --git a/packages/shared/src/types.ts b/packages/shared/src/types.ts index e392a576..f979fdd7 100644 --- a/packages/shared/src/types.ts +++ b/packages/shared/src/types.ts @@ -1151,6 +1151,8 @@ export interface InviteLinkSummary { /** Joined from users.username at read time. `'Deleted User'` when the creator's account is tombstoned. `null` only if the FK is somehow unresolvable (defensive). */ createdByUsername: string | null; createdAt: number; + /** Epoch ms of the most recent redemption; `null` when the invite has zero redemptions. */ + lastRedeemedAt: number | null; /** Server-constructed full URL, e.g. `https://host.example/register?invite=`. Clients must NOT assemble this themselves. */ url: string; } From 3fdf5016202c4d61d023c236d052de85efc7e390 Mon Sep 17 00:00:00 2001 From: Jannis Braun <151788261+TheZwiss@users.noreply.github.com> Date: Wed, 29 Apr 2026 02:36:52 +0200 Subject: [PATCH 36/41] refactor(web): RegistrationPanel tab strip restyle + count badges --- .../RegistrationPanel.tsx | 81 ++++++++++++++++--- 1 file changed, 68 insertions(+), 13 deletions(-) diff --git a/packages/web/src/components/modals/instanceSettingsPanels/RegistrationPanel.tsx b/packages/web/src/components/modals/instanceSettingsPanels/RegistrationPanel.tsx index b8c48f19..76dce4fa 100644 --- a/packages/web/src/components/modals/instanceSettingsPanels/RegistrationPanel.tsx +++ b/packages/web/src/components/modals/instanceSettingsPanels/RegistrationPanel.tsx @@ -1,6 +1,6 @@ import { createPortal } from 'react-dom'; import { useCallback, useEffect, useRef, useState } from 'react'; -import type { InviteLinkSummary, InviteRedemption } from '@backspace/shared'; +import type { InviteLinkSummary, InviteRedemption, InviteStatus } from '@backspace/shared'; import { api } from '../../../api/client'; import { useSettingsStore } from '../../../stores/settingsStore'; import { useUIStore } from '../../../stores/uiStore'; @@ -42,6 +42,33 @@ function formatExpiry(invite: InviteLinkSummary): string { return `Expires in ${hours}h`; } +function inviteStatusDotColor(status: InviteStatus): string { + switch (status) { + case 'active': return 'bg-status-online'; + case 'expired': return 'bg-accent-rose'; + case 'exhausted': return 'bg-accent-amber'; + case 'revoked': return 'bg-txt-tertiary'; + } +} + +function inviteStatusPillColor(status: InviteStatus): string { + switch (status) { + case 'expired': return 'bg-accent-rose/15 text-accent-rose'; + case 'exhausted': return 'bg-accent-amber/15 text-accent-amber'; + case 'revoked': return 'bg-white/5 text-txt-tertiary'; + case 'active': return ''; // never rendered for active + } +} + +function inviteStatusLabel(status: InviteStatus): string { + switch (status) { + case 'active': return 'Active'; + case 'expired': return 'Expired'; + case 'exhausted': return 'Exhausted'; + case 'revoked': return 'Revoked'; + } +} + type ExpiryPresetId = '1h' | '24h' | '7d' | '30d' | 'never' | 'custom'; /** Edit modal additionally supports a 'keep' option (don't change expiry on PATCH). */ @@ -1218,6 +1245,8 @@ export function RegistrationPanel() { const [tab, setTab] = useState<'active' | 'archived'>('active'); const [invites, setInvites] = useState([]); const [invitesLoading, setInvitesLoading] = useState(false); + const [activeCount, setActiveCount] = useState(0); + const [archivedCount, setArchivedCount] = useState(0); // Tracks the currently displayed tab so in-flight fetches can detect when // the user has switched tabs and discard their stale response. Without this @@ -1245,6 +1274,23 @@ export function RegistrationPanel() { [addToast], ); + const refreshCounts = useCallback(async () => { + try { + const [a, r] = await Promise.all([ + api.invites.list('active'), + api.invites.list('archived'), + ]); + setActiveCount(a.invites.length); + setArchivedCount(r.invites.length); + } catch { + // Leave previous counts on transient failure + } + }, []); + + useEffect(() => { + refreshCounts(); + }, [refreshCounts]); + useEffect(() => { if (instanceSettings) { setDraft({ @@ -1345,6 +1391,7 @@ export function RegistrationPanel() { {/* Invite Links */}
+ {/* Row 1: heading + create button */}
Invite Links
-
- {(['active', 'archived'] as const).map((t) => ( + {/* Row 2: tab strip (left) + filter placeholder (right — Commit 4) */} +
+
- ))} + +
+
{invitesLoading ? ( @@ -1382,7 +1437,7 @@ export function RegistrationPanel() { ) : (
{invites.map((inv) => ( - fetchInvites(tab)} /> + { fetchInvites(tab); refreshCounts(); }} /> ))}
)} @@ -1419,7 +1474,7 @@ export function RegistrationPanel() { {showCreate && ( setShowCreate(false)} - onCreated={() => fetchInvites('active')} + onCreated={() => { fetchInvites('active'); refreshCounts(); }} /> )} From b91bc89c5810431f92b7d1e2d30ba6ccde53e126 Mon Sep 17 00:00:00 2001 From: Jannis Braun <151788261+TheZwiss@users.noreply.github.com> Date: Wed, 29 Apr 2026 02:40:33 +0200 Subject: [PATCH 37/41] =?UTF-8?q?refactor(web):=20RegistrationPanel=20Invi?= =?UTF-8?q?teRow=20=E2=86=92=20collapsed/expanded=20model?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../RegistrationPanel.tsx | 342 +++++++++++------- 1 file changed, 206 insertions(+), 136 deletions(-) diff --git a/packages/web/src/components/modals/instanceSettingsPanels/RegistrationPanel.tsx b/packages/web/src/components/modals/instanceSettingsPanels/RegistrationPanel.tsx index 76dce4fa..2efc19e6 100644 --- a/packages/web/src/components/modals/instanceSettingsPanels/RegistrationPanel.tsx +++ b/packages/web/src/components/modals/instanceSettingsPanels/RegistrationPanel.tsx @@ -974,62 +974,34 @@ function RedemptionsModal({ invite, onClose }: RedemptionsModalProps) { interface InviteRowProps { invite: InviteLinkSummary; + expanded: boolean; + onToggleExpand: () => void; onMutate: () => void; } /** - * One row in the invite list. Owns its own modal/popover/confirm-dialog state so the - * parent panel only deals with the list-level fetch + tab state. + * One row in the invite list. Renders as a clickable collapsed header that, when + * expanded, reveals a meta grid + status-specific action row. Owns its own modal + * and confirm-dialog state so the parent panel only manages list-level fetch + + * single-row expansion state (`expandedInviteId`). * * Action surface depends on `invite.status`: - * - active → Copy link · Edit · Revoke · ⋯ (View redemptions, Delete) - * - non-active → Reinstate · ⋯ (View redemptions, Delete) - * - * The kebab popover dismisses on outside click and Escape; clicks inside its items - * already call `setShowKebab(false)` before triggering the action. + * - active → Copy link · Edit · Revoke · View redemptions + * - non-active → Reinstate · Delete permanently · View redemptions */ -function InviteRow({ invite, onMutate }: InviteRowProps) { +function InviteRow({ invite, expanded, onToggleExpand, onMutate }: InviteRowProps) { const addToast = useUIStore((s) => s.addToast); const [showEdit, setShowEdit] = useState(false); const [showReinstate, setShowReinstate] = useState(false); const [showRedemptions, setShowRedemptions] = useState(false); - const [showKebab, setShowKebab] = useState(false); const [confirmRevoke, setConfirmRevoke] = useState(false); const [confirmDelete, setConfirmDelete] = useState(false); const [actionLoading, setActionLoading] = useState(false); - const kebabContainerRef = useRef(null); - - // Dismiss kebab popover on outside click + Escape. Mirrors the pattern used in - // TransferOwnershipModal — listen on document, check containment via ref. - useEffect(() => { - if (!showKebab) return; - const handleMouseDown = (e: MouseEvent) => { - if ( - kebabContainerRef.current && - !kebabContainerRef.current.contains(e.target as Node) - ) { - setShowKebab(false); - } - }; - const handleKey = (e: KeyboardEvent) => { - if (e.key === 'Escape') { - // Stop propagation so the parent settings modal doesn't ALSO close. - e.stopPropagation(); - setShowKebab(false); - } - }; - document.addEventListener('mousedown', handleMouseDown); - document.addEventListener('keydown', handleKey, true); - return () => { - document.removeEventListener('mousedown', handleMouseDown); - document.removeEventListener('keydown', handleKey, true); - }; - }, [showKebab]); const usageLabel = invite.maxUses === null - ? `${invite.usedCount} use${invite.usedCount === 1 ? '' : 's'} · unlimited` - : `${invite.usedCount} / ${invite.maxUses} uses`; + ? `${invite.usedCount} / ∞` + : `${invite.usedCount} / ${invite.maxUses}`; const usageNearLimit = invite.maxUses !== null && invite.maxUses > 0 && invite.usedCount / invite.maxUses >= 0.8; @@ -1071,110 +1043,191 @@ function InviteRow({ invite, onMutate }: InviteRowProps) { } }; - const statusPillClass = - invite.status === 'expired' - ? 'bg-rose-500/20 text-rose-400' - : invite.status === 'exhausted' - ? 'bg-amber-500/20 text-amber-400' - : 'bg-white/10 text-txt-tertiary'; + const isActive = invite.status === 'active'; + const createdByLabel = invite.createdByUsername ?? 'Unknown'; + + // Subtitle (collapsed view) + // active → "X / Y uses · Expires in 3 days · Created by alice" + // archived → "X / Y uses · Revoked 4/12/2026 · 2d ago" + const subtitle = isActive + ? `${usageLabel} uses · ${formatExpiry(invite)} · Created by ${createdByLabel}` + : `${usageLabel} uses · ${formatExpiry(invite)} · ${formatRelative(invite.createdAt)}`; + + // Archived row 1 second-cell label + value. EXHAUSTED has no dedicated terminal + // timestamp on the invite, so we surface lastRedeemedAt (the moment that drove + // it to exhausted) when known, falling back to em-dash if absent. + let archivedTerminalLabel: string; + let archivedTerminalValue: string; + if (invite.status === 'expired') { + archivedTerminalLabel = 'EXPIRED AT'; + archivedTerminalValue = invite.expiresAt !== null ? formatRelative(invite.expiresAt) : '—'; + } else if (invite.status === 'revoked') { + archivedTerminalLabel = 'REVOKED AT'; + archivedTerminalValue = invite.revokedAt !== null ? formatRelative(invite.revokedAt) : '—'; + } else { + // exhausted + archivedTerminalLabel = 'EXHAUSTED'; + archivedTerminalValue = + invite.lastRedeemedAt !== null ? formatRelative(invite.lastRedeemedAt) : '—'; + } + + const tokenDisplay = `…${invite.token.slice(-6)}`; + const lastRedeemedDisplay = + invite.lastRedeemedAt !== null ? formatRelative(invite.lastRedeemedAt) : '—'; return ( <> -
-
-
- - {invite.name} - - - · {usageLabel} - - {invite.status !== 'active' && ( - + {/* Collapsed clickable header */} +
+
+
+
+
- {invite.status} + {invite.name} +
+
{subtitle}
+
+
+
+ {!isActive && ( + + {inviteStatusLabel(invite.status)} )} -
-
- {formatExpiry(invite)} · Created by {invite.createdByUsername ?? 'Unknown'} ·{' '} - {formatRelative(invite.createdAt)} + {expanded ? '▾' : '▸'}
-
- {invite.status === 'active' ? ( - <> - - - - - ) : ( - - )} - - {showKebab && ( -
- - + {/* Expanded body */} + {expanded && ( +
+
+ {/* Row 1: USED · (EXPIRES | terminal-status AT) · CREATED */} +
+
+
Used
+
+ {usageLabel} +
+
+ {isActive ? ( +
+
Expires
+
{formatExpiry(invite)}
+
+ ) : ( +
+
+ {archivedTerminalLabel} +
+
{archivedTerminalValue}
+
+ )} +
+
Created
+
{formatRelative(invite.createdAt)}
+
+
+ + {/* Row 2: CREATED BY · TOKEN · LAST REDEEMED */} +
+
+
Created by
+
+ {createdByLabel} +
+
+
+
Token
+
+ {tokenDisplay} +
+
+
+
Last redeemed
+
{lastRedeemedDisplay}
+
+
+ + {/* Action row */} +
+ {isActive ? ( + <> + + + + + + ) : ( + <> + + + + + )} +
- )} -
+
+ )}
{showEdit && ( @@ -1247,6 +1300,15 @@ export function RegistrationPanel() { const [invitesLoading, setInvitesLoading] = useState(false); const [activeCount, setActiveCount] = useState(0); const [archivedCount, setArchivedCount] = useState(0); + // Single-row expansion state — only one InviteRow at a time may be expanded. + // Lifted to the panel so switching tabs can reset it; otherwise an expanded row + // that scrolls out of the visible list keeps stale state. + const [expandedInviteId, setExpandedInviteId] = useState(null); + + const handleTabSwitch = (next: 'active' | 'archived') => { + setTab(next); + setExpandedInviteId(null); + }; // Tracks the currently displayed tab so in-flight fetches can detect when // the user has switched tabs and discard their stale response. Without this @@ -1408,7 +1470,7 @@ export function RegistrationPanel() {
+ + {open && ( + <> +
setOpen(false)} /> +
+ {view === 'archived' && ( + <> +
+ Status +
+ {archivedStatusOptions.map((s) => ( + + ))} +
+ + )} +
+ Sort by +
+ {view === 'active' + ? activeSortOptions.map((opt) => ( + + )) + : archivedSortOptions.map((opt) => ( + + ))} +
+ + )} +
+ ); +} + type ExpiryPresetId = '1h' | '24h' | '7d' | '30d' | 'never' | 'custom'; /** Edit modal additionally supports a 'keep' option (don't change expiry on PATCH). */ @@ -1305,6 +1481,23 @@ export function RegistrationPanel() { // that scrolls out of the visible list keeps stale state. const [expandedInviteId, setExpandedInviteId] = useState(null); + // Sort / filter state — independent per tab so switching tabs preserves each + // tab's last selection. + const [activeSort, setActiveSort] = useState('recent'); + const [archivedSort, setArchivedSort] = useState('recent'); + const [archivedStatusFilter, setArchivedStatusFilter] = useState>( + () => new Set(['expired', 'exhausted', 'revoked']), + ); + + const toggleArchivedStatus = (s: ArchivedStatus) => { + setArchivedStatusFilter((prev) => { + const next = new Set(prev); + if (next.has(s)) next.delete(s); + else next.add(s); + return next; + }); + }; + const handleTabSwitch = (next: 'active' | 'archived') => { setTab(next); setExpandedInviteId(null); @@ -1367,6 +1560,18 @@ export function RegistrationPanel() { fetchInvites(tab); }, [tab, fetchInvites]); + // Derived sorted + filtered list. Tab badge counts (`activeCount`/`archivedCount`) + // are NOT derived from this — they reflect the full unfiltered bucket size. + // Must be declared before any conditional early-return to satisfy rules-of-hooks. + const displayInvites = useMemo(() => { + let list = invites; + if (tab === 'archived') { + list = filterInvitesByStatus(list, archivedStatusFilter); + } + list = sortInvites(list, tab === 'active' ? activeSort : archivedSort); + return list; + }, [invites, tab, activeSort, archivedSort, archivedStatusFilter]); + if (!draft) return
Loading settings...
; const hasChanges = !!instanceSettings && ( @@ -1465,7 +1670,7 @@ export function RegistrationPanel() {
- {/* Row 2: tab strip (left) + filter placeholder (right — Commit 4) */} + {/* Row 2: tab strip (left) + FilterDropdown (right) */}
-
+
{invitesLoading ? ( @@ -1496,9 +1709,13 @@ export function RegistrationPanel() {
{tab === 'active' ? 'No active invite links.' : 'No archived invite links.'}
+ ) : displayInvites.length === 0 ? ( +
+ No invites match the current filter. +
) : (
- {invites.map((inv) => ( + {displayInvites.map((inv) => ( Date: Wed, 29 Apr 2026 02:47:44 +0200 Subject: [PATCH 39/41] =?UTF-8?q?refactor(web):=20CreateInviteModal=20?= =?UTF-8?q?=E2=80=94=20icon-badge=20header=20+=20uppercase=20section=20lab?= =?UTF-8?q?els=20+=20dominant=20submit?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../RegistrationPanel.tsx | 59 ++++++++++++------- 1 file changed, 38 insertions(+), 21 deletions(-) diff --git a/packages/web/src/components/modals/instanceSettingsPanels/RegistrationPanel.tsx b/packages/web/src/components/modals/instanceSettingsPanels/RegistrationPanel.tsx index f487d95d..9b5358e0 100644 --- a/packages/web/src/components/modals/instanceSettingsPanels/RegistrationPanel.tsx +++ b/packages/web/src/components/modals/instanceSettingsPanels/RegistrationPanel.tsx @@ -468,18 +468,32 @@ function CreateInviteModal({ onClose, onCreated }: CreateInviteModalProps) { className="relative w-full max-w-md mx-4 glass-modal rounded-lg animate-slide-up overflow-hidden" onClick={(e) => e.stopPropagation()} > + {/* Header */} +
+
+ + + + +
+
+

Create invite link

+

+ Generate a shareable link that lets people register on this instance. You'll set how many times it can be used and when it expires. +

+
+
+ { e.preventDefault(); handleCreate(); }} - className="p-6 space-y-5" + className="px-5 pt-4 pb-5 space-y-5" > -

Create invite link

- {/* Name */}
- +
Name
- +
Max uses