Skip to content

Commit 8092425

Browse files
AgentX010collinsezedike
authored andcommitted
fix(api): add GET-only guards and align local API
1 parent d7825d6 commit 8092425

14 files changed

Lines changed: 304 additions & 58 deletions

File tree

‎api/__tests__/handlers.test.ts‎

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -681,6 +681,14 @@ describe("GET /api/v1/vaults", () => {
681681
});
682682
});
683683

684+
it("rejects non-GET methods with 405", async () => {
685+
const res = makeRes();
686+
await vaultsHandler(fakeReq({ method: "POST" }), res);
687+
expect(res.statusCode).toBe(405);
688+
expect(res.headers["Allow"]).toBe("GET");
689+
expect(fetchAllVaults).not.toHaveBeenCalled();
690+
});
691+
684692
it("reports cached: true when served from cache", async () => {
685693
vi.mocked(fetchVaultsWithCache).mockResolvedValueOnce({
686694
vaults: [
@@ -748,6 +756,17 @@ describe("GET /api/v1/vaults/:vaultId", () => {
748756
expect(res.statusCode).toBe(404);
749757
expect(res.headers["Cache-Control"]).toBeUndefined();
750758
});
759+
760+
it("rejects non-GET methods with 405", async () => {
761+
const res = makeRes();
762+
await vaultDetailHandler(
763+
fakeReq({ method: "POST", query: { vaultId: "meridian-usdc" } }),
764+
res
765+
);
766+
expect(res.statusCode).toBe(405);
767+
expect(res.headers["Allow"]).toBe("GET");
768+
expect(fetchAllVaults).not.toHaveBeenCalled();
769+
});
751770
});
752771

753772
describe("GET /api/v1/positions/:publicKey", () => {
@@ -761,6 +780,17 @@ describe("GET /api/v1/positions/:publicKey", () => {
761780
expect(resolvePositions).not.toHaveBeenCalled();
762781
});
763782

783+
it("rejects non-GET methods with 405", async () => {
784+
const res = makeRes();
785+
await positionsHandler(
786+
fakeReq({ method: "POST", query: { publicKey: PUBKEY } }),
787+
res
788+
);
789+
expect(res.statusCode).toBe(405);
790+
expect(res.headers["Allow"]).toBe("GET");
791+
expect(resolvePositions).not.toHaveBeenCalled();
792+
});
793+
764794
it("returns the resolved positions for a valid key", async () => {
765795
const res = makeRes();
766796
await positionsHandler(
@@ -1489,4 +1519,20 @@ describe("GET /api/v1/admin/vault-state", () => {
14891519

14901520
expect(res.statusCode).toBe(503);
14911521
});
1522+
1523+
it("rejects non-GET methods with 405", async () => {
1524+
const res = makeRes();
1525+
await adminHandler(
1526+
fakeReq({
1527+
query: { resource: "vault-state" },
1528+
method: "POST",
1529+
headers: {},
1530+
}),
1531+
res
1532+
);
1533+
1534+
expect(res.statusCode).toBe(405);
1535+
expect(res.headers["Allow"]).toBe("GET");
1536+
expect(fetchCoordinatorState).not.toHaveBeenCalled();
1537+
});
14921538
});

‎api/__tests__/middleware.test.ts‎

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import {
55
checkRateLimit,
66
isCronAuthorized,
77
isCronSecretConfigured,
8+
requireGetMethod,
89
resetRateLimitForTesting,
910
} from "../_lib/middleware.js";
1011

@@ -98,6 +99,25 @@ describe("applyCors", () => {
9899
});
99100
});
100101

102+
// requireGetMethod ----------------------------------------------------------------
103+
104+
describe("requireGetMethod", () => {
105+
it("allows GET requests without writing a response", () => {
106+
const res = fakeRes();
107+
expect(requireGetMethod(fakeReq("GET"), res)).toBe(false);
108+
expect(res.statusCode).toBe(200);
109+
expect(res.ended).toBe(false);
110+
});
111+
112+
it("returns 405 with an Allow header for non-GET requests", () => {
113+
const res = fakeRes();
114+
expect(requireGetMethod(fakeReq("POST"), res)).toBe(true);
115+
expect(res.statusCode).toBe(405);
116+
expect(res.headers["Allow"]).toBe("GET");
117+
expect(res.body).toEqual({ error: "Method not allowed" });
118+
});
119+
});
120+
101121
// checkRateLimit ------------------------------------------------------------------
102122

103123
describe("checkRateLimit", () => {

‎api/_lib/middleware.ts‎

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,21 @@ export function applyCors(req: VercelRequest, res: VercelResponse): boolean {
7676
return false;
7777
}
7878

79+
/**
80+
* Reject any method other than GET on a read-only route. Returns true when a
81+
* 405 has already been sent, so callers use `if (requireGetMethod(req, res))
82+
* return;`.
83+
*/
84+
export function requireGetMethod(
85+
req: VercelRequest,
86+
res: VercelResponse
87+
): boolean {
88+
if (req.method === "GET") return false;
89+
res.setHeader("Allow", "GET");
90+
res.status(405).json({ error: "Method not allowed" });
91+
return true;
92+
}
93+
7994
// Cache policy shared by both vault-read endpoints: the list in
8095
// v1/vaults/index.ts and the single-vault read in v1/vaults/[vaultId].ts.
8196
// Both must use the same policy — #1051 happened because the detail endpoint

‎api/v1/admin/[resource].ts‎

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,10 @@
11
import type { VercelRequest, VercelResponse } from "@vercel/node";
22
import { handleGetAdminHistory, handleGetVaultState } from "@meridian/api-core";
3-
import { applyCors, checkRateLimit } from "../../_lib/middleware.js";
3+
import {
4+
applyCors,
5+
checkRateLimit,
6+
requireGetMethod,
7+
} from "../../_lib/middleware.js";
48

59
const HISTORY_CACHE_CONTROL = "public, s-maxage=30, stale-while-revalidate=120";
610

@@ -25,17 +29,15 @@ export default async function handler(req: VercelRequest, res: VercelResponse) {
2529
const { resource } = req.query;
2630

2731
if (resource === "vault-state") {
32+
if (requireGetMethod(req, res)) return;
2833
const result = await handleGetVaultState();
2934
if (result.error) console.error("[admin/vault-state] error:", result.error);
3035
res.setHeader("Cache-Control", "no-store");
3136
return res.status(result.status).json(result.body);
3237
}
3338

3439
if (resource === "history") {
35-
if (req.method !== "GET") {
36-
res.setHeader("Allow", "GET");
37-
return res.status(405).json({ error: "Method not allowed" });
38-
}
40+
if (requireGetMethod(req, res)) return;
3941
const raw = req.query["vaultId"];
4042
const vaultId = typeof raw === "string" ? raw : undefined;
4143
if (!vaultId) return res.status(400).json({ error: "vaultId is required" });

‎api/v1/keepers/[action].ts‎

Lines changed: 21 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -25,19 +25,21 @@ import {
2525
isCronSecretConfigured,
2626
} from "../../_lib/middleware.js";
2727

28-
// Consolidated from four separate files (accrue/health/rebalance/alert) to
29-
// stay under Vercel's per-deployment Serverless Functions cap. URL paths are
30-
// unchanged: /api/v1/keepers/accrue, /api/v1/keepers/health,
31-
// /api/v1/keepers/rebalance, and /api/v1/keepers/alert all route here via
32-
// the [action].ts dynamic segment, with req.query.action set accordingly.
28+
// Consolidated from the former per-keeper files to stay under Vercel's
29+
// per-deployment Serverless Functions cap. URL paths are unchanged:
30+
// /api/v1/keepers/accrue, /api/v1/keepers/health,
31+
// /api/v1/keepers/rebalance, /api/v1/keepers/alert, and
32+
// /api/v1/keepers/snapshot all route here via the [action].ts dynamic
33+
// segment, with req.query.action set accordingly.
3334
//
34-
// health is read-only and public, same as before; accrue/rebalance/alert
35-
// still require the cron bearer token and are never CORS-gated. accrue and
36-
// rebalance sign and submit real transactions off a funded/admin account;
37-
// alert signs nothing but still costs an RPC round trip per known vault plus
38-
// a webhook POST per unauthenticated probe, so it gets the same rate-limit
39-
// backstop. That auth split is preserved exactly as it was when these were
40-
// separate files.
35+
// health is read-only and public, same as before; accrue/rebalance/alert/
36+
// snapshot still require the cron bearer token and are never CORS-gated.
37+
// accrue and rebalance sign and submit real transactions off a funded/admin
38+
// account; alert signs nothing but still costs an RPC round trip per known
39+
// vault plus a webhook POST per unauthenticated probe; snapshot reads every
40+
// tracked wallet and writes the resulting series. They all keep the same
41+
// rate-limit backstop. That auth split is preserved exactly as it was when
42+
// these were separate files.
4143
export default async function handler(req: VercelRequest, res: VercelResponse) {
4244
const { action } = req.query;
4345

@@ -71,14 +73,14 @@ export default async function handler(req: VercelRequest, res: VercelResponse) {
7173
return res.status(405).json({ error: "Method not allowed" });
7274
}
7375

74-
// No applyCors: cron-invoked, never browser-facing. But all three sign or
75-
// submit real transactions, or read/post on their behalf, unlike simple
76-
// reads, so they still get a rate-limit backstop. Checked before auth,
77-
// deliberately, even though that costs a Redis round trip on an
76+
// No applyCors: cron-invoked, never browser-facing. But these actions sign
77+
// or submit real transactions, or read and write on their behalf, unlike
78+
// simple reads, so they still get a rate-limit backstop. Checked before
79+
// auth, deliberately, even though that costs a Redis round trip on an
7880
// unauthenticated probe: this is the volume-abuse backstop for *all*
79-
// traffic, not just correctly authenticated traffic — if it only ran
80-
// after a successful auth check, unauthenticated/wrong-token spam would
81-
// be entirely unbounded.
81+
// traffic, not just correctly authenticated traffic. If it only ran after
82+
// a successful auth check, unauthenticated or wrong-token spam would be
83+
// entirely unbounded.
8284
try {
8385
if (!(await checkRateLimit(req, res, { strict: true }))) return;
8486
} catch (err) {

‎api/v1/positions/[publicKey].ts‎

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,14 @@
11
import type { VercelRequest, VercelResponse } from "@vercel/node";
22
import { handleGetPositions } from "@meridian/api-core";
3-
import { applyCors, checkRateLimit } from "../../_lib/middleware.js";
3+
import {
4+
applyCors,
5+
checkRateLimit,
6+
requireGetMethod,
7+
} from "../../_lib/middleware.js";
48

59
export default async function handler(req: VercelRequest, res: VercelResponse) {
610
if (applyCors(req, res)) return;
11+
if (requireGetMethod(req, res)) return;
712
try {
813
if (!(await checkRateLimit(req, res))) return;
914
} catch (err) {

‎api/v1/vaults/[vaultId].ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { handleGetVaultById } from "@meridian/api-core";
44
import {
55
applyCors,
66
checkRateLimit,
7+
requireGetMethod,
78
vaultReadCacheControl,
89
} from "../../_lib/middleware.js";
910

@@ -16,6 +17,7 @@ const KNOWN_VAULT_IDS = new Set(
1617

1718
export default async function handler(req: VercelRequest, res: VercelResponse) {
1819
if (applyCors(req, res)) return;
20+
if (requireGetMethod(req, res)) return;
1921
try {
2022
if (!(await checkRateLimit(req, res))) return;
2123
} catch (err) {

‎api/v1/vaults/index.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,11 +3,13 @@ import { handleGetVaults } from "@meridian/api-core";
33
import {
44
applyCors,
55
checkRateLimit,
6+
requireGetMethod,
67
vaultReadCacheControl,
78
} from "../../_lib/middleware.js";
89

910
export default async function handler(req: VercelRequest, res: VercelResponse) {
1011
if (applyCors(req, res)) return;
12+
if (requireGetMethod(req, res)) return;
1113
try {
1214
if (!(await checkRateLimit(req, res))) return;
1315
} catch (err) {

‎apps/api-local/src/__tests__/admin-keepers.test.ts‎

Lines changed: 58 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,11 +4,16 @@ import { adminRoute } from "../routes/admin.js";
44
import { keepersRoute } from "../routes/keepers.js";
55

66
vi.mock("@meridian/api-core", () => ({
7+
handleGetAdminHistory: vi.fn(),
78
handleGetVaultState: vi.fn(),
89
handleGetKeeperHealth: vi.fn(),
910
}));
1011

11-
import { handleGetVaultState, handleGetKeeperHealth } from "@meridian/api-core";
12+
import {
13+
handleGetAdminHistory,
14+
handleGetVaultState,
15+
handleGetKeeperHealth,
16+
} from "@meridian/api-core";
1217

1318
function buildApp() {
1419
const app = Fastify({ logger: false });
@@ -115,6 +120,58 @@ describe("GET /api/v1/admin/vault-state", () => {
115120
});
116121
});
117122

123+
describe("GET /api/v1/admin/history", () => {
124+
it("returns admin actions for the requested vault", async () => {
125+
const app = buildApp();
126+
const actions = [{ action: "pause", ledger: 42 }];
127+
vi.mocked(handleGetAdminHistory).mockResolvedValue({
128+
status: 200,
129+
body: { vaultId: "meridian-usdc", actions },
130+
});
131+
132+
const res = await app.inject({
133+
method: "GET",
134+
url: "/api/v1/admin/history?vaultId=meridian-usdc",
135+
});
136+
expect(res.statusCode).toBe(200);
137+
expect(res.json()).toEqual({ vaultId: "meridian-usdc", actions });
138+
expect(handleGetAdminHistory).toHaveBeenCalledWith("meridian-usdc");
139+
});
140+
141+
it("returns 400 without calling the handler when vaultId is missing", async () => {
142+
const app = buildApp();
143+
const res = await app.inject({
144+
method: "GET",
145+
url: "/api/v1/admin/history",
146+
});
147+
expect(res.statusCode).toBe(400);
148+
expect(res.json()).toEqual({ error: "vaultId is required" });
149+
expect(handleGetAdminHistory).not.toHaveBeenCalled();
150+
});
151+
152+
it("returns the handler status and logs an error", async () => {
153+
const app = buildApp();
154+
const logError = vi.spyOn(app.log, "error");
155+
const err = new Error("horizon unavailable");
156+
vi.mocked(handleGetAdminHistory).mockResolvedValue({
157+
status: 500,
158+
body: { error: "Failed to fetch admin history" },
159+
error: err,
160+
});
161+
162+
const res = await app.inject({
163+
method: "GET",
164+
url: "/api/v1/admin/history?vaultId=meridian-usdc",
165+
});
166+
expect(res.statusCode).toBe(500);
167+
expect(res.json()).toEqual({ error: "Failed to fetch admin history" });
168+
expect(logError).toHaveBeenCalledWith(
169+
err,
170+
"[admin] failed to read admin history"
171+
);
172+
});
173+
});
174+
118175
describe("GET /api/v1/keepers/health", () => {
119176
it("returns 200 with the keeper health report", async () => {
120177
const app = buildApp();

0 commit comments

Comments
 (0)