Skip to content

Commit 6c3cf68

Browse files
committed
fix: review fixes — yield curve key, input validation, NaN guard, CI/CD
- Fix yield curve issuerType: G_N_C (AAA) default, G_N_A for all_gov - Add input validation on SDMX key segments (reject path traversal) - Add NaN guard on parseFloat in convert_currency - Add .int().positive() to lastNObservations Zod schemas - Fix timer leak in fetchWithRetry (try/finally on clearTimeout) - Add CI workflow (test on PR, Node 18/20/22) - Add publish workflow (npm publish on release)
1 parent e204673 commit 6c3cf68

8 files changed

Lines changed: 116 additions & 21 deletions

File tree

.github/workflows/ci.yml

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
name: CI
2+
3+
on:
4+
push:
5+
branches: [main]
6+
pull_request:
7+
branches: [main]
8+
9+
jobs:
10+
test:
11+
runs-on: ubuntu-latest
12+
strategy:
13+
matrix:
14+
node-version: [18, 20, 22]
15+
steps:
16+
- uses: actions/checkout@v4
17+
- uses: actions/setup-node@v4
18+
with:
19+
node-version: ${{ matrix.node-version }}
20+
cache: npm
21+
- run: npm ci
22+
- run: npm run lint
23+
- run: npm test
24+
- run: npm run build

.github/workflows/publish.yml

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
name: Publish to npm
2+
3+
on:
4+
release:
5+
types: [published]
6+
7+
jobs:
8+
publish:
9+
runs-on: ubuntu-latest
10+
permissions:
11+
contents: read
12+
id-token: write
13+
steps:
14+
- uses: actions/checkout@v4
15+
- uses: actions/setup-node@v4
16+
with:
17+
node-version: 22
18+
registry-url: https://registry.npmjs.org
19+
cache: npm
20+
- run: npm ci
21+
- run: npm test
22+
- run: npm run build
23+
- run: npm publish --provenance --access public
24+
env:
25+
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}

src/ecb-client.ts

Lines changed: 10 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -46,12 +46,16 @@ async function fetchWithRetry(
4646
const controller = new AbortController();
4747
const timeout = setTimeout(() => controller.abort(), config.timeoutMs);
4848

49-
const response = await fetch(url, {
50-
method: "GET",
51-
headers,
52-
signal: controller.signal,
53-
});
54-
clearTimeout(timeout);
49+
let response: Response;
50+
try {
51+
response = await fetch(url, {
52+
method: "GET",
53+
headers,
54+
signal: controller.signal,
55+
});
56+
} finally {
57+
clearTimeout(timeout);
58+
}
5559

5660
if (response.status >= 500 && attempt < config.maxRetries) {
5761
const delay = 2 ** attempt * 1000;

src/index.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,8 @@ Examples of questions this tool answers:
5555
.describe("End date (YYYY-MM-DD, YYYY-MM, or YYYY)"),
5656
lastNObservations: z
5757
.number()
58+
.int()
59+
.positive()
5860
.optional()
5961
.describe("Return only the last N data points"),
6062
},
@@ -91,6 +93,8 @@ Examples of questions this tool answers:
9193
.describe("End date (YYYY-MM-DD, YYYY-MM, or YYYY)"),
9294
lastNObservations: z
9395
.number()
96+
.int()
97+
.positive()
9498
.optional()
9599
.describe("Return only the last N data points"),
96100
},
@@ -131,6 +135,8 @@ Examples of questions this tool answers:
131135
endPeriod: z.string().optional().describe("End date (YYYY-MM, or YYYY)"),
132136
lastNObservations: z
133137
.number()
138+
.int()
139+
.positive()
134140
.optional()
135141
.describe("Return only the last N data points"),
136142
},
@@ -174,6 +180,8 @@ Examples of questions this tool answers:
174180
.describe("End date (YYYY-MM-DD, YYYY-MM, or YYYY)"),
175181
lastNObservations: z
176182
.number()
183+
.int()
184+
.positive()
177185
.optional()
178186
.describe("Return only the last N data points"),
179187
},
@@ -218,6 +226,8 @@ Examples of questions this tool answers:
218226
endPeriod: z.string().optional().describe("End date (YYYY-MM, or YYYY)"),
219227
lastNObservations: z
220228
.number()
229+
.int()
230+
.positive()
221231
.optional()
222232
.describe("Return only the last N data points"),
223233
},

src/sdmx-keys.ts

Lines changed: 22 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -47,17 +47,29 @@ const ICP_MEASURE_MAP: Record<string, string> = {
4747
INDEX: "INX",
4848
};
4949

50+
const VALID_SEGMENT = /^[A-Z0-9_+]+$/;
51+
5052
function normalize(value: string): string {
5153
return value.trim().toUpperCase();
5254
}
5355

56+
function validateSegment(value: string, label: string): string {
57+
const normalized = normalize(value);
58+
if (!VALID_SEGMENT.test(normalized)) {
59+
throw new Error(
60+
`Invalid ${label}: "${value}". Only letters, digits, and underscores are allowed.`,
61+
);
62+
}
63+
return normalized;
64+
}
65+
5466
/**
5567
* Exchange rates (EXR dataflow).
5668
* Template: {freq}.{target}.EUR.SP00.A
5769
*/
5870
export function buildExrKey(target: string, freq = "D"): string {
59-
const f = FREQ_MAP[normalize(freq)] || normalize(freq);
60-
const t = normalize(target);
71+
const f = FREQ_MAP[normalize(freq)] || validateSegment(freq, "frequency");
72+
const t = validateSegment(target, "currency");
6173
return `${f}.${t}.EUR.SP00.A`;
6274
}
6375

@@ -87,15 +99,15 @@ export function buildFmKey(type: string): string {
8799

88100
/**
89101
* Yield curve (YC dataflow).
90-
* Template: B.U2.EUR.4F.G_N_A.SV_C_YM.{maturity}
102+
* Template: B.U2.EUR.4F.{issuer}.SV_C_YM.{maturity}
91103
*
92-
* If issuerType is "all_gov", uses G_N_A (all government bonds).
93-
* If "aaa", uses G_N_A (AAA-rated, which is the ECB default for YC).
104+
* issuerType "aaa" (default) uses G_N_C (AAA-rated government bonds).
105+
* issuerType "all_gov" uses G_N_A (all government bonds regardless of rating).
94106
*/
95107
export function buildYcKey(maturity?: string, issuerType?: string): string {
96108
const m = maturity ? `SR_${normalize(maturity)}` : "SR_10Y";
97109
const issuer =
98-
issuerType && normalize(issuerType) === "ALL_GOV" ? "G_N_A" : "G_N_A";
110+
issuerType && normalize(issuerType) === "ALL_GOV" ? "G_N_A" : "G_N_C";
99111
return `B.U2.EUR.4F.${issuer}.SV_C_YM.${m}`;
100112
}
101113

@@ -107,8 +119,9 @@ export function buildYcKey(maturity?: string, issuerType?: string): string {
107119
* Measure defaults to "ANR" (annual rate of change).
108120
*/
109121
export function buildIcpKey(country = "U2", measure = "annual_rate"): string {
110-
const c = normalize(country);
111-
const m = ICP_MEASURE_MAP[normalize(measure)] || normalize(measure);
122+
const c = validateSegment(country, "country");
123+
const m =
124+
ICP_MEASURE_MAP[normalize(measure)] || validateSegment(measure, "measure");
112125
return `M.${c}.N.000000.4.${m}`;
113126
}
114127

@@ -125,7 +138,7 @@ export function buildBsiKey(
125138
measure = "outstanding",
126139
country = "U2",
127140
): string {
128-
const c = normalize(country);
141+
const c = validateSegment(country, "country");
129142
const agg = normalize(aggregate);
130143
const item = MONEY_AGGREGATE_MAP[agg];
131144
if (!item) {

src/tools/exchange-rate.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,9 @@ export async function handleConvertCurrency(
9090
return `No exchange rate data available for EUR/${to}.`;
9191
}
9292
rate = Number.parseFloat(rows[rows.length - 1].OBS_VALUE);
93+
if (Number.isNaN(rate)) {
94+
return `Exchange rate for EUR/${to} is not available (missing observation).`;
95+
}
9396
rateDate = rows[rows.length - 1].TIME_PERIOD;
9497
result = amount * rate;
9598
} else if (isToEur) {
@@ -101,6 +104,9 @@ export async function handleConvertCurrency(
101104
return `No exchange rate data available for EUR/${from}.`;
102105
}
103106
rate = Number.parseFloat(rows[rows.length - 1].OBS_VALUE);
107+
if (Number.isNaN(rate)) {
108+
return `Exchange rate for EUR/${from} is not available (missing observation).`;
109+
}
104110
rateDate = rows[rows.length - 1].TIME_PERIOD;
105111
result = amount / rate;
106112
} else {
@@ -126,6 +132,9 @@ export async function handleConvertCurrency(
126132

127133
const fromRate = Number.parseFloat(fromRow.OBS_VALUE);
128134
const toRate = Number.parseFloat(toRow.OBS_VALUE);
135+
if (Number.isNaN(fromRate) || Number.isNaN(toRate)) {
136+
return `Exchange rate data is missing for ${Number.isNaN(fromRate) ? from : to}. Cannot compute cross-rate.`;
137+
}
129138
rate = toRate / fromRate;
130139
rateDate = fromDate;
131140
result = amount * rate;

tests/sdmx-keys.test.ts

Lines changed: 14 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -81,19 +81,29 @@ describe("buildFmKey", () => {
8181

8282
describe("buildYcKey", () => {
8383
it("builds 10Y AAA yield curve key (defaults)", () => {
84-
expect(buildYcKey()).toBe("B.U2.EUR.4F.G_N_A.SV_C_YM.SR_10Y");
84+
expect(buildYcKey()).toBe("B.U2.EUR.4F.G_N_C.SV_C_YM.SR_10Y");
8585
});
8686

8787
it("builds specific maturity", () => {
88-
expect(buildYcKey("5Y")).toBe("B.U2.EUR.4F.G_N_A.SV_C_YM.SR_5Y");
88+
expect(buildYcKey("5Y")).toBe("B.U2.EUR.4F.G_N_C.SV_C_YM.SR_5Y");
8989
});
9090

9191
it("builds 3M maturity", () => {
92-
expect(buildYcKey("3M")).toBe("B.U2.EUR.4F.G_N_A.SV_C_YM.SR_3M");
92+
expect(buildYcKey("3M")).toBe("B.U2.EUR.4F.G_N_C.SV_C_YM.SR_3M");
9393
});
9494

9595
it("is case-insensitive for maturity", () => {
96-
expect(buildYcKey("10y")).toBe("B.U2.EUR.4F.G_N_A.SV_C_YM.SR_10Y");
96+
expect(buildYcKey("10y")).toBe("B.U2.EUR.4F.G_N_C.SV_C_YM.SR_10Y");
97+
});
98+
99+
it("uses G_N_A for all_gov issuer type", () => {
100+
expect(buildYcKey("10Y", "all_gov")).toBe(
101+
"B.U2.EUR.4F.G_N_A.SV_C_YM.SR_10Y",
102+
);
103+
});
104+
105+
it("uses G_N_C (AAA) by default", () => {
106+
expect(buildYcKey("10Y", "aaa")).toBe("B.U2.EUR.4F.G_N_C.SV_C_YM.SR_10Y");
97107
});
98108
});
99109

tests/tools/yield-curve.test.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,7 @@ describe("handleGetYieldCurve", () => {
4444

4545
expect(client.fetchData).toHaveBeenCalledWith(
4646
"YC",
47-
"B.U2.EUR.4F.G_N_A.SV_C_YM.SR_10Y",
47+
"B.U2.EUR.4F.G_N_C.SV_C_YM.SR_10Y",
4848
expect.any(Object),
4949
);
5050
});
@@ -55,7 +55,7 @@ describe("handleGetYieldCurve", () => {
5555

5656
expect(client.fetchData).toHaveBeenCalledWith(
5757
"YC",
58-
"B.U2.EUR.4F.G_N_A.SV_C_YM.SR_5Y",
58+
"B.U2.EUR.4F.G_N_C.SV_C_YM.SR_5Y",
5959
expect.any(Object),
6060
);
6161
});

0 commit comments

Comments
 (0)