Implement space sharing, member portability and recoverable content edits
Some checks failed
Use-case regression gate / usecase-regressions (push) Failing after 6m43s

Assistant: codex
Assistant-Model: gpt-6.1-sol
Assistant-Session: 01a119cf-56d0-7fa3-840b-eb8732e1db0b
This commit is contained in:
tegwick 2026-10-08 09:17:31 +02:00
parent a2220afa1a
commit dd83c90d73
38 changed files with 1833 additions and 136 deletions

View file

@ -1,4 +1,4 @@
.PHONY: help sync migrate run test lint shell check test-usecases test-browser setup-usecases
.PHONY: help sync migrate run test lint shell check test-usecases test-browser setup-usecases check-communication
.DEFAULT_GOAL := help
@ -36,3 +36,6 @@ test-browser: ## Browser journeys against isolated test app
check: lint ## Full lint/regression gate, including every established browser journey
uv run pytest --verify-usecases -m '' --junitxml=var/test-results/check.xml
check-communication: ## Send-free owner retry/privacy checks; set EMAIL_CONNECT_ROOT
uv run python scripts/check-communication.py --owner "$(EMAIL_CONNECT_ROOT)"

View file

@ -8,4 +8,6 @@ urlpatterns = [
path("", views.landing, name="landing"),
path("app/", views.app_home, name="app_home"),
path("account/session/", views.account_session, name="account_session"),
path("account/data/", views.account_data, name="account_data"),
path("account/export/", views.account_export, name="account_export"),
]

View file

@ -1,8 +1,19 @@
from django.contrib import messages
from django.contrib.auth import logout
from django.contrib.auth.decorators import login_required
from django.http import HttpRequest, HttpResponse, HttpResponseForbidden, JsonResponse
from django.shortcuts import redirect, render
from django.views.decorators.http import require_http_methods, require_safe
from coulomb_social.apps.members.portability import (
delete_local_member,
deletion_blockers,
export_member,
has_foreign_ownership,
retained_spaces,
)
from coulomb_social.apps.spaces.assets import local_asset_url
from coulomb_social.apps.spaces.pageops import PageOpsError
from coulomb_social.apps.spaces.services import spaces_for_member
from .principal import build_principal
@ -57,3 +68,43 @@ def account_session(request: HttpRequest) -> HttpResponse:
def healthz(_request: HttpRequest) -> JsonResponse:
return JsonResponse({"status": "ok", "service": "coulomb-social"})
@login_required
@require_http_methods(["GET", "POST"])
def account_data(request):
principal = build_principal(request, authz_resource_id="account_data")
member = principal.get("member")
if not principal["authz_allow"] or member is None:
return HttpResponseForbidden("Local account access unavailable.")
if request.method == "POST":
try:
if request.POST.get("confirmation") != "DELETE":
raise PageOpsError("Type DELETE to confirm local account removal")
delete_local_member(member)
except PageOpsError as exc:
messages.error(request, exc.message)
else:
logout(request)
messages.success(request, "Your local account was removed. Shared content is retained.")
return redirect("core:landing")
response = render(request, "core/account_data.html", {
"principal": principal, "member": member,
"retained_spaces": retained_spaces(member), "blockers": deletion_blockers(member),
"ownership_review": has_foreign_ownership(member),
})
response["Cache-Control"] = "private, no-store"
return response
@login_required
@require_safe
def account_export(request):
principal = build_principal(request, authz_resource_id="account_export")
member = principal.get("member")
if not principal["authz_allow"] or member is None:
return HttpResponseForbidden("Local account access unavailable.")
response = JsonResponse(export_member(member), json_dumps_params={"indent": 2})
response["Content-Disposition"] = 'attachment; filename="coulomb-account.json"'
response["Cache-Control"] = "private, no-store"
return response

View file

@ -0,0 +1,82 @@
"""App-local portability. Platform identity and shared content stay with their owners."""
from django.db import transaction
from django.db.models import Q
from coulomb_social.apps.spaces import pageops
from coulomb_social.apps.spaces.models import Space, SpaceMembership
from coulomb_social.apps.spaces.pageops import PageOpsError
from .models import Member
def retained_spaces(member):
"""Include archived spaces: archiving does not abandon their ownership."""
return Space.objects.filter(
Q(created_by=member) | Q(memberships__member=member, memberships__role="owner"),
tenant_id=member.tenant_id,
).distinct().order_by("pk")
def has_foreign_ownership(member):
return Space.objects.filter(created_by=member).exclude(tenant_id=member.tenant_id).exists()
def deletion_blockers(member):
blockers = []
for space in retained_spaces(member):
successor = SpaceMembership.objects.filter(
space=space, role="owner", member__tenant_id=space.tenant_id,
member__user__is_active=True,
).exclude(member=member).exists()
if not successor:
blockers.append(space)
return blockers
def export_member(member):
"""Only the requesting member's personal records; no tokens or other members."""
grants = SpaceMembership.objects.filter(member=member, space__tenant_id=member.tenant_id)
return {
"schema_version": 1,
"scope": "coulomb-social app-local records",
"member": {
field: str(getattr(member, field) or "") for field in (
"id", "tenant_id", "issuer", "subject", "user_engine_user_id",
"display_name", "email", "created_at", "updated_at", "last_login_at",
)
},
"user": {field: str(getattr(member.user, field) or "") for field in (
"id", "username", "email", "full_name", "date_joined",
)},
"memberships": [
{"space_id": str(g.space_id), "space_slug": g.space.slug, "role": g.role,
"created_at": g.created_at.isoformat()} for g in grants.select_related("space")
],
"created_spaces": [
{"id": str(s.pk), "slug": s.slug, "title": s.title, "is_active": s.is_active}
for s in Space.objects.filter(created_by=member, tenant_id=member.tenant_id)
],
"retention": (
"Shared pages and assets remain in their spaces. Backups follow operator retention."
),
"platform_identity": "NetKingdom and user-engine records are managed separately.",
}
@pageops.content_writer
@transaction.atomic
def delete_local_member(member):
# Serialize against sharing on the retained spaces, then against grants to
# this member. The FK cascades remove all grants and the local session user.
ids = list(retained_spaces(member).values_list("pk", flat=True))
list(Space.objects.filter(pk__in=ids).order_by("pk").select_for_update())
member = Member.objects.select_for_update().filter(pk=member.pk).first()
if member is None:
return
if has_foreign_ownership(member):
raise PageOpsError("An inconsistent ownership record needs operator review before removal")
if deletion_blockers(member):
raise PageOpsError("Give another active member owner access to every retained space first")
Space.objects.filter(created_by=member).update(created_by=None)
member.user.delete()

View file

@ -1,5 +1,6 @@
from django.contrib import admin
from . import pageops
from .models import Space, SpaceMembership
@ -11,6 +12,11 @@ class SpaceMembershipInline(admin.TabularInline):
@admin.register(Space)
class SpaceAdmin(admin.ModelAdmin):
def save_model(self, request, obj, form, change):
with pageops.content_write_lock():
obj.full_clean()
super().save_model(request, obj, form, change)
list_display = (
"title",
"slug",

View file

@ -90,6 +90,10 @@ def validate_space_tree(
raise pageops.PageOpsError("Destination must not be a symlink")
if dest.exists() and not replace:
raise pageops.PageOpsError("Destination already exists; pass replace=True to overwrite")
if dest.exists() and pageops.page_aliases(space_slug):
raise pageops.PageOpsError(
"Retained page addresses need reconciliation before replacing content"
)
return tree, space_slug
@ -97,6 +101,22 @@ def import_space_tree(
tree: Path, *, tenant_id: str, slug: str | None = None,
member: Member | None = None, title: str | None = None,
replace: bool = False, memberships: list[tuple[Member, str]] | None = None,
) -> ImportReport:
# Invalid proposals remain write-free, including lock-file creation.
validate_space_tree(tree, tenant_id=tenant_id, slug=slug, member=member,
replace=replace, title=title)
for mapped, role in memberships or []:
if mapped.tenant_id != tenant_id or role not in SpaceMembership.Role.values:
raise pageops.PageOpsError("Invalid membership tenant or role")
return _import_space_tree(tree, tenant_id=tenant_id, slug=slug, member=member,
title=title, replace=replace, memberships=memberships)
@pageops.content_writer
def _import_space_tree(
tree: Path, *, tenant_id: str, slug: str | None = None,
member: Member | None = None, title: str | None = None,
replace: bool = False, memberships: list[tuple[Member, str]] | None = None,
) -> ImportReport:
"""Stage content, then publish with DB writes; restore on ordinary errors.

View file

@ -84,6 +84,13 @@ class Space(models.Model):
def __str__(self) -> str:
return f"{self.title} ({self.slug})@{self.tenant_id}"
def clean(self):
super().clean()
if Space.objects.filter(slug=self.slug).exclude(pk=self.pk).exists():
raise ValidationError({"slug": "This content instance already uses that slug"})
if self.created_by_id and self.created_by.tenant_id != self.tenant_id:
raise ValidationError({"created_by": "Creator belongs to another tenant"})
@property
def has_content_binding(self) -> bool:
return bool(self.forgejo_owner and self.forgejo_repo)
@ -103,7 +110,7 @@ class Space(models.Model):
class SpaceMembership(models.Model):
"""Optional explicit membership; v1 list also allows tenant-wide visibility."""
"""Explicit tenant-matched access; ungranted tenant members cannot read."""
class Role(models.TextChoices):
OWNER = "owner", "Owner"
@ -130,3 +137,8 @@ class SpaceMembership(models.Model):
def __str__(self) -> str:
return f"{self.member_id}@{self.space.slug}:{self.role}"
def clean(self):
super().clean()
if self.space_id and self.member_id and self.space.tenant_id != self.member.tenant_id:
raise ValidationError({"member": "Member belongs to another tenant"})

View file

@ -12,12 +12,20 @@ The app DB holds Space rows as an index; bodies live on disk.
from __future__ import annotations
import fcntl
import hashlib
import json
import logging
import os
import re
import shutil
import tempfile
import threading
from contextlib import contextmanager
from dataclasses import dataclass, field
from functools import wraps
from pathlib import Path
from urllib.parse import quote, unquote, urlsplit
from django.conf import settings
from django.utils.text import slugify as django_slugify
@ -25,6 +33,67 @@ from django.utils.text import slugify as django_slugify
logger = logging.getLogger(__name__)
_SLUG_RE = re.compile(r"^[a-z0-9]+(?:-[a-z0-9]+)*$")
_WRITER_LOCK = threading.RLock()
_WRITER_STATE = threading.local()
@contextmanager
def content_write_lock():
"""Serialize cooperating app/import workers on one mounted content instance."""
with _WRITER_LOCK:
if getattr(_WRITER_STATE, "active", False):
yield
return
root = content_root()
root.mkdir(parents=True, exist_ok=True)
fd = os.open(root / ".writer.lock", os.O_CREAT | os.O_RDWR | os.O_NOFOLLOW, 0o600)
try:
fcntl.flock(fd, fcntl.LOCK_EX)
_WRITER_STATE.active = True
yield
finally:
_WRITER_STATE.active = False
os.close(fd)
def content_writer(function):
@wraps(function)
def locked(*args, **kwargs):
with content_write_lock():
return function(*args, **kwargs)
return locked
def _fsync_directory(path):
fd = os.open(path, os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW)
try:
os.fsync(fd)
finally:
os.close(fd)
def _remember(space_slug, page_slug, data):
"""Immutable content-addressed versions are part of the paired content backup."""
directory = content_root() / ".revisions" / space_slug / page_slug
directory.mkdir(parents=True, exist_ok=True)
digest = hashlib.sha256(data).hexdigest()
path = directory / f"{digest}.md"
if path.exists():
if path.read_bytes() != data:
raise PageOpsError("Stored revision integrity check failed")
return
temporary = None
try:
with tempfile.NamedTemporaryFile(dir=directory, delete=False) as stream:
temporary = Path(stream.name)
stream.write(data)
stream.flush()
os.fsync(stream.fileno())
os.replace(temporary, path)
_fsync_directory(directory)
finally:
if temporary is not None:
temporary.unlink(missing_ok=True)
class PageOpsError(Exception):
@ -42,6 +111,7 @@ class PageDoc:
body: str = ""
meta: dict = field(default_factory=dict)
path: str = ""
revision: str = ""
@property
def exists(self) -> bool:
@ -58,6 +128,8 @@ def content_root() -> Path:
def space_dir(space_slug: str) -> Path:
if not _SLUG_RE.fullmatch(space_slug):
raise PageOpsError("Invalid space slug")
return content_root() / "spaces" / space_slug
@ -70,8 +142,15 @@ def assets_dir(space_slug: str) -> Path:
def page_file(space_slug: str, page_slug: str) -> Path:
if not _SLUG_RE.fullmatch(page_slug):
raise PageOpsError("Invalid page slug")
name = "index.md" if page_slug == "index" else f"{page_slug}.md"
return pages_dir(space_slug) / name
path = pages_dir(space_slug) / name
for component in (content_root() / "spaces", space_dir(space_slug),
pages_dir(space_slug), path):
if component.is_symlink():
raise PageOpsError("Content paths must not be symlinks")
return path
def normalize_slug(value: str, *, allow_index: bool = True) -> str:
@ -123,6 +202,7 @@ def parse_frontmatter(text: str) -> tuple[dict, str]:
def dump_frontmatter(meta: dict, body: str) -> str:
body = body.replace("\r\n", "\n").replace("\r", "\n")
lines = ["---"]
for key, value in meta.items():
if value is None or value == "":
@ -141,11 +221,59 @@ def dump_frontmatter(meta: dict, body: str) -> str:
def ensure_space_tree(space_slug: str) -> Path:
root = space_dir(space_slug)
page_file(space_slug, "index")
if assets_dir(space_slug).is_symlink():
raise PageOpsError("Content paths must not be symlinks")
pages_dir(space_slug).mkdir(parents=True, exist_ok=True)
assets_dir(space_slug).mkdir(parents=True, exist_ok=True)
return root
def page_aliases(space_slug):
page_file(space_slug, "index") # Validate the trusted tree boundaries.
path = space_dir(space_slug) / ".page-aliases.json"
if path.is_symlink():
raise PageOpsError("Content paths must not be symlinks")
if not path.exists():
return {}
try:
aliases = json.loads(path.read_text(encoding="utf-8"))
if not isinstance(aliases, dict):
raise ValueError
for source, target in aliases.items():
if (not _SLUG_RE.fullmatch(source) or not isinstance(target, list)
or len(target) != 2 or not all(
isinstance(part, str) and _SLUG_RE.fullmatch(part) for part in target
)):
raise ValueError
return aliases
except (ValueError, TypeError) as exc:
raise PageOpsError("Page-address history needs operator review") from exc
@content_writer
def remember_page_address(source_space, source_page, dest_space, dest_page):
source_page = normalize_slug(source_page)
dest_page = normalize_slug(dest_page)
space_dir(dest_space)
aliases = page_aliases(source_space)
aliases[source_page] = [dest_space, dest_page]
directory = space_dir(source_space)
temporary = None
try:
with tempfile.NamedTemporaryFile(dir=directory, delete=False) as stream:
temporary = Path(stream.name)
stream.write((json.dumps(aliases, sort_keys=True) + "\n").encode("utf-8"))
stream.flush()
os.fsync(stream.fileno())
os.replace(temporary, directory / ".page-aliases.json")
_fsync_directory(directory)
finally:
if temporary is not None:
temporary.unlink(missing_ok=True)
@content_writer
def write_page(
space_slug: str,
page_slug: str,
@ -155,6 +283,7 @@ def write_page(
visual: str = "",
body: str = "",
extra_meta: dict | None = None,
expected_revision: str | None = None,
) -> PageDoc:
page_slug = normalize_slug(page_slug) if page_slug != "index" else "index"
ensure_space_tree(space_slug)
@ -167,7 +296,29 @@ def write_page(
if extra_meta:
meta.update(extra_meta)
path = page_file(space_slug, page_slug)
path.write_text(dump_frontmatter(meta, body or ""), encoding="utf-8")
exists = path.is_file()
current = path.read_bytes() if exists else b""
if not exists and page_slug in page_aliases(space_slug):
raise PageOpsError("This page address is reserved by a renamed or moved page")
revision = hashlib.sha256(current).hexdigest() if exists else ""
if expected_revision is not None and expected_revision != revision:
raise PageOpsError("This page changed. Reopen it and merge your edits before saving")
data = dump_frontmatter(meta, body or "").encode("utf-8")
if exists:
_remember(space_slug, page_slug, current)
_remember(space_slug, page_slug, data)
temporary = None
try:
with tempfile.NamedTemporaryFile(dir=path.parent, delete=False) as stream:
temporary = Path(stream.name)
stream.write(data)
stream.flush()
os.fsync(stream.fileno())
os.replace(temporary, path)
_fsync_directory(path.parent)
finally:
if temporary is not None:
temporary.unlink(missing_ok=True)
return read_page(space_slug, page_slug)
@ -176,7 +327,8 @@ def read_page(space_slug: str, page_slug: str) -> PageDoc:
path = page_file(space_slug, page_slug)
if not path.is_file():
return PageDoc(slug=page_slug, path="")
text = path.read_text(encoding="utf-8")
data = path.read_bytes()
text = data.decode("utf-8").replace("\r\n", "\n").replace("\r", "\n")
meta, body = parse_frontmatter(text)
return PageDoc(
slug=page_slug,
@ -186,6 +338,7 @@ def read_page(space_slug: str, page_slug: str) -> PageDoc:
body=body,
meta=meta,
path=str(path),
revision=hashlib.sha256(data).hexdigest(),
)
@ -202,14 +355,81 @@ def list_pages(space_slug: str) -> list[PageDoc]:
return docs
@content_writer
def delete_page(space_slug: str, page_slug: str) -> None:
if page_slug == "index":
raise PageOpsError("Cannot delete the space index page")
path = page_file(space_slug, normalize_slug(page_slug))
if path.is_file():
_remember(space_slug, page_slug, path.read_bytes())
path.unlink()
_fsync_directory(path.parent)
_BODY_ASSET = re.compile(
r"(?P<prefix>\]\(\s*<?|\]:\s*<?)(?P<url>(?:\.\./|\./)?assets/[^\s)>]+)"
)
def _copy_assets(source_space, dest_space, dest_page, visual, body):
"""Copy local Visual and Markdown link/image destinations as one staged set."""
from .assets import asset_parts, open_local_asset
copied = {}
created = []
def copy_reference(reference, *, optional=False):
parsed = urlsplit(reference)
path = unquote(parsed.path)
for prefix in ("../assets/", "./assets/", "assets/"):
if path.startswith(prefix):
path = path[len(prefix):]
break
else:
raise PageOpsError("Local attachments must be inside the source space assets")
try:
parts = asset_parts(path)
if path not in copied:
try:
source_file = open_local_asset(source_space, path)
except FileNotFoundError:
if optional:
return reference
raise
# Include the relative-path hash to distinguish nested equal filenames.
suffix = (
"" if len(parts) == 1 else f"-{hashlib.sha256(path.encode()).hexdigest()[:12]}"
)
dest_name = f"{dest_page}{suffix}-{parts[-1]}"
with source_file:
with open_local_asset(dest_space, dest_name, create=True) as stream:
created.append(assets_dir(dest_space) / dest_name)
shutil.copyfileobj(source_file, stream)
stream.flush()
os.fsync(stream.fileno())
copied[path] = f"assets/{quote(dest_name)}"
suffix = (f"?{parsed.query}" if parsed.query else "")
suffix += f"#{parsed.fragment}" if parsed.fragment else ""
return copied[path] + suffix
except (OSError, ValueError) as exc:
raise PageOpsError(
"Cannot copy the destination Visual or body attachment safely"
) from exc
try:
if visual and not visual.startswith(("http", "/")):
visual = copy_reference(visual, optional=True)
body = _BODY_ASSET.sub(
lambda match: match["prefix"] + copy_reference(match["url"]), body,
)
return visual, body, created
except BaseException:
for path in created:
path.unlink(missing_ok=True)
raise
@content_writer
def copy_page(
source_space: str,
source_page: str,
@ -222,76 +442,49 @@ def copy_page(
if not src.exists:
raise PageOpsError("Source page not found")
dest_page = normalize_slug(dest_page)
if page_file(dest_space, dest_page).is_file():
if (page_file(dest_space, dest_page).is_file()
or dest_page in page_aliases(dest_space)):
raise PageOpsError("Destination page already exists")
ensure_space_tree(dest_space)
# copy visual asset if relative
visual = src.visual
if visual and not visual.startswith("http") and not visual.startswith("/"):
from .assets import open_local_asset
if not visual.startswith("assets/"):
raise PageOpsError("Visual must be inside the source space assets")
try:
source_file = open_local_asset(source_space, visual[len("assets/"):])
except FileNotFoundError:
source_file = None
except (OSError, ValueError) as exc:
raise PageOpsError("Cannot copy this Visual safely") from exc
if source_file is not None:
dest_name = f"{dest_page}-{Path(visual).name}"
with source_file:
try:
with open_local_asset(dest_space, dest_name, create=True) as destination_file:
shutil.copyfileobj(source_file, destination_file)
except OSError as exc:
raise PageOpsError("Cannot write the destination Visual safely") from exc
visual = f"assets/{dest_name}"
new_title = title if title is not None else (src.title or dest_page)
if new_title == src.title and source_space == dest_space:
new_title = f"{src.title} (copy)" if src.title else f"{dest_page}"
return write_page(
dest_space,
dest_page,
title=new_title,
abstractor=src.abstractor,
visual=visual,
body=src.body,
extra_meta={
k: v for k, v in src.meta.items()
if k not in {"title", "abstractor", "visual", "space"}
},
visual, body, created = _copy_assets(
source_space, dest_space, dest_page, src.visual, src.body,
)
new_title = title if title is not None else (src.title or dest_page)
if title is None and new_title == src.title and source_space == dest_space:
new_title = f"{src.title} (copy)" if src.title else f"{dest_page}"
try:
return write_page(
dest_space, dest_page, title=new_title, abstractor=src.abstractor,
visual=visual, body=body, expected_revision="",
extra_meta={k: v for k, v in src.meta.items()
if k not in {"title", "abstractor", "visual", "space"}},
)
except BaseException:
if not page_file(dest_space, dest_page).exists():
for path in created:
path.unlink(missing_ok=True)
raise
@content_writer
def transfer_page(
source_space: str,
source_page: str,
dest_space: str,
dest_page: str | None = None,
) -> PageDoc:
dest_page = dest_page or source_page
source_page = normalize_slug(source_page)
dest_page = normalize_slug(dest_page or source_page)
if source_page == "index":
raise PageOpsError("Cannot transfer the space index page")
doc = copy_page(source_space, source_page, dest_space, dest_page, title=None)
# restore original title (copy_page may suffix)
src = read_page(source_space, source_page)
write_page(
dest_space,
dest_page,
title=src.title,
abstractor=src.abstractor,
visual=doc.visual,
body=src.body,
extra_meta={
k: v for k, v in src.meta.items()
if k not in {"title", "abstractor", "visual", "space"}
},
)
copy_page(source_space, source_page, dest_space, dest_page, title=src.title)
remember_page_address(source_space, source_page, dest_space, dest_page)
delete_page(source_space, source_page)
return read_page(dest_space, dest_page)
@content_writer
def init_space_content(
space_slug: str,
*,
@ -313,6 +506,7 @@ def init_space_content(
)
@content_writer
def rename_space_tree(old_slug: str, new_slug: str) -> None:
old = space_dir(old_slug)
new = space_dir(new_slug)
@ -324,6 +518,7 @@ def rename_space_tree(old_slug: str, new_slug: str) -> None:
old.rename(new)
@content_writer
def archive_space_tree(space_slug: str) -> None:
"""Soft-archive: rename tree to .archived-<slug> if present."""
root = space_dir(space_slug)
@ -331,7 +526,7 @@ def archive_space_tree(space_slug: str) -> None:
return
dest = content_root() / "spaces" / f".archived-{space_slug}"
if dest.exists():
shutil.rmtree(dest)
raise PageOpsError("A retained archive already exists; resolve it before archiving again")
root.rename(dest)

View file

@ -2,8 +2,9 @@
from __future__ import annotations
from django.core.exceptions import ValidationError
from django.db import transaction
from django.db.models import Q, QuerySet
from django.db.models import Count, Q, QuerySet
from coulomb_social.apps.members.models import Member
@ -16,7 +17,10 @@ def spaces_for_member(member: Member | None) -> QuerySet[Space]:
if member is None:
return Space.objects.none()
qs = Space.objects.filter(tenant_id=member.tenant_id, is_active=True)
ambiguous = Space.objects.values("slug").annotate(n=Count("pk")).filter(n__gt=1)
qs = Space.objects.filter(tenant_id=member.tenant_id, is_active=True).exclude(
slug__in=ambiguous.values("slug")
)
return (
qs.filter(Q(memberships__member=member) | Q(created_by=member))
.distinct()
@ -34,17 +38,61 @@ 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
if Space.objects.filter(slug=space.slug).exclude(pk=space.pk).exists():
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 can_manage_space(member: Member | None, space: Space) -> bool:
if not can_write_space(member, space):
return False
grant = SpaceMembership.objects.filter(space=space, member=member).first()
return grant.role == SpaceMembership.Role.OWNER if grant else space.created_by_id == member.pk
@transaction.atomic
def set_space_access(member: Member, space: Space, target_id, role: str) -> None:
"""Owners grant access to an existing, exact local member; never invite by email."""
space = Space.objects.select_for_update().get(pk=space.pk)
if not can_manage_space(member, space):
raise pageops.PageOpsError("Only a space owner can manage sharing")
try:
target = Member.objects.select_for_update().get(pk=target_id, tenant_id=space.tenant_id)
except (Member.DoesNotExist, ValidationError, ValueError, TypeError):
raise pageops.PageOpsError("Choose an existing member in this tenant") from None
if role not in {*SpaceMembership.Role.values, "remove"}:
raise pageops.PageOpsError("Choose a valid access role")
# A creator has an implicit grant. Do not claim to revoke that grant by
# deleting a membership that would simply re-enable the fallback.
if target.pk == space.created_by_id and role != SpaceMembership.Role.OWNER:
raise pageops.PageOpsError("The creator must retain owner access")
current = SpaceMembership.objects.filter(space=space, member=target).first()
if current and current.role == "owner" and role != "owner":
others = SpaceMembership.objects.filter(
space=space, role="owner", member__tenant_id=space.tenant_id,
).exclude(member=target)
if not others.exists() and not (
space.created_by_id and space.created_by_id != target.pk
and space.created_by.tenant_id == space.tenant_id
):
raise pageops.PageOpsError("Keep at least one owner")
if role == "remove":
SpaceMembership.objects.filter(space=space, member=target).delete()
else:
SpaceMembership.objects.update_or_create(
space=space, member=target, defaults={"role": role},
)
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")
@pageops.content_writer
@transaction.atomic
def create_space(
member: Member,
@ -54,6 +102,8 @@ def create_space(
slug: str | None = None,
visual: str = "",
) -> Space:
if member is None:
raise pageops.PageOpsError("Sign in with a local member profile before creating a space")
title = (title or "").strip()
if not title:
raise pageops.PageOpsError("Title is required")
@ -85,6 +135,7 @@ def create_space(
return space
@pageops.content_writer
@transaction.atomic
def update_space(
member: Member,
@ -93,8 +144,14 @@ def update_space(
title: str | None = None,
abstractor: str | None = None,
visual: str | None = None,
expected_revision: str | None = None,
) -> Space:
require_space_write(member, space)
index = pageops.read_page(space.slug, "index")
if expected_revision is not None and index.revision != expected_revision:
raise pageops.PageOpsError(
"This space changed. Reopen it and merge your edits before saving"
)
if title is not None:
space.title = title.strip() or space.title
if abstractor is not None:
@ -103,7 +160,6 @@ def update_space(
space.visual = visual
space.save()
# Keep index.md in sync
index = pageops.read_page(space.slug, "index")
body = index.body if index.exists else f"# {space.title}\n"
pageops.write_page(
space.slug,
@ -112,21 +168,21 @@ def update_space(
abstractor=space.description,
visual=space.visual,
body=body,
extra_meta={"bubble_type": "space"},
extra_meta={**{k: v for k, v in index.meta.items()
if k not in {"title", "abstractor", "visual", "space"}},
"bubble_type": "space"},
expected_revision=expected_revision,
)
return space
@pageops.content_writer
@transaction.atomic
def archive_space(member: Member, space: Space) -> Space:
require_space_write(member, space)
space.is_active = False
space.save(update_fields=["is_active", "updated_at"])
try:
pageops.archive_space_tree(space.slug)
except Exception:
# Tree archive is best-effort
pass
pageops.archive_space_tree(space.slug)
return space
@ -134,6 +190,7 @@ def list_space_pages(space: Space) -> list[pageops.PageDoc]:
return pageops.list_pages(space.slug)
@pageops.content_writer
def put_space_page(
member: Member,
space: Space,
@ -144,6 +201,7 @@ def put_space_page(
visual: str = "",
body: str = "",
extra_meta: dict | None = None,
expected_revision: str | None = None,
) -> pageops.PageDoc:
require_space_write(member, space)
metadata = (pageops.read_page(space.slug, page_slug).meta
@ -157,6 +215,7 @@ def put_space_page(
body=body,
extra_meta={k: v for k, v in metadata.items()
if k not in {"title", "abstractor", "visual", "space"}},
expected_revision=expected_revision,
)
@ -181,6 +240,27 @@ def copy_space_page(
)
@pageops.content_writer
def save_space_page(member, space, source_slug, target_slug, *, expected_revision, **fields):
require_space_write(member, space)
source = pageops.read_page(space.slug, source_slug)
if expected_revision is None or source.revision != expected_revision:
raise pageops.PageOpsError(
"This page changed. Reopen it and merge your edits before saving"
)
if source_slug == "index" and target_slug != "index":
raise pageops.PageOpsError("The space index file is fixed")
if target_slug != source_slug and pageops.page_file(space.slug, target_slug).exists():
raise pageops.PageOpsError("A page with that slug already exists")
doc = put_space_page(member, space, target_slug, extra_meta=source.meta,
expected_revision=expected_revision if source_slug == target_slug else "",
**fields)
if source_slug != target_slug:
pageops.remember_page_address(space.slug, source_slug, space.slug, target_slug)
pageops.delete_page(space.slug, source_slug)
return doc
def transfer_space_page(
member: Member,
space: Space,

View file

@ -13,6 +13,7 @@ urlpatterns = [
path("new/", views.space_create, name="create"),
path("<slug:slug>/assets/<path:asset_path>", views.space_asset, name="asset"),
path("<slug:slug>/edit/", views.space_edit, name="edit"),
path("<slug:slug>/sharing/", views.space_sharing, name="sharing"),
path("<slug:slug>/archive/", views.space_archive, name="archive"),
path("<slug:slug>/refresh/", views.space_refresh, name="refresh"),
path("<slug:slug>/pages/new/", views.page_new, name="page_new"),

View file

@ -31,15 +31,18 @@ from .content import (
forgejo_repo_url,
load_space_page,
)
from .models import Space
from .models import Space, SpaceMembership
from .services import (
archive_space,
can_manage_space,
can_write_space,
copy_space_page,
create_space,
get_space_for_member,
list_space_pages,
put_space_page,
save_space_page,
set_space_access,
spaces_for_member,
transfer_space_page,
update_space,
@ -48,6 +51,12 @@ from .services import (
logger = logging.getLogger(__name__)
def _mutation_error(exc):
if isinstance(exc, pageops.PageOpsError):
return exc.message
return "The content write could not be confirmed. Reopen the page before retrying."
@login_required
@require_safe
def space_asset(request: HttpRequest, slug: str, asset_path: str) -> HttpResponse:
@ -101,8 +110,8 @@ def space_create(request: HttpRequest) -> HttpResponse:
)
messages.success(request, f"Space “{space.title}” created.")
return redirect("spaces:detail", slug=space.slug)
except pageops.PageOpsError as exc:
messages.error(request, exc.message)
except (pageops.PageOpsError, OSError) as exc:
messages.error(request, _mutation_error(exc))
return render(
request,
"spaces/form.html",
@ -130,17 +139,23 @@ def space_edit(request: HttpRequest, slug: str) -> HttpResponse:
return HttpResponseForbidden("You have read-only access to this space.")
if request.method == "POST":
try:
if "revision" not in request.POST:
raise pageops.PageOpsError("Reopen this space before saving")
update_space(
member,
space,
title=(request.POST.get("title") or "").strip(),
abstractor=(request.POST.get("abstractor") or "").strip(),
visual=(request.POST.get("visual") or "").strip(),
expected_revision=request.POST.get("revision"),
)
messages.success(request, "Space updated.")
return redirect("spaces:detail", slug=space.slug)
except pageops.PageOpsError as exc:
messages.error(request, exc.message)
except (pageops.PageOpsError, OSError) as exc:
messages.error(request, _mutation_error(exc))
space.title = request.POST.get("title", space.title)
space.description = request.POST.get("abstractor", space.description)
space.visual = request.POST.get("visual", space.visual)
return render(
request,
"spaces/form.html",
@ -150,6 +165,8 @@ def space_edit(request: HttpRequest, slug: str) -> HttpResponse:
"form_title": f"Edit {space.title}",
"space": space,
"submit_label": "Save",
"revision": (request.POST.get("revision", "") if request.method == "POST"
else pageops.read_page(space.slug, "index").revision),
},
)
@ -169,8 +186,8 @@ def space_archive(request: HttpRequest, slug: str) -> HttpResponse:
try:
archive_space(member, space)
messages.success(request, f"Space “{space.title}” archived.")
except pageops.PageOpsError as exc:
messages.error(request, exc.message)
except (pageops.PageOpsError, OSError) as exc:
messages.error(request, _mutation_error(exc))
return redirect("core:app_home")
@ -185,6 +202,33 @@ def space_detail(request: HttpRequest, slug: str) -> HttpResponse:
return HttpResponseNotFound("Space not found.")
page = request.GET.get("page") or "index"
# Old incoming links survive rename/move, but every hop still requires an
# active exact tenant/space grant. Never redirect an unauthorized reader.
try:
page = pageops.normalize_slug(page)
target_space, target_page = space, page
visited = set()
while not pageops.read_page(target_space.slug, target_page).exists:
key = (target_space.slug, target_page)
if key in visited or len(visited) >= 16:
return HttpResponseNotFound("Page not found.")
visited.add(key)
alias = pageops.page_aliases(target_space.slug).get(target_page)
if alias is None:
break
target_space = get_space_for_member(member, alias[0])
if target_space is None:
return HttpResponseNotFound("Page not found.")
target_page = alias[1]
if (target_space.pk, target_page) != (space.pk, page):
if not pageops.read_page(target_space.slug, target_page).exists:
return HttpResponseNotFound("Page not found.")
target = reverse("spaces:detail", kwargs={"slug": target_space.slug})
response = redirect(f"{target}?page={target_page}")
response["Cache-Control"] = "private, no-store"
return response
except pageops.PageOpsError:
return HttpResponseNotFound("Page not found.")
rendered = load_space_page(space, page)
pages = list_space_pages(space)
return render(
@ -198,6 +242,7 @@ def space_detail(request: HttpRequest, slug: str) -> HttpResponse:
"space_visual_url": local_asset_url(space.slug, space.visual),
"pages": pages,
"can_write": can_write_space(member, space),
"can_manage": can_manage_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),
@ -205,6 +250,36 @@ def space_detail(request: HttpRequest, slug: str) -> HttpResponse:
)
@login_required
@require_http_methods(["GET", "POST"])
def space_sharing(request, slug):
principal, forbid = _principal_or_forbid(request, "space_sharing")
if forbid:
return forbid
member = principal.get("member")
space = get_space_for_member(member, slug)
if space is None:
return HttpResponseNotFound("Space not found.")
if not can_manage_space(member, space):
return HttpResponseForbidden("Only a space owner can manage sharing.")
if request.method == "POST":
try:
set_space_access(member, space, request.POST.get("member_id"),
request.POST.get("role", ""))
except (pageops.PageOpsError, OSError) as exc:
messages.error(request, _mutation_error(exc))
else:
messages.success(request, "Space access updated.")
return redirect("spaces:sharing", slug=slug)
response = render(request, "spaces/sharing.html", {
"space": space, "roles": SpaceMembership.Role.choices,
"grants": space.memberships.filter(member__tenant_id=space.tenant_id)
.select_related("member"),
})
response["Cache-Control"] = "private, no-store"
return response
@login_required
@require_http_methods(["GET", "POST"])
def page_edit(request: HttpRequest, slug: str, page_slug: str = "index") -> HttpResponse:
@ -221,8 +296,8 @@ def page_edit(request: HttpRequest, slug: str, page_slug: str = "index") -> Http
if page_slug != "index":
try:
page_slug = pageops.normalize_slug(page_slug)
except pageops.PageOpsError as exc:
messages.error(request, exc.message)
except (pageops.PageOpsError, OSError) as exc:
messages.error(request, _mutation_error(exc))
return redirect("spaces:detail", slug=slug)
doc = pageops.read_page(space.slug, page_slug)
@ -237,41 +312,18 @@ def page_edit(request: HttpRequest, slug: str, page_slug: str = "index") -> Http
target_slug = "index"
else:
target_slug = pageops.normalize_slug(new_slug)
if target_slug != page_slug and page_slug != "index":
# rename: write new, delete old
if pageops.page_file(space.slug, target_slug).is_file():
raise pageops.PageOpsError("A page with that slug already exists")
put_space_page(
member,
space,
target_slug,
title=title,
abstractor=abstractor,
visual=visual,
body=body,
extra_meta=doc.meta,
)
pageops.delete_page(space.slug, page_slug)
page_slug = target_slug
else:
put_space_page(
member,
space,
target_slug,
title=title,
abstractor=abstractor,
visual=visual,
body=body,
)
page_slug = target_slug
save_space_page(member, space, page_slug, target_slug,
expected_revision=request.POST.get("revision"),
title=title, abstractor=abstractor, visual=visual, body=body)
page_slug = target_slug
clear_content_cache_for_space(space)
messages.success(request, "Page saved.")
target = reverse("spaces:detail", kwargs={"slug": space.slug})
if page_slug != "index":
target = f"{target}?page={page_slug}"
return redirect(target)
except pageops.PageOpsError as exc:
messages.error(request, exc.message)
except (pageops.PageOpsError, OSError) as exc:
messages.error(request, _mutation_error(exc))
doc = pageops.PageDoc(
slug=page_slug,
title=title,
@ -279,6 +331,7 @@ def page_edit(request: HttpRequest, slug: str, page_slug: str = "index") -> Http
visual=visual,
body=body,
path=doc.path,
revision=request.POST.get("revision", ""),
)
return render(
@ -289,6 +342,7 @@ def page_edit(request: HttpRequest, slug: str, page_slug: str = "index") -> Http
"display_name": principal["display_name"],
"space": space,
"doc": doc,
"submitted_slug": request.POST.get("slug", page_slug),
"is_new": not doc.exists,
"form_title": "Edit or rename page" if doc.exists else "New page",
},
@ -331,13 +385,14 @@ def page_new(request: HttpRequest, slug: str) -> HttpResponse:
abstractor=abstractor,
visual=visual,
body=body,
expected_revision="",
)
clear_content_cache_for_space(space)
messages.success(request, "Page created.")
target = reverse("spaces:detail", kwargs={"slug": space.slug})
return redirect(f"{target}?page={page_slug}")
except pageops.PageOpsError as exc:
messages.error(request, exc.message)
except (pageops.PageOpsError, OSError) as exc:
messages.error(request, _mutation_error(exc))
return render(
request,
@ -377,8 +432,8 @@ def page_delete(request: HttpRequest, slug: str, page_slug: str) -> HttpResponse
pageops.delete_page(space.slug, page_slug)
clear_content_cache_for_space(space)
messages.success(request, f"Page “{page_slug}” deleted.")
except pageops.PageOpsError as exc:
messages.error(request, exc.message)
except (pageops.PageOpsError, OSError) as exc:
messages.error(request, _mutation_error(exc))
return redirect("spaces:detail", slug=slug)
@ -417,8 +472,8 @@ def page_copy(request: HttpRequest, slug: str, page_slug: str) -> HttpResponse:
if doc.slug != "index":
target = f"{target}?page={doc.slug}"
return redirect(target)
except pageops.PageOpsError as exc:
messages.error(request, exc.message)
except (pageops.PageOpsError, OSError) as exc:
messages.error(request, _mutation_error(exc))
return render(
request,
"spaces/page_copy.html",
@ -468,8 +523,8 @@ def page_transfer(request: HttpRequest, slug: str, page_slug: str) -> HttpRespon
if doc.slug != "index":
target = f"{target}?page={doc.slug}"
return redirect(target)
except pageops.PageOpsError as exc:
messages.error(request, exc.message)
except (pageops.PageOpsError, OSError) as exc:
messages.error(request, _mutation_error(exc))
return render(
request,
"spaces/page_transfer.html",

View file

@ -154,6 +154,7 @@
<div class="menu-panel" role="menu">
<div class="menu-meta">Signed in</div>
<a href="{% url 'core:account_session' %}" role="menuitem">Session details</a>
<a href="{% url 'core:account_data' %}" role="menuitem">Your app data</a>
<a href="{% url 'identity:logout' %}" role="menuitem">Sign out</a>
</div>
</details>

View file

@ -0,0 +1,26 @@
{% extends "base.html" %}
{% block title %}Your app data — {{ site_name }}{% endblock %}
{% block content %}
<h1>Your app data</h1>
<p>Your member ID: <code>{{ member.id }}</code>. Share this ID with a space owner to request access.</p>
<p><a class="btn" href="{% url 'core:account_export' %}">Export my account records</a></p>
<p>The export includes your local profile, identity references and memberships. Pages and assets remain in their spaces.</p>
<h2>Leaving the app</h2>
<p>Removing your local account removes its profile and space grants and signs you out.
Shared pages and assets stay with their spaces. Existing backups follow the operator's retention policy.
Your platform identity and mailbox remain active; signing in again creates a new local profile without your old grants.</p>
<h2>Spaces you are responsible for</h2>
<ul>{% for space in retained_spaces %}<li>{{ space.title }}{% if not space.is_active %} (archived){% endif %}</li>{% empty %}<li>None</li>{% endfor %}</ul>
{% if ownership_review %}
<p>An inconsistent ownership record needs operator review before removal.</p>
{% elif blockers %}
<p>Before removal, give another active member owner access to each of these spaces:</p>
<ul>{% for space in blockers %}<li>{{ space.title }}</li>{% endfor %}</ul>
{% else %}
<form method="post">{% csrf_token %}
<label for="confirmation">Type DELETE to remove your local account</label>
<input id="confirmation" name="confirmation" type="text" required autocomplete="off">
<button class="btn secondary" type="submit">Remove local account</button>
</form>
{% endif %}
{% endblock %}

View file

@ -97,6 +97,7 @@
<div class="content-actions" style="margin:0;">
<a class="btn secondary small" href="{% url 'spaces:edit' space.slug %}">Edit space</a>
<a class="btn secondary small" href="{% url 'spaces:page_new' space.slug %}">New page</a>
{% if can_manage %}<a class="btn secondary small" href="{% url 'spaces:sharing' space.slug %}">Share space</a>{% endif %}
</div>
{% endif %}
</div>

View file

@ -34,6 +34,7 @@
<form method="post" class="card" style="margin-top:1rem;">
{% csrf_token %}
{% if space %}<input type="hidden" name="revision" value="{{ revision }}">{% endif %}
<label for="id_title">Title</label>
<input type="text" id="id_title" name="title" required maxlength="255"
value="{% if space %}{{ space.title }}{% endif %}" autocomplete="off">

View file

@ -34,6 +34,7 @@
<form method="post" class="card" style="margin-top:1rem;">
{% csrf_token %}
<input type="hidden" name="revision" value="{{ doc.revision }}">
<label for="id_title">Title</label>
<input type="text" id="id_title" name="title" maxlength="255"
value="{{ doc.title }}" autocomplete="off"
@ -44,7 +45,7 @@
<input type="text" id="id_slug" name="slug" value="index" readonly maxlength="80">
{% else %}
<input type="text" id="id_slug" name="slug" maxlength="80"
value="{{ doc.slug }}"
value="{{ submitted_slug|default:doc.slug }}"
pattern="[a-z0-9]+(?:-[a-z0-9]+)*"
placeholder="page-slug" autocomplete="off"
{% if is_new %}required{% endif %}>

View file

@ -0,0 +1,21 @@
{% extends "base.html" %}
{% block title %}Share {{ space.title }} — {{ site_name }}{% endblock %}
{% block content %}
<p><a href="{% url 'spaces:detail' space.slug %}">← {{ space.title }}</a></p>
<h1>Share space</h1>
<p>Ask the person for the member ID shown in Your app data. They must already have signed in to this tenant.
Viewers can read and copy content. Members can edit. Owners can also manage sharing.
Access covers every page and image in this space. This action does not send email.</p>
<form method="post" class="card">{% csrf_token %}
<label for="member_id">Member ID</label>
<input id="member_id" name="member_id" type="text" required autocomplete="off">
<label for="role">Access</label>
<select id="role" name="role">
{% for value, label in roles %}<option value="{{ value }}" {% if value == 'viewer' %}selected{% endif %}>{{ label }}</option>{% endfor %}
<option value="remove">Remove access</option>
</select>
<p><button class="btn" type="submit">Update access</button></p>
</form>
<h2>Current access</h2>
<ul>{% for grant in grants %}<li>{{ grant.member.display_name|default:grant.member.subject }} — {{ grant.get_role_display }} — <code>{{ grant.member_id }}</code></li>{% empty %}<li>The creator has owner access.</li>{% endfor %}</ul>
{% endblock %}

View file

@ -0,0 +1,36 @@
{
"schema_version": 1,
"owner": "email-connect",
"passed": true,
"network": "all socket connects and DNS resolution denied",
"tests": 39,
"skipped": 0,
"failed": 0,
"missing_required_tests": [],
"source_sha256": {
"src/email_connect/__init__.py": "0f1ae5e9251f80b0bd5220b1d7bf91735d6ea273e07aa26cb7852a7950be7087",
"src/email_connect/adapter_contract.py": "5059b2eaaac2408f4e247bb468a97960eb3f823e63c3eeb151a1c2250e1f3bbf",
"src/email_connect/cli.py": "6ac6ae8acd091ba2d2102589be3b75fe3a48392309e40e61f8383deb3329ad42",
"src/email_connect/config.py": "6ca89fb7c3e1f35bc4faedfba8c3313d4366b2cb7830ef88ed3f7d2313f7867e",
"src/email_connect/evidence.py": "42cca03234a79dc78033ec1fbaf0b1785e6089ef3f3128e2e9d666ed239a3582",
"src/email_connect/mailbox.py": "7f0fe7e19ce1f814269609c4236152042f0f49bf3b517114b3b3f6f482471362",
"src/email_connect/models.py": "7f8136646b663a097280286dccce76a90abb7190ea0712fff7da32d47526521c",
"src/email_connect/parser.py": "786b291256ddfac5a8622c743622508cdd9f45f06156d0f5487ce399fad1287c",
"src/email_connect/recipients.py": "68792f44f0d2ba204649542aa8bd6b0f209d89c675baa4fc0767f1250a29045f",
"src/email_connect/reporting.py": "8f8ce5f98e4347b778893d08a9e58f2bab3b44b80c159fcf52d0b5772429737c",
"src/email_connect/scanner.py": "6266cbad2dbc610c1f6df1c051ded2ee55b43d7ff30af6eab38721924931c1ed",
"src/email_connect/storage.py": "57fbd6d3b2e7541c0bf52d87a4f7361a4ff4a03511620d2a1c3789632b73afeb",
"src/email_connect/transactional.py": "e917616911d11b00ec60d9ff129a7db804184051a24b8aa23d8809c8c2195cb2",
"tests/test_transactional.py": "7b443defb15b25427257045def348af0d7e5819c27659a52b7a53d78599301e6"
},
"contract_sha256": "7bcd65c29e572c0abbd3784e7ad1e8341f963aa12345c901f7946eac4e999c1d",
"acceptance_complete": false,
"remaining_acceptance": [
"CSOC-UC-009 isolated issuer reset, inbox and app browser journey",
"CSOC-UC-010/011 isolated registration and invitation browser journeys",
"Independent caller authentication, tenant/template scope, limits and scoped status",
"Platform sender and independent ledger backup custody admission",
"Named inbound/reply/bounce operations, alignment and retention",
"Per-flow productive user acceptance"
]
}

View file

@ -0,0 +1,118 @@
# WP-0008 / WP-0010 implementation review — 2026-10-08
This is a local source review. The current production release remains revision15;
these changes have not been packaged or deployed. Both workplans remain active.
Technical implementation decision: `9ffb5125-5551-4101-9025-5b6f72f40940`.
## Product behavior now implemented
Space owners can open **Share space**, enter an existing tenant member's ID and
grant Viewer, Member or Owner access, or remove a grant. The member finds their
ID under **Your app data**. No directory/email enumeration or notification send
is involved. Viewers can read/copy content; members can write; only owners manage
access. Creator access and the last owner cannot be revoked through this flow.
Revocation denies subsequent page/image reads immediately.
**Your app data** exports the requesting person's local profile, platform identity
references, memberships and created-space metadata as a private JSON download.
It excludes credentials, session tokens and other people's profiles. It does not
export platform-owned account records or claim that shared pages belong to one
person. Shared content is retained when a member leaves.
The exit screen explains retained content/backups and lists ownership obligations,
including archived spaces. Removal requires another active same-tenant owner for
every retained space and typing DELETE. It clears creator attribution, deletes
the app-local Member/User and their grants, and signs out; old sessions can no
longer authenticate. NetKingdom/user-engine/mailbox identity remains separate.
Signing in again creates a new local profile without the old grants. Corrupt
cross-tenant creator links require operator review without disclosing foreign
space titles. No live profile was removed while testing.
## Storage, revision and link behavior
The existing slug-keyed content layout is retained. App reads, lists and service
writes fail closed on any duplicate slug, including inactive/foreign index rows.
App creation/import and admin metadata saves serialize through the same mounted
content writer lock; admin validation rejects global slug collisions and
cross-tenant creator/grant links. Ordinary sharing cannot create a cross-tenant
grant. Raw database/filesystem maintenance remains a paused-writer operation;
this increment does not introduce a database-wide global slug constraint or
tenant-directory migration. Production tenancy/cohort reconciliation stays T04.
Cooperating threads/processes use a reentrant Python lock and Linux `flock` on
`CONTENT_ROOT/.writer.lock`. App/import filesystem writes are serialized. Local
account removal also takes that lock so new owned spaces cannot race its exit
check. The existing one-instance persistent volume and paused-writer backup
protocol remain required; this is not a distributed multi-node writer protocol.
Page/space edit forms carry an exact byte-hash revision. A stale or missing
revision preserves the submitted text and asks the writer to reopen/merge instead
of overwriting another save. New-page/copy destination collisions also remain
protected under the lock. Writes stage complete files, fsync, atomically replace
the active file and fsync the directory. Browser CRLF is normalized to LF.
Original imported metadata, including source identifiers, survives page rename
and space-index edits.
Exact previous/new Markdown versions live at
`CONTENT_ROOT/.revisions/<space>/<page>/<sha256>.md`, including before deletion.
They are private content-plane recovery material covered by the paired backup;
they are not pushed to Forgejo. Retention/capacity admission and acceptance of
this revision mechanism remain T02/T05 obligations. No automatic purge exists.
An existing archived tree is retained rather than overwritten by a later archive.
Copy/move stages local Visual and Markdown inline/reference-linked attachments,
rewrites their destination URLs, distinguishes equal nested filenames and
preserves query/fragment suffixes. Destination collisions, missing linked assets,
traversal and symlinks refuse the operation; partial newly created assets are
removed when publication fails. The source remains on failed destination writes.
Uncertain completed writes preserve the destination for review. Product errors
do not disclose private filesystem diagnostics. Raw HTML and arbitrary custom
Markdown conventions still require corpus reconciliation.
Rename/move retains `.page-aliases.json` in the original space. Old incoming page
addresses resolve to the current page only after checking active exact grants at
every hop, including both source and destination spaces. Old addresses cannot be
reused for unrelated content. Denied destinations, loops and missing targets
fail closed. Content replacement/import refuses retained aliases until their
disposition is explicitly reconciled. Multi-file moves are not crash-atomic;
paired recovery and attended maintenance remain the recovery protocol.
## Communication regression implementation
UC-009–012 are registered with synthetic fixtures and `established: false`.
UC-009 preserves its recorded personal recovery acceptance; the other cases
remain candidates. Empty browser bindings are explicit gaps rather than claimed
end-to-end protection. UC-008's existing mandatory tests remain registered.
`make check-communication EMAIL_CONNECT_ROOT=/path/to/email-connect` copies only
owner Python source and its synthetic transactional test module to a private
temporary directory. It strips inherited credentials, denies network connects
and DNS, disables plugin autoload, and verifies 21 exact required test functions
without skip/xfail. The original owner checkout remains untouched. A redacted
receipt hashes the actual executed source/test/contract and reports results plus
outstanding owner/user acceptance. No production account, provider or mailbox is
used. See `communication-regressions-2026-10-08.json`.
## Validation and remaining live gates
`make check`: **182 passed**, repository-wide Ruff clean, both real Chromium
journeys run. New cases cover sharing/revocation, personal export, CSRF, last-owner
and archived-content retention, old-session denial, ambiguous storage, concurrent
threads/processes, stale forms, blank imported pages, symlinks, linked attachments,
incoming-address access and failed destination publication. Communication runner
tests prove environment isolation, network denial and refused missing/disabled
coverage. Owner gate: **39 passed, zero skips**. Django system check passes;
`makemigrations --check --dry-run` finds no schema changes.
WP-0008-T01/T02/T03/T04/T05/T06/T08/T09 remain open for the relevant pinned release,
deployment/operational capacity, offsite custody/retention and recovery admission,
actual corpus/tenancy/privacy reconciliation, service contract and productive
per-journey user acceptance. Existing RPF-WP-0053 admission/activation handoffs
remain the live backup dependency; no new offsite capture or timer is activated.
WP-0010-T04/T05/T06 remain open for independent caller authentication, approved
tenant/template limits and scoped status, platform sender custody, ledger backup
admission (EMAIL-WP-0007-T03/T04 and RPF-WP-0053-T05), named reply/bounce ownership,
inbound access/alignment/retention and isolated owner-spanning browser journeys.
No external owner admission, new caller, email send, mailbox scan, runtime change,
productive transfer acceptance or Bubble retirement is inferred from these tests.

View file

@ -36,11 +36,11 @@ acceptance. Do not mark transfer accepted on the strength of tests alone.
|---|---|---|---|---|
| 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 |
| 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 | Visuals are live; local revisions and stale-edit protection implemented; new release and revision/retention acceptance pending | 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 | Local linked-attachment copy and guarded incoming-address preservation implemented; actual corpus/custom conventions and 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 | Owner-managed same-tenant sharing/revocation, viewer denial and ambiguous-storage guards implemented locally; release, tenancy and original privacy review remain | 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** | Local personal JSON export and reviewed exit screen implemented; shared content retained, successor owner required; deployment and productive user acceptance pending | 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 storage and actual paired restore are evidenced; offsite custody/retention, monitoring and operational rollback remain | T01/T02; pending |
| CSOC-UC-008 | Prepare the next ReichelAg founder call from the full previous protocol, write the new notes, copy them for manual email distribution | User-confirmed 2026-10-06; exact Bubble UI not freshly observed | Duplicate/edit implemented; clipboard action added; latest real protocol, mail-client paste and persistence review pending | T03/T05/T08/T09/T10; pending |
### Concrete scenario specification for the first review
@ -64,7 +64,8 @@ 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.
permission is checked before copy/move. Review the locally implemented Share
space UI after its release; original privacy equivalence remains 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.

View file

@ -0,0 +1,36 @@
{
"schema_version": 1,
"owner": "email-connect",
"test_file": "tests/test_transactional.py",
"required_tests": [
"test_rejects_auth_recipient_template_and_key",
"test_sends_fixed_template_once",
"test_resend_is_separate_event_and_sends_again",
"test_suppression_blocks_send",
"test_provider_timeout_temporary_and_permanent_are_redacted",
"test_status_authenticates_without_sending_and_redacts_errors",
"test_registration_verification_is_digest_only_and_single_use",
"test_registration_cancellation_is_single_use_and_prevents_verification",
"test_mailbox_evidence_is_not_authorization",
"test_lost_post_send_record_never_resends_after_restart",
"test_definitive_failure_retries_but_changed_payload_is_rejected",
"test_disconnect_after_smtp_acceptance_never_releases_reservation",
"test_verification_retry_after_restart_returns_same_receipt",
"test_verification_binding_cannot_change_on_retry",
"test_verification_uncertain_send_is_not_repeated_after_restart",
"test_verification_acceptance_record_failure_does_not_resend",
"test_verification_definitive_deferral_allows_retry_and_cancels_old_handle",
"test_verification_permanent_failure_stays_terminal",
"test_verification_concurrent_connections_reserve_before_provider_call",
"test_verification_tenant_and_client_operations_are_separate",
"test_legacy_verification_is_preserved_without_resending"
],
"remaining_acceptance": [
"CSOC-UC-009 isolated issuer reset, inbox and app browser journey",
"CSOC-UC-010/011 isolated registration and invitation browser journeys",
"Independent caller authentication, tenant/template scope, limits and scoped status",
"Platform sender and independent ledger backup custody admission",
"Named inbound/reply/bounce operations, alignment and retention",
"Per-flow productive user acceptance"
]
}

View file

@ -32,7 +32,10 @@ accept migration or a live release.
Private page covers are visible per founder review. Missing Spaces-list
preview is corrected in revision15; live owner preview/hash preservation and
actual production-pair isolated recovery pass. List-preview review is pending.
- Writer, reader and outsider access; sharing and portability UX: pending T04.
- Writer, reader and outsider access; owner sharing/revocation, personal export
and retained-content exit UI are implemented locally and browser-tested.
Release and productive user review remain T04; see
[implementation review](../release/sharing-portability-review-2026-10-08.md).
- Content restart durability and paired real-content isolated restore verified.
Durable backup custody/retention and operational rollback remain T02.

View file

@ -5,10 +5,11 @@ SMTP; application invitation/verification delivery uses the existing
email-connect service. ReichelAg protocol distribution remains UC-008's manual
clipboard/email workflow.
These stable IDs extend the discovery catalogue. They are not yet entries in
the executable product registry: owner-spanning browser coverage is still
required under T06. Personal recovery has user acceptance; the gap in its
automated end-to-end protection remains explicit.
These stable IDs are registered in `registry.json` with synthetic fixture
references, app-side fast checks and `established: false`. Owner-spanning browser
coverage is still required under T06. Personal recovery retains its recorded
user acceptance; it is not marked as an established automated journey until
its full fast/browser contract can be protected. UC-010–012 remain candidates.
| ID | Actor, goal and steps | Expected outcome / evidence | Status and owner |
| --- | --- | --- | --- |
@ -36,11 +37,11 @@ live sends are part of the gate.
| Registration handoff | `tests/test_shell.py::test_registration_link_uses_only_configured_destination` | UC-010 browser request/verify/cancel; preserve tenant/client binding through owner flow |
| Invitation auth/template/recipient/key/suppression | email-connect `tests/test_transactional.py`: `test_rejects_auth_recipient_template_and_key`, `test_suppression_blocks_send` | Separate caller identities, caller/template/tenant scope, request limits; no authority from payload `source` |
| Invitation retry / uncertain outcome | Owner tests `test_sends_fixed_template_once`, `test_resend_is_separate_event_and_sends_again`, `test_lost_post_send_record_never_resends_after_restart`, `test_definitive_failure_retries_but_changed_payload_is_rejected`, `test_disconnect_after_smtp_acceptance_never_releases_reservation` | Per-caller event namespace, bounded retry schedule and operator handling of unknown outcomes |
| Verification privacy and replay | Owner tests `test_registration_verification_is_digest_only_and_single_use`, `test_registration_cancellation_is_single_use_and_prevents_verification`, `test_mailbox_evidence_is_not_authorization` | Retry-safe challenge creation/submission; expired-handle and binding denials through browser; failure/redaction across every verification path |
| Verification privacy and replay | Owner tests `test_registration_verification_is_digest_only_and_single_use`, `test_registration_cancellation_is_single_use_and_prevents_verification`, `test_mailbox_evidence_is_not_authorization`, plus retry/restart/concurrency functions bound below | Expired-handle and binding denials through an isolated owner-spanning browser journey |
| Provider failure / diagnostic redaction | Owner tests `test_provider_timeout_temporary_and_permanent_are_redacted`, `test_status_authenticates_without_sending_and_redacts_errors` | No tokens, recipient/body or provider details in diagnostic projections; caller-scoped status; provider acceptance and inbox receipt shown separately |
| Reply/bounce operations | Offline scanner fixtures exist in email-connect | Named monitored inbox, authorized inbound access, correlated bounce → suppression, bounded retention and support escalation |
Fresh owner baseline: 27 transactional tests pass at source
Historical owner baseline (2026-10-07): 27 transactional tests pass at source
`73702b7e1a1671948b0545ef32d6e0de9cca9c65`, clean checkout.
This is local source evidence, not a deployed version or fresh inbox receipt.
Reproduction from this repository's installed Python environment:
@ -52,7 +53,28 @@ PYTHONDONTWRITEBYTECODE=1 PYTHONPATH=/home/worsch/email-connect/src \
--basetemp=/tmp/csoc-email-owner-tests
```
Before registering these as protected executable journeys, bind exact fast and
browser node IDs in their owning repositories, arrange the isolated cross-owner
harness, and link owner regression receipts here. `make check` currently
protects UC-008; it does not execute the issuer/email-connect suites.
## Reproducible owner regression gate — 2026-10-08
Run `make check-communication EMAIL_CONNECT_ROOT=/path/to/email-connect`.
`communication-regressions.json` binds 21 required test functions covering
authorization/template/recipient denial, suppression, provider failure and
redaction, invitation uncertainty and verification restart/concurrency safety.
The runner copies only Python source and that synthetic test module to a private
temporary tree. It denies socket connects and DNS, strips inherited credentials,
disables plugin autoload, and refuses removed or skipped/xfail contract coverage.
It leaves the owner checkout untouched. Gate tests in `make check` prove those
failure boundaries. No mailbox, SMTP, issuer or live account is contacted.
The redacted receipt at `var/test-results/communication.json` binds exact
executed source/test/contract hashes and records counts, missing tests and
outstanding acceptance. The current owner check passes **39 tests, zero skips**.
This closes the stale verification-retry baseline above: accepted and uncertain
outcomes now retain their reservations across retries/restarts; definitive
retryable refusals cancel the failed challenge before retry.
Before marking UC-009–012 as established protected journeys, bind the missing
browser node IDs in their owners, arrange the isolated issuer/mailbox/application
harness, and record owner admission. `make check` protects UC-008 and tests the
new app UI; `check-communication` is a separate explicitly selected owner gate.
Neither check closes independent caller/sender/limits, ledger backup admission,
inbox ownership, DNS alignment or productive per-flow user acceptance.

View file

@ -35,6 +35,93 @@
"Paste into the user's usual mail client and review presentation",
"Confirm live persistence and measured paired restore before transfer"
]
},
{
"id": "CSOC-UC-009",
"title": "Personal account recovery",
"established": false,
"confirmation": "user-confirmed",
"transfer_status": "accepted",
"narrative": "docs/usecases/platform-communication.md",
"fixture": "tests/usecases/fixtures/platform-communication-009.json",
"contract": [
"Request a new password reset, receive it privately and sign in to the workspace",
"Identity credentials and reset handles remain outside the application"
],
"tests": {
"fast": [
"tests/test_identity_regressions.py::test_consumed_callback_state_cannot_be_replayed",
"tests/test_identity_regressions.py::test_same_email_different_identity_cannot_take_over_member"
],
"browser": []
},
"owner_regression_contract": "docs/usecases/communication-regressions.json",
"manual_review": [
"Isolated owner-spanning browser coverage and admission remain required"
],
"acceptance_ref": "history/20261006T185411Z-personal-workspace-acceptance.md"
},
{
"id": "CSOC-UC-010",
"title": "Registration verification",
"established": false,
"confirmation": "inferred",
"transfer_status": "pending-user-review",
"narrative": "docs/usecases/platform-communication.md",
"fixture": "tests/usecases/fixtures/platform-communication-010.json",
"contract": [
"Single-use tenant/client-bound verification is retry-safe and conveys no authorization"
],
"tests": {
"fast": [
"tests/test_shell.py::test_registration_link_uses_only_configured_destination"
],
"browser": []
},
"owner_regression_contract": "docs/usecases/communication-regressions.json",
"manual_review": [
"Isolated owner-spanning browser coverage and admission remain required"
]
},
{
"id": "CSOC-UC-011",
"title": "Member invitation",
"established": false,
"confirmation": "inferred",
"transfer_status": "pending-user-review",
"narrative": "docs/usecases/platform-communication.md",
"fixture": "tests/usecases/fixtures/platform-communication-011.json",
"contract": [
"Fixed invitation template, one provider submission per event, suppression and safe retries"
],
"tests": {
"fast": [],
"browser": []
},
"owner_regression_contract": "docs/usecases/communication-regressions.json",
"manual_review": [
"Isolated owner-spanning browser coverage and admission remain required"
]
},
{
"id": "CSOC-UC-012",
"title": "Application notification and reply",
"established": false,
"confirmation": "inferred",
"transfer_status": "deferred",
"narrative": "docs/usecases/platform-communication.md",
"fixture": "tests/usecases/fixtures/platform-communication-012.json",
"contract": [
"Each application notification needs an admitted caller, template, recipient purpose and monitored reply route"
],
"tests": {
"fast": [],
"browser": []
},
"owner_regression_contract": "docs/usecases/communication-regressions.json",
"manual_review": [
"Isolated owner-spanning browser coverage and admission remain required"
]
}
]
}

View file

@ -0,0 +1,114 @@
#!/usr/bin/env python3
"""Run exact owner retry/privacy regressions on a private, network-denied snapshot.
Only source and one synthetic test module are copied. The owner's configuration,
environment, caches and runtime databases are neither read nor mutated.
"""
import argparse
import hashlib
import json
import os
import subprocess
import sys
import tempfile
import xml.etree.ElementTree as ET
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
CONTRACT = ROOT / "docs/usecases/communication-regressions.json"
GUARD = '''
import socket
import pytest
def forbidden(*args, **kwargs):
raise RuntimeError("Communication regressions forbid all network access")
def pytest_sessionstart(session):
socket.socket.connect = forbidden
socket.socket.connect_ex = forbidden
socket.getaddrinfo = forbidden
socket.create_connection = forbidden
def pytest_collection_modifyitems(items):
for item in items:
if any(item.get_closest_marker(m) for m in ("skip", "skipif", "xfail")):
raise pytest.UsageError("Communication contracts cannot skip or xfail")
'''
def check(owner, receipt):
contract = json.loads(CONTRACT.read_text(encoding="utf-8"))
if contract["schema_version"] != 1:
raise ValueError("Unsupported communication contract")
owner = owner.resolve(strict=True)
source = owner / "src/email_connect"
test = owner / contract["test_file"]
if not source.is_dir() or not test.is_file() or source.is_symlink() or test.is_symlink():
raise ValueError("Owner source and synthetic test module must be regular local files")
files = sorted(source.rglob("*.py")) + [test]
if any(p.is_symlink() for p in files):
raise ValueError("Owner Python sources must not be symlinks")
# Snapshot exact bytes first. Hash receipts bind the code actually executed,
# including an uncommitted source change, rather than assuming a HEAD pin.
hashes = {}
with tempfile.TemporaryDirectory(prefix="csoc-communication-") as private:
workspace = Path(private)
for path in files:
relative = path.relative_to(owner)
data = path.read_bytes()
hashes[str(relative)] = hashlib.sha256(data).hexdigest()
target = workspace / relative
target.parent.mkdir(parents=True, exist_ok=True)
target.write_bytes(data)
(workspace / "conftest.py").write_text(GUARD, encoding="utf-8")
(workspace / "pytest.ini").write_text("[pytest]\n", encoding="utf-8")
report = workspace / "result.xml"
result = subprocess.run(
[sys.executable, "-m", "pytest", "-q", "-c", str(workspace / "pytest.ini"),
str(workspace / contract["test_file"]), "-p", "no:cacheprovider",
"--junitxml", str(report), "--basetemp", str(workspace / "test-data")],
cwd=workspace, capture_output=True, timeout=120,
env={"PATH": os.defpath, "PYTHONPATH": str(workspace / "src"),
"PYTHONDONTWRITEBYTECODE": "1", "PYTEST_DISABLE_PLUGIN_AUTOLOAD": "1"},
)
cases = ET.parse(report).findall(".//testcase") if report.is_file() else []
covered = {case.attrib["name"].split("[", 1)[0] for case in cases}
missing = sorted(set(contract["required_tests"]) - covered)
disabled = sum(len(case.findall("skipped")) for case in cases)
failed = sum(len(case.findall("failure")) + len(case.findall("error")) for case in cases)
passed = result.returncode == 0 and bool(cases) and not (missing or disabled or failed)
evidence = {
"schema_version": 1, "owner": contract["owner"], "passed": passed,
"network": "all socket connects and DNS resolution denied",
"tests": len(cases), "skipped": disabled, "failed": failed,
"missing_required_tests": missing, "source_sha256": hashes,
"contract_sha256": hashlib.sha256(CONTRACT.read_bytes()).hexdigest(),
"acceptance_complete": False,
"remaining_acceptance": contract["remaining_acceptance"],
}
# Do not publish raw pytest output: reports contain no message bodies,
# handles, bearer values or provider diagnostics, including on failures.
receipt.parent.mkdir(parents=True, exist_ok=True)
receipt.write_text(json.dumps(evidence, indent=2) + "\n", encoding="utf-8")
return evidence
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--owner", type=Path, required=True)
parser.add_argument("--receipt", type=Path,
default=ROOT / "var/test-results/communication.json")
args = parser.parse_args()
try:
evidence = check(args.owner, args.receipt)
except (OSError, ValueError, KeyError, subprocess.TimeoutExpired, ET.ParseError):
print("Communication check could not produce a valid isolated receipt", file=sys.stderr)
return 1
print(json.dumps({key: evidence[key] for key in
("passed", "tests", "skipped", "failed", "missing_required_tests")}))
return 0 if evidence["passed"] else 1
if __name__ == "__main__":
raise SystemExit(main())

View file

@ -0,0 +1,71 @@
import assert from 'node:assert/strict';
import { chromium } from 'playwright';
const base = new URL(process.argv[2]);
if (base.protocol !== 'http:' || !['localhost', '127.0.0.1'].includes(base.hostname)) {
throw new Error('Sharing checks require an isolated loopback server');
}
const browser = await chromium.launch({headless: true});
try {
const context = await browser.newContext();
await context.route('**/*', route => new URL(route.request().url()).origin === base.origin
? route.continue() : route.abort());
const page = await context.newPage();
page.setDefaultTimeout(10000);
async function login(subject) {
await page.goto(`${base.origin}/auth/dev-login/`);
await page.getByLabel('Subject').fill(subject);
await page.getByLabel('Tenant').fill('tenant:sharing-test');
await page.getByRole('button', {name: 'Establish session'}).click();
await page.waitForURL('**/app/');
}
const detail = `${base.origin}/app/spaces/shared-browser/`;
await login('sharing-reader');
await page.goto(`${base.origin}/account/data/`);
const readerId = await page.locator('main code').first().innerText();
assert.match(readerId, /^[0-9a-f-]{36}$/);
await login('sharing-owner');
await page.goto(detail);
await page.getByRole('link', {name: 'Share space', exact: true}).click();
await page.getByLabel('Member ID').fill(readerId);
await page.getByLabel('Access', {exact: true}).selectOption('viewer');
await page.getByRole('button', {name: 'Update access'}).click();
await page.waitForURL('**/sharing/');
assert.match(await page.locator('main').innerText(), /Space access updated/);
await login('sharing-reader');
await page.goto(`${detail}?page=notes`);
assert.match(await page.locator('#page-content').innerText(), /Original notes/);
assert.equal(await page.getByRole('link', {name: 'Share space', exact: true}).count(), 0);
assert.equal(await page.getByRole('link', {name: 'Edit page', exact: true}).count(), 0);
await login('sharing-owner');
await page.goto(`${detail}pages/notes/edit/`);
const otherTab = await context.newPage();
await otherTab.goto(`${detail}pages/notes/edit/`);
await page.getByLabel('Body (markdown)').fill('First saved notes');
await page.getByRole('button', {name: 'Save page'}).click();
await page.waitForURL('**?page=notes');
await otherTab.getByLabel('Body (markdown)').fill('Unsaved second-tab notes');
await otherTab.getByRole('button', {name: 'Save page'}).click();
assert.match(await otherTab.locator('main').innerText(), /Reopen it and merge/);
assert.equal(await otherTab.getByLabel('Body (markdown)').inputValue(), 'Unsaved second-tab notes');
await otherTab.close();
await page.goto(`${detail}sharing/`);
await page.getByLabel('Member ID').fill(readerId);
await page.getByLabel('Access', {exact: true}).selectOption('owner');
await page.getByRole('button', {name: 'Update access'}).click();
await page.waitForURL('**/sharing/');
await page.goto(`${base.origin}/account/data/`);
await page.getByLabel('Type DELETE to remove your local account').fill('DELETE');
await page.getByRole('button', {name: 'Remove local account'}).click();
await page.waitForURL(base.origin + '/');
assert.equal(await page.locator('header').getByRole('link', {name: 'Sign in', exact: true}).count(), 1);
await login('sharing-reader');
await page.goto(`${detail}?page=notes`);
assert.match(await page.locator('#page-content').innerText(), /First saved notes/);
await page.goto(`${base.origin}/account/data/`);
assert.match(await page.locator('main').innerText(), /Before removal/);
assert.equal(await page.getByRole('button', {name: 'Remove local account'}).count(), 0);
console.log('Sharing, concurrent edits and retained-content exit passed');
} finally {
await browser.close();
}

View file

@ -0,0 +1,57 @@
"""Prove the cross-owner gate cannot send, hide skips or leak inherited credentials."""
import importlib.util
import json
from pathlib import Path
import pytest
@pytest.fixture
def gate(tmp_path, monkeypatch):
script = Path(__file__).resolve().parents[1] / "scripts/check-communication.py"
spec = importlib.util.spec_from_file_location("communication_gate", script)
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
contract = tmp_path / "contract.json"
contract.write_text(json.dumps({
"schema_version": 1, "owner": "synthetic-owner",
"test_file": "tests/test_contract.py", "required_tests": ["test_contract"],
"remaining_acceptance": ["User review remains separate"],
}))
monkeypatch.setattr(module, "CONTRACT", contract)
owner = tmp_path / "owner"
(owner / "src/email_connect").mkdir(parents=True)
(owner / "src/email_connect/__init__.py").write_text("")
(owner / "tests").mkdir()
return module, owner, tmp_path / "receipt.json"
def test_communication_gate_scrubs_credentials_and_does_not_modify_owner(gate, monkeypatch):
module, owner, receipt = gate
monkeypatch.setenv("SMTP_PASSWORD", "synthetic-inherited-secret")
test = owner / "tests/test_contract.py"
test.write_text(
"import os\ndef test_contract():\n assert 'SMTP_PASSWORD' not in os.environ\n"
)
before = sorted(p.relative_to(owner) for p in owner.rglob("*"))
evidence = module.check(owner, receipt)
assert evidence["passed"] and evidence["tests"] == 1
assert evidence["acceptance_complete"] is False
assert "synthetic-inherited-secret" not in receipt.read_text()
assert sorted(p.relative_to(owner) for p in owner.rglob("*")) == before
@pytest.mark.parametrize("body", [
("import socket\ndef test_contract():\n"
" socket.create_connection(('smtp.example.test', 25))\n"),
"import pytest\n@pytest.mark.skip\ndef test_contract():\n pass\n",
"def test_renamed():\n pass\n",
])
def test_communication_gate_refuses_network_disabled_or_missing_contract(gate, body):
module, owner, receipt = gate
(owner / "tests/test_contract.py").write_text(body)
evidence = module.check(owner, receipt)
assert evidence["passed"] is False
assert "smtp.example.test" not in receipt.read_text()
assert "RuntimeError" not in receipt.read_text()

View file

@ -0,0 +1,285 @@
"""Conflict/failure and attachment fidelity checks with synthetic local content."""
import subprocess
import sys
from concurrent.futures import ThreadPoolExecutor
from pathlib import Path
import pytest
from django.urls import reverse
from coulomb_social.apps.spaces import pageops
from coulomb_social.apps.spaces.services import create_space, save_space_page
from tests.test_space_permissions import make_member
@pytest.fixture
def trees(tmp_path, settings):
settings.CONTENT_ROOT = str(tmp_path / "content")
for slug in ("source", "destination"):
pageops.init_space_content(slug, title=slug)
return tmp_path / "content"
def test_simultaneous_edits_have_one_winner_and_durable_old_and_new_versions(trees):
before = pageops.write_page("source", "notes", title="Notes", body="Original")
original = Path(before.path).read_bytes()
def save(body):
try:
pageops.write_page("source", "notes", title="Notes", body=body,
expected_revision=before.revision)
return "saved"
except pageops.PageOpsError:
return "conflict"
with ThreadPoolExecutor(max_workers=2) as pool:
assert sorted(pool.map(save, ("First edit", "Second edit"))) == ["conflict", "saved"]
versions = [p.read_bytes() for p in (trees / ".revisions/source/notes").glob("*.md")]
assert original in versions
assert Path(before.path).read_bytes() in versions
assert len(versions) == 2
pageops.delete_page("source", "notes")
assert not Path(before.path).exists()
assert original in versions
def test_failed_atomic_publish_leaves_original_readable_and_history_retained(trees, monkeypatch):
doc = pageops.write_page("source", "notes", title="Notes", body="Original")
original = Path(doc.path).read_bytes()
replace = pageops.os.replace
def fail_page(source, destination):
if Path(destination) == Path(doc.path):
raise OSError("synthetic disk failure")
return replace(source, destination)
monkeypatch.setattr(pageops.os, "replace", fail_page)
with pytest.raises(OSError):
pageops.write_page("source", "notes", title="Notes", body="New")
assert Path(doc.path).read_bytes() == original
assert sorted(p.name for p in Path(doc.path).parent.iterdir()) == ["index.md", "notes.md"]
def test_empty_imported_page_has_a_real_revision_and_can_be_edited(trees):
pageops.page_file("source", "empty").write_bytes(b"")
source = pageops.read_page("source", "empty")
assert source.exists and source.revision
pageops.write_page("source", "empty", body="New notes", expected_revision=source.revision)
assert pageops.read_page("source", "empty").body == "New notes\n"
assert any(p.read_bytes() == b"" for p in (trees / ".revisions/source/empty").glob("*.md"))
@pytest.mark.parametrize("operation", ["copy", "move"])
def test_body_assets_reference_links_visual_and_nested_equal_names_survive(trees, operation):
assets = pageops.assets_dir("source")
(assets / "nested").mkdir()
(assets / "diagram.png").write_bytes(b"synthetic image one")
(assets / "nested/diagram.png").write_bytes(b"synthetic image two")
body = ('![Diagram](../assets/diagram.png)\n'
'![Nested][picture]\n\n[picture]: assets/nested/diagram.png\n'
'[File](assets/diagram.png#detail)\n')
source = pageops.write_page("source", "notes", title="Notes", body=body,
visual="assets/diagram.png")
original = Path(source.path).read_bytes()
if operation == "copy":
doc = pageops.copy_page("source", "notes", "destination", "new-notes")
assert Path(source.path).read_bytes() == original
else:
doc = pageops.transfer_page("source", "notes", "destination", "new-notes")
assert not Path(source.path).exists()
assert doc.visual == "assets/new-notes-diagram.png"
assert "![Diagram](assets/new-notes-diagram.png)" in doc.body
assert "[File](assets/new-notes-diagram.png#detail)" in doc.body
assert "assets/nested/" not in doc.body
assert sorted(p.read_bytes() for p in pageops.assets_dir("destination").iterdir()) == [
b"synthetic image one", b"synthetic image two",
]
def test_attachment_failure_rolls_back_partial_copy_and_does_not_move_source(trees):
(pageops.assets_dir("source") / "present.png").write_bytes(b"synthetic image")
source = pageops.write_page("source", "notes", title="Notes",
body="![One](assets/present.png)\n![Two](assets/missing.png)")
before = Path(source.path).read_bytes()
with pytest.raises(pageops.PageOpsError, match="attachment safely"):
pageops.transfer_page("source", "notes", "destination", "notes")
assert Path(source.path).read_bytes() == before
assert list(pageops.assets_dir("destination").iterdir()) == []
assert not pageops.read_page("destination", "notes").exists
@pytest.mark.django_db
def test_stale_rename_and_missing_revision_preserve_user_input_and_current_page(trees, client):
owner = make_member("owner")
space = create_space(owner, title="Conflict", slug="conflict")
before = pageops.write_page(space.slug, "notes", title="Notes", body="Original")
pageops.write_page(space.slug, "notes", title="Notes", body="Other writer")
client.force_login(owner.user)
url = reverse("spaces:page_edit", args=[space.slug, "notes"])
for revision in (None, before.revision):
fields = {"title": "Mine", "slug": "renamed", "body": "My unsaved notes"}
if revision is not None:
fields["revision"] = revision
response = client.post(url, fields)
assert response.status_code == 200
assert b"Reopen it and merge" in response.content
assert b"My unsaved notes" in response.content
assert pageops.read_page(space.slug, "notes").body == "Other writer\n"
assert not pageops.read_page(space.slug, "renamed").exists
current = pageops.read_page(space.slug, "notes")
save_space_page(owner, space, "notes", "renamed", expected_revision=current.revision,
title="Merged", body="Other writer plus my notes")
assert not pageops.read_page(space.slug, "notes").exists
@pytest.mark.django_db
def test_space_edit_conflict_does_not_overwrite_new_index_body_or_metadata(trees, client):
owner = make_member("owner")
space = create_space(owner, title="Conflict", slug="conflict")
before = pageops.read_page(space.slug, "index")
pageops.write_page(space.slug, "index", title="Conflict", body="Another writer's index",
extra_meta={"source_id": "synthetic-source"})
client.force_login(owner.user)
fields = {"title": "My title", "abstractor": "My abstract", "revision": before.revision}
url = reverse("spaces:edit", args=[space.slug])
response = client.post(url, fields)
assert b"space changed" in response.content and b"My title" in response.content
space.refresh_from_db()
assert space.title == "Conflict"
current = pageops.read_page(space.slug, "index")
assert current.body == "Another writer's index\n"
fields["revision"] = current.revision
assert client.post(url, fields).status_code == 302
assert pageops.read_page(space.slug, "index").meta["source_id"] == "synthetic-source"
def test_page_symlink_cannot_read_or_overwrite_another_space(trees):
target = pageops.page_file("destination", "index")
pageops.page_file("source", "linked").symlink_to(target)
before = target.read_bytes()
with pytest.raises(pageops.PageOpsError, match="symlink"):
pageops.read_page("source", "linked")
with pytest.raises(pageops.PageOpsError, match="symlink"):
pageops.write_page("source", "linked", body="Tampered")
assert target.read_bytes() == before
@pytest.mark.django_db
def test_incoming_page_addresses_survive_rename_move_and_enforce_every_grant(trees, client):
from coulomb_social.apps.spaces.services import set_space_access, transfer_space_page
owner, reader = make_member("owner"), make_member("reader")
source = create_space(owner, title="Shared", slug="shared")
target = create_space(owner, title="Private", slug="private")
set_space_access(owner, source, reader.pk, "viewer")
doc = pageops.write_page(source.slug, "notes", title="Notes", body="Retained content")
save_space_page(owner, source, "notes", "renamed", expected_revision=doc.revision,
title="Notes", body=doc.body)
client.force_login(reader.user)
old_url = reverse("spaces:detail", args=[source.slug]) + "?page=notes"
response = client.get(old_url, follow=True)
assert response.status_code == 200
assert b"Retained content" in response.content
transfer_space_page(owner, source, "renamed", target, dest_slug="moved")
assert client.get(old_url).status_code == 404
assert b"Private" not in client.get(old_url).content
set_space_access(owner, target, reader.pk, "viewer")
response = client.get(old_url, follow=True)
assert response.status_code == 200
assert b"Retained content" in response.content
assert response.redirect_chain == [
(reverse("spaces:detail", args=[target.slug]) + "?page=moved", 302),
]
with pytest.raises(pageops.PageOpsError, match="reserved"):
pageops.write_page(source.slug, "notes", body="Hijack previous address")
set_space_access(owner, source, reader.pk, "remove")
assert client.get(old_url).status_code == 404
def test_failed_destination_publish_rolls_back_assets_and_keeps_source(trees, monkeypatch):
assets = pageops.assets_dir("source")
(assets / "diagram.png").write_bytes(b"synthetic image")
source = pageops.write_page("source", "notes", body="![Diagram](assets/diagram.png)")
before = Path(source.path).read_bytes()
original = pageops.os.replace
def fail_target(old, new):
if Path(new) == pageops.page_file("destination", "notes"):
raise OSError("synthetic destination failure")
return original(old, new)
monkeypatch.setattr(pageops.os, "replace", fail_target)
with pytest.raises(OSError):
pageops.transfer_page("source", "notes", "destination", "notes")
assert Path(source.path).read_bytes() == before
assert not pageops.read_page("destination", "notes").exists
assert list(pageops.assets_dir("destination").iterdir()) == []
assert not pageops.page_aliases("source")
@pytest.mark.django_db
def test_destination_disk_failure_is_visible_without_losing_source(trees, client, monkeypatch):
owner = make_member("owner")
space = create_space(owner, title="Work", slug="work")
source = pageops.write_page(space.slug, "notes", title="Notes", body="Original notes")
original = pageops.os.replace
def fail_target(old, new):
if Path(new) == pageops.page_file(space.slug, "copy"):
raise OSError("synthetic private storage path must not be disclosed")
return original(old, new)
monkeypatch.setattr(pageops.os, "replace", fail_target)
client.force_login(owner.user)
response = client.post(reverse("spaces:page_copy", args=[space.slug, source.slug]), {
"dest_slug": "copy", "title": "Copy",
})
assert response.status_code == 200
assert b"could not be confirmed" in response.content
assert b"private storage path" not in response.content
assert pageops.read_page(space.slug, "notes").body == "Original notes\n"
assert not pageops.read_page(space.slug, "copy").exists
def test_separate_processes_share_the_writer_lock_and_reject_stale_revision(trees):
doc = pageops.write_page("source", "notes", title="Notes", body="Original")
code = '''
import sys
from django.conf import settings
settings.configure(CONTENT_ROOT=sys.argv[1])
from coulomb_social.apps.spaces import pageops
if sys.argv[3] == "hold":
with pageops.content_write_lock():
print("locked", flush=True)
sys.stdin.readline()
pageops.write_page("source", "notes", body="First process", expected_revision=sys.argv[2])
else:
print("attempting", flush=True)
try:
pageops.write_page("source", "notes", body="Second process", expected_revision=sys.argv[2])
except pageops.PageOpsError:
print("conflict", flush=True)
'''
children = []
try:
for mode in ("hold", "write"):
child = subprocess.Popen(
[sys.executable, "-c", code, str(trees), doc.revision, mode],
stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True,
)
children.append(child)
assert child.stdout.readline().strip() == ("locked" if mode == "hold" else "attempting")
with pytest.raises(subprocess.TimeoutExpired):
children[1].communicate(timeout=0.25)
_, errors = children[0].communicate(input="release\n", timeout=10)
assert children[0].returncode == 0, errors
output, errors = children[1].communicate(timeout=10)
assert children[1].returncode == 0, errors
assert output.strip() == "conflict"
assert pageops.read_page("source", "notes").body == "First process\n"
finally:
for child in children:
if child.poll() is None:
child.terminate()
child.communicate(timeout=10)

View file

@ -0,0 +1,170 @@
"""App-local sharing/exit with synthetic identities and retained shared work."""
import pytest
from django.core.exceptions import ValidationError
from django.test import Client
from django.urls import reverse
from coulomb_social.apps.members.models import Member, User
from coulomb_social.apps.members.portability import delete_local_member
from coulomb_social.apps.spaces import pageops
from coulomb_social.apps.spaces.models import Space, SpaceMembership
from coulomb_social.apps.spaces.services import create_space, set_space_access
from tests.test_space_permissions import make_member
pytestmark = pytest.mark.django_db
@pytest.fixture
def people(tmp_path, settings, client):
settings.CONTENT_ROOT = str(tmp_path / "content")
owner, reader, outsider = [make_member(s) for s in ("owner", "reader", "outsider")]
space = create_space(owner, title="Retained work", slug="retained-work")
client.force_login(owner.user)
return owner, reader, outsider, space
def test_owner_shares_reader_then_revokes_without_email(people, client):
owner, reader, _, space = people
url = reverse("spaces:sharing", kwargs={"slug": space.slug})
assert b"Share space" in client.get(reverse("spaces:detail", args=[space.slug])).content
assert client.post(url, {"member_id": str(reader.pk), "role": "viewer"}).status_code == 302
client.force_login(reader.user)
assert client.get(reverse("spaces:detail", args=[space.slug])).status_code == 200
assert client.get(url).status_code == 403
assert client.post(url, {"member_id": str(owner.pk), "role": "remove"}).status_code == 403
client.force_login(owner.user)
assert client.post(url, {"member_id": str(reader.pk), "role": "remove"}).status_code == 302
client.force_login(reader.user)
assert client.get(reverse("spaces:detail", args=[space.slug])).status_code == 404
@pytest.mark.parametrize("target", ["foreign", "missing", "malformed"])
def test_sharing_rejects_foreign_missing_and_malformed_ids(people, client, target):
_, _, _, space = people
ids = {"foreign": str(make_member("foreign", tenant="tenant:b").pk),
"missing": "00000000-0000-0000-0000-000000000000", "malformed": "bad-id"}
url = reverse("spaces:sharing", args=[space.slug])
response = client.post(url, {"member_id": ids[target], "role": "owner"})
assert response.status_code == 200
assert b"existing member in this tenant" in response.content
assert space.memberships.count() == 1
def test_creator_and_last_owner_cannot_be_revoked(people):
owner, reader, _, space = people
with pytest.raises(pageops.PageOpsError, match="creator"):
set_space_access(owner, space, owner.pk, "remove")
set_space_access(owner, space, reader.pk, "owner")
space.created_by = None
space.save()
set_space_access(reader, space, owner.pk, "remove")
with pytest.raises(pageops.PageOpsError, match="at least one owner"):
set_space_access(reader, space, reader.pk, "viewer")
def test_export_is_personal_tenant_scoped_and_never_exports_secrets(people, client):
owner, reader, _, space = people
reader.email = "other-person@example.test"
reader.save()
set_space_access(owner, space, reader.pk, "viewer")
foreign = Space.objects.create(tenant_id="tenant:b", slug="foreign", title="Foreign")
SpaceMembership.objects.create(space=foreign, member=owner, role="viewer")
session = client.session
session["access_token"] = "synthetic-secret"
session.save()
response = client.get(reverse("core:account_export"))
assert response.status_code == 200
assert response["Cache-Control"] == "private, no-store"
assert response["Content-Disposition"].startswith("attachment;")
data = response.json()
assert data["member"]["id"] == str(owner.pk)
assert [g["space_slug"] for g in data["memberships"]] == [space.slug]
for private in (b"synthetic-secret", b"other-person@example.test", b"password", b"Foreign"):
assert private not in response.content
client.force_login(reader.user)
assert client.get(reverse("core:account_export")).json()["member"]["id"] == str(reader.pk)
@pytest.mark.parametrize("archived", [False, True])
def test_exit_blocks_last_owner_and_retains_content(people, client, archived):
owner, _, _, space = people
space.is_active = not archived
space.save()
before = pageops.page_file(space.slug, "index").read_bytes()
url = reverse("core:account_data")
assert b"Before removal" in client.get(url).content
assert client.post(url, {"confirmation": "DELETE"}).status_code == 200
assert Member.objects.filter(pk=owner.pk).exists()
assert pageops.page_file(space.slug, "index").read_bytes() == before
def test_exit_with_successor_removes_local_grants_and_all_old_sessions(people, client):
owner, reader, _, space = people
set_space_access(owner, space, reader.pk, "owner")
other_session = Client()
other_session.force_login(owner.user)
before = pageops.page_file(space.slug, "index").read_bytes()
url = reverse("core:account_data")
assert client.post(url, {"confirmation": "wrong"}).status_code == 200
response = client.post(url, {"confirmation": "DELETE"})
assert response.status_code == 302
assert not Member.objects.filter(pk=owner.pk).exists()
assert not User.objects.filter(pk=owner.user_id).exists()
space.refresh_from_db()
assert space.created_by_id is None
assert pageops.page_file(space.slug, "index").read_bytes() == before
assert other_session.get(reverse("core:app_home")).status_code == 302
client.force_login(reader.user)
assert client.get(reverse("spaces:detail", args=[space.slug])).status_code == 200
def test_inactive_successor_cannot_release_owner_responsibility(people):
owner, reader, _, space = people
set_space_access(owner, space, reader.pk, "owner")
reader.user.is_active = False
reader.user.save()
with pytest.raises(pageops.PageOpsError, match="another active member"):
delete_local_member(owner)
def test_account_and_sharing_posts_require_csrf(people):
owner, reader, _, space = people
guarded = Client(enforce_csrf_checks=True)
guarded.force_login(owner.user)
assert guarded.post(reverse("core:account_data"), {"confirmation": "DELETE"}).status_code == 403
assert guarded.post(reverse("spaces:sharing", args=[space.slug]), {
"member_id": str(reader.pk), "role": "owner",
}).status_code == 403
def test_admin_validation_rejects_cross_tenant_grants_and_colliding_storage(people):
_, _, _, space = people
foreign = make_member("foreign", tenant="tenant:b")
with pytest.raises(ValidationError, match="another tenant"):
SpaceMembership(space=space, member=foreign).full_clean()
with pytest.raises(ValidationError, match="already uses that slug"):
Space(tenant_id=foreign.tenant_id, slug=space.slug, title="Collision").full_clean()
def test_corrupt_foreign_creator_is_blocked_without_disclosing_other_tenant(people, client):
owner, _, _, _ = people
Space.objects.create(tenant_id="tenant:b", slug="foreign", title="Foreign private title",
created_by=owner)
response = client.get(reverse("core:account_data"))
assert b"operator review" in response.content
assert b"Foreign private title" not in response.content
response = client.post(reverse("core:account_data"), {"confirmation": "DELETE"})
assert b"Foreign private title" not in response.content
assert Member.objects.filter(pk=owner.pk).exists()
def test_legacy_ambiguous_storage_denies_pages_lists_and_writes(people, client):
owner, _, _, space = people
Space.objects.create(tenant_id="tenant:b", slug=space.slug, title="Collision")
assert client.get(reverse("spaces:detail", args=[space.slug])).status_code == 404
assert client.get(reverse("spaces:page_edit", args=[space.slug, "index"])).status_code == 404
assert space.title.encode() not in client.get(reverse("core:app_home")).content
with pytest.raises(pageops.PageOpsError, match="read-only"):
from coulomb_social.apps.spaces.services import put_space_page
put_space_page(owner, space, "notes", title="Attempt")

View file

@ -37,7 +37,8 @@ def test_full_protocol_duplicate_edit_and_clipboard_control(tmp_path, settings,
edited = scenario["next_body"]
response = client.post(reverse("spaces:page_edit", kwargs={
"slug": space.slug, "page_slug": "next-call",
}), {"title": "Next call", "slug": "next-call", "body": edited})
}), {"title": "Next call", "slug": "next-call", "body": edited,
"revision": pageops.read_page(space.slug, "next-call").revision})
assert response.status_code == 302
assert pageops.read_page(space.slug, "previous-call").body == body
response = client.get(reverse("spaces:detail", kwargs={"slug": space.slug}) + "?page=next-call")
@ -99,6 +100,7 @@ def test_protocol_naming_rename_and_collision_safety(tmp_path, settings, client)
"slug": space.slug, "page_slug": duplicate.slug,
})
fields = {"title": "Renamed call", "slug": source.slug,
"revision": duplicate.revision,
"abstractor": duplicate.abstractor, "visual": duplicate.visual,
"body": duplicate.body}
response = client.post(edit_url, fields)
@ -119,7 +121,7 @@ def test_protocol_naming_rename_and_collision_safety(tmp_path, settings, client)
assert renamed.meta["source_modified_at"] == "12345"
assert Path(source.path).read_bytes() == source_bytes
# Changing title alone preserves the filename and imported source metadata.
fields.update(title="Corrected title", slug="renamed-call")
fields.update(title="Corrected title", slug="renamed-call", revision=renamed.revision)
response = client.post(reverse("spaces:page_edit", kwargs={
"slug": space.slug, "page_slug": "renamed-call",
}), fields)
@ -137,13 +139,15 @@ def test_protocol_naming_rename_and_collision_safety(tmp_path, settings, client)
assert Path(renamed.path).read_bytes() == protected
# Renaming to index is refused; changing an index title cannot move its file.
fields["slug"] = "index"
fields["revision"] = renamed.revision
response = client.post(reverse("spaces:page_edit", kwargs={
"slug": space.slug, "page_slug": renamed.slug,
}), fields)
assert response.status_code == 200 and b"already exists" in response.content
response = client.post(reverse("spaces:page_edit", kwargs={
"slug": space.slug, "page_slug": "index",
}), {"title": "Index title", "slug": "move-index", "body": "Index notes\n"})
}), {"title": "Index title", "slug": "move-index", "body": "Index notes\n",
"revision": pageops.read_page(space.slug, "index").revision})
assert response.status_code == 302
assert pageops.read_page(space.slug, "index").title == "Index title"
assert not pageops.read_page(space.slug, "move-index").exists

View file

@ -71,7 +71,8 @@ def test_edit_space_updates_index_frontmatter(client, settings, content_root):
)
r = client.post(
reverse("spaces:edit", kwargs={"slug": "lab"}),
{"title": "Lab 2", "abstractor": "New abstract", "visual": "assets/v.jpg"},
{"title": "Lab 2", "abstractor": "New abstract", "visual": "assets/v.jpg",
"revision": pageops.read_page("lab", "index").revision},
)
assert r.status_code == 302
space = Space.objects.get(slug="lab")
@ -146,6 +147,7 @@ def test_page_crud_copy_transfer(client, settings, content_root):
reverse("spaces:page_edit", kwargs={"slug": "alpha", "page_slug": "notes"}),
{
"title": "Notes v2",
"revision": pageops.read_page("alpha", "notes").revision,
"slug": "notes",
"abstractor": "Updated",
"visual": "assets/n.jpg",

View file

@ -0,0 +1,8 @@
{
"synthetic": true,
"usecase_id": "CSOC-UC-009",
"tenant": "tenant:communication-test",
"recipient": "member@example.test",
"transport": "injected provider; no live sends",
"identity_authority": "isolated owner fixture"
}

View file

@ -0,0 +1,8 @@
{
"synthetic": true,
"usecase_id": "CSOC-UC-010",
"tenant": "tenant:communication-test",
"recipient": "member@example.test",
"transport": "injected provider; no live sends",
"identity_authority": "isolated owner fixture"
}

View file

@ -0,0 +1,8 @@
{
"synthetic": true,
"usecase_id": "CSOC-UC-011",
"tenant": "tenant:communication-test",
"recipient": "member@example.test",
"transport": "injected provider; no live sends",
"identity_authority": "isolated owner fixture"
}

View file

@ -0,0 +1,8 @@
{
"synthetic": true,
"usecase_id": "CSOC-UC-012",
"tenant": "tenant:communication-test",
"recipient": "member@example.test",
"transport": "injected provider; no live sends",
"identity_authority": "isolated owner fixture"
}

View file

@ -0,0 +1,35 @@
"""Candidate sharing/portability journey; synthetic app-only browser checks."""
import subprocess
from pathlib import Path
import pytest
from coulomb_social.apps.spaces.services import create_space, put_space_page
from tests.test_space_permissions import make_member
@pytest.mark.browser
@pytest.mark.django_db(transaction=True)
def test_sharing_conflict_and_account_exit_through_ui(tmp_path, settings, request):
settings.DEBUG = True
settings.OIDC_ENABLED = False
settings.USER_ENGINE_BASE_URL = ""
settings.FLEX_AUTH_BASE_URL = ""
settings.FORGEJO_BASE_URL = ""
settings.CONTENT_ROOT = str(tmp_path / "content")
owner = make_member("sharing-owner", tenant="tenant:sharing-test")
reader = make_member("sharing-reader", tenant="tenant:sharing-test")
# Dev login uses this same exact issuer/subject association.
for member in (owner, reader):
member.issuer = "https://local.dev/issuer"
member.save()
space = create_space(owner, title="Synthetic shared work", slug="shared-browser")
put_space_page(owner, space, "notes", title="Notes", body="Original notes")
server = request.getfixturevalue("live_server")
root = Path(__file__).resolve().parents[2]
result = subprocess.run(
["node", str(root / "scripts/test-sharing-browser.mjs"), str(server)],
cwd=root, capture_output=True, text=True, timeout=120,
)
assert result.returncode == 0, result.stdout + result.stderr

View file

@ -8,7 +8,7 @@ status: active
owner: codex
topic_slug: coulomb-social
created: "2026-10-06"
updated: "2026-10-07"
updated: "2026-10-08"
origin: residual
origin_ref: CSOC-WP-0007
depends_on:
@ -639,3 +639,40 @@ upgrade20.58seconds, app1/1. Read-only live list preview/image bytes, outsider
exclusion, owner grant and all75stored hashes verified. Health/static200,
clipboard unchanged. Snapshot-helper pin/7tests/server dry-run pass. T09 awaits
founder list-preview/full workflow review; other T02/T04/T05 requirements stay open.
## Sharing, portability and content revision increment — 2026-10-08
T04 local implementation now provides owner-only Share space grants/revocation
by exact same-tenant member ID, with viewer/member/owner roles, creator/last-owner
protection and immediate subsequent-read denial. Your app data exports only the
requesting member's local records; explicit DELETE removes local Member/User and
grants, invalidates old sessions and retains shared content with another active
same-tenant owner. Archived spaces still require a successor. Platform identity
is separate. Corrupt foreign ownership blocks removal without disclosing names.
Lists/pages/service writes now deny duplicate slug-keyed storage, and serialized
app/import/admin writes plus admin validation reject ordinary collisions and
cross-tenant grants. Production tenancy/privacy and privileged maintenance
reconciliation remain T04; no database-wide global constraint is claimed.
T05 local implementation adds immutable content-addressed previous/new versions,
atomic fsync-backed publication, stale page/space form rejection with retained
input, and cooperating thread/process writer locks. Blank imported pages remain
editable; original source metadata survives renaming/index edits. Visual and
Markdown body-linked attachments copy with rewritten destinations and failure
cleanup; old incoming page addresses survive rename/move with source/destination
permission checks and reserved-address reuse denial. Replacing an imported tree
with retained addresses now requires reconciliation. Existing archives are never
overwritten silently. Raw HTML/custom Markdown, actual corpus fidelity,
revision retention/capacity acceptance and crash/operational recovery remain open.
Full make check: **182 passed**, Ruff clean, mandatory UC-008 and new sharing/
concurrent-edit/account-exit Chromium journeys pass. New failure, parallel
thread/process, tenant, ownership, CSRF, blank-page, attachment and incoming-link
checks pass. Django system check passes; no schema changes detected. Review:
docs/release/sharing-portability-review-2026-10-08.md. Source implementation
choice: decision9ffb5125-5551-4101-9025-5b6f72f40940. This is not acceptance of a
durable retention policy or authority for deployment/cutover. No private corpus,
member profile, live image, offsite archive or timer changed. T01/T02/T03/T04/T05/
T06/T08/T09 remain live for their existing admission, deployment, reconciliation
and productive user acceptance gates; this workplan remains active.

View file

@ -186,3 +186,32 @@ end-to-end journeys: T06 remains open until isolated issuer/mailbox/app browser
coverage and owner regression bindings are delivered. UC-008 stays protected by
make check; protocol distribution remains manual. WP-0008-T07 lint debt closed;
full application gate98passed. No Bubble/apex cutover or new notification send.
## Executable communication regression increment — 2026-10-08
T06 registers UC-009–012 with synthetic fixture references and explicit
established:false/browser gaps. UC-009 retains its existing personal recovery
acceptance; the remaining flows are candidates. The UC-008 protected registry
and manual email distribution remain intact. communication-regressions.json
binds21 exact owner functions; make check-communication snapshots Python source
and the synthetic transactional test into a private temporary tree, denies all
socket connects/DNS, strips inherited credentials, disables plugin autoload,
refuses skipped/xfail or missing contract tests and produces redacted source/
test/contract-hash receipts. The owner checkout is not changed.
Actual current owner gate: **39 passed, zero skips**, including verification
restart/uncertain/concurrent retry safety. Local runner failure tests prove
network denial, environment isolation and missing/disabled-coverage refusal.
Application gate: **182 passed**, Ruff and both real Chromium journeys pass.
Review: docs/release/sharing-portability-review-2026-10-08.md; public owner receipt:
docs/release/communication-regressions-2026-10-08.json. This closes stale baseline
reporting, not owner admission or full end-to-end automated protection.
T04/T05/T06 remain progress: independent caller/template/tenant admission, limits,
caller-scoped status, platform sender and independent ledger backup custody,
inbound/reply/bounce ownership, alignment/retention and isolated issuer/mailbox/app
browser journeys still need owner returns. EMAIL-WP-0007-T03/T04 and
RPF-WP-0053-T05 remain the live ledger custody/activation handoffs. No external
owner source/runtime, mailbox access, caller admission or email send changed;
no productive new communication flow was accepted or this workplan finished.