Skip to content

feat(boards): share boards by role and keep project settings per member - #9797

Open
joshistoast wants to merge 28 commits into
mainfrom
feat/sharing-foundations
Open

joshistoast wants to merge 28 commits into
mainfrom
feat/sharing-foundations

Conversation

@joshistoast

@joshistoast joshistoast commented Oct 10, 2026 •

Copy link
Copy Markdown
Collaborator

Summary

Lays the foundations for sharing in 7.0, so that later collaboration work (members, share dialog, project grants, locks) does not need another data migration.

Board roles. Every board has an owner and a role per caller: Viewer, Contributor, Editor or Owner, with administrators overriding.

  • One board_access service decides every board and item action. Routers, graph execution, workflow calls and upload events all use it.
  • A board that exists but the caller cannot open answers 403, as an image or video they cannot read does, so a revoked grant is never mistaken for a deleted board; only a missing board answers 404. An action their role does not allow also answers 403.
  • board_visibility and shared_boards become general_access (Restricted / Everyone can view / Everyone can contribute) plus a board_members table.
  • BoardDTO reports my_role, allowed_actions, admin_override and general_access.
  • Owners no longer permanently delete other people's media. Those items return to their own owner's Uncategorized. An administrator deleting their own board is an owner here; only an administrator deleting another user's board deletes everything on it.
  • Media DTOs carry project_id only for administrators and for the project's own owner.

Global project ids. Project ids are unique across accounts. Queue reads by origin are scoped to the caller.

Per-member project settings. A project's content (name, canvas, workflows) is saved apart from each member's workspace (layout, widgets, generation settings, prompt history, selection, staging):

  • project_workspaces table and PUT /projects/{id}/workspace
  • document schema 4, with a schema floor that refuses older clients
  • a project the server still holds whole is split by its first save: one PUT /projects/{id} carrying the content, the raised document floor and the member's workspace, stored in one transaction, so a save that half lands cannot lose the member's settings
  • workspace saves declare the newest canvas schema the client reads, as content saves do
  • .invk v3: project.json is the content, with an optional workspace.json behind Include my settings for this project
  • new canvas and workflow items get UUID-based ids

Gallery board UI:

  • Shared with me and, for administrators, Other users' boards, with rows like "Ana · Can view".
  • Board menus offer only what the caller's role allows, with a header saying whose board it is. Moving a board, from its menu or by dragging it onto another project or the Library, is offered only on the caller's own boards.
  • Access lets owners and administrators set general access.
  • Results, uploads and moves never target a board the caller can only view: results go to the project's Inbox, which is marked Auto.
  • A refused or revoked action refreshes the list with a notice.

What's New: a "Share boards" headline (multi-user only) and notes on Public boards and per-member settings.

Builds on #9713.

Related Issues / Discussions

Fixes the board half of #9673. Workflow sharing is still listed under known bugs.

QA Instructions

Ran on this branch:

Follow-up at 492c1be: pnpm check:release passes (lint, 10292 unit and 2925 browser tests, fixtures, architecture performance, project-file journeys, accessibility); pytest tests/app tests/fixtures -n 4: 8657 passed, 34 skipped, 5 xfailed; openapi.json and schema.ts regenerated; the shared API package's lint and tests pass. Verified in Chromium against the mock backend: revoking a member's access to their selected board moves the view to the project Inbox without rewriting the saved selection, which returns when access is granted again; the Change Board submenu and the workflow board field show the same folded listing as the panel. The three new migration tests now take the shared database fixture, so CI runs them on MySQL and MariaDB; only SQLite was available here.

First round:

  • pnpm -C invokeai/frontend/webv2 check:release:

    • lint
    • 10203 unit tests and 2918 browser tests
    • architecture performance
    • project-file journeys
    • accessibility
    • all pass. One browser test, ToastContrast (untouched here), can time out on a hover under a heavy full run; it passes 3/3 on its own.
  • uv tool run ruff@0.11.2 check / format --check on the 85 changed Python files: clean.

  • uv run --no-sync python -m pytest -n 4: 13406 passed, 102 failed, 193 skipped. Each failure was traced to this machine, not this branch:

    • 91 in tests/model_identification: the Git LFS fixtures are not fetched here.
    • 3 in test_app_info.py (busy database): they fail only while webv2/dist is built, because the UI's catch-all mount answers first. With the build moved aside, the file passes 36/36.
    • 2 (test_16_channel_vae_loader, minimax_h3/test_text_encoder_checkpoint) fail the same way on feat(boards): boards belong to projects, with a Library tier and whole-project transfer #9713's branch.
    • 1 (test_directory_install_rollback_race_preserves_both_source_artifacts) depends on the checkout filesystem's directory order. It passes 2/2 from a worktree of this branch, as on feat(boards): boards belong to projects, with a Library tier and whole-project transfer #9713's.
    • 5 (test_ministral3_encoder_config, test_mistral_encoder_checkpoint_loader, test_ideogram4_diffusers_loader, test_migration_loader) ran out of /tmp quota here and pass when rerun.
    • The run also reported 4 collection errors in test_model_cache_ram_budget.py, caused by running with -p no:warnings; the file passes without it.

    No failing test is in a file this branch changes.

  • MySQL and MariaDB run the same suite in CI; the one query-plan check (SQLite's EXPLAIN QUERY PLAN) is marked SQLite-only.

  • Each migration has its own tests; board roles are migrated from 6.13- and alpha.1-shaped data (test_migration_2026_10_10_{board_roles,global_project_ids,project_workspaces}.py).

To try sharing in the browser against the mock backend:

  1. node --input-type=module -e "import { startMockBackend } from './scripts/mock-backend.mjs'; await startMockBackend(9191, { profile: 'representative' });" from invokeai/frontend/webv2
  2. INVOKEAI_DEV_BACKEND=http://127.0.0.1:9191 pnpm exec vite dev --port 5273
  3. curl -X POST -H 'content-type: application/json' -d '{"intermediatesCaller":"user","sharedBoards":true}' http://127.0.0.1:9191/__faults
    • "multiuser-admin" instead of "user" gives the administrator view.
    • POST /__board-access revokes access behind the page's back.
  4. Sign in with any email and password, then open the Gallery:
    • Shared with me lists a board you can edit, one you can add to, and one you can only view.
    • Selecting the view-only board moves Auto to the Inbox.
    • Your own boards' menus have Access.

Verified in the browser this way:

  • each role's menu and the admin's
  • the Access submenu and its change notice
  • a revocation (notice, board removed)
  • the Inbox redirect and its tooltip
  • the disabled upload control
  • the What's New dialog in multi-user mode
  • export with and without settings, and import of v2 and v3 files

Review

Second review: a sign-out between the two requests of the lazy split could permanently wipe a member's workspace (the split is now one server transaction); losing access to a board was treated as the board being deleted (an unreadable board now answers 403, and the client keeps its references). Non-blocking findings fixed: an administrator deleting their own shared board hard-deleted other members' media; project_id leaked through image and video DTOs; a revoked or view-only destination was caught only at save time (destinations are decided from a fetched board listing on every submit, upload and canvas save, and failed runs and uploads refresh the listing); explicit board ids in workflow fields were never checked and the board field's options were cached for the session; a board created a moment ago could be overwritten by an older listing; whether results reached an archived board depended on cache timing; the workspace save ignored the canvas schema floor; the new migrations never ran against MySQL/MariaDB in CI; a share on a board with a NULL owner was dropped; re-keyed documents were re-serialized differently; identity was resolved differently at queue time; single-account board folding was applied in one place only; the mock backend had drifted; a workflow id of __proto__ lost its lastRun. The upgrade note about re-keyed project ids in open tabs is in the admin guide.

First review: material findings were fixed before merge. Among them:

  • Results could still go to a revoked or archived selected board, or, briefly, miss a just-created board; destination resolution now uses the newest relevant board listing.
  • Workflow board fields and field uploads now respect view-only boards.
  • An export with settings left out could still carry them for a project this build cannot read; it is now refused with a message.
  • A failed workspace save on import or duplicate is now reported.
  • Refusal ordering and document/canvas refusal reasons were corrected.
  • The release performance gate now passes; request counts are one lower on every route.

Remaining limits:

  • Media DTOs do not carry their owner, so the item menu cannot yet hide Delete or Remove on someone else's media. The server refuses them with its message.
  • In 7.0, exporting a closed project that is still stored whole includes the owner's settings. That is correct while projects have one member; 7.1 members will need the caller's own.

Compatibility / Rollout

  • Migrations: three linear migrations, after feat(boards): boards belong to projects, with a Library tier and whole-project transfer #9713's:

    • board roles (general_access, board_members; board_visibility/shared_boards dropped)
    • global project ids (re-keys duplicate ids with a logged warning; clears dangling boards.project_id)
    • project_workspaces plus minimum_document_schema_version

    Private boards become Restricted, shared boards Everyone can view, and public boards Everyone can contribute.

  • API:

    • board_visibility is kept as a deprecated alias derived from general_access until 8.0; sending both is a 422.
    • The upload socket event drops board_visibility, shared_user_ids and board_owner_id.
    • project_id on boards, images and videos is returned only to owners and administrators.
    • An existing board the caller cannot read answers 403 (Not authorized to access this board); 404 means the board does not exist.
    • PUT /projects/{id} takes an optional workspace for the save that splits a whole project, and answers ProjectWithWorkspaceDTO; a workspace on a save that leaves the document floor below 4 is 400 workspace_needs_split_document. PUT /projects/{id}/workspace takes max_canvas_schema_version.
    • openapi.json and schema.ts are regenerated.
  • Clients:

    • Earlier 7.0 alphas are refused projects saved in schema 4 (412 document_schema_unsupported) and refuse .invk v3 files.
    • This build still reads schema ≤3 projects and v2 files, and splits them on first save.
  • Docs: the multi-user user and admin guides ("Upgrading to 7.0"), the projects and gallery guides, the API guide, the database-layer guide and known bugs are updated.

Checklist

  • The PR has a short but descriptive title, suitable for a changelog
  • Meaningful regression coverage added / updated where needed; obsolete tests/code removed
  • Persisted-state and API changes include required migrations / compatibility validation
  • Relevant performance/efficiency opportunities considered; material claims have evidence
  • Material review findings resolved and relevant checks rerun
  • Documentation added / updated (if applicable)
  • Updated What's New copy (if doing a release after this PR)

Replace board visibility, is_public and shared_boards with board_members and general_access, read through the query layer.
Key projects by project_id across accounts, re-keying any shared id, and add per-member project_workspaces with a document schema floor.
Clear an account's board project memberships when the account is deleted.
Add a board access service that grants viewer, contributor, editor and owner actions from membership and general access; routers, graph execution, workflow calls and upload events all ask it.
Boards report general_access, my_role and allowed_actions, keep board_visibility as a deprecated alias, and answer 404 when unreadable and 403 when an action is not allowed.
Drop the unused general_access index, and document the roles and the API changes.
…o their owner

New draft projects mint project-<uuid> ids like every other client path; a create naming another account's id answers 409 and changes nothing.
Origin-scoped queue reads (item ids, current, next, status) return a non-admin only their own items, keeping their query plans.
Document global project ids and the queue scoping.
Projects read with the caller's workspace, which saves through its own revision-checked route and indexes its media separately.
A document schema floor refuses clients that predate the split; deleting a project or account drops every member's workspace references.
Document the content and workspace contract.
Layers, reference images, adjustments and workflow items take UUID-based ids from
createItemId instead of a timestamp with a short random suffix.
…'s workspace

Saves send the content (raising the document floor to 4) and then the caller's workspace,
each at its own revision, and loads join the two halves back into one document.
Drafts, recovery, conflicts, copies and duplicates track both halves, and the conflict banner names a settings-only conflict.
…settings

.invk v3 writes project.json as content beside an optional workspace.json, behind a remembered
"Include my settings for this project" option; v2 files still import, and newer schemas are refused from the manifest.
Imports and duplicates create the content, then save the creator's settings, warning when that save fails.
Groups the project halves module into the shared startup chunk, restoring the editor's request budget.
Boards shared with you list under Shared with me and admins' other-user boards under their own group; menus offer only what your role allows, with Access for owners.
Results, uploads and moves never target a board you can only view: they go to the project inbox, and refused or revoked access refreshes the list with a notice.
…ttings in What's New

Multi-user servers show a Share boards headline and the note that Public boards are now Everyone can contribute.
Every server notes that layout, generation settings and prompt history stay each member's own in a project.
@github-actions github-actions Bot added api python PRs that change python files services PRs that change app services frontend PRs that change frontend files python-tests PRs that change python tests docs PRs that change docs labels Oct 10, 2026
Board drag between projects is offered only on the caller's own boards their role lets them move.
A refused archive restores the auto-add board only if the user has not chosen another since.
The workspace reference query-plan check runs on SQLite only.
A board the caller can no longer open reads as gone when a project loads, as a deleted one does.
Board moves stay gated on the caller's role rather than board visibility: an owner can move a board whatever its general access.
Base automatically changed from feat/boards-library to main October 11, 2026 01:26
joshistoast and others added 5 commits October 10, 2026 22:21
Observe borrow-release wakeups directly and allow session GC to finish on busy runners. Give nested-loop failure cleanup a consistent integration-test deadline.
# Conflicts:
#	tests/app/services/session_processor/test_lent_device_scheduling.py

@lstein lstein left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I reviewed this at 83d4a29 by trying to break each area: board authorization, migrations and SQL, the per-member workspace split, and the gallery client. The PR's own tests pass. I ran the migrations on 6.13- and alpha.1-shaped SQLite databases. I had no MySQL/MariaDB server, so I checked the server path by reading the code and compiling its DDL offline. I found two blockers. Both lose or corrupt members' data.

Blockers

1. The lazy split from schema 3 to 4 can permanently wipe a member's workspace

durableSyncedPersistence.ts:1510-1557 (pushHalves) splits a whole (schema-3) project in two separate requests. It first sends the content PUT with minimum_document_schema_version: 4, then sends PUT /projects/{id}/workspace. On the server, ProjectRecordService.update (project_records_default.py:298) replaces projects.data with the content alone. Between the two requests, the server no longer holds the member's layout, generation settings, prompt history, selection or staging.

recordRequiresDocumentUpgrade (:377-380) makes this split happen on the first save after opening, even when nothing changed. No user action is needed to trigger it.

Triggering sequence:

  1. Open a project saved before this PR.
  2. The content PUT commits.
  3. Before the workspace PUT lands, the user signs out. endLifetime aborts owner.signal, so assertOwner() throws before the workspace request is sent. accountOwnedWorkbenchDatabase.ts:51-59 then deletes the account's IndexedDB, local draft included. A tab closing, or a private window with no draft store plus a failed request, ends the same way.
  4. Every later load joins the content with createDefaultProjectWorkspace(). The member's settings are gone.

Before this PR every save was one whole-document transaction, so a save that only half landed could not destroy data already on the server. In the same window, media_references.replace(owner_kind="project") (:307) also drops the references to staged canvas candidates. Those candidates are intermediates, so cleanup can delete them while a surviving draft still names them.

Suggested fix: do the upgrade in one server transaction (content plus the first workspace row in one request), or keep the whole document until the workspace row exists.

2. Losing access to a board is now treated as the board being deleted

_require (routers/_access.py:22-37) now answers 404 for a board the caller can't read. On the base it answered 403, and the old get_board comment explained why: "clients take it as the board being gone, and clear what names it". The client side was not changed:

  • WorkbenchRuntime.tsx:46-75 checks every board a project names (selected board, auto-add board, queue items) through galleryBoardExists (gallery/data/backend.ts:1071-1087).
  • galleryBoardExists maps a 404 to "doesn't exist".
  • That runs reconcileDeletedGalleryBoard (workbenchState.ts:3051-3104).

Triggering sequence:

  1. A owns board X with general access Everyone can contribute.
  2. B selects X and generates into it.
  3. A sets X to Restricted.
  4. B reopens the project. In B's saved workspace:
    • selectedBoardId becomes 'none', so later results go to Uncategorized, not to the project Inbox the PR describes.
    • The auto-add board is reset.
    • Every locally known item on X, including B's own images that are still on X on the server, gets boardId: 'none'.

None of this is undone if A grants access again. A reversible permission change triggers an irreversible cleanup. Either the server needs a status that separates "gone" from "denied" (as #9394 did for media), or the client must stop treating this 404 as deletion.

Non-blocking

  • Admin owners still permanently delete other people's media. delete_board (boards.py:174) sets cascade_user_id = None for any admin. So an admin deleting their own shared board with include_images=true hard-deletes other members' uploads instead of returning them to their owners' Uncategorized. admin_override reports false in that case (board_access_common.py:109), so the UI can't warn. test_delete_board_with_include_images_admin_skips_user_filter asserts this behaviour.
  • project_id leaks through item DTOs. _describe hides project_id on a BoardDTO the caller doesn't own. ImageDTO.project_id / VideoDTO.project_id are returned unredacted to anyone who can view a project member board shared with them, through GET /images/i/{name}, the list endpoints and by-names. Combined with the 409 on project create for an existing id, this confirms the project exists.
  • A revoked or view-only destination is caught only at save time, and nothing refreshes afterwards.
    • There is no access-change event, and refetchOnWindowFocus is false. A cached add_media stays true after a downgrade.
    • With no listing cached at all (gallery widget closed for more than 5 minutes, or straight after startup), canAddToCachedGalleryBoard returns listings.length === 0 (queryCache.ts:480). That lets a view-only board through.
    • Either way the run fails in ImagesInterface.save after the whole generation. Boards are re-fetched only after a successful result (features/queue/runtime.ts:1054,1125,1244), so every later run fails the same way.
    • Failed uploads behave the same: useGalleryUploadAction.ts:160-168 returns without invalidateGallery.
  • Explicit board ids in workflow board fields are never checked. resolveBoardSentinel (buildGraph.ts:71-81) redirects only auto and empty values. BoardInput's option list is cached for the whole session (boardOptionsRequest), so it doesn't follow grants or revocations.
  • A board created a moment ago can be overwritten by an older listing. A boards fetch started before createBoard can land between the optimistic patch and scheduleGalleryInvalidation's board invalidation (queryCache.ts:690-717). Results then go to the Inbox, and no Auto badge is shown.
  • Whether results reach an archived board depends on cache timing. Whether results go to a hidden archived board depends on whether an include-archived listing is still cached (queryCache.ts:455-480).
  • The workspace save ignores the canvas schema floor. save_workspace checks only the document floor, and ProjectWorkspaceSaveRequest has no max_canvas_schema_version. Yet the workspace holds canvas-schema state (stagingArea, selectedLayerId).
  • The new migrations never run against MySQL/MariaDB in CI. All three test_migration_2026_10_10_* files call Database.open_sqlite directly, so they don't get the uses_database mark. The server branches therefore aren't exercised with data: the duplicate re-key, the DROP PRIMARY KEY, ADD PRIMARY KEY swap, and the shared_boards copy. test_portable_migrations runs that DDL on empty tables only.
  • A share on a board with a NULL owner is dropped. migration_2026_10_10_board_roles.py:68 filters on _shared_boards.c.user_id != _boards.c.user_id, which is NULL when boards.user_id is NULL. or_(_boards.c.user_id.is_(None), …) fixes it. Only hand-edited databases should hit this.
  • Re-keyed project documents are re-serialized differently. They go through plain json.dumps (migration_2026_10_10_global_project_ids.py:79,126). The app uses ensure_ascii=False with compact separators (project_records_default.py:42). A document near the size limit can come out over it.
  • Browser state for a re-keyed id has no migration path. An open tab's unsynced edits, or an IndexedDB draft, under the old id comes back as a "project deleted" conflict. One line in the release notes is probably enough.
  • Identity is resolved differently at queue time. workflow_call_batch._assert_user_can_access_board (:311-332) ignores single-user mode and reads is_admin from the demoted system user. BoardsInterface._viewer refuses a deactivated user but not a deleted one. ImagesInterface and VideosInterface refuse both.
  • Single-account board folding is applied in one place only. asSingleAccountBoards runs only in useGalleryBoards. The image context menu (ImageContextMenu.tsx:1073) and the workflow BoardInput show raw DTOs, so the same board appears as the user's own in the panel and under "Other users' boards" there.
  • The mock backend has drifted from the server. mock-backend.mjs:1094,1108,1762 still refuses to claim a board whose board_visibility !== 'private', and its seeded boards have no board_visibility.
  • A workflow id of __proto__ loses its lastRun. projectHalves.ts:83-88 writes into a plain object literal. Only a crafted file can do this, and only the run preview is lost.

What held up

  • Board authorization:
    • Every board, item, project and queue-origin route I traced goes through board_access.
    • Moves need AddMedia on the destination plus ownership of the item.
    • Removal is decided against the item's current board.
    • All delete paths use ItemAction.Delete.
    • item_action_for_changes fails closed.
    • The board_visibility alias can't be used to escalate, and sending both fields gets 422.
    • Revocation after enqueue wins at save and read time.
    • Socket audiences don't reach outsiders for a Restricted board.
  • Migrations:
    • The visibility mapping holds on 6.13- and alpha.1-shaped data.
    • Re-keying rewrites every server-side reference I could find: data, boards, images, queue project_id/origin, media references and client state.
    • SQLite rolls back cleanly on a mid-migration failure, and each crash point on a server converges on rerun.
  • Workspace split:
    • Workspaces are isolated per caller.
    • Old clients are refused on every write path, including those that send no version.
    • Concurrent saves of the two halves reconcile correctly.
    • The .invk v2/v3 import edge cases are handled.

…at a revoked board as refused

A whole project is split into content and the member's workspace by one request that stores both in one
transaction, so a save that half lands can no longer wipe the member's layout, settings and staging.
An existing board the caller cannot read answers 403 instead of 404, and the client keeps its references
to it; media DTOs carry a project id only for its owner and administrators; an administrator deleting
their own board no longer deletes other members' media.
Gallery destinations are decided from a fetched board listing on every submit, upload and canvas save,
workflow board fields follow revocations, and the new migrations run on every CI database.
…'s workspace

A save that raises the document floor across the split without a workspace answers 400 split_needs_workspace,
so no client can leave split content stored beside no workspace row. The mock backend mirrors both split refusals.
A split project created at floor 4 holds no workspace row until the creator's first workspace save; the guard only
keeps the split of a whole project from landing without the member's workspace.
@joshistoast
joshistoast requested a review from lstein October 17, 2026 17:33

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

api docs PRs that change docs frontend PRs that change frontend files python PRs that change python files python-tests PRs that change python tests services PRs that change app services

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants