Skip to content
Merged
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
47 changes: 35 additions & 12 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -18,17 +18,9 @@ coverage/
.vitest/
.nyc_output/
test-results/
junit.xml

# Test snapshots (all formats)
**/__snapshots__/
*.snap
*.snapshot
*.snapshot.json
testsnapshot/
testSnapshot/
test-snapshot/
test-snapshots/
.env
.env.local
*.env.*

# Vitest cache
.vitest-cache/
Expand All @@ -41,6 +33,27 @@ test-snapshots/
.DS_Store
Thumbs.db

# Task / scratch files
*.log
task1.md
task2.md
task3.md
task4.md
somzilla.md

# Test snapshots & temporary output
**/__snapshots__/
*.snap
*.snapshot
*.snapshot.json
testsnapshot/
testSnapshot/
test-snapshot/
test-snapshots/
/tmp/
*.profile.json
*.speedscope.json

# IDE / editor
.vscode/
.idea/
Expand All @@ -49,9 +62,19 @@ Thumbs.db
*~

# Logs
*.log
npm-debug.log*
yarn-error.log*
pnpm-debug.log*

# Temp files
*.tmp
*.temp

# Size-limit cache
.size-limit/

# Vitest UI
__vitest_browser__/

# Temp / scratch files
/tmp/
Expand Down
15 changes: 15 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,21 @@
"types": "./dist/utils.d.ts",
"import": "./dist/utils.js",
"require": "./dist/utils.cjs"
},
"./mock": {
"types": "./dist/mock/index.d.ts",
"import": "./dist/mock/index.js",
"require": "./dist/mock/index.cjs"
},
"./react": {
"types": "./dist/react/index.d.ts",
"import": "./dist/react/index.js",
"require": "./dist/react/index.cjs"
},
"./telemetry": {
"types": "./dist/telemetryModule.d.ts",
"import": "./dist/telemetryModule.js",
"require": "./dist/telemetryModule.cjs"
}
},
"files": [
Expand Down
159 changes: 159 additions & 0 deletions src/configValidator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -333,3 +333,162 @@ export class InvalidConfigError extends StellarSplitError {
Object.setPrototypeOf(this, new.target.prototype);
}
}

// ---------------------------------------------------------------------------
// #883 — ConfigurationError with structured field / value / hint fields
// ---------------------------------------------------------------------------

/**
* The network shorthand accepted by {@link ConfigurationError} and the
* enhanced strict validators.
*/
export type NetworkShorthand = "testnet" | "mainnet";

/** Map from shorthand to official Stellar network passphrases. */
export const NETWORK_PASSPHRASE_MAP: Record<NetworkShorthand, string> = {
testnet: "Test SDF Network ; September 2015",
mainnet: "Public Global Stellar Network ; September 2015",
};

/**
* Structured configuration error that pinpoints exactly which field is wrong,
* what value was supplied, and how to fix it.
*
* @example
* ```ts
* throw new ConfigurationError({
* field: 'rpcUrl',
* value: 'ftp://example.com',
* hint: 'rpcUrl must use the https:// scheme.',
* });
* ```
*/
export class ConfigurationError extends StellarSplitError {
/** The name of the misconfigured field. */
readonly field: string;
/** The value that was supplied (stringified). */
readonly value: unknown;
/** A human-readable suggestion for how to fix the problem. */
readonly hint: string;

constructor(params: { field: string; value?: unknown; hint: string }) {
const { field, value, hint } = params;
const valueStr =
value === undefined ? "(not provided)" : JSON.stringify(value);
const message = `Configuration error on field "${field}": ${hint} (got ${valueStr})`;
super(message, "CONFIGURATION_ERROR", { field, value, hint });
this.name = "ConfigurationError";
this.field = field;
this.value = value;
this.hint = hint;
Object.setPrototypeOf(this, new.target.prototype);
}
}

// ---------------------------------------------------------------------------
// Extended strict-validation helpers (required by issue #883)
// ---------------------------------------------------------------------------

/**
* Validate a raw config object against the strict rules introduced in #883.
*
* Throws {@link ConfigurationError} on the first invalid field so the
* developer gets a clear, actionable message.
*
* Rules checked (in order):
* 1. `rpcUrl` — must be a valid **https** URL (http not allowed in strict mode)
* 2. `contractId` — must be a valid Stellar C-address (56 chars, C-prefix)
* 3. `network` — must be `"testnet"` or `"mainnet"` when provided
* 4. `networkPassphrase` vs `network` — must match if both are provided
*/
export function validateConfigStrict(config: {
rpcUrl: unknown;
contractId: unknown;
network?: unknown;
networkPassphrase?: unknown;
}): void {
// ---- rpcUrl ---------------------------------------------------------------
if (!config.rpcUrl || typeof config.rpcUrl !== "string") {
throw new ConfigurationError({
field: "rpcUrl",
value: config.rpcUrl,
hint: "rpcUrl is required and must be a non-empty string with an https:// URL.",
});
}

let parsedUrl: URL;
try {
parsedUrl = new URL(config.rpcUrl as string);
} catch {
throw new ConfigurationError({
field: "rpcUrl",
value: config.rpcUrl,
hint: "rpcUrl must be a valid URL, e.g. https://soroban-testnet.stellar.org",
});
}

if (parsedUrl.protocol !== "https:") {
throw new ConfigurationError({
field: "rpcUrl",
value: config.rpcUrl,
hint: `rpcUrl must use the https:// scheme. Received "${parsedUrl.protocol}". Example: https://soroban-testnet.stellar.org`,
});
}

// ---- contractId -----------------------------------------------------------
if (!config.contractId || typeof config.contractId !== "string") {
throw new ConfigurationError({
field: "contractId",
value: config.contractId,
hint: "contractId is required and must be a valid Stellar C-address (56 characters, starts with 'C').",
});
}

if (
!(config.contractId as string).startsWith("C") ||
(config.contractId as string).length !== 56
) {
throw new ConfigurationError({
field: "contractId",
value: config.contractId,
hint: "contractId must be a 56-character Stellar contract address starting with 'C'. Use StrKey.encodeContract() to generate one.",
});
}

try {
StrKey.decodeContract(config.contractId as string);
} catch {
throw new ConfigurationError({
field: "contractId",
value: config.contractId,
hint: "contractId failed Stellar StrKey validation. Ensure you are using a properly encoded C-address.",
});
}

// ---- network --------------------------------------------------------------
if (config.network !== undefined) {
if (
config.network !== "testnet" &&
config.network !== "mainnet"
) {
throw new ConfigurationError({
field: "network",
value: config.network,
hint: 'network must be either "testnet" or "mainnet".',
});
}

// ---- networkPassphrase vs network mismatch --------------------------------
if (config.networkPassphrase !== undefined) {
const expected =
NETWORK_PASSPHRASE_MAP[config.network as NetworkShorthand];
if (config.networkPassphrase !== expected) {
throw new ConfigurationError({
field: "networkPassphrase",
value: config.networkPassphrase,
hint: `networkPassphrase does not match the selected network "${config.network}". Expected: "${expected}"`,
});
}
}
}
}
Loading
Loading