Add Forgejo edit/refresh sync path for space content (T05)
Keep git as source of truth: deep-link to Forgejo editor, manual refresh to drop cache, and optional signed push webhook for automatic invalidation.
This commit is contained in:
parent
9c10037f34
commit
3f30bcd340
9 changed files with 458 additions and 5 deletions
|
|
@ -189,3 +189,56 @@ _FETCH_CACHE: dict[tuple[str, str, str, str, str], FetchedFile] = {}
|
|||
|
||||
def clear_content_cache() -> None:
|
||||
_FETCH_CACHE.clear()
|
||||
|
||||
|
||||
def clear_content_cache_for_repo(owner: str, repo: str) -> int:
|
||||
"""Drop cached files for one Forgejo repo. Returns number of entries removed."""
|
||||
owner = (owner or "").strip()
|
||||
repo = (repo or "").strip()
|
||||
if not owner or not repo:
|
||||
return 0
|
||||
victims = [k for k in _FETCH_CACHE if k[0] == owner and k[1] == repo]
|
||||
for key in victims:
|
||||
del _FETCH_CACHE[key]
|
||||
return len(victims)
|
||||
|
||||
|
||||
def clear_content_cache_for_space(space: Space) -> int:
|
||||
if not space.has_content_binding:
|
||||
return 0
|
||||
return clear_content_cache_for_repo(space.forgejo_owner, space.forgejo_repo)
|
||||
|
||||
|
||||
def forgejo_repo_url(space: Space) -> str:
|
||||
base = (getattr(settings, "FORGEJO_BASE_URL", None) or "").rstrip("/")
|
||||
if not space.has_content_binding or not base:
|
||||
return ""
|
||||
return f"{base}/{space.forgejo_owner}/{space.forgejo_repo}"
|
||||
|
||||
|
||||
def forgejo_edit_url(space: Space, page: str | None = None) -> str:
|
||||
"""Deep-link to Forgejo's file editor for the page markdown (git remains SoR)."""
|
||||
base = forgejo_repo_url(space)
|
||||
if not base:
|
||||
return ""
|
||||
try:
|
||||
page_slug = normalize_page_slug(page)
|
||||
path = page_path_for(space, page_slug)
|
||||
except ContentFetchError:
|
||||
return base
|
||||
branch = space.default_branch or "main"
|
||||
# Gitea/Forgejo: /{owner}/{repo}/_edit/{branch}/{path}
|
||||
return f"{base}/_edit/{branch}/{path}"
|
||||
|
||||
|
||||
def forgejo_blob_url(space: Space, page: str | None = None) -> str:
|
||||
base = forgejo_repo_url(space)
|
||||
if not base:
|
||||
return ""
|
||||
try:
|
||||
page_slug = normalize_page_slug(page)
|
||||
path = page_path_for(space, page_slug)
|
||||
except ContentFetchError:
|
||||
return base
|
||||
branch = space.default_branch or "main"
|
||||
return f"{base}/src/branch/{branch}/{path}"
|
||||
|
|
|
|||
|
|
@ -5,5 +5,11 @@ from . import views
|
|||
app_name = "spaces"
|
||||
|
||||
urlpatterns = [
|
||||
path(
|
||||
"hooks/forgejo/",
|
||||
views.forgejo_content_webhook,
|
||||
name="forgejo_webhook",
|
||||
),
|
||||
path("<slug:slug>/refresh/", views.space_refresh, name="refresh"),
|
||||
path("<slug:slug>/", views.space_detail, name="detail"),
|
||||
]
|
||||
|
|
|
|||
|
|
@ -1,12 +1,37 @@
|
|||
import hashlib
|
||||
import hmac
|
||||
import json
|
||||
import logging
|
||||
|
||||
from django.conf import settings
|
||||
from django.contrib import messages
|
||||
from django.contrib.auth.decorators import login_required
|
||||
from django.http import HttpRequest, HttpResponse, HttpResponseForbidden, HttpResponseNotFound
|
||||
from django.shortcuts import render
|
||||
from django.http import (
|
||||
HttpRequest,
|
||||
HttpResponse,
|
||||
HttpResponseForbidden,
|
||||
HttpResponseNotFound,
|
||||
JsonResponse,
|
||||
)
|
||||
from django.shortcuts import redirect, render
|
||||
from django.views.decorators.csrf import csrf_exempt
|
||||
from django.views.decorators.http import require_POST
|
||||
|
||||
from coulomb_social.apps.core.principal import build_principal
|
||||
|
||||
from .content import load_space_page
|
||||
from .content import (
|
||||
clear_content_cache_for_repo,
|
||||
clear_content_cache_for_space,
|
||||
forgejo_blob_url,
|
||||
forgejo_edit_url,
|
||||
forgejo_repo_url,
|
||||
load_space_page,
|
||||
)
|
||||
from .models import Space
|
||||
from .services import get_space_for_member, spaces_for_member
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@login_required
|
||||
def space_detail(request: HttpRequest, slug: str) -> HttpResponse:
|
||||
|
|
@ -30,9 +55,122 @@ def space_detail(request: HttpRequest, slug: str) -> HttpResponse:
|
|||
"display_name": principal["display_name"],
|
||||
"space": space,
|
||||
"page": rendered,
|
||||
"forgejo_repo_url": forgejo_repo_url(space),
|
||||
"forgejo_edit_url": forgejo_edit_url(space, page),
|
||||
"forgejo_blob_url": forgejo_blob_url(space, page),
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@login_required
|
||||
@require_POST
|
||||
def space_refresh(request: HttpRequest, slug: str) -> HttpResponse:
|
||||
"""Drop cache for this space's Forgejo binding and re-show the page."""
|
||||
principal = build_principal(request, authz_resource_id="space_refresh")
|
||||
if not principal["authz_allow"]:
|
||||
return HttpResponseForbidden("Not authorized.")
|
||||
member = principal.get("member")
|
||||
space = get_space_for_member(member, slug)
|
||||
if space is None:
|
||||
return HttpResponseNotFound("Space not found.")
|
||||
n = clear_content_cache_for_space(space)
|
||||
messages.success(
|
||||
request,
|
||||
f"Content cache cleared ({n} entries). Fresh fetch on this page load.",
|
||||
)
|
||||
page = request.POST.get("page") or request.GET.get("page") or "index"
|
||||
from django.urls import reverse
|
||||
|
||||
target = reverse("spaces:detail", kwargs={"slug": slug})
|
||||
if page and page != "index":
|
||||
target = f"{target}?page={page}"
|
||||
return redirect(target)
|
||||
|
||||
|
||||
@csrf_exempt
|
||||
@require_POST
|
||||
def forgejo_content_webhook(request: HttpRequest) -> JsonResponse:
|
||||
"""Forgejo/Gitea push webhook → invalidate content cache for matching spaces.
|
||||
|
||||
Auth: header ``X-Coulomb-Webhook-Secret`` or ``X-Gitea-Signature`` (HMAC-SHA256
|
||||
of body with FORGEJO_WEBHOOK_SECRET) or shared secret query (discouraged).
|
||||
Configure in Forgejo: repository → Webhooks → Gitea (JSON) → push events.
|
||||
"""
|
||||
secret = (getattr(settings, "FORGEJO_WEBHOOK_SECRET", None) or "").strip()
|
||||
if not secret:
|
||||
return JsonResponse(
|
||||
{"ok": False, "error": "webhook not configured"},
|
||||
status=503,
|
||||
)
|
||||
if not _webhook_authorized(request, secret):
|
||||
return JsonResponse({"ok": False, "error": "unauthorized"}, status=401)
|
||||
|
||||
try:
|
||||
payload = json.loads(request.body.decode("utf-8") or "{}")
|
||||
except json.JSONDecodeError:
|
||||
return JsonResponse({"ok": False, "error": "invalid json"}, status=400)
|
||||
|
||||
owner, repo = _repo_from_push_payload(payload)
|
||||
if not owner or not repo:
|
||||
return JsonResponse({"ok": False, "error": "no repository in payload"}, status=400)
|
||||
|
||||
cleared = clear_content_cache_for_repo(owner, repo)
|
||||
matched = Space.objects.filter(
|
||||
forgejo_owner__iexact=owner,
|
||||
forgejo_repo__iexact=repo,
|
||||
is_active=True,
|
||||
).count()
|
||||
logger.info(
|
||||
"Forgejo webhook cache invalidate owner=%s repo=%s cleared=%s spaces=%s",
|
||||
owner,
|
||||
repo,
|
||||
cleared,
|
||||
matched,
|
||||
)
|
||||
return JsonResponse(
|
||||
{
|
||||
"ok": True,
|
||||
"owner": owner,
|
||||
"repo": repo,
|
||||
"cache_entries_cleared": cleared,
|
||||
"spaces_matched": matched,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def list_spaces_context(member) -> dict:
|
||||
return {"spaces": list(spaces_for_member(member))}
|
||||
|
||||
|
||||
def _webhook_authorized(request: HttpRequest, secret: str) -> bool:
|
||||
header_secret = request.headers.get("X-Coulomb-Webhook-Secret", "")
|
||||
if header_secret and hmac.compare_digest(header_secret, secret):
|
||||
return True
|
||||
# Gitea/Forgejo HMAC-SHA256 hex of body
|
||||
sig = request.headers.get("X-Gitea-Signature") or request.headers.get(
|
||||
"X-Hub-Signature-256", ""
|
||||
)
|
||||
if sig.startswith("sha256="):
|
||||
sig = sig[len("sha256=") :]
|
||||
if sig:
|
||||
digest = hmac.new(
|
||||
secret.encode("utf-8"), request.body, hashlib.sha256
|
||||
).hexdigest()
|
||||
if hmac.compare_digest(digest, sig):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _repo_from_push_payload(payload: dict) -> tuple[str, str]:
|
||||
repo = payload.get("repository") or {}
|
||||
full = (repo.get("full_name") or "").strip()
|
||||
if "/" in full:
|
||||
owner, name = full.split("/", 1)
|
||||
return owner, name
|
||||
owner = (repo.get("owner") or {})
|
||||
if isinstance(owner, dict):
|
||||
owner_name = owner.get("login") or owner.get("username") or ""
|
||||
else:
|
||||
owner_name = str(owner)
|
||||
name = repo.get("name") or ""
|
||||
return str(owner_name), str(name)
|
||||
|
|
|
|||
|
|
@ -124,5 +124,7 @@ FLEX_AUTH_TIMEOUT_SECONDS = config("FLEX_AUTH_TIMEOUT_SECONDS", default=3.0, cas
|
|||
FORGEJO_BASE_URL = config("FORGEJO_BASE_URL", default="https://forgejo.coulomb.social")
|
||||
FORGEJO_TOKEN = config("FORGEJO_TOKEN", default="") # optional; public raw needs none
|
||||
FORGEJO_TIMEOUT_SECONDS = config("FORGEJO_TIMEOUT_SECONDS", default=10.0, cast=float)
|
||||
# Shared secret for POST /app/spaces/hooks/forgejo/ (push → cache invalidate)
|
||||
FORGEJO_WEBHOOK_SECRET = config("FORGEJO_WEBHOOK_SECRET", default="")
|
||||
# Optional local root for offline tests: <root>/<space-slug>/<content_root>/index.md
|
||||
SPACE_CONTENT_FIXTURE_ROOT = config("SPACE_CONTENT_FIXTURE_ROOT", default="")
|
||||
|
|
|
|||
|
|
@ -23,11 +23,19 @@
|
|||
border-left: 3px solid var(--color-primary);
|
||||
color: var(--color-muted);
|
||||
}
|
||||
.content-meta { font-size: 0.85rem; margin-top: 0.5rem; }
|
||||
.content-meta { font-size: 0.85rem; margin-top: 0.75rem; }
|
||||
.content-error {
|
||||
border-color: #fcd34d;
|
||||
background: #fffbeb;
|
||||
}
|
||||
.content-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
margin: 1rem 0 0;
|
||||
align-items: center;
|
||||
}
|
||||
.content-actions form { display: inline; margin: 0; }
|
||||
</style>
|
||||
{% endblock %}
|
||||
{% block content %}
|
||||
|
|
@ -39,6 +47,25 @@
|
|||
<p class="muted">{{ space.description }}</p>
|
||||
{% endif %}
|
||||
|
||||
{% if space.has_content_binding %}
|
||||
<div class="content-actions">
|
||||
{% if forgejo_edit_url %}
|
||||
<a class="btn small" href="{{ forgejo_edit_url }}" target="_blank" rel="noopener">Edit in Forgejo</a>
|
||||
{% endif %}
|
||||
{% if forgejo_blob_url %}
|
||||
<a class="btn secondary small" href="{{ forgejo_blob_url }}" target="_blank" rel="noopener">View source</a>
|
||||
{% endif %}
|
||||
<form method="post" action="{% url 'spaces:refresh' space.slug %}">
|
||||
{% csrf_token %}
|
||||
<input type="hidden" name="page" value="{{ page.slug|default:'index' }}">
|
||||
<button type="submit" class="btn secondary small">Refresh content</button>
|
||||
</form>
|
||||
</div>
|
||||
<p class="muted content-meta" style="margin-top:0.5rem;">
|
||||
Git is the source of truth. Edit in Forgejo, then refresh (or wait for webhook).
|
||||
</p>
|
||||
{% endif %}
|
||||
|
||||
{% if page.error %}
|
||||
<div class="card content-error">
|
||||
<h2 style="margin-top:0;">Content unavailable</h2>
|
||||
|
|
|
|||
|
|
@ -77,3 +77,20 @@ When Bubble can retire:
|
|||
make test
|
||||
make run # offline or local OIDC redirect
|
||||
```
|
||||
|
||||
## Space content (Forgejo)
|
||||
|
||||
See `docs/spaces-content.md`. Demo:
|
||||
|
||||
```bash
|
||||
kubectl -n coulomb-social exec deploy/coulomb-social -- python manage.py seed_demo_space
|
||||
```
|
||||
|
||||
Optional webhook secret (when configured in cluster env):
|
||||
|
||||
| Key | Purpose |
|
||||
|-----|---------|
|
||||
| `FORGEJO_WEBHOOK_SECRET` | Push webhook cache bust |
|
||||
| `FORGEJO_TOKEN` | Private repo raw/API reads |
|
||||
|
||||
Webhook URL: `https://app.coulomb.social/app/spaces/hooks/forgejo/`
|
||||
63
docs/spaces-content.md
Normal file
63
docs/spaces-content.md
Normal file
|
|
@ -0,0 +1,63 @@
|
|||
# Space content — Forgejo markdown (read + write path)
|
||||
|
||||
See **ADR-0002**. App DB holds metadata; page bodies are markdown in Forgejo.
|
||||
|
||||
## Read (T04)
|
||||
|
||||
1. Space row has `forgejo_owner`, `forgejo_repo`, `default_branch`, `content_root`.
|
||||
2. App fetches `{content_root}/index.md` (or `?page=<slug>` → `{slug}.md`) via Forgejo raw URL.
|
||||
3. HTML is sanitized and cached in-process (successes only).
|
||||
|
||||
Env (non-secret defaults in settings):
|
||||
|
||||
| Variable | Purpose |
|
||||
|----------|---------|
|
||||
| `FORGEJO_BASE_URL` | default `https://forgejo.coulomb.social` |
|
||||
| `FORGEJO_TOKEN` | optional; needed for private repos |
|
||||
| `FORGEJO_WEBHOOK_SECRET` | optional; enables push webhook cache bust |
|
||||
|
||||
## Write / sync (T05)
|
||||
|
||||
**Git remains the source of truth.** There is no in-app markdown editor in v1.
|
||||
|
||||
### Operator / author workflow
|
||||
|
||||
1. Open the space on app.coulomb.social.
|
||||
2. Click **Edit in Forgejo** (file editor) or **View source**.
|
||||
3. Commit on the space’s `default_branch` (usually `main`).
|
||||
4. Click **Refresh content** on the space page (or rely on webhook below).
|
||||
|
||||
Demo seed (public raw, no token):
|
||||
|
||||
```bash
|
||||
python manage.py seed_demo_space
|
||||
# binds demo → coulomb/coulomb-social @ docs/space-fixtures/demo/pages
|
||||
```
|
||||
|
||||
### Webhook (optional, recommended)
|
||||
|
||||
So authors do not need to click Refresh after every push:
|
||||
|
||||
1. Set `FORGEJO_WEBHOOK_SECRET` in the app env Secret (not in git).
|
||||
2. In Forgejo: repo → **Settings → Webhooks → Add webhook → Gitea**.
|
||||
3. **Target URL:** `https://app.coulomb.social/app/spaces/hooks/forgejo/`
|
||||
4. **HTTP Method:** POST, content type JSON.
|
||||
5. **Secret:** same value as `FORGEJO_WEBHOOK_SECRET` (Forgejo signs with HMAC-SHA256).
|
||||
6. Trigger: **Push** events.
|
||||
|
||||
Auth accepted by the app:
|
||||
|
||||
- `X-Gitea-Signature: <hex hmac-sha256 of body>` (Forgejo default when secret set), or
|
||||
- `X-Coulomb-Webhook-Secret: <shared secret>`
|
||||
|
||||
Response JSON: `{ ok, owner, repo, cache_entries_cleared, spaces_matched }`.
|
||||
|
||||
### Fail closed
|
||||
|
||||
- Missing binding → clear UI error, no Bubble fallback.
|
||||
- Fetch errors → error card; previous cache entry is not used after Refresh.
|
||||
|
||||
## Related
|
||||
|
||||
- `docs/adr/ADR-0002-space-content-forgejo-markdown.md`
|
||||
- `python manage.py seed_demo_space`
|
||||
143
tests/test_content_sync.py
Normal file
143
tests/test_content_sync.py
Normal file
|
|
@ -0,0 +1,143 @@
|
|||
import hashlib
|
||||
import hmac
|
||||
import json
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
from django.urls import reverse
|
||||
|
||||
from coulomb_social.apps.members.models import Member
|
||||
from coulomb_social.apps.spaces.content import (
|
||||
clear_content_cache,
|
||||
clear_content_cache_for_repo,
|
||||
forgejo_edit_url,
|
||||
)
|
||||
from coulomb_social.apps.spaces.forgejo import FetchedFile
|
||||
from coulomb_social.apps.spaces.models import Space
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_forgejo_edit_url():
|
||||
space = Space(
|
||||
tenant_id="t",
|
||||
slug="demo",
|
||||
title="D",
|
||||
forgejo_owner="coulomb",
|
||||
forgejo_repo="coulomb-social",
|
||||
default_branch="main",
|
||||
content_root="docs/space-fixtures/demo/pages",
|
||||
)
|
||||
url = forgejo_edit_url(space, "index")
|
||||
assert url.endswith(
|
||||
"/coulomb/coulomb-social/_edit/main/docs/space-fixtures/demo/pages/index.md"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_refresh_clears_cache_and_refetches(client, settings):
|
||||
clear_content_cache()
|
||||
settings.DEBUG = True
|
||||
settings.OIDC_ENABLED = False
|
||||
client.post(
|
||||
reverse("identity:dev_login"),
|
||||
{
|
||||
"subject": "writer",
|
||||
"issuer": "https://local.dev/issuer",
|
||||
"name": "Writer",
|
||||
"tenant": "tenant:coulomb",
|
||||
},
|
||||
)
|
||||
member = Member.objects.get(subject="writer")
|
||||
Space.objects.create(
|
||||
tenant_id="tenant:coulomb",
|
||||
slug="demo",
|
||||
title="Demo",
|
||||
created_by=member,
|
||||
forgejo_owner="coulomb",
|
||||
forgejo_repo="coulomb-social",
|
||||
content_root="docs/space-fixtures/demo/pages",
|
||||
)
|
||||
calls = {"n": 0}
|
||||
|
||||
def fake_fetch(**kwargs):
|
||||
calls["n"] += 1
|
||||
return FetchedFile(
|
||||
path=kwargs["path"],
|
||||
text=f"# Version {calls['n']}\n\nbody\n",
|
||||
source="forgejo-raw",
|
||||
)
|
||||
|
||||
with patch(
|
||||
"coulomb_social.apps.spaces.content.fetch_raw_file", side_effect=fake_fetch
|
||||
):
|
||||
r1 = client.get(reverse("spaces:detail", kwargs={"slug": "demo"}))
|
||||
assert r1.status_code == 200
|
||||
assert b"Version 1" in r1.content
|
||||
# cached — still v1
|
||||
r2 = client.get(reverse("spaces:detail", kwargs={"slug": "demo"}))
|
||||
assert b"Version 1" in r2.content
|
||||
assert calls["n"] == 1
|
||||
# refresh
|
||||
r3 = client.post(reverse("spaces:refresh", kwargs={"slug": "demo"}))
|
||||
assert r3.status_code == 302
|
||||
r4 = client.get(reverse("spaces:detail", kwargs={"slug": "demo"}))
|
||||
assert b"Version 2" in r4.content
|
||||
assert calls["n"] == 2
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_webhook_hmac_invalidates_repo_cache(client, settings):
|
||||
clear_content_cache()
|
||||
settings.FORGEJO_WEBHOOK_SECRET = "test-webhook-secret-at-least-16"
|
||||
Space.objects.create(
|
||||
tenant_id="tenant:coulomb",
|
||||
slug="demo",
|
||||
title="Demo",
|
||||
forgejo_owner="coulomb",
|
||||
forgejo_repo="coulomb-social",
|
||||
content_root="pages",
|
||||
)
|
||||
# prime cache
|
||||
from coulomb_social.apps.spaces import content as content_mod
|
||||
|
||||
key = (
|
||||
"coulomb",
|
||||
"coulomb-social",
|
||||
"main",
|
||||
"pages/index.md",
|
||||
settings.FORGEJO_BASE_URL,
|
||||
)
|
||||
content_mod._FETCH_CACHE[key] = FetchedFile(
|
||||
path="pages/index.md", text="# old", source="forgejo-raw"
|
||||
)
|
||||
|
||||
body = json.dumps(
|
||||
{"repository": {"full_name": "coulomb/coulomb-social", "name": "coulomb-social"}}
|
||||
).encode()
|
||||
sig = hmac.new(
|
||||
settings.FORGEJO_WEBHOOK_SECRET.encode(), body, hashlib.sha256
|
||||
).hexdigest()
|
||||
r = client.post(
|
||||
reverse("spaces:forgejo_webhook"),
|
||||
data=body,
|
||||
content_type="application/json",
|
||||
headers={"X-Gitea-Signature": sig},
|
||||
)
|
||||
assert r.status_code == 200
|
||||
data = r.json()
|
||||
assert data["ok"] is True
|
||||
assert data["cache_entries_cleared"] >= 1
|
||||
assert data["spaces_matched"] == 1
|
||||
assert key not in content_mod._FETCH_CACHE
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_webhook_rejects_bad_secret(client, settings):
|
||||
settings.FORGEJO_WEBHOOK_SECRET = "test-webhook-secret-at-least-16"
|
||||
r = client.post(
|
||||
reverse("spaces:forgejo_webhook"),
|
||||
data=b"{}",
|
||||
content_type="application/json",
|
||||
headers={"X-Coulomb-Webhook-Secret": "wrong"},
|
||||
)
|
||||
assert r.status_code == 401
|
||||
|
|
@ -145,7 +145,7 @@ command; env `FORGEJO_BASE_URL` / optional `FORGEJO_TOKEN`.
|
|||
|
||||
```task
|
||||
id: CSOC-WP-0004-T05
|
||||
status: todo
|
||||
status: done
|
||||
priority: medium
|
||||
state_hub_task_id: "30ed5def-2718-4379-834c-920e751e4d0b"
|
||||
```
|
||||
|
|
@ -160,6 +160,10 @@ Prefer the smaller path that keeps git as source of truth.
|
|||
**Done when:** a content change in Forgejo appears in the app without redeploy,
|
||||
and (if in-app write exists) a save produces a commit without secrets in git.
|
||||
|
||||
2026-08-12: **Edit in Forgejo** + **View source** + **Refresh content** on space
|
||||
detail; repo-scoped cache invalidate; optional push webhook
|
||||
`POST /app/spaces/hooks/forgejo/` (HMAC or shared secret). Docs:
|
||||
`docs/spaces-content.md`. No in-app editor (git remains SoR).
|
||||
## T06 — Seed and runbook for app.coulomb.social
|
||||
|
||||
```task
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue