diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..3660c87 --- /dev/null +++ b/.env.example @@ -0,0 +1,15 @@ +# Local Aegis SDK development — copy to `.env` and fill in values. +# Never commit `.env` (see .gitignore). + +# Required after you deploy the Aegis contract to local Quickstart / standalone. +AEGIS_CONTRACT_ID= + +# Optional overrides (defaults match Stellar Quickstart on localhost:8000) +# AEGIS_LOCAL_RPC_URL=http://localhost:8000/soroban/rpc +# AEGIS_LOCAL_NETWORK_PASSPHRASE=Standalone Network ; February 2017 +# AEGIS_LOCAL_HORIZON_URL=http://localhost:8000 +# AEGIS_LOCAL_FRIENDBOT_URL=http://localhost:8000/friendbot + +# Optional ephemeral signer for local write calls. Prefer Keypair.random() in code. +# Never paste a testnet/mainnet secret here. +# AEGIS_LOCAL_SECRET_KEY= diff --git a/.gitignore b/.gitignore index 76fb2e5..e284f20 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,6 @@ .idea node_modules -dist \ No newline at end of file +dist +.env +.env.local +.env.*.local diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 62afcbb..03d1f3c 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -6,9 +6,10 @@ We welcome open-source contributions! As middleware, this SDK is critical for th 1. **Fork & Clone:** Fork the repo and clone it locally. 2. **Install Dependencies:** Run `npm install`. 3. **Branching:** Use `feat/`, `fix/`, or `chore/` prefixes. -4. **Testing:** You MUST write unit tests in `tests/` for any new methods added. PRs without test coverage will be rejected. For predictable SDK responses without live RPC, use the mock client from `@aegis/sdk/testing` (see `docs/testing.md`). Behavior changes should follow the [Test-First Contribution Guide](docs/test-first-contribution.md), including happy-path, negative-path, and no-test justification rules. -5. **Formatting:** Ensure `npm run lint` and `npm run format` pass before opening a PR. -6. **CI Verification:** Run `npm run check` locally to verify that build, unit tests, and runtime compatibility checks pass. PRs with failing GitHub Actions CI checks will not be reviewed or merged until all status checks are green (see [CI Pass Requirements](docs/ci-pass-requirements.md) and [CI Resolution Workflow](docs/ci-resolution-workflow.md)). +4. **Local network (optional):** For integration against Quickstart, see [Local development](docs/local-development.md) (`npm run local:up`, `createLocalClient`, `AEGIS_CONTRACT_ID`). Unit tests should keep using `@aegis/sdk/testing` mocks — do not require Docker for `npm test`. +5. **Testing:** You MUST write unit tests in `tests/` for any new methods added. PRs without test coverage will be rejected. For predictable SDK responses without live RPC, use the mock client from `@aegis/sdk/testing` (see `docs/testing.md`). Behavior changes should follow the [Test-First Contribution Guide](docs/test-first-contribution.md), including happy-path, negative-path, and no-test justification rules. +6. **Formatting:** Ensure `npm run lint` and `npm run format` pass before opening a PR. +7. **CI Verification:** Run `npm run check` locally to verify that build, unit tests, and runtime compatibility checks pass. PRs with failing GitHub Actions CI checks will not be reviewed or merged until all status checks are green (see [CI Pass Requirements](docs/ci-pass-requirements.md) and [CI Resolution Workflow](docs/ci-resolution-workflow.md)). Search the codebase for `// TODO:` comments to find areas that need immediate help! diff --git a/README.md b/README.md index 0ccdb4d..45b1cb2 100644 --- a/README.md +++ b/README.md @@ -104,6 +104,29 @@ if (event.kind === 'transfer') { See [Contract Event Decoder](./docs/contract-events.md) for supported topics, unknown fallback behaviour, and dashboard integration guidance. +## Local Development + +Use the local-development helpers against Stellar Quickstart / standalone +instead of hand-rolling `environment: 'local'`: + +```typescript +import { createLocalClient, checkLocalNetwork } from '@aegis/sdk'; +import { Keypair } from '@stellar/stellar-sdk'; + +const client = createLocalClient({ + contractId: process.env.AEGIS_CONTRACT_ID!, + env: process.env, + keypair: Keypair.random(), +}); + +const health = await checkLocalNetwork(client); +``` + +See [Local development](./docs/local-development.md) for Docker Compose +(`npm run local:up`), env vars, loopback policy, and security assumptions. +Environment presets (testnet / local / mainnet) are documented in +[Environment presets](./docs/environments.md). + ## Testing To run the SDK unit tests locally: diff --git a/docker-compose.local.yml b/docker-compose.local.yml new file mode 100644 index 0000000..86820f4 --- /dev/null +++ b/docker-compose.local.yml @@ -0,0 +1,26 @@ +# Local Stellar Quickstart for Aegis SDK development. +# Usage: npm run local:up +# +# After the container is healthy, deploy the Aegis contract with stellar-cli +# against the standalone network and set AEGIS_CONTRACT_ID in your .env. + +services: + stellar-quickstart: + image: stellar/quickstart:latest + container_name: aegis-sdk-local-stellar + ports: + - '8000:8000' + # --local selects Networks.STANDALONE; --enable-soroban-rpc exposes RPC. + command: ['--local', '--enable-soroban-rpc'] + healthcheck: + test: + [ + 'CMD', + 'curl', + '-sf', + 'http://localhost:8000/soroban/rpc', + ] + interval: 10s + timeout: 5s + retries: 30 + start_period: 40s diff --git a/docs/api-reference.md b/docs/api-reference.md index e047534..5de195c 100644 --- a/docs/api-reference.md +++ b/docs/api-reference.md @@ -16,6 +16,16 @@ The entry point for interacting with the Aegis Protocol. Either `environment` or both `rpcUrl` and `networkPassphrase` must be provided. Invalid or unsafe configuration (malformed URLs, insecure `http://` overrides outside the `local` preset, empty passphrases, unavailable environments) throws a `ConfigValidationError`. +### Local development helpers + +For Quickstart / standalone workflows prefer: + +* `createLocalClient(options)` / `resolveLocalConfig(options)` — typed local defaults, injected env loading, required contract ID, loopback enforcement +* `checkLocalNetwork(target)` — readiness probe (`ready` | `unavailable` | `misconfigured`) +* `LOCAL_DEV_DEFAULTS` / `LOCAL_ENV_KEYS` — shared constants + +See [Local development](./local-development.md). Environment presets: [Environment Presets](./environments.md). + ### Client Modules * `client.compliance`: Whitelist & KYC verification module (`ComplianceModule`). * `client.asset`: Minting & transferring RWA tokens module (`AssetModule`). diff --git a/docs/environments.md b/docs/environments.md index dc50f34..b226ae2 100644 --- a/docs/environments.md +++ b/docs/environments.md @@ -48,6 +48,19 @@ Overrides are validated: malformed URLs and empty passphrases throw a `ConfigVal Plain `http://` URLs are only accepted when `environment: 'local'` — using `http://` against `testnet` or `mainnet` throws, since it almost always indicates a misconfigured endpoint. +## Local development workflow + +For contributor machines and CI sandboxes, prefer the dedicated local helpers +over a bare `environment: 'local'` preset: + +* `createLocalClient` / `resolveLocalConfig` — defaults, env loading, required contract ID +* Loopback / `host.docker.internal` enforcement for plain `http://` RPC URLs +* `checkLocalNetwork` — readiness probe with “start Docker” hints +* `npm run local:up` — optional Quickstart via `docker-compose.local.yml` + +See [Local development](./local-development.md) for the full workflow, env var +table, security assumptions, and reviewer checklist. + ## The `mainnet` Preset Is Gated The Aegis protocol has not yet been audited/deployed on Stellar mainnet, so the `mainnet` diff --git a/docs/local-development.md b/docs/local-development.md new file mode 100644 index 0000000..bbebc61 --- /dev/null +++ b/docs/local-development.md @@ -0,0 +1,181 @@ +# Local development configuration + +Typed helpers for running the Aegis SDK against a **local Stellar standalone / +Quickstart** network. This is for developer machines and CI sandboxes — not a +substitute for testnet integration testing. + +> **Compliance note:** Local whitelist / KYC behaviour reflects whatever the +> locally deployed Aegis contract reports. It is protocol-level test data only +> and is **not** legal, financial, or regulatory compliance advice. + +## Why this exists + +`environment: 'local'` alone only fills `rpcUrl` and `networkPassphrase`. Local +work also needs: + +* a **deployed contract ID** (no safe default) +* **loopback enforcement** so plain `http://` cannot silently point at a remote host +* optional **env-based** loading for `.env` / CI secrets injection +* a **readiness probe** that says “start Docker” instead of “retry with backoff” + +## Quickstart + +```bash +# Optional: start Quickstart (Soroban RPC on :8000) +npm run local:up + +# Copy env template and set your deployed contract ID +cp .env.example .env +``` + +```typescript +import { createLocalClient, checkLocalNetwork } from '@aegis/sdk'; +import { Keypair } from '@stellar/stellar-sdk'; + +const client = createLocalClient({ + contractId: process.env.AEGIS_CONTRACT_ID!, + // Or inject the whole map explicitly (preferred in tests / browsers): + env: process.env, + keypair: Keypair.random(), // ephemeral local signer only +}); + +const health = await checkLocalNetwork(client); +if (health.status !== 'ready') { + console.error(health.message, health.hint); +} +``` + +`resolveLocalConfig` / `createLocalClient` never read `process.env` at module +scope. Pass `env: process.env` (Node) or individual fields when you want env +overrides — this keeps the browser bundle free of Node globals. + +## Defaults + +| Field | Default | +| --- | --- | +| `rpcUrl` | `http://localhost:8000/soroban/rpc` | +| `networkPassphrase` | `Networks.STANDALONE` (`Standalone Network ; February 2017`) | +| `horizonUrl` | `http://localhost:8000` | +| `friendbotUrl` | `http://localhost:8000/friendbot` | +| `contractId` | **required** — no default | + +Constants: `LOCAL_DEV_DEFAULTS`, `LOCAL_ENV_KEYS`. + +## Environment variables + +| Variable | Purpose | +| --- | --- | +| `AEGIS_CONTRACT_ID` | StrKey contract ID from your local deploy | +| `AEGIS_LOCAL_RPC_URL` | Override RPC URL | +| `AEGIS_LOCAL_NETWORK_PASSPHRASE` | Override passphrase (rarely needed) | +| `AEGIS_LOCAL_HORIZON_URL` | Horizon / Quickstart root | +| `AEGIS_LOCAL_FRIENDBOT_URL` | Friendbot URL for funding accounts | +| `AEGIS_LOCAL_SECRET_KEY` | Optional signer seed (`S...`) — ephemeral keys only | + +Precedence per field: **explicit option → env → default**. + +Whitespace-only values are treated as unset. + +## Loopback / local-host policy + +Plain `http://` RPC URLs are accepted only when the host is: + +* loopback — `localhost`, `127.0.0.0/8`, `::1`, `0.0.0.0` +* or Docker Desktop’s `host.docker.internal` (SDK container → host Quickstart) + +Anything else throws `LocalConfigError` with code `NON_LOOPBACK_RPC_URL` unless +you pass `allowNonLoopbackRpc: true` (trusted private networks only). + +This is **enforcement**, not a soft warning. Generic +`new AegisClient({ environment: 'local', rpcUrl: 'http://remote...' })` still +goes through `resolveClientConfig` and does not apply this guard — use +`createLocalClient` / `resolveLocalConfig` for local workflows. + +## Readiness probe + +```typescript +const health = await checkLocalNetwork({ + contractId: process.env.AEGIS_CONTRACT_ID!, + env: process.env, +}); + +// health.status: 'ready' | 'unavailable' | 'misconfigured' +``` + +| Status | Meaning | Typical hint | +| --- | --- | --- | +| `ready` | `getHealth` succeeded; passphrase matches when available | — | +| `unavailable` | Connection refused / timeout on local RPC | `npm run local:up` | +| `misconfigured` | Reachable but wrong passphrase / unexpected RPC error | Check `--local` / RPC path | + +A refused connection is **not** marked retryable — restarting a stopped +container requires operator action, not backoff. + +## Edge cases and failure codes + +| Code | When | +| --- | --- | +| `MISSING_CONTRACT_ID` | No `contractId` option and no `AEGIS_CONTRACT_ID` | +| `INVALID_CONTRACT_ID` | Value is not a StrKey contract ID (e.g. `"C..."`) | +| `INVALID_RPC_URL` | Unparseable or non-http(s) URL | +| `NON_LOOPBACK_RPC_URL` | Insecure remote http without opt-in | +| `INVALID_SECRET_KEY` | `AEGIS_LOCAL_SECRET_KEY` / `secretKey` is not a valid seed | +| `INVALID_NETWORK_PASSPHRASE` | Empty passphrase override | +| `LOCAL_NETWORK_UNAVAILABLE` | Reserved for thrown readiness failures (probe returns status instead) | +| `LOCAL_NETWORK_MISCONFIGURED` | Reserved for thrown readiness failures (probe returns status instead) | + +Errors never echo secret keys or credential-bearing URL userinfo. + +## Security assumptions + +1. **Local keys are disposable.** Generate with `Keypair.random()`. Never reuse + testnet/mainnet secrets in `.env`. +2. **`.env` is not committed.** See `.env.example` and `.gitignore`. +3. **HTTP is loopback-scoped** via `resolveLocalConfig`. Do not disable that + guard to point at public infrastructure. +4. **Friendbot / Horizon URLs** in defaults are public local endpoints — they + are not production funding rails. +5. **Do not `JSON.stringify` a `ResolvedLocalDevConfig` that includes a + `keypair`** into GitHub issues — Keypair objects can serialise seed bytes. + Share `checkLocalNetwork` output or redact first. + +## Docker Compose + +```bash +npm run local:up # docker compose -f docker-compose.local.yml up -d +npm run local:down +``` + +The compose file pins the documented Quickstart image and publishes port +`8000`. After it is healthy, deploy the Aegis contract with `stellar-cli` +against the standalone network, then set `AEGIS_CONTRACT_ID`. + +> Image tags move. For shared CI, pin a digest in your fork’s compose override. + +## Troubleshooting + +| Symptom | Likely cause | Fix | +| --- | --- | --- | +| `status: 'unavailable'` | Container not running / wrong port | `npm run local:up`, check `:8000` | +| Passphrase mismatch | Quickstart not started with `--local` | Recreate the container from `docker-compose.local.yml` | +| HTTP 404 from RPC | Path differs across Quickstart versions | Try `/rpc` vs `/soroban/rpc` via `AEGIS_LOCAL_RPC_URL` | +| Unfunded account | Need Friendbot | `GET {friendbotUrl}?addr=G...` | +| `NON_LOOPBACK_RPC_URL` | Remote http URL | Use localhost or set `allowNonLoopbackRpc: true` deliberately | + +## Reviewer checklist + +- [ ] Local helpers live in `src/config/local.ts` (not ad-hoc in examples). +- [ ] Loopback / `host.docker.internal` policy is enforced by default. +- [ ] Missing contract ID fails with a typed error and deploy hint. +- [ ] Secret keys never appear in error messages or returned plain fields. +- [ ] Tests cover precedence, rejection matrix, and readiness-unavailable. +- [ ] Docs state that local compliance checks are not legal advice. +- [ ] README / `docs/environments.md` link here. +- [ ] Change stays compatible with `environment: 'local'` on `AegisClient`. + +## Related + +* [Environment presets](./environments.md) +* [Network failures](./network-failures.md) +* [Testing utilities](./testing.md) +* [API reference](./api-reference.md) diff --git a/package.json b/package.json index 03b1908..4a47d9a 100644 --- a/package.json +++ b/package.json @@ -20,6 +20,8 @@ "test:compat": "node scripts/check-compat.mjs", "check": "npm run build && npm test -- --runInBand && npm run test:compat", "verify": "npm run lint && npm run format && npm run build && npm test -- --runInBand && npm run test:compat", + "local:up": "docker compose -f docker-compose.local.yml up -d", + "local:down": "docker compose -f docker-compose.local.yml down", "lint": "eslint src --ext .ts", "format": "prettier --write \"src/**/*.ts\"", "prepare": "npm run build" diff --git a/src/config/environments.ts b/src/config/environments.ts index 38d913f..d320d03 100644 --- a/src/config/environments.ts +++ b/src/config/environments.ts @@ -34,7 +34,8 @@ export const AEGIS_ENVIRONMENTS: Record>; + +export interface LocalDevConfigOptions { + /** Deployed Aegis contract ID on the local network (`C...`). */ + contractId?: string; + rpcUrl?: string; + networkPassphrase?: string; + horizonUrl?: string; + friendbotUrl?: string; + /** Prefer this over `secretKey` when you already hold a Keypair instance. */ + keypair?: Keypair; + /** + * Optional secret key string. Never echoed in errors or returned fields. + * Ignored when `keypair` is provided. + */ + secretKey?: string; + /** + * Allow plain-http RPC URLs whose host is not loopback / docker-desktop. + * Default `false` — local http against a remote host is almost always a + * misconfiguration that would leak signing traffic in cleartext. + */ + allowNonLoopbackRpc?: boolean; + /** + * Injected environment map (e.g. `process.env` in Node). Omit in browsers. + */ + env?: LocalEnvRecord; +} + +/** + * Fully resolved local-development configuration. + * + * Safe to inspect in tests and docs. Never contains the secret key string — + * only an optional `Keypair` instance when signing was requested. + */ +export interface ResolvedLocalDevConfig { + readonly environment: 'local'; + readonly rpcUrl: string; + readonly networkPassphrase: string; + readonly contractId: string; + readonly horizonUrl: string; + readonly friendbotUrl: string; + readonly keypair?: Keypair; + /** True when the RPC host is a true loopback address. */ + readonly loopback: boolean; + /** True when the RPC host is an allowed local-dev host (loopback or docker). */ + readonly localDevHost: boolean; +} + +export type LocalNetworkStatus = 'ready' | 'unavailable' | 'misconfigured'; + +/** + * Result of {@link checkLocalNetwork}. Safe for dashboards and support pastes. + */ +export interface LocalNetworkHealth { + readonly status: LocalNetworkStatus; + readonly rpcDisplay: string; + readonly networkPassphraseMatches: boolean | null; + readonly message: string; + readonly hint?: string; + readonly retryable: boolean; +} + +/** + * True for loopback hostnames (`localhost`, `127.0.0.0/8`, `::1`, `0.0.0.0`). + */ +export function isLoopbackHostname(hostname: string): boolean { + const host = normalizeHostname(hostname); + if (host === 'localhost' || host === '::1' || host === '0.0.0.0') { + return true; + } + return /^127(?:\.\d{1,3}){3}$/.test(host); +} + +/** + * True for hosts treated as local-development targets by this SDK. + * + * Includes true loopback plus `host.docker.internal` (SDK-in-container talking + * to Quickstart on the Docker host). + */ +export function isAllowedLocalDevHostname(hostname: string): boolean { + const host = normalizeHostname(hostname); + return isLoopbackHostname(host) || host === 'host.docker.internal'; +} + +/** + * Parses `url` and reports whether its host is loopback / allowed for local http. + */ +export function inspectLocalRpcUrl(url: string): { + valid: boolean; + loopback: boolean; + localDevHost: boolean; + protocol: string; + display: string; +} { + let parsed: URL; + try { + parsed = new URL(url); + } catch { + return { + valid: false, + loopback: false, + localDevHost: false, + protocol: 'unknown', + display: '', + }; + } + + const hasCredentials = Boolean(parsed.username || parsed.password); + const display = `${parsed.protocol}//${ + hasCredentials ? '@' : '' + }${parsed.host}${parsed.pathname === '/' ? '' : parsed.pathname}`; + + return { + valid: parsed.protocol === 'http:' || parsed.protocol === 'https:', + loopback: isLoopbackHostname(parsed.hostname), + localDevHost: isAllowedLocalDevHostname(parsed.hostname), + protocol: parsed.protocol.replace(/:$/, ''), + display, + }; +} + +/** + * Resolves local-development configuration. + * + * Precedence for each field: explicit option → injected env → {@link LOCAL_DEV_DEFAULTS}. + * `contractId` has no default and must be supplied (option or `AEGIS_CONTRACT_ID`). + */ +export function resolveLocalConfig( + options: LocalDevConfigOptions = {}, +): ResolvedLocalDevConfig { + const env = options.env ?? {}; + + const rpcUrl = firstNonEmpty( + options.rpcUrl, + env[LOCAL_ENV_KEYS.rpcUrl], + LOCAL_DEV_DEFAULTS.rpcUrl, + )!; + const networkPassphrase = firstNonEmpty( + options.networkPassphrase, + env[LOCAL_ENV_KEYS.networkPassphrase], + LOCAL_DEV_DEFAULTS.networkPassphrase, + )!; + const horizonUrl = firstNonEmpty( + options.horizonUrl, + env[LOCAL_ENV_KEYS.horizonUrl], + LOCAL_DEV_DEFAULTS.horizonUrl, + )!; + const friendbotUrl = firstNonEmpty( + options.friendbotUrl, + env[LOCAL_ENV_KEYS.friendbotUrl], + LOCAL_DEV_DEFAULTS.friendbotUrl, + )!; + const contractId = firstNonEmpty( + options.contractId, + env[LOCAL_ENV_KEYS.contractId], + ); + + if (!contractId) { + throw new LocalConfigError( + 'Local development requires a contractId (deploy the Aegis contract locally, then set AEGIS_CONTRACT_ID or pass contractId).', + 'MISSING_CONTRACT_ID', + 'Deploy with stellar-cli against the local network, then export AEGIS_CONTRACT_ID=C...', + ); + } + + if (!StrKey.isValidContract(contractId)) { + throw new LocalConfigError( + 'Invalid contractId: expected a StrKey-encoded Soroban contract ID.', + 'INVALID_CONTRACT_ID', + 'Placeholders like "C..." are not valid. Use the ID returned by your local deploy.', + ); + } + + if ( + typeof networkPassphrase !== 'string' || + networkPassphrase.trim().length === 0 + ) { + throw new LocalConfigError( + 'Invalid networkPassphrase: must be a non-empty string.', + 'INVALID_NETWORK_PASSPHRASE', + ); + } + + const inspection = inspectLocalRpcUrl(rpcUrl); + if (!inspection.valid) { + throw new LocalConfigError( + `Invalid rpcUrl: "${inspection.display}" is not a valid http(s) URL.`, + 'INVALID_RPC_URL', + ); + } + + if (inspection.protocol === 'http' && !inspection.localDevHost) { + if (options.allowNonLoopbackRpc !== true) { + throw new LocalConfigError( + `Refusing insecure non-local rpcUrl "${inspection.display}". ` + + 'Local http is limited to loopback / host.docker.internal unless allowNonLoopbackRpc is true.', + 'NON_LOOPBACK_RPC_URL', + 'Point AEGIS_LOCAL_RPC_URL at localhost, or pass allowNonLoopbackRpc: true only for trusted private networks.', + ); + } + } + + const keypair = resolveKeypair(options, env); + + return Object.freeze({ + environment: 'local', + rpcUrl, + networkPassphrase, + contractId, + horizonUrl, + friendbotUrl, + ...(keypair ? { keypair } : {}), + loopback: inspection.loopback, + localDevHost: inspection.localDevHost, + }); +} + +/** + * Converts a resolved local config into an `AegisClientConfig` suitable for + * `new AegisClient(...)` or the role-aware factories. + */ +export function toAegisClientConfig( + resolved: ResolvedLocalDevConfig, +): AegisClientConfig { + return { + environment: 'local', + rpcUrl: resolved.rpcUrl, + networkPassphrase: resolved.networkPassphrase, + contractId: resolved.contractId, + ...(resolved.keypair ? { keypair: resolved.keypair } : {}), + }; +} + +/** + * Constructs an `AegisClient` preconfigured for local Quickstart / standalone. + */ +export function createLocalClient( + options: LocalDevConfigOptions = {}, +): AegisClient { + return new AegisClient(toAegisClientConfig(resolveLocalConfig(options))); +} + +/** + * Probes whether the local Soroban RPC is reachable and matches the expected + * network passphrase. + * + * Unlike generic network diagnostics, a refused connection on loopback maps to + * "start the local network" rather than "retry with backoff". + */ +export async function checkLocalNetwork( + target: AegisClient | ResolvedLocalDevConfig | LocalDevConfigOptions, +): Promise { + const resolved = + target instanceof AegisClient + ? { + rpcUrl: readClientRpcUrl(target), + networkPassphrase: target.networkPassphrase, + } + : isResolvedLocal(target) + ? target + : resolveLocalConfig(target); + + const inspection = inspectLocalRpcUrl(resolved.rpcUrl); + const server = new rpc.Server(resolved.rpcUrl, { + allowHttp: resolved.rpcUrl.startsWith('http://'), + }); + + try { + await server.getHealth(); + } catch (error) { + const failure = classifyNetworkFailure(error); + if ( + failure.code === 'RPC_UNAVAILABLE' || + failure.code === 'TIMEOUT' || + failure.code === 'UNKNOWN' + ) { + return freezeHealth({ + status: 'unavailable', + rpcDisplay: inspection.display, + networkPassphraseMatches: null, + message: 'The local Soroban RPC endpoint is not reachable.', + hint: + 'Start the local network (npm run local:up or docker compose -f docker-compose.local.yml up -d), then retry.', + retryable: false, + }); + } + + return freezeHealth({ + status: 'misconfigured', + rpcDisplay: inspection.display, + networkPassphraseMatches: null, + message: failure.message, + hint: 'Inspect the local RPC path and Quickstart flags; newer images may expose /rpc instead of /soroban/rpc.', + retryable: failure.retryable, + }); + } + + let networkPassphraseMatches: boolean | null = null; + try { + const network = await server.getNetwork(); + if (typeof network?.passphrase === 'string') { + networkPassphraseMatches = + network.passphrase === resolved.networkPassphrase; + } + } catch { + networkPassphraseMatches = null; + } + + if (networkPassphraseMatches === false) { + return freezeHealth({ + status: 'misconfigured', + rpcDisplay: inspection.display, + networkPassphraseMatches: false, + message: + 'The local RPC is reachable, but its network passphrase does not match the configured standalone passphrase.', + hint: `Expected "${Networks.STANDALONE}". Confirm the Quickstart container was started with --local.`, + retryable: false, + }); + } + + return freezeHealth({ + status: 'ready', + rpcDisplay: inspection.display, + networkPassphraseMatches, + message: 'Local Soroban RPC is reachable.', + retryable: false, + }); +} + +function resolveKeypair( + options: LocalDevConfigOptions, + env: LocalEnvRecord, +): Keypair | undefined { + if (options.keypair) { + if (!(options.keypair instanceof Keypair)) { + throw new LocalConfigError( + 'Invalid keypair: expected a Stellar Keypair instance.', + 'INVALID_SECRET_KEY', + ); + } + return options.keypair; + } + + const secret = firstNonEmpty(options.secretKey, env[LOCAL_ENV_KEYS.secretKey]); + if (!secret) { + return undefined; + } + + try { + return Keypair.fromSecret(secret); + } catch { + throw new LocalConfigError( + 'Invalid secret key for local development. Expected a Stellar secret seed (S...).', + 'INVALID_SECRET_KEY', + 'Generate an ephemeral key with Keypair.random() for local work. Never reuse testnet/mainnet secrets.', + ); + } +} + +function firstNonEmpty( + ...values: Array +): string | undefined { + for (const value of values) { + if (typeof value === 'string' && value.trim().length > 0) { + return value.trim(); + } + } + return undefined; +} + +function normalizeHostname(hostname: string): string { + return hostname.trim().toLowerCase().replace(/^\[|\]$/g, ''); +} + +function isResolvedLocal( + value: ResolvedLocalDevConfig | LocalDevConfigOptions, +): value is ResolvedLocalDevConfig { + return ( + typeof value === 'object' && + value !== null && + 'environment' in value && + (value as ResolvedLocalDevConfig).environment === 'local' && + typeof (value as ResolvedLocalDevConfig).contractId === 'string' && + typeof (value as ResolvedLocalDevConfig).loopback === 'boolean' + ); +} + +function readClientRpcUrl(client: AegisClient): string { + const server = client.rpcServer as rpc.Server & { + serverURL?: { toString(): string } | string; + }; + if (server.serverURL && typeof server.serverURL === 'object') { + return server.serverURL.toString().replace(/\/$/, ''); + } + if (typeof server.serverURL === 'string' && server.serverURL) { + return server.serverURL.replace(/\/$/, ''); + } + return LOCAL_DEV_DEFAULTS.rpcUrl; +} + +function freezeHealth(health: LocalNetworkHealth): LocalNetworkHealth { + return Object.freeze(health); +} diff --git a/src/errors/local.ts b/src/errors/local.ts new file mode 100644 index 0000000..9877821 --- /dev/null +++ b/src/errors/local.ts @@ -0,0 +1,29 @@ +/** + * Typed failures for local-development configuration and network readiness. + * + * Messages are intentionally free of secrets, raw RPC URLs with credentials, + * and secret-key material so they remain safe for logs and support pastes. + */ +export type LocalConfigErrorCode = + | 'MISSING_CONTRACT_ID' + | 'INVALID_CONTRACT_ID' + | 'INVALID_RPC_URL' + | 'NON_LOOPBACK_RPC_URL' + | 'INVALID_SECRET_KEY' + | 'INVALID_NETWORK_PASSPHRASE' + | 'LOCAL_NETWORK_UNAVAILABLE' + | 'LOCAL_NETWORK_MISCONFIGURED'; + +export class LocalConfigError extends Error { + public readonly code: LocalConfigErrorCode; + /** Optional recovery hint suitable for dashboards and docs. */ + public readonly hint?: string; + + constructor(message: string, code: LocalConfigErrorCode, hint?: string) { + super(message); + this.name = 'LocalConfigError'; + this.code = code; + this.hint = hint; + Object.setPrototypeOf(this, LocalConfigError.prototype); + } +} diff --git a/src/index.ts b/src/index.ts index b498dcf..54602ab 100644 --- a/src/index.ts +++ b/src/index.ts @@ -42,6 +42,25 @@ export { } from './diagnostics/network'; export { resolveClientConfig } from './config/validate'; export { AEGIS_ENVIRONMENTS, getEnvironmentPreset } from './config/environments'; +export { + LOCAL_DEV_DEFAULTS, + LOCAL_ENV_KEYS, + checkLocalNetwork, + createLocalClient, + inspectLocalRpcUrl, + isAllowedLocalDevHostname, + isLoopbackHostname, + resolveLocalConfig, + toAegisClientConfig, +} from './config/local'; +export type { + LocalDevConfigOptions, + LocalEnvRecord, + LocalNetworkHealth, + LocalNetworkStatus, + ResolvedLocalDevConfig, +} from './config/local'; +export * from './errors/local'; export * from './types/portfolio'; export * from './errors/portfolio'; export * from './types/role'; diff --git a/tests/config.test.ts b/tests/config.test.ts index a4257cb..8b19981 100644 --- a/tests/config.test.ts +++ b/tests/config.test.ts @@ -24,6 +24,7 @@ describe('AegisClient environment presets', () => { }); expect(client.networkPassphrase).toBe(AEGIS_ENVIRONMENTS.local.networkPassphrase); + expect(client.networkPassphrase).toBe(Networks.STANDALONE); }); it('rejects the mainnet preset unless allowMainnet is set', () => { diff --git a/tests/fixtures/local-config.ts b/tests/fixtures/local-config.ts new file mode 100644 index 0000000..dd0778c --- /dev/null +++ b/tests/fixtures/local-config.ts @@ -0,0 +1,40 @@ +import { Networks } from '@stellar/stellar-sdk'; + +/** Valid StrKey contract ID used across local-config fixtures (not a real deploy). */ +export const LOCAL_FIXTURE_CONTRACT_ID = + 'CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABSC4'; + +export const LOCAL_FIXTURE_RPC_URL = 'http://localhost:8000/soroban/rpc'; + +export const localEnvValid = Object.freeze({ + AEGIS_CONTRACT_ID: LOCAL_FIXTURE_CONTRACT_ID, + AEGIS_LOCAL_RPC_URL: LOCAL_FIXTURE_RPC_URL, + AEGIS_LOCAL_NETWORK_PASSPHRASE: Networks.STANDALONE, + AEGIS_LOCAL_HORIZON_URL: 'http://localhost:8000', + AEGIS_LOCAL_FRIENDBOT_URL: 'http://localhost:8000/friendbot', +}); + +export const localEnvMissingContract = Object.freeze({ + AEGIS_LOCAL_RPC_URL: LOCAL_FIXTURE_RPC_URL, +}); + +export const localEnvNonLoopback = Object.freeze({ + AEGIS_CONTRACT_ID: LOCAL_FIXTURE_CONTRACT_ID, + AEGIS_LOCAL_RPC_URL: 'http://rpc.example.com/soroban/rpc', +}); + +export const localEnvDockerHost = Object.freeze({ + AEGIS_CONTRACT_ID: LOCAL_FIXTURE_CONTRACT_ID, + AEGIS_LOCAL_RPC_URL: 'http://host.docker.internal:8000/soroban/rpc', +}); + +export const localEnvMalformedUrl = Object.freeze({ + AEGIS_CONTRACT_ID: LOCAL_FIXTURE_CONTRACT_ID, + AEGIS_LOCAL_RPC_URL: 'not-a-url', +}); + +export const localEnvWhitespace = Object.freeze({ + AEGIS_CONTRACT_ID: ' ', + AEGIS_LOCAL_RPC_URL: ' ', + AEGIS_LOCAL_SECRET_KEY: ' ', +}); diff --git a/tests/local-config.test.ts b/tests/local-config.test.ts new file mode 100644 index 0000000..125ad11 --- /dev/null +++ b/tests/local-config.test.ts @@ -0,0 +1,263 @@ +import { Keypair, Networks } from '@stellar/stellar-sdk'; +import { + LOCAL_DEV_DEFAULTS, + LOCAL_ENV_KEYS, + LocalConfigError, + checkLocalNetwork, + createLocalClient, + inspectLocalRpcUrl, + isAllowedLocalDevHostname, + isLoopbackHostname, + resolveLocalConfig, + toAegisClientConfig, +} from '../src'; +import { + LOCAL_FIXTURE_CONTRACT_ID, + LOCAL_FIXTURE_RPC_URL, + localEnvDockerHost, + localEnvMalformedUrl, + localEnvMissingContract, + localEnvNonLoopback, + localEnvValid, + localEnvWhitespace, +} from './fixtures/local-config'; + +describe('local hostname helpers', () => { + it.each([ + ['localhost', true], + ['127.0.0.1', true], + ['127.1.2.3', true], + ['::1', true], + ['[::1]', true], + ['0.0.0.0', true], + ['host.docker.internal', false], + ['rpc.example.com', false], + ])('isLoopbackHostname(%s) => %s', (host, expected) => { + expect(isLoopbackHostname(host)).toBe(expected); + }); + + it('treats host.docker.internal as an allowed local-dev host', () => { + expect(isAllowedLocalDevHostname('host.docker.internal')).toBe(true); + expect(isAllowedLocalDevHostname('rpc.example.com')).toBe(false); + }); +}); + +describe('resolveLocalConfig', () => { + it('resolves defaults when only a contract ID is provided', () => { + const resolved = resolveLocalConfig({ + contractId: LOCAL_FIXTURE_CONTRACT_ID, + }); + + expect(resolved).toMatchObject({ + environment: 'local', + rpcUrl: LOCAL_DEV_DEFAULTS.rpcUrl, + networkPassphrase: Networks.STANDALONE, + contractId: LOCAL_FIXTURE_CONTRACT_ID, + horizonUrl: LOCAL_DEV_DEFAULTS.horizonUrl, + friendbotUrl: LOCAL_DEV_DEFAULTS.friendbotUrl, + loopback: true, + localDevHost: true, + }); + expect(resolved.keypair).toBeUndefined(); + }); + + it('prefers explicit options over env over defaults', () => { + const resolved = resolveLocalConfig({ + contractId: LOCAL_FIXTURE_CONTRACT_ID, + rpcUrl: 'http://127.0.0.1:9000/soroban/rpc', + friendbotUrl: 'http://127.0.0.1:9000/friendbot', + env: { + ...localEnvValid, + AEGIS_LOCAL_RPC_URL: 'http://localhost:8000/soroban/rpc', + }, + }); + + expect(resolved.rpcUrl).toBe('http://127.0.0.1:9000/soroban/rpc'); + expect(resolved.friendbotUrl).toBe('http://127.0.0.1:9000/friendbot'); + expect(resolved.networkPassphrase).toBe(Networks.STANDALONE); + }); + + it('loads configuration from an injected env record', () => { + const resolved = resolveLocalConfig({ env: localEnvValid }); + + expect(resolved.contractId).toBe(LOCAL_FIXTURE_CONTRACT_ID); + expect(resolved.rpcUrl).toBe(LOCAL_FIXTURE_RPC_URL); + expect(Object.keys(LOCAL_ENV_KEYS)).toEqual( + expect.arrayContaining([ + 'rpcUrl', + 'networkPassphrase', + 'contractId', + 'horizonUrl', + 'friendbotUrl', + 'secretKey', + ]), + ); + }); + + it('treats whitespace-only env values as unset', () => { + expect(() => + resolveLocalConfig({ env: localEnvWhitespace }), + ).toThrow(LocalConfigError); + + try { + resolveLocalConfig({ env: localEnvWhitespace }); + } catch (error) { + expect(error).toBeInstanceOf(LocalConfigError); + expect((error as LocalConfigError).code).toBe('MISSING_CONTRACT_ID'); + } + }); + + it('requires a contract ID with an actionable hint', () => { + try { + resolveLocalConfig({ env: localEnvMissingContract }); + throw new Error('expected LocalConfigError'); + } catch (error) { + expect(error).toBeInstanceOf(LocalConfigError); + expect((error as LocalConfigError).code).toBe('MISSING_CONTRACT_ID'); + expect((error as LocalConfigError).hint).toMatch(/AEGIS_CONTRACT_ID/); + } + }); + + it('rejects invalid contract ID placeholders', () => { + try { + resolveLocalConfig({ contractId: 'C...' }); + throw new Error('expected LocalConfigError'); + } catch (error) { + expect(error).toBeInstanceOf(LocalConfigError); + expect((error as LocalConfigError).code).toBe('INVALID_CONTRACT_ID'); + expect((error as Error).message).not.toContain('C_YOUR'); + } + }); + + it('rejects non-loopback http RPC URLs by default', () => { + try { + resolveLocalConfig({ env: localEnvNonLoopback }); + throw new Error('expected LocalConfigError'); + } catch (error) { + expect(error).toBeInstanceOf(LocalConfigError); + expect((error as LocalConfigError).code).toBe('NON_LOOPBACK_RPC_URL'); + expect((error as Error).message).not.toContain('password'); + } + }); + + it('allows non-loopback http when explicitly opted in', () => { + const resolved = resolveLocalConfig({ + env: localEnvNonLoopback, + allowNonLoopbackRpc: true, + }); + + expect(resolved.rpcUrl).toBe('http://rpc.example.com/soroban/rpc'); + expect(resolved.localDevHost).toBe(false); + expect(resolved.loopback).toBe(false); + }); + + it('allows host.docker.internal without opt-in', () => { + const resolved = resolveLocalConfig({ env: localEnvDockerHost }); + expect(resolved.localDevHost).toBe(true); + expect(resolved.loopback).toBe(false); + }); + + it('rejects malformed RPC URLs without echoing junk input', () => { + try { + resolveLocalConfig({ env: localEnvMalformedUrl }); + throw new Error('expected LocalConfigError'); + } catch (error) { + expect(error).toBeInstanceOf(LocalConfigError); + expect((error as LocalConfigError).code).toBe('INVALID_RPC_URL'); + expect((error as Error).message).toContain(''); + expect((error as Error).message).not.toContain('not-a-url'); + } + }); + + it('loads a secret key into a Keypair without returning the secret', () => { + const keypair = Keypair.random(); + const resolved = resolveLocalConfig({ + contractId: LOCAL_FIXTURE_CONTRACT_ID, + secretKey: keypair.secret(), + }); + + expect(resolved.keypair?.publicKey()).toBe(keypair.publicKey()); + expect(JSON.stringify(resolved)).not.toContain(keypair.secret()); + expect(resolved).not.toHaveProperty('secretKey'); + }); + + it('rejects invalid secret keys without echoing them', () => { + const bad = 'S_NOT_A_REAL_SECRET_VALUE_LEAK_CHECK'; + try { + resolveLocalConfig({ + contractId: LOCAL_FIXTURE_CONTRACT_ID, + secretKey: bad, + }); + throw new Error('expected LocalConfigError'); + } catch (error) { + expect(error).toBeInstanceOf(LocalConfigError); + expect((error as LocalConfigError).code).toBe('INVALID_SECRET_KEY'); + expect((error as Error).message).not.toContain(bad); + expect(JSON.stringify(error)).not.toContain(bad); + } + }); + + it('maps to AegisClientConfig for environment: local', () => { + const resolved = resolveLocalConfig({ + contractId: LOCAL_FIXTURE_CONTRACT_ID, + }); + expect(toAegisClientConfig(resolved)).toEqual({ + environment: 'local', + rpcUrl: resolved.rpcUrl, + networkPassphrase: resolved.networkPassphrase, + contractId: resolved.contractId, + }); + }); +}); + +describe('createLocalClient', () => { + it('constructs an AegisClient against the local preset', () => { + const client = createLocalClient({ + contractId: LOCAL_FIXTURE_CONTRACT_ID, + }); + + expect(client.contractId).toBe(LOCAL_FIXTURE_CONTRACT_ID); + expect(client.networkPassphrase).toBe(Networks.STANDALONE); + expect(client.compliance).toBeDefined(); + }); +}); + +describe('inspectLocalRpcUrl', () => { + it('redacts credentials from the display form', () => { + const inspection = inspectLocalRpcUrl( + 'http://user:secret@localhost:8000/soroban/rpc', + ); + expect(inspection.display).toBe( + 'http://@localhost:8000/soroban/rpc', + ); + expect(inspection.display).not.toContain('secret'); + expect(inspection.loopback).toBe(true); + }); +}); + +describe('checkLocalNetwork', () => { + it('reports unavailable with a start-network hint when RPC refuses connections', async () => { + const health = await checkLocalNetwork({ + contractId: LOCAL_FIXTURE_CONTRACT_ID, + // High unused port — connection should be refused quickly. + rpcUrl: 'http://127.0.0.1:59999/soroban/rpc', + }); + + expect(health.status).toBe('unavailable'); + expect(health.retryable).toBe(false); + expect(health.hint).toMatch(/local:up|docker compose/i); + expect(health.rpcDisplay).toContain('127.0.0.1:59999'); + expect(JSON.stringify(health)).not.toContain('ECONNREFUSED'); + }); + + it('accepts a constructed local client', async () => { + const client = createLocalClient({ + contractId: LOCAL_FIXTURE_CONTRACT_ID, + rpcUrl: 'http://127.0.0.1:59998/soroban/rpc', + }); + + const health = await checkLocalNetwork(client); + expect(health.status).toBe('unavailable'); + expect(health.message).toMatch(/not reachable/i); + }); +});