mirror of
https://github.com/Routstr/routstrd.git
synced 2026-10-05 12:28:23 +00:00
fix: harden 402 guard recursion depth and make SDK API drift noisy
- Bound findStringField recursion to maxDepth=10 so adversarially deep 402 bodies cannot cause runaway recursion. Real 402 bodies nest 3-4 levels; 10 is generous while bounding the worst case. - Add runtime shape checks with logger.warn() to all four monkey-patches (_handleErrorResponse, createProviderToken, topUp) so SDK API drift is noisy instead of silent. The topUp patch previously called .bind() without verifying the method exists — a real TypeError on SDK upgrade. - Thread logger into installProvider402BodyCapture for the new warning. - Tests: depth-limit edge cases (4-level match + 50-level no-crash) and three API-drift detection tests verifying warnings fire when SDK methods are missing.
This commit is contained in:
@@ -86,9 +86,20 @@ function safeStringify(value: unknown): string {
|
||||
}
|
||||
}
|
||||
|
||||
/** Recursively find the first string-valued occurrence of `field`. */
|
||||
function findStringField(value: unknown, field: string): string | undefined {
|
||||
if (!value || typeof value !== "object") return undefined;
|
||||
/**
|
||||
* Recursively find the first string-valued occurrence of `field`.
|
||||
*
|
||||
* Bounded to `maxDepth` levels so an adversarially deep or cyclic-ish 402
|
||||
* body cannot cause runaway recursion. Real 402 bodies nest 3-4 levels deep
|
||||
* at most (`{"detail":{"error":{"message":...,"code":...}}}`), so 10 is
|
||||
* generous while still bounding the worst case.
|
||||
*/
|
||||
function findStringField(
|
||||
value: unknown,
|
||||
field: string,
|
||||
maxDepth = 10,
|
||||
): string | undefined {
|
||||
if (!value || typeof value !== "object" || maxDepth <= 0) return undefined;
|
||||
|
||||
for (const [key, entry] of Object.entries(value as Record<string, unknown>)) {
|
||||
if (key === field && typeof entry === "string" && entry.trim()) {
|
||||
@@ -96,7 +107,7 @@ function findStringField(value: unknown, field: string): string | undefined {
|
||||
}
|
||||
}
|
||||
for (const entry of Object.values(value as Record<string, unknown>)) {
|
||||
const nested = findStringField(entry, field);
|
||||
const nested = findStringField(entry, field, maxDepth - 1);
|
||||
if (nested) return nested;
|
||||
}
|
||||
return undefined;
|
||||
|
||||
@@ -72,12 +72,19 @@ const ERROR_BODY_PATCH_MARKER = Symbol.for("routstrd.provider402BodyPatched");
|
||||
export function installProvider402BodyCapture(
|
||||
client: RoutstrClientLike,
|
||||
guard: Provider402Guard,
|
||||
logger?: LoggerLike,
|
||||
): void {
|
||||
const target = client as RoutstrClientLike & {
|
||||
[ERROR_BODY_PATCH_MARKER]?: boolean;
|
||||
_handleErrorResponse?: (...args: unknown[]) => Promise<unknown>;
|
||||
};
|
||||
if (target[ERROR_BODY_PATCH_MARKER] || typeof target._handleErrorResponse !== "function") {
|
||||
if (target[ERROR_BODY_PATCH_MARKER]) return;
|
||||
if (typeof target._handleErrorResponse !== "function") {
|
||||
logger?.warn(
|
||||
"[wallet] _handleErrorResponse not found on SDK client — " +
|
||||
"402 body capture disabled. If the SDK has changed its API, " +
|
||||
"this patch and the 402 guard need updating.",
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -294,7 +301,15 @@ export function installCreateProviderTokenFallback(
|
||||
createProviderToken?: (options: TopUpOptions) => Promise<TopUpResult>;
|
||||
[CREATE_TOKEN_PATCH_MARKER]?: boolean;
|
||||
};
|
||||
if (balanceManager[CREATE_TOKEN_PATCH_MARKER] || typeof balanceManager.createProviderToken !== "function") return;
|
||||
if (balanceManager[CREATE_TOKEN_PATCH_MARKER]) return;
|
||||
if (typeof balanceManager.createProviderToken !== "function") {
|
||||
logger.warn(
|
||||
"[wallet] createProviderToken not found on balance manager — " +
|
||||
"createProviderToken fallback disabled. If the SDK has changed " +
|
||||
"its API, this patch needs updating.",
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const originalCreateProviderToken = balanceManager.createProviderToken.bind(balanceManager);
|
||||
|
||||
@@ -477,12 +492,20 @@ export function installMintFallbackTopUp(
|
||||
installCreateProviderTokenFallback(client, walletClient, walletAdapter, logger, upstreamProviderUrl);
|
||||
|
||||
const guard = provider402Guard ?? createProvider402Guard();
|
||||
installProvider402BodyCapture(client, guard);
|
||||
installProvider402BodyCapture(client, guard, logger);
|
||||
|
||||
const balanceManager = client.getBalanceManager() as BalanceManagerLike & {
|
||||
[PATCH_MARKER]?: boolean;
|
||||
};
|
||||
if (balanceManager[PATCH_MARKER]) return;
|
||||
if (typeof balanceManager.topUp !== "function") {
|
||||
logger.warn(
|
||||
"[wallet] topUp not found on balance manager — " +
|
||||
"mint fallback top-up disabled. If the SDK has changed " +
|
||||
"its API, this patch needs updating.",
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const originalTopUp = balanceManager.topUp.bind(balanceManager);
|
||||
balanceManager.topUp = async (options: TopUpOptions): Promise<TopUpResult> => {
|
||||
@@ -664,7 +687,14 @@ export function installMintUnreachableErrorRetry(
|
||||
if (patchedClient[ERROR_RETRY_PATCH_MARKER]) return;
|
||||
|
||||
const originalHandleErrorResponse = patchedClient._handleErrorResponse;
|
||||
if (typeof originalHandleErrorResponse !== "function") return;
|
||||
if (typeof originalHandleErrorResponse !== "function") {
|
||||
logger.warn(
|
||||
"[wallet] _handleErrorResponse not found on SDK client — " +
|
||||
"mint-unreachable retry disabled. If the SDK has changed " +
|
||||
"its API, this patch needs updating.",
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
patchedClient._handleErrorResponse = async function patchedHandleErrorResponse(
|
||||
this: Record<string, any>,
|
||||
|
||||
@@ -56,6 +56,22 @@ describe("classifyProvider402", () => {
|
||||
expect(classifyProvider402("gateway timeout")).toBe("unknown");
|
||||
expect(classifyProvider402('{"detail":"something else"}')).toBe("unknown");
|
||||
});
|
||||
|
||||
test("finds limit_source at realistic nesting depth (4 levels)", () => {
|
||||
// Real 402 bodies nest 3-4 levels deep.
|
||||
const body = JSON.stringify({
|
||||
detail: { error: { metadata: { limit_source: "openrouter_credits" } } },
|
||||
});
|
||||
expect(classifyProvider402(body)).toBe("provider_side");
|
||||
});
|
||||
|
||||
test("does not crash on adversarially deep nesting", () => {
|
||||
// 50 levels deep — well beyond the depth limit. The field is never found,
|
||||
// so it falls through to "unknown" instead of stack-overflowing.
|
||||
let nested: Record<string, unknown> = { limit_source: "openrouter_credits" };
|
||||
for (let i = 0; i < 50; i++) nested = { nested };
|
||||
expect(classifyProvider402(JSON.stringify(nested))).toBe("unknown");
|
||||
});
|
||||
});
|
||||
|
||||
describe("provider 402 guard", () => {
|
||||
|
||||
@@ -481,6 +481,62 @@ describe("Hardened fallback — concurrency & idempotency", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("Hardened fallback — SDK API drift detection", () => {
|
||||
test("warns when topUp is missing from balance manager", () => {
|
||||
// Simulate an SDK upgrade that renames or removes topUp.
|
||||
const balanceManager = createBalanceManager();
|
||||
delete (balanceManager as { topUp?: unknown }).topUp;
|
||||
const client = { getBalanceManager: () => balanceManager };
|
||||
|
||||
const warnings: string[] = [];
|
||||
installMintFallbackTopUp(
|
||||
client as never,
|
||||
createCocodClient(["https://mint-a.example"]),
|
||||
createWalletAdapter(),
|
||||
{ log: () => undefined, warn: (msg: string) => warnings.push(msg), error: () => undefined },
|
||||
);
|
||||
|
||||
// Should have warned, not silently swallowed.
|
||||
expect(warnings.some((w) => w.includes("topUp not found"))).toBe(true);
|
||||
});
|
||||
|
||||
test("warns when createProviderToken is missing from balance manager", () => {
|
||||
const balanceManager = createBalanceManager();
|
||||
delete (balanceManager as { createProviderToken?: unknown }).createProviderToken;
|
||||
const client = { getBalanceManager: () => balanceManager };
|
||||
|
||||
const warnings: string[] = [];
|
||||
installMintFallbackTopUp(
|
||||
client as never,
|
||||
createCocodClient(["https://mint-a.example"]),
|
||||
createWalletAdapter(),
|
||||
{ log: () => undefined, warn: (msg: string) => warnings.push(msg), error: () => undefined },
|
||||
);
|
||||
|
||||
expect(warnings.some((w) => w.includes("createProviderToken not found"))).toBe(true);
|
||||
});
|
||||
|
||||
test("warns when _handleErrorResponse is missing from SDK client", () => {
|
||||
// Simulate an SDK upgrade that renames _handleErrorResponse.
|
||||
// createBalanceManager provides topUp + createProviderToken so those patches
|
||||
// install cleanly, but the error-retry and 402-body-capture patches should warn.
|
||||
const balanceManager = createBalanceManager();
|
||||
const client = { getBalanceManager: () => balanceManager };
|
||||
|
||||
const warnings: string[] = [];
|
||||
installMintFallbackTopUp(
|
||||
client as never,
|
||||
createCocodClient(["https://mint-a.example"]),
|
||||
createWalletAdapter(),
|
||||
{ log: () => undefined, warn: (msg: string) => warnings.push(msg), error: () => undefined },
|
||||
);
|
||||
|
||||
expect(
|
||||
warnings.some((w) => w.includes("_handleErrorResponse not found")),
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Hardened fallback — API key safety", () => {
|
||||
test("API key is not logged in error messages", async () => {
|
||||
const balanceManager = createBalanceManager();
|
||||
|
||||
Reference in New Issue
Block a user