diff --git a/.env.example b/.env.example index 770d9ae..40a8f08 100644 --- a/.env.example +++ b/.env.example @@ -5,7 +5,12 @@ DB_PORT=5432 DB_USERNAME=postgres DB_PASSWORD=postgres DB_NAME=agentverse -DB_SYNCHRONIZE=true +# Migrations own the schema. Docker Compose interpolates this same file, so +# leaving it true would auto-synchronize on top of a migrated database. +# Run `npm run migration:run` before starting outside Docker. +DB_SYNCHRONIZE=false +# Container entrypoint applies migrations before starting +RUN_MIGRATIONS_ON_START=true DB_LOGGING=false DB_SEED_ON_STARTUP=true @@ -41,6 +46,8 @@ MOCK_PAYMENT_FAIL=false AWS_REGION=us-east-1 AWS_ACCESS_KEY_ID= AWS_SECRET_ACCESS_KEY= +# Required when NODE_ENV=production; bootstrap aborts if it is unset +AWS_KMS_KEY_ID= # S3-compatible encrypted prompt storage (MinIO defaults for Docker Compose) S3_ENDPOINT=http://localhost:9000 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 34d60f2..bc71326 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -54,11 +54,18 @@ jobs: - name: Install dependencies run: npm ci - - name: Lint - run: npm run lint - - name: Test run: npm test + # The Jest config sets rootDir to "src", so `npm test` does not pick up + # anything under test/. Keep the integration suite in the CI gate. + - name: E2E test + run: npm run test:e2e + - name: Build run: npm run build + + # Lint remains last so test and build results are visible while the + # repository-wide debt tracked in Backend #14 is remediated. + - name: Lint + run: npm run lint diff --git a/.github/workflows/staging-smoke.yml b/.github/workflows/staging-smoke.yml new file mode 100644 index 0000000..a32361a --- /dev/null +++ b/.github/workflows/staging-smoke.yml @@ -0,0 +1,205 @@ +name: staging-smoke + +# Provisions a throwaway, production-like environment on every run and proves the +# market journey against it: schema built by migrations, DB_SYNCHRONIZE=false, +# the compiled entrypoint, and the smoke suite in test/staging-smoke.smoke-spec.ts. +# +# This is deliberately a separate workflow from `ci`. `ci` owns lint/test/build +# quality gates and is red on main for reasons tracked in #14; gating release +# evidence on that would mean this never runs. + +on: + push: + branches: + - main + pull_request: + branches: + - main + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: staging-smoke-${{ github.ref }} + cancel-in-progress: true + +jobs: + smoke: + runs-on: ubuntu-latest + timeout-minutes: 20 + + services: + postgres: + image: postgres:16-alpine + env: + POSTGRES_USER: postgres + POSTGRES_PASSWORD: postgres + POSTGRES_DB: agentverse_staging + ports: + - 5432:5432 + options: >- + --health-cmd "pg_isready -U postgres -d agentverse_staging" + --health-interval 5s + --health-timeout 5s + --health-retries 5 + + env: + DB_HOST: 127.0.0.1 + DB_PORT: 5432 + DB_USERNAME: postgres + DB_PASSWORD: postgres + DB_NAME: agentverse_staging + # The point of this workflow: the schema is owned by migrations, never by + # auto-synchronize. `ci` runs with DB_SYNCHRONIZE=true, which is why the + # drift this suite catches was invisible there. + DB_SYNCHRONIZE: false + DB_SEED_ON_STARTUP: false + DB_LOGGING: false + JWT_SECRET: staging-smoke-secret + STELLAR_NETWORK: testnet + STELLAR_RPC_URL: https://soroban-testnet.stellar.org + STELLAR_NETWORK_PASSPHRASE: Test SDF Network ; September 2015 + # No long-lived signing key and no real contract id are configured here, and + # neither is stored as a secret. The smoke suite runs with none at all; the + # boot step alone exports a throwaway keypair it generates in-process and + # never signs with, plus PLACEHOLDER contract ids, because production env + # validation requires those variables to be present. See + # docs/release-runbook.md for what a configured environment needs. + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: 22 + cache: npm + + - name: Install dependencies + run: npm ci + + - name: Build + run: npm run build + + - name: Verify the deploy entrypoints exist + run: | + set -euo pipefail + for artifact in dist/main.js dist/database/data-source.js; do + if [ ! -f "$artifact" ]; then + echo "::error::$artifact was not emitted; the container entrypoint cannot start" + exit 1 + fi + echo "ok: $artifact" + done + + - name: Apply migrations with the compiled data source + run: npm run migration:run:prod + + - name: Verify the schema is owned by migrations + run: | + set -euo pipefail + applied=$(node -e " + const { Client } = require('pg'); + const c = new Client({ + host: process.env.DB_HOST, port: Number(process.env.DB_PORT), + user: process.env.DB_USERNAME, password: process.env.DB_PASSWORD, + database: process.env.DB_NAME, + }); + c.connect() + .then(() => c.query('SELECT count(*)::int AS n FROM migrations')) + .then((r) => { console.log(r.rows[0].n); return c.end(); }) + .catch((e) => { console.error(e.message); process.exit(1); }); + ") + echo "migrations applied: $applied" + if [ "$applied" -lt 5 ]; then + echo "::error::expected at least 5 applied migrations, found $applied" + exit 1 + fi + + - name: Start the compiled application under production validation + run: | + set -euo pipefail + # Generated per run, never written to a file, never echoed, never + # persisted. env.validation.ts requires the variable in production; + # this run never signs anything with it. Masked so it cannot appear in + # the log even if something downstream decides to print its environment. + STELLAR_ADMIN_SECRET_KEY="$(node -e "console.log(require('@stellar/stellar-sdk').Keypair.random().secret())")" + echo "::add-mask::$STELLAR_ADMIN_SECRET_KEY" + export STELLAR_ADMIN_SECRET_KEY + # Required by the same validator. Nothing in this run calls KMS; a real + # environment supplies the deployed key id. + export AWS_KMS_KEY_ID="alias/agentverse-staging" + export AWS_REGION="us-east-1" + export CORS_ORIGINS="http://localhost:3000" + export SOROBAN_TOKEN_MINT_CONTRACT_ID="PLACEHOLDER" + export SOROBAN_TOKEN_SALE_CONTRACT_ID="PLACEHOLDER" + export SOROBAN_MARKETPLACE_CONTRACT_ID="PLACEHOLDER" + export NODE_ENV=production + export PORT=3000 + # nohup so the process survives this step's shell exiting. + nohup node dist/main > staging-boot.log 2>&1 & + echo $! > app.pid + for attempt in $(seq 1 30); do + if curl -fsS http://127.0.0.1:3000/api/health/live > /dev/null 2>&1; then + echo "application answered liveness after ${attempt}s" + exit 0 + fi + sleep 1 + done + echo "::error::application did not become live within 30s" + cat staging-boot.log + exit 1 + + - name: Probe readiness against the deployed schema + run: | + set -euo pipefail + body=$(curl -fsS http://127.0.0.1:3000/api/health) + echo "$body" + node -e " + const report = JSON.parse(process.argv[1]); + const byName = Object.fromEntries(report.checks.map((c) => [c.name, c])); + const failures = []; + if (report.status !== 'ok') failures.push('status is ' + report.status); + if (byName.database?.status !== 'ok') failures.push('database check failed'); + if (byName.schema?.status !== 'ok') failures.push('schema check failed'); + if (byName.schema?.detail !== 'AlignMigratedSchemaWithEntities1700000004000') { + failures.push('unexpected applied migration: ' + byName.schema?.detail); + } + if (failures.length) { console.error(failures.join('; ')); process.exit(1); } + console.log('readiness verified'); + " "$body" + + - name: Stop the application + if: always() + run: | + if [ -f app.pid ]; then kill "$(cat app.pid)" 2>/dev/null || true; fi + + - name: Run the staging smoke suite + run: npm run test:smoke + + - name: Assert no signing key reached the repository or the logs + if: always() + run: | + set -euo pipefail + # Stellar secret seeds are 56 characters starting with 'S'. Committed + # tracked files and this run's captured output must contain none. + if git grep -nIE '\bS[A-Z2-7]{55}\b' -- . ':!*.lock' ':!package-lock.json'; then + echo "::error::a Stellar secret seed appears in a tracked file" + exit 1 + fi + if [ -f staging-boot.log ] && grep -qE '\bS[A-Z2-7]{55}\b' staging-boot.log; then + echo "::error::a Stellar secret seed was printed to the application log" + exit 1 + fi + echo "no signing key found in tracked files or captured logs" + + - name: Upload smoke evidence + if: always() + uses: actions/upload-artifact@v4 + with: + name: staging-smoke-evidence + path: staging-boot.log + if-no-files-found: warn + retention-days: 14 diff --git a/Dockerfile b/Dockerfile index d47efab..61a8581 100644 --- a/Dockerfile +++ b/Dockerfile @@ -19,6 +19,9 @@ RUN chmod +x /app/entrypoint.sh && \ chown -R appuser:appgroup /app USER appuser EXPOSE 3000 +# /api/health is dependency-aware and answers 503 when the database or schema is +# unavailable, so an unhealthy container now means something. Restart-only probes +# should target /api/health/live instead. HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \ CMD wget --no-verbose --tries=1 --spider http://localhost:3000/api/health || exit 1 ENTRYPOINT ["/app/entrypoint.sh"] diff --git a/README.md b/README.md index 0db7b90..5514b39 100644 --- a/README.md +++ b/README.md @@ -26,9 +26,14 @@ Environment validation is centralized in `src/config/env.validation.ts`. ```bash npm ci cp .env.example .env +npm run migration:run npm run start:dev ``` +`.env.example` sets `DB_SYNCHRONIZE=false`, so the schema comes from migrations +here exactly as it does in a deployment. Until they are applied, `/api/health` +reports the missing schema and answers 503. + ## Docker ```bash @@ -39,16 +44,44 @@ docker compose down ## Tests ```bash -npm test +npm test # unit tests +npm run test:e2e # e2e tests, no database required npm run test:cov npm run build ``` +The staging smoke suite is separate because it needs a real, migrated database: + +```bash +npm run migration:run +npm run test:smoke +``` + +It refuses to run unless `DB_HOST` and `DB_NAME` are set, so it cannot pass by +finding nothing to do. + ## CI `.github/workflows/ci.yml` runs on push and pull request to `main`. It installs dependencies, then runs lint, tests, and build. +`.github/workflows/staging-smoke.yml` provisions a throwaway production-like +environment on the same events: it builds, applies migrations with the compiled +data source, boots the compiled app under production validation with +`DB_SYNCHRONIZE=false`, probes `/api/health`, and runs the smoke suite. + +## Health + +| Endpoint | Answers | Use for | +| --- | --- | --- | +| `GET /api/health` | Database, applied migration, Soroban RPC, marketplace contract, delivery worker. `503` when a required dependency is down | Rollout gates, load balancers | +| `GET /api/health/live` | Process liveness only, always `200` | Restart probes | + +## Deployment + +See `docs/release-runbook.md` for the environment contract, deploy and rollback +procedure, evidence to record, and the gaps that block a full release. + ## Swagger OpenAPI docs are available in development at: @@ -68,7 +101,7 @@ Docs use bearer auth and stay disabled in production unless `SWAGGER_ENABLED=tru | `DB_USERNAME` | `postgres` | Database user | | `DB_PASSWORD` | `postgres` | Database password | | `DB_NAME` | `agentverse` | Database name | -| `DB_SYNCHRONIZE` | `true` in dev, `false` in prod | TypeORM schema sync | +| `DB_SYNCHRONIZE` | `false` in `.env.example`; `false` in prod | TypeORM schema sync. Keep `false` wherever migrations own the schema — Compose interpolates `.env`, so this file decides what the container gets | | `DB_LOGGING` | `false` | TypeORM SQL logging | | `JWT_SECRET` | `dev-secret` in dev | JWT signing secret | | `JWT_EXPIRES_IN` | `24h` | JWT token lifetime | @@ -77,24 +110,26 @@ Docs use bearer auth and stay disabled in production unless `SWAGGER_ENABLED=tru | `PROMPT_CONTENT_ENCRYPTION_KEY` | — | Base64-encoded 32-byte key used to encrypt prompt blobs before storage | | `AWS_KMS_KEY_ID` | — | KMS key used with tenant and delivery encryption context | | `PROMPT_DELIVERY_WORKER_ENABLED` | `false` | Enables the PostgreSQL delivery worker polling loop | +| `STELLAR_NETWORK` | `testnet` | Stellar network name | +| `STELLAR_RPC_URL` | `https://soroban-testnet.stellar.org` | Soroban RPC endpoint | +| `STELLAR_NETWORK_PASSPHRASE` | `Test SDF Network ; September 2015` | Stellar network passphrase | +| `CORS_ORIGINS` | `*` in dev | Comma-separated allowed origins | +| `SOROBAN_TOKEN_MINT_CONTRACT_ID` | empty | Mint contract ID | +| `SOROBAN_TOKEN_SALE_CONTRACT_ID` | empty | Sale contract ID | +| `STELLAR_ADMIN_SECRET_KEY` | empty | Admin signing key. Optional at boot; required by env validation when `NODE_ENV=production` | +| `SWAGGER_ENABLED` | `false` in prod | Force docs on in production | +| `RUN_MIGRATIONS_ON_START` | `true` | Container entrypoint applies migrations before starting | ## Marketplace purchase flow -Purchase intents are JWT-protected and scoped to published `PROMPT` assets. The buyer signs the returned unsigned XDR locally, then submits only the transaction hash for RPC verification. Run the purchase migration before starting a deployment: +Purchase intents are JWT-protected and scoped to published `PROMPT` assets. The buyer signs the returned unsigned XDR locally, then submits only the transaction hash for RPC verification. Migrations must be applied before a deployment serves traffic; the container entrypoint does this automatically, and outside Docker: ```bash -npm run migration:run +npm run migration:run # from source, uses ts-node +npm run migration:run:prod # from dist/, for the production image ``` Production requires `SOROBAN_MARKETPLACE_CONTRACT_ID`; the Testnet contract cannot be omitted or replaced by the development mock. -| `STELLAR_NETWORK` | `testnet` | Stellar network name | -| `STELLAR_RPC_URL` | `https://soroban-testnet.stellar.org` | Soroban RPC endpoint | -| `STELLAR_NETWORK_PASSPHRASE` | `Test SDF Network ; September 2015` | Stellar network passphrase | -| `CORS_ORIGINS` | `*` in dev | Comma-separated allowed origins | -| `SOROBAN_TOKEN_MINT_CONTRACT_ID` | empty | Mint contract ID | -| `SOROBAN_TOKEN_SALE_CONTRACT_ID` | empty | Sale contract ID | -| `STELLAR_ADMIN_SECRET_KEY` | empty | Optional admin key | -| `SWAGGER_ENABLED` | `false` in prod | Force docs on in production | ## Notes diff --git a/docker-compose.yml b/docker-compose.yml index 53c90f2..c7168fa 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -19,6 +19,9 @@ services: timeout: 5s retries: 5 + # Reserved for the encrypted prompt blob store. No application code imports + # @aws-sdk/client-s3 yet, so the api service does not wait on it; gating + # startup on a bucket nothing reads only adds a way for a clean deploy to hang. minio: image: minio/minio:latest container_name: agentverse-storage @@ -54,7 +57,10 @@ services: DB_USERNAME: ${DB_USERNAME:-postgres} DB_PASSWORD: ${DB_PASSWORD:-postgres} DB_NAME: ${DB_NAME:-agentverse} - DB_SYNCHRONIZE: ${DB_SYNCHRONIZE:-true} + # The schema is applied by entrypoint.sh via migration:run, not by + # TypeORM auto-synchronize. Auto-sync against a migrated database silently + # rewrites columns and indexes. + DB_SYNCHRONIZE: ${DB_SYNCHRONIZE:-false} JWT_SECRET: ${JWT_SECRET:-dev-secret} PORT: 3000 STELLAR_NETWORK: ${STELLAR_NETWORK:-testnet} @@ -64,8 +70,6 @@ services: depends_on: postgres: condition: service_healthy - minio: - condition: service_healthy entrypoint: [ "/app/entrypoint.sh" ] volumes: diff --git a/docs/adr/004-migration-owned-schema.md b/docs/adr/004-migration-owned-schema.md new file mode 100644 index 0000000..afa8d07 --- /dev/null +++ b/docs/adr/004-migration-owned-schema.md @@ -0,0 +1,43 @@ +# ADR 004: Migrations own the deployed schema + +## Status +Accepted + +## Context +ADR 002 added a migration workflow but left `synchronize` as a runtime option, and +nothing applied migrations at deploy time. Both `docker-compose.yml` and the `ci` +workflow therefore ran with `DB_SYNCHRONIZE=true`, so every environment built its +schema by auto-synchronize and no environment exercised the migrations. + +That hid drift between the migration DDL and the entity definitions. All fifteen +uuid primary keys were created without `DEFAULT uuid_generate_v4()`, which +`@PrimaryGeneratedColumn('uuid')` depends on, and `asset_type_enum` was created +with four of the six declared `AssetType` values. A deployment provisioned by +migrations connected, reported healthy, and then failed every insert. + +## Decision +Migrations are the only mechanism that builds the deployed schema. + +- `entrypoint.sh` applies migrations before starting the process and aborts the + boot if any fails, so a clean deploy provisions itself and never serves traffic + on a half-built schema. +- `docker-compose.yml` defaults `DB_SYNCHRONIZE` to `false`, and `.env.example` + sets it to `false` too: Compose interpolates that file, so it is the one that + decides what the container receives. +- A migration aligns the existing schema with the entities, and the staging smoke + suite asserts there is no remaining drift that changes what the database can + store, for the entities registered in `DatabaseModule`. Identifier-only + differences — index, foreign-key and enum type names — are tolerated: the + migrations name them explicitly while TypeORM derives hashed names, and neither + affects a deployment running with `synchronize: false`. Two gaps are known and + recorded in the spec rather than implied away: an entity absent from that array + is invisible to the guard, as `token_transactions` is today, and + `purchases."transactionHash"` is deliberately left wider than the entity. + +## Consequences +- A schema change that is not expressed as a migration fails the smoke suite + rather than being silently applied at startup. +- Multi-replica rollouts must set `RUN_MIGRATIONS_ON_START=false` and run + migrations as a separate release step; concurrent runners race. +- Reverting is bounded by what PostgreSQL can undo. Enum values cannot be + removed, so migration down paths are documented rather than assumed reversible. diff --git a/docs/release-runbook.md b/docs/release-runbook.md new file mode 100644 index 0000000..2a38099 --- /dev/null +++ b/docs/release-runbook.md @@ -0,0 +1,182 @@ +# Testnet Staging Release Runbook + +Covers provisioning, verifying, and rolling back the Testnet staging environment +for the Backend. Values that only a maintainer with deploy access can produce are +left empty and marked `TODO(maintainer)` next to the command that produces them. +An invented URL or contract id here would read as evidence, so none are written. + +## Environment contract + +`src/config/env.validation.ts` requires all sixteen of these when +`NODE_ENV=production`; bootstrap throws naming the first missing one. + +| Variable | Notes | +| --- | --- | +| `DB_HOST`, `DB_PORT`, `DB_USERNAME`, `DB_PASSWORD`, `DB_NAME` | PostgreSQL 16 connection. | +| `JWT_SECRET` | Session signing key. Rotating it invalidates every issued token. | +| `STELLAR_NETWORK`, `STELLAR_RPC_URL`, `STELLAR_NETWORK_PASSPHRASE` | `testnet`, the Soroban RPC endpoint, and `Test SDF Network ; September 2015`. | +| `SOROBAN_MARKETPLACE_CONTRACT_ID` | Deployed PromptMarketplace. A value containing `PLACEHOLDER` is treated as unconfigured by the health check and by the purchase mock gate. | +| `SOROBAN_TOKEN_MINT_CONTRACT_ID`, `SOROBAN_TOKEN_SALE_CONTRACT_ID` | Token contracts. Absent values degrade token operations rather than blocking boot. | +| `STELLAR_ADMIN_SECRET_KEY` | Long-lived signing key for admin token operations. See **Secret ownership**. | +| `CORS_ORIGINS` | Explicit origins. `*` is rejected in production. | +| `AWS_REGION`, `AWS_KMS_KEY_ID` | KMS boundary for encrypted prompt delivery. | + +Set `DB_SYNCHRONIZE=false`; `.env.example` already does. Compose interpolates +`${DB_SYNCHRONIZE}` from that same `.env`, so the file the operator copies is what +the container actually gets — a `true` there defeats the compose default. +Auto-synchronize against a migrated database rewrites columns and indexes in +place; on `purchases` it would drop and re-add `transactionHash`, discarding the +hashes the replay guard depends on. + +Optional: `RUN_MIGRATIONS_ON_START` (default `true`) and +`PROMPT_DELIVERY_WORKER_ENABLED`, which the worker compares to the exact string +`true` — `1`, `TRUE` and `yes` all leave it disabled. + +## Deploy + +The container entrypoint waits for PostgreSQL, applies migrations, and aborts the +boot if any migration fails, so a clean environment provisions itself. + +```bash +docker compose up --build -d +docker compose logs -f api +``` + +Compose defaults `NODE_ENV` to `production` but supplies only nine of the sixteen +required variables itself; the rest come from the `.env` file it reads. +`cp .env.example .env` alone is not enough — `SOROBAN_MARKETPLACE_CONTRACT_ID`, +`SOROBAN_TOKEN_MINT_CONTRACT_ID`, `SOROBAN_TOKEN_SALE_CONTRACT_ID`, +`STELLAR_ADMIN_SECRET_KEY` and `AWS_KMS_KEY_ID` ship empty and bootstrap aborts +naming the first one it finds unset. Fill them, or run with +`NODE_ENV=development` for a local stack. + +Without Docker, the same sequence is: + +```bash +npm ci && npm run build +npm run migration:run:prod +npm run start:prod +``` + +`migration:run:prod` uses the compiled data source, so it works in the production +image where `ts-node` has been pruned. Set `RUN_MIGRATIONS_ON_START=false` and run +migrations as a separate release step before a multi-replica rollout; concurrent +migration runners race. + +## Verify + +```bash +curl -fsS "$BASE_URL/api/health" +``` + +A ready deployment answers `200` with `status: "ok"` and a `checks` array. Each +required check must be `ok`: + +- **database** — `SELECT 1` round-trip. +- **schema** — `detail` is the most recently applied migration. Compare it with + the newest file in `src/database/migrations/`; a mismatch means the deploy is + running against an older schema. +- **sorobanRpc** — required once a real marketplace contract is configured. +- **marketplaceContract**, **deliveryWorker** — reported, never required. + +`GET /api/health/live` answers process liveness only. Use it for restart probes +so a transient dependency outage does not cycle containers, and `/api/health` for +rollout and load-balancer gates. + +Then run the smoke suite against a **migrated, unconfigured** database — a +throwaway one, or a staging database with no marketplace contract set: + +```bash +npm run test:smoke +``` + +It refuses to run without `DB_HOST` and `DB_NAME` rather than passing vacuously. + +It does not run against a fully configured environment. With a real +`SOROBAN_MARKETPLACE_CONTRACT_ID`, building a purchase intent goes to Soroban RPC +and needs a funded buyer account, which is criterion 4's blocker, not something +the suite can arrange. Point it at the deployment's schema, not its live +contract, until that is resolved. + +## Evidence to record + +Fill this table on every staging release and attach it to the release issue. + +| Item | Value | How to produce it | +| --- | --- | --- | +| Backend URL | `TODO(maintainer)` | Deploy target's public URL. | +| UI URL | `TODO(maintainer)` | Blocked: see **Known gaps**. | +| Marketplace contract id | `TODO(maintainer)` | From the Smart-contracts deploy; must not contain `PLACEHOLDER`. | +| Applied migration | `TODO(maintainer)` | `curl -fsS "$BASE_URL/api/health" \| jq -r '.checks[] \| select(.name=="schema") \| .detail'` | +| Image digest | `TODO(maintainer)` | Only once images are published to a registry — no workflow in this repo builds or pushes one. Until then record the commit and the local image id: `docker image inspect --format '{{.Id}}' `. | +| Commit | `TODO(maintainer)` | `git rev-parse HEAD` | +| Smoke run | `TODO(maintainer)` | URL of the `staging-smoke` workflow run for that commit. | + +## Rollback and reconciliation + +Roll back the application first, schema second, and only when the schema is +actually the problem. + +1. Redeploy the previous build — by image digest if the deployment publishes + images, otherwise by the previous commit — with `RUN_MIGRATIONS_ON_START=false`, + so the rollback cannot re-apply the migration being backed out. +2. Confirm `/api/health` reports the expected `schema` detail for that image. +3. Revert one migration at a time with + `node ./node_modules/typeorm/cli.js migration:revert -d dist/database/data-source.js`, + checking `/api/health` between each. + +Reconciliation notes: + +- `AlignMigratedSchemaWithEntities1700000004000` cannot fully revert. PostgreSQL + cannot remove an enum value, so `MODEL` and `ORACLE` remain on + `asset_type_enum` after a revert. They are additive and unused by existing + rows; leaving them is safe. +- Never resolve schema drift by enabling `DB_SYNCHRONIZE`. Write a migration. +- A confirmed Stellar transaction cannot be reversed. Reconcile a bad settlement + by correcting listing access and the matching credit, never by rewriting + `purchases.transactionHash` — the partial unique index on it is the replay + guard. +- For delivery incidents, follow `docs/prompt-delivery-runbook.md`. + +## Secret ownership + +Deploy-environment secrets are held by the Stellar-AgentVerse maintainers, not by +contributors. No secret in this repository, and no value printed by CI. + +| Secret | Owner | Rotation | +| --- | --- | --- | +| `STELLAR_ADMIN_SECRET_KEY` | Maintainers | Fund a new Testnet identity, migrate contract admin to it, then retire the old key. | +| `JWT_SECRET` | Maintainers | Rotate on suspicion of exposure; every issued token is invalidated. | +| `AWS_KMS_KEY_ID` | Maintainers | Follow the key-rotation entry in `docs/prompt-delivery-runbook.md`. Never export DEKs. | +| `DB_PASSWORD` | Maintainers | Rotate in the database first, then the deployment. | + +The `staging-smoke` workflow configures no long-lived signing key. It generates a throwaway +Stellar keypair inside a single step to satisfy production env validation, never +writes it to a file or to `$GITHUB_ENV`, and never signs with it. A final step +fails the run if a Stellar secret seed appears in a tracked file or in the +captured application log. + +## Known gaps + +Blocking issues, not oversights. Each is out of scope for the staging provisioning +work and tracked elsewhere. + +- **No deployed environment.** The repository has no GitHub deployments and no + hosting account a contributor can reach. Every procedure above is exercised in + CI against a throwaway PostgreSQL; none of it has been run against a real + deployed URL. +- **No UI wallet authentication.** The UI does not implement the Freighter + challenge/verify handshake against these endpoints, so the deployed-UI leg of + the journey cannot be verified. The Backend side is covered by the smoke + suite's wallet-authentication group. +- **No publication boundary.** Nothing publishes an asset over HTTP; `POST + /api/assets` creates a `DRAFT` and no route promotes it. The smoke suite + promotes its fixture directly in the database and says so. Tracked by #15. +- **No settled Testnet purchase.** Confirmation does not enqueue a delivery + command, and no AI provider adapter is registered, so a purchase cannot + produce an encrypted delivery result. Tracked by #9. The smoke suite marks + that case as pending rather than asserting a substitute. +- **Chain verification is not exercised in CI.** With no marketplace contract + configured, purchase confirmation runs in its mock-verification mode. The + purchase guards the suite asserts are all evaluated before verification is + reached, so they hold either way; the verification step itself is not covered. diff --git a/entrypoint.sh b/entrypoint.sh index 2195cd0..0415d50 100644 --- a/entrypoint.sh +++ b/entrypoint.sh @@ -4,6 +4,7 @@ set -e DB_HOST="${DB_HOST:-postgres}" DB_PORT="${DB_PORT:-5432}" DB_USERNAME="${DB_USERNAME:-postgres}" +RUN_MIGRATIONS_ON_START="${RUN_MIGRATIONS_ON_START:-true}" echo "Waiting for PostgreSQL at $DB_HOST:$DB_PORT..." @@ -21,4 +22,17 @@ while [ $i -le 30 ]; do sleep 1 done +# The schema is owned by migrations, not by DB_SYNCHRONIZE. Applying them here +# keeps a clean deploy self-provisioning; `set -e` aborts the boot if any +# migration fails, so the container never serves traffic on a half-built schema. +# Set RUN_MIGRATIONS_ON_START=false when a separate release job owns migrations +# (required for multi-replica rollouts, where concurrent runners would race). +if [ "$RUN_MIGRATIONS_ON_START" = "true" ]; then + echo "Applying database migrations..." + node ./node_modules/typeorm/cli.js migration:run -d dist/database/data-source.js + echo "Migrations applied." +else + echo "RUN_MIGRATIONS_ON_START is not 'true'; skipping migrations." +fi + exec node dist/main diff --git a/package.json b/package.json index 3c1b0cf..6f5367f 100644 --- a/package.json +++ b/package.json @@ -21,7 +21,9 @@ "test:watch": "jest --watch", "test:cov": "jest --coverage", "test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand", - "test:e2e": "jest --config ./test/jest-e2e.json" + "test:e2e": "jest --config ./test/jest-e2e.json", + "test:smoke": "jest --config ./test/jest-smoke.json", + "migration:run:prod": "node ./node_modules/typeorm/cli.js migration:run -d dist/database/data-source.js" }, "dependencies": { "@aws-sdk/client-kms": "^3.716.0", diff --git a/src/database/data-source.ts b/src/database/data-source.ts index 773cce9..1880c38 100644 --- a/src/database/data-source.ts +++ b/src/database/data-source.ts @@ -43,6 +43,9 @@ export const dataSourceOptions = { UserAsset, Tag, Purchase, + DeliveryCommandEntity, + DeliveryResultEntity, + DeliveryOutboxEntity, ], migrations: [__dirname + '/migrations/*{.ts,.js}'], synchronize: false, diff --git a/src/database/migrations/1700000004000-AlignMigratedSchemaWithEntities.ts b/src/database/migrations/1700000004000-AlignMigratedSchemaWithEntities.ts new file mode 100644 index 0000000..0cadce3 --- /dev/null +++ b/src/database/migrations/1700000004000-AlignMigratedSchemaWithEntities.ts @@ -0,0 +1,79 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +/** + * Aligns a migration-provisioned schema with the entity definitions the + * application actually boots with. + * + * Migrations 1700000000000-1700000003000 declare every uuid primary key as + * `"id" uuid PRIMARY KEY` with no default, but the entities use + * `@PrimaryGeneratedColumn('uuid')`, for which TypeORM's Postgres driver relies + * on the column default rather than generating the value in the application. + * Under `DB_SYNCHRONIZE=true` the auto-sync adds the default and the mismatch is + * invisible; under `DB_SYNCHRONIZE=false` every insert that does not set `id` + * explicitly fails with `null value in column "id" ... violates not-null + * constraint`. + * + * The same class of drift applies to `asset_type_enum`, which was created with + * four values while `AssetType` declares six. + * + * It does not touch purchases."transactionHash", whose width differs from the + * entity for a reason recorded at the end of up(). + */ +const UUID_PRIMARY_KEY_TABLES = [ + 'activity_logs', + 'asset_capabilities', + 'asset_metrics', + 'asset_specs', + 'asset_workflow_steps', + 'assets', + 'credit_packages', + 'delivery_commands', + 'delivery_outbox', + 'delivery_results', + 'purchases', + 'tags', + 'user_assets', + 'wallet_transactions', + 'wallets', +]; + +const MISSING_ASSET_TYPES = ['MODEL', 'ORACLE']; + +export class AlignMigratedSchemaWithEntities1700000004000 implements MigrationInterface { + name = 'AlignMigratedSchemaWithEntities1700000004000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(`CREATE EXTENSION IF NOT EXISTS "uuid-ossp"`); + + for (const table of UUID_PRIMARY_KEY_TABLES) { + await queryRunner.query( + `ALTER TABLE "${table}" ALTER COLUMN "id" SET DEFAULT uuid_generate_v4()`, + ); + } + + for (const value of MISSING_ASSET_TYPES) { + await queryRunner.query( + `ALTER TYPE "asset_type_enum" ADD VALUE IF NOT EXISTS '${value}'`, + ); + } + + // purchases."transactionHash" is deliberately left at the varchar(128) + // 1700000001000 created it with, even though the entity declares + // varchar(64). ConfirmPurchaseDto accepts 32-128 characters with no hex or + // exact-length check, so narrowing the column converts a malformed hash + // from a rejected request into a 22001 error inside confirm() — a 500 + // where a 400 belongs. Narrow it together with the DTO, not before. + } + + public async down(queryRunner: QueryRunner): Promise { + for (const table of UUID_PRIMARY_KEY_TABLES) { + await queryRunner.query( + `ALTER TABLE "${table}" ALTER COLUMN "id" DROP DEFAULT`, + ); + } + + // PostgreSQL cannot remove a value from an enum type. 'MODEL' and 'ORACLE' + // stay on asset_type_enum after a revert; they are additive and unused by + // any row this migration creates, so leaving them is safe. + } +} diff --git a/src/health/health.controller.spec.ts b/src/health/health.controller.spec.ts index f5aed41..34e710b 100644 --- a/src/health/health.controller.spec.ts +++ b/src/health/health.controller.spec.ts @@ -1,46 +1,81 @@ import { Test, TestingModule } from '@nestjs/testing'; +import { HttpStatus } from '@nestjs/common'; +import type { Response } from 'express'; import { HealthController } from './health.controller'; -import { DataSource } from 'typeorm'; +import { HealthReport, HealthService } from './health.service'; + +function reportWith(overrides: Partial): HealthReport { + return { + status: 'ok', + timestamp: new Date().toISOString(), + uptime: 1, + db: 'connected', + checks: [{ name: 'database', status: 'ok', required: true }], + ...overrides, + }; +} describe('HealthController', () => { let controller: HealthController; - let mockDataSource: Partial; + let healthService: { check: jest.Mock }; + let res: Response; + let statusSpy: jest.Mock; beforeEach(async () => { - mockDataSource = { - query: jest.fn(), - }; + healthService = { check: jest.fn() }; const module: TestingModule = await Test.createTestingModule({ controllers: [HealthController], - providers: [ - { provide: DataSource, useValue: mockDataSource }, - ], + providers: [{ provide: HealthService, useValue: healthService }], }).compile(); controller = module.get(HealthController); + statusSpy = jest.fn().mockReturnThis(); + res = { status: statusSpy } as unknown as Response; }); - it('should return ok with db connected when query succeeds', async () => { - (mockDataSource.query as jest.Mock).mockResolvedValue([{ 1: 1 }]); - const result = await controller.check(); + it('should return 200 and the dependency report when everything is healthy', async () => { + healthService.check.mockResolvedValue(reportWith({})); + + const result = await controller.check(res); + + expect(statusSpy).toHaveBeenCalledWith(HttpStatus.OK); expect(result.status).toBe('ok'); expect(result.db).toBe('connected'); - expect(result).toHaveProperty('timestamp'); - expect(result).toHaveProperty('uptime'); + expect(result.checks).toEqual( + expect.arrayContaining([expect.objectContaining({ name: 'database' })]), + ); }); - it('should return ok with db error when query fails', async () => { - (mockDataSource.query as jest.Mock).mockRejectedValue(new Error('DB down')); - const result = await controller.check(); - expect(result.status).toBe('ok'); + it('should return 503 when a required dependency is down', async () => { + healthService.check.mockResolvedValue( + reportWith({ + status: 'error', + db: 'error', + checks: [ + { + name: 'database', + status: 'error', + required: true, + detail: 'DB down', + }, + ], + }), + ); + + const result = await controller.check(res); + + expect(statusSpy).toHaveBeenCalledWith(HttpStatus.SERVICE_UNAVAILABLE); + expect(result.status).toBe('error'); expect(result.db).toBe('error'); }); - it('should return ok with db error when datasource is not initialized', async () => { - (mockDataSource.query as jest.Mock).mockRejectedValue(new Error('not initialized')); - const result = await controller.check(); + it('should keep liveness at 200 regardless of dependencies', () => { + const result = controller.live(); + expect(result.status).toBe('ok'); - expect(result.db).toBe('error'); + expect(result).toHaveProperty('timestamp'); + expect(result).toHaveProperty('uptime'); + expect(healthService.check).not.toHaveBeenCalled(); }); }); diff --git a/src/health/health.controller.ts b/src/health/health.controller.ts index 8130bdd..c081efa 100644 --- a/src/health/health.controller.ts +++ b/src/health/health.controller.ts @@ -1,25 +1,42 @@ -import { Controller, Get } from '@nestjs/common'; -import { DataSource } from 'typeorm'; +import { Controller, Get, HttpStatus, Res } from '@nestjs/common'; import { SkipThrottle } from '@nestjs/throttler'; +import type { Response } from 'express'; +import { HealthReport, HealthService } from './health.service'; @Controller('health') @SkipThrottle() export class HealthController { - constructor(private dataSource: DataSource) {} + constructor(private readonly health: HealthService) {} + /** + * Readiness. Returns 503 when a required dependency is down so that rollout + * gates and load balancers stop sending traffic to a deployment that cannot + * serve it. Responses are not wrapped by ResponseInterceptor (it bypasses any + * path containing `/health`). + */ @Get() - async check() { - let dbStatus = 'connected'; - try { - await this.dataSource.query('SELECT 1'); - } catch { - dbStatus = 'error'; - } + async check( + @Res({ passthrough: true }) res: Response, + ): Promise { + const report = await this.health.check(); + + res.status( + report.status === 'ok' ? HttpStatus.OK : HttpStatus.SERVICE_UNAVAILABLE, + ); + + return report; + } + + /** + * Liveness. Answers "is this process running", nothing more. Restart probes + * use this so a transient dependency outage does not cycle containers. + */ + @Get('live') + live() { return { status: 'ok', timestamp: new Date().toISOString(), uptime: process.uptime(), - db: dbStatus, }; } } diff --git a/src/health/health.module.ts b/src/health/health.module.ts index 7476abe..41c3ff5 100644 --- a/src/health/health.module.ts +++ b/src/health/health.module.ts @@ -1,7 +1,12 @@ import { Module } from '@nestjs/common'; +import { ConfigModule } from '@nestjs/config'; +import { sorobanConfig } from '../tokens/config/soroban.config'; import { HealthController } from './health.controller'; +import { HealthService } from './health.service'; @Module({ + imports: [ConfigModule.forFeature(sorobanConfig)], controllers: [HealthController], + providers: [HealthService], }) export class HealthModule {} diff --git a/src/health/health.service.spec.ts b/src/health/health.service.spec.ts new file mode 100644 index 0000000..70fddb2 --- /dev/null +++ b/src/health/health.service.spec.ts @@ -0,0 +1,189 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { DataSource } from 'typeorm'; +import { sorobanConfig } from '../tokens/config/soroban.config'; +import { DependencyCheck, HealthService } from './health.service'; + +const APPLIED_MIGRATION = 'AlignMigratedSchemaWithEntities1700000004000'; + +function find(checks: DependencyCheck[], name: string): DependencyCheck { + const check = checks.find((candidate) => candidate.name === name); + if (!check) throw new Error(`missing check: ${name}`); + return check; +} + +describe('HealthService', () => { + let service: HealthService; + let dataSource: { query: jest.Mock; options: { synchronize: boolean } }; + let soroban: { rpcUrl: string; contracts: { purchaseContractId: string } }; + let fetchMock: jest.Mock; + + const originalFetch = global.fetch; + const originalWorkerFlag = process.env.PROMPT_DELIVERY_WORKER_ENABLED; + + async function build() { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + HealthService, + { provide: DataSource, useValue: dataSource }, + { provide: sorobanConfig.KEY, useValue: soroban }, + ], + }).compile(); + + service = module.get(HealthService); + } + + beforeEach(async () => { + dataSource = { + options: { synchronize: false }, + query: jest + .fn() + .mockImplementation((sql: string) => + sql.includes('migrations') + ? Promise.resolve([{ name: APPLIED_MIGRATION }]) + : Promise.resolve([{ '?column?': 1 }]), + ), + }; + soroban = { + rpcUrl: 'https://soroban-testnet.stellar.org', + contracts: { purchaseContractId: '' }, + }; + fetchMock = jest.fn().mockResolvedValue({ ok: true, status: 200 }); + global.fetch = fetchMock as unknown as typeof fetch; + delete process.env.PROMPT_DELIVERY_WORKER_ENABLED; + + await build(); + }); + + afterEach(() => { + global.fetch = originalFetch; + if (originalWorkerFlag === undefined) { + delete process.env.PROMPT_DELIVERY_WORKER_ENABLED; + } else { + process.env.PROMPT_DELIVERY_WORKER_ENABLED = originalWorkerFlag; + } + }); + + it('should report ok when the database and schema are reachable', async () => { + const report = await service.check(); + + expect(report.status).toBe('ok'); + expect(report.db).toBe('connected'); + expect(find(report.checks, 'database').status).toBe('ok'); + expect(find(report.checks, 'schema').detail).toBe(APPLIED_MIGRATION); + }); + + it('should report error when the database is unreachable', async () => { + dataSource.query.mockRejectedValue(new Error('connection refused')); + + const report = await service.check(); + + expect(report.status).toBe('error'); + expect(report.db).toBe('error'); + expect(find(report.checks, 'database').detail).toContain( + 'connection refused', + ); + }); + + it('should report error when no migration has been applied', async () => { + dataSource.query.mockImplementation((sql: string) => + sql.includes('migrations') + ? Promise.resolve([]) + : Promise.resolve([{ ok: 1 }]), + ); + + const report = await service.check(); + + expect(report.status).toBe('error'); + expect(find(report.checks, 'schema').detail).toBe( + 'no migrations have been applied', + ); + }); + + it('should not require a migrations table when auto-synchronize owns the schema', async () => { + dataSource.options.synchronize = true; + await build(); + + const report = await service.check(); + + // The schema is legitimately absent from `migrations` in this mode, so the + // probe must not hold the deployment permanently unready. + expect(report.status).toBe('ok'); + expect(find(report.checks, 'schema').status).toBe('skipped'); + expect(find(report.checks, 'schema').required).toBe(false); + }); + + it('should fail the database check rather than hang when a query never settles', async () => { + jest.useFakeTimers(); + dataSource.query.mockImplementation(() => new Promise(() => {})); + + const pending = service.check(); + await jest.advanceTimersByTimeAsync(4000); + const report = await pending; + jest.useRealTimers(); + + expect(report.status).toBe('error'); + expect(find(report.checks, 'database').detail).toContain('did not answer'); + }); + + it('should treat Soroban RPC as optional while no marketplace contract is configured', async () => { + fetchMock.mockRejectedValue(new Error('rpc unreachable')); + + const report = await service.check(); + + expect(report.status).toBe('ok'); + expect(find(report.checks, 'sorobanRpc').status).toBe('error'); + expect(find(report.checks, 'sorobanRpc').required).toBe(false); + expect(find(report.checks, 'marketplaceContract').status).toBe('skipped'); + }); + + it('should treat Soroban RPC as required once a marketplace contract is configured', async () => { + soroban.contracts.purchaseContractId = + 'CDEPLOYEDMARKETPLACECONTRACTIDFORUNITTEST'; + await build(); + fetchMock.mockRejectedValue(new Error('rpc unreachable')); + + const report = await service.check(); + + expect(report.status).toBe('error'); + expect(find(report.checks, 'sorobanRpc').required).toBe(true); + expect(find(report.checks, 'marketplaceContract').status).toBe('ok'); + }); + + it('should not treat a PLACEHOLDER contract id as a configured contract', async () => { + soroban.contracts.purchaseContractId = 'PLACEHOLDER'; + await build(); + fetchMock.mockRejectedValue(new Error('rpc unreachable')); + + const report = await service.check(); + + expect(report.status).toBe('ok'); + expect(find(report.checks, 'sorobanRpc').required).toBe(false); + expect(find(report.checks, 'marketplaceContract').status).toBe('skipped'); + }); + + it('should surface a disabled delivery worker without failing the probe', async () => { + const report = await service.check(); + + expect(report.status).toBe('ok'); + expect(find(report.checks, 'deliveryWorker').status).toBe('skipped'); + }); + + it('should report the delivery worker as enabled only for the exact flag value', async () => { + process.env.PROMPT_DELIVERY_WORKER_ENABLED = 'TRUE'; + expect(find((await service.check()).checks, 'deliveryWorker').status).toBe( + 'skipped', + ); + + process.env.PROMPT_DELIVERY_WORKER_ENABLED = 'true'; + expect(find((await service.check()).checks, 'deliveryWorker').status).toBe( + 'ok', + ); + }); + + it('should cache the Soroban RPC probe instead of calling it on every request', async () => { + await service.check(); + await service.check(); + + expect(fetchMock).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/health/health.service.ts b/src/health/health.service.ts new file mode 100644 index 0000000..16a4068 --- /dev/null +++ b/src/health/health.service.ts @@ -0,0 +1,273 @@ +import { Inject, Injectable, Logger } from '@nestjs/common'; +import { DataSource } from 'typeorm'; +import { sorobanConfig } from '../tokens/config/soroban.config'; + +export type DependencyStatus = 'ok' | 'error' | 'skipped'; + +export interface DependencyCheck { + name: string; + status: DependencyStatus; + required: boolean; + latencyMs?: number; + detail?: string; +} + +export interface HealthReport { + status: 'ok' | 'error'; + timestamp: string; + uptime: number; + /** Retained for backward compatibility with the previous response shape. */ + db: 'connected' | 'error'; + checks: DependencyCheck[]; +} + +/** Soroban RPC is polled at most once per window; health probes run often. */ +const RPC_CACHE_TTL_MS = 15_000; +const RPC_TIMEOUT_MS = 2_000; +/** + * A partitioned database accepts the TCP connect and then never answers, so an + * unbounded query would hang the probe instead of reporting 503 — the one thing + * a readiness endpoint must not do. + */ +const DB_TIMEOUT_MS = 3_000; + +type SorobanSettings = { + rpcUrl: string; + contracts: { purchaseContractId: string }; +}; + +@Injectable() +export class HealthService { + private readonly logger = new Logger(HealthService.name); + private rpcCache?: { expiresAt: number; check: DependencyCheck }; + + constructor( + private readonly dataSource: DataSource, + @Inject(sorobanConfig.KEY) private readonly soroban: SorobanSettings, + ) {} + + /** + * Verifies the dependencies a request actually needs, not just that the + * process is up. A required dependency in `error` makes the whole report + * `error`, which the controller surfaces as HTTP 503. + */ + async check(): Promise { + // Concurrent: one slow dependency must not add its latency to the others. + const checks = await Promise.all([ + this.checkDatabase(), + this.checkSchema(), + this.checkSorobanRpc(), + Promise.resolve(this.checkMarketplaceContract()), + Promise.resolve(this.checkDeliveryWorker()), + ]); + + const degraded = checks.some( + (check) => check.required && check.status === 'error', + ); + const database = checks.find((check) => check.name === 'database'); + + return { + status: degraded ? 'error' : 'ok', + timestamp: new Date().toISOString(), + uptime: process.uptime(), + db: database?.status === 'ok' ? 'connected' : 'error', + checks, + }; + } + + /** Auto-synchronize builds the schema itself and never creates a migrations table. */ + private get schemaIsMigrationOwned(): boolean { + return this.dataSource.options?.synchronize !== true; + } + + private async withDeadline(work: Promise, label: string): Promise { + let timer: NodeJS.Timeout | undefined; + try { + return await Promise.race([ + work, + new Promise((_, reject) => { + timer = setTimeout( + () => + reject( + new Error(`${label} did not answer within ${DB_TIMEOUT_MS}ms`), + ), + DB_TIMEOUT_MS, + ); + }), + ]); + } finally { + if (timer) clearTimeout(timer); + } + } + + private async checkDatabase(): Promise { + const startedAt = Date.now(); + try { + await this.withDeadline(this.dataSource.query('SELECT 1'), 'database'); + return { + name: 'database', + status: 'ok', + required: true, + latencyMs: Date.now() - startedAt, + }; + } catch (error) { + return { + name: 'database', + status: 'error', + required: true, + latencyMs: Date.now() - startedAt, + detail: (error as Error).message, + }; + } + } + + /** + * A deploy that starts with `DB_SYNCHRONIZE=false` and no migrations applied + * connects successfully and then fails every query against a missing table. + * Reporting the applied migration also tells an operator which schema + * version is live, which the release runbook records. + */ + private async checkSchema(): Promise { + if (!this.schemaIsMigrationOwned) { + return { + name: 'schema', + status: 'skipped', + required: false, + detail: + 'DB_SYNCHRONIZE is enabled; the schema is owned by auto-synchronize', + }; + } + + try { + const rows = await this.withDeadline( + this.dataSource.query<{ name: string }[]>( + 'SELECT name FROM migrations ORDER BY timestamp DESC LIMIT 1', + ), + 'schema', + ); + const latest = rows?.[0]?.name; + + if (!latest) { + return { + name: 'schema', + status: 'error', + required: true, + detail: 'no migrations have been applied', + }; + } + + return { + name: 'schema', + status: 'ok', + required: true, + detail: latest, + }; + } catch (error) { + return { + name: 'schema', + status: 'error', + required: true, + detail: (error as Error).message, + }; + } + } + + /** + * TokensService.validateConfig() and PurchasesService's mock gate both treat a + * contract id containing 'PLACEHOLDER' as absent; health reports it the same + * way so a half-configured environment is not shown as ready. + */ + private hasMarketplaceContract(): boolean { + const contractId = this.soroban.contracts.purchaseContractId; + return Boolean(contractId) && !contractId.includes('PLACEHOLDER'); + } + + /** + * Required only once a marketplace contract is configured: without RPC the + * deployment cannot build or verify a purchase, but a Backend running with + * no contract has nothing to reach RPC for. + */ + private async checkSorobanRpc(): Promise { + const required = this.hasMarketplaceContract(); + + if (this.rpcCache && this.rpcCache.expiresAt > Date.now()) { + return { ...this.rpcCache.check, required }; + } + + const startedAt = Date.now(); + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), RPC_TIMEOUT_MS); + + let check: DependencyCheck; + try { + const response = await fetch(this.soroban.rpcUrl, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'getHealth' }), + signal: controller.signal, + }); + + check = response.ok + ? { + name: 'sorobanRpc', + status: 'ok', + required, + latencyMs: Date.now() - startedAt, + } + : { + name: 'sorobanRpc', + status: 'error', + required, + latencyMs: Date.now() - startedAt, + detail: `rpc responded ${response.status}`, + }; + } catch (error) { + check = { + name: 'sorobanRpc', + status: 'error', + required, + latencyMs: Date.now() - startedAt, + detail: (error as Error).message, + }; + } finally { + clearTimeout(timer); + } + + this.rpcCache = { expiresAt: Date.now() + RPC_CACHE_TTL_MS, check }; + return check; + } + + private checkMarketplaceContract(): DependencyCheck { + return this.hasMarketplaceContract() + ? { + name: 'marketplaceContract', + status: 'ok', + required: false, + detail: this.soroban.contracts.purchaseContractId, + } + : { + name: 'marketplaceContract', + status: 'skipped', + required: false, + detail: 'SOROBAN_MARKETPLACE_CONTRACT_ID is not configured', + }; + } + + /** + * The worker gate is read verbatim from the environment by + * PromptDeliveryWorker; reporting it here makes a staging deployment that + * silently stalls at AUTHORIZED visible from the health endpoint. + */ + private checkDeliveryWorker(): DependencyCheck { + const enabled = process.env.PROMPT_DELIVERY_WORKER_ENABLED === 'true'; + + return { + name: 'deliveryWorker', + status: enabled ? 'ok' : 'skipped', + required: false, + detail: enabled + ? 'polling enabled' + : 'PROMPT_DELIVERY_WORKER_ENABLED is not "true"', + }; + } +} diff --git a/src/tokens/tokens.service.spec.ts b/src/tokens/tokens.service.spec.ts index b908c1f..5fba5d3 100644 --- a/src/tokens/tokens.service.spec.ts +++ b/src/tokens/tokens.service.spec.ts @@ -97,6 +97,43 @@ describe('TokensService', () => { jest.useRealTimers(); }); + describe('without a configured admin secret', () => { + async function bootWithoutAdminSecret(): Promise { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + TokensService, + { + provide: sorobanConfig.KEY, + useValue: { ...defaultConfig, adminSecretKey: '' }, + }, + ], + }).compile(); + + return module.get(TokensService); + } + + it('starts without deriving a keypair instead of crashing the bootstrap', async () => { + const unconfigured = await bootWithoutAdminSecret(); + + // Previously this threw from Keypair.fromSecret(''), killing bootstrap. + // The next test covers the other half: no keypair was derived either. + expect(() => unconfigured.onModuleInit()).not.toThrow(); + }); + + it('reports the missing secret when a token operation needs to sign', async () => { + const unconfigured = await bootWithoutAdminSecret(); + unconfigured.onModuleInit(); + + const result = await unconfigured.mintTokens('GBUYER', '10'); + + expect(result).toEqual({ + error: 'mintTokens failed', + details: + 'STELLAR_ADMIN_SECRET_KEY is not configured; token operations are unavailable', + }); + }); + }); + it('initializes the Stellar RPC server on module init', () => { expect(StellarSdk.rpc.Server).toHaveBeenCalledWith('https://rpc.test'); expect((service as any).rpc).toBeDefined(); diff --git a/src/tokens/tokens.service.ts b/src/tokens/tokens.service.ts index 9937d34..bd6f497 100644 --- a/src/tokens/tokens.service.ts +++ b/src/tokens/tokens.service.ts @@ -10,7 +10,7 @@ export class TokensService implements OnModuleInit { private readonly logger = new Logger(TokensService.name); private rpc!: StellarSdk.rpc.Server; private networkPassphrase!: string; - private adminKeypair!: StellarSdk.Keypair; + private adminKeypair?: StellarSdk.Keypair; constructor( @Inject(sorobanConfig.KEY) @@ -27,12 +27,30 @@ export class TokensService implements OnModuleInit { this.rpc = new StellarSdk.rpc.Server(this.config.rpcUrl); this.networkPassphrase = this.config.networkPassphrase; this.validateConfig(); - this.adminKeypair = StellarSdk.Keypair.fromSecret( - this.config.adminSecretKey, - ); + + // validateConfig() only warns when the admin secret is absent, so the + // keypair must be optional too — building it unconditionally turned that + // warning into a bootstrap crash and made the process unstartable without a + // long-lived signing key. Token operations now fail at the point of use. + if (this.config.adminSecretKey) { + this.adminKeypair = StellarSdk.Keypair.fromSecret( + this.config.adminSecretKey, + ); + } + this.logger.log(`Connected to Stellar RPC: ${this.config.rpcUrl}`); } + private requireAdminKeypair(): StellarSdk.Keypair { + if (!this.adminKeypair) { + throw new Error( + 'STELLAR_ADMIN_SECRET_KEY is not configured; token operations are unavailable', + ); + } + + return this.adminKeypair; + } + private validateConfig(): void { const missing: string[] = []; @@ -91,7 +109,8 @@ export class TokensService implements OnModuleInit { fnName: string, args: StellarSdk.xdr.ScVal[], ): Promise<{ hash: string; finalStatus: string }> { - const account = await this.rpc.getAccount(this.adminKeypair.publicKey()); + const adminKeypair = this.requireAdminKeypair(); + const account = await this.rpc.getAccount(adminKeypair.publicKey()); const contract = new StellarSdk.Contract(contractId); const tx = new StellarSdk.TransactionBuilder(account, { @@ -104,7 +123,7 @@ export class TokensService implements OnModuleInit { const simResult = await this.rpc.simulateTransaction(tx); const assembled = StellarSdk.rpc.assembleTransaction(tx, simResult).build(); - assembled.sign(this.adminKeypair); + assembled.sign(adminKeypair); const sendResponse = await this.rpc.sendTransaction(assembled); if ( sendResponse.status === 'ERROR' || @@ -215,7 +234,9 @@ export class TokensService implements OnModuleInit { } try { - const account = await this.rpc.getAccount(this.adminKeypair.publicKey()); + const account = await this.rpc.getAccount( + this.requireAdminKeypair().publicKey(), + ); const contract = new StellarSdk.Contract(this.config.contracts.tokenMint); const tx = new StellarSdk.TransactionBuilder(account, { @@ -232,7 +253,7 @@ export class TokensService implements OnModuleInit { .build(); const simResult = await this.rpc.simulateTransaction(tx); - // eslint-disable-next-line @typescript-eslint/no-explicit-any + const retval = (simResult as any).result?.retval; if (!retval) { diff --git a/test/app.e2e-spec.ts b/test/app.e2e-spec.ts index 1fbc9ab..4d81703 100644 --- a/test/app.e2e-spec.ts +++ b/test/app.e2e-spec.ts @@ -4,17 +4,44 @@ import request from 'supertest'; import { App } from 'supertest/types'; import { DataSource } from 'typeorm'; import { HealthController } from '../src/health/health.controller'; +import { HealthService } from '../src/health/health.service'; +import { sorobanConfig } from '../src/tokens/config/soroban.config'; + +interface HealthBody { + status: string; + db?: string; + checks?: { name: string; status: string }[]; +} + +function bodyOf(response: { body: unknown }): HealthBody { + return response.body as HealthBody; +} describe('HealthController (e2e)', () => { let app: INestApplication; const dataSourceMock = { query: jest.fn(), + options: { synchronize: false }, + }; + const sorobanMock = { + rpcUrl: 'https://soroban-testnet.stellar.org', + contracts: { purchaseContractId: '' }, }; + const originalFetch = global.fetch; + beforeEach(async () => { + global.fetch = jest + .fn() + .mockResolvedValue({ ok: true, status: 200 }) as unknown as typeof fetch; + const moduleFixture: TestingModule = await Test.createTestingModule({ controllers: [HealthController], - providers: [{ provide: DataSource, useValue: dataSourceMock }], + providers: [ + HealthService, + { provide: DataSource, useValue: dataSourceMock }, + { provide: sorobanConfig.KEY, useValue: sorobanMock }, + ], }).compile(); app = moduleFixture.createNestApplication(); @@ -24,18 +51,55 @@ describe('HealthController (e2e)', () => { afterEach(async () => { await app.close(); + global.fetch = originalFetch; jest.clearAllMocks(); }); - it('/api/health (GET)', async () => { - dataSourceMock.query.mockResolvedValue([{ 1: 1 }]); + it('/api/health (GET) reports the applied schema when dependencies are healthy', async () => { + dataSourceMock.query.mockImplementation((sql: string) => + sql.includes('migrations') + ? Promise.resolve([ + { name: 'AlignMigratedSchemaWithEntities1700000004000' }, + ]) + : Promise.resolve([{ '?column?': 1 }]), + ); await request(app.getHttpServer()) .get('/api/health') .expect(200) .expect((res) => { - expect(res.body.status).toBe('ok'); - expect(res.body.db).toBe('connected'); + const report = bodyOf(res); + expect(report.status).toBe('ok'); + expect(report.db).toBe('connected'); + expect(report.checks).toEqual( + expect.arrayContaining([ + expect.objectContaining({ name: 'schema', status: 'ok' }), + ]), + ); + }); + }); + + it('/api/health (GET) returns 503 when the database is unreachable', async () => { + dataSourceMock.query.mockRejectedValue(new Error('connection refused')); + + await request(app.getHttpServer()) + .get('/api/health') + .expect(503) + .expect((res) => { + const report = bodyOf(res); + expect(report.status).toBe('error'); + expect(report.db).toBe('error'); + }); + }); + + it('/api/health/live (GET) stays 200 when the database is unreachable', async () => { + dataSourceMock.query.mockRejectedValue(new Error('connection refused')); + + await request(app.getHttpServer()) + .get('/api/health/live') + .expect(200) + .expect((res) => { + expect(bodyOf(res).status).toBe('ok'); }); }); }); diff --git a/test/jest-smoke.json b/test/jest-smoke.json new file mode 100644 index 0000000..4402f85 --- /dev/null +++ b/test/jest-smoke.json @@ -0,0 +1,11 @@ +{ + "moduleFileExtensions": ["js", "json", "ts"], + "rootDir": ".", + "testEnvironment": "node", + "testRegex": ".smoke-spec.ts$", + "testTimeout": 60000, + "maxWorkers": 1, + "transform": { + "^.+\\.(t|j)s$": "ts-jest" + } +} diff --git a/test/staging-smoke.smoke-spec.ts b/test/staging-smoke.smoke-spec.ts new file mode 100644 index 0000000..a8f36e9 --- /dev/null +++ b/test/staging-smoke.smoke-spec.ts @@ -0,0 +1,545 @@ +/** + * Staging smoke suite. + * + * Runs the market journey against a REAL PostgreSQL whose schema was built by + * `migration:run`, with `DB_SYNCHRONIZE=false` — the configuration a deployed + * environment uses, and the one under which auto-sync can no longer paper over + * schema drift. `npm run test:e2e` deliberately does not pick this file up: the + * e2e specs mock their data layer and run without a database, this one must not. + * + * What this suite proves: + * - a migration-provisioned schema can actually serve reads and writes; + * - the wallet handshake verifies real Ed25519 signatures; + * - authorization, duplicate-confirmation and replay guards reject as designed. + * + * What it does NOT prove, and why: + * - Chain verification. With no `SOROBAN_MARKETPLACE_CONTRACT_ID` configured, + * PurchasesService runs in its documented mock-verification mode + * (src/marketplace/purchases.service.ts:44-49). The purchase guards below + * are all evaluated BEFORE verifyTransaction() is reached, so they are + * exercised for real; the confirmation that precedes them is not. The real + * settlement case stays pending until a contract is configured — see + * docs/release-runbook.md. + * - Delivery of an encrypted result. Nothing enqueues a delivery command on + * confirmation yet, so there is no result to retrieve (Backend #9). + */ +import { INestApplication } from '@nestjs/common'; +import { Test, TestingModule } from '@nestjs/testing'; +import { Keypair } from '@stellar/stellar-sdk'; +import request from 'supertest'; +import { App } from 'supertest/types'; +import { DataSource } from 'typeorm'; +import { AppModule } from '../src/app.module'; +import { setupApp } from '../src/config'; +import { HttpExceptionFilter } from '../src/common/filters/http-exception.filter'; +import { ResponseInterceptor } from '../src/common/interceptors/response.interceptor'; +import { Asset, AssetStatus, AssetType, User } from '../src/database/entities'; +import { Purchase } from '../src/database/entities/purchase.entity'; + +const EXPECTED_ASSET_TYPES = [ + 'AGENT', + 'PROMPT', + 'MODEL', + 'DATASET', + 'TOOL', + 'ORACLE', +]; + +const APPLIED_MIGRATION = 'AlignMigratedSchemaWithEntities1700000004000'; + +/** ResponseInterceptor wraps every non-health payload. */ +interface Envelope { + data: T; + meta: { timestamp: string }; +} + +interface ErrorBody { + message: string; +} + +interface HealthBody { + status: string; + db: string; + checks: { name: string; status: string; detail?: string }[]; +} + +interface IntentBody { + purchaseId: string; + contractId: string; + unsignedXdr: string; +} + +function bodyOf(response: { body: unknown }): T { + return response.body as T; +} + +/** + * Drift that changes what the database can store. Identifier-only differences + * (index, foreign-key and enum type names) are expected: the migrations name + * them explicitly while TypeORM derives hashed names, and neither affects a + * deployment running with `synchronize: false`. + * + * Known blind spot: because index renames are tolerated, an index the entities + * declare but no migration creates would also be tolerated, including a UNIQUE + * one. The explicit uuid-default and enum-value assertions above cover the two + * drift classes that actually broke a migrated deployment; tightening this to + * pair every CREATE INDEX with a matching DROP is worth doing when a missing + * index bites. + */ +const SCHEMA_BREAKING_PATTERNS: { label: string; pattern: RegExp }[] = [ + { label: 'missing table', pattern: /^CREATE TABLE/i }, + { label: 'unexpected table', pattern: /^DROP TABLE/i }, + { label: 'missing column', pattern: /ADD "[^"]+" /i }, + { label: 'unexpected column', pattern: /DROP COLUMN/i }, + { + label: 'missing uuid default', + pattern: /SET DEFAULT uuid_generate_v4\(\)/i, + }, + { label: 'missing enum value', pattern: /ADD VALUE/i }, + { + label: 'column type mismatch', + pattern: /ALTER COLUMN "[^"]+" TYPE (?!"public")/i, + }, +]; + +/** + * purchases."transactionHash" is varchar(128) in the database and varchar(64) on + * the entity, on purpose: ConfirmPurchaseDto accepts 32-128 characters, so + * narrowing the column would turn a malformed hash into a 500 inside confirm(). + * The column is wider than the entity, never narrower, so nothing the + * application can produce fails to store. + */ +const ACCEPTED_DRIFT = [/"transactionHash"/i]; + +function requireDatabaseEnv(): void { + if (!process.env.DB_HOST || !process.env.DB_NAME) { + throw new Error( + 'staging smoke suite requires DB_HOST and DB_NAME to point at a migrated ' + + 'database; refusing to run against an unconfigured target rather than ' + + 'passing vacuously', + ); + } +} + +describe('Testnet staging smoke', () => { + let app: INestApplication; + let http: App; + let dataSource: DataSource; + + const marketplaceContractId = + process.env.SOROBAN_MARKETPLACE_CONTRACT_ID?.trim() ?? ''; + const chainVerificationConfigured = marketplaceContractId.length > 0; + + const buyer = Keypair.random(); + const otherBuyer = Keypair.random(); + const createdAssetIds: string[] = []; + const createdPurchaseIds: string[] = []; + const authenticatedKeys: string[] = []; + + let buyerToken: string; + let otherBuyerToken: string; + let publishedPromptId: string; + + async function authenticate(keypair: Keypair): Promise { + authenticatedKeys.push(keypair.publicKey()); + + const challengeResponse = await request(http) + .post('/api/auth/challenge') + .send({ publicKey: keypair.publicKey() }) + .expect(200); + + const { challenge } = + bodyOf>(challengeResponse).data; + const signature = keypair + .sign(Buffer.from(challenge, 'utf-8')) + .toString('hex'); + + const walletResponse = await request(http) + .post('/api/auth/wallet') + .send({ publicKey: keypair.publicKey(), signature }) + .expect(200); + + return bodyOf>(walletResponse).data.token; + } + + async function createIntent(token: string): Promise { + const response = await request(http) + .post('/api/marketplace/purchases') + .set('Authorization', `Bearer ${token}`) + .send({ assetId: publishedPromptId }) + .expect(201); + + const { purchaseId } = bodyOf>(response).data; + createdPurchaseIds.push(purchaseId); + return purchaseId; + } + + // Not `async`: callers chain supertest's own `.expect()` on the returned Test. + function confirm(purchaseId: string, token: string, transactionHash: string) { + return request(http) + .post(`/api/marketplace/purchases/${purchaseId}/confirm`) + .set('Authorization', `Bearer ${token}`) + .send({ transactionHash }); + } + + beforeAll(async () => { + requireDatabaseEnv(); + + const moduleFixture: TestingModule = await Test.createTestingModule({ + imports: [AppModule], + }).compile(); + + // Same pipeline main.ts installs, so response shapes and error codes match + // what a deployed environment returns. + app = moduleFixture.createNestApplication(); + setupApp(app); + app.useGlobalFilters(new HttpExceptionFilter()); + app.useGlobalInterceptors(new ResponseInterceptor()); + await app.init(); + + http = app.getHttpServer(); + dataSource = app.get(DataSource); + + buyerToken = await authenticate(buyer); + otherBuyerToken = await authenticate(otherBuyer); + + // There is no publication endpoint yet (Backend #15 owns the curated + // publication boundary), so the fixture is promoted directly. Recorded as a + // gap in docs/release-runbook.md rather than presented as a real publication. + const assets = dataSource.getRepository(Asset); + const fixture = assets.create({ + name: 'Smoke private prompt', + slug: `smoke-private-prompt-${Date.now()}`, + description: 'Fixture for the staging smoke suite', + type: AssetType.PROMPT, + status: AssetStatus.PUBLISHED, + creatorPublicKey: otherBuyer.publicKey(), + price: 10, + }); + const asset = await assets.save(fixture); + + publishedPromptId = asset.id; + createdAssetIds.push(asset.id); + }, 60_000); + + afterAll(async () => { + if (dataSource?.isInitialized) { + // Each delete is independent: a failure in one must not strand the others, + // or a partial run leaves rows behind in a shared environment. + const cleanups: [string, string[], () => Promise][] = [ + [ + 'purchases', + createdPurchaseIds, + () => dataSource.getRepository(Purchase).delete(createdPurchaseIds), + ], + [ + 'assets', + createdAssetIds, + () => dataSource.getRepository(Asset).delete(createdAssetIds), + ], + // The wallet handshake upserts a real row per public key. + [ + 'users', + authenticatedKeys, + () => dataSource.getRepository(User).delete(authenticatedKeys), + ], + ]; + + for (const [label, ids, run] of cleanups) { + // TypeORM rejects an empty criteria list outright. + if (!ids.length) continue; + try { + await run(); + } catch (error) { + console.warn(`smoke cleanup failed for ${label}:`, error); + } + } + } + await app?.close(); + }); + + describe('deploy shape', () => { + it('serves a schema built by migrations, not by auto-synchronize', async () => { + expect(dataSource.options.synchronize).toBe(false); + + const applied = await dataSource.query<{ name: string }[]>( + 'SELECT name FROM migrations ORDER BY timestamp ASC', + ); + + expect(applied.length).toBeGreaterThanOrEqual(5); + expect(applied.map((row) => row.name)).toContain(APPLIED_MIGRATION); + }); + + it('reports dependency health rather than process liveness', async () => { + const response = await request(http).get('/api/health').expect(200); + const report = bodyOf(response); + const byName = Object.fromEntries( + report.checks.map((check) => [check.name, check]), + ); + + expect(report.status).toBe('ok'); + expect(report.db).toBe('connected'); + expect(byName.database.status).toBe('ok'); + expect(byName.schema.status).toBe('ok'); + expect(byName.schema.detail).toBe(APPLIED_MIGRATION); + }); + + it('gives every uuid primary key a database-side default', async () => { + const missing = await dataSource.query<{ table_name: string }[]>(` + SELECT c.table_name + FROM information_schema.columns c + JOIN information_schema.table_constraints tc + ON tc.table_name = c.table_name + AND tc.table_schema = 'public' + AND tc.constraint_type = 'PRIMARY KEY' + JOIN information_schema.key_column_usage k + ON k.constraint_name = tc.constraint_name + AND k.column_name = c.column_name + WHERE c.table_schema = 'public' + AND c.data_type = 'uuid' + AND c.column_default IS NULL + `); + + expect(missing.map((row) => row.table_name)).toEqual([]); + }); + + it('accepts every AssetType the application can produce', async () => { + const values = await dataSource.query<{ enumlabel: string }[]>(` + SELECT e.enumlabel + FROM pg_type t + JOIN pg_enum e ON e.enumtypid = t.oid + WHERE t.typname = 'asset_type_enum' + `); + + expect(values.map((row) => row.enumlabel).sort()).toEqual( + [...EXPECTED_ASSET_TYPES].sort(), + ); + }); + + it('has no schema drift that would change what the database can store', async () => { + const { upQueries } = await dataSource.driver.createSchemaBuilder().log(); + const statements = upQueries.map((query) => + query.query.replace(/\s+/g, ' ').trim(), + ); + + const breaking = statements + .filter( + (statement) => + !ACCEPTED_DRIFT.some((accepted) => accepted.test(statement)), + ) + .flatMap((statement) => + SCHEMA_BREAKING_PATTERNS.filter(({ pattern }) => + pattern.test(statement), + ).map(({ label }) => `${label}: ${statement}`), + ); + + expect(breaking).toEqual([]); + }); + }); + + describe('wallet authentication', () => { + it('issues a token for a signature the claimed key actually produced', async () => { + const token = await authenticate(Keypair.random()); + + expect(token.split('.')).toHaveLength(3); + }); + + it('rejects a challenge signed by a different key', async () => { + const claimed = Keypair.random(); + const impostor = Keypair.random(); + + const challengeResponse = await request(http) + .post('/api/auth/challenge') + .send({ publicKey: claimed.publicKey() }) + .expect(200); + + const { challenge } = + bodyOf>(challengeResponse).data; + const signature = impostor + .sign(Buffer.from(challenge, 'utf-8')) + .toString('hex'); + + await request(http) + .post('/api/auth/wallet') + .send({ publicKey: claimed.publicKey(), signature }) + .expect(401); + }); + + it('rejects a replayed challenge', async () => { + const keypair = Keypair.random(); + // Authenticates once below, so its user row needs cleaning up too. + authenticatedKeys.push(keypair.publicKey()); + + const challengeResponse = await request(http) + .post('/api/auth/challenge') + .send({ publicKey: keypair.publicKey() }) + .expect(200); + + const { challenge } = + bodyOf>(challengeResponse).data; + const signature = keypair + .sign(Buffer.from(challenge, 'utf-8')) + .toString('hex'); + + await request(http) + .post('/api/auth/wallet') + .send({ publicKey: keypair.publicKey(), signature }) + .expect(200); + + // The challenge is single-use, so the same signature must not work twice. + await request(http) + .post('/api/auth/wallet') + .send({ publicKey: keypair.publicKey(), signature }) + .expect(401); + }); + }); + + describe('write path on a migrated schema', () => { + it('creates an asset without the application supplying a primary key', async () => { + const response = await request(http) + .post('/api/assets') + .set('Authorization', `Bearer ${buyerToken}`) + .send({ + name: 'Smoke prompt', + type: AssetType.PROMPT, + description: 'Fixture created by the staging smoke suite', + price: 10, + }) + .expect(201); + + const { id } = bodyOf>(response).data; + createdAssetIds.push(id); + + expect(id).toMatch( + /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i, + ); + }); + + it('rejects an unauthenticated create', async () => { + await request(http) + .post('/api/assets') + .send({ name: 'No token', type: AssetType.PROMPT }) + .expect(401); + }); + }); + + describe('purchase guards', () => { + it('refuses an unauthenticated purchase intent', async () => { + await request(http) + .post('/api/marketplace/purchases') + .send({ assetId: publishedPromptId }) + .expect(401); + }); + + it('binds the intent to the configured contract and network', async () => { + const response = await request(http) + .post('/api/marketplace/purchases') + .set('Authorization', `Bearer ${buyerToken}`) + .send({ assetId: publishedPromptId }) + .expect(201); + + const intent = bodyOf>(response).data; + createdPurchaseIds.push(intent.purchaseId); + + if (chainVerificationConfigured) { + // A configured environment must never fall back to the mock builder. + expect(intent.contractId).toBe(marketplaceContractId); + expect(intent.unsignedXdr).not.toContain('mock-unsigned-xdr'); + } else { + expect(intent.contractId).toBe('MOCK_CONTRACT'); + } + }); + + it('rejects a confirmation from a wallet that does not own the purchase', async () => { + const purchaseId = await createIntent(buyerToken); + + const response = await confirm( + purchaseId, + otherBuyerToken, + 'f'.repeat(64), + ); + + expect(response.status).toBe(401); + expect(bodyOf(response).message).toBe( + 'Purchase does not belong to this user', + ); + }); + + it('rejects a second confirmation of the same purchase', async () => { + const purchaseId = await createIntent(buyerToken); + const transactionHash = `a${'0'.repeat(63)}`; + + await confirm(purchaseId, buyerToken, transactionHash).expect(200); + + const response = await confirm(purchaseId, buyerToken, transactionHash); + + expect(response.status).toBe(409); + expect(bodyOf(response).message).toBe( + 'Purchase is already verified', + ); + }); + + it('rejects a transaction hash that already settled another purchase', async () => { + const settledHash = `b${'1'.repeat(63)}`; + + const firstPurchaseId = await createIntent(buyerToken); + await confirm(firstPurchaseId, buyerToken, settledHash).expect(200); + + const replayPurchaseId = await createIntent(buyerToken); + const response = await confirm(replayPurchaseId, buyerToken, settledHash); + + expect(response.status).toBe(409); + expect(bodyOf(response).message).toBe( + 'Transaction hash has already been used', + ); + }); + + it('does not leak a settled purchase to another wallet', async () => { + const purchaseId = await createIntent(buyerToken); + await confirm(purchaseId, buyerToken, `c${'2'.repeat(63)}`).expect(200); + + await request(http) + .get(`/api/marketplace/purchases/${purchaseId}/access`) + .set('Authorization', `Bearer ${buyerToken}`) + .expect(200); + + const response = await request(http) + .get(`/api/marketplace/purchases/${purchaseId}/access`) + .set('Authorization', `Bearer ${otherBuyerToken}`); + + expect(response.status).toBe(401); + expect(bodyOf(response).message).toBe( + 'Access denied: not the purchase owner', + ); + }); + }); + + describe('encrypted delivery', () => { + it('refuses an unauthenticated delivery read', async () => { + await request(http) + .get('/api/prompt-delivery/00000000-0000-0000-0000-000000000000') + .expect(401); + }); + + it('does not disclose whether another wallet has a delivery result', async () => { + const purchaseId = await createIntent(buyerToken); + + const response = await request(http) + .get(`/api/prompt-delivery/${purchaseId}`) + .set('Authorization', `Bearer ${otherBuyerToken}`); + + expect(response.status).toBe(404); + expect(bodyOf(response).message).toBe( + 'Delivery result not found', + ); + }); + }); + + // Blocked, not forgotten. Settling a real Testnet purchase needs a deployed + // marketplace contract with a registered prompt (Backend #9, #15) and a + // funded buyer identity; retrieving an encrypted result additionally needs + // confirmation to enqueue a delivery command, which it does not yet do. + it.todo( + 'settles a real Testnet purchase and returns an encrypted delivery result', + ); +}); diff --git a/tsconfig.build.json b/tsconfig.build.json index 64f86c6..6fe65cb 100644 --- a/tsconfig.build.json +++ b/tsconfig.build.json @@ -1,4 +1,5 @@ { "extends": "./tsconfig.json", + "include": ["src/**/*"], "exclude": ["node_modules", "test", "dist", "**/*spec.ts"] }