Repository navigation
feat(boards): share boards by role and keep project settings per member - #9797
joshistoast wants to merge 28 commits into
Conversation
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.
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.
…haring-foundations
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.
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
left a comment
There was a problem hiding this comment.
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:
- Open a project saved before this PR.
- The content
PUTcommits. - Before the workspace
PUTlands, the user signs out.endLifetimeabortsowner.signal, soassertOwner()throws before the workspace request is sent.accountOwnedWorkbenchDatabase.ts:51-59then 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. - 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-75checks every board a project names (selected board, auto-add board, queue items) throughgalleryBoardExists(gallery/data/backend.ts:1071-1087).galleryBoardExistsmaps a 404 to "doesn't exist".- That runs
reconcileDeletedGalleryBoard(workbenchState.ts:3051-3104).
Triggering sequence:
- A owns board X with general access Everyone can contribute.
- B selects X and generates into it.
- A sets X to Restricted.
- B reopens the project. In B's saved workspace:
selectedBoardIdbecomes'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) setscascade_user_id = Nonefor any admin. So an admin deleting their own shared board withinclude_images=truehard-deletes other members' uploads instead of returning them to their owners' Uncategorized.admin_overridereportsfalsein that case (board_access_common.py:109), so the UI can't warn.test_delete_board_with_include_images_admin_skips_user_filterasserts this behaviour. project_idleaks through item DTOs._describehidesproject_idon aBoardDTOthe caller doesn't own.ImageDTO.project_id/VideoDTO.project_idare returned unredacted to anyone who can view a project member board shared with them, throughGET /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
refetchOnWindowFocusis false. A cachedadd_mediastays true after a downgrade. - With no listing cached at all (gallery widget closed for more than 5 minutes, or straight after startup),
canAddToCachedGalleryBoardreturnslistings.length === 0(queryCache.ts:480). That lets a view-only board through. - Either way the run fails in
ImagesInterface.saveafter 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-168returns withoutinvalidateGallery.
- There is no access-change event, and
- Explicit board ids in workflow board fields are never checked.
resolveBoardSentinel(buildGraph.ts:71-81) redirects onlyautoand 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
createBoardcan land between the optimistic patch andscheduleGalleryInvalidation'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_workspacechecks only the document floor, andProjectWorkspaceSaveRequesthas nomax_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 callDatabase.open_sqlitedirectly, so they don't get theuses_databasemark. The server branches therefore aren't exercised with data: the duplicate re-key, theDROP PRIMARY KEY, ADD PRIMARY KEYswap, and theshared_boardscopy.test_portable_migrationsruns that DDL on empty tables only. - A share on a board with a NULL owner is dropped.
migration_2026_10_10_board_roles.py:68filters on_shared_boards.c.user_id != _boards.c.user_id, which is NULL whenboards.user_idis 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 usesensure_ascii=Falsewith 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 readsis_adminfrom the demotedsystemuser.BoardsInterface._viewerrefuses a deactivated user but not a deleted one.ImagesInterfaceandVideosInterfacerefuse both. - Single-account board folding is applied in one place only.
asSingleAccountBoardsruns only inuseGalleryBoards. The image context menu (ImageContextMenu.tsx:1073) and the workflowBoardInputshow 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,1762still refuses to claim a board whoseboard_visibility !== 'private', and its seeded boards have noboard_visibility. - A workflow id of
__proto__loses itslastRun.projectHalves.ts:83-88writes 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_changesfails closed.- The
board_visibilityalias 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.
- Every board, item, project and queue-origin route I traced goes through
- 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.
…eat/sharing-foundations
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.
board_accessservice decides every board and item action. Routers, graph execution, workflow calls and upload events all use it.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 answers404. An action their role does not allow also answers403.board_visibilityandshared_boardsbecomegeneral_access(Restricted / Everyone can view / Everyone can contribute) plus aboard_memberstable.BoardDTOreportsmy_role,allowed_actions,admin_overrideandgeneral_access.project_idonly 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_workspacestable andPUT /projects/{id}/workspacePUT /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.invkv3:project.jsonis the content, with an optionalworkspace.jsonbehind Include my settings for this projectGallery board UI:
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:releasepasses (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.jsonandschema.tsregenerated; 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: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 --checkon 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:tests/model_identification: the Git LFS fixtures are not fetched here.test_app_info.py(busy database): they fail only whilewebv2/distis built, because the UI's catch-all mount answers first. With the build moved aside, the file passes 36/36.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.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.test_ministral3_encoder_config,test_mistral_encoder_checkpoint_loader,test_ideogram4_diffusers_loader,test_migration_loader) ran out of/tmpquota here and pass when rerun.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:
node --input-type=module -e "import { startMockBackend } from './scripts/mock-backend.mjs'; await startMockBackend(9191, { profile: 'representative' });"frominvokeai/frontend/webv2INVOKEAI_DEV_BACKEND=http://127.0.0.1:9191 pnpm exec vite dev --port 5273curl -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-accessrevokes access behind the page's back.Verified in the browser this way:
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_idleaked 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 itslastRun. 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:
Remaining limits:
Compatibility / Rollout
Migrations: three linear migrations, after feat(boards): boards belong to projects, with a Library tier and whole-project transfer #9713's:
general_access,board_members;board_visibility/shared_boardsdropped)boards.project_id)project_workspacesplusminimum_document_schema_versionPrivate boards become Restricted, shared boards Everyone can view, and public boards Everyone can contribute.
API:
board_visibilityis kept as a deprecated alias derived fromgeneral_accessuntil 8.0; sending both is a422.board_visibility,shared_user_idsandboard_owner_id.project_idon boards, images and videos is returned only to owners and administrators.403(Not authorized to access this board);404means the board does not exist.PUT /projects/{id}takes an optionalworkspacefor the save that splits a whole project, and answersProjectWithWorkspaceDTO; aworkspaceon a save that leaves the document floor below 4 is400workspace_needs_split_document.PUT /projects/{id}/workspacetakesmax_canvas_schema_version.openapi.jsonandschema.tsare regenerated.Clients:
412 document_schema_unsupported) and refuse.invkv3 files.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
What's Newcopy (if doing a release after this PR)