From ac4fa881cedb1bb3e97c1dc45fed01e74487dde2 Mon Sep 17 00:00:00 2001 From: Alan Shaw Date: Thu, 6 Aug 2026 18:27:06 +0200 Subject: [PATCH 1/3] feat: add UCAN revocation service --- .env | 1 + CLAUDE.md | 4 ++- README.md | 2 ++ compose.yml | 1 + embed.go | 5 ++- pkg/generate/keys.go | 1 + pkg/stack/options.go | 11 +++++++ pkg/stack/stack.go | 19 +++++++++++ pkg/stack/testmode_env.go | 2 ++ pkg/workspace/workspace.go | 1 + systems/hilt/compose.yml | 3 ++ systems/ingot/compose.yml | 3 ++ systems/swarf/README.md | 64 ++++++++++++++++++++++++++++++++++++ systems/swarf/compose.yml | 67 ++++++++++++++++++++++++++++++++++++++ tests/e2e/smoke_test.go | 4 +++ 15 files changed, 184 insertions(+), 4 deletions(-) create mode 100644 systems/swarf/README.md create mode 100644 systems/swarf/compose.yml diff --git a/.env b/.env index d0bb941..5e9f76e 100644 --- a/.env +++ b/.env @@ -17,6 +17,7 @@ #HILT_IMAGE= #INGOT_IMAGE= #PLC_IMAGE= +#SWARF_IMAGE= # Forge Client #GUPPY_IMAGE= diff --git a/CLAUDE.md b/CLAUDE.md index a73c0e3..0ae0061 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -74,7 +74,7 @@ smelt/ │ ├── upload/ # Upload orchestration (mock w3infra) │ ├── hilt/ # Tenant management (Fil One Tenant API + UCAN RPC) │ ├── plc/ # did:plc directory (reference impl; hilt publishes tenant DIDs here) -│ ├── ingot/ # S3 facade (built from sibling ../ingot checkout) +│ ├── swarf/ # UCAN revocation service (+ its own postgres) │ ├── guppy/ # CLI client │ ├── ingot/ # S3 gateway over Forge (+ its own postgres) │ ├── telemetry/ # Observability stack (present but not wired into Makefile) @@ -317,6 +317,8 @@ All host-side ports live in a dedicated `15XXX` range to avoid collision with co | plc-postgres | 15121 | PostgreSQL | did:plc directory store | | ingot | 15130 | S3/HTTP | S3 gateway over Forge | | ingot-postgres | 15131 | PostgreSQL | Ingot registry/metadata | +| swarf | 15140 | HTTP/UCAN | UCAN revocation service | +| swarf-postgres | 15141 | PostgreSQL | Swarf revocation store | | guppy | (none) | CLI | Client container | **Piri Shared Storage** (only emitted when at least one node uses that backend): diff --git a/README.md b/README.md index 6476127..89a764a 100644 --- a/README.md +++ b/README.md @@ -78,6 +78,8 @@ All host ports live in a dedicated `15XXX` range to avoid collision with common | plc-postgres | 15121 | Postgres for the did:plc directory | | ingot | 15130 | S3 gateway over Forge | | ingot-postgres | 15131 | Postgres for ingot's registry/metadata | +| swarf | 15140 | UCAN revocation service | +| swarf-postgres | 15141 | Postgres for swarf's revocation store | | guppy | — | CLI client for uploads (no exposed port) | Piri's shared storage backends are generated from `smelt.yml` and only run when diff --git a/compose.yml b/compose.yml index 9dd58cf..5bd4d91 100644 --- a/compose.yml +++ b/compose.yml @@ -25,6 +25,7 @@ include: - path: systems/upload/compose.yml - path: systems/guppy/compose.yml - path: systems/plc/compose.yml + - path: systems/swarf/compose.yml - path: systems/hilt/compose.yml - path: systems/ingot/compose.yml diff --git a/embed.go b/embed.go index ad01dd9..fc87263 100644 --- a/embed.go +++ b/embed.go @@ -40,13 +40,12 @@ import "embed" //go:embed systems/signing-service/compose.yml //go:embed systems/signing-service/config/* +//go:embed systems/swarf/compose.yml + //go:embed systems/upload/compose.yml //go:embed systems/upload/config/* //go:embed systems/upload/post_start.sh -//go:embed systems/ingot/compose.yml -//go:embed systems/ingot/config/* - // Curated snapshots shipped with the Go module so external consumers // (importers of pkg/stack) can call stack.WithEmbeddedSnapshot without // knowing anything about smelt's on-disk layout. New directories diff --git a/pkg/generate/keys.go b/pkg/generate/keys.go index e7bab4a..fa89a0f 100644 --- a/pkg/generate/keys.go +++ b/pkg/generate/keys.go @@ -27,6 +27,7 @@ var nonPiriServiceKeys = []string{ "guppy", "hilt", "ingot", + "swarf", } // GenerateKeys generates all cryptographic keys for the stack. diff --git a/pkg/stack/options.go b/pkg/stack/options.go index 70e6b1b..2c19e13 100644 --- a/pkg/stack/options.go +++ b/pkg/stack/options.go @@ -26,6 +26,7 @@ type config struct { blockchainImage string ipniImage string ingotImage string + swarfImage string // Binary injection: bind-mount host-built binaries over the published // images instead of rebuilding the image. serviceBinaries holds explicit @@ -94,6 +95,9 @@ func (c *config) buildEnv() map[string]string { if c.ingotImage != "" { env["INGOT_IMAGE"] = c.ingotImage } + if c.swarfImage != "" { + env["SWARF_IMAGE"] = c.swarfImage + } return env } @@ -254,6 +258,13 @@ func WithIngotImage(image string) Option { } } +// WithSwarfImage sets the swarf (UCAN revocation) container image. +func WithSwarfImage(image string) Option { + return func(c *config) { + c.swarfImage = image + } +} + // WithTimeout sets the maximum time to wait for the stack to start. func WithTimeout(d time.Duration) Option { return func(c *config) { diff --git a/pkg/stack/stack.go b/pkg/stack/stack.go index c95efb7..3c5327c 100644 --- a/pkg/stack/stack.go +++ b/pkg/stack/stack.go @@ -252,6 +252,8 @@ func NewStack(ctx context.Context, t *testing.T, opts ...Option) (*Stack, error) WaitForService("delegator", wait.ForHTTP("/healthcheck").WithPort("80/tcp").WithStartupTimeout(2*time.Minute)). WaitForService("email", wait.ForHTTP("/api/server").WithPort("80/tcp").WithStartupTimeout(2*time.Minute)). WaitForService("plc", wait.ForHTTP("/_health").WithPort("3000/tcp").WithStartupTimeout(2*time.Minute)). + // Swarf has no /health route; GET / is the server-info endpoint. + WaitForService("swarf", wait.ForHTTP("/").WithPort("80/tcp").WithStartupTimeout(2*time.Minute)). WaitForService("hilt", wait.ForHTTP("/health").WithPort("80/tcp").WithStartupTimeout(2*time.Minute)). WaitForService("ingot", wait.ForHTTP("/health").WithPort("9000/tcp").WithStartupTimeout(2*time.Minute)) @@ -442,6 +444,23 @@ func (s *Stack) IngotEndpoint() string { return fmt.Sprintf("http://%s:%s", host, port.Port()) } +// SwarfEndpoint returns the HTTP endpoint for the swarf revocation service. +func (s *Stack) SwarfEndpoint() string { + container, err := s.compose.ServiceContainer(context.Background(), "swarf") + if err != nil { + s.t.Fatalf("getting swarf container: %v", err) + } + host, err := container.Host(context.Background()) + if err != nil { + s.t.Fatalf("getting swarf host: %v", err) + } + port, err := container.MappedPort(context.Background(), "80/tcp") + if err != nil { + s.t.Fatalf("getting swarf port: %v", err) + } + return fmt.Sprintf("http://%s:%s", host, port.Port()) +} + // maybeBinaryOverride builds workspace-selected service binaries (when enabled) // and writes a compose override mounting them — plus any explicitly-provided // binaries (WithServiceBinary / WithPiriBinary) — over the published images. diff --git a/pkg/stack/testmode_env.go b/pkg/stack/testmode_env.go index 8ef9038..99d983b 100644 --- a/pkg/stack/testmode_env.go +++ b/pkg/stack/testmode_env.go @@ -55,6 +55,8 @@ func testModeEnv(nodes []manifest.ResolvedPiriNode) map[string]string { "SMELT_PLC_POSTGRES_PORT": "5432", "SMELT_INGOT_PORT": "9000", "SMELT_INGOT_POSTGRES_PORT": "5432", + "SMELT_SWARF_PORT": "80", + "SMELT_SWARF_POSTGRES_PORT": "5432", // Piri shared infra — only used when any node declares postgres/s3, // but harmless to set unconditionally (compose ignores unknown diff --git a/pkg/workspace/workspace.go b/pkg/workspace/workspace.go index 69b74de..6e0c06b 100644 --- a/pkg/workspace/workspace.go +++ b/pkg/workspace/workspace.go @@ -50,6 +50,7 @@ var Services = map[string]serviceBuild{ "guppy": {moduleDir: "guppy", buildTarget: ".", binPath: "/usr/bin/guppy"}, "hilt": {moduleDir: "hilt", buildTarget: "./cmd/main.go", binPath: "/usr/bin/hilt"}, "ingot": {moduleDir: "ingot", buildTarget: "./cmd/ingot", binPath: "/usr/bin/ingot", configPath: "/etc/ingot/config.yaml"}, + "swarf": {moduleDir: "swarf", buildTarget: "./cmd/swarf", binPath: "/usr/bin/swarf", configPath: "/etc/swarf/config.yaml"}, } // libforgeDir is the workspace dir of the shared library. Its presence in the diff --git a/systems/hilt/compose.yml b/systems/hilt/compose.yml index 84c776e..a2cd0b2 100644 --- a/systems/hilt/compose.yml +++ b/systems/hilt/compose.yml @@ -51,6 +51,9 @@ services: condition: service_healthy plc: condition: service_healthy + # Startup ordering only — hilt is not configured to call swarf yet + swarf: + condition: service_healthy hilt-postgres: condition: service_healthy hilt-vault: diff --git a/systems/ingot/compose.yml b/systems/ingot/compose.yml index 4339f63..c70fe9f 100644 --- a/systems/ingot/compose.yml +++ b/systems/ingot/compose.yml @@ -33,6 +33,9 @@ services: condition: service_healthy indexer: condition: service_healthy + # Startup ordering only — ingot is not configured to call swarf yet + swarf: + condition: service_healthy ingot-postgres: condition: service_healthy healthcheck: diff --git a/systems/swarf/README.md b/systems/swarf/README.md new file mode 100644 index 0000000..cf94724 --- /dev/null +++ b/systems/swarf/README.md @@ -0,0 +1,64 @@ +# Swarf (UCAN Revocation) + +Swarf is the Forge network's UCAN revocation service. It accepts +`ucan/revoke` invocations over a UCAN RPC endpoint, stores revocation +records (revoked delegation CID + delegation path witness + cause +invocation) in Postgres, and serves them back over HTTP: + +- `POST /` — UCAN RPC (`ucan/revoke`) +- `GET /revocation/:cid` — DAG-JSON revocation record (404 if absent) +- `GET /revocations/:since` — SSE firehose of revocation records + (`0` or an RFC3339 timestamp) +- `GET /.well-known/did.json` — did:web document +- `GET /` — server info (also the healthcheck target; swarf has no + `/health` route) + +## Services + +- **swarf** - Revocation service (`ghcr.io/fil-forge/swarf:main`) +- **swarf-postgres** - PostgreSQL for swarf's revocation store (goose + migrations run at swarf startup) + +## Ports + +| Host Port | Container Port | Service | Description | +|-----------|----------------|---------|-------------| +| 15140 | 80 | swarf | UCAN RPC (`POST /`), revocation reads, did:web doc | +| 15141 | 5432 | swarf-postgres | PostgreSQL | + +## Configuration + +All configuration is via `SWARF_*` environment variables in `compose.yml`: + +- Identity: `/keys/swarf.pem` wrapped as `did:web:swarf` (DID document + served at `/.well-known/did.json`). +- Storage: postgres via the `swarf-postgres` sidecar (dev-only + `swarf:swarf` credentials; data persists in the `swarf-postgres-data` + volume). +- `SWARF_SERVER_INSECURE_DID_RESOLUTION=true` so did:web documents + resolve over plain HTTP inside the compose network. + +## Keys + +- `../../generated/keys/swarf.pem` - Swarf service identity (Ed25519) + +## Volumes + +- `swarf-postgres-data` - Swarf's revocation records + +## Dependencies + +- swarf-postgres (service_healthy) + +## Smoke Test + +```bash +curl -sf http://localhost:15140/ # server info banner +curl -s http://localhost:15140/.well-known/did.json | jq .id # "did:web:swarf" +curl -si http://localhost:15140/revocation/bafyreib3mqe6t2z3xwqcwoohw6f5o5t5nprfltmbjbmzynyqcnpxrcut4q # 404 +``` + +## Used By + +- Hilt and Ingot — startup ordering is wired (`depends_on`), but neither + is configured to call swarf yet. diff --git a/systems/swarf/compose.yml b/systems/swarf/compose.yml new file mode 100644 index 0000000..c5cf9d0 --- /dev/null +++ b/systems/swarf/compose.yml @@ -0,0 +1,67 @@ +# Swarf System - UCAN revocation service +# +# Accepts ucan/revoke invocations over UCAN RPC, stores revocation records +# (revoked delegation CID + path witness + cause) in Postgres, and serves +# them back over HTTP plus an SSE firehose. +# Used by: hilt, ingot (config wiring pending) + +services: + swarf: + image: ${SWARF_IMAGE:-ghcr.io/fil-forge/swarf:main} + user: "0:0" # Run as root for volume permissions (key files are 600) + ports: + # Internal port 80 for did:web resolution + - "${SMELT_SWARF_PORT:-15140:80}" + environment: + - SWARF_SERVER_HOST=0.0.0.0 + - SWARF_SERVER_PORT=80 + - SWARF_IDENTITY_KEY_FILE=/keys/swarf.pem + - SWARF_IDENTITY_SERVICE_ID=did:web:swarf + - SWARF_STORAGE_TYPE=postgres + # Dev-only creds, like hilt's hilt:hilt + - SWARF_STORAGE_POSTGRES_DSN=postgres://swarf:swarf@swarf-postgres:5432/swarf?sslmode=disable + # Resolve did:web documents over plain HTTP inside the compose network + - SWARF_SERVER_INSECURE_DID_RESOLUTION=true + - SWARF_LOG_LEVEL=info + volumes: + - ../../generated/keys/swarf.pem:/keys/swarf.pem:ro + depends_on: + swarf-postgres: + condition: service_healthy + healthcheck: + # Swarf has no /health route; GET / is the server-info endpoint + test: ["CMD", "curl", "-sf", "http://localhost:80/"] + start_interval: 1s + interval: 10s + timeout: 5s + retries: 5 + start_period: 10s + restart: unless-stopped + networks: + - forge-network + + # Postgres for swarf's revocation store. Swarf runs goose migrations on + # startup, so the `swarf` database created by POSTGRES_DB is enough. + swarf-postgres: + image: postgres:16-alpine + ports: + - "${SMELT_SWARF_POSTGRES_PORT:-15141:5432}" + environment: + - POSTGRES_USER=swarf + - POSTGRES_PASSWORD=swarf + - POSTGRES_DB=swarf + volumes: + - swarf-postgres-data:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U swarf -d swarf"] + start_interval: 1s + interval: 5s + timeout: 3s + retries: 10 + start_period: 10s + restart: unless-stopped + networks: + - forge-network + +volumes: + swarf-postgres-data: diff --git a/tests/e2e/smoke_test.go b/tests/e2e/smoke_test.go index 543e583..90e4870 100644 --- a/tests/e2e/smoke_test.go +++ b/tests/e2e/smoke_test.go @@ -67,6 +67,10 @@ func TestUploadAndRetrieve(t *testing.T) { // Here we only assert the definition boots to healthy. waitHTTPOK(t, s.IngotEndpoint()+"/health", 2*time.Minute) + // Same for swarf (no /health route; GET / is the server-info + // endpoint). + waitHTTPOK(t, s.SwarfEndpoint()+"/", 2*time.Minute) + gup, err := guppy.NewContainerClient(s) if err != nil { t.Fatal(err) From b9240747993e16258ece53def47fd9b84192ea1b Mon Sep 17 00:00:00 2001 From: Alan Shaw Date: Fri, 7 Aug 2026 10:50:13 +0200 Subject: [PATCH 2/3] feat: add healthcheck --- pkg/stack/stack.go | 3 +-- systems/swarf/README.md | 5 +++-- systems/swarf/compose.yml | 3 +-- tests/e2e/smoke_test.go | 5 ++--- 4 files changed, 7 insertions(+), 9 deletions(-) diff --git a/pkg/stack/stack.go b/pkg/stack/stack.go index 3c5327c..8ce51a3 100644 --- a/pkg/stack/stack.go +++ b/pkg/stack/stack.go @@ -252,8 +252,7 @@ func NewStack(ctx context.Context, t *testing.T, opts ...Option) (*Stack, error) WaitForService("delegator", wait.ForHTTP("/healthcheck").WithPort("80/tcp").WithStartupTimeout(2*time.Minute)). WaitForService("email", wait.ForHTTP("/api/server").WithPort("80/tcp").WithStartupTimeout(2*time.Minute)). WaitForService("plc", wait.ForHTTP("/_health").WithPort("3000/tcp").WithStartupTimeout(2*time.Minute)). - // Swarf has no /health route; GET / is the server-info endpoint. - WaitForService("swarf", wait.ForHTTP("/").WithPort("80/tcp").WithStartupTimeout(2*time.Minute)). + WaitForService("swarf", wait.ForHTTP("/health").WithPort("80/tcp").WithStartupTimeout(2*time.Minute)). WaitForService("hilt", wait.ForHTTP("/health").WithPort("80/tcp").WithStartupTimeout(2*time.Minute)). WaitForService("ingot", wait.ForHTTP("/health").WithPort("9000/tcp").WithStartupTimeout(2*time.Minute)) diff --git a/systems/swarf/README.md b/systems/swarf/README.md index cf94724..eee2c4d 100644 --- a/systems/swarf/README.md +++ b/systems/swarf/README.md @@ -10,8 +10,8 @@ invocation) in Postgres, and serves them back over HTTP: - `GET /revocations/:since` — SSE firehose of revocation records (`0` or an RFC3339 timestamp) - `GET /.well-known/did.json` — did:web document -- `GET /` — server info (also the healthcheck target; swarf has no - `/health` route) +- `GET /health` — health status +- `GET /` — server info ## Services @@ -53,6 +53,7 @@ All configuration is via `SWARF_*` environment variables in `compose.yml`: ## Smoke Test ```bash +curl -sf http://localhost:15140/health # {"status":"healthy"} curl -sf http://localhost:15140/ # server info banner curl -s http://localhost:15140/.well-known/did.json | jq .id # "did:web:swarf" curl -si http://localhost:15140/revocation/bafyreib3mqe6t2z3xwqcwoohw6f5o5t5nprfltmbjbmzynyqcnpxrcut4q # 404 diff --git a/systems/swarf/compose.yml b/systems/swarf/compose.yml index c5cf9d0..8cd9098 100644 --- a/systems/swarf/compose.yml +++ b/systems/swarf/compose.yml @@ -29,8 +29,7 @@ services: swarf-postgres: condition: service_healthy healthcheck: - # Swarf has no /health route; GET / is the server-info endpoint - test: ["CMD", "curl", "-sf", "http://localhost:80/"] + test: ["CMD", "curl", "-sf", "http://localhost:80/health"] start_interval: 1s interval: 10s timeout: 5s diff --git a/tests/e2e/smoke_test.go b/tests/e2e/smoke_test.go index 90e4870..9164a0f 100644 --- a/tests/e2e/smoke_test.go +++ b/tests/e2e/smoke_test.go @@ -67,9 +67,8 @@ func TestUploadAndRetrieve(t *testing.T) { // Here we only assert the definition boots to healthy. waitHTTPOK(t, s.IngotEndpoint()+"/health", 2*time.Minute) - // Same for swarf (no /health route; GET / is the server-info - // endpoint). - waitHTTPOK(t, s.SwarfEndpoint()+"/", 2*time.Minute) + // Same for swarf. + waitHTTPOK(t, s.SwarfEndpoint()+"/health", 2*time.Minute) gup, err := guppy.NewContainerClient(s) if err != nil { From 57e5564210380ab9cd0e610fb09b96f8d1e62d12 Mon Sep 17 00:00:00 2001 From: Alan Shaw Date: Fri, 7 Aug 2026 11:22:07 +0200 Subject: [PATCH 3/3] fix: use PLC service --- systems/swarf/README.md | 4 ++++ systems/swarf/compose.yml | 5 +++++ 2 files changed, 9 insertions(+) diff --git a/systems/swarf/README.md b/systems/swarf/README.md index eee2c4d..1f542cf 100644 --- a/systems/swarf/README.md +++ b/systems/swarf/README.md @@ -37,6 +37,9 @@ All configuration is via `SWARF_*` environment variables in `compose.yml`: volume). - `SWARF_SERVER_INSECURE_DID_RESOLUTION=true` so did:web documents resolve over plain HTTP inside the compose network. +- did:plc directory: the local reference server at `http://plc:3000` + (`SWARF_PLC_DIRECTORY`), used to resolve did:plc issuers such as hilt + tenant identities. ## Keys @@ -48,6 +51,7 @@ All configuration is via `SWARF_*` environment variables in `compose.yml`: ## Dependencies +- plc (service_healthy) - swarf-postgres (service_healthy) ## Smoke Test diff --git a/systems/swarf/compose.yml b/systems/swarf/compose.yml index 8cd9098..6560b78 100644 --- a/systems/swarf/compose.yml +++ b/systems/swarf/compose.yml @@ -22,10 +22,15 @@ services: - SWARF_STORAGE_POSTGRES_DSN=postgres://swarf:swarf@swarf-postgres:5432/swarf?sslmode=disable # Resolve did:web documents over plain HTTP inside the compose network - SWARF_SERVER_INSECURE_DID_RESOLUTION=true + # Resolve did:plc DIDs (e.g. hilt tenant identities) against the local + # PLC directory instead of the default https://plc.directory + - SWARF_PLC_DIRECTORY=http://plc:3000 - SWARF_LOG_LEVEL=info volumes: - ../../generated/keys/swarf.pem:/keys/swarf.pem:ro depends_on: + plc: + condition: service_healthy swarf-postgres: condition: service_healthy healthcheck: