From efd38be2e3255f38fc2a90195b416b758d4cd993 Mon Sep 17 00:00:00 2001 From: CogeloEasy Date: Sat, 29 Aug 2026 18:01:40 -0400 Subject: [PATCH 01/11] fix(db): align the migrated schema with the entity definitions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A deployment provisioned by `migration:run` and started with DB_SYNCHRONIZE=false connects, reports healthy, and then fails every insert: QueryFailedError: null value in column "id" of relation "assets" violates not-null constraint The migrations declare every uuid primary key as `"id" uuid PRIMARY KEY` with no default, but the entities use @PrimaryGeneratedColumn('uuid'), which the TypeORM Postgres driver implements through the column default rather than generating the value in the application. All fifteen uuid primary keys were affected. AssetsService.create() never sets an id, so POST /api/assets 500s on any migrated database. The same class of drift left asset_type_enum with four of the six values AssetType declares, so MODEL and ORACLE could not be persisted, and left purchases."transactionHash" two characters short of the entity's varchar(64) — which a later synchronize would reconcile by dropping and re-adding the column, discarding the hashes the replay guard depends on. None of this was visible because docker-compose.yml and the ci workflow both ran with DB_SYNCHRONIZE=true, and auto-synchronize adds the defaults. data-source.ts also imported the three delivery entities without listing them, so the next `migration:generate` would have planned to drop delivery_commands, delivery_outbox and delivery_results. Refs #17 Co-Authored-By: Claude Opus 5 --- src/database/data-source.ts | 3 + ...0004000-AlignMigratedSchemaWithEntities.ts | 82 +++++++++++++++++++ 2 files changed, 85 insertions(+) create mode 100644 src/database/migrations/1700000004000-AlignMigratedSchemaWithEntities.ts 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..5f22693 --- /dev/null +++ b/src/database/migrations/1700000004000-AlignMigratedSchemaWithEntities.ts @@ -0,0 +1,82 @@ +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. + */ +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}'`, + ); + } + + // Purchase.transactionHash is varchar(64); 1700000001000 created it as + // varchar(128). A Stellar transaction hash is 64 hex characters, so no + // stored value can exceed the narrower width. Left as-is, a later + // `synchronize` run would reconcile this by dropping and re-adding the + // column, which would discard the hashes the replay guard depends on. + await queryRunner.query( + `ALTER TABLE "purchases" ALTER COLUMN "transactionHash" TYPE varchar(64)`, + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "purchases" ALTER COLUMN "transactionHash" TYPE varchar(128)`, + ); + + 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. + } +} From 52c041b6ea0cd89e5d0713731882277355ffe561 Mon Sep 17 00:00:00 2001 From: CogeloEasy Date: Sat, 29 Aug 2026 18:01:54 -0400 Subject: [PATCH 02/11] fix(tokens): start without a configured admin signing key validateConfig() warns that token operations are unavailable when STELLAR_ADMIN_SECRET_KEY is absent, and the next line called Keypair.fromSecret('') anyway, which throws and kills the bootstrap. The warning promised graceful degradation the code did not deliver, and requiring a long-lived Stellar secret merely to start the process works against keeping one out of CI. The keypair is now derived only when a secret is configured, and the two call sites that sign go through requireAdminKeypair(), so a missing secret surfaces as a clear error on the operation that needs it rather than as a boot failure. Refs #17 Co-Authored-By: Claude Opus 5 --- src/tokens/tokens.service.spec.ts | 37 +++++++++++++++++++++++++++++++ src/tokens/tokens.service.ts | 37 ++++++++++++++++++++++++------- 2 files changed, 66 insertions(+), 8 deletions(-) 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) { From 754fa902d02e7419e9ad9cc3dc3860bb83286944 Mon Sep 17 00:00:00 2001 From: CogeloEasy Date: Sat, 29 Aug 2026 18:01:54 -0400 Subject: [PATCH 03/11] fix(health): report dependency health instead of process liveness GET /api/health caught the database error, set db: 'error', and returned status: 'ok' with HTTP 200 regardless, so the Docker HEALTHCHECK only ever proved the process was listening. Its own spec asserted that a failed query still yields 'ok'. It now checks the database, the applied migration, Soroban RPC, the marketplace contract and the delivery worker, and answers 503 when a required dependency is down. Reporting the applied migration also catches the case this issue is about: a deployment started with DB_SYNCHRONIZE=false against a schema no migration has built connects fine and fails on first query. Soroban RPC counts as required only once a real marketplace contract is configured; a value containing PLACEHOLDER is treated as absent, matching how TokensService.validateConfig() and the purchase mock gate already read it. The RPC probe is cached for 15s and times out after 2s so an unauthenticated, unthrottled endpoint cannot be used to amplify traffic at the RPC. /api/health/live is new and answers process liveness only, for restart probes that should not cycle a container over a transient dependency outage. Refs #17 Co-Authored-By: Claude Opus 5 --- Dockerfile | 3 + src/health/health.controller.spec.ts | 77 ++++++--- src/health/health.controller.ts | 39 +++-- src/health/health.module.ts | 5 + src/health/health.service.spec.ts | 162 +++++++++++++++++++ src/health/health.service.ts | 228 +++++++++++++++++++++++++++ test/app.e2e-spec.ts | 73 ++++++++- 7 files changed, 550 insertions(+), 37 deletions(-) create mode 100644 src/health/health.service.spec.ts create mode 100644 src/health/health.service.ts 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/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..2bd63cb --- /dev/null +++ b/src/health/health.service.spec.ts @@ -0,0 +1,162 @@ +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 }; + 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 = { + 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 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..828c5b5 --- /dev/null +++ b/src/health/health.service.ts @@ -0,0 +1,228 @@ +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; + +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 { + const checks = [ + await this.checkDatabase(), + await this.checkSchema(), + await this.checkSorobanRpc(), + this.checkMarketplaceContract(), + 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, + }; + } + + private async checkDatabase(): Promise { + const startedAt = Date.now(); + try { + await this.dataSource.query('SELECT 1'); + 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 { + try { + const rows = await this.dataSource.query<{ name: string }[]>( + 'SELECT name FROM migrations ORDER BY timestamp DESC LIMIT 1', + ); + 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/test/app.e2e-spec.ts b/test/app.e2e-spec.ts index 1fbc9ab..723cf06 100644 --- a/test/app.e2e-spec.ts +++ b/test/app.e2e-spec.ts @@ -4,17 +4,43 @@ 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(), }; + 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 +50,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'); }); }); }); From e43782b30ec2b1578c365a8576bf10d32d0930d2 Mon Sep 17 00:00:00 2001 From: CogeloEasy Date: Sat, 29 Aug 2026 18:02:13 -0400 Subject: [PATCH 04/11] chore(release): apply migrations on start and pin the build output Three things stood between a clean checkout and a deployment that starts. Nothing applied migrations. entrypoint.sh went straight from waiting on PostgreSQL to `exec node dist/main`, so with DB_SYNCHRONIZE=false the container served traffic against whatever schema happened to exist. It now runs them and, under `set -e`, aborts the boot if any fails. RUN_MIGRATIONS_ON_START=false hands that to a separate release job, which multi-replica rollouts need because concurrent migration runners race. `npm run build && npm run start:prod` could not start. The root-level demo-prompt.ts and test-kms.ts widened the TypeScript root directory, so `nest build` emitted dist/src/main.js while start:prod and entrypoint.sh both run `node dist/main`. The Docker image escaped this only because its builder stage copies just src/ (Dockerfile:7); tsconfig.build.json now makes that restriction explicit, so dist/main.js and dist/database/data-source.js are emitted the same way everywhere. migration:run:prod runs migrations from dist/, which the production image needs since it prunes ts-node. Compose built its schema by auto-synchronize while declaring NODE_ENV=production, which is what hid the schema drift. It now defaults DB_SYNCHRONIZE to false. The api service also no longer waits on MinIO: no file under src/ imports @aws-sdk/client-s3, so that gate could only ever delay startup for a bucket nothing reads. The service stays defined for the blob-storage work. AWS_KMS_KEY_ID is required in production but was missing from .env.example entirely, so filling that file in still produced a container that aborted at bootstrap. Refs #17 Co-Authored-By: Claude Opus 5 --- .env.example | 4 ++++ docker-compose.yml | 10 +++++++--- entrypoint.sh | 14 ++++++++++++++ package.json | 4 +++- tsconfig.build.json | 1 + 5 files changed, 29 insertions(+), 4 deletions(-) diff --git a/.env.example b/.env.example index 770d9ae..dee5538 100644 --- a/.env.example +++ b/.env.example @@ -6,6 +6,8 @@ DB_USERNAME=postgres DB_PASSWORD=postgres DB_NAME=agentverse DB_SYNCHRONIZE=true +# Keep false wherever migrations own the schema (Docker Compose defaults to false) +RUN_MIGRATIONS_ON_START=true DB_LOGGING=false DB_SEED_ON_STARTUP=true @@ -41,6 +43,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/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/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/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"] } From d8d819bb362527e9133412b4c364da4303bcd5f4 Mon Sep 17 00:00:00 2001 From: CogeloEasy Date: Sat, 29 Aug 2026 18:02:13 -0400 Subject: [PATCH 05/11] test(release): add a Testnet staging smoke suite MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Runs the market journey against a real PostgreSQL whose schema was built by migrations, with DB_SYNCHRONIZE=false — the configuration a deployed environment uses, and the one under which auto-sync can no longer hide drift. Eighteen assertions covering the deploy shape (migrations applied, every uuid primary key defaulted, every AssetType storable, no drift that changes what the database can hold), the wallet handshake against real Ed25519 signatures including wrong-signer and replayed-challenge rejection, a write path that does not supply its own primary key, and the purchase guards this issue asks about: unauthorized confirmation, duplicate confirmation, replayed transaction hash, and cross-wallet access to a settled purchase and to a delivery result. It refuses to run without DB_HOST and DB_NAME rather than passing vacuously, and uses its own jest config so `npm run test:e2e` — whose specs mock their data layer and need no database — is unaffected. What it does not prove is stated in the file header rather than implied: with no marketplace contract configured the confirmation preceding each guard is mock-verified, and no encrypted delivery result can exist because confirmation does not enqueue a delivery command. The real-settlement case is left as a todo naming its blockers instead of asserting a substitute for it. Refs #17 Co-Authored-By: Claude Opus 5 --- test/jest-smoke.json | 11 + test/staging-smoke.smoke-spec.ts | 513 +++++++++++++++++++++++++++++++ 2 files changed, 524 insertions(+) create mode 100644 test/jest-smoke.json create mode 100644 test/staging-smoke.smoke-spec.ts 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..3e53854 --- /dev/null +++ b/test/staging-smoke.smoke-spec.ts @@ -0,0 +1,513 @@ +/** + * 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, + }, +]; + +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 asset: Asset = await assets.save( + 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', + }), + ); + + publishedPromptId = asset.id; + createdAssetIds.push(asset.id); + }, 60_000); + + afterAll(async () => { + if (dataSource?.isInitialized) { + if (createdPurchaseIds.length) { + await dataSource.getRepository(Purchase).delete(createdPurchaseIds); + } + if (createdAssetIds.length) { + await dataSource.getRepository(Asset).delete(createdAssetIds); + } + // The wallet handshake upserts a real row per public key. Run against a + // shared environment this suite must not accumulate throwaway identities. + if (authenticatedKeys.length) { + await dataSource.getRepository(User).delete(authenticatedKeys); + } + } + 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.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', + ); +}); From cb6f6a67aae94c2b54c9ad7fc46629ba5ffbd6d3 Mon Sep 17 00:00:00 2001 From: CogeloEasy Date: Sat, 29 Aug 2026 18:02:28 -0400 Subject: [PATCH 06/11] ci(release): provision a production-like environment and run the smoke suite Builds, verifies the deploy entrypoints were emitted, applies migrations with the compiled data source, boots the compiled application under production env validation with DB_SYNCHRONIZE=false, probes /api/health for the expected applied migration, then runs the smoke suite against that database. Deliberately a separate workflow from `ci`. That one owns the lint, test and build quality gates and is red on main for reasons tracked in #14; gating release evidence on it would mean this never runs. It touches none of those steps. No SOROBAN_MARKETPLACE_CONTRACT_ID and no STELLAR_ADMIN_SECRET_KEY are set anywhere in it. The admin key that production validation demands is generated inside a single step, never written to a file or to $GITHUB_ENV, and never used to sign; a final step fails the run if a Stellar secret seed appears in a tracked file or in the captured application log. Refs #17 Co-Authored-By: Claude Opus 5 --- .github/workflows/staging-smoke.yml | 201 ++++++++++++++++++++++++++++ 1 file changed, 201 insertions(+) create mode 100644 .github/workflows/staging-smoke.yml diff --git a/.github/workflows/staging-smoke.yml b/.github/workflows/staging-smoke.yml new file mode 100644 index 0000000..3f6a264 --- /dev/null +++ b/.github/workflows/staging-smoke.yml @@ -0,0 +1,201 @@ +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 SOROBAN_MARKETPLACE_CONTRACT_ID and no STELLAR_ADMIN_SECRET_KEY are set + # anywhere in this workflow. That is intentional on both counts: it keeps + # long-lived signing keys out of CI, and it proves the application boots + # without one. A configured environment is exercised by running this suite + # against real staging (see docs/release-runbook.md). + + 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. + export STELLAR_ADMIN_SECRET_KEY="$(node -e "console.log(require('@stellar/stellar-sdk').Keypair.random().secret())")" + # 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 From 8294a7c843a97a9bac7d27d1ad8799d77cfff1a9 Mon Sep 17 00:00:00 2001 From: CogeloEasy Date: Sat, 29 Aug 2026 18:02:29 -0400 Subject: [PATCH 07/11] docs(release): add the staging release runbook and ADR 004 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The runbook carries the sixteen-variable production environment contract taken from env.validation.ts rather than from the README, the deploy and verify procedure, rollback and reconciliation notes, and secret ownership. Values only a maintainer with deploy access can produce — URLs, contract ids, image digests, the smoke run — are empty TODO(maintainer) fields next to the command that produces each. An invented URL here would read as evidence, so there are none. The known gaps that block criteria 3 and 4 are listed as blockers with the issue that owns each, not omitted. ADR 004 records why migrations, not synchronize, own the deployed schema, and which drift the smoke suite tolerates: identifier-only differences, because the migrations name indexes and constraints explicitly while TypeORM derives hashed names, and neither affects a deployment running with synchronize false. README gains the health endpoints, the smoke suite, the new workflow, and a pointer to the runbook. Its environment table also had eight rows stranded below the marketplace section by an earlier edit; they are back inside the table. Refs #17 Co-Authored-By: Claude Opus 5 --- README.md | 54 ++++++-- docs/adr/004-migration-owned-schema.md | 37 ++++++ docs/release-runbook.md | 171 +++++++++++++++++++++++++ 3 files changed, 250 insertions(+), 12 deletions(-) create mode 100644 docs/adr/004-migration-owned-schema.md create mode 100644 docs/release-runbook.md diff --git a/README.md b/README.md index 0db7b90..55e035a 100644 --- a/README.md +++ b/README.md @@ -39,16 +39,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 +96,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` | `true` in dev, `false` in prod and in Docker Compose | TypeORM schema sync. Keep `false` wherever migrations own the schema | | `DB_LOGGING` | `false` | TypeORM SQL logging | | `JWT_SECRET` | `dev-secret` in dev | JWT signing secret | | `JWT_EXPIRES_IN` | `24h` | JWT token lifetime | @@ -77,24 +105,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/docs/adr/004-migration-owned-schema.md b/docs/adr/004-migration-owned-schema.md new file mode 100644 index 0000000..76bb544 --- /dev/null +++ b/docs/adr/004-migration-owned-schema.md @@ -0,0 +1,37 @@ +# 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`. +- 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. 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`. + +## 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..1eb7157 --- /dev/null +++ b/docs/release-runbook.md @@ -0,0 +1,171 @@ +# 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 health and by token config. | +| `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`. Auto-synchronize against a migrated database rewrites +columns and indexes in place; on `purchases` that 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 the deployment's database: + +```bash +npm run test:smoke +``` + +It refuses to run without `DB_HOST` and `DB_NAME` rather than passing vacuously. + +## 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)` | `docker image inspect --format '{{index .RepoDigests 0}}' ` | +| 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 image digest 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 sets no 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. From 13c91b96b9c187147ef3497e315eac61d89ffbf1 Mon Sep 17 00:00:00 2001 From: CogeloEasy Date: Sat, 29 Aug 2026 18:34:06 -0400 Subject: [PATCH 08/11] fix(db): leave purchases.transactionHash at its migrated width MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The migration narrowed the column from varchar(128) to the entity's varchar(64) on the stated grounds that "a Stellar transaction hash is 64 hex characters, so no stored value can exceed the narrower width". Nothing enforces that: ConfirmPurchaseDto accepts 32-128 characters with no hex or exact-length check, and confirm() writes the client-supplied string straight to the column on both the failure and the success branch. So the narrowing converted a malformed-but-accepted hash from a stored value into a 22001 error inside confirm(), which nothing catches — a 500 where a 400 belongs, and it would take the PurchaseStatus.FAILED write down with it. It also made the migration unsafe to apply to any database already holding such a row, which under `set -e` in the entrypoint means a boot abort rather than a degraded start. Narrowing the column and tightening the DTO have to happen together, and the DTO belongs to the purchase flow, not to release provisioning. The column keeps the width the migrations gave it; the smoke suite's drift guard names this one difference as accepted and says why, rather than being quietly relaxed. Refs #17 Co-Authored-By: Claude Opus 5 --- ...0004000-AlignMigratedSchemaWithEntities.ts | 21 ++--- test/staging-smoke.smoke-spec.ts | 84 +++++++++++++------ 2 files changed, 67 insertions(+), 38 deletions(-) diff --git a/src/database/migrations/1700000004000-AlignMigratedSchemaWithEntities.ts b/src/database/migrations/1700000004000-AlignMigratedSchemaWithEntities.ts index 5f22693..0cadce3 100644 --- a/src/database/migrations/1700000004000-AlignMigratedSchemaWithEntities.ts +++ b/src/database/migrations/1700000004000-AlignMigratedSchemaWithEntities.ts @@ -15,6 +15,9 @@ import { MigrationInterface, QueryRunner } from 'typeorm'; * * 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', @@ -54,21 +57,15 @@ export class AlignMigratedSchemaWithEntities1700000004000 implements MigrationIn ); } - // Purchase.transactionHash is varchar(64); 1700000001000 created it as - // varchar(128). A Stellar transaction hash is 64 hex characters, so no - // stored value can exceed the narrower width. Left as-is, a later - // `synchronize` run would reconcile this by dropping and re-adding the - // column, which would discard the hashes the replay guard depends on. - await queryRunner.query( - `ALTER TABLE "purchases" ALTER COLUMN "transactionHash" TYPE varchar(64)`, - ); + // 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 { - await queryRunner.query( - `ALTER TABLE "purchases" ALTER COLUMN "transactionHash" TYPE varchar(128)`, - ); - for (const table of UUID_PRIMARY_KEY_TABLES) { await queryRunner.query( `ALTER TABLE "${table}" ALTER COLUMN "id" DROP DEFAULT`, diff --git a/test/staging-smoke.smoke-spec.ts b/test/staging-smoke.smoke-spec.ts index 3e53854..a8f36e9 100644 --- a/test/staging-smoke.smoke-spec.ts +++ b/test/staging-smoke.smoke-spec.ts @@ -102,6 +102,15 @@ const SCHEMA_BREAKING_PATTERNS: { label: string; pattern: RegExp }[] = [ }, ]; +/** + * 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( @@ -198,17 +207,16 @@ describe('Testnet staging smoke', () => { // 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 asset: Asset = await assets.save( - 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 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); @@ -216,16 +224,35 @@ describe('Testnet staging smoke', () => { afterAll(async () => { if (dataSource?.isInitialized) { - if (createdPurchaseIds.length) { - await dataSource.getRepository(Purchase).delete(createdPurchaseIds); - } - if (createdAssetIds.length) { - await dataSource.getRepository(Asset).delete(createdAssetIds); - } - // The wallet handshake upserts a real row per public key. Run against a - // shared environment this suite must not accumulate throwaway identities. - if (authenticatedKeys.length) { - await dataSource.getRepository(User).delete(authenticatedKeys); + // 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(); @@ -295,11 +322,16 @@ describe('Testnet staging smoke', () => { query.query.replace(/\s+/g, ' ').trim(), ); - const breaking = statements.flatMap((statement) => - SCHEMA_BREAKING_PATTERNS.filter(({ pattern }) => - pattern.test(statement), - ).map(({ label }) => `${label}: ${statement}`), - ); + 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([]); }); From dd8b26675970c3d05bab6ec6f64423c18286cf45 Mon Sep 17 00:00:00 2001 From: CogeloEasy Date: Sat, 29 Aug 2026 18:34:06 -0400 Subject: [PATCH 09/11] fix(health): skip the schema check when auto-synchronize owns it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two defects in the readiness endpoint this branch added. TypeORM creates the `migrations` table only through the migration runner, and DatabaseModule configures neither `migrations` nor `migrationsRun`. So on any deployment where auto-synchronize builds the schema the table simply does not exist, and the schema check reported a required failure — a permanent 503 on a working application, including for the README's own local-setup flow and for a container run with RUN_MIGRATIONS_ON_START=false, which this branch documents as legitimate. The check now reports `skipped` when DB_SYNCHRONIZE is on, and stays required only where migrations genuinely own the schema. The probes were also unbounded and serialized. A partitioned database accepts the TCP connect and never answers, so `SELECT 1` would block until the OS timeout and the schema query would block again after it — the endpoint that exists to tell a load balancer "stop sending traffic" would hang instead of answering 503. Both queries now carry a 3s deadline, and the five checks run concurrently so one slow dependency does not add its latency to the rest. Refs #17 Co-Authored-By: Claude Opus 5 --- src/health/health.service.spec.ts | 29 +++++++++++++- src/health/health.service.ts | 65 ++++++++++++++++++++++++++----- test/app.e2e-spec.ts | 1 + 3 files changed, 84 insertions(+), 11 deletions(-) diff --git a/src/health/health.service.spec.ts b/src/health/health.service.spec.ts index 2bd63cb..70fddb2 100644 --- a/src/health/health.service.spec.ts +++ b/src/health/health.service.spec.ts @@ -13,7 +13,7 @@ function find(checks: DependencyCheck[], name: string): DependencyCheck { describe('HealthService', () => { let service: HealthService; - let dataSource: { query: jest.Mock }; + let dataSource: { query: jest.Mock; options: { synchronize: boolean } }; let soroban: { rpcUrl: string; contracts: { purchaseContractId: string } }; let fetchMock: jest.Mock; @@ -34,6 +34,7 @@ describe('HealthService', () => { beforeEach(async () => { dataSource = { + options: { synchronize: false }, query: jest .fn() .mockImplementation((sql: string) => @@ -98,6 +99,32 @@ describe('HealthService', () => { ); }); + 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')); diff --git a/src/health/health.service.ts b/src/health/health.service.ts index 828c5b5..16a4068 100644 --- a/src/health/health.service.ts +++ b/src/health/health.service.ts @@ -24,6 +24,12 @@ export interface HealthReport { /** 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; @@ -46,13 +52,14 @@ export class HealthService { * `error`, which the controller surfaces as HTTP 503. */ async check(): Promise { - const checks = [ - await this.checkDatabase(), - await this.checkSchema(), - await this.checkSorobanRpc(), - this.checkMarketplaceContract(), - this.checkDeliveryWorker(), - ]; + // 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', @@ -68,10 +75,35 @@ export class HealthService { }; } + /** 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.dataSource.query('SELECT 1'); + await this.withDeadline(this.dataSource.query('SELECT 1'), 'database'); return { name: 'database', status: 'ok', @@ -96,9 +128,22 @@ export class HealthService { * 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.dataSource.query<{ name: string }[]>( - 'SELECT name FROM migrations ORDER BY timestamp DESC LIMIT 1', + 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; diff --git a/test/app.e2e-spec.ts b/test/app.e2e-spec.ts index 723cf06..4d81703 100644 --- a/test/app.e2e-spec.ts +++ b/test/app.e2e-spec.ts @@ -21,6 +21,7 @@ describe('HealthController (e2e)', () => { let app: INestApplication; const dataSourceMock = { query: jest.fn(), + options: { synchronize: false }, }; const sorobanMock = { rpcUrl: 'https://soroban-testnet.stellar.org', From b5d954bf5eadba51a1087374a0a77359518e3b01 Mon Sep 17 00:00:00 2001 From: CogeloEasy Date: Sat, 29 Aug 2026 18:34:20 -0400 Subject: [PATCH 10/11] fix(release): make the DB_SYNCHRONIZE=false default reachable MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `docker-compose.yml` defaulted `DB_SYNCHRONIZE` to `false`, and both ADR 004 and the README said so — but the api service also declares `env_file: .env`, and Compose interpolates `${DB_SYNCHRONIZE}` from that same file. `.env.example` still shipped `DB_SYNCHRONIZE=true`, and `cp .env.example .env` is the flow the README documents, so the only compose path anyone follows resolved the default to `true`. The container applied all five migrations and then let auto-synchronize rewrite the result — precisely what ADR 004 says must never happen, and invisible to the health check, because the migrations table is there and populated either way. `.env.example` now sets `false`, so the file the operator copies agrees with the compose default instead of overriding it, and the README's local setup runs migrations like every other environment does. Also corrected in the docs this branch added: - The runbook told maintainers to run the smoke suite against staging. With a real marketplace contract configured, building a purchase intent goes to Soroban RPC and needs a funded buyer — criterion 4's blocker. It now says plainly that the suite runs against a migrated, unconfigured database. - The evidence table's image-digest row named a `RepoDigests` command, and rollback step 1 depended on it. No workflow here builds or pushes an image. - The environment table said a `PLACEHOLDER` marketplace id is treated as unconfigured "by token config"; TokensService never inspects that variable. - The workflow's env block claimed no admin key or contract id is set anywhere in it, sixty lines above the step that sets both. Reworded to what it actually does, and the generated keypair is now masked so it cannot surface in the log. - ADR 004 claimed the smoke suite covers drift generally; it covers the entities registered in DatabaseModule, which is why unmigrated `token_transactions` is invisible to it. Refs #17 Co-Authored-By: Claude Opus 5 --- .env.example | 7 +++++-- .github/workflows/staging-smoke.yml | 18 +++++++++------- README.md | 7 ++++++- docs/adr/004-migration-owned-schema.md | 14 +++++++++---- docs/release-runbook.md | 29 ++++++++++++++++++-------- 5 files changed, 52 insertions(+), 23 deletions(-) diff --git a/.env.example b/.env.example index dee5538..40a8f08 100644 --- a/.env.example +++ b/.env.example @@ -5,8 +5,11 @@ DB_PORT=5432 DB_USERNAME=postgres DB_PASSWORD=postgres DB_NAME=agentverse -DB_SYNCHRONIZE=true -# Keep false wherever migrations own the schema (Docker Compose defaults to false) +# 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 diff --git a/.github/workflows/staging-smoke.yml b/.github/workflows/staging-smoke.yml index 3f6a264..a32361a 100644 --- a/.github/workflows/staging-smoke.yml +++ b/.github/workflows/staging-smoke.yml @@ -60,11 +60,12 @@ jobs: STELLAR_NETWORK: testnet STELLAR_RPC_URL: https://soroban-testnet.stellar.org STELLAR_NETWORK_PASSPHRASE: Test SDF Network ; September 2015 - # No SOROBAN_MARKETPLACE_CONTRACT_ID and no STELLAR_ADMIN_SECRET_KEY are set - # anywhere in this workflow. That is intentional on both counts: it keeps - # long-lived signing keys out of CI, and it proves the application boots - # without one. A configured environment is exercised by running this suite - # against real staging (see docs/release-runbook.md). + # 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 @@ -122,8 +123,11 @@ jobs: 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. - export STELLAR_ADMIN_SECRET_KEY="$(node -e "console.log(require('@stellar/stellar-sdk').Keypair.random().secret())")" + # 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" diff --git a/README.md b/README.md index 55e035a..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 @@ -96,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 and in Docker Compose | TypeORM schema sync. Keep `false` wherever migrations own the schema | +| `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 | diff --git a/docs/adr/004-migration-owned-schema.md b/docs/adr/004-migration-owned-schema.md index 76bb544..afa8d07 100644 --- a/docs/adr/004-migration-owned-schema.md +++ b/docs/adr/004-migration-owned-schema.md @@ -21,12 +21,18 @@ 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`. +- `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. 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`. + 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 diff --git a/docs/release-runbook.md b/docs/release-runbook.md index 1eb7157..2a38099 100644 --- a/docs/release-runbook.md +++ b/docs/release-runbook.md @@ -15,15 +15,18 @@ An invented URL or contract id here would read as evidence, so none are written. | `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 health and by token config. | +| `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`. Auto-synchronize against a migrated database rewrites -columns and indexes in place; on `purchases` that would drop and re-add -`transactionHash`, discarding the hashes the replay guard depends on. +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 @@ -80,7 +83,8 @@ required check must be `ok`: so a transient dependency outage does not cycle containers, and `/api/health` for rollout and load-balancer gates. -Then run the smoke suite against the deployment's database: +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 @@ -88,6 +92,12 @@ 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. @@ -98,7 +108,7 @@ Fill this table on every staging release and attach it to the release issue. | 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)` | `docker image inspect --format '{{index .RepoDigests 0}}' ` | +| 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. | @@ -107,8 +117,9 @@ Fill this table on every staging release and attach it to the release issue. Roll back the application first, schema second, and only when the schema is actually the problem. -1. Redeploy the previous image digest with `RUN_MIGRATIONS_ON_START=false`, so - the rollback cannot re-apply the migration being backed out. +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`, @@ -139,7 +150,7 @@ contributors. No secret in this repository, and no value printed by CI. | `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 sets no signing key. It generates a throwaway +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 From d7a67e6335c3910b1f6b23ae543b9ebd54beca07 Mon Sep 17 00:00:00 2001 From: Joaco2603 Date: Mon, 31 Aug 2026 18:29:05 -0600 Subject: [PATCH 11/11] ci(release): run integration gates before legacy lint --- .github/workflows/ci.yml | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) 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