diff --git a/deno.json b/deno.json index c245613..792a73d 100644 --- a/deno.json +++ b/deno.json @@ -1,6 +1,6 @@ { "name": "@moonlight-protocol/council-platform", - "version": "0.6.4", + "version": "0.6.5", "license": "MIT", "exports": "./src/main.ts", "tasks": { diff --git a/src/core/service/auth/council-auth.ts b/src/core/service/auth/council-auth.ts index ee17931..7488995 100644 --- a/src/core/service/auth/council-auth.ts +++ b/src/core/service/auth/council-auth.ts @@ -2,6 +2,8 @@ import { Keypair } from "stellar-sdk"; import { Buffer } from "buffer"; import type { Logger } from "@/utils/logger/index.ts"; import { withSpan } from "@/core/tracing.ts"; +import * as E from "@/core/service/auth/error.ts"; +import { PlatformError } from "@/error/index.ts"; const MAX_PENDING_CHALLENGES = 1000; let challengeTtlMs = 5 * 60 * 1000; @@ -28,7 +30,7 @@ export function createCouncilChallenge( cleanupExpiredChallenges(deps); if (pendingChallenges.size >= MAX_PENDING_CHALLENGES) { - throw new Error("Too many pending challenges. Try again later."); + throw new E.TOO_MANY_CHALLENGES(); } const nonceBytes = crypto.getRandomValues(new Uint8Array(32)); const nonce = btoa(String.fromCharCode(...nonceBytes)); @@ -57,16 +59,16 @@ export function verifyCouncilChallenge( const challenge = pendingChallenges.get(nonce); if (!challenge) { - throw new Error("Challenge not found or expired"); + throw new E.CHALLENGE_NOT_FOUND(); } if (Date.now() - challenge.createdAt > challengeTtlMs) { pendingChallenges.delete(nonce); - throw new Error("Challenge expired"); + throw new E.CHALLENGE_EXPIRED(); } if (challenge.publicKey !== publicKey) { - throw new Error("Public key mismatch"); + throw new E.PUBLIC_KEY_MISMATCH(); } // Don't consume nonce yet — only delete after successful verification @@ -109,15 +111,17 @@ export function verifyCouncilChallenge( // Raw format const rawNonce = Buffer.from(nonce, "base64"); if (!keypair.verify(rawNonce, sigBuffer)) { - throw new Error("Invalid signature"); + throw new E.INVALID_SIGNATURE(); } span.addEvent("signature_verified_raw"); } } } catch (e) { - throw e instanceof Error && e.message === "Invalid signature" - ? e - : new Error("Invalid signature"); + // Already-structured failures bubble as-is; any raw verify() error + // (malformed key/signature bytes) is wrapped as INVALID_SIGNATURE with + // the original preserved as the cause. + if (PlatformError.is(e)) throw e; + throw new E.INVALID_SIGNATURE(e); } // Signature verified — consume the nonce (one-time use) diff --git a/src/core/service/auth/error.ts b/src/core/service/auth/error.ts new file mode 100644 index 0000000..3073454 --- /dev/null +++ b/src/core/service/auth/error.ts @@ -0,0 +1,99 @@ +import { PlatformError } from "@/error/index.ts"; + +/** + * Council operator authentication errors (challenge issuance + signature + * verification). Each carries a stable `code` the console maps to operator copy, + * and a redacted `api` projection — the internal reason never leaks verbatim. + */ +export enum COUNCIL_AUTH_ERROR_CODES { + TOO_MANY_CHALLENGES = "COUNCIL_AUTH_001", + CHALLENGE_NOT_FOUND = "COUNCIL_AUTH_002", + CHALLENGE_EXPIRED = "COUNCIL_AUTH_003", + PUBLIC_KEY_MISMATCH = "COUNCIL_AUTH_004", + INVALID_SIGNATURE = "COUNCIL_AUTH_005", +} + +const source = "@service/auth"; + +export class TOO_MANY_CHALLENGES extends PlatformError { + constructor() { + super({ + source, + code: COUNCIL_AUTH_ERROR_CODES.TOO_MANY_CHALLENGES, + message: "Too many pending challenges", + details: + "The pending-challenge buffer is full; requests are rate limited.", + api: { + status: 429, + message: "Too many pending sign-in attempts", + details: "Please wait a moment and try signing in again.", + }, + }); + } +} + +export class CHALLENGE_NOT_FOUND extends PlatformError { + constructor() { + super({ + source, + code: COUNCIL_AUTH_ERROR_CODES.CHALLENGE_NOT_FOUND, + message: "Challenge not found or expired", + details: "No pending challenge matched the supplied nonce.", + api: { + status: 401, + message: "Sign-in challenge not found or expired", + details: "Your sign-in request has expired. Please start again.", + }, + }); + } +} + +export class CHALLENGE_EXPIRED extends PlatformError { + constructor() { + super({ + source, + code: COUNCIL_AUTH_ERROR_CODES.CHALLENGE_EXPIRED, + message: "Challenge expired", + details: "The challenge exceeded its time-to-live before verification.", + api: { + status: 401, + message: "Sign-in challenge expired", + details: "Your sign-in request took too long. Please start again.", + }, + }); + } +} + +export class PUBLIC_KEY_MISMATCH extends PlatformError { + constructor() { + super({ + source, + code: COUNCIL_AUTH_ERROR_CODES.PUBLIC_KEY_MISMATCH, + message: "Public key mismatch", + details: "The verifying public key did not match the challenge subject.", + api: { + status: 401, + message: "Sign-in key mismatch", + details: + "The wallet used to sign did not match the one that started sign-in.", + }, + }); + } +} + +export class INVALID_SIGNATURE extends PlatformError { + constructor(error?: Error | unknown) { + super({ + source, + code: COUNCIL_AUTH_ERROR_CODES.INVALID_SIGNATURE, + message: "Invalid signature", + details: "The supplied signature failed verification for all formats.", + baseError: error, + api: { + status: 401, + message: "Invalid signature", + details: "The signature could not be verified. Please try again.", + }, + }); + } +} diff --git a/src/core/service/channel/channel-state.service.ts b/src/core/service/channel/channel-state.service.ts index 4c7e63b..62cf456 100644 --- a/src/core/service/channel/channel-state.service.ts +++ b/src/core/service/channel/channel-state.service.ts @@ -1,6 +1,7 @@ import { NETWORK_RPC_SERVER } from "@/config/env.ts"; import { withSpan } from "@/core/tracing.ts"; import type { Logger } from "@/utils/logger/index.ts"; +import * as E from "@/core/service/channel/error.ts"; export interface ChannelOnChainState { totalDeposited: bigint | null; @@ -41,7 +42,7 @@ export function queryChannelState( }; } catch (error) { log.error(error, "failed to query channel state from RPC"); - throw new Error("Failed to query channel on-chain state"); + throw new E.CHANNEL_STATE_QUERY_FAILED(error); } }); } diff --git a/src/core/service/channel/error.ts b/src/core/service/channel/error.ts new file mode 100644 index 0000000..eb1c6ff --- /dev/null +++ b/src/core/service/channel/error.ts @@ -0,0 +1,27 @@ +import { PlatformError } from "@/error/index.ts"; + +/** Channel service errors (on-chain channel state reads). */ +export enum CHANNEL_ERROR_CODES { + STATE_QUERY_FAILED = "CHANNEL_001", +} + +const source = "@service/channel"; + +export class CHANNEL_STATE_QUERY_FAILED extends PlatformError { + constructor(error?: Error | unknown) { + super({ + source, + code: CHANNEL_ERROR_CODES.STATE_QUERY_FAILED, + message: "Failed to query channel on-chain state", + details: "The channel Auth contract read did not respond successfully.", + baseError: error, + api: { + // 503 — the network dependency is temporarily unavailable; retry. + status: 503, + message: "Channel network is temporarily unavailable", + details: + "The network could not be reached to read channel state. Please try again shortly.", + }, + }); + } +} diff --git a/src/core/service/escrow/error.ts b/src/core/service/escrow/error.ts new file mode 100644 index 0000000..f34ebf6 --- /dev/null +++ b/src/core/service/escrow/error.ts @@ -0,0 +1,43 @@ +import { PlatformError } from "@/error/index.ts"; + +/** Escrow service errors (transparent deposits to non-registered recipients). */ +export enum ESCROW_ERROR_CODES { + INVALID_AMOUNT = "ESCROW_001", + RECIPIENT_NOT_ACTIVE = "ESCROW_002", +} + +const source = "@service/escrow"; + +export class INVALID_AMOUNT extends PlatformError { + constructor() { + super({ + source, + code: ESCROW_ERROR_CODES.INVALID_AMOUNT, + message: "Amount must be positive", + details: "An escrow was requested with a non-positive amount.", + api: { + status: 400, + message: "Amount must be positive", + details: "The escrow amount must be greater than zero.", + }, + }); + } +} + +export class RECIPIENT_NOT_ACTIVE extends PlatformError { + constructor() { + super({ + source, + code: ESCROW_ERROR_CODES.RECIPIENT_NOT_ACTIVE, + message: "Recipient is not registered or not active", + details: + "The escrow recipient has no active custodial registration for the channel.", + api: { + status: 400, + message: "Recipient is not registered or not active", + details: + "The recipient does not have an active account for this channel.", + }, + }); + } +} diff --git a/src/core/service/escrow/escrow.service.ts b/src/core/service/escrow/escrow.service.ts index 8c95345..17d09d4 100644 --- a/src/core/service/escrow/escrow.service.ts +++ b/src/core/service/escrow/escrow.service.ts @@ -6,6 +6,7 @@ import { CustodialUserStatus } from "@/persistence/drizzle/entity/custodial-user import { deriveP256PublicKey } from "@/core/service/custody/key-derivation.service.ts"; import { withSpan } from "@/core/tracing.ts"; import type { Logger } from "@/utils/logger/index.ts"; +import * as E from "@/core/service/escrow/error.ts"; const escrowRepo = new CouncilEscrowRepository(drizzleClient); const userRepo = new CustodialUserRepository(drizzleClient); @@ -95,7 +96,7 @@ export function createEscrow(opts: { span.setAttribute("escrow.asset_code", opts.assetCode); if (opts.amount <= 0n) { - throw new Error("Amount must be positive"); + throw new E.INVALID_AMOUNT(); } const escrow = await escrowRepo.create({ @@ -203,7 +204,7 @@ export function releaseEscrowsForRecipient( channelContractId, ); if (!user || user.status !== CustodialUserStatus.ACTIVE) { - throw new Error("Recipient is not registered or not active"); + throw new E.RECIPIENT_NOT_ACTIVE(); } let totalReleased = 0n; diff --git a/src/core/tracing.ts b/src/core/tracing.ts index c7ebc7c..f811fbc 100644 --- a/src/core/tracing.ts +++ b/src/core/tracing.ts @@ -2,6 +2,12 @@ import { type Span, SpanStatusCode, trace } from "@opentelemetry/api"; const tracer = trace.getTracer("council-platform"); +/** The active W3C trace id, if a span is in scope — for log correlation. */ +export function currentTraceId(): string | undefined { + const spanContext = trace.getActiveSpan()?.spanContext(); + return spanContext?.traceId; +} + export function withSpan( name: string, fn: (span: Span) => Promise | T, diff --git a/src/error/index.ts b/src/error/index.ts index 73514e4..0fdc580 100644 --- a/src/error/index.ts +++ b/src/error/index.ts @@ -20,7 +20,13 @@ export class PlatformError extends Error { readonly api?: APIDetails; constructor(e: PlatformErrorShape) { - super(e.message); + // Preserve the wrapped original as the native `cause` so the full chain + // flows into logs/OTel (see logger `flattenCauses`), while the redacted + // `api` projection is all that reaches the client. + super( + e.message, + e.baseError !== undefined ? { cause: e.baseError } : undefined, + ); this.name = "ML Platform Error: " + e.code; this.code = e.code; this.source = e.source; diff --git a/src/http/default-schemas.ts b/src/http/default-schemas.ts index e041668..c484baf 100644 --- a/src/http/default-schemas.ts +++ b/src/http/default-schemas.ts @@ -21,6 +21,9 @@ export const errorStatusSchema = z.union([ z.literal(Status.InternalServerError), z.literal(Status.TooManyRequests), z.literal(Status.Conflict), + // 503 — a dependency (e.g. the Soroban RPC / channel on-chain read) is + // temporarily unreachable and the client should retry. + z.literal(Status.ServiceUnavailable), ]); export const errorResponseSchema = z.object({ diff --git a/src/http/middleware/auth/index.ts b/src/http/middleware/auth/index.ts index c7ec487..efe0cf9 100644 --- a/src/http/middleware/auth/index.ts +++ b/src/http/middleware/auth/index.ts @@ -5,22 +5,22 @@ import type { JwtPayload } from "@/core/service/auth/generate-jwt.ts"; import type { Logger } from "@/utils/logger/index.ts"; import { isDefined } from "@/utils/type-guards/is-defined.ts"; import * as E from "@/http/middleware/auth/error.ts"; -import { PIPE_APIError } from "@/http/pipelines/error-pipeline.ts"; +import { PlatformError } from "@/error/index.ts"; export function jwtMiddleware( deps: { log: Logger }, ): (ctx: Context, next: () => Promise) => Promise { + // deps kept for signature parity with the other middleware factories. + void deps; return async (ctx, next) => { const authorization = ctx.request.headers.get("authorization"); if (!isDefined(authorization)) { - await PIPE_APIError(ctx, deps).run(new E.MISSING_AUTHORIZATION_HEADER()); - return; + throw new E.MISSING_AUTHORIZATION_HEADER(); } const parts = authorization.split(" "); if (parts.length !== 2 || parts[0] !== "Bearer") { - await PIPE_APIError(ctx, deps).run(new E.INVALID_AUTHORIZATION_HEADER()); - return; + throw new E.INVALID_AUTHORIZATION_HEADER(); } const token = parts[1]; @@ -30,16 +30,14 @@ export function jwtMiddleware( const now = Math.floor(Date.now() / 1000); if (typeof payload.exp === "number" && now > payload.exp) { - await PIPE_APIError(ctx, deps).run(new E.EXPIRED_TOKEN()); - return; + throw new E.EXPIRED_TOKEN(); } ctx.state.session = payload; } catch (error) { - await PIPE_APIError(ctx, deps).run( - new E.JWT_VERIFICATION_FAILED(error), - ); - return; + // Preserve already-structured auth errors; wrap the raw verify() failure. + if (PlatformError.is(error)) throw error; + throw new E.JWT_VERIFICATION_FAILED(error); } await next(); }; diff --git a/src/http/middleware/error.ts b/src/http/middleware/error.ts new file mode 100644 index 0000000..8cb897c --- /dev/null +++ b/src/http/middleware/error.ts @@ -0,0 +1,38 @@ +import type { Context, Next } from "@oak/oak"; +import type { Logger } from "@/utils/logger/index.ts"; +import { PIPE_APIError } from "@/http/pipelines/error-pipeline.ts"; +import { currentTraceId } from "@/core/tracing.ts"; + +/** + * Outermost error boundary for the HTTP layer. + * + * Any error thrown by a downstream middleware, route, pipeline, or service + * bubbles here. We log it with the full cause chain plus correlation ids + * (requestId / traceId), then translate it to the structured `ErrorResponse` + * (`{ status, code, message, details }`) at the edge via `PIPE_APIError`. + * + * This is the single place where an internal error becomes a client-facing + * response, so handlers and services `throw` (raw or `PlatformError`) instead of + * hand-writing ad-hoc `{ message }` bodies — the redacted `api` projection is + * all that ever reaches the client; the chain/stack stay in logs and OTel. + */ +export function errorMiddleware( + deps: { log: Logger }, +): (ctx: Context, next: Next) => Promise { + const log = deps.log.scope("errorMiddleware"); + + return async (ctx, next) => { + try { + await next(); + } catch (error) { + log.error(error, "request failed", { + requestId: ctx.state.requestId, + traceId: currentTraceId(), + method: ctx.request.method, + path: new URL(ctx.request.url).pathname, + }); + + await PIPE_APIError(ctx, deps).run(error as Error); + } + }; +} diff --git a/src/http/v1/admin/auth/challenge.ts b/src/http/v1/admin/auth/challenge.ts index e737e7b..13a5e0e 100644 --- a/src/http/v1/admin/auth/challenge.ts +++ b/src/http/v1/admin/auth/challenge.ts @@ -1,6 +1,7 @@ import { type Context, Status } from "@oak/oak"; import { Keypair } from "stellar-sdk"; import { createCouncilChallenge } from "@/core/service/auth/council-auth.ts"; +import * as E from "@/http/v1/error.ts"; import type { Logger } from "@/utils/logger/index.ts"; export function handlePostChallenge( @@ -10,40 +11,33 @@ export function handlePostChallenge( return async (ctx) => { log.info("postChallenge"); + + let body; try { - const body = await ctx.request.body.json(); - const { publicKey } = body; + body = await ctx.request.body.json(); + } catch (error) { + throw new E.INVALID_REQUEST_BODY(error); + } + const { publicKey } = body; - if (!publicKey || typeof publicKey !== "string") { - ctx.response.status = Status.BadRequest; - ctx.response.body = { message: "publicKey is required" }; - return; - } + if (!publicKey || typeof publicKey !== "string") { + throw new E.VALIDATION_FAILED("publicKey is required"); + } - try { - Keypair.fromPublicKey(publicKey); - } catch { - ctx.response.status = Status.BadRequest; - ctx.response.body = { message: "Invalid Stellar public key format" }; - return; - } + try { + Keypair.fromPublicKey(publicKey); + } catch { + throw new E.VALIDATION_FAILED("Invalid Stellar public key format"); + } - const { nonce } = createCouncilChallenge(publicKey, { log }); + // createCouncilChallenge throws a structured TOO_MANY_CHALLENGES (429) that + // the edge translates; no ad-hoc catch needed. + const { nonce } = createCouncilChallenge(publicKey, { log }); - ctx.response.status = Status.OK; - ctx.response.body = { - message: "Challenge created", - data: { nonce }, - }; - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - if (message.includes("Too many pending challenges")) { - ctx.response.status = 429; - ctx.response.body = { message }; - return; - } - ctx.response.status = Status.InternalServerError; - ctx.response.body = { message: "Failed to create challenge" }; - } + ctx.response.status = Status.OK; + ctx.response.body = { + message: "Challenge created", + data: { nonce }, + }; }; } diff --git a/src/http/v1/admin/auth/verify.ts b/src/http/v1/admin/auth/verify.ts index 46bb7ff..eb7a166 100644 --- a/src/http/v1/admin/auth/verify.ts +++ b/src/http/v1/admin/auth/verify.ts @@ -3,6 +3,7 @@ import { verifyCouncilChallenge } from "@/core/service/auth/council-auth.ts"; import generateJwt from "@/core/service/auth/generate-jwt.ts"; import { drizzleClient } from "@/persistence/drizzle/config.ts"; import { WalletUserRepository } from "@/persistence/drizzle/repository/wallet-user.repository.ts"; +import * as E from "@/http/v1/error.ts"; import type { Logger } from "@/utils/logger/index.ts"; const walletUserRepo = new WalletUserRepository(drizzleClient); @@ -14,41 +15,41 @@ export function handlePostVerify( return async (ctx) => { log.info("postVerify"); + + let body; try { - const body = await ctx.request.body.json(); - const { nonce, signature, publicKey } = body; - - if (!nonce || !signature || !publicKey) { - ctx.response.status = Status.BadRequest; - ctx.response.body = { - message: "nonce, signature, and publicKey are required", - }; - return; - } - - const { token } = await verifyCouncilChallenge( - nonce, - signature, - publicKey, - { - generateToken: (subject, sessionId) => - generateJwt(subject, sessionId), - }, - { log }, + body = await ctx.request.body.json(); + } catch (error) { + throw new E.INVALID_REQUEST_BODY(error); + } + const { nonce, signature, publicKey } = body; + + if (!nonce || !signature || !publicKey) { + throw new E.VALIDATION_FAILED( + "nonce, signature, and publicKey are required", ); + } - await walletUserRepo.findOrCreate(publicKey); + // verifyCouncilChallenge throws structured COUNCIL_AUTH_* errors (401/429) + // that the edge translates with their specific codes — no blanket + // "Authentication failed" swallow. + const { token } = await verifyCouncilChallenge( + nonce, + signature, + publicKey, + { + generateToken: (subject, sessionId) => generateJwt(subject, sessionId), + }, + { log }, + ); - log.event("authentication successful"); - ctx.response.status = Status.OK; - ctx.response.body = { - message: "Authentication successful", - data: { token }, - }; - } catch (error) { - log.error(error, "council auth failed"); - ctx.response.status = Status.Unauthorized; - ctx.response.body = { message: "Authentication failed" }; - } + await walletUserRepo.findOrCreate(publicKey); + + log.event("authentication successful"); + ctx.response.status = Status.OK; + ctx.response.body = { + message: "Authentication successful", + data: { token }, + }; }; } diff --git a/src/http/v1/council/channels.ts b/src/http/v1/council/channels.ts index 8a906e5..5e129c9 100644 --- a/src/http/v1/council/channels.ts +++ b/src/http/v1/council/channels.ts @@ -10,6 +10,7 @@ import { ChannelPendingAction, ChannelStatus, } from "@/persistence/drizzle/entity/council-channel.entity.ts"; +import * as E from "@/http/v1/error.ts"; import type { Logger } from "@/utils/logger/index.ts"; const metadataRepo = new CouncilMetadataRepository(drizzleClient); @@ -57,23 +58,16 @@ export function handleListChannels( return async (ctx) => { log.info("listChannels"); - try { - const councilId = requireCouncilId(ctx); - if (!councilId) return; - if (!await requireCouncilOwnership(ctx, councilId, metadataRepo)) return; + const councilId = requireCouncilId(ctx); + await requireCouncilOwnership(ctx, councilId, metadataRepo); - const channels = await channelRepo.listAll(councilId); + const channels = await channelRepo.listAll(councilId); - ctx.response.status = Status.OK; - ctx.response.body = { - message: "Channels retrieved", - data: channels.map(formatChannel), - }; - } catch (error) { - log.error(error, "failed to list channels"); - ctx.response.status = Status.InternalServerError; - ctx.response.body = { message: "Failed to retrieve channels" }; - } + ctx.response.status = Status.OK; + ctx.response.body = { + message: "Channels retrieved", + data: channels.map(formatChannel), + }; }; } @@ -84,116 +78,93 @@ export function handleAddChannel( return async (ctx) => { log.info("addChannel"); + const councilId = requireCouncilId(ctx); + await requireCouncilOwnership(ctx, councilId, metadataRepo); + log.debug("councilId", councilId); + + let body; try { - const councilId = requireCouncilId(ctx); - if (!councilId) return; - if (!await requireCouncilOwnership(ctx, councilId, metadataRepo)) return; - log.debug("councilId", councilId); - - const body = await ctx.request.body.json(); - const { - channelContractId, - assetCode, - assetContractId, - issuerAddress, - label, - } = body; - - if (!channelContractId || typeof channelContractId !== "string") { - ctx.response.status = Status.BadRequest; - ctx.response.body = { message: "channelContractId is required" }; - return; - } - - if (!StrKey.isValidContractId(channelContractId)) { - ctx.response.status = Status.BadRequest; - ctx.response.body = { message: "Invalid Soroban contract ID format" }; - return; - } - - if (!assetCode || typeof assetCode !== "string") { - ctx.response.status = Status.BadRequest; - ctx.response.body = { message: "assetCode is required" }; - return; - } - - if (assetCode.length > 12 || !/^[a-zA-Z0-9]+$/.test(assetCode)) { - ctx.response.status = Status.BadRequest; - ctx.response.body = { - message: "assetCode must be 1-12 alphanumeric characters", - }; - return; - } - - if ( - assetContractId && typeof assetContractId === "string" && - !StrKey.isValidContractId(assetContractId) - ) { - ctx.response.status = Status.BadRequest; - ctx.response.body = { message: "Invalid asset contract ID format" }; - return; - } - - if (label && typeof label !== "string") { - ctx.response.status = Status.BadRequest; - ctx.response.body = { message: "label must be a string" }; - return; - } - if (label && label.length > 200) { - ctx.response.status = Status.BadRequest; - ctx.response.body = { message: "label must be at most 200 characters" }; - return; - } - - const existing = await channelRepo.findByContractId( - councilId, - channelContractId, + body = await ctx.request.body.json(); + } catch (error) { + throw new E.INVALID_REQUEST_BODY(error); + } + const { + channelContractId, + assetCode, + assetContractId, + issuerAddress, + label, + } = body; + + if (!channelContractId || typeof channelContractId !== "string") { + throw new E.VALIDATION_FAILED("channelContractId is required"); + } + + if (!StrKey.isValidContractId(channelContractId)) { + throw new E.VALIDATION_FAILED("Invalid Soroban contract ID format"); + } + + if (!assetCode || typeof assetCode !== "string") { + throw new E.VALIDATION_FAILED("assetCode is required"); + } + + if (assetCode.length > 12 || !/^[a-zA-Z0-9]+$/.test(assetCode)) { + throw new E.VALIDATION_FAILED( + "assetCode must be 1-12 alphanumeric characters", ); - if (existing) { - ctx.response.status = Status.Conflict; - ctx.response.body = { - message: "Channel with this contract ID already exists", - }; - return; - } - - const channel = await channelRepo.create({ - id: crypto.randomUUID(), - councilId, - channelContractId, - assetCode: assetCode.trim(), - assetContractId: assetContractId?.trim() ?? null, - label: label?.trim() ?? null, - createdAt: new Date(), - updatedAt: new Date(), - }); + } - try { - await knownAssetRepo.upsert( - assetCode.trim(), - (issuerAddress || "").trim(), - ); - } catch { /* best effort */ } + if ( + assetContractId && typeof assetContractId === "string" && + !StrKey.isValidContractId(assetContractId) + ) { + throw new E.VALIDATION_FAILED("Invalid asset contract ID format"); + } - log.debug("channelContractId", channelContractId); - log.debug("assetCode", assetCode); - log.event("channel added"); + if (label && typeof label !== "string") { + throw new E.VALIDATION_FAILED("label must be a string"); + } + if (label && label.length > 200) { + throw new E.VALIDATION_FAILED("label must be at most 200 characters"); + } - ctx.response.status = Status.OK; - ctx.response.body = { - message: "Channel added", - data: formatChannel(channel), - }; - } catch (error) { - if (error instanceof SyntaxError) { - ctx.response.status = Status.BadRequest; - ctx.response.body = { message: "Invalid request body" }; - } else { - log.error(error, "failed to add channel"); - ctx.response.status = Status.InternalServerError; - ctx.response.body = { message: "Failed to add channel" }; - } + const existing = await channelRepo.findByContractId( + councilId, + channelContractId, + ); + if (existing) { + throw new E.RESOURCE_CONFLICT( + "Channel with this contract ID already exists", + ); } + + const channel = await channelRepo.create({ + id: crypto.randomUUID(), + councilId, + channelContractId, + assetCode: assetCode.trim(), + assetContractId: assetContractId?.trim() ?? null, + label: label?.trim() ?? null, + createdAt: new Date(), + updatedAt: new Date(), + }); + + try { + await knownAssetRepo.upsert( + assetCode.trim(), + (issuerAddress || "").trim(), + ); + } catch { /* best effort */ } + + log.debug("channelContractId", channelContractId); + log.debug("assetCode", assetCode); + log.event("channel added"); + + ctx.response.status = Status.OK; + ctx.response.body = { + message: "Channel added", + data: formatChannel(channel), + }; }; } @@ -206,69 +177,55 @@ export function handleGetChannel( return async (ctx) => { log.info("getChannel"); + const params = (ctx as unknown as { params?: RouteParams }).params; + const id = params?.id; + + if (!id) { + throw new E.RESOURCE_ID_REQUIRED("Channel ID"); + } + + const channel = await channelRepo.findById(id); + if (!channel) { + throw new E.RESOURCE_NOT_FOUND("Channel not found"); + } + + await requireCouncilOwnership(ctx, channel.councilId, metadataRepo); + try { - const params = (ctx as unknown as { params?: RouteParams }).params; - const id = params?.id; - - if (!id) { - ctx.response.status = Status.BadRequest; - ctx.response.body = { message: "Channel ID is required" }; - return; - } - - const channel = await channelRepo.findById(id); - if (!channel) { - ctx.response.status = Status.NotFound; - ctx.response.body = { message: "Channel not found" }; - return; - } - - if ( - !await requireCouncilOwnership(ctx, channel.councilId, metadataRepo) - ) { - return; - } - - try { - const onChainState = await queryChannelState( - channel.channelContractId, - { - log, - }, - ); - - await channelRepo.update(channel.id, { - totalDeposited: onChainState.totalDeposited, - totalWithdrawn: onChainState.totalWithdrawn, - utxoCount: onChainState.utxoCount, - lastSyncedAt: new Date(), - }); - - ctx.response.status = Status.OK; - ctx.response.body = { - message: "Channel retrieved", - data: { - ...formatChannel(channel), - state: { - totalDeposited: onChainState.totalDeposited?.toString() ?? null, - totalWithdrawn: onChainState.totalWithdrawn?.toString() ?? null, - utxoCount: onChainState.utxoCount?.toString() ?? null, - lastSyncedAt: new Date().toISOString(), - ledgerSequence: onChainState.ledgerSequence, - }, + const onChainState = await queryChannelState( + channel.channelContractId, + { + log, + }, + ); + + await channelRepo.update(channel.id, { + totalDeposited: onChainState.totalDeposited, + totalWithdrawn: onChainState.totalWithdrawn, + utxoCount: onChainState.utxoCount, + lastSyncedAt: new Date(), + }); + + ctx.response.status = Status.OK; + ctx.response.body = { + message: "Channel retrieved", + data: { + ...formatChannel(channel), + state: { + totalDeposited: onChainState.totalDeposited?.toString() ?? null, + totalWithdrawn: onChainState.totalWithdrawn?.toString() ?? null, + utxoCount: onChainState.utxoCount?.toString() ?? null, + lastSyncedAt: new Date().toISOString(), + ledgerSequence: onChainState.ledgerSequence, }, - }; - } catch { - ctx.response.status = Status.OK; - ctx.response.body = { - message: "Channel retrieved (cached state, RPC unavailable)", - data: formatChannel(channel), - }; - } - } catch (error) { - log.error(error, "failed to get channel"); - ctx.response.status = Status.InternalServerError; - ctx.response.body = { message: "Failed to retrieve channel" }; + }, + }; + } catch { + ctx.response.status = Status.OK; + ctx.response.body = { + message: "Channel retrieved (cached state, RPC unavailable)", + data: formatChannel(channel), + }; } }; } @@ -280,52 +237,38 @@ export function handleRemoveChannel( return async (ctx) => { log.info("removeChannel"); - try { - const params = (ctx as unknown as { params?: RouteParams }).params; - const id = params?.id; - - if (!id) { - ctx.response.status = Status.BadRequest; - ctx.response.body = { message: "Channel ID is required" }; - return; - } - - const channel = await channelRepo.findById(id); - if (!channel) { - ctx.response.status = Status.NotFound; - ctx.response.body = { message: "Channel not found" }; - return; - } - - if ( - !await requireCouncilOwnership(ctx, channel.councilId, metadataRepo) - ) { - return; - } - - // Record the council's intent ONLY. The authoritative `status` flip to - // "disabled" is written by the event-watcher when the quorum-authorized - // disable_channel call is confirmed on-chain — never here (no optimistic - // authoritative write). The on-chain quorum tx is signed client-side. - const updated = await channelRepo.setPendingAction( - id, - ChannelPendingAction.DISABLE, - ); + const params = (ctx as unknown as { params?: RouteParams }).params; + const id = params?.id; - log.debug("id", id); - log.debug("channelContractId", channel.channelContractId); - log.event("channel disable requested (pending on-chain confirmation)"); + if (!id) { + throw new E.RESOURCE_ID_REQUIRED("Channel ID"); + } - ctx.response.status = Status.Accepted; - ctx.response.body = { - message: "Channel disable requested; pending on-chain confirmation", - data: formatChannel(updated), - }; - } catch (error) { - log.error(error, "failed to request channel disable"); - ctx.response.status = Status.InternalServerError; - ctx.response.body = { message: "Failed to request channel disable" }; + const channel = await channelRepo.findById(id); + if (!channel) { + throw new E.RESOURCE_NOT_FOUND("Channel not found"); } + + await requireCouncilOwnership(ctx, channel.councilId, metadataRepo); + + // Record the council's intent ONLY. The authoritative `status` flip to + // "disabled" is written by the event-watcher when the quorum-authorized + // disable_channel call is confirmed on-chain — never here (no optimistic + // authoritative write). The on-chain quorum tx is signed client-side. + const updated = await channelRepo.setPendingAction( + id, + ChannelPendingAction.DISABLE, + ); + + log.debug("id", id); + log.debug("channelContractId", channel.channelContractId); + log.event("channel disable requested (pending on-chain confirmation)"); + + ctx.response.status = Status.Accepted; + ctx.response.body = { + message: "Channel disable requested; pending on-chain confirmation", + data: formatChannel(updated), + }; }; } @@ -336,51 +279,37 @@ export function handleEnableChannel( return async (ctx) => { log.info("enableChannel"); - try { - const params = (ctx as unknown as { params?: RouteParams }).params; - const id = params?.id; - - if (!id) { - ctx.response.status = Status.BadRequest; - ctx.response.body = { message: "Channel ID is required" }; - return; - } - - const channel = await channelRepo.findByIdIncludeDeleted(id); - if (!channel || channel.status !== ChannelStatus.DISABLED) { - ctx.response.status = Status.NotFound; - ctx.response.body = { message: "Disabled channel not found" }; - return; - } - - if ( - !await requireCouncilOwnership(ctx, channel.councilId, metadataRepo) - ) { - return; - } - - // Intent only — the watcher flips `status` back to "enabled" once the - // quorum-authorized enable_channel call is confirmed on-chain. Re-enable - // reuses the same on-chain enable action. - const updated = await channelRepo.setPendingAction( - id, - ChannelPendingAction.ENABLE, - ); + const params = (ctx as unknown as { params?: RouteParams }).params; + const id = params?.id; - log.debug("id", id); - log.debug("channelContractId", channel.channelContractId); - log.event("channel re-enable requested (pending on-chain confirmation)"); + if (!id) { + throw new E.RESOURCE_ID_REQUIRED("Channel ID"); + } - ctx.response.status = Status.Accepted; - ctx.response.body = { - message: "Channel re-enable requested; pending on-chain confirmation", - data: formatChannel(updated), - }; - } catch (error) { - log.error(error, "failed to request channel re-enable"); - ctx.response.status = Status.InternalServerError; - ctx.response.body = { message: "Failed to request channel re-enable" }; + const channel = await channelRepo.findByIdIncludeDeleted(id); + if (!channel || channel.status !== ChannelStatus.DISABLED) { + throw new E.RESOURCE_NOT_FOUND("Disabled channel not found"); } + + await requireCouncilOwnership(ctx, channel.councilId, metadataRepo); + + // Intent only — the watcher flips `status` back to "enabled" once the + // quorum-authorized enable_channel call is confirmed on-chain. Re-enable + // reuses the same on-chain enable action. + const updated = await channelRepo.setPendingAction( + id, + ChannelPendingAction.ENABLE, + ); + + log.debug("id", id); + log.debug("channelContractId", channel.channelContractId); + log.event("channel re-enable requested (pending on-chain confirmation)"); + + ctx.response.status = Status.Accepted; + ctx.response.body = { + message: "Channel re-enable requested; pending on-chain confirmation", + data: formatChannel(updated), + }; }; } @@ -391,22 +320,15 @@ export function handleListDisabledChannels( return async (ctx) => { log.info("listDisabledChannels"); - try { - const councilId = requireCouncilId(ctx); - if (!councilId) return; - if (!await requireCouncilOwnership(ctx, councilId, metadataRepo)) return; + const councilId = requireCouncilId(ctx); + await requireCouncilOwnership(ctx, councilId, metadataRepo); - const channels = await channelRepo.listDisabled(councilId); + const channels = await channelRepo.listDisabled(councilId); - ctx.response.status = Status.OK; - ctx.response.body = { - message: "Disabled channels retrieved", - data: channels.map(formatChannel), - }; - } catch (error) { - log.error(error, "failed to list disabled channels"); - ctx.response.status = Status.InternalServerError; - ctx.response.body = { message: "Failed to retrieve disabled channels" }; - } + ctx.response.status = Status.OK; + ctx.response.body = { + message: "Disabled channels retrieved", + data: channels.map(formatChannel), + }; }; } diff --git a/src/http/v1/council/error.ts b/src/http/v1/council/error.ts new file mode 100644 index 0000000..855a41f --- /dev/null +++ b/src/http/v1/council/error.ts @@ -0,0 +1,44 @@ +import { PlatformError } from "@/error/index.ts"; + +/** + * Council-specific HTTP-edge errors. Generic request validation lives in + * `@/http/v1/error.ts`; these carry council-governance-specific codes the + * console maps to operator copy. + */ +export enum HTTP_COUNCIL_ERROR_CODES { + MISSING_COUNCIL_ID = "HTTP_COUNCIL_001", + COUNCIL_NOT_FOUND = "HTTP_COUNCIL_002", +} + +const source = "@http/v1/council"; + +export class MISSING_COUNCIL_ID extends PlatformError { + constructor() { + super({ + source, + code: HTTP_COUNCIL_ERROR_CODES.MISSING_COUNCIL_ID, + message: "councilId query parameter is required", + api: { + status: 400, + message: "councilId query parameter is required", + details: "Include a councilId query parameter with the request.", + }, + }); + } +} + +export class COUNCIL_NOT_FOUND extends PlatformError { + constructor() { + super({ + source, + code: HTTP_COUNCIL_ERROR_CODES.COUNCIL_NOT_FOUND, + message: "Council not found", + details: "No council matched the id for the authenticated owner.", + api: { + status: 404, + message: "Council not found", + details: "The requested council does not exist or is not yours.", + }, + }); + } +} diff --git a/src/http/v1/council/escrow.ts b/src/http/v1/council/escrow.ts index 9443514..48d400e 100644 --- a/src/http/v1/council/escrow.ts +++ b/src/http/v1/council/escrow.ts @@ -10,6 +10,7 @@ import type { JwtSessionData } from "@/http/middleware/auth/index.ts"; import type { Logger } from "@/utils/logger/index.ts"; import { drizzleClient } from "@/persistence/drizzle/config.ts"; import { CouncilProviderRepository } from "@/persistence/drizzle/repository/council-provider.repository.ts"; +import * as E from "@/http/v1/error.ts"; const providerRepo = new CouncilProviderRepository(drizzleClient); const AMOUNT_RE = /^\d+$/; @@ -29,61 +30,45 @@ export function handleGetRecipientUtxos( return async (ctx) => { log.info("getRecipientUtxos"); - try { - const params = (ctx as unknown as { params?: RouteParams }).params; - const address = params?.address; + const params = (ctx as unknown as { params?: RouteParams }).params; + const address = params?.address; - if (!address) { - ctx.response.status = Status.BadRequest; - ctx.response.body = { message: "Recipient address is required" }; - return; - } + if (!address) { + throw new E.RESOURCE_ID_REQUIRED("Recipient address"); + } - const channelContractId = ctx.request.url.searchParams.get( - "channelContractId", - ); - if (!channelContractId) { - ctx.response.status = Status.BadRequest; - ctx.response.body = { - message: "channelContractId query param is required", - }; - return; - } - - const councilId = ctx.request.url.searchParams.get("councilId"); - if (!councilId) { - ctx.response.status = Status.BadRequest; - ctx.response.body = { message: "councilId query param is required" }; - return; - } - - const count = Number(ctx.request.url.searchParams.get("count") || "1"); - if (!Number.isInteger(count) || count < 1 || count > 300) { - ctx.response.status = Status.BadRequest; - ctx.response.body = { message: "count must be 1-300" }; - return; - } - - const result = await getRecipientUtxos( - councilId, - address, - channelContractId, - count, - deps, - ); + const channelContractId = ctx.request.url.searchParams.get( + "channelContractId", + ); + if (!channelContractId) { + throw new E.RESOURCE_ID_REQUIRED("channelContractId query param"); + } - ctx.response.status = Status.OK; - ctx.response.body = { - message: result.registered - ? "Recipient has UTXO addresses" - : "Recipient not registered", - data: result, - }; - } catch (error) { - log.error(error, "failed to check recipient UTXOs"); - ctx.response.status = Status.InternalServerError; - ctx.response.body = { message: "Failed to check recipient" }; + const councilId = ctx.request.url.searchParams.get("councilId"); + if (!councilId) { + throw new E.RESOURCE_ID_REQUIRED("councilId query param"); + } + + const count = Number(ctx.request.url.searchParams.get("count") || "1"); + if (!Number.isInteger(count) || count < 1 || count > 300) { + throw new E.VALIDATION_FAILED("count must be 1-300"); } + + const result = await getRecipientUtxos( + councilId, + address, + channelContractId, + count, + deps, + ); + + ctx.response.status = Status.OK; + ctx.response.body = { + message: result.registered + ? "Recipient has UTXO addresses" + : "Recipient not registered", + data: result, + }; }; } @@ -107,90 +92,71 @@ export function handlePostEscrow( return async (ctx) => { log.info("postEscrow"); + const session = ctx.state.session as JwtSessionData; + + let body; try { - const session = ctx.state.session as JwtSessionData; - - const body = await ctx.request.body.json(); - const { - councilId, - senderAddress, - recipientAddress, - amount, - assetCode, - channelContractId, - } = body; - - if ( - !councilId || !senderAddress || !recipientAddress || !amount || - !assetCode || !channelContractId - ) { - ctx.response.status = Status.BadRequest; - ctx.response.body = { - message: - "councilId, senderAddress, recipientAddress, amount, assetCode, and channelContractId are required", - }; - return; - } - - if (typeof amount !== "string" || !AMOUNT_RE.test(amount)) { - ctx.response.status = Status.BadRequest; - ctx.response.body = { - message: "amount must be a positive integer string (stroops)", - }; - return; - } - - const amountBigInt = BigInt(amount); - if (amountBigInt <= 0n) { - ctx.response.status = Status.BadRequest; - ctx.response.body = { message: "amount must be positive" }; - return; - } - - if (!StrKey.isValidContractId(channelContractId)) { - ctx.response.status = Status.BadRequest; - ctx.response.body = { message: "Invalid channelContractId" }; - return; - } - - // Verify the calling provider belongs to this council - const provider = await providerRepo.findByPublicKey( - councilId, - session.sub, - ); - if (!provider) { - ctx.response.status = Status.Forbidden; - ctx.response.body = { - message: "Provider not a member of this council", - }; - return; - } - - const result = await createEscrow({ - councilId, - senderAddress, - recipientAddress, - amount: amountBigInt, - assetCode, - channelContractId, - submittedByProvider: session.sub, - }, { log }); - - ctx.response.status = Status.OK; - ctx.response.body = { - message: "Escrow created", - data: result, - }; + body = await ctx.request.body.json(); } catch (error) { - if (error instanceof SyntaxError) { - ctx.response.status = Status.BadRequest; - ctx.response.body = { message: "Invalid request body" }; - } else { - log.error(error, "failed to create escrow"); - ctx.response.status = Status.InternalServerError; - ctx.response.body = { message: "Failed to create escrow" }; - } + throw new E.INVALID_REQUEST_BODY(error); + } + const { + councilId, + senderAddress, + recipientAddress, + amount, + assetCode, + channelContractId, + } = body; + + if ( + !councilId || !senderAddress || !recipientAddress || !amount || + !assetCode || !channelContractId + ) { + throw new E.VALIDATION_FAILED( + "councilId, senderAddress, recipientAddress, amount, assetCode, and channelContractId are required", + ); + } + + if (typeof amount !== "string" || !AMOUNT_RE.test(amount)) { + throw new E.VALIDATION_FAILED( + "amount must be a positive integer string (stroops)", + ); + } + + const amountBigInt = BigInt(amount); + if (amountBigInt <= 0n) { + throw new E.VALIDATION_FAILED("amount must be positive"); } + + if (!StrKey.isValidContractId(channelContractId)) { + throw new E.VALIDATION_FAILED("Invalid channelContractId"); + } + + // Verify the calling provider belongs to this council + const provider = await providerRepo.findByPublicKey( + councilId, + session.sub, + ); + if (!provider) { + throw new E.FORBIDDEN("Provider not a member of this council"); + } + + const result = await createEscrow({ + councilId, + senderAddress, + recipientAddress, + amount: amountBigInt, + assetCode, + channelContractId, + submittedByProvider: session.sub, + }, { log }); + + ctx.response.status = Status.OK; + ctx.response.body = { + message: "Escrow created", + data: result, + }; }; } @@ -207,32 +173,24 @@ export function handleGetEscrowSummary( return async (ctx) => { log.info("getEscrowSummary"); - try { - const params = (ctx as unknown as { params?: RouteParams }).params; - const address = params?.address; - - if (!address) { - ctx.response.status = Status.BadRequest; - ctx.response.body = { message: "Recipient address is required" }; - return; - } - - const summary = await getEscrowSummary(address, deps); - - ctx.response.status = Status.OK; - ctx.response.body = { - message: "Escrow summary retrieved", - data: { - pendingCount: summary.pendingCount, - pendingTotal: summary.pendingTotal.toString(), - escrows: summary.escrows, - }, - }; - } catch (error) { - log.error(error, "failed to get escrow summary"); - ctx.response.status = Status.InternalServerError; - ctx.response.body = { message: "Failed to retrieve escrow summary" }; + const params = (ctx as unknown as { params?: RouteParams }).params; + const address = params?.address; + + if (!address) { + throw new E.RESOURCE_ID_REQUIRED("Recipient address"); } + + const summary = await getEscrowSummary(address, deps); + + ctx.response.status = Status.OK; + ctx.response.body = { + message: "Escrow summary retrieved", + data: { + pendingCount: summary.pendingCount, + pendingTotal: summary.pendingTotal.toString(), + escrows: summary.escrows, + }, + }; }; } @@ -251,51 +209,41 @@ export function handlePostEscrowRelease( return async (ctx) => { log.info("postEscrowRelease"); - try { - const params = (ctx as unknown as { params?: RouteParams }).params; - const address = params?.address; - - if (!address) { - ctx.response.status = Status.BadRequest; - ctx.response.body = { message: "Recipient address is required" }; - return; - } - - const body = await ctx.request.body.json(); - const { channelContractId } = body; - - if (!channelContractId || !StrKey.isValidContractId(channelContractId)) { - ctx.response.status = Status.BadRequest; - ctx.response.body = { message: "Valid channelContractId is required" }; - return; - } - - const result = await releaseEscrowsForRecipient( - address, - channelContractId, - { log }, - ); + const params = (ctx as unknown as { params?: RouteParams }).params; + const address = params?.address; + + if (!address) { + throw new E.RESOURCE_ID_REQUIRED("Recipient address"); + } - ctx.response.status = Status.OK; - ctx.response.body = { - message: result.released > 0 - ? `Released ${result.released} escrow(s)` - : "No pending escrows for this recipient", - data: { - released: result.released, - totalReleased: result.totalReleased.toString(), - totalFees: result.totalFees.toString(), - }, - }; + let body; + try { + body = await ctx.request.body.json(); } catch (error) { - if (error instanceof SyntaxError) { - ctx.response.status = Status.BadRequest; - ctx.response.body = { message: "Invalid request body" }; - } else { - log.error(error, "failed to release escrow"); - ctx.response.status = Status.InternalServerError; - ctx.response.body = { message: "Failed to release escrow" }; - } + throw new E.INVALID_REQUEST_BODY(error); + } + const { channelContractId } = body; + + if (!channelContractId || !StrKey.isValidContractId(channelContractId)) { + throw new E.VALIDATION_FAILED("Valid channelContractId is required"); } + + const result = await releaseEscrowsForRecipient( + address, + channelContractId, + { log }, + ); + + ctx.response.status = Status.OK; + ctx.response.body = { + message: result.released > 0 + ? `Released ${result.released} escrow(s)` + : "No pending escrows for this recipient", + data: { + released: result.released, + totalReleased: result.totalReleased.toString(), + totalFees: result.totalFees.toString(), + }, + }; }; } diff --git a/src/http/v1/council/helpers.ts b/src/http/v1/council/helpers.ts index 1df7d18..1a4c962 100644 --- a/src/http/v1/council/helpers.ts +++ b/src/http/v1/council/helpers.ts @@ -1,35 +1,33 @@ -import { type Context, Status } from "@oak/oak"; +import type { Context } from "@oak/oak"; import type { CouncilMetadataRepository } from "@/persistence/drizzle/repository/council-metadata.repository.ts"; import type { CouncilMetadata } from "@/persistence/drizzle/entity/council-metadata.entity.ts"; +import * as E from "@/http/v1/council/error.ts"; /** - * Extract councilId from query parameter. Returns null and sets 400 response if missing. + * Extract councilId from the query string, or throw a structured + * `MISSING_COUNCIL_ID` (400) that the edge translates to an `ErrorResponse`. */ -export function requireCouncilId(ctx: Context): string | null { +export function requireCouncilId(ctx: Context): string { const councilId = ctx.request.url.searchParams.get("councilId"); if (!councilId) { - ctx.response.status = Status.BadRequest; - ctx.response.body = { message: "councilId query parameter is required" }; - return null; + throw new E.MISSING_COUNCIL_ID(); } return councilId; } /** - * Verify the authenticated user owns the specified council. - * Returns the council if owned, null otherwise (with 404 response set). + * Verify the authenticated user owns the council. Returns the council when + * owned; otherwise throws a structured `COUNCIL_NOT_FOUND` (404). */ export async function requireCouncilOwnership( ctx: Context, councilId: string, metadataRepo: CouncilMetadataRepository, -): Promise { +): Promise { const ownerPublicKey = (ctx.state.session as { sub: string }).sub; const council = await metadataRepo.getByIdAndOwner(councilId, ownerPublicKey); if (!council) { - ctx.response.status = Status.NotFound; - ctx.response.body = { message: "Council not found" }; - return null; + throw new E.COUNCIL_NOT_FOUND(); } return council; } diff --git a/src/http/v1/council/join-requests.ts b/src/http/v1/council/join-requests.ts index 3a2ee9b..21ca8ca 100644 --- a/src/http/v1/council/join-requests.ts +++ b/src/http/v1/council/join-requests.ts @@ -11,6 +11,8 @@ import { ProviderStatus, } from "@/persistence/drizzle/entity/council-provider.entity.ts"; import { CouncilMetadataRepository } from "@/persistence/drizzle/repository/council-metadata.repository.ts"; +import * as E from "@/http/v1/error.ts"; +import { COUNCIL_NOT_FOUND, MISSING_COUNCIL_ID } from "./error.ts"; import type { Logger } from "@/utils/logger/index.ts"; const metadataRepo = new CouncilMetadataRepository(drizzleClient); @@ -60,47 +62,35 @@ export function handleListJoinRequests( return async (ctx) => { log.info("listJoinRequests"); - try { - const councilId = ctx.request.url.searchParams.get("councilId"); - if (!councilId) { - ctx.response.status = Status.BadRequest; - ctx.response.body = { - message: "councilId query parameter is required", - }; - return; - } - - // Verify ownership - const ownerPublicKey = (ctx.state.session as { sub: string }).sub; - const council = await metadataRepo.getByIdAndOwner( - councilId, - ownerPublicKey, - ); - if (!council) { - ctx.response.status = Status.NotFound; - ctx.response.body = { message: "Council not found" }; - return; - } + const councilId = ctx.request.url.searchParams.get("councilId"); + if (!councilId) { + throw new MISSING_COUNCIL_ID(); + } - const statusFilter = ctx.request.url.searchParams.get("status"); + // Verify ownership + const ownerPublicKey = (ctx.state.session as { sub: string }).sub; + const council = await metadataRepo.getByIdAndOwner( + councilId, + ownerPublicKey, + ); + if (!council) { + throw new COUNCIL_NOT_FOUND(); + } - let requests; - if (statusFilter === "PENDING") { - requests = await joinRequestRepo.listPending(councilId); - } else { - requests = await joinRequestRepo.listAll(councilId); - } + const statusFilter = ctx.request.url.searchParams.get("status"); - ctx.response.status = Status.OK; - ctx.response.body = { - message: "Join requests retrieved", - data: requests.map(formatJoinRequest), - }; - } catch (error) { - log.error(error, "failed to list join requests"); - ctx.response.status = Status.InternalServerError; - ctx.response.body = { message: "Failed to retrieve join requests" }; + let requests; + if (statusFilter === "PENDING") { + requests = await joinRequestRepo.listPending(councilId); + } else { + requests = await joinRequestRepo.listAll(councilId); } + + ctx.response.status = Status.OK; + ctx.response.body = { + message: "Join requests retrieved", + data: requests.map(formatJoinRequest), + }; }; } @@ -119,132 +109,114 @@ export function handleApproveJoinRequest( return async (ctx) => { log.info("approveJoinRequest"); - try { - const params = (ctx as unknown as { params?: RouteParams }).params; - const id = params?.id; - - if (!id) { - ctx.response.status = Status.BadRequest; - ctx.response.body = { message: "Request ID is required" }; - return; - } - log.debug("id", id); + const params = (ctx as unknown as { params?: RouteParams }).params; + const id = params?.id; - const adminPublicKey = (ctx.state.session as { sub: string }).sub; + if (!id) { + throw new E.RESOURCE_ID_REQUIRED("Request ID"); + } + log.debug("id", id); - // Verify the request's council is owned by this admin - const requestRow = await joinRequestRepo.findById(id); - if (!requestRow) { - ctx.response.status = Status.NotFound; - ctx.response.body = { message: "Join request not found" }; - return; - } - const council = await metadataRepo.getByIdAndOwner( - requestRow.councilId, - adminPublicKey, - ); - if (!council) { - ctx.response.status = Status.NotFound; - ctx.response.body = { message: "Join request not found" }; - return; - } + const adminPublicKey = (ctx.state.session as { sub: string }).sub; + + // Verify the request's council is owned by this admin + const requestRow = await joinRequestRepo.findById(id); + if (!requestRow) { + throw new E.RESOURCE_NOT_FOUND("Join request not found"); + } + const council = await metadataRepo.getByIdAndOwner( + requestRow.councilId, + adminPublicKey, + ); + if (!council) { + throw new E.RESOURCE_NOT_FOUND("Join request not found"); + } - // Atomic read-check-update inside a transaction with row lock. - // SELECT ... FOR UPDATE prevents concurrent approvals of the same request. - const request = await drizzleClient.transaction(async (tx) => { - const [row] = await tx - .select() - .from(providerJoinRequest) - .where( - and( - eq(providerJoinRequest.id, id), - isNull(providerJoinRequest.deletedAt), - ), - ) - .for("update") - .limit(1); - - if (!row) return null; - if (row.status !== JoinRequestStatus.PENDING) return row; - - // Update request status - await tx - .update(providerJoinRequest) - .set({ - status: JoinRequestStatus.APPROVED, - reviewedAt: new Date(), - reviewedBy: adminPublicKey, - updatedAt: new Date(), - }) - .where(eq(providerJoinRequest.id, id)); - - // Create provider record if not exists for this council - const [existing] = await tx - .select() - .from(councilProvider) - .where( - and( - eq(councilProvider.councilId, row.councilId), - eq(councilProvider.publicKey, row.publicKey), - ), - ) - .limit(1); - - if (!existing) { - await tx.insert(councilProvider).values({ - id: crypto.randomUUID(), - councilId: row.councilId, - publicKey: row.publicKey, - status: ProviderStatus.ACTIVE, - label: row.label, - contactEmail: row.contactEmail, - providerUrl: row.providerUrl, - jurisdictions: row.jurisdictions, - createdAt: new Date(), - updatedAt: new Date(), - }); - } - - return { - ...row, + // Atomic read-check-update inside a transaction with row lock. + // SELECT ... FOR UPDATE prevents concurrent approvals of the same request. + const request = await drizzleClient.transaction(async (tx) => { + const [row] = await tx + .select() + .from(providerJoinRequest) + .where( + and( + eq(providerJoinRequest.id, id), + isNull(providerJoinRequest.deletedAt), + ), + ) + .for("update") + .limit(1); + + if (!row) return null; + if (row.status !== JoinRequestStatus.PENDING) return row; + + // Update request status + await tx + .update(providerJoinRequest) + .set({ status: JoinRequestStatus.APPROVED, reviewedAt: new Date(), reviewedBy: adminPublicKey, - }; - }); - - if (!request) { - ctx.response.status = Status.NotFound; - ctx.response.body = { message: "Join request not found" }; - return; + updatedAt: new Date(), + }) + .where(eq(providerJoinRequest.id, id)); + + // Create provider record if not exists for this council + const [existing] = await tx + .select() + .from(councilProvider) + .where( + and( + eq(councilProvider.councilId, row.councilId), + eq(councilProvider.publicKey, row.publicKey), + ), + ) + .limit(1); + + if (!existing) { + await tx.insert(councilProvider).values({ + id: crypto.randomUUID(), + councilId: row.councilId, + publicKey: row.publicKey, + status: ProviderStatus.ACTIVE, + label: row.label, + contactEmail: row.contactEmail, + providerUrl: row.providerUrl, + jurisdictions: row.jurisdictions, + createdAt: new Date(), + updatedAt: new Date(), + }); } - if (request.status !== JoinRequestStatus.APPROVED) { - ctx.response.status = Status.Conflict; - ctx.response.body = { - message: `Request is already ${request.status}`, - }; - return; - } + return { + ...row, + status: JoinRequestStatus.APPROVED, + reviewedAt: new Date(), + reviewedBy: adminPublicKey, + }; + }); - log.debug("providerKey", request.publicKey); - log.event("join request approved"); + if (!request) { + throw new E.RESOURCE_NOT_FOUND("Join request not found"); + } - ctx.response.status = Status.OK; - ctx.response.body = { - message: "Join request approved", - data: formatJoinRequest({ - ...request, - status: JoinRequestStatus.APPROVED, - reviewedAt: new Date(), - reviewedBy: adminPublicKey, - }), - }; - } catch (error) { - log.error(error, "failed to approve join request"); - ctx.response.status = Status.InternalServerError; - ctx.response.body = { message: "Failed to approve join request" }; + if (request.status !== JoinRequestStatus.APPROVED) { + throw new E.RESOURCE_CONFLICT(`Request is already ${request.status}`); } + + log.debug("providerKey", request.publicKey); + log.event("join request approved"); + + ctx.response.status = Status.OK; + ctx.response.body = { + message: "Join request approved", + data: formatJoinRequest({ + ...request, + status: JoinRequestStatus.APPROVED, + reviewedAt: new Date(), + reviewedBy: adminPublicKey, + }), + }; }; } @@ -259,96 +231,78 @@ export function handleRejectJoinRequest( return async (ctx) => { log.info("rejectJoinRequest"); - try { - const params = (ctx as unknown as { params?: RouteParams }).params; - const id = params?.id; - - if (!id) { - ctx.response.status = Status.BadRequest; - ctx.response.body = { message: "Request ID is required" }; - return; - } - log.debug("id", id); + const params = (ctx as unknown as { params?: RouteParams }).params; + const id = params?.id; - const adminPublicKey = (ctx.state.session as { sub: string }).sub; + if (!id) { + throw new E.RESOURCE_ID_REQUIRED("Request ID"); + } + log.debug("id", id); - // Verify the request's council is owned by this admin - const rejectRequestRow = await joinRequestRepo.findById(id); - if (!rejectRequestRow) { - ctx.response.status = Status.NotFound; - ctx.response.body = { message: "Join request not found" }; - return; - } - const rejectCouncil = await metadataRepo.getByIdAndOwner( - rejectRequestRow.councilId, - adminPublicKey, - ); - if (!rejectCouncil) { - ctx.response.status = Status.NotFound; - ctx.response.body = { message: "Join request not found" }; - return; - } + const adminPublicKey = (ctx.state.session as { sub: string }).sub; - const request = await drizzleClient.transaction(async (tx) => { - const [row] = await tx - .select() - .from(providerJoinRequest) - .where( - and( - eq(providerJoinRequest.id, id), - isNull(providerJoinRequest.deletedAt), - ), - ) - .for("update") - .limit(1); - - if (!row) return null; - if (row.status !== JoinRequestStatus.PENDING) return row; - - await tx - .update(providerJoinRequest) - .set({ - status: JoinRequestStatus.REJECTED, - reviewedAt: new Date(), - reviewedBy: adminPublicKey, - updatedAt: new Date(), - }) - .where(eq(providerJoinRequest.id, id)); - - return { - ...row, + // Verify the request's council is owned by this admin + const rejectRequestRow = await joinRequestRepo.findById(id); + if (!rejectRequestRow) { + throw new E.RESOURCE_NOT_FOUND("Join request not found"); + } + const rejectCouncil = await metadataRepo.getByIdAndOwner( + rejectRequestRow.councilId, + adminPublicKey, + ); + if (!rejectCouncil) { + throw new E.RESOURCE_NOT_FOUND("Join request not found"); + } + + const request = await drizzleClient.transaction(async (tx) => { + const [row] = await tx + .select() + .from(providerJoinRequest) + .where( + and( + eq(providerJoinRequest.id, id), + isNull(providerJoinRequest.deletedAt), + ), + ) + .for("update") + .limit(1); + + if (!row) return null; + if (row.status !== JoinRequestStatus.PENDING) return row; + + await tx + .update(providerJoinRequest) + .set({ status: JoinRequestStatus.REJECTED, reviewedAt: new Date(), reviewedBy: adminPublicKey, - }; - }); + updatedAt: new Date(), + }) + .where(eq(providerJoinRequest.id, id)); + + return { + ...row, + status: JoinRequestStatus.REJECTED, + reviewedAt: new Date(), + reviewedBy: adminPublicKey, + }; + }); - if (!request) { - ctx.response.status = Status.NotFound; - ctx.response.body = { message: "Join request not found" }; - return; - } + if (!request) { + throw new E.RESOURCE_NOT_FOUND("Join request not found"); + } - if (request.status !== JoinRequestStatus.REJECTED) { - ctx.response.status = Status.Conflict; - ctx.response.body = { - message: `Request is already ${request.status}`, - }; - return; - } + if (request.status !== JoinRequestStatus.REJECTED) { + throw new E.RESOURCE_CONFLICT(`Request is already ${request.status}`); + } - log.debug("providerKey", request.publicKey); - log.event("join request rejected"); + log.debug("providerKey", request.publicKey); + log.event("join request rejected"); - ctx.response.status = Status.OK; - ctx.response.body = { - message: "Join request rejected", - data: formatJoinRequest(request), - }; - } catch (error) { - log.error(error, "failed to reject join request"); - ctx.response.status = Status.InternalServerError; - ctx.response.body = { message: "Failed to reject join request" }; - } + ctx.response.status = Status.OK; + ctx.response.body = { + message: "Join request rejected", + data: formatJoinRequest(request), + }; }; } diff --git a/src/http/v1/council/jurisdictions.ts b/src/http/v1/council/jurisdictions.ts index f81d7a7..8fcc34c 100644 --- a/src/http/v1/council/jurisdictions.ts +++ b/src/http/v1/council/jurisdictions.ts @@ -3,6 +3,7 @@ import { drizzleClient } from "@/persistence/drizzle/config.ts"; import { CouncilJurisdictionRepository } from "@/persistence/drizzle/repository/council-jurisdiction.repository.ts"; import { requireCouncilId, requireCouncilOwnership } from "./helpers.ts"; import { CouncilMetadataRepository } from "@/persistence/drizzle/repository/council-metadata.repository.ts"; +import * as E from "@/http/v1/error.ts"; import type { Logger } from "@/utils/logger/index.ts"; const metadataRepo = new CouncilMetadataRepository(drizzleClient); @@ -18,27 +19,20 @@ export function handleListJurisdictions( return async (ctx) => { log.info("listJurisdictions"); - try { - const councilId = requireCouncilId(ctx); - if (!councilId) return; - if (!await requireCouncilOwnership(ctx, councilId, metadataRepo)) return; - - const jurisdictions = await jurisdictionRepo.listAll(councilId); - - ctx.response.status = Status.OK; - ctx.response.body = { - message: "Jurisdictions retrieved", - data: jurisdictions.map((j) => ({ - id: j.id, - countryCode: j.countryCode, - label: j.label, - })), - }; - } catch (error) { - log.error(error, "failed to list jurisdictions"); - ctx.response.status = Status.InternalServerError; - ctx.response.body = { message: "Failed to retrieve jurisdictions" }; - } + const councilId = requireCouncilId(ctx); + await requireCouncilOwnership(ctx, councilId, metadataRepo); + + const jurisdictions = await jurisdictionRepo.listAll(councilId); + + ctx.response.status = Status.OK; + ctx.response.body = { + message: "Jurisdictions retrieved", + data: jurisdictions.map((j) => ({ + id: j.id, + countryCode: j.countryCode, + label: j.label, + })), + }; }; } @@ -49,84 +43,70 @@ export function handleAddJurisdiction( return async (ctx) => { log.info("addJurisdiction"); + const councilId = requireCouncilId(ctx); + await requireCouncilOwnership(ctx, councilId, metadataRepo); + log.debug("councilId", councilId); + + let body; try { - const councilId = requireCouncilId(ctx); - if (!councilId) return; - if (!await requireCouncilOwnership(ctx, councilId, metadataRepo)) return; - log.debug("councilId", councilId); - - const body = await ctx.request.body.json(); - const { countryCode, label } = body; - - if (!countryCode || typeof countryCode !== "string") { - ctx.response.status = Status.BadRequest; - ctx.response.body = { message: "countryCode is required" }; - return; - } - - const code = countryCode.toUpperCase(); - if (!COUNTRY_CODE_RE.test(code)) { - ctx.response.status = Status.BadRequest; - ctx.response.body = { - message: - "countryCode must be a valid ISO 3166-1 alpha-2 code (e.g. US, BR, DE)", - }; - return; - } - - const existing = await jurisdictionRepo.findByCountryCode( - councilId, - code, + body = await ctx.request.body.json(); + } catch (error) { + throw new E.INVALID_REQUEST_BODY(error); + } + const { countryCode, label } = body; + + if (!countryCode || typeof countryCode !== "string") { + throw new E.VALIDATION_FAILED("countryCode is required"); + } + + const code = countryCode.toUpperCase(); + if (!COUNTRY_CODE_RE.test(code)) { + throw new E.VALIDATION_FAILED( + "countryCode must be a valid ISO 3166-1 alpha-2 code (e.g. US, BR, DE)", ); - if (existing) { - ctx.response.status = Status.Conflict; - ctx.response.body = { message: `Jurisdiction ${code} already exists` }; - return; - } + } - const deleted = await jurisdictionRepo.findDeletedByCountryCode( + const existing = await jurisdictionRepo.findByCountryCode( + councilId, + code, + ); + if (existing) { + throw new E.RESOURCE_CONFLICT(`Jurisdiction ${code} already exists`); + } + + const deleted = await jurisdictionRepo.findDeletedByCountryCode( + councilId, + code, + ); + let jurisdiction; + if (deleted) { + jurisdiction = await jurisdictionRepo.update(deleted.id, { + deletedAt: null, + label: label?.trim() ?? deleted.label, + }); + } else { + jurisdiction = await jurisdictionRepo.create({ + id: crypto.randomUUID(), councilId, - code, - ); - let jurisdiction; - if (deleted) { - jurisdiction = await jurisdictionRepo.update(deleted.id, { - deletedAt: null, - label: label?.trim() ?? deleted.label, - }); - } else { - jurisdiction = await jurisdictionRepo.create({ - id: crypto.randomUUID(), - councilId, - countryCode: code, - label: label?.trim() ?? null, - createdAt: new Date(), - updatedAt: new Date(), - }); - } - - log.debug("countryCode", code); - log.event("jurisdiction added"); - - ctx.response.status = Status.OK; - ctx.response.body = { - message: "Jurisdiction added", - data: { - id: jurisdiction.id, - countryCode: jurisdiction.countryCode, - label: jurisdiction.label, - }, - }; - } catch (error) { - if (error instanceof SyntaxError) { - ctx.response.status = Status.BadRequest; - ctx.response.body = { message: "Invalid request body" }; - } else { - log.error(error, "failed to add jurisdiction"); - ctx.response.status = Status.InternalServerError; - ctx.response.body = { message: "Failed to add jurisdiction" }; - } + countryCode: code, + label: label?.trim() ?? null, + createdAt: new Date(), + updatedAt: new Date(), + }); } + + log.debug("countryCode", code); + log.event("jurisdiction added"); + + ctx.response.status = Status.OK; + ctx.response.body = { + message: "Jurisdiction added", + data: { + id: jurisdiction.id, + countryCode: jurisdiction.countryCode, + label: jurisdiction.label, + }, + }; }; } @@ -139,42 +119,31 @@ export function handleRemoveJurisdiction( return async (ctx) => { log.info("removeJurisdiction"); - try { - const councilId = requireCouncilId(ctx); - if (!councilId) return; - if (!await requireCouncilOwnership(ctx, councilId, metadataRepo)) return; - log.debug("councilId", councilId); + const councilId = requireCouncilId(ctx); + await requireCouncilOwnership(ctx, councilId, metadataRepo); + log.debug("councilId", councilId); - const params = (ctx as unknown as { params?: RouteParams }).params; - const code = params?.code?.toUpperCase(); + const params = (ctx as unknown as { params?: RouteParams }).params; + const code = params?.code?.toUpperCase(); - if (!code || !COUNTRY_CODE_RE.test(code)) { - ctx.response.status = Status.BadRequest; - ctx.response.body = { message: "Valid country code is required" }; - return; - } + if (!code || !COUNTRY_CODE_RE.test(code)) { + throw new E.VALIDATION_FAILED("Valid country code is required"); + } - const existing = await jurisdictionRepo.findByCountryCode( - councilId, - code, - ); - if (!existing) { - ctx.response.status = Status.NotFound; - ctx.response.body = { message: `Jurisdiction ${code} not found` }; - return; - } + const existing = await jurisdictionRepo.findByCountryCode( + councilId, + code, + ); + if (!existing) { + throw new E.RESOURCE_NOT_FOUND(`Jurisdiction ${code} not found`); + } - await jurisdictionRepo.delete(existing.id); + await jurisdictionRepo.delete(existing.id); - log.debug("countryCode", code); - log.event("jurisdiction removed"); + log.debug("countryCode", code); + log.event("jurisdiction removed"); - ctx.response.status = Status.OK; - ctx.response.body = { message: `Jurisdiction ${code} removed` }; - } catch (error) { - log.error(error, "failed to remove jurisdiction"); - ctx.response.status = Status.InternalServerError; - ctx.response.body = { message: "Failed to remove jurisdiction" }; - } + ctx.response.status = Status.OK; + ctx.response.body = { message: `Jurisdiction ${code} removed` }; }; } diff --git a/src/http/v1/council/metadata.ts b/src/http/v1/council/metadata.ts index cce7c6b..6955f65 100644 --- a/src/http/v1/council/metadata.ts +++ b/src/http/v1/council/metadata.ts @@ -3,6 +3,7 @@ import { drizzleClient } from "@/persistence/drizzle/config.ts"; import { CouncilMetadataRepository } from "@/persistence/drizzle/repository/council-metadata.repository.ts"; import { encryptSecret } from "@/core/crypto/encrypt-secret.ts"; import { SERVICE_AUTH_SECRET } from "@/config/env.ts"; +import * as E from "@/http/v1/error.ts"; import type { Logger } from "@/utils/logger/index.ts"; const metadataRepo = new CouncilMetadataRepository(drizzleClient); @@ -22,44 +23,32 @@ export function handleGetMetadata( return async (ctx) => { log.info("getMetadata"); - try { - const councilId = getCouncilId(ctx); - if (!councilId) { - ctx.response.status = Status.BadRequest; - ctx.response.body = { - message: "councilId query parameter is required", - }; - return; - } - - const ownerPublicKey = (ctx.state.session as { sub: string }).sub; - const metadata = await metadataRepo.getByIdAndOwner( - councilId, - ownerPublicKey, - ); + const councilId = getCouncilId(ctx); + if (!councilId) { + throw new E.RESOURCE_ID_REQUIRED("councilId query parameter"); + } - if (!metadata) { - ctx.response.status = Status.NotFound; - ctx.response.body = { message: "Council not found" }; - return; - } + const ownerPublicKey = (ctx.state.session as { sub: string }).sub; + const metadata = await metadataRepo.getByIdAndOwner( + councilId, + ownerPublicKey, + ); - ctx.response.status = Status.OK; - ctx.response.body = { - message: "Council metadata retrieved", - data: { - councilId: metadata.id, - name: metadata.name, - description: metadata.description, - contactEmail: metadata.contactEmail, - councilPublicKey: metadata.councilPublicKey, - }, - }; - } catch (error) { - log.error(error, "failed to get council metadata"); - ctx.response.status = Status.InternalServerError; - ctx.response.body = { message: "Failed to retrieve council metadata" }; + if (!metadata) { + throw new E.RESOURCE_NOT_FOUND("Council not found"); } + + ctx.response.status = Status.OK; + ctx.response.body = { + message: "Council metadata retrieved", + data: { + councilId: metadata.id, + name: metadata.name, + description: metadata.description, + contactEmail: metadata.contactEmail, + councilPublicKey: metadata.councilPublicKey, + }, + }; }; } @@ -74,26 +63,20 @@ export function handleListCouncils( return async (ctx) => { log.info("listCouncils"); - try { - const ownerPublicKey = (ctx.state.session as { sub: string }).sub; - const councils = await metadataRepo.listByOwner(ownerPublicKey); - - ctx.response.status = Status.OK; - ctx.response.body = { - message: "Councils retrieved", - data: councils.map((c) => ({ - councilId: c.id, - name: c.name, - description: c.description, - contactEmail: c.contactEmail, - councilPublicKey: c.councilPublicKey, - })), - }; - } catch (error) { - log.error(error, "failed to list councils"); - ctx.response.status = Status.InternalServerError; - ctx.response.body = { message: "Failed to list councils" }; - } + const ownerPublicKey = (ctx.state.session as { sub: string }).sub; + const councils = await metadataRepo.listByOwner(ownerPublicKey); + + ctx.response.status = Status.OK; + ctx.response.body = { + message: "Councils retrieved", + data: councils.map((c) => ({ + councilId: c.id, + name: c.name, + description: c.description, + contactEmail: c.contactEmail, + councilPublicKey: c.councilPublicKey, + })), + }; }; } @@ -109,147 +92,122 @@ export function handlePutMetadata( return async (ctx) => { log.info("putMetadata"); + let body; try { - const body = await ctx.request.body.json(); - const { councilId, name, description, contactEmail, opexPublicKey } = - body; - - if (!councilId || typeof councilId !== "string") { - ctx.response.status = Status.BadRequest; - ctx.response.body = { message: "councilId is required" }; - return; - } - - if (!name || typeof name !== "string" || name.trim().length === 0) { - ctx.response.status = Status.BadRequest; - ctx.response.body = { message: "name is required" }; - return; - } + body = await ctx.request.body.json(); + } catch (error) { + throw new E.INVALID_REQUEST_BODY(error); + } + const { councilId, name, description, contactEmail, opexPublicKey } = body; - if (name.length > 200) { - ctx.response.status = Status.BadRequest; - ctx.response.body = { message: "name must be at most 200 characters" }; - return; - } + if (!councilId || typeof councilId !== "string") { + throw new E.RESOURCE_ID_REQUIRED("councilId"); + } - if (description && typeof description !== "string") { - ctx.response.status = Status.BadRequest; - ctx.response.body = { message: "description must be a string" }; - return; - } - if (description && description.length > 2000) { - ctx.response.status = Status.BadRequest; - ctx.response.body = { - message: "description must be at most 2000 characters", - }; - return; - } + if (!name || typeof name !== "string" || name.trim().length === 0) { + throw new E.VALIDATION_FAILED("name is required"); + } - if (contactEmail && typeof contactEmail !== "string") { - ctx.response.status = Status.BadRequest; - ctx.response.body = { message: "contactEmail must be a string" }; - return; - } - if (contactEmail && contactEmail.length > 200) { - ctx.response.status = Status.BadRequest; - ctx.response.body = { - message: "contactEmail must be at most 200 characters", - }; - return; - } + if (name.length > 200) { + throw new E.VALIDATION_FAILED("name must be at most 200 characters"); + } - const sessionPublicKey = (ctx.state.session as { sub: string })?.sub; + if (description && typeof description !== "string") { + throw new E.VALIDATION_FAILED("description must be a string"); + } + if (description && description.length > 2000) { + throw new E.VALIDATION_FAILED( + "description must be at most 2000 characters", + ); + } - const updateData: Record = { - name: name.trim(), - }; - if (description !== undefined) { - updateData.description = description?.trim() ?? null; - } - if (contactEmail !== undefined) { - updateData.contactEmail = contactEmail?.trim() ?? null; - } - if (sessionPublicKey) updateData.councilPublicKey = sessionPublicKey; - if (opexPublicKey) { - try { - const { Keypair } = await import("stellar-sdk"); - Keypair.fromPublicKey(opexPublicKey.trim()); - } catch { - ctx.response.status = Status.BadRequest; - ctx.response.body = { - message: "opexPublicKey must be a valid Stellar public key", - }; - return; - } - updateData.opexPublicKey = opexPublicKey.trim(); - } + if (contactEmail && typeof contactEmail !== "string") { + throw new E.VALIDATION_FAILED("contactEmail must be a string"); + } + if (contactEmail && contactEmail.length > 200) { + throw new E.VALIDATION_FAILED( + "contactEmail must be at most 200 characters", + ); + } - // Verify ownership if council already exists - const existing = await metadataRepo.getByIdIncludingDeleted(councilId); - if (existing && existing.councilPublicKey !== sessionPublicKey) { - log.debug("councilId", councilId); - log.debug("existingOwner", existing.councilPublicKey); - log.debug("sessionOwner", sessionPublicKey); - log.error( - new Error("ownership mismatch"), - "council ownership mismatch on update", - ); - ctx.response.status = Status.NotFound; - ctx.response.body = { message: "Council not found" }; - return; - } + const sessionPublicKey = (ctx.state.session as { sub: string })?.sub; - // Generate per-council derivation root on first creation only. - // The root is random 32 bytes used as the HKDF root for custodial user keys. - // Stored encrypted at rest with SERVICE_AUTH_SECRET. - // - // For updates, carry the existing root through to the upsert so the - // ON CONFLICT insert row satisfies the NOT NULL constraint on - // encrypted_derivation_root. Never overwrite an existing root. - const isNewCouncil = !existing; - if (isNewCouncil) { - const root = crypto.getRandomValues(new Uint8Array(32)); - updateData.encryptedDerivationRoot = await encryptSecret( - root, - SERVICE_AUTH_SECRET, + const updateData: Record = { + name: name.trim(), + }; + if (description !== undefined) { + updateData.description = description?.trim() ?? null; + } + if (contactEmail !== undefined) { + updateData.contactEmail = contactEmail?.trim() ?? null; + } + if (sessionPublicKey) updateData.councilPublicKey = sessionPublicKey; + if (opexPublicKey) { + try { + const { Keypair } = await import("stellar-sdk"); + Keypair.fromPublicKey(opexPublicKey.trim()); + } catch { + throw new E.VALIDATION_FAILED( + "opexPublicKey must be a valid Stellar public key", ); - root.fill(0); // Best-effort zeroization - } else { - updateData.encryptedDerivationRoot = existing.encryptedDerivationRoot; } + updateData.opexPublicKey = opexPublicKey.trim(); + } - const metadata = await metadataRepo.upsert(councilId, updateData); - - // The event watcher service polls the DB on a periodic interval - // and starts a watcher for any council that doesn't yet have one. - // No direct call is needed here — keeping the handler free of - // async side effects (and trivially testable). - + // Verify ownership if council already exists + const existing = await metadataRepo.getByIdIncludingDeleted(councilId); + if (existing && existing.councilPublicKey !== sessionPublicKey) { log.debug("councilId", councilId); - log.debug("name", metadata.name); - log.event("council metadata updated"); + log.debug("existingOwner", existing.councilPublicKey); + log.debug("sessionOwner", sessionPublicKey); + log.error( + new Error("ownership mismatch"), + "council ownership mismatch on update", + ); + throw new E.RESOURCE_NOT_FOUND("Council not found"); + } - ctx.response.status = Status.OK; - ctx.response.body = { - message: "Council metadata updated", - data: { - councilId: metadata.id, - name: metadata.name, - description: metadata.description, - contactEmail: metadata.contactEmail, - councilPublicKey: metadata.councilPublicKey, - }, - }; - } catch (error) { - if (error instanceof SyntaxError) { - ctx.response.status = Status.BadRequest; - ctx.response.body = { message: "Invalid request body" }; - } else { - log.error(error, "failed to update metadata"); - ctx.response.status = Status.InternalServerError; - ctx.response.body = { message: "Failed to update metadata" }; - } + // Generate per-council derivation root on first creation only. + // The root is random 32 bytes used as the HKDF root for custodial user keys. + // Stored encrypted at rest with SERVICE_AUTH_SECRET. + // + // For updates, carry the existing root through to the upsert so the + // ON CONFLICT insert row satisfies the NOT NULL constraint on + // encrypted_derivation_root. Never overwrite an existing root. + const isNewCouncil = !existing; + if (isNewCouncil) { + const root = crypto.getRandomValues(new Uint8Array(32)); + updateData.encryptedDerivationRoot = await encryptSecret( + root, + SERVICE_AUTH_SECRET, + ); + root.fill(0); // Best-effort zeroization + } else { + updateData.encryptedDerivationRoot = existing.encryptedDerivationRoot; } + + const metadata = await metadataRepo.upsert(councilId, updateData); + + // The event watcher service polls the DB on a periodic interval + // and starts a watcher for any council that doesn't yet have one. + // No direct call is needed here — keeping the handler free of + // async side effects (and trivially testable). + + log.debug("councilId", councilId); + log.debug("name", metadata.name); + log.event("council metadata updated"); + + ctx.response.status = Status.OK; + ctx.response.body = { + message: "Council metadata updated", + data: { + councilId: metadata.id, + name: metadata.name, + description: metadata.description, + contactEmail: metadata.contactEmail, + councilPublicKey: metadata.councilPublicKey, + }, + }; }; } @@ -264,37 +222,25 @@ export function handleDeleteMetadata( return async (ctx) => { log.info("deleteMetadata"); - try { - const councilId = getCouncilId(ctx); - if (!councilId) { - ctx.response.status = Status.BadRequest; - ctx.response.body = { - message: "councilId query parameter is required", - }; - return; - } - - // Verify ownership before deleting - const ownerPublicKey = (ctx.state.session as { sub: string }).sub; - const council = await metadataRepo.getByIdAndOwner( - councilId, - ownerPublicKey, - ); - if (!council) { - ctx.response.status = Status.NotFound; - ctx.response.body = { message: "Council not found" }; - return; - } + const councilId = getCouncilId(ctx); + if (!councilId) { + throw new E.RESOURCE_ID_REQUIRED("councilId query parameter"); + } - await metadataRepo.deleteCouncil(councilId); - log.debug("councilId", councilId); - log.event("council deleted"); - ctx.response.status = Status.OK; - ctx.response.body = { message: "Council deleted" }; - } catch (error) { - log.error(error, "failed to delete council"); - ctx.response.status = Status.InternalServerError; - ctx.response.body = { message: "Failed to delete council" }; + // Verify ownership before deleting + const ownerPublicKey = (ctx.state.session as { sub: string }).sub; + const council = await metadataRepo.getByIdAndOwner( + councilId, + ownerPublicKey, + ); + if (!council) { + throw new E.RESOURCE_NOT_FOUND("Council not found"); } + + await metadataRepo.deleteCouncil(councilId); + log.debug("councilId", councilId); + log.event("council deleted"); + ctx.response.status = Status.OK; + ctx.response.body = { message: "Council deleted" }; }; } diff --git a/src/http/v1/council/providers.ts b/src/http/v1/council/providers.ts index be533f8..8547e24 100644 --- a/src/http/v1/council/providers.ts +++ b/src/http/v1/council/providers.ts @@ -3,6 +3,7 @@ import { drizzleClient } from "@/persistence/drizzle/config.ts"; import { CouncilProviderRepository } from "@/persistence/drizzle/repository/council-provider.repository.ts"; import { requireCouncilId, requireCouncilOwnership } from "./helpers.ts"; import { CouncilMetadataRepository } from "@/persistence/drizzle/repository/council-metadata.repository.ts"; +import * as E from "@/http/v1/error.ts"; import type { Logger } from "@/utils/logger/index.ts"; const metadataRepo = new CouncilMetadataRepository(drizzleClient); @@ -36,30 +37,23 @@ export function handleListProviders( return async (ctx) => { log.info("listProviders"); - try { - const councilId = requireCouncilId(ctx); - if (!councilId) return; - if (!await requireCouncilOwnership(ctx, councilId, metadataRepo)) return; - - const statusFilter = ctx.request.url.searchParams.get("status"); - - let providers; - if (statusFilter === "ACTIVE") { - providers = await providerRepo.listActive(councilId); - } else { - providers = await providerRepo.listAll(councilId); - } - - ctx.response.status = Status.OK; - ctx.response.body = { - message: "Providers retrieved", - data: providers.map(formatProvider), - }; - } catch (error) { - log.error(error, "failed to list providers"); - ctx.response.status = Status.InternalServerError; - ctx.response.body = { message: "Failed to retrieve providers" }; + const councilId = requireCouncilId(ctx); + await requireCouncilOwnership(ctx, councilId, metadataRepo); + + const statusFilter = ctx.request.url.searchParams.get("status"); + + let providers; + if (statusFilter === "ACTIVE") { + providers = await providerRepo.listActive(councilId); + } else { + providers = await providerRepo.listAll(councilId); } + + ctx.response.status = Status.OK; + ctx.response.body = { + message: "Providers retrieved", + data: providers.map(formatProvider), + }; }; } @@ -72,39 +66,25 @@ export function handleGetProvider( return async (ctx) => { log.info("getProvider"); - try { - const params = (ctx as unknown as { params?: RouteParams }).params; - const id = params?.id; - - if (!id) { - ctx.response.status = Status.BadRequest; - ctx.response.body = { message: "Provider ID is required" }; - return; - } - - const provider = await providerRepo.findById(id); - if (!provider) { - ctx.response.status = Status.NotFound; - ctx.response.body = { message: "Provider not found" }; - return; - } - - if ( - !await requireCouncilOwnership(ctx, provider.councilId, metadataRepo) - ) { - return; - } - - ctx.response.status = Status.OK; - ctx.response.body = { - message: "Provider retrieved", - data: formatProvider(provider), - }; - } catch (error) { - log.error(error, "failed to get provider"); - ctx.response.status = Status.InternalServerError; - ctx.response.body = { message: "Failed to retrieve provider" }; + const params = (ctx as unknown as { params?: RouteParams }).params; + const id = params?.id; + + if (!id) { + throw new E.RESOURCE_ID_REQUIRED("Provider ID"); } + + const provider = await providerRepo.findById(id); + if (!provider) { + throw new E.RESOURCE_NOT_FOUND("Provider not found"); + } + + await requireCouncilOwnership(ctx, provider.councilId, metadataRepo); + + ctx.response.status = Status.OK; + ctx.response.body = { + message: "Provider retrieved", + data: formatProvider(provider), + }; }; } @@ -115,57 +95,43 @@ export function handleUpdateProvider( return async (ctx) => { log.info("updateProvider"); + const params = (ctx as unknown as { params?: RouteParams }).params; + const id = params?.id; + + if (!id) { + throw new E.RESOURCE_ID_REQUIRED("Provider ID"); + } + + const provider = await providerRepo.findById(id); + if (!provider) { + throw new E.RESOURCE_NOT_FOUND("Provider not found"); + } + + await requireCouncilOwnership(ctx, provider.councilId, metadataRepo); + + let body; try { - const params = (ctx as unknown as { params?: RouteParams }).params; - const id = params?.id; - - if (!id) { - ctx.response.status = Status.BadRequest; - ctx.response.body = { message: "Provider ID is required" }; - return; - } - - const provider = await providerRepo.findById(id); - if (!provider) { - ctx.response.status = Status.NotFound; - ctx.response.body = { message: "Provider not found" }; - return; - } - - if ( - !await requireCouncilOwnership(ctx, provider.councilId, metadataRepo) - ) { - return; - } - - const body = await ctx.request.body.json(); - const { label, contactEmail } = body; - - await providerRepo.update(id, { - label: label?.trim() ?? provider.label, - contactEmail: contactEmail?.trim() ?? provider.contactEmail, - }); - - const updated = await providerRepo.findById(id); - - log.debug("id", id); - log.debug("publicKey", provider.publicKey); - log.event("provider metadata updated"); - - ctx.response.status = Status.OK; - ctx.response.body = { - message: "Provider updated", - data: formatProvider(updated!), - }; + body = await ctx.request.body.json(); } catch (error) { - if (error instanceof SyntaxError) { - ctx.response.status = Status.BadRequest; - ctx.response.body = { message: "Invalid request body" }; - } else { - log.error(error, "failed to update provider"); - ctx.response.status = Status.InternalServerError; - ctx.response.body = { message: "Failed to update provider" }; - } + throw new E.INVALID_REQUEST_BODY(error); } + const { label, contactEmail } = body; + + await providerRepo.update(id, { + label: label?.trim() ?? provider.label, + contactEmail: contactEmail?.trim() ?? provider.contactEmail, + }); + + const updated = await providerRepo.findById(id); + + log.debug("id", id); + log.debug("publicKey", provider.publicKey); + log.event("provider metadata updated"); + + ctx.response.status = Status.OK; + ctx.response.body = { + message: "Provider updated", + data: formatProvider(updated!), + }; }; } diff --git a/src/http/v1/council/sign.ts b/src/http/v1/council/sign.ts index 6757702..d4278ae 100644 --- a/src/http/v1/council/sign.ts +++ b/src/http/v1/council/sign.ts @@ -10,6 +10,7 @@ import { registerCustodialUser, } from "@/core/service/custody/custody.service.ts"; import type { JwtSessionData } from "@/http/middleware/auth/index.ts"; +import * as E from "@/http/v1/error.ts"; import type { Logger } from "@/utils/logger/index.ts"; const providerRepo = new CouncilProviderRepository(drizzleClient); @@ -23,10 +24,10 @@ const HEX_RE = /^[0-9a-f]+$/i; function hexToBytes(hex: string): Uint8Array { if (hex.length % 2 !== 0) { - throw new Error("Hex string must have even length"); + throw new E.VALIDATION_FAILED("Hex string must have even length"); } if (!HEX_RE.test(hex)) { - throw new Error("Invalid hex characters"); + throw new E.VALIDATION_FAILED("Invalid hex characters"); } const bytes = new Uint8Array(hex.length / 2); for (let i = 0; i < bytes.length; i++) { @@ -81,63 +82,49 @@ export function handlePostRegisterUser( return async (ctx) => { log.info("postRegisterUser"); - try { - const session = ctx.state.session as JwtSessionData; - - const body = await ctx.request.body.json(); - const { councilId, externalId, channelContractId } = body; + const session = ctx.state.session as JwtSessionData; - if (!councilId || typeof councilId !== "string") { - ctx.response.status = Status.BadRequest; - ctx.response.body = { message: "councilId is required" }; - return; - } + let body; + try { + body = await ctx.request.body.json(); + } catch (error) { + throw new E.INVALID_REQUEST_BODY(error); + } + const { councilId, externalId, channelContractId } = body; - const providerError = await validateProviderSession( - councilId, - session, - deps, - ); - if (providerError) { - ctx.response.status = Status.Forbidden; - ctx.response.body = { message: providerError }; - return; - } + if (!councilId || typeof councilId !== "string") { + throw new E.VALIDATION_FAILED("councilId is required"); + } - if (!externalId || typeof externalId !== "string") { - ctx.response.status = Status.BadRequest; - ctx.response.body = { message: "externalId is required" }; - return; - } + const providerError = await validateProviderSession( + councilId, + session, + deps, + ); + if (providerError) { + throw new E.FORBIDDEN(providerError); + } - if (!channelContractId || typeof channelContractId !== "string") { - ctx.response.status = Status.BadRequest; - ctx.response.body = { message: "channelContractId is required" }; - return; - } + if (!externalId || typeof externalId !== "string") { + throw new E.VALIDATION_FAILED("externalId is required"); + } - const result = await registerCustodialUser({ - councilId, - externalId, - channelContractId, - providerPublicKey: session.sub, - }, { log }); - - ctx.response.status = Status.OK; - ctx.response.body = { - message: "User registered", - data: result, - }; - } catch (error) { - if (error instanceof SyntaxError) { - ctx.response.status = Status.BadRequest; - ctx.response.body = { message: "Invalid request body" }; - } else { - log.error(error, "failed to register custodial user"); - ctx.response.status = Status.InternalServerError; - ctx.response.body = { message: "Failed to register user" }; - } + if (!channelContractId || typeof channelContractId !== "string") { + throw new E.VALIDATION_FAILED("channelContractId is required"); } + + const result = await registerCustodialUser({ + councilId, + externalId, + channelContractId, + providerPublicKey: session.sub, + }, { log }); + + ctx.response.status = Status.OK; + ctx.response.body = { + message: "User registered", + data: result, + }; }; } @@ -156,66 +143,52 @@ export function handlePostGetKeys( return async (ctx) => { log.info("postGetKeys"); - try { - const session = ctx.state.session as JwtSessionData; - - const body = await ctx.request.body.json(); - const { councilId, externalId, channelContractId, indices } = body; - - if (!councilId || typeof councilId !== "string") { - ctx.response.status = Status.BadRequest; - ctx.response.body = { message: "councilId is required" }; - return; - } + const session = ctx.state.session as JwtSessionData; - const providerError = await validateProviderSession( - councilId, - session, - deps, - ); - if (providerError) { - ctx.response.status = Status.Forbidden; - ctx.response.body = { message: providerError }; - return; - } + let body; + try { + body = await ctx.request.body.json(); + } catch (error) { + throw new E.INVALID_REQUEST_BODY(error); + } + const { councilId, externalId, channelContractId, indices } = body; - if (!externalId || !channelContractId || !Array.isArray(indices)) { - ctx.response.status = Status.BadRequest; - ctx.response.body = { - message: "externalId, channelContractId, and indices are required", - }; - return; - } + if (!councilId || typeof councilId !== "string") { + throw new E.VALIDATION_FAILED("councilId is required"); + } - if (indices.length > 300) { - ctx.response.status = Status.BadRequest; - ctx.response.body = { message: "Maximum 300 indices per request" }; - return; - } + const providerError = await validateProviderSession( + councilId, + session, + deps, + ); + if (providerError) { + throw new E.FORBIDDEN(providerError); + } - const publicKeys = await getUserPublicKeys( - councilId, - externalId, - channelContractId, - indices, - deps, + if (!externalId || !channelContractId || !Array.isArray(indices)) { + throw new E.VALIDATION_FAILED( + "externalId, channelContractId, and indices are required", ); + } - ctx.response.status = Status.OK; - ctx.response.body = { - message: "Public keys derived", - data: { publicKeys }, - }; - } catch (error) { - if (error instanceof SyntaxError) { - ctx.response.status = Status.BadRequest; - ctx.response.body = { message: "Invalid request body" }; - } else { - log.error(error, "failed to derive public keys"); - ctx.response.status = Status.InternalServerError; - ctx.response.body = { message: "Failed to derive public keys" }; - } + if (indices.length > 300) { + throw new E.VALIDATION_FAILED("Maximum 300 indices per request"); } + + const publicKeys = await getUserPublicKeys( + councilId, + externalId, + channelContractId, + indices, + deps, + ); + + ctx.response.status = Status.OK; + ctx.response.body = { + message: "Public keys derived", + data: { publicKeys }, + }; }; } @@ -242,136 +215,109 @@ export function handlePostSignSpend( return async (ctx) => { log.info("postSignSpend"); + const session = ctx.state.session as JwtSessionData; + + let body; try { - const session = ctx.state.session as JwtSessionData; + body = await ctx.request.body.json(); + } catch (error) { + throw new E.INVALID_REQUEST_BODY(error); + } + const { councilId, channelContractId, spends } = body; - const body = await ctx.request.body.json(); - const { councilId, channelContractId, spends } = body; + if (!councilId || typeof councilId !== "string") { + throw new E.VALIDATION_FAILED("councilId is required"); + } - if (!councilId || typeof councilId !== "string") { - ctx.response.status = Status.BadRequest; - ctx.response.body = { message: "councilId is required" }; - return; - } + const providerError = await validateProviderSession( + councilId, + session, + deps, + ); + if (providerError) { + throw new E.FORBIDDEN(providerError); + } - const providerError = await validateProviderSession( - councilId, - session, - deps, + if (!channelContractId || typeof channelContractId !== "string") { + throw new E.VALIDATION_FAILED("channelContractId is required"); + } + + if (!Array.isArray(spends) || spends.length === 0) { + throw new E.VALIDATION_FAILED( + "spends array is required and must not be empty", ); - if (providerError) { - ctx.response.status = Status.Forbidden; - ctx.response.body = { message: providerError }; - return; + } + + if (spends.length > 300) { + throw new E.VALIDATION_FAILED("Maximum 300 spends per request"); + } + + const signatures: string[] = []; + + for (const spend of spends) { + const { externalId, utxoIndex, message } = spend; + + if (!externalId || typeof utxoIndex !== "number" || !message) { + throw new E.VALIDATION_FAILED( + "Each spend requires externalId, utxoIndex, and message", + ); } - if (!channelContractId || typeof channelContractId !== "string") { - ctx.response.status = Status.BadRequest; - ctx.response.body = { message: "channelContractId is required" }; - return; + if (!Number.isInteger(utxoIndex) || utxoIndex < 0 || utxoIndex >= 300) { + throw new E.VALIDATION_FAILED( + `utxoIndex must be an integer 0-299, got ${utxoIndex}`, + ); } - if (!Array.isArray(spends) || spends.length === 0) { - ctx.response.status = Status.BadRequest; - ctx.response.body = { - message: "spends array is required and must not be empty", - }; - return; + // Verify user exists and is active + const user = await userRepo.findByExternalIdAndChannel( + externalId, + channelContractId, + ); + if (!user) { + throw new E.RESOURCE_NOT_FOUND("User not registered for this channel"); } - if (spends.length > 300) { - ctx.response.status = Status.BadRequest; - ctx.response.body = { message: "Maximum 300 spends per request" }; - return; + if (user.status !== CustodialUserStatus.ACTIVE) { + throw new E.FORBIDDEN("User is suspended"); } - const signatures: string[] = []; - - for (const spend of spends) { - const { externalId, utxoIndex, message } = spend; - - if (!externalId || typeof utxoIndex !== "number" || !message) { - ctx.response.status = Status.BadRequest; - ctx.response.body = { - message: "Each spend requires externalId, utxoIndex, and message", - }; - return; - } - - if (!Number.isInteger(utxoIndex) || utxoIndex < 0 || utxoIndex >= 300) { - ctx.response.status = Status.BadRequest; - ctx.response.body = { - message: `utxoIndex must be an integer 0-299, got ${utxoIndex}`, - }; - return; - } - - // Verify user exists and is active - const user = await userRepo.findByExternalIdAndChannel( - externalId, - channelContractId, - ); - if (!user) { - ctx.response.status = Status.NotFound; - ctx.response.body = { - message: `User not registered for this channel`, - }; - return; - } - - if (user.status !== CustodialUserStatus.ACTIVE) { - ctx.response.status = Status.Forbidden; - ctx.response.body = { message: "User is suspended" }; - return; - } - - // Only the provider that registered this user can request signatures - if ( - user.registeredByProvider && - user.registeredByProvider !== session.sub - ) { - ctx.response.status = Status.Forbidden; - ctx.response.body = { - message: "Not authorized to sign for this user", - }; - return; - } - - let messageBytes: Uint8Array; - try { - messageBytes = hexToBytes(message); - } catch { - ctx.response.status = Status.BadRequest; - ctx.response.body = { - message: "message must be a valid hex string with even length", - }; - return; - } - const signature = await signWithDerivedKey( - councilId, - channelContractId, - externalId, - utxoIndex, - messageBytes, - deps, + // Only the provider that registered this user can request signatures + if ( + user.registeredByProvider && + user.registeredByProvider !== session.sub + ) { + throw new E.FORBIDDEN("Not authorized to sign for this user"); + } + + let messageBytes: Uint8Array; + try { + messageBytes = hexToBytes(message); + } catch { + throw new E.VALIDATION_FAILED( + "message must be a valid hex string with even length", ); - signatures.push(bytesToHex(signature)); } + const signature = await signWithDerivedKey( + councilId, + channelContractId, + externalId, + utxoIndex, + messageBytes, + deps, + ); + signatures.push(bytesToHex(signature)); + } - log.debug("channelContractId", channelContractId); - log.debug("spendCount", spends.length); - log.debug("provider", session.sub); - log.event("spend signatures generated"); + log.debug("channelContractId", channelContractId); + log.debug("spendCount", spends.length); + log.debug("provider", session.sub); + log.event("spend signatures generated"); - ctx.response.status = Status.OK; - ctx.response.body = { - message: "Signatures generated", - data: { signatures }, - }; - } catch (error) { - log.error(error, "failed to sign spend operations"); - ctx.response.status = Status.InternalServerError; - ctx.response.body = { message: "Failed to generate signatures" }; - } + ctx.response.status = Status.OK; + ctx.response.body = { + message: "Signatures generated", + data: { signatures }, + }; }; } diff --git a/src/http/v1/error.test.ts b/src/http/v1/error.test.ts new file mode 100644 index 0000000..1b4ebb5 --- /dev/null +++ b/src/http/v1/error.test.ts @@ -0,0 +1,84 @@ +import { assertEquals, assertInstanceOf } from "@std/assert"; +import type { ErrorResponse } from "@/http/default-schemas.ts"; +import { PlatformError } from "@/error/index.ts"; +import * as E from "@/http/v1/error.ts"; +import * as AuthE from "@/core/service/auth/error.ts"; + +// The edge translation the global errorMiddleware applies (see +// P_ErrorToApiResponse): any thrown error → the structured +// `{ status, code, message, details? }` ErrorResponse, redacting non-platform +// errors to a generic 500. +function toApi(error: unknown): ErrorResponse { + return PlatformError.is(error) + ? error.getAPIError() + : PlatformError.fromUnknown(error).getAPIError(); +} + +Deno.test("VALIDATION_FAILED - is a PlatformError with a 400 code", () => { + const err = new E.VALIDATION_FAILED("countryCode is required"); + assertInstanceOf(err, PlatformError); + assertInstanceOf(err, Error); + assertEquals(err.code, "HTTP_REQ_002"); + const api = err.getAPIError(); + assertEquals(api.status, 400); + assertEquals(api.message, "countryCode is required"); +}); + +Deno.test("RESOURCE_ID_REQUIRED - templates the resource into the message", () => { + const err = new E.RESOURCE_ID_REQUIRED("Channel ID"); + assertEquals(err.code, "HTTP_REQ_003"); + assertEquals(err.getAPIError().message, "Channel ID is required"); +}); + +Deno.test("RESOURCE_NOT_FOUND - 404", () => { + const api = new E.RESOURCE_NOT_FOUND("Channel not found").getAPIError(); + assertEquals(api.status, 404); + assertEquals(api.message, "Channel not found"); +}); + +Deno.test("RESOURCE_CONFLICT - 409", () => { + const api = new E.RESOURCE_CONFLICT("Already exists").getAPIError(); + assertEquals(api.status, 409); +}); + +Deno.test("FORBIDDEN - 403", () => { + const err = new E.FORBIDDEN("Not authorized to sign for this user"); + assertEquals(err.code, "HTTP_REQ_006"); + assertEquals(err.getAPIError().status, 403); +}); + +Deno.test("INVALID_REQUEST_BODY - wraps the parse error as the cause", () => { + const cause = new SyntaxError("Unexpected end of JSON input"); + const err = new E.INVALID_REQUEST_BODY(cause); + assertEquals(err.code, "HTTP_REQ_001"); + assertEquals(err.getAPIError().status, 400); + // The original is preserved on the native cause chain for logs/OTel, never + // in the redacted api projection. + assertEquals((err as Error & { cause?: unknown }).cause, cause); + assertEquals(err.getAPIError().details, undefined); +}); + +Deno.test("COUNCIL_AUTH INVALID_SIGNATURE - redacts internal detail behind a 401 code", () => { + const err = new AuthE.INVALID_SIGNATURE(new Error("bad DER bytes")); + assertEquals(err.code, "COUNCIL_AUTH_005"); + const api = err.getAPIError(); + assertEquals(api.status, 401); + assertEquals(api.message, "Invalid signature"); +}); + +Deno.test("edge translation - a PlatformError maps to its own api projection", () => { + const res = toApi(new E.RESOURCE_NOT_FOUND("Channel not found")); + assertEquals(res, { + status: 404, + code: "HTTP_REQ_004", + message: "Channel not found", + details: undefined, + }); +}); + +Deno.test("edge translation - an unknown raw error is redacted to a generic 500", () => { + const res = toApi(new Error("stack-y internal detail with secrets")); + assertEquals(res.status, 500); + assertEquals(res.code, "GEN_001"); + assertEquals(res.message, "Internal server error."); +}); diff --git a/src/http/v1/error.ts b/src/http/v1/error.ts new file mode 100644 index 0000000..006856d --- /dev/null +++ b/src/http/v1/error.ts @@ -0,0 +1,111 @@ +import { PlatformError } from "@/error/index.ts"; + +/** + * Generic HTTP request errors shared across the v1 routes. Business-meaningful + * cases get their own stable `code`; generic field validation shares + * `VALIDATION_FAILED` and passes its message through — the console maps known + * codes and falls back to the body message otherwise. + */ +export enum HTTP_REQUEST_ERROR_CODES { + INVALID_REQUEST_BODY = "HTTP_REQ_001", + VALIDATION_FAILED = "HTTP_REQ_002", + RESOURCE_ID_REQUIRED = "HTTP_REQ_003", + RESOURCE_NOT_FOUND = "HTTP_REQ_004", + RESOURCE_CONFLICT = "HTTP_REQ_005", + FORBIDDEN = "HTTP_REQ_006", +} + +const source = "@http/v1/request"; + +export class INVALID_REQUEST_BODY extends PlatformError { + constructor(error?: Error | unknown) { + super({ + source, + code: HTTP_REQUEST_ERROR_CODES.INVALID_REQUEST_BODY, + message: "Invalid request body", + details: "The HTTP request body could not be parsed as JSON.", + baseError: error, + api: { + status: 400, + message: "Invalid request body", + }, + }); + } +} + +export class VALIDATION_FAILED extends PlatformError { + constructor(message: string, details?: string) { + super({ + source, + code: HTTP_REQUEST_ERROR_CODES.VALIDATION_FAILED, + message, + details, + api: { + status: 400, + message, + details, + }, + }); + } +} + +export class RESOURCE_ID_REQUIRED extends PlatformError { + constructor(resource: string) { + super({ + source, + code: HTTP_REQUEST_ERROR_CODES.RESOURCE_ID_REQUIRED, + message: `${resource} is required`, + meta: { resource }, + api: { + status: 400, + message: `${resource} is required`, + }, + }); + } +} + +export class RESOURCE_NOT_FOUND extends PlatformError { + constructor(message: string) { + super({ + source, + code: HTTP_REQUEST_ERROR_CODES.RESOURCE_NOT_FOUND, + message, + api: { + status: 404, + message, + }, + }); + } +} + +export class RESOURCE_CONFLICT extends PlatformError { + constructor(message: string, details?: string) { + super({ + source, + code: HTTP_REQUEST_ERROR_CODES.RESOURCE_CONFLICT, + message, + details, + api: { + status: 409, + message, + details, + }, + }); + } +} + +export class FORBIDDEN extends PlatformError { + constructor(message: string, details?: string) { + super({ + source, + code: HTTP_REQUEST_ERROR_CODES.FORBIDDEN, + message, + details, + api: { + status: 403, + message, + details, + }, + }); + } +} diff --git a/src/http/v1/public/join-request.ts b/src/http/v1/public/join-request.ts index 892743b..697db89 100644 --- a/src/http/v1/public/join-request.ts +++ b/src/http/v1/public/join-request.ts @@ -6,6 +6,7 @@ import { type SignedPayload, verifyPayload, } from "@/core/crypto/signed-payload.ts"; +import * as E from "@/http/v1/error.ts"; import type { Logger } from "@/utils/logger/index.ts"; interface JoinRequestPayload { @@ -29,199 +30,166 @@ export function createPostJoinRequestHandler( return async (ctx: Context) => { log.info("postJoinRequest"); + let body; try { - const body = await ctx.request.body.json(); - - // Determine if this is a signed envelope or a plain request - let data: JoinRequestPayload; - let signature: string | null = null; - - if ( - body.payload != null && typeof body.payload === "object" && - typeof body.signature === "string" && - typeof body.publicKey === "string" && - typeof body.timestamp === "number" - ) { - // Signed envelope from provider-platform — discriminated by payload being - // an object (not a primitive) plus the presence of a numeric timestamp. - // A plain join request may contain fields named payload/signature/publicKey - // as strings, but will never have this exact shape. - const envelope = body as SignedPayload; - const valid = await verifyPayload(envelope); - if (!valid) { - ctx.response.status = Status.BadRequest; - ctx.response.body = { message: "Invalid signature" }; - return; - } - data = envelope.payload; - signature = envelope.signature; - - // Ensure envelope publicKey matches payload publicKey - if (envelope.publicKey !== data.publicKey) { - ctx.response.status = Status.BadRequest; - ctx.response.body = { - message: "Signer does not match payload publicKey", - }; - return; - } - } else { - // Plain request (backwards-compatible with council-console #/join form) - data = body as JoinRequestPayload; - } + body = await ctx.request.body.json(); + } catch (error) { + throw new E.INVALID_REQUEST_BODY(error); + } - const { - publicKey, - label, - contactEmail, - jurisdictions, - callbackEndpoint, - } = data; - // Provider-platform includes its base URL alongside the signed envelope - const providerUrl: string | null = typeof body.providerUrl === "string" - ? body.providerUrl.trim() - : null; - // For signed payloads, councilId must come from inside the verified envelope. - // Query param fallback only for unsigned plain requests. - const councilId = signature - ? (data.councilId || "") - : (data.councilId || ctx.request.url.searchParams.get("councilId") || - ""); - if (!councilId) { - ctx.response.status = Status.BadRequest; - ctx.response.body = { message: "councilId is required" }; - return; + // Determine if this is a signed envelope or a plain request + let data: JoinRequestPayload; + let signature: string | null = null; + + if ( + body.payload != null && typeof body.payload === "object" && + typeof body.signature === "string" && + typeof body.publicKey === "string" && + typeof body.timestamp === "number" + ) { + // Signed envelope from provider-platform — discriminated by payload being + // an object (not a primitive) plus the presence of a numeric timestamp. + // A plain join request may contain fields named payload/signature/publicKey + // as strings, but will never have this exact shape. + const envelope = body as SignedPayload; + const valid = await verifyPayload(envelope); + if (!valid) { + throw new E.VALIDATION_FAILED("Invalid signature"); } - - if (!publicKey || typeof publicKey !== "string") { - ctx.response.status = Status.BadRequest; - ctx.response.body = { message: "publicKey is required" }; - return; + data = envelope.payload; + signature = envelope.signature; + + // Ensure envelope publicKey matches payload publicKey + if (envelope.publicKey !== data.publicKey) { + throw new E.VALIDATION_FAILED( + "Signer does not match payload publicKey", + ); } + } else { + // Plain request (backwards-compatible with council-console #/join form) + data = body as JoinRequestPayload; + } - try { - Keypair.fromPublicKey(publicKey); - } catch { - ctx.response.status = Status.BadRequest; - ctx.response.body = { message: "Invalid Stellar public key format" }; - return; - } + const { + publicKey, + label, + contactEmail, + jurisdictions, + callbackEndpoint, + } = data; + // Provider-platform includes its base URL alongside the signed envelope + const providerUrl: string | null = typeof body.providerUrl === "string" + ? body.providerUrl.trim() + : null; + // For signed payloads, councilId must come from inside the verified envelope. + // Query param fallback only for unsigned plain requests. + const councilId = signature + ? (data.councilId || "") + : (data.councilId || ctx.request.url.searchParams.get("councilId") || + ""); + if (!councilId) { + throw new E.VALIDATION_FAILED("councilId is required"); + } - if (label && typeof label !== "string") { - ctx.response.status = Status.BadRequest; - ctx.response.body = { message: "label must be a string" }; - return; - } - if (label && label.length > 200) { - ctx.response.status = Status.BadRequest; - ctx.response.body = { message: "label must be at most 200 characters" }; - return; - } + if (!publicKey || typeof publicKey !== "string") { + throw new E.VALIDATION_FAILED("publicKey is required"); + } - if (contactEmail && typeof contactEmail !== "string") { - ctx.response.status = Status.BadRequest; - ctx.response.body = { message: "contactEmail must be a string" }; - return; - } - if (contactEmail && contactEmail.length > 200) { - ctx.response.status = Status.BadRequest; - ctx.response.body = { - message: "contactEmail must be at most 200 characters", - }; - return; - } + try { + Keypair.fromPublicKey(publicKey); + } catch { + throw new E.VALIDATION_FAILED("Invalid Stellar public key format"); + } - if (jurisdictions && !Array.isArray(jurisdictions)) { - ctx.response.status = Status.BadRequest; - ctx.response.body = { - message: "jurisdictions must be an array of country codes", - }; - return; - } - if (jurisdictions && jurisdictions.length > 50) { - ctx.response.status = Status.BadRequest; - ctx.response.body = { - message: "jurisdictions must have at most 50 entries", - }; - return; - } + if (label && typeof label !== "string") { + throw new E.VALIDATION_FAILED("label must be a string"); + } + if (label && label.length > 200) { + throw new E.VALIDATION_FAILED("label must be at most 200 characters"); + } - if (callbackEndpoint && typeof callbackEndpoint !== "string") { - ctx.response.status = Status.BadRequest; - ctx.response.body = { message: "callbackEndpoint must be a string" }; - return; - } - if (callbackEndpoint && callbackEndpoint.length > 500) { - ctx.response.status = Status.BadRequest; - ctx.response.body = { - message: "callbackEndpoint must be at most 500 characters", - }; - return; - } - if (callbackEndpoint) { - try { - const parsed = new URL(callbackEndpoint); - if (parsed.protocol !== "http:" && parsed.protocol !== "https:") { - throw new Error("bad protocol"); - } - } catch { - ctx.response.status = Status.BadRequest; - ctx.response.body = { - message: "callbackEndpoint must be a valid HTTP(S) URL", - }; - return; - } - } + if (contactEmail && typeof contactEmail !== "string") { + throw new E.VALIDATION_FAILED("contactEmail must be a string"); + } + if (contactEmail && contactEmail.length > 200) { + throw new E.VALIDATION_FAILED( + "contactEmail must be at most 200 characters", + ); + } - // Check for existing pending request for this council - const existing = await joinRequestRepo.findPendingByPublicKey( - councilId, - publicKey, + if (jurisdictions && !Array.isArray(jurisdictions)) { + throw new E.VALIDATION_FAILED( + "jurisdictions must be an array of country codes", ); - if (existing) { - ctx.response.status = Status.Conflict; - ctx.response.body = { - message: "A pending join request already exists for this public key", - }; - return; - } + } + if (jurisdictions && jurisdictions.length > 50) { + throw new E.VALIDATION_FAILED( + "jurisdictions must have at most 50 entries", + ); + } - const request = await joinRequestRepo.create({ - id: crypto.randomUUID(), - councilId, - publicKey, - label: label?.trim() ?? null, - contactEmail: contactEmail?.trim() ?? null, - jurisdictions: jurisdictions ? JSON.stringify(jurisdictions) : null, - callbackEndpoint: callbackEndpoint?.trim() ?? null, - providerUrl, - signature, - status: JoinRequestStatus.PENDING, - createdAt: new Date(), - updatedAt: new Date(), - }); - - log.debug("publicKey", publicKey); - log.debug("signed", !!signature); - log.event("join request submitted"); - - ctx.response.status = Status.OK; - ctx.response.body = { - message: "Join request submitted", - data: { - id: request.id, - publicKey: request.publicKey, - status: request.status, - }, - }; - } catch (error) { - if (error instanceof SyntaxError) { - ctx.response.status = Status.BadRequest; - ctx.response.body = { message: "Invalid request body" }; - } else { - log.error(error, "failed to create join request"); - ctx.response.status = Status.InternalServerError; - ctx.response.body = { message: "Failed to submit join request" }; + if (callbackEndpoint && typeof callbackEndpoint !== "string") { + throw new E.VALIDATION_FAILED("callbackEndpoint must be a string"); + } + if (callbackEndpoint && callbackEndpoint.length > 500) { + throw new E.VALIDATION_FAILED( + "callbackEndpoint must be at most 500 characters", + ); + } + if (callbackEndpoint) { + let parsed: URL; + try { + parsed = new URL(callbackEndpoint); + } catch { + throw new E.VALIDATION_FAILED( + "callbackEndpoint must be a valid HTTP(S) URL", + ); + } + if (parsed.protocol !== "http:" && parsed.protocol !== "https:") { + throw new E.VALIDATION_FAILED( + "callbackEndpoint must be a valid HTTP(S) URL", + ); } } + + // Check for existing pending request for this council + const existing = await joinRequestRepo.findPendingByPublicKey( + councilId, + publicKey, + ); + if (existing) { + throw new E.RESOURCE_CONFLICT( + "A pending join request already exists for this public key", + ); + } + + const request = await joinRequestRepo.create({ + id: crypto.randomUUID(), + councilId, + publicKey, + label: label?.trim() ?? null, + contactEmail: contactEmail?.trim() ?? null, + jurisdictions: jurisdictions ? JSON.stringify(jurisdictions) : null, + callbackEndpoint: callbackEndpoint?.trim() ?? null, + providerUrl, + signature, + status: JoinRequestStatus.PENDING, + createdAt: new Date(), + updatedAt: new Date(), + }); + + log.debug("publicKey", publicKey); + log.debug("signed", !!signature); + log.event("join request submitted"); + + ctx.response.status = Status.OK; + ctx.response.body = { + message: "Join request submitted", + data: { + id: request.id, + publicKey: request.publicKey, + status: request.status, + }, + }; }; } diff --git a/src/http/v1/public/join-request_test.ts b/src/http/v1/public/join-request_test.ts index c3e61ad..a898f7b 100644 --- a/src/http/v1/public/join-request_test.ts +++ b/src/http/v1/public/join-request_test.ts @@ -2,6 +2,7 @@ import { assertEquals } from "@std/assert"; import { Keypair } from "stellar-sdk"; import { createPostJoinRequestHandler } from "./join-request.ts"; import { newNoop } from "@/utils/logger/index.ts"; +import { runHandler } from "../../../../tests/test_app.ts"; const TEST_COUNCIL_ID = "CDLZFC3SYJYDZT7K67VZ75HPJVIEUVNIXF47ZG2FB2RMQQVU2HHGCYSC"; @@ -28,15 +29,17 @@ function createMockContext(body: unknown): any { Deno.test("join-request: rejects missing councilId", async () => { const kp = Keypair.random(); const ctx = createMockContext({ publicKey: kp.publicKey() }); - await handler(ctx); + await runHandler(ctx, handler); assertEquals(ctx.response.status, 400); + assertEquals(ctx.response.body.code, "HTTP_REQ_002"); assertEquals(ctx.response.body.message, "councilId is required"); }); Deno.test("join-request: rejects missing publicKey", async () => { const ctx = createMockContext({ councilId: TEST_COUNCIL_ID }); - await handler(ctx); + await runHandler(ctx, handler); assertEquals(ctx.response.status, 400); + assertEquals(ctx.response.body.code, "HTTP_REQ_002"); assertEquals(ctx.response.body.message, "publicKey is required"); }); @@ -45,8 +48,9 @@ Deno.test("join-request: rejects invalid Stellar key format", async () => { councilId: TEST_COUNCIL_ID, publicKey: "not-a-key", }); - await handler(ctx); + await runHandler(ctx, handler); assertEquals(ctx.response.status, 400); + assertEquals(ctx.response.body.code, "HTTP_REQ_002"); assertEquals(ctx.response.body.message, "Invalid Stellar public key format"); }); @@ -57,8 +61,9 @@ Deno.test("join-request: rejects label over 200 chars", async () => { publicKey: kp.publicKey(), label: "x".repeat(201), }); - await handler(ctx); + await runHandler(ctx, handler); assertEquals(ctx.response.status, 400); + assertEquals(ctx.response.body.code, "HTTP_REQ_002"); assertEquals( ctx.response.body.message, "label must be at most 200 characters", @@ -72,8 +77,9 @@ Deno.test("join-request: rejects jurisdictions over 50 entries", async () => { publicKey: kp.publicKey(), jurisdictions: Array(51).fill("XX"), }); - await handler(ctx); + await runHandler(ctx, handler); assertEquals(ctx.response.status, 400); + assertEquals(ctx.response.body.code, "HTTP_REQ_002"); assertEquals( ctx.response.body.message, "jurisdictions must have at most 50 entries", @@ -87,8 +93,9 @@ Deno.test("join-request: rejects non-HTTP callbackEndpoint", async () => { publicKey: kp.publicKey(), callbackEndpoint: "file:///etc/passwd", }); - await handler(ctx); + await runHandler(ctx, handler); assertEquals(ctx.response.status, 400); + assertEquals(ctx.response.body.code, "HTTP_REQ_002"); assertEquals( ctx.response.body.message, "callbackEndpoint must be a valid HTTP(S) URL", @@ -102,8 +109,9 @@ Deno.test("join-request: rejects callbackEndpoint over 500 chars", async () => { publicKey: kp.publicKey(), callbackEndpoint: "https://example.com/" + "a".repeat(500), }); - await handler(ctx); + await runHandler(ctx, handler); assertEquals(ctx.response.status, 400); + assertEquals(ctx.response.body.code, "HTTP_REQ_002"); assertEquals( ctx.response.body.message, "callbackEndpoint must be at most 500 characters", diff --git a/src/main.ts b/src/main.ts index 1203989..a07234e 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1,6 +1,7 @@ import { Application } from "@oak/oak"; import { buildApiRouter } from "@/http/v1/v1.routes.ts"; +import { errorMiddleware } from "@/http/middleware/error.ts"; import { appendRequestIdMiddleware } from "@/http/middleware/append-request-id.ts"; import { appendResponseHeadersMiddleware } from "@/http/middleware/append-response-headers.ts"; import { traceContextMiddleware } from "@/http/middleware/trace-context.ts"; @@ -27,6 +28,9 @@ async function bootstrap() { app.use(corsMiddleware); app.use(traceContextMiddleware); + // Outermost error boundary — must wrap requestId + routes so anything they + // throw is logged with correlation and translated to a StructuredError. + app.use(errorMiddleware(deps)); app.use(appendRequestIdMiddleware(deps)); app.use(appendResponseHeadersMiddleware); const apiV1 = buildApiRouter(deps); diff --git a/src/utils/logger/index.ts b/src/utils/logger/index.ts index 5623024..073aacc 100644 --- a/src/utils/logger/index.ts +++ b/src/utils/logger/index.ts @@ -10,11 +10,14 @@ export enum Level { Disabled = 3, } +/** Correlation ids (request/trace/council/account) attached to a log record. */ +export type Correlation = { [key: string]: unknown }; + export interface Logger { info(msg: string): void; event(msg: string): void; debug(key: string, value: unknown): void; - error(err: unknown, msg: string): void; + error(err: unknown, msg: string, corr?: Correlation): void; scope(name: string): Logger; } @@ -37,6 +40,35 @@ interface Record { key?: string; value?: unknown; error?: string; + corr?: Correlation; +} + +/** + * Flatten an error and its `cause` chain into a single "msg <- cause <- ..." + * string so the full wrapped context (per the error-bubbling standard) lands in + * the logs even though only a redacted `StructuredError` reaches the client. + */ +function flattenCauses(err: unknown): string { + const parts: string[] = []; + let current: unknown = err; + const seen = new Set(); + for (let depth = 0; depth < 16 && current != null; depth++) { + if (seen.has(current)) break; + seen.add(current); + parts.push(current instanceof Error ? current.message : String(current)); + current = current instanceof Error + ? (current as { cause?: unknown }).cause + : undefined; + } + return parts.join(" <- "); +} + +function renderCorr(corr?: Correlation): string { + if (!corr) return ""; + const pairs = Object.entries(corr) + .filter(([, v]) => v !== undefined) + .map(([k, v]) => `${k}=${stringify(v)}`); + return pairs.length ? " " + pairs.join(" ") : ""; } type Format = (r: Record) => string; @@ -108,7 +140,9 @@ function humanFormat(colored: boolean): Format { stringify(r.value) } (${r.scope})`; case "error": - return `${ts} ${redLb("ERR")} [${r.scope}] ${r.msg} error="${r.error}"`; + return `${ts} ${redLb("ERR")} [${r.scope}] ${r.msg} error="${r.error}"${ + renderCorr(r.corr) + }`; } }; } @@ -124,6 +158,7 @@ const jsonFormat: Format = (r) => { if (r.key !== undefined) out.key = r.key; if (r.value !== undefined) out.value = safeJsonValue(r.value); if (r.error !== undefined) out.error = r.error; + if (r.corr !== undefined) out.corr = safeJsonValue(r.corr) as Correlation; try { return JSON.stringify(out); } catch (err) { @@ -172,15 +207,16 @@ class LoggerImpl implements Logger { this.emit({ ts: now(), level: "debug", scope: this.scopePath, key, value }); } - error(err: unknown, msg: string): void { + error(err: unknown, msg: string, corr?: Correlation): void { // ERR always emits regardless of level (matches go-logger / zerolog). - const detail = err instanceof Error ? err.message : String(err); + // Flatten the full cause chain so wrapped context survives into the logs. this.emit({ ts: now(), level: "error", scope: this.scopePath, msg, - error: detail, + error: flattenCauses(err), + corr, }); } diff --git a/src/utils/logger/logger.test.ts b/src/utils/logger/logger.test.ts new file mode 100644 index 0000000..33e0104 --- /dev/null +++ b/src/utils/logger/logger.test.ts @@ -0,0 +1,43 @@ +import { assertEquals, assertStringIncludes } from "@std/assert"; +import { Level, newLogger, type Writer } from "./index.ts"; +import { PlatformError } from "@/error/index.ts"; + +function capture(): { writer: Writer; lines: string[] } { + const lines: string[] = []; + return { writer: { write: (l) => lines.push(l) }, lines }; +} + +Deno.test("logger.error flattens the full cause chain", () => { + const { writer, lines } = capture(); + const log = newLogger(Level.Info, { writer }); + + const root = new Error("rpc timeout"); + const mid = new Error("submit failed", { cause: root }); + const top = new PlatformError({ + source: "@service/channel", + code: "CHANNEL_001", + message: "Failed to query channel on-chain state", + baseError: mid, + }); + + log.error(top, "request failed"); + + assertEquals(lines.length, 1); + assertStringIncludes( + lines[0], + "Failed to query channel on-chain state <- submit failed <- rpc timeout", + ); +}); + +Deno.test("logger.error renders correlation ids", () => { + const { writer, lines } = capture(); + const log = newLogger(Level.Info, { writer }); + + log.error(new Error("boom"), "request failed", { + requestId: "req-123", + traceId: "trace-abc", + }); + + assertStringIncludes(lines[0], "requestId=req-123"); + assertStringIncludes(lines[0], "traceId=trace-abc"); +}); diff --git a/tests/integration/api/admin-auth.test.ts b/tests/integration/api/admin-auth.test.ts index 1c6f527..5b57649 100644 --- a/tests/integration/api/admin-auth.test.ts +++ b/tests/integration/api/admin-auth.test.ts @@ -5,7 +5,7 @@ */ import { assertEquals, assertExists } from "@std/assert"; import { newNoop } from "@/utils/logger/index.ts"; -import { createMockContext } from "../../test_app.ts"; +import { createMockContext, runHandler } from "../../test_app.ts"; import { ensureInitialized } from "../../test_helpers.ts"; import { Keypair } from "stellar-sdk"; import { Buffer } from "buffer"; @@ -28,7 +28,7 @@ Deno.test("POST /admin/auth/challenge - returns a nonce", async () => { body: { publicKey: pk }, }); - await handlePostChallenge({ log: newNoop() })(ctx); + await runHandler(ctx, handlePostChallenge({ log: newNoop() })); const res = getResponse(); assertEquals(res.status, 200); @@ -45,10 +45,11 @@ Deno.test("POST /admin/auth/challenge - rejects missing publicKey", async () => body: {}, }); - await handlePostChallenge({ log: newNoop() })(ctx); + await runHandler(ctx, handlePostChallenge({ log: newNoop() })); const res = getResponse(); assertEquals(res.status, 400); + assertEquals(res.body.code, "HTTP_REQ_002"); assertEquals(res.body.message, "publicKey is required"); }); @@ -60,10 +61,11 @@ Deno.test("POST /admin/auth/challenge - rejects invalid Stellar key format", asy body: { publicKey: "not-a-stellar-key" }, }); - await handlePostChallenge({ log: newNoop() })(ctx); + await runHandler(ctx, handlePostChallenge({ log: newNoop() })); const res = getResponse(); assertEquals(res.status, 400); + assertEquals(res.body.code, "HTTP_REQ_002"); assertEquals(res.body.message, "Invalid Stellar public key format"); }); @@ -80,7 +82,7 @@ Deno.test("POST /admin/auth/verify - valid signature returns JWT", async () => { method: "POST", body: { publicKey: pk }, }); - await handlePostChallenge({ log: newNoop() })(challengeCtx.ctx); + await runHandler(challengeCtx.ctx, handlePostChallenge({ log: newNoop() })); const nonce = challengeCtx.getResponse().body.data.nonce; // Step 2: sign the nonce (raw format) @@ -94,7 +96,7 @@ Deno.test("POST /admin/auth/verify - valid signature returns JWT", async () => { body: { nonce, signature, publicKey: pk }, }); - await handlePostVerify({ log: newNoop() })(ctx); + await runHandler(ctx, handlePostVerify({ log: newNoop() })); const res = getResponse(); assertEquals(res.status, 200); @@ -112,7 +114,7 @@ Deno.test("POST /admin/auth/verify - invalid signature returns 401", async () => method: "POST", body: { publicKey: pk }, }); - await handlePostChallenge({ log: newNoop() })(challengeCtx.ctx); + await runHandler(challengeCtx.ctx, handlePostChallenge({ log: newNoop() })); const nonce = challengeCtx.getResponse().body.data.nonce; // Sign with a different key @@ -126,11 +128,14 @@ Deno.test("POST /admin/auth/verify - invalid signature returns 401", async () => body: { nonce, signature, publicKey: pk }, }); - await handlePostVerify({ log: newNoop() })(ctx); + await runHandler(ctx, handlePostVerify({ log: newNoop() })); const res = getResponse(); assertEquals(res.status, 401); - assertEquals(res.body.message, "Authentication failed"); + // The edge now surfaces the specific structured auth code + copy instead of a + // blanket "Authentication failed". + assertEquals(res.body.code, "COUNCIL_AUTH_005"); + assertEquals(res.body.message, "Invalid signature"); }); Deno.test("POST /admin/auth/verify - rejects missing fields", async () => { @@ -141,10 +146,11 @@ Deno.test("POST /admin/auth/verify - rejects missing fields", async () => { body: { nonce: "some-nonce" }, }); - await handlePostVerify({ log: newNoop() })(ctx); + await runHandler(ctx, handlePostVerify({ log: newNoop() })); const res = getResponse(); assertEquals(res.status, 400); + assertEquals(res.body.code, "HTTP_REQ_002"); assertEquals( res.body.message, "nonce, signature, and publicKey are required", diff --git a/tests/integration/api/council-channels.test.ts b/tests/integration/api/council-channels.test.ts index 4094517..0159878 100644 --- a/tests/integration/api/council-channels.test.ts +++ b/tests/integration/api/council-channels.test.ts @@ -5,7 +5,7 @@ */ import { assertEquals } from "@std/assert"; import { newNoop } from "@/utils/logger/index.ts"; -import { createMockContext } from "../../test_app.ts"; +import { createMockContext, runHandler } from "../../test_app.ts"; import { ADMIN_KEYPAIR, drizzleClient, @@ -65,7 +65,7 @@ Deno.test("GET /council/channels - lists channels", async () => { query: { councilId: "default" }, state: { ...adminState }, }); - await handleListChannels({ log: newNoop() })(ctx); + await runHandler(ctx, handleListChannels({ log: newNoop() })); const res = getResponse(); assertEquals(res.status, 200); @@ -92,7 +92,7 @@ Deno.test("POST /council/channels - adds a channel", async () => { query: { councilId: "default" }, state: { ...adminState }, }); - await handleAddChannel({ log: newNoop() })(ctx); + await runHandler(ctx, handleAddChannel({ log: newNoop() })); const res = getResponse(); assertEquals(res.status, 200); @@ -112,10 +112,11 @@ Deno.test("POST /council/channels - rejects invalid contract ID", async () => { query: { councilId: "default" }, state: { ...adminState }, }); - await handleAddChannel({ log: newNoop() })(ctx); + await runHandler(ctx, handleAddChannel({ log: newNoop() })); const res = getResponse(); assertEquals(res.status, 400); + assertEquals(res.body.code, "HTTP_REQ_002"); assertEquals(res.body.message, "Invalid Soroban contract ID format"); }); @@ -132,10 +133,15 @@ Deno.test("POST /council/channels - rejects duplicate contract ID", async () => query: { councilId: "default" }, state: { ...adminState }, }); - await handleAddChannel({ log: newNoop() })(ctx); + await runHandler(ctx, handleAddChannel({ log: newNoop() })); const res = getResponse(); assertEquals(res.status, 409); + assertEquals(res.body.code, "HTTP_REQ_005"); + assertEquals( + res.body.message, + "Channel with this contract ID already exists", + ); }); Deno.test("POST /council/channels - rejects missing assetCode", async () => { @@ -149,10 +155,11 @@ Deno.test("POST /council/channels - rejects missing assetCode", async () => { query: { councilId: "default" }, state: { ...adminState }, }); - await handleAddChannel({ log: newNoop() })(ctx); + await runHandler(ctx, handleAddChannel({ log: newNoop() })); const res = getResponse(); assertEquals(res.status, 400); + assertEquals(res.body.code, "HTTP_REQ_002"); assertEquals(res.body.message, "assetCode is required"); }); @@ -172,7 +179,7 @@ Deno.test("GET /council/channels/:id - returns channel with state", async () => params: { id: channel.id }, state: { ...adminState }, }); - await handleGetChannel({ log: newNoop() })(ctx); + await runHandler(ctx, handleGetChannel({ log: newNoop() })); const res = getResponse(); assertEquals(res.status, 200); @@ -187,10 +194,12 @@ Deno.test("GET /council/channels/:id - returns 404 for non-existent", async () = params: { id: "non-existent-id" }, state: { ...adminState }, }); - await handleGetChannel({ log: newNoop() })(ctx); + await runHandler(ctx, handleGetChannel({ log: newNoop() })); const res = getResponse(); assertEquals(res.status, 404); + assertEquals(res.body.code, "HTTP_REQ_004"); + assertEquals(res.body.message, "Channel not found"); }); // --------------------------------------------------------------------------- @@ -209,7 +218,7 @@ Deno.test("DELETE /council/channels/:id - requests disable (pending, not authori params: { id: channel.id }, state: { ...adminState }, }); - await handleRemoveChannel({ log: newNoop() })(ctx); + await runHandler(ctx, handleRemoveChannel({ log: newNoop() })); const res = getResponse(); // 202: intent recorded, awaiting on-chain confirmation. The endpoint sets the @@ -241,7 +250,7 @@ Deno.test("POST /council/channels/:id/enable - re-enables disabled channel", asy params: { id: channel.id }, state: { ...adminState }, }); - await handleRemoveChannel({ log: newNoop() })(disableCtx.ctx); + await runHandler(disableCtx.ctx, handleRemoveChannel({ log: newNoop() })); await confirmOnChain(channel.channelContractId, false); // Then re-enable — endpoint records intent for the now-disabled channel. @@ -250,7 +259,7 @@ Deno.test("POST /council/channels/:id/enable - re-enables disabled channel", asy params: { id: channel.id }, state: { ...adminState }, }); - await handleEnableChannel({ log: newNoop() })(ctx); + await runHandler(ctx, handleEnableChannel({ log: newNoop() })); const res = getResponse(); assertEquals(res.status, 202); @@ -277,7 +286,7 @@ Deno.test("GET /council/channels/disabled - lists disabled channels", async () = params: { id: ch.id }, state: { ...adminState }, }); - await handleRemoveChannel({ log: newNoop() })(disableCtx.ctx); + await runHandler(disableCtx.ctx, handleRemoveChannel({ log: newNoop() })); await confirmOnChain(ch.channelContractId, false); const { ctx, getResponse } = createMockContext({ @@ -285,7 +294,7 @@ Deno.test("GET /council/channels/disabled - lists disabled channels", async () = query: { councilId: "default" }, state: { ...adminState }, }); - await handleListDisabledChannels({ log: newNoop() })(ctx); + await runHandler(ctx, handleListDisabledChannels({ log: newNoop() })); const res = getResponse(); assertEquals(res.status, 200); @@ -312,10 +321,11 @@ Deno.test("POST /council/channels - rejects assetCode over 12 chars", async () = query: { councilId: "default" }, state: { ...adminState }, }); - await handleAddChannel({ log: newNoop() })(ctx); + await runHandler(ctx, handleAddChannel({ log: newNoop() })); const res = getResponse(); assertEquals(res.status, 400); + assertEquals(res.body.code, "HTTP_REQ_002"); assertEquals( res.body.message, "assetCode must be 1-12 alphanumeric characters", @@ -333,10 +343,11 @@ Deno.test("POST /council/channels - rejects assetCode with special characters", query: { councilId: "default" }, state: { ...adminState }, }); - await handleAddChannel({ log: newNoop() })(ctx); + await runHandler(ctx, handleAddChannel({ log: newNoop() })); const res = getResponse(); assertEquals(res.status, 400); + assertEquals(res.body.code, "HTTP_REQ_002"); assertEquals( res.body.message, "assetCode must be 1-12 alphanumeric characters", @@ -358,10 +369,11 @@ Deno.test("POST /council/channels - rejects label over 200 chars", async () => { query: { councilId: "default" }, state: { ...adminState }, }); - await handleAddChannel({ log: newNoop() })(ctx); + await runHandler(ctx, handleAddChannel({ log: newNoop() })); const res = getResponse(); assertEquals(res.status, 400); + assertEquals(res.body.code, "HTTP_REQ_002"); assertEquals(res.body.message, "label must be at most 200 characters"); }); @@ -376,10 +388,11 @@ Deno.test("POST /council/channels - rejects malformed JSON", async () => { query: { councilId: "default" }, state: { ...adminState }, }); - await handleAddChannel({ log: newNoop() })(ctx); + await runHandler(ctx, handleAddChannel({ log: newNoop() })); const res = getResponse(); assertEquals(res.status, 400); + assertEquals(res.body.code, "HTTP_REQ_001"); assertEquals(res.body.message, "Invalid request body"); }); @@ -398,9 +411,10 @@ Deno.test("POST /council/channels/:id/enable - returns 404 for active channel", params: { id: channel.id }, state: { ...adminState }, }); - await handleEnableChannel({ log: newNoop() })(ctx); + await runHandler(ctx, handleEnableChannel({ log: newNoop() })); const res = getResponse(); assertEquals(res.status, 404); + assertEquals(res.body.code, "HTTP_REQ_004"); assertEquals(res.body.message, "Disabled channel not found"); }); diff --git a/tests/integration/api/council-escrow.test.ts b/tests/integration/api/council-escrow.test.ts index fc4d19e..76155c5 100644 --- a/tests/integration/api/council-escrow.test.ts +++ b/tests/integration/api/council-escrow.test.ts @@ -7,7 +7,7 @@ */ import { assertEquals, assertExists } from "@std/assert"; import { newNoop } from "@/utils/logger/index.ts"; -import { createMockContext } from "../../test_app.ts"; +import { createMockContext, runHandler } from "../../test_app.ts"; import { ADMIN_KEYPAIR, ensureInitialized, @@ -78,7 +78,7 @@ Deno.test("POST /council/escrow - creates escrow with provider JWT", async () => }, state: providerState(providerKp.publicKey()), }); - await handlePostEscrow({ log: newNoop() })(ctx); + await runHandler(ctx, handlePostEscrow({ log: newNoop() })); const res = getResponse(); assertEquals(res.status, 200); @@ -102,7 +102,7 @@ Deno.test("POST /council/escrow - rejects non-provider JWT", async () => { }, state: adminState(), }); - await handlePostEscrow({ log: newNoop() })(ctx); + await runHandler(ctx, handlePostEscrow({ log: newNoop() })); const res = getResponse(); assertEquals(res.status, 403); @@ -131,10 +131,11 @@ Deno.test("POST /council/escrow - rejects invalid amount", async () => { }, state: providerState(providerKp.publicKey()), }); - await handlePostEscrow({ log: newNoop() })(ctx); + await runHandler(ctx, handlePostEscrow({ log: newNoop() })); const res = getResponse(); assertEquals(res.status, 400); + assertEquals(res.body.code, "HTTP_REQ_002"); assertEquals( res.body.message, "amount must be a positive integer string (stroops)", @@ -163,10 +164,11 @@ Deno.test("POST /council/escrow - rejects missing fields", async () => { }, state: providerState(providerKp.publicKey()), }); - await handlePostEscrow({ log: newNoop() })(ctx); + await runHandler(ctx, handlePostEscrow({ log: newNoop() })); const res = getResponse(); assertEquals(res.status, 400); + assertEquals(res.body.code, "HTTP_REQ_002"); }); // --------------------------------------------------------------------------- @@ -196,7 +198,7 @@ Deno.test("GET /council/escrow/:address - returns escrow summary", async () => { params: { address: recipientAddr }, state: adminState(), }); - await handleGetEscrowSummary({ log: newNoop() })(ctx); + await runHandler(ctx, handleGetEscrowSummary({ log: newNoop() })); const res = getResponse(); assertEquals(res.status, 200); @@ -227,7 +229,7 @@ Deno.test("GET /council/recipient/:address/utxos - returns registered=false for count: "1", }, }); - await handleGetRecipientUtxos({ log: newNoop() })(ctx); + await runHandler(ctx, handleGetRecipientUtxos({ log: newNoop() })); const res = getResponse(); assertEquals(res.status, 200); @@ -256,7 +258,7 @@ Deno.test("GET /council/recipient/:address/utxos - returns registered=true for r count: "2", }, }); - await handleGetRecipientUtxos({ log: newNoop() })(ctx); + await runHandler(ctx, handleGetRecipientUtxos({ log: newNoop() })); const res = getResponse(); assertEquals(res.status, 200); @@ -301,7 +303,7 @@ Deno.test("POST /council/escrow/:address/release - releases held escrows", async body: { channelContractId: TEST_CONTRACT_ID }, state: adminState(), }); - await handlePostEscrowRelease({ log: newNoop() })(ctx); + await runHandler(ctx, handlePostEscrowRelease({ log: newNoop() })); const res = getResponse(); assertEquals(res.status, 200); @@ -334,10 +336,11 @@ Deno.test("POST /council/escrow - rejects invalid channelContractId", async () = }, state: providerState(providerKp.publicKey()), }); - await handlePostEscrow({ log: newNoop() })(ctx); + await runHandler(ctx, handlePostEscrow({ log: newNoop() })); const res = getResponse(); assertEquals(res.status, 400); + assertEquals(res.body.code, "HTTP_REQ_002"); assertEquals(res.body.message, "Invalid channelContractId"); }); @@ -354,9 +357,10 @@ Deno.test("POST /council/escrow/:address/release - rejects invalid channelContra body: { channelContractId: "bad" }, state: adminState(), }); - await handlePostEscrowRelease({ log: newNoop() })(ctx); + await runHandler(ctx, handlePostEscrowRelease({ log: newNoop() })); const res = getResponse(); assertEquals(res.status, 400); + assertEquals(res.body.code, "HTTP_REQ_002"); assertEquals(res.body.message, "Valid channelContractId is required"); }); diff --git a/tests/integration/api/council-join-requests.test.ts b/tests/integration/api/council-join-requests.test.ts index 55f029b..74ee11f 100644 --- a/tests/integration/api/council-join-requests.test.ts +++ b/tests/integration/api/council-join-requests.test.ts @@ -10,7 +10,7 @@ */ import { assertEquals } from "@std/assert"; import { newNoop } from "@/utils/logger/index.ts"; -import { createMockContext } from "../../test_app.ts"; +import { createMockContext, runHandler } from "../../test_app.ts"; import { ADMIN_KEYPAIR, ensureInitialized, @@ -53,7 +53,7 @@ Deno.test("GET /council/provider-requests - lists all join requests", async () = query: { councilId: "default" }, state: { ...adminState }, }); - await handleListJoinRequests({ log: newNoop() })(ctx); + await runHandler(ctx, handleListJoinRequests({ log: newNoop() })); const res = getResponse(); assertEquals(res.status, 200); @@ -75,7 +75,7 @@ Deno.test("GET /council/provider-requests?status=PENDING - filters pending", asy query: { status: "PENDING", councilId: "default" }, state: { ...adminState }, }); - await handleListJoinRequests({ log: newNoop() })(ctx); + await runHandler(ctx, handleListJoinRequests({ log: newNoop() })); const res = getResponse(); assertEquals(res.status, 200); @@ -102,7 +102,7 @@ Deno.test("POST /council/provider-requests/:id/reject - rejects a pending reques params: { id: request.id }, state: { ...adminState }, }); - await handleRejectJoinRequest({ log: newNoop() })(ctx); + await runHandler(ctx, handleRejectJoinRequest({ log: newNoop() })); const res = getResponse(); assertEquals(res.status, 200); @@ -120,10 +120,11 @@ Deno.test("POST /council/provider-requests/:id/reject - returns 404 for non-exis params: { id: "non-existent" }, state: { ...adminState }, }); - await handleRejectJoinRequest({ log: newNoop() })(ctx); + await runHandler(ctx, handleRejectJoinRequest({ log: newNoop() })); const res = getResponse(); assertEquals(res.status, 404); + assertEquals(res.body.code, "HTTP_REQ_004"); assertEquals(res.body.message, "Join request not found"); }); @@ -140,10 +141,11 @@ Deno.test("POST /council/provider-requests/:id/reject - returns 409 for already params: { id: request.id }, state: { ...adminState }, }); - await handleRejectJoinRequest({ log: newNoop() })(ctx); + await runHandler(ctx, handleRejectJoinRequest({ log: newNoop() })); const res = getResponse(); assertEquals(res.status, 409); + assertEquals(res.body.code, "HTTP_REQ_005"); }); // --------------------------------------------------------------------------- @@ -161,10 +163,11 @@ Deno.test("POST /council/provider-requests/:id/approve - returns 404 for non-exi params: { id: "non-existent" }, state: { ...adminState }, }); - await handleApproveJoinRequest({ log: newNoop() })(ctx); + await runHandler(ctx, handleApproveJoinRequest({ log: newNoop() })); const res = getResponse(); assertEquals(res.status, 404); + assertEquals(res.body.code, "HTTP_REQ_004"); assertEquals(res.body.message, "Join request not found"); }); @@ -181,8 +184,9 @@ Deno.test("POST /council/provider-requests/:id/approve - returns 409 for already params: { id: request.id }, state: { ...adminState }, }); - await handleApproveJoinRequest({ log: newNoop() })(ctx); + await runHandler(ctx, handleApproveJoinRequest({ log: newNoop() })); const res = getResponse(); assertEquals(res.status, 409); + assertEquals(res.body.code, "HTTP_REQ_005"); }); diff --git a/tests/integration/api/council-jurisdictions.test.ts b/tests/integration/api/council-jurisdictions.test.ts index a1cb621..89901fe 100644 --- a/tests/integration/api/council-jurisdictions.test.ts +++ b/tests/integration/api/council-jurisdictions.test.ts @@ -5,7 +5,7 @@ */ import { assertEquals, assertExists } from "@std/assert"; import { newNoop } from "@/utils/logger/index.ts"; -import { createMockContext } from "../../test_app.ts"; +import { createMockContext, runHandler } from "../../test_app.ts"; import { ADMIN_KEYPAIR, ensureInitialized, @@ -45,7 +45,7 @@ Deno.test("GET /council/jurisdictions - lists jurisdictions", async () => { query: { councilId: "default" }, state: { ...adminState }, }); - await handleListJurisdictions({ log: newNoop() })(ctx); + await runHandler(ctx, handleListJurisdictions({ log: newNoop() })); const res = getResponse(); assertEquals(res.status, 200); @@ -67,7 +67,7 @@ Deno.test("POST /council/jurisdictions - adds a jurisdiction", async () => { query: { councilId: "default" }, state: { ...adminState }, }); - await handleAddJurisdiction({ log: newNoop() })(ctx); + await runHandler(ctx, handleAddJurisdiction({ log: newNoop() })); const res = getResponse(); assertEquals(res.status, 200); @@ -88,10 +88,11 @@ Deno.test("POST /council/jurisdictions - rejects invalid country code", async () query: { councilId: "default" }, state: { ...adminState }, }); - await handleAddJurisdiction({ log: newNoop() })(ctx); + await runHandler(ctx, handleAddJurisdiction({ log: newNoop() })); const res = getResponse(); assertEquals(res.status, 400); + assertEquals(res.body.code, "HTTP_REQ_002"); }); Deno.test("POST /council/jurisdictions - rejects duplicate country code", async () => { @@ -107,10 +108,11 @@ Deno.test("POST /council/jurisdictions - rejects duplicate country code", async query: { councilId: "default" }, state: { ...adminState }, }); - await handleAddJurisdiction({ log: newNoop() })(ctx); + await runHandler(ctx, handleAddJurisdiction({ log: newNoop() })); const res = getResponse(); assertEquals(res.status, 409); + assertEquals(res.body.code, "HTTP_REQ_005"); }); // --------------------------------------------------------------------------- @@ -130,7 +132,7 @@ Deno.test("DELETE /council/jurisdictions/:code - removes a jurisdiction", async query: { councilId: "default" }, state: { ...adminState }, }); - await handleRemoveJurisdiction({ log: newNoop() })(ctx); + await runHandler(ctx, handleRemoveJurisdiction({ log: newNoop() })); const res = getResponse(); assertEquals(res.status, 200); @@ -148,8 +150,10 @@ Deno.test("DELETE /council/jurisdictions/:code - returns 404 for non-existent", query: { councilId: "default" }, state: { ...adminState }, }); - await handleRemoveJurisdiction({ log: newNoop() })(ctx); + await runHandler(ctx, handleRemoveJurisdiction({ log: newNoop() })); const res = getResponse(); assertEquals(res.status, 404); + assertEquals(res.body.code, "HTTP_REQ_004"); + assertEquals(res.body.message, "Jurisdiction ZZ not found"); }); diff --git a/tests/integration/api/council-metadata.test.ts b/tests/integration/api/council-metadata.test.ts index bc47183..60f9e9c 100644 --- a/tests/integration/api/council-metadata.test.ts +++ b/tests/integration/api/council-metadata.test.ts @@ -6,7 +6,7 @@ import { assertEquals } from "@std/assert"; import { newNoop } from "@/utils/logger/index.ts"; import { Keypair } from "stellar-sdk"; -import { createMockContext } from "../../test_app.ts"; +import { createMockContext, runHandler } from "../../test_app.ts"; import { ADMIN_KEYPAIR, ensureInitialized, @@ -43,10 +43,11 @@ Deno.test("GET /council/metadata - returns 404 when no metadata exists", async ( state: { ...adminState }, }); - await handleGetMetadata({ log: newNoop() })(ctx); + await runHandler(ctx, handleGetMetadata({ log: newNoop() })); const res = getResponse(); assertEquals(res.status, 404); + assertEquals(res.body.code, "HTTP_REQ_004"); assertEquals(res.body.message, "Council not found"); }); @@ -61,7 +62,7 @@ Deno.test("GET /council/metadata - returns existing metadata", async () => { state: { ...adminState }, }); - await handleGetMetadata({ log: newNoop() })(ctx); + await runHandler(ctx, handleGetMetadata({ log: newNoop() })); const res = getResponse(); assertEquals(res.status, 200); @@ -89,7 +90,7 @@ Deno.test("PUT /council/metadata - updates metadata", async () => { state: { ...adminState }, }); - await handlePutMetadata({ log: newNoop() })(ctx); + await runHandler(ctx, handlePutMetadata({ log: newNoop() })); const res = getResponse(); assertEquals(res.status, 200); @@ -109,10 +110,11 @@ Deno.test("PUT /council/metadata - rejects missing name", async () => { state: { ...adminState }, }); - await handlePutMetadata({ log: newNoop() })(ctx); + await runHandler(ctx, handlePutMetadata({ log: newNoop() })); const res = getResponse(); assertEquals(res.status, 400); + assertEquals(res.body.code, "HTTP_REQ_002"); assertEquals(res.body.message, "name is required"); }); @@ -126,10 +128,11 @@ Deno.test("PUT /council/metadata - rejects name over 200 chars", async () => { state: { ...adminState }, }); - await handlePutMetadata({ log: newNoop() })(ctx); + await runHandler(ctx, handlePutMetadata({ log: newNoop() })); const res = getResponse(); assertEquals(res.status, 400); + assertEquals(res.body.code, "HTTP_REQ_002"); assertEquals(res.body.message, "name must be at most 200 characters"); }); @@ -148,7 +151,7 @@ Deno.test("PUT /council/metadata - partial upsert preserves existing fields", as state: { ...adminState }, }); - await handlePutMetadata({ log: newNoop() })(ctx); + await runHandler(ctx, handlePutMetadata({ log: newNoop() })); const res = getResponse(); assertEquals(res.status, 200); @@ -177,7 +180,7 @@ Deno.test("PUT /council/metadata - sets councilPublicKey from session sub", asyn }, }); - await handlePutMetadata({ log: newNoop() })(ctx); + await runHandler(ctx, handlePutMetadata({ log: newNoop() })); const res = getResponse(); assertEquals(res.status, 200); @@ -194,7 +197,7 @@ Deno.test("PUT /council/metadata - creates record when none exists", async () => state: { ...adminState }, }); - await handlePutMetadata({ log: newNoop() })(ctx); + await runHandler(ctx, handlePutMetadata({ log: newNoop() })); const res = getResponse(); assertEquals(res.status, 200); @@ -212,10 +215,11 @@ Deno.test("PUT /council/metadata - rejects malformed JSON", async () => { state: { ...adminState }, }); - await handlePutMetadata({ log: newNoop() })(ctx); + await runHandler(ctx, handlePutMetadata({ log: newNoop() })); const res = getResponse(); assertEquals(res.status, 400); + assertEquals(res.body.code, "HTTP_REQ_001"); assertEquals(res.body.message, "Invalid request body"); }); @@ -230,10 +234,11 @@ Deno.test("PUT /council/metadata - rejects description over 2000 chars", async ( state: { ...adminState }, }); - await handlePutMetadata({ log: newNoop() })(ctx); + await runHandler(ctx, handlePutMetadata({ log: newNoop() })); const res = getResponse(); assertEquals(res.status, 400); + assertEquals(res.body.code, "HTTP_REQ_002"); assertEquals(res.body.message, "description must be at most 2000 characters"); }); @@ -248,10 +253,11 @@ Deno.test("PUT /council/metadata - rejects contactEmail over 200 chars", async ( state: { ...adminState }, }); - await handlePutMetadata({ log: newNoop() })(ctx); + await runHandler(ctx, handlePutMetadata({ log: newNoop() })); const res = getResponse(); assertEquals(res.status, 400); + assertEquals(res.body.code, "HTTP_REQ_002"); assertEquals(res.body.message, "contactEmail must be at most 200 characters"); }); @@ -270,7 +276,7 @@ Deno.test("DELETE /council/metadata - deletes all council data", async () => { state: { ...adminState }, }); - await handleDeleteMetadata({ log: newNoop() })(ctx); + await runHandler(ctx, handleDeleteMetadata({ log: newNoop() })); const res = getResponse(); assertEquals(res.status, 200); diff --git a/tests/integration/api/council-providers.test.ts b/tests/integration/api/council-providers.test.ts index 7e80487..f5ccf76 100644 --- a/tests/integration/api/council-providers.test.ts +++ b/tests/integration/api/council-providers.test.ts @@ -5,7 +5,7 @@ */ import { assertEquals } from "@std/assert"; import { newNoop } from "@/utils/logger/index.ts"; -import { createMockContext } from "../../test_app.ts"; +import { createMockContext, runHandler } from "../../test_app.ts"; import { ADMIN_KEYPAIR, ensureInitialized, @@ -54,7 +54,7 @@ Deno.test("GET /council/providers - lists all providers", async () => { query: { councilId: "default" }, state: { ...adminState }, }); - await handleListProviders({ log: newNoop() })(ctx); + await runHandler(ctx, handleListProviders({ log: newNoop() })); const res = getResponse(); assertEquals(res.status, 200); @@ -81,7 +81,7 @@ Deno.test("GET /council/providers?status=ACTIVE - filters to active", async () = query: { status: "ACTIVE", councilId: "default" }, state: { ...adminState }, }); - await handleListProviders({ log: newNoop() })(ctx); + await runHandler(ctx, handleListProviders({ log: newNoop() })); const res = getResponse(); assertEquals(res.status, 200); @@ -105,7 +105,7 @@ Deno.test("GET /council/providers/:id - returns provider details", async () => { params: { id: provider.id }, state: { ...adminState }, }); - await handleGetProvider({ log: newNoop() })(ctx); + await runHandler(ctx, handleGetProvider({ log: newNoop() })); const res = getResponse(); assertEquals(res.status, 200); @@ -121,10 +121,12 @@ Deno.test("GET /council/providers/:id - returns 404 for non-existent", async () params: { id: "non-existent" }, state: { ...adminState }, }); - await handleGetProvider({ log: newNoop() })(ctx); + await runHandler(ctx, handleGetProvider({ log: newNoop() })); const res = getResponse(); assertEquals(res.status, 404); + assertEquals(res.body.code, "HTTP_REQ_004"); + assertEquals(res.body.message, "Provider not found"); }); // --------------------------------------------------------------------------- @@ -144,7 +146,7 @@ Deno.test("PUT /council/providers/:id - updates provider metadata", async () => body: { label: "Updated Label", contactEmail: "new@example.com" }, state: { ...adminState }, }); - await handleUpdateProvider({ log: newNoop() })(ctx); + await runHandler(ctx, handleUpdateProvider({ log: newNoop() })); const res = getResponse(); assertEquals(res.status, 200); diff --git a/tests/integration/api/council-sign.test.ts b/tests/integration/api/council-sign.test.ts index 86a2941..8026063 100644 --- a/tests/integration/api/council-sign.test.ts +++ b/tests/integration/api/council-sign.test.ts @@ -7,7 +7,7 @@ */ import { assertEquals, assertExists } from "@std/assert"; import { newNoop } from "@/utils/logger/index.ts"; -import { createMockContext } from "../../test_app.ts"; +import { createMockContext, runHandler } from "../../test_app.ts"; import { CustodialUserStatus, ensureInitialized, @@ -72,7 +72,7 @@ Deno.test("POST /council/sign/register - creates user with provider JWT", async }, state: providerState(providerKp.publicKey()), }); - await handlePostRegisterUser({ log: newNoop() })(ctx); + await runHandler(ctx, handlePostRegisterUser({ log: newNoop() })); const res = getResponse(); assertEquals(res.status, 200); @@ -97,7 +97,7 @@ Deno.test("POST /council/sign/register - rejects non-provider JWT", async () => }, state: adminState(kp.publicKey()), }); - await handlePostRegisterUser({ log: newNoop() })(ctx); + await runHandler(ctx, handlePostRegisterUser({ log: newNoop() })); const res = getResponse(); assertEquals(res.status, 403); @@ -122,13 +122,13 @@ Deno.test("POST /council/sign/register - returns same data for duplicate registr // First registration const first = createMockContext({ method: "POST", body, state }); - await handlePostRegisterUser({ log: newNoop() })(first.ctx); + await runHandler(first.ctx, handlePostRegisterUser({ log: newNoop() })); const res1 = first.getResponse(); assertEquals(res1.status, 200); // Second registration (duplicate) const second = createMockContext({ method: "POST", body, state }); - await handlePostRegisterUser({ log: newNoop() })(second.ctx); + await runHandler(second.ctx, handlePostRegisterUser({ log: newNoop() })); const res2 = second.getResponse(); assertEquals(res2.status, 200); @@ -154,10 +154,11 @@ Deno.test("POST /council/sign/register - rejects missing externalId", async () = body: { councilId: "default", channelContractId: TEST_CONTRACT_ID }, state: providerState(providerKp.publicKey()), }); - await handlePostRegisterUser({ log: newNoop() })(ctx); + await runHandler(ctx, handlePostRegisterUser({ log: newNoop() })); const res = getResponse(); assertEquals(res.status, 400); + assertEquals(res.body.code, "HTTP_REQ_002"); assertEquals(res.body.message, "externalId is required"); }); @@ -187,7 +188,7 @@ Deno.test("POST /council/sign/keys - returns derived public keys", async () => { }, state, }); - await handlePostRegisterUser({ log: newNoop() })(regCtx.ctx); + await runHandler(regCtx.ctx, handlePostRegisterUser({ log: newNoop() })); assertEquals(regCtx.getResponse().status, 200); // Request keys at indices [0, 1, 2] @@ -201,7 +202,7 @@ Deno.test("POST /council/sign/keys - returns derived public keys", async () => { }, state, }); - await handlePostGetKeys({ log: newNoop() })(ctx); + await runHandler(ctx, handlePostGetKeys({ log: newNoop() })); const res = getResponse(); assertEquals(res.status, 200); @@ -233,7 +234,7 @@ Deno.test("POST /council/sign/keys - rejects unregistered user", async () => { }, state: providerState(providerKp.publicKey()), }); - await handlePostGetKeys({ log: newNoop() })(ctx); + await runHandler(ctx, handlePostGetKeys({ log: newNoop() })); const res = getResponse(); assertEquals(res.status, 500); @@ -261,10 +262,11 @@ Deno.test("POST /council/sign/keys - rejects more than 300 indices", async () => }, state: providerState(providerKp.publicKey()), }); - await handlePostGetKeys({ log: newNoop() })(ctx); + await runHandler(ctx, handlePostGetKeys({ log: newNoop() })); const res = getResponse(); assertEquals(res.status, 400); + assertEquals(res.body.code, "HTTP_REQ_002"); assertEquals(res.body.message, "Maximum 300 indices per request"); }); @@ -294,7 +296,7 @@ Deno.test("POST /council/sign/spend - returns signatures for valid request", asy }, state, }); - await handlePostRegisterUser({ log: newNoop() })(regCtx.ctx); + await runHandler(regCtx.ctx, handlePostRegisterUser({ log: newNoop() })); assertEquals(regCtx.getResponse().status, 200); // Sign a spend @@ -311,7 +313,7 @@ Deno.test("POST /council/sign/spend - returns signatures for valid request", asy }, state, }); - await handlePostSignSpend({ log: newNoop() })(ctx); + await runHandler(ctx, handlePostSignSpend({ log: newNoop() })); const res = getResponse(); assertEquals(res.status, 200); @@ -338,7 +340,7 @@ Deno.test("POST /council/sign/spend - rejects non-provider JWT", async () => { }, state: adminState(kp.publicKey()), }); - await handlePostSignSpend({ log: newNoop() })(ctx); + await runHandler(ctx, handlePostSignSpend({ log: newNoop() })); const res = getResponse(); assertEquals(res.status, 403); @@ -365,10 +367,11 @@ Deno.test("POST /council/sign/spend - rejects unregistered user", async () => { }, state: providerState(providerKp.publicKey()), }); - await handlePostSignSpend({ log: newNoop() })(ctx); + await runHandler(ctx, handlePostSignSpend({ log: newNoop() })); const res = getResponse(); assertEquals(res.status, 404); + assertEquals(res.body.code, "HTTP_REQ_004"); assertEquals(res.body.message, "User not registered for this channel"); }); @@ -406,7 +409,7 @@ Deno.test("POST /council/sign/spend - rejects wrong provider for user", async () }, state: providerState(providerBKp.publicKey()), }); - await handlePostSignSpend({ log: newNoop() })(ctx); + await runHandler(ctx, handlePostSignSpend({ log: newNoop() })); const res = getResponse(); assertEquals(res.status, 403); @@ -441,7 +444,7 @@ Deno.test("POST /council/sign/spend - rejects suspended user", async () => { }, state: providerState(providerKp.publicKey()), }); - await handlePostSignSpend({ log: newNoop() })(ctx); + await runHandler(ctx, handlePostSignSpend({ log: newNoop() })); const res = getResponse(); assertEquals(res.status, 403); @@ -475,10 +478,11 @@ Deno.test("POST /council/sign/spend - rejects invalid hex message", async () => }, state: providerState(providerKp.publicKey()), }); - await handlePostSignSpend({ log: newNoop() })(ctx); + await runHandler(ctx, handlePostSignSpend({ log: newNoop() })); const res = getResponse(); assertEquals(res.status, 400); + assertEquals(res.body.code, "HTTP_REQ_002"); assertEquals( res.body.message, "message must be a valid hex string with even length", diff --git a/tests/integration/api/middleware.test.ts b/tests/integration/api/middleware.test.ts index 93efc8e..888b36e 100644 --- a/tests/integration/api/middleware.test.ts +++ b/tests/integration/api/middleware.test.ts @@ -4,7 +4,7 @@ * Run with: deno test --allow-all --no-check --config tests/deno.json tests/integration/api/middleware.test.ts */ import { assertEquals } from "@std/assert"; -import { createMockContext } from "../../test_app.ts"; +import { createMockContext, runMiddleware } from "../../test_app.ts"; import { createWalletJwt } from "../../test_jwt.ts"; import { corsMiddleware } from "@/http/middleware/cors.ts"; @@ -155,13 +155,14 @@ Deno.test("jwtMiddleware - rejects missing Authorization header", async () => { const { ctx, getResponse } = createMockContext({ method: "GET" }); let nextCalled = false; // deno-lint-ignore require-await -- mock satisfies oak Next() => Promise - await jwtMiddleware({ log: newNoop() })(ctx, async () => { + await runMiddleware(ctx, jwtMiddleware({ log: newNoop() }), async () => { nextCalled = true; }); assertEquals(nextCalled, false); const res = getResponse(); assertEquals(res.status, 401); + assertEquals(res.body.code, "HTTP_AUTH_001"); assertEquals(res.body.message, "Missing authorization header"); }); @@ -172,13 +173,14 @@ Deno.test("jwtMiddleware - rejects invalid Authorization format", async () => { }); let nextCalled = false; // deno-lint-ignore require-await -- mock satisfies oak Next() => Promise - await jwtMiddleware({ log: newNoop() })(ctx, async () => { + await runMiddleware(ctx, jwtMiddleware({ log: newNoop() }), async () => { nextCalled = true; }); assertEquals(nextCalled, false); const res = getResponse(); assertEquals(res.status, 401); + assertEquals(res.body.code, "HTTP_AUTH_002"); assertEquals(res.body.message, "Invalid authorization header"); }); @@ -189,13 +191,14 @@ Deno.test("jwtMiddleware - rejects invalid JWT token", async () => { }); let nextCalled = false; // deno-lint-ignore require-await -- mock satisfies oak Next() => Promise - await jwtMiddleware({ log: newNoop() })(ctx, async () => { + await runMiddleware(ctx, jwtMiddleware({ log: newNoop() }), async () => { nextCalled = true; }); assertEquals(nextCalled, false); const res = getResponse(); assertEquals(res.status, 401); + assertEquals(res.body.code, "HTTP_AUTH_004"); assertEquals(res.body.message, "JWT verification failed"); }); @@ -207,7 +210,7 @@ Deno.test("jwtMiddleware - accepts valid wallet JWT and sets session", async () }); let nextCalled = false; // deno-lint-ignore require-await -- mock satisfies oak Next() => Promise - await jwtMiddleware({ log: newNoop() })(ctx, async () => { + await runMiddleware(ctx, jwtMiddleware({ log: newNoop() }), async () => { nextCalled = true; }); diff --git a/tests/integration/api/public-routes.test.ts b/tests/integration/api/public-routes.test.ts index 70ab3af..297eb97 100644 --- a/tests/integration/api/public-routes.test.ts +++ b/tests/integration/api/public-routes.test.ts @@ -11,7 +11,7 @@ * Run with: deno test --allow-all --no-check --config tests/deno.json tests/integration/api/public-routes.test.ts */ import { assertEquals, assertExists } from "@std/assert"; -import { createMockContext } from "../../test_app.ts"; +import { createMockContext, runHandler } from "../../test_app.ts"; import { ensureInitialized, JoinRequestStatus, @@ -95,7 +95,7 @@ Deno.test("POST /public/provider/join-request - creates a join request", async ( }, }); - await joinRequestHandler(ctx); + await runHandler(ctx, joinRequestHandler); const res = getResponse(); assertEquals(res.status, 200); @@ -115,10 +115,11 @@ Deno.test("POST /public/provider/join-request - rejects missing publicKey", asyn body: { councilId: "default", label: "No Key" }, }); - await joinRequestHandler(ctx); + await runHandler(ctx, joinRequestHandler); const res = getResponse(); assertEquals(res.status, 400); + assertEquals(res.body.code, "HTTP_REQ_002"); assertEquals(res.body.message, "publicKey is required"); }); @@ -132,10 +133,11 @@ Deno.test("POST /public/provider/join-request - rejects invalid Stellar key", as body: { publicKey: "not-a-stellar-key", councilId: "default" }, }); - await joinRequestHandler(ctx); + await runHandler(ctx, joinRequestHandler); const res = getResponse(); assertEquals(res.status, 400); + assertEquals(res.body.code, "HTTP_REQ_002"); assertEquals(res.body.message, "Invalid Stellar public key format"); }); @@ -152,10 +154,11 @@ Deno.test("POST /public/provider/join-request - rejects duplicate pending reques body: { publicKey: pk, councilId: "default" }, }); - await joinRequestHandler(ctx); + await runHandler(ctx, joinRequestHandler); const res = getResponse(); assertEquals(res.status, 409); + assertEquals(res.body.code, "HTTP_REQ_005"); assertEquals( res.body.message, "A pending join request already exists for this public key", @@ -175,7 +178,7 @@ Deno.test("POST /public/provider/join-request - allows request if previous was a body: { publicKey: pk, councilId: "default", label: "Second attempt" }, }); - await joinRequestHandler(ctx); + await runHandler(ctx, joinRequestHandler); const res = getResponse(); assertEquals(res.status, 200); @@ -193,9 +196,10 @@ Deno.test("POST /public/provider/join-request - rejects label over 200 chars", a body: { publicKey: pk, councilId: "default", label: "x".repeat(201) }, }); - await joinRequestHandler(ctx); + await runHandler(ctx, joinRequestHandler); const res = getResponse(); assertEquals(res.status, 400); + assertEquals(res.body.code, "HTTP_REQ_002"); assertEquals(res.body.message, "label must be at most 200 characters"); }); diff --git a/tests/test_app.ts b/tests/test_app.ts index 6a77888..297b0da 100644 --- a/tests/test_app.ts +++ b/tests/test_app.ts @@ -6,6 +6,44 @@ * and call route handlers directly — same pattern as provider-platform. */ +import { PIPE_APIError } from "@/http/pipelines/error-pipeline.ts"; +import { newNoop } from "@/utils/logger/index.ts"; + +/** + * Run a route handler through the same edge error-translation the global + * `errorMiddleware` applies in production. Handlers now `throw` structured + * `PlatformError`s instead of writing ad-hoc bodies, so error paths must go + * through `PIPE_APIError` to yield the `{ status, code, message, details }` + * response the client (and these assertions) see. Success paths are unaffected. + */ +export async function runHandler( + ctx: any, + handler: (ctx: any) => Promise | void, +): Promise { + try { + await handler(ctx); + } catch (error) { + await PIPE_APIError(ctx, { log: newNoop() }).run(error as Error); + } +} + +/** + * Same edge translation as `runHandler`, for middleware under test that now + * `throw`s structured errors (e.g. `jwtMiddleware`) instead of writing the + * response itself. + */ +export async function runMiddleware( + ctx: any, + middleware: (ctx: any, next: () => Promise) => Promise, + next: () => Promise, +): Promise { + try { + await middleware(ctx, next); + } catch (error) { + await PIPE_APIError(ctx, { log: newNoop() }).run(error as Error); + } +} + export type MockResponse = { status: number; body: any;