diff --git a/Dockerfile b/Dockerfile index 3442ab4..5885faa 100644 --- a/Dockerfile +++ b/Dockerfile @@ -36,12 +36,14 @@ WORKDIR /app COPY --from=deps /app/.venv /app/.venv COPY manage.py pyproject.toml ./ COPY coulomb_social ./coulomb_social +COPY scripts/docker-entrypoint.sh /app/docker-entrypoint.sh -RUN chown -R appuser:appuser /app +RUN chmod +x /app/docker-entrypoint.sh \ + && chown -R appuser:appuser /app USER appuser EXPOSE 8000 HEALTHCHECK --interval=30s --timeout=5s --start-period=20s \ CMD python -c "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8000/healthz')" || exit 1 -CMD ["gunicorn", "coulomb_social.wsgi:application", "--bind", "0.0.0.0:8000", "--workers", "2"] +ENTRYPOINT ["/app/docker-entrypoint.sh"] diff --git a/INTENT.md b/INTENT.md index f9f582e..bbef96c 100644 --- a/INTENT.md +++ b/INTENT.md @@ -74,6 +74,7 @@ The rebuild is deliberately **product-faithful first**: UI and content parity ma - `workplans/CSOC-WP-0002-netkingdom-user-management-reestablish.md` — **done**: identity shell on app.coulomb.social - `workplans/CSOC-WP-0004-app-shell-and-space-content.md` — **current path**: leave login shell; spaces + Forgejo markdown - `docs/adr/ADR-0001-netkingdom-identity.md` — accepted identity decision +- `docs/adr/ADR-0002-space-content-forgejo-markdown.md` — space content as markdown in Forgejo - `workplans/CSOC-WP-0001-bubble-io-exit-assessment.md` — Bubble inventory/migration **after** product foundation - `workplans/CSOC-WP-0003-self-registration-and-assurance.md` — public registration when NetKingdom mail path lands - `the-custodian/docs/coulomb-social-rebuild-seed.md` — original workplan seed (CUST-WP-0058-T08) diff --git a/SCOPE.md b/SCOPE.md index 38a0cf4..6d8c3ac 100644 --- a/SCOPE.md +++ b/SCOPE.md @@ -68,7 +68,8 @@ coulomb.social connects people around shared projects and complementary capabili - Status: **parallel hosts** — identity live; product content under construction - **`https://app.coulomb.social`**: Railiance rebuild (OIDC, shell); spaces/content next - **`https://coulomb.social`**: Bubble.io full product until cutover decision -- Active path: **`CSOC-WP-0004`** (app shell + Forgejo markdown spaces) +- Active path: **`CSOC-WP-0004`** (spaces metadata + Forgejo markdown content) +- Content ADR: `docs/adr/ADR-0002-space-content-forgejo-markdown.md` - Deferred: bulk Bubble migration (`CSOC-WP-0001`); public self-registration (`CSOC-WP-0003` / NK) - Prior art: design extract; CSOC-WP-0002 identity acceptance (2026-08-10) diff --git a/coulomb_social/apps/core/views.py b/coulomb_social/apps/core/views.py index 0cab190..20b9d08 100644 --- a/coulomb_social/apps/core/views.py +++ b/coulomb_social/apps/core/views.py @@ -2,6 +2,8 @@ from django.contrib.auth.decorators import login_required from django.http import HttpRequest, HttpResponse, HttpResponseForbidden, JsonResponse from django.shortcuts import redirect, render +from coulomb_social.apps.spaces.services import spaces_for_member + from .principal import build_principal @@ -19,8 +21,8 @@ def app_home(request: HttpRequest) -> HttpResponse: f"Not authorized to view shell ({principal['authz_reason']})." ) - # Spaces list is empty until CSOC-WP-0004-T02; keep the product empty-state. - spaces: list[dict] = [] + member = principal.get("member") + spaces = list(spaces_for_member(member)) return render( request, "core/app_home.html", diff --git a/coulomb_social/apps/spaces/__init__.py b/coulomb_social/apps/spaces/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/coulomb_social/apps/spaces/admin.py b/coulomb_social/apps/spaces/admin.py new file mode 100644 index 0000000..1f62acd --- /dev/null +++ b/coulomb_social/apps/spaces/admin.py @@ -0,0 +1,34 @@ +from django.contrib import admin + +from .models import Space, SpaceMembership + + +class SpaceMembershipInline(admin.TabularInline): + model = SpaceMembership + extra = 0 + raw_id_fields = ("member",) + + +@admin.register(Space) +class SpaceAdmin(admin.ModelAdmin): + list_display = ( + "title", + "slug", + "tenant_id", + "is_active", + "forgejo_owner", + "forgejo_repo", + "updated_at", + ) + list_filter = ("tenant_id", "is_active") + search_fields = ("title", "slug", "forgejo_repo") + prepopulated_fields = {"slug": ("title",)} + raw_id_fields = ("created_by",) + inlines = [SpaceMembershipInline] + + +@admin.register(SpaceMembership) +class SpaceMembershipAdmin(admin.ModelAdmin): + list_display = ("space", "member", "role", "created_at") + list_filter = ("role",) + raw_id_fields = ("space", "member") diff --git a/coulomb_social/apps/spaces/apps.py b/coulomb_social/apps/spaces/apps.py new file mode 100644 index 0000000..cb6cdfe --- /dev/null +++ b/coulomb_social/apps/spaces/apps.py @@ -0,0 +1,7 @@ +from django.apps import AppConfig + + +class SpacesConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "coulomb_social.apps.spaces" + label = "spaces" diff --git a/coulomb_social/apps/spaces/migrations/0001_initial.py b/coulomb_social/apps/spaces/migrations/0001_initial.py new file mode 100644 index 0000000..e06e67f --- /dev/null +++ b/coulomb_social/apps/spaces/migrations/0001_initial.py @@ -0,0 +1,150 @@ +# Generated by Django 6.1 on 2026-08-11 23:29 + +import coulomb_social.apps.spaces.models +import django.db.models.deletion +import uuid +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ("members", "0001_initial"), + ] + + operations = [ + migrations.CreateModel( + name="Space", + fields=[ + ( + "id", + models.UUIDField( + default=uuid.uuid4, + editable=False, + primary_key=True, + serialize=False, + ), + ), + ("tenant_id", models.CharField(db_index=True, max_length=64)), + ( + "slug", + models.SlugField( + max_length=80, + validators=[ + coulomb_social.apps.spaces.models.validate_space_slug + ], + ), + ), + ("title", models.CharField(max_length=255)), + ("description", models.TextField(blank=True)), + ( + "forgejo_owner", + models.CharField( + blank=True, + help_text="Forgejo org or user owning the repo (e.g. coulomb).", + max_length=128, + ), + ), + ( + "forgejo_repo", + models.CharField( + blank=True, + help_text="Repository name (e.g. space-my-space).", + max_length=128, + ), + ), + ( + "default_branch", + models.CharField(blank=True, default="main", max_length=128), + ), + ( + "content_root", + models.CharField( + blank=True, + default="pages", + help_text="Path prefix inside the repo for markdown pages.", + max_length=255, + ), + ), + ("is_active", models.BooleanField(default=True)), + ("created_at", models.DateTimeField(auto_now_add=True)), + ("updated_at", models.DateTimeField(auto_now=True)), + ( + "created_by", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.PROTECT, + related_name="spaces_created", + to="members.member", + ), + ), + ], + options={ + "ordering": ["title", "slug"], + }, + ), + migrations.CreateModel( + name="SpaceMembership", + fields=[ + ( + "id", + models.UUIDField( + default=uuid.uuid4, + editable=False, + primary_key=True, + serialize=False, + ), + ), + ( + "role", + models.CharField( + choices=[ + ("owner", "Owner"), + ("member", "Member"), + ("viewer", "Viewer"), + ], + default="member", + max_length=16, + ), + ), + ("created_at", models.DateTimeField(auto_now_add=True)), + ( + "member", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="space_memberships", + to="members.member", + ), + ), + ( + "space", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="memberships", + to="spaces.space", + ), + ), + ], + ), + migrations.AddIndex( + model_name="space", + index=models.Index( + fields=["tenant_id", "is_active"], name="spaces_spac_tenant__c3a447_idx" + ), + ), + migrations.AddConstraint( + model_name="space", + constraint=models.UniqueConstraint( + fields=("tenant_id", "slug"), name="spaces_space_tenant_slug_uniq" + ), + ), + migrations.AddConstraint( + model_name="spacemembership", + constraint=models.UniqueConstraint( + fields=("space", "member"), name="spaces_membership_space_member_uniq" + ), + ), + ] diff --git a/coulomb_social/apps/spaces/migrations/__init__.py b/coulomb_social/apps/spaces/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/coulomb_social/apps/spaces/models.py b/coulomb_social/apps/spaces/models.py new file mode 100644 index 0000000..853b638 --- /dev/null +++ b/coulomb_social/apps/spaces/models.py @@ -0,0 +1,115 @@ +"""Space metadata — content bodies live in Forgejo (markdown), not here.""" + +from __future__ import annotations + +import re +import uuid + +from django.core.exceptions import ValidationError +from django.db import models +from django.utils.text import slugify + + +_SLUG_RE = re.compile(r"^[a-z0-9]+(?:-[a-z0-9]+)*$") + + +def validate_space_slug(value: str) -> None: + if not _SLUG_RE.match(value or ""): + raise ValidationError( + "Slug must be lowercase alphanumeric with single hyphens (e.g. my-space)." + ) + + +class Space(models.Model): + """Tenant-scoped co-creation space. + + Postgres holds metadata and Forgejo binding pointers only. Long-form pages + are markdown in the bound repository (ADR-0002). + """ + + id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) + tenant_id = models.CharField(max_length=64, db_index=True) + slug = models.SlugField(max_length=80, validators=[validate_space_slug]) + title = models.CharField(max_length=255) + description = models.TextField(blank=True) + created_by = models.ForeignKey( + "members.Member", + on_delete=models.PROTECT, + related_name="spaces_created", + null=True, + blank=True, + ) + # Optional content binding (Forgejo) — empty until T04 wires the read path + forgejo_owner = models.CharField( + max_length=128, + blank=True, + help_text="Forgejo org or user owning the repo (e.g. coulomb).", + ) + forgejo_repo = models.CharField( + max_length=128, + blank=True, + help_text="Repository name (e.g. space-my-space).", + ) + default_branch = models.CharField(max_length=128, blank=True, default="main") + content_root = models.CharField( + max_length=255, + blank=True, + default="pages", + help_text="Path prefix inside the repo for markdown pages.", + ) + is_active = models.BooleanField(default=True) + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + class Meta: + constraints = [ + models.UniqueConstraint( + fields=["tenant_id", "slug"], + name="spaces_space_tenant_slug_uniq", + ), + ] + indexes = [ + models.Index(fields=["tenant_id", "is_active"]), + ] + ordering = ["title", "slug"] + + def __str__(self) -> str: + return f"{self.title} ({self.slug})@{self.tenant_id}" + + @property + def has_content_binding(self) -> bool: + return bool(self.forgejo_owner and self.forgejo_repo) + + @classmethod + def suggest_slug(cls, title: str) -> str: + return slugify(title)[:80] or "space" + + +class SpaceMembership(models.Model): + """Optional explicit membership; v1 list also allows tenant-wide visibility.""" + + class Role(models.TextChoices): + OWNER = "owner", "Owner" + MEMBER = "member", "Member" + VIEWER = "viewer", "Viewer" + + id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) + space = models.ForeignKey(Space, on_delete=models.CASCADE, related_name="memberships") + member = models.ForeignKey( + "members.Member", + on_delete=models.CASCADE, + related_name="space_memberships", + ) + role = models.CharField(max_length=16, choices=Role.choices, default=Role.MEMBER) + created_at = models.DateTimeField(auto_now_add=True) + + class Meta: + constraints = [ + models.UniqueConstraint( + fields=["space", "member"], + name="spaces_membership_space_member_uniq", + ), + ] + + def __str__(self) -> str: + return f"{self.member_id}@{self.space.slug}:{self.role}" diff --git a/coulomb_social/apps/spaces/services.py b/coulomb_social/apps/spaces/services.py new file mode 100644 index 0000000..7eb2503 --- /dev/null +++ b/coulomb_social/apps/spaces/services.py @@ -0,0 +1,39 @@ +"""Space queries scoped by tenant membership.""" + +from __future__ import annotations + +from django.db.models import Q, QuerySet + +from coulomb_social.apps.members.models import Member + +from .models import Space, SpaceMembership + + +def spaces_for_member(member: Member | None) -> QuerySet[Space]: + """Spaces visible to this member (same tenant, active). + + Explicit SpaceMembership grants access; if none exist for the tenant yet, + all active tenant spaces are listed (operator-seeded MVP). Once any + memberships exist for the tenant, only membership + created_by apply. + """ + if member is None: + return Space.objects.none() + + qs = Space.objects.filter(tenant_id=member.tenant_id, is_active=True) + tenant_has_memberships = SpaceMembership.objects.filter( + space__tenant_id=member.tenant_id + ).exists() + if not tenant_has_memberships: + return qs.order_by("title", "slug") + + return ( + qs.filter(Q(memberships__member=member) | Q(created_by=member)) + .distinct() + .order_by("title", "slug") + ) + + +def get_space_for_member(member: Member | None, slug: str) -> Space | None: + if member is None: + return None + return spaces_for_member(member).filter(slug=slug).first() diff --git a/coulomb_social/apps/spaces/tests.py b/coulomb_social/apps/spaces/tests.py new file mode 100644 index 0000000..7ce503c --- /dev/null +++ b/coulomb_social/apps/spaces/tests.py @@ -0,0 +1,3 @@ +from django.test import TestCase + +# Create your tests here. diff --git a/coulomb_social/apps/spaces/urls.py b/coulomb_social/apps/spaces/urls.py new file mode 100644 index 0000000..3b4e84a --- /dev/null +++ b/coulomb_social/apps/spaces/urls.py @@ -0,0 +1,9 @@ +from django.urls import path + +from . import views + +app_name = "spaces" + +urlpatterns = [ + path("/", views.space_detail, name="detail"), +] diff --git a/coulomb_social/apps/spaces/views.py b/coulomb_social/apps/spaces/views.py new file mode 100644 index 0000000..2d5a6f9 --- /dev/null +++ b/coulomb_social/apps/spaces/views.py @@ -0,0 +1,34 @@ +from django.contrib.auth.decorators import login_required +from django.http import HttpRequest, HttpResponse, HttpResponseForbidden, HttpResponseNotFound +from django.shortcuts import render + +from coulomb_social.apps.core.principal import build_principal + +from .services import get_space_for_member, spaces_for_member + + +@login_required +def space_detail(request: HttpRequest, slug: str) -> HttpResponse: + principal = build_principal(request, authz_resource_id="space_detail") + if not principal["authz_allow"]: + return HttpResponseForbidden( + f"Not authorized to view spaces ({principal['authz_reason']})." + ) + member = principal.get("member") + space = get_space_for_member(member, slug) + if space is None: + return HttpResponseNotFound("Space not found.") + return render( + request, + "spaces/detail.html", + { + "principal": principal, + "display_name": principal["display_name"], + "space": space, + }, + ) + + +# used by core.app_home — re-export list helper +def list_spaces_context(member) -> dict: + return {"spaces": list(spaces_for_member(member))} diff --git a/coulomb_social/settings/base.py b/coulomb_social/settings/base.py index 1ba7db1..951758a 100644 --- a/coulomb_social/settings/base.py +++ b/coulomb_social/settings/base.py @@ -21,6 +21,7 @@ INSTALLED_APPS = [ "coulomb_social.apps.core", "coulomb_social.apps.members", "coulomb_social.apps.identity", + "coulomb_social.apps.spaces", ] MIDDLEWARE = [ diff --git a/coulomb_social/templates/core/app_home.html b/coulomb_social/templates/core/app_home.html index 6a1a660..4fd4794 100644 --- a/coulomb_social/templates/core/app_home.html +++ b/coulomb_social/templates/core/app_home.html @@ -4,15 +4,20 @@

Spaces

Co-creation spaces for your tenant. - Content will live as markdown in Forgejo-backed repositories. + Page content will live as markdown in Forgejo-backed repositories.

{% if spaces %} @@ -20,8 +25,8 @@

No spaces yet

- Spaces and Forgejo-backed content land in the next steps - (CSOC-WP-0004-T02+). Use the profile menu for session diagnostics. + Operators can seed a space via Django admin (tenant-matched). + Create-space UI and Forgejo binding land next (T04–T06).

{% endif %} diff --git a/coulomb_social/templates/spaces/detail.html b/coulomb_social/templates/spaces/detail.html new file mode 100644 index 0000000..c369378 --- /dev/null +++ b/coulomb_social/templates/spaces/detail.html @@ -0,0 +1,36 @@ +{% extends "base.html" %} +{% block title %}{{ space.title }} — {{ site_name }}{% endblock %} +{% block content %} +

+ ← Spaces +

+

{{ space.title }}

+ {% if space.description %} +

{{ space.description }}

+ {% endif %} + +
+

Space metadata

+
+
Slug
{{ space.slug }}
+
Tenant
{{ space.tenant_id }}
+
Content
+
+ {% if space.has_content_binding %} + {{ space.forgejo_owner }}/{{ space.forgejo_repo }} + @ {{ space.default_branch }} + · root {{ space.content_root }} + {% else %} + Not bound to a Forgejo repo yet (CSOC-WP-0004-T04). + {% endif %} +
+
+
+ +
+

Pages

+

+ Markdown page rendering from Forgejo lands in T04. This view is metadata only. +

+
+{% endblock %} diff --git a/coulomb_social/urls.py b/coulomb_social/urls.py index be9b426..34dc11c 100644 --- a/coulomb_social/urls.py +++ b/coulomb_social/urls.py @@ -5,5 +5,6 @@ urlpatterns = [ path("admin/", admin.site.urls), path("healthz", include("coulomb_social.apps.core.urls_health")), path("auth/", include("coulomb_social.apps.identity.urls")), + path("app/spaces/", include("coulomb_social.apps.spaces.urls")), path("", include("coulomb_social.apps.core.urls")), ] diff --git a/docs/adr/ADR-0002-space-content-forgejo-markdown.md b/docs/adr/ADR-0002-space-content-forgejo-markdown.md new file mode 100644 index 0000000..94a35cd --- /dev/null +++ b/docs/adr/ADR-0002-space-content-forgejo-markdown.md @@ -0,0 +1,123 @@ +# ADR-0002 — Space content as markdown in Forgejo + +| Field | Value | +|-------|--------| +| Status | **Provisional** (accepted for T04 vertical slice; revisit write path) | +| Date | 2026-08-12 | +| Deciders | bernd | +| Workplan | CSOC-WP-0004-T03 | + +## Context + +coulomb.social co-creation **spaces** need durable page/artefact content. +Bubble stores opaque page graphs. The rebuild should: + +- keep content **agent- and human-editable** in git, +- version and review changes like normal software, +- avoid making Postgres the system of record for long-form prose, +- align with Forgejo already hosting fleet code on railiance01. + +Identity and space **metadata** stay in the app DB (`Space`, memberships). + +## Decision + +### 1. Canonical content form + +- **Markdown** (CommonMark + limited GFM: tables, fenced code, images). +- Static assets (images, attachments) live beside markdown under a content root. +- Postgres stores **metadata and binding only** — never full page bodies as SoR. + +### 2. Repository layout (v1) + +**One Forgejo repository per space** under a dedicated org (recommended: +`coulomb-spaces` or tenant-scoped org later). + +```text +// + README.md # optional space intro + pages/ + index.md # default landing page + .md + assets/ # images etc. referenced from pages +``` + +| Field on `Space` | Meaning | +|------------------|---------| +| `forgejo_owner` | org/user | +| `forgejo_repo` | repo name | +| `default_branch` | usually `main` | +| `content_root` | default `pages` | + +**Branch policy (v1):** app reads from `default_branch` only. PRs for review +are a human/Forgejo workflow; the app does not merge PRs in v1. + +**Monorepo alternative (deferred):** single repo with `spaces//…` — +rejected for v1 to keep permissions and migration packages simple. + +### 3. Read path (T04) + +1. App resolves `Space` by tenant + slug (authz already enforced). +2. If binding incomplete → empty state (no silent Bubble fetch). +3. Fetch file via **Forgejo raw/contents API** (preferred) or shallow cache: + - HTTP GET with service token from env/OpenBao. + - Cache rendered HTML or raw markdown in memory/disk with short TTL + + optional webhook invalidation later. +4. Render markdown → HTML with a locked-down sanitizer (no raw script). +5. Fail closed on 404/403/network errors with operator-visible reason. + +### 4. Write path (T05 — provisional) + +**v1 preference:** **edit in Forgejo** (web UI or git) + app refresh. + +- Lowest security surface (no app-held write credentials required if public-read + internal repos use deploy token read-only). +- Optional later: in-app editor → commit as bot user via API. + +**Not in v1:** bidirectional live sync with Bubble. + +### 5. Mapping from Bubble (later CSOC-WP-0001) + +| Bubble concept | Target | +|----------------|--------| +| Space / room-like container | `Space` row + Forgejo repo | +| Page / chunk prose | `pages/.md` | +| Attachments | `assets/…` | +| Permissions | `SpaceMembership` + NetKingdom groups (refine later) | + +Export scripts should emit markdown files + a manifest JSON for binding fields. + +### 6. Secrets + +| Secret | Storage | +|--------|---------| +| Forgejo API token (read, later write) | K8s Secret / OpenBao — env e.g. `FORGEJO_TOKEN` | +| Forgejo base URL | non-secret config e.g. `FORGEJO_BASE_URL=https://forgejo.coulomb.social` | + +Never commit tokens. Never render tokens in Session details. + +## Consequences + +**Positive** + +- Content is git-native and agent-friendly. +- Clear boundary: app = membership + UX; Forgejo = document history. +- Migration can ship repos per space without rewriting history into SQL. + +**Negative / follow-ups** + +- Need Forgejo org, tokens, and network reachability from the app pod. +- Offline/local dev needs a stub or fixture markdown path. +- Search and cross-space queries need a separate index later. + +## Open questions (non-blocking for T04) + +1. Org name: `coulomb-spaces` vs per-tenant org. +2. Private repos only vs public read for some community spaces. +3. When to add webhook-driven cache purge. +4. Whether user OAuth to Forgejo is required for in-app write (T05). + +## References + +- CSOC-WP-0004, Space model in `coulomb_social.apps.spaces` +- ADR-0001 NetKingdom identity +- Host posture: `docs/deploy.md` (app.coulomb.social) diff --git a/scripts/docker-entrypoint.sh b/scripts/docker-entrypoint.sh new file mode 100755 index 0000000..5935245 --- /dev/null +++ b/scripts/docker-entrypoint.sh @@ -0,0 +1,4 @@ +#!/bin/sh +set -e +python manage.py migrate --noinput +exec gunicorn coulomb_social.wsgi:application --bind 0.0.0.0:8000 --workers 2 diff --git a/tests/test_spaces.py b/tests/test_spaces.py new file mode 100644 index 0000000..6e6c995 --- /dev/null +++ b/tests/test_spaces.py @@ -0,0 +1,111 @@ +import pytest +from django.urls import reverse + +from coulomb_social.apps.members.models import Member, User +from coulomb_social.apps.spaces.models import Space, SpaceMembership +from coulomb_social.apps.spaces.services import spaces_for_member + + +def _login(client, settings, *, subject: str, tenant: str, name: str): + settings.DEBUG = True + settings.OIDC_ENABLED = False + settings.DEFAULT_TENANT_ID = tenant + return client.post( + reverse("identity:dev_login"), + { + "subject": subject, + "issuer": "https://local.dev/issuer", + "name": name, + "tenant": tenant, + }, + ) + + +@pytest.mark.django_db +def test_spaces_list_empty_for_tenant(client, settings): + _login(client, settings, subject="u1", tenant="tenant:a", name="User One") + r = client.get(reverse("core:app_home")) + assert r.status_code == 200 + assert b"No spaces yet" in r.content + + +@pytest.mark.django_db +def test_spaces_list_shows_same_tenant_only(client, settings): + _login(client, settings, subject="u1", tenant="tenant:a", name="User One") + member = Member.objects.get(subject="u1") + Space.objects.create( + tenant_id="tenant:a", + slug="alpha", + title="Alpha Space", + created_by=member, + ) + Space.objects.create( + tenant_id="tenant:b", + slug="beta", + title="Beta Space", + ) + + r = client.get(reverse("core:app_home")) + assert r.status_code == 200 + assert b"Alpha Space" in r.content + assert b"Beta Space" not in r.content + assert b"No spaces yet" not in r.content + + +@pytest.mark.django_db +def test_space_detail_tenant_isolation(client, settings): + _login(client, settings, subject="u1", tenant="tenant:a", name="User One") + Space.objects.create(tenant_id="tenant:b", slug="secret", title="Secret") + r = client.get(reverse("spaces:detail", kwargs={"slug": "secret"})) + assert r.status_code == 404 + + +@pytest.mark.django_db +def test_space_detail_ok(client, settings): + _login(client, settings, subject="u1", tenant="tenant:a", name="User One") + member = Member.objects.get(subject="u1") + Space.objects.create( + tenant_id="tenant:a", + slug="lab", + title="Lab", + description="Research lab", + created_by=member, + forgejo_owner="coulomb", + forgejo_repo="space-lab", + ) + r = client.get(reverse("spaces:detail", kwargs={"slug": "lab"})) + assert r.status_code == 200 + assert b"Lab" in r.content + assert b"coulomb/space-lab" in r.content + + +@pytest.mark.django_db +def test_membership_narrows_visibility(client, settings): + _login(client, settings, subject="u1", tenant="tenant:a", name="User One") + member = Member.objects.get(subject="u1") + other_user = User.objects.create_user(username="other") + other = Member.objects.create( + tenant_id="tenant:a", + user=other_user, + user_engine_user_id="usr_other", + issuer="https://local.dev/issuer", + subject="other-sub", + display_name="Other", + ) + open_space = Space.objects.create( + tenant_id="tenant:a", slug="open", title="Open", created_by=other + ) + closed = Space.objects.create( + tenant_id="tenant:a", slug="closed", title="Closed", created_by=other + ) + # Any membership in tenant switches mode to membership-scoped + SpaceMembership.objects.create( + space=closed, member=other, role=SpaceMembership.Role.OWNER + ) + SpaceMembership.objects.create( + space=open_space, member=member, role=SpaceMembership.Role.MEMBER + ) + + visible = list(spaces_for_member(member).values_list("slug", flat=True)) + assert "open" in visible + assert "closed" not in visible diff --git a/workplans/CSOC-WP-0004-app-shell-and-space-content.md b/workplans/CSOC-WP-0004-app-shell-and-space-content.md index 02cf671..7b3dcf4 100644 --- a/workplans/CSOC-WP-0004-app-shell-and-space-content.md +++ b/workplans/CSOC-WP-0004-app-shell-and-space-content.md @@ -75,7 +75,7 @@ dump removed from home body. Deploy with next image for app.coulomb.social. ```task id: CSOC-WP-0004-T02 -status: todo +status: done priority: high state_hub_task_id: "5fd91718-151d-4216-acf2-2104a45cddf9" ``` @@ -92,11 +92,14 @@ Migrations + admin + minimal list/detail routes behind auth. **Done when:** authenticated user can list zero-or-more spaces from DB; tests cover tenant isolation basics. +2026-08-12: `spaces` app — `Space` + `SpaceMembership`, tenant-scoped list on +`/app/`, detail `/app/spaces//`, admin seed path. Tests cover isolation. + ## T03 — Content model ADR: markdown + Forgejo ```task id: CSOC-WP-0004-T03 -status: todo +status: done priority: high state_hub_task_id: "628243e0-732f-42c1-b4e3-9b8cd82ea530" ``` @@ -113,6 +116,9 @@ Write `docs/adr/ADR-0002-space-content-forgejo-markdown.md` deciding: **Done when:** ADR accepted (or explicitly provisional with open questions listed) and linked from INTENT/SCOPE. +2026-08-12: Provisional ADR-0002 committed; linked from INTENT/SCOPE. One repo +per space, `pages/` root, Forgejo API read for T04; write-in-Forgejo for T05. + ## T04 — Read path: render space markdown from bound repo ```task @@ -170,6 +176,7 @@ names, smoke checklist on app.coulomb.social. Update `docs/deploy.md` and id: CSOC-WP-0004-T07 status: done priority: high +state_hub_task_id: "8487443a-dfd2-4220-a90b-bca2b5b04c4c" ``` Keep the current principal card fields available as **detail information**