diff --git a/INTENT.md b/INTENT.md index 9e8b673..0cdbfc9 100644 --- a/INTENT.md +++ b/INTENT.md @@ -1,6 +1,6 @@ --- repo: coulomb-social -updated: "2026-08-10" +updated: "2026-10-06" status: active --- @@ -27,6 +27,26 @@ This repository is the **owning home** for the rebuild: Binky Hedgehog GmbH is **client #1** of its own platform (DR-2 B). The app is not a Core Hub extension; it is a normal client-facing business application. +## Current focus — User-reviewed use-case off-ramp + +Find and describe the use cases actually used in the current coulomb.social +implementation, rebuild them on our ecosystem, and move them off Bubble one +reviewed cohort at a time. Start with a nominated spaces/pages workflow under +the accepted stage-1 cut; captured feature/type inventories are discovery inputs, +not proof of complete current use-case behavior. + +The first milestone is **ready for user review of the first use-case transfer**: +the user can recognize migrated work, continue creating/editing/organizing pages, +share with the intended readers, and trust persistence and recovery. A comparison +walkthrough records old/new steps and gaps; the user accepts or rejects each +journey for transfer. Broader off-ramp continues by observing, describing, +rebuilding, comparing and transferring subsequent use cases. Full Bubble +retirement follows explicit disposition of all retained use cases. + +Execution: `workplans/CSOC-WP-0008-production-transfer-acceptance.md`. +Product backlog: `docs/usecases/catalog.md`. +User review: `docs/usecases/first-transfer-review.md`. + ## Core Idea > Exit bubble.io by duplicating the product surface into an agentic, code-owned stack — not by bolting more low-code onto a stalled base. diff --git a/SCOPE.md b/SCOPE.md index 9794eda..570ab5a 100644 --- a/SCOPE.md +++ b/SCOPE.md @@ -36,9 +36,10 @@ filesystem tenant namespace or a database-wide concurrency guarantee. ## Practical limits -- Space visibility has a tenant-wide bootstrap fallback until any tenant - memberships exist. Membership roles are stored but viewer/editor enforcement - and Bubble privacy parity are incomplete. Imported mappings add/update grants; +- Space reads now require explicit membership or a creator grant. Viewer + memberships are read-only across product mutation routes and service writes; + owner/member grants permit writes. Sharing-management UI and exact Bubble + privacy parity remain incomplete. Imported mappings add/update grants; replacement does not revoke old grants. Review access before a real cohort import. - Copy/transfer handles relative Visual files, not arbitrary body-linked assets, incoming page links, graph identity or Bubble parent/chunk semantics. Visual diff --git a/WORK-RECORDS.md b/WORK-RECORDS.md index db1f046..6887a42 100644 --- a/WORK-RECORDS.md +++ b/WORK-RECORDS.md @@ -55,10 +55,12 @@ | task | CSOC-WP-0008-T01 | wait | — | workplans/CSOC-WP-0008-production-transfer-acceptance.md | | task | CSOC-WP-0008-T02 | todo | — | workplans/CSOC-WP-0008-production-transfer-acceptance.md | | task | CSOC-WP-0008-T03 | wait | — | workplans/CSOC-WP-0008-production-transfer-acceptance.md | -| task | CSOC-WP-0008-T04 | todo | — | workplans/CSOC-WP-0008-production-transfer-acceptance.md | +| task | CSOC-WP-0008-T04 | progress | — | workplans/CSOC-WP-0008-production-transfer-acceptance.md | | task | CSOC-WP-0008-T05 | todo | — | workplans/CSOC-WP-0008-production-transfer-acceptance.md | | task | CSOC-WP-0008-T06 | wait | — | workplans/CSOC-WP-0008-production-transfer-acceptance.md | | task | CSOC-WP-0008-T07 | todo | — | workplans/CSOC-WP-0008-production-transfer-acceptance.md | +| task | CSOC-WP-0008-T08 | progress | — | workplans/CSOC-WP-0008-production-transfer-acceptance.md | +| task | CSOC-WP-0008-T09 | progress | — | workplans/CSOC-WP-0008-production-transfer-acceptance.md | | intake | CSOC-IN-0001 | closed | blue | docs/intakes/csoc-residuals.md | | intake | CSOC-IN-0002 | closed | green | docs/intakes/csoc-residuals.md | | intake | CSOC-IN-0003 | open | blue | docs/intakes/csoc-residuals.md | diff --git a/coulomb_social/apps/spaces/services.py b/coulomb_social/apps/spaces/services.py index 321865a..e00d957 100644 --- a/coulomb_social/apps/spaces/services.py +++ b/coulomb_social/apps/spaces/services.py @@ -12,22 +12,11 @@ from .models import Space, SpaceMembership def spaces_for_member(member: Member | None) -> QuerySet[Space]: - """Spaces visible to this member (same tenant, active). - - Explicit SpaceMembership grants access; if none exist for the tenant yet, - all active tenant spaces are listed (operator-seeded MVP). Once any - memberships exist for the tenant, only membership + created_by apply. - """ + """Only active tenant spaces with an explicit membership or creator link.""" if member is None: return Space.objects.none() qs = Space.objects.filter(tenant_id=member.tenant_id, is_active=True) - tenant_has_memberships = SpaceMembership.objects.filter( - space__tenant_id=member.tenant_id - ).exists() - if not tenant_has_memberships: - return qs.order_by("title", "slug") - return ( qs.filter(Q(memberships__member=member) | Q(created_by=member)) .distinct() @@ -41,6 +30,21 @@ def get_space_for_member(member: Member | None, slug: str) -> Space | None: return spaces_for_member(member).filter(slug=slug).first() +def can_write_space(member: Member | None, space: Space) -> bool: + """Require an exact tenant/space grant, not a same-slug visibility match.""" + if member is None or member.tenant_id != space.tenant_id or not space.is_active: + return False + membership = SpaceMembership.objects.filter(space=space, member=member).first() + if membership is not None: + return membership.role in {SpaceMembership.Role.OWNER, SpaceMembership.Role.MEMBER} + return space.created_by_id == member.pk + + +def require_space_write(member: Member | None, space: Space) -> None: + if not can_write_space(member, space): + raise pageops.PageOpsError("You have read-only access to this space") + + @transaction.atomic def create_space( member: Member, @@ -90,8 +94,7 @@ def update_space( abstractor: str | None = None, visual: str | None = None, ) -> Space: - if get_space_for_member(member, space.slug) is None: - raise pageops.PageOpsError("Space not found") + require_space_write(member, space) if title is not None: space.title = title.strip() or space.title if abstractor is not None: @@ -116,8 +119,7 @@ def update_space( @transaction.atomic def archive_space(member: Member, space: Space) -> Space: - if get_space_for_member(member, space.slug) is None: - raise pageops.PageOpsError("Space not found") + require_space_write(member, space) space.is_active = False space.save(update_fields=["is_active", "updated_at"]) try: @@ -142,8 +144,7 @@ def put_space_page( visual: str = "", body: str = "", ) -> pageops.PageDoc: - if get_space_for_member(member, space.slug) is None: - raise pageops.PageOpsError("Space not found") + require_space_write(member, space) return pageops.write_page( space.slug, page_slug, @@ -163,11 +164,9 @@ def copy_space_page( dest_space: Space | None = None, title: str | None = None, ) -> pageops.PageDoc: - if get_space_for_member(member, space.slug) is None: - raise pageops.PageOpsError("Source space not found") + require_space_write(member, space) target = dest_space or space - if get_space_for_member(member, target.slug) is None: - raise pageops.PageOpsError("Destination space not found") + require_space_write(member, target) return pageops.copy_page( space.slug, page_slug, @@ -185,10 +184,8 @@ def transfer_space_page( *, dest_slug: str | None = None, ) -> pageops.PageDoc: - if get_space_for_member(member, space.slug) is None: - raise pageops.PageOpsError("Source space not found") - if get_space_for_member(member, dest_space.slug) is None: - raise pageops.PageOpsError("Destination space not found") + require_space_write(member, space) + require_space_write(member, dest_space) return pageops.transfer_page( space.slug, page_slug, diff --git a/coulomb_social/apps/spaces/views.py b/coulomb_social/apps/spaces/views.py index d089266..a8cd7c6 100644 --- a/coulomb_social/apps/spaces/views.py +++ b/coulomb_social/apps/spaces/views.py @@ -32,6 +32,7 @@ from .content import ( from .models import Space from .services import ( archive_space, + can_write_space, copy_space_page, create_space, get_space_for_member, @@ -101,6 +102,8 @@ def space_edit(request: HttpRequest, slug: str) -> HttpResponse: space = get_space_for_member(member, slug) if space is None: return HttpResponseNotFound("Space not found.") + if not can_write_space(member, space): + return HttpResponseForbidden("You have read-only access to this space.") if request.method == "POST": try: update_space( @@ -137,6 +140,8 @@ def space_archive(request: HttpRequest, slug: str) -> HttpResponse: space = get_space_for_member(member, slug) if space is None: return HttpResponseNotFound("Space not found.") + if not can_write_space(member, space): + return HttpResponseForbidden("You have read-only access to this space.") try: archive_space(member, space) messages.success(request, f"Space “{space.title}” archived.") @@ -167,6 +172,7 @@ def space_detail(request: HttpRequest, slug: str) -> HttpResponse: "space": space, "page": rendered, "pages": pages, + "can_write": can_write_space(member, space), "forgejo_repo_url": forgejo_repo_url(space), "forgejo_edit_url": forgejo_edit_url(space, page), "forgejo_blob_url": forgejo_blob_url(space, page), @@ -184,6 +190,8 @@ def page_edit(request: HttpRequest, slug: str, page_slug: str = "index") -> Http space = get_space_for_member(member, slug) if space is None: return HttpResponseNotFound("Space not found.") + if not can_write_space(member, space): + return HttpResponseForbidden("You have read-only access to this space.") if page_slug != "index": try: @@ -271,6 +279,8 @@ def page_new(request: HttpRequest, slug: str) -> HttpResponse: space = get_space_for_member(member, slug) if space is None: return HttpResponseNotFound("Space not found.") + if not can_write_space(member, space): + return HttpResponseForbidden("You have read-only access to this space.") title = "" abstractor = "" @@ -333,6 +343,8 @@ def page_delete(request: HttpRequest, slug: str, page_slug: str) -> HttpResponse space = get_space_for_member(member, slug) if space is None: return HttpResponseNotFound("Space not found.") + if not can_write_space(member, space): + return HttpResponseForbidden("You have read-only access to this space.") try: if get_space_for_member(member, space.slug) is None: raise pageops.PageOpsError("Space not found") @@ -354,13 +366,17 @@ def page_copy(request: HttpRequest, slug: str, page_slug: str) -> HttpResponse: space = get_space_for_member(member, slug) if space is None: return HttpResponseNotFound("Space not found.") - all_spaces = list(spaces_for_member(member)) + if not can_write_space(member, space): + return HttpResponseForbidden("You have read-only access to this space.") + all_spaces = [s for s in spaces_for_member(member) if can_write_space(member, s)] if request.method == "POST": dest_slug = (request.POST.get("dest_slug") or f"{page_slug}-copy").strip() dest_space_slug = (request.POST.get("dest_space") or space.slug).strip() dest_space = get_space_for_member(member, dest_space_slug) title = (request.POST.get("title") or "").strip() or None try: + if dest_space is None: + raise pageops.PageOpsError("Choose a destination space") doc = copy_space_page( member, space, @@ -400,7 +416,10 @@ def page_transfer(request: HttpRequest, slug: str, page_slug: str) -> HttpRespon space = get_space_for_member(member, slug) if space is None: return HttpResponseNotFound("Space not found.") - all_spaces = [s for s in spaces_for_member(member) if s.slug != space.slug] + if not can_write_space(member, space): + return HttpResponseForbidden("You have read-only access to this space.") + all_spaces = [s for s in spaces_for_member(member) + if s.slug != space.slug and can_write_space(member, s)] if request.method == "POST": dest_space_slug = (request.POST.get("dest_space") or "").strip() dest_slug = (request.POST.get("dest_slug") or page_slug).strip() diff --git a/coulomb_social/templates/spaces/detail.html b/coulomb_social/templates/spaces/detail.html index c2b1492..0013571 100644 --- a/coulomb_social/templates/spaces/detail.html +++ b/coulomb_social/templates/spaces/detail.html @@ -90,13 +90,17 @@

Visual: {{ space.visual }}

{% endif %} + {% if can_write %}
Edit space New page
+ {% endif %} + {% if not can_write %}

Read-only access

{% endif %}
+ {% if can_write %} Edit page {% if page.slug and page.slug != 'index' %} Copy @@ -112,6 +116,7 @@ {% if forgejo_edit_url %} Edit in Forgejo {% endif %} + {% endif %} {% if forgejo_blob_url %} View source {% endif %} diff --git a/docs/deploy.md b/docs/deploy.md index 5c0dd5e..69d6710 100644 --- a/docs/deploy.md +++ b/docs/deploy.md @@ -132,6 +132,26 @@ kubectl -n coulomb-social exec deploy/coulomb-social -- \ # optional: --slug demo --title "Demo space" --tenant tenant:coulomb ``` +Before the browser smoke, grant the nominated member access to the seeded +space. Seed/admin/import rows do not become tenant-public automatically. Use the +reviewed deployment administration path, or Django admin, to create an explicit +SpaceMembership for the correct tenant and issuer+subject. Existing spaces +without grants/creator links will be hidden by the new access rules. + +Example ORM operation (substitute the **reviewed** non-secret identity): + +```python +from coulomb_social.apps.members.models import Member +from coulomb_social.apps.spaces.models import Space, SpaceMembership +member = Member.objects.get( + tenant_id="tenant:coulomb", issuer="https://your-issuer", subject="known-subject" +) +space = Space.objects.get(tenant_id=member.tenant_id, slug="demo") +SpaceMembership.objects.update_or_create( + space=space, member=member, defaults={"role": SpaceMembership.Role.OWNER} +) +``` + Browser: 1. https://app.coulomb.social/ → **Sign in** (tegwick) @@ -181,7 +201,7 @@ Space.objects.update_or_create( 4. If the repo is private, set `FORGEJO_TOKEN` (read-only deploy token) via `railiance-apps` env-secret tooling — **never** commit the token. 5. Open `https://app.coulomb.social/app/spaces/my-space/` as a signed-in member - of that tenant. + with an explicit membership or creator grant. ### D. Optional push webhook (auto cache bust) diff --git a/docs/migration/corpus-import.md b/docs/migration/corpus-import.md index dcbff1c..78a27dd 100644 --- a/docs/migration/corpus-import.md +++ b/docs/migration/corpus-import.md @@ -27,11 +27,11 @@ matched by **tenant + issuer + subject**, never email or a password. } ``` -Roles: `owner`, `member`, `viewer`. Mapping preserves these labels; current -product authorization does not yet enforce viewer write denial. Do not rely on -role labels as a privacy guarantee. Each Space gets an owner and explicit grants, -which disables the tenant-wide bootstrap visibility fallback for that tenant. -Review the effect on existing spaces before executing a real cohort migration. +Roles: `owner`, `member`, `viewer`. Product authorization denies writes for viewer grants and permits +owner/member writes; reads require an explicit membership or creator link. +Tenant-wide bootstrap visibility has been removed. The manifest supplies owner +and explicit grants, but does not certify that they match original Bubble +privacy. Review the effect on existing spaces before executing a real cohort migration. ## Run @@ -69,8 +69,9 @@ tenant boundaries intact: filesystem paths remain slug-keyed. The single-tree command shares validation and staging. `--member-subject` must resolve to exactly one tenant-local Member; use the corpus manifest when issuer -ambiguity is possible. Single-tree operator imports may still omit membership, -so they are not a replacement for approved cohort mapping. +ambiguity is possible. Single-tree imports may still omit membership, +in which case the space is hidden unless it has an existing membership/creator +grant. They are not a replacement for approved cohort mapping. ## Paired backup/restore protocol (acceptance still pending) diff --git a/docs/session-pickup.md b/docs/session-pickup.md index 40ae5fc..2be59c4 100644 --- a/docs/session-pickup.md +++ b/docs/session-pickup.md @@ -1,3 +1,19 @@ +# Current implementation focus — 2026-10-06 + +CSOC-WP-0008 now targets the first **user-reviewed use-case transfer off Bubble**. +Milestone, sequencing and open tasks are in that workplan; first catalogue and +walkthrough are docs/usecases/catalog.md and first-transfer-review.md. Original +journeys remain inferred until observed/confirmed; first real cohort pending. + +Implementation started with explicit membership/creator reads, reader mutation +denial, correct destination write permissions and read-only UI. Unbound seeded +spaces are now hidden; grant nominated members explicitly before deployment. +86 tests pass; no live release or cohort move was executed. Next: sharing UX and +member portability (T04), content/Visual fidelity (T05), paired recovery (T02), +then exact release preparation (T01) and cohort review (T03/T08/T09). + +--- + # Current pickup — 2026-10-06 WP-0007 audits scope and implements validated corpus import with explicit diff --git a/docs/usecases/catalog.md b/docs/usecases/catalog.md new file mode 100644 index 0000000..2aacae0 --- /dev/null +++ b/docs/usecases/catalog.md @@ -0,0 +1,71 @@ +# Use-case catalogue — Bubble off-ramp + +This is the working product backlog for finding, describing, rebuilding and +transferring use cases. A capability name or exported type does not prove an +actual current user workflow. Candidate journeys below derive from the dated +Bubble inventory/capability map; no fresh authenticated Bubble observation has +been performed in this session. All transfer decisions are **pending user review**. + +## Transfer workflow + +For every journey: capture a real example and original steps → describe actor, +goal, inputs/results and privacy → implement equivalent behavior → rehearse +representative data → compare old/new from the user's perspective → record the +user's acceptance or gaps → move that use case and monitor it. Record follow-up +gaps in live work records before claiming completion. Bubble remains available +for journeys not yet accepted; full off-ramp follows their disposition. + +Evidence labels: `inferred` (inventory/package signals), `observed` (dated actual +workflow), `confirmed` (user validates goal/steps). Implementation and acceptance +are separate: tests can prove new behavior, not the original workflow or user +acceptance. Do not mark transfer accepted on the strength of tests alone. + +## First review cohort — candidate journeys + +| ID | Actor and user goal | Bubble evidence / confidence | Rebuild and remaining gaps | Owner / transfer | +|---|---|---|---|---| +| CSOC-UC-001 | Returning/new member signs in and reaches their work | Login/signup strings and historical identity evidence; original transfer journey inferred | OIDC + registration entry implemented; current cohort onboarding and live Case B need review | WP-0008-T01/T03; pending | +| CSOC-UC-002 | Writer finds an existing space and opens a page to continue work | `vw_spaces`, `vw_pages`, space/page fields; inferred; one historical import rehearsal | Space/page navigation and markdown implemented; nominated real content, visuals, missing hierarchy and recognizability need review | T03/T05; pending | +| CSOC-UC-003 | Writer creates and maintains a space/page with Title, Abstractor, Visual and body | Space/page create/edit symbols and captured card UI; inferred | Metadata/body forms implemented; Visual serving, safe content versioning and restart persistence outstanding | T01/T04/T05; pending | +| CSOC-UC-004 | Writer makes an independent copy, or moves a page to another workspace | Copy icons captured; exact Bubble transfer action unresolved | Copy/move implemented; arbitrary assets/links and actual original semantics need comparison | T04/T05; pending | +| CSOC-UC-005 | Reader sees shared work; outsider cannot access it; writer remains able to edit | Members/Guests/IsPrivate fields; original privacy rules inferred | First slice enforces viewer write denial and explicit membership/creator reads; membership management and exact Bubble privacy still outstanding | T04; pending | +| CSOC-UC-006 | Member can take their app-local information with them or leave with a clear content outcome | Platform-contract portability need; Bubble equivalent **unknown** | Export/delete not implemented; ownership/retention UX requires definition | T04; pending | +| CSOC-UC-007 | Member returns after a restart/incident and their work is still there | Continuity requirement, not a captured Bubble action | Persistent volume and paired DB/content restore evidence outstanding | T01/T02; pending | + +### Concrete scenario specification for the first review + +Use a user-nominated existing space, a representative page with actual Visual and +body-linked assets, a second destination space, a writer, a reader and an +ungranted same-tenant member. Keep all private names/content/account mappings +outside git. Confirm the original paths and whether “page” means a page, chunk +or article before import. Until nominated, synthetic examples prove mechanics +only and cannot be labelled the real transfer cohort. + +For UC-002/003: open the original page, note navigation and what is visible; +open its migrated equivalent, compare Title/Abstractor/Visual/body and essential +links; edit it, reopen it, and verify the change survives restart. +For UC-004: confirm original copy/move semantics; copy into the second space, +edit the copy and confirm the source is independent; move another page and +verify source removal, destination content and access. +For UC-005: reader can open the shared page, sees read-only access and cannot +change it by direct requests; outsider gets no content; destination write +permission is checked before copy/move. Exact sharing UI remains part of T04. +For UC-006: compare the app-local export and proposed leaving behavior before +confirming deletion; platform account handling is explicitly separate. +For UC-007: compare the pre-incident content and access with an isolated restore. + +## Later candidates to discover, not silently discard + +| Candidate | Evidence and unresolved user question | +|---|---| +| Research hierarchy / chunks / cards | `vw_pages`, `vw_chunks`, parent/chunk/card signals. Which actual workflows require these rather than page bodies? | +| Find work by search / featured spaces / feed | `vw_search`, `vw_feed`, featured labels. Which discovery tasks are actively used? | +| Invite people and manage membership | Invite/member/guest signals. Who shares with whom, with which permissions and confirmation? | +| Follow/favorite/interact | Likes/follow/social type signals. Which workflows are used rather than abandoned UI? | +| Work tracking / ventures / challenges | Tasklet/venture/challenge types. Capture a real complete journey before choosing its rebuild form. | +| Marketplace and payments | Marketing and checkout symbols. Confirm whether there is any active end-to-end use before prioritizing. | + +Historical source: `docs/capability/existing-bubble-capability-map.md`, +`docs/bubble-inventory/2026-08-12-surface-inventory.md`, migration rehearsal and +accepted stage-1 cut. Unknowns remain open; this catalogue does not change that +cut or claim full parity. diff --git a/docs/usecases/first-transfer-review.md b/docs/usecases/first-transfer-review.md new file mode 100644 index 0000000..21756b5 --- /dev/null +++ b/docs/usecases/first-transfer-review.md @@ -0,0 +1,48 @@ +# First use-case transfer — user review + +**Milestone:** I can continue a nominated piece of my existing work on the new +app, recognize the content, organize it, share it with the intended people and +trust it to remain available. I can see exactly which old use cases can move +and which still need rebuilding. + +**Status:** walkthrough prepared; user review not yet ready. WP-0008 is active. +Local permission/import tests do not establish a deployed, migrated review +instance. This document will carry the release/evidence links before review. + +## Review preparation (owned by WP-0008) + +- Review host and immutable release: pending T01. +- Nominated source workspace/pages and private member mapping: pending T03. +- Original Bubble walkthrough and confidence confirmation: pending T08/user. +- Migrated content/visual/link reconciliation and accepted differences: pending T03/T05. +- Writer, reader and outsider access; sharing and portability UX: pending T04. +- Restart durability and measured isolated restore: pending T01/T02. + +Use a private companion record for real content and account identities. Record +only non-secret receipts, stable journey IDs and user verdicts here. + +## Walkthrough and expected user results + +| Journey | What you do | What you should experience | Review verdict | +|---|---|---|---| +| UC-001 | Sign in (or register) and find the nominated space | Reach the right work without a Bubble login dependency | Pending | +| UC-002 | Open the same representative page in Bubble and the new app | Recognizable title, abstract, visual, body and useful navigation; differences clearly disclosed | Pending | +| UC-003 | Edit a page, make a new page and update space details; revisit after restart | Changes remain readable and usable without manual Git/database actions | Pending | +| UC-004 | Copy into a second space, edit the copy, then move another page | Copy is independent; moved page leaves source and appears intact at destination; permissions remain correct | Pending | +| UC-005 | Repeat as reader, then outsider; try editing/copy/move URLs directly | Reader sees shared content and read-only controls; changes denied; outsider sees no private content | Pending | +| UC-006 | Export app-local account information; review leaving/ownership outcome | Understand what can be taken away and what deletion retains/transfers before confirming it | Pending | +| UC-007 | Inspect the restored isolated workspace and permission checks | Content and access match the agreed recovery point; recovery delay is known | Pending | + +For each row record: original steps observed, new steps performed, release/date, +result (`pass`, `gap`, `not tested`), gap work-record ID, user verdict +(`accepted for transfer`, `needs rebuilding`, `deferred`) and explicit acceptance +reference. Prepared/implemented/tested never automatically means accepted. + +## After your review + +Move only accepted use cases and their agreed content/cohort. Decide where new +work happens for each accepted journey; keep the Bubble equivalent available +according to the agreed rollback/reference policy. File rejected differences as +live work, select the next user-prioritized cohort and repeat. Retiring Bubble +entirely is a separate final decision after every retained use case has been +transferred or explicitly discontinued by you. diff --git a/tests/test_space_permissions.py b/tests/test_space_permissions.py new file mode 100644 index 0000000..7f88ea8 --- /dev/null +++ b/tests/test_space_permissions.py @@ -0,0 +1,130 @@ +"""User journey UC-005: reader visibility with mutation denial.""" + +import pytest +from django.urls import reverse + +from coulomb_social.apps.members.models import Member, User +from coulomb_social.apps.spaces import pageops +from coulomb_social.apps.spaces.models import Space, SpaceMembership +from coulomb_social.apps.spaces.services import ( + can_write_space, + copy_space_page, + create_space, + put_space_page, + transfer_space_page, +) + + +def make_member(subject, tenant="tenant:a"): + return Member.objects.create( + tenant_id=tenant, user=User.objects.create_user(username=subject), + user_engine_user_id=subject, issuer="https://issuer.test", subject=subject, + ) + + +@pytest.fixture +def shared(tmp_path, settings, client): + settings.CONTENT_ROOT = str(tmp_path / "content") + owner = make_member("owner") + reader = make_member("reader") + outsider = make_member("outsider") + space = create_space(owner, title="Shared", slug="shared") + put_space_page(owner, space, "notes", title="Notes", body="Original work") + SpaceMembership.objects.create(space=space, member=reader, role="viewer") + client.force_login(reader.user) + return owner, reader, outsider, space + + +@pytest.mark.django_db +@pytest.mark.parametrize("route", ["edit", "archive", "page_new", "page_edit", + "page_delete", "page_copy", "page_transfer"]) +def test_reader_cannot_mutate_by_direct_request(shared, client, route): + _, _, _, space = shared + kwargs = {"slug": space.slug} + if route in {"page_edit", "page_delete", "page_copy", "page_transfer"}: + kwargs["page_slug"] = "notes" + response = client.post(reverse("spaces:" + route, kwargs=kwargs), + {"title": "Changed", "body": "Tampered", + "dest_space": "shared", "dest_slug": "copy"}) + assert response.status_code == 403 + assert pageops.read_page("shared", "notes").body == "Original work\n" + assert not pageops.read_page("shared", "copy").exists + space.refresh_from_db() + assert space.title == "Shared" + assert space.is_active + + +@pytest.mark.django_db +def test_reader_sees_read_only_controls_outsider_sees_no_content(shared, client): + _, _, outsider, space = shared + url = reverse("spaces:detail", kwargs={"slug": space.slug}) + "?page=notes" + response = client.get(url) + assert response.status_code == 200 + assert b"Original work" in response.content + assert b"Read-only access" in response.content + assert b"Edit page" not in response.content + assert b"New page" not in response.content + client.force_login(outsider.user) + response = client.get(url) + assert response.status_code == 404 + assert b"Original work" not in response.content + + +@pytest.mark.django_db +def test_member_can_edit_but_viewer_cannot_use_services(shared): + _, reader, _, space = shared + with pytest.raises(pageops.PageOpsError, match="read-only"): + put_space_page(reader, space, "notes", title="Changed") + membership = SpaceMembership.objects.get(space=space, member=reader) + membership.role = "member" + membership.save() + put_space_page(reader, space, "notes", title="Changed", body="Allowed") + assert pageops.read_page("shared", "notes").body == "Allowed\n" + + +@pytest.mark.django_db +@pytest.mark.parametrize("operation", ["copy", "move"]) +def test_destination_read_only_denies_transfer_without_source_change(shared, operation): + owner, _, _, source = shared + other = make_member("destination-owner") + destination = create_space(other, title="Destination", slug="destination") + SpaceMembership.objects.create(space=destination, member=owner, role="viewer") + with pytest.raises(pageops.PageOpsError, match="read-only"): + if operation == "copy": + copy_space_page(owner, source, "notes", dest_space=destination, dest_slug="copy") + else: + transfer_space_page(owner, source, "notes", destination) + assert pageops.read_page("shared", "notes").exists + assert not pageops.read_page("destination", "notes").exists + assert not pageops.read_page("destination", "copy").exists + + +@pytest.mark.django_db +def test_same_slug_other_tenant_cannot_be_written(shared): + owner, _, _, _ = shared + foreign = Space.objects.create(tenant_id="tenant:b", slug="shared", title="Foreign", + created_by=owner) + assert not can_write_space(owner, foreign) + with pytest.raises(pageops.PageOpsError, match="read-only"): + put_space_page(owner, foreign, "notes", title="Changed") + assert pageops.read_page("shared", "notes").body == "Original work\n" + + +@pytest.mark.django_db +def test_space_without_grants_is_not_tenant_public(client): + member = make_member("ungranted") + space = Space.objects.create(tenant_id=member.tenant_id, slug="private", title="Private") + client.force_login(member.user) + assert client.get(reverse("spaces:detail", kwargs={"slug": space.slug})).status_code == 404 + assert b"Private" not in client.get(reverse("core:app_home")).content + + +@pytest.mark.django_db +def test_explicit_reader_role_overrides_creator_write_grant(shared): + owner, _, _, space = shared + membership = SpaceMembership.objects.get(space=space, member=owner) + membership.role = "viewer" + membership.save() + assert not can_write_space(owner, space) + with pytest.raises(pageops.PageOpsError, match="read-only"): + put_space_page(owner, space, "notes", title="Should not change") diff --git a/workplans/CSOC-WP-0008-production-transfer-acceptance.md b/workplans/CSOC-WP-0008-production-transfer-acceptance.md index 107fa7b..147c08a 100644 --- a/workplans/CSOC-WP-0008-production-transfer-acceptance.md +++ b/workplans/CSOC-WP-0008-production-transfer-acceptance.md @@ -1,7 +1,7 @@ --- id: CSOC-WP-0008 type: workplan -title: "Production persistence, recovery and community transfer acceptance" +title: "First user-reviewed use-case transfer off Bubble" domain: communication repo: coulomb-social status: active @@ -16,7 +16,7 @@ depends_on: state_hub_workstream_id: "0ee96868-875c-536e-a661-07198c07a8ce" --- -# CSOC-WP-0008 — Production transfer acceptance +# CSOC-WP-0008 — First user-reviewed use-case transfer Success is a recoverable, accepted community transfer under the existing stage-1 cut. Local migration tooling is implemented by WP-0007; that is not live parity @@ -24,6 +24,44 @@ or a production readiness claim. Existing CSOC-IN-0003 and CSOC-IN-0004 remain open until the corresponding acceptance below is evidenced. This record does not assign or authorize changes in platform-owner repositories. +## User milestone — Ready to review the first use-case transfer + +At completion, you can sign into the new app, open a nominated migrated space, +recognize its pages and visuals, continue writing and organizing that work, +copy or move a page, and share it with a reader who cannot alter it. Your changes +survive an application restart, and the team can recover the content and access +from a tested backup. You can inspect/export your app-local information and +review how leaving the app handles owned content before any deletion. + +A side-by-side walkthrough describes the original Bubble steps, equivalent new +steps, expected results and remaining differences. You review this as a user and +accept or reject **each use case** for productive transfer. Completion means the +first nominated cohort is ready for this review; it does not pre-approve all +use cases or retire Bubble. Apex retirement remains an explicit later decision. + +The main goal is now a continuing off-ramp: find and describe actual Bubble use +cases, rebuild them within our ecosystem, compare behavior and transfer approved +use cases incrementally. The historical feature inventory supplies candidate +journeys, not proof that each is used or works in the current Bubble product. +Marketplace/social features stay candidates for subsequent cohorts until actual +use and user priority justify their implementation. + +## Execution and review order + +1. Capture candidate journeys from existing evidence; confirm real examples with + the user during review. Keep evidence confidence and unanswered questions. +2. Build and test the missing sharing/access, content, portability and recovery + behavior required for the nominated cohort (T04/T05/T02/T07). +3. Prepare a concrete release and representative content rehearsal; obtain the + exact live change authorization required by the estate before deploying (T01). +4. Reconcile the first nominated corpus/cohort and demonstrate end-to-end user + journeys and recovery (T03/T08/T09). +5. User accepts individual journeys or records gaps as follow-up work. Transfer + those accepted use cases; keep Bubble available for remaining cases (T06). + +The earlier 71-space/109-article capture remains an inventory to reconcile, not +an instruction to bulk-move every space before the first user review. + ## T01 — Prepare persistence and deployment acceptance ```task @@ -69,12 +107,14 @@ priority: high state_hub_task_id: "0615eff2-bf2b-503b-8f15-0591d4666446" ``` -Dependencies: founder-approved corpus/cohort, reviewed identity membership map, +Dependencies: founder-nominated first-use-case corpus/cohort, reviewed identity membership map, T01/T02 acceptance, local access to private prepared trees. Use the new corpus manifest and dry-run; resolve every unmapped/inactive identity by NetKingdom onboarding rather than password/email matching. Rehearse separately, reconcile all 71 known spaces and 109 captured articles against actual expected inventory, -report omitted/thin trees, missing assets and parent/privacy lossiness. Approve +report omitted/thin trees, missing assets and parent/privacy lossiness. +Execute the first nominated representative cohort; disposition other spaces +explicitly as subsequent transfers rather than silently excluding them. Approve role assignments and additional pre-existing grants before real import; content replacement is additive for membership and does not revoke old grants. Close CSOC-IN-0003 only after real cohort receipts and exception disposition. @@ -83,7 +123,7 @@ Close CSOC-IN-0003 only after real cohort receipts and exception disposition. ```task id: CSOC-WP-0008-T04 -status: todo +status: progress priority: high state_hub_task_id: "eb4da28e-ea04-52fa-92d6-f78296348b83" ``` @@ -143,3 +183,55 @@ Resolve the pre-existing Ruff findings recorded by the WP-0007 assessment, without changing generated schema semantics or weakening checks. Full lint and tests must pass; prioritize meaningful unused-fixture/test cleanup over silencing rules. This task is quality debt rather than a claim of broken runtime behavior. + +## T08 — Describe Bubble use cases and rebuild acceptance + +```task +id: CSOC-WP-0008-T08 +status: progress +priority: high +state_hub_task_id: "fca49f28-a959-51e2-ad24-5fd73b1e5316" +``` + +Create docs/usecases/catalog.md with stable journey IDs, actors, user goals, +original/rebuild steps, expected results, evidence confidence, gaps, owner tasks +and transfer status. Start from captured Bubble inventory; mark inferred steps +and uncertain semantics. User confirmation is required before calling a journey +observed/accepted. Prioritize working in spaces/pages and sharing access; retain +research/chunks, discovery, social and marketplace candidates for later cohorts. + +## T09 — User review walkthrough and per-use-case off-ramp receipts + +```task +id: CSOC-WP-0008-T09 +status: progress +priority: high +state_hub_task_id: "648acce6-21cd-540f-b3b0-c2fcb8eacc60" +``` + +Write docs/usecases/first-transfer-review.md with a realistic example space and +writer, reader and outsider checks; original/new steps; expected visible results; +restart and restore checks; pass/gap/accepted-for-transfer fields. Record actual +release/host, corpus reconciliation and recovery receipts before asking the user +to execute review. Local tests do not mark this milestone done. Capture the +user's acceptance per journey and remaining gaps as live work records, then +prepare the next cohort. The user's review is the transfer gate, not a proxy for +blanket Bubble retirement. + +## Implementation started — 2026-10-06 + +T04 first slice implemented: explicit membership/creator visibility; removed +tenant-wide bootstrap reads; enforced viewer read-only access in product routes +and service writes, exact tenant/space checks, and source/destination write +checks for copy/move. Read-only controls reflect those permissions. Seed/admin +spaces now need explicit grants; deploy/import docs reflect this behavior. + +T08/T09 initial catalogue and review walkthrough prepared under docs/usecases/. +Original Bubble journeys remain inferred pending observation/user confirmation; +real cohort and release evidence remain pending. T04 continues for sharing UI, +member export/delete, tenant filesystem invariant and original privacy parity. +The milestone is not reached by this local implementation slice. + +Validation: **86 tests passed**; Ruff passes changed service/view/test files; +`git diff --check` passes for edited files. Existing +19 repository-wide lint findings remain T07 debt; no production change made.