diff --git a/api/__tests__/handlers.test.ts b/api/__tests__/handlers.test.ts index 8813f674..26a8687c 100644 --- a/api/__tests__/handlers.test.ts +++ b/api/__tests__/handlers.test.ts @@ -681,6 +681,14 @@ describe("GET /api/v1/vaults", () => { }); }); + it("rejects non-GET methods with 405", async () => { + const res = makeRes(); + await vaultsHandler(fakeReq({ method: "POST" }), res); + expect(res.statusCode).toBe(405); + expect(res.headers["Allow"]).toBe("GET"); + expect(fetchAllVaults).not.toHaveBeenCalled(); + }); + it("reports cached: true when served from cache", async () => { vi.mocked(fetchVaultsWithCache).mockResolvedValueOnce({ vaults: [ @@ -748,6 +756,17 @@ describe("GET /api/v1/vaults/:vaultId", () => { expect(res.statusCode).toBe(404); expect(res.headers["Cache-Control"]).toBeUndefined(); }); + + it("rejects non-GET methods with 405", async () => { + const res = makeRes(); + await vaultDetailHandler( + fakeReq({ method: "POST", query: { vaultId: "meridian-usdc" } }), + res + ); + expect(res.statusCode).toBe(405); + expect(res.headers["Allow"]).toBe("GET"); + expect(fetchAllVaults).not.toHaveBeenCalled(); + }); }); describe("GET /api/v1/positions/:publicKey", () => { @@ -761,6 +780,17 @@ describe("GET /api/v1/positions/:publicKey", () => { expect(resolvePositions).not.toHaveBeenCalled(); }); + it("rejects non-GET methods with 405", async () => { + const res = makeRes(); + await positionsHandler( + fakeReq({ method: "POST", query: { publicKey: PUBKEY } }), + res + ); + expect(res.statusCode).toBe(405); + expect(res.headers["Allow"]).toBe("GET"); + expect(resolvePositions).not.toHaveBeenCalled(); + }); + it("returns the resolved positions for a valid key", async () => { const res = makeRes(); await positionsHandler( @@ -1489,4 +1519,20 @@ describe("GET /api/v1/admin/vault-state", () => { expect(res.statusCode).toBe(503); }); + + it("rejects non-GET methods with 405", async () => { + const res = makeRes(); + await adminHandler( + fakeReq({ + query: { resource: "vault-state" }, + method: "POST", + headers: {}, + }), + res + ); + + expect(res.statusCode).toBe(405); + expect(res.headers["Allow"]).toBe("GET"); + expect(fetchCoordinatorState).not.toHaveBeenCalled(); + }); }); diff --git a/api/__tests__/middleware.test.ts b/api/__tests__/middleware.test.ts index 22e61fdb..ed84be6e 100644 --- a/api/__tests__/middleware.test.ts +++ b/api/__tests__/middleware.test.ts @@ -5,6 +5,7 @@ import { checkRateLimit, isCronAuthorized, isCronSecretConfigured, + requireGetMethod, resetRateLimitForTesting, } from "../_lib/middleware.js"; @@ -98,6 +99,25 @@ describe("applyCors", () => { }); }); +// requireGetMethod ---------------------------------------------------------------- + +describe("requireGetMethod", () => { + it("allows GET requests without writing a response", () => { + const res = fakeRes(); + expect(requireGetMethod(fakeReq("GET"), res)).toBe(false); + expect(res.statusCode).toBe(200); + expect(res.ended).toBe(false); + }); + + it("returns 405 with an Allow header for non-GET requests", () => { + const res = fakeRes(); + expect(requireGetMethod(fakeReq("POST"), res)).toBe(true); + expect(res.statusCode).toBe(405); + expect(res.headers["Allow"]).toBe("GET"); + expect(res.body).toEqual({ error: "Method not allowed" }); + }); +}); + // checkRateLimit ------------------------------------------------------------------ describe("checkRateLimit", () => { diff --git a/api/_lib/middleware.ts b/api/_lib/middleware.ts index c2741d4c..5d0d2306 100644 --- a/api/_lib/middleware.ts +++ b/api/_lib/middleware.ts @@ -76,6 +76,21 @@ export function applyCors(req: VercelRequest, res: VercelResponse): boolean { return false; } +/** + * Reject any method other than GET on a read-only route. Returns true when a + * 405 has already been sent, so callers use `if (requireGetMethod(req, res)) + * return;`. + */ +export function requireGetMethod( + req: VercelRequest, + res: VercelResponse +): boolean { + if (req.method === "GET") return false; + res.setHeader("Allow", "GET"); + res.status(405).json({ error: "Method not allowed" }); + return true; +} + // Cache policy shared by both vault-read endpoints: the list in // v1/vaults/index.ts and the single-vault read in v1/vaults/[vaultId].ts. // Both must use the same policy — #1051 happened because the detail endpoint diff --git a/api/v1/admin/[resource].ts b/api/v1/admin/[resource].ts index 1e87863e..96315fb5 100644 --- a/api/v1/admin/[resource].ts +++ b/api/v1/admin/[resource].ts @@ -1,6 +1,10 @@ import type { VercelRequest, VercelResponse } from "@vercel/node"; import { handleGetAdminHistory, handleGetVaultState } from "@meridian/api-core"; -import { applyCors, checkRateLimit } from "../../_lib/middleware.js"; +import { + applyCors, + checkRateLimit, + requireGetMethod, +} from "../../_lib/middleware.js"; const HISTORY_CACHE_CONTROL = "public, s-maxage=30, stale-while-revalidate=120"; @@ -25,6 +29,7 @@ export default async function handler(req: VercelRequest, res: VercelResponse) { const { resource } = req.query; if (resource === "vault-state") { + if (requireGetMethod(req, res)) return; const result = await handleGetVaultState(); if (result.error) console.error("[admin/vault-state] error:", result.error); res.setHeader("Cache-Control", "no-store"); @@ -32,10 +37,7 @@ export default async function handler(req: VercelRequest, res: VercelResponse) { } if (resource === "history") { - if (req.method !== "GET") { - res.setHeader("Allow", "GET"); - return res.status(405).json({ error: "Method not allowed" }); - } + if (requireGetMethod(req, res)) return; const raw = req.query["vaultId"]; const vaultId = typeof raw === "string" ? raw : undefined; if (!vaultId) return res.status(400).json({ error: "vaultId is required" }); diff --git a/api/v1/keepers/[action].ts b/api/v1/keepers/[action].ts index cbfd7889..9d2a984c 100644 --- a/api/v1/keepers/[action].ts +++ b/api/v1/keepers/[action].ts @@ -25,19 +25,21 @@ import { isCronSecretConfigured, } from "../../_lib/middleware.js"; -// Consolidated from four separate files (accrue/health/rebalance/alert) to -// stay under Vercel's per-deployment Serverless Functions cap. URL paths are -// unchanged: /api/v1/keepers/accrue, /api/v1/keepers/health, -// /api/v1/keepers/rebalance, and /api/v1/keepers/alert all route here via -// the [action].ts dynamic segment, with req.query.action set accordingly. +// Consolidated from the former per-keeper files to stay under Vercel's +// per-deployment Serverless Functions cap. URL paths are unchanged: +// /api/v1/keepers/accrue, /api/v1/keepers/health, +// /api/v1/keepers/rebalance, /api/v1/keepers/alert, and +// /api/v1/keepers/snapshot all route here via the [action].ts dynamic +// segment, with req.query.action set accordingly. // -// health is read-only and public, same as before; accrue/rebalance/alert -// still require the cron bearer token and are never CORS-gated. accrue and -// rebalance sign and submit real transactions off a funded/admin account; -// alert signs nothing but still costs an RPC round trip per known vault plus -// a webhook POST per unauthenticated probe, so it gets the same rate-limit -// backstop. That auth split is preserved exactly as it was when these were -// separate files. +// health is read-only and public, same as before; accrue/rebalance/alert/ +// snapshot still require the cron bearer token and are never CORS-gated. +// accrue and rebalance sign and submit real transactions off a funded/admin +// account; alert signs nothing but still costs an RPC round trip per known +// vault plus a webhook POST per unauthenticated probe; snapshot reads every +// tracked wallet and writes the resulting series. They all keep the same +// rate-limit backstop. That auth split is preserved exactly as it was when +// these were separate files. export default async function handler(req: VercelRequest, res: VercelResponse) { const { action } = req.query; @@ -71,14 +73,14 @@ export default async function handler(req: VercelRequest, res: VercelResponse) { return res.status(405).json({ error: "Method not allowed" }); } - // No applyCors: cron-invoked, never browser-facing. But all three sign or - // submit real transactions, or read/post on their behalf, unlike simple - // reads, so they still get a rate-limit backstop. Checked before auth, - // deliberately, even though that costs a Redis round trip on an + // No applyCors: cron-invoked, never browser-facing. But these actions sign + // or submit real transactions, or read and write on their behalf, unlike + // simple reads, so they still get a rate-limit backstop. Checked before + // auth, deliberately, even though that costs a Redis round trip on an // unauthenticated probe: this is the volume-abuse backstop for *all* - // traffic, not just correctly authenticated traffic — if it only ran - // after a successful auth check, unauthenticated/wrong-token spam would - // be entirely unbounded. + // traffic, not just correctly authenticated traffic. If it only ran after + // a successful auth check, unauthenticated or wrong-token spam would be + // entirely unbounded. try { if (!(await checkRateLimit(req, res, { strict: true }))) return; } catch (err) { diff --git a/api/v1/positions/[publicKey].ts b/api/v1/positions/[publicKey].ts index d46f808e..124e8df6 100644 --- a/api/v1/positions/[publicKey].ts +++ b/api/v1/positions/[publicKey].ts @@ -1,9 +1,14 @@ import type { VercelRequest, VercelResponse } from "@vercel/node"; import { handleGetPositions } from "@meridian/api-core"; -import { applyCors, checkRateLimit } from "../../_lib/middleware.js"; +import { + applyCors, + checkRateLimit, + requireGetMethod, +} from "../../_lib/middleware.js"; export default async function handler(req: VercelRequest, res: VercelResponse) { if (applyCors(req, res)) return; + if (requireGetMethod(req, res)) return; try { if (!(await checkRateLimit(req, res))) return; } catch (err) { diff --git a/api/v1/vaults/[vaultId].ts b/api/v1/vaults/[vaultId].ts index a4e535eb..d1da8b7b 100644 --- a/api/v1/vaults/[vaultId].ts +++ b/api/v1/vaults/[vaultId].ts @@ -4,6 +4,7 @@ import { handleGetVaultById } from "@meridian/api-core"; import { applyCors, checkRateLimit, + requireGetMethod, vaultReadCacheControl, } from "../../_lib/middleware.js"; @@ -16,6 +17,7 @@ const KNOWN_VAULT_IDS = new Set( export default async function handler(req: VercelRequest, res: VercelResponse) { if (applyCors(req, res)) return; + if (requireGetMethod(req, res)) return; try { if (!(await checkRateLimit(req, res))) return; } catch (err) { diff --git a/api/v1/vaults/index.ts b/api/v1/vaults/index.ts index 280a6e0e..1a8132e5 100644 --- a/api/v1/vaults/index.ts +++ b/api/v1/vaults/index.ts @@ -3,11 +3,13 @@ import { handleGetVaults } from "@meridian/api-core"; import { applyCors, checkRateLimit, + requireGetMethod, vaultReadCacheControl, } from "../../_lib/middleware.js"; export default async function handler(req: VercelRequest, res: VercelResponse) { if (applyCors(req, res)) return; + if (requireGetMethod(req, res)) return; try { if (!(await checkRateLimit(req, res))) return; } catch (err) { diff --git a/apps/api-local/src/__tests__/admin-keepers.test.ts b/apps/api-local/src/__tests__/admin-keepers.test.ts index 0bfa54a5..c903848b 100644 --- a/apps/api-local/src/__tests__/admin-keepers.test.ts +++ b/apps/api-local/src/__tests__/admin-keepers.test.ts @@ -4,11 +4,16 @@ import { adminRoute } from "../routes/admin.js"; import { keepersRoute } from "../routes/keepers.js"; vi.mock("@meridian/api-core", () => ({ + handleGetAdminHistory: vi.fn(), handleGetVaultState: vi.fn(), handleGetKeeperHealth: vi.fn(), })); -import { handleGetVaultState, handleGetKeeperHealth } from "@meridian/api-core"; +import { + handleGetAdminHistory, + handleGetVaultState, + handleGetKeeperHealth, +} from "@meridian/api-core"; function buildApp() { const app = Fastify({ logger: false }); @@ -98,20 +103,79 @@ describe("GET /api/v1/admin/vault-state", () => { expect(logError).not.toHaveBeenCalled(); }); - it("returns 404 for unregistered admin paths and non-GET methods", async () => { + it("returns 404 for unregistered admin paths", async () => { const app = buildApp(); const unknown = await app.inject({ method: "GET", url: "/api/v1/admin/nope", }); expect(unknown.statusCode).toBe(404); + }); - const post = await app.inject({ - method: "POST", - url: "/api/v1/admin/vault-state", - }); - expect(post.statusCode).toBe(404); + it("rejects non-GET admin methods with 405", async () => { + const app = buildApp(); + for (const url of [ + "/api/v1/admin/vault-state", + "/api/v1/admin/history?vaultId=meridian-usdc", + ]) { + const post = await app.inject({ method: "POST", url }); + expect(post.statusCode).toBe(405); + expect(post.headers.allow).toBe("GET"); + } expect(handleGetVaultState).not.toHaveBeenCalled(); + expect(handleGetAdminHistory).not.toHaveBeenCalled(); + }); +}); + +describe("GET /api/v1/admin/history", () => { + it("returns admin actions for the requested vault", async () => { + const app = buildApp(); + const actions = [{ action: "pause", ledger: 42 }]; + vi.mocked(handleGetAdminHistory).mockResolvedValue({ + status: 200, + body: { vaultId: "meridian-usdc", actions }, + }); + + const res = await app.inject({ + method: "GET", + url: "/api/v1/admin/history?vaultId=meridian-usdc", + }); + expect(res.statusCode).toBe(200); + expect(res.json()).toEqual({ vaultId: "meridian-usdc", actions }); + expect(handleGetAdminHistory).toHaveBeenCalledWith("meridian-usdc"); + }); + + it("returns 400 without calling the handler when vaultId is missing", async () => { + const app = buildApp(); + const res = await app.inject({ + method: "GET", + url: "/api/v1/admin/history", + }); + expect(res.statusCode).toBe(400); + expect(res.json()).toEqual({ error: "vaultId is required" }); + expect(handleGetAdminHistory).not.toHaveBeenCalled(); + }); + + it("returns the handler status and logs an error", async () => { + const app = buildApp(); + const logError = vi.spyOn(app.log, "error"); + const err = new Error("horizon unavailable"); + vi.mocked(handleGetAdminHistory).mockResolvedValue({ + status: 500, + body: { error: "Failed to fetch admin history" }, + error: err, + }); + + const res = await app.inject({ + method: "GET", + url: "/api/v1/admin/history?vaultId=meridian-usdc", + }); + expect(res.statusCode).toBe(500); + expect(res.json()).toEqual({ error: "Failed to fetch admin history" }); + expect(logError).toHaveBeenCalledWith( + err, + "[admin] failed to read admin history" + ); }); }); diff --git a/apps/api-local/src/__tests__/routes.test.ts b/apps/api-local/src/__tests__/routes.test.ts index 39b7bdbe..5d0a1037 100644 --- a/apps/api-local/src/__tests__/routes.test.ts +++ b/apps/api-local/src/__tests__/routes.test.ts @@ -26,6 +26,33 @@ vi.mock("@meridian/stellar-sdk-helpers", async (importOriginal) => { })), selectBestVault: vi.fn(), isVaultCacheWarm: vi.fn(() => false), + KNOWN_POOLS: { + mainnet: { + "blend-usdc-fixed": { + id: "blend-usdc-fixed", + name: "Blend Capital", + protocol: "blend", + label: "Fixed Pool", + asset: "USDC", + }, + "meridian-usdc": { + id: "meridian-usdc", + name: "Meridian", + protocol: "meridian", + label: "USDC Vault", + asset: "USDC", + }, + }, + testnet: { + "meridian-usdc": { + id: "meridian-usdc", + name: "Meridian", + protocol: "meridian", + label: "USDC Vault", + asset: "USDC", + }, + }, + }, // api-core now calls this on the deposit/withdraw build path. Without a // stub it resolved to `undefined` and threw, turning five route cases into // 500s instead of their expected statuses. @@ -450,18 +477,30 @@ describe("GET /api/v1/vaults", () => { const res = await app.inject({ method: "GET", url: "/api/v1/vaults" }); expect(res.statusCode).toBe(500); }); + + it("rejects non-GET methods with 405", async () => { + const app = buildApp(); + const res = await app.inject({ method: "POST", url: "/api/v1/vaults" }); + expect(res.statusCode).toBe(405); + expect(res.headers.allow).toBe("GET"); + expect(fetchAllVaults).not.toHaveBeenCalled(); + }); }); -describe("rate limiting on /api/v1/tx/deposit", () => { - it("returns 429 after 10 requests within a minute", async () => { +describe("rate limiting on /api/v1/tx", () => { + it("shares one 100 request budget across tx routes", async () => { const app = Fastify({ logger: false }); - await app.register(rateLimit, { max: 1000, timeWindow: "1 minute" }); + await app.register(rateLimit, { max: 100, timeWindow: "1 minute" }); app.register(txRoute, { prefix: "/api/v1/tx" }); await app.ready(); vi.mocked(buildDepositTx).mockResolvedValue({ xdr: "TXXDR", fee: "100" }); + vi.mocked(buildWithdrawTx).mockResolvedValue({ + xdr: "WITHDRAWXDR", + fee: "100", + }); - const opts = { + const deposit = { method: "POST" as const, url: "/api/v1/tx/deposit", headers: { "content-type": "application/json" }, @@ -472,14 +511,23 @@ describe("rate limiting on /api/v1/tx/deposit", () => { riskAcknowledged: true, }), }; + const withdraw = { + method: "POST" as const, + url: "/api/v1/tx/withdraw", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + walletAddress: WALLET, + vaultId: "blend-usdc-fixed", + shares: "5", + }), + }; - for (let i = 0; i < 10; i++) { - const res = await app.inject(opts); - expect(res.statusCode).toBe(200); + for (let i = 0; i < 50; i++) { + expect((await app.inject(deposit)).statusCode).toBe(200); + expect((await app.inject(withdraw)).statusCode).toBe(200); } - const over = await app.inject(opts); - expect(over.statusCode).toBe(429); + expect((await app.inject(deposit)).statusCode).toBe(429); }); }); @@ -497,7 +545,7 @@ describe("GET /api/v1/vaults/:vaultId", () => { expect(res.json()).toMatchObject({ id: "blend-usdc-fixed" }); }); - it("returns 404 for an unknown vault ID", async () => { + it("returns 404 for an unknown vault ID without fetching", async () => { const app = buildApp(); vi.mocked(fetchAllVaults).mockResolvedValue([] as never); @@ -507,6 +555,7 @@ describe("GET /api/v1/vaults/:vaultId", () => { }); expect(res.statusCode).toBe(404); expect(res.json()).toHaveProperty("error"); + expect(fetchAllVaults).not.toHaveBeenCalled(); }); it("returns 500 when the SDK throws", async () => { @@ -520,4 +569,15 @@ describe("GET /api/v1/vaults/:vaultId", () => { expect(res.statusCode).toBe(500); expect(res.json()).toHaveProperty("error"); }); + + it("rejects non-GET methods with 405", async () => { + const app = buildApp(); + const res = await app.inject({ + method: "POST", + url: "/api/v1/vaults/blend-usdc-fixed", + }); + expect(res.statusCode).toBe(405); + expect(res.headers.allow).toBe("GET"); + expect(fetchAllVaults).not.toHaveBeenCalled(); + }); }); diff --git a/apps/api-local/src/index.ts b/apps/api-local/src/index.ts index 39b47be8..759af7bb 100644 --- a/apps/api-local/src/index.ts +++ b/apps/api-local/src/index.ts @@ -16,7 +16,8 @@ import { txRoute } from "./routes/tx"; import { keepersRoute } from "./routes/keepers"; import { adminRoute } from "./routes/admin"; -// Rate limits: global 100 req/min; /tx/deposit and /tx/withdraw 10 req/min per IP. +// Rate limits: one shared 100 req/min limiter across all routes, matching the +// deployed handlers' shared Upstash sliding window (api/_lib/middleware.ts). // Body limit: 10 KB on all routes to block oversized payload attacks. // Security headers: X-Content-Type-Options and X-Frame-Options on every response. const app = Fastify({ logger: true, bodyLimit: 10 * 1024 }); @@ -27,6 +28,7 @@ const redisClient = process.env.REDIS_URL await app.register(cors, { origin: process.env.ALLOWED_ORIGIN ?? DEFAULT_ALLOWED_ORIGIN, + methods: ["GET", "POST", "OPTIONS"], }); await app.register(rateLimit, { max: 100, diff --git a/apps/api-local/src/routes/admin.ts b/apps/api-local/src/routes/admin.ts index b4df1157..f3dd9791 100644 --- a/apps/api-local/src/routes/admin.ts +++ b/apps/api-local/src/routes/admin.ts @@ -1,5 +1,5 @@ import type { FastifyPluginAsync } from "fastify"; -import { handleGetVaultState } from "@meridian/api-core"; +import { handleGetAdminHistory, handleGetVaultState } from "@meridian/api-core"; export const adminRoute: FastifyPluginAsync = async (app) => { app.get("/vault-state", async (_req, reply) => { @@ -9,4 +9,27 @@ export const adminRoute: FastifyPluginAsync = async (app) => { } return reply.code(result.status).send(result.body); }); + + app.get("/history", async (req, reply) => { + const { vaultId } = req.query as { vaultId?: string }; + if (!vaultId) return reply.code(400).send({ error: "vaultId is required" }); + + const result = await handleGetAdminHistory(vaultId); + if (result.error) { + app.log.error(result.error, "[admin] failed to read admin history"); + } + return reply.code(result.status).send(result.body); + }); + + for (const url of ["/vault-state", "/history"]) { + app.route({ + method: ["POST", "PUT", "PATCH", "DELETE"], + url, + handler: async (_req, reply) => + reply + .code(405) + .header("Allow", "GET") + .send({ error: "method not allowed" }), + }); + } }; diff --git a/apps/api-local/src/routes/tx.ts b/apps/api-local/src/routes/tx.ts index b1beca88..dabcdf20 100644 --- a/apps/api-local/src/routes/tx.ts +++ b/apps/api-local/src/routes/tx.ts @@ -7,29 +7,21 @@ import { } from "@meridian/api-core"; export const txRoute: FastifyPluginAsync = async (app) => { - app.post( - "/deposit", - { config: { rateLimit: { max: 10, timeWindow: "1 minute" } } }, - async (req, reply) => { - const result = await handleDepositRequest(req.body); - if (result.error) { - app.log.error({ err: result.error }, "[tx/deposit] build failed"); - } - reply.code(result.status).send(result.body); + app.post("/deposit", async (req, reply) => { + const result = await handleDepositRequest(req.body); + if (result.error) { + app.log.error({ err: result.error }, "[tx/deposit] build failed"); } - ); + reply.code(result.status).send(result.body); + }); - app.post( - "/withdraw", - { config: { rateLimit: { max: 10, timeWindow: "1 minute" } } }, - async (req, reply) => { - const result = await handleWithdrawRequest(req.body); - if (result.error) { - app.log.error({ err: result.error }, "[tx/withdraw] build failed"); - } - reply.code(result.status).send(result.body); + app.post("/withdraw", async (req, reply) => { + const result = await handleWithdrawRequest(req.body); + if (result.error) { + app.log.error({ err: result.error }, "[tx/withdraw] build failed"); } - ); + reply.code(result.status).send(result.body); + }); app.post("/add-trustline", async (req, reply) => { const result = await handleAddTrustlineRequest(req.body); diff --git a/apps/api-local/src/routes/vaults.ts b/apps/api-local/src/routes/vaults.ts index ad8b12f0..b769c5d8 100644 --- a/apps/api-local/src/routes/vaults.ts +++ b/apps/api-local/src/routes/vaults.ts @@ -1,6 +1,14 @@ import type { FastifyPluginAsync } from "fastify"; +import { KNOWN_POOLS } from "@meridian/stellar-sdk-helpers"; import { handleGetVaults, handleGetVaultById } from "@meridian/api-core"; +const KNOWN_VAULT_IDS = new Set( + [ + ...Object.values(KNOWN_POOLS.mainnet), + ...Object.values(KNOWN_POOLS.testnet), + ].map((pool) => pool.id) +); + export const vaultsRoute: FastifyPluginAsync = async (app) => { app.get("/", async (_req, reply) => { const result = await handleGetVaults(); @@ -12,10 +20,32 @@ export const vaultsRoute: FastifyPluginAsync = async (app) => { app.get("/:vaultId", async (req, reply) => { const { vaultId } = req.params as { vaultId: string }; + if (!KNOWN_VAULT_IDS.has(vaultId)) + return reply.code(404).send({ error: "vault not found", vaultId }); const result = await handleGetVaultById(vaultId); if (result.error) { app.log.error(result.error, "[vaults] failed to fetch vault by id"); } return reply.code(result.status).send(result.body); }); + + app.route({ + method: ["POST", "PUT", "PATCH", "DELETE"], + url: "/", + handler: async (_req, reply) => + reply + .code(405) + .header("Allow", "GET") + .send({ error: "method not allowed" }), + }); + + app.route({ + method: ["POST", "PUT", "PATCH", "DELETE"], + url: "/:vaultId", + handler: async (_req, reply) => + reply + .code(405) + .header("Allow", "GET") + .send({ error: "method not allowed" }), + }); };