From e179a8d24d5e98842a9376b08c301aea631ebc9c Mon Sep 17 00:00:00 2001 From: s6pa1rta3n-lab Date: Tue, 8 Sep 2026 15:32:03 -0400 Subject: [PATCH] refactor(config): centralize environment access behind typed configuration --- apps/web/package.json | 1 + apps/web/src/components/PasskeyPanel.tsx | 5 +- apps/web/src/hooks/useDashboardData.ts | 5 +- apps/web/src/hooks/useLiveRound.ts | 13 +- apps/web/src/lib/chain.ts | 15 +- apps/web/src/lib/config.ts | 7 +- apps/web/src/passkey-config.ts | 12 +- apps/web/vite.config.ts | 2 + coverage.config.json | 1 + package.json | 6 +- packages/config/package.json | 31 + packages/config/src/browser.test.ts | 27 + packages/config/src/browser.ts | 18 + packages/config/src/errors.test.ts | 49 ++ packages/config/src/errors.ts | 87 +++ packages/config/src/index.ts | 47 ++ packages/config/src/readers.test.ts | 287 ++++++++ packages/config/src/readers.ts | 663 +++++++++++++++++++ packages/config/src/schema.test.ts | 34 + packages/config/src/schema.ts | 17 + packages/config/src/secret.test.ts | 48 ++ packages/config/src/secret.ts | 88 +++ packages/config/src/system.ts | 11 + packages/config/tsconfig.json | 13 + packages/sdk/package.json | 1 + packages/sdk/scripts/live-smoke.ts | 10 +- packages/sdk/scripts/mainnet-micro.ts | 22 +- packages/sdk/scripts/mainnet-ready.ts | 30 +- packages/sdk/scripts/mainnet-verify.ts | 19 +- packages/sdk/src/mainnet-readiness.ts | 3 +- packages/sdk/src/submitter.ts | 4 +- pnpm-lock.yaml | 40 ++ scripts/check-deploy-docs.mjs | 6 +- scripts/check-direct-env-access.mjs | 131 ++++ scripts/check-direct-env-access.test.mjs | 56 ++ services/agent/package.json | 1 + services/agent/scripts/agents-e2e.ts | 18 +- services/agent/scripts/usdc-setup.ts | 14 +- services/appraisal-api/package.json | 1 + services/appraisal-api/scripts/usdc-setup.ts | 15 +- services/appraisal-api/scripts/x402-e2e.ts | 12 +- services/appraisal-api/src/config.ts | 8 +- services/auction-template/package.json | 1 + services/auction-template/sealed-auction.ts | 10 +- services/drand-tools/package.json | 1 + services/drand-tools/src/quicknet.ts | 5 +- services/drand-tools/src/vectors.ts | 4 +- services/keeper/package.json | 1 + services/keeper/scripts/keeper-e2e.ts | 8 +- services/keeper/scripts/lifecycle-e2e.ts | 8 +- services/keeper/scripts/mainnet-settle.ts | 10 +- services/keeper/scripts/usdc-setup.ts | 12 +- services/keeper/src/dry-run.ts | 4 +- services/keeper/src/queue.ts | 7 +- services/keeper/src/serve.ts | 17 +- services/keeper/src/status-server.ts | 7 +- services/keeper/src/store.ts | 4 +- services/keeper/src/watch-loop.ts | 10 +- services/keeper/src/watch.ts | 11 +- services/receipt-cli/package.json | 1 + services/receipt-cli/src/index.ts | 9 +- 61 files changed, 1854 insertions(+), 154 deletions(-) create mode 100644 packages/config/package.json create mode 100644 packages/config/src/browser.test.ts create mode 100644 packages/config/src/browser.ts create mode 100644 packages/config/src/errors.test.ts create mode 100644 packages/config/src/errors.ts create mode 100644 packages/config/src/index.ts create mode 100644 packages/config/src/readers.test.ts create mode 100644 packages/config/src/readers.ts create mode 100644 packages/config/src/schema.test.ts create mode 100644 packages/config/src/schema.ts create mode 100644 packages/config/src/secret.test.ts create mode 100644 packages/config/src/secret.ts create mode 100644 packages/config/src/system.ts create mode 100644 packages/config/tsconfig.json create mode 100644 scripts/check-direct-env-access.mjs create mode 100644 scripts/check-direct-env-access.test.mjs diff --git a/apps/web/package.json b/apps/web/package.json index 673d14c8..fd96f05c 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -20,6 +20,7 @@ "@stellar/freighter-api": "^6.0.1", "@stellar/stellar-sdk": "^15.1.0", "@sub-rosa/agent": "workspace:*", + "@sub-rosa/config": "workspace:*", "@sub-rosa/sdk": "workspace:*", "@sub-rosa/time": "workspace:*", "@sub-rosa/tlock": "workspace:*", diff --git a/apps/web/src/components/PasskeyPanel.tsx b/apps/web/src/components/PasskeyPanel.tsx index 259f1c5a..865cfeb0 100644 --- a/apps/web/src/components/PasskeyPanel.tsx +++ b/apps/web/src/components/PasskeyPanel.tsx @@ -1,5 +1,5 @@ -// Copyright (c) 2026 Sub Rosa contributors import { useMemo, useState } from "react"; +import { getBrowserEnv } from "@sub-rosa/config/browser"; import { CAP_SAFETY_COPY } from "../demo/trace"; import { useTime } from "../lib/time"; import { @@ -46,7 +46,8 @@ export function PasskeyPanel() { } | null>(null); const walletWasmHash = resolvePasskeyWalletWasmHash(); - const rpId = import.meta.env.VITE_PASSKEY_RP_ID ?? window.location.hostname; + const env = getBrowserEnv(); + const rpId = env.VITE_PASSKEY_RP_ID ?? window.location.hostname; const passkeyAvailable = useMemo( () => typeof window !== "undefined" && diff --git a/apps/web/src/hooks/useDashboardData.ts b/apps/web/src/hooks/useDashboardData.ts index 9446c526..060227ec 100644 --- a/apps/web/src/hooks/useDashboardData.ts +++ b/apps/web/src/hooks/useDashboardData.ts @@ -1,5 +1,5 @@ -// Copyright (c) 2026 Sub Rosa contributors import { useCallback, useEffect, useState } from "react"; +import { getBrowserEnv } from "@sub-rosa/config/browser"; import type { DashboardData } from "../dashboard/types"; import { DASHBOARD_FIXTURE } from "../dashboard/fixture"; import { assertDashboardData } from "../dashboard/fixture-health-check"; @@ -42,7 +42,8 @@ export function isStale(fetchedAt: string | null | undefined, nowMs: number): bo export function useDashboardData(): UseDashboardDataResult { const { clock, scheduler } = useTime(); - const endpoint = import.meta.env.VITE_DASHBOARD_ENDPOINT as string | undefined; + const env = getBrowserEnv(); + const endpoint = env.VITE_DASHBOARD_ENDPOINT as string | undefined; const useFixture = !endpoint?.trim(); const [state, setState] = useState(() => ({ diff --git a/apps/web/src/hooks/useLiveRound.ts b/apps/web/src/hooks/useLiveRound.ts index 32d51dab..40401215 100644 --- a/apps/web/src/hooks/useLiveRound.ts +++ b/apps/web/src/hooks/useLiveRound.ts @@ -1,14 +1,15 @@ -// Copyright (c) 2026 Sub Rosa contributors import { useEffect, useState } from "react"; import type { Round, BidState } from "@sub-rosa/sdk"; +import { getBrowserEnv } from "@sub-rosa/config/browser"; import { useTime } from "../lib/time"; -const RPC = import.meta.env.VITE_RPC_URL ?? "https://soroban-testnet.stellar.org"; +const env = getBrowserEnv(); +const RPC = env.VITE_RPC_URL ?? "https://soroban-testnet.stellar.org"; const NETWORK = - import.meta.env.VITE_NETWORK_PASSPHRASE ?? "Test SDF Network ; September 2015"; -const CONTRACT = import.meta.env.VITE_CONTRACT_ID as string | undefined; -const ROUND_ID = import.meta.env.VITE_ROUND_ID - ? BigInt(import.meta.env.VITE_ROUND_ID) + env.VITE_NETWORK_PASSPHRASE ?? "Test SDF Network ; September 2015"; +const CONTRACT = env.VITE_CONTRACT_ID as string | undefined; +const ROUND_ID = env.VITE_ROUND_ID + ? BigInt(env.VITE_ROUND_ID) : undefined; export interface LiveSnapshot { diff --git a/apps/web/src/lib/chain.ts b/apps/web/src/lib/chain.ts index 9122896d..893f4542 100644 --- a/apps/web/src/lib/chain.ts +++ b/apps/web/src/lib/chain.ts @@ -1,4 +1,3 @@ -// Copyright (c) 2026 Sub Rosa contributors import { Buffer } from "buffer"; import { getAddress, @@ -7,17 +6,19 @@ import { } from "@stellar/freighter-api"; import { RoundContract } from "@sub-rosa/sdk"; import { useMemo } from "react"; +import { getBrowserEnv } from "@sub-rosa/config/browser"; import { formatEscrowAmount } from "./amount"; +const env = getBrowserEnv(); export const LOGO_SRC = "/sub-rosa-logo.png"; -export const RPC_URL = import.meta.env.VITE_RPC_URL ?? "https://soroban-testnet.stellar.org"; +export const RPC_URL = env.VITE_RPC_URL ?? "https://soroban-testnet.stellar.org"; export const NETWORK = - import.meta.env.VITE_NETWORK_PASSPHRASE ?? "Test SDF Network ; September 2015"; -export const CONTRACT_ID = import.meta.env.VITE_CONTRACT_ID; -export const ESCROW_TOKEN_LABEL = import.meta.env.VITE_ESCROW_TOKEN_LABEL ?? "token"; -export const DEFAULT_ROUND_ID = import.meta.env.VITE_ROUND_ID - ? BigInt(import.meta.env.VITE_ROUND_ID) + env.VITE_NETWORK_PASSPHRASE ?? "Test SDF Network ; September 2015"; +export const CONTRACT_ID = env.VITE_CONTRACT_ID; +export const ESCROW_TOKEN_LABEL = env.VITE_ESCROW_TOKEN_LABEL ?? "token"; +export const DEFAULT_ROUND_ID = env.VITE_ROUND_ID + ? BigInt(env.VITE_ROUND_ID) : null; /** Seconds between commit deadline and Drand round R (the “Wait for Drand R” UI phase). */ diff --git a/apps/web/src/lib/config.ts b/apps/web/src/lib/config.ts index e1e23f53..34943fae 100644 --- a/apps/web/src/lib/config.ts +++ b/apps/web/src/lib/config.ts @@ -1,4 +1,5 @@ -// Copyright (c) 2026 Sub Rosa contributors +import { getBrowserEnv } from "@sub-rosa/config/browser"; + export interface ConfigIssue { key: string; message: string; @@ -29,7 +30,7 @@ const PLACEHOLDER_VALUES: Record = { }; export function validatePublicConfig( - env: Record = import.meta.env, + env: Record = getBrowserEnv(), ): ConfigIssue[] { const issues: ConfigIssue[] = []; @@ -82,7 +83,7 @@ export function validatePublicConfig( } export function hasConfigIssues( - env: Record = import.meta.env, + env: Record = getBrowserEnv(), ): boolean { return validatePublicConfig(env).length > 0; } diff --git a/apps/web/src/passkey-config.ts b/apps/web/src/passkey-config.ts index 9bff2262..2fbf477c 100644 --- a/apps/web/src/passkey-config.ts +++ b/apps/web/src/passkey-config.ts @@ -1,17 +1,19 @@ -// Copyright (c) 2026 Sub Rosa contributors +import { getBrowserEnv } from "@sub-rosa/config/browser"; + +const env = getBrowserEnv(); + /** Public testnet smart-wallet WASM (passkey-kit demo). Not a secret. */ export const PASSKEY_TESTNET_WALLET_WASM_HASH = "ecd990f0b45ca6817149b6175f79b32efb442f35731985a084131e8265c4cd90"; export const PASSKEY_RPC_URL = - import.meta.env.VITE_RPC_URL ?? "https://soroban-testnet.stellar.org"; + env.VITE_RPC_URL ?? "https://soroban-testnet.stellar.org"; export const PASSKEY_NETWORK_PASSPHRASE = - import.meta.env.VITE_NETWORK_PASSPHRASE ?? "Test SDF Network ; September 2015"; + env.VITE_NETWORK_PASSPHRASE ?? "Test SDF Network ; September 2015"; export function resolvePasskeyWalletWasmHash(): string | undefined { - const fromEnv = import.meta.env.VITE_PASSKEY_WALLET_WASM_HASH?.trim(); + const fromEnv = env.VITE_PASSKEY_WALLET_WASM_HASH?.trim(); if (fromEnv) return fromEnv; - // Default for local jury demo — same hash as passkey-kit-demo on testnet. return PASSKEY_TESTNET_WALLET_WASM_HASH; } diff --git a/apps/web/vite.config.ts b/apps/web/vite.config.ts index acea4eb5..516da3a4 100644 --- a/apps/web/vite.config.ts +++ b/apps/web/vite.config.ts @@ -25,6 +25,8 @@ export default defineConfig({ events: require.resolve("events/"), "node:crypto": require.resolve("crypto-browserify"), "node:buffer": require.resolve("buffer/"), + "process/browser": require.resolve("process/browser"), + process: require.resolve("process/browser"), }, }, define: { diff --git a/coverage.config.json b/coverage.config.json index a7c1a7ba..0de7cad5 100644 --- a/coverage.config.json +++ b/coverage.config.json @@ -1,6 +1,7 @@ { "lineThresholdPercent": 70, "workspaces": [ + "packages/config", "packages/sdk", "packages/tlock", "packages/round-bindings", diff --git a/package.json b/package.json index 3619f968..6acdc189 100644 --- a/package.json +++ b/package.json @@ -81,6 +81,10 @@ "receipt:typecheck": "pnpm --filter @sub-rosa/receipt-cli typecheck", "time:test": "pnpm --filter @sub-rosa/time test", "time:guard": "node scripts/check-direct-time-access.mjs", - "time:guard:test": "node --test scripts/check-direct-time-access.test.mjs" + "time:guard:test": "node --test scripts/check-direct-time-access.test.mjs", + "config:test": "pnpm --filter @sub-rosa/config test", + "config:typecheck": "pnpm --filter @sub-rosa/config typecheck", + "config:guard": "node scripts/check-direct-env-access.mjs", + "config:guard:test": "node --test scripts/check-direct-env-access.test.mjs" } } diff --git a/packages/config/package.json b/packages/config/package.json new file mode 100644 index 00000000..362312a3 --- /dev/null +++ b/packages/config/package.json @@ -0,0 +1,31 @@ +{ + "name": "@sub-rosa/config", + "version": "0.1.0", + "private": true, + "type": "module", + "description": "Typed environment access boundary, declarative schemas, readers, and secret redaction.", + "repository": { + "type": "git", + "url": "https://github.com/Sub-Rosa-Issue/sub-rosa-issue.git", + "directory": "packages/config" + }, + "main": "src/index.ts", + "types": "src/index.ts", + "exports": { + ".": "./src/index.ts", + "./system": "./src/system.ts", + "./browser": "./src/browser.ts" + }, + "scripts": { + "test": "node --import tsx --test src/readers.test.ts src/secret.test.ts src/errors.test.ts src/schema.test.ts src/browser.test.ts", + "typecheck": "tsc --noEmit -p tsconfig.json" + }, + "dependencies": { + "@stellar/stellar-sdk": "^15.1.0" + }, + "devDependencies": { + "@types/node": "^25.9.1", + "tsx": "^4.22.4", + "typescript": "^6.0.3" + } +} diff --git a/packages/config/src/browser.test.ts b/packages/config/src/browser.test.ts new file mode 100644 index 00000000..985dd9b7 --- /dev/null +++ b/packages/config/src/browser.test.ts @@ -0,0 +1,27 @@ +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; +import { getBrowserEnv } from "./browser.js"; +import { ConfigError } from "./errors.js"; +import { readBrowserPublic } from "./readers.js"; + +describe("readBrowserPublic and getBrowserEnv", () => { + it("enforces VITE_ prefix on browser-facing variables", () => { + const val = readBrowserPublic({ VITE_RPC_URL: "https://rpc.example.com" }, "VITE_RPC_URL"); + assert.equal(val, "https://rpc.example.com"); + + assert.throws( + () => readBrowserPublic({ SECRET_KEY: "secret" }, "SECRET_KEY"), + (err: unknown) => { + assert.ok(err instanceof ConfigError); + assert.match(err.message, /must start with VITE_ prefix/); + return true; + }, + ); + }); + + it("reads browser environment with injection support", () => { + const custom = { VITE_API: "https://api.example.com" }; + const env = getBrowserEnv(custom); + assert.equal(env.VITE_API, "https://api.example.com"); + }); +}); diff --git a/packages/config/src/browser.ts b/packages/config/src/browser.ts new file mode 100644 index 00000000..2461cad0 --- /dev/null +++ b/packages/config/src/browser.ts @@ -0,0 +1,18 @@ +/** + * Approved bootstrap adapter reading the Vite browser environment. + * + * @param customEnv Optional environment map override for testing. + * @returns Record of environment variable keys and values. + */ +export function getBrowserEnv( + customEnv?: Record, +): Record { + if (customEnv) { + return customEnv; + } + try { + return (import.meta as unknown as { env?: Record }).env ?? {}; + } catch { + return {}; + } +} diff --git a/packages/config/src/errors.test.ts b/packages/config/src/errors.test.ts new file mode 100644 index 00000000..ff79991e --- /dev/null +++ b/packages/config/src/errors.test.ts @@ -0,0 +1,49 @@ +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; +import { + ConfigError, + EmptyEnvironmentVariableError, + MalformedEnvironmentVariableError, + MissingEnvironmentVariableError, +} from "./errors.js"; + +describe("ConfigError hierarchy", () => { + it("formats error messages with key prefix and code", () => { + const error = new ConfigError("TEST_VAR", "something failed", "MALFORMED"); + assert.equal(error.key, "TEST_VAR"); + assert.equal(error.variable, "TEST_VAR"); + assert.equal(error.code, "MALFORMED"); + assert.equal(error.message, "TEST_VAR: something failed"); + assert.ok(error instanceof Error); + assert.ok(error instanceof ConfigError); + }); + + it("distinguishes missing environment variables", () => { + const error = new MissingEnvironmentVariableError("PORT"); + assert.equal(error.key, "PORT"); + assert.equal(error.code, "MISSING"); + assert.equal(error.message, "PORT: required environment variable is missing"); + assert.ok(error instanceof ConfigError); + assert.ok(error instanceof MissingEnvironmentVariableError); + }); + + it("distinguishes empty environment variables", () => { + const error = new EmptyEnvironmentVariableError("RPC_URL"); + assert.equal(error.key, "RPC_URL"); + assert.equal(error.code, "EMPTY"); + assert.equal(error.message, "RPC_URL: environment variable cannot be empty"); + assert.ok(error instanceof ConfigError); + assert.ok(error instanceof EmptyEnvironmentVariableError); + }); + + it("distinguishes malformed environment variables", () => { + const cause = new Error("inner parser error"); + const error = new MalformedEnvironmentVariableError("TIMEOUT", "must be an integer", { cause }); + assert.equal(error.key, "TIMEOUT"); + assert.equal(error.code, "MALFORMED"); + assert.equal(error.message, "TIMEOUT: must be an integer"); + assert.equal(error.cause, cause); + assert.ok(error instanceof ConfigError); + assert.ok(error instanceof MalformedEnvironmentVariableError); + }); +}); diff --git a/packages/config/src/errors.ts b/packages/config/src/errors.ts new file mode 100644 index 00000000..270064e5 --- /dev/null +++ b/packages/config/src/errors.ts @@ -0,0 +1,87 @@ +/** + * Configuration error codes categorizing environment read failures. + */ +export type ConfigErrorCode = "MISSING" | "EMPTY" | "MALFORMED"; + +/** + * Base error class for all configuration read and validation failures. + */ +export class ConfigError extends Error { + readonly key: string; + readonly variable: string; + readonly code: ConfigErrorCode; + + /** + * Constructs a new ConfigError. + * + * @param key Environment variable key name. + * @param message Failure description without secrets. + * @param code Error category code. + * @param options Standard ErrorOptions containing cause. + */ + constructor( + key: string, + message: string, + code: ConfigErrorCode = "MALFORMED", + options?: ErrorOptions, + ) { + super(`${key}: ${message}`, options); + this.name = "ConfigError"; + this.key = key; + this.variable = key; + this.code = code; + Object.setPrototypeOf(this, new.target.prototype); + } +} + +/** + * Error thrown when a required environment variable is not defined. + */ +export class MissingEnvironmentVariableError extends ConfigError { + /** + * Constructs a new MissingEnvironmentVariableError. + * + * @param key Environment variable key name. + * @param message Optional detail message. + */ + constructor(key: string, message: string = "required environment variable is missing") { + super(key, message, "MISSING"); + this.name = "MissingEnvironmentVariableError"; + Object.setPrototypeOf(this, new.target.prototype); + } +} + +/** + * Error thrown when an environment variable is defined but contains only whitespace. + */ +export class EmptyEnvironmentVariableError extends ConfigError { + /** + * Constructs a new EmptyEnvironmentVariableError. + * + * @param key Environment variable key name. + * @param message Optional detail message. + */ + constructor(key: string, message: string = "environment variable cannot be empty") { + super(key, message, "EMPTY"); + this.name = "EmptyEnvironmentVariableError"; + Object.setPrototypeOf(this, new.target.prototype); + } +} + +/** + * Error thrown when an environment variable fails format or type parsing. + */ +export class MalformedEnvironmentVariableError extends ConfigError { + /** + * Constructs a new MalformedEnvironmentVariableError. + * + * @param key Environment variable key name. + * @param message Parsing failure explanation without sensitive data. + * @param options Standard ErrorOptions containing cause. + */ + constructor(key: string, message: string, options?: ErrorOptions) { + super(key, message, "MALFORMED", options); + this.name = "MalformedEnvironmentVariableError"; + Object.setPrototypeOf(this, new.target.prototype); + } +} diff --git a/packages/config/src/index.ts b/packages/config/src/index.ts new file mode 100644 index 00000000..1b556b14 --- /dev/null +++ b/packages/config/src/index.ts @@ -0,0 +1,47 @@ +export { + ConfigError, + type ConfigErrorCode, + EmptyEnvironmentVariableError, + MalformedEnvironmentVariableError, + MissingEnvironmentVariableError, +} from "./errors.js"; + +export { + isSecret, + secret, + SecretValue, +} from "./secret.js"; + +export { + type BooleanReaderOptions, + type EnumReaderOptions, + type IntegerReaderOptions, + type NumberReaderOptions, + type SecretReaderOptions, + type StellarIdentifierReaderOptions, + type StringReaderOptions, + type UrlReaderOptions, + readBoolean, + readBrowserPublic, + readEnum, + readInteger, + readNumber, + readSecret, + readStellarContractId, + readStellarPublicKey, + readStellarSecretKey, + readString, + readUrl, +} from "./readers.js"; + +export { + defineSchema, +} from "./schema.js"; + +export { + getSystemEnv, +} from "./system.js"; + +export { + getBrowserEnv, +} from "./browser.js"; diff --git a/packages/config/src/readers.test.ts b/packages/config/src/readers.test.ts new file mode 100644 index 00000000..e9f18ca0 --- /dev/null +++ b/packages/config/src/readers.test.ts @@ -0,0 +1,287 @@ +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; +import { Keypair } from "@stellar/stellar-sdk"; +import { + EmptyEnvironmentVariableError, + MalformedEnvironmentVariableError, + MissingEnvironmentVariableError, +} from "./errors.js"; +import { + readBoolean, + readEnum, + readInteger, + readNumber, + readSecret, + readStellarContractId, + readStellarPublicKey, + readStellarSecretKey, + readString, + readUrl, +} from "./readers.js"; +import { isSecret } from "./secret.js"; + +const TEST_KEYPAIR = Keypair.random(); +const VALID_PUBKEY = TEST_KEYPAIR.publicKey(); +const VALID_SECRET = TEST_KEYPAIR.secret(); +const VALID_CONTRACT = "CA7KSDEYJEPGZEB2ZROTLUWKQQ6GIRIQNGG6Z745MZ34QHP4UJPWODEX"; + +describe("readString", () => { + it("reads present string and trims whitespace", () => { + const val = readString({ NAME: " sub-rosa " }, "NAME"); + assert.equal(val, "sub-rosa"); + }); + + it("returns default value when variable is missing", () => { + const val = readString({}, "NAME", { default: "fallback" }); + assert.equal(val, "fallback"); + }); + + it("throws MissingEnvironmentVariableError when required variable is absent", () => { + assert.throws( + () => readString({}, "NAME", { required: true }), + (err: unknown) => { + assert.ok(err instanceof MissingEnvironmentVariableError); + assert.equal(err.key, "NAME"); + return true; + }, + ); + }); + + it("throws EmptyEnvironmentVariableError when variable contains only whitespace", () => { + assert.throws( + () => readString({ NAME: " " }, "NAME", { required: true }), + (err: unknown) => { + assert.ok(err instanceof EmptyEnvironmentVariableError); + assert.equal(err.key, "NAME"); + return true; + }, + ); + }); + + it("allows empty string when allowEmpty is true", () => { + const val = readString({ NAME: "" }, "NAME", { allowEmpty: true }); + assert.equal(val, ""); + }); +}); + +describe("readBoolean", () => { + it("parses valid true representations", () => { + assert.equal(readBoolean({ FLAG: "true" }, "FLAG"), true); + assert.equal(readBoolean({ FLAG: "TRUE" }, "FLAG"), true); + assert.equal(readBoolean({ FLAG: "1" }, "FLAG"), true); + }); + + it("parses valid false representations", () => { + assert.equal(readBoolean({ FLAG: "false" }, "FLAG"), false); + assert.equal(readBoolean({ FLAG: "FALSE" }, "FLAG"), false); + assert.equal(readBoolean({ FLAG: "0" }, "FLAG"), false); + }); + + it("falls back to default boolean when omitted", () => { + assert.equal(readBoolean({}, "FLAG", { default: true }), true); + assert.equal(readBoolean({}, "FLAG", { default: false }), false); + }); + + it("throws MalformedEnvironmentVariableError on invalid boolean input", () => { + assert.throws( + () => readBoolean({ FLAG: "yes" }, "FLAG"), + (err: unknown) => { + assert.ok(err instanceof MalformedEnvironmentVariableError); + assert.equal(err.key, "FLAG"); + return true; + }, + ); + }); +}); + +describe("readInteger", () => { + it("parses valid base-10 integer", () => { + assert.equal(readInteger({ PORT: "8080" }, "PORT"), 8080); + assert.equal(readInteger({ COUNT: "-5" }, "COUNT"), -5); + }); + + it("applies default when missing", () => { + assert.equal(readInteger({}, "PORT", { default: 3000 }), 3000); + }); + + it("rejects non-integer strings and floats", () => { + assert.throws( + () => readInteger({ PORT: "8080abc" }, "PORT"), + (err: unknown) => { + assert.ok(err instanceof MalformedEnvironmentVariableError); + assert.equal(err.key, "PORT"); + return true; + }, + ); + assert.throws( + () => readInteger({ PORT: "80.5" }, "PORT"), + (err: unknown) => { + assert.ok(err instanceof MalformedEnvironmentVariableError); + return true; + }, + ); + }); + + it("enforces min and max bounds", () => { + assert.throws( + () => readInteger({ PORT: "10" }, "PORT", { min: 1024 }), + (err: unknown) => { + assert.ok(err instanceof MalformedEnvironmentVariableError); + assert.match(err.message, /at least 1024/); + return true; + }, + ); + assert.throws( + () => readInteger({ PORT: "70000" }, "PORT", { max: 65535 }), + (err: unknown) => { + assert.ok(err instanceof MalformedEnvironmentVariableError); + assert.match(err.message, /at most 65535/); + return true; + }, + ); + }); +}); + +describe("readNumber", () => { + it("parses positive floating point numbers", () => { + assert.equal(readNumber({ PRICE: "0.15" }, "PRICE", { positive: true }), 0.15); + }); + + it("rejects non-positive numbers when positive is requested", () => { + assert.throws( + () => readNumber({ PRICE: "0" }, "PRICE", { positive: true }), + (err: unknown) => { + assert.ok(err instanceof MalformedEnvironmentVariableError); + return true; + }, + ); + assert.throws( + () => readNumber({ PRICE: "-1.5" }, "PRICE", { positive: true }), + (err: unknown) => { + assert.ok(err instanceof MalformedEnvironmentVariableError); + return true; + }, + ); + }); +}); + +describe("readUrl", () => { + it("parses valid http/https URLs and normalizes trailing slashes", () => { + assert.equal( + readUrl({ RPC: "https://stellar.org/rpc/" }, "RPC"), + "https://stellar.org/rpc", + ); + assert.equal( + readUrl({ RPC: "http://localhost:8000/" }, "RPC"), + "http://localhost:8000", + ); + }); + + it("applies default URL when missing", () => { + assert.equal( + readUrl({}, "RPC", { default: "https://default.stellar.org" }), + "https://default.stellar.org", + ); + }); + + it("rejects invalid URLs and unsupported protocols", () => { + assert.throws( + () => readUrl({ RPC: "not-a-url" }, "RPC"), + (err: unknown) => { + assert.ok(err instanceof MalformedEnvironmentVariableError); + return true; + }, + ); + assert.throws( + () => readUrl({ RPC: "ftp://example.com" }, "RPC"), + (err: unknown) => { + assert.ok(err instanceof MalformedEnvironmentVariableError); + return true; + }, + ); + }); + + it("rejects URLs with credentials when requireNoCredentials is set", () => { + assert.throws( + () => readUrl({ RPC: "https://user:pass@example.com" }, "RPC", { requireNoCredentials: true }), + (err: unknown) => { + assert.ok(err instanceof MalformedEnvironmentVariableError); + assert.match(err.message, /must not contain credentials/); + return true; + }, + ); + }); +}); + +describe("readEnum", () => { + const NETWORKS = ["stellar:testnet", "stellar:pubnet"] as const; + + it("parses valid enum option", () => { + assert.equal(readEnum({ NET: "stellar:testnet" }, "NET", NETWORKS), "stellar:testnet"); + }); + + it("falls back to default enum", () => { + assert.equal(readEnum({}, "NET", NETWORKS, { default: "stellar:pubnet" }), "stellar:pubnet"); + }); + + it("rejects disallowed enum variant", () => { + assert.throws( + () => readEnum({ NET: "stellar:local" }, "NET", NETWORKS), + (err: unknown) => { + assert.ok(err instanceof MalformedEnvironmentVariableError); + assert.match(err.message, /must be one of: stellar:testnet, stellar:pubnet/); + return true; + }, + ); + }); +}); + +describe("Stellar identifiers and secrets", () => { + it("validates Stellar public keys", () => { + assert.equal(readStellarPublicKey({ PUB: VALID_PUBKEY }, "PUB"), VALID_PUBKEY); + assert.throws( + () => readStellarPublicKey({ PUB: "GNOTVALID" }, "PUB"), + (err: unknown) => { + assert.ok(err instanceof MalformedEnvironmentVariableError); + assert.match(err.message, /must be a valid Stellar G\.\.\. account address/); + return true; + }, + ); + }); + + it("validates Stellar contract addresses", () => { + assert.equal(readStellarContractId({ CONTRACT: VALID_CONTRACT }, "CONTRACT"), VALID_CONTRACT); + assert.throws( + () => readStellarContractId({ CONTRACT: "CNOTVALID" }, "CONTRACT"), + (err: unknown) => { + assert.ok(err instanceof MalformedEnvironmentVariableError); + assert.match(err.message, /must be a valid Stellar C\.\.\. contract address/); + return true; + }, + ); + }); + + it("validates and wraps Stellar secret keys without exposing secret on error", () => { + const wrapped = readStellarSecretKey({ SEC: VALID_SECRET }, "SEC", { required: true }); + assert.ok(isSecret(wrapped)); + assert.equal(wrapped.unwrap(), VALID_SECRET); + assert.equal(wrapped.toString(), "[REDACTED]"); + + assert.throws( + () => readStellarSecretKey({ SEC: "SBADSECRET123" }, "SEC", { required: true }), + (err: unknown) => { + assert.ok(err instanceof MalformedEnvironmentVariableError); + assert.match(err.message, /must be a valid Stellar secret key/); + assert.ok(!err.message.includes("SBADSECRET123")); + return true; + }, + ); + }); + + it("reads generic secrets as SecretValue container", () => { + const wrapped = readSecret({ API_KEY: "my-api-key" }, "API_KEY", { required: true }); + assert.ok(isSecret(wrapped)); + assert.equal(wrapped.unwrap(), "my-api-key"); + assert.equal(wrapped.toString(), "[REDACTED]"); + }); +}); diff --git a/packages/config/src/readers.ts b/packages/config/src/readers.ts new file mode 100644 index 00000000..a5cfb79f --- /dev/null +++ b/packages/config/src/readers.ts @@ -0,0 +1,663 @@ +import { Keypair, StrKey } from "@stellar/stellar-sdk"; +import { + ConfigError, + EmptyEnvironmentVariableError, + MalformedEnvironmentVariableError, + MissingEnvironmentVariableError, +} from "./errors.js"; +import { secret, SecretValue } from "./secret.js"; + +/** + * Options configuring string reading behavior. + */ +export interface StringReaderOptions { + readonly required?: boolean; + readonly default?: string; + readonly trim?: boolean; + readonly allowEmpty?: boolean; +} + +/** + * Options configuring boolean reading behavior. + */ +export interface BooleanReaderOptions { + readonly required?: boolean; + readonly default?: boolean; +} + +/** + * Options configuring integer reading behavior. + */ +export interface IntegerReaderOptions { + readonly required?: boolean; + readonly default?: number; + readonly min?: number; + readonly max?: number; +} + +/** + * Options configuring floating-point number reading behavior. + */ +export interface NumberReaderOptions { + readonly required?: boolean; + readonly default?: number; + readonly min?: number; + readonly max?: number; + readonly positive?: boolean; +} + +/** + * Options configuring URL reading behavior. + */ +export interface UrlReaderOptions { + readonly required?: boolean; + readonly default?: string; + readonly normalizeTrailingSlash?: boolean; + readonly requireNoCredentials?: boolean; + readonly allowedProtocols?: readonly string[]; +} + +/** + * Options configuring enum reading behavior. + */ +export interface EnumReaderOptions { + readonly required?: boolean; + readonly default?: T; +} + +/** + * Options configuring Stellar identifier reading behavior. + */ +export interface StellarIdentifierReaderOptions { + readonly required?: boolean; + readonly default?: string; +} + +/** + * Options configuring secret reading behavior. + */ +export interface SecretReaderOptions { + readonly required?: boolean; + readonly default?: string; +} + +/** + * Reads a string value from an environment map with trimming and presence checks. + * + * @param env Environment map. + * @param key Variable name. + * @param options Configuration options. + * @returns Parsed string or undefined. + */ +export function readString( + env: Record, + key: string, + options: StringReaderOptions & { required: true }, +): string; +export function readString( + env: Record, + key: string, + options: StringReaderOptions & { default: string }, +): string; +export function readString( + env: Record, + key: string, + options?: StringReaderOptions, +): string | undefined; +export function readString( + env: Record, + key: string, + options: StringReaderOptions = {}, +): string | undefined { + const raw = env[key]; + if (raw === undefined) { + if (options.required) { + throw new MissingEnvironmentVariableError(key); + } + return options.default; + } + + const shouldTrim = options.trim ?? true; + const processed = shouldTrim ? raw.trim() : raw; + + if (processed === "") { + if (options.allowEmpty) { + return processed; + } + if (options.required) { + throw new EmptyEnvironmentVariableError(key); + } + return options.default; + } + + return processed; +} + +/** + * Reads and parses a boolean value from an environment map. + * + * @param env Environment map. + * @param key Variable name. + * @param options Configuration options. + * @returns Parsed boolean or undefined. + */ +export function readBoolean( + env: Record, + key: string, + options: BooleanReaderOptions & { required: true }, +): boolean; +export function readBoolean( + env: Record, + key: string, + options: BooleanReaderOptions & { default: boolean }, +): boolean; +export function readBoolean( + env: Record, + key: string, + options?: BooleanReaderOptions, +): boolean | undefined; +export function readBoolean( + env: Record, + key: string, + options: BooleanReaderOptions = {}, +): boolean | undefined { + const str = readString(env, key, { + required: options.required, + default: options.default !== undefined ? String(options.default) : undefined, + }); + + if (str === undefined) { + return options.default; + } + + const normalized = str.toLowerCase(); + if (normalized === "true" || normalized === "1") { + return true; + } + if (normalized === "false" || normalized === "0") { + return false; + } + + throw new MalformedEnvironmentVariableError( + key, + "must be a boolean ('true', 'false', '1', '0')", + ); +} + +/** + * Reads and parses a base-10 integer from an environment map. + * + * @param env Environment map. + * @param key Variable name. + * @param options Configuration options. + * @returns Parsed integer or undefined. + */ +export function readInteger( + env: Record, + key: string, + options: IntegerReaderOptions & { required: true }, +): number; +export function readInteger( + env: Record, + key: string, + options: IntegerReaderOptions & { default: number }, +): number; +export function readInteger( + env: Record, + key: string, + options?: IntegerReaderOptions, +): number | undefined; +export function readInteger( + env: Record, + key: string, + options: IntegerReaderOptions = {}, +): number | undefined { + const str = readString(env, key, { + required: options.required, + default: options.default !== undefined ? String(options.default) : undefined, + }); + + if (str === undefined) { + return options.default; + } + + if (!/^-?\d+$/.test(str)) { + throw new MalformedEnvironmentVariableError( + key, + "must be a valid base-10 integer", + ); + } + + const parsed = Number.parseInt(str, 10); + if (!Number.isSafeInteger(parsed)) { + throw new MalformedEnvironmentVariableError( + key, + "must be a safe base-10 integer", + ); + } + + if (options.min !== undefined && parsed < options.min) { + throw new MalformedEnvironmentVariableError( + key, + `must be at least ${options.min}`, + ); + } + if (options.max !== undefined && parsed > options.max) { + throw new MalformedEnvironmentVariableError( + key, + `must be at most ${options.max}`, + ); + } + + return parsed; +} + +/** + * Reads and parses a floating-point number from an environment map. + * + * @param env Environment map. + * @param key Variable name. + * @param options Configuration options. + * @returns Parsed number or undefined. + */ +export function readNumber( + env: Record, + key: string, + options: NumberReaderOptions & { required: true }, +): number; +export function readNumber( + env: Record, + key: string, + options: NumberReaderOptions & { default: number }, +): number; +export function readNumber( + env: Record, + key: string, + options?: NumberReaderOptions, +): number | undefined; +export function readNumber( + env: Record, + key: string, + options: NumberReaderOptions = {}, +): number | undefined { + const str = readString(env, key, { + required: options.required, + default: options.default !== undefined ? String(options.default) : undefined, + }); + + if (str === undefined) { + return options.default; + } + + const parsed = Number(str); + if (!Number.isFinite(parsed)) { + throw new MalformedEnvironmentVariableError( + key, + "must be a valid finite number", + ); + } + + if (options.positive && parsed <= 0) { + throw new MalformedEnvironmentVariableError( + key, + "must be a positive number", + ); + } + if (options.min !== undefined && parsed < options.min) { + throw new MalformedEnvironmentVariableError( + key, + `must be at least ${options.min}`, + ); + } + if (options.max !== undefined && parsed > options.max) { + throw new MalformedEnvironmentVariableError( + key, + `must be at most ${options.max}`, + ); + } + + return parsed; +} + +/** + * Reads and validates an HTTP or HTTPS URL from an environment map. + * + * @param env Environment map. + * @param key Variable name. + * @param options Configuration options. + * @returns Validated URL string or undefined. + */ +export function readUrl( + env: Record, + key: string, + options: UrlReaderOptions & { required: true }, +): string; +export function readUrl( + env: Record, + key: string, + options: UrlReaderOptions & { default: string }, +): string; +export function readUrl( + env: Record, + key: string, + options?: UrlReaderOptions, +): string | undefined; +export function readUrl( + env: Record, + key: string, + options: UrlReaderOptions = {}, +): string | undefined { + const str = readString(env, key, { + required: options.required, + default: options.default, + }); + + if (str === undefined) { + return options.default; + } + + let parsedUrl: URL; + try { + parsedUrl = new URL(str); + } catch (cause) { + throw new MalformedEnvironmentVariableError( + key, + "must be a valid absolute URL", + { cause }, + ); + } + + const allowed = options.allowedProtocols ?? ["http:", "https:"]; + if (!allowed.includes(parsedUrl.protocol)) { + throw new MalformedEnvironmentVariableError( + key, + `protocol must be one of: ${allowed.join(", ")}`, + ); + } + + if (options.requireNoCredentials && (parsedUrl.username || parsedUrl.password)) { + throw new MalformedEnvironmentVariableError( + key, + "must not contain credentials", + ); + } + + const normalizeTrailing = options.normalizeTrailingSlash ?? true; + const serialized = parsedUrl.toString(); + return normalizeTrailing ? serialized.replace(/\/+$/, "") : serialized; +} + +/** + * Reads and validates an enum string from an environment map. + * + * @param env Environment map. + * @param key Variable name. + * @param allowedValues Array of permitted string variants. + * @param options Configuration options. + * @returns Validated enum variant or undefined. + */ +export function readEnum( + env: Record, + key: string, + allowedValues: readonly T[], + options: EnumReaderOptions & { required: true }, +): T; +export function readEnum( + env: Record, + key: string, + allowedValues: readonly T[], + options: EnumReaderOptions & { default: T }, +): T; +export function readEnum( + env: Record, + key: string, + allowedValues: readonly T[], + options?: EnumReaderOptions, +): T | undefined; +export function readEnum( + env: Record, + key: string, + allowedValues: readonly T[], + options: EnumReaderOptions = {}, +): T | undefined { + const str = readString(env, key, { + required: options.required, + default: options.default, + }); + + if (str === undefined) { + return options.default; + } + + if (!(allowedValues as readonly string[]).includes(str)) { + throw new MalformedEnvironmentVariableError( + key, + `must be one of: ${allowedValues.join(", ")}`, + ); + } + + return str as T; +} + +/** + * Reads and validates a Stellar Ed25519 public account address (G...). + * + * @param env Environment map. + * @param key Variable name. + * @param options Configuration options. + * @returns Validated Stellar public key or undefined. + */ +export function readStellarPublicKey( + env: Record, + key: string, + options: StellarIdentifierReaderOptions & { required: true }, +): string; +export function readStellarPublicKey( + env: Record, + key: string, + options: StellarIdentifierReaderOptions & { default: string }, +): string; +export function readStellarPublicKey( + env: Record, + key: string, + options?: StellarIdentifierReaderOptions, +): string | undefined; +export function readStellarPublicKey( + env: Record, + key: string, + options: StellarIdentifierReaderOptions = {}, +): string | undefined { + const str = readString(env, key, { + required: options.required, + default: options.default, + }); + + if (str === undefined) { + return options.default; + } + + if (!StrKey.isValidEd25519PublicKey(str)) { + throw new MalformedEnvironmentVariableError( + key, + "must be a valid Stellar G... account address", + ); + } + + return str; +} + +/** + * Reads and validates a Stellar contract address (C...). + * + * @param env Environment map. + * @param key Variable name. + * @param options Configuration options. + * @returns Validated Stellar contract address or undefined. + */ +export function readStellarContractId( + env: Record, + key: string, + options: StellarIdentifierReaderOptions & { required: true }, +): string; +export function readStellarContractId( + env: Record, + key: string, + options: StellarIdentifierReaderOptions & { default: string }, +): string; +export function readStellarContractId( + env: Record, + key: string, + options?: StellarIdentifierReaderOptions, +): string | undefined; +export function readStellarContractId( + env: Record, + key: string, + options: StellarIdentifierReaderOptions = {}, +): string | undefined { + const str = readString(env, key, { + required: options.required, + default: options.default, + }); + + if (str === undefined) { + return options.default; + } + + if (!StrKey.isValidContract(str)) { + throw new MalformedEnvironmentVariableError( + key, + "must be a valid Stellar C... contract address", + ); + } + + return str; +} + +/** + * Reads and validates a Stellar secret seed (S...), returning a SecretValue container. + * + * @param env Environment map. + * @param key Variable name. + * @param options Configuration options. + * @returns SecretValue container or undefined. + */ +export function readStellarSecretKey( + env: Record, + key: string, + options: StellarIdentifierReaderOptions & { required: true }, +): SecretValue; +export function readStellarSecretKey( + env: Record, + key: string, + options: StellarIdentifierReaderOptions & { default: string }, +): SecretValue; +export function readStellarSecretKey( + env: Record, + key: string, + options?: StellarIdentifierReaderOptions, +): SecretValue | undefined; +export function readStellarSecretKey( + env: Record, + key: string, + options: StellarIdentifierReaderOptions = {}, +): SecretValue | undefined { + const str = readString(env, key, { + required: options.required, + default: options.default, + }); + + if (str === undefined) { + return options.default !== undefined ? secret(options.default) : undefined; + } + + try { + Keypair.fromSecret(str); + } catch (cause) { + throw new MalformedEnvironmentVariableError( + key, + "must be a valid Stellar secret key", + { cause }, + ); + } + + return secret(str); +} + +/** + * Reads a sensitive string into an opaque SecretValue container. + * + * @param env Environment map. + * @param key Variable name. + * @param options Configuration options. + * @returns SecretValue container or undefined. + */ +export function readSecret( + env: Record, + key: string, + options: SecretReaderOptions & { required: true }, +): SecretValue; +export function readSecret( + env: Record, + key: string, + options: SecretReaderOptions & { default: string }, +): SecretValue; +export function readSecret( + env: Record, + key: string, + options?: SecretReaderOptions, +): SecretValue | undefined; +export function readSecret( + env: Record, + key: string, + options: SecretReaderOptions = {}, +): SecretValue | undefined { + const str = readString(env, key, { + required: options.required, + default: options.default, + }); + + if (str === undefined) { + return options.default !== undefined ? secret(options.default) : undefined; + } + + return secret(str); +} + +/** + * Reads a browser-exposed configuration variable, enforcing the VITE_ prefix. + * + * @param env Environment map. + * @param key Variable name. + * @param options Configuration options. + * @returns Parsed string or undefined. + */ +export function readBrowserPublic( + env: Record, + key: string, + options: StringReaderOptions & { required: true }, +): string; +export function readBrowserPublic( + env: Record, + key: string, + options: StringReaderOptions & { default: string }, +): string; +export function readBrowserPublic( + env: Record, + key: string, + options?: StringReaderOptions, +): string | undefined; +export function readBrowserPublic( + env: Record, + key: string, + options: StringReaderOptions = {}, +): string | undefined { + if (!key.startsWith("VITE_")) { + throw new ConfigError( + key, + "browser-exposed environment variables must start with VITE_ prefix", + "MALFORMED", + ); + } + + return readString(env, key, options); +} diff --git a/packages/config/src/schema.test.ts b/packages/config/src/schema.test.ts new file mode 100644 index 00000000..06c35b8d --- /dev/null +++ b/packages/config/src/schema.test.ts @@ -0,0 +1,34 @@ +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; +import { readInteger, readString } from "./readers.js"; +import { defineSchema } from "./schema.js"; + +describe("defineSchema", () => { + interface ServerConfig { + readonly host: string; + readonly port: number; + } + + const parseServerConfig = defineSchema((env) => ({ + host: readString(env, "TEST_HOST", { default: "127.0.0.1" }), + port: readInteger(env, "TEST_PORT", { default: 8080 }), + })); + + it("produces an immutable frozen configuration object", () => { + const config = parseServerConfig({ TEST_HOST: "0.0.0.0", TEST_PORT: "9000" }); + assert.equal(config.host, "0.0.0.0"); + assert.equal(config.port, 9000); + assert.ok(Object.isFrozen(config)); + assert.throws(() => { + (config as any).host = "changed"; + }); + }); + + it("supports dependency injection without touching process state", () => { + const injected = { TEST_HOST: "test.local", TEST_PORT: "443" }; + + const config = parseServerConfig(injected); + assert.equal(config.host, "test.local"); + assert.equal(config.port, 443); + }); +}); diff --git a/packages/config/src/schema.ts b/packages/config/src/schema.ts new file mode 100644 index 00000000..ddc77d50 --- /dev/null +++ b/packages/config/src/schema.ts @@ -0,0 +1,17 @@ +import { getSystemEnv } from "./system.js"; + +/** + * Creates an immutable typed configuration factory from a parser function. + * + * @param parser Function mapping an environment record to a typed configuration. + * @returns Factory function accepting an optional environment override and returning a frozen configuration. + */ +export function defineSchema( + parser: (env: Record) => T, +): (env?: Record) => Readonly { + return (env?: Record): Readonly => { + const resolvedEnv = env ?? getSystemEnv(); + const config = parser(resolvedEnv); + return Object.freeze(config); + }; +} diff --git a/packages/config/src/secret.test.ts b/packages/config/src/secret.test.ts new file mode 100644 index 00000000..3b9f39ac --- /dev/null +++ b/packages/config/src/secret.test.ts @@ -0,0 +1,48 @@ +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; +import { inspect } from "node:util"; +import { isSecret, secret, SecretValue } from "./secret.js"; + +describe("SecretValue", () => { + it("unwraps raw sensitive string", () => { + const sensitive = "super-secret-stellar-key-12345"; + const wrapped = secret(sensitive); + assert.equal(wrapped.unwrap(), sensitive); + }); + + it("redacts string conversion", () => { + const wrapped = secret("raw-token"); + assert.equal(wrapped.toString(), "[REDACTED]"); + assert.equal(`${wrapped}`, "[REDACTED]"); + assert.equal(String(wrapped), "[REDACTED]"); + }); + + it("redacts JSON serialization", () => { + const payload = { token: secret("secret-abc"), name: "sub-rosa" }; + const serialized = JSON.stringify(payload); + assert.equal(serialized, JSON.stringify({ token: "[REDACTED]", name: "sub-rosa" })); + assert.ok(!serialized.includes("secret-abc")); + }); + + it("redacts node util.inspect output", () => { + const wrapped = secret("private-key"); + const inspected = inspect(wrapped); + assert.equal(inspected, "[REDACTED]"); + assert.ok(!inspected.includes("private-key")); + }); + + it("identifies SecretValue instances with isSecret predicate", () => { + const wrapped = new SecretValue("test"); + assert.ok(isSecret(wrapped)); + assert.ok(isSecret(secret("test"))); + assert.ok(!isSecret("plain string")); + assert.ok(!isSecret({})); + assert.ok(!isSecret(null)); + assert.ok(!isSecret(undefined)); + }); + + it("freezes instance preventing modification", () => { + const wrapped = secret("data"); + assert.ok(Object.isFrozen(wrapped)); + }); +}); diff --git a/packages/config/src/secret.ts b/packages/config/src/secret.ts new file mode 100644 index 00000000..f758c559 --- /dev/null +++ b/packages/config/src/secret.ts @@ -0,0 +1,88 @@ +const SECRET_BRAND = Symbol("sub-rosa.secret"); + +/** + * Opaque container for sensitive configuration values preventing accidental logging. + */ +export class SecretValue { + private readonly [SECRET_BRAND]: true = true; + private readonly raw: T; + + /** + * Constructs a new SecretValue wrapper. + * + * @param value Underlying secret data. + */ + constructor(value: T) { + this.raw = value; + Object.freeze(this); + } + + /** + * Retrieves the raw secret value for cryptographic and authentication operations. + * + * @returns Unredacted secret content. + */ + unwrap(): T { + return this.raw; + } + + /** + * Redacted string representation. + * + * @returns Constant redacted marker. + */ + toString(): string { + return "[REDACTED]"; + } + + /** + * Redacted JSON representation. + * + * @returns Constant redacted marker. + */ + toJSON(): string { + return "[REDACTED]"; + } + + /** + * Custom inspect formatter for Node.js util.inspect and console output. + * + * @returns Constant redacted marker. + */ + [Symbol.for("nodejs.util.inspect.custom")](): string { + return "[REDACTED]"; + } + + /** + * Primitive coercion handler returning the redacted marker. + * + * @returns Constant redacted marker. + */ + [Symbol.toPrimitive](): string { + return "[REDACTED]"; + } +} + +/** + * Wraps a sensitive string or payload in an opaque SecretValue container. + * + * @param value Sensitive configuration value. + * @returns Wrapped SecretValue. + */ +export function secret(value: T): SecretValue { + return new SecretValue(value); +} + +/** + * Checks whether an arbitrary value is a SecretValue container. + * + * @param value Value to check. + * @returns Type predicate indicating SecretValue. + */ +export function isSecret(value: unknown): value is SecretValue { + return ( + typeof value === "object" && + value !== null && + (value as Record)[SECRET_BRAND] === true + ); +} diff --git a/packages/config/src/system.ts b/packages/config/src/system.ts new file mode 100644 index 00000000..44f843ec --- /dev/null +++ b/packages/config/src/system.ts @@ -0,0 +1,11 @@ +/** + * Approved bootstrap adapter reading the Node.js process environment. + * + * @returns Record of environment variable keys and values. + */ +export function getSystemEnv(): Record { + if (typeof process !== "undefined" && process.env) { + return process.env; + } + return {}; +} diff --git a/packages/config/tsconfig.json b/packages/config/tsconfig.json new file mode 100644 index 00000000..a6f8b748 --- /dev/null +++ b/packages/config/tsconfig.json @@ -0,0 +1,13 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "Bundler", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "types": ["node"], + "noEmit": true + }, + "include": ["src"] +} diff --git a/packages/sdk/package.json b/packages/sdk/package.json index 720e2ac2..0b7ebc0f 100644 --- a/packages/sdk/package.json +++ b/packages/sdk/package.json @@ -28,6 +28,7 @@ "typecheck": "tsc --noEmit -p tsconfig.json" }, "dependencies": { + "@sub-rosa/config": "workspace:*", "@sub-rosa/logging": "workspace:*", "@openzeppelin/relayer-plugin-channels": "^0.20.0", "@stellar/stellar-sdk": "^15.1.0", diff --git a/packages/sdk/scripts/live-smoke.ts b/packages/sdk/scripts/live-smoke.ts index 758c47f0..d60bb9da 100644 --- a/packages/sdk/scripts/live-smoke.ts +++ b/packages/sdk/scripts/live-smoke.ts @@ -23,11 +23,11 @@ import { sealBid, } from "@sub-rosa/tlock"; import { systemClock } from "@sub-rosa/time"; +import { getSystemEnv } from "@sub-rosa/config"; import { RoundContract, SubRosaClient } from "../src/index.js"; -// Real Drand quicknet parameters (bls-unchained-g1-rfc9380), Fp2 = (c1,c0) — -// the exact constants proven on-chain in the contract's BLS tests. +const env = getSystemEnv(); const DRAND_GENESIS = 1_692_803_367n; const DRAND_PERIOD = 3n; const DST = "BLS_SIG_BLS12381G1_XMD:SHA-256_SSWU_RO_NUL_"; @@ -37,15 +37,15 @@ const DRAND_NEGGEN_C1C0 = "13e02b6052719f607dacd3a088274f65596bd0d09920b61ab5da61bbdc7f5049334cf11213945d57e5ac7d055d042b7e024aa2b2f08f0a91260805272dc51051c6e47ad4fa403b02b4510b647ae3d1770bac0326a805bbefd48056c8c121bdb813fa4d4a0ad8b1ce186ed5061789213d993923066dddaf1040bc3ff59f825c78df74f2d75467e25e0f55f8a00fa030ed0d1b3cc2c7027888be51d9ef691d77bcb679afda66c73f17f9ee3837a55024f78c71363275a75d75d86bab79f74782aa"; function reqEnv(name: string): string { - const v = process.env[name]; + const v = env[name]; if (!v) throw new Error(`missing required env var ${name}`); return v; } const RPC_URL = - process.env.RPC_URL ?? "https://soroban-testnet.stellar.org"; + env.RPC_URL ?? "https://soroban-testnet.stellar.org"; const NETWORK = - process.env.NETWORK_PASSPHRASE ?? "Test SDF Network ; September 2015"; + env.NETWORK_PASSPHRASE ?? "Test SDF Network ; September 2015"; const hex = (s: string) => Buffer.from(s, "hex"); const sha256 = (s: string) => createHash("sha256").update(s).digest(); diff --git a/packages/sdk/scripts/mainnet-micro.ts b/packages/sdk/scripts/mainnet-micro.ts index 13c34bed..2bbc71f6 100644 --- a/packages/sdk/scripts/mainnet-micro.ts +++ b/packages/sdk/scripts/mainnet-micro.ts @@ -21,23 +21,25 @@ import { defaultMainnetReadinessInput, runMainnetReadiness, } from "../src/mainnet-readiness.js"; +import { getSystemEnv } from "@sub-rosa/config"; import { generateAuditorKeypair, generateNonce, quicknet, sealBid } from "@sub-rosa/tlock"; import { systemClock } from "@sub-rosa/time"; +const env = getSystemEnv(); const DRAND_GENESIS = 1_692_803_367; const DRAND_PERIOD = 3; -const DEFAULT_BID = 500_000n; // 0.05 XLM -const DEFAULT_ESCROW = 1_000_000n; // 0.1 XLM +const DEFAULT_BID = 500_000n; +const DEFAULT_ESCROW = 1_000_000n; function reqEnv(name: string): string { - const v = process.env[name]; + const v = env[name]; if (!v) throw new Error(`missing required env var ${name}`); return v; } function parseStroops(name: string, fallback: bigint): bigint { - const raw = process.env[name]; + const raw = env[name]; if (!raw) return fallback; const v = BigInt(raw); if (v <= 0n) throw new Error(`${name} must be positive`); @@ -49,7 +51,7 @@ function parseStroops(name: string, fallback: bigint): bigint { function printChecklist(bid: bigint, escrow: bigint, execute: boolean) { diagnostics.info("sub-rosa-mainnet-micro-runner", "Sub Rosa — mainnet micro runner\n"); - diagnostics.info("contract-existing", "Contract (existing):", { "value1_0": process.env.ROUND_CONTRACT_ID ?? MAINNET_ARTIFACTS.contractId }); + diagnostics.info("contract-existing", "Contract (existing):", { "value1_0": env.ROUND_CONTRACT_ID ?? MAINNET_ARTIFACTS.contractId }); diagnostics.info("token-native-xlm-sac", "Token: native XLM SAC"); diagnostics.info("bid-stroops", "Bid (stroops): ", { "value1_0": bid.toString(), "value2_1": `(${(Number(bid) / 1e7).toFixed(7)} XLM)` }); diagnostics.info("escrow-stroops", "Escrow (stroops): ", { "value1_0": escrow.toString(), "value2_1": `(${(Number(escrow) / 1e7).toFixed(7)} XLM)` }); @@ -83,16 +85,16 @@ async function main() { return; } - if (process.env.MAINNET_CONFIRM !== "SUB_ROSA_MAINNET") { + if (env.MAINNET_CONFIRM !== "SUB_ROSA_MAINNET") { throw new Error('set MAINNET_CONFIRM=SUB_ROSA_MAINNET to execute on mainnet'); } - assertMainnetConfirmed(); + assertMainnetConfirmed(env); const operatorSecret = reqEnv("OPERATOR_SECRET"); const bidderSecret = reqEnv("BIDDER_SECRET"); - const contractId = process.env.ROUND_CONTRACT_ID ?? MAINNET_ARTIFACTS.contractId; - const rpcUrl = process.env.RPC_URL ?? MAINNET_ARTIFACTS.rpcUrl; - const network = process.env.NETWORK_PASSPHRASE ?? MAINNET_ARTIFACTS.networkPassphrase; + const contractId = env.ROUND_CONTRACT_ID ?? MAINNET_ARTIFACTS.contractId; + const rpcUrl = env.RPC_URL ?? MAINNET_ARTIFACTS.rpcUrl; + const network = env.NETWORK_PASSPHRASE ?? MAINNET_ARTIFACTS.networkPassphrase; const operatorKp = Keypair.fromSecret(operatorSecret); const bidderKp = Keypair.fromSecret(bidderSecret); diff --git a/packages/sdk/scripts/mainnet-ready.ts b/packages/sdk/scripts/mainnet-ready.ts index 3c43565d..61fe9257 100644 --- a/packages/sdk/scripts/mainnet-ready.ts +++ b/packages/sdk/scripts/mainnet-ready.ts @@ -1,11 +1,6 @@ import { createLogger } from '@sub-rosa/logging'; +import { getSystemEnv } from '@sub-rosa/config'; const diagnostics = createLogger("packages.sdk.scripts.mainnet-ready"); -// Consolidated mainnet launch readiness — read-only by default. -// -// Usage: -// pnpm mainnet:ready -// pnpm mainnet:ready -- --dry-run -// pnpm mainnet:ready -- --with-balances --strict import { Keypair } from "@stellar/stellar-sdk"; @@ -22,25 +17,26 @@ const DEFAULT_READER_PUBKEY = "GCDARJFKKSTJYAZC647H4ZSSSPXPPSKOWOHGMUNCT22VG74KXZ5BHVNR"; async function main() { + const env = getSystemEnv(); const dryRun = - process.argv.includes("--dry-run") || process.env.MAINNET_DRY_RUN === "1"; + process.argv.includes("--dry-run") || env.MAINNET_DRY_RUN === "1"; const withBalances = process.argv.includes("--with-balances"); const strict = process.argv.includes("--strict"); - const rpcUrl = process.env.RPC_URL ?? MAINNET_ARTIFACTS.rpcUrl; + const rpcUrl = env.RPC_URL ?? MAINNET_ARTIFACTS.rpcUrl; const networkPassphrase = - process.env.NETWORK_PASSPHRASE ?? MAINNET_ARTIFACTS.networkPassphrase; + env.NETWORK_PASSPHRASE ?? MAINNET_ARTIFACTS.networkPassphrase; const contractId = - process.env.ROUND_CONTRACT_ID ?? MAINNET_ARTIFACTS.contractId; + env.ROUND_CONTRACT_ID ?? MAINNET_ARTIFACTS.contractId; - const operatorAccount = process.env.OPERATOR_SECRET - ? Keypair.fromSecret(process.env.OPERATOR_SECRET).publicKey() + const operatorAccount = env.OPERATOR_SECRET + ? Keypair.fromSecret(env.OPERATOR_SECRET).publicKey() : undefined; - const keeperAccount = process.env.KEEPER_SECRET - ? Keypair.fromSecret(process.env.KEEPER_SECRET).publicKey() + const keeperAccount = env.KEEPER_SECRET + ? Keypair.fromSecret(env.KEEPER_SECRET).publicKey() : undefined; - const bidderAccount = process.env.BIDDER_SECRET - ? Keypair.fromSecret(process.env.BIDDER_SECRET).publicKey() + const bidderAccount = env.BIDDER_SECRET + ? Keypair.fromSecret(env.BIDDER_SECRET).publicKey() : undefined; const input = defaultMainnetReadinessInput({ @@ -61,7 +57,7 @@ async function main() { networkPassphrase, contractId, publicKey: - process.env.MAINNET_READER_PUBKEY ?? DEFAULT_READER_PUBKEY, + env.MAINNET_READER_PUBKEY ?? DEFAULT_READER_PUBKEY, }); const report = await runMainnetReadiness(input, { reader }); diff --git a/packages/sdk/scripts/mainnet-verify.ts b/packages/sdk/scripts/mainnet-verify.ts index 7baff131..b7dd6c52 100644 --- a/packages/sdk/scripts/mainnet-verify.ts +++ b/packages/sdk/scripts/mainnet-verify.ts @@ -1,15 +1,14 @@ import { createLogger } from '@sub-rosa/logging'; +import { getSystemEnv } from '@sub-rosa/config'; const diagnostics = createLogger("packages.sdk.scripts.mainnet-verify"); -// Read-only mainnet proof checker — no transactions, no secrets required. -// -// Verifies the deployed Round contract and settled round 1 match frozen artifacts. import { SubRosaClient } from "../src/client.js"; import { MAINNET_ARTIFACTS } from "../src/mainnet-artifacts.js"; import { verifySettledRoundProof } from "../src/mainnet-readiness.js"; async function main() { - const dryRun = process.argv.includes("--dry-run") || process.env.MAINNET_DRY_RUN === "1"; + const env = getSystemEnv(); + const dryRun = process.argv.includes("--dry-run") || env.MAINNET_DRY_RUN === "1"; diagnostics.info("sub-rosa-mainnet-settlement-proof-read-only", "Sub Rosa — mainnet settlement proof (read-only)\n"); diagnostics.info("checklist", "Checklist:"); @@ -38,13 +37,13 @@ async function main() { } const reader = new SubRosaClient({ - rpcUrl: process.env.RPC_URL ?? MAINNET_ARTIFACTS.rpcUrl, - networkPassphrase: process.env.NETWORK_PASSPHRASE ?? MAINNET_ARTIFACTS.networkPassphrase, - contractId: process.env.ROUND_CONTRACT_ID ?? MAINNET_ARTIFACTS.contractId, - publicKey: process.env.MAINNET_READER_PUBKEY ?? "GCDARJFKKSTJYAZC647H4ZSSSPXPPSKOWOHGMUNCT22VG74KXZ5BHVNR", + rpcUrl: env.RPC_URL ?? MAINNET_ARTIFACTS.rpcUrl, + networkPassphrase: env.NETWORK_PASSPHRASE ?? MAINNET_ARTIFACTS.networkPassphrase, + contractId: env.ROUND_CONTRACT_ID ?? MAINNET_ARTIFACTS.contractId, + publicKey: env.MAINNET_READER_PUBKEY ?? "GCDARJFKKSTJYAZC647H4ZSSSPXPPSKOWOHGMUNCT22VG74KXZ5BHVNR", }); - const roundId = BigInt(process.env.ROUND_ID ?? String(MAINNET_ARTIFACTS.settledRoundId)); + const roundId = BigInt(env.ROUND_ID ?? String(MAINNET_ARTIFACTS.settledRoundId)); await verifySettledRoundProof(reader, roundId, { bidStroops: MAINNET_ARTIFACTS.bidStroops, escrowStroops: MAINNET_ARTIFACTS.escrowStroops, @@ -52,7 +51,7 @@ async function main() { }); diagnostics.info("mainnet-verify-passed", "✅ MAINNET VERIFY PASSED"); - diagnostics.info("contract", " contract:", { "value1_0": process.env.ROUND_CONTRACT_ID ?? MAINNET_ARTIFACTS.contractId }); + diagnostics.info("contract", " contract:", { "value1_0": env.ROUND_CONTRACT_ID ?? MAINNET_ARTIFACTS.contractId }); diagnostics.info("round", " round: ", { "value1_0": roundId.toString(), "value2_1": "status:", "status_2": MAINNET_ARTIFACTS.status }); diagnostics.info("r", " R: ", { "value1_0": MAINNET_ARTIFACTS.revealRound.toString() }); diagnostics.info("bid", " bid: ", { "bidXlm_0": MAINNET_ARTIFACTS.bidXlm, "value2_1": "XLM" }); diff --git a/packages/sdk/src/mainnet-readiness.ts b/packages/sdk/src/mainnet-readiness.ts index 847f118b..fd1a6886 100644 --- a/packages/sdk/src/mainnet-readiness.ts +++ b/packages/sdk/src/mainnet-readiness.ts @@ -9,6 +9,7 @@ import { TransactionBuilder, } from "@stellar/stellar-sdk"; +import { getSystemEnv } from "@sub-rosa/config"; import type { SubRosaClient } from "./client.js"; import { MAINNET_ARTIFACTS, @@ -80,7 +81,7 @@ export function nativeXlmSacId(networkPassphrase: string): string { } export function assertMainnetConfirmed( - env: Record = process.env, + env: Record = getSystemEnv(), ): void { if (env.MAINNET_CONFIRM?.trim() !== MAINNET_CONFIRM_PHRASE) { throw new Error( diff --git a/packages/sdk/src/submitter.ts b/packages/sdk/src/submitter.ts index d1817d46..5174f461 100644 --- a/packages/sdk/src/submitter.ts +++ b/packages/sdk/src/submitter.ts @@ -1,4 +1,4 @@ -// SPDX-License-Identifier: MIT +import { getSystemEnv } from "@sub-rosa/config"; import { SubRosaSubmitError } from "./errors.js"; export interface SubmittedTransaction { @@ -57,7 +57,7 @@ export function createOzChannelsSubmitter(config: OzChannelsSubmitterConfig): Tr } export function createOzChannelsSubmitterFromEnv( - env: Record = process.env, + env: Record = getSystemEnv(), ): TransactionSubmitter | undefined { const baseUrl = env.OZ_CHANNELS_BASE_URL ?? env.OZ_RELAYER_CHANNELS_URL; const apiKey = env.OZ_CHANNELS_API_KEY ?? env.OZ_RELAYER_API_KEY; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9f68a677..cac2d0fd 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -21,6 +21,9 @@ importers: '@sub-rosa/agent': specifier: workspace:* version: link:../../services/agent + '@sub-rosa/config': + specifier: workspace:* + version: link:../../packages/config '@sub-rosa/sdk': specifier: workspace:* version: link:../../packages/sdk @@ -83,6 +86,22 @@ importers: specifier: ^6.3.5 version: 6.4.3(@types/node@25.9.1)(tsx@4.22.4) + packages/config: + dependencies: + '@stellar/stellar-sdk': + specifier: ^15.1.0 + version: 15.1.0 + devDependencies: + '@types/node': + specifier: ^25.9.1 + version: 25.9.1 + tsx: + specifier: ^4.22.4 + version: 4.22.4 + typescript: + specifier: ^6.0.3 + version: 6.0.3 + packages/logging: {} packages/round-bindings: @@ -112,6 +131,9 @@ importers: '@stellar/stellar-sdk': specifier: ^15.1.0 version: 15.1.0 + '@sub-rosa/config': + specifier: workspace:* + version: link:../config '@sub-rosa/logging': specifier: workspace:* version: link:../logging @@ -186,6 +208,9 @@ importers: '@sub-rosa/appraisal-api': specifier: workspace:* version: link:../appraisal-api + '@sub-rosa/config': + specifier: workspace:* + version: link:../../packages/config '@sub-rosa/keeper': specifier: workspace:* version: link:../keeper @@ -220,6 +245,9 @@ importers: '@stellar/stellar-sdk': specifier: ^15.1.0 version: 15.1.0 + '@sub-rosa/config': + specifier: workspace:* + version: link:../../packages/config '@sub-rosa/logging': specifier: workspace:* version: link:../../packages/logging @@ -242,6 +270,9 @@ importers: services/auction-template: dependencies: + '@sub-rosa/config': + specifier: workspace:* + version: link:../../packages/config '@sub-rosa/keeper': specifier: workspace:* version: link:../keeper @@ -279,6 +310,9 @@ importers: '@noble/hashes': specifier: ^2.2.0 version: 2.2.0 + '@sub-rosa/config': + specifier: workspace:* + version: link:../../packages/config '@sub-rosa/logging': specifier: workspace:* version: link:../../packages/logging @@ -295,6 +329,9 @@ importers: services/keeper: dependencies: + '@sub-rosa/config': + specifier: workspace:* + version: link:../../packages/config '@sub-rosa/logging': specifier: workspace:* version: link:../../packages/logging @@ -323,6 +360,9 @@ importers: services/receipt-cli: dependencies: + '@sub-rosa/config': + specifier: workspace:* + version: link:../../packages/config '@sub-rosa/logging': specifier: workspace:* version: link:../../packages/logging diff --git a/scripts/check-deploy-docs.mjs b/scripts/check-deploy-docs.mjs index 2eb7345c..361ce3e9 100644 --- a/scripts/check-deploy-docs.mjs +++ b/scripts/check-deploy-docs.mjs @@ -25,12 +25,14 @@ const diagnostics = createLogger("scripts.check-deploy-docs"); import { readFileSync } from "node:fs"; import { resolve } from "node:path"; +import { getSystemEnv } from "../packages/config/src/system.ts"; // `DEPLOY_DOCS_ROOT` lets tests redirect reads to a fixture project tree. // In normal use, `pnpm docs:check` runs from the repo root, so process.cwd() // is the project root. -const ROOT = process.env.DEPLOY_DOCS_ROOT - ? process.env.DEPLOY_DOCS_ROOT +const env = getSystemEnv(); +const ROOT = env.DEPLOY_DOCS_ROOT + ? env.DEPLOY_DOCS_ROOT : process.cwd(); const PATHS = { diff --git a/scripts/check-direct-env-access.mjs b/scripts/check-direct-env-access.mjs new file mode 100644 index 00000000..ae5b9e2c --- /dev/null +++ b/scripts/check-direct-env-access.mjs @@ -0,0 +1,131 @@ +#!/usr/bin/env node +import { readdirSync, readFileSync, statSync } from "node:fs"; +import { join, relative } from "node:path"; +import { pathToFileURL } from "node:url"; +import { createLogger } from "../packages/logging/src/index.cjs"; + +const diagnostics = createLogger("scripts.check-direct-env-access"); + +const ROOT = new URL("..", import.meta.url).pathname; +const SCAN_ROOTS = ["packages", "services", "apps", "scripts"]; +const ALLOWED = new Set([ + "packages/config/src/system.ts", + "packages/config/src/browser.ts", + "scripts/check-direct-env-access.test.mjs", +]); + +const PATTERNS = [ + { name: "process.env", re: /(?} */ + const all = []; + for (const scanRoot of SCAN_ROOTS) { + const abs = join(rootDir, scanRoot); + try { + statSync(abs); + } catch { + continue; + } + for (const file of walk(abs)) { + const relPath = relative(rootDir, file).split("\\").join("/"); + const content = readFileSync(file, "utf-8"); + all.push(...findViolations(content, relPath)); + } + } + return all; +} + +/** + * Main command line execution routine. + */ +function main() { + const violations = scanTree(); + diagnostics.info("direct-env-access-guard", "\nDirect environment access guard"); + diagnostics.info("progress", "=".repeat(72)); + diagnostics.info("scanned", ` scanned: ${SCAN_ROOTS.join(", ")}`); + diagnostics.info("allowed", ` allowed: ${[...ALLOWED].join(", ")}`); + diagnostics.info("progress-2", "=".repeat(72)); + + if (violations.length === 0) { + diagnostics.info("pass-no-direct-env-access", "PASS no direct environment access outside @sub-rosa/config bootstrap adapters."); + process.exit(0); + } + + diagnostics.error("fail", `FAIL ${violations.length} violation(s):`); + for (const v of violations) { + diagnostics.error("progress-3", ` ${v.relPath}:${v.line} ${v.pattern} ${v.text}`); + } + diagnostics.error("use-sub-rosa-config", "\nUse @sub-rosa/config (readers, defineSchema, getSystemEnv, getBrowserEnv) instead."); + process.exit(1); +} + +if (import.meta.url === pathToFileURL(process.argv[1] ?? "").href) { + main(); +} diff --git a/scripts/check-direct-env-access.test.mjs b/scripts/check-direct-env-access.test.mjs new file mode 100644 index 00000000..dd8ac100 --- /dev/null +++ b/scripts/check-direct-env-access.test.mjs @@ -0,0 +1,56 @@ +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; +import { findViolations } from "./check-direct-env-access.mjs"; + +describe("findViolations", () => { + it("allows packages/config/src/system.ts", () => { + const hits = findViolations( + "export function getSystemEnv() { return process.env; }", + "packages/config/src/system.ts", + ); + assert.equal(hits.length, 0); + }); + + it("allows packages/config/src/browser.ts", () => { + const hits = findViolations( + "export function getBrowserEnv() { return import.meta.env; }", + "packages/config/src/browser.ts", + ); + assert.equal(hits.length, 0); + }); + + it("flags process.env access in service and package code", () => { + const hits = findViolations( + "const port = process.env.PORT;\nconst host = process.env['HOST'];", + "services/keeper/src/serve.ts", + ); + assert.equal(hits.length, 2); + assert.equal(hits[0].pattern, "process.env"); + assert.equal(hits[1].pattern, "process.env"); + }); + + it("flags import.meta.env access in browser code", () => { + const hits = findViolations( + "const rpc = import.meta.env.VITE_RPC_URL;", + "apps/web/src/lib/chain.ts", + ); + assert.equal(hits.length, 1); + assert.equal(hits[0].pattern, "import.meta.env"); + }); + + it("ignores string literals mentioning process.env", () => { + const hits = findViolations( + 'define: { "process.env": "{}" }', + "apps/web/vite.config.ts", + ); + assert.equal(hits.length, 0); + }); + + it("ignores comment lines mentioning process.env", () => { + const hits = findViolations( + "// Default fallback when process.env is unset\n/* process.env.FOO */\n * process.env.BAR", + "packages/sdk/src/client.ts", + ); + assert.equal(hits.length, 0); + }); +}); diff --git a/services/agent/package.json b/services/agent/package.json index 1832a3d3..3ab51a20 100644 --- a/services/agent/package.json +++ b/services/agent/package.json @@ -21,6 +21,7 @@ "typecheck": "tsc --noEmit -p tsconfig.json" }, "dependencies": { + "@sub-rosa/config": "workspace:*", "@sub-rosa/logging": "workspace:*", "@stellar/stellar-sdk": "^15.1.0", "@sub-rosa/appraisal-api": "workspace:*", diff --git a/services/agent/scripts/agents-e2e.ts b/services/agent/scripts/agents-e2e.ts index 5714bde7..5d3c1a60 100644 --- a/services/agent/scripts/agents-e2e.ts +++ b/services/agent/scripts/agents-e2e.ts @@ -31,6 +31,7 @@ import { RoundContract, SubRosaClient } from "@sub-rosa/sdk"; import { generateAuditorKeypair, quicknet } from "@sub-rosa/tlock"; import { systemTime } from "@sub-rosa/time"; import { buildAppraisalServer } from "@sub-rosa/appraisal-api"; +import { getSystemEnv } from "@sub-rosa/config"; import { createSessionMandate, @@ -41,6 +42,7 @@ import { } from "../src/index.js"; import { writeDemoTrace } from "../src/write-demo-trace.js"; +const env = getSystemEnv(); const DRAND_GENESIS = 1_692_803_367; const DRAND_PERIOD = 3; const DST = "BLS_SIG_BLS12381G1_XMD:SHA-256_SSWU_RO_NUL_"; @@ -49,10 +51,10 @@ const DRAND_PUBKEY_C1C0 = const DRAND_NEGGEN_C1C0 = "13e02b6052719f607dacd3a088274f65596bd0d09920b61ab5da61bbdc7f5049334cf11213945d57e5ac7d055d042b7e024aa2b2f08f0a91260805272dc51051c6e47ad4fa403b02b4510b647ae3d1770bac0326a805bbefd48056c8c121bdb813fa4d4a0ad8b1ce186ed5061789213d993923066dddaf1040bc3ff59f825c78df74f2d75467e25e0f55f8a00fa030ed0d1b3cc2c7027888be51d9ef691d77bcb679afda66c73f17f9ee3837a55024f78c71363275a75d75d86bab79f74782aa"; -const RPC_URL = process.env.RPC_URL ?? "https://soroban-testnet.stellar.org"; -const HORIZON_URL = process.env.HORIZON_URL ?? "https://horizon-testnet.stellar.org"; -const NETWORK = process.env.NETWORK_PASSPHRASE ?? Networks.TESTNET; -const X402_NETWORK = process.env.X402_NETWORK ?? "stellar:testnet"; +const RPC_URL = env.RPC_URL ?? "https://soroban-testnet.stellar.org"; +const HORIZON_URL = env.HORIZON_URL ?? "https://horizon-testnet.stellar.org"; +const NETWORK = env.NETWORK_PASSPHRASE ?? Networks.TESTNET; +const X402_NETWORK = env.X402_NETWORK ?? "stellar:testnet"; const { clock, scheduler } = systemTime; @@ -60,7 +62,7 @@ const hex = (s: string) => Buffer.from(s, "hex"); const sha256 = (s: string) => createHash("sha256").update(s).digest(); const sleep = (ms: number) => scheduler.sleep(ms); const reqEnv = (n: string): string => { - const v = process.env[n]; + const v = env[n]; if (!v) throw new Error(`missing required env var ${n}`); return v; }; @@ -171,7 +173,7 @@ async function main() { const issuerSecret = reqEnv("ISSUER_SECRET"); const wasmHash = reqEnv("WASM_HASH"); const usdcSac = reqEnv("USDC_SAC"); - const appraisalPrice = Number(process.env.PRICE ?? "0.10"); + const appraisalPrice = Number(env.PRICE ?? "0.10"); const issuerKp = Keypair.fromSecret(issuerSecret); const asset = new Asset("USDC", issuerKp.publicKey()); @@ -461,9 +463,9 @@ async function main() { }; await writeJson("artifacts/canonical-demo-trace.json", demoTrace); - if (process.env.SUB_ROSA_WRITE_WEB_TRACE !== "0") { + if (env.SUB_ROSA_WRITE_WEB_TRACE !== "0") { await writeDemoTrace( - process.env.SUB_ROSA_WEB_DEMO_TRACE_OUT ?? "apps/web/src/demo/demo-trace.generated.ts", + env.SUB_ROSA_WEB_DEMO_TRACE_OUT ?? "apps/web/src/demo/demo-trace.generated.ts", demoTrace, ); } diff --git a/services/agent/scripts/usdc-setup.ts b/services/agent/scripts/usdc-setup.ts index d0f081b5..9504d3b2 100644 --- a/services/agent/scripts/usdc-setup.ts +++ b/services/agent/scripts/usdc-setup.ts @@ -13,14 +13,16 @@ import { TransactionBuilder, xdr, } from "@stellar/stellar-sdk"; +import { getSystemEnv } from "@sub-rosa/config"; -const HORIZON_URL = process.env.HORIZON_URL ?? "https://horizon-testnet.stellar.org"; -const NETWORK = process.env.NETWORK_PASSPHRASE ?? Networks.TESTNET; -const ASSET_CODE = process.env.ASSET_CODE ?? "USDC"; -const MINT_AMOUNT = process.env.MINT_AMOUNT ?? "1000"; +const env = getSystemEnv(); +const HORIZON_URL = env.HORIZON_URL ?? "https://horizon-testnet.stellar.org"; +const NETWORK = env.NETWORK_PASSPHRASE ?? Networks.TESTNET; +const ASSET_CODE = env.ASSET_CODE ?? "USDC"; +const MINT_AMOUNT = env.MINT_AMOUNT ?? "1000"; const reqEnv = (n: string): string => { - const v = process.env[n]; + const v = env[n]; if (!v) throw new Error(`missing required env var ${n}`); return v; }; @@ -48,7 +50,7 @@ async function main() { await submit(kp, Operation.changeTrust({ asset })); diagnostics.info("trustline-ok", `trustline OK: ${kp.publicKey()}`); } - const operatorSecret = process.env.OPERATOR_SECRET; + const operatorSecret = env.OPERATOR_SECRET; if (operatorSecret) { const operator = Keypair.fromSecret(operatorSecret); await submit(operator, Operation.changeTrust({ asset })); diff --git a/services/appraisal-api/package.json b/services/appraisal-api/package.json index 2bb7e81b..eb85458d 100644 --- a/services/appraisal-api/package.json +++ b/services/appraisal-api/package.json @@ -16,6 +16,7 @@ "typecheck": "tsc --noEmit -p tsconfig.json" }, "dependencies": { + "@sub-rosa/config": "workspace:*", "@sub-rosa/logging": "workspace:*", "@stellar/stellar-sdk": "^15.1.0", "@x402/core": "^2.14.0", diff --git a/services/appraisal-api/scripts/usdc-setup.ts b/services/appraisal-api/scripts/usdc-setup.ts index 16e025b6..d2aac1b4 100644 --- a/services/appraisal-api/scripts/usdc-setup.ts +++ b/services/appraisal-api/scripts/usdc-setup.ts @@ -1,8 +1,6 @@ import { createLogger } from '@sub-rosa/logging'; +import { getSystemEnv } from '@sub-rosa/config'; const diagnostics = createLogger("services.appraisal-api.scripts.usdc-setup"); -// USDC asset provisioning for the x402 e2e (classic ops via Horizon). -// Trustlines for the payer (client) + resource server, and mint USDC to the -// payer. The facilitator needs XLM only, so it gets no trustline. import { Asset, @@ -15,13 +13,14 @@ import { xdr, } from "@stellar/stellar-sdk"; -const HORIZON_URL = process.env.HORIZON_URL ?? "https://horizon-testnet.stellar.org"; -const NETWORK = process.env.NETWORK_PASSPHRASE ?? Networks.TESTNET; -const ASSET_CODE = process.env.ASSET_CODE ?? "USDC"; -const MINT_AMOUNT = process.env.MINT_AMOUNT ?? "1000"; +const env = getSystemEnv(); +const HORIZON_URL = env.HORIZON_URL ?? "https://horizon-testnet.stellar.org"; +const NETWORK = env.NETWORK_PASSPHRASE ?? Networks.TESTNET; +const ASSET_CODE = env.ASSET_CODE ?? "USDC"; +const MINT_AMOUNT = env.MINT_AMOUNT ?? "1000"; const reqEnv = (n: string): string => { - const v = process.env[n]; + const v = env[n]; if (!v) throw new Error(`missing required env var ${n}`); return v; }; diff --git a/services/appraisal-api/scripts/x402-e2e.ts b/services/appraisal-api/scripts/x402-e2e.ts index 00aeee93..5fa68d35 100644 --- a/services/appraisal-api/scripts/x402-e2e.ts +++ b/services/appraisal-api/scripts/x402-e2e.ts @@ -24,13 +24,15 @@ import { import { appraise, parseAppraisalRequest } from "../src/appraisal.js"; import { buildAppraisalServer } from "../src/server.js"; import { createPaidFetch } from "../src/client.js"; +import { getSystemEnv } from "@sub-rosa/config"; -const RPC_URL = process.env.RPC_URL ?? "https://soroban-testnet.stellar.org"; -const NETWORK = process.env.NETWORK_PASSPHRASE ?? "Test SDF Network ; September 2015"; -const X402_NETWORK = process.env.X402_NETWORK ?? "stellar:testnet"; +const env = getSystemEnv(); +const RPC_URL = env.RPC_URL ?? "https://soroban-testnet.stellar.org"; +const NETWORK = env.NETWORK_PASSPHRASE ?? "Test SDF Network ; September 2015"; +const X402_NETWORK = env.X402_NETWORK ?? "stellar:testnet"; const reqEnv = (n: string): string => { - const v = process.env[n]; + const v = env[n]; if (!v) throw new Error(`missing required env var ${n}`); return v; }; @@ -44,7 +46,7 @@ async function main() { const clientSecret = reqEnv("CLIENT_SECRET"); const serverSecret = reqEnv("SERVER_SECRET"); const usdcSac = reqEnv("USDC_SAC"); - const price = Number(process.env.PRICE ?? "0.10"); + const price = Number(env.PRICE ?? "0.10"); const clientPub = Keypair.fromSecret(clientSecret).publicKey(); const serverPub = Keypair.fromSecret(serverSecret).publicKey(); diff --git a/services/appraisal-api/src/config.ts b/services/appraisal-api/src/config.ts index 693d6429..61582fd1 100644 --- a/services/appraisal-api/src/config.ts +++ b/services/appraisal-api/src/config.ts @@ -1,5 +1,5 @@ -// Copyright (c) 2026 Sub Rosa contributors import { Keypair, StrKey } from "@stellar/stellar-sdk"; +import { ConfigError, getSystemEnv } from "@sub-rosa/config"; import { DEFAULT_TESTNET_RPC_URL, STELLAR_PUBNET_CAIP2, @@ -9,7 +9,7 @@ import { } from "@x402/stellar"; import type { Network } from "@x402/core/types"; -export class AppraisalConfigError extends Error { +export class AppraisalConfigError extends ConfigError { readonly name = "AppraisalConfigError"; constructor( @@ -17,7 +17,7 @@ export class AppraisalConfigError extends Error { message: string, options?: ErrorOptions, ) { - super(`${variable}: ${message}`, options); + super(variable, message, "MALFORMED", options); } } @@ -171,7 +171,7 @@ function validateAsset(asset: string): void { } export function configFromEnv( - env: Record = process.env, + env: Record = getSystemEnv(), ): AppraisalServerConfig { const facilitatorSecret = requiredEnv(env, "FACILITATOR_SECRET"); const payTo = requiredEnv(env, "PAY_TO"); diff --git a/services/auction-template/package.json b/services/auction-template/package.json index 4a4680a7..81fe62a5 100644 --- a/services/auction-template/package.json +++ b/services/auction-template/package.json @@ -16,6 +16,7 @@ "typecheck": "tsc --noEmit -p tsconfig.json" }, "dependencies": { + "@sub-rosa/config": "workspace:*", "@sub-rosa/logging": "workspace:*", "@sub-rosa/keeper": "workspace:*", "@sub-rosa/sdk": "workspace:*", diff --git a/services/auction-template/sealed-auction.ts b/services/auction-template/sealed-auction.ts index 7314ccd9..de579306 100644 --- a/services/auction-template/sealed-auction.ts +++ b/services/auction-template/sealed-auction.ts @@ -16,14 +16,16 @@ import { toHex, } from "@sub-rosa/tlock"; import { systemTime } from "@sub-rosa/time"; +import { getSystemEnv } from "@sub-rosa/config"; +const env = getSystemEnv(); const DRAND_GENESIS = 1_692_803_367; const DRAND_PERIOD = 3; const DST = "BLS_SIG_BLS12381G1_XMD:SHA-256_SSWU_RO_NUL_"; const DRAND_PUBKEY_C1C0 = "03cf0f2896adee7eb8b5f01fcad3912212c437e0073e911fb90022d3e760183c8c4b450b6a0a6c3ac6a5776a2d1064510d1fec758c921cc22b0e17e63aaf4bcb5ed66304de9cf809bd274ca73bab4af5a6e9c76a4bc09e76eae8991ef5ece45a01a714f2edb74119a2f2b0d5a7c75ba902d163700a61bc224ededd8e63aef7be1aaf8e93d7a9718b047ccddb3eb5d68b0e5db2b6bfbb01c867749cadffca88b36c24f3012ba09fc4d3022c5c37dce0f977d3adb5d183c7477c442b1f04515273"; const DRAND_NEGGEN_C1C0 = "13e02b6052719f607dacd3a088274f65596bd0d09920b61ab5da61bbdc7f5049334cf11213945d57e5ac7d055d042b7e024aa2b2f08f0a91260805272dc51051c6e47ad4fa403b02b4510b647ae3d1770bac0326a805bbefd48056c8c121bdb813fa4d4a0ad8b1ce186ed5061789213d993923066dddaf1040bc3ff59f825c78df74f2d75467e25e0f55f8a00fa030ed0d1b3cc2c7027888be51d9ef691d77bcb679afda66c73f17f9ee3837a55024f78c71363275a75d75d86bab79f74782aa"; -const RPC_URL = process.env.RPC_URL ?? "https://soroban-testnet.stellar.org"; -const NETWORK = process.env.NETWORK_PASSPHRASE ?? "Test SDF Network ; September 2015"; +const RPC_URL = env.RPC_URL ?? "https://soroban-testnet.stellar.org"; +const NETWORK = env.NETWORK_PASSPHRASE ?? "Test SDF Network ; September 2015"; const VOID_GRACE = 3600; const { clock, scheduler } = systemTime; @@ -32,7 +34,7 @@ const hex = (s: string) => Buffer.from(s, "hex"); const sha256 = (s: string) => createHash("sha256").update(s).digest(); const sleep = (ms: number) => scheduler.sleep(ms); const reqEnv = (n: string): string => { - const v = process.env[n]; + const v = env[n]; if (!v) throw new Error(`missing required env var ${n}`); return v; }; @@ -279,7 +281,7 @@ async function testnetMain() { } async function main() { - const isFixture = process.env.FIXTURE === "1"; + const isFixture = env.FIXTURE === "1"; if (isFixture) { await fixtureMain(); } else { diff --git a/services/drand-tools/package.json b/services/drand-tools/package.json index f886a76a..49579677 100644 --- a/services/drand-tools/package.json +++ b/services/drand-tools/package.json @@ -15,6 +15,7 @@ "typecheck": "tsc --noEmit -p tsconfig.json" }, "dependencies": { + "@sub-rosa/config": "workspace:*", "@sub-rosa/logging": "workspace:*", "@noble/curves": "^2.2.0", "@noble/hashes": "^2.2.0" diff --git a/services/drand-tools/src/quicknet.ts b/services/drand-tools/src/quicknet.ts index 1fa89a3b..b1b1cc94 100644 --- a/services/drand-tools/src/quicknet.ts +++ b/services/drand-tools/src/quicknet.ts @@ -2,10 +2,13 @@ // Live Drand quicknet access. No hardcoded beacons — everything is fetched from // the public API so the harness validates against the real network. +import { getSystemEnv } from "@sub-rosa/config"; + export const QUICKNET_HASH = "52db9ba70e0cc0f6eaf7803dd07447a1f5477735fd3f661792ba94600c84e971"; -const API = process.env.DRAND_API_URL ?? "https://api.drand.sh"; +const env = getSystemEnv(); +const API = env.DRAND_API_URL ?? "https://api.drand.sh"; export interface ChainInfo { public_key: string; // compressed G2, hex (96 bytes) diff --git a/services/drand-tools/src/vectors.ts b/services/drand-tools/src/vectors.ts index 73790c67..34aedfa0 100644 --- a/services/drand-tools/src/vectors.ts +++ b/services/drand-tools/src/vectors.ts @@ -4,12 +4,14 @@ const diagnostics = createLogger("services.drand-tools.src.vectors"); // Emit a frozen quicknet test vector for the contract's Rust BLS test. // Real network data captured at a fixed finalized round — not a mock. +import { getSystemEnv } from "@sub-rosa/config"; import { getBeacon, getChainInfo } from "./quicknet.js"; import { pubkeyToSoroban, negatedG2Generator, encodeG1, toHex } from "./encode.js"; import { bls12_381 as bls } from "@noble/curves/bls12-381.js"; import { verifyBeacon } from "./parity.js"; -const ROUND = Number(process.env.VECTOR_ROUND ?? 29155653); +const env = getSystemEnv(); +const ROUND = Number(env.VECTOR_ROUND ?? 29155653); const info = await getChainInfo(); const b = await getBeacon(ROUND); diff --git a/services/keeper/package.json b/services/keeper/package.json index f75c0f2b..07a9221d 100644 --- a/services/keeper/package.json +++ b/services/keeper/package.json @@ -19,6 +19,7 @@ "typecheck": "tsc --noEmit -p tsconfig.json" }, "dependencies": { + "@sub-rosa/config": "workspace:*", "@sub-rosa/logging": "workspace:*", "@sub-rosa/sdk": "workspace:*", "@sub-rosa/time": "workspace:*", diff --git a/services/keeper/scripts/keeper-e2e.ts b/services/keeper/scripts/keeper-e2e.ts index 4e399d16..5db62ad0 100644 --- a/services/keeper/scripts/keeper-e2e.ts +++ b/services/keeper/scripts/keeper-e2e.ts @@ -23,11 +23,13 @@ import { sealBid, } from "@sub-rosa/tlock"; import { systemTime } from "@sub-rosa/time"; +import { getSystemEnv } from "@sub-rosa/config"; import { keepRound } from "../src/keeper.js"; const { clock, scheduler } = systemTime; +const env = getSystemEnv(); const DRAND_GENESIS = 1_692_803_367; const DRAND_PERIOD = 3; const DST = "BLS_SIG_BLS12381G1_XMD:SHA-256_SSWU_RO_NUL_"; @@ -36,12 +38,12 @@ const DRAND_PUBKEY_C1C0 = const DRAND_NEGGEN_C1C0 = "13e02b6052719f607dacd3a088274f65596bd0d09920b61ab5da61bbdc7f5049334cf11213945d57e5ac7d055d042b7e024aa2b2f08f0a91260805272dc51051c6e47ad4fa403b02b4510b647ae3d1770bac0326a805bbefd48056c8c121bdb813fa4d4a0ad8b1ce186ed5061789213d993923066dddaf1040bc3ff59f825c78df74f2d75467e25e0f55f8a00fa030ed0d1b3cc2c7027888be51d9ef691d77bcb679afda66c73f17f9ee3837a55024f78c71363275a75d75d86bab79f74782aa"; -const RPC_URL = process.env.RPC_URL ?? "https://soroban-testnet.stellar.org"; +const RPC_URL = env.RPC_URL ?? "https://soroban-testnet.stellar.org"; const NETWORK = - process.env.NETWORK_PASSPHRASE ?? "Test SDF Network ; September 2015"; + env.NETWORK_PASSPHRASE ?? "Test SDF Network ; September 2015"; function reqEnv(name: string): string { - const v = process.env[name]; + const v = env[name]; if (!v) throw new Error(`missing required env var ${name}`); return v; } diff --git a/services/keeper/scripts/lifecycle-e2e.ts b/services/keeper/scripts/lifecycle-e2e.ts index 3e68445b..f600beff 100644 --- a/services/keeper/scripts/lifecycle-e2e.ts +++ b/services/keeper/scripts/lifecycle-e2e.ts @@ -35,9 +35,11 @@ import { sealBid, } from "@sub-rosa/tlock"; import { systemTime } from "@sub-rosa/time"; +import { getSystemEnv } from "@sub-rosa/config"; import { closeRound, keepRound } from "../src/keeper.js"; +const env = getSystemEnv(); const { clock, scheduler } = systemTime; const DRAND_GENESIS = 1_692_803_367; @@ -48,15 +50,15 @@ const DRAND_PUBKEY_C1C0 = const DRAND_NEGGEN_C1C0 = "13e02b6052719f607dacd3a088274f65596bd0d09920b61ab5da61bbdc7f5049334cf11213945d57e5ac7d055d042b7e024aa2b2f08f0a91260805272dc51051c6e47ad4fa403b02b4510b647ae3d1770bac0326a805bbefd48056c8c121bdb813fa4d4a0ad8b1ce186ed5061789213d993923066dddaf1040bc3ff59f825c78df74f2d75467e25e0f55f8a00fa030ed0d1b3cc2c7027888be51d9ef691d77bcb679afda66c73f17f9ee3837a55024f78c71363275a75d75d86bab79f74782aa"; -const RPC_URL = process.env.RPC_URL ?? "https://soroban-testnet.stellar.org"; +const RPC_URL = env.RPC_URL ?? "https://soroban-testnet.stellar.org"; const NETWORK = - process.env.NETWORK_PASSPHRASE ?? "Test SDF Network ; September 2015"; + env.NETWORK_PASSPHRASE ?? "Test SDF Network ; September 2015"; const hex = (s: string) => Buffer.from(s, "hex"); const sha256 = (s: string) => createHash("sha256").update(s).digest(); const sleep = (ms: number) => scheduler.sleep(ms); const reqEnv = (n: string): string => { - const v = process.env[n]; + const v = env[n]; if (!v) throw new Error(`missing required env var ${n}`); return v; }; diff --git a/services/keeper/scripts/mainnet-settle.ts b/services/keeper/scripts/mainnet-settle.ts index 5b16bd3b..6acaef53 100644 --- a/services/keeper/scripts/mainnet-settle.ts +++ b/services/keeper/scripts/mainnet-settle.ts @@ -16,18 +16,20 @@ import { } from "@sub-rosa/sdk"; import { quicknet } from "@sub-rosa/tlock"; import { systemTime } from "@sub-rosa/time"; +import { getSystemEnv } from "@sub-rosa/config"; import { closeRound, keepRound } from "../src/keeper.js"; +const env = getSystemEnv(); const { clock, scheduler } = systemTime; -const RPC_URL = process.env.RPC_URL ?? "https://rpc.ankr.com/stellar_soroban"; +const RPC_URL = env.RPC_URL ?? "https://rpc.ankr.com/stellar_soroban"; const NETWORK = - process.env.NETWORK_PASSPHRASE ?? + env.NETWORK_PASSPHRASE ?? "Public Global Stellar Network ; September 2015"; function reqEnv(name: string): string { - const v = process.env[name]; + const v = env[name]; if (!v) throw new Error(`missing required env var ${name}`); return v; } @@ -41,7 +43,7 @@ async function main() { const keeperSecret = reqEnv("KEEPER_SECRET"); const contractId = reqEnv("ROUND_CONTRACT_ID"); - const roundId = BigInt(process.env.ROUND_ID ?? "1"); + const roundId = BigInt(env.ROUND_ID ?? "1"); const keeperKp = Keypair.fromSecret(keeperSecret); const reader = new SubRosaClient({ diff --git a/services/keeper/scripts/usdc-setup.ts b/services/keeper/scripts/usdc-setup.ts index 715a1169..240e6a98 100644 --- a/services/keeper/scripts/usdc-setup.ts +++ b/services/keeper/scripts/usdc-setup.ts @@ -17,15 +17,17 @@ import { TransactionBuilder, xdr, } from "@stellar/stellar-sdk"; +import { getSystemEnv } from "@sub-rosa/config"; +const env = getSystemEnv(); const HORIZON_URL = - process.env.HORIZON_URL ?? "https://horizon-testnet.stellar.org"; -const NETWORK = process.env.NETWORK_PASSPHRASE ?? Networks.TESTNET; -const ASSET_CODE = process.env.ASSET_CODE ?? "USDC"; -const MINT_AMOUNT = process.env.MINT_AMOUNT ?? "1000"; // whole USDC per bidder + env.HORIZON_URL ?? "https://horizon-testnet.stellar.org"; +const NETWORK = env.NETWORK_PASSPHRASE ?? Networks.TESTNET; +const ASSET_CODE = env.ASSET_CODE ?? "USDC"; +const MINT_AMOUNT = env.MINT_AMOUNT ?? "1000"; const reqEnv = (n: string): string => { - const v = process.env[n]; + const v = env[n]; if (!v) throw new Error(`missing required env var ${n}`); return v; }; diff --git a/services/keeper/src/dry-run.ts b/services/keeper/src/dry-run.ts index 4097b26d..a0dd9779 100644 --- a/services/keeper/src/dry-run.ts +++ b/services/keeper/src/dry-run.ts @@ -1,6 +1,6 @@ -// Copyright (c) 2026 Sub Rosa contributors import type { BidState, Round, SubRosaClient } from "@sub-rosa/sdk"; import { systemClock } from "@sub-rosa/time"; +import { getSystemEnv } from "@sub-rosa/config"; import { VOID_GRACE_SECONDS } from "./keeper.js"; @@ -88,7 +88,7 @@ function parseMaxWaitSeconds(value: string | undefined): number { } export function parseKeeperRunConfig( - env: Record = process.env, + env: Record = getSystemEnv(), ): KeeperRunConfig { const dryRun = parseBooleanEnv(env.KEEPER_DRY_RUN, "KEEPER_DRY_RUN"); const keeperSecret = env.KEEPER_SECRET?.trim() || undefined; diff --git a/services/keeper/src/queue.ts b/services/keeper/src/queue.ts index a913b447..106e91f8 100644 --- a/services/keeper/src/queue.ts +++ b/services/keeper/src/queue.ts @@ -1,5 +1,5 @@ -// Copyright (c) 2026 Sub Rosa contributors import { createLogger } from '@sub-rosa/logging'; +import { getSystemEnv } from '@sub-rosa/config'; const diagnostics = createLogger("services.keeper.src.queue"); import { KeeperStore, normalizeRoundId } from "./store.js"; @@ -31,8 +31,9 @@ function main() { usage(); } const roundId = normalizeRoundId(rawRoundId); - const contractId = process.env.ROUND_CONTRACT_ID; - const network = process.env.NETWORK_PASSPHRASE; + const env = getSystemEnv(); + const contractId = env.ROUND_CONTRACT_ID; + const network = env.NETWORK_PASSPHRASE; store.addRound(roundId, { contractId, network }); diagnostics.info("added-round", `Added round ${roundId} to the queue.`); } else if (cmd === "list") { diff --git a/services/keeper/src/serve.ts b/services/keeper/src/serve.ts index d7406c49..95996b5f 100644 --- a/services/keeper/src/serve.ts +++ b/services/keeper/src/serve.ts @@ -25,24 +25,27 @@ const diagnostics = createLogger("services.keeper.src.serve"); import { Keypair } from "@stellar/stellar-sdk"; import { SubRosaClient } from "@sub-rosa/sdk"; import { quicknet } from "@sub-rosa/tlock"; +import { getSystemEnv } from "@sub-rosa/config"; import { createSettlementGuard } from "./settlement-guard.js"; import { createStatusServer, withGracefulShutdown } from "./status-server.js"; import { KeeperStore } from "./store.js"; import { runWatchLoop } from "./watch-loop.js"; +const env = getSystemEnv(); + function reqEnv(name: string): string { - const v = process.env[name]; + const v = env[name]; if (!v) throw new Error(`missing required env var ${name}`); return v; } async function main() { - const pollMs = Number(process.env.WATCH_POLL_MS ?? "15000"); + const pollMs = Number(env.WATCH_POLL_MS ?? "15000"); const contractId = reqEnv("ROUND_CONTRACT_ID"); - const rpcUrl = process.env.RPC_URL ?? "https://soroban-testnet.stellar.org"; + const rpcUrl = env.RPC_URL ?? "https://soroban-testnet.stellar.org"; const networkPassphrase = - process.env.NETWORK_PASSPHRASE ?? "Test SDF Network ; September 2015"; + env.NETWORK_PASSPHRASE ?? "Test SDF Network ; September 2015"; const keeperSecret = reqEnv("KEEPER_SECRET"); const sdk = new SubRosaClient({ @@ -72,9 +75,9 @@ async function main() { stopping = true; }); - const statusEnabled = (process.env.KEEPER_STATUS_ENABLE ?? "true").toLowerCase() !== "false"; - const statusHost = process.env.KEEPER_STATUS_HOST ?? "127.0.0.1"; - const statusPort = Number(process.env.KEEPER_STATUS_PORT ?? "8090"); + const statusEnabled = (env.KEEPER_STATUS_ENABLE ?? "true").toLowerCase() !== "false"; + const statusHost = env.KEEPER_STATUS_HOST ?? "127.0.0.1"; + const statusPort = Number(env.KEEPER_STATUS_PORT ?? "8090"); let statusHandle: ReturnType | undefined; if (statusEnabled) { diff --git a/services/keeper/src/status-server.ts b/services/keeper/src/status-server.ts index d892440c..302ba48f 100644 --- a/services/keeper/src/status-server.ts +++ b/services/keeper/src/status-server.ts @@ -1,5 +1,5 @@ -// Copyright (c) 2026 Sub Rosa contributors import { createLogger, type Logger } from '@sub-rosa/logging'; +import { getSystemEnv } from '@sub-rosa/config'; const diagnostics = createLogger("services.keeper.src.status-server"); import http from "node:http"; @@ -158,8 +158,9 @@ function healthzHandler( } export function createStatusServer(config: StatusServerConfig): http.Server { - const host = config.host ?? process.env.KEEPER_STATUS_HOST ?? "127.0.0.1"; - const port = config.port ?? Number(process.env.KEEPER_STATUS_PORT ?? "8090"); + const env = getSystemEnv(); + const host = config.host ?? env.KEEPER_STATUS_HOST ?? "127.0.0.1"; + const port = config.port ?? Number(env.KEEPER_STATUS_PORT ?? "8090"); const source: BuildStatusSource = { logger: config.logger, diff --git a/services/keeper/src/store.ts b/services/keeper/src/store.ts index f4e04091..21d001b8 100644 --- a/services/keeper/src/store.ts +++ b/services/keeper/src/store.ts @@ -1,5 +1,5 @@ -// Copyright (c) 2026 Sub Rosa contributors import { createLogger, type Logger } from '@sub-rosa/logging'; +import { getSystemEnv } from '@sub-rosa/config'; const diagnostics = createLogger("services.keeper.src.store"); import * as fs from "fs"; import * as path from "path"; @@ -64,7 +64,7 @@ export class KeeperStore { constructor(storePath?: string, private readonly logger: Logger = diagnostics) { this.storePath = - storePath || process.env.KEEPER_STORE_PATH || ".keeper-store.json"; + storePath || getSystemEnv().KEEPER_STORE_PATH || ".keeper-store.json"; this.data = this.loadStore(); } diff --git a/services/keeper/src/watch-loop.ts b/services/keeper/src/watch-loop.ts index c448db66..279df689 100644 --- a/services/keeper/src/watch-loop.ts +++ b/services/keeper/src/watch-loop.ts @@ -12,6 +12,7 @@ import type { SubRosaClient } from "@sub-rosa/sdk"; import type { DrandClient } from "@sub-rosa/tlock"; import { resolveTimeContext, systemTime, type PartialTimeContext } from "@sub-rosa/time"; +import { getSystemEnv } from "@sub-rosa/config"; import { discoverRoundIds, @@ -52,13 +53,14 @@ function summarizeTick(t: WatchTickResult): string { } async function resolveRoundIds(reader: SubRosaClient): Promise { - const spec = process.env.WATCH_ROUND_IDS?.trim(); + const env = getSystemEnv(); + const spec = env.WATCH_ROUND_IDS?.trim(); if (spec) return parseRoundIdSpec(spec); - const single = process.env.ROUND_ID?.trim(); + const single = env.ROUND_ID?.trim(); if (single) return [BigInt(single)]; return discoverRoundIds(reader, { - from: BigInt(process.env.WATCH_FROM ?? "1"), - maxProbe: Number(process.env.WATCH_MAX_ROUNDS ?? "64"), + from: BigInt(env.WATCH_FROM ?? "1"), + maxProbe: Number(env.WATCH_MAX_ROUNDS ?? "64"), }); } diff --git a/services/keeper/src/watch.ts b/services/keeper/src/watch.ts index 12a7972e..a0bce595 100644 --- a/services/keeper/src/watch.ts +++ b/services/keeper/src/watch.ts @@ -21,19 +21,22 @@ import { quicknet } from "@sub-rosa/tlock"; import { createSettlementGuard } from "./settlement-guard.js"; import { KeeperStore } from "./store.js"; import { runWatchLoop } from "./watch-loop.js"; +import { getSystemEnv } from "@sub-rosa/config"; + +const env = getSystemEnv(); function reqEnv(name: string): string { - const v = process.env[name]; + const v = env[name]; if (!v) throw new Error(`missing required env var ${name}`); return v; } async function main() { - const pollMs = Number(process.env.WATCH_POLL_MS ?? "15000"); + const pollMs = Number(env.WATCH_POLL_MS ?? "15000"); const contractId = reqEnv("ROUND_CONTRACT_ID"); - const rpcUrl = process.env.RPC_URL ?? "https://soroban-testnet.stellar.org"; + const rpcUrl = env.RPC_URL ?? "https://soroban-testnet.stellar.org"; const networkPassphrase = - process.env.NETWORK_PASSPHRASE ?? "Test SDF Network ; September 2015"; + env.NETWORK_PASSPHRASE ?? "Test SDF Network ; September 2015"; const keeperSecret = reqEnv("KEEPER_SECRET"); const sdk = new SubRosaClient({ diff --git a/services/receipt-cli/package.json b/services/receipt-cli/package.json index 0edea34a..df9223d4 100644 --- a/services/receipt-cli/package.json +++ b/services/receipt-cli/package.json @@ -19,6 +19,7 @@ "typecheck": "tsc --noEmit -p tsconfig.json" }, "dependencies": { + "@sub-rosa/config": "workspace:*", "@sub-rosa/logging": "workspace:*", "@sub-rosa/sdk": "workspace:*", "@sub-rosa/time": "workspace:*", diff --git a/services/receipt-cli/src/index.ts b/services/receipt-cli/src/index.ts index 034294f3..1951f9e3 100644 --- a/services/receipt-cli/src/index.ts +++ b/services/receipt-cli/src/index.ts @@ -7,6 +7,7 @@ const diagnostics = createLogger("services.receipt-cli.src.index"); import { readFileSync, writeFileSync } from "node:fs"; import { createHash } from "node:crypto"; import { SubRosaClient, parseReceipt, serializeReceipt, verifyReceipt, redactReceipt } from "@sub-rosa/sdk"; +import { getSystemEnv } from "@sub-rosa/config"; import { buildJsonOutput } from "./json-output.js"; function usage(): never { @@ -29,12 +30,12 @@ Environment for "export": process.exit(1); } -async function cmdExport(roundIdStr: string) { +async function cmdExport(roundIdStr: string, env: Record = getSystemEnv()) { const roundId = BigInt(roundIdStr); - const rpcUrl = process.env.RPC_URL ?? "https://soroban-testnet.stellar.org"; + const rpcUrl = env.RPC_URL ?? "https://soroban-testnet.stellar.org"; const networkPassphrase = - process.env.NETWORK_PASSPHRASE ?? "Test SDF Network ; September 2015"; - const contractId = process.env.CONTRACT_ID; + env.NETWORK_PASSPHRASE ?? "Test SDF Network ; September 2015"; + const contractId = env.CONTRACT_ID; if (!contractId) { diagnostics.error("contract-id-env-var-is-required-for-export", "CONTRACT_ID env var is required for export"); process.exit(1);