Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,13 @@ PACTO__BOT_ID=sigstack
# PACTO__DEFAULT_RECIPIENT=npub1...
PACTO__TIMEOUT=15s

# Optional: co-host the Pacto Nostr relay itself:
# cd docker && docker compose --profile relay up -d --build
# See docker/relay-config.toml and "Hosting the Pacto Nostr Relay" in CLAUDE.md
# Host port for the relay websocket (macOS: 7000 clashes with AirPlay
# Receiver — use 7447 or disable AirPlay)
# RELAY_PORT=7000

# Payment Configuration (x402)
# Set to true to enable credit tracking and payment system
PAYMENTS__ENABLED=false
Expand Down
26 changes: 26 additions & 0 deletions .github/workflows/commitlint.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
name: commitlint

on:
pull_request:
push:
branches: [main, master]

jobs:
commitlint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- uses: actions/setup-node@v4
with:
node-version: "22"
cache: npm
- run: npm ci
- name: Lint commit messages
run: |
if [ "${{ github.event_name }}" = "pull_request" ]; then
npx --no -- commitlint --from "${{ github.event.pull_request.base.sha }}" --to "${{ github.event.pull_request.head.sha }}" --verbose
else
npx --no -- commitlint --from "${{ github.event.before }}" --to "${{ github.sha }}" --verbose
fi
42 changes: 42 additions & 0 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
name: test

on:
pull_request:
push:
branches: [main, master]

jobs:
rust:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4

- uses: dtolnay/rust-toolchain@stable
with:
# Keep in sync with scripts/ci.sh local prerequisites.
components: rustfmt, clippy, llvm-tools-preview

- uses: Swatinem/rust-cache@v2

- name: Install cargo-llvm-cov
uses: taiki-e/install-action@cargo-llvm-cov

- name: Format
run: cargo fmt --all -- --check

- name: Clippy
run: cargo clippy --workspace --all-targets -- -D warnings

- name: Test with coverage gate (≥90% lines)
run: |
cargo llvm-cov --workspace --lcov --output-path lcov.info \
--fail-under-lines 90 \
--ignore-filename-regex '(^|/)main\.rs$'

- name: Upload LCOV
if: always()
uses: actions/upload-artifact@v4
with:
name: lcov
path: lcov.info
if-no-files-found: ignore
82 changes: 82 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -566,6 +566,88 @@ from source and copies in your `pacto-bot-api.toml`). Push it as linux/amd64,
set `PACTO_BOT_API_IMAGE` plus the `PACTO_BOT_NSEC` encrypted secret, and
uncomment the `pacto-bot-api` service block in `docker/phala-compose.yaml`.

## Hosting the Pacto Nostr Relay

The deployment can also host the Pacto network's Nostr relay itself
([nostr-rs-relay](https://github.com/scsibug/nostr-rs-relay), pinned to the
same version as
[pacto-dev-env](https://github.com/covenant-gov/pacto-dev-env)). Opt-in via
the `relay` compose profile.

Running the relay inside the TEE extends the verifiability story from the bot
to the relay: the binary is built from a pinned upstream tag and the policy
(`docker/relay-config.toml`) is baked into the image, so both are
reproducible and auditable. They become covered by the attested
`compose_hash` once the image reference in `docker/phala-compose.yaml` is a
literal sha256 digest (like the existing `signal-api` service) — a tag or
env-var reference is not attested. On Phala the sqlite database lives on the
CVM's encrypted disk.

What the relay stores is not plaintext — Pacto DMs and group messages arrive
as NIP-59 gift wraps and MLS ciphertexts. The relay still observes connection
metadata (client IPs, timing, event sizes), which is the standard Nostr trust
model; running it in the TEE removes the relay *operator* from that picture.

### Local Setup

```bash
cd docker
docker compose --profile relay up -d --build
# together with the Pacto daemon:
docker compose --profile relay --profile pacto up -d --build
# teardown (a plain `docker compose down` ignores profiled services):
docker compose --profile relay down
```

- Host endpoint: `ws://localhost:7000`; in-network: `ws://nostr-relay:8080`
- macOS: AirPlay Receiver listens on port 7000 — set `RELAY_PORT=7447` in
`docker/.env` (or disable AirPlay Receiver) if the port is taken
- Point the co-located daemon at it via `relays` in
`docker/pacto-bot-api.toml` (see the example file)
- Events persist in the `relay-data` volume
- Health check: `curl -H 'Accept: application/nostr+json' localhost:7000`
returns the NIP-11 relay information document

### Phala Deployment

1. Build and push the image (the config is baked in, so rebuild after any
`relay-config.toml` change):
```bash
docker buildx build --platform linux/amd64 \
-t YOUR_DOCKERHUB/pacto-nostr-relay:0.9.0 -f docker/Dockerfile.relay --push docker
```
2. Uncomment the `nostr-relay` service and `relay-data` volume in
`docker/phala-compose.yaml`, pin the image by its pushed sha256 digest
(`docker buildx imagetools inspect ...`), then redeploy
3. The relay is served at `wss://<app-id>-7000.<gateway-domain>.phala.network`
(the dstack gateway terminates TLS and proxies WebSocket upgrades; the
gateway domain depends on the cluster — check the Phala dashboard)
4. Serving it as `relay.pacto.chat` is more than a DNS record: for custom
domains the gateway does TLS *passthrough*, so TLS must terminate inside
the CVM — add a [dstack-ingress](https://github.com/Dstack-TEE/dstack-examples/tree/main/custom-domain)
service (Cloudflare DNS + certbot) with `TARGET_ENDPOINT` pointing at
`nostr-relay:8080`, and keep `relay_url` in `relay-config.toml` in sync
before building. Until the domain actually points here, the advertised
`relay_url` is aspirational

### Event Kind Policy

`event_kind_allowlist` in `relay-config.toml` ships commented out. The kinds
Pacto uses today (from pacto-app's nostr-sdk usage: the `stored_event.rs`
kinds, `Kind::Metadata`, `Kind::GiftWrap`, the kind-9 chat rumor, and
nostr-sdk 0.43's MLS kinds 443-445) are
`0, 7, 9, 14, 15, 16, 443, 444, 445, 1059, 30078`. Enable the
allowlist only after confirming the set with the Pacto team — an over-strict
allowlist silently breaks clients, and it is spam control, not a security
boundary.

Note: pacto-dev-env's `relay-config.toml` places `event_kind_allowlist` under
an `[events]` section (and the sqlite path under `[database] path`), neither
of which exists in nostr-rs-relay — upstream silently ignores unknown keys,
so that allowlist has never actually been enforced in the dev environment.
This repo's config uses the real keys (`[limits] event_kind_allowlist`,
`[database] data_directory`).

## Testing

```bash
Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ This project implements a Signal bot that runs inside a Dstack-powered TEE (Inte
- In-memory conversation storage (no external persistence)
- Group chat support with shared conversation context
- OpenAI-compatible API integration
- Optional co-hosted Nostr relay for the [Pacto](https://github.com/covenant-gov/pacto-app) network (opt-in `relay` compose profile)

## Bot Commands

Expand Down
60 changes: 60 additions & 0 deletions docker/Dockerfile.relay
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
# Co-hosted Pacto Nostr relay (nostr-rs-relay).
#
# Pinned to the same upstream version as covenant-gov/pacto-dev-env and built
# from source (upstream publishes amd64-only images; this builds natively on
# any platform). relay-config.toml is baked in so the same image works on
# Phala, where CVMs cannot bind-mount local files, and so the relay policy is
# part of the attested image.
#
# Local use: cd docker && docker compose --profile relay up -d --build
#
# Phala build (from the repo root; note the digest it pushes — the service in
# phala-compose.yaml should pin it so the relay is covered by attestation):
# docker buildx build --platform linux/amd64 \
# -t YOUR_DOCKERHUB/pacto-nostr-relay:0.9.0 \
# -f docker/Dockerfile.relay --push docker

FROM rust:1-bookworm AS builder

ARG NOSTR_RELAY_VERSION=0.9.0

RUN apt-get update \
&& apt-get install -y --no-install-recommends cmake git libssl-dev pkg-config protobuf-compiler \
&& rm -rf /var/lib/apt/lists/*

WORKDIR /src
RUN git clone --depth 1 --branch "${NOSTR_RELAY_VERSION}" \
https://github.com/scsibug/nostr-rs-relay.git . \
&& cargo build --release

# ---
FROM debian:bookworm-slim

RUN apt-get update \
&& apt-get install -y --no-install-recommends ca-certificates curl libssl3 \
&& rm -rf /var/lib/apt/lists/*

RUN groupadd --system --gid 1000 relay \
&& useradd --system --uid 1000 --gid relay \
--home-dir /var/lib/nostr-relay --shell /sbin/nologin relay

RUN mkdir -p /usr/src/app /var/lib/nostr-relay \
&& chown -R relay:relay /usr/src/app /var/lib/nostr-relay

# nostr-rs-relay reads config.toml from its working directory
WORKDIR /usr/src/app
COPY --from=builder /src/target/release/nostr-rs-relay ./nostr-rs-relay
COPY --chown=relay:relay relay-config.toml ./config.toml

USER relay

# Without this the relay logs errors only (upstream's Dockerfile sets the same)
ENV RUST_LOG=info,nostr_rs_relay=info

EXPOSE 8080
VOLUME ["/var/lib/nostr-relay"]

# Pass --config explicitly: with the implicit cwd lookup, a broken config
# makes 0.9.0 silently start on upstream defaults (no Pacto policy, sqlite
# outside the volume) instead of failing fast.
CMD ["./nostr-rs-relay", "--config", "./config.toml"]
32 changes: 32 additions & 0 deletions docker/docker-compose.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,36 @@ services:
- internal
restart: unless-stopped

# Co-hosted Nostr relay for the Pacto network (nostr-rs-relay, pinned to
# the same version as covenant-gov/pacto-dev-env; config baked in from
# relay-config.toml). Opt-in: `docker compose --profile relay up -d --build`.
# Reachable at ws://localhost:7000 on the host and ws://nostr-relay:8080
# inside the compose network (add it to `relays` in pacto-bot-api.toml).
# macOS: AirPlay Receiver squats on port 7000 — set RELAY_PORT=7447 (or
# disable AirPlay Receiver) if startup fails with "address already in use".
nostr-relay:
build:
context: .
dockerfile: Dockerfile.relay
container_name: nostr-relay
profiles: ["relay"]
environment:
- RUST_LOG=${LOG_LEVEL:-info}
ports:
- "${RELAY_PORT:-7000}:8080"
volumes:
- relay-data:/var/lib/nostr-relay
networks:
- internal
healthcheck:
# NIP-11 relay information document
test: ["CMD", "curl", "-f", "-H", "Accept: application/nostr+json", "http://localhost:8080"]
interval: 30s
timeout: 10s
retries: 3
start_period: 15s
restart: unless-stopped

signal-registration-proxy:
build:
context: ..
Expand Down Expand Up @@ -150,6 +180,8 @@ volumes:
driver: local
pacto-data:
driver: local
relay-data:
driver: local

networks:
internal:
Expand Down
3 changes: 3 additions & 0 deletions docker/pacto-bot-api.toml.example
Original file line number Diff line number Diff line change
Expand Up @@ -21,4 +21,7 @@ id = "sigstack"
npub = "npub1REPLACE_WITH_YOUR_BOT_NPUB"
signing = { backend = "nsec", nsec = "${PACTO_BOT_NSEC}" }
relays = ["wss://relay.pacto.chat"]
# Co-hosting the relay (docker compose --profile relay)? Use the in-network
# URL, optionally alongside public relays:
# relays = ["ws://nostr-relay:8080", "wss://relay.pacto.chat"]
capabilities = ["ReadMessages", "SendMessages"]
29 changes: 29 additions & 0 deletions docker/phala-compose.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,32 @@ services:
# - pacto-data:/var/lib/pacto-bot-api
# restart: unless-stopped

# Co-hosted Pacto Nostr relay (nostr-rs-relay; docker/Dockerfile.relay bakes
# in docker/relay-config.toml, so policy changes require an image rebuild).
# Build & push (linux/amd64), then uncomment this service and the
# relay-data volume below:
# docker buildx build --platform linux/amd64 \
# -t YOUR_DOCKERHUB/pacto-nostr-relay:0.9.0 -f docker/Dockerfile.relay --push docker
# Pin the image by the pushed sha256 digest (docker buildx imagetools
# inspect ...), like signal-api above — only a literal digest makes the
# relay binary + policy part of the attested compose_hash; a tag or env-var
# reference is not attested.
# Public endpoint (TLS terminated by the dstack gateway; the gateway domain
# depends on the cluster the CVM runs in — see the Phala dashboard):
# wss://<app-id>-7000.<gateway-domain>.phala.network
# The sqlite database lives in relay-data, on the CVM's encrypted disk.
#
# nostr-relay:
# image: YOUR_DOCKERHUB/pacto-nostr-relay@sha256:REPLACE_WITH_PUSHED_DIGEST
# container_name: nostr-relay
# environment:
# - RUST_LOG=${LOG_LEVEL:-info}
# ports:
# - "7000:8080"
# volumes:
# - relay-data:/var/lib/nostr-relay
# restart: unless-stopped

signal-registration-proxy:
image: ${SIGNAL_PROXY_IMAGE}
container_name: signal-registration-proxy
Expand Down Expand Up @@ -132,3 +158,6 @@ volumes:
driver: local
pacto-data:
driver: local
# Uncomment together with the nostr-relay service above:
# relay-data:
# driver: local
Loading
Loading