Skip to content
Merged
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
20 changes: 8 additions & 12 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
# All defaults here work with `docker compose up` out of the box.
#
# Service ports (defaults):
# MySQL → localhost:3306
# Postgres → localhost:5432
# Redis → localhost:6379
# Typesense → localhost:8108
# Adminer → localhost:8082 (DB browser UI)
Expand All @@ -16,13 +16,14 @@
# =============================================================================

# -----------------------------------------------------------------------------
# Database (MySQL 8.0)
# Database (Postgres 17)
# -----------------------------------------------------------------------------
DATABASE_URL=mysql://sprout:sprout_dev@localhost:3306/sprout
MYSQL_ROOT_PASSWORD=sprout_dev
MYSQL_USER=sprout
MYSQL_PASSWORD=sprout_dev
MYSQL_DATABASE=sprout
DATABASE_URL=postgres://sprout:sprout_dev@localhost:5432/sprout
PGHOST=localhost
PGPORT=5432
PGUSER=sprout
PGPASSWORD=sprout_dev
PGDATABASE=sprout

# -----------------------------------------------------------------------------
# Redis 7
Expand Down Expand Up @@ -85,11 +86,6 @@ RUST_LOG=sprout_relay=debug,sprout_db=debug,sprout_auth=debug,sprout_pubsub=debu
# OTLP tracing endpoint (optional — leave unset to disable)
# OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4317

# -----------------------------------------------------------------------------
# sqlx (offline mode for Docker builds — set to true in CI/Docker)
# -----------------------------------------------------------------------------
SQLX_OFFLINE=false

# -----------------------------------------------------------------------------
# Huddle (LiveKit integration)
# -----------------------------------------------------------------------------
Expand Down
12 changes: 10 additions & 2 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -130,15 +130,23 @@ jobs:
docker logs "${container}" || true
return 1
}
wait_healthy "MySQL" "sprout-mysql"
wait_healthy "Postgres" "sprout-postgres"
wait_healthy "Redis" "sprout-redis"
wait_healthy "Typesense" "sprout-typesense"
- name: Apply database schema
run: ./bin/pgschema apply --file schema/schema.sql --auto-approve
env:
PGHOST: localhost
PGPORT: "5432"
PGUSER: sprout
PGPASSWORD: sprout_dev
PGDATABASE: sprout
- name: Build relay
run: cargo build -p sprout-relay
- name: Start relay
run: |
nohup env \
DATABASE_URL=mysql://sprout:sprout_dev@localhost:3306/sprout \
DATABASE_URL=postgres://sprout:sprout_dev@localhost:5432/sprout \
REDIS_URL=redis://localhost:6379 \
TYPESENSE_URL=http://localhost:8108 \
TYPESENSE_API_KEY=sprout_dev_key \
Expand Down
24 changes: 12 additions & 12 deletions ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ Sprout is a Rust monorepo (~22.7K LOC across 13 crates), licensed Apache 2.0 und
└──────────┬──────────────┬──────────────────────────────────────────┘
│ │
┌─────▼──────┐ ┌────▼──────┐
MySQL │ │ Redis │
Postgres │ │ Redis │
│ (events, │ │ (presence │
│ channels, │ │ SET EX, │
│ tokens, │ │ typing │
Expand Down Expand Up @@ -69,7 +69,7 @@ Sprout is a Rust monorepo (~22.7K LOC across 13 crates), licensed Apache 2.0 und
```
sprout-core (zero I/O — types, verification, filter matching, kind registry)
├── sprout-db (MySQL: events, channels, tokens, workflows, audit)
├── sprout-db (Postgres: events, channels, tokens, workflows, audit)
├── sprout-auth (NIP-42, Okta JWT, API tokens, scopes, rate limiting)
├── sprout-pubsub (Redis pub/sub, presence, typing indicators)
├── sprout-search (Typesense: index, query, delete)
Expand Down Expand Up @@ -245,7 +245,7 @@ Presence events skip membership checks and use local-only fan-out. Multi-node pr
3. REDIS PUBLISH — pubsub.publish_event (no DB write)
```

Ephemeral events are never stored in MySQL and never appear in REQ historical queries.
Ephemeral events are never stored in Postgres and never appear in REQ historical queries.

### Handler Semaphore

Expand Down Expand Up @@ -304,7 +304,7 @@ This prevents a race where a non-member receives live fan-out events from a priv

### Historical Query (EOSE)

After registering, the REQ handler queries MySQL for stored events matching the filters (up to 500 per filter, hard cap). These are sent as `["EVENT", sub_id, event]` frames before `["EOSE", sub_id]`. New events arriving after EOSE are delivered via the fan-out path.
After registering, the REQ handler queries Postgres for stored events matching the filters (up to 500 per filter, hard cap). These are sent as `["EVENT", sub_id, event]` frames before `["EOSE", sub_id]`. New events arriving after EOSE are delivered via the fan-out path.

---

Expand Down Expand Up @@ -377,7 +377,7 @@ pub trait RateLimiter: Send + Sync { ... }

---

### sprout-db — MySQL Event Store
### sprout-db — Postgres Event Store

**3,698 LOC.** All database access. Uses `sqlx::query()` (runtime, not compile-time macros) — no `.sqlx/` offline cache required.

Expand Down Expand Up @@ -407,7 +407,7 @@ pub trait RateLimiter: Send + Sync { ... }
- Approval tokens: raw token never reaches the DB — caller hashes with SHA-256 before passing to `create_api_token`.
- DDL injection protection in partition manager: allowlist of table names + strict suffix/date validators.

**Does NOT:** cache queries, implement connection pooling logic (delegated to sqlx), or make network calls outside MySQL.
**Does NOT:** cache queries, implement connection pooling logic (delegated to sqlx), or make network calls outside Postgres.

---

Expand Down Expand Up @@ -457,7 +457,7 @@ EXPIRE sprout:typing:{channel_id} 60
- `delete_event()` is idempotent: 404 treated as success.
- Permission filtering is **caller's responsibility** — `sprout-search` provides the `filter_by` mechanism but does not enforce access policy.

**Does NOT:** enforce channel membership or access control. Does NOT store events in MySQL.
**Does NOT:** enforce channel membership or access control. Does NOT store events in Postgres.

---

Expand Down Expand Up @@ -732,7 +732,7 @@ Every security-sensitive operation uses an explicit, verified pattern. No implic
| Token storage | SHA-256 hash only — raw token shown once at mint, never stored |
| JWKS cache | Double-checked locking; HTTP fetch with no lock held (prevents global DoS) |
| NIP-42 timestamp | ±60 second tolerance — prevents replay attacks |
| AUTH events | Never stored in MySQL, never logged in audit chain |
| AUTH events | Never stored in Postgres, never logged in audit chain |
| Scopeless JWT | Defaults to `[MessagesRead]` only — least-privilege default |

### Input Validation
Expand Down Expand Up @@ -766,7 +766,7 @@ Applied in: `sprout-workflow` (CallWebhook action), `sprout-core` (shared utilit

- Channel membership is the only gate — enforced by the relay at every operation
- REQ handler checks access before subscription registration — no race window for private channel leaks
- TOCTOU-safe membership operations: all check-then-modify sequences run inside MySQL transactions
- TOCTOU-safe membership operations: all check-then-modify sequences run inside Postgres transactions
- Approval tokens: UUID (CSPRNG), stored as SHA-256 hash, single-use enforced with `AND status = 'pending'` in UPDATE

### Webhook Security
Expand All @@ -785,13 +785,13 @@ Docker Compose provides the full local development stack. All services include h

| Service | Image | Port | Purpose |
|---------|-------|------|---------|
| MySQL | `mysql:8.0` | 3306 | Primary event store — events, channels, tokens, workflows, audit |
| Postgres | `postgres:17-alpine` | 5432 | Primary event store — events, channels, tokens, workflows, audit |
| Redis | `redis:7-alpine` | 6379 | Pub/sub fan-out, presence (SET EX), typing (sorted sets) |
| Typesense | `typesense/typesense:27.1` | 8108 | Full-text search index |
| Adminer | `adminer` | 8080 | MySQL web UI (dev only) |
| Adminer | `adminer` | 8080 | DB web UI (dev only) |
| Keycloak | `quay.io/keycloak/keycloak:26` | 8443 | Local OAuth/OIDC stand-in for Okta |

### MySQL Schema (key tables)
### Postgres Schema (key tables)

| Table | Purpose |
|-------|---------|
Expand Down
2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ tower-http = { version = "0.6", features = ["trace", "cors", "compression-gzip"

# Database
sqlx = { version = "0.8", features = [
"runtime-tokio-rustls", "mysql", "uuid", "chrono", "json"
"runtime-tokio-rustls", "postgres", "uuid", "chrono", "json"
] }

# Redis
Expand Down
43 changes: 21 additions & 22 deletions TESTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,7 @@ goose run --help | head -5
```bash
sqlx --version
# If missing:
cargo install sqlx-cli --no-default-features --features mysql
# pgschema manages the schema now — sqlx-cli is no longer needed
```

### screen
Expand Down Expand Up @@ -113,7 +113,7 @@ lsof -ti :3000 | xargs kill -9 2>/dev/null

# Check Docker services — if already running, skip `docker compose up`
docker compose ps --format '{{.Name}} {{.Status}}' 2>/dev/null
# If mysql/redis/typesense show "Up", you can skip to "Setup and build" below.
# If postgres/redis/typesense show "Up", you can skip to "Setup and build" below.
# If not running:
docker compose up -d
```
Expand All @@ -136,9 +136,9 @@ docker compose up -d
export $(cat .env | grep -v "^#" | grep -v "^$" | xargs) 2>/dev/null

# Reset database (fresh state for tests)
docker exec sprout-mysql mysql -u root -psprout_dev -e \
docker exec sprout-postgres psql -U sprout -d postgres -c \
"DROP DATABASE IF EXISTS sprout; CREATE DATABASE sprout;" 2>/dev/null
sqlx migrate run --database-url "$DATABASE_URL"
./bin/pgschema apply --file schema/schema.sql --auto-approve

# Build the full workspace (relay, MCP server, ACP harness, test client, etc.)
cargo build --release --workspace
Expand Down Expand Up @@ -227,16 +227,16 @@ Run all commands from the sprout repo root.
cd /path/to/sprout
. bin/activate-hermit

# 1. Start Docker services (MySQL, Redis, Typesense, Keycloak)
# 1. Start Docker services (Postgres, Redis, Typesense, Keycloak)
docker compose down -v && docker compose up -d
docker compose ps # All services should show "Up"

# 2. Configure environment
[ -f .env ] || cp .env.example .env
export $(cat .env | grep -v "^#" | grep -v "^$" | xargs) 2>/dev/null

# 3. Run database migrations
sqlx migrate run --database-url "$DATABASE_URL"
# 3. Apply database schema
./bin/pgschema apply --file schema/schema.sql --auto-approve

# 4. Build all binaries (sprout-acp, sprout-mcp-server, mention, sprout-admin)
cargo build --release --workspace
Expand Down Expand Up @@ -1328,8 +1328,8 @@ To start fresh with no stale events, use a new keypair (mint a new token) for th
docker compose ps
# If any service is not "Up":
docker compose down -v && docker compose up -d
# Wait 30s then re-run migrations:
sqlx migrate run --database-url "$DATABASE_URL"
# Wait 30s then re-apply schema:
./bin/pgschema apply --file schema/schema.sql --auto-approve
```

---
Expand Down Expand Up @@ -1380,7 +1380,7 @@ Manual testing guide for the Sprout Agent Channel Protection feature. Follow the
### 1.1 Sprout Relay

- Running Sprout relay in dev mode with `require_auth_token=false` disabled (auth tokens required for all tests)
- MySQL database with the `agent_channel_protection` migration applied (see §2.2)
- Postgres database with schema applied via pgschema (see §2.2)
- Default relay URL: `http://localhost:3001` — adjust if different

### 1.2 Tools Required
Expand All @@ -1389,7 +1389,7 @@ Manual testing guide for the Sprout Agent Channel Protection feature. Follow the
|------|---------|---------|
| `curl` | REST API testing | Pre-installed on macOS/Linux |
| `websocat` | NIP-29 WebSocket testing | `brew install websocat` or `cargo install websocat` |
| `mysql` / `mysql-client` | DB verification queries | `brew install mysql-client` |
| `psql` / `postgresql-client` | DB verification queries | `brew install postgresql` |
| `sprout-admin` | Minting agent tokens | Built from `crates/sprout-admin/` |
| `jq` | Pretty-print JSON responses | `brew install jq` |

Expand Down Expand Up @@ -1476,20 +1476,19 @@ curl -s http://localhost:3001/health | jq .
The `agent_channel_protection` migration adds `agent_owner_pubkey` and `channel_add_policy` to the `users` table.

```bash
# Using sqlx-cli
cargo install sqlx-cli --no-default-features --features mysql
sqlx migrate run --database-url "$DATABASE_URL"
# Apply schema via pgschema
./bin/pgschema apply --file schema/schema.sql --auto-approve

# Or via justfile if configured
just migrate
```

Verify migration applied:
Verify schema applied:
```bash
mysql -u root -p sprout -e "DESCRIBE users;" | grep -E "agent_owner|channel_add"
docker exec sprout-postgres psql -U sprout -d sprout -c "\d users" | grep -E "agent_owner|channel_add"
# Expected output:
# agent_owner_pubkey | varbinary(32) | YES | MUL | NULL |
# channel_add_policy | enum(...) | NO | | anyone |
# agent_owner_pubkey | bytea | | |
# channel_add_policy | channel_add_policy | | not null | 'anyone'::channel_add_policy
```

### 2.3 Create a Test Channel
Expand Down Expand Up @@ -2042,10 +2041,10 @@ curl -s http://localhost:3001/api/users/me \
Direct SQL queries to verify schema and data state.

```bash
# Connect to MySQL
mysql -u root -p sprout
# Or with DATABASE_URL
mysql "$DATABASE_URL"
# Connect to Postgres
docker exec -it sprout-postgres psql -U sprout -d sprout
# Or with the connection URL:
psql "$DATABASE_URL"
```

### 6.1 Verify Migration Applied (AC-6 prerequisite)
Expand Down
1 change: 1 addition & 0 deletions bin/.biome-2.4.7.pkg
1 change: 1 addition & 0 deletions bin/.pgschema-1.7.4.pkg
1 change: 1 addition & 0 deletions bin/biome
5 changes: 1 addition & 4 deletions bin/hermit.hcl
Original file line number Diff line number Diff line change
@@ -1,4 +1 @@
manage-git = false

github-token-auth {
}
manage-git = true
1 change: 1 addition & 0 deletions bin/pgschema
2 changes: 1 addition & 1 deletion crates/sprout-admin/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ async fn main() -> Result<()> {
let cli = Cli::parse();

let db_url = std::env::var("DATABASE_URL")
.unwrap_or_else(|_| "mysql://sprout:sprout_dev@localhost:3306/sprout".to_string());
.unwrap_or_else(|_| "postgres://sprout:sprout_dev@localhost:5432/sprout".to_string());

let db = Db::new(&DbConfig {
database_url: db_url,
Expand Down
2 changes: 1 addition & 1 deletion crates/sprout-audit/src/lib.rs
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
#![deny(unsafe_code)]
#![warn(missing_docs)]
//! Tamper-evident hash-chain audit log. Each entry chains to the previous via
//! SHA-256. Single-writer via MySQL `GET_LOCK`. AUTH events (kind 22242)
//! SHA-256. Single-writer via Postgres `pg_advisory_lock`. AUTH events (kind 22242)
//! are rejected — they carry bearer tokens.

/// Audit action types recorded in the log.
Expand Down
30 changes: 7 additions & 23 deletions crates/sprout-audit/src/schema.rs
Original file line number Diff line number Diff line change
@@ -1,34 +1,18 @@
/// DDL for the `audit_log` table. Passed to [`sqlx::raw_sql`] on startup.
///
/// Note: `CREATE TABLE IF NOT EXISTS` does not alter existing tables. If the
/// live database has `event_kind SMALLINT` from an earlier schema, run
/// [`AUDIT_MIGRATE_SQL`] once to widen the column to `INT`.
pub const AUDIT_SCHEMA_SQL: &str = r#"
CREATE TABLE IF NOT EXISTS audit_log (
seq BIGINT NOT NULL PRIMARY KEY,
timestamp DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6),
timestamp TIMESTAMPTZ NOT NULL DEFAULT NOW(),
event_id VARCHAR(255) NOT NULL,
event_kind INT NOT NULL,
actor_pubkey VARCHAR(255) NOT NULL,
action VARCHAR(64) NOT NULL,
channel_id BINARY(16),
metadata JSON NOT NULL,
channel_id BYTEA,
metadata JSONB NOT NULL,
prev_hash VARCHAR(64) NOT NULL,
hash VARCHAR(64) NOT NULL,
INDEX idx_audit_log_timestamp (timestamp),
INDEX idx_audit_log_actor (actor_pubkey),
INDEX idx_audit_log_channel (channel_id)
hash VARCHAR(64) NOT NULL
);
"#;

/// One-time migration: widens `event_kind` from `SMALLINT` to `INT` on databases
/// created before the column type was corrected. Safe to run on an already-`INT`
/// column — MySQL is a no-op when the type matches.
///
/// Run this manually:
/// ```sql
/// ALTER TABLE audit_log MODIFY COLUMN event_kind INT NOT NULL;
/// ```
pub const AUDIT_MIGRATE_SQL: &str = r#"
ALTER TABLE audit_log MODIFY COLUMN event_kind INT NOT NULL;
CREATE INDEX IF NOT EXISTS idx_audit_log_timestamp ON audit_log (timestamp);
CREATE INDEX IF NOT EXISTS idx_audit_log_actor ON audit_log (actor_pubkey);
CREATE INDEX IF NOT EXISTS idx_audit_log_channel ON audit_log (channel_id);
"#;
Loading
Loading