sx-pub Phase 1: DB schema, IPFS wiring, actor + webfinger + collections + status endpoints

Foundation for the sx-pub federated publishing protocol:

- 6 SQLAlchemy models: actor, collections, documents, activities, followers, following
- Conditional DB enablement in sx_docs (DATABASE_URL present → enable DB)
- Python IO helpers: get_or_create_actor (auto-generates RSA keypair),
  list_collections, check_status, seed_default_collections
- 4 defhandler endpoints returning text/sx (no JSON):
  /pub/actor, /pub/webfinger, /pub/collections, /pub/status
- Alembic migration infrastructure for sx service
- Docker compose: DB + pgbouncer + IPFS + env vars

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-03-25 01:17:27 +00:00
parent 858275dff9
commit 7b3d763291
8 changed files with 486 additions and 1 deletions

35
sx/alembic.ini Normal file
View File

@@ -0,0 +1,35 @@
[alembic]
script_location = alembic
sqlalchemy.url =
[loggers]
keys = root,sqlalchemy,alembic
[handlers]
keys = console
[formatters]
keys = generic
[logger_root]
level = WARN
handlers = console
[logger_sqlalchemy]
level = WARN
handlers =
qualname = sqlalchemy.engine
[logger_alembic]
level = INFO
handlers =
qualname = alembic
[handler_console]
class = StreamHandler
args = (sys.stderr,)
level = NOTSET
formatter = generic
[formatter_generic]
format = %(levelname)-5.5s [%(name)s] %(message)s

17
sx/alembic/env.py Normal file
View File

@@ -0,0 +1,17 @@
from alembic import context
from shared.db.alembic_env import run_alembic
MODELS = [
"shared.models.sx_pub",
]
TABLES = frozenset({
"sx_pub_actor",
"sx_pub_collections",
"sx_pub_documents",
"sx_pub_activities",
"sx_pub_followers",
"sx_pub_following",
})
run_alembic(context.config, MODELS, TABLES)

View File

@@ -62,8 +62,10 @@ def create_app() -> "Quart":
extra_kw = {}
if SX_STANDALONE:
extra_kw["no_db"] = True
extra_kw["no_oauth"] = True
# Enable DB if DATABASE_URL is set (needed for sx-pub)
if not os.getenv("DATABASE_URL"):
extra_kw["no_db"] = True
app = create_base_app(
"sx",

98
sx/sx/handlers/pub-api.sx Normal file
View File

@@ -0,0 +1,98 @@
;; ==========================================================================
;; sx-pub Phase 1 API endpoints — actor, webfinger, collections, status
;;
;; All responses are text/sx. No JSON.
;; ==========================================================================
;; --------------------------------------------------------------------------
;; Actor
;; --------------------------------------------------------------------------
(defhandler pub-actor
:path "/pub/actor"
:method :get
:returns "element"
(&key)
(let ((actor (helper "pub-actor-data")))
(do
(set-response-header "Content-Type" "text/sx; charset=utf-8")
(str
"(SxActor"
"\n :id \"https://" (get actor "domain") "/pub/actor\""
"\n :type \"SxPublisher\""
"\n :name \"" (get actor "display-name") "\""
"\n :summary \"" (get actor "summary") "\""
"\n :inbox \"/pub/inbox\""
"\n :outbox \"/pub/outbox\""
"\n :followers \"/pub/followers\""
"\n :following \"/pub/following\""
"\n :public-key-pem \"" (get actor "public-key-pem") "\")"))))
;; --------------------------------------------------------------------------
;; Webfinger
;; --------------------------------------------------------------------------
(defhandler pub-webfinger
:path "/pub/webfinger"
:method :get
:returns "element"
(&key)
(let ((resource (helper "request-arg" "resource" ""))
(actor (helper "pub-actor-data")))
(let ((expected (str "acct:" (get actor "preferred-username") "@" (get actor "domain"))))
(if (!= resource expected)
(do
(set-response-status 404)
(str "(Error :message \"Resource not found\")"))
(do
(set-response-header "Content-Type" "text/sx; charset=utf-8")
(str
"(SxWebfinger"
"\n :subject \"" expected "\""
"\n :actor \"https://" (get actor "domain") "/pub/actor\""
"\n :type \"SxPublisher\")"))))))
;; --------------------------------------------------------------------------
;; Collections
;; --------------------------------------------------------------------------
(defhandler pub-collections
:path "/pub/collections"
:method :get
:returns "element"
(&key)
(let ((collections (helper "pub-collections-data")))
(do
(set-response-header "Content-Type" "text/sx; charset=utf-8")
(let ((items (map (fn (c)
(str "\n (SxCollection"
" :slug \"" (get c "slug") "\""
" :name \"" (get c "name") "\""
" :description \"" (get c "description") "\""
" :href \"/pub/" (get c "slug") "\")"))
collections)))
(str "(SxCollections" (join "" items) ")")))))
;; --------------------------------------------------------------------------
;; Status
;; --------------------------------------------------------------------------
(defhandler pub-status
:path "/pub/status"
:method :get
:returns "element"
(&key)
(let ((status (helper "pub-status-data")))
(do
(set-response-header "Content-Type" "text/sx; charset=utf-8")
(str
"(SxPubStatus"
"\n :healthy " (get status "healthy")
"\n :db \"" (get status "db") "\""
"\n :ipfs \"" (get status "ipfs") "\""
"\n :actor \"" (get status "actor") "\""
"\n :domain \"" (or (get status "domain") "unknown") "\")"))))

View File

@@ -37,6 +37,10 @@ def _register_sx_helpers() -> None:
"spec-explorer-data": _spec_explorer_data,
"spec-explorer-data-by-slug": _spec_explorer_data_by_slug,
"handler-source": _handler_source,
# sx-pub helpers (only functional when DATABASE_URL is set)
"pub-actor-data": _pub_actor_data,
"pub-collections-data": _pub_collections_data,
"pub-status-data": _pub_status_data,
})
@@ -1717,3 +1721,22 @@ def _page_helpers_demo_data() -> dict:
results["attr-keys"] = list(ATTR_DETAILS.keys())
return results
# ---------------------------------------------------------------------------
# sx-pub helpers — thin wrappers for SX access
# ---------------------------------------------------------------------------
async def _pub_actor_data():
from .pub_helpers import get_or_create_actor
return await get_or_create_actor()
async def _pub_collections_data():
from .pub_helpers import list_collections
return await list_collections()
async def _pub_status_data():
from .pub_helpers import check_status
return await check_status()

144
sx/sxc/pages/pub_helpers.py Normal file
View File

@@ -0,0 +1,144 @@
"""sx-pub Python IO helpers — actor management, IPFS status, collections.
These are called from SX defhandlers via (helper "pub-..." args...).
All DB access uses g.s (per-request async session from register_db).
"""
from __future__ import annotations
import logging
import os
from typing import Any
logger = logging.getLogger("sx.pub")
SX_PUB_DOMAIN = os.getenv("SX_PUB_DOMAIN", "pub.sx-web.org")
async def get_or_create_actor() -> dict[str, Any]:
"""Get or create the singleton sx-pub actor. Auto-generates RSA keypair."""
from quart import g
from sqlalchemy import select
from shared.models.sx_pub import SxPubActor
result = await g.s.execute(
select(SxPubActor).where(SxPubActor.preferred_username == "sx")
)
actor = result.scalar_one_or_none()
if actor is None:
from cryptography.hazmat.primitives.asymmetric import rsa
from cryptography.hazmat.primitives import serialization
private_key = rsa.generate_private_key(
public_exponent=65537, key_size=2048,
)
private_pem = private_key.private_bytes(
encoding=serialization.Encoding.PEM,
format=serialization.PrivateFormat.PKCS8,
encryption_algorithm=serialization.NoEncryption(),
).decode("utf-8")
public_pem = private_key.public_key().public_bytes(
encoding=serialization.Encoding.PEM,
format=serialization.PublicFormat.SubjectPublicKeyInfo,
).decode("utf-8")
actor = SxPubActor(
preferred_username="sx",
display_name="SX Language",
summary="Federated SX specification publisher",
public_key_pem=public_pem,
private_key_pem=private_pem,
domain=SX_PUB_DOMAIN,
)
g.s.add(actor)
await g.s.flush()
logger.info("Created sx-pub actor id=%d domain=%s", actor.id, SX_PUB_DOMAIN)
# Seed default collections on first run
await seed_default_collections()
return {
"preferred-username": actor.preferred_username,
"display-name": actor.display_name or actor.preferred_username,
"summary": actor.summary or "",
"public-key-pem": actor.public_key_pem,
"domain": actor.domain,
}
async def seed_default_collections() -> None:
"""Create default collections if they don't exist."""
from quart import g
from sqlalchemy import select
from shared.models.sx_pub import SxPubCollection
defaults = [
("core-specs", "Core Specifications", "Language spec files — evaluator, parser, primitives, render", 0),
("platforms", "Platforms", "Host platform implementations — JavaScript, Python, OCaml", 1),
("components", "Components", "Reusable UI components published as content-addressed SX", 2),
("libraries", "Libraries", "SX library modules — stdlib, signals, freeze, web forms", 3),
]
for slug, name, description, order in defaults:
exists = await g.s.execute(
select(SxPubCollection).where(SxPubCollection.slug == slug)
)
if exists.scalar_one_or_none() is None:
g.s.add(SxPubCollection(
slug=slug, name=name, description=description, sort_order=order,
))
await g.s.flush()
logger.info("Seeded %d default collections", len(defaults))
async def list_collections() -> list[dict[str, Any]]:
"""List all pub collections."""
from quart import g
from sqlalchemy import select
from shared.models.sx_pub import SxPubCollection
result = await g.s.execute(
select(SxPubCollection).order_by(SxPubCollection.sort_order)
)
return [
{
"slug": c.slug,
"name": c.name,
"description": c.description or "",
}
for c in result.scalars().all()
]
async def check_status() -> dict[str, Any]:
"""Health check — DB, IPFS, actor."""
status: dict[str, Any] = {"healthy": "true"}
# DB
try:
from quart import g
from sqlalchemy import text
await g.s.execute(text("SELECT 1"))
status["db"] = "connected"
except Exception as e:
status["db"] = f"error: {e}"
status["healthy"] = "false"
# IPFS
try:
from shared.utils.ipfs_client import is_available
ok = await is_available()
status["ipfs"] = "available" if ok else "unavailable"
except Exception as e:
status["ipfs"] = f"error: {e}"
# Actor
try:
actor = await get_or_create_actor()
status["actor"] = actor["preferred-username"]
status["domain"] = actor["domain"]
except Exception as e:
status["actor"] = f"error: {e}"
status["healthy"] = "false"
return status