diff --git a/src/plugins/error-handler.ts b/src/plugins/error-handler.ts index cdcdbe4..bee096c 100644 --- a/src/plugins/error-handler.ts +++ b/src/plugins/error-handler.ts @@ -4,6 +4,35 @@ import { ZodError } from "zod"; import { AppError } from "../lib/errors"; import { toRequestLimitError } from "../lib/request-limits"; +function isHorizonError(error: unknown): error is Error & { + response?: { status?: number }; + status?: number; + statusCode?: number; + operation?: string; + code?: string; +} { + if (!error || typeof error !== "object") return false; + const candidate = error as Record; + const responseStatus = typeof candidate.response === "object" && candidate.response + ? (candidate.response as { status?: number }).status + : undefined; + const status = typeof candidate.status === "number" ? candidate.status : undefined; + const code = typeof candidate.code === "string" ? candidate.code : undefined; + const operation = typeof candidate.operation === "string" ? candidate.operation : undefined; + const name = typeof candidate.name === "string" ? candidate.name : undefined; + + return ( + typeof responseStatus === "number" || + typeof status === "number" || + typeof code === "string" || + typeof operation === "string" || + name === "TimeoutError" || + name === "TransportError" || + name === "BadRequestError" || + name === "NotFoundError" + ); +} + export default fp(async function errorHandlerPlugin(app: FastifyInstance) { app.setErrorHandler((err: Error, req: FastifyRequest, reply: FastifyReply) => { const requestId = req.id as string; @@ -54,6 +83,50 @@ export default fp(async function errorHandlerPlugin(app: FastifyInstance) { }); } + const upstreamStatus = + (err as Record).response && typeof (err as any).response === "object" + ? (err as any).response.status + : (err as any).statusCode ?? (err as any).status; + + if (isHorizonError(err) && typeof upstreamStatus === "number") { + const operation = (err as any).operation ?? "Horizon request"; + req.log.warn( + { + requestId, + operation, + statusCode: upstreamStatus, + errorCode: (err as any).code ?? "UPSTREAM_ERROR", + err, + }, + "Horizon upstream failure" + ); + + if (upstreamStatus === 429) { + return reply.code(429).send({ + code: "RATE_LIMITED", + error: "RATE_LIMITED", + message: "Horizon is rate limiting requests. Please retry shortly.", + requestId, + }); + } + + if (upstreamStatus >= 500 || upstreamStatus === 408) { + return reply.code(502).send({ + code: "UPSTREAM_ERROR", + error: "UPSTREAM_ERROR", + message: `${operation} is temporarily unavailable. Please retry shortly.`, + requestId, + }); + } + + return reply.code(502).send({ + code: "UPSTREAM_ERROR", + error: "UPSTREAM_ERROR", + message: `${operation} failed while contacting the Stellar network.`, + requestId, + }); + } + if ((err as any).statusCode === 429) { return reply.code(429).send({ code: "RATE_LIMITED", diff --git a/src/plugins/openapi.ts b/src/plugins/openapi.ts index e942735..b9caf97 100644 --- a/src/plugins/openapi.ts +++ b/src/plugins/openapi.ts @@ -1,9 +1,10 @@ import { FastifyInstance } from "fastify"; +import fp from "fastify-plugin"; import fastifySwagger from "@fastify/swagger"; import fastifySwaggerUi from "@fastify/swagger-ui"; import { config } from "../config"; -export default async function openAPIPlugin(app: FastifyInstance) { +export default fp(async function openAPIPlugin(app: FastifyInstance) { await app.register(fastifySwagger, { openapi: { openapi: "3.0.0", @@ -117,4 +118,4 @@ export default async function openAPIPlugin(app: FastifyInstance) { deepLinking: false, }, }); -} +}, { name: "openapi-plugin" }); diff --git a/src/routes/expenses.ts b/src/routes/expenses.ts index eb32283..ef3c8ff 100644 --- a/src/routes/expenses.ts +++ b/src/routes/expenses.ts @@ -32,197 +32,423 @@ const expenseInclude = { export default async function expenseRoutes(app: FastifyInstance) { app.addHook("preHandler", app.authenticate); - // -- create ----------------------------------------------------------------- - app.post("/groups/:id/expenses", async (req) => { - const auth = requireUser(req); - const { id: groupId } = idParamSchema.parse(req.params); - await requireMembership(groupId, auth.id); - - const body = createExpenseSchema.parse(req.body); - validateAmount(body.amount); - const asset = validateAsset(body.assetCode, body.assetIssuer ?? null); - - const payerUserId = body.payerUserId ?? auth.id; - - let computed; - try { - computed = computeShares(body.amount, body.splitType as SplitType, body.shares); - } catch (e: any) { - throw Errors.badRequest("invalid_split", e?.message ?? "Invalid split"); - } + app.post( + "/groups/:id/expenses", + { + schema: { + tags: ["expenses"], + summary: "Create an expense", + description: "Create a group expense with a payer, split configuration, and asset metadata, and calculate the resulting share allocations.", + params: { + type: "object", + required: ["id"], + properties: { id: { type: "string", minLength: 1, maxLength: 64 } }, + additionalProperties: false, + }, + body: { + type: "object", + required: ["title", "amount", "assetCode", "splitType", "shares"], + properties: { + title: { type: "string", minLength: 1, maxLength: 80 }, + description: { type: ["string", "null"], maxLength: 500 }, + amount: { type: "string" }, + assetCode: { type: "string", minLength: 1 }, + assetIssuer: { type: ["string", "null"] }, + splitType: { type: "string", enum: ["equal", "exact", "percent", "shares"] }, + payerUserId: { type: ["string", "null"] }, + memo: { type: ["string", "null"], maxLength: 24 }, + receiptUrl: { type: ["string", "null"] }, + shares: { + type: "array", + items: { + type: "object", + required: ["userId"], + properties: { + userId: { type: "string" }, + shareAmount: { type: ["string", "number", "null"] }, + percent: { type: ["number", "null"] }, + }, + }, + }, + }, + additionalProperties: false, + }, + response: { + 200: { + type: "object", + properties: { + expense: { + type: "object", + properties: { + id: { type: "string" }, + groupId: { type: "string" }, + payerUserId: { type: "string" }, + title: { type: "string" }, + description: { type: ["string", "null"] }, + amount: { type: "string" }, + assetCode: { type: "string" }, + assetIssuer: { type: ["string", "null"] }, + splitType: { type: "string" }, + memo: { type: ["string", "null"] }, + receiptUrl: { type: ["string", "null"] }, + createdAt: { type: "string", format: "date-time" }, + }, + }, + }, + }, + }, + }, + }, + async (req) => { + const auth = requireUser(req); + const { id: groupId } = idParamSchema.parse(req.params); + await requireMembership(groupId, auth.id); - const participantIds = [...new Set(computed.map((share) => share.userId))]; - const members = await prisma.groupMember.findMany({ - where: { groupId, userId: { in: participantIds } }, - select: { userId: true, user: { select: { stellarPublicKey: true } } }, - }); - if (members.length !== participantIds.length) { - throw Errors.badRequest("invalid_split", "Every split participant must be an active group member"); - } + const body = createExpenseSchema.parse(req.body); + validateAmount(body.amount); + const asset = validateAsset(body.assetCode, body.assetIssuer ?? null); - // A non-native asset can only be paid to an account that has trusted it. - // Without this check the expense is created happily and every settlement - // built from it fails on submission with op_no_trust — after members have - // been asked to pay, which is the most expensive point to discover it. - // - // Native XLM needs no trustline, so it skips the Horizon round trip - // entirely rather than paying for a lookup whose answer is always yes. - if (asset.type !== "native") { - await assertParticipantsCanHoldAsset({ - participants: members.map((member) => ({ - userId: member.userId, - stellarPublicKey: member.user.stellarPublicKey, - })), - assetCode: body.assetCode, - assetIssuer: body.assetIssuer ?? null, - }); - } + const payerUserId = body.payerUserId ?? auth.id; - const memo = body.memo?.trim() || shortCode().slice(0, 8); + let computed; + try { + computed = computeShares(body.amount, body.splitType as SplitType, body.shares); + } catch (e: any) { + throw Errors.badRequest("invalid_split", e?.message ?? "Invalid split"); + } - const expense = await prisma.$transaction(async (tx) => { - const created = await tx.expense.create({ - data: { - groupId, - payerUserId, - title: body.title, - description: body.description, - amount: body.amount, + const participantIds = [...new Set(computed.map((share) => share.userId))]; + const members = await prisma.groupMember.findMany({ + where: { groupId, userId: { in: participantIds } }, + select: { userId: true, user: { select: { stellarPublicKey: true } } }, + }); + if (members.length !== participantIds.length) { + throw Errors.badRequest("invalid_split", "Every split participant must be an active group member"); + } + + if (asset.type !== "native") { + await assertParticipantsCanHoldAsset({ + participants: members.map((member) => ({ + userId: member.userId, + stellarPublicKey: member.user.stellarPublicKey, + })), assetCode: body.assetCode, assetIssuer: body.assetIssuer ?? null, - splitType: body.splitType, - memo, - receiptUrl: body.receiptUrl ?? null, - shares: { - create: computed.map((c) => ({ - userId: c.userId, - shareAmount: c.shareAmount, - status: c.userId === payerUserId ? "settled" : "pending", - })), + }); + } + + const memo = body.memo?.trim() || shortCode().slice(0, 8); + + const expense = await prisma.$transaction(async (tx) => { + const created = await tx.expense.create({ + data: { + groupId, + payerUserId, + title: body.title, + description: body.description, + amount: body.amount, + assetCode: body.assetCode, + assetIssuer: body.assetIssuer ?? null, + splitType: body.splitType, + memo, + receiptUrl: body.receiptUrl ?? null, + shares: { + create: computed.map((c) => ({ + userId: c.userId, + shareAmount: c.shareAmount, + status: c.userId === payerUserId ? "settled" : "pending", + })), + }, }, - }, - include: expenseInclude, - }); + include: expenseInclude, + }); + + await auditTx(tx, { + userId: auth.id, + groupId, + action: "expense.create", + entityType: "expense", + entityId: created.id, + metadata: { amount: body.amount, assetCode: body.assetCode }, + }); - await auditTx(tx, { - userId: auth.id, - groupId, - action: "expense.create", - entityType: "expense", - entityId: created.id, - metadata: { amount: body.amount, assetCode: body.assetCode }, + return created; }); - return created; - }); - - return { expense: serializeExpense(expense) }; - }); - - // -- list ------------------------------------------------------------------- - app.get("/groups/:id/expenses", async (req) => { - const auth = requireUser(req); - const { id: groupId } = idParamSchema.parse(req.params); - const query = expenseListQuerySchema.parse(req.query ?? {}); - // Membership is checked before any row is read, and the `groupId` filter - // the service applies is what scopes the page — never the cursor. - await requireMembership(groupId, auth.id); - - const { items, meta } = await listGroupExpenses(groupId, query, expenseInclude); - - return { expenses: items.map(serializeExpense), meta }; - }); - - // -- get one ---------------------------------------------------------------- - app.get("/expenses/:id", async (req) => { - const auth = requireUser(req); - const { id } = idParamSchema.parse(req.params); - const expense = await prisma.expense.findUnique({ - where: { id }, - include: expenseInclude, - }); - if (!expense) throw Errors.notFound("Expense not found"); - await requireMembership(expense.groupId, auth.id); - return { expense: serializeExpense(expense) }; - }); - - // -- update (metadata only) ------------------------------------------------- - app.patch("/expenses/:id", async (req) => { - const auth = requireUser(req); - const { id } = z.object({ id: z.string() }).parse(req.params); - const body = updateExpenseSchema.parse(req.body); - - // The membership/role check and the update run in one transaction: a - // concurrent removal or demotion of `auth.id` between the check and the - // write cannot slip an unauthorized edit through. - const updated = await prisma.$transaction(async (tx) => { - const expense = await tx.expense.findUnique({ where: { id } }); - if (!expense) throw Errors.notFound("Expense not found"); - const ctx = await requireMembership(expense.groupId, auth.id, tx); - if (expense.payerUserId !== auth.id && ctx.role !== "admin") { - throw Errors.forbidden("Only the payer or an admin can edit this expense"); - } + return { expense: serializeExpense(expense) }; + } + ); - const result = await tx.expense.update({ - where: { id }, - data: { - ...(body.title !== undefined && { title: body.title }), - ...(body.description !== undefined && { description: body.description }), - ...(body.memo !== undefined && { memo: body.memo }), - ...(body.receiptUrl !== undefined && { receiptUrl: body.receiptUrl }), + app.get( + "/groups/:id/expenses", + { + schema: { + tags: ["expenses"], + summary: "List group expenses", + description: "Return the paginated, filterable list of expenses for a group, including optional totals and status filtering.", + params: { + type: "object", + required: ["id"], + properties: { id: { type: "string", minLength: 1, maxLength: 64 } }, + additionalProperties: false, + }, + querystring: { + type: "object", + properties: { + cursor: { type: "string" }, + limit: { type: "integer", minimum: 1, maximum: 50 }, + order: { type: "string", enum: ["asc", "desc"] }, + asset: { type: ["string", "null"] }, + status: { type: ["string", "null"], enum: ["SETTLED", "PENDING", "OVERDUE"] }, + startDate: { type: ["string", "null"], format: "date-time" }, + endDate: { type: ["string", "null"], format: "date-time" }, + includeTotal: { type: ["boolean", "string", "null"] }, + }, + additionalProperties: true, + }, + response: { + 200: { + type: "object", + properties: { + expenses: { + type: "array", + items: { + type: "object", + properties: { + id: { type: "string" }, + groupId: { type: "string" }, + payerUserId: { type: "string" }, + title: { type: "string" }, + description: { type: ["string", "null"] }, + amount: { type: "string" }, + assetCode: { type: "string" }, + assetIssuer: { type: ["string", "null"] }, + splitType: { type: "string" }, + memo: { type: ["string", "null"] }, + receiptUrl: { type: ["string", "null"] }, + createdAt: { type: "string", format: "date-time" }, + }, + }, + }, + meta: { + type: "object", + properties: { + nextCursor: { type: ["string", "null"] }, + hasMore: { type: "boolean" }, + total: { type: ["integer", "null"] }, + }, + }, + }, + }, + }, + }, + }, + async (req) => { + const auth = requireUser(req); + const { id: groupId } = idParamSchema.parse(req.params); + const query = expenseListQuerySchema.parse(req.query ?? {}); + + await requireMembership(groupId, auth.id); + + const { items, meta } = await listGroupExpenses(groupId, query, expenseInclude); + return { expenses: items.map(serializeExpense), meta }; + } + ); + + app.get( + "/expenses/:id", + { + schema: { + tags: ["expenses"], + summary: "Get an expense by id", + description: "Fetch one expense and its participant shares after verifying the caller is a group member.", + params: { + type: "object", + required: ["id"], + properties: { id: { type: "string", minLength: 1, maxLength: 64 } }, + additionalProperties: false, + }, + response: { + 200: { + type: "object", + properties: { + expense: { + type: "object", + properties: { + id: { type: "string" }, + groupId: { type: "string" }, + payerUserId: { type: "string" }, + title: { type: "string" }, + description: { type: ["string", "null"] }, + amount: { type: "string" }, + assetCode: { type: "string" }, + assetIssuer: { type: ["string", "null"] }, + splitType: { type: "string" }, + memo: { type: ["string", "null"] }, + receiptUrl: { type: ["string", "null"] }, + createdAt: { type: "string", format: "date-time" }, + }, + }, + }, + }, }, + }, + }, + async (req) => { + const auth = requireUser(req); + const { id } = idParamSchema.parse(req.params); + const expense = await prisma.expense.findUnique({ + where: { id }, include: expenseInclude, }); + if (!expense) throw Errors.notFound("Expense not found"); + await requireMembership(expense.groupId, auth.id); + return { expense: serializeExpense(expense) }; + } + ); - await auditTx(tx, { - userId: auth.id, - groupId: expense.groupId, - action: "expense.update", - entityType: "expense", - entityId: id, - }); + app.patch( + "/expenses/:id", + { + schema: { + tags: ["expenses"], + summary: "Update expense metadata", + description: "Update editable expense fields such as title, description, memo, or receipt URL, restricted to the payer or an admin.", + params: { + type: "object", + required: ["id"], + properties: { id: { type: "string", minLength: 1, maxLength: 64 } }, + additionalProperties: false, + }, + body: { + type: "object", + properties: { + title: { type: "string", minLength: 1, maxLength: 80 }, + description: { type: ["string", "null"], maxLength: 500 }, + memo: { type: ["string", "null"], maxLength: 24 }, + receiptUrl: { type: ["string", "null"] }, + }, + additionalProperties: false, + }, + response: { + 200: { + type: "object", + properties: { + expense: { + type: "object", + properties: { + id: { type: "string" }, + groupId: { type: "string" }, + payerUserId: { type: "string" }, + title: { type: "string" }, + description: { type: ["string", "null"] }, + amount: { type: "string" }, + assetCode: { type: "string" }, + assetIssuer: { type: ["string", "null"] }, + splitType: { type: "string" }, + memo: { type: ["string", "null"] }, + receiptUrl: { type: ["string", "null"] }, + createdAt: { type: "string", format: "date-time" }, + }, + }, + }, + }, + }, + }, + }, + async (req) => { + const auth = requireUser(req); + const { id } = z.object({ id: z.string() }).parse(req.params); + const body = updateExpenseSchema.parse(req.body); - return result; - }); - return { expense: serializeExpense(updated) }; - }); + const updated = await prisma.$transaction(async (tx) => { + const expense = await tx.expense.findUnique({ where: { id } }); + if (!expense) throw Errors.notFound("Expense not found"); + const ctx = await requireMembership(expense.groupId, auth.id, tx); + if (expense.payerUserId !== auth.id && ctx.role !== "admin") { + throw Errors.forbidden("Only the payer or an admin can edit this expense"); + } - // -- delete ----------------------------------------------------------------- - app.delete("/expenses/:id", async (req) => { - const auth = requireUser(req); - const { id } = idParamSchema.parse(req.params); + const result = await tx.expense.update({ + where: { id }, + data: { + ...(body.title !== undefined && { title: body.title }), + ...(body.description !== undefined && { description: body.description }), + ...(body.memo !== undefined && { memo: body.memo }), + ...(body.receiptUrl !== undefined && { receiptUrl: body.receiptUrl }), + }, + include: expenseInclude, + }); - // Same atomicity concern as the update route above: check and delete - // happen in one transaction. - await prisma.$transaction(async (tx) => { - const found = await tx.expense.findUnique({ - where: { id }, - include: { shares: true }, + await auditTx(tx, { + userId: auth.id, + groupId: expense.groupId, + action: "expense.update", + entityType: "expense", + entityId: id, + }); + + return result; }); - if (!found) throw Errors.notFound("Expense not found"); - const ctx = await requireMembership(found.groupId, auth.id, tx); - if (found.payerUserId !== auth.id && ctx.role !== "admin") { - throw Errors.forbidden("Only the payer or an admin can delete this expense"); - } - const hasSettled = found.shares.some( - (s) => s.status === "settled" && s.userId !== found.payerUserId - ); - if (hasSettled) { - throw Errors.conflict( - "expense_settled", - "Cannot delete an expense that already has settled shares" + return { expense: serializeExpense(updated) }; + } + ); + + app.delete( + "/expenses/:id", + { + schema: { + tags: ["expenses"], + summary: "Delete an expense", + description: "Delete an expense after confirming the caller has rights and the expense has no settled shares that would make deletion unsafe.", + params: { + type: "object", + required: ["id"], + properties: { id: { type: "string", minLength: 1, maxLength: 64 } }, + additionalProperties: false, + }, + response: { + 200: { + type: "object", + properties: { ok: { type: "boolean" } }, + }, + }, + }, + }, + async (req) => { + const auth = requireUser(req); + const { id } = idParamSchema.parse(req.params); + + await prisma.$transaction(async (tx) => { + const found = await tx.expense.findUnique({ + where: { id }, + include: { shares: true }, + }); + if (!found) throw Errors.notFound("Expense not found"); + const ctx = await requireMembership(found.groupId, auth.id, tx); + if (found.payerUserId !== auth.id && ctx.role !== "admin") { + throw Errors.forbidden("Only the payer or an admin can delete this expense"); + } + const hasSettled = found.shares.some( + (s) => s.status === "settled" && s.userId !== found.payerUserId ); - } + if (hasSettled) { + throw Errors.conflict( + "expense_settled", + "Cannot delete an expense that already has settled shares" + ); + } - await tx.expense.delete({ where: { id } }); - await auditTx(tx, { - userId: auth.id, - groupId: found.groupId, - action: "expense.delete", - entityType: "expense", - entityId: id, + await tx.expense.delete({ where: { id } }); + await auditTx(tx, { + userId: auth.id, + groupId: found.groupId, + action: "expense.delete", + entityType: "expense", + entityId: id, + }); }); - }); - return { ok: true }; - }); + return { ok: true }; + } + ); } diff --git a/src/routes/groups.ts b/src/routes/groups.ts index e850136..16a8e88 100644 --- a/src/routes/groups.ts +++ b/src/routes/groups.ts @@ -40,14 +40,54 @@ export default async function groupRoutes(app: FastifyInstance) { app.addHook("preHandler", app.authenticate); // -- create ----------------------------------------------------------------- - app.post("/groups", { config: { rateLimit: { max: config.RATE_LIMIT_GROUP, timeWindow: "1 minute" } } }, async (req) => { - const auth = requireUser(req); - const body = z - .object({ - name: z.string().min(1).max(60), - description: z.string().max(280).optional(), - }) - .parse(req.body); + app.post( + "/groups", + { + config: { rateLimit: { max: config.RATE_LIMIT_GROUP, timeWindow: "1 minute" } }, + schema: { + tags: ["groups"], + summary: "Create a group", + description: "Create a new group and add the authenticated user as its first admin.", + body: { + type: "object", + required: ["name"], + properties: { + name: { type: "string", minLength: 1, maxLength: 60 }, + description: { type: ["string", "null"], maxLength: 280 }, + }, + additionalProperties: false, + }, + response: { + 200: { + type: "object", + properties: { + group: { + type: "object", + properties: { + id: { type: "string" }, + name: { type: "string" }, + description: { type: ["string", "null"] }, + createdByUserId: { type: "string" }, + treasuryEnabled: { type: "boolean" }, + treasuryAccountPublicKey: { type: ["string", "null"] }, + treasuryRequiredSigners: { type: ["integer", "null"] }, + archived: { type: "boolean" }, + createdAt: { type: "string", format: "date-time" }, + }, + }, + }, + }, + }, + }, + }, + async (req) => { + const auth = requireUser(req); + const body = z + .object({ + name: z.string().min(1).max(60), + description: z.string().max(280).optional(), + }) + .parse(req.body); const group = await prisma.$transaction(async (tx) => { const created = await tx.group.create({ @@ -76,10 +116,46 @@ export default async function groupRoutes(app: FastifyInstance) { // list would scale that work with a user's group count. Membership rows are // ordered by `joinedAt`, which is this resource's creation timestamp, so the // shared cursor helpers are given that field as `createdAt`. - app.get("/groups", async (req) => { - const auth = requireUser(req); - const { cursor, limit, order } = paginationQuerySchema.parse(req.query ?? {}); - const position = requireCursor(cursor); + app.get( + "/groups", + { + schema: { + tags: ["groups"], + summary: "List groups for the current user", + description: "Return the paginated list of groups the authenticated user belongs to, including each group's summary metadata and net position.", + querystring: { + type: "object", + properties: { + cursor: { type: "string" }, + limit: { type: "integer", minimum: 1, maximum: 50 }, + order: { type: "string", enum: ["asc", "desc"] }, + }, + additionalProperties: true, + }, + response: { + 200: { + type: "object", + properties: { + groups: { + type: "array", + items: { type: "object", additionalProperties: true }, + }, + meta: { + type: "object", + properties: { + nextCursor: { type: ["string", "null"] }, + hasMore: { type: "boolean" }, + }, + }, + }, + }, + }, + }, + }, + async (req) => { + const auth = requireUser(req); + const { cursor, limit, order } = paginationQuerySchema.parse(req.query ?? {}); + const position = requireCursor(cursor); const cursorScope = position ? { @@ -125,10 +201,43 @@ export default async function groupRoutes(app: FastifyInstance) { }); // -- on-chain balance ------------------------------------------------------- - app.get("/groups/:id/balance", async (req) => { - const auth = requireUser(req); - const { id } = z.object({ id: z.string().min(1).max(64) }).parse(req.params); - await requireMembership(id, auth.id); + app.get( + "/groups/:id/balance", + { + schema: { + tags: ["groups"], + summary: "Get treasury balances for a group", + description: "Fetch the current on-chain balances for a group's treasury account, filtered to supported assets.", + params: { + type: "object", + required: ["id"], + properties: { id: { type: "string", minLength: 1, maxLength: 64 } }, + additionalProperties: false, + }, + response: { + 200: { + type: "object", + properties: { + balances: { + type: "array", + items: { + type: "object", + required: ["asset", "balance"], + properties: { + asset: { type: "string", enum: ["XLM", "USDC"] }, + balance: { type: "string" }, + }, + }, + }, + }, + }, + }, + }, + }, + async (req) => { + const auth = requireUser(req); + const { id } = z.object({ id: z.string().min(1).max(64) }).parse(req.params); + await requireMembership(id, auth.id); const cached = groupBalanceCache.get(id); if (cached && cached.expiresAt > Date.now()) { @@ -168,11 +277,67 @@ export default async function groupRoutes(app: FastifyInstance) { }); // -- detail ----------------------------------------------------------------- - app.get("/groups/:id", async (req) => { - const auth = requireUser(req); - const { id } = z.object({ id: z.string() }).parse(req.params); - const { cursor, limit } = paginationQuerySchema.parse(req.query ?? {}); - const ctx = await requireMembership(id, auth.id); + app.get( + "/groups/:id", + { + schema: { + tags: ["groups"], + summary: "Get a group and its members", + description: "Retrieve a group's details, membership list, and the current user's role within it.", + params: { + type: "object", + required: ["id"], + properties: { id: { type: "string", minLength: 1 } }, + additionalProperties: false, + }, + querystring: { + type: "object", + properties: { + cursor: { type: "string" }, + limit: { type: "integer", minimum: 1, maximum: 50 }, + }, + additionalProperties: true, + }, + response: { + 200: { + type: "object", + properties: { + group: { + type: "object", + properties: { + id: { type: "string" }, + name: { type: "string" }, + description: { type: ["string", "null"] }, + createdByUserId: { type: "string" }, + treasuryEnabled: { type: "boolean" }, + treasuryAccountPublicKey: { type: ["string", "null"] }, + treasuryRequiredSigners: { type: ["integer", "null"] }, + archived: { type: "boolean" }, + createdAt: { type: "string", format: "date-time" }, + }, + }, + members: { + type: "array", + items: { type: "object", additionalProperties: true }, + }, + yourRole: { type: "string", enum: ["admin", "member"] }, + meta: { + type: "object", + properties: { + nextCursor: { type: ["string", "null"] }, + hasMore: { type: "boolean" }, + }, + }, + }, + }, + }, + }, + }, + async (req) => { + const auth = requireUser(req); + const { id } = z.object({ id: z.string() }).parse(req.params); + const { cursor, limit } = paginationQuerySchema.parse(req.query ?? {}); + const ctx = await requireMembership(id, auth.id); const group = await prisma.group.findUnique({ where: { id } }); if (!group) throw Errors.notFound("Group not found"); @@ -221,9 +386,39 @@ export default async function groupRoutes(app: FastifyInstance) { }); // -- invite (by public key or invite code) --------------------------------- - app.post("/groups/:id/invite", async (req, reply) => { - const auth = requireUser(req); - const { id } = z.object({ id: z.string() }).parse(req.params); + app.post( + "/groups/:id/invite", + { + schema: { + tags: ["groups"], + summary: "Invite a member by public key or invite code", + description: "Create a pending group invitation, either for a Stellar public key or for a legacy code-based invite.", + params: { + type: "object", + required: ["id"], + properties: { id: { type: "string", minLength: 1 } }, + additionalProperties: false, + }, + response: { + 200: { + type: "object", + properties: { + invitation: { type: "object", additionalProperties: true }, + invite: { type: "object", additionalProperties: true }, + }, + }, + 201: { + type: "object", + properties: { + invitation: { type: "object", additionalProperties: true }, + }, + }, + }, + }, + }, + async (req, reply) => { + const auth = requireUser(req); + const { id } = z.object({ id: z.string() }).parse(req.params); // Direct invitation by Stellar public key if ( @@ -335,9 +530,45 @@ export default async function groupRoutes(app: FastifyInstance) { }); // -- join ------------------------------------------------------------------- - app.post("/groups/join", async (req) => { - const auth = requireUser(req); - const body = z.object({ code: z.string().min(1) }).parse(req.body); + app.post( + "/groups/join", + { + schema: { + tags: ["groups"], + summary: "Join a group with an invite code", + description: "Join a group using a valid invite code, checking expiration and usage limits before adding the member.", + body: { + type: "object", + required: ["code"], + properties: { code: { type: "string", minLength: 1 } }, + additionalProperties: false, + }, + response: { + 200: { + type: "object", + properties: { + group: { + type: "object", + properties: { + id: { type: "string" }, + name: { type: "string" }, + description: { type: ["string", "null"] }, + createdByUserId: { type: "string" }, + treasuryEnabled: { type: "boolean" }, + treasuryAccountPublicKey: { type: ["string", "null"] }, + treasuryRequiredSigners: { type: ["integer", "null"] }, + archived: { type: "boolean" }, + createdAt: { type: "string", format: "date-time" }, + }, + }, + }, + }, + }, + }, + }, + async (req) => { + const auth = requireUser(req); + const body = z.object({ code: z.string().min(1) }).parse(req.body); const invite = await prisma.invite.findUnique({ where: { code: body.code.toUpperCase() }, @@ -380,9 +611,25 @@ export default async function groupRoutes(app: FastifyInstance) { }); // -- leave ------------------------------------------------------------------ - app.post("/groups/:id/leave", async (req) => { - const auth = requireUser(req); - const { id } = z.object({ id: z.string() }).parse(req.params); + app.post( + "/groups/:id/leave", + { + schema: { + tags: ["groups"], + summary: "Leave a group", + description: "Leave the group after the last-admin safety checks ensure the group remains administrable.", + params: { + type: "object", + required: ["id"], + properties: { id: { type: "string", minLength: 1 } }, + additionalProperties: false, + }, + response: { 200: { type: "object", properties: { ok: { type: "boolean" } } } }, + }, + }, + async (req) => { + const auth = requireUser(req); + const { id } = z.object({ id: z.string() }).parse(req.params); // The membership check, the last-admin guard, and the removal all run // inside one transaction so a concurrent leave/removal by another admin @@ -417,11 +664,27 @@ export default async function groupRoutes(app: FastifyInstance) { }); // -- remove member --------------------------------------------------------- - app.delete("/groups/:id/members/:memberId", async (req) => { - const auth = requireUser(req); - const { id, memberId } = z - .object({ id: z.string(), memberId: z.string() }) - .parse(req.params); + app.delete( + "/groups/:id/members/:memberId", + { + schema: { + tags: ["groups"], + summary: "Remove a member from a group", + description: "Remove a member from the group after verifying the caller is an admin and the group will still have at least one admin.", + params: { + type: "object", + required: ["id", "memberId"], + properties: { id: { type: "string", minLength: 1 }, memberId: { type: "string", minLength: 1 } }, + additionalProperties: false, + }, + response: { 200: { type: "object", properties: { ok: { type: "boolean" } } } }, + }, + }, + async (req) => { + const auth = requireUser(req); + const { id, memberId } = z + .object({ id: z.string(), memberId: z.string() }) + .parse(req.params); await requireAdmin(id, auth.id); if (memberId === auth.id) { @@ -484,15 +747,54 @@ export default async function groupRoutes(app: FastifyInstance) { * lost while the change commits). `auditTx` deliberately does not swallow * errors, so a failed audit write rolls the role change back with it. */ - app.post("/groups/:id/members/role", async (req) => { - const auth = requireUser(req); - const { id } = z.object({ id: z.string() }).parse(req.params); - const body = z - .object({ - userId: z.string().min(1).max(64), - role: z.enum(["admin", "member"]), - }) - .parse(req.body); + app.post( + "/groups/:id/members/role", + { + schema: { + tags: ["groups"], + summary: "Update a member role", + description: "Promote or demote a group member while enforcing the last-admin protection rules.", + params: { + type: "object", + required: ["id"], + properties: { id: { type: "string", minLength: 1 } }, + additionalProperties: false, + }, + body: { + type: "object", + required: ["userId", "role"], + properties: { + userId: { type: "string", minLength: 1, maxLength: 64 }, + role: { type: "string", enum: ["admin", "member"] }, + }, + additionalProperties: false, + }, + response: { + 200: { + type: "object", + properties: { + member: { + type: "object", + required: ["userId", "role"], + properties: { + userId: { type: "string" }, + role: { type: "string", enum: ["admin", "member"] }, + }, + }, + }, + }, + }, + }, + }, + async (req) => { + const auth = requireUser(req); + const { id } = z.object({ id: z.string() }).parse(req.params); + const body = z + .object({ + userId: z.string().min(1).max(64), + role: z.enum(["admin", "member"]), + }) + .parse(req.body); const updated = await prisma.$transaction(async (tx) => { // Authorization is re-checked inside the transaction so a concurrent @@ -552,9 +854,45 @@ export default async function groupRoutes(app: FastifyInstance) { }); // -- archive ---------------------------------------------------------------- - app.post("/groups/:id/archive", async (req) => { - const auth = requireUser(req); - const { id } = z.object({ id: z.string() }).parse(req.params); + app.post( + "/groups/:id/archive", + { + schema: { + tags: ["groups"], + summary: "Archive a group", + description: "Archive a group so it is no longer active while preserving its data and membership history.", + params: { + type: "object", + required: ["id"], + properties: { id: { type: "string", minLength: 1 } }, + additionalProperties: false, + }, + response: { + 200: { + type: "object", + properties: { + group: { + type: "object", + properties: { + id: { type: "string" }, + name: { type: "string" }, + description: { type: ["string", "null"] }, + createdByUserId: { type: "string" }, + treasuryEnabled: { type: "boolean" }, + treasuryAccountPublicKey: { type: ["string", "null"] }, + treasuryRequiredSigners: { type: ["integer", "null"] }, + archived: { type: "boolean" }, + createdAt: { type: "string", format: "date-time" }, + }, + }, + }, + }, + }, + }, + }, + async (req) => { + const auth = requireUser(req); + const { id } = z.object({ id: z.string() }).parse(req.params); const group = await prisma.$transaction(async (tx) => { await requireAdmin(id, auth.id, tx); diff --git a/src/services/horizon-retry.ts b/src/services/horizon-retry.ts index 05a4f3a..8a6a916 100644 --- a/src/services/horizon-retry.ts +++ b/src/services/horizon-retry.ts @@ -67,9 +67,9 @@ export function classifyHorizonError(error: unknown): HorizonErrorCategory { // Horizon SDK error shapes — the response body may carry result_codes. const response = extractResponse(error); if (response) { - const resultCodes = response.extras?.result_codes; - if (resultCodes) { - const txCode = resultCodes.transaction_result_code; + const resultCodes = normalizeResultCodes(response.extras?.result_codes ?? response.result_codes); + if (resultCodes.length > 0) { + const txCode = resultCodes.find((code) => code.startsWith("tx_")); // These Stellar transaction codes are permanent — the transaction // itself is invalid and retrying with the same envelope won't help. const permanentTxCodes = [ @@ -86,7 +86,7 @@ export function classifyHorizonError(error: unknown): HorizonErrorCategory { // Operation-level errors may be transient (e.g. underfunded can happen // when a concurrent payment consumed the balance). - const opCodes = resultCodes.operations ?? []; + const opCodes = resultCodes.filter((code) => code.startsWith("op_")); const permanentOpCodes = [ "op_no_destination", "op_no_trust", @@ -114,6 +114,22 @@ export function classifyHorizonError(error: unknown): HorizonErrorCategory { return "transient"; } +function normalizeResultCodes(resultCodes: unknown): string[] { + if (Array.isArray(resultCodes)) { + return resultCodes.filter((code): code is string => typeof code === "string"); + } + if (resultCodes && typeof resultCodes === "object") { + const codes = resultCodes as { [key: string]: unknown }; + const values = [ + codes.transaction_result_code, + ...(Array.isArray(codes.operations) ? codes.operations : []), + ...(Array.isArray(codes.operation_results) ? codes.operation_results : []), + ]; + return values.filter((code): code is string => typeof code === "string"); + } + return []; +} + function extractResponse( error: unknown ): { status?: number; extras?: { result_codes?: any } } | null { diff --git a/src/services/stellar.ts b/src/services/stellar.ts index 1e6eba8..788dbfb 100644 --- a/src/services/stellar.ts +++ b/src/services/stellar.ts @@ -73,7 +73,31 @@ export async function withHorizonFailover(operation: (horizon: Horizon.Server } function logUpstreamError(e: unknown, codes: unknown): void { - console.error("[stellar] Upstream error:", e instanceof Error ? e.message : String(e), codes ? JSON.stringify(codes) : ""); + const normalizedCodes = normalizeResultCodes(codes); + log.warn( + { + error: e instanceof Error ? e.message : String(e), + status: (e as any)?.response?.status ?? (e as any)?.status ?? undefined, + resultCodes: normalizedCodes.length > 0 ? normalizedCodes : undefined, + }, + "stellar horizon submission rejected" + ); +} + +function normalizeResultCodes(resultCodes: unknown): string[] { + if (Array.isArray(resultCodes)) { + return resultCodes.filter((code): code is string => typeof code === "string"); + } + if (resultCodes && typeof resultCodes === "object") { + const codes = resultCodes as { [key: string]: unknown }; + const values = [ + codes.transaction_result_code, + ...(Array.isArray(codes.operations) ? codes.operations : []), + ...(Array.isArray(codes.operation_results) ? codes.operation_results : []), + ]; + return values.filter((code): code is string => typeof code === "string"); + } + return []; } /** diff --git a/tests/error-handler.test.ts b/tests/error-handler.test.ts index 9566d07..5972eaa 100644 --- a/tests/error-handler.test.ts +++ b/tests/error-handler.test.ts @@ -61,6 +61,16 @@ beforeAll(async () => { throw Errors.upstream("Anchor service unavailable"); }); + app.get("/test/horizon-rate-limit", async () => { + const err = new Error("Horizon rate limited"); + Object.assign(err, { + response: { status: 429 }, + operation: "Horizon.loadAccount", + name: "BadRequestError", + }); + throw err; + }); + app.get("/test/with-details", async () => { throw new AppError(400, "VALIDATION_ERROR", "Bad input", [ { field: "amount", message: "Required" }, @@ -141,6 +151,16 @@ describe("AppError transformation", () => { expect(body.details).toBeUndefined(); }); + it("maps Horizon rate-limit exceptions to the stable upstream response", async () => { + const res = await app.inject({ method: "GET", url: "/test/horizon-rate-limit" }); + + expect(res.statusCode).toBe(429); + const body = res.json(); + expect(body.code).toBe("RATE_LIMITED"); + expect(body.message).toBe("Horizon is rate limiting requests. Please retry shortly."); + expect(body.requestId).toBeTruthy(); + }); + it("includes details when AppError has a details payload", async () => { const res = await app.inject({ method: "GET", url: "/test/with-details" }); diff --git a/tests/horizon-retry.test.ts b/tests/horizon-retry.test.ts index 2d9c67b..a14a080 100644 --- a/tests/horizon-retry.test.ts +++ b/tests/horizon-retry.test.ts @@ -67,6 +67,19 @@ describe("classifyHorizonError", () => { expect(classifyHorizonError(error)).toBe("permanent"); }); + it("classifies array-shaped Horizon tx_bad_seq as permanent", () => { + const error = { + response: { + data: { + extras: { + result_codes: ["tx_bad_seq"], + }, + }, + }, + }; + expect(classifyHorizonError(error)).toBe("permanent"); + }); + it("classifies Horizon op_underfunded as permanent", () => { const error = { response: {