Context
docs/multi-user.md advertised per-user root folders, but they were never implemented — corrected in the docs by #1219. This issue tracks actually building the feature. Surfaced by a user who enabled BINDERY_ENFORCE_TENANCY expecting their own root folders and found none (no UI as admin or user).
Current state (the gap)
root_folders has an owner_user_id column (migration 025), but:
RootFolderRepo.Create (internal/db/root_folders.go) never sets it — every folder is created owner=0 (global).
RootFolderRepo.List never filters by user — every user sees the whole shared pool.
- The management UI (
web/src/pages/settings/RootFoldersTab.tsx) is admin-only (ADMIN_TABS in SettingsPage.tsx); regular users can't see or manage root folders at all.
- Import destinations are not user-aware.
Scanner.effectiveLibraryDir (internal/importer/scanner.go) resolves the target as: author's RootFolderID → global library.defaultRootFolderId setting → BINDERY_LIBRARY_DIR. No user dimension anywhere, so even with per-user folders, files wouldn't land in a per-user tree.
So tenancy today isolates what each user sees (authors/books/profiles/queue/history), not where files live. All users share one physical library tree.
Scope to actually implement
This is more than CRUD scoping — the load-bearing piece is the import-path resolver.
- DB layer —
RootFolderRepo.Create stamp owner_user_id; List scope via QueryScopeFor (admin / userID==0 passthrough, mirroring authors/books repos); ownership checks on Get/Update/Delete (Delete already calls CheckOwnership, but it's inert while everything is owner=0).
- API — thread the requesting user's ID through the root-folder handlers (
internal/api/root_folders.go), CreateForUser-style, like authors.
- Import path resolver (critical) — make
effectiveLibraryDir user-aware: resolve the destination from the owning user's root folder / per-user default, not just per-author + global default. Decide how this composes with the existing per-author RootFolderID.
- Per-user default —
library.defaultRootFolderId is a single global setting; needs a per-user default (or per-user fallback) so each user's adds route correctly.
- UI — surface root folder management to regular users (it's currently admin-only). Decide the model: shared admin pool plus per-user folders, or fully per-user.
- Back-compat / migration — existing
owner=0 folders stay shared; define semantics when tenancy is on (treat owner=0 as shared-to-all vs admin-only).
Open design questions
- Do admins keep a shared global pool and users get their own, or is it fully per-user under
EnforceTenancy?
- With tenancy off (default), behaviour must stay exactly as today (single shared pool).
- How does a per-user root folder interact with the per-author
RootFolderID already selectable in the Add/Edit Author modals?
Acceptance
- With
BINDERY_ENFORCE_TENANCY=true, a non-admin user can create/list/manage only their own root folders, and their imports land in their own tree.
- Tenancy off → unchanged single-pool behaviour.
- The capability matrix in
docs/multi-user.md can truthfully list root folders as per-user again.
Context
docs/multi-user.mdadvertised per-user root folders, but they were never implemented — corrected in the docs by #1219. This issue tracks actually building the feature. Surfaced by a user who enabledBINDERY_ENFORCE_TENANCYexpecting their own root folders and found none (no UI as admin or user).Current state (the gap)
root_foldershas anowner_user_idcolumn (migration 025), but:RootFolderRepo.Create(internal/db/root_folders.go) never sets it — every folder is createdowner=0(global).RootFolderRepo.Listnever filters by user — every user sees the whole shared pool.web/src/pages/settings/RootFoldersTab.tsx) is admin-only (ADMIN_TABSinSettingsPage.tsx); regular users can't see or manage root folders at all.Scanner.effectiveLibraryDir(internal/importer/scanner.go) resolves the target as: author'sRootFolderID→ globallibrary.defaultRootFolderIdsetting →BINDERY_LIBRARY_DIR. No user dimension anywhere, so even with per-user folders, files wouldn't land in a per-user tree.So tenancy today isolates what each user sees (authors/books/profiles/queue/history), not where files live. All users share one physical library tree.
Scope to actually implement
This is more than CRUD scoping — the load-bearing piece is the import-path resolver.
RootFolderRepo.Createstampowner_user_id;Listscope viaQueryScopeFor(admin /userID==0passthrough, mirroringauthors/booksrepos); ownership checks on Get/Update/Delete (Delete already callsCheckOwnership, but it's inert while everything is owner=0).internal/api/root_folders.go),CreateForUser-style, like authors.effectiveLibraryDiruser-aware: resolve the destination from the owning user's root folder / per-user default, not just per-author + global default. Decide how this composes with the existing per-authorRootFolderID.library.defaultRootFolderIdis a single global setting; needs a per-user default (or per-user fallback) so each user's adds route correctly.owner=0folders stay shared; define semantics when tenancy is on (treat owner=0 as shared-to-all vs admin-only).Open design questions
EnforceTenancy?RootFolderIDalready selectable in the Add/Edit Author modals?Acceptance
BINDERY_ENFORCE_TENANCY=true, a non-admin user can create/list/manage only their own root folders, and their imports land in their own tree.docs/multi-user.mdcan truthfully list root folders as per-user again.