From 58d67c1f3806f266a3b6ca551697f26ecb18cfcf Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 20 Feb 2026 03:38:14 +0000 Subject: [PATCH 1/5] Initial plan From 32c568d7caeefe60a0d5a2e4a24270314313ad7b Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 20 Feb 2026 03:42:31 +0000 Subject: [PATCH 2/5] Fix: strip image preview from InvocationProgressEvent sent to admin room Co-authored-by: lstein <111189+lstein@users.noreply.github.com> --- invokeai/app/api/sockets.py | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/invokeai/app/api/sockets.py b/invokeai/app/api/sockets.py index 41f54e31b14..fcead54eb1e 100644 --- a/invokeai/app/api/sockets.py +++ b/invokeai/app/api/sockets.py @@ -227,8 +227,13 @@ async def _handle_queue_event(self, event: FastAPIEvent[QueueEventBase]): # Emit to the user's room await self._sio.emit(event=event_name, data=event_data.model_dump(mode="json"), room=user_room) - # Also emit to admin room so admins can see all events - await self._sio.emit(event=event_name, data=event_data.model_dump(mode="json"), room="admin") + # Also emit to admin room so admins can see all events, but strip image preview data + # from InvocationProgressEvent to prevent admins from seeing other users' image content + if isinstance(event_data, InvocationProgressEvent): + admin_event_data = event_data.model_copy(update={"image": None}) + await self._sio.emit(event=event_name, data=admin_event_data.model_dump(mode="json"), room="admin") + else: + await self._sio.emit(event=event_name, data=event_data.model_dump(mode="json"), room="admin") logger.debug(f"Emitted private invocation event {event_name} to user room {user_room} and admin room") From 3d88f0d19f1762505d9380e6a4ce29fb868056d8 Mon Sep 17 00:00:00 2001 From: Lincoln Stein Date: Thu, 19 Feb 2026 22:49:16 -0500 Subject: [PATCH 3/5] chore: ruff --- invokeai/app/services/shared/sqlite/sqlite_util.py | 1 - 1 file changed, 1 deletion(-) diff --git a/invokeai/app/services/shared/sqlite/sqlite_util.py b/invokeai/app/services/shared/sqlite/sqlite_util.py index 5162e1a1c8f..fb8ca9fca38 100644 --- a/invokeai/app/services/shared/sqlite/sqlite_util.py +++ b/invokeai/app/services/shared/sqlite/sqlite_util.py @@ -32,7 +32,6 @@ from invokeai.app.services.shared.sqlite_migrator.migrations.migration_27 import build_migration_27 from invokeai.app.services.shared.sqlite_migrator.migrations.migration_28 import build_migration_28 from invokeai.app.services.shared.sqlite_migrator.migrations.migration_29 import build_migration_29 - from invokeai.app.services.shared.sqlite_migrator.sqlite_migrator_impl import SqliteMigrator From 1e092722bc16f8bc5fbe713e63d45e6398fbbf9f Mon Sep 17 00:00:00 2001 From: Lincoln Stein Date: Thu, 19 Feb 2026 22:53:47 -0500 Subject: [PATCH 4/5] fix(backend): add migration_29 file --- .../migrations/migration_29.py | 222 ++++++++++++++++++ 1 file changed, 222 insertions(+) create mode 100644 invokeai/app/services/shared/sqlite_migrator/migrations/migration_29.py diff --git a/invokeai/app/services/shared/sqlite_migrator/migrations/migration_29.py b/invokeai/app/services/shared/sqlite_migrator/migrations/migration_29.py new file mode 100644 index 00000000000..853d3bce2d2 --- /dev/null +++ b/invokeai/app/services/shared/sqlite_migrator/migrations/migration_29.py @@ -0,0 +1,222 @@ +"""Migration 29: Add multi-user support. + +This migration adds the database schema for multi-user support, including: +- users table for user accounts +- user_sessions table for session management +- user_invitations table for invitation system +- shared_boards table for board sharing +- Adding user_id columns to existing tables for data ownership +""" + +import sqlite3 + +from invokeai.app.services.shared.sqlite_migrator.sqlite_migrator_common import Migration + + +class Migration29Callback: + """Migration to add multi-user support.""" + + def __call__(self, cursor: sqlite3.Cursor) -> None: + self._create_users_table(cursor) + self._create_user_sessions_table(cursor) + self._create_user_invitations_table(cursor) + self._create_shared_boards_table(cursor) + self._update_boards_table(cursor) + self._update_images_table(cursor) + self._update_workflows_table(cursor) + self._update_session_queue_table(cursor) + self._update_style_presets_table(cursor) + self._create_system_user(cursor) + + def _create_users_table(self, cursor: sqlite3.Cursor) -> None: + """Create users table.""" + cursor.execute(""" + CREATE TABLE IF NOT EXISTS users ( + user_id TEXT NOT NULL PRIMARY KEY, + email TEXT NOT NULL UNIQUE, + display_name TEXT, + password_hash TEXT NOT NULL, + is_admin BOOLEAN NOT NULL DEFAULT FALSE, + is_active BOOLEAN NOT NULL DEFAULT TRUE, + created_at DATETIME NOT NULL DEFAULT(STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW')), + updated_at DATETIME NOT NULL DEFAULT(STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW')), + last_login_at DATETIME + ); + """) + + cursor.execute("CREATE INDEX IF NOT EXISTS idx_users_email ON users(email);") + cursor.execute("CREATE INDEX IF NOT EXISTS idx_users_is_admin ON users(is_admin);") + cursor.execute("CREATE INDEX IF NOT EXISTS idx_users_is_active ON users(is_active);") + + cursor.execute(""" + CREATE TRIGGER IF NOT EXISTS tg_users_updated_at + AFTER UPDATE ON users FOR EACH ROW + BEGIN + UPDATE users SET updated_at = STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW') + WHERE user_id = old.user_id; + END; + """) + + def _create_user_sessions_table(self, cursor: sqlite3.Cursor) -> None: + """Create user_sessions table for session management.""" + cursor.execute(""" + CREATE TABLE IF NOT EXISTS user_sessions ( + session_id TEXT NOT NULL PRIMARY KEY, + user_id TEXT NOT NULL, + token_hash TEXT NOT NULL, + expires_at DATETIME NOT NULL, + created_at DATETIME NOT NULL DEFAULT(STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW')), + last_activity_at DATETIME NOT NULL DEFAULT(STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW')), + FOREIGN KEY (user_id) REFERENCES users(user_id) ON DELETE CASCADE + ); + """) + + cursor.execute("CREATE INDEX IF NOT EXISTS idx_user_sessions_user_id ON user_sessions(user_id);") + cursor.execute("CREATE INDEX IF NOT EXISTS idx_user_sessions_token_hash ON user_sessions(token_hash);") + cursor.execute("CREATE INDEX IF NOT EXISTS idx_user_sessions_expires_at ON user_sessions(expires_at);") + + def _create_user_invitations_table(self, cursor: sqlite3.Cursor) -> None: + """Create user_invitations table for invitation system.""" + cursor.execute(""" + CREATE TABLE IF NOT EXISTS user_invitations ( + invitation_id TEXT NOT NULL PRIMARY KEY, + email TEXT NOT NULL, + invited_by TEXT NOT NULL, + invitation_code TEXT NOT NULL UNIQUE, + is_admin BOOLEAN NOT NULL DEFAULT FALSE, + expires_at DATETIME NOT NULL, + used_at DATETIME, + created_at DATETIME NOT NULL DEFAULT(STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW')), + FOREIGN KEY (invited_by) REFERENCES users(user_id) ON DELETE CASCADE + ); + """) + + cursor.execute("CREATE INDEX IF NOT EXISTS idx_user_invitations_email ON user_invitations(email);") + cursor.execute( + "CREATE INDEX IF NOT EXISTS idx_user_invitations_invitation_code ON user_invitations(invitation_code);" + ) + cursor.execute("CREATE INDEX IF NOT EXISTS idx_user_invitations_expires_at ON user_invitations(expires_at);") + + def _create_shared_boards_table(self, cursor: sqlite3.Cursor) -> None: + """Create shared_boards table for board sharing.""" + cursor.execute(""" + CREATE TABLE IF NOT EXISTS shared_boards ( + board_id TEXT NOT NULL, + user_id TEXT NOT NULL, + can_edit BOOLEAN NOT NULL DEFAULT FALSE, + shared_at DATETIME NOT NULL DEFAULT(STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW')), + PRIMARY KEY (board_id, user_id), + FOREIGN KEY (board_id) REFERENCES boards(board_id) ON DELETE CASCADE, + FOREIGN KEY (user_id) REFERENCES users(user_id) ON DELETE CASCADE + ); + """) + + cursor.execute("CREATE INDEX IF NOT EXISTS idx_shared_boards_user_id ON shared_boards(user_id);") + cursor.execute("CREATE INDEX IF NOT EXISTS idx_shared_boards_board_id ON shared_boards(board_id);") + + def _update_boards_table(self, cursor: sqlite3.Cursor) -> None: + """Add user_id and is_public columns to boards table.""" + # Check if boards table exists + cursor.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='boards';") + if cursor.fetchone() is None: + return + + # Check if user_id column exists + cursor.execute("PRAGMA table_info(boards);") + columns = [row[1] for row in cursor.fetchall()] + + if "user_id" not in columns: + cursor.execute("ALTER TABLE boards ADD COLUMN user_id TEXT DEFAULT 'system';") + cursor.execute("CREATE INDEX IF NOT EXISTS idx_boards_user_id ON boards(user_id);") + + if "is_public" not in columns: + cursor.execute("ALTER TABLE boards ADD COLUMN is_public BOOLEAN NOT NULL DEFAULT FALSE;") + cursor.execute("CREATE INDEX IF NOT EXISTS idx_boards_is_public ON boards(is_public);") + + def _update_images_table(self, cursor: sqlite3.Cursor) -> None: + """Add user_id column to images table.""" + # Check if images table exists + cursor.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='images';") + if cursor.fetchone() is None: + return + + cursor.execute("PRAGMA table_info(images);") + columns = [row[1] for row in cursor.fetchall()] + + if "user_id" not in columns: + cursor.execute("ALTER TABLE images ADD COLUMN user_id TEXT DEFAULT 'system';") + cursor.execute("CREATE INDEX IF NOT EXISTS idx_images_user_id ON images(user_id);") + + def _update_workflows_table(self, cursor: sqlite3.Cursor) -> None: + """Add user_id and is_public columns to workflows table.""" + # Check if workflows table exists + cursor.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='workflows';") + if cursor.fetchone() is None: + return + + cursor.execute("PRAGMA table_info(workflows);") + columns = [row[1] for row in cursor.fetchall()] + + if "user_id" not in columns: + cursor.execute("ALTER TABLE workflows ADD COLUMN user_id TEXT DEFAULT 'system';") + cursor.execute("CREATE INDEX IF NOT EXISTS idx_workflows_user_id ON workflows(user_id);") + + if "is_public" not in columns: + cursor.execute("ALTER TABLE workflows ADD COLUMN is_public BOOLEAN NOT NULL DEFAULT FALSE;") + cursor.execute("CREATE INDEX IF NOT EXISTS idx_workflows_is_public ON workflows(is_public);") + + def _update_session_queue_table(self, cursor: sqlite3.Cursor) -> None: + """Add user_id column to session_queue table.""" + # Check if session_queue table exists + cursor.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='session_queue';") + if cursor.fetchone() is None: + return + + cursor.execute("PRAGMA table_info(session_queue);") + columns = [row[1] for row in cursor.fetchall()] + + if "user_id" not in columns: + cursor.execute("ALTER TABLE session_queue ADD COLUMN user_id TEXT DEFAULT 'system';") + cursor.execute("CREATE INDEX IF NOT EXISTS idx_session_queue_user_id ON session_queue(user_id);") + + def _update_style_presets_table(self, cursor: sqlite3.Cursor) -> None: + """Add user_id and is_public columns to style_presets table.""" + # Check if style_presets table exists + cursor.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='style_presets';") + if cursor.fetchone() is None: + return + + cursor.execute("PRAGMA table_info(style_presets);") + columns = [row[1] for row in cursor.fetchall()] + + if "user_id" not in columns: + cursor.execute("ALTER TABLE style_presets ADD COLUMN user_id TEXT DEFAULT 'system';") + cursor.execute("CREATE INDEX IF NOT EXISTS idx_style_presets_user_id ON style_presets(user_id);") + + if "is_public" not in columns: + cursor.execute("ALTER TABLE style_presets ADD COLUMN is_public BOOLEAN NOT NULL DEFAULT FALSE;") + cursor.execute("CREATE INDEX IF NOT EXISTS idx_style_presets_is_public ON style_presets(is_public);") + + def _create_system_user(self, cursor: sqlite3.Cursor) -> None: + """Create system user for backward compatibility. + + The system user is NOT an admin - it's just used to own existing data + from before multi-user support was added. Real admin users should be + created through the /auth/setup endpoint. + """ + cursor.execute(""" + INSERT OR IGNORE INTO users (user_id, email, display_name, password_hash, is_admin, is_active) + VALUES ('system', 'system@system.invokeai', 'System', '', FALSE, TRUE); + """) + + +def build_migration_29() -> Migration: + """Builds the migration object for migrating from version 28 to version 29. + + This migration adds multi-user support to the database schema. + """ + return Migration( + from_version=28, + to_version=29, + callback=Migration29Callback(), + ) From 13114537976f236006ba07ec9a1a0c69bb89c049 Mon Sep 17 00:00:00 2001 From: Lincoln Stein Date: Thu, 19 Feb 2026 23:09:17 -0500 Subject: [PATCH 5/5] chore(tests): fix migration_29 test --- tests/test_sqlite_migrator.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/tests/test_sqlite_migrator.py b/tests/test_sqlite_migrator.py index 42fdb6d798e..b9e02c0b5e5 100644 --- a/tests/test_sqlite_migrator.py +++ b/tests/test_sqlite_migrator.py @@ -298,14 +298,14 @@ def test_idempotent_migrations(migrator: SqliteMigrator, migration_create_test_t assert migrator._get_current_version(cursor) == 1 -def test_migration_26_creates_users_table(logger: Logger) -> None: - """Test that migration 26 creates the users table and related tables.""" - from invokeai.app.services.shared.sqlite_migrator.migrations.migration_26 import Migration26Callback +def test_migration_29_creates_users_table(logger: Logger) -> None: + """Test that migration 29 creates the users table and related tables.""" + from invokeai.app.services.shared.sqlite_migrator.migrations.migration_29 import Migration29Callback db = SqliteDatabase(db_path=None, logger=logger, verbose=False) cursor = db._conn.cursor() - # Create minimal tables that migration 26 expects to exist + # Create minimal tables that migration 29 expects to exist cursor.execute("CREATE TABLE IF NOT EXISTS boards (board_id TEXT PRIMARY KEY);") cursor.execute("CREATE TABLE IF NOT EXISTS images (image_name TEXT PRIMARY KEY);") cursor.execute("CREATE TABLE IF NOT EXISTS workflows (workflow_id TEXT PRIMARY KEY);") @@ -313,7 +313,7 @@ def test_migration_26_creates_users_table(logger: Logger) -> None: db._conn.commit() # Run migration callback directly (not through migrator to avoid chain validation) - migration_callback = Migration26Callback() + migration_callback = Migration29Callback() migration_callback(cursor) db._conn.commit()