diff --git a/dev-pub.sh b/dev-pub.sh new file mode 100755 index 00000000..47074458 --- /dev/null +++ b/dev-pub.sh @@ -0,0 +1,30 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Dev mode for sx-pub (SX-based ActivityPub) +# Bind-mounted source + auto-reload on externalnet +# Browse to pub.sx-web.org +# +# Usage: +# ./dev-pub.sh # Start sx-pub dev +# ./dev-pub.sh down # Stop +# ./dev-pub.sh logs # Tail logs +# ./dev-pub.sh --build # Rebuild image then start + +COMPOSE="docker compose -p sx-pub -f docker-compose.dev-pub.yml" + +case "${1:-up}" in + down) + $COMPOSE down + ;; + logs) + $COMPOSE logs -f sx_pub + ;; + *) + BUILD_FLAG="" + if [[ "${1:-}" == "--build" ]]; then + BUILD_FLAG="--build" + fi + $COMPOSE up $BUILD_FLAG + ;; +esac diff --git a/docker-compose.dev-pub.yml b/docker-compose.dev-pub.yml new file mode 100644 index 00000000..f2867030 --- /dev/null +++ b/docker-compose.dev-pub.yml @@ -0,0 +1,112 @@ +# Dev mode for sx-pub (SX-based ActivityPub) +# Starts as sx_docs clone — AP protocol built in SX from scratch +# Accessible at pub.sx-web.org via Caddy on externalnet +# Own DB + pgbouncer + IPFS node + +services: + sx_pub: + image: registry.rose-ash.com:5000/sx_docs:latest + environment: + SX_STANDALONE: "true" + SECRET_KEY: "${SECRET_KEY:-pub-dev-secret}" + REDIS_URL: redis://redis:6379/0 + DATABASE_URL: postgresql+asyncpg://postgres:change-me@pgbouncer:5432/sx_pub + WORKERS: "1" + ENVIRONMENT: development + RELOAD: "true" + SX_USE_REF: "1" + SX_USE_OCAML: "1" + SX_OCAML_BIN: "/app/bin/sx_server" + SX_BOUNDARY_STRICT: "1" + SX_DEV: "1" + OCAMLRUNPARAM: "b" + IPFS_API: http://ipfs:5001 + ports: + - "8014:8000" + volumes: + - /root/sx-pub/_config/dev-sh-config.yaml:/app/config/app-config.yaml:ro + - ./shared:/app/shared + - ./sx/app.py:/app/app.py + - ./sx/sxc:/app/sxc + - ./sx/bp:/app/bp + - ./sx/services:/app/services + - ./sx/content:/app/content + - ./sx/sx:/app/sx + - ./sx/path_setup.py:/app/path_setup.py + - ./sx/entrypoint.sh:/usr/local/bin/entrypoint.sh + - ./sx/__init__.py:/app/__init__.py:ro + # Spec + web SX files + - ./spec:/app/spec:ro + - ./web:/app/web:ro + # OCaml SX kernel binary + - ./hosts/ocaml/_build/default/bin/sx_server.exe:/app/bin/sx_server:ro + # sibling models for cross-domain SQLAlchemy imports + - ./blog/__init__.py:/app/blog/__init__.py:ro + - ./blog/models:/app/blog/models:ro + - ./market/__init__.py:/app/market/__init__.py:ro + - ./market/models:/app/market/models:ro + - ./cart/__init__.py:/app/cart/__init__.py:ro + - ./cart/models:/app/cart/models:ro + - ./events/__init__.py:/app/events/__init__.py:ro + - ./events/models:/app/events/models:ro + - ./federation/__init__.py:/app/federation/__init__.py:ro + - ./federation/models:/app/federation/models:ro + - ./account/__init__.py:/app/account/__init__.py:ro + - ./account/models:/app/account/models:ro + - ./relations/__init__.py:/app/relations/__init__.py:ro + - ./relations/models:/app/relations/models:ro + - ./likes/__init__.py:/app/likes/__init__.py:ro + - ./likes/models:/app/likes/models:ro + - ./orders/__init__.py:/app/orders/__init__.py:ro + - ./orders/models:/app/orders/models:ro + depends_on: + - pgbouncer + - redis + - ipfs + networks: + - externalnet + - default + restart: unless-stopped + + db: + image: postgres:16 + environment: + POSTGRES_USER: postgres + POSTGRES_PASSWORD: change-me + POSTGRES_DB: sx_pub + volumes: + - db_data:/var/lib/postgresql/data + restart: unless-stopped + + pgbouncer: + image: edoburu/pgbouncer:latest + environment: + DB_HOST: db + DB_PORT: "5432" + DB_USER: postgres + DB_PASSWORD: change-me + POOL_MODE: transaction + DEFAULT_POOL_SIZE: "10" + MAX_CLIENT_CONN: "100" + AUTH_TYPE: plain + depends_on: + - db + restart: unless-stopped + + ipfs: + image: ipfs/kubo:latest + volumes: + - ipfs_data:/data/ipfs + restart: unless-stopped + + redis: + image: redis:7-alpine + restart: unless-stopped + +volumes: + db_data: + ipfs_data: + +networks: + externalnet: + external: true diff --git a/shared/static/scripts/sx-browser.js b/shared/static/scripts/sx-browser.js index 18357a24..48e9b420 100644 --- a/shared/static/scripts/sx-browser.js +++ b/shared/static/scripts/sx-browser.js @@ -14,7 +14,7 @@ // ========================================================================= var NIL = Object.freeze({ _nil: true, toString: function() { return "nil"; } }); - var SX_VERSION = "2026-03-24T22:31:01Z"; + var SX_VERSION = "2026-03-24T22:34:15Z"; function isNil(x) { return x === NIL || x === null || x === undefined; } function isSxTruthy(x) { return x !== false && !isNil(x); } @@ -4769,7 +4769,7 @@ PRIMITIVES["bind-sse-swap"] = bindSseSwap; var eventName = slice(name, 6); return (isSxTruthy(!isSxTruthy(isProcessed(el, (String("on:") + String(eventName))))) ? (markProcessed(el, (String("on:") + String(eventName))), (function() { var exprs = sxParse(body); - return domOn(el, eventName, function(e) { return (function() { + return domListen(el, eventName, function(e) { return (function() { var handlerEnv = envExtend({}); envBind(handlerEnv, "event", e); envBind(handlerEnv, "this", el); @@ -4826,7 +4826,7 @@ PRIMITIVES["process-one"] = processOne; var els = domQueryAll(sxOr(root, domBody()), "[data-sx-emit]"); return forEach(function(el) { return (isSxTruthy(!isSxTruthy(isProcessed(el, "emit"))) ? (markProcessed(el, "emit"), (function() { var eventName = domGetAttr(el, "data-sx-emit"); - return (isSxTruthy(eventName) ? domOn(el, "click", function(e) { return (function() { + return (isSxTruthy(eventName) ? domListen(el, "click", function(e) { return (function() { var detailJson = domGetAttr(el, "data-sx-emit-detail"); var detail = (isSxTruthy(detailJson) ? jsonParse(detailJson) : {}); return domDispatch(el, eventName, detail); @@ -5973,12 +5973,12 @@ PRIMITIVES["clear-stores"] = clearStores; PRIMITIVES["emit-event"] = emitEvent; // on-event - var onEvent = function(el, eventName, handler) { return domOn(el, eventName, handler); }; + var onEvent = function(el, eventName, handler) { return domListen(el, eventName, handler); }; PRIMITIVES["on-event"] = onEvent; // bridge-event var bridgeEvent = function(el, eventName, targetSignal, transformFn) { return effect(function() { return (function() { - var remove = domOn(el, eventName, function(e) { return (function() { + var remove = domListen(el, eventName, function(e) { return (function() { var detail = eventDetail(e); var newVal = (isSxTruthy(transformFn) ? cekCall(transformFn, [detail]) : detail); return reset_b(targetSignal, newVal); @@ -6327,8 +6327,8 @@ PRIMITIVES["resource"] = resource; // If lambda takes 0 params, call without event arg (convenience for on-click handlers) var wrapped = isLambda(handler) ? (lambdaParams(handler).length === 0 - ? function(e) { try { cekCall(handler, NIL); } catch(err) { console.error("[sx-ref] domListen handler error:", name, err); } } - : function(e) { try { cekCall(handler, [e]); } catch(err) { console.error("[sx-ref] domListen handler error:", name, err); } }) + ? function(e) { try { cekCall(handler, NIL); } catch(err) { console.error("[sx-ref] domListen handler error:", name, err); } finally { runPostRenderHooks(); } } + : function(e) { try { cekCall(handler, [e]); } catch(err) { console.error("[sx-ref] domListen handler error:", name, err); } finally { runPostRenderHooks(); } }) : handler; if (name === "click") logInfo("domListen: click on <" + (el.tagName||"?").toLowerCase() + "> text=" + (el.textContent||"").substring(0,20) + " isLambda=" + isLambda(handler)); var passiveEvents = { touchstart: 1, touchmove: 1, wheel: 1, scroll: 1 }; diff --git a/sx/sx/nav-data.sx b/sx/sx/nav-data.sx index f3e0af0c..a88937c0 100644 --- a/sx/sx/nav-data.sx +++ b/sx/sx/nav-data.sx @@ -443,7 +443,8 @@ :children (list {:label "SX URLs" :href "/sx/(applications.(sx-urls))"} {:label "CSSX" :href "/sx/(applications.(cssx))" :children cssx-nav-items} - {:label "Protocols" :href "/sx/(applications.(protocol))" :children protocols-nav-items})} + {:label "Protocols" :href "/sx/(applications.(protocol))" :children protocols-nav-items} + {:label "sx-pub" :href "/sx/(applications.(sx-pub))"})} {:label "Etc" :href "/sx/(etc)" :children (list {:label "Essays" :href "/sx/(etc.(essay))" :children essays-nav-items} diff --git a/sx/sx/page-functions.sx b/sx/sx/page-functions.sx index b507a8c4..ac7ac1f3 100644 --- a/sx/sx/page-functions.sx +++ b/sx/sx/page-functions.sx @@ -461,6 +461,13 @@ (define protocol (make-page-fn "~protocols/wire-format-content" "~protocols/" nil "-content")) +;; sx-pub (under applications) +(define sx-pub + (fn (slug) + (if (nil? slug) + '(~sx-pub/overview-content) + nil))) + ;; Essays (under etc) ;; Convention: ~essays/{slug}/essay-{slug} (define essay diff --git a/sx/sx/plans/sx-pub.sx b/sx/sx/plans/sx-pub.sx new file mode 100644 index 00000000..76c561d8 --- /dev/null +++ b/sx/sx/plans/sx-pub.sx @@ -0,0 +1,257 @@ +;; --------------------------------------------------------------------------- +;; sx-pub: Federated SX Publishing Protocol +;; --------------------------------------------------------------------------- + +(defcomp ~sx-pub/overview-content () + (~docs/page :title "sx-pub" + + (~docs/section :title "What It Is" :id "what" + (p "A federated content publishing protocol built entirely in SX. No JSON. Content pinned to IPFS, referenced by CID. Blockchain-anchored provenance.") + (p "One actor — " (code "sx") " — publishes SX specs, platform libraries, components, and documentation as content-addressed packages. Other sx-pub servers can follow, mirror, and refer to content by CID. Human-readable paths map to immutable IPFS addresses.") + (div :class "rounded border border-violet-200 bg-violet-50 p-4 mt-4" + (p :class "text-violet-900 font-medium" "The key insight") + (p :class "text-violet-800" "IPFS is the database. SX is the wire format. defhandlers are the endpoints. The wire format is the programming language is the component system is the package manager."))) + + ;; ----------------------------------------------------------------------- + ;; Why ActivityPub Failed — and Why LISP Fixes It + ;; ----------------------------------------------------------------------- + + (~docs/section :title "Why ActivityPub Failed" :id "why" + (p "ActivityPub's problems aren't bugs — they're consequences of a fundamental design error: " (strong "the wire format is dead data") ". JSON-LD activities describe what happened but can't " (em "do") " anything. Every consumer must independently interpret, render, and extend. The fediverse is N incompatible applications showing the same inert data differently.") + + (~docs/subsection :title "1. The Rendering Gap" + (p "An ActivityPub Note looks like this:") + (~docs/code :code "{\"type\": \"Note\",\n \"content\": \"
Hello world
\",\n \"attributedTo\": \"https://example.com/users/alice\"}") + (p "The content is an HTML string embedded in a JSON string. Two formats nested, neither evaluable. Every client — Mastodon, Pleroma, Misskey, Lemmy — parses this independently and wraps it in their own UI. A poll renders differently everywhere. An event looks different everywhere. There is no shared component model because the protocol has no concept of components.") + (p "In sx-pub, the content " (em "is") " the component:") + (~docs/code :code "(Note\n :attributed-to \"https://pub.sx-web.org/pub/actor\"\n :content (p \"Hello \" (strong \"world\")))") + (p "No HTML-in-JSON. No parsing step. The receiver evaluates the s-expression and gets rendered output. The content carries its own rendering semantics.")) + + (~docs/subsection :title "2. Inert Data vs. Evaluable Programs" + (p "A JSON-LD activity is a description. It says \"Alice created a Note with this HTML.\" The receiving server must then decide: how do I display a Note? What if it has attachments? What about custom emoji? What about a poll? Every new feature requires every implementation to add rendering code.") + (p "An SX activity is a " (em "program") ". It evaluates to its own UI. A poll isn't a JSON blob that each client renders differently — it's a " (code "(defcomp ~poll ...)") " that evaluates identically everywhere because the CEK machine has deterministic semantics. " (strong "The wire format is the renderer."))) + + (~docs/subsection :title "3. Extensions Are Impossible" + (p "ActivityPub extensions (FEPs) add new JSON fields that most implementations ignore. Want to federate a recipe, a calendar event, a code snippet with syntax highlighting? Write a spec, wait years for implementations to support it, accept that most never will.") + (p "In sx-pub, extensions are just components. Publish " (code "(defcomp ~recipe ...)") " to IPFS. Anyone who follows you gets the component. Their server can evaluate it immediately — " (strong "no implementation changes needed") ". The extension mechanism is the same as the content mechanism: pin an s-expression, reference it by CID.")) + + (~docs/subsection :title "4. No Content Integrity" + (p "ActivityPub has no content addressing. A server can silently edit a federated post. HTTP signatures verify the " (em "transport") " but not the " (em "content") ". There's no way to prove that what you see is what was originally published.") + (p "sx-pub content is IPFS-pinned and referenced by CID (content hash). The CID " (em "is") " the identity. Mutate a single character and the CID changes. Merkle trees of CIDs get anchored to Bitcoin via OpenTimestamps — cryptographic proof of what was published and when.")) + + (~docs/subsection :title "5. The N-Client Problem" + (p "Because AP content is inert, every client must build its own renderer, interaction model, and extension system. The result: Mastodon is one application, Pleroma is another, Misskey is another. They can exchange data, but the " (em "experience") " is entirely determined by the client. Rich content degrades to plain text when the client doesn't understand it.") + (p "sx-pub eliminates this entirely. Content carries its rendering logic. A server that receives " (code "(defcomp ~interactive-chart ...)") " can render it correctly without any prior knowledge of charts — because the component is self-contained, sandboxed (boundary enforcement prevents IO in pure components), and deterministic. " (strong "Every server renders the same content the same way."))) + + (~docs/subsection :title "6. No Dependency System" + (p "AP has no concept of shared code. If ten ActivityPub projects all need a Markdown parser, ten teams write ten Markdown parsers. There's no mechanism to publish reusable logic that travels between servers.") + (p "sx-pub's " (code ":requires") " field on published documents declares CID dependencies. A component that uses a chart library says " (code ":requires (list \"bafy...chart-lib\")") ". Pin the dependency tree and everything works offline. " (strong "This is a package manager built into the federation protocol.") " No npm, no crates.io, no central registry — just content-addressed s-expressions on IPFS.")) + + (~docs/subsection :title "7. The @context Illusion" + (p "JSON-LD @context was supposed to provide semantic interoperability. In practice it's a constant source of bugs. Implementations disagree on which contexts to support, which extensions to recognise, how to resolve conflicts. The W3C ActivityStreams context document is a single point of failure that the entire fediverse depends on.") + (p "SX needs no context resolution. " (code "(Publish :actor \"...\" :object (SxDocument :cid \"...\"))") " is self-describing. The s-expression " (em "is") " the meaning. Symbols are symbols. Keywords are keywords. No indirection, no remote JSON documents, no semantic web machinery.")) + + (div :class "rounded border border-rose-200 bg-rose-50 p-4 mt-6" + (p :class "text-rose-900 font-medium" "The fundamental difference") + (p :class "text-rose-800" "ActivityPub federates " (em "descriptions") " of content. Consumers must independently figure out how to present them. sx-pub federates " (em "programs") " — self-contained, evaluable, deterministic, content-addressed. The gap between \"data\" and \"application\" disappears because in LISP, they were never separate to begin with."))) + + ;; ----------------------------------------------------------------------- + ;; Wire Format + ;; ----------------------------------------------------------------------- + + (~docs/section :title "Wire Format" :id "wire-format" + (p "Everything is s-expressions. No JSON-LD, no @context resolution, no nested HTML-in-JSON strings.") + + (~docs/subsection :title "Actor" + (~docs/code :code "(SxActor\n :id \"https://pub.sx-web.org/pub/actor\"\n :type \"SxPublisher\"\n :name \"sx\"\n :summary \"SX language — specs, platforms, components\"\n :public-key-pem \"-----BEGIN PUBLIC KEY-----\\n...\"\n :inbox \"/pub/inbox\"\n :outbox \"/pub/outbox\"\n :followers \"/pub/followers\"\n :following \"/pub/following\"\n :collections (list\n (SxCollection :name \"core-specs\" :href \"/pub/core-specs\")\n (SxCollection :name \"platforms\" :href \"/pub/platforms\")\n (SxCollection :name \"components\" :href \"/pub/components\")))")) + + (~docs/subsection :title "Publish Activity" + (p "When content is published, it's pinned to IPFS and announced to followers:") + (~docs/code :code "(Publish\n :actor \"https://pub.sx-web.org/pub/actor\"\n :published \"2026-03-24T12:00:00Z\"\n :object (SxDocument\n :name \"evaluator\"\n :path \"/pub/core-specs/evaluator\"\n :cid \"bafy...eval123\"\n :content-type \"text/sx\"\n :size 48000\n :hash \"sha3-256:abc123...\"\n :requires (list \"bafy...parser\" \"bafy...primitives\")\n :summary \"CEK machine evaluator — frames, utils, step function\"))") + (p "The " (code ":requires") " field declares CID dependencies — a component that imports the parser declares that dependency. Receivers can pin the full dependency graph.")) + + (~docs/subsection :title "Follow — Subscribe to a Remote Server" + (~docs/code :code "(Follow\n :actor \"https://pub.sx-web.org/pub/actor\"\n :object \"https://other.example.com/pub/actor\")") + (p "When accepted, the remote server delivers " (code "(Publish ...)") " activities to our inbox. We pin their CIDs locally.")) + + (~docs/subsection :title "Announce — Mirror Remote Content" + (~docs/code :code "(Announce\n :actor \"https://pub.sx-web.org/pub/actor\"\n :object \"bafy...remote-cid\")") + (p "Re-publish content from a followed server to our own followers. The CID is already pinned — we're just signalling that we endorse it.")) + + (~docs/subsection :title "Anchor — Blockchain Provenance Record" + (~docs/code :code "(Anchor\n :tree-cid \"bafy...merkle-tree\"\n :leaf-index 42\n :ots-cid \"bafy...ots-proof\"\n :btc-txid \"abc123...def\"\n :btc-block 890123\n :anchored-at \"2026-03-24T12:00:00Z\")") + (p "Embedded in every " (code "(Publish ...)") " activity. Receivers can verify: the Merkle tree was anchored in that Bitcoin block, and the CID is a leaf in that tree. Inclusion proof is verifiable offline."))) + + ;; ----------------------------------------------------------------------- + ;; Endpoints + ;; ----------------------------------------------------------------------- + + (~docs/section :title "Endpoints" :id "endpoints" + (p "All endpoints are " (code "defhandler") " definitions in " (code ".sx") " files. Python provides only IO primitives behind the boundary.") + + (table :class "w-full text-sm" + (thead + (tr :class "border-b border-stone-200 text-left" + (th :class "py-2 pr-4" "Method") + (th :class "py-2 pr-4" "Route") + (th :class "py-2" "Purpose"))) + (tbody :class "text-stone-600" + (tr :class "border-b border-stone-100" + (td :class "py-2 pr-4 font-mono text-xs" "GET") (td :class "py-2 pr-4 font-mono text-xs" "/pub/actor") (td :class "py-2" "Actor profile")) + (tr :class "border-b border-stone-100" + (td :class "py-2 pr-4 font-mono text-xs" "GET") (td :class "py-2 pr-4 font-mono text-xs" "/pub/outbox") (td :class "py-2" "Published activity feed")) + (tr :class "border-b border-stone-100" + (td :class "py-2 pr-4 font-mono text-xs" "POST") (td :class "py-2 pr-4 font-mono text-xs" "/pub/inbox") (td :class "py-2" "Receive activities from remote servers")) + (tr :class "border-b border-stone-100" + (td :class "py-2 pr-4 font-mono text-xs" "GET") (td :class "py-2 pr-4 font-mono text-xs" "/pub/⟨collection⟩") (td :class "py-2" "List items in a collection")) + (tr :class "border-b border-stone-100" + (td :class "py-2 pr-4 font-mono text-xs" "GET") (td :class "py-2 pr-4 font-mono text-xs" "/pub/⟨collection⟩/⟨slug⟩") (td :class "py-2" "Document by path — resolves CID, fetches from IPFS")) + (tr :class "border-b border-stone-100" + (td :class "py-2 pr-4 font-mono text-xs" "GET") (td :class "py-2 pr-4 font-mono text-xs" "/pub/cid/⟨cid⟩") (td :class "py-2" "Direct CID fetch (immutable, cache forever)")) + (tr :class "border-b border-stone-100" + (td :class "py-2 pr-4 font-mono text-xs" "POST") (td :class "py-2 pr-4 font-mono text-xs" "/pub/publish") (td :class "py-2" "Pin to IPFS + create Publish activity + announce")) + (tr :class "border-b border-stone-100" + (td :class "py-2 pr-4 font-mono text-xs" "POST") (td :class "py-2 pr-4 font-mono text-xs" "/pub/follow") (td :class "py-2" "Follow a remote sx-pub server")) + (tr :class "border-b border-stone-100" + (td :class "py-2 pr-4 font-mono text-xs" "GET") (td :class "py-2 pr-4 font-mono text-xs" "/pub/followers") (td :class "py-2" "Who follows us")) + (tr :class "border-b border-stone-100" + (td :class "py-2 pr-4 font-mono text-xs" "GET") (td :class "py-2 pr-4 font-mono text-xs" "/pub/following") (td :class "py-2" "Who we follow")) + (tr :class "border-b border-stone-100" + (td :class "py-2 pr-4 font-mono text-xs" "GET") (td :class "py-2 pr-4 font-mono text-xs" "/.well-known/webfinger") (td :class "py-2" "Discovery")) + (tr + (td :class "py-2 pr-4 font-mono text-xs" "GET") (td :class "py-2 pr-4 font-mono text-xs" "/pub/anchor/⟨tree-cid⟩") (td :class "py-2" "Merkle tree verification"))))) + + ;; ----------------------------------------------------------------------- + ;; Storage + ;; ----------------------------------------------------------------------- + + (~docs/section :title "Storage" :id "storage" + (p "IPFS is the canonical content store. PostgreSQL is the local index.") + + (table :class "w-full text-sm" + (thead + (tr :class "border-b border-stone-200 text-left" + (th :class "py-2 pr-4" "What") + (th :class "py-2" "Where"))) + (tbody :class "text-stone-600" + (tr :class "border-b border-stone-100" + (td :class "py-2 pr-4" "Content (SX source)") (td :class "py-2" "IPFS — pinned, referenced by CID")) + (tr :class "border-b border-stone-100" + (td :class "py-2 pr-4" "Path → CID index") (td :class "py-2" "PostgreSQL")) + (tr :class "border-b border-stone-100" + (td :class "py-2 pr-4" "Outbox activities") (td :class "py-2" "PostgreSQL")) + (tr :class "border-b border-stone-100" + (td :class "py-2 pr-4" "Followers / following") (td :class "py-2" "PostgreSQL")) + (tr :class "border-b border-stone-100" + (td :class "py-2 pr-4" "Anchor records") (td :class "py-2" "PostgreSQL")) + (tr :class "border-b border-stone-100" + (td :class "py-2 pr-4" "Remote mirrored content") (td :class "py-2" "PostgreSQL + IPFS (pinned locally)")) + (tr :class "border-b border-stone-100" + (td :class "py-2 pr-4" "Content cache") (td :class "py-2" "Redis")) + (tr + (td :class "py-2 pr-4" "Actor keypair") (td :class "py-2" "PostgreSQL"))))) + + ;; ----------------------------------------------------------------------- + ;; SX / Python Split + ;; ----------------------------------------------------------------------- + + (~docs/section :title "SX / Python Split" :id "split" + (p "SX handles all composition, logic, and rendering. Python provides IO primitives behind the boundary.") + + (div :class "grid grid-cols-2 gap-6" + (div + (h4 :class "font-semibold text-stone-700 mb-2" "Pure SX (defhandler + defcomp)") + (ul :class "text-sm text-stone-600 space-y-1 list-disc pl-5" + (li "All HTTP routing and request dispatch") + (li "Content negotiation (Accept header)") + (li "Response construction (SX actor docs, collections)") + (li "UI components for browsing") + (li "Activity serialization") + (li "Nav data for /pub/ sections"))) + (div + (h4 :class "font-semibold text-stone-700 mb-2" "Python Helpers (IO boundary)") + (ul :class "text-sm text-stone-600 space-y-1 list-disc pl-5" + (li (code "pub-ipfs-add") " / " (code "pub-ipfs-get") " / " (code "pub-ipfs-pin") " — IPFS ops") + (li (code "pub-db-query") " / " (code "pub-db-insert") " — database") + (li (code "pub-sign") " / " (code "pub-verify-signature") " — HTTP signatures") + (li (code "pub-hash") " — SHA3-256") + (li (code "pub-anchor-batch") " — OpenTimestamps") + (li (code "pub-deliver") " — POST to follower inboxes"))))) + + ;; ----------------------------------------------------------------------- + ;; Flows + ;; ----------------------------------------------------------------------- + + (~docs/section :title "Flows" :id "flows" + + (~docs/subsection :title "Publish" + (p :class "text-sm text-stone-600 mb-2" "Publishing is " (strong "anchor-then-announce") " — content is provenance-sealed before anyone sees it. This prevents a malicious follower from seeing content, anchoring it first, and claiming provenance.") + (ol :class "text-sm text-stone-600 space-y-2 list-decimal pl-5" + (li (code "POST /pub/publish") " with collection, slug, SX content") + (li "Pin to IPFS → get CID (content is now addressable but not announced)") + (li "Store path→CID in DB (status: " (code "anchoring") ")") + (li "Build Merkle tree, submit to OpenTimestamps, wait for Bitcoin confirmation") + (li "On confirmation: update status to " (code "anchored") ", record proof") + (li "Construct " (code "(Publish ...)") " with anchor proof embedded") + (li "Deliver to follower inboxes — " (strong "only now") " is it public")) + (p :class "text-sm text-stone-500 mt-2 italic" "This means publishing isn't instant — there's a delay while the anchor confirms. That's a feature: it forces a deliberate pace and makes provenance airtight.") + (~docs/code :code "(Publish\n :actor \"https://pub.sx-web.org/pub/actor\"\n :published \"2026-03-24T12:00:00Z\"\n :object (SxDocument\n :name \"evaluator\"\n :cid \"bafy...eval123\"\n :anchor (Anchor\n :tree-cid \"bafy...merkle\"\n :ots-cid \"bafy...proof\"\n :btc-txid \"abc123...\"\n :btc-block 890123)))") + (p :class "text-sm text-stone-600" "Receivers verify: the CID was anchored in that Bitcoin block " (em "before") " the Publish was sent. No way to backdate.")) + + (~docs/subsection :title "Follow" + (ol :class "text-sm text-stone-600 space-y-2 list-decimal pl-5" + (li (code "POST /pub/follow") " with remote actor URL") + (li "Fetch remote actor document (" (code "text/sx") ")") + (li "Send " (code "(Follow ...)") " to their inbox, signed with our key") + (li "They verify signature, add us to followers, send " (code "(Accept ...)")) + (li "We store them in our following list") + (li "They start delivering " (code "(Publish ...)") " activities to our inbox"))) + + (~docs/subsection :title "Receive (inbox)" + (ol :class "text-sm text-stone-600 space-y-2 list-decimal pl-5" + (li "Remote server POSTs " (code "(Publish ...)") " to our inbox") + (li "Verify HTTP signature against their actor's public key") + (li "Pin the referenced CID to our local IPFS node") + (li "Index the remote content in our DB (marked as mirrored)") + (li "Now we can resolve that CID locally — " (code "/pub/cid/bafy...") " just works"))) + + (~docs/subsection :title "Anchoring" + (p :class "text-sm text-stone-600 mb-2" "Anchoring is integrated into the publish flow — not a separate background job. Content waits for provenance before going public.") + (ol :class "text-sm text-stone-600 space-y-2 list-decimal pl-5" + (li "Batch pending CIDs into a Merkle tree (multiple publishes can share one tree)") + (li "Pin Merkle tree to IPFS") + (li "Submit Merkle root to OpenTimestamps calendar servers") + (li "Poll for Bitcoin confirmation (typically ~1 hour)") + (li "On confirmation: store proof, transition content to " (code "anchored") " status") + (li "Trigger delivery of Publish activities for all items in the batch")) + (p :class "text-sm text-stone-500 mt-2 italic" "Batching amortises the anchor cost — one Bitcoin attestation can seal hundreds of CIDs via the Merkle tree. Each CID gets an inclusion proof linking it to the anchored root."))) + + ;; ----------------------------------------------------------------------- + ;; CID ↔ Path + ;; ----------------------------------------------------------------------- + + (~docs/section :title "CID ↔ Path Translation" :id "cid-path" + (p "Published SX refers to dependencies by CID — the immutable, universal identifier. But humans browse by path.") + + (~docs/code :code ";; Canonical SX (stored in IPFS) references by CID\n(defcomp ~my-app/dashboard (&key data)\n ;; This component depends on a chart library published at another CID\n (let ((chart-lib (require \"bafy...chart-lib-cid\")))\n (div :class \"dashboard\"\n (chart-lib/bar-chart :data data))))\n\n;; But on pub.sx-web.org you browse by path:\n;; /pub/components/dashboard\n;; /pub/core-specs/evaluator\n;; /pub/platforms/javascript\n;;\n;; The DB maps: path → CID → IPFS content\n;; Following a remote server maps: their-path → CID → our local pin") + + (p "When you follow another sx-pub server and they publish a component, you get the " (code "(Publish ...)") " activity with the CID. Your server pins it. Now " (code "(require \"bafy...\")") " resolves locally — no network round-trip, no central registry, no package manager.")) + + ;; ----------------------------------------------------------------------- + ;; Phases + ;; ----------------------------------------------------------------------- + + (~docs/section :title "Implementation Phases" :id "phases" + (div :class "space-y-4" + (div :class "rounded border border-emerald-200 bg-emerald-50 p-4" + (h4 :class "font-semibold text-emerald-800" "Phase 1: Foundation") + (p :class "text-emerald-700 text-sm" "DB schema, async IPFS client, actor endpoint, webfinger, " (code "/pub/actor") " returns SX actor document.")) + (div :class "rounded border border-sky-200 bg-sky-50 p-4" + (h4 :class "font-semibold text-sky-800" "Phase 2: Publishing") + (p :class "text-sky-700 text-sm" "Pin to IPFS, path→CID index, collection browsing. Publish the actual SX spec files as the first content.")) + (div :class "rounded border border-violet-200 bg-violet-50 p-4" + (h4 :class "font-semibold text-violet-800" "Phase 3: Federation") + (p :class "text-violet-700 text-sm" "Inbox/outbox, follow/accept, HTTP signature verification, activity delivery, content mirroring.")) + (div :class "rounded border border-amber-200 bg-amber-50 p-4" + (h4 :class "font-semibold text-amber-800" "Phase 4: Anchoring") + (p :class "text-amber-700 text-sm" "Merkle trees, OpenTimestamps, Bitcoin proof, provenance UI."))))))