From e9885b700e799acb75d2270282c154be986547ec Mon Sep 17 00:00:00 2001 From: "prd-carapulse[bot]" <264278285+prd-carapulse[bot]@users.noreply.github.com> Date: Wed, 12 Aug 2026 13:21:45 +0000 Subject: [PATCH 01/16] feat(midnight-crossed-books): add readonly mode Co-authored-by: Julien Thomas <61523188+julien-devatom@users.noreply.github.com> --- bots/midnight-crossed-books/README.md | 13 ++- .../midnight-crossed-books/docker-compose.yml | 3 +- .../application/crossed-books-bot.service.ts | 30 ++++++- bots/midnight-crossed-books/src/bootstrap.ts | 87 +++++++++++-------- .../src/config/config.service.ts | 26 +++++- .../resolver/readonly-mutation.error.ts | 7 ++ .../resolver/resolver.transport.ts | 29 +++++-- .../crossed-books-bot.service.test.ts | 27 +++++- .../test/config/config.service.test.ts | 20 +++++ .../resolver/resolver.transport.test.ts | 28 ++++++ 10 files changed, 222 insertions(+), 48 deletions(-) create mode 100644 bots/midnight-crossed-books/src/infrastructure/resolver/readonly-mutation.error.ts create mode 100644 bots/midnight-crossed-books/test/infrastructure/resolver/resolver.transport.test.ts diff --git a/bots/midnight-crossed-books/README.md b/bots/midnight-crossed-books/README.md index a7c2eb39..792fc964 100644 --- a/bots/midnight-crossed-books/README.md +++ b/bots/midnight-crossed-books/README.md @@ -38,7 +38,8 @@ The generated files live under each infrastructure adapter's `generated/` direct - `CHAIN_ID` — required, currently `8453`. - `RPC_URL` — required. `RPC_URL_FALLBACK` is optional. -- `RESOLVER_PRIVATE_KEY` — required `0x`-prefixed 32-byte bot key. +- `READONLY` — optional; set to `true` (or `1`) to simulate and log profitable matches without submitting transactions. +- `RESOLVER_PRIVATE_KEY` — required `0x`-prefixed 32-byte bot key unless `READONLY` is enabled. - `RESOLVER_ADDRESS` — optional deterministic deployment override. - `API_BASE_URL` — Morpho API origin, default `https://api.morpho.org`. - `ROUTER_API_BASE_URL` — Router API origin, defaults to `API_BASE_URL` for the public gateway. @@ -57,6 +58,16 @@ pnpm --filter @repo/contracts run deploy:crossed-books-resolver ## Run +```sh +CHAIN_ID=8453 RPC_URL=https://… READONLY=true \ +pnpm --filter @morpho-org/midnight-crossed-books run start +``` + +Readonly mode uses the resolver address as the simulation caller, logs each profitable result as +`match.computed`, and never creates a signer, transaction queue, or submission. + +To execute profitable resolutions instead, provide the signer key: + ```sh CHAIN_ID=8453 RPC_URL=https://… RESOLVER_PRIVATE_KEY=0x… \ pnpm --filter @morpho-org/midnight-crossed-books run start diff --git a/bots/midnight-crossed-books/docker-compose.yml b/bots/midnight-crossed-books/docker-compose.yml index f19771e7..90e655ed 100644 --- a/bots/midnight-crossed-books/docker-compose.yml +++ b/bots/midnight-crossed-books/docker-compose.yml @@ -6,7 +6,8 @@ services: environment: CHAIN_ID: '8453' RPC_URL: ${RPC_URL:?set RPC_URL} - RESOLVER_PRIVATE_KEY: ${RESOLVER_PRIVATE_KEY:?set RESOLVER_PRIVATE_KEY} + READONLY: ${READONLY:-false} + RESOLVER_PRIVATE_KEY: ${RESOLVER_PRIVATE_KEY:-} RESOLVER_ADDRESS: ${RESOLVER_ADDRESS:-} API_BASE_URL: ${API_BASE_URL:-https://api.morpho.org} ROUTER_API_BASE_URL: ${ROUTER_API_BASE_URL:-} diff --git a/bots/midnight-crossed-books/src/application/crossed-books-bot.service.ts b/bots/midnight-crossed-books/src/application/crossed-books-bot.service.ts index b86d1f62..5ef46f7e 100644 --- a/bots/midnight-crossed-books/src/application/crossed-books-bot.service.ts +++ b/bots/midnight-crossed-books/src/application/crossed-books-bot.service.ts @@ -29,6 +29,17 @@ interface BotLogger { } export class CrossedBooksBotService { + /** + * Creates one resolver workflow. + * @param markets - Listed-market discovery port. + * @param books - Takeable-book reader. + * @param matching - Pure crossed-offer matcher. + * @param resolver - Simulation and optional submission port. + * @param maxMatches - Maximum matches encoded into one resolution. + * @param inflightMarketIds - Current write-mode transaction labels. + * @param readOnly - Whether successful simulations are logged instead of submitted. + * @param logger - Structured operator logger. + */ constructor( private readonly markets: ListedMarketsService, private readonly books: OrderBookService, @@ -36,9 +47,16 @@ export class CrossedBooksBotService { private readonly resolver: ResolverService, private readonly maxMatches: number, private readonly inflightMarketIds: () => ReadonlySet, + private readonly readOnly: boolean, private readonly logger: BotLogger ) {} + /** + * Computes the first profitable crossed resolution for one block. + * @param blockNumber - Block label used only when queueing a write-mode transaction. + * @returns Submission status and the number of listed markets inspected. + * @remarks Always simulates first. Readonly mode logs `match.computed` and performs no submission. + */ async run({ blockNumber }: { blockNumber: bigint }) { const markets = await this.markets.listListedActiveMarkets() const inflight = this.inflightMarketIds() @@ -65,12 +83,18 @@ export class CrossedBooksBotService { continue } - await this.resolver.submit(simulation.prepared, blockNumber) - this.logger.info('match.submitted', { + const fields = { marketId, units: matches.reduce((total, match) => total + match.units, 0n), profit: simulation.prepared.profit - }) + } + if (this.readOnly) { + this.logger.info('match.computed', fields) + return { submitted: false, markets: markets.length } + } + + await this.resolver.submit(simulation.prepared, blockNumber) + this.logger.info('match.submitted', fields) return { submitted: true, markets: markets.length } } diff --git a/bots/midnight-crossed-books/src/bootstrap.ts b/bots/midnight-crossed-books/src/bootstrap.ts index ba18d3ce..dbab85ad 100644 --- a/bots/midnight-crossed-books/src/bootstrap.ts +++ b/bots/midnight-crossed-books/src/bootstrap.ts @@ -32,6 +32,14 @@ function resolverSelector() { return toFunctionSelector(resolveAbi) } +/** + * Composes the crossed-books resolver runtime for the selected environment mode. + * @param environment - Runtime configuration and optional observability values. + * @returns A lifecycle handle that polls immediately and then follows new blocks when started. + * @throws `Error` when configuration is invalid or required contracts are not deployed. + * @remarks Readonly composition creates no signer, pending transaction queue, or balance monitor; + * both modes perform RPC deployment checks during composition. + */ export async function createApplication( environment: Record = process.env ) { @@ -43,23 +51,40 @@ export async function createApplication( ...railwayContext() } }) - const signer = createSigner({ - chain: config.chain, - rpcUrl: config.rpcUrl, - rpcUrlFallback: config.rpcUrlFallback, - privateKey: config.privateKey, - policy: { - chainId: config.chainId, - executor: config.resolver, - selector: resolverSelector(), - maxFeePerGasWei: config.maxFeeWei, - maxGasLimit: DEFAULT_MAX_GAS_LIMIT, - maxDataBytes: DEFAULT_MAX_DATA_BYTES - }, - logger - }) - const sender = signer.account.address const chainClient = createDeploylessClient(config) + let sender = config.resolver + let signer: ReturnType | undefined + let queue: ReturnType | undefined + + if (!config.readOnly) { + const privateKey = config.privateKey + if (!privateKey) throw new Error('Write mode requires a resolver private key') + signer = createSigner({ + chain: config.chain, + rpcUrl: config.rpcUrl, + rpcUrlFallback: config.rpcUrlFallback, + privateKey, + policy: { + chainId: config.chainId, + executor: config.resolver, + selector: resolverSelector(), + maxFeePerGasWei: config.maxFeeWei, + maxGasLimit: DEFAULT_MAX_GAS_LIMIT, + maxDataBytes: DEFAULT_MAX_DATA_BYTES + }, + logger + }) + sender = signer.account.address + queue = createPendingQueue({ + send: signer.send, + getReceipt: signer.getReceipt, + getBaseFee: signer.getBaseFee, + syncNonce: signer.syncNonce, + getConsumedNonce: signer.consumedNonce, + maxFeeWei: config.maxFeeWei, + logger + }) + } await assertContractDeployed(chainClient, config.midnight, 'Midnight singleton') await assertContractDeployed( @@ -69,28 +94,18 @@ export async function createApplication( 'deploy it with `pnpm --filter @repo/contracts run deploy:crossed-books-resolver`' ) - const queue = createPendingQueue({ - send: signer.send, - getReceipt: signer.getReceipt, - getBaseFee: signer.getBaseFee, - syncNonce: signer.syncNonce, - getConsumedNonce: signer.consumedNonce, - maxFeeWei: config.maxFeeWei, - logger - }) const markets = new MorphoApiService( createMorphoApiClient(config.apiBaseUrl), config.chainId as 8453 ) const books = new RouterApiService(createRouterApiClient(config.routerApiBaseUrl)) const matching = new MatchingService() + const submission = queue && signer ? { queue, signer, maxFeeWei: config.maxFeeWei } : undefined const resolverTransport = new ViemResolverTransport( chainClient, sender, config.resolver, - queue, - signer, - config.maxFeeWei + submission ) const resolver = new ResolverExecutionService( resolverTransport, @@ -103,10 +118,13 @@ export async function createApplication( matching, resolver, config.maxMatches, - () => queue.inflightLabels(), + () => queue?.inflightLabels() ?? new Set(), + config.readOnly, logger ) - const balance = createBalanceMonitor({ address: sender, read: signer.balance, logger }) + const balance = signer + ? createBalanceMonitor({ address: sender, read: signer.balance, logger }) + : undefined const heartbeat = createHeartbeatMonitor({ url: environment.BETTERSTACK_HEARTBEAT_URL, logger @@ -116,13 +134,13 @@ export async function createApplication( const runner = createRunner({ getBlockNumber: () => getBlockNumber(chainClient), tick: async blockNumber => { - if (Date.now() < nextScanAt || queue.size > 0) return + if (Date.now() < nextScanAt || (queue?.size ?? 0) > 0) return nextScanAt = Date.now() + config.scanIntervalMs await bot.run({ blockNumber }) }, maintain: async blockNumber => { - await queue.onBlock(blockNumber) - await balance.maybeLog(blockNumber) + await queue?.onBlock(blockNumber) + await balance?.maybeLog(blockNumber) }, logger }) @@ -130,7 +148,8 @@ export async function createApplication( return { async start() { logger.info('startup', { - sender, + readOnly: config.readOnly, + sender: config.readOnly ? undefined : sender, midnight: config.midnight, resolver: config.resolver, minimumProfit: config.minimumProfit, diff --git a/bots/midnight-crossed-books/src/config/config.service.ts b/bots/midnight-crossed-books/src/config/config.service.ts index 3d058426..410f6f18 100644 --- a/bots/midnight-crossed-books/src/config/config.service.ts +++ b/bots/midnight-crossed-books/src/config/config.service.ts @@ -24,14 +24,26 @@ function unsignedDecimal(environment: Environment, name: string, fallback?: stri } export class ConfigService { + /** + * Loads and validates resolver configuration from environment values. + * @param environment - Runtime environment; defaults to `process.env`. + * @returns Immutable configuration with signer material omitted in readonly mode. + * @throws `Error` when a required write-mode value or another runtime value is invalid. + * @remarks This method performs no network access and does not retain `RESOLVER_PRIVATE_KEY` when + * readonly mode is enabled. + */ static from(environment: Environment = process.env) { const chainId = Number(unsignedDecimal(environment, 'CHAIN_ID')) if (chainId !== base.id) { throw new Error(`Unsupported CHAIN_ID ${chainId}; supported: ${base.id}`) } - const privateKey = required(environment, 'RESOLVER_PRIVATE_KEY') - if (!isHex(privateKey, { strict: true }) || privateKey.length !== PRIVATE_KEY_HEX_LENGTH) { + const readOnly = /^(1|true)$/i.test(environment.READONLY?.trim() || '') + const privateKey = readOnly ? undefined : required(environment, 'RESOLVER_PRIVATE_KEY') + if ( + privateKey !== undefined && + (!isHex(privateKey, { strict: true }) || privateKey.length !== PRIVATE_KEY_HEX_LENGTH) + ) { throw new Error('RESOLVER_PRIVATE_KEY must be a 0x-prefixed 32-byte hex string') } @@ -74,6 +86,7 @@ export class ConfigService { : CrossedBooksResolver.with(MIDNIGHT).address, rpcUrl: required(environment, 'RPC_URL'), rpcUrlFallback: environment.RPC_URL_FALLBACK?.trim() || undefined, + readOnly, privateKey, apiBaseUrl, routerApiBaseUrl, @@ -93,7 +106,8 @@ export class ConfigService { resolver: Address rpcUrl: string rpcUrlFallback: string | undefined - privateKey: Hex + readOnly: boolean + privateKey: Hex | undefined apiBaseUrl: string routerApiBaseUrl: string scanIntervalMs: number @@ -125,10 +139,16 @@ export class ConfigService { return this.values.rpcUrlFallback } + /** Returns the validated signer key in write mode and `undefined` in readonly mode. */ get privateKey() { return this.values.privateKey } + /** Returns whether transaction signing and submission are disabled. */ + get readOnly() { + return this.values.readOnly + } + get apiBaseUrl() { return this.values.apiBaseUrl } diff --git a/bots/midnight-crossed-books/src/infrastructure/resolver/readonly-mutation.error.ts b/bots/midnight-crossed-books/src/infrastructure/resolver/readonly-mutation.error.ts new file mode 100644 index 00000000..921b7ced --- /dev/null +++ b/bots/midnight-crossed-books/src/infrastructure/resolver/readonly-mutation.error.ts @@ -0,0 +1,7 @@ +/** Raised when a readonly resolver transport is asked to mutate chain state. */ +export class ReadonlyMutationError extends Error { + /** Creates a credential-free, operator-safe readonly mutation failure. */ + constructor() { + super('Readonly resolver transport cannot submit transactions') + } +} diff --git a/bots/midnight-crossed-books/src/infrastructure/resolver/resolver.transport.ts b/bots/midnight-crossed-books/src/infrastructure/resolver/resolver.transport.ts index 13b25cdf..b40bd26b 100644 --- a/bots/midnight-crossed-books/src/infrastructure/resolver/resolver.transport.ts +++ b/bots/midnight-crossed-books/src/infrastructure/resolver/resolver.transport.ts @@ -8,6 +8,8 @@ import { call } from 'viem/actions' import type { PreparedResolution } from '../../domain/order-book' +import { ReadonlyMutationError } from './readonly-mutation.error' + export type ResolverSimulation = | { status: 'success'; data: Hex } | { status: 'revert'; reason: string } @@ -18,13 +20,22 @@ export interface ResolverTransport { } export class ViemResolverTransport implements ResolverTransport { + /** + * Creates an RPC simulation transport with optional write capabilities. + * @param client - Public chain client used for `eth_call`. + * @param sender - Simulation caller and write-mode profit recipient. + * @param resolver - Resolver contract target. + * @param submission - Signer and queue dependencies; omission makes submission fail closed. + */ constructor( private readonly client: Client, private readonly sender: Address, private readonly resolver: Address, - private readonly queue: PendingQueue, - private readonly signer: Signer, - private readonly maxFeeWei: bigint + private readonly submission?: { + queue: PendingQueue + signer: Signer + maxFeeWei: bigint + } ) {} async simulate(data: Hex): Promise { @@ -48,10 +59,18 @@ export class ViemResolverTransport implements ResolverTransport { return { status: 'success', data: result.data.data } } + /** + * Queues the immutable request prepared by simulation. + * @param prepared - Resolver target calldata and market label. + * @param blockNumber - Block used to seed queue fee and replacement policy. + * @returns A promise that resolves once the request is accepted by the queue. + * @throws `ReadonlyMutationError` when submission dependencies were intentionally omitted. + */ async submit(prepared: PreparedResolution, blockNumber: bigint) { - const fees = initialFees(await this.signer.getBaseFee(), this.maxFeeWei) + if (!this.submission) throw new ReadonlyMutationError() + const fees = initialFees(await this.submission.signer.getBaseFee(), this.submission.maxFeeWei) - await this.queue.submit({ + await this.submission.queue.submit({ request: { to: this.resolver, data: prepared.data }, label: prepared.marketId, ...fees, diff --git a/bots/midnight-crossed-books/test/application/crossed-books-bot.service.test.ts b/bots/midnight-crossed-books/test/application/crossed-books-bot.service.test.ts index 437d424e..dfe96636 100644 --- a/bots/midnight-crossed-books/test/application/crossed-books-bot.service.test.ts +++ b/bots/midnight-crossed-books/test/application/crossed-books-bot.service.test.ts @@ -37,6 +37,7 @@ function setup( simulation?: SimulationResult booksError?: Error maxMatches?: number + readOnly?: boolean } = {} ) { const listListedActiveMarkets = vi.fn(async () => overrides.markets ?? [MARKET]) @@ -68,6 +69,7 @@ function setup( resolver, overrides.maxMatches ?? 10, () => overrides.inflight ?? new Set(), + overrides.readOnly ?? false, logger ) @@ -78,7 +80,8 @@ function setup( getTakeableBook, match, simulate, - submit + submit, + logger } } @@ -161,6 +164,28 @@ describe('CrossedBooksBotService', () => { expect(result).toEqual({ submitted: true, markets: 1 }) }) + test('logs the computed result without submitting in readonly mode', async () => { + const prepared: PreparedResolution = { + marketId: MARKET_ID, + data: '0x1234', + profit: 42n + } + const { service, submit, logger } = setup({ + readOnly: true, + simulation: { status: 'ok', prepared } + }) + + const result = await service.run({ blockNumber: 10n }) + + expect(submit).not.toHaveBeenCalled() + expect(logger.info).toHaveBeenCalledWith('match.computed', { + marketId: MARKET_ID, + units: 5n, + profit: 42n + }) + expect(result).toEqual({ submitted: false, markets: 1 }) + }) + test('isolates a book failure and continues to the next market', async () => { let calls = 0 const { service, books, submit } = setup({ markets: [MARKET, OTHER_MARKET] }) diff --git a/bots/midnight-crossed-books/test/config/config.service.test.ts b/bots/midnight-crossed-books/test/config/config.service.test.ts index d162b482..4149e405 100644 --- a/bots/midnight-crossed-books/test/config/config.service.test.ts +++ b/bots/midnight-crossed-books/test/config/config.service.test.ts @@ -20,6 +20,26 @@ describe('ConfigService', () => { expect(config.minimumProfit).toBe(1n) expect(config.maxMatches).toBe(10) expect(config.resolver).toMatch(/^0x[0-9a-fA-F]{40}$/) + expect(config.readOnly).toBe(false) + expect(config.privateKey).toBe(KEY) + }) + + test('does not require a resolver private key in readonly mode', () => { + const config = ConfigService.from({ + CHAIN_ID: '8453', + RPC_URL: 'http://rpc.example', + READONLY: 'true', + RESOLVER_PRIVATE_KEY: 'ignored-in-readonly-mode' + }) + + expect(config.readOnly).toBe(true) + expect(config.privateKey).toBeUndefined() + }) + + test('requires a resolver private key in normal mode', () => { + expect(() => ConfigService.from({ CHAIN_ID: '8453', RPC_URL: 'http://rpc.example' })).toThrow( + 'RESOLVER_PRIVATE_KEY' + ) }) test('normalizes a trailing slash from the API URL', () => { diff --git a/bots/midnight-crossed-books/test/infrastructure/resolver/resolver.transport.test.ts b/bots/midnight-crossed-books/test/infrastructure/resolver/resolver.transport.test.ts new file mode 100644 index 00000000..5987d9b0 --- /dev/null +++ b/bots/midnight-crossed-books/test/infrastructure/resolver/resolver.transport.test.ts @@ -0,0 +1,28 @@ +import { createPublicClient, custom } from 'viem' +import { base } from 'viem/chains' +import { describe, expect, test } from 'vitest' + +import { ReadonlyMutationError } from '../../../src/infrastructure/resolver/readonly-mutation.error' +import { ViemResolverTransport } from '../../../src/infrastructure/resolver/resolver.transport' +import { MARKET_ID } from '../../fixtures/offers' + +const ADDRESS = `0x${'11'.repeat(20)}` as const + +const client = createPublicClient({ + chain: base, + transport: custom({ + request: async () => { + throw new Error('unexpected RPC request') + } + }) +}) + +describe('ViemResolverTransport', () => { + test('fails closed when readonly composition attempts to submit', async () => { + const transport = new ViemResolverTransport(client, ADDRESS, ADDRESS) + + await expect( + transport.submit({ marketId: MARKET_ID, data: '0x1234', profit: 42n }, 10n) + ).rejects.toBeInstanceOf(ReadonlyMutationError) + }) +}) From e19cbbaef92812ab7d8b80bf647824f46dd6adb3 Mon Sep 17 00:00:00 2001 From: "prd-carapulse[bot]" <264278285+prd-carapulse[bot]@users.noreply.github.com> Date: Wed, 12 Aug 2026 13:34:39 +0000 Subject: [PATCH 02/16] fix(midnight-crossed-books): address review findings Co-authored-by: Julien Thomas <61523188+julien-devatom@users.noreply.github.com> --- .../application/crossed-books-bot.service.ts | 5 +++- bots/midnight-crossed-books/src/bootstrap.ts | 3 ++- .../resolver-private-key-required.error.ts | 7 ++++++ .../crossed-books-bot.service.test.ts | 23 +++++++++++++++++++ 4 files changed, 36 insertions(+), 2 deletions(-) create mode 100644 bots/midnight-crossed-books/src/config/resolver-private-key-required.error.ts diff --git a/bots/midnight-crossed-books/src/application/crossed-books-bot.service.ts b/bots/midnight-crossed-books/src/application/crossed-books-bot.service.ts index 5ef46f7e..f9494820 100644 --- a/bots/midnight-crossed-books/src/application/crossed-books-bot.service.ts +++ b/bots/midnight-crossed-books/src/application/crossed-books-bot.service.ts @@ -60,6 +60,7 @@ export class CrossedBooksBotService { async run({ blockNumber }: { blockNumber: bigint }) { const markets = await this.markets.listListedActiveMarkets() const inflight = this.inflightMarketIds() + let computed = false for (const { marketId } of markets) { if (inflight.has(marketId)) continue @@ -90,7 +91,8 @@ export class CrossedBooksBotService { } if (this.readOnly) { this.logger.info('match.computed', fields) - return { submitted: false, markets: markets.length } + computed = true + continue } await this.resolver.submit(simulation.prepared, blockNumber) @@ -99,6 +101,7 @@ export class CrossedBooksBotService { return { submitted: true, markets: markets.length } } + if (computed) return { submitted: false, markets: markets.length } this.logger.info('tick.no_match', { markets: markets.length }) return { submitted: false, markets: markets.length } } diff --git a/bots/midnight-crossed-books/src/bootstrap.ts b/bots/midnight-crossed-books/src/bootstrap.ts index dbab85ad..ab218e29 100644 --- a/bots/midnight-crossed-books/src/bootstrap.ts +++ b/bots/midnight-crossed-books/src/bootstrap.ts @@ -17,6 +17,7 @@ import { getBlockNumber } from 'viem/actions' import { CrossedBooksBotService } from './application/crossed-books-bot.service' import { ConfigService } from './config/config.service' +import { ResolverPrivateKeyRequiredError } from './config/resolver-private-key-required.error' import { MatchingService } from './domain/matching.service' import { createMorphoApiClient } from './infrastructure/morpho-api/client' import { MorphoApiService } from './infrastructure/morpho-api/service' @@ -58,7 +59,7 @@ export async function createApplication( if (!config.readOnly) { const privateKey = config.privateKey - if (!privateKey) throw new Error('Write mode requires a resolver private key') + if (!privateKey) throw new ResolverPrivateKeyRequiredError() signer = createSigner({ chain: config.chain, rpcUrl: config.rpcUrl, diff --git a/bots/midnight-crossed-books/src/config/resolver-private-key-required.error.ts b/bots/midnight-crossed-books/src/config/resolver-private-key-required.error.ts new file mode 100644 index 00000000..9bd586dd --- /dev/null +++ b/bots/midnight-crossed-books/src/config/resolver-private-key-required.error.ts @@ -0,0 +1,7 @@ +/** Raised when write-mode composition is missing resolver signing authority. */ +export class ResolverPrivateKeyRequiredError extends Error { + /** Creates a credential-free configuration invariant failure. */ + constructor() { + super('Write mode requires a resolver private key') + } +} diff --git a/bots/midnight-crossed-books/test/application/crossed-books-bot.service.test.ts b/bots/midnight-crossed-books/test/application/crossed-books-bot.service.test.ts index dfe96636..8963c5bc 100644 --- a/bots/midnight-crossed-books/test/application/crossed-books-bot.service.test.ts +++ b/bots/midnight-crossed-books/test/application/crossed-books-bot.service.test.ts @@ -186,6 +186,29 @@ describe('CrossedBooksBotService', () => { expect(result).toEqual({ submitted: false, markets: 1 }) }) + test('logs every computed result without submitting in readonly mode', async () => { + const { service, getTakeableBook, simulate, submit, logger } = setup({ + readOnly: true, + markets: [MARKET, OTHER_MARKET] + }) + + const result = await service.run({ blockNumber: 10n }) + + expect(getTakeableBook).toHaveBeenCalledTimes(2) + expect(simulate).toHaveBeenCalledTimes(2) + expect(submit).not.toHaveBeenCalled() + expect(logger.info).toHaveBeenCalledWith( + 'match.computed', + expect.objectContaining({ marketId: MARKET_ID }) + ) + expect(logger.info).toHaveBeenCalledWith( + 'match.computed', + expect.objectContaining({ marketId: OTHER_MARKET_ID }) + ) + expect(logger.info).not.toHaveBeenCalledWith('tick.no_match', expect.anything()) + expect(result).toEqual({ submitted: false, markets: 2 }) + }) + test('isolates a book failure and continues to the next market', async () => { let calls = 0 const { service, books, submit } = setup({ markets: [MARKET, OTHER_MARKET] }) From d211480caaf76937060124e7e8141d04e33f13a1 Mon Sep 17 00:00:00 2001 From: "prd-carapulse[bot]" <264278285+prd-carapulse[bot]@users.noreply.github.com> Date: Wed, 12 Aug 2026 14:05:46 +0000 Subject: [PATCH 03/16] fix(midnight-crossed-books): harden readonly configuration --- bots/midnight-crossed-books/README.md | 11 +++++++++ .../scripts/deploy-railway.ts | 14 ++++------- .../midnight-crossed-books/scripts/railway.ts | 22 +++++++++++++++++ .../src/config/config.service.ts | 14 +++++++++-- .../test/config/config.service.test.ts | 4 +++- .../test/scripts/railway.test.ts | 24 ++++++++++++++++++- 6 files changed, 75 insertions(+), 14 deletions(-) diff --git a/bots/midnight-crossed-books/README.md b/bots/midnight-crossed-books/README.md index 792fc964..469051e7 100644 --- a/bots/midnight-crossed-books/README.md +++ b/bots/midnight-crossed-books/README.md @@ -86,6 +86,17 @@ RPC_URL=https://… RESOLVER_PRIVATE_KEY=0x… \ pnpm --filter @morpho-org/midnight-crossed-books run deploy:railway ``` +For a readonly service, set `READONLY=true` and omit `RESOLVER_PRIVATE_KEY`: + +```sh +RAILWAY_PROJECT_ID=… RAILWAY_ENVIRONMENT=staging \ +RPC_URL=https://… READONLY=true \ +pnpm --filter @morpho-org/midnight-crossed-books run deploy:railway +``` + +The provisioning command writes the selected `READONLY` mode to Railway. A write-mode deployment +still requires a valid resolver private key. + CI subsequently runs the same command with `DEPLOY_ONLY=true`, so GitHub holds only a project/environment-scoped Railway token. Pushes to `main` deploy staging through the `crossed-books-staging` GitHub Environment. Production deploys use the `release-crossed-books` diff --git a/bots/midnight-crossed-books/scripts/deploy-railway.ts b/bots/midnight-crossed-books/scripts/deploy-railway.ts index eb7760ea..c55f0bd3 100644 --- a/bots/midnight-crossed-books/scripts/deploy-railway.ts +++ b/bots/midnight-crossed-books/scripts/deploy-railway.ts @@ -9,7 +9,7 @@ import { $ } from 'execa' import { dirname, resolve } from 'node:path' import { fileURLToPath } from 'node:url' -import { parseLatestStatus, parseServices } from './railway' +import { parseLatestStatus, parseServices, resolveProvisioningConfiguration } from './railway' const PROJECT_ID = required(process.env, 'RAILWAY_PROJECT_ID') const ENVIRONMENT = process.env.RAILWAY_ENVIRONMENT?.trim() || 'production' @@ -52,12 +52,6 @@ function errorDetails(error: unknown) { return error instanceof Error ? error.message : String(error) } -function assertPrivateKey(key: string) { - if (!/^0x[0-9a-fA-F]{64}$/.test(key)) { - throw new Error('RESOLVER_PRIVATE_KEY must be a 0x-prefixed 32-byte hex string') - } -} - async function assertCli() { const { error } = await tryCatch($`railway --version`) if (error) { @@ -165,15 +159,15 @@ if (DEPLOY_ONLY) { reportStatus(await waitForDeploy()) } else { const rpcUrl = required(process.env, 'RPC_URL') - const resolverPrivateKey = required(process.env, 'RESOLVER_PRIVATE_KEY') - assertPrivateKey(resolverPrivateKey) + const { readOnly, resolverPrivateKey } = resolveProvisioningConfiguration(process.env) await ensureContext() await ensureService() await setVariable('CHAIN_ID=8453') + await setVariable(`READONLY=${readOnly}`) await setVariable(`RAILWAY_DOCKERFILE_PATH=${DOCKERFILE_PATH}`) await setSecret('RPC_URL', rpcUrl) - await setSecret('RESOLVER_PRIVATE_KEY', resolverPrivateKey) + if (resolverPrivateKey) await setSecret('RESOLVER_PRIVATE_KEY', resolverPrivateKey) await deployService() reportStatus(await waitForDeploy()) } diff --git a/bots/midnight-crossed-books/scripts/railway.ts b/bots/midnight-crossed-books/scripts/railway.ts index 1af38de1..7f1aa611 100644 --- a/bots/midnight-crossed-books/scripts/railway.ts +++ b/bots/midnight-crossed-books/scripts/railway.ts @@ -1,6 +1,28 @@ import { tryCatch } from '@repo/utils' type RailwayService = { name: string } +type Env = Record + +function required(env: Env, name: string) { + const value = env[name]?.trim() + if (!value) throw new Error(`Missing required env var: ${name}`) + return value +} + +export function resolveProvisioningConfiguration(env: Env) { + const value = env.READONLY?.trim().toLowerCase() + if (value && !['true', 'false', '1', '0'].includes(value)) { + throw new Error('READONLY must be one of: true, false, 1, 0') + } + + const readOnly = value === 'true' || value === '1' + const resolverPrivateKey = readOnly ? undefined : required(env, 'RESOLVER_PRIVATE_KEY') + if (resolverPrivateKey && !/^0x[0-9a-fA-F]{64}$/.test(resolverPrivateKey)) { + throw new Error('RESOLVER_PRIVATE_KEY must be a 0x-prefixed 32-byte hex string') + } + + return { readOnly, resolverPrivateKey } +} function isRecord(value: unknown): value is Record { return typeof value === 'object' && value !== null diff --git a/bots/midnight-crossed-books/src/config/config.service.ts b/bots/midnight-crossed-books/src/config/config.service.ts index 410f6f18..ab3ac026 100644 --- a/bots/midnight-crossed-books/src/config/config.service.ts +++ b/bots/midnight-crossed-books/src/config/config.service.ts @@ -5,6 +5,7 @@ import { getAddress, isAddress, isHex, parseGwei } from 'viem' import { base } from 'viem/chains' import { DEFAULT_MAX_MATCHES } from '../domain/matching.service' +import { ResolverPrivateKeyRequiredError } from './resolver-private-key-required.error' const MIDNIGHT = getAddress('0xAdedD8ab6dE832766Fedf0FaC4992E5C4D3EA18A') const PRIVATE_KEY_HEX_LENGTH = 66 @@ -23,6 +24,14 @@ function unsignedDecimal(environment: Environment, name: string, fallback?: stri return value } +function boolean(environment: Environment, name: string, fallback = false) { + const value = environment[name]?.trim().toLowerCase() + if (!value) return fallback + if (value === '1' || value === 'true') return true + if (value === '0' || value === 'false') return false + throw new Error(`${name} must be one of: true, false, 1, 0`) +} + export class ConfigService { /** * Loads and validates resolver configuration from environment values. @@ -38,8 +47,9 @@ export class ConfigService { throw new Error(`Unsupported CHAIN_ID ${chainId}; supported: ${base.id}`) } - const readOnly = /^(1|true)$/i.test(environment.READONLY?.trim() || '') - const privateKey = readOnly ? undefined : required(environment, 'RESOLVER_PRIVATE_KEY') + const readOnly = boolean(environment, 'READONLY') + const privateKey = readOnly ? undefined : environment.RESOLVER_PRIVATE_KEY?.trim() + if (!readOnly && !privateKey) throw new ResolverPrivateKeyRequiredError() if ( privateKey !== undefined && (!isHex(privateKey, { strict: true }) || privateKey.length !== PRIVATE_KEY_HEX_LENGTH) diff --git a/bots/midnight-crossed-books/test/config/config.service.test.ts b/bots/midnight-crossed-books/test/config/config.service.test.ts index 4149e405..0bc119b3 100644 --- a/bots/midnight-crossed-books/test/config/config.service.test.ts +++ b/bots/midnight-crossed-books/test/config/config.service.test.ts @@ -1,6 +1,7 @@ import { describe, expect, test } from 'vitest' import { ConfigService } from '../../src/config/config.service' +import { ResolverPrivateKeyRequiredError } from '../../src/config/resolver-private-key-required.error' const KEY = `0x${'11'.repeat(32)}` const REQUIRED = { @@ -38,7 +39,7 @@ describe('ConfigService', () => { test('requires a resolver private key in normal mode', () => { expect(() => ConfigService.from({ CHAIN_ID: '8453', RPC_URL: 'http://rpc.example' })).toThrow( - 'RESOLVER_PRIVATE_KEY' + ResolverPrivateKeyRequiredError ) }) @@ -71,6 +72,7 @@ describe('ConfigService', () => { test.each([ [{ ...REQUIRED, CHAIN_ID: '1' }, 'Unsupported CHAIN_ID'], [{ ...REQUIRED, CHAIN_ID: '0x2105' }, 'CHAIN_ID'], + [{ ...REQUIRED, READONLY: 'treu' }, 'READONLY'], [{ ...REQUIRED, RESOLVER_PRIVATE_KEY: '0x12' }, 'RESOLVER_PRIVATE_KEY'], [{ ...REQUIRED, RESOLVER_ADDRESS: 'not-an-address' }, 'RESOLVER_ADDRESS'], [{ ...REQUIRED, API_BASE_URL: 'not-a-url' }, 'API_BASE_URL'], diff --git a/bots/midnight-crossed-books/test/scripts/railway.test.ts b/bots/midnight-crossed-books/test/scripts/railway.test.ts index 5cc52429..0f35f414 100644 --- a/bots/midnight-crossed-books/test/scripts/railway.test.ts +++ b/bots/midnight-crossed-books/test/scripts/railway.test.ts @@ -1,6 +1,28 @@ +import { readFileSync } from 'node:fs' import { describe, expect, test } from 'vitest' -import { parseLatestStatus, parseServices } from '../../scripts/railway' +import { + parseLatestStatus, + parseServices, + resolveProvisioningConfiguration +} from '../../scripts/railway' + +describe('Railway provisioning configuration', () => { + test('supports readonly provisioning without a resolver private key', () => { + expect(resolveProvisioningConfiguration({ READONLY: 'true' })).toEqual({ + readOnly: true, + resolverPrivateKey: undefined + }) + }) + + test('wires readonly mode into first-time Railway provisioning', () => { + const deploy = readFileSync(new URL('../../scripts/deploy-railway.ts', import.meta.url), 'utf8') + + expect(deploy).toContain('resolveProvisioningConfiguration(process.env)') + expect(deploy).toContain('await setVariable(`READONLY=${readOnly}`)') + expect(deploy).toContain("if (resolverPrivateKey) await setSecret('RESOLVER_PRIVATE_KEY'") + }) +}) describe('Railway CLI output parsing', () => { test('parses service arrays and ignores nameless entries', () => { From 240341532887fb449e16ee720a5e563015e931ad Mon Sep 17 00:00:00 2001 From: "prd-carapulse[bot]" <264278285+prd-carapulse[bot]@users.noreply.github.com> Date: Wed, 12 Aug 2026 14:11:22 +0000 Subject: [PATCH 04/16] fix(midnight-crossed-books): preserve readonly caller semantics Co-authored-by: Julien Thomas <61523188+julien-devatom@users.noreply.github.com> --- bots/midnight-crossed-books/README.md | 17 ++++--- .../midnight-crossed-books/docker-compose.yml | 1 + .../scripts/deploy-railway.ts | 5 +- .../midnight-crossed-books/scripts/railway.ts | 49 +++++++++++++------ bots/midnight-crossed-books/src/bootstrap.ts | 13 +++-- .../src/config/config.service.ts | 33 +++++++++---- .../src/config/invalid-configuration.error.ts | 2 + .../src/config/readonly.utils.ts | 15 ++++++ .../test/config/config.service.test.ts | 40 ++++++++++++++- .../resolver/resolver.transport.test.ts | 26 +++++++++- .../test/scripts/railway.test.ts | 42 ++++++++++++++-- 11 files changed, 200 insertions(+), 43 deletions(-) create mode 100644 bots/midnight-crossed-books/src/config/invalid-configuration.error.ts create mode 100644 bots/midnight-crossed-books/src/config/readonly.utils.ts diff --git a/bots/midnight-crossed-books/README.md b/bots/midnight-crossed-books/README.md index 469051e7..e097d0fc 100644 --- a/bots/midnight-crossed-books/README.md +++ b/bots/midnight-crossed-books/README.md @@ -38,7 +38,8 @@ The generated files live under each infrastructure adapter's `generated/` direct - `CHAIN_ID` — required, currently `8453`. - `RPC_URL` — required. `RPC_URL_FALLBACK` is optional. -- `READONLY` — optional; set to `true` (or `1`) to simulate and log profitable matches without submitting transactions. +- `READONLY` — optional; `true`/`1` enables simulation-only mode, while absent/`false`/`0` selects write mode. Other values are rejected. +- `SIMULATION_CALLER_ADDRESS` — required in readonly mode. Set it to the public EOA that would execute resolutions in write mode so `msg.sender`, profit transfers, and reverts match execution without loading its private key. - `RESOLVER_PRIVATE_KEY` — required `0x`-prefixed 32-byte bot key unless `READONLY` is enabled. - `RESOLVER_ADDRESS` — optional deterministic deployment override. - `API_BASE_URL` — Morpho API origin, default `https://api.morpho.org`. @@ -60,11 +61,13 @@ pnpm --filter @repo/contracts run deploy:crossed-books-resolver ```sh CHAIN_ID=8453 RPC_URL=https://… READONLY=true \ +SIMULATION_CALLER_ADDRESS=0x… \ pnpm --filter @morpho-org/midnight-crossed-books run start ``` -Readonly mode uses the resolver address as the simulation caller, logs each profitable result as -`match.computed`, and never creates a signer, transaction queue, or submission. +Readonly mode uses `SIMULATION_CALLER_ADDRESS` as the execution-equivalent simulation caller, logs +each profitable result as `match.computed`, and never creates a signer, transaction queue, or +submission. Only the public EOA address is required; do not provide or derive its private key. To execute profitable resolutions instead, provide the signer key: @@ -86,16 +89,16 @@ RPC_URL=https://… RESOLVER_PRIVATE_KEY=0x… \ pnpm --filter @morpho-org/midnight-crossed-books run deploy:railway ``` -For a readonly service, set `READONLY=true` and omit `RESOLVER_PRIVATE_KEY`: +For keyless readonly Railway provisioning, replace the private key with the public caller address: ```sh RAILWAY_PROJECT_ID=… RAILWAY_ENVIRONMENT=staging \ -RPC_URL=https://… READONLY=true \ +RPC_URL=https://… READONLY=true SIMULATION_CALLER_ADDRESS=0x… \ pnpm --filter @morpho-org/midnight-crossed-books run deploy:railway ``` -The provisioning command writes the selected `READONLY` mode to Railway. A write-mode deployment -still requires a valid resolver private key. +The deploy script validates and propagates `READONLY` and `SIMULATION_CALLER_ADDRESS`; it does not +require or install `RESOLVER_PRIVATE_KEY` in readonly mode. Write mode still requires a valid key. CI subsequently runs the same command with `DEPLOY_ONLY=true`, so GitHub holds only a project/environment-scoped Railway token. Pushes to `main` deploy staging through the diff --git a/bots/midnight-crossed-books/docker-compose.yml b/bots/midnight-crossed-books/docker-compose.yml index 90e655ed..15836537 100644 --- a/bots/midnight-crossed-books/docker-compose.yml +++ b/bots/midnight-crossed-books/docker-compose.yml @@ -7,6 +7,7 @@ services: CHAIN_ID: '8453' RPC_URL: ${RPC_URL:?set RPC_URL} READONLY: ${READONLY:-false} + SIMULATION_CALLER_ADDRESS: ${SIMULATION_CALLER_ADDRESS:-} RESOLVER_PRIVATE_KEY: ${RESOLVER_PRIVATE_KEY:-} RESOLVER_ADDRESS: ${RESOLVER_ADDRESS:-} API_BASE_URL: ${API_BASE_URL:-https://api.morpho.org} diff --git a/bots/midnight-crossed-books/scripts/deploy-railway.ts b/bots/midnight-crossed-books/scripts/deploy-railway.ts index c55f0bd3..08881281 100644 --- a/bots/midnight-crossed-books/scripts/deploy-railway.ts +++ b/bots/midnight-crossed-books/scripts/deploy-railway.ts @@ -159,12 +159,15 @@ if (DEPLOY_ONLY) { reportStatus(await waitForDeploy()) } else { const rpcUrl = required(process.env, 'RPC_URL') - const { readOnly, resolverPrivateKey } = resolveProvisioningConfiguration(process.env) + const { readOnly, resolverPrivateKey, simulationCaller } = resolveProvisioningConfiguration( + process.env + ) await ensureContext() await ensureService() await setVariable('CHAIN_ID=8453') await setVariable(`READONLY=${readOnly}`) + if (simulationCaller) await setVariable(`SIMULATION_CALLER_ADDRESS=${simulationCaller}`) await setVariable(`RAILWAY_DOCKERFILE_PATH=${DOCKERFILE_PATH}`) await setSecret('RPC_URL', rpcUrl) if (resolverPrivateKey) await setSecret('RESOLVER_PRIVATE_KEY', resolverPrivateKey) diff --git a/bots/midnight-crossed-books/scripts/railway.ts b/bots/midnight-crossed-books/scripts/railway.ts index 7f1aa611..2577c22f 100644 --- a/bots/midnight-crossed-books/scripts/railway.ts +++ b/bots/midnight-crossed-books/scripts/railway.ts @@ -1,27 +1,46 @@ import { tryCatch } from '@repo/utils' +import { getAddress, isAddress, isHex } from 'viem' + +import { InvalidConfigurationError } from '../src/config/invalid-configuration.error' +import { parseReadonly } from '../src/config/readonly.utils' +import { ResolverPrivateKeyRequiredError } from '../src/config/resolver-private-key-required.error' type RailwayService = { name: string } type Env = Record -function required(env: Env, name: string) { - const value = env[name]?.trim() - if (!value) throw new Error(`Missing required env var: ${name}`) - return value -} - -export function resolveProvisioningConfiguration(env: Env) { - const value = env.READONLY?.trim().toLowerCase() - if (value && !['true', 'false', '1', '0'].includes(value)) { - throw new Error('READONLY must be one of: true, false, 1, 0') +/** + * Validates mode-specific Railway provisioning values without retaining unused signing material. + * @param env - Local deploy environment containing mode, caller, and optional signing key. + * @returns Canonical readonly, simulation-caller, and write-key values for Railway installation. + * @throws `InvalidConfigurationError` when mode, caller, or key syntax is invalid. + * @throws `ResolverPrivateKeyRequiredError` when write mode has no signing key. + * @remarks Readonly mode never reads or returns `RESOLVER_PRIVATE_KEY`. + */ +export const resolveProvisioningConfiguration = (env: Env) => { + const readOnly = parseReadonly(env.READONLY) + if (readOnly) { + const caller = env.SIMULATION_CALLER_ADDRESS?.trim() + if (!caller || !isAddress(caller, { strict: false })) { + throw new InvalidConfigurationError( + 'Readonly mode requires a valid SIMULATION_CALLER_ADDRESS' + ) + } + return { + readOnly, + resolverPrivateKey: undefined, + simulationCaller: getAddress(caller) + } } - const readOnly = value === 'true' || value === '1' - const resolverPrivateKey = readOnly ? undefined : required(env, 'RESOLVER_PRIVATE_KEY') - if (resolverPrivateKey && !/^0x[0-9a-fA-F]{64}$/.test(resolverPrivateKey)) { - throw new Error('RESOLVER_PRIVATE_KEY must be a 0x-prefixed 32-byte hex string') + const resolverPrivateKey = env.RESOLVER_PRIVATE_KEY?.trim() + if (!resolverPrivateKey) throw new ResolverPrivateKeyRequiredError() + if (!isHex(resolverPrivateKey, { strict: true }) || resolverPrivateKey.length !== 66) { + throw new InvalidConfigurationError( + 'RESOLVER_PRIVATE_KEY must be a 0x-prefixed 32-byte hex string' + ) } - return { readOnly, resolverPrivateKey } + return { readOnly, resolverPrivateKey, simulationCaller: undefined } } function isRecord(value: unknown): value is Record { diff --git a/bots/midnight-crossed-books/src/bootstrap.ts b/bots/midnight-crossed-books/src/bootstrap.ts index ab218e29..0b02a555 100644 --- a/bots/midnight-crossed-books/src/bootstrap.ts +++ b/bots/midnight-crossed-books/src/bootstrap.ts @@ -17,6 +17,7 @@ import { getBlockNumber } from 'viem/actions' import { CrossedBooksBotService } from './application/crossed-books-bot.service' import { ConfigService } from './config/config.service' +import { InvalidConfigurationError } from './config/invalid-configuration.error' import { ResolverPrivateKeyRequiredError } from './config/resolver-private-key-required.error' import { MatchingService } from './domain/matching.service' import { createMorphoApiClient } from './infrastructure/morpho-api/client' @@ -37,7 +38,8 @@ function resolverSelector() { * Composes the crossed-books resolver runtime for the selected environment mode. * @param environment - Runtime configuration and optional observability values. * @returns A lifecycle handle that polls immediately and then follows new blocks when started. - * @throws `Error` when configuration is invalid or required contracts are not deployed. + * @throws `InvalidConfigurationError` when readonly mode or its caller is invalid. + * @throws `Error` when other configuration is invalid or required contracts are not deployed. * @remarks Readonly composition creates no signer, pending transaction queue, or balance monitor; * both modes perform RPC deployment checks during composition. */ @@ -57,7 +59,12 @@ export async function createApplication( let signer: ReturnType | undefined let queue: ReturnType | undefined - if (!config.readOnly) { + if (config.readOnly) { + if (!config.simulationCaller) { + throw new InvalidConfigurationError('Readonly mode requires SIMULATION_CALLER_ADDRESS') + } + sender = config.simulationCaller + } else { const privateKey = config.privateKey if (!privateKey) throw new ResolverPrivateKeyRequiredError() signer = createSigner({ @@ -150,7 +157,7 @@ export async function createApplication( async start() { logger.info('startup', { readOnly: config.readOnly, - sender: config.readOnly ? undefined : sender, + sender, midnight: config.midnight, resolver: config.resolver, minimumProfit: config.minimumProfit, diff --git a/bots/midnight-crossed-books/src/config/config.service.ts b/bots/midnight-crossed-books/src/config/config.service.ts index ab3ac026..29d3fb82 100644 --- a/bots/midnight-crossed-books/src/config/config.service.ts +++ b/bots/midnight-crossed-books/src/config/config.service.ts @@ -5,6 +5,8 @@ import { getAddress, isAddress, isHex, parseGwei } from 'viem' import { base } from 'viem/chains' import { DEFAULT_MAX_MATCHES } from '../domain/matching.service' +import { InvalidConfigurationError } from './invalid-configuration.error' +import { parseReadonly } from './readonly.utils' import { ResolverPrivateKeyRequiredError } from './resolver-private-key-required.error' const MIDNIGHT = getAddress('0xAdedD8ab6dE832766Fedf0FaC4992E5C4D3EA18A') @@ -24,20 +26,14 @@ function unsignedDecimal(environment: Environment, name: string, fallback?: stri return value } -function boolean(environment: Environment, name: string, fallback = false) { - const value = environment[name]?.trim().toLowerCase() - if (!value) return fallback - if (value === '1' || value === 'true') return true - if (value === '0' || value === 'false') return false - throw new Error(`${name} must be one of: true, false, 1, 0`) -} - export class ConfigService { /** * Loads and validates resolver configuration from environment values. * @param environment - Runtime environment; defaults to `process.env`. * @returns Immutable configuration with signer material omitted in readonly mode. - * @throws `Error` when a required write-mode value or another runtime value is invalid. + * @throws `InvalidConfigurationError` when readonly mode or its caller is invalid. + * @throws `ResolverPrivateKeyRequiredError` when write mode has no signing key. + * @throws `Error` when another required runtime value is invalid. * @remarks This method performs no network access and does not retain `RESOLVER_PRIVATE_KEY` when * readonly mode is enabled. */ @@ -47,7 +43,7 @@ export class ConfigService { throw new Error(`Unsupported CHAIN_ID ${chainId}; supported: ${base.id}`) } - const readOnly = boolean(environment, 'READONLY') + const readOnly = parseReadonly(environment.READONLY) const privateKey = readOnly ? undefined : environment.RESOLVER_PRIVATE_KEY?.trim() if (!readOnly && !privateKey) throw new ResolverPrivateKeyRequiredError() if ( @@ -62,6 +58,16 @@ export class ConfigService { throw new Error('RESOLVER_ADDRESS must be an EVM address') } + const simulationCaller = environment.SIMULATION_CALLER_ADDRESS?.trim() + if (readOnly && !simulationCaller) { + throw new InvalidConfigurationError( + 'Readonly mode requires SIMULATION_CALLER_ADDRESS to preserve execution caller semantics' + ) + } + if (simulationCaller && !isAddress(simulationCaller, { strict: false })) { + throw new InvalidConfigurationError('SIMULATION_CALLER_ADDRESS must be an EVM address') + } + const apiBaseUrl = (environment.API_BASE_URL?.trim() || 'https://api.morpho.org').replace( /\/$/, '' @@ -98,6 +104,7 @@ export class ConfigService { rpcUrlFallback: environment.RPC_URL_FALLBACK?.trim() || undefined, readOnly, privateKey, + simulationCaller: simulationCaller ? getAddress(simulationCaller) : undefined, apiBaseUrl, routerApiBaseUrl, scanIntervalMs, @@ -118,6 +125,7 @@ export class ConfigService { rpcUrlFallback: string | undefined readOnly: boolean privateKey: Hex | undefined + simulationCaller: Address | undefined apiBaseUrl: string routerApiBaseUrl: string scanIntervalMs: number @@ -159,6 +167,11 @@ export class ConfigService { return this.values.readOnly } + /** Returns the validated execution-equivalent caller used only for keyless simulation. */ + get simulationCaller() { + return this.values.simulationCaller + } + get apiBaseUrl() { return this.values.apiBaseUrl } diff --git a/bots/midnight-crossed-books/src/config/invalid-configuration.error.ts b/bots/midnight-crossed-books/src/config/invalid-configuration.error.ts new file mode 100644 index 00000000..737e5baa --- /dev/null +++ b/bots/midnight-crossed-books/src/config/invalid-configuration.error.ts @@ -0,0 +1,2 @@ +/** Raised when an operator-provided runtime value cannot be used safely. */ +export class InvalidConfigurationError extends Error {} diff --git a/bots/midnight-crossed-books/src/config/readonly.utils.ts b/bots/midnight-crossed-books/src/config/readonly.utils.ts new file mode 100644 index 00000000..11adfbd6 --- /dev/null +++ b/bots/midnight-crossed-books/src/config/readonly.utils.ts @@ -0,0 +1,15 @@ +import { InvalidConfigurationError } from './invalid-configuration.error' + +/** + * Parses the fail-closed readonly mode switch. + * @param value - Raw `READONLY` environment value. + * @returns `true` for `true`/`1`; `false` for absent, empty, `false`, or `0` values. + * @throws `InvalidConfigurationError` for every other nonempty value. + * @remarks Parsing is case-insensitive and performs no side effects. + */ +export const parseReadonly = (value: string | undefined) => { + const normalized = value?.trim().toLowerCase() || '' + if (normalized === 'true' || normalized === '1') return true + if (normalized === '' || normalized === 'false' || normalized === '0') return false + throw new InvalidConfigurationError('READONLY must be one of: true, 1, false, 0') +} diff --git a/bots/midnight-crossed-books/test/config/config.service.test.ts b/bots/midnight-crossed-books/test/config/config.service.test.ts index 0bc119b3..f867e484 100644 --- a/bots/midnight-crossed-books/test/config/config.service.test.ts +++ b/bots/midnight-crossed-books/test/config/config.service.test.ts @@ -1,6 +1,7 @@ import { describe, expect, test } from 'vitest' import { ConfigService } from '../../src/config/config.service' +import { InvalidConfigurationError } from '../../src/config/invalid-configuration.error' import { ResolverPrivateKeyRequiredError } from '../../src/config/resolver-private-key-required.error' const KEY = `0x${'11'.repeat(32)}` @@ -30,14 +31,51 @@ describe('ConfigService', () => { CHAIN_ID: '8453', RPC_URL: 'http://rpc.example', READONLY: 'true', + SIMULATION_CALLER_ADDRESS: `0x${'33'.repeat(20)}`, RESOLVER_PRIVATE_KEY: 'ignored-in-readonly-mode' }) expect(config.readOnly).toBe(true) expect(config.privateKey).toBeUndefined() + expect(config.simulationCaller).toBe(`0x${'33'.repeat(20)}`) }) - test('requires a resolver private key in normal mode', () => { + test.each(['true', 'TRUE', '1'])('accepts READONLY=%s as readonly mode', value => { + const config = ConfigService.from({ + CHAIN_ID: '8453', + RPC_URL: 'http://rpc.example', + READONLY: value, + SIMULATION_CALLER_ADDRESS: `0x${'33'.repeat(20)}` + }) + + expect(config.readOnly).toBe(true) + }) + + test.each([undefined, '', 'false', 'FALSE', '0'])('accepts READONLY=%s as write mode', value => { + expect(ConfigService.from({ ...REQUIRED, READONLY: value }).readOnly).toBe(false) + }) + + test.each(['yes', '2', 'truthy'])('rejects malformed READONLY=%s fail-closed', value => { + expect(() => ConfigService.from({ ...REQUIRED, READONLY: value })).toThrow( + InvalidConfigurationError + ) + }) + + test('requires a validated keyless simulation caller in readonly mode', () => { + expect(() => + ConfigService.from({ CHAIN_ID: '8453', RPC_URL: 'http://rpc.example', READONLY: 'true' }) + ).toThrow(InvalidConfigurationError) + expect(() => + ConfigService.from({ + CHAIN_ID: '8453', + RPC_URL: 'http://rpc.example', + READONLY: 'true', + SIMULATION_CALLER_ADDRESS: 'not-an-address' + }) + ).toThrow(InvalidConfigurationError) + }) + + test('requires a resolver private key in normal mode with a named error', () => { expect(() => ConfigService.from({ CHAIN_ID: '8453', RPC_URL: 'http://rpc.example' })).toThrow( ResolverPrivateKeyRequiredError ) diff --git a/bots/midnight-crossed-books/test/infrastructure/resolver/resolver.transport.test.ts b/bots/midnight-crossed-books/test/infrastructure/resolver/resolver.transport.test.ts index 5987d9b0..9b5a9d91 100644 --- a/bots/midnight-crossed-books/test/infrastructure/resolver/resolver.transport.test.ts +++ b/bots/midnight-crossed-books/test/infrastructure/resolver/resolver.transport.test.ts @@ -1,12 +1,13 @@ import { createPublicClient, custom } from 'viem' import { base } from 'viem/chains' -import { describe, expect, test } from 'vitest' +import { describe, expect, test, vi } from 'vitest' import { ReadonlyMutationError } from '../../../src/infrastructure/resolver/readonly-mutation.error' import { ViemResolverTransport } from '../../../src/infrastructure/resolver/resolver.transport' import { MARKET_ID } from '../../fixtures/offers' const ADDRESS = `0x${'11'.repeat(20)}` as const +const CALLER = `0x${'22'.repeat(20)}` as const const client = createPublicClient({ chain: base, @@ -18,6 +19,29 @@ const client = createPublicClient({ }) describe('ViemResolverTransport', () => { + test('simulates the exact calldata from the configured execution-equivalent caller', async () => { + const request = vi.fn(async () => '0xabcd' as const) + const simulationClient = createPublicClient({ chain: base, transport: custom({ request }) }) + const transport = new ViemResolverTransport(simulationClient, CALLER, ADDRESS) + + await expect(transport.simulate('0x1234')).resolves.toEqual({ + status: 'success', + data: '0xabcd' + }) + expect(request).toHaveBeenCalledWith({ + method: 'eth_call', + params: [ + { + data: '0x1234', + from: CALLER, + to: ADDRESS, + value: '0x0' + }, + 'latest' + ] + }) + }) + test('fails closed when readonly composition attempts to submit', async () => { const transport = new ViemResolverTransport(client, ADDRESS, ADDRESS) diff --git a/bots/midnight-crossed-books/test/scripts/railway.test.ts b/bots/midnight-crossed-books/test/scripts/railway.test.ts index 0f35f414..f8d15e87 100644 --- a/bots/midnight-crossed-books/test/scripts/railway.test.ts +++ b/bots/midnight-crossed-books/test/scripts/railway.test.ts @@ -6,20 +6,52 @@ import { parseServices, resolveProvisioningConfiguration } from '../../scripts/railway' +import { InvalidConfigurationError } from '../../src/config/invalid-configuration.error' +import { ResolverPrivateKeyRequiredError } from '../../src/config/resolver-private-key-required.error' + +const KEY = `0x${'11'.repeat(32)}` +const CALLER = `0x${'22'.repeat(20)}` describe('Railway provisioning configuration', () => { - test('supports readonly provisioning without a resolver private key', () => { - expect(resolveProvisioningConfiguration({ READONLY: 'true' })).toEqual({ + test('supports keyless readonly provisioning with an execution-equivalent caller', () => { + expect( + resolveProvisioningConfiguration({ + READONLY: 'TRUE', + SIMULATION_CALLER_ADDRESS: CALLER, + RESOLVER_PRIVATE_KEY: 'ignored-in-readonly-mode' + }) + ).toEqual({ readOnly: true, - resolverPrivateKey: undefined + resolverPrivateKey: undefined, + simulationCaller: CALLER + }) + }) + + test('preserves write-mode key requirements and provisioning', () => { + expect(resolveProvisioningConfiguration({ RESOLVER_PRIVATE_KEY: KEY })).toEqual({ + readOnly: false, + resolverPrivateKey: KEY, + simulationCaller: undefined }) + expect(() => resolveProvisioningConfiguration({})).toThrow(ResolverPrivateKeyRequiredError) + }) + + test.each([ + [{ READONLY: 'yes', RESOLVER_PRIVATE_KEY: KEY }, 'READONLY'], + [{ READONLY: 'true' }, 'SIMULATION_CALLER_ADDRESS'], + [{ READONLY: 'true', SIMULATION_CALLER_ADDRESS: 'invalid' }, 'SIMULATION_CALLER_ADDRESS'], + [{ RESOLVER_PRIVATE_KEY: '0x12' }, 'RESOLVER_PRIVATE_KEY'] + ])('rejects invalid provisioning configuration %#', (environment, message) => { + expect(() => resolveProvisioningConfiguration(environment)).toThrow(InvalidConfigurationError) + expect(() => resolveProvisioningConfiguration(environment)).toThrow(message) }) - test('wires readonly mode into first-time Railway provisioning', () => { + test('wires readonly mode and caller into first-time Railway provisioning', () => { const deploy = readFileSync(new URL('../../scripts/deploy-railway.ts', import.meta.url), 'utf8') - expect(deploy).toContain('resolveProvisioningConfiguration(process.env)') + expect(deploy).toMatch(/resolveProvisioningConfiguration\(\s*process\.env\s*\)/) expect(deploy).toContain('await setVariable(`READONLY=${readOnly}`)') + expect(deploy).toContain('await setVariable(`SIMULATION_CALLER_ADDRESS=${simulationCaller}`)') expect(deploy).toContain("if (resolverPrivateKey) await setSecret('RESOLVER_PRIVATE_KEY'") }) }) From 79acc321a9e22701cfbf765c4383337e34cfedfd Mon Sep 17 00:00:00 2001 From: "prd-carapulse[bot]" <264278285+prd-carapulse[bot]@users.noreply.github.com> Date: Wed, 12 Aug 2026 14:49:02 +0000 Subject: [PATCH 05/16] fix(midnight-crossed-books): harden readonly mode transitions Co-authored-by: Julien Thomas <61523188+julien-devatom@users.noreply.github.com> --- bots/midnight-crossed-books/README.md | 6 +- .../scripts/deploy-railway.ts | 37 ++++- .../invalid-railway-variable-list.error.ts | 8 ++ .../railway-variable-operation.error.ts | 18 +++ .../midnight-crossed-books/scripts/railway.ts | 63 ++++++++- .../src/config/config.service.ts | 19 +-- ...invalid-simulation-caller-address.error.ts | 10 ++ .../test/config/config.service.test.ts | 12 ++ .../test/scripts/railway.test.ts | 127 +++++++++++++++++- 9 files changed, 269 insertions(+), 31 deletions(-) create mode 100644 bots/midnight-crossed-books/scripts/invalid-railway-variable-list.error.ts create mode 100644 bots/midnight-crossed-books/scripts/railway-variable-operation.error.ts create mode 100644 bots/midnight-crossed-books/src/config/invalid-simulation-caller-address.error.ts diff --git a/bots/midnight-crossed-books/README.md b/bots/midnight-crossed-books/README.md index e097d0fc..2bce8c16 100644 --- a/bots/midnight-crossed-books/README.md +++ b/bots/midnight-crossed-books/README.md @@ -39,7 +39,7 @@ The generated files live under each infrastructure adapter's `generated/` direct - `CHAIN_ID` — required, currently `8453`. - `RPC_URL` — required. `RPC_URL_FALLBACK` is optional. - `READONLY` — optional; `true`/`1` enables simulation-only mode, while absent/`false`/`0` selects write mode. Other values are rejected. -- `SIMULATION_CALLER_ADDRESS` — required in readonly mode. Set it to the public EOA that would execute resolutions in write mode so `msg.sender`, profit transfers, and reverts match execution without loading its private key. +- `SIMULATION_CALLER_ADDRESS` — required in readonly mode. Set it to the non-zero public EOA that would execute resolutions in write mode so `msg.sender`, profit transfers, and reverts match execution without loading its private key. The operator is responsible for supplying this public caller address; the zero address is rejected. - `RESOLVER_PRIVATE_KEY` — required `0x`-prefixed 32-byte bot key unless `READONLY` is enabled. - `RESOLVER_ADDRESS` — optional deterministic deployment override. - `API_BASE_URL` — Morpho API origin, default `https://api.morpho.org`. @@ -98,7 +98,9 @@ pnpm --filter @morpho-org/midnight-crossed-books run deploy:railway ``` The deploy script validates and propagates `READONLY` and `SIMULATION_CALLER_ADDRESS`; it does not -require or install `RESOLVER_PRIVATE_KEY` in readonly mode. Write mode still requires a valid key. +require or install `RESOLVER_PRIVATE_KEY` in readonly mode. It also removes a stale private key when +switching to readonly and removes a stale simulation caller when switching to write mode, aborting +before the mode change if deletion fails. Write mode still requires a valid key. CI subsequently runs the same command with `DEPLOY_ONLY=true`, so GitHub holds only a project/environment-scoped Railway token. Pushes to `main` deploy staging through the diff --git a/bots/midnight-crossed-books/scripts/deploy-railway.ts b/bots/midnight-crossed-books/scripts/deploy-railway.ts index 08881281..5283e970 100644 --- a/bots/midnight-crossed-books/scripts/deploy-railway.ts +++ b/bots/midnight-crossed-books/scripts/deploy-railway.ts @@ -9,7 +9,14 @@ import { $ } from 'execa' import { dirname, resolve } from 'node:path' import { fileURLToPath } from 'node:url' -import { parseLatestStatus, parseServices, resolveProvisioningConfiguration } from './railway' +import { + parseLatestStatus, + parseServices, + parseVariableKeys, + resolveProvisioningConfiguration, + synchronizeModeVariables +} from './railway' +import { RailwayVariableOperationError } from './railway-variable-operation.error' const PROJECT_ID = required(process.env, 'RAILWAY_PROJECT_ID') const ENVIRONMENT = process.env.RAILWAY_ENVIRONMENT?.trim() || 'production' @@ -105,6 +112,22 @@ async function setSecret(name: string, value: string) { console.log(`Set ${name} on ${SERVICE} (secret).`) } +const listVariableKeys = async () => { + const { data, error } = await tryCatch( + $`railway variable list -s ${SERVICE} -e ${ENVIRONMENT} -p ${PROJECT_ID} --json`.then( + result => result.stdout + ) + ) + if (error || typeof data !== 'string') throw new RailwayVariableOperationError('list') + return parseVariableKeys(data) +} + +const deleteVariable = async (name: string) => { + const { error } = await tryCatch($`railway variable delete ${name} -s ${SERVICE} --skip-deploys`) + if (error) throw new RailwayVariableOperationError('delete', name) + console.log(`Deleted ${name} on ${SERVICE} (stale).`) +} + async function deployService() { const message = `deploy midnight crossed-books ${ENVIRONMENT}` const { error } = await tryCatch( @@ -159,18 +182,18 @@ if (DEPLOY_ONLY) { reportStatus(await waitForDeploy()) } else { const rpcUrl = required(process.env, 'RPC_URL') - const { readOnly, resolverPrivateKey, simulationCaller } = resolveProvisioningConfiguration( - process.env - ) + const config = resolveProvisioningConfiguration(process.env) await ensureContext() await ensureService() await setVariable('CHAIN_ID=8453') - await setVariable(`READONLY=${readOnly}`) - if (simulationCaller) await setVariable(`SIMULATION_CALLER_ADDRESS=${simulationCaller}`) + await synchronizeModeVariables(config, await listVariableKeys(), { + deleteVariable, + setSecret, + setVariable + }) await setVariable(`RAILWAY_DOCKERFILE_PATH=${DOCKERFILE_PATH}`) await setSecret('RPC_URL', rpcUrl) - if (resolverPrivateKey) await setSecret('RESOLVER_PRIVATE_KEY', resolverPrivateKey) await deployService() reportStatus(await waitForDeploy()) } diff --git a/bots/midnight-crossed-books/scripts/invalid-railway-variable-list.error.ts b/bots/midnight-crossed-books/scripts/invalid-railway-variable-list.error.ts new file mode 100644 index 00000000..5425b000 --- /dev/null +++ b/bots/midnight-crossed-books/scripts/invalid-railway-variable-list.error.ts @@ -0,0 +1,8 @@ +/** Raised when Railway returns a variable list that cannot be safely interpreted. */ +export class InvalidRailwayVariableListError extends Error { + /** Creates a response-shape failure without retaining or printing variable values. */ + constructor() { + super('Railway returned an invalid variable list') + this.name = 'InvalidRailwayVariableListError' + } +} diff --git a/bots/midnight-crossed-books/scripts/railway-variable-operation.error.ts b/bots/midnight-crossed-books/scripts/railway-variable-operation.error.ts new file mode 100644 index 00000000..ce70e507 --- /dev/null +++ b/bots/midnight-crossed-books/scripts/railway-variable-operation.error.ts @@ -0,0 +1,18 @@ +type RailwayVariableOperation = 'delete' | 'list' + +/** Raised when a Railway variable operation fails without exposing variable values. */ +export class RailwayVariableOperationError extends Error { + /** + * Creates a secret-safe CLI operation failure. + * @param operation - Failed Railway operation. + * @param variableName - Variable name for a targeted operation; never a value. + */ + constructor(operation: RailwayVariableOperation, variableName?: string) { + super( + variableName + ? `Failed to ${operation} Railway variable ${variableName}` + : `Failed to ${operation} Railway variables` + ) + this.name = 'RailwayVariableOperationError' + } +} diff --git a/bots/midnight-crossed-books/scripts/railway.ts b/bots/midnight-crossed-books/scripts/railway.ts index 2577c22f..fd27bf63 100644 --- a/bots/midnight-crossed-books/scripts/railway.ts +++ b/bots/midnight-crossed-books/scripts/railway.ts @@ -1,12 +1,22 @@ import { tryCatch } from '@repo/utils' -import { getAddress, isAddress, isHex } from 'viem' +import { getAddress, isAddress, isAddressEqual, isHex, zeroAddress } from 'viem' import { InvalidConfigurationError } from '../src/config/invalid-configuration.error' +import { InvalidSimulationCallerAddressError } from '../src/config/invalid-simulation-caller-address.error' import { parseReadonly } from '../src/config/readonly.utils' import { ResolverPrivateKeyRequiredError } from '../src/config/resolver-private-key-required.error' +import { InvalidRailwayVariableListError } from './invalid-railway-variable-list.error' type RailwayService = { name: string } type Env = Record +type ProvisioningConfiguration = + | { readOnly: true; resolverPrivateKey: undefined; simulationCaller: `0x${string}` } + | { readOnly: false; resolverPrivateKey: string; simulationCaller: undefined } +type ModeVariableOperations = { + deleteVariable: (name: string) => Promise + setSecret: (name: string, value: string) => Promise + setVariable: (value: string) => Promise +} /** * Validates mode-specific Railway provisioning values without retaining unused signing material. @@ -16,14 +26,12 @@ type Env = Record * @throws `ResolverPrivateKeyRequiredError` when write mode has no signing key. * @remarks Readonly mode never reads or returns `RESOLVER_PRIVATE_KEY`. */ -export const resolveProvisioningConfiguration = (env: Env) => { +export const resolveProvisioningConfiguration = (env: Env): ProvisioningConfiguration => { const readOnly = parseReadonly(env.READONLY) if (readOnly) { const caller = env.SIMULATION_CALLER_ADDRESS?.trim() - if (!caller || !isAddress(caller, { strict: false })) { - throw new InvalidConfigurationError( - 'Readonly mode requires a valid SIMULATION_CALLER_ADDRESS' - ) + if (!caller || !isAddress(caller, { strict: false }) || isAddressEqual(caller, zeroAddress)) { + throw new InvalidSimulationCallerAddressError() } return { readOnly, @@ -43,6 +51,49 @@ export const resolveProvisioningConfiguration = (env: Env) => { return { readOnly, resolverPrivateKey, simulationCaller: undefined } } +/** + * Synchronizes Railway's mutually exclusive mode variables before changing the active mode. + * @param config - Validated mode-specific provisioning values. + * @param existingKeys - Variable names currently installed on the target service. + * @param operations - Secret-safe Railway variable mutation operations. + * @returns A promise that resolves after incompatible variables are removed and the mode is set. + * @throws The underlying mutation error; mode is not changed when incompatible-variable deletion + * fails. + * @remarks Secret values are passed only to `setSecret` and are never logged by this helper. + */ +export const synchronizeModeVariables = async ( + config: ReturnType, + existingKeys: ReadonlySet, + operations: ModeVariableOperations +) => { + if (config.readOnly) { + if (existingKeys.has('RESOLVER_PRIVATE_KEY')) { + await operations.deleteVariable('RESOLVER_PRIVATE_KEY') + } + await operations.setVariable(`SIMULATION_CALLER_ADDRESS=${config.simulationCaller}`) + } else { + if (existingKeys.has('SIMULATION_CALLER_ADDRESS')) { + await operations.deleteVariable('SIMULATION_CALLER_ADDRESS') + } + await operations.setSecret('RESOLVER_PRIVATE_KEY', config.resolverPrivateKey) + } + await operations.setVariable(`READONLY=${config.readOnly}`) +} + +/** + * Reads only variable names from Railway's JSON response. + * @param raw - Raw JSON emitted by `railway variable list --json`. + * @returns Installed variable names without retaining their values. + * @throws `InvalidRailwayVariableListError` when Railway returns malformed or unexpected JSON. + */ +export const parseVariableKeys = (raw: string) => { + const { data, error } = tryCatch(() => JSON.parse(raw) as unknown) + if (error || !isRecord(data) || Array.isArray(data)) { + throw new InvalidRailwayVariableListError() + } + return new Set(Object.keys(data)) +} + function isRecord(value: unknown): value is Record { return typeof value === 'object' && value !== null } diff --git a/bots/midnight-crossed-books/src/config/config.service.ts b/bots/midnight-crossed-books/src/config/config.service.ts index 29d3fb82..10537abc 100644 --- a/bots/midnight-crossed-books/src/config/config.service.ts +++ b/bots/midnight-crossed-books/src/config/config.service.ts @@ -1,11 +1,11 @@ import type { Address, Chain, Hex } from 'viem' import { CrossedBooksResolver } from '@repo/contracts' -import { getAddress, isAddress, isHex, parseGwei } from 'viem' +import { getAddress, isAddress, isAddressEqual, isHex, parseGwei, zeroAddress } from 'viem' import { base } from 'viem/chains' import { DEFAULT_MAX_MATCHES } from '../domain/matching.service' -import { InvalidConfigurationError } from './invalid-configuration.error' +import { InvalidSimulationCallerAddressError } from './invalid-simulation-caller-address.error' import { parseReadonly } from './readonly.utils' import { ResolverPrivateKeyRequiredError } from './resolver-private-key-required.error' @@ -59,13 +59,14 @@ export class ConfigService { } const simulationCaller = environment.SIMULATION_CALLER_ADDRESS?.trim() - if (readOnly && !simulationCaller) { - throw new InvalidConfigurationError( - 'Readonly mode requires SIMULATION_CALLER_ADDRESS to preserve execution caller semantics' - ) - } - if (simulationCaller && !isAddress(simulationCaller, { strict: false })) { - throw new InvalidConfigurationError('SIMULATION_CALLER_ADDRESS must be an EVM address') + if ( + (readOnly && !simulationCaller) || + (simulationCaller + ? !isAddress(simulationCaller, { strict: false }) || + isAddressEqual(getAddress(simulationCaller), zeroAddress) + : false) + ) { + throw new InvalidSimulationCallerAddressError() } const apiBaseUrl = (environment.API_BASE_URL?.trim() || 'https://api.morpho.org').replace( diff --git a/bots/midnight-crossed-books/src/config/invalid-simulation-caller-address.error.ts b/bots/midnight-crossed-books/src/config/invalid-simulation-caller-address.error.ts new file mode 100644 index 00000000..1c2ce37b --- /dev/null +++ b/bots/midnight-crossed-books/src/config/invalid-simulation-caller-address.error.ts @@ -0,0 +1,10 @@ +import { InvalidConfigurationError } from './invalid-configuration.error' + +/** Raised when a simulation caller is missing, malformed, or the zero address. */ +export class InvalidSimulationCallerAddressError extends InvalidConfigurationError { + /** Creates a credential-free caller validation failure. */ + constructor() { + super('SIMULATION_CALLER_ADDRESS must be a public non-zero EVM address') + this.name = 'InvalidSimulationCallerAddressError' + } +} diff --git a/bots/midnight-crossed-books/test/config/config.service.test.ts b/bots/midnight-crossed-books/test/config/config.service.test.ts index f867e484..97761ad8 100644 --- a/bots/midnight-crossed-books/test/config/config.service.test.ts +++ b/bots/midnight-crossed-books/test/config/config.service.test.ts @@ -2,6 +2,7 @@ import { describe, expect, test } from 'vitest' import { ConfigService } from '../../src/config/config.service' import { InvalidConfigurationError } from '../../src/config/invalid-configuration.error' +import { InvalidSimulationCallerAddressError } from '../../src/config/invalid-simulation-caller-address.error' import { ResolverPrivateKeyRequiredError } from '../../src/config/resolver-private-key-required.error' const KEY = `0x${'11'.repeat(32)}` @@ -75,6 +76,17 @@ describe('ConfigService', () => { ).toThrow(InvalidConfigurationError) }) + test('rejects the zero simulation caller address with a named error', () => { + expect(() => + ConfigService.from({ + CHAIN_ID: '8453', + RPC_URL: 'http://rpc.example', + READONLY: 'true', + SIMULATION_CALLER_ADDRESS: '0x0000000000000000000000000000000000000000' + }) + ).toThrow(InvalidSimulationCallerAddressError) + }) + test('requires a resolver private key in normal mode with a named error', () => { expect(() => ConfigService.from({ CHAIN_ID: '8453', RPC_URL: 'http://rpc.example' })).toThrow( ResolverPrivateKeyRequiredError diff --git a/bots/midnight-crossed-books/test/scripts/railway.test.ts b/bots/midnight-crossed-books/test/scripts/railway.test.ts index f8d15e87..1e8472fe 100644 --- a/bots/midnight-crossed-books/test/scripts/railway.test.ts +++ b/bots/midnight-crossed-books/test/scripts/railway.test.ts @@ -1,16 +1,25 @@ import { readFileSync } from 'node:fs' -import { describe, expect, test } from 'vitest' +import { describe, expect, test, vi } from 'vitest' import { parseLatestStatus, parseServices, - resolveProvisioningConfiguration + parseVariableKeys, + resolveProvisioningConfiguration, + synchronizeModeVariables } from '../../scripts/railway' import { InvalidConfigurationError } from '../../src/config/invalid-configuration.error' +import { InvalidSimulationCallerAddressError } from '../../src/config/invalid-simulation-caller-address.error' import { ResolverPrivateKeyRequiredError } from '../../src/config/resolver-private-key-required.error' const KEY = `0x${'11'.repeat(32)}` -const CALLER = `0x${'22'.repeat(20)}` +const CALLER: `0x${string}` = `0x${'22'.repeat(20)}` + +const operations = () => ({ + deleteVariable: vi.fn().mockResolvedValue(undefined), + setSecret: vi.fn().mockResolvedValue(undefined), + setVariable: vi.fn().mockResolvedValue(undefined) +}) describe('Railway provisioning configuration', () => { test('supports keyless readonly provisioning with an execution-equivalent caller', () => { @@ -46,17 +55,121 @@ describe('Railway provisioning configuration', () => { expect(() => resolveProvisioningConfiguration(environment)).toThrow(message) }) - test('wires readonly mode and caller into first-time Railway provisioning', () => { + test('rejects the zero simulation caller address with a named error', () => { + expect(() => + resolveProvisioningConfiguration({ + READONLY: 'true', + SIMULATION_CALLER_ADDRESS: '0x0000000000000000000000000000000000000000' + }) + ).toThrow(InvalidSimulationCallerAddressError) + }) + + test('removes a stale private key before switching to readonly mode', async () => { + const railway = operations() + + await synchronizeModeVariables( + { readOnly: true, resolverPrivateKey: undefined, simulationCaller: CALLER }, + new Set(['RESOLVER_PRIVATE_KEY']), + railway + ) + + expect(railway.deleteVariable).toHaveBeenCalledExactlyOnceWith('RESOLVER_PRIVATE_KEY') + expect(railway.setSecret).not.toHaveBeenCalled() + expect(railway.setVariable.mock.calls).toEqual([ + [`SIMULATION_CALLER_ADDRESS=${CALLER}`], + ['READONLY=true'] + ]) + }) + + test('removes a stale simulation caller before switching to write mode', async () => { + const railway = operations() + + await synchronizeModeVariables( + { readOnly: false, resolverPrivateKey: KEY, simulationCaller: undefined }, + new Set(['SIMULATION_CALLER_ADDRESS']), + railway + ) + + expect(railway.deleteVariable).toHaveBeenCalledExactlyOnceWith('SIMULATION_CALLER_ADDRESS') + expect(railway.setSecret).toHaveBeenCalledExactlyOnceWith('RESOLVER_PRIVATE_KEY', KEY) + expect(railway.setVariable).toHaveBeenCalledExactlyOnceWith('READONLY=false') + }) + + test('does not change readonly mode when stale-key deletion fails', async () => { + const railway = operations() + railway.deleteVariable.mockRejectedValue(new Error('delete failed')) + const config = resolveProvisioningConfiguration({ + READONLY: 'true', + SIMULATION_CALLER_ADDRESS: CALLER + }) + + await expect( + synchronizeModeVariables(config, new Set(['RESOLVER_PRIVATE_KEY']), railway) + ).rejects.toThrow('delete failed') + expect(railway.setSecret).not.toHaveBeenCalled() + expect(railway.setVariable).not.toHaveBeenCalled() + }) + + test('does not change write mode when stale-caller deletion fails', async () => { + const railway = operations() + railway.deleteVariable.mockRejectedValue(new Error('delete failed')) + const config = resolveProvisioningConfiguration({ RESOLVER_PRIVATE_KEY: KEY }) + + await expect( + synchronizeModeVariables(config, new Set(['SIMULATION_CALLER_ADDRESS']), railway) + ).rejects.toThrow('delete failed') + expect(railway.setSecret).not.toHaveBeenCalled() + expect(railway.setVariable).not.toHaveBeenCalled() + }) + + test('provisions a new readonly service without attempting an absent-key deletion', async () => { + const railway = operations() + + await synchronizeModeVariables( + { readOnly: true, resolverPrivateKey: undefined, simulationCaller: CALLER }, + new Set(), + railway + ) + + expect(railway.deleteVariable).not.toHaveBeenCalled() + expect(railway.setVariable).toHaveBeenCalledTimes(2) + }) + + test('provisions a new write service without attempting an absent-caller deletion', async () => { + const railway = operations() + + await synchronizeModeVariables( + { readOnly: false, resolverPrivateKey: KEY, simulationCaller: undefined }, + new Set(), + railway + ) + + expect(railway.deleteVariable).not.toHaveBeenCalled() + expect(railway.setSecret).toHaveBeenCalledExactlyOnceWith('RESOLVER_PRIVATE_KEY', KEY) + expect(railway.setVariable).toHaveBeenCalledExactlyOnceWith('READONLY=false') + }) + + test('wires fail-closed mode synchronization into Railway provisioning', () => { const deploy = readFileSync(new URL('../../scripts/deploy-railway.ts', import.meta.url), 'utf8') expect(deploy).toMatch(/resolveProvisioningConfiguration\(\s*process\.env\s*\)/) - expect(deploy).toContain('await setVariable(`READONLY=${readOnly}`)') - expect(deploy).toContain('await setVariable(`SIMULATION_CALLER_ADDRESS=${simulationCaller}`)') - expect(deploy).toContain("if (resolverPrivateKey) await setSecret('RESOLVER_PRIVATE_KEY'") + expect(deploy).toContain('await listVariableKeys()') + expect(deploy).toContain('await synchronizeModeVariables(') + expect(deploy).toContain('deleteVariable') }) }) describe('Railway CLI output parsing', () => { + test('extracts variable names without exposing values', () => { + expect(parseVariableKeys('{"RESOLVER_PRIVATE_KEY":"secret","READONLY":"true"}')).toEqual( + new Set(['RESOLVER_PRIVATE_KEY', 'READONLY']) + ) + }) + + test.each(['not-json', '[]', 'null'])('rejects an unsafe variable list response %#', raw => { + expect(() => parseVariableKeys(raw)).toThrow('Railway returned an invalid variable list') + }) + test('parses service arrays and ignores nameless entries', () => { const raw = JSON.stringify([{ name: 'bot' }, { id: 'missing-name' }]) From 97fa2d5c71d88b7c40b69d888b59bd141478a25f Mon Sep 17 00:00:00 2001 From: "prd-carapulse[bot]" <264278285+prd-carapulse[bot]@users.noreply.github.com> Date: Wed, 12 Aug 2026 15:04:05 +0000 Subject: [PATCH 06/16] fix(midnight-crossed-books): scope Railway variable mutations Co-authored-by: Julien Thomas <61523188+julien-devatom@users.noreply.github.com> --- .../scripts/deploy-railway.ts | 14 ++-- .../midnight-crossed-books/scripts/railway.ts | 56 +++++++++++++ .../test/scripts/railway.test.ts | 81 ++++++++++++++++++- 3 files changed, 143 insertions(+), 8 deletions(-) diff --git a/bots/midnight-crossed-books/scripts/deploy-railway.ts b/bots/midnight-crossed-books/scripts/deploy-railway.ts index 5283e970..6a9733ab 100644 --- a/bots/midnight-crossed-books/scripts/deploy-railway.ts +++ b/bots/midnight-crossed-books/scripts/deploy-railway.ts @@ -13,6 +13,9 @@ import { parseLatestStatus, parseServices, parseVariableKeys, + railwayVariableDeleteArgs, + railwayVariableListArgs, + railwayVariableSetArgs, resolveProvisioningConfiguration, synchronizeModeVariables } from './railway' @@ -21,6 +24,7 @@ import { RailwayVariableOperationError } from './railway-variable-operation.erro const PROJECT_ID = required(process.env, 'RAILWAY_PROJECT_ID') const ENVIRONMENT = process.env.RAILWAY_ENVIRONMENT?.trim() || 'production' const SERVICE = ENVIRONMENT === 'production' ? 'bot' : `${ENVIRONMENT}-bot` +const VARIABLE_TARGET = { environment: ENVIRONMENT, projectId: PROJECT_ID, service: SERVICE } const DOCKERFILE_PATH = 'bots/midnight-crossed-books/Dockerfile' const REPO_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..', '..', '..') const DEPLOY_ONLY = /^(1|true)$/i.test(process.env.DEPLOY_ONLY?.trim() || '') @@ -99,14 +103,14 @@ async function ensureService() { async function setVariable(value: string) { const key = value.split('=')[0] - const { error } = await tryCatch($`railway variable set ${value} -s ${SERVICE} --skip-deploys`) + const { error } = await tryCatch($('railway', railwayVariableSetArgs(value, VARIABLE_TARGET))) if (error) throw new Error(`Failed to set ${key} on ${SERVICE}: ${errorDetails(error)}`) console.log(`Set ${key} on ${SERVICE}.`) } async function setSecret(name: string, value: string) { const { error } = await tryCatch( - $({ input: value })`railway variable set ${name} --stdin -s ${SERVICE} --skip-deploys` + $({ input: value })('railway', railwayVariableSetArgs(name, VARIABLE_TARGET, { stdin: true })) ) if (error) throw new Error(`Failed to set ${name} on ${SERVICE}`) console.log(`Set ${name} on ${SERVICE} (secret).`) @@ -114,16 +118,14 @@ async function setSecret(name: string, value: string) { const listVariableKeys = async () => { const { data, error } = await tryCatch( - $`railway variable list -s ${SERVICE} -e ${ENVIRONMENT} -p ${PROJECT_ID} --json`.then( - result => result.stdout - ) + $('railway', railwayVariableListArgs(VARIABLE_TARGET)).then(result => result.stdout) ) if (error || typeof data !== 'string') throw new RailwayVariableOperationError('list') return parseVariableKeys(data) } const deleteVariable = async (name: string) => { - const { error } = await tryCatch($`railway variable delete ${name} -s ${SERVICE} --skip-deploys`) + const { error } = await tryCatch($('railway', railwayVariableDeleteArgs(name, VARIABLE_TARGET))) if (error) throw new RailwayVariableOperationError('delete', name) console.log(`Deleted ${name} on ${SERVICE} (stale).`) } diff --git a/bots/midnight-crossed-books/scripts/railway.ts b/bots/midnight-crossed-books/scripts/railway.ts index fd27bf63..17382e3a 100644 --- a/bots/midnight-crossed-books/scripts/railway.ts +++ b/bots/midnight-crossed-books/scripts/railway.ts @@ -9,6 +9,7 @@ import { InvalidRailwayVariableListError } from './invalid-railway-variable-list type RailwayService = { name: string } type Env = Record +type RailwayVariableTarget = { environment: string; projectId: string; service: string } type ProvisioningConfiguration = | { readOnly: true; resolverPrivateKey: undefined; simulationCaller: `0x${string}` } | { readOnly: false; resolverPrivateKey: string; simulationCaller: undefined } @@ -18,6 +19,61 @@ type ModeVariableOperations = { setVariable: (value: string) => Promise } +const railwayVariableTargetArgs = (target: RailwayVariableTarget) => [ + '-s', + target.service, + '-e', + target.environment, + '-p', + target.projectId +] + +/** + * Builds arguments for listing variables on one Railway deployment target. + * @param target - Project, environment, and service that must own the listed variables. + * @returns CLI arguments including explicit service, environment, project, and JSON-output flags. + */ +export const railwayVariableListArgs = (target: RailwayVariableTarget) => [ + 'variable', + 'list', + ...railwayVariableTargetArgs(target), + '--json' +] + +/** + * Builds arguments for deleting one variable from one Railway deployment target. + * @param name - Variable name to remove; a value is never accepted by this command builder. + * @param target - Project, environment, and service that must own the deleted variable. + * @returns CLI arguments including the name and explicit service, environment, and project flags. + */ +export const railwayVariableDeleteArgs = (name: string, target: RailwayVariableTarget) => [ + 'variable', + 'delete', + name, + ...railwayVariableTargetArgs(target) +] + +/** + * Builds arguments for setting one variable on one Railway deployment target. + * @param value - Public `KEY=VALUE` assignment, or only a variable name when stdin is enabled. + * @param target - Project, environment, and service that must receive the variable. + * @param options - Enables secret-safe stdin input without placing the value in command arguments. + * @returns CLI arguments including explicit service, environment, project, and no-deploy flags. + * @remarks Secret values remain on stdin when `stdin` is true; only the variable name enters args. + */ +export const railwayVariableSetArgs = ( + value: string, + target: RailwayVariableTarget, + { stdin = false }: { stdin?: boolean } = {} +) => [ + 'variable', + 'set', + value, + ...(stdin ? ['--stdin'] : []), + ...railwayVariableTargetArgs(target), + '--skip-deploys' +] + /** * Validates mode-specific Railway provisioning values without retaining unused signing material. * @param env - Local deploy environment containing mode, caller, and optional signing key. diff --git a/bots/midnight-crossed-books/test/scripts/railway.test.ts b/bots/midnight-crossed-books/test/scripts/railway.test.ts index 1e8472fe..8cb34fc7 100644 --- a/bots/midnight-crossed-books/test/scripts/railway.test.ts +++ b/bots/midnight-crossed-books/test/scripts/railway.test.ts @@ -5,6 +5,9 @@ import { parseLatestStatus, parseServices, parseVariableKeys, + railwayVariableDeleteArgs, + railwayVariableListArgs, + railwayVariableSetArgs, resolveProvisioningConfiguration, synchronizeModeVariables } from '../../scripts/railway' @@ -14,6 +17,7 @@ import { ResolverPrivateKeyRequiredError } from '../../src/config/resolver-priva const KEY = `0x${'11'.repeat(32)}` const CALLER: `0x${string}` = `0x${'22'.repeat(20)}` +const TARGET = { environment: 'production', projectId: 'project-id', service: 'bot' } const operations = () => ({ deleteVariable: vi.fn().mockResolvedValue(undefined), @@ -64,8 +68,12 @@ describe('Railway provisioning configuration', () => { ).toThrow(InvalidSimulationCallerAddressError) }) - test('removes a stale private key before switching to readonly mode', async () => { + test('removes a stale private key from the exact target before readonly mode', async () => { const railway = operations() + const commands: string[][] = [] + railway.deleteVariable.mockImplementation(async name => { + commands.push(railwayVariableDeleteArgs(name, TARGET)) + }) await synchronizeModeVariables( { readOnly: true, resolverPrivateKey: undefined, simulationCaller: CALLER }, @@ -74,6 +82,19 @@ describe('Railway provisioning configuration', () => { ) expect(railway.deleteVariable).toHaveBeenCalledExactlyOnceWith('RESOLVER_PRIVATE_KEY') + expect(commands).toEqual([ + [ + 'variable', + 'delete', + 'RESOLVER_PRIVATE_KEY', + '-s', + 'bot', + '-e', + 'production', + '-p', + 'project-id' + ] + ]) expect(railway.setSecret).not.toHaveBeenCalled() expect(railway.setVariable.mock.calls).toEqual([ [`SIMULATION_CALLER_ADDRESS=${CALLER}`], @@ -81,8 +102,12 @@ describe('Railway provisioning configuration', () => { ]) }) - test('removes a stale simulation caller before switching to write mode', async () => { + test('removes a stale caller from the exact target before write mode', async () => { const railway = operations() + const commands: string[][] = [] + railway.deleteVariable.mockImplementation(async name => { + commands.push(railwayVariableDeleteArgs(name, TARGET)) + }) await synchronizeModeVariables( { readOnly: false, resolverPrivateKey: KEY, simulationCaller: undefined }, @@ -91,6 +116,19 @@ describe('Railway provisioning configuration', () => { ) expect(railway.deleteVariable).toHaveBeenCalledExactlyOnceWith('SIMULATION_CALLER_ADDRESS') + expect(commands).toEqual([ + [ + 'variable', + 'delete', + 'SIMULATION_CALLER_ADDRESS', + '-s', + 'bot', + '-e', + 'production', + '-p', + 'project-id' + ] + ]) expect(railway.setSecret).toHaveBeenCalledExactlyOnceWith('RESOLVER_PRIVATE_KEY', KEY) expect(railway.setVariable).toHaveBeenCalledExactlyOnceWith('READONLY=false') }) @@ -157,6 +195,45 @@ describe('Railway provisioning configuration', () => { expect(deploy).toContain('await synchronizeModeVariables(') expect(deploy).toContain('deleteVariable') }) + + test('explicitly scopes variable listing and setting to the target', () => { + expect(railwayVariableListArgs(TARGET)).toEqual([ + 'variable', + 'list', + '-s', + 'bot', + '-e', + 'production', + '-p', + 'project-id', + '--json' + ]) + expect(railwayVariableSetArgs('READONLY=true', TARGET)).toEqual([ + 'variable', + 'set', + 'READONLY=true', + '-s', + 'bot', + '-e', + 'production', + '-p', + 'project-id', + '--skip-deploys' + ]) + expect(railwayVariableSetArgs('RPC_URL', TARGET, { stdin: true })).toEqual([ + 'variable', + 'set', + 'RPC_URL', + '--stdin', + '-s', + 'bot', + '-e', + 'production', + '-p', + 'project-id', + '--skip-deploys' + ]) + }) }) describe('Railway CLI output parsing', () => { From 471dc1830fd97f50a5b0f485205e1ad8d24c9390 Mon Sep 17 00:00:00 2001 From: "prd-carapulse[bot]" <264278285+prd-carapulse[bot]@users.noreply.github.com> Date: Wed, 12 Aug 2026 15:45:42 +0000 Subject: [PATCH 07/16] fix(midnight-crossed-books): avoid secret variable listing Co-authored-by: Julien Thomas <61523188+julien-devatom@users.noreply.github.com> --- bots/midnight-crossed-books/README.md | 5 +- .../scripts/deploy-railway.ts | 31 +-- .../railway-access-token-required.error.ts | 8 + .../railway-variable-operation.error.ts | 2 +- .../midnight-crossed-books/scripts/railway.ts | 253 +++++++++++++++--- .../test/scripts/railway.test.ts | 244 ++++++++++------- 6 files changed, 389 insertions(+), 154 deletions(-) create mode 100644 bots/midnight-crossed-books/scripts/railway-access-token-required.error.ts diff --git a/bots/midnight-crossed-books/README.md b/bots/midnight-crossed-books/README.md index 2bce8c16..1834ca8b 100644 --- a/bots/midnight-crossed-books/README.md +++ b/bots/midnight-crossed-books/README.md @@ -100,7 +100,10 @@ pnpm --filter @morpho-org/midnight-crossed-books run deploy:railway The deploy script validates and propagates `READONLY` and `SIMULATION_CALLER_ADDRESS`; it does not require or install `RESOLVER_PRIVATE_KEY` in readonly mode. It also removes a stale private key when switching to readonly and removes a stale simulation caller when switching to write mode, aborting -before the mode change if deletion fails. Write mode still requires a valid key. +before the mode change if deletion fails. Deletion uses `RAILWAY_TOKEN` (or `RAILWAY_API_TOKEN`) with +Railway's key-only variable metadata and an explicitly project/environment/service/name-scoped +mutation; it never runs `railway variable list` or retrieves variable values. Write mode still +requires a valid key. CI subsequently runs the same command with `DEPLOY_ONLY=true`, so GitHub holds only a project/environment-scoped Railway token. Pushes to `main` deploy staging through the diff --git a/bots/midnight-crossed-books/scripts/deploy-railway.ts b/bots/midnight-crossed-books/scripts/deploy-railway.ts index 6a9733ab..9d8adc90 100644 --- a/bots/midnight-crossed-books/scripts/deploy-railway.ts +++ b/bots/midnight-crossed-books/scripts/deploy-railway.ts @@ -10,12 +10,11 @@ import { dirname, resolve } from 'node:path' import { fileURLToPath } from 'node:url' import { + deleteRailwayVariable, parseLatestStatus, parseServices, - parseVariableKeys, - railwayVariableDeleteArgs, - railwayVariableListArgs, railwayVariableSetArgs, + resolveRailwayAccessToken, resolveProvisioningConfiguration, synchronizeModeVariables } from './railway' @@ -104,7 +103,7 @@ async function ensureService() { async function setVariable(value: string) { const key = value.split('=')[0] const { error } = await tryCatch($('railway', railwayVariableSetArgs(value, VARIABLE_TARGET))) - if (error) throw new Error(`Failed to set ${key} on ${SERVICE}: ${errorDetails(error)}`) + if (error) throw new RailwayVariableOperationError('set', key) console.log(`Set ${key} on ${SERVICE}.`) } @@ -112,22 +111,20 @@ async function setSecret(name: string, value: string) { const { error } = await tryCatch( $({ input: value })('railway', railwayVariableSetArgs(name, VARIABLE_TARGET, { stdin: true })) ) - if (error) throw new Error(`Failed to set ${name} on ${SERVICE}`) + if (error) throw new RailwayVariableOperationError('set', name) console.log(`Set ${name} on ${SERVICE} (secret).`) } -const listVariableKeys = async () => { - const { data, error } = await tryCatch( - $('railway', railwayVariableListArgs(VARIABLE_TARGET)).then(result => result.stdout) - ) - if (error || typeof data !== 'string') throw new RailwayVariableOperationError('list') - return parseVariableKeys(data) -} - const deleteVariable = async (name: string) => { - const { error } = await tryCatch($('railway', railwayVariableDeleteArgs(name, VARIABLE_TARGET))) - if (error) throw new RailwayVariableOperationError('delete', name) - console.log(`Deleted ${name} on ${SERVICE} (stale).`) + const deleted = await deleteRailwayVariable({ + fetcher: fetch, + name, + target: VARIABLE_TARGET, + token: resolveRailwayAccessToken(process.env) + }) + console.log( + deleted ? `Deleted ${name} on ${SERVICE} (stale).` : `${name} is already absent on ${SERVICE}.` + ) } async function deployService() { @@ -189,7 +186,7 @@ if (DEPLOY_ONLY) { await ensureContext() await ensureService() await setVariable('CHAIN_ID=8453') - await synchronizeModeVariables(config, await listVariableKeys(), { + await synchronizeModeVariables(config, { deleteVariable, setSecret, setVariable diff --git a/bots/midnight-crossed-books/scripts/railway-access-token-required.error.ts b/bots/midnight-crossed-books/scripts/railway-access-token-required.error.ts new file mode 100644 index 00000000..ea46f81d --- /dev/null +++ b/bots/midnight-crossed-books/scripts/railway-access-token-required.error.ts @@ -0,0 +1,8 @@ +/** Raised when secret-safe Railway API operations have no supported API credential. */ +export class RailwayAccessTokenRequiredError extends Error { + /** Creates a credential-free provisioning failure. */ + constructor() { + super('RAILWAY_TOKEN or RAILWAY_API_TOKEN is required for safe Railway variable deletion') + this.name = 'RailwayAccessTokenRequiredError' + } +} diff --git a/bots/midnight-crossed-books/scripts/railway-variable-operation.error.ts b/bots/midnight-crossed-books/scripts/railway-variable-operation.error.ts index ce70e507..499a3fa2 100644 --- a/bots/midnight-crossed-books/scripts/railway-variable-operation.error.ts +++ b/bots/midnight-crossed-books/scripts/railway-variable-operation.error.ts @@ -1,4 +1,4 @@ -type RailwayVariableOperation = 'delete' | 'list' +type RailwayVariableOperation = 'delete' | 'set' /** Raised when a Railway variable operation fails without exposing variable values. */ export class RailwayVariableOperationError extends Error { diff --git a/bots/midnight-crossed-books/scripts/railway.ts b/bots/midnight-crossed-books/scripts/railway.ts index 17382e3a..79b4dce3 100644 --- a/bots/midnight-crossed-books/scripts/railway.ts +++ b/bots/midnight-crossed-books/scripts/railway.ts @@ -6,10 +6,17 @@ import { InvalidSimulationCallerAddressError } from '../src/config/invalid-simul import { parseReadonly } from '../src/config/readonly.utils' import { ResolverPrivateKeyRequiredError } from '../src/config/resolver-private-key-required.error' import { InvalidRailwayVariableListError } from './invalid-railway-variable-list.error' +import { RailwayAccessTokenRequiredError } from './railway-access-token-required.error' +import { RailwayVariableOperationError } from './railway-variable-operation.error' type RailwayService = { name: string } type Env = Record type RailwayVariableTarget = { environment: string; projectId: string; service: string } +type RailwayAccessToken = { + header: 'authorization' | 'project-access-token' + value: string +} +type RailwayVariableTargetIds = { environmentId: string; serviceId: string } type ProvisioningConfiguration = | { readOnly: true; resolverPrivateKey: undefined; simulationCaller: `0x${string}` } | { readOnly: false; resolverPrivateKey: string; simulationCaller: undefined } @@ -29,29 +36,215 @@ const railwayVariableTargetArgs = (target: RailwayVariableTarget) => [ ] /** - * Builds arguments for listing variables on one Railway deployment target. - * @param target - Project, environment, and service that must own the listed variables. - * @returns CLI arguments including explicit service, environment, project, and JSON-output flags. + * Resolves an API credential without exposing it in command arguments or logs. + * @param env - Deploy environment containing a project or account Railway token. + * @returns The supported Railway API authentication header and value. + * @throws `RailwayAccessTokenRequiredError` when no API credential is available. */ -export const railwayVariableListArgs = (target: RailwayVariableTarget) => [ - 'variable', - 'list', - ...railwayVariableTargetArgs(target), - '--json' -] +export const resolveRailwayAccessToken = (env: Env): RailwayAccessToken => { + const projectToken = env.RAILWAY_TOKEN?.trim() + if (projectToken) return { header: 'project-access-token', value: projectToken } + + const apiToken = env.RAILWAY_API_TOKEN?.trim() + if (apiToken) return { header: 'authorization', value: `Bearer ${apiToken}` } + throw new RailwayAccessTokenRequiredError() +} + +const RAILWAY_GRAPHQL_ENDPOINT = 'https://backboard.railway.com/graphql/v2' +const TARGET_QUERY = `query RailwayVariableTarget($projectId: String!) { + project(id: $projectId) { + environments { edges { node { id name } } } + services { edges { node { id name } } } + } +}` +const VARIABLE_METADATA_QUERY = `query RailwayVariableMetadata( + $projectId: String! + $environmentId: String! + $after: String +) { + environment(id: $environmentId, projectId: $projectId) { + variables(first: 100, after: $after) { + edges { node { name serviceId } } + pageInfo { endCursor hasNextPage } + } + } +}` +const VARIABLE_DELETE_MUTATION = `mutation RailwayVariableDelete( + $projectId: String! + $environmentId: String! + $serviceId: String! + $name: String! +) { + variableDelete(input: { + projectId: $projectId + environmentId: $environmentId + serviceId: $serviceId + name: $name + }) +}` + +const recordField = (value: unknown, field: string) => + isRecord(value) && isRecord(value[field]) ? value[field] : undefined + +const edgesOf = (value: unknown) => { + const edges = isRecord(value) ? value.edges : undefined + return Array.isArray(edges) ? edges : [] +} + +const postRailwayGraphql = async ({ + body, + error, + fetcher, + token +}: { + body: { query: string; variables: Record } + error: RailwayVariableOperationError + fetcher: typeof fetch + token: RailwayAccessToken +}) => { + const result = await tryCatch( + fetcher(RAILWAY_GRAPHQL_ENDPOINT, { + body: JSON.stringify(body), + headers: { 'content-type': 'application/json', [token.header]: token.value }, + method: 'POST' + }) + ) + if (result.error || !result.data.ok) throw error + + const json = await tryCatch(result.data.json()) + if (json.error || !isRecord(json.data) || Array.isArray(json.data.errors)) throw error + return json.data.data +} + +const resolveRailwayVariableTarget = async ({ + error, + fetcher, + target, + token +}: { + error: RailwayVariableOperationError + fetcher: typeof fetch + target: RailwayVariableTarget + token: RailwayAccessToken +}): Promise => { + const data = await postRailwayGraphql({ + body: { query: TARGET_QUERY, variables: { projectId: target.projectId } }, + error, + fetcher, + token + }) + const project = recordField(data, 'project') + const environment = edgesOf(recordField(project, 'environments')) + .map(edge => recordField(edge, 'node')) + .find(node => node?.name === target.environment) + const service = edgesOf(recordField(project, 'services')) + .map(edge => recordField(edge, 'node')) + .find(node => node?.name === target.service) + if (typeof environment?.id !== 'string' || typeof service?.id !== 'string') throw error + return { environmentId: environment.id, serviceId: service.id } +} + +const railwayVariableExists = async ({ + error, + fetcher, + ids, + name, + target, + token +}: { + error: RailwayVariableOperationError + fetcher: typeof fetch + ids: RailwayVariableTargetIds + name: string + target: RailwayVariableTarget + token: RailwayAccessToken +}) => { + let after: string | null = null + do { + const data = await postRailwayGraphql({ + body: { + query: VARIABLE_METADATA_QUERY, + variables: { + after, + environmentId: ids.environmentId, + projectId: target.projectId + } + }, + error, + fetcher, + token + }) + const variables = recordField(recordField(data, 'environment'), 'variables') + const pageInfo = recordField(variables, 'pageInfo') + if ( + !variables || + !Array.isArray(variables.edges) || + !pageInfo || + typeof pageInfo.hasNextPage !== 'boolean' + ) { + throw new InvalidRailwayVariableListError() + } + const nodes = variables.edges.map(edge => recordField(edge, 'node')) + if ( + nodes.some( + node => + typeof node?.name !== 'string' || + (node.serviceId !== null && typeof node.serviceId !== 'string') + ) + ) { + throw new InvalidRailwayVariableListError() + } + const found = nodes.some(node => node?.name === name && node.serviceId === ids.serviceId) + if (found) return true + + if (!pageInfo.hasNextPage) return false + if (typeof pageInfo.endCursor !== 'string' || !pageInfo.endCursor) throw error + after = pageInfo.endCursor + } while (after) + return false +} /** - * Builds arguments for deleting one variable from one Railway deployment target. - * @param name - Variable name to remove; a value is never accepted by this command builder. - * @param target - Project, environment, and service that must own the deleted variable. - * @returns CLI arguments including the name and explicit service, environment, and project flags. + * Idempotently deletes one Railway variable without retrieving variable values. + * @param parameters - Fetch implementation, credential, exact target names, and variable name. + * @returns `true` when a variable was deleted, or `false` when key-only metadata proved it absent. + * @throws `RailwayVariableOperationError` when target lookup, metadata transport, or deletion fails. + * @throws `InvalidRailwayVariableListError` when key-only metadata has an unsafe shape. + * @remarks Requests only project/environment/service IDs and paginated key metadata before issuing + * an explicitly project-, environment-, service-, and name-scoped delete mutation. */ -export const railwayVariableDeleteArgs = (name: string, target: RailwayVariableTarget) => [ - 'variable', - 'delete', +export const deleteRailwayVariable = async ({ + fetcher, name, - ...railwayVariableTargetArgs(target) -] + target, + token +}: { + fetcher: typeof fetch + name: string + target: RailwayVariableTarget + token: RailwayAccessToken +}) => { + const error = new RailwayVariableOperationError('delete', name) + const ids = await resolveRailwayVariableTarget({ error, fetcher, target, token }) + if (!(await railwayVariableExists({ error, fetcher, ids, name, target, token }))) return false + + const data = await postRailwayGraphql({ + body: { + query: VARIABLE_DELETE_MUTATION, + variables: { + environmentId: ids.environmentId, + name, + projectId: target.projectId, + serviceId: ids.serviceId + } + }, + error, + fetcher, + token + }) + if (!isRecord(data) || data.variableDelete !== true) throw error + return true +} /** * Builds arguments for setting one variable on one Railway deployment target. @@ -110,7 +303,6 @@ export const resolveProvisioningConfiguration = (env: Env): ProvisioningConfigur /** * Synchronizes Railway's mutually exclusive mode variables before changing the active mode. * @param config - Validated mode-specific provisioning values. - * @param existingKeys - Variable names currently installed on the target service. * @param operations - Secret-safe Railway variable mutation operations. * @returns A promise that resolves after incompatible variables are removed and the mode is set. * @throws The underlying mutation error; mode is not changed when incompatible-variable deletion @@ -119,37 +311,18 @@ export const resolveProvisioningConfiguration = (env: Env): ProvisioningConfigur */ export const synchronizeModeVariables = async ( config: ReturnType, - existingKeys: ReadonlySet, operations: ModeVariableOperations ) => { if (config.readOnly) { - if (existingKeys.has('RESOLVER_PRIVATE_KEY')) { - await operations.deleteVariable('RESOLVER_PRIVATE_KEY') - } + await operations.deleteVariable('RESOLVER_PRIVATE_KEY') await operations.setVariable(`SIMULATION_CALLER_ADDRESS=${config.simulationCaller}`) } else { - if (existingKeys.has('SIMULATION_CALLER_ADDRESS')) { - await operations.deleteVariable('SIMULATION_CALLER_ADDRESS') - } + await operations.deleteVariable('SIMULATION_CALLER_ADDRESS') await operations.setSecret('RESOLVER_PRIVATE_KEY', config.resolverPrivateKey) } await operations.setVariable(`READONLY=${config.readOnly}`) } -/** - * Reads only variable names from Railway's JSON response. - * @param raw - Raw JSON emitted by `railway variable list --json`. - * @returns Installed variable names without retaining their values. - * @throws `InvalidRailwayVariableListError` when Railway returns malformed or unexpected JSON. - */ -export const parseVariableKeys = (raw: string) => { - const { data, error } = tryCatch(() => JSON.parse(raw) as unknown) - if (error || !isRecord(data) || Array.isArray(data)) { - throw new InvalidRailwayVariableListError() - } - return new Set(Object.keys(data)) -} - function isRecord(value: unknown): value is Record { return typeof value === 'object' && value !== null } diff --git a/bots/midnight-crossed-books/test/scripts/railway.test.ts b/bots/midnight-crossed-books/test/scripts/railway.test.ts index 8cb34fc7..eb02c7be 100644 --- a/bots/midnight-crossed-books/test/scripts/railway.test.ts +++ b/bots/midnight-crossed-books/test/scripts/railway.test.ts @@ -2,12 +2,11 @@ import { readFileSync } from 'node:fs' import { describe, expect, test, vi } from 'vitest' import { + deleteRailwayVariable, parseLatestStatus, parseServices, - parseVariableKeys, - railwayVariableDeleteArgs, - railwayVariableListArgs, railwayVariableSetArgs, + resolveRailwayAccessToken, resolveProvisioningConfiguration, synchronizeModeVariables } from '../../scripts/railway' @@ -18,6 +17,30 @@ import { ResolverPrivateKeyRequiredError } from '../../src/config/resolver-priva const KEY = `0x${'11'.repeat(32)}` const CALLER: `0x${string}` = `0x${'22'.repeat(20)}` const TARGET = { environment: 'production', projectId: 'project-id', service: 'bot' } +const TOKEN = { header: 'project-access-token' as const, value: 'railway-token' } + +const jsonResponse = (body: unknown, status = 200) => + new Response(JSON.stringify(body), { headers: { 'content-type': 'application/json' }, status }) + +const targetResponse = { + data: { + project: { + environments: { edges: [{ node: { id: 'environment-id', name: 'production' } }] }, + services: { edges: [{ node: { id: 'service-id', name: 'bot' } }] } + } + } +} + +const metadataResponse = (names: string[]) => ({ + data: { + environment: { + variables: { + edges: names.map(name => ({ node: { name, serviceId: 'service-id' } })), + pageInfo: { endCursor: null, hasNextPage: false } + } + } + } +}) const operations = () => ({ deleteVariable: vi.fn().mockResolvedValue(undefined), @@ -40,8 +63,13 @@ describe('Railway provisioning configuration', () => { }) }) - test('preserves write-mode key requirements and provisioning', () => { - expect(resolveProvisioningConfiguration({ RESOLVER_PRIVATE_KEY: KEY })).toEqual({ + test('preserves write-mode key requirements and ignores the readonly caller', () => { + expect( + resolveProvisioningConfiguration({ + RESOLVER_PRIVATE_KEY: KEY, + SIMULATION_CALLER_ADDRESS: 'invalid-and-ignored-in-write-mode' + }) + ).toEqual({ readOnly: false, resolverPrivateKey: KEY, simulationCaller: undefined @@ -68,33 +96,15 @@ describe('Railway provisioning configuration', () => { ).toThrow(InvalidSimulationCallerAddressError) }) - test('removes a stale private key from the exact target before readonly mode', async () => { + test('removes a stale private key before readonly mode', async () => { const railway = operations() - const commands: string[][] = [] - railway.deleteVariable.mockImplementation(async name => { - commands.push(railwayVariableDeleteArgs(name, TARGET)) - }) await synchronizeModeVariables( { readOnly: true, resolverPrivateKey: undefined, simulationCaller: CALLER }, - new Set(['RESOLVER_PRIVATE_KEY']), railway ) expect(railway.deleteVariable).toHaveBeenCalledExactlyOnceWith('RESOLVER_PRIVATE_KEY') - expect(commands).toEqual([ - [ - 'variable', - 'delete', - 'RESOLVER_PRIVATE_KEY', - '-s', - 'bot', - '-e', - 'production', - '-p', - 'project-id' - ] - ]) expect(railway.setSecret).not.toHaveBeenCalled() expect(railway.setVariable.mock.calls).toEqual([ [`SIMULATION_CALLER_ADDRESS=${CALLER}`], @@ -102,33 +112,15 @@ describe('Railway provisioning configuration', () => { ]) }) - test('removes a stale caller from the exact target before write mode', async () => { + test('removes a stale caller before write mode', async () => { const railway = operations() - const commands: string[][] = [] - railway.deleteVariable.mockImplementation(async name => { - commands.push(railwayVariableDeleteArgs(name, TARGET)) - }) await synchronizeModeVariables( { readOnly: false, resolverPrivateKey: KEY, simulationCaller: undefined }, - new Set(['SIMULATION_CALLER_ADDRESS']), railway ) expect(railway.deleteVariable).toHaveBeenCalledExactlyOnceWith('SIMULATION_CALLER_ADDRESS') - expect(commands).toEqual([ - [ - 'variable', - 'delete', - 'SIMULATION_CALLER_ADDRESS', - '-s', - 'bot', - '-e', - 'production', - '-p', - 'project-id' - ] - ]) expect(railway.setSecret).toHaveBeenCalledExactlyOnceWith('RESOLVER_PRIVATE_KEY', KEY) expect(railway.setVariable).toHaveBeenCalledExactlyOnceWith('READONLY=false') }) @@ -141,9 +133,7 @@ describe('Railway provisioning configuration', () => { SIMULATION_CALLER_ADDRESS: CALLER }) - await expect( - synchronizeModeVariables(config, new Set(['RESOLVER_PRIVATE_KEY']), railway) - ).rejects.toThrow('delete failed') + await expect(synchronizeModeVariables(config, railway)).rejects.toThrow('delete failed') expect(railway.setSecret).not.toHaveBeenCalled() expect(railway.setVariable).not.toHaveBeenCalled() }) @@ -153,61 +143,25 @@ describe('Railway provisioning configuration', () => { railway.deleteVariable.mockRejectedValue(new Error('delete failed')) const config = resolveProvisioningConfiguration({ RESOLVER_PRIVATE_KEY: KEY }) - await expect( - synchronizeModeVariables(config, new Set(['SIMULATION_CALLER_ADDRESS']), railway) - ).rejects.toThrow('delete failed') + await expect(synchronizeModeVariables(config, railway)).rejects.toThrow('delete failed') expect(railway.setSecret).not.toHaveBeenCalled() expect(railway.setVariable).not.toHaveBeenCalled() }) - test('provisions a new readonly service without attempting an absent-key deletion', async () => { - const railway = operations() - - await synchronizeModeVariables( - { readOnly: true, resolverPrivateKey: undefined, simulationCaller: CALLER }, - new Set(), - railway - ) - - expect(railway.deleteVariable).not.toHaveBeenCalled() - expect(railway.setVariable).toHaveBeenCalledTimes(2) - }) - - test('provisions a new write service without attempting an absent-caller deletion', async () => { - const railway = operations() - - await synchronizeModeVariables( - { readOnly: false, resolverPrivateKey: KEY, simulationCaller: undefined }, - new Set(), - railway - ) - - expect(railway.deleteVariable).not.toHaveBeenCalled() - expect(railway.setSecret).toHaveBeenCalledExactlyOnceWith('RESOLVER_PRIVATE_KEY', KEY) - expect(railway.setVariable).toHaveBeenCalledExactlyOnceWith('READONLY=false') - }) - test('wires fail-closed mode synchronization into Railway provisioning', () => { const deploy = readFileSync(new URL('../../scripts/deploy-railway.ts', import.meta.url), 'utf8') expect(deploy).toMatch(/resolveProvisioningConfiguration\(\s*process\.env\s*\)/) - expect(deploy).toContain('await listVariableKeys()') expect(deploy).toContain('await synchronizeModeVariables(') expect(deploy).toContain('deleteVariable') + expect(deploy).not.toContain('variable list') + expect(deploy).not.toContain('railwayVariableListArgs') + expect(deploy).toContain("throw new RailwayVariableOperationError('set', key)") + expect(deploy).toContain("throw new RailwayVariableOperationError('set', name)") + expect(deploy).not.toMatch(/Failed to set.*errorDetails/) }) - test('explicitly scopes variable listing and setting to the target', () => { - expect(railwayVariableListArgs(TARGET)).toEqual([ - 'variable', - 'list', - '-s', - 'bot', - '-e', - 'production', - '-p', - 'project-id', - '--json' - ]) + test('explicitly scopes variable setting to the target', () => { expect(railwayVariableSetArgs('READONLY=true', TARGET)).toEqual([ 'variable', 'set', @@ -236,17 +190,117 @@ describe('Railway provisioning configuration', () => { }) }) -describe('Railway CLI output parsing', () => { - test('extracts variable names without exposing values', () => { - expect(parseVariableKeys('{"RESOLVER_PRIVATE_KEY":"secret","READONLY":"true"}')).toEqual( - new Set(['RESOLVER_PRIVATE_KEY', 'READONLY']) +describe('Railway variable deletion API', () => { + test('resolves supported API authentication without logging or CLI arguments', () => { + expect(resolveRailwayAccessToken({ RAILWAY_TOKEN: 'project-token' })).toEqual({ + header: 'project-access-token', + value: 'project-token' + }) + expect(resolveRailwayAccessToken({ RAILWAY_API_TOKEN: 'account-token' })).toEqual({ + header: 'authorization', + value: 'Bearer account-token' + }) + expect(() => resolveRailwayAccessToken({})).toThrow('RAILWAY_TOKEN or RAILWAY_API_TOKEN') + }) + + test('deletes an existing variable through key-only metadata and an exact target', async () => { + const fetcher = vi + .fn() + .mockResolvedValueOnce(jsonResponse(targetResponse)) + .mockResolvedValueOnce(jsonResponse(metadataResponse(['RESOLVER_PRIVATE_KEY']))) + .mockResolvedValueOnce(jsonResponse({ data: { variableDelete: true } })) + + await expect( + deleteRailwayVariable({ fetcher, name: 'RESOLVER_PRIVATE_KEY', target: TARGET, token: TOKEN }) + ).resolves.toBe(true) + + expect(fetcher).toHaveBeenCalledTimes(3) + expect(fetcher.mock.calls[0]?.[0]).toBe('https://backboard.railway.com/graphql/v2') + expect(fetcher.mock.calls[0]?.[1]).toMatchObject({ + headers: { 'content-type': 'application/json', 'project-access-token': 'railway-token' }, + method: 'POST' + }) + const requests = fetcher.mock.calls.map(([, init]) => + JSON.parse(typeof init?.body === 'string' ? init.body : '') ) + expect(requests[0]).toMatchObject({ variables: { projectId: 'project-id' } }) + expect(requests[1]).toMatchObject({ + variables: { after: null, environmentId: 'environment-id', projectId: 'project-id' } + }) + expect(requests[1]?.query).toContain('node { name serviceId }') + expect(requests[1]?.query).not.toContain('value') + expect(requests[2]).toMatchObject({ + variables: { + environmentId: 'environment-id', + name: 'RESOLVER_PRIVATE_KEY', + projectId: 'project-id', + serviceId: 'service-id' + } + }) + expect(fetcher.mock.calls.every(([, init]) => init?.headers)).toBe(true) + }) + + test('treats an absent targeted variable as an idempotent success without mutation', async () => { + const fetcher = vi + .fn() + .mockResolvedValueOnce(jsonResponse(targetResponse)) + .mockResolvedValueOnce(jsonResponse(metadataResponse([]))) + + await expect( + deleteRailwayVariable({ fetcher, name: 'RESOLVER_PRIVATE_KEY', target: TARGET, token: TOKEN }) + ).resolves.toBe(false) + expect(fetcher).toHaveBeenCalledTimes(2) }) - test.each(['not-json', '[]', 'null'])('rejects an unsafe variable list response %#', raw => { - expect(() => parseVariableKeys(raw)).toThrow('Railway returned an invalid variable list') + test('fails closed when key-only metadata is malformed', async () => { + const fetcher = vi + .fn() + .mockResolvedValueOnce(jsonResponse(targetResponse)) + .mockResolvedValueOnce(jsonResponse({ data: { environment: {} } })) + + const result = deleteRailwayVariable({ + fetcher, + name: 'RESOLVER_PRIVATE_KEY', + target: TARGET, + token: TOKEN + }) + await expect(result).rejects.toMatchObject({ name: 'InvalidRailwayVariableListError' }) + await expect(result).rejects.not.toThrow('secret') + expect(fetcher).toHaveBeenCalledTimes(2) }) + test.each([ + jsonResponse({ errors: [{ message: 'secret-bearing upstream failure' }] }), + jsonResponse({ data: { variableDelete: false } }), + jsonResponse({ data: { variableDelete: true } }, 503) + ])('fails closed with a named sanitized deletion error', async failure => { + const fetcher = vi + .fn() + .mockResolvedValueOnce(jsonResponse(targetResponse)) + .mockResolvedValueOnce(jsonResponse(metadataResponse(['RESOLVER_PRIVATE_KEY']))) + .mockResolvedValueOnce(failure) + + const result = deleteRailwayVariable({ + fetcher, + name: 'RESOLVER_PRIVATE_KEY', + target: TARGET, + token: TOKEN + }) + await expect(result).rejects.toMatchObject({ name: 'RailwayVariableOperationError' }) + await expect(result).rejects.toThrow('Failed to delete Railway variable RESOLVER_PRIVATE_KEY') + await expect(result).rejects.not.toThrow('secret-bearing upstream failure') + }) + + test('uses no Railway variable list command or raw-value endpoint', () => { + const source = readFileSync(new URL('../../scripts/railway.ts', import.meta.url), 'utf8') + + expect(source).not.toContain("'variable',\n 'list'") + expect(source).not.toContain('railway variable list') + expect(source).not.toContain('EnvironmentVariables') + }) +}) + +describe('Railway CLI output parsing', () => { test('parses service arrays and ignores nameless entries', () => { const raw = JSON.stringify([{ name: 'bot' }, { id: 'missing-name' }]) From cb337bb5091347082b13223d41c3d33b753327aa Mon Sep 17 00:00:00 2001 From: "prd-carapulse[bot]" <264278285+prd-carapulse[bot]@users.noreply.github.com> Date: Wed, 12 Aug 2026 16:12:06 +0000 Subject: [PATCH 08/16] fix(midnight-crossed-books): isolate simulation caller by mode Co-authored-by: Julien Thomas <61523188+julien-devatom@users.noreply.github.com> --- .../src/config/config.service.ts | 11 +++++------ .../test/config/config.service.test.ts | 15 +++++++++++++++ 2 files changed, 20 insertions(+), 6 deletions(-) diff --git a/bots/midnight-crossed-books/src/config/config.service.ts b/bots/midnight-crossed-books/src/config/config.service.ts index 10537abc..cc76147a 100644 --- a/bots/midnight-crossed-books/src/config/config.service.ts +++ b/bots/midnight-crossed-books/src/config/config.service.ts @@ -58,13 +58,12 @@ export class ConfigService { throw new Error('RESOLVER_ADDRESS must be an EVM address') } - const simulationCaller = environment.SIMULATION_CALLER_ADDRESS?.trim() + const simulationCaller = readOnly ? environment.SIMULATION_CALLER_ADDRESS?.trim() : undefined if ( - (readOnly && !simulationCaller) || - (simulationCaller - ? !isAddress(simulationCaller, { strict: false }) || - isAddressEqual(getAddress(simulationCaller), zeroAddress) - : false) + readOnly && + (!simulationCaller || + !isAddress(simulationCaller, { strict: false }) || + isAddressEqual(getAddress(simulationCaller), zeroAddress)) ) { throw new InvalidSimulationCallerAddressError() } diff --git a/bots/midnight-crossed-books/test/config/config.service.test.ts b/bots/midnight-crossed-books/test/config/config.service.test.ts index 97761ad8..a0107dc6 100644 --- a/bots/midnight-crossed-books/test/config/config.service.test.ts +++ b/bots/midnight-crossed-books/test/config/config.service.test.ts @@ -56,6 +56,21 @@ describe('ConfigService', () => { expect(ConfigService.from({ ...REQUIRED, READONLY: value }).readOnly).toBe(false) }) + test.each([ + 'not-an-address', + '0x0000000000000000000000000000000000000000', + `0x${'33'.repeat(20)}` + ])('ignores stale SIMULATION_CALLER_ADDRESS=%s in write mode', simulationCaller => { + const config = ConfigService.from({ + ...REQUIRED, + SIMULATION_CALLER_ADDRESS: simulationCaller + }) + + expect(config.readOnly).toBe(false) + expect(config.privateKey).toBe(KEY) + expect(config.simulationCaller).toBeUndefined() + }) + test.each(['yes', '2', 'truthy'])('rejects malformed READONLY=%s fail-closed', value => { expect(() => ConfigService.from({ ...REQUIRED, READONLY: value })).toThrow( InvalidConfigurationError From b9f2c9c535146c10eff9048366739e0816906b46 Mon Sep 17 00:00:00 2001 From: "prd-carapulse[bot]" <264278285+prd-carapulse[bot]@users.noreply.github.com> Date: Wed, 12 Aug 2026 16:50:57 +0000 Subject: [PATCH 09/16] fix(midnight-crossed-books): bound Railway metadata pagination Co-authored-by: Julien Thomas <61523188+julien-devatom@users.noreply.github.com> --- .../midnight-crossed-books/scripts/railway.ts | 14 ++- .../test/scripts/railway.test.ts | 99 +++++++++++++++++++ 2 files changed, 112 insertions(+), 1 deletion(-) diff --git a/bots/midnight-crossed-books/scripts/railway.ts b/bots/midnight-crossed-books/scripts/railway.ts index 79b4dce3..07009616 100644 --- a/bots/midnight-crossed-books/scripts/railway.ts +++ b/bots/midnight-crossed-books/scripts/railway.ts @@ -51,6 +51,7 @@ export const resolveRailwayAccessToken = (env: Env): RailwayAccessToken => { } const RAILWAY_GRAPHQL_ENDPOINT = 'https://backboard.railway.com/graphql/v2' +const RAILWAY_VARIABLE_METADATA_MAX_PAGES = 100 const TARGET_QUERY = `query RailwayVariableTarget($projectId: String!) { project(id: $projectId) { environments { edges { node { id name } } } @@ -160,7 +161,11 @@ const railwayVariableExists = async ({ token: RailwayAccessToken }) => { let after: string | null = null + let pageCount = 0 + const seenCursors = new Set() do { + if (pageCount >= RAILWAY_VARIABLE_METADATA_MAX_PAGES) throw error + pageCount += 1 const data = await postRailwayGraphql({ body: { query: VARIABLE_METADATA_QUERY, @@ -198,7 +203,14 @@ const railwayVariableExists = async ({ if (found) return true if (!pageInfo.hasNextPage) return false - if (typeof pageInfo.endCursor !== 'string' || !pageInfo.endCursor) throw error + if ( + typeof pageInfo.endCursor !== 'string' || + !pageInfo.endCursor.trim() || + seenCursors.has(pageInfo.endCursor) + ) { + throw error + } + seenCursors.add(pageInfo.endCursor) after = pageInfo.endCursor } while (after) return false diff --git a/bots/midnight-crossed-books/test/scripts/railway.test.ts b/bots/midnight-crossed-books/test/scripts/railway.test.ts index eb02c7be..8ccc1dbd 100644 --- a/bots/midnight-crossed-books/test/scripts/railway.test.ts +++ b/bots/midnight-crossed-books/test/scripts/railway.test.ts @@ -42,6 +42,21 @@ const metadataResponse = (names: string[]) => ({ } }) +const paginatedMetadataResponse = ( + endCursor: unknown, + hasNextPage = true, + names: string[] = [] +) => ({ + data: { + environment: { + variables: { + edges: names.map(name => ({ node: { name, serviceId: 'service-id' } })), + pageInfo: { endCursor, hasNextPage } + } + } + } +}) + const operations = () => ({ deleteVariable: vi.fn().mockResolvedValue(undefined), setSecret: vi.fn().mockResolvedValue(undefined), @@ -252,6 +267,90 @@ describe('Railway variable deletion API', () => { expect(fetcher).toHaveBeenCalledTimes(2) }) + test('preserves valid multi-page lookup and finds the target on the second page', async () => { + const fetcher = vi + .fn() + .mockResolvedValueOnce(jsonResponse(targetResponse)) + .mockResolvedValueOnce(jsonResponse(paginatedMetadataResponse('next-page'))) + .mockResolvedValueOnce(jsonResponse(metadataResponse(['RESOLVER_PRIVATE_KEY']))) + .mockResolvedValueOnce(jsonResponse({ data: { variableDelete: true } })) + + await expect( + deleteRailwayVariable({ fetcher, name: 'RESOLVER_PRIVATE_KEY', target: TARGET, token: TOKEN }) + ).resolves.toBe(true) + expect(fetcher).toHaveBeenCalledTimes(4) + + const secondPageRequest = JSON.parse( + typeof fetcher.mock.calls[2]?.[1]?.body === 'string' ? fetcher.mock.calls[2][1].body : '' + ) + expect(secondPageRequest.variables.after).toBe('next-page') + }) + + test.each([ + { cursors: ['repeated-cursor', 'repeated-cursor'], requestCount: 3 }, + { cursors: ['cursor-a', 'cursor-b', 'cursor-a'], requestCount: 4 } + ])( + 'fails closed on repeated or cyclic metadata cursors %#', + async ({ cursors, requestCount }) => { + const fetcher = vi.fn().mockResolvedValueOnce(jsonResponse(targetResponse)) + for (const cursor of cursors) { + fetcher.mockResolvedValueOnce(jsonResponse(paginatedMetadataResponse(cursor))) + } + + const result = deleteRailwayVariable({ + fetcher, + name: 'RESOLVER_PRIVATE_KEY', + target: TARGET, + token: TOKEN + }) + await expect(result).rejects.toMatchObject({ name: 'RailwayVariableOperationError' }) + await expect(result).rejects.toThrow('Failed to delete Railway variable RESOLVER_PRIVATE_KEY') + await expect(result).rejects.not.toThrow(/repeated-cursor|cursor-a|cursor-b|railway-token/) + expect(fetcher).toHaveBeenCalledTimes(requestCount) + } + ) + + test.each([null, undefined, '', ' '])( + 'fails closed on a missing or empty next cursor without exposing metadata: %#', + async endCursor => { + const fetcher = vi + .fn() + .mockResolvedValueOnce(jsonResponse(targetResponse)) + .mockResolvedValueOnce(jsonResponse(paginatedMetadataResponse(endCursor))) + + const result = deleteRailwayVariable({ + fetcher, + name: 'RESOLVER_PRIVATE_KEY', + target: TARGET, + token: TOKEN + }) + await expect(result).rejects.toMatchObject({ name: 'RailwayVariableOperationError' }) + await expect(result).rejects.toThrow('Failed to delete Railway variable RESOLVER_PRIVATE_KEY') + await expect(result).rejects.not.toThrow(/railway-token|secret-bearing|cursor/) + expect(fetcher).toHaveBeenCalledTimes(2) + } + ) + + test('fails closed after at most 100 metadata pages with endlessly unique cursors', async () => { + const fetcher = vi.fn().mockResolvedValueOnce(jsonResponse(targetResponse)) + for (let page = 1; page <= 100; page += 1) { + fetcher.mockResolvedValueOnce( + jsonResponse(paginatedMetadataResponse(`secret-cursor-${page}`)) + ) + } + + const result = deleteRailwayVariable({ + fetcher, + name: 'RESOLVER_PRIVATE_KEY', + target: TARGET, + token: TOKEN + }) + await expect(result).rejects.toMatchObject({ name: 'RailwayVariableOperationError' }) + await expect(result).rejects.toThrow('Failed to delete Railway variable RESOLVER_PRIVATE_KEY') + await expect(result).rejects.not.toThrow(/secret-cursor|railway-token/) + expect(fetcher).toHaveBeenCalledTimes(101) + }) + test('fails closed when key-only metadata is malformed', async () => { const fetcher = vi .fn() From db2c6c42936a1fb9403d6ac14123de90ae102e30 Mon Sep 17 00:00:00 2001 From: "prd-carapulse[bot]" <264278285+prd-carapulse[bot]@users.noreply.github.com> Date: Thu, 13 Aug 2026 08:36:40 +0000 Subject: [PATCH 10/16] fix(midnight-crossed-books): preserve Railway CLI provisioning --- .../scripts/deploy-railway.ts | 19 ++++++++++++--- .../midnight-crossed-books/scripts/railway.ts | 10 ++++++++ .../test/scripts/railway.test.ts | 23 +++++++++++++++++++ 3 files changed, 49 insertions(+), 3 deletions(-) diff --git a/bots/midnight-crossed-books/scripts/deploy-railway.ts b/bots/midnight-crossed-books/scripts/deploy-railway.ts index 9d8adc90..c1b13035 100644 --- a/bots/midnight-crossed-books/scripts/deploy-railway.ts +++ b/bots/midnight-crossed-books/scripts/deploy-railway.ts @@ -13,6 +13,7 @@ import { deleteRailwayVariable, parseLatestStatus, parseServices, + railwayVariableDeleteArgs, railwayVariableSetArgs, resolveRailwayAccessToken, resolveProvisioningConfiguration, @@ -92,12 +93,13 @@ async function listServices() { async function ensureService() { if ((await listServices()).some(service => service.name === SERVICE)) { console.log(`Service ${SERVICE} already exists.`) - return + return false } const { error } = await tryCatch($`railway add --service ${SERVICE} --json`) if (error) throw new Error(`Failed to create service ${SERVICE}: ${errorDetails(error)}`) console.log(`Created service ${SERVICE}.`) + return true } async function setVariable(value: string) { @@ -116,6 +118,13 @@ async function setSecret(name: string, value: string) { } const deleteVariable = async (name: string) => { + if (!process.env.RAILWAY_TOKEN?.trim() && !process.env.RAILWAY_API_TOKEN?.trim()) { + const { error } = await tryCatch($('railway', railwayVariableDeleteArgs(name, VARIABLE_TARGET))) + if (error) throw new RailwayVariableOperationError('delete', name) + console.log(`Deleted ${name} on ${SERVICE} (stale).`) + return + } + const deleted = await deleteRailwayVariable({ fetcher: fetch, name, @@ -127,6 +136,10 @@ const deleteVariable = async (name: string) => { ) } +const skipVariableDeletion = async (name: string) => { + console.log(`${name} is already absent on newly created service ${SERVICE}.`) +} + async function deployService() { const message = `deploy midnight crossed-books ${ENVIRONMENT}` const { error } = await tryCatch( @@ -184,10 +197,10 @@ if (DEPLOY_ONLY) { const config = resolveProvisioningConfiguration(process.env) await ensureContext() - await ensureService() + const serviceCreated = await ensureService() await setVariable('CHAIN_ID=8453') await synchronizeModeVariables(config, { - deleteVariable, + deleteVariable: serviceCreated ? skipVariableDeletion : deleteVariable, setSecret, setVariable }) diff --git a/bots/midnight-crossed-books/scripts/railway.ts b/bots/midnight-crossed-books/scripts/railway.ts index 07009616..ca3956e8 100644 --- a/bots/midnight-crossed-books/scripts/railway.ts +++ b/bots/midnight-crossed-books/scripts/railway.ts @@ -258,6 +258,16 @@ export const deleteRailwayVariable = async ({ return true } +export const railwayVariableDeleteArgs = (name: string, target: RailwayVariableTarget) => [ + 'variable', + 'delete', + name, + '-s', + target.service, + '-e', + target.environment +] + /** * Builds arguments for setting one variable on one Railway deployment target. * @param value - Public `KEY=VALUE` assignment, or only a variable name when stdin is enabled. diff --git a/bots/midnight-crossed-books/test/scripts/railway.test.ts b/bots/midnight-crossed-books/test/scripts/railway.test.ts index 8ccc1dbd..bb0920ff 100644 --- a/bots/midnight-crossed-books/test/scripts/railway.test.ts +++ b/bots/midnight-crossed-books/test/scripts/railway.test.ts @@ -5,6 +5,7 @@ import { deleteRailwayVariable, parseLatestStatus, parseServices, + railwayVariableDeleteArgs, railwayVariableSetArgs, resolveRailwayAccessToken, resolveProvisioningConfiguration, @@ -176,6 +177,16 @@ describe('Railway provisioning configuration', () => { expect(deploy).not.toMatch(/Failed to set.*errorDetails/) }) + test('preserves fresh-service and CLI-login provisioning without an API token', () => { + const deploy = readFileSync(new URL('../../scripts/deploy-railway.ts', import.meta.url), 'utf8') + + expect(deploy).toContain('const serviceCreated = await ensureService()') + expect(deploy).toContain( + 'deleteVariable: serviceCreated ? skipVariableDeletion : deleteVariable' + ) + expect(deploy).toContain("$('railway', railwayVariableDeleteArgs(name, VARIABLE_TARGET))") + }) + test('explicitly scopes variable setting to the target', () => { expect(railwayVariableSetArgs('READONLY=true', TARGET)).toEqual([ 'variable', @@ -203,6 +214,18 @@ describe('Railway provisioning configuration', () => { '--skip-deploys' ]) }) + + test('builds a linked-context CLI deletion without requiring an API token', () => { + expect(railwayVariableDeleteArgs('RESOLVER_PRIVATE_KEY', TARGET)).toEqual([ + 'variable', + 'delete', + 'RESOLVER_PRIVATE_KEY', + '-s', + 'bot', + '-e', + 'production' + ]) + }) }) describe('Railway variable deletion API', () => { From f4fbfe77aacd423a8a7c982bc7b242f3beb4452a Mon Sep 17 00:00:00 2001 From: "prd-carapulse[bot]" <264278285+prd-carapulse[bot]@users.noreply.github.com> Date: Thu, 13 Aug 2026 08:47:01 +0000 Subject: [PATCH 11/16] fix(midnight-crossed-books): use supported Railway variable flags Remove the unsupported project flag from Railway variable set commands and cover both public and stdin-backed variable writes. --- bots/midnight-crossed-books/scripts/railway.ts | 6 ++---- bots/midnight-crossed-books/test/scripts/railway.test.ts | 6 +----- 2 files changed, 3 insertions(+), 9 deletions(-) diff --git a/bots/midnight-crossed-books/scripts/railway.ts b/bots/midnight-crossed-books/scripts/railway.ts index ca3956e8..2cc7bb43 100644 --- a/bots/midnight-crossed-books/scripts/railway.ts +++ b/bots/midnight-crossed-books/scripts/railway.ts @@ -30,9 +30,7 @@ const railwayVariableTargetArgs = (target: RailwayVariableTarget) => [ '-s', target.service, '-e', - target.environment, - '-p', - target.projectId + target.environment ] /** @@ -273,7 +271,7 @@ export const railwayVariableDeleteArgs = (name: string, target: RailwayVariableT * @param value - Public `KEY=VALUE` assignment, or only a variable name when stdin is enabled. * @param target - Project, environment, and service that must receive the variable. * @param options - Enables secret-safe stdin input without placing the value in command arguments. - * @returns CLI arguments including explicit service, environment, project, and no-deploy flags. + * @returns CLI arguments including supported service, environment, and no-deploy flags. * @remarks Secret values remain on stdin when `stdin` is true; only the variable name enters args. */ export const railwayVariableSetArgs = ( diff --git a/bots/midnight-crossed-books/test/scripts/railway.test.ts b/bots/midnight-crossed-books/test/scripts/railway.test.ts index bb0920ff..6c627913 100644 --- a/bots/midnight-crossed-books/test/scripts/railway.test.ts +++ b/bots/midnight-crossed-books/test/scripts/railway.test.ts @@ -187,7 +187,7 @@ describe('Railway provisioning configuration', () => { expect(deploy).toContain("$('railway', railwayVariableDeleteArgs(name, VARIABLE_TARGET))") }) - test('explicitly scopes variable setting to the target', () => { + test('uses only supported target flags when setting variables', () => { expect(railwayVariableSetArgs('READONLY=true', TARGET)).toEqual([ 'variable', 'set', @@ -196,8 +196,6 @@ describe('Railway provisioning configuration', () => { 'bot', '-e', 'production', - '-p', - 'project-id', '--skip-deploys' ]) expect(railwayVariableSetArgs('RPC_URL', TARGET, { stdin: true })).toEqual([ @@ -209,8 +207,6 @@ describe('Railway provisioning configuration', () => { 'bot', '-e', 'production', - '-p', - 'project-id', '--skip-deploys' ]) }) From 91efac1a693116000e6f08e8b87d8c864349e5f3 Mon Sep 17 00:00:00 2001 From: "prd-carapulse[bot]" <264278285+prd-carapulse[bot]@users.noreply.github.com> Date: Thu, 13 Aug 2026 09:10:10 +0000 Subject: [PATCH 12/16] fix(midnight-crossed-books): address Railway review feedback Use supported Railway deletion paths, preserve idempotent CLI reruns, and pass explicit project context. --- .../scripts/deploy-railway.ts | 9 +- .../invalid-railway-variable-list.error.ts | 8 - .../midnight-crossed-books/scripts/railway.ts | 127 +++---------- .../test/scripts/railway.test.ts | 177 +++--------------- 4 files changed, 63 insertions(+), 258 deletions(-) delete mode 100644 bots/midnight-crossed-books/scripts/invalid-railway-variable-list.error.ts diff --git a/bots/midnight-crossed-books/scripts/deploy-railway.ts b/bots/midnight-crossed-books/scripts/deploy-railway.ts index c1b13035..55c0cac7 100644 --- a/bots/midnight-crossed-books/scripts/deploy-railway.ts +++ b/bots/midnight-crossed-books/scripts/deploy-railway.ts @@ -11,6 +11,7 @@ import { fileURLToPath } from 'node:url' import { deleteRailwayVariable, + isRailwayVariableMissingError, parseLatestStatus, parseServices, railwayVariableDeleteArgs, @@ -120,7 +121,13 @@ async function setSecret(name: string, value: string) { const deleteVariable = async (name: string) => { if (!process.env.RAILWAY_TOKEN?.trim() && !process.env.RAILWAY_API_TOKEN?.trim()) { const { error } = await tryCatch($('railway', railwayVariableDeleteArgs(name, VARIABLE_TARGET))) - if (error) throw new RailwayVariableOperationError('delete', name) + if (error && !isRailwayVariableMissingError(name, errorDetails(error))) { + throw new RailwayVariableOperationError('delete', name) + } + if (error) { + console.log(`${name} is already absent on ${SERVICE}.`) + return + } console.log(`Deleted ${name} on ${SERVICE} (stale).`) return } diff --git a/bots/midnight-crossed-books/scripts/invalid-railway-variable-list.error.ts b/bots/midnight-crossed-books/scripts/invalid-railway-variable-list.error.ts deleted file mode 100644 index 5425b000..00000000 --- a/bots/midnight-crossed-books/scripts/invalid-railway-variable-list.error.ts +++ /dev/null @@ -1,8 +0,0 @@ -/** Raised when Railway returns a variable list that cannot be safely interpreted. */ -export class InvalidRailwayVariableListError extends Error { - /** Creates a response-shape failure without retaining or printing variable values. */ - constructor() { - super('Railway returned an invalid variable list') - this.name = 'InvalidRailwayVariableListError' - } -} diff --git a/bots/midnight-crossed-books/scripts/railway.ts b/bots/midnight-crossed-books/scripts/railway.ts index 2cc7bb43..9f881ed9 100644 --- a/bots/midnight-crossed-books/scripts/railway.ts +++ b/bots/midnight-crossed-books/scripts/railway.ts @@ -5,7 +5,6 @@ import { InvalidConfigurationError } from '../src/config/invalid-configuration.e import { InvalidSimulationCallerAddressError } from '../src/config/invalid-simulation-caller-address.error' import { parseReadonly } from '../src/config/readonly.utils' import { ResolverPrivateKeyRequiredError } from '../src/config/resolver-private-key-required.error' -import { InvalidRailwayVariableListError } from './invalid-railway-variable-list.error' import { RailwayAccessTokenRequiredError } from './railway-access-token-required.error' import { RailwayVariableOperationError } from './railway-variable-operation.error' @@ -30,7 +29,9 @@ const railwayVariableTargetArgs = (target: RailwayVariableTarget) => [ '-s', target.service, '-e', - target.environment + target.environment, + '-p', + target.projectId ] /** @@ -49,25 +50,12 @@ export const resolveRailwayAccessToken = (env: Env): RailwayAccessToken => { } const RAILWAY_GRAPHQL_ENDPOINT = 'https://backboard.railway.com/graphql/v2' -const RAILWAY_VARIABLE_METADATA_MAX_PAGES = 100 const TARGET_QUERY = `query RailwayVariableTarget($projectId: String!) { project(id: $projectId) { environments { edges { node { id name } } } services { edges { node { id name } } } } }` -const VARIABLE_METADATA_QUERY = `query RailwayVariableMetadata( - $projectId: String! - $environmentId: String! - $after: String -) { - environment(id: $environmentId, projectId: $projectId) { - variables(first: 100, after: $after) { - edges { node { name serviceId } } - pageInfo { endCursor hasNextPage } - } - } -}` const VARIABLE_DELETE_MUTATION = `mutation RailwayVariableDelete( $projectId: String! $environmentId: String! @@ -143,85 +131,13 @@ const resolveRailwayVariableTarget = async ({ return { environmentId: environment.id, serviceId: service.id } } -const railwayVariableExists = async ({ - error, - fetcher, - ids, - name, - target, - token -}: { - error: RailwayVariableOperationError - fetcher: typeof fetch - ids: RailwayVariableTargetIds - name: string - target: RailwayVariableTarget - token: RailwayAccessToken -}) => { - let after: string | null = null - let pageCount = 0 - const seenCursors = new Set() - do { - if (pageCount >= RAILWAY_VARIABLE_METADATA_MAX_PAGES) throw error - pageCount += 1 - const data = await postRailwayGraphql({ - body: { - query: VARIABLE_METADATA_QUERY, - variables: { - after, - environmentId: ids.environmentId, - projectId: target.projectId - } - }, - error, - fetcher, - token - }) - const variables = recordField(recordField(data, 'environment'), 'variables') - const pageInfo = recordField(variables, 'pageInfo') - if ( - !variables || - !Array.isArray(variables.edges) || - !pageInfo || - typeof pageInfo.hasNextPage !== 'boolean' - ) { - throw new InvalidRailwayVariableListError() - } - const nodes = variables.edges.map(edge => recordField(edge, 'node')) - if ( - nodes.some( - node => - typeof node?.name !== 'string' || - (node.serviceId !== null && typeof node.serviceId !== 'string') - ) - ) { - throw new InvalidRailwayVariableListError() - } - const found = nodes.some(node => node?.name === name && node.serviceId === ids.serviceId) - if (found) return true - - if (!pageInfo.hasNextPage) return false - if ( - typeof pageInfo.endCursor !== 'string' || - !pageInfo.endCursor.trim() || - seenCursors.has(pageInfo.endCursor) - ) { - throw error - } - seenCursors.add(pageInfo.endCursor) - after = pageInfo.endCursor - } while (after) - return false -} - /** - * Idempotently deletes one Railway variable without retrieving variable values. + * Deletes one Railway variable without retrieving variable values. * @param parameters - Fetch implementation, credential, exact target names, and variable name. - * @returns `true` when a variable was deleted, or `false` when key-only metadata proved it absent. - * @throws `RailwayVariableOperationError` when target lookup, metadata transport, or deletion fails. - * @throws `InvalidRailwayVariableListError` when key-only metadata has an unsafe shape. - * @remarks Requests only project/environment/service IDs and paginated key metadata before issuing - * an explicitly project-, environment-, service-, and name-scoped delete mutation. + * @returns `true` when Railway accepts the deletion. + * @throws `RailwayVariableOperationError` when target lookup, transport, or deletion fails. + * @remarks Resolves only project/environment/service IDs before issuing an explicitly scoped + * deletion; it never requests variable names or values. */ export const deleteRailwayVariable = async ({ fetcher, @@ -236,8 +152,6 @@ export const deleteRailwayVariable = async ({ }) => { const error = new RailwayVariableOperationError('delete', name) const ids = await resolveRailwayVariableTarget({ error, fetcher, target, token }) - if (!(await railwayVariableExists({ error, fetcher, ids, name, target, token }))) return false - const data = await postRailwayGraphql({ body: { query: VARIABLE_DELETE_MUTATION, @@ -256,14 +170,31 @@ export const deleteRailwayVariable = async ({ return true } +/** + * Recognizes Railway CLI's documented missing-variable deletion failure. + * @param name - Exact variable name passed to `railway variable delete`. + * @param details - Captured CLI stderr or error message. + * @returns Whether the failure says that exact variable is absent. + * @throws Nothing. + * @remarks Performs no logging or mutation and should only turn this one idempotent case into success. + */ +export const isRailwayVariableMissingError = (name: string, details: string) => + details.includes(`Variable '${name}' not found`) + +/** + * Builds arguments for deleting one variable from an exact Railway deployment target. + * @param name - Name of the variable to delete; no variable value enters command arguments. + * @param target - Project, environment, and service from which the variable must be deleted. + * @returns CLI arguments with explicit project, environment, and service context. + * @throws Nothing; the caller handles Railway CLI failures, including a missing variable in a + * linked CLI session. + * @remarks Builds arguments only and performs no I/O or mutation itself. + */ export const railwayVariableDeleteArgs = (name: string, target: RailwayVariableTarget) => [ 'variable', 'delete', name, - '-s', - target.service, - '-e', - target.environment + ...railwayVariableTargetArgs(target) ] /** diff --git a/bots/midnight-crossed-books/test/scripts/railway.test.ts b/bots/midnight-crossed-books/test/scripts/railway.test.ts index 6c627913..b88a10a5 100644 --- a/bots/midnight-crossed-books/test/scripts/railway.test.ts +++ b/bots/midnight-crossed-books/test/scripts/railway.test.ts @@ -3,6 +3,7 @@ import { describe, expect, test, vi } from 'vitest' import { deleteRailwayVariable, + isRailwayVariableMissingError, parseLatestStatus, parseServices, railwayVariableDeleteArgs, @@ -32,32 +33,6 @@ const targetResponse = { } } -const metadataResponse = (names: string[]) => ({ - data: { - environment: { - variables: { - edges: names.map(name => ({ node: { name, serviceId: 'service-id' } })), - pageInfo: { endCursor: null, hasNextPage: false } - } - } - } -}) - -const paginatedMetadataResponse = ( - endCursor: unknown, - hasNextPage = true, - names: string[] = [] -) => ({ - data: { - environment: { - variables: { - edges: names.map(name => ({ node: { name, serviceId: 'service-id' } })), - pageInfo: { endCursor, hasNextPage } - } - } - } -}) - const operations = () => ({ deleteVariable: vi.fn().mockResolvedValue(undefined), setSecret: vi.fn().mockResolvedValue(undefined), @@ -187,7 +162,7 @@ describe('Railway provisioning configuration', () => { expect(deploy).toContain("$('railway', railwayVariableDeleteArgs(name, VARIABLE_TARGET))") }) - test('uses only supported target flags when setting variables', () => { + test('includes explicit project context when setting variables', () => { expect(railwayVariableSetArgs('READONLY=true', TARGET)).toEqual([ 'variable', 'set', @@ -196,6 +171,8 @@ describe('Railway provisioning configuration', () => { 'bot', '-e', 'production', + '-p', + 'project-id', '--skip-deploys' ]) expect(railwayVariableSetArgs('RPC_URL', TARGET, { stdin: true })).toEqual([ @@ -207,11 +184,13 @@ describe('Railway provisioning configuration', () => { 'bot', '-e', 'production', + '-p', + 'project-id', '--skip-deploys' ]) }) - test('builds a linked-context CLI deletion without requiring an API token', () => { + test('includes explicit project context in CLI deletion', () => { expect(railwayVariableDeleteArgs('RESOLVER_PRIVATE_KEY', TARGET)).toEqual([ 'variable', 'delete', @@ -219,9 +198,24 @@ describe('Railway provisioning configuration', () => { '-s', 'bot', '-e', - 'production' + 'production', + '-p', + 'project-id' ]) }) + + test('recognizes only the Railway CLI missing-variable failure as idempotent', () => { + expect( + isRailwayVariableMissingError( + 'RESOLVER_PRIVATE_KEY', + "Error: Variable 'RESOLVER_PRIVATE_KEY' not found" + ) + ).toBe(true) + expect( + isRailwayVariableMissingError('RESOLVER_PRIVATE_KEY', "Variable 'OTHER_KEY' not found") + ).toBe(false) + expect(isRailwayVariableMissingError('RESOLVER_PRIVATE_KEY', 'Unauthorized')).toBe(false) + }) }) describe('Railway variable deletion API', () => { @@ -237,18 +231,17 @@ describe('Railway variable deletion API', () => { expect(() => resolveRailwayAccessToken({})).toThrow('RAILWAY_TOKEN or RAILWAY_API_TOKEN') }) - test('deletes an existing variable through key-only metadata and an exact target', async () => { + test('deletes an existing variable without an unsupported metadata preflight', async () => { const fetcher = vi .fn() .mockResolvedValueOnce(jsonResponse(targetResponse)) - .mockResolvedValueOnce(jsonResponse(metadataResponse(['RESOLVER_PRIVATE_KEY']))) .mockResolvedValueOnce(jsonResponse({ data: { variableDelete: true } })) await expect( deleteRailwayVariable({ fetcher, name: 'RESOLVER_PRIVATE_KEY', target: TARGET, token: TOKEN }) ).resolves.toBe(true) - expect(fetcher).toHaveBeenCalledTimes(3) + expect(fetcher).toHaveBeenCalledTimes(2) expect(fetcher.mock.calls[0]?.[0]).toBe('https://backboard.railway.com/graphql/v2') expect(fetcher.mock.calls[0]?.[1]).toMatchObject({ headers: { 'content-type': 'application/json', 'project-access-token': 'railway-token' }, @@ -259,11 +252,6 @@ describe('Railway variable deletion API', () => { ) expect(requests[0]).toMatchObject({ variables: { projectId: 'project-id' } }) expect(requests[1]).toMatchObject({ - variables: { after: null, environmentId: 'environment-id', projectId: 'project-id' } - }) - expect(requests[1]?.query).toContain('node { name serviceId }') - expect(requests[1]?.query).not.toContain('value') - expect(requests[2]).toMatchObject({ variables: { environmentId: 'environment-id', name: 'RESOLVER_PRIVATE_KEY', @@ -271,122 +259,10 @@ describe('Railway variable deletion API', () => { serviceId: 'service-id' } }) + expect(requests.every(request => !request.query.includes('environment(id:'))).toBe(true) expect(fetcher.mock.calls.every(([, init]) => init?.headers)).toBe(true) }) - test('treats an absent targeted variable as an idempotent success without mutation', async () => { - const fetcher = vi - .fn() - .mockResolvedValueOnce(jsonResponse(targetResponse)) - .mockResolvedValueOnce(jsonResponse(metadataResponse([]))) - - await expect( - deleteRailwayVariable({ fetcher, name: 'RESOLVER_PRIVATE_KEY', target: TARGET, token: TOKEN }) - ).resolves.toBe(false) - expect(fetcher).toHaveBeenCalledTimes(2) - }) - - test('preserves valid multi-page lookup and finds the target on the second page', async () => { - const fetcher = vi - .fn() - .mockResolvedValueOnce(jsonResponse(targetResponse)) - .mockResolvedValueOnce(jsonResponse(paginatedMetadataResponse('next-page'))) - .mockResolvedValueOnce(jsonResponse(metadataResponse(['RESOLVER_PRIVATE_KEY']))) - .mockResolvedValueOnce(jsonResponse({ data: { variableDelete: true } })) - - await expect( - deleteRailwayVariable({ fetcher, name: 'RESOLVER_PRIVATE_KEY', target: TARGET, token: TOKEN }) - ).resolves.toBe(true) - expect(fetcher).toHaveBeenCalledTimes(4) - - const secondPageRequest = JSON.parse( - typeof fetcher.mock.calls[2]?.[1]?.body === 'string' ? fetcher.mock.calls[2][1].body : '' - ) - expect(secondPageRequest.variables.after).toBe('next-page') - }) - - test.each([ - { cursors: ['repeated-cursor', 'repeated-cursor'], requestCount: 3 }, - { cursors: ['cursor-a', 'cursor-b', 'cursor-a'], requestCount: 4 } - ])( - 'fails closed on repeated or cyclic metadata cursors %#', - async ({ cursors, requestCount }) => { - const fetcher = vi.fn().mockResolvedValueOnce(jsonResponse(targetResponse)) - for (const cursor of cursors) { - fetcher.mockResolvedValueOnce(jsonResponse(paginatedMetadataResponse(cursor))) - } - - const result = deleteRailwayVariable({ - fetcher, - name: 'RESOLVER_PRIVATE_KEY', - target: TARGET, - token: TOKEN - }) - await expect(result).rejects.toMatchObject({ name: 'RailwayVariableOperationError' }) - await expect(result).rejects.toThrow('Failed to delete Railway variable RESOLVER_PRIVATE_KEY') - await expect(result).rejects.not.toThrow(/repeated-cursor|cursor-a|cursor-b|railway-token/) - expect(fetcher).toHaveBeenCalledTimes(requestCount) - } - ) - - test.each([null, undefined, '', ' '])( - 'fails closed on a missing or empty next cursor without exposing metadata: %#', - async endCursor => { - const fetcher = vi - .fn() - .mockResolvedValueOnce(jsonResponse(targetResponse)) - .mockResolvedValueOnce(jsonResponse(paginatedMetadataResponse(endCursor))) - - const result = deleteRailwayVariable({ - fetcher, - name: 'RESOLVER_PRIVATE_KEY', - target: TARGET, - token: TOKEN - }) - await expect(result).rejects.toMatchObject({ name: 'RailwayVariableOperationError' }) - await expect(result).rejects.toThrow('Failed to delete Railway variable RESOLVER_PRIVATE_KEY') - await expect(result).rejects.not.toThrow(/railway-token|secret-bearing|cursor/) - expect(fetcher).toHaveBeenCalledTimes(2) - } - ) - - test('fails closed after at most 100 metadata pages with endlessly unique cursors', async () => { - const fetcher = vi.fn().mockResolvedValueOnce(jsonResponse(targetResponse)) - for (let page = 1; page <= 100; page += 1) { - fetcher.mockResolvedValueOnce( - jsonResponse(paginatedMetadataResponse(`secret-cursor-${page}`)) - ) - } - - const result = deleteRailwayVariable({ - fetcher, - name: 'RESOLVER_PRIVATE_KEY', - target: TARGET, - token: TOKEN - }) - await expect(result).rejects.toMatchObject({ name: 'RailwayVariableOperationError' }) - await expect(result).rejects.toThrow('Failed to delete Railway variable RESOLVER_PRIVATE_KEY') - await expect(result).rejects.not.toThrow(/secret-cursor|railway-token/) - expect(fetcher).toHaveBeenCalledTimes(101) - }) - - test('fails closed when key-only metadata is malformed', async () => { - const fetcher = vi - .fn() - .mockResolvedValueOnce(jsonResponse(targetResponse)) - .mockResolvedValueOnce(jsonResponse({ data: { environment: {} } })) - - const result = deleteRailwayVariable({ - fetcher, - name: 'RESOLVER_PRIVATE_KEY', - target: TARGET, - token: TOKEN - }) - await expect(result).rejects.toMatchObject({ name: 'InvalidRailwayVariableListError' }) - await expect(result).rejects.not.toThrow('secret') - expect(fetcher).toHaveBeenCalledTimes(2) - }) - test.each([ jsonResponse({ errors: [{ message: 'secret-bearing upstream failure' }] }), jsonResponse({ data: { variableDelete: false } }), @@ -395,7 +271,6 @@ describe('Railway variable deletion API', () => { const fetcher = vi .fn() .mockResolvedValueOnce(jsonResponse(targetResponse)) - .mockResolvedValueOnce(jsonResponse(metadataResponse(['RESOLVER_PRIVATE_KEY']))) .mockResolvedValueOnce(failure) const result = deleteRailwayVariable({ From f9a13e928a8ae604357eaaf3dde0a12a38917f8a Mon Sep 17 00:00:00 2001 From: "prd-carapulse[bot]" <264278285+prd-carapulse[bot]@users.noreply.github.com> Date: Thu, 13 Aug 2026 09:37:11 +0000 Subject: [PATCH 13/16] fix(midnight-crossed-books): make Railway mode transitions safe --- .../midnight-crossed-books/scripts/railway.ts | 50 +++++++-- .../test/scripts/railway.test.ts | 101 +++++++++++++----- 2 files changed, 114 insertions(+), 37 deletions(-) diff --git a/bots/midnight-crossed-books/scripts/railway.ts b/bots/midnight-crossed-books/scripts/railway.ts index 9f881ed9..6e665671 100644 --- a/bots/midnight-crossed-books/scripts/railway.ts +++ b/bots/midnight-crossed-books/scripts/railway.ts @@ -56,6 +56,17 @@ const TARGET_QUERY = `query RailwayVariableTarget($projectId: String!) { services { edges { node { id name } } } } }` +const VARIABLES_QUERY = `query VariablesForServiceDeployment( + $projectId: String! + $environmentId: String! + $serviceId: String! +) { + variablesForServiceDeployment( + projectId: $projectId + environmentId: $environmentId + serviceId: $serviceId + ) +}` const VARIABLE_DELETE_MUTATION = `mutation RailwayVariableDelete( $projectId: String! $environmentId: String! @@ -132,12 +143,12 @@ const resolveRailwayVariableTarget = async ({ } /** - * Deletes one Railway variable without retrieving variable values. + * Idempotently deletes one Railway variable from an explicitly scoped deployment target. * @param parameters - Fetch implementation, credential, exact target names, and variable name. - * @returns `true` when Railway accepts the deletion. + * @returns `true` when Railway accepts the deletion, or `false` when the variable is already absent. * @throws `RailwayVariableOperationError` when target lookup, transport, or deletion fails. - * @remarks Resolves only project/environment/service IDs before issuing an explicitly scoped - * deletion; it never requests variable names or values. + * @remarks Railway's API exposes key existence through a value-bearing map. This function only + * checks the requested own key, never logs or returns the map, and skips deletion when absent. */ export const deleteRailwayVariable = async ({ fetcher, @@ -152,6 +163,23 @@ export const deleteRailwayVariable = async ({ }) => { const error = new RailwayVariableOperationError('delete', name) const ids = await resolveRailwayVariableTarget({ error, fetcher, target, token }) + const listed = await postRailwayGraphql({ + body: { + query: VARIABLES_QUERY, + variables: { + environmentId: ids.environmentId, + projectId: target.projectId, + serviceId: ids.serviceId + } + }, + error, + fetcher, + token + }) + const variables = recordField(listed, 'variablesForServiceDeployment') + if (!variables) throw error + if (!Object.prototype.hasOwnProperty.call(variables, name)) return false + const data = await postRailwayGraphql({ body: { query: VARIABLE_DELETE_MUTATION, @@ -255,9 +283,10 @@ export const resolveProvisioningConfiguration = (env: Env): ProvisioningConfigur * Synchronizes Railway's mutually exclusive mode variables before changing the active mode. * @param config - Validated mode-specific provisioning values. * @param operations - Secret-safe Railway variable mutation operations. - * @returns A promise that resolves after incompatible variables are removed and the mode is set. - * @throws The underlying mutation error; mode is not changed when incompatible-variable deletion - * fails. + * @returns A promise that resolves after the replacement variable and mode are set, then the + * incompatible variable is removed. + * @throws The underlying mutation error. A failed replacement or mode update leaves the old + * mode-specific variable in place. * @remarks Secret values are passed only to `setSecret` and are never logged by this helper. */ export const synchronizeModeVariables = async ( @@ -265,13 +294,14 @@ export const synchronizeModeVariables = async ( operations: ModeVariableOperations ) => { if (config.readOnly) { - await operations.deleteVariable('RESOLVER_PRIVATE_KEY') await operations.setVariable(`SIMULATION_CALLER_ADDRESS=${config.simulationCaller}`) + await operations.setVariable('READONLY=true') + await operations.deleteVariable('RESOLVER_PRIVATE_KEY') } else { - await operations.deleteVariable('SIMULATION_CALLER_ADDRESS') await operations.setSecret('RESOLVER_PRIVATE_KEY', config.resolverPrivateKey) + await operations.setVariable('READONLY=false') + await operations.deleteVariable('SIMULATION_CALLER_ADDRESS') } - await operations.setVariable(`READONLY=${config.readOnly}`) } function isRecord(value: unknown): value is Record { diff --git a/bots/midnight-crossed-books/test/scripts/railway.test.ts b/bots/midnight-crossed-books/test/scripts/railway.test.ts index b88a10a5..f18e899a 100644 --- a/bots/midnight-crossed-books/test/scripts/railway.test.ts +++ b/bots/midnight-crossed-books/test/scripts/railway.test.ts @@ -32,6 +32,9 @@ const targetResponse = { } } } +const variablesResponse = { + data: { variablesForServiceDeployment: { RESOLVER_PRIVATE_KEY: 'secret-value' } } +} const operations = () => ({ deleteVariable: vi.fn().mockResolvedValue(undefined), @@ -87,56 +90,78 @@ describe('Railway provisioning configuration', () => { ).toThrow(InvalidSimulationCallerAddressError) }) - test('removes a stale private key before readonly mode', async () => { - const railway = operations() + test('switches to readonly mode before deleting the stale private key', async () => { + const events: string[] = [] + const railway = { + deleteVariable: vi.fn(async name => { + events.push(`delete:${name}`) + }), + setSecret: vi.fn(async (name, _value) => { + events.push(`secret:${name}`) + }), + setVariable: vi.fn(async value => { + events.push(`set:${value}`) + }) + } await synchronizeModeVariables( { readOnly: true, resolverPrivateKey: undefined, simulationCaller: CALLER }, railway ) - expect(railway.deleteVariable).toHaveBeenCalledExactlyOnceWith('RESOLVER_PRIVATE_KEY') - expect(railway.setSecret).not.toHaveBeenCalled() - expect(railway.setVariable.mock.calls).toEqual([ - [`SIMULATION_CALLER_ADDRESS=${CALLER}`], - ['READONLY=true'] + expect(events).toEqual([ + `set:SIMULATION_CALLER_ADDRESS=${CALLER}`, + 'set:READONLY=true', + 'delete:RESOLVER_PRIVATE_KEY' ]) + expect(railway.setSecret).not.toHaveBeenCalled() }) - test('removes a stale caller before write mode', async () => { - const railway = operations() + test('switches to write mode before deleting the stale caller', async () => { + const events: string[] = [] + const railway = { + deleteVariable: vi.fn(async name => { + events.push(`delete:${name}`) + }), + setSecret: vi.fn(async (name, _value) => { + events.push(`secret:${name}`) + }), + setVariable: vi.fn(async value => { + events.push(`set:${value}`) + }) + } await synchronizeModeVariables( { readOnly: false, resolverPrivateKey: KEY, simulationCaller: undefined }, railway ) - expect(railway.deleteVariable).toHaveBeenCalledExactlyOnceWith('SIMULATION_CALLER_ADDRESS') - expect(railway.setSecret).toHaveBeenCalledExactlyOnceWith('RESOLVER_PRIVATE_KEY', KEY) - expect(railway.setVariable).toHaveBeenCalledExactlyOnceWith('READONLY=false') + expect(events).toEqual([ + 'secret:RESOLVER_PRIVATE_KEY', + 'set:READONLY=false', + 'delete:SIMULATION_CALLER_ADDRESS' + ]) }) - test('does not change readonly mode when stale-key deletion fails', async () => { + test('does not delete the private key when switching to readonly mode fails', async () => { const railway = operations() - railway.deleteVariable.mockRejectedValue(new Error('delete failed')) + railway.setVariable.mockRejectedValueOnce(new Error('set failed')) const config = resolveProvisioningConfiguration({ READONLY: 'true', SIMULATION_CALLER_ADDRESS: CALLER }) - await expect(synchronizeModeVariables(config, railway)).rejects.toThrow('delete failed') - expect(railway.setSecret).not.toHaveBeenCalled() - expect(railway.setVariable).not.toHaveBeenCalled() + await expect(synchronizeModeVariables(config, railway)).rejects.toThrow('set failed') + expect(railway.deleteVariable).not.toHaveBeenCalled() }) - test('does not change write mode when stale-caller deletion fails', async () => { + test('does not delete the caller when switching to write mode fails', async () => { const railway = operations() - railway.deleteVariable.mockRejectedValue(new Error('delete failed')) + railway.setVariable.mockRejectedValueOnce(new Error('set failed')) const config = resolveProvisioningConfiguration({ RESOLVER_PRIVATE_KEY: KEY }) - await expect(synchronizeModeVariables(config, railway)).rejects.toThrow('delete failed') - expect(railway.setSecret).not.toHaveBeenCalled() - expect(railway.setVariable).not.toHaveBeenCalled() + await expect(synchronizeModeVariables(config, railway)).rejects.toThrow('set failed') + expect(railway.deleteVariable).not.toHaveBeenCalled() }) test('wires fail-closed mode synchronization into Railway provisioning', () => { @@ -231,17 +256,18 @@ describe('Railway variable deletion API', () => { expect(() => resolveRailwayAccessToken({})).toThrow('RAILWAY_TOKEN or RAILWAY_API_TOKEN') }) - test('deletes an existing variable without an unsupported metadata preflight', async () => { + test('deletes an existing variable after confirming that its key exists', async () => { const fetcher = vi .fn() .mockResolvedValueOnce(jsonResponse(targetResponse)) + .mockResolvedValueOnce(jsonResponse(variablesResponse)) .mockResolvedValueOnce(jsonResponse({ data: { variableDelete: true } })) await expect( deleteRailwayVariable({ fetcher, name: 'RESOLVER_PRIVATE_KEY', target: TARGET, token: TOKEN }) ).resolves.toBe(true) - expect(fetcher).toHaveBeenCalledTimes(2) + expect(fetcher).toHaveBeenCalledTimes(3) expect(fetcher.mock.calls[0]?.[0]).toBe('https://backboard.railway.com/graphql/v2') expect(fetcher.mock.calls[0]?.[1]).toMatchObject({ headers: { 'content-type': 'application/json', 'project-access-token': 'railway-token' }, @@ -252,6 +278,13 @@ describe('Railway variable deletion API', () => { ) expect(requests[0]).toMatchObject({ variables: { projectId: 'project-id' } }) expect(requests[1]).toMatchObject({ + variables: { + environmentId: 'environment-id', + projectId: 'project-id', + serviceId: 'service-id' + } + }) + expect(requests[2]).toMatchObject({ variables: { environmentId: 'environment-id', name: 'RESOLVER_PRIVATE_KEY', @@ -263,14 +296,28 @@ describe('Railway variable deletion API', () => { expect(fetcher.mock.calls.every(([, init]) => init?.headers)).toBe(true) }) + test('treats an already-absent variable as an idempotent deletion', async () => { + const fetcher = vi + .fn() + .mockResolvedValueOnce(jsonResponse(targetResponse)) + .mockResolvedValueOnce( + jsonResponse({ data: { variablesForServiceDeployment: { OTHER_KEY: 'value' } } }) + ) + + await expect( + deleteRailwayVariable({ fetcher, name: 'RESOLVER_PRIVATE_KEY', target: TARGET, token: TOKEN }) + ).resolves.toBe(false) + expect(fetcher).toHaveBeenCalledTimes(2) + }) + test.each([ jsonResponse({ errors: [{ message: 'secret-bearing upstream failure' }] }), - jsonResponse({ data: { variableDelete: false } }), jsonResponse({ data: { variableDelete: true } }, 503) ])('fails closed with a named sanitized deletion error', async failure => { const fetcher = vi .fn() .mockResolvedValueOnce(jsonResponse(targetResponse)) + .mockResolvedValueOnce(jsonResponse(variablesResponse)) .mockResolvedValueOnce(failure) const result = deleteRailwayVariable({ @@ -284,12 +331,12 @@ describe('Railway variable deletion API', () => { await expect(result).rejects.not.toThrow('secret-bearing upstream failure') }) - test('uses no Railway variable list command or raw-value endpoint', () => { + test('uses no Railway variable list command that could print raw values', () => { const source = readFileSync(new URL('../../scripts/railway.ts', import.meta.url), 'utf8') expect(source).not.toContain("'variable',\n 'list'") expect(source).not.toContain('railway variable list') - expect(source).not.toContain('EnvironmentVariables') + expect(source).toContain('variablesForServiceDeployment') }) }) From 90e8d3225235710bda9eb99921da6be7697f475f Mon Sep 17 00:00:00 2001 From: "prd-carapulse[bot]" <264278285+prd-carapulse[bot]@users.noreply.github.com> Date: Thu, 13 Aug 2026 09:58:21 +0000 Subject: [PATCH 14/16] fix(midnight-crossed-books): scope Railway variable lookup --- .../midnight-crossed-books/scripts/railway.ts | 6 +-- .../test/scripts/railway.test.ts | 38 ++++++++++++++++--- 2 files changed, 36 insertions(+), 8 deletions(-) diff --git a/bots/midnight-crossed-books/scripts/railway.ts b/bots/midnight-crossed-books/scripts/railway.ts index 6e665671..0f996ee8 100644 --- a/bots/midnight-crossed-books/scripts/railway.ts +++ b/bots/midnight-crossed-books/scripts/railway.ts @@ -56,12 +56,12 @@ const TARGET_QUERY = `query RailwayVariableTarget($projectId: String!) { services { edges { node { id name } } } } }` -const VARIABLES_QUERY = `query VariablesForServiceDeployment( +const VARIABLES_QUERY = `query Variables( $projectId: String! $environmentId: String! $serviceId: String! ) { - variablesForServiceDeployment( + variables( projectId: $projectId environmentId: $environmentId serviceId: $serviceId @@ -176,7 +176,7 @@ export const deleteRailwayVariable = async ({ fetcher, token }) - const variables = recordField(listed, 'variablesForServiceDeployment') + const variables = recordField(listed, 'variables') if (!variables) throw error if (!Object.prototype.hasOwnProperty.call(variables, name)) return false diff --git a/bots/midnight-crossed-books/test/scripts/railway.test.ts b/bots/midnight-crossed-books/test/scripts/railway.test.ts index f18e899a..206dbc36 100644 --- a/bots/midnight-crossed-books/test/scripts/railway.test.ts +++ b/bots/midnight-crossed-books/test/scripts/railway.test.ts @@ -33,7 +33,7 @@ const targetResponse = { } } const variablesResponse = { - data: { variablesForServiceDeployment: { RESOLVER_PRIVATE_KEY: 'secret-value' } } + data: { variables: { RESOLVER_PRIVATE_KEY: 'secret-value' } } } const operations = () => ({ @@ -256,6 +256,35 @@ describe('Railway variable deletion API', () => { expect(() => resolveRailwayAccessToken({})).toThrow('RAILWAY_TOKEN or RAILWAY_API_TOKEN') }) + test('checks service-scoped variables before deleting an existing variable', async () => { + const fetcher = vi + .fn() + .mockResolvedValueOnce(jsonResponse(targetResponse)) + .mockResolvedValueOnce( + jsonResponse({ data: { variables: { RESOLVER_PRIVATE_KEY: 'secret-value' } } }) + ) + .mockResolvedValueOnce(jsonResponse({ data: { variableDelete: true } })) + + await expect( + deleteRailwayVariable({ fetcher, name: 'RESOLVER_PRIVATE_KEY', target: TARGET, token: TOKEN }) + ).resolves.toBe(true) + + const variableRequestBody = fetcher.mock.calls[1]?.[1]?.body + expect(typeof variableRequestBody).toBe('string') + if (typeof variableRequestBody !== 'string') throw new Error('Expected a JSON request body') + const variableRequest = JSON.parse(variableRequestBody) as { + query: string + variables: Record + } + expect(variableRequest.query).toContain('variables(') + expect(variableRequest.query).not.toContain('variablesForServiceDeployment') + expect(variableRequest.variables).toEqual({ + environmentId: 'environment-id', + projectId: 'project-id', + serviceId: 'service-id' + }) + }) + test('deletes an existing variable after confirming that its key exists', async () => { const fetcher = vi .fn() @@ -300,9 +329,7 @@ describe('Railway variable deletion API', () => { const fetcher = vi .fn() .mockResolvedValueOnce(jsonResponse(targetResponse)) - .mockResolvedValueOnce( - jsonResponse({ data: { variablesForServiceDeployment: { OTHER_KEY: 'value' } } }) - ) + .mockResolvedValueOnce(jsonResponse({ data: { variables: { OTHER_KEY: 'value' } } })) await expect( deleteRailwayVariable({ fetcher, name: 'RESOLVER_PRIVATE_KEY', target: TARGET, token: TOKEN }) @@ -336,7 +363,8 @@ describe('Railway variable deletion API', () => { expect(source).not.toContain("'variable',\n 'list'") expect(source).not.toContain('railway variable list') - expect(source).toContain('variablesForServiceDeployment') + expect(source).toContain('variables(') + expect(source).not.toContain('variablesForServiceDeployment') }) }) From 9f6fbb01344e8d5aae9e92fb6d66953e0b83e7d3 Mon Sep 17 00:00:00 2001 From: "prd-carapulse[bot]" <264278285+prd-carapulse[bot]@users.noreply.github.com> Date: Thu, 13 Aug 2026 10:19:53 +0000 Subject: [PATCH 15/16] fix(midnight-crossed-books): avoid reading stale signing keys --- .../scripts/deploy-railway.ts | 1 + .../midnight-crossed-books/scripts/railway.ts | 47 ++++++++++--------- .../test/scripts/railway.test.ts | 37 +++++++++++++++ 3 files changed, 64 insertions(+), 21 deletions(-) diff --git a/bots/midnight-crossed-books/scripts/deploy-railway.ts b/bots/midnight-crossed-books/scripts/deploy-railway.ts index 55c0cac7..4e27b76d 100644 --- a/bots/midnight-crossed-books/scripts/deploy-railway.ts +++ b/bots/midnight-crossed-books/scripts/deploy-railway.ts @@ -135,6 +135,7 @@ const deleteVariable = async (name: string) => { const deleted = await deleteRailwayVariable({ fetcher: fetch, name, + readValuesBeforeDelete: name !== 'RESOLVER_PRIVATE_KEY', target: VARIABLE_TARGET, token: resolveRailwayAccessToken(process.env) }) diff --git a/bots/midnight-crossed-books/scripts/railway.ts b/bots/midnight-crossed-books/scripts/railway.ts index 0f996ee8..fb5b6164 100644 --- a/bots/midnight-crossed-books/scripts/railway.ts +++ b/bots/midnight-crossed-books/scripts/railway.ts @@ -143,42 +143,47 @@ const resolveRailwayVariableTarget = async ({ } /** - * Idempotently deletes one Railway variable from an explicitly scoped deployment target. - * @param parameters - Fetch implementation, credential, exact target names, and variable name. - * @returns `true` when Railway accepts the deletion, or `false` when the variable is already absent. + * Deletes one Railway variable from an explicitly scoped deployment target. + * @param parameters - Fetch implementation, credential, exact target names, variable name, and + * whether a value-bearing existence check is safe before deletion. + * @returns `true` when Railway accepts the deletion, or `false` when a preflight proves the + * variable is already absent. * @throws `RailwayVariableOperationError` when target lookup, transport, or deletion fails. - * @remarks Railway's API exposes key existence through a value-bearing map. This function only - * checks the requested own key, never logs or returns the map, and skips deletion when absent. + * @remarks Set `readValuesBeforeDelete` to `false` for secrets that must not enter this process. */ export const deleteRailwayVariable = async ({ fetcher, name, + readValuesBeforeDelete = true, target, token }: { fetcher: typeof fetch name: string + readValuesBeforeDelete?: boolean target: RailwayVariableTarget token: RailwayAccessToken }) => { const error = new RailwayVariableOperationError('delete', name) const ids = await resolveRailwayVariableTarget({ error, fetcher, target, token }) - const listed = await postRailwayGraphql({ - body: { - query: VARIABLES_QUERY, - variables: { - environmentId: ids.environmentId, - projectId: target.projectId, - serviceId: ids.serviceId - } - }, - error, - fetcher, - token - }) - const variables = recordField(listed, 'variables') - if (!variables) throw error - if (!Object.prototype.hasOwnProperty.call(variables, name)) return false + if (readValuesBeforeDelete) { + const listed = await postRailwayGraphql({ + body: { + query: VARIABLES_QUERY, + variables: { + environmentId: ids.environmentId, + projectId: target.projectId, + serviceId: ids.serviceId + } + }, + error, + fetcher, + token + }) + const variables = recordField(listed, 'variables') + if (!variables) throw error + if (!Object.prototype.hasOwnProperty.call(variables, name)) return false + } const data = await postRailwayGraphql({ body: { diff --git a/bots/midnight-crossed-books/test/scripts/railway.test.ts b/bots/midnight-crossed-books/test/scripts/railway.test.ts index 206dbc36..19c4171e 100644 --- a/bots/midnight-crossed-books/test/scripts/railway.test.ts +++ b/bots/midnight-crossed-books/test/scripts/railway.test.ts @@ -177,6 +177,12 @@ describe('Railway provisioning configuration', () => { expect(deploy).not.toMatch(/Failed to set.*errorDetails/) }) + test('keeps stale signing key values out of readonly provisioning', () => { + const deploy = readFileSync(new URL('../../scripts/deploy-railway.ts', import.meta.url), 'utf8') + + expect(deploy).toContain("readValuesBeforeDelete: name !== 'RESOLVER_PRIVATE_KEY'") + }) + test('preserves fresh-service and CLI-login provisioning without an API token', () => { const deploy = readFileSync(new URL('../../scripts/deploy-railway.ts', import.meta.url), 'utf8') @@ -256,6 +262,37 @@ describe('Railway variable deletion API', () => { expect(() => resolveRailwayAccessToken({})).toThrow('RAILWAY_TOKEN or RAILWAY_API_TOKEN') }) + test('deletes a private key without reading Railway variable values', async () => { + const fetcher = vi + .fn() + .mockResolvedValueOnce(jsonResponse(targetResponse)) + .mockResolvedValueOnce(jsonResponse({ data: { variableDelete: true } })) + + await expect( + deleteRailwayVariable({ + fetcher, + name: 'RESOLVER_PRIVATE_KEY', + readValuesBeforeDelete: false, + target: TARGET, + token: TOKEN + }) + ).resolves.toBe(true) + + expect(fetcher).toHaveBeenCalledTimes(2) + const requests = fetcher.mock.calls.map(([, init]) => + JSON.parse(typeof init?.body === 'string' ? init.body : '') + ) + expect(requests.every(request => !request.query.includes('variables('))).toBe(true) + expect(requests[1]).toMatchObject({ + variables: { + environmentId: 'environment-id', + name: 'RESOLVER_PRIVATE_KEY', + projectId: 'project-id', + serviceId: 'service-id' + } + }) + }) + test('checks service-scoped variables before deleting an existing variable', async () => { const fetcher = vi .fn() From 5be87efca5f490e9579bb7c783c5d678adc60e8d Mon Sep 17 00:00:00 2001 From: "prd-carapulse[bot]" <264278285+prd-carapulse[bot]@users.noreply.github.com> Date: Thu, 13 Aug 2026 10:34:48 +0000 Subject: [PATCH 16/16] fix(midnight-crossed-books): make Railway deletes secret-safe --- .../scripts/deploy-railway.ts | 1 - .../midnight-crossed-books/scripts/railway.ts | 44 +----- .../test/scripts/railway.test.ts | 142 ++++-------------- 3 files changed, 38 insertions(+), 149 deletions(-) diff --git a/bots/midnight-crossed-books/scripts/deploy-railway.ts b/bots/midnight-crossed-books/scripts/deploy-railway.ts index 4e27b76d..55c0cac7 100644 --- a/bots/midnight-crossed-books/scripts/deploy-railway.ts +++ b/bots/midnight-crossed-books/scripts/deploy-railway.ts @@ -135,7 +135,6 @@ const deleteVariable = async (name: string) => { const deleted = await deleteRailwayVariable({ fetcher: fetch, name, - readValuesBeforeDelete: name !== 'RESOLVER_PRIVATE_KEY', target: VARIABLE_TARGET, token: resolveRailwayAccessToken(process.env) }) diff --git a/bots/midnight-crossed-books/scripts/railway.ts b/bots/midnight-crossed-books/scripts/railway.ts index fb5b6164..5f563fb2 100644 --- a/bots/midnight-crossed-books/scripts/railway.ts +++ b/bots/midnight-crossed-books/scripts/railway.ts @@ -56,17 +56,6 @@ const TARGET_QUERY = `query RailwayVariableTarget($projectId: String!) { services { edges { node { id name } } } } }` -const VARIABLES_QUERY = `query Variables( - $projectId: String! - $environmentId: String! - $serviceId: String! -) { - variables( - projectId: $projectId - environmentId: $environmentId - serviceId: $serviceId - ) -}` const VARIABLE_DELETE_MUTATION = `mutation RailwayVariableDelete( $projectId: String! $environmentId: String! @@ -144,47 +133,24 @@ const resolveRailwayVariableTarget = async ({ /** * Deletes one Railway variable from an explicitly scoped deployment target. - * @param parameters - Fetch implementation, credential, exact target names, variable name, and - * whether a value-bearing existence check is safe before deletion. - * @returns `true` when Railway accepts the deletion, or `false` when a preflight proves the - * variable is already absent. + * @param parameters - Fetch implementation, credential, exact target names, and variable name. + * @returns `true` when Railway deletes the variable, or `false` when it is already absent. * @throws `RailwayVariableOperationError` when target lookup, transport, or deletion fails. - * @remarks Set `readValuesBeforeDelete` to `false` for secrets that must not enter this process. + * @remarks Calls the key-only deletion mutation directly so no service variable values enter this process. */ export const deleteRailwayVariable = async ({ fetcher, name, - readValuesBeforeDelete = true, target, token }: { fetcher: typeof fetch name: string - readValuesBeforeDelete?: boolean target: RailwayVariableTarget token: RailwayAccessToken }) => { const error = new RailwayVariableOperationError('delete', name) const ids = await resolveRailwayVariableTarget({ error, fetcher, target, token }) - if (readValuesBeforeDelete) { - const listed = await postRailwayGraphql({ - body: { - query: VARIABLES_QUERY, - variables: { - environmentId: ids.environmentId, - projectId: target.projectId, - serviceId: ids.serviceId - } - }, - error, - fetcher, - token - }) - const variables = recordField(listed, 'variables') - if (!variables) throw error - if (!Object.prototype.hasOwnProperty.call(variables, name)) return false - } - const data = await postRailwayGraphql({ body: { query: VARIABLE_DELETE_MUTATION, @@ -199,8 +165,8 @@ export const deleteRailwayVariable = async ({ fetcher, token }) - if (!isRecord(data) || data.variableDelete !== true) throw error - return true + if (!isRecord(data) || typeof data.variableDelete !== 'boolean') throw error + return data.variableDelete } /** diff --git a/bots/midnight-crossed-books/test/scripts/railway.test.ts b/bots/midnight-crossed-books/test/scripts/railway.test.ts index 19c4171e..b8a9b5e8 100644 --- a/bots/midnight-crossed-books/test/scripts/railway.test.ts +++ b/bots/midnight-crossed-books/test/scripts/railway.test.ts @@ -32,9 +32,6 @@ const targetResponse = { } } } -const variablesResponse = { - data: { variables: { RESOLVER_PRIVATE_KEY: 'secret-value' } } -} const operations = () => ({ deleteVariable: vi.fn().mockResolvedValue(undefined), @@ -177,12 +174,6 @@ describe('Railway provisioning configuration', () => { expect(deploy).not.toMatch(/Failed to set.*errorDetails/) }) - test('keeps stale signing key values out of readonly provisioning', () => { - const deploy = readFileSync(new URL('../../scripts/deploy-railway.ts', import.meta.url), 'utf8') - - expect(deploy).toContain("readValuesBeforeDelete: name !== 'RESOLVER_PRIVATE_KEY'") - }) - test('preserves fresh-service and CLI-login provisioning without an API token', () => { const deploy = readFileSync(new URL('../../scripts/deploy-railway.ts', import.meta.url), 'utf8') @@ -262,114 +253,47 @@ describe('Railway variable deletion API', () => { expect(() => resolveRailwayAccessToken({})).toThrow('RAILWAY_TOKEN or RAILWAY_API_TOKEN') }) - test('deletes a private key without reading Railway variable values', async () => { + test.each(['RESOLVER_PRIVATE_KEY', 'SIMULATION_CALLER_ADDRESS'])( + 'deletes %s without reading Railway variable values', + async name => { + const fetcher = vi + .fn() + .mockResolvedValueOnce(jsonResponse(targetResponse)) + .mockResolvedValueOnce(jsonResponse({ data: { variableDelete: true } })) + + await expect( + deleteRailwayVariable({ fetcher, name, target: TARGET, token: TOKEN }) + ).resolves.toBe(true) + + expect(fetcher).toHaveBeenCalledTimes(2) + const requests = fetcher.mock.calls.map(([, init]) => + JSON.parse(typeof init?.body === 'string' ? init.body : '') + ) + expect(requests.every(request => !request.query.includes('variables('))).toBe(true) + expect(requests[1]).toMatchObject({ + variables: { + environmentId: 'environment-id', + name, + projectId: 'project-id', + serviceId: 'service-id' + } + }) + } + ) + + test('treats a false direct-delete result as an idempotent no-op', async () => { const fetcher = vi .fn() .mockResolvedValueOnce(jsonResponse(targetResponse)) - .mockResolvedValueOnce(jsonResponse({ data: { variableDelete: true } })) + .mockResolvedValueOnce(jsonResponse({ data: { variableDelete: false } })) await expect( deleteRailwayVariable({ fetcher, name: 'RESOLVER_PRIVATE_KEY', - readValuesBeforeDelete: false, target: TARGET, token: TOKEN }) - ).resolves.toBe(true) - - expect(fetcher).toHaveBeenCalledTimes(2) - const requests = fetcher.mock.calls.map(([, init]) => - JSON.parse(typeof init?.body === 'string' ? init.body : '') - ) - expect(requests.every(request => !request.query.includes('variables('))).toBe(true) - expect(requests[1]).toMatchObject({ - variables: { - environmentId: 'environment-id', - name: 'RESOLVER_PRIVATE_KEY', - projectId: 'project-id', - serviceId: 'service-id' - } - }) - }) - - test('checks service-scoped variables before deleting an existing variable', async () => { - const fetcher = vi - .fn() - .mockResolvedValueOnce(jsonResponse(targetResponse)) - .mockResolvedValueOnce( - jsonResponse({ data: { variables: { RESOLVER_PRIVATE_KEY: 'secret-value' } } }) - ) - .mockResolvedValueOnce(jsonResponse({ data: { variableDelete: true } })) - - await expect( - deleteRailwayVariable({ fetcher, name: 'RESOLVER_PRIVATE_KEY', target: TARGET, token: TOKEN }) - ).resolves.toBe(true) - - const variableRequestBody = fetcher.mock.calls[1]?.[1]?.body - expect(typeof variableRequestBody).toBe('string') - if (typeof variableRequestBody !== 'string') throw new Error('Expected a JSON request body') - const variableRequest = JSON.parse(variableRequestBody) as { - query: string - variables: Record - } - expect(variableRequest.query).toContain('variables(') - expect(variableRequest.query).not.toContain('variablesForServiceDeployment') - expect(variableRequest.variables).toEqual({ - environmentId: 'environment-id', - projectId: 'project-id', - serviceId: 'service-id' - }) - }) - - test('deletes an existing variable after confirming that its key exists', async () => { - const fetcher = vi - .fn() - .mockResolvedValueOnce(jsonResponse(targetResponse)) - .mockResolvedValueOnce(jsonResponse(variablesResponse)) - .mockResolvedValueOnce(jsonResponse({ data: { variableDelete: true } })) - - await expect( - deleteRailwayVariable({ fetcher, name: 'RESOLVER_PRIVATE_KEY', target: TARGET, token: TOKEN }) - ).resolves.toBe(true) - - expect(fetcher).toHaveBeenCalledTimes(3) - expect(fetcher.mock.calls[0]?.[0]).toBe('https://backboard.railway.com/graphql/v2') - expect(fetcher.mock.calls[0]?.[1]).toMatchObject({ - headers: { 'content-type': 'application/json', 'project-access-token': 'railway-token' }, - method: 'POST' - }) - const requests = fetcher.mock.calls.map(([, init]) => - JSON.parse(typeof init?.body === 'string' ? init.body : '') - ) - expect(requests[0]).toMatchObject({ variables: { projectId: 'project-id' } }) - expect(requests[1]).toMatchObject({ - variables: { - environmentId: 'environment-id', - projectId: 'project-id', - serviceId: 'service-id' - } - }) - expect(requests[2]).toMatchObject({ - variables: { - environmentId: 'environment-id', - name: 'RESOLVER_PRIVATE_KEY', - projectId: 'project-id', - serviceId: 'service-id' - } - }) - expect(requests.every(request => !request.query.includes('environment(id:'))).toBe(true) - expect(fetcher.mock.calls.every(([, init]) => init?.headers)).toBe(true) - }) - - test('treats an already-absent variable as an idempotent deletion', async () => { - const fetcher = vi - .fn() - .mockResolvedValueOnce(jsonResponse(targetResponse)) - .mockResolvedValueOnce(jsonResponse({ data: { variables: { OTHER_KEY: 'value' } } })) - - await expect( - deleteRailwayVariable({ fetcher, name: 'RESOLVER_PRIVATE_KEY', target: TARGET, token: TOKEN }) ).resolves.toBe(false) expect(fetcher).toHaveBeenCalledTimes(2) }) @@ -381,7 +305,6 @@ describe('Railway variable deletion API', () => { const fetcher = vi .fn() .mockResolvedValueOnce(jsonResponse(targetResponse)) - .mockResolvedValueOnce(jsonResponse(variablesResponse)) .mockResolvedValueOnce(failure) const result = deleteRailwayVariable({ @@ -395,12 +318,13 @@ describe('Railway variable deletion API', () => { await expect(result).rejects.not.toThrow('secret-bearing upstream failure') }) - test('uses no Railway variable list command that could print raw values', () => { + test('uses no Railway variable query or list command that could read raw values', () => { const source = readFileSync(new URL('../../scripts/railway.ts', import.meta.url), 'utf8') expect(source).not.toContain("'variable',\n 'list'") expect(source).not.toContain('railway variable list') - expect(source).toContain('variables(') + expect(source).not.toContain('query Variables(') + expect(source).not.toContain('variables(') expect(source).not.toContain('variablesForServiceDeployment') }) })