Skip to content

Commit 29f6ca1

Browse files
MajorTalclaude
andauthored
fix(sdk): normalize wire-vs-type drift in subdomains/faucet/usage (#164)
* fix(sdk): normalize wire-vs-type drift in subdomains/faucet/usage Three SDK methods declared one return shape and returned another at runtime in 1.51.0, surfaced in run402#163: - `subdomains.list` returned the gateway envelope `{ subdomains: [...] }` but was typed as `SubdomainSummary[]` — crashed the shipped `list_subdomains` MCP tool with TypeError on iteration. - `getUsage` declared `lease_expires_at: string` but the gateway omits the field entirely. Relaxed to `?: string | null` here; gateway-side fix filed in run402-private#143. - `allowance.faucet` declared camelCase `transactionHash`/`amount` but the gateway returns snake_case `transaction_hash`/`amount_usd_micros`, so callers saw `undefined` in log lines. Now normalized at the SDK boundary; `amountUsdMicros` added for callers wanting the raw number. Test mocks updated to mirror the actual wire shapes so the next regression gets caught before shipping — the previous mocks agreed with the typed shape, not reality, which is how the drift slipped through. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(test): align cli-e2e mocks with real gateway shapes The e2e mocks for `GET /subdomains/v1` and `POST /faucet/v1` returned the SDK's old typed shape rather than what the live gateway emits, so they couldn't catch the drifts fixed in the previous commit. After the SDK normalization, the subdomains mock no longer matched the unwrap path and `subdomains list` failed in CI. - `/subdomains/v1` GET → `{ subdomains: [...] }` (was a bare array). - `/faucet/v1` POST → `{ transaction_hash, amount_usd_micros, ... }` (was `{ tx_hash, amount, ... }`). Same principle as the SDK unit-test mock updates: tests verify reality, not what the type says. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 8cdaf68 commit 29f6ca1

8 files changed

Lines changed: 103 additions & 17 deletions

File tree

cli-e2e.test.mjs

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -138,9 +138,9 @@ function mockFetch(input, init) {
138138
// Strip query string for route matching
139139
const pathNoQuery = path.split("?")[0];
140140

141-
// Faucet
141+
// Faucet — wire shape: snake_case + amount in micros (SDK normalizes).
142142
if (path === "/faucet/v1" && method === "POST") {
143-
return Promise.resolve(json({ tx_hash: "0xabc123", amount: "250000", token: "USDC", network: "base-sepolia" }));
143+
return Promise.resolve(json({ transaction_hash: "0xabc123", amount_usd_micros: 250000, token: "USDC", network: "base-sepolia" }));
144144
}
145145

146146
// Tiers
@@ -344,7 +344,8 @@ function mockFetch(input, init) {
344344
return Promise.resolve(json({ name: "my-app", url: "https://my-app.run402.com", deployment_id: "dpl_test456" }, 201));
345345
}
346346
if (path === "/subdomains/v1" && method === "GET") {
347-
return Promise.resolve(json([{ name: "my-app", url: "https://my-app.run402.com" }]));
347+
// Wire shape: gateway responds `{ subdomains: [...] }`; SDK unwraps.
348+
return Promise.resolve(json({ subdomains: [{ name: "my-app", url: "https://my-app.run402.com", deployment_id: "dpl_test456", deployment_url: "https://dpl_test456.run402.com" }] }));
348349
}
349350
if (path.match(/^\/subdomains\/v1\//) && method === "DELETE") {
350351
return Promise.resolve(json({ status: "ok" }));

sdk/src/namespaces/allowance.test.ts

Lines changed: 43 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,13 +13,17 @@ import assert from "node:assert/strict";
1313
import { Run402 } from "../index.js";
1414
import type { AllowanceData, CredentialsProvider } from "../credentials.js";
1515

16-
function makeCreds(allowance: AllowanceData | null): CredentialsProvider {
17-
return {
16+
function makeCreds(allowance: AllowanceData | null, opts: { saveAllowance?: boolean } = {}): CredentialsProvider {
17+
const provider: CredentialsProvider = {
1818
async getAuth() { return null; },
1919
async getProject() { return null; },
2020
async readAllowance() { return allowance; },
2121
getAllowancePath() { return "/tmp/allowance.json"; },
2222
};
23+
if (opts.saveAllowance) {
24+
provider.saveAllowance = async () => {};
25+
}
26+
return provider;
2327
}
2428

2529
function sdk(creds: CredentialsProvider): Run402 {
@@ -28,6 +32,10 @@ function sdk(creds: CredentialsProvider): Run402 {
2832
return new Run402({ apiBase: "https://api.test", credentials: creds, fetch: stubFetch });
2933
}
3034

35+
function sdkWithFetch(creds: CredentialsProvider, fetchImpl: typeof globalThis.fetch): Run402 {
36+
return new Run402({ apiBase: "https://api.test", credentials: creds, fetch: fetchImpl });
37+
}
38+
3139
describe("allowance.status", () => {
3240
it("returns faucet_used=true when the on-disk allowance's internal funded marker is set", async () => {
3341
const result = await sdk(makeCreds({
@@ -66,3 +74,36 @@ describe("allowance.status", () => {
6674
assert.ok(!Object.prototype.hasOwnProperty.call(result, "funded"));
6775
});
6876
});
77+
78+
describe("allowance.faucet", () => {
79+
// Regression test for GH-163: the live gateway responds with snake_case keys
80+
// and `amount_usd_micros` (number). The SDK must normalize to the typed
81+
// camelCase shape so callers don't see `undefined` for transactionHash/amount.
82+
it("normalizes the snake_case + micros wire shape into the typed camelCase result", async () => {
83+
const wireBody = {
84+
transaction_hash: "0xabc123",
85+
amount_usd_micros: 250000,
86+
token: "USDC",
87+
network: "base-sepolia",
88+
};
89+
const fetchImpl: typeof globalThis.fetch = async () =>
90+
new Response(JSON.stringify(wireBody), { status: 200, headers: { "Content-Type": "application/json" } });
91+
92+
const creds = makeCreds({
93+
address: "0xAbC",
94+
privateKey: "0xpk",
95+
created: "2026-01-01T00:00:00.000Z",
96+
}, { saveAllowance: true });
97+
98+
const result = await sdkWithFetch(creds, fetchImpl).allowance.faucet();
99+
100+
assert.equal(result.transactionHash, "0xabc123",
101+
"wire's `transaction_hash` must surface as `transactionHash`");
102+
assert.equal(result.amountUsdMicros, 250000,
103+
"wire's `amount_usd_micros` must surface as `amountUsdMicros`");
104+
assert.equal(result.amount, "0.25",
105+
"amount must be a display-formatted string derived from micros");
106+
assert.equal(result.token, "USDC");
107+
assert.equal(result.network, "base-sepolia");
108+
});
109+
});

sdk/src/namespaces/allowance.ts

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,17 @@ export interface AllowanceCreateResult {
3535

3636
export interface FaucetResult {
3737
transactionHash: string;
38+
/** Display-formatted amount, e.g. `"0.25"`. Computed from `amountUsdMicros`. */
3839
amount: string;
40+
/** Raw amount in micro-USD (1_000_000 = $1.00). */
41+
amountUsdMicros: number;
42+
token: string;
43+
network: string;
44+
}
45+
46+
interface FaucetWireBody {
47+
transaction_hash: string;
48+
amount_usd_micros: number;
3949
token: string;
4050
network: string;
4151
}
@@ -130,12 +140,21 @@ export class Allowance {
130140
resolvedAddress = data.address;
131141
}
132142

133-
const result = await this.client.request<FaucetResult>("/faucet/v1", {
143+
// Wire shape is snake_case (`transaction_hash`, `amount_usd_micros`);
144+
// normalize to the typed camelCase surface (regression: GH-163).
145+
const wire = await this.client.request<FaucetWireBody>("/faucet/v1", {
134146
method: "POST",
135147
body: { address: resolvedAddress },
136148
withAuth: false,
137149
context: "requesting faucet funds",
138150
});
151+
const result: FaucetResult = {
152+
transactionHash: wire.transaction_hash,
153+
amountUsdMicros: wire.amount_usd_micros,
154+
amount: (wire.amount_usd_micros / 1_000_000).toFixed(2),
155+
token: wire.token,
156+
network: wire.network,
157+
};
139158

140159
// Best-effort update of the local allowance's funded/lastFaucet fields.
141160
const reader = this.client.credentials.readAllowance;

sdk/src/namespaces/projects.test.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -280,6 +280,8 @@ describe("projects.list", () => {
280280

281281
describe("projects.getUsage", () => {
282282
it("GETs /projects/v1/admin/:id/usage with service key", async () => {
283+
// Mirrors the live gateway shape — `lease_expires_at` is intentionally
284+
// absent because the endpoint doesn't compute it (see GH-163).
283285
const { fetch, calls } = mockFetch(() =>
284286
jsonResponse({
285287
project_id: "prj_known",
@@ -288,7 +290,6 @@ describe("projects.getUsage", () => {
288290
api_calls_limit: 1000,
289291
storage_bytes: 1024,
290292
storage_limit_bytes: 1048576,
291-
lease_expires_at: "2026-05-01T00:00:00Z",
292293
status: "active",
293294
}),
294295
);
@@ -299,6 +300,8 @@ describe("projects.getUsage", () => {
299300
assert.equal(calls[0]!.headers["Authorization"], "Bearer service_xxx");
300301
assert.equal(result.tier, "prototype");
301302
assert.equal(result.api_calls, 10);
303+
assert.equal(result.lease_expires_at, undefined,
304+
"gateway omits lease_expires_at; type is optional so callers don't read a non-existent string");
302305
});
303306

304307
it("throws ProjectNotFound for unknown ids before hitting the network", async () => {

sdk/src/namespaces/projects.types.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -53,7 +53,12 @@ export interface UsageReport {
5353
api_calls_limit: number;
5454
storage_bytes: number;
5555
storage_limit_bytes: number;
56-
lease_expires_at: string;
56+
/**
57+
* Optional: the `/projects/v1/admin/:id/usage` endpoint does not currently
58+
* include the lease expiry. Read it from `tier.status()` if you need it.
59+
* `null` is reserved for unleased accounts (see GH-163).
60+
*/
61+
lease_expires_at?: string | null;
5762
status: string;
5863
}
5964

sdk/src/namespaces/secrets.test.ts

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -92,11 +92,21 @@ describe("subdomains", () => {
9292
assert.equal(calls[0]!.method, "DELETE");
9393
});
9494

95-
it("list requires a projectId and GETs with bearer", async () => {
96-
const { fetch, calls } = mockFetch(() => json([]));
97-
await sdk(fetch).subdomains.list("prj_k");
95+
it("list requires a projectId and GETs with bearer, unwrapping the gateway envelope", async () => {
96+
// Gateway shape is `{ subdomains: [...] }`; SDK must hand back a bare array.
97+
const { fetch, calls } = mockFetch(() =>
98+
json({
99+
subdomains: [
100+
{ name: "demo", url: "https://demo.run402.com", deployment_id: "dep_1", deployment_url: "https://x.run402.com" },
101+
],
102+
}),
103+
);
104+
const result = await sdk(fetch).subdomains.list("prj_k");
98105
assert.equal(calls[0]!.method, "GET");
99106
assert.equal(calls[0]!.headers["Authorization"], "Bearer s");
107+
assert.ok(Array.isArray(result), "list() must return a bare array, not the envelope");
108+
assert.equal(result.length, 1);
109+
assert.equal(result[0]!.name, "demo");
100110
});
101111
});
102112

sdk/src/namespaces/subdomains.ts

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -74,9 +74,15 @@ export class Subdomains {
7474
const project = await this.client.getProject(projectId);
7575
if (!project) throw new ProjectNotFound(projectId, "listing subdomains");
7676

77-
return this.client.request<SubdomainSummary[]>("/subdomains/v1", {
78-
headers: { Authorization: `Bearer ${project.service_key}` },
79-
context: "listing subdomains",
80-
});
77+
// Gateway responds `{ subdomains: [...] }`; unwrap so callers get the
78+
// array shape the type promises (regression: GH-163).
79+
const body = await this.client.request<{ subdomains: SubdomainSummary[] }>(
80+
"/subdomains/v1",
81+
{
82+
headers: { Authorization: `Bearer ${project.service_key}` },
83+
context: "listing subdomains",
84+
},
85+
);
86+
return body.subdomains ?? [];
8187
}
8288
}

src/tools/init.test.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -45,8 +45,9 @@ function mockFetch(opts: {
4545
const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url;
4646

4747
if (url.includes("/faucet/v1")) {
48+
// Wire shape (snake_case + micros) — SDK normalizes for callers.
4849
return new Response(
49-
JSON.stringify(opts.faucetBody ?? { transactionHash: "0xabc", amount: "0.25", token: "USDC", network: "base-sepolia" }),
50+
JSON.stringify(opts.faucetBody ?? { transaction_hash: "0xabc", amount_usd_micros: 250000, token: "USDC", network: "base-sepolia" }),
5051
{ status: opts.faucetOk !== false ? 200 : 429, headers: { "Content-Type": "application/json" } },
5152
);
5253
}
@@ -104,7 +105,7 @@ describe("init tool", () => {
104105
funded: false,
105106
rail: "x402",
106107
});
107-
mockFetch({ faucetOk: true, faucetBody: { transactionHash: "0xabc", amount: "0.25", token: "USDC", network: "base-sepolia" } });
108+
mockFetch({ faucetOk: true, faucetBody: { transaction_hash: "0xabc", amount_usd_micros: 250000, token: "USDC", network: "base-sepolia" } });
108109

109110
const result = await handleInit({});
110111
const text = result.content[0]!.text;

0 commit comments

Comments
 (0)