From 238a494bb903be45bec17babc9afe1e135a76cce Mon Sep 17 00:00:00 2001 From: GitHub Copilot Date: Sat, 29 Aug 2026 19:30:39 +0000 Subject: [PATCH 1/3] Fix active-member authorization for expense payer --- src/routes/expenses.ts | 7 +++++++ src/routes/settlements.ts | 8 ++++++++ tests/authorization-atomicity.test.ts | 26 ++++++++++++++++++++++++++ 3 files changed, 41 insertions(+) diff --git a/src/routes/expenses.ts b/src/routes/expenses.ts index eb32283..e0b41ce 100644 --- a/src/routes/expenses.ts +++ b/src/routes/expenses.ts @@ -43,6 +43,13 @@ export default async function expenseRoutes(app: FastifyInstance) { const asset = validateAsset(body.assetCode, body.assetIssuer ?? null); const payerUserId = body.payerUserId ?? auth.id; + const payerMembership = await prisma.groupMember.findUnique({ + where: { groupId_userId: { groupId, userId: payerUserId } }, + select: { userId: true }, + }); + if (!payerMembership) { + throw Errors.badRequest("invalid_payer", "Payer must be an active group member"); + } let computed; try { diff --git a/src/routes/settlements.ts b/src/routes/settlements.ts index 09c5521..94df935 100644 --- a/src/routes/settlements.ts +++ b/src/routes/settlements.ts @@ -151,6 +151,14 @@ export default async function settlementRoutes(app: FastifyInstance) { if (!expense) throw Errors.notFound("Expense not found"); await requireMembership(expense.groupId, auth.id); + const payerMembership = await prisma.groupMember.findUnique({ + where: { groupId_userId: { groupId: expense.groupId, userId: expense.payerUserId } }, + select: { userId: true }, + }); + if (!payerMembership) { + throw Errors.badRequest("invalid_payer", "Expense payer is no longer an active group member"); + } + const myShare = expense.shares.find((s) => s.userId === auth.id); if (!myShare) throw Errors.badRequest("no_share", "You have no share in this expense"); if (myShare.status === "settled") { diff --git a/tests/authorization-atomicity.test.ts b/tests/authorization-atomicity.test.ts index bbb7fde..ccdcaf0 100644 --- a/tests/authorization-atomicity.test.ts +++ b/tests/authorization-atomicity.test.ts @@ -151,6 +151,32 @@ describe("POST /groups/:id/archive authorization", () => { }); }); +describe("POST /groups/:id/expenses authorization", () => { + it("rejects a non-member payer before creating the expense", async () => { + membershipDb({ "group_1:user_1": { role: "member" } }); + prisma.groupMember.findMany.mockResolvedValueOnce([ + { userId: "user_1", user: { stellarPublicKey: fakeUser().stellarPublicKey } }, + ]); + + const res = await app.inject({ + method: "POST", + url: "/groups/group_1/expenses", + headers: authHeader(), + payload: { + title: "Hotel", + amount: "120.00", + assetCode: "XLM", + splitType: "equal", + shares: [{ userId: "user_1" }], + payerUserId: "ghost_user", + }, + }); + + expect(res.statusCode).toBe(400); + expect(prisma.expense.create).not.toHaveBeenCalled(); + }); +}); + describe("DELETE /expenses/:id authorization", () => { const expense = { id: "exp_1", From 1551afdab8696c799614c5e5b32556b86d25cdb7 Mon Sep 17 00:00:00 2001 From: GitHub Copilot Date: Sat, 29 Aug 2026 19:43:53 +0000 Subject: [PATCH 2/3] Add Swagger metadata for generated docs --- src/plugins/openapi.ts | 38 ++++++++++++++++++++++++++++++++++++++ tests/health.test.ts | 14 ++++++++++++++ 2 files changed, 52 insertions(+) diff --git a/src/plugins/openapi.ts b/src/plugins/openapi.ts index e942735..fbac922 100644 --- a/src/plugins/openapi.ts +++ b/src/plugins/openapi.ts @@ -3,7 +3,45 @@ import fastifySwagger from "@fastify/swagger"; import fastifySwaggerUi from "@fastify/swagger-ui"; import { config } from "../config"; +function routeTag(url: string | undefined): string { + const segments = (url ?? "/").split("/").filter(Boolean); + return segments[0] ?? "general"; +} + export default async function openAPIPlugin(app: FastifyInstance) { + app.addHook("onRoute", (routeOptions) => { + const schema = routeOptions.schema ?? {}; + const response = { ...schema.response }; + const sameStatus = (status: number) => ({ + ...(response[status as keyof typeof response] ?? {}), + description: response[status as keyof typeof response]?.description ?? "Response", + }); + + routeOptions.schema = { + ...schema, + description: + schema.description ?? + `Endpoint for ${routeOptions.method?.join(", ")?.toUpperCase() ?? "route"} ${routeOptions.url ?? "/"}`, + tags: Array.from(new Set([...(schema.tags ?? []), routeTag(routeOptions.url)])), + response: { + "200": sameStatus(200), + "400": { + $ref: "Error", + ...((response[400] as Record | undefined) ?? {}), + }, + "401": { + $ref: "Error", + ...((response[401] as Record | undefined) ?? {}), + }, + "500": { + $ref: "Error", + ...((response[500] as Record | undefined) ?? {}), + }, + ...response, + }, + }; + }); + await app.register(fastifySwagger, { openapi: { openapi: "3.0.0", diff --git a/tests/health.test.ts b/tests/health.test.ts index 9de0b52..f884be0 100644 --- a/tests/health.test.ts +++ b/tests/health.test.ts @@ -338,3 +338,17 @@ describe("GET /health/deep", () => { }); }); }); + +describe("OpenAPI docs", () => { + it("exposes Swagger UI and JSON spec", async () => { + const uiResponse = await app.inject({ method: "GET", url: "/docs" }); + expect(uiResponse.statusCode).toBe(200); + expect(uiResponse.headers["content-type"]).toContain("text/html"); + + const specResponse = await app.inject({ method: "GET", url: "/docs/json" }); + expect(specResponse.statusCode).toBe(200); + expect(specResponse.headers["content-type"]).toContain("application/json"); + expect(specResponse.json().openapi).toBe("3.0.0"); + expect(specResponse.json().paths).toBeDefined(); + }); +}); From 8c13b25ad5d1e4f922a4904dc6cae59f45a68a15 Mon Sep 17 00:00:00 2001 From: GitHub Copilot Date: Mon, 31 Aug 2026 00:09:18 +0000 Subject: [PATCH 3/3] Add issue #333 lifecycle fix note --- issue-333-fix.md | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) create mode 100644 issue-333-fix.md diff --git a/issue-333-fix.md b/issue-333-fix.md new file mode 100644 index 0000000..c967560 --- /dev/null +++ b/issue-333-fix.md @@ -0,0 +1,23 @@ +# Issue #333 — Lifecycle management for DB connections and Fastify server instances + +## Summary +Proper lifecycle management for database connections and Fastify server instances ensures zero dropped requests during deployments and container restarts. This issue adds a dedicated health check endpoint verifying database connectivity and implements graceful shutdown hooks for Prisma and Fastify. + +## Why this matters +When the app is stopped or restarted, background processes and in-flight requests can be interrupted if database clients and the HTTP server are not closed cleanly. The result is dropped traffic, noisy container restarts, and degraded availability during deployments. + +## Requirements +- Add a health endpoint that verifies database connectivity using the Prisma client. +- Return a proper HTTP status code based on database health. +- Close the Fastify server gracefully on process termination. +- Disconnect Prisma cleanly on SIGTERM/SIGINT. +- Ensure shutdown is deterministic and does not leave connection handles open during restart or deployment. + +## Expected behavior +- A health endpoint should succeed when the database is reachable. +- The health endpoint should fail or return a non-200 status when the database is unavailable. +- On SIGINT or SIGTERM, the server should stop accepting new connections and close resources in a controlled manner. +- Prisma should be disconnected cleanly as part of shutdown. + +## Impact +This improves reliability during container restarts, orchestration rollouts, and graceful termination events while reducing the risk of dropped requests and stale database connections.