diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..0a18cc8 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,14 @@ +.git +.github +**/node_modules +**/.next +**/.turbo +**/dist +**/__pycache__ +**/.pytest_cache +services/olas-adapter/.venv +playwright-report +test-results +.env +.env.* +!.env.example diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml new file mode 100644 index 0000000..9a133ee --- /dev/null +++ b/.github/workflows/deploy.yml @@ -0,0 +1,90 @@ +name: Deploy + +on: + workflow_dispatch: + inputs: + environment: + description: Deployment target + type: choice + options: [preview, production] + default: preview + required: true + +permissions: + contents: read + packages: write + +jobs: + verify: + name: Verify release + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: pnpm/action-setup@v4 + with: + version: 10.34.5 + - uses: actions/setup-node@v4 + with: + node-version-file: .node-version + cache: pnpm + - run: pnpm install --frozen-lockfile + - run: pnpm format:check + - run: pnpm lint + - run: pnpm typecheck + - run: pnpm test + + publish: + name: Publish container images + needs: verify + runs-on: ubuntu-latest + environment: ${{ inputs.environment }} + if: ${{ inputs.environment == 'preview' || github.ref == 'refs/heads/main' }} + steps: + - uses: actions/checkout@v4 + - uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + - uses: docker/metadata-action@v5 + id: meta + with: + images: ghcr.io/${{ github.repository }}/synesis + tags: type=sha + - uses: docker/build-push-action@v6 + with: + context: . + file: deploy/Dockerfile.node + push: true + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} + - uses: docker/build-push-action@v6 + with: + context: . + file: deploy/Dockerfile.olas + push: true + tags: ghcr.io/${{ github.repository }}/synesis-olas:${{ github.sha }} + + deploy-web: + name: Deploy web (optional Vercel) + needs: [verify, publish] + runs-on: ubuntu-latest + environment: ${{ inputs.environment }} + steps: + - uses: actions/checkout@v4 + - name: Deploy when Vercel secrets are configured + env: + VERCEL_TOKEN: ${{ secrets.VERCEL_TOKEN }} + VERCEL_ORG_ID: ${{ secrets.VERCEL_ORG_ID }} + VERCEL_PROJECT_ID: ${{ secrets.VERCEL_PROJECT_ID }} + run: | + if [ -z "$VERCEL_TOKEN" ] || [ -z "$VERCEL_ORG_ID" ] || [ -z "$VERCEL_PROJECT_ID" ]; then + echo "Vercel secrets are not configured; container images were published successfully." + exit 0 + fi + corepack enable + corepack prepare pnpm@10.34.5 --activate + pnpm install --frozen-lockfile + pnpm dlx vercel@latest pull --yes --environment=${{ inputs.environment }} --token="$VERCEL_TOKEN" + pnpm dlx vercel@latest build --token="$VERCEL_TOKEN" + pnpm dlx vercel@latest deploy --prebuilt --token="$VERCEL_TOKEN" diff --git a/deploy/.env.example b/deploy/.env.example new file mode 100644 index 0000000..f14b8b5 --- /dev/null +++ b/deploy/.env.example @@ -0,0 +1,12 @@ +# Safe local defaults. Live mode requires rotated secrets and the explicit acknowledgement. +SYNESIS_MODE=demo +SYNESIS_LIVE_ACKNOWLEDGED= +POSTGRES_USER=synesis +POSTGRES_PASSWORD=synesis +POSTGRES_DB=synesis +SYNESIS_WEB_PORT=3000 +SYNESIS_API_PORT=4000 +SYNESIS_WEB_ORIGINS=http://localhost:3000 +NEXT_PUBLIC_API_ORIGIN=http://localhost:4000 +SYNESIS_SECRET_ENCRYPTION_KEY=0000000000000000000000000000000000000000000000000000000000000000 +OLAS_ADAPTER_INTERNAL_TOKEN=synesis-demo-internal-token-change-me diff --git a/deploy/Dockerfile.node b/deploy/Dockerfile.node new file mode 100644 index 0000000..aa1ca6e --- /dev/null +++ b/deploy/Dockerfile.node @@ -0,0 +1,24 @@ +FROM node:24-bookworm-slim AS build + +WORKDIR /workspace +RUN corepack enable + +COPY package.json pnpm-lock.yaml pnpm-workspace.yaml turbo.json tsconfig.json ./ +COPY apps ./apps +COPY packages ./packages +RUN pnpm install --frozen-lockfile +RUN pnpm build + +FROM node:24-bookworm-slim AS runtime + +WORKDIR /workspace +RUN corepack enable +COPY --from=build /workspace/package.json /workspace/pnpm-lock.yaml /workspace/pnpm-workspace.yaml /workspace/turbo.json ./ +COPY --from=build /workspace/node_modules ./node_modules +COPY --from=build /workspace/apps ./apps +COPY --from=build /workspace/packages ./packages +COPY deploy/entrypoint.sh /usr/local/bin/synesis-entrypoint +RUN chmod +x /usr/local/bin/synesis-entrypoint + +ENV NODE_ENV=production +ENTRYPOINT ["/usr/local/bin/synesis-entrypoint"] diff --git a/deploy/Dockerfile.olas b/deploy/Dockerfile.olas new file mode 100644 index 0000000..789e3f5 --- /dev/null +++ b/deploy/Dockerfile.olas @@ -0,0 +1,13 @@ +FROM ghcr.io/astral-sh/uv:0.8.17-python3.11-bookworm-slim + +WORKDIR /service +COPY services/olas-adapter/pyproject.toml services/olas-adapter/uv.lock ./ +RUN uv sync --locked --no-dev +COPY services/olas-adapter/src ./src +COPY deploy/entrypoint.sh /usr/local/bin/synesis-entrypoint +RUN chmod +x /usr/local/bin/synesis-entrypoint + +ENV PATH="/service/.venv/bin:$PATH" +ENV PYTHONUNBUFFERED=1 +ENTRYPOINT ["/usr/local/bin/synesis-entrypoint"] +CMD ["uvicorn", "synesis_olas.main:app", "--host", "0.0.0.0", "--port", "8100"] diff --git a/deploy/README.md b/deploy/README.md index 5bc2dac..1c72ef7 100644 --- a/deploy/README.md +++ b/deploy/README.md @@ -1,9 +1,42 @@ # Deployment -Synesis will be deployed as separate web, API, worker, and Olas adapter services, -with managed PostgreSQL and Redis. Container images, Compose orchestration, and -production manifests are tracked by issue #28 so this scaffold does not create a -false deployment path before the service contracts exist. +Synesis ships as separate web, API, worker, and Olas adapter containers with +PostgreSQL and Redis on a private network. The Compose file is the canonical +local and preview topology; production should use managed PostgreSQL/Redis and +an encrypted secret store with the same service contracts. -The workspace can currently run without PostgreSQL or Redis: all service health -checks and quality gates are deterministic and side-effect free. +## Local stack + +```powershell +Copy-Item deploy/.env.example .env +docker compose -f deploy/compose.yaml up --build +``` + +Open `http://localhost:3000`. The API health endpoint is available at +`http://localhost:4000/health`. Database migrations run as a one-shot service +before the API and worker become healthy. Stop the stack with +`docker compose -f deploy/compose.yaml down`; add `-v` only when intentionally +removing the local Postgres and Redis volumes. + +The default `SYNESIS_MODE=demo` cannot broadcast value movement. Every service +uses `deploy/entrypoint.sh`; changing to `SYNESIS_MODE=live` fails closed unless +`SYNESIS_LIVE_ACKNOWLEDGED=I_UNDERSTAND_LIVE_VALUE_MOVEMENT` is explicitly set. +Live mode additionally requires rotated adapter tokens, a real organization +wallet, and a private Base RPC as validated by the adapter configuration. + +## Hosted environments + +- Preview uses isolated databases, Redis, adapter tokens, and KeeperHub + credentials. It must never receive production secrets or a production wallet. +- Production runs the API, worker, and adapter on private service networking; + expose only web and the API health/read endpoints through the edge. Configure + encrypted secrets, TLS, automated Postgres backups/PITR, Redis persistence, + health checks, and image rollback to the previous immutable digest. +- The `Deploy` workflow is manual. Its `production` environment is intentionally + protected by GitHub environment reviewers; configure the required approval + rule before adding a Vercel or container-host token. CI must pass before a + production dispatch. + +For a rollback, redeploy the previous image digest, run the matching migration +rollback only when the migration is backward-compatible, and verify `/health`, +queue lag, and proof verification before reopening traffic. diff --git a/deploy/compose.yaml b/deploy/compose.yaml new file mode 100644 index 0000000..f874bac --- /dev/null +++ b/deploy/compose.yaml @@ -0,0 +1,165 @@ +name: synesis + +services: + postgres: + image: postgres:17-alpine + environment: + POSTGRES_USER: ${POSTGRES_USER:-synesis} + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-synesis} + POSTGRES_DB: ${POSTGRES_DB:-synesis} + volumes: + - postgres-data:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U $${POSTGRES_USER} -d $${POSTGRES_DB}"] + interval: 5s + timeout: 5s + retries: 12 + restart: unless-stopped + + redis: + image: redis:7.4-alpine + command: ["redis-server", "--appendonly", "yes"] + volumes: + - redis-data:/data + healthcheck: + test: ["CMD", "redis-cli", "ping"] + interval: 5s + timeout: 3s + retries: 12 + restart: unless-stopped + + migrate: + build: + context: .. + dockerfile: deploy/Dockerfile.node + environment: + DATABASE_URL: postgresql://${POSTGRES_USER:-synesis}:${POSTGRES_PASSWORD:-synesis}@postgres:5432/${POSTGRES_DB:-synesis} + SYNESIS_MODE: ${SYNESIS_MODE:-demo} + SYNESIS_LIVE_ACKNOWLEDGED: ${SYNESIS_LIVE_ACKNOWLEDGED:-} + command: ["pnpm", "--filter", "@synesis/database", "db:migrate"] + depends_on: + postgres: + condition: service_healthy + restart: "no" + + api: + build: + context: .. + dockerfile: deploy/Dockerfile.node + environment: + DATABASE_URL: postgresql://${POSTGRES_USER:-synesis}:${POSTGRES_PASSWORD:-synesis}@postgres:5432/${POSTGRES_DB:-synesis} + REDIS_URL: redis://redis:6379 + SYNESIS_API_PORT: 4000 + SYNESIS_MODE: ${SYNESIS_MODE:-demo} + SYNESIS_LIVE_ACKNOWLEDGED: ${SYNESIS_LIVE_ACKNOWLEDGED:-} + SYNESIS_WEB_ORIGINS: ${SYNESIS_WEB_ORIGINS:-http://localhost:3000} + SYNESIS_SECRET_ENCRYPTION_KEY: ${SYNESIS_SECRET_ENCRYPTION_KEY:-0000000000000000000000000000000000000000000000000000000000000000} + OLAS_ADAPTER_URL: http://olas-adapter:8100 + OLAS_ADAPTER_INTERNAL_TOKEN: ${OLAS_ADAPTER_INTERNAL_TOKEN:-synesis-demo-internal-token-change-me} + command: ["pnpm", "--filter", "@synesis/api", "start"] + ports: + - "${SYNESIS_API_PORT:-4000}:4000" + depends_on: + migrate: + condition: service_completed_successfully + redis: + condition: service_healthy + healthcheck: + test: + [ + "CMD", + "node", + "-e", + "fetch('http://127.0.0.1:4000/health').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))", + ] + interval: 10s + timeout: 5s + retries: 12 + restart: unless-stopped + + worker: + build: + context: .. + dockerfile: deploy/Dockerfile.node + environment: + DATABASE_URL: postgresql://${POSTGRES_USER:-synesis}:${POSTGRES_PASSWORD:-synesis}@postgres:5432/${POSTGRES_DB:-synesis} + REDIS_URL: redis://redis:6379 + SYNESIS_MODE: ${SYNESIS_MODE:-demo} + SYNESIS_LIVE_ACKNOWLEDGED: ${SYNESIS_LIVE_ACKNOWLEDGED:-} + command: ["pnpm", "--filter", "@synesis/worker", "start"] + depends_on: + migrate: + condition: service_completed_successfully + redis: + condition: service_healthy + restart: unless-stopped + + olas-adapter: + build: + context: .. + dockerfile: deploy/Dockerfile.olas + environment: + SYNESIS_MODE: ${SYNESIS_MODE:-demo} + SYNESIS_LIVE_ACKNOWLEDGED: ${SYNESIS_LIVE_ACKNOWLEDGED:-} + OLAS_ADAPTER_INTERNAL_TOKEN: ${OLAS_ADAPTER_INTERNAL_TOKEN:-synesis-demo-internal-token-change-me} + KEEPERHUB_GATEWAY_URL: http://api:4000/internal/v1/keeperhub/submit-call + expose: + - "8100" + depends_on: + api: + condition: service_healthy + healthcheck: + test: + [ + "CMD", + "python", + "-c", + "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8100/health')", + ] + interval: 10s + timeout: 5s + retries: 12 + restart: unless-stopped + + web: + build: + context: .. + dockerfile: deploy/Dockerfile.node + environment: + SYNESIS_MODE: ${SYNESIS_MODE:-demo} + SYNESIS_LIVE_ACKNOWLEDGED: ${SYNESIS_LIVE_ACKNOWLEDGED:-} + NEXT_PUBLIC_API_ORIGIN: ${NEXT_PUBLIC_API_ORIGIN:-http://localhost:4000} + command: + [ + "pnpm", + "--filter", + "@synesis/web", + "exec", + "next", + "start", + "--hostname", + "0.0.0.0", + "--port", + "3000", + ] + ports: + - "${SYNESIS_WEB_PORT:-3000}:3000" + depends_on: + api: + condition: service_healthy + healthcheck: + test: + [ + "CMD", + "node", + "-e", + "fetch('http://127.0.0.1:3000/app').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))", + ] + interval: 10s + timeout: 5s + retries: 12 + restart: unless-stopped + +volumes: + postgres-data: + redis-data: diff --git a/deploy/entrypoint.sh b/deploy/entrypoint.sh new file mode 100644 index 0000000..568f16d --- /dev/null +++ b/deploy/entrypoint.sh @@ -0,0 +1,10 @@ +#!/bin/sh +set -eu + +if [ "${SYNESIS_MODE:-demo}" = "live" ] \ + && [ "${SYNESIS_LIVE_ACKNOWLEDGED:-}" != "I_UNDERSTAND_LIVE_VALUE_MOVEMENT" ]; then + echo "Refusing live start: set SYNESIS_LIVE_ACKNOWLEDGED=I_UNDERSTAND_LIVE_VALUE_MOVEMENT" >&2 + exit 78 +fi + +exec "$@" diff --git a/docs/LIVE_ACCEPTANCE.md b/docs/LIVE_ACCEPTANCE.md new file mode 100644 index 0000000..523eb07 --- /dev/null +++ b/docs/LIVE_ACCEPTANCE.md @@ -0,0 +1,36 @@ +# Live Base acceptance + +The live acceptance journey is deliberately separate from unit/CI tests. It +must be run by an authorized operator against a deployed environment with +rotated KeeperHub credentials, a funded low-value organization wallet, and a +private Base RPC. This repository does not contain those credentials and the +runner never invents receipts or broadcasts a transaction. + +## Preflight and report verification + +```powershell +$env:SYNESIS_MODE = "live" +$env:SYNESIS_LIVE_ACKNOWLEDGED = "I_UNDERSTAND_LIVE_VALUE_MOVEMENT" +$env:SYNESIS_API_ORIGIN = "https://api.example.com" +$env:SYNESIS_ACCEPTANCE_REPORT = "./acceptance-report.json" +corepack pnpm acceptance:live +``` + +The command first checks `/health`, then validates the report for all eight +required outcomes: two paid Olas requests, two verified deliveries, quorum, +bounded Aave USDC execution with an independently observed position delta, a +public proof, and a replay that moved no value. It rejects wrong-chain reports, +missing receipt evidence, duplicate transaction hashes, and any replay that was +accepted or moved value. Without a report it performs only the read-only +preflight. + +`--execute` is intentionally not a broadcaster. It additionally requires +`SYNESIS_ACCEPTANCE_ALLOW=1` and documents that the approved deployment +workflow—not an ad-hoc script—must initiate the value-moving action. + +The report should contain `environment: "live"`, `chainId: 8453`, `requests` and +`deliveries` arrays of length two, `quorum.passed`, `execution.strategy` equal +to `AAVE_V3_USDC_SUPPLY`, `proof` identifiers, and +`replay: { attempted: true, accepted: false, valueMoved: false }`. Keep the +report, KeeperHub receipts, Base explorer URLs, Olas delivery hashes, and the +public proof URL together as the judge-facing evidence bundle. diff --git a/package.json b/package.json index 1eaf57c..58cde6f 100644 --- a/package.json +++ b/package.json @@ -10,6 +10,7 @@ "scripts": { "build": "turbo run build --concurrency=2", "dev": "turbo run dev --parallel", + "acceptance:live": "node scripts/live-acceptance.mjs", "format": "prettier --write .", "format:check": "prettier --check .", "lint": "turbo run lint --concurrency=2", diff --git a/scripts/live-acceptance.mjs b/scripts/live-acceptance.mjs new file mode 100644 index 0000000..70c215c --- /dev/null +++ b/scripts/live-acceptance.mjs @@ -0,0 +1,150 @@ +import { readFile } from "node:fs/promises"; + +const ACK = "I_UNDERSTAND_LIVE_VALUE_MOVEMENT"; +const apiOrigin = process.env.SYNESIS_API_ORIGIN; +const args = new Set(process.argv.slice(2)); + +const fail = (message) => { + console.error(`Live acceptance refused: ${message}`); + process.exitCode = 78; +}; + +const requireLiveGate = () => { + if (process.env.SYNESIS_MODE !== "live") { + fail("SYNESIS_MODE=live is required; no external writes were attempted"); + return false; + } + if (process.env.SYNESIS_LIVE_ACKNOWLEDGED !== ACK) { + fail( + `set SYNESIS_LIVE_ACKNOWLEDGED=${ACK}; no external writes were attempted`, + ); + return false; + } + if (!apiOrigin) { + fail("SYNESIS_API_ORIGIN must point at the deployed API"); + return false; + } + return true; +}; + +const requireString = (value, label) => { + if (typeof value !== "string" || value.trim() === "") + throw new Error(`${label} must be a non-empty string`); +}; + +const validateReport = (report) => { + if (!report || typeof report !== "object") + throw new Error("report must be an object"); + if (report.environment !== "live") + throw new Error("report.environment must be live"); + if (report.chainId !== 8453) + throw new Error("report.chainId must be Base mainnet (8453)"); + if (!Array.isArray(report.requests) || report.requests.length !== 2) + throw new Error("exactly two paid Olas requests are required"); + if (!Array.isArray(report.deliveries) || report.deliveries.length !== 2) + throw new Error("exactly two confirmed Olas deliveries are required"); + + const requestIds = new Set(); + for (const [index, request] of report.requests.entries()) { + requireString( + request?.keeperhubExecutionId, + `requests[${index}].keeperhubExecutionId`, + ); + requireString(request?.txHash, `requests[${index}].txHash`); + if (request.receiptStatus !== "success") + throw new Error( + `requests[${index}] does not have a verified successful receipt`, + ); + if (requestIds.has(request.keeperhubExecutionId)) + throw new Error("duplicate Olas execution id"); + requestIds.add(request.keeperhubExecutionId); + } + for (const [index, delivery] of report.deliveries.entries()) { + requireString(delivery?.requestId, `deliveries[${index}].requestId`); + requireString(delivery?.contentHash, `deliveries[${index}].contentHash`); + if (delivery.schemaValid !== true || delivery.integrityValid !== true) + throw new Error( + `deliveries[${index}] failed schema/integrity validation`, + ); + } + if (report.quorum?.passed !== true) + throw new Error("deterministic quorum did not pass"); + requireString(report.quorum?.reportHash, "quorum.reportHash"); + if (report.execution?.strategy !== "AAVE_V3_USDC_SUPPLY") + throw new Error("execution.strategy must be AAVE_V3_USDC_SUPPLY"); + requireString( + report.execution?.keeperhubExecutionId, + "execution.keeperhubExecutionId", + ); + requireString(report.execution?.txHash, "execution.txHash"); + if ( + report.execution.receiptStatus !== "success" || + report.execution.positionDeltaObserved !== true + ) + throw new Error( + "Aave execution receipt or independently observed position delta is missing", + ); + requireString(report.proof?.publicId, "proof.publicId"); + requireString(report.proof?.rootHash, "proof.rootHash"); + requireString(report.proof?.verificationUrl, "proof.verificationUrl"); + if ( + report.replay?.attempted !== true || + report.replay.accepted !== false || + report.replay.valueMoved !== false + ) + throw new Error("replay safety invariant failed"); + + const txHashes = [ + ...report.requests.map((request) => request.txHash), + report.execution.txHash, + ]; + if (new Set(txHashes).size !== txHashes.length) + throw new Error("value-moving transaction hashes must be unique"); + return { + chainId: report.chainId, + requests: report.requests.length, + deliveries: report.deliveries.length, + proof: report.proof.publicId, + replayValueMoved: report.replay.valueMoved, + }; +}; + +const preflight = async () => { + const response = await fetch(new URL("/health", apiOrigin), { + headers: process.env.SYNESIS_ACCEPTANCE_TOKEN + ? { authorization: `Bearer ${process.env.SYNESIS_ACCEPTANCE_TOKEN}` } + : undefined, + }); + const health = await response.json(); + if (!response.ok || health.status === "error") + throw new Error(`API health check failed (${response.status})`); + return health; +}; + +if (!requireLiveGate()) process.exit(); +try { + const health = await preflight(); + console.info(`Live API preflight passed: ${health.service ?? "api"}`); + const reportPath = process.env.SYNESIS_ACCEPTANCE_REPORT; + if (!reportPath) { + console.info( + "No report supplied; preflight completed and no value movement was attempted.", + ); + process.exit(); + } + const report = JSON.parse(await readFile(reportPath, "utf8")); + console.info( + `Acceptance report verified: ${JSON.stringify(validateReport(report))}`, + ); + if (args.has("--execute")) { + if (process.env.SYNESIS_ACCEPTANCE_ALLOW !== "1") + throw new Error("--execute requires SYNESIS_ACCEPTANCE_ALLOW=1"); + console.info( + "Report-only mode is complete; execution must be initiated through the approved API workflow.", + ); + } +} catch (error) { + fail( + error instanceof Error ? error.message : "unexpected acceptance failure", + ); +}