Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
87 changes: 87 additions & 0 deletions docs/diagnostics.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
# Diagnostics reporter

The SDK can build a redacted diagnostics report of config and runtime state,
intended to be pasted into a support request or bug report without further
editing — though you should still read it before sharing (see
[Before you share it](#before-you-share-it)).

## What it checks

| Section | What it reports |
| ----------- | ---------------------------------------------------------------------------- |
| `config` | Whether `contractId`, `rpcUrl`, and `networkPassphrase` are present; which environment preset (if any) is in use; whether a signer is configured; whether the config resolves at all |
| `runtime` | Whether the RPC endpoint is reachable; the declared client role (if a role-aware client is passed); whether a signer is configured |
| `complianceFailure` | Optional — classifies a caught error's `code` into a domain (`portfolio` \| `role` \| `config` \| `unknown`) |

## Build a report

```ts
import { buildDiagnosticsReport } from '@aegis/sdk';

const config = {
contractId: 'C...',
environment: 'testnet',
keypair, // optional
};
const aegis = createReadOnlyClient(config);

const report = await buildDiagnosticsReport({
config, // the same object passed to the client/factory
client: aegis, // a constructed AegisClient or role-aware client
});

console.log(JSON.stringify(report, null, 2));
```

Pass `config` even when config is missing or invalid — `diagnoseConfig`
never throws, so a broken setup still produces a report:

```ts
const report = await buildDiagnosticsReport({ config: userSuppliedConfig });
console.log(report.config.status); // 'invalid'
console.log(report.config.errorCode); // e.g. 'MISSING_CONFIG'
```

To classify a compliance-related failure alongside the report, pass the
caught error as `complianceError`:

```ts
try {
await aegis.compliance.checkWhitelist(address);
} catch (error) {
const report = await buildDiagnosticsReport({ config, client: aegis, complianceError: error });
console.log(report.complianceFailure); // { domain: 'portfolio', code: 'COMPLIANCE_ERROR', classified: true }
}
```

The individual builders (`diagnoseConfig`, `buildRuntimeDiagnostics`,
`classifyComplianceFailure`) are also exported directly if you only need one
section.

## What is never included

- **Private keys, secret seeds, or the `Keypair` object itself.** Only
`signerConfigured: boolean` is reported — existence, not content.
- **Stellar addresses** (investor addresses, signer public keys) — these
identify an account/person and are treated as identity data, not config.
- **RPC URL query strings or fragments.** Only `origin` and `pathname` are
reported; a URL like `https://rpc.example.com/soroban?apiKey=...` is
reported as `{ origin: 'https://rpc.example.com', path: '/soroban' }`.
- **Raw error messages or causes.** Failure classification reads only an
error's closed `code` field (e.g. `COMPLIANCE_ERROR`, `RPC_UNAVAILABLE`) —
never `.message` or `.cause`, which elsewhere in the SDK may interpolate
raw RPC responses or addresses.
- **Any config field the reporter doesn't explicitly know about.** The
reporter is built as an allowlist: it reads named fields one at a time and
assembles the result from them. A field it has no code for — a custom
credential someone added to their config object, for example — is absent
from the report by default, not present until someone remembers to hide
it.

## Before you share it

This reporter is designed to be safe by default, but no automated redaction
is a substitute for a human check. Before pasting a report into a GitHub
issue, support ticket, or chat: read it once. If your config object had
anything unusual attached to it, or if you're unsure, don't share it until
you've confirmed the output looks right.
35 changes: 35 additions & 0 deletions src/diagnostics/compliance.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import { PortfolioError } from '../errors/portfolio';
import { RoleError } from '../errors/role';
import { ConfigValidationError } from '../errors/config';

export type ComplianceFailureDomain = 'portfolio' | 'role' | 'config' | 'unknown';

export interface ComplianceFailureClassification {
domain: ComplianceFailureDomain;
code: string;
classified: boolean;
}

/**
* Classifies a caught error into a safe, closed-vocabulary compliance
* failure summary for support diagnostics.
*
* Only the error's `code` (a closed enum on every SDK error class) is ever
* read — never `.message` or `.cause`, both of which interpolate raw values
* elsewhere in the SDK (RPC URLs, upstream error text) and could carry an
* investor address or other identity data. An error this function doesn't
* recognize is reported as `{ domain: 'unknown', classified: false }` rather
* than having its message inspected to guess a category.
*/
export function classifyComplianceFailure(error: unknown): ComplianceFailureClassification {
if (error instanceof PortfolioError) {
return Object.freeze({ domain: 'portfolio', code: error.code, classified: true });
}
if (error instanceof RoleError) {
return Object.freeze({ domain: 'role', code: error.code, classified: true });
}
if (error instanceof ConfigValidationError) {
return Object.freeze({ domain: 'config', code: error.code, classified: true });
}
return Object.freeze({ domain: 'unknown', code: 'UNKNOWN', classified: false });
}
112 changes: 112 additions & 0 deletions src/diagnostics/config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
import { AegisClientConfig, resolveClientConfig } from '../config/validate';
import { AEGIS_ENVIRONMENTS, AegisEnvironmentName } from '../config/environments';
import { ConfigErrorCode, ConfigValidationError } from '../errors/config';

/**
* Safe, allowlisted description of an `AegisClientConfig`'s RPC endpoint.
* Only `origin` and `pathname` are ever included — query strings and hashes
* are the parts of a URL most likely to carry API keys or tokens, so they
* are never read into a diagnostic, redacted or otherwise.
*/
export interface RpcUrlDiagnostic {
present: boolean;
origin?: string;
path?: string;
}

export type ConfigDiagnosticsStatus = 'ok' | 'invalid';

export interface ConfigDiagnostics {
contractId: { present: boolean };
environment: { name: AegisEnvironmentName | 'custom' | 'unsupported' | 'unset' };
rpcUrl: RpcUrlDiagnostic;
networkPassphrase: { present: boolean };
signerConfigured: boolean;
status: ConfigDiagnosticsStatus;
errorCode?: ConfigErrorCode;
}

/**
* Builds a safe, allowlisted diagnostic of an `AegisClientConfig`-shaped value.
*
* This never throws, even for missing, malformed, or entirely bogus input —
* invalid config is reported as `status: 'invalid'` with the validation
* `errorCode`, not as an exception. Every field is read explicitly by name;
* nothing from the input object is copied into the result wholesale, so a
* field the caller adds that this function doesn't know about (a stray
* secret, an unexpected credential) is silently absent from the report
* rather than silently included.
*/
export function diagnoseConfig(rawConfig: unknown): ConfigDiagnostics {
const candidate = isPlainObject(rawConfig) ? rawConfig : {};

let resolvedRpcUrl: string | undefined;
let resolvedNetworkPassphrase: string | undefined;
let errorCode: ConfigErrorCode | undefined;

try {
const resolved = resolveClientConfig(candidate as unknown as AegisClientConfig);
resolvedRpcUrl = resolved.rpcUrl;
resolvedNetworkPassphrase = resolved.networkPassphrase;
} catch (error) {
errorCode = error instanceof ConfigValidationError ? error.code : 'MISSING_CONFIG';
}

const effectiveRpcUrl = resolvedRpcUrl ?? readString(candidate.rpcUrl);
const effectiveNetworkPassphrase =
resolvedNetworkPassphrase ?? readString(candidate.networkPassphrase);

return Object.freeze({
contractId: { present: isNonEmptyString(candidate.contractId) },
environment: { name: describeEnvironment(candidate) },
rpcUrl: describeRpcUrl(effectiveRpcUrl),
networkPassphrase: { present: effectiveNetworkPassphrase.length > 0 },
signerConfigured: candidate.keypair !== undefined && candidate.keypair !== null,
status: errorCode ? 'invalid' : 'ok',
...(errorCode ? { errorCode } : {}),
});
}

function describeEnvironment(
candidate: Record<string, unknown>,
): AegisEnvironmentName | 'custom' | 'unsupported' | 'unset' {
if (typeof candidate.environment === 'string') {
// A named environment always takes priority over rpcUrl/networkPassphrase during
// resolution (see `resolveClientConfig`), so an unrecognized name is reported as
// 'unsupported' even when other fields are present — matching actual resolution
// behavior rather than the raw (and possibly mistaken) string itself.
return candidate.environment in AEGIS_ENVIRONMENTS
? (candidate.environment as AegisEnvironmentName)
: 'unsupported';
}
if (isNonEmptyString(candidate.rpcUrl) || isNonEmptyString(candidate.networkPassphrase)) {
return 'custom';
}
return 'unset';
}

function describeRpcUrl(rpcUrl: string): RpcUrlDiagnostic {
if (!rpcUrl) {
return { present: false };
}
try {
const parsed = new URL(rpcUrl);
return { present: true, origin: parsed.origin, path: parsed.pathname };
} catch {
// Malformed value — report presence only. The raw string is never
// echoed back since a copy/paste mistake could put a token there.
return { present: true };
}
}

function isNonEmptyString(value: unknown): boolean {
return typeof value === 'string' && value.length > 0;
}

function readString(value: unknown): string {
return typeof value === 'string' ? value : '';
}

function isPlainObject(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null;
}
77 changes: 77 additions & 0 deletions src/diagnostics/report.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
import { AegisClient } from '../client';
import type { AegisReadOnlyClient } from '../client-factory';
import { diagnoseConfig, ConfigDiagnostics } from './config';
import { buildRuntimeDiagnostics, RuntimeDiagnostics } from './runtime';
import { classifyComplianceFailure, ComplianceFailureClassification } from './compliance';

export interface AegisDiagnosticsReport {
generatedAt: string;
config: ConfigDiagnostics;
runtime: RuntimeDiagnostics;
complianceFailure?: ComplianceFailureClassification;
}

export interface DiagnosticsReportInput {
/** The same config object passed to the client constructor / factory function. */
config?: unknown;
/** A constructed `AegisClient`, or a role-aware client from `client-factory`. */
client?: AegisClient | AegisReadOnlyClient;
/** An error caught from a compliance-sensitive operation, to classify alongside the report. */
complianceError?: unknown;
}

const UNPROBED_RUNTIME: RuntimeDiagnostics = Object.freeze({
rpcReachability: 'unknown',
role: 'unspecified',
signerConfigured: false,
});

/**
* Builds a full, redacted diagnostics report for Aegis SDK support requests:
* config validity, RPC reachability, declared role, signer presence, and
* (optionally) a classified compliance failure.
*
* Redaction is structural, not a final pass: `diagnoseConfig` and
* `buildRuntimeDiagnostics` each build their own allowlisted result, and this
* function only ever assembles those results — it never has access to the
* raw config object's unlisted fields or to the client's keypair, so there is
* no step here that could leak either.
*/
export async function buildDiagnosticsReport(
input: DiagnosticsReportInput,
): Promise<AegisDiagnosticsReport> {
const config = diagnoseConfig(input.config);

let runtime = UNPROBED_RUNTIME;
if (input.client) {
const client = input.client;
runtime = isRoleAwareClient(client)
? await buildRuntimeDiagnostics(client.client, { role: client.role })
: await buildRuntimeDiagnostics(client);
}

const complianceFailure =
input.complianceError !== undefined
? classifyComplianceFailure(input.complianceError)
: undefined;

return Object.freeze({
generatedAt: new Date().toISOString(),
config,
runtime,
...(complianceFailure ? { complianceFailure } : {}),
});
}

/**
* Distinguishes a role-aware client (from `client-factory`) from a plain
* `AegisClient`. Both classes have a `.role` property, but they mean
* different things: on `AegisClient` it's the `RoleModule` instance, on a
* role-aware client it's the declared `ClientRole` string — so the check
* must inspect the value's type, not just the property's presence.
*/
function isRoleAwareClient(
target: AegisClient | AegisReadOnlyClient,
): target is AegisReadOnlyClient {
return typeof (target as AegisReadOnlyClient).role === 'string';
}
42 changes: 42 additions & 0 deletions src/diagnostics/runtime.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import { AegisClient } from '../client';
import { ClientRole } from '../types/client-factory';
import { buildNetworkFailureDiagnostic, NetworkFailureDiagnostic } from './network';

export type RpcReachability = 'reachable' | 'unreachable' | 'unknown';

export interface RuntimeDiagnostics {
rpcReachability: RpcReachability;
rpcFailure?: NetworkFailureDiagnostic;
role: ClientRole | 'unspecified';
signerConfigured: boolean;
}

/**
* Probes live runtime state for a constructed `AegisClient`: whether the RPC
* endpoint responds, the declared role (if known), and whether a signer is
* configured. Never returns the signer itself — only its presence — and any
* RPC failure is passed through the existing redacted network-failure
* diagnostic rather than the raw error.
*/
export async function buildRuntimeDiagnostics(
client: AegisClient,
opts: { role?: ClientRole } = {},
): Promise<RuntimeDiagnostics> {
let rpcReachability: RpcReachability = 'unknown';
let rpcFailure: NetworkFailureDiagnostic | undefined;

try {
await client.runNetworkOperation(() => client.rpcServer.getHealth());
rpcReachability = 'reachable';
} catch (error) {
rpcReachability = 'unreachable';
rpcFailure = buildNetworkFailureDiagnostic(error);
}

return Object.freeze({
rpcReachability,
...(rpcFailure ? { rpcFailure } : {}),
role: opts.role ?? 'unspecified',
signerConfigured: !!client.keypair,
});
}
8 changes: 8 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,14 @@ export {
NetworkFailureDiagnostic,
NetworkRecoveryAction,
} from './diagnostics/network';
export { diagnoseConfig, ConfigDiagnostics, ConfigDiagnosticsStatus, RpcUrlDiagnostic } from './diagnostics/config';
export { buildRuntimeDiagnostics, RuntimeDiagnostics, RpcReachability } from './diagnostics/runtime';
export {
classifyComplianceFailure,
ComplianceFailureClassification,
ComplianceFailureDomain,
} from './diagnostics/compliance';
export { buildDiagnosticsReport, AegisDiagnosticsReport, DiagnosticsReportInput } from './diagnostics/report';
export { resolveClientConfig } from './config/validate';
export { AEGIS_ENVIRONMENTS, getEnvironmentPreset } from './config/environments';
export * from './types/portfolio';
Expand Down
Loading