--- name: add-entity-sharing description: >- Add authenticated sharing/access-grant support to a Thrive crown entity (load/find owner+access_status, leaf access panel, owner chip, non-owner mutations, forget redirect, API/WebUI ACL tests). Use when the user asks to add sharing, access grants, invite/forget, or reader/writer support for an entity like todos, vacations, habits, docs, or similar. --- # Add entity sharing support Mirror the **todo** / **vacation** pattern. Prefer vacations as the simpler leaf reference; todos for life-plan association quirks. Canonical references: - Backend: `src/core/jupiter/core/apps/vacations/sub/vacation/service/load.py`, `sub/vacation/use_case/find.py`, `sub/vacation/use_case/load.py` - Leaf UI: `src/webui/app/routes/app/workspace/apps/vacations/vacation/$id.tsx` - Trunk UI: `src/webui/app/routes/app/workspace/apps/vacations.tsx` - Todo extras: `src/core/jupiter/core/apps/todo/components/properties-editor.tsx` - Tests: `itests/api/vacations.test.py`, `itests/webui/entities/vacations.test.py` - Access infra: `src/core/jupiter/core/common/sub/access/` ## Checklist Copy and track: ``` Sharing Progress: - [ ] 1. Allowlist + crown ACL bases - [ ] 2. Load result: owner + access_status - [ ] 3. Find result: owner + access_status + cross-workspace - [ ] 4. Mutation use cases (writer for non-owners) - [ ] 5. Generate clients - [ ] 6. Leaf panel access wiring - [ ] 7. Trunk owner chip - [ ] 8. Entity-specific foreign refs (if any) - [ ] 9. API + WebUI ACL tests ``` ## 1. Allowlist + crown ACL - Entity type must be in `ALLOWED_SHARED_ACCESS_OWNER_TYPES` in `src/core/jupiter/core/common/sub/access/shareable.py`. - If the entity needs extra access refresh beyond OwnsLink/ContainsLink cascade, add a `RefreshAccessForEntityService` and register it in `REFRESH_ACCESS_FOR_ENTITY_SERVICES` in that same file. - Create must grant `OWNER` (via `JupiterCreateCrownEntityUseCase` / `create_entity`). - Load/find/update/archive/remove should already use `Jupiter*CrownEntityUseCase` bases (READER for load/find, WRITER for mutations). If not, migrate them. ## 2. Load: return owner + access_status In the entity load **service** result: ```python owner: UserLight access_status: AccessStatus | None # None for public/guest load ``` Resolve with: - `LoadUserThatOwnsEntityService().do_it(uow, entity_link)` - `GetAccessLevelForEntityService().do_it(...)` only when `user_ref_id` is set Pass `user_ref_id=context.user.ref_id` from the authenticated load use case. Public load must omit `user_ref_id` so `access_status` stays `None`. ## 3. Find: owner + access_status + cross-workspace In each find result entry: ```python owner: UserLight access_status: AccessStatus # non-optional ``` Required pattern (do **not** constrain to the caller's collection parent): ```python entities = await self.find_all_entities( uow, context.user.ref_id, Entity, allow_archived=..., filter_ref_ids=... ) ``` Then bulk-resolve: - `OwnerUserRefIdsForEntitiesService` + `UserRepository.find_all_light_by_ref_ids` - `AccessStatusRepository.load_all_for_entities_and_user` Use `@use_case_result_part` for the entry type when matching todos/vacations. ## 4. Mutation use cases for non-owners Shared **writers** must be able to update/archive/remove without owning linked crown entities they are not retargeting. - Prefer crown bases: `load_entity` / `check_entity` → WRITER on the entity. - If update validates related crown entities (aspects, chapters, …), only ACL-check them when the ref id **actually changes**. Keeping the owner's existing links must succeed for shared writers. - Tag/contact upsert and publish typically require OWNER — leave as-is; disable those UI controls via `accessStatus` when not owner. ## 5. Generate clients After changing use-case IO: ```bash mise run generate-client-code ``` Wait for `Client code generation complete` on stderr. ## 6. Leaf panel access wiring In `$id.tsx` (or equivalent leaf): 1. Loader returns `owner` and `accessStatus` from load result. 2. Gate editing: ```ts const inputsEnabled = navigation.state === "idle" && !entity.archived && accessStatusAllowsWriterOrAbove(loaderData.accessStatus); ``` 3. Pass to `LeafPanel` (note spelling `accessable`): ```tsx accessable accessOwner={loaderData.owner} accessStatus={loaderData.accessStatus} ``` 4. Pass `accessStatus` through to publish so non-owners cannot publish. 5. `returnLocation` on the panel is used by Forget → redirect to trunk. Forget flow (shared infra — do not break it): - `forget-grant` action **redirects** to `returnLocation` after remove. - Do **not** skip revalidation for forget-grant: trunk must reload so the entity disappears from the list; redirect avoids reloading the leaf. ## 7. Trunk owner chip In the trunk list, wrap each card: ```tsx ``` Imports: `@jupiter/core/infra/component/chips`, `#/core/users/components/user-light-chip`. ## 8. Entity-specific foreign refs If the entity points at other crown entities in the **owner's** workspace (e.g. todo → aspect/chapter/goal): - Loader already returns those linked entities from load service. - Merge them into the viewer's select option lists for **display**. - Keep those controls **read-only** when the linked ref is not in the viewer's own summaries (`lifePlanAssociationsInWorkspace` pattern in `todo/components/properties-editor.tsx`). - Detach foreign aspect parent chains for tree helpers (`parent_aspect_ref_id: null` when merging). ## 9. Tests Port reader/writer scenarios from todos/vacations. ### API (`itests/api/.test.py`) - Fixture: other user with feature enabled + `grant__access` - No grant: load/update/archive denied - READER: load ok; update/archive denied; assert `owner` / `access_status` - WRITER: load+update; load+archive - Keep auth-required test ### WebUI (`itests/webui/entities/.test.py`) - No grant: absent from trunk; leaf shows access denial - READER: visible in trunk; leaf read-only (inputs/archive disabled) - WRITER: can update; can archive Invite via `invite_users_to_entity_sync` with `NamedEntityTag.` and `AccessLevel.READER|WRITER`. ## Gotchas | Issue | Fix | |-------|-----| | Find uses `parent_ref_id=collection` | Use `find_all_entities` / `parent_ref_id=None` | | Forget then leaf loader 401 | Redirect from forget-grant; don't stay on leaf | | Forget then stump list stale | Do not `shouldRevalidate=false` for forget-grant | | Shared todo AspectSelect crash | Merge foreign aspect; disable life-plan edits | | Update ACL on unchanged aspect | Only `load_entity` related crowns when value changes | | Prop spelling | LeafPanel uses `accessable` (not `accessible`) | ## Out of scope - Public publish (ADR 0010) is separate from access grants. - Generic access routes (`invite`, `remove-grant`, `forget-grant`, `get-access-for-entity`, `AccessPanel`) are shared — reuse them.