diff --git a/src/config/logger.ts b/src/config/logger.ts index e1f6efa..02da0bc 100644 --- a/src/config/logger.ts +++ b/src/config/logger.ts @@ -1,19 +1,10 @@ -import { Logger, LogLevel } from "@/utils/logger/index.ts"; -import { loadOptionalEnv } from "@/utils/env/loadEnv.ts"; - -const LOG_LEVEL = - (loadOptionalEnv("LOG_LEVEL") ?? "INFO") as keyof typeof LogLevel; - -if (LOG_LEVEL !== undefined && LOG_LEVEL in LogLevel) { - // Valid log level -} else { - console.warn( - `Invalid LOG_LEVEL: "${LOG_LEVEL}". Falling back to INFO. Valid values: ${ - Object.keys(LogLevel).filter((k) => isNaN(Number(k))).join(", ") - }`, - ); +import { type Logger, newLogger, parseLevel } from "@/utils/logger/index.ts"; + +/** + * Creates the root logger from `LOG_LEVEL` env var. Called once in main.ts; + * the returned logger is threaded through to every service and route handler + * via dependency injection. There is no module-level singleton. + */ +export function createLogger(): Logger { + return newLogger(parseLevel(Deno.env.get("LOG_LEVEL"))); } - -const LOG = new Logger(LogLevel[LOG_LEVEL] ?? LogLevel.INFO); - -export { LOG }; diff --git a/src/core/service/auth/service/service-auth-secret.ts b/src/core/service/auth/service/service-auth-secret.ts index f896edf..1eda9c3 100644 --- a/src/core/service/auth/service/service-auth-secret.ts +++ b/src/core/service/auth/service/service-auth-secret.ts @@ -14,9 +14,8 @@ if (!SERVICE_AUTH_SECRET || SERVICE_AUTH_SECRET.trim().length === 0) { "SERVICE_AUTH_SECRET must be set and non-empty in production. A random secret would invalidate all JWTs on restart.", ); } - console.warn( - "WARNING: SERVICE_AUTH_SECRET is not set. Generating a random secret. This is NOT recommended for production environments.", - ); + // Dev mode: a random secret is generated below. main.ts emits an event + // when bootstrap detects this so the logger can carry the notice. } export const authSecret = SERVICE_AUTH_SECRET || generateSecret(); diff --git a/src/core/service/auth/wallet-auth.ts b/src/core/service/auth/wallet-auth.ts index f3dfda3..752834c 100644 --- a/src/core/service/auth/wallet-auth.ts +++ b/src/core/service/auth/wallet-auth.ts @@ -1,6 +1,6 @@ import { Keypair } from "stellar-sdk"; import { Buffer } from "buffer"; -import { LOG } from "@/config/logger.ts"; +import type { Logger } from "@/utils/logger/index.ts"; import { withSpan } from "@/core/tracing.ts"; const MAX_PENDING_CHALLENGES = 1000; @@ -18,15 +18,22 @@ interface PendingChallenge { const pendingChallenges = new Map(); -export function createWalletChallenge(publicKey: string): { nonce: string } { - cleanupExpiredChallenges(); +export function createWalletChallenge( + publicKey: string, + deps: { log: Logger }, +): { nonce: string } { + const log = deps.log.scope("createWalletChallenge"); + log.info("createWalletChallenge"); + log.debug("publicKey", publicKey); + + cleanupExpiredChallenges(deps); if (pendingChallenges.size >= MAX_PENDING_CHALLENGES) { throw new Error("Too many pending challenges. Try again later."); } const nonceBytes = crypto.getRandomValues(new Uint8Array(32)); const nonce = btoa(String.fromCharCode(...nonceBytes)); pendingChallenges.set(nonce, { nonce, publicKey, createdAt: Date.now() }); - LOG.debug("Wallet challenge created", { publicKey }); + log.event("wallet challenge created"); return { nonce }; } @@ -39,7 +46,12 @@ export function verifyWalletChallenge( signature: string, publicKey: string, config: WalletAuthConfig, + deps: { log: Logger }, ): Promise<{ token: string }> { + const log = deps.log.scope("verifyWalletChallenge"); + log.info("verifyWalletChallenge"); + log.debug("publicKey", publicKey); + return withSpan("WalletAuth.verify", async (span) => { span.setAttribute("wallet.public_key", publicKey); const challenge = pendingChallenges.get(nonce); @@ -112,16 +124,21 @@ export function verifyWalletChallenge( ).join(""); const token = await config.generateToken(publicKey, hashedSessionId); - LOG.info("Wallet auth successful", { publicKey }); + log.event("wallet auth successful"); return { token }; }); } -function cleanupExpiredChallenges(): void { +function cleanupExpiredChallenges(deps: { log: Logger }): void { + const log = deps.log.scope("cleanupExpiredChallenges"); + log.info("cleanupExpiredChallenges"); const now = Date.now(); + let removed = 0; for (const [nonce, challenge] of pendingChallenges) { if (now - challenge.createdAt > challengeTtlMs) { pendingChallenges.delete(nonce); + removed++; } } + log.debug("removed", removed); } diff --git a/src/core/service/auth/wallet-auth_test.ts b/src/core/service/auth/wallet-auth_test.ts index 97f1e18..84f5ff8 100644 --- a/src/core/service/auth/wallet-auth_test.ts +++ b/src/core/service/auth/wallet-auth_test.ts @@ -7,12 +7,14 @@ import { verifyWalletChallenge, type WalletAuthConfig, } from "./wallet-auth.ts"; +import { newNoop } from "@/utils/logger/index.ts"; const TEST_TOKEN = "test-jwt-token"; const config: WalletAuthConfig = { generateToken: (_subject: string, _sessionId: string) => Promise.resolve(TEST_TOKEN), }; +const deps = { log: newNoop() }; function signNonceRaw(kp: Keypair, nonce: string): string { // Raw format: sign the decoded nonce bytes (matches the wallet @@ -23,14 +25,14 @@ function signNonceRaw(kp: Keypair, nonce: string): string { Deno.test("createWalletChallenge returns a base64 nonce", () => { const kp = Keypair.random(); - const { nonce } = createWalletChallenge(kp.publicKey()); + const { nonce } = createWalletChallenge(kp.publicKey(), deps); // 32 random bytes → 44 char base64 (with padding) assertEquals(nonce.length, 44); }); Deno.test("verifyWalletChallenge succeeds with a valid raw signature", async () => { const kp = Keypair.random(); - const { nonce } = createWalletChallenge(kp.publicKey()); + const { nonce } = createWalletChallenge(kp.publicKey(), deps); const signature = signNonceRaw(kp, nonce); const { token } = await verifyWalletChallenge( @@ -38,6 +40,7 @@ Deno.test("verifyWalletChallenge succeeds with a valid raw signature", async () signature, kp.publicKey(), config, + deps, ); assertEquals(token, TEST_TOKEN); }); @@ -51,6 +54,7 @@ Deno.test("verifyWalletChallenge rejects an unknown nonce", async () => { "irrelevant", kp.publicKey(), config, + deps, ), Error, "Challenge not found or expired", @@ -63,11 +67,18 @@ Deno.test("verifyWalletChallenge rejects on public key mismatch", async () => { // must be rejected before the signature check. const owner = Keypair.random(); const attacker = Keypair.random(); - const { nonce } = createWalletChallenge(owner.publicKey()); + const { nonce } = createWalletChallenge(owner.publicKey(), deps); const signature = signNonceRaw(attacker, nonce); await assertRejects( - () => verifyWalletChallenge(nonce, signature, attacker.publicKey(), config), + () => + verifyWalletChallenge( + nonce, + signature, + attacker.publicKey(), + config, + deps, + ), Error, "Public key mismatch", ); @@ -76,12 +87,19 @@ Deno.test("verifyWalletChallenge rejects on public key mismatch", async () => { Deno.test("verifyWalletChallenge rejects an invalid signature", async () => { const kp = Keypair.random(); const other = Keypair.random(); - const { nonce } = createWalletChallenge(kp.publicKey()); + const { nonce } = createWalletChallenge(kp.publicKey(), deps); // Signature from a different key — should fail across all 3 verification formats. const badSignature = signNonceRaw(other, nonce); await assertRejects( - () => verifyWalletChallenge(nonce, badSignature, kp.publicKey(), config), + () => + verifyWalletChallenge( + nonce, + badSignature, + kp.publicKey(), + config, + deps, + ), Error, "Invalid signature", ); @@ -91,12 +109,13 @@ Deno.test("verifyWalletChallenge rejects an expired challenge", async () => { setChallengeTtlMs(1); // 1ms TTL try { const kp = Keypair.random(); - const { nonce } = createWalletChallenge(kp.publicKey()); + const { nonce } = createWalletChallenge(kp.publicKey(), deps); await new Promise((r) => setTimeout(r, 5)); const signature = signNonceRaw(kp, nonce); await assertRejects( - () => verifyWalletChallenge(nonce, signature, kp.publicKey(), config), + () => + verifyWalletChallenge(nonce, signature, kp.publicKey(), config, deps), Error, "Challenge expired", ); @@ -107,14 +126,14 @@ Deno.test("verifyWalletChallenge rejects an expired challenge", async () => { Deno.test("verifyWalletChallenge consumes the nonce on success (single-use)", async () => { const kp = Keypair.random(); - const { nonce } = createWalletChallenge(kp.publicKey()); + const { nonce } = createWalletChallenge(kp.publicKey(), deps); const signature = signNonceRaw(kp, nonce); // First call succeeds… - await verifyWalletChallenge(nonce, signature, kp.publicKey(), config); + await verifyWalletChallenge(nonce, signature, kp.publicKey(), config, deps); // …second call with the same nonce must fail (replay protection). await assertRejects( - () => verifyWalletChallenge(nonce, signature, kp.publicKey(), config), + () => verifyWalletChallenge(nonce, signature, kp.publicKey(), config, deps), Error, "Challenge not found or expired", ); @@ -123,7 +142,7 @@ Deno.test("verifyWalletChallenge consumes the nonce on success (single-use)", as Deno.test("verifyWalletChallenge does NOT consume the nonce on a bad signature", async () => { const kp = Keypair.random(); const other = Keypair.random(); - const { nonce } = createWalletChallenge(kp.publicKey()); + const { nonce } = createWalletChallenge(kp.publicKey(), deps); // First, fail with a bad signature. await assertRejects( @@ -133,6 +152,7 @@ Deno.test("verifyWalletChallenge does NOT consume the nonce on a bad signature", signNonceRaw(other, nonce), kp.publicKey(), config, + deps, ), Error, "Invalid signature", @@ -145,6 +165,7 @@ Deno.test("verifyWalletChallenge does NOT consume the nonce on a bad signature", goodSignature, kp.publicKey(), config, + deps, ); assertEquals(token, TEST_TOKEN); }); diff --git a/src/core/service/payment/payment-session.ts b/src/core/service/payment/payment-session.ts index 4ced648..7332b70 100644 --- a/src/core/service/payment/payment-session.ts +++ b/src/core/service/payment/payment-session.ts @@ -8,7 +8,7 @@ * * Sessions expire after 5 minutes if not submitted. */ -import { LOG } from "@/config/logger.ts"; +import type { Logger } from "@/utils/logger/index.ts"; const SESSION_TTL_MS = 5 * 60 * 1000; @@ -47,17 +47,27 @@ export interface PaymentSession { const sessions = new Map(); -export function createSession(session: PaymentSession): void { +export function createSession( + session: PaymentSession, + deps: { log: Logger }, +): void { + const log = deps.log.scope("paymentSession"); sessions.set(session.id, session); - LOG.debug("Payment session created", { id: session.id }); + log.debug("id", session.id); + log.event("payment session created"); } -export function getSession(id: string): PaymentSession | undefined { +export function getSession( + id: string, + deps: { log: Logger }, +): PaymentSession | undefined { + const log = deps.log.scope("paymentSession"); const session = sessions.get(id); if (!session) return undefined; if (Date.now() - session.createdAt > SESSION_TTL_MS) { sessions.delete(id); - LOG.debug("Payment session expired", { id }); + log.debug("id", id); + log.event("payment session expired"); return undefined; } return session; diff --git a/src/core/service/provider-auth.ts b/src/core/service/provider-auth.ts index f6c0e17..a1344cc 100644 --- a/src/core/service/provider-auth.ts +++ b/src/core/service/provider-auth.ts @@ -10,7 +10,7 @@ */ import { Keypair, Transaction } from "stellar-sdk"; import { PAY_SERVICE_SK } from "@/config/env.ts"; -import { LOG } from "@/config/logger.ts"; +import type { Logger } from "@/utils/logger/index.ts"; import { withSpan } from "@/core/tracing.ts"; interface CachedAuth { @@ -51,7 +51,14 @@ function parseJwtExpiry(jwt: string): number { * Get a valid JWT for the given provider-platform URL. * Returns a cached JWT if still valid, otherwise authenticates fresh. */ -export function getProviderJwt(ppUrl: string): Promise { +export function getProviderJwt( + ppUrl: string, + deps: { log: Logger }, +): Promise { + const log = deps.log.scope("getProviderJwt"); + log.info("getProviderJwt"); + log.debug("ppUrl", ppUrl); + return withSpan("ProviderAuth.getJwt", async (span) => { span.setAttribute("provider.url", ppUrl); const cached = cache.get(ppUrl); @@ -65,7 +72,8 @@ export function getProviderJwt(ppUrl: string): Promise { const publicKey = keypair.publicKey(); span.setAttribute("provider.public_key", publicKey); - LOG.debug("Authenticating with provider-platform", { ppUrl, publicKey }); + log.debug("publicKey", publicKey); + log.event("requesting challenge"); // 1. Get challenge const challengeRes = await fetch( @@ -83,6 +91,8 @@ export function getProviderJwt(ppUrl: string): Promise { throw new Error("Provider returned no challenge XDR"); } + log.event("challenge received"); + // 2. Co-sign the challenge transaction // The provider uses "Standalone Network ; February 2017" for local, // but we parse the XDR without needing the passphrase for signing — @@ -94,6 +104,8 @@ export function getProviderJwt(ppUrl: string): Promise { tx.sign(keypair); const signedXdr = tx.toXDR(); + log.event("submitting signed challenge"); + // 3. Submit co-signed challenge const verifyRes = await fetch(`${ppUrl}/api/v1/stellar/auth`, { method: "POST", @@ -113,7 +125,7 @@ export function getProviderJwt(ppUrl: string): Promise { } cache.set(ppUrl, { jwt, expiresAt: parseJwtExpiry(jwt) }); - LOG.info("Authenticated with provider-platform", { ppUrl, publicKey }); + log.event("authenticated with provider-platform"); return jwt; }); diff --git a/src/http/middleware/admin/index.ts b/src/http/middleware/admin/index.ts index 4d28069..5bba6b7 100644 --- a/src/http/middleware/admin/index.ts +++ b/src/http/middleware/admin/index.ts @@ -3,7 +3,7 @@ import { jwtMiddleware, type JwtSessionData, } from "@/http/middleware/auth/index.ts"; -import { LOG } from "@/config/logger.ts"; +import type { Logger } from "@/utils/logger/index.ts"; import { loadOptionalEnv } from "@/utils/env/loadEnv.ts"; import { MODE } from "@/config/env.ts"; @@ -32,36 +32,43 @@ function getAdminWallets(): Set { * Runs jwtMiddleware first to verify the token, then checks the subject * against the allowlist. */ -export async function adminMiddleware( - ctx: Context, - next: () => Promise, -) { - // First verify the JWT is valid - await jwtMiddleware(ctx, async () => { - // JWT verified — check if the wallet is in the admin allowlist - const session = ctx.state.session as JwtSessionData; - const adminWallets = getAdminWallets(); +export function adminMiddleware( + deps: { log: Logger }, +): (ctx: Context, next: () => Promise) => Promise { + const log = deps.log.scope("adminMiddleware"); - if (adminWallets.size === 0) { - LOG.warn("Admin access attempted but ADMIN_WALLETS is empty"); - ctx.response.status = 403; - ctx.response.body = { message: "Admin access not configured" }; - return; - } + return async (ctx, next) => { + // First verify the JWT is valid + await jwtMiddleware(deps)(ctx, async () => { + // JWT verified — check if the wallet is in the admin allowlist + const session = ctx.state.session as JwtSessionData; + const adminWallets = getAdminWallets(); - // In development mode, skip the allowlist check - if (MODE === "development") { - await next(); - return; - } + if (adminWallets.size === 0) { + log.error( + new Error("ADMIN_WALLETS empty"), + "admin access attempted but ADMIN_WALLETS is empty", + ); + ctx.response.status = 403; + ctx.response.body = { message: "Admin access not configured" }; + return; + } + + // In development mode, skip the allowlist check + if (MODE === "development") { + await next(); + return; + } - if (!adminWallets.has(session.sub)) { - LOG.warn("Admin access denied", { wallet: session.sub }); - ctx.response.status = 403; - ctx.response.body = { message: "Forbidden" }; - return; - } + if (!adminWallets.has(session.sub)) { + log.debug("wallet", session.sub); + log.error(new Error("not in allowlist"), "admin access denied"); + ctx.response.status = 403; + ctx.response.body = { message: "Forbidden" }; + return; + } - await next(); - }); + await next(); + }); + }; } diff --git a/src/http/middleware/append-request-id.ts b/src/http/middleware/append-request-id.ts index f966a4a..91981e9 100644 --- a/src/http/middleware/append-request-id.ts +++ b/src/http/middleware/append-request-id.ts @@ -1,12 +1,16 @@ import type { Context } from "@oak/oak"; -import { LOG } from "@/config/logger.ts"; +import type { Logger } from "@/utils/logger/index.ts"; -export async function appendRequestIdMiddleware( - ctx: Context, - next: () => Promise, -) { - const requestId = crypto.randomUUID(); - ctx.state.requestId = requestId; - LOG.info("Incoming request with ID:", { requestId }); - await next(); +export function appendRequestIdMiddleware( + deps: { log: Logger }, +): (ctx: Context, next: () => Promise) => Promise { + const log = deps.log.scope("requestId"); + + return async (ctx, next) => { + const requestId = crypto.randomUUID(); + ctx.state.requestId = requestId; + log.debug("requestId", requestId); + log.event("incoming request"); + await next(); + }; } diff --git a/src/http/middleware/auth/index.ts b/src/http/middleware/auth/index.ts index e42cc57..c7ec487 100644 --- a/src/http/middleware/auth/index.ts +++ b/src/http/middleware/auth/index.ts @@ -2,46 +2,47 @@ import type { Context } from "@oak/oak"; import { verify } from "@zaubrik/djwt"; import { SERVICE_AUTH_SECRET_AS_CRYPTO_KEY } from "@/core/service/auth/service/service-auth-secret.ts"; 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"; -export async function jwtMiddleware( - ctx: Context, - next: () => Promise, -) { - const authorization = ctx.request.headers.get("authorization"); - if (!isDefined(authorization)) { - return await PIPE_APIError(ctx).run(new E.MISSING_AUTHORIZATION_HEADER()); - } +export function jwtMiddleware( + deps: { log: Logger }, +): (ctx: Context, next: () => Promise) => Promise { + 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; + } - const parts = authorization.split(" "); - if (parts.length !== 2 || parts[0] !== "Bearer") { - return await PIPE_APIError(ctx).run(new E.INVALID_AUTHORIZATION_HEADER()); - } - const token = parts[1]; + const parts = authorization.split(" "); + if (parts.length !== 2 || parts[0] !== "Bearer") { + await PIPE_APIError(ctx, deps).run(new E.INVALID_AUTHORIZATION_HEADER()); + return; + } + const token = parts[1]; - try { - const secretKey = SERVICE_AUTH_SECRET_AS_CRYPTO_KEY; - // verify() will throw if verification fails. - const payload = await verify(token, secretKey); + try { + const secretKey = SERVICE_AUTH_SECRET_AS_CRYPTO_KEY; + const payload = await verify(token, secretKey); - // Optionally, you can decode the token to inspect all fields - // (verify already returns the payload if ) - // const payload = decode(token); + 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; + } - // Check expiration manually if needed - const now = Math.floor(Date.now() / 1000); - if (typeof payload.exp === "number" && now > payload.exp) { - return await PIPE_APIError(ctx).run(new E.EXPIRED_TOKEN()); + ctx.state.session = payload; + } catch (error) { + await PIPE_APIError(ctx, deps).run( + new E.JWT_VERIFICATION_FAILED(error), + ); + return; } - - // Attach the verified payload to ctx.state for later use. - ctx.state.session = payload; - } catch (error) { - return await PIPE_APIError(ctx).run(new E.JWT_VERIFICATION_FAILED(error)); - } - await next(); + await next(); + }; } export type JwtSessionData = JwtPayload; diff --git a/src/http/pipelines/error-pipeline.ts b/src/http/pipelines/error-pipeline.ts index a138ee7..409b376 100644 --- a/src/http/pipelines/error-pipeline.ts +++ b/src/http/pipelines/error-pipeline.ts @@ -1,10 +1,14 @@ import { Pipeline } from "@fifo/convee"; import type { Context } from "@oak/oak"; +import type { Logger } from "@/utils/logger/index.ts"; import { P_SetErrorResponse } from "@/http/processes/set-api-response.ts"; import { P_ErrorToApiResponse } from "@/http/processes/error-to-api-response.ts"; -export const PIPE_APIError = (ctx: Context) => { - return Pipeline.create([P_ErrorToApiResponse(), P_SetErrorResponse(ctx)], { - name: "APIErrorProcessingPipeline", - }); +export const PIPE_APIError = (ctx: Context, deps: { log: Logger }) => { + return Pipeline.create( + [P_ErrorToApiResponse(), P_SetErrorResponse(ctx, deps)], + { + name: "APIErrorProcessingPipeline", + }, + ); }; diff --git a/src/http/processes/set-api-response.ts b/src/http/processes/set-api-response.ts index 1ca84d2..4d19047 100644 --- a/src/http/processes/set-api-response.ts +++ b/src/http/processes/set-api-response.ts @@ -2,16 +2,19 @@ import type { Context } from "@oak/oak"; import { ProcessEngine } from "@fifo/convee"; import type { MetadataHelper } from "@fifo/convee"; import type { ErrorResponse } from "@/http/default-schemas.ts"; -import { LOG } from "@/config/logger.ts"; +import type { Logger } from "@/utils/logger/index.ts"; const PROCESS_NAME = "SetErrorResponse" as const; -const P_SetErrorResponse = (ctx: Context) => { +const P_SetErrorResponse = (ctx: Context, deps: { log: Logger }) => { + const log = deps.log.scope("setApiResponse"); + const setApiResponse = ( response: ErrorResponse, _metadataHelper?: MetadataHelper, ): Context => { - LOG.trace("Setting API response on context", { status: response.status }); + log.debug("status", response.status); + log.event("setting API response on context"); ctx.response.status = response.status; ctx.response.body = response; return ctx; diff --git a/src/http/v1/account/me.ts b/src/http/v1/account/me.ts index b5eac78..48eb359 100644 --- a/src/http/v1/account/me.ts +++ b/src/http/v1/account/me.ts @@ -1,7 +1,7 @@ import { type Context, Status } from "@oak/oak"; import { drizzleClient } from "@/persistence/drizzle/config.ts"; import { PayAccountRepository } from "@/persistence/drizzle/repository/pay-account.repository.ts"; -import { LOG } from "@/config/logger.ts"; +import type { Logger } from "@/utils/logger/index.ts"; import type { JwtSessionData } from "@/http/middleware/auth/index.ts"; import { validateDisplayName, @@ -18,29 +18,34 @@ const accountRepo = new PayAccountRepository(drizzleClient); * Returns the authenticated wallet's pay account. * 404 if the wallet has not yet completed signup. */ -export const getMeHandler = async (ctx: Context) => { - try { - const session = ctx.state.session as JwtSessionData; - const account = await accountRepo.findByPublicKey(session.sub); - if (!account) { - ctx.response.status = Status.NotFound; - ctx.response.body = { message: "Account not found" }; - return; +export function handleGetMe( + deps: { log: Logger }, +): (ctx: Context) => Promise { + const log = deps.log.scope("getMe"); + + return async (ctx) => { + log.info("getMe"); + try { + const session = ctx.state.session as JwtSessionData; + const account = await accountRepo.findByPublicKey(session.sub); + if (!account) { + ctx.response.status = Status.NotFound; + ctx.response.body = { message: "Account not found" }; + return; + } + await accountRepo.updateLastSeen(session.sub); + ctx.response.status = Status.OK; + ctx.response.body = { + message: "Account retrieved", + data: formatAccount(account), + }; + } catch (error) { + log.error(error, "failed to get account"); + ctx.response.status = Status.InternalServerError; + ctx.response.body = { message: "Failed to retrieve account" }; } - await accountRepo.updateLastSeen(session.sub); - ctx.response.status = Status.OK; - ctx.response.body = { - message: "Account retrieved", - data: formatAccount(account), - }; - } catch (error) { - LOG.error("Failed to get account", { - error: error instanceof Error ? error.message : String(error), - }); - ctx.response.status = Status.InternalServerError; - ctx.response.body = { message: "Failed to retrieve account" }; - } -}; + }; +} /** * PATCH /api/v1/account/me @@ -49,87 +54,91 @@ export const getMeHandler = async (ctx: Context) => { * Editable: email, jurisdictionCountryCode, displayName. * walletPublicKey is immutable. */ -export const patchMeHandler = async (ctx: Context) => { - try { - const session = ctx.state.session as JwtSessionData; - const walletPublicKey = session.sub; +export function handlePatchMe( + deps: { log: Logger }, +): (ctx: Context) => Promise { + const log = deps.log.scope("patchMe"); - const existing = await accountRepo.findByPublicKey(walletPublicKey); - if (!existing) { - ctx.response.status = Status.NotFound; - ctx.response.body = { message: "Account not found" }; - return; - } - - const body = await ctx.request.body.json().catch(() => ({})); - const updates: Record = {}; + return async (ctx) => { + log.info("patchMe"); + try { + const session = ctx.state.session as JwtSessionData; + const walletPublicKey = session.sub; + log.debug("walletPublicKey", walletPublicKey); - if (body.email !== undefined) { - const err = validateEmail(body.email); - if (err) { - ctx.response.status = Status.BadRequest; - ctx.response.body = { message: err }; + const existing = await accountRepo.findByPublicKey(walletPublicKey); + if (!existing) { + ctx.response.status = Status.NotFound; + ctx.response.body = { message: "Account not found" }; return; } - updates.email = (body.email as string).trim(); - } - if (body.jurisdictionCountryCode !== undefined) { - const err = validateJurisdiction(body.jurisdictionCountryCode); - if (err) { + const body = await ctx.request.body.json().catch(() => ({})); + const updates: Record = {}; + + if (body.email !== undefined) { + const err = validateEmail(body.email); + if (err) { + ctx.response.status = Status.BadRequest; + ctx.response.body = { message: err }; + return; + } + updates.email = (body.email as string).trim(); + } + + if (body.jurisdictionCountryCode !== undefined) { + const err = validateJurisdiction(body.jurisdictionCountryCode); + if (err) { + ctx.response.status = Status.BadRequest; + ctx.response.body = { message: err }; + return; + } + updates.jurisdictionCountryCode = + (body.jurisdictionCountryCode as string).toUpperCase(); + } + + if (body.displayName !== undefined) { + const err = validateDisplayName(body.displayName); + if (err) { + ctx.response.status = Status.BadRequest; + ctx.response.body = { message: err }; + return; + } + updates.displayName = body.displayName === null + ? null + : (body.displayName as string).trim(); + } + + if (Object.keys(updates).length === 0) { ctx.response.status = Status.BadRequest; - ctx.response.body = { message: err }; + ctx.response.body = { message: "No editable fields provided" }; return; } - updates.jurisdictionCountryCode = (body.jurisdictionCountryCode as string) - .toUpperCase(); - } - if (body.displayName !== undefined) { - const err = validateDisplayName(body.displayName); - if (err) { - ctx.response.status = Status.BadRequest; - ctx.response.body = { message: err }; + const updated = await accountRepo.update(walletPublicKey, updates); + if (!updated) { + ctx.response.status = Status.InternalServerError; + ctx.response.body = { message: "Failed to update account" }; return; } - updates.displayName = body.displayName === null - ? null - : (body.displayName as string).trim(); - } - if (Object.keys(updates).length === 0) { - ctx.response.status = Status.BadRequest; - ctx.response.body = { message: "No editable fields provided" }; - return; - } + log.debug("fields", Object.keys(updates)); + log.event("pay account updated"); - const updated = await accountRepo.update(walletPublicKey, updates); - if (!updated) { + ctx.response.status = Status.OK; + ctx.response.body = { + message: "Account updated", + data: formatAccount(updated), + }; + } catch (error) { + if (error instanceof SyntaxError) { + ctx.response.status = Status.BadRequest; + ctx.response.body = { message: "Invalid request body" }; + return; + } + log.error(error, "failed to update account"); ctx.response.status = Status.InternalServerError; ctx.response.body = { message: "Failed to update account" }; - return; - } - - LOG.info("Pay account updated", { - walletPublicKey, - fields: Object.keys(updates), - }); - - ctx.response.status = Status.OK; - ctx.response.body = { - message: "Account updated", - data: formatAccount(updated), - }; - } catch (error) { - if (error instanceof SyntaxError) { - ctx.response.status = Status.BadRequest; - ctx.response.body = { message: "Invalid request body" }; - return; } - LOG.error("Failed to update account", { - error: error instanceof Error ? error.message : String(error), - }); - ctx.response.status = Status.InternalServerError; - ctx.response.body = { message: "Failed to update account" }; - } -}; + }; +} diff --git a/src/http/v1/account/opex.ts b/src/http/v1/account/opex.ts index 3f2c2c4..6e32325 100644 --- a/src/http/v1/account/opex.ts +++ b/src/http/v1/account/opex.ts @@ -3,7 +3,7 @@ import { drizzleClient } from "@/persistence/drizzle/config.ts"; import { PayAccountRepository } from "@/persistence/drizzle/repository/pay-account.repository.ts"; import { encryptSk } from "@/core/crypto/encrypt-sk.ts"; import { SERVICE_AUTH_SECRET } from "@/config/env.ts"; -import { LOG } from "@/config/logger.ts"; +import type { Logger } from "@/utils/logger/index.ts"; import type { JwtSessionData } from "@/http/middleware/auth/index.ts"; const accountRepo = new PayAccountRepository(drizzleClient); @@ -17,65 +17,69 @@ const accountRepo = new PayAccountRepository(drizzleClient); * * Body: { secretKey, publicKey, feePct } */ -export const postOpexHandler = async (ctx: Context) => { - try { - const session = ctx.state.session as JwtSessionData; - const walletPublicKey = session.sub; +export function handlePostOpex( + deps: { log: Logger }, +): (ctx: Context) => Promise { + const log = deps.log.scope("postOpex"); - const body = await ctx.request.body.json().catch(() => ({})); - const { secretKey, publicKey, feePct } = body; + return async (ctx) => { + log.info("postOpex"); + try { + const session = ctx.state.session as JwtSessionData; + const walletPublicKey = session.sub; + log.debug("walletPublicKey", walletPublicKey); - if (typeof secretKey !== "string" || !secretKey.startsWith("S")) { - ctx.response.status = Status.BadRequest; - ctx.response.body = { message: "Invalid secretKey" }; - return; - } - if (typeof publicKey !== "string" || !publicKey.startsWith("G")) { - ctx.response.status = Status.BadRequest; - ctx.response.body = { message: "Invalid publicKey" }; - return; - } - if (typeof feePct !== "number" || feePct < 0 || feePct > 100) { - ctx.response.status = Status.BadRequest; - ctx.response.body = { - message: "feePct must be a number between 0 and 100", - }; - return; - } + const body = await ctx.request.body.json().catch(() => ({})); + const { secretKey, publicKey, feePct } = body; - const account = await accountRepo.findByPublicKey(walletPublicKey); - if (!account) { - ctx.response.status = Status.NotFound; - ctx.response.body = { - message: "Account not found. Create an account first.", - }; - return; - } + if (typeof secretKey !== "string" || !secretKey.startsWith("S")) { + ctx.response.status = Status.BadRequest; + ctx.response.body = { message: "Invalid secretKey" }; + return; + } + if (typeof publicKey !== "string" || !publicKey.startsWith("G")) { + ctx.response.status = Status.BadRequest; + ctx.response.body = { message: "Invalid publicKey" }; + return; + } + if (typeof feePct !== "number" || feePct < 0 || feePct > 100) { + ctx.response.status = Status.BadRequest; + ctx.response.body = { + message: "feePct must be a number between 0 and 100", + }; + return; + } + + const account = await accountRepo.findByPublicKey(walletPublicKey); + if (!account) { + ctx.response.status = Status.NotFound; + ctx.response.body = { + message: "Account not found. Create an account first.", + }; + return; + } - const encrypted = await encryptSk(secretKey, SERVICE_AUTH_SECRET); + const encrypted = await encryptSk(secretKey, SERVICE_AUTH_SECRET); - await accountRepo.update(walletPublicKey, { - opexPublicKey: publicKey, - encryptedOpexSk: encrypted, - feePct: String(feePct), - }); + await accountRepo.update(walletPublicKey, { + opexPublicKey: publicKey, + encryptedOpexSk: encrypted, + feePct: String(feePct), + }); - LOG.info("OpEx account registered", { - walletPublicKey, - opexPublicKey: publicKey, - feePct, - }); + log.debug("opexPublicKey", publicKey); + log.debug("feePct", feePct); + log.event("OpEx account registered"); - ctx.response.status = Status.OK; - ctx.response.body = { - message: "OpEx account registered", - data: { opexPublicKey: publicKey, feePct }, - }; - } catch (error) { - LOG.error("Failed to register OpEx account", { - error: error instanceof Error ? error.message : String(error), - }); - ctx.response.status = Status.InternalServerError; - ctx.response.body = { message: "Failed to register OpEx account" }; - } -}; + ctx.response.status = Status.OK; + ctx.response.body = { + message: "OpEx account registered", + data: { opexPublicKey: publicKey, feePct }, + }; + } catch (error) { + log.error(error, "failed to register OpEx account"); + ctx.response.status = Status.InternalServerError; + ctx.response.body = { message: "Failed to register OpEx account" }; + } + }; +} diff --git a/src/http/v1/account/post.ts b/src/http/v1/account/post.ts index 48f7048..b25cadb 100644 --- a/src/http/v1/account/post.ts +++ b/src/http/v1/account/post.ts @@ -1,7 +1,7 @@ import { type Context, Status } from "@oak/oak"; import { drizzleClient } from "@/persistence/drizzle/config.ts"; import { PayAccountRepository } from "@/persistence/drizzle/repository/pay-account.repository.ts"; -import { LOG } from "@/config/logger.ts"; +import type { Logger } from "@/utils/logger/index.ts"; import type { JwtSessionData } from "@/http/middleware/auth/index.ts"; import { validateDisplayName, @@ -20,77 +20,85 @@ const accountRepo = new PayAccountRepository(drizzleClient); * * Body: { email, jurisdictionCountryCode, displayName? } */ -export const postAccountHandler = async (ctx: Context) => { - try { - const session = ctx.state.session as JwtSessionData; - const walletPublicKey = session.sub; +export function handlePostAccount( + deps: { log: Logger }, +): (ctx: Context) => Promise { + const log = deps.log.scope("postAccount"); - const body = await ctx.request.body.json().catch(() => ({})); - const { email, jurisdictionCountryCode, displayName } = body; + return async (ctx) => { + log.info("postAccount"); + try { + const session = ctx.state.session as JwtSessionData; + const walletPublicKey = session.sub; + log.debug("walletPublicKey", walletPublicKey); - const emailErr = validateEmail(email); - if (emailErr) { - ctx.response.status = Status.BadRequest; - ctx.response.body = { message: emailErr }; - return; - } - const jurisdictionErr = validateJurisdiction(jurisdictionCountryCode); - if (jurisdictionErr) { - ctx.response.status = Status.BadRequest; - ctx.response.body = { message: jurisdictionErr }; - return; - } - const displayNameErr = validateDisplayName(displayName); - if (displayNameErr) { - ctx.response.status = Status.BadRequest; - ctx.response.body = { message: displayNameErr }; - return; - } + const body = await ctx.request.body.json().catch(() => ({})); + const { email, jurisdictionCountryCode, displayName } = body; - // Idempotent: if the account exists, return it. - const existing = await accountRepo.findByPublicKey(walletPublicKey); - if (existing) { - await accountRepo.updateLastSeen(walletPublicKey); - ctx.response.status = Status.OK; - ctx.response.body = { - message: "Account already exists", - data: formatAccount(existing), - }; - return; - } + const emailErr = validateEmail(email); + if (emailErr) { + ctx.response.status = Status.BadRequest; + ctx.response.body = { message: emailErr }; + return; + } + const jurisdictionErr = validateJurisdiction(jurisdictionCountryCode); + if (jurisdictionErr) { + ctx.response.status = Status.BadRequest; + ctx.response.body = { message: jurisdictionErr }; + return; + } + const displayNameErr = validateDisplayName(displayName); + if (displayNameErr) { + ctx.response.status = Status.BadRequest; + ctx.response.body = { message: displayNameErr }; + return; + } - const now = new Date(); - const created = await accountRepo.create({ - walletPublicKey, - email: (email as string).trim(), - jurisdictionCountryCode: (jurisdictionCountryCode as string) - .toUpperCase(), - displayName: typeof displayName === "string" ? displayName.trim() : null, - lastSeenAt: now, - createdAt: now, - updatedAt: now, - }); + // Idempotent: if the account exists, return it. + const existing = await accountRepo.findByPublicKey(walletPublicKey); + if (existing) { + await accountRepo.updateLastSeen(walletPublicKey); + ctx.response.status = Status.OK; + ctx.response.body = { + message: "Account already exists", + data: formatAccount(existing), + }; + return; + } - LOG.info("Pay account created", { walletPublicKey }); + const now = new Date(); + const created = await accountRepo.create({ + walletPublicKey, + email: (email as string).trim(), + jurisdictionCountryCode: (jurisdictionCountryCode as string) + .toUpperCase(), + displayName: typeof displayName === "string" + ? displayName.trim() + : null, + lastSeenAt: now, + createdAt: now, + updatedAt: now, + }); - ctx.response.status = Status.Created; - ctx.response.body = { - message: "Account created", - data: formatAccount(created), - }; - } catch (error) { - if (error instanceof SyntaxError) { - ctx.response.status = Status.BadRequest; - ctx.response.body = { message: "Invalid request body" }; - return; + log.event("pay account created"); + + ctx.response.status = Status.Created; + ctx.response.body = { + message: "Account created", + data: formatAccount(created), + }; + } catch (error) { + if (error instanceof SyntaxError) { + ctx.response.status = Status.BadRequest; + ctx.response.body = { message: "Invalid request body" }; + return; + } + log.error(error, "failed to create account"); + ctx.response.status = Status.InternalServerError; + ctx.response.body = { message: "Failed to create account" }; } - LOG.error("Failed to create account", { - error: error instanceof Error ? error.message : String(error), - }); - ctx.response.status = Status.InternalServerError; - ctx.response.body = { message: "Failed to create account" }; - } -}; + }; +} function formatAccount(row: { walletPublicKey: string; diff --git a/src/http/v1/account/routes.ts b/src/http/v1/account/routes.ts index f198d61..ebffadf 100644 --- a/src/http/v1/account/routes.ts +++ b/src/http/v1/account/routes.ts @@ -1,14 +1,19 @@ import { Router } from "@oak/oak"; +import type { Logger } from "@/utils/logger/index.ts"; import { jwtMiddleware } from "@/http/middleware/auth/index.ts"; -import { postAccountHandler } from "./post.ts"; -import { getMeHandler, patchMeHandler } from "./me.ts"; -import { postOpexHandler } from "./opex.ts"; +import { handlePostAccount } from "./post.ts"; +import { handleGetMe, handlePatchMe } from "./me.ts"; +import { handlePostOpex } from "./opex.ts"; -const accountRouter = new Router(); - -accountRouter.post("/account", jwtMiddleware, postAccountHandler); -accountRouter.get("/account/me", jwtMiddleware, getMeHandler); -accountRouter.patch("/account/me", jwtMiddleware, patchMeHandler); -accountRouter.post("/account/opex", jwtMiddleware, postOpexHandler); - -export default accountRouter; +export function buildAccountRouter(deps: { log: Logger }): Router { + const accountRouter = new Router(); + accountRouter.post("/account", jwtMiddleware(deps), handlePostAccount(deps)); + accountRouter.get("/account/me", jwtMiddleware(deps), handleGetMe(deps)); + accountRouter.patch("/account/me", jwtMiddleware(deps), handlePatchMe(deps)); + accountRouter.post( + "/account/opex", + jwtMiddleware(deps), + handlePostOpex(deps), + ); + return accountRouter; +} diff --git a/src/http/v1/admin/councils.ts b/src/http/v1/admin/councils.ts index 81ebf52..3c7fc83 100644 --- a/src/http/v1/admin/councils.ts +++ b/src/http/v1/admin/councils.ts @@ -4,7 +4,7 @@ import { CouncilRepository } from "@/persistence/drizzle/repository/council.repo import { CouncilChannelRepository } from "@/persistence/drizzle/repository/council-channel.repository.ts"; import { CouncilJurisdictionRepository } from "@/persistence/drizzle/repository/council-jurisdiction.repository.ts"; import { CouncilPpRepository } from "@/persistence/drizzle/repository/council-pp.repository.ts"; -import { LOG } from "@/config/logger.ts"; +import type { Logger } from "@/utils/logger/index.ts"; const councilRepo = new CouncilRepository(drizzleClient); const channelRepo = new CouncilChannelRepository(drizzleClient); @@ -13,359 +13,451 @@ const ppRepo = new CouncilPpRepository(drizzleClient); // ─── Councils ─────────────────────────────────────────────── -export const listCouncils = async (ctx: Context) => { - const councils = await councilRepo.findAll(); - ctx.response.body = { data: councils }; -}; - -export const getCouncil = async (ctx: RouterContext) => { - const id = ctx.params.id; - const row = await councilRepo.findById(id); - if (!row) { - ctx.response.status = Status.NotFound; - ctx.response.body = { message: "Council not found" }; - return; - } - const [channels, jurisdictions, pps] = await Promise.all([ - channelRepo.findByCouncilId(id), - jurisdictionRepo.findByCouncilId(id), - ppRepo.findByCouncilId(id), - ]); - ctx.response.body = { - data: { - ...row, - channels, - jurisdictions: jurisdictions.map((j) => j.countryCode), - pps, - }, - }; -}; - -export const createCouncil = async (ctx: Context) => { - try { - const body = await ctx.request.body.json(); - const { name, channelAuthId, channels, jurisdictions, active } = body; +export function handleListCouncils( + deps: { log: Logger }, +): (ctx: Context) => Promise { + const log = deps.log.scope("listCouncils"); - if (!name || !channelAuthId) { - ctx.response.status = Status.BadRequest; - ctx.response.body = { message: "name and channelAuthId are required" }; + return async (ctx) => { + log.info("listCouncils"); + const councils = await councilRepo.findAll(); + ctx.response.body = { data: councils }; + }; +} + +export function handleGetCouncil( + deps: { log: Logger }, +): (ctx: RouterContext) => Promise { + const log = deps.log.scope("getCouncil"); + + return async (ctx) => { + log.info("getCouncil"); + const id = ctx.params.id; + log.debug("id", id); + const row = await councilRepo.findById(id); + if (!row) { + ctx.response.status = Status.NotFound; + ctx.response.body = { message: "Council not found" }; return; } + const [channels, jurisdictions, pps] = await Promise.all([ + channelRepo.findByCouncilId(id), + jurisdictionRepo.findByCouncilId(id), + ppRepo.findByCouncilId(id), + ]); + ctx.response.body = { + data: { + ...row, + channels, + jurisdictions: jurisdictions.map((j) => j.countryCode), + pps, + }, + }; + }; +} - const row = await councilRepo.create({ - name, - channelAuthId, - active: active ?? true, - }); - - // Create channels if provided - if (Array.isArray(channels) && channels.length > 0) { - for (const ch of channels) { - if (!ch.assetCode || !ch.assetContractId || !ch.privacyChannelId) { - continue; - } - await channelRepo.create({ - councilId: row.id, - assetCode: ch.assetCode, - assetContractId: ch.assetContractId, - privacyChannelId: ch.privacyChannelId, - active: ch.active ?? true, - }); - } - } +export function handleCreateCouncil( + deps: { log: Logger }, +): (ctx: Context) => Promise { + const log = deps.log.scope("createCouncil"); - // Create jurisdictions if provided - if (Array.isArray(jurisdictions) && jurisdictions.length > 0) { - await jurisdictionRepo.bulkCreate( - jurisdictions - .filter((code: unknown) => - typeof code === "string" && code.length > 0 - ) - .map((code: string) => ({ - councilId: row.id, - countryCode: code.trim(), - })), - ); - } + return async (ctx) => { + log.info("createCouncil"); + try { + const body = await ctx.request.body.json(); + const { name, channelAuthId, channels, jurisdictions, active } = body; - // Create privacy providers if discovered from council-platform - const providers: unknown[] = Array.isArray(body.providers) - ? body.providers - : []; - for (const pp of providers) { - const p = pp as Record; - if (!p.publicKey || !p.providerUrl) continue; - await ppRepo.create({ - councilId: row.id, - name: (typeof p.label === "string" && p.label) || - String(p.publicKey).substring(0, 8), - url: String(p.providerUrl), - publicKey: String(p.publicKey), - active: true, + if (!name || !channelAuthId) { + ctx.response.status = Status.BadRequest; + ctx.response.body = { message: "name and channelAuthId are required" }; + return; + } + + const row = await councilRepo.create({ + name, + channelAuthId, + active: active ?? true, }); - } - LOG.info("Council created", { id: row.id, name }); - ctx.response.status = Status.Created; - ctx.response.body = { data: row }; - } catch (error) { - LOG.error("Failed to create council", { - error: error instanceof Error ? error.message : String(error), - }); - ctx.response.status = Status.InternalServerError; - ctx.response.body = { message: "Failed to create council" }; - } -}; - -export const updateCouncil = async (ctx: RouterContext) => { - const id = ctx.params.id; - try { - const body = await ctx.request.body.json(); - const { channels: _channels, jurisdictions, ...councilFields } = body; - - const row = await councilRepo.update(id, councilFields); - if (!row) { - ctx.response.status = Status.NotFound; - ctx.response.body = { message: "Council not found" }; - return; - } + // Create channels if provided + if (Array.isArray(channels) && channels.length > 0) { + for (const ch of channels) { + if (!ch.assetCode || !ch.assetContractId || !ch.privacyChannelId) { + continue; + } + await channelRepo.create({ + councilId: row.id, + assetCode: ch.assetCode, + assetContractId: ch.assetContractId, + privacyChannelId: ch.privacyChannelId, + active: ch.active ?? true, + }); + } + } - // Replace jurisdictions if provided - if (Array.isArray(jurisdictions)) { - await jurisdictionRepo.removeByCouncilId(id); - if (jurisdictions.length > 0) { + // Create jurisdictions if provided + if (Array.isArray(jurisdictions) && jurisdictions.length > 0) { await jurisdictionRepo.bulkCreate( jurisdictions .filter((code: unknown) => typeof code === "string" && code.length > 0 ) .map((code: string) => ({ - councilId: id, + councilId: row.id, countryCode: code.trim(), })), ); } + + // Create privacy providers if discovered from council-platform + const providers: unknown[] = Array.isArray(body.providers) + ? body.providers + : []; + for (const pp of providers) { + const p = pp as Record; + if (!p.publicKey || !p.providerUrl) continue; + await ppRepo.create({ + councilId: row.id, + name: (typeof p.label === "string" && p.label) || + String(p.publicKey).substring(0, 8), + url: String(p.providerUrl), + publicKey: String(p.publicKey), + active: true, + }); + } + + log.debug("id", row.id); + log.debug("name", name); + log.event("council created"); + ctx.response.status = Status.Created; + ctx.response.body = { data: row }; + } catch (error) { + log.error(error, "failed to create council"); + ctx.response.status = Status.InternalServerError; + ctx.response.body = { message: "Failed to create council" }; } + }; +} + +export function handleUpdateCouncil( + deps: { log: Logger }, +): (ctx: RouterContext) => Promise { + const log = deps.log.scope("updateCouncil"); - ctx.response.body = { data: row }; - } catch (error) { - LOG.error("Failed to update council", { - error: error instanceof Error ? error.message : String(error), - }); - ctx.response.status = Status.InternalServerError; - ctx.response.body = { message: "Failed to update council" }; - } -}; - -export const deleteCouncil = async (ctx: RouterContext) => { - const id = ctx.params.id; - const deleted = await councilRepo.remove(id); - if (!deleted) { - ctx.response.status = Status.NotFound; - ctx.response.body = { message: "Council not found" }; - return; - } - ctx.response.status = Status.NoContent; -}; + return async (ctx) => { + log.info("updateCouncil"); + const id = ctx.params.id; + log.debug("id", id); + try { + const body = await ctx.request.body.json(); + const { channels: _channels, jurisdictions, ...councilFields } = body; + + const row = await councilRepo.update(id, councilFields); + if (!row) { + ctx.response.status = Status.NotFound; + ctx.response.body = { message: "Council not found" }; + return; + } + + // Replace jurisdictions if provided + if (Array.isArray(jurisdictions)) { + await jurisdictionRepo.removeByCouncilId(id); + if (jurisdictions.length > 0) { + await jurisdictionRepo.bulkCreate( + jurisdictions + .filter((code: unknown) => + typeof code === "string" && code.length > 0 + ) + .map((code: string) => ({ + councilId: id, + countryCode: code.trim(), + })), + ); + } + } + + ctx.response.body = { data: row }; + } catch (error) { + log.error(error, "failed to update council"); + ctx.response.status = Status.InternalServerError; + ctx.response.body = { message: "Failed to update council" }; + } + }; +} + +export function handleDeleteCouncil( + deps: { log: Logger }, +): (ctx: RouterContext) => Promise { + const log = deps.log.scope("deleteCouncil"); + + return async (ctx) => { + log.info("deleteCouncil"); + const id = ctx.params.id; + log.debug("id", id); + const deleted = await councilRepo.remove(id); + if (!deleted) { + ctx.response.status = Status.NotFound; + ctx.response.body = { message: "Council not found" }; + return; + } + ctx.response.status = Status.NoContent; + }; +} /** * POST /admin/councils/discover * Proxy: fetches council info from a council-platform URL server-side. */ -export const discoverCouncil = async (ctx: Context) => { - try { - const body = await ctx.request.body.json(); - const { councilUrl } = body; - - if (!councilUrl || typeof councilUrl !== "string") { - ctx.response.status = Status.BadRequest; - ctx.response.body = { message: "councilUrl is required" }; - return; - } +export function handleDiscoverCouncil( + deps: { log: Logger }, +): (ctx: Context) => Promise { + const log = deps.log.scope("discoverCouncil"); - let parsed: URL; + return async (ctx) => { + log.info("discoverCouncil"); try { - parsed = new URL(councilUrl); - } catch { - ctx.response.status = Status.BadRequest; - ctx.response.body = { message: "Invalid URL" }; - return; - } + const body = await ctx.request.body.json(); + const { councilUrl } = body; - let councilId = parsed.searchParams.get("council"); - if (!councilId) { - const hashMatch = councilUrl.match(/[#?&]council=([A-Z0-9]+)/); - if (hashMatch) councilId = hashMatch[1]; - } + if (!councilUrl || typeof councilUrl !== "string") { + ctx.response.status = Status.BadRequest; + ctx.response.body = { message: "councilUrl is required" }; + return; + } + + let parsed: URL; + try { + parsed = new URL(councilUrl); + } catch { + ctx.response.status = Status.BadRequest; + ctx.response.body = { message: "Invalid URL" }; + return; + } - const baseUrl = `${parsed.origin}`; - const qs = councilId ? `?councilId=${encodeURIComponent(councilId)}` : ""; + let councilId = parsed.searchParams.get("council"); + if (!councilId) { + const hashMatch = councilUrl.match(/[#?&]council=([A-Z0-9]+)/); + if (hashMatch) councilId = hashMatch[1]; + } - const res = await fetch(`${baseUrl}/api/v1/public/council${qs}`); - if (!res.ok) { - ctx.response.status = res.status; - ctx.response.body = { - message: `Council platform returned ${res.status}`, - }; - return; - } + const baseUrl = `${parsed.origin}`; + const qs = councilId ? `?councilId=${encodeURIComponent(councilId)}` : ""; - const data = await res.json(); - ctx.response.body = data; - } catch (error) { - LOG.error("Failed to discover council", { - error: error instanceof Error ? error.message : String(error), - }); - ctx.response.status = Status.InternalServerError; - ctx.response.body = { message: "Failed to discover council" }; - } -}; + const res = await fetch(`${baseUrl}/api/v1/public/council${qs}`); + if (!res.ok) { + ctx.response.status = res.status; + ctx.response.body = { + message: `Council platform returned ${res.status}`, + }; + return; + } + + const data = await res.json(); + ctx.response.body = data; + } catch (error) { + log.error(error, "failed to discover council"); + ctx.response.status = Status.InternalServerError; + ctx.response.body = { message: "Failed to discover council" }; + } + }; +} // ─── Council Channels ────────────────────────────────────── -export const listCouncilChannels = async (ctx: RouterContext) => { - const councilId = ctx.params.councilId; - const existing = await councilRepo.findById(councilId); - if (!existing) { - ctx.response.status = Status.NotFound; - ctx.response.body = { message: "Council not found" }; - return; - } - const channels = await channelRepo.findByCouncilId(councilId); - ctx.response.body = { data: channels }; -}; - -export const createCouncilChannel = async (ctx: RouterContext) => { - const councilId = ctx.params.councilId; - try { +export function handleListCouncilChannels( + deps: { log: Logger }, +): (ctx: RouterContext) => Promise { + const log = deps.log.scope("listCouncilChannels"); + + return async (ctx) => { + log.info("listCouncilChannels"); + const councilId = ctx.params.councilId; const existing = await councilRepo.findById(councilId); if (!existing) { ctx.response.status = Status.NotFound; ctx.response.body = { message: "Council not found" }; return; } + const channels = await channelRepo.findByCouncilId(councilId); + ctx.response.body = { data: channels }; + }; +} - const body = await ctx.request.body.json(); - const { assetCode, assetContractId, privacyChannelId, active } = body; +export function handleCreateCouncilChannel( + deps: { log: Logger }, +): (ctx: RouterContext) => Promise { + const log = deps.log.scope("createCouncilChannel"); - if (!assetCode || !assetContractId || !privacyChannelId) { - ctx.response.status = Status.BadRequest; - ctx.response.body = { - message: - "assetCode, assetContractId, and privacyChannelId are required", - }; + return async (ctx) => { + log.info("createCouncilChannel"); + const councilId = ctx.params.councilId; + try { + const existing = await councilRepo.findById(councilId); + if (!existing) { + ctx.response.status = Status.NotFound; + ctx.response.body = { message: "Council not found" }; + return; + } + + const body = await ctx.request.body.json(); + const { assetCode, assetContractId, privacyChannelId, active } = body; + + if (!assetCode || !assetContractId || !privacyChannelId) { + ctx.response.status = Status.BadRequest; + ctx.response.body = { + message: + "assetCode, assetContractId, and privacyChannelId are required", + }; + return; + } + + const row = await channelRepo.create({ + councilId, + assetCode, + assetContractId, + privacyChannelId, + active: active ?? true, + }); + log.debug("id", row.id); + log.debug("councilId", councilId); + log.debug("assetCode", assetCode); + log.event("council channel created"); + ctx.response.status = Status.Created; + ctx.response.body = { data: row }; + } catch (error) { + log.error(error, "failed to create council channel"); + ctx.response.status = Status.InternalServerError; + ctx.response.body = { message: "Failed to create channel" }; + } + }; +} + +export function handleDeleteCouncilChannel( + deps: { log: Logger }, +): (ctx: RouterContext) => Promise { + const log = deps.log.scope("deleteCouncilChannel"); + + return async (ctx) => { + log.info("deleteCouncilChannel"); + const id = ctx.params.channelId; + const deleted = await channelRepo.remove(id); + if (!deleted) { + ctx.response.status = Status.NotFound; + ctx.response.body = { message: "Channel not found" }; return; } - - const row = await channelRepo.create({ - councilId, - assetCode, - assetContractId, - privacyChannelId, - active: active ?? true, - }); - LOG.info("Council channel created", { id: row.id, councilId, assetCode }); - ctx.response.status = Status.Created; - ctx.response.body = { data: row }; - } catch (error) { - LOG.error("Failed to create council channel", { - error: error instanceof Error ? error.message : String(error), - }); - ctx.response.status = Status.InternalServerError; - ctx.response.body = { message: "Failed to create channel" }; - } -}; - -export const deleteCouncilChannel = async (ctx: RouterContext) => { - const id = ctx.params.channelId; - const deleted = await channelRepo.remove(id); - if (!deleted) { - ctx.response.status = Status.NotFound; - ctx.response.body = { message: "Channel not found" }; - return; - } - ctx.response.status = Status.NoContent; -}; + ctx.response.status = Status.NoContent; + }; +} // ─── Council PPs ──────────────────────────────────────────── -export const listCouncilPps = async (ctx: RouterContext) => { - const councilId = ctx.params.councilId; - const existing = await councilRepo.findById(councilId); - if (!existing) { - ctx.response.status = Status.NotFound; - ctx.response.body = { message: "Council not found" }; - return; - } - const pps = await ppRepo.findByCouncilId(councilId); - ctx.response.body = { data: pps }; -}; - -export const createCouncilPp = async (ctx: RouterContext) => { - const councilId = ctx.params.councilId; - try { +export function handleListCouncilPps( + deps: { log: Logger }, +): (ctx: RouterContext) => Promise { + const log = deps.log.scope("listCouncilPps"); + + return async (ctx) => { + log.info("listCouncilPps"); + const councilId = ctx.params.councilId; const existing = await councilRepo.findById(councilId); if (!existing) { ctx.response.status = Status.NotFound; ctx.response.body = { message: "Council not found" }; return; } + const pps = await ppRepo.findByCouncilId(councilId); + ctx.response.body = { data: pps }; + }; +} - const body = await ctx.request.body.json(); - const { name, url, publicKey, active } = body; +export function handleCreateCouncilPp( + deps: { log: Logger }, +): (ctx: RouterContext) => Promise { + const log = deps.log.scope("createCouncilPp"); - if (!name || !url || !publicKey) { - ctx.response.status = Status.BadRequest; - ctx.response.body = { message: "name, url, and publicKey are required" }; - return; + return async (ctx) => { + log.info("createCouncilPp"); + const councilId = ctx.params.councilId; + try { + const existing = await councilRepo.findById(councilId); + if (!existing) { + ctx.response.status = Status.NotFound; + ctx.response.body = { message: "Council not found" }; + return; + } + + const body = await ctx.request.body.json(); + const { name, url, publicKey, active } = body; + + if (!name || !url || !publicKey) { + ctx.response.status = Status.BadRequest; + ctx.response.body = { + message: "name, url, and publicKey are required", + }; + return; + } + + const row = await ppRepo.create({ + councilId, + name, + url, + publicKey, + active: active ?? true, + }); + log.debug("id", row.id); + log.debug("councilId", councilId); + log.debug("name", name); + log.event("council PP created"); + ctx.response.status = Status.Created; + ctx.response.body = { data: row }; + } catch (error) { + log.error(error, "failed to create council PP"); + ctx.response.status = Status.InternalServerError; + ctx.response.body = { message: "Failed to create PP" }; } + }; +} - const row = await ppRepo.create({ - councilId, - name, - url, - publicKey, - active: active ?? true, - }); - LOG.info("Council PP created", { id: row.id, councilId, name }); - ctx.response.status = Status.Created; - ctx.response.body = { data: row }; - } catch (error) { - LOG.error("Failed to create council PP", { - error: error instanceof Error ? error.message : String(error), - }); - ctx.response.status = Status.InternalServerError; - ctx.response.body = { message: "Failed to create PP" }; - } -}; - -export const updateCouncilPp = async (ctx: RouterContext) => { - const id = ctx.params.ppId; - try { - const body = await ctx.request.body.json(); - const row = await ppRepo.update(id, body); - if (!row) { +export function handleUpdateCouncilPp( + deps: { log: Logger }, +): (ctx: RouterContext) => Promise { + const log = deps.log.scope("updateCouncilPp"); + + return async (ctx) => { + log.info("updateCouncilPp"); + const id = ctx.params.ppId; + try { + const body = await ctx.request.body.json(); + const row = await ppRepo.update(id, body); + if (!row) { + ctx.response.status = Status.NotFound; + ctx.response.body = { message: "PP not found" }; + return; + } + ctx.response.body = { data: row }; + } catch (error) { + log.error(error, "failed to update PP"); + ctx.response.status = Status.InternalServerError; + ctx.response.body = { message: "Failed to update PP" }; + } + }; +} + +export function handleDeleteCouncilPp( + deps: { log: Logger }, +): (ctx: RouterContext) => Promise { + const log = deps.log.scope("deleteCouncilPp"); + + return async (ctx) => { + log.info("deleteCouncilPp"); + const id = ctx.params.ppId; + const deleted = await ppRepo.remove(id); + if (!deleted) { ctx.response.status = Status.NotFound; ctx.response.body = { message: "PP not found" }; return; } - ctx.response.body = { data: row }; - } catch (error) { - LOG.error("Failed to update PP", { - error: error instanceof Error ? error.message : String(error), - }); - ctx.response.status = Status.InternalServerError; - ctx.response.body = { message: "Failed to update PP" }; - } -}; - -export const deleteCouncilPp = async (ctx: RouterContext) => { - const id = ctx.params.ppId; - const deleted = await ppRepo.remove(id); - if (!deleted) { - ctx.response.status = Status.NotFound; - ctx.response.body = { message: "PP not found" }; - return; - } - ctx.response.status = Status.NoContent; -}; + ctx.response.status = Status.NoContent; + }; +} diff --git a/src/http/v1/admin/routes.ts b/src/http/v1/admin/routes.ts index 4a1ef8c..a1249eb 100644 --- a/src/http/v1/admin/routes.ts +++ b/src/http/v1/admin/routes.ts @@ -1,46 +1,61 @@ import { Router } from "@oak/oak"; +import type { Logger } from "@/utils/logger/index.ts"; import { adminMiddleware } from "@/http/middleware/admin/index.ts"; import { - createCouncil, - createCouncilChannel, - createCouncilPp, - deleteCouncil, - deleteCouncilChannel, - deleteCouncilPp, - discoverCouncil, - getCouncil, - listCouncilChannels, - listCouncilPps, - listCouncils, - updateCouncil, - updateCouncilPp, + handleCreateCouncil, + handleCreateCouncilChannel, + handleCreateCouncilPp, + handleDeleteCouncil, + handleDeleteCouncilChannel, + handleDeleteCouncilPp, + handleDiscoverCouncil, + handleGetCouncil, + handleListCouncilChannels, + handleListCouncilPps, + handleListCouncils, + handleUpdateCouncil, + handleUpdateCouncilPp, } from "@/http/v1/admin/councils.ts"; -const adminRouter = new Router({ prefix: "/admin" }); +export function buildAdminRouter(deps: { log: Logger }): Router { + const adminRouter = new Router({ prefix: "/admin" }); -// All admin routes require JWT + wallet in ADMIN_WALLETS allowlist -adminRouter.use(adminMiddleware); + // All admin routes require JWT + wallet in ADMIN_WALLETS allowlist + adminRouter.use(adminMiddleware(deps)); -// Councils -adminRouter.post("/councils/discover", discoverCouncil); -adminRouter.get("/councils", listCouncils); -adminRouter.post("/councils", createCouncil); -adminRouter.get("/councils/:id", getCouncil); -adminRouter.patch("/councils/:id", updateCouncil); -adminRouter.delete("/councils/:id", deleteCouncil); + // Councils + adminRouter.post("/councils/discover", handleDiscoverCouncil(deps)); + adminRouter.get("/councils", handleListCouncils(deps)); + adminRouter.post("/councils", handleCreateCouncil(deps)); + adminRouter.get("/councils/:id", handleGetCouncil(deps)); + adminRouter.patch("/councils/:id", handleUpdateCouncil(deps)); + adminRouter.delete("/councils/:id", handleDeleteCouncil(deps)); -// Council Channels (nested under council) -adminRouter.get("/councils/:councilId/channels", listCouncilChannels); -adminRouter.post("/councils/:councilId/channels", createCouncilChannel); -adminRouter.delete( - "/councils/:councilId/channels/:channelId", - deleteCouncilChannel, -); + // Council Channels (nested under council) + adminRouter.get( + "/councils/:councilId/channels", + handleListCouncilChannels(deps), + ); + adminRouter.post( + "/councils/:councilId/channels", + handleCreateCouncilChannel(deps), + ); + adminRouter.delete( + "/councils/:councilId/channels/:channelId", + handleDeleteCouncilChannel(deps), + ); -// Council PPs (nested under council) -adminRouter.get("/councils/:councilId/pps", listCouncilPps); -adminRouter.post("/councils/:councilId/pps", createCouncilPp); -adminRouter.patch("/councils/:councilId/pps/:ppId", updateCouncilPp); -adminRouter.delete("/councils/:councilId/pps/:ppId", deleteCouncilPp); + // Council PPs (nested under council) + adminRouter.get("/councils/:councilId/pps", handleListCouncilPps(deps)); + adminRouter.post("/councils/:councilId/pps", handleCreateCouncilPp(deps)); + adminRouter.patch( + "/councils/:councilId/pps/:ppId", + handleUpdateCouncilPp(deps), + ); + adminRouter.delete( + "/councils/:councilId/pps/:ppId", + handleDeleteCouncilPp(deps), + ); -export default adminRouter; + return adminRouter; +} diff --git a/src/http/v1/auth/challenge.ts b/src/http/v1/auth/challenge.ts index 666987d..091695d 100644 --- a/src/http/v1/auth/challenge.ts +++ b/src/http/v1/auth/challenge.ts @@ -1,45 +1,54 @@ import { type Context, Status } from "@oak/oak"; import { Keypair } from "stellar-sdk"; import { createWalletChallenge } from "@/core/service/auth/wallet-auth.ts"; +import type { Logger } from "@/utils/logger/index.ts"; import { withSpan } from "@/core/tracing.ts"; -export const postChallengeHandler = (ctx: Context) => - withSpan("P_AuthChallenge", async (span) => { - try { - const body = await ctx.request.body.json(); - const { publicKey } = body; +export function handlePostChallenge( + deps: { log: Logger }, +): (ctx: Context) => Promise { + const log = deps.log.scope("postChallenge"); - if (!publicKey || typeof publicKey !== "string") { - ctx.response.status = Status.BadRequest; - ctx.response.body = { message: "publicKey is required" }; - return; - } + return (ctx) => + withSpan("P_AuthChallenge", async (span) => { + log.info("postChallenge"); + try { + const body = await ctx.request.body.json(); + const { publicKey } = body; - span.setAttribute("wallet.public_key", publicKey); + if (!publicKey || typeof publicKey !== "string") { + ctx.response.status = Status.BadRequest; + ctx.response.body = { message: "publicKey is required" }; + return; + } - try { - Keypair.fromPublicKey(publicKey); - } catch { - ctx.response.status = Status.BadRequest; - ctx.response.body = { message: "Invalid Stellar public key format" }; - return; - } + span.setAttribute("wallet.public_key", publicKey); + log.debug("publicKey", publicKey); + + try { + Keypair.fromPublicKey(publicKey); + } catch { + ctx.response.status = Status.BadRequest; + ctx.response.body = { message: "Invalid Stellar public key format" }; + return; + } - const { nonce } = createWalletChallenge(publicKey); + const { nonce } = createWalletChallenge(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.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.InternalServerError; - ctx.response.body = { message: "Failed to create challenge" }; - } - }); + }); +} diff --git a/src/http/v1/auth/routes.ts b/src/http/v1/auth/routes.ts index 3392657..e0cde9f 100644 --- a/src/http/v1/auth/routes.ts +++ b/src/http/v1/auth/routes.ts @@ -1,10 +1,11 @@ import { Router } from "@oak/oak"; -import { postChallengeHandler } from "./challenge.ts"; -import { postVerifyHandler } from "./verify.ts"; +import type { Logger } from "@/utils/logger/index.ts"; +import { handlePostChallenge } from "./challenge.ts"; +import { handlePostVerify } from "./verify.ts"; -const authRouter = new Router(); - -authRouter.post("/auth/challenge", postChallengeHandler); -authRouter.post("/auth/verify", postVerifyHandler); - -export default authRouter; +export function buildAuthRouter(deps: { log: Logger }): Router { + const authRouter = new Router(); + authRouter.post("/auth/challenge", handlePostChallenge(deps)); + authRouter.post("/auth/verify", handlePostVerify(deps)); + return authRouter; +} diff --git a/src/http/v1/auth/verify.ts b/src/http/v1/auth/verify.ts index 14a73f2..695b1bc 100644 --- a/src/http/v1/auth/verify.ts +++ b/src/http/v1/auth/verify.ts @@ -1,7 +1,7 @@ import { type Context, Status } from "@oak/oak"; import { verifyWalletChallenge } from "@/core/service/auth/wallet-auth.ts"; import generateJwt from "@/core/service/auth/generate-jwt.ts"; -import { LOG } from "@/config/logger.ts"; +import type { Logger } from "@/utils/logger/index.ts"; import { withSpan } from "@/core/tracing.ts"; /** @@ -13,42 +13,50 @@ import { withSpan } from "@/core/tracing.ts"; * is authenticated, but the user is not yet "in" Moonlight Pay until they * complete signup via POST /api/v1/account. */ -export const postVerifyHandler = (ctx: Context) => - withSpan("P_AuthVerify", async (span) => { - try { - const body = await ctx.request.body.json(); - const { nonce, signature, publicKey } = body; +export function handlePostVerify( + deps: { log: Logger }, +): (ctx: Context) => Promise { + const log = deps.log.scope("postVerify"); - if (!nonce || !signature || !publicKey) { - ctx.response.status = Status.BadRequest; - ctx.response.body = { - message: "nonce, signature, and publicKey are required", - }; - return; - } + return (ctx) => + withSpan("P_AuthVerify", async (span) => { + log.info("postVerify"); + 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; + } - span.setAttribute("wallet.public_key", publicKey); + span.setAttribute("wallet.public_key", publicKey); + log.debug("publicKey", publicKey); - const { token } = await verifyWalletChallenge( - nonce, - signature, - publicKey, - { - generateToken: (subject, sessionId) => - generateJwt(subject, sessionId), - }, - ); + const { token } = await verifyWalletChallenge( + nonce, + signature, + publicKey, + { + generateToken: (subject, sessionId) => + generateJwt(subject, sessionId), + }, + { log }, + ); - ctx.response.status = Status.OK; - ctx.response.body = { - message: "Authentication successful", - data: { token }, - }; - } catch (error) { - LOG.warn("Wallet auth failed", { - error: error instanceof Error ? error.message : String(error), - }); - ctx.response.status = Status.Unauthorized; - ctx.response.body = { message: "Authentication failed" }; - } - }); + log.event("authentication successful"); + ctx.response.status = Status.OK; + ctx.response.body = { + message: "Authentication successful", + data: { token }, + }; + } catch (error) { + log.error(error, "wallet auth failed"); + ctx.response.status = Status.Unauthorized; + ctx.response.body = { message: "Authentication failed" }; + } + }); +} diff --git a/src/http/v1/pay/instant-execute.ts b/src/http/v1/pay/instant-execute.ts index a5c7c0f..fcb28a8 100644 --- a/src/http/v1/pay/instant-execute.ts +++ b/src/http/v1/pay/instant-execute.ts @@ -22,7 +22,7 @@ import { STELLAR_NETWORK_PASSPHRASE, STELLAR_RPC_URL, } from "@/config/env.ts"; -import { LOG } from "@/config/logger.ts"; +import type { Logger } from "@/utils/logger/index.ts"; import { withSpan } from "@/core/tracing.ts"; const councilRepo = new CouncilRepository(drizzleClient); @@ -51,354 +51,374 @@ const accountRepo = new PayAccountRepository(drizzleClient); * merchantUtxoIds — reserved UTXO IDs from prepare * } */ -export const executeInstantHandler = (ctx: Context) => - withSpan("P_ExecuteInstant", async (span) => { - let merchantUtxoIds: string[] | undefined; - - try { - const body = await ctx.request.body.json().catch(() => ({})); - const { - customerPaymentHash, - merchantWallet, - amountStroops: amountStr, - assetCode: requestedAsset, - description, - } = body; - merchantUtxoIds = body.merchantUtxoIds; - - if (!customerPaymentHash || !merchantWallet || !amountStr) { - ctx.response.status = Status.BadRequest; - ctx.response.body = { - message: - "customerPaymentHash, merchantWallet, and amountStroops are required", - }; - return; - } +export function handleExecuteInstant( + deps: { log: Logger }, +): (ctx: Context) => Promise { + const log = deps.log.scope("executeInstant"); + + return (ctx) => + withSpan("P_ExecuteInstant", async (span) => { + log.info("executeInstant"); + let merchantUtxoIds: string[] | undefined; + + try { + const body = await ctx.request.body.json().catch(() => ({})); + const { + customerPaymentHash, + merchantWallet, + amountStroops: amountStr, + assetCode: requestedAsset, + description, + } = body; + merchantUtxoIds = body.merchantUtxoIds; + + if (!customerPaymentHash || !merchantWallet || !amountStr) { + ctx.response.status = Status.BadRequest; + ctx.response.body = { + message: + "customerPaymentHash, merchantWallet, and amountStroops are required", + }; + return; + } - const assetCode = requestedAsset || "XLM"; - const amountStroops = BigInt(amountStr); - span.setAttribute("merchant.public_key", merchantWallet); - span.setAttribute("asset.code", assetCode); - span.setAttribute("amount.stroops", amountStroops.toString()); - span.setAttribute("customer.payment_hash", customerPaymentHash); - - // ─── 1. Look up merchant and OpEx ────────────────────── - const merchant = await accountRepo.findByPublicKey(merchantWallet); - if (!merchant) { - ctx.response.status = Status.NotFound; - ctx.response.body = { message: "Merchant not found" }; - return; - } - if (!merchant.opexPublicKey || !merchant.encryptedOpexSk) { - ctx.response.status = Status.UnprocessableEntity; - ctx.response.body = { - message: "Merchant has no OpEx account configured", - }; - return; - } - const feePct = merchant.feePct ? Number(merchant.feePct) : 0; - - // ─── 2. Find council + channel + PP ──────────────────── - const councils = await councilRepo.findByJurisdiction( - merchant.jurisdictionCountryCode, - ); - let selectedCouncil = null; - let selectedChannel = null; - for (const c of councils) { - const channel = await channelRepo.findByCouncilIdAndAsset( - c.id, - assetCode, + const assetCode = requestedAsset || "XLM"; + const amountStroops = BigInt(amountStr); + span.setAttribute("merchant.public_key", merchantWallet); + span.setAttribute("asset.code", assetCode); + span.setAttribute("amount.stroops", amountStroops.toString()); + span.setAttribute("customer.payment_hash", customerPaymentHash); + + log.debug("merchantWallet", merchantWallet); + log.debug("assetCode", assetCode); + log.debug("amountStroops", amountStroops.toString()); + log.debug("customerPaymentHash", customerPaymentHash); + + // ─── 1. Look up merchant and OpEx ────────────────────── + const merchant = await accountRepo.findByPublicKey(merchantWallet); + if (!merchant) { + ctx.response.status = Status.NotFound; + ctx.response.body = { message: "Merchant not found" }; + return; + } + if (!merchant.opexPublicKey || !merchant.encryptedOpexSk) { + ctx.response.status = Status.UnprocessableEntity; + ctx.response.body = { + message: "Merchant has no OpEx account configured", + }; + return; + } + const feePct = merchant.feePct ? Number(merchant.feePct) : 0; + + // ─── 2. Find council + channel + PP ──────────────────── + const councils = await councilRepo.findByJurisdiction( + merchant.jurisdictionCountryCode, ); - if (channel) { - selectedCouncil = c; - selectedChannel = channel; - break; + let selectedCouncil = null; + let selectedChannel = null; + for (const c of councils) { + const channel = await channelRepo.findByCouncilIdAndAsset( + c.id, + assetCode, + ); + if (channel) { + selectedCouncil = c; + selectedChannel = channel; + break; + } } - } - if (!selectedCouncil || !selectedChannel) { - ctx.response.status = Status.ServiceUnavailable; - ctx.response.body = { message: `No ${assetCode} channel available` }; - if (merchantUtxoIds) await utxoRepo.release(merchantUtxoIds); - return; - } - span.setAttribute("council.id", selectedCouncil.id); - span.setAttribute("channel.id", selectedChannel.id); - - const pps = await ppRepo.findActiveByCouncilId(selectedCouncil.id); - if (pps.length === 0) { - ctx.response.status = Status.ServiceUnavailable; - ctx.response.body = { message: "No privacy provider available" }; - if (merchantUtxoIds) await utxoRepo.release(merchantUtxoIds); - return; - } - const pp = pps[Math.floor(Math.random() * pps.length)]; - span.setAttribute("pp.id", pp.id); - - // ─── 3. Verify customer payment on-chain ─────────────── - const horizonUrl = STELLAR_RPC_URL.includes("/soroban/rpc") - ? STELLAR_RPC_URL.replace("/soroban/rpc", "") - : STELLAR_RPC_URL; - - const txRes = await fetch( - `${horizonUrl}/transactions/${customerPaymentHash}/operations`, - ); - if (!txRes.ok) { - ctx.response.status = Status.BadRequest; - ctx.response.body = { message: "Customer payment not found on-chain" }; - if (merchantUtxoIds) await utxoRepo.release(merchantUtxoIds); - return; - } - const txOps = await txRes.json(); - const paymentOp = txOps._embedded?.records?.find( - ( - op: { - type: string; - to?: string; - amount?: string; - funder?: string; - account?: string; - }, - ) => - (op.type === "payment" && op.to === merchant.opexPublicKey) || - (op.type === "create_account" && - op.account === merchant.opexPublicKey), - ); - if (!paymentOp) { - ctx.response.status = Status.BadRequest; - ctx.response.body = { - message: "No payment to OpEx address found in transaction", - }; - if (merchantUtxoIds) await utxoRepo.release(merchantUtxoIds); - return; - } - const paidAmount = paymentOp.amount ?? paymentOp.starting_balance ?? "0"; - const paidStroops = BigInt(Math.round(parseFloat(paidAmount) * 1e7)); - if (paidStroops < amountStroops) { - ctx.response.status = Status.BadRequest; - ctx.response.body = { - message: - `Insufficient payment: expected ${amountStroops}, got ${paidStroops}`, - }; - if (merchantUtxoIds) await utxoRepo.release(merchantUtxoIds); - return; - } + if (!selectedCouncil || !selectedChannel) { + ctx.response.status = Status.ServiceUnavailable; + ctx.response.body = { message: `No ${assetCode} channel available` }; + if (merchantUtxoIds) await utxoRepo.release(merchantUtxoIds); + return; + } + span.setAttribute("council.id", selectedCouncil.id); + span.setAttribute("channel.id", selectedChannel.id); + + const pps = await ppRepo.findActiveByCouncilId(selectedCouncil.id); + if (pps.length === 0) { + ctx.response.status = Status.ServiceUnavailable; + ctx.response.body = { message: "No privacy provider available" }; + if (merchantUtxoIds) await utxoRepo.release(merchantUtxoIds); + return; + } + const pp = pps[Math.floor(Math.random() * pps.length)]; + span.setAttribute("pp.id", pp.id); - // ─── 4. Calculate net amount ─────────────────────────── - const feeStroops = amountStroops * BigInt(Math.round(feePct * 100)) / - 10000n; - const netStroops = amountStroops - feeStroops; - span.setAttribute("net.stroops", netStroops.toString()); - span.setAttribute("fee.stroops", feeStroops.toString()); - - // ─── 5. Decrypt OpEx SK and deposit into channel ─────── - const opexSk = await decryptSk( - merchant.encryptedOpexSk, - SERVICE_AUTH_SECRET, - ); - const opexKeypair = Keypair.fromSecret(opexSk); - const networkPassphrase = STELLAR_NETWORK_PASSPHRASE; - - const server = new rpc.Server(STELLAR_RPC_URL, { - allowHttp: STELLAR_RPC_URL.startsWith("http://"), - }); - - const opexAccount = await server.getAccount(opexKeypair.publicKey()); - const sacContract = new Contract(selectedChannel.assetContractId); - const depositTx = new TransactionBuilder(opexAccount, { - fee: "10000000", - networkPassphrase, - }) - .addOperation( - sacContract.call( - "transfer", - new Address(opexKeypair.publicKey()).toScVal(), - new Address(selectedChannel.privacyChannelId).toScVal(), - nativeToScVal(netStroops, { type: "i128" }), - ), - ) - .setTimeout(300) - .build(); - - const sim = await server.simulateTransaction(depositTx); - if ("error" in sim && sim.error) { - throw new Error(`Deposit simulation failed: ${sim.error}`); - } - const preparedDeposit = rpc.assembleTransaction(depositTx, sim).build(); - preparedDeposit.sign(opexKeypair); - const depositResult = await server.sendTransaction(preparedDeposit); - span.setAttribute("deposit.tx_hash", depositResult.hash); - - const deadline = Date.now() + 60000; - while (Date.now() < deadline) { - const status = await server.getTransaction(depositResult.hash); - if (status.status === "SUCCESS") break; - if (status.status === "FAILED") { - throw new Error("Deposit transaction failed on-chain"); + // ─── 3. Verify customer payment on-chain ─────────────── + log.event("verifying customer payment on-chain"); + const horizonUrl = STELLAR_RPC_URL.includes("/soroban/rpc") + ? STELLAR_RPC_URL.replace("/soroban/rpc", "") + : STELLAR_RPC_URL; + + const txRes = await fetch( + `${horizonUrl}/transactions/${customerPaymentHash}/operations`, + ); + if (!txRes.ok) { + ctx.response.status = Status.BadRequest; + ctx.response.body = { + message: "Customer payment not found on-chain", + }; + if (merchantUtxoIds) await utxoRepo.release(merchantUtxoIds); + return; } - await new Promise((r) => setTimeout(r, 2000)); - } + const txOps = await txRes.json(); + const paymentOp = txOps._embedded?.records?.find( + ( + op: { + type: string; + to?: string; + amount?: string; + funder?: string; + account?: string; + }, + ) => + (op.type === "payment" && op.to === merchant.opexPublicKey) || + (op.type === "create_account" && + op.account === merchant.opexPublicKey), + ); + if (!paymentOp) { + ctx.response.status = Status.BadRequest; + ctx.response.body = { + message: "No payment to OpEx address found in transaction", + }; + if (merchantUtxoIds) await utxoRepo.release(merchantUtxoIds); + return; + } + const paidAmount = paymentOp.amount ?? paymentOp.starting_balance ?? + "0"; + const paidStroops = BigInt(Math.round(parseFloat(paidAmount) * 1e7)); + if (paidStroops < amountStroops) { + ctx.response.status = Status.BadRequest; + ctx.response.body = { + message: + `Insufficient payment: expected ${amountStroops}, got ${paidStroops}`, + }; + if (merchantUtxoIds) await utxoRepo.release(merchantUtxoIds); + return; + } + log.event("customer payment verified"); + + // ─── 4. Calculate net amount ─────────────────────────── + const feeStroops = amountStroops * BigInt(Math.round(feePct * 100)) / + 10000n; + const netStroops = amountStroops - feeStroops; + span.setAttribute("net.stroops", netStroops.toString()); + span.setAttribute("fee.stroops", feeStroops.toString()); + + // ─── 5. Decrypt OpEx SK and deposit into channel ─────── + log.event("depositing OpEx to privacy channel"); + const opexSk = await decryptSk( + merchant.encryptedOpexSk, + SERVICE_AUTH_SECRET, + ); + const opexKeypair = Keypair.fromSecret(opexSk); + const networkPassphrase = STELLAR_NETWORK_PASSPHRASE; - LOG.info("Deposit confirmed on-chain", { txHash: depositResult.hash }); - - // ─── 6. Build MLXDR bundle ───────────────────────────── - const merchantUtxos = await utxoRepo.findByIds( - Array.isArray(merchantUtxoIds) ? merchantUtxoIds : [], - ); - - const merchantAmounts = partitionAmount(netStroops, merchantUtxos.length); - const merchantCreateOps = merchantUtxos.map((u, i) => - MoonlightOperation.create( - Uint8Array.from(atob(u.utxoPublicKey), (c) => c.charCodeAt(0)), - merchantAmounts[i], - ) - ); - - const tempCount = merchantUtxos.length; - const tempKeypairs: Array< - { publicKey: Uint8Array; privateKey: Uint8Array } - > = []; - for (let i = 0; i < tempCount; i++) { - const seed = crypto.getRandomValues(new Uint8Array(32)); - tempKeypairs.push(await deriveP256Keypair(seed)); - } + const server = new rpc.Server(STELLAR_RPC_URL, { + allowHttp: STELLAR_RPC_URL.startsWith("http://"), + }); + + const opexAccount = await server.getAccount(opexKeypair.publicKey()); + const sacContract = new Contract(selectedChannel.assetContractId); + const depositTx = new TransactionBuilder(opexAccount, { + fee: "10000000", + networkPassphrase, + }) + .addOperation( + sacContract.call( + "transfer", + new Address(opexKeypair.publicKey()).toScVal(), + new Address(selectedChannel.privacyChannelId).toScVal(), + nativeToScVal(netStroops, { type: "i128" }), + ), + ) + .setTimeout(300) + .build(); + + const sim = await server.simulateTransaction(depositTx); + if ("error" in sim && sim.error) { + throw new Error(`Deposit simulation failed: ${sim.error}`); + } + const preparedDeposit = rpc.assembleTransaction(depositTx, sim).build(); + preparedDeposit.sign(opexKeypair); + const depositResult = await server.sendTransaction(preparedDeposit); + span.setAttribute("deposit.tx_hash", depositResult.hash); + + const deadline = Date.now() + 60000; + while (Date.now() < deadline) { + const status = await server.getTransaction(depositResult.hash); + if (status.status === "SUCCESS") break; + if (status.status === "FAILED") { + throw new Error("Deposit transaction failed on-chain"); + } + await new Promise((r) => setTimeout(r, 2000)); + } - const tempAmounts = partitionAmount(netStroops, tempCount); - const tempCreateOps = tempKeypairs.map((kp, i) => - MoonlightOperation.create(kp.publicKey, tempAmounts[i]) - ); + log.debug("txHash", depositResult.hash); + log.event("deposit confirmed on-chain"); - const expirationLedger = 999999999; + // ─── 6. Build MLXDR bundle ───────────────────────────── + const merchantUtxos = await utxoRepo.findByIds( + Array.isArray(merchantUtxoIds) ? merchantUtxoIds : [], + ); - const depositOp = MoonlightOperation.deposit( - opexKeypair.publicKey() as `G${string}`, - netStroops, - ).addConditions(tempCreateOps.map((op) => op.toCondition())); + const merchantAmounts = partitionAmount( + netStroops, + merchantUtxos.length, + ); + const merchantCreateOps = merchantUtxos.map((u, i) => + MoonlightOperation.create( + Uint8Array.from(atob(u.utxoPublicKey), (c) => c.charCodeAt(0)), + merchantAmounts[i], + ) + ); - const spendOps = []; - for (let i = 0; i < tempKeypairs.length; i++) { - const spendOp = MoonlightOperation.spend(tempKeypairs[i].publicKey); - for (const merchantCreate of merchantCreateOps) { - spendOp.addCondition(merchantCreate.toCondition()); + const tempCount = merchantUtxos.length; + const tempKeypairs: Array< + { publicKey: Uint8Array; privateKey: Uint8Array } + > = []; + for (let i = 0; i < tempCount; i++) { + const seed = crypto.getRandomValues(new Uint8Array(32)); + tempKeypairs.push(await deriveP256Keypair(seed)); } - // deno-lint-ignore no-explicit-any - const utxoAdapter: any = { - publicKey: tempKeypairs[i].publicKey, - signPayload: async (hash: Uint8Array) => { - const hashBuf = new ArrayBuffer(hash.length); - new Uint8Array(hashBuf).set(hash); - const pkcs8 = buildPkcs8P256(tempKeypairs[i].privateKey); - const key = await crypto.subtle.importKey( - "pkcs8", - pkcs8, - { name: "ECDSA", namedCurve: "P-256" }, - false, - ["sign"], - ); - const sig = await crypto.subtle.sign( - { name: "ECDSA", hash: "SHA-256" }, - key, - hashBuf, - ); - return new Uint8Array(sig); - }, - }; - await spendOp.signWithUTXO( - utxoAdapter, - selectedChannel.privacyChannelId as `C${string}`, - expirationLedger, + + const tempAmounts = partitionAmount(netStroops, tempCount); + const tempCreateOps = tempKeypairs.map((kp, i) => + MoonlightOperation.create(kp.publicKey, tempAmounts[i]) ); - spendOps.push(spendOp); - } - const operationsMLXDR = [ - depositOp.toMLXDR(), - ...tempCreateOps.map((op) => op.toMLXDR()), - ...spendOps.map((op) => op.toMLXDR()), - ...merchantCreateOps.map((op) => op.toMLXDR()), - ]; - - // ─── 7. Submit bundle to provider-platform ───────────── - const providerJwt = await getProviderJwt(pp.url); - const bundleRes = await fetch( - `${pp.url}/api/v1/providers/${pp.publicKey}/bundles`, - { - method: "POST", - headers: { - "Content-Type": "application/json", - "Authorization": `Bearer ${providerJwt}`, + const expirationLedger = 999999999; + + const depositOp = MoonlightOperation.deposit( + opexKeypair.publicKey() as `G${string}`, + netStroops, + ).addConditions(tempCreateOps.map((op) => op.toCondition())); + + const spendOps = []; + for (let i = 0; i < tempKeypairs.length; i++) { + const spendOp = MoonlightOperation.spend(tempKeypairs[i].publicKey); + for (const merchantCreate of merchantCreateOps) { + spendOp.addCondition(merchantCreate.toCondition()); + } + // deno-lint-ignore no-explicit-any + const utxoAdapter: any = { + publicKey: tempKeypairs[i].publicKey, + signPayload: async (hash: Uint8Array) => { + const hashBuf = new ArrayBuffer(hash.length); + new Uint8Array(hashBuf).set(hash); + const pkcs8 = buildPkcs8P256(tempKeypairs[i].privateKey); + const key = await crypto.subtle.importKey( + "pkcs8", + pkcs8, + { name: "ECDSA", namedCurve: "P-256" }, + false, + ["sign"], + ); + const sig = await crypto.subtle.sign( + { name: "ECDSA", hash: "SHA-256" }, + key, + hashBuf, + ); + return new Uint8Array(sig); + }, + }; + await spendOp.signWithUTXO( + utxoAdapter, + selectedChannel.privacyChannelId as `C${string}`, + expirationLedger, + ); + spendOps.push(spendOp); + } + + const operationsMLXDR = [ + depositOp.toMLXDR(), + ...tempCreateOps.map((op) => op.toMLXDR()), + ...spendOps.map((op) => op.toMLXDR()), + ...merchantCreateOps.map((op) => op.toMLXDR()), + ]; + + // ─── 7. Submit bundle to provider-platform ───────────── + log.event("submitting bundle to provider-platform"); + const providerJwt = await getProviderJwt(pp.url, { log }); + const bundleRes = await fetch( + `${pp.url}/api/v1/providers/${pp.publicKey}/bundles`, + { + method: "POST", + headers: { + "Content-Type": "application/json", + "Authorization": `Bearer ${providerJwt}`, + }, + body: JSON.stringify({ + operationsMLXDR, + channelContractId: selectedChannel.privacyChannelId, + }), }, - body: JSON.stringify({ - operationsMLXDR, - channelContractId: selectedChannel.privacyChannelId, - }), - }, - ); - - if (!bundleRes.ok) { - const errBody = await bundleRes.text().catch(() => ""); - LOG.error("Provider bundle submission failed", { - status: bundleRes.status, - body: errBody, - }); - ctx.response.status = Status.BadGateway; - ctx.response.body = { - message: "Payment processing failed — provider rejected the bundle", - }; - if (merchantUtxoIds) await utxoRepo.release(merchantUtxoIds); - return; - } + ); - const bundleData = await bundleRes.json().catch(() => ({})); - const bundleId = bundleData?.data?.operationsBundleId ?? null; - if (bundleId) span.setAttribute("bundle.id", bundleId); + if (!bundleRes.ok) { + const errBody = await bundleRes.text().catch(() => ""); + log.debug("status", bundleRes.status); + log.debug("body", errBody); + log.error( + new Error(`HTTP ${bundleRes.status}`), + "provider bundle submission failed", + ); + ctx.response.status = Status.BadGateway; + ctx.response.body = { + message: "Payment processing failed — provider rejected the bundle", + }; + if (merchantUtxoIds) await utxoRepo.release(merchantUtxoIds); + return; + } - // ─── 8. Record transactions ──────────────────────────── - if (Array.isArray(merchantUtxoIds) && merchantUtxoIds.length > 0) { - await utxoRepo.markSpent(merchantUtxoIds); - } + const bundleData = await bundleRes.json().catch(() => ({})); + const bundleId = bundleData?.data?.operationsBundleId ?? null; + if (bundleId) span.setAttribute("bundle.id", bundleId); - const inTx = await txRepo.create({ - walletPublicKey: merchantWallet, - direction: "IN", - status: "COMPLETED", - method: "CRYPTO_INSTANT", - amountStroops: netStroops, - feeStroops, - counterparty: null, - description: description ?? null, - bundleId, - completedAt: new Date(), - }); - - LOG.info("Instant payment completed", { - merchantWallet, - amountStroops: amountStroops.toString(), - netStroops: netStroops.toString(), - feeStroops: feeStroops.toString(), - bundleId, - txId: inTx.id, - }); - - ctx.response.body = { - data: { - transactionId: inTx.id, - bundleId, + // ─── 8. Record transactions ──────────────────────────── + if (Array.isArray(merchantUtxoIds) && merchantUtxoIds.length > 0) { + await utxoRepo.markSpent(merchantUtxoIds); + } + + const inTx = await txRepo.create({ + walletPublicKey: merchantWallet, + direction: "IN", status: "COMPLETED", - }, - }; - } catch (error) { - LOG.error("Failed to execute instant payment", { - error: error instanceof Error ? error.message : String(error), - }); - ctx.response.status = Status.InternalServerError; - ctx.response.body = { message: "Failed to process payment" }; - if (merchantUtxoIds) { - try { - await utxoRepo.release(merchantUtxoIds); - } catch { /* best effort */ } + method: "CRYPTO_INSTANT", + amountStroops: netStroops, + feeStroops, + counterparty: null, + description: description ?? null, + bundleId, + completedAt: new Date(), + }); + + log.debug("netStroops", netStroops.toString()); + log.debug("feeStroops", feeStroops.toString()); + log.debug("bundleId", bundleId); + log.debug("txId", inTx.id); + log.event("instant payment completed"); + + ctx.response.body = { + data: { + transactionId: inTx.id, + bundleId, + status: "COMPLETED", + }, + }; + } catch (error) { + log.error(error, "failed to execute instant payment"); + ctx.response.status = Status.InternalServerError; + ctx.response.body = { message: "Failed to process payment" }; + if (merchantUtxoIds) { + try { + await utxoRepo.release(merchantUtxoIds); + } catch { /* best effort */ } + } } - } - }); + }); +} // ─── Helpers ─────────────────────────────────────────────────── diff --git a/src/http/v1/pay/instant-prepare.ts b/src/http/v1/pay/instant-prepare.ts index 21aa56a..b847d53 100644 --- a/src/http/v1/pay/instant-prepare.ts +++ b/src/http/v1/pay/instant-prepare.ts @@ -5,7 +5,7 @@ import { CouncilChannelRepository } from "@/persistence/drizzle/repository/counc import { CouncilPpRepository } from "@/persistence/drizzle/repository/council-pp.repository.ts"; import { ReceiveUtxoRepository } from "@/persistence/drizzle/repository/receive-utxo.repository.ts"; import { PayAccountRepository } from "@/persistence/drizzle/repository/pay-account.repository.ts"; -import { LOG } from "@/config/logger.ts"; +import type { Logger } from "@/utils/logger/index.ts"; import { STELLAR_NETWORK_PASSPHRASE } from "@/config/env.ts"; import { withSpan } from "@/core/tracing.ts"; @@ -24,174 +24,181 @@ const accountRepo = new PayAccountRepository(drizzleClient); * a privacy provider URL, and the merchant's receive UTXO public keys so * the frontend can build the deposit operation. */ -export const prepareInstantHandler = (ctx: Context) => - withSpan("P_PrepareInstant", async (span) => { - try { - const body = await ctx.request.body.json().catch(() => ({})); - const { - merchantWallet, - amountXlm, - customerWallet, - assetCode: requestedAsset, - payerJurisdiction, - } = body; - - if (!merchantWallet || !amountXlm || !customerWallet) { - ctx.response.status = Status.BadRequest; - ctx.response.body = { - message: "merchantWallet, amountXlm, and customerWallet are required", - }; - return; - } +export function handlePrepareInstant( + deps: { log: Logger }, +): (ctx: Context) => Promise { + const log = deps.log.scope("prepareInstant"); + + return (ctx) => + withSpan("P_PrepareInstant", async (span) => { + log.info("prepareInstant"); + try { + const body = await ctx.request.body.json().catch(() => ({})); + const { + merchantWallet, + amountXlm, + customerWallet, + assetCode: requestedAsset, + payerJurisdiction, + } = body; - span.setAttribute("merchant.public_key", merchantWallet); - span.setAttribute("customer.public_key", customerWallet); + if (!merchantWallet || !amountXlm || !customerWallet) { + ctx.response.status = Status.BadRequest; + ctx.response.body = { + message: + "merchantWallet, amountXlm, and customerWallet are required", + }; + return; + } - const assetCode = requestedAsset || "XLM"; - span.setAttribute("asset.code", assetCode); + span.setAttribute("merchant.public_key", merchantWallet); + span.setAttribute("customer.public_key", customerWallet); + log.debug("merchantWallet", merchantWallet); + log.debug("customerWallet", customerWallet); - const amount = parseFloat(amountXlm); - if (isNaN(amount) || amount <= 0) { - ctx.response.status = Status.BadRequest; - ctx.response.body = { message: "amountXlm must be a positive number" }; - return; - } + const assetCode = requestedAsset || "XLM"; + span.setAttribute("asset.code", assetCode); + log.debug("assetCode", assetCode); - // Look up the merchant - const merchant = await accountRepo.findByPublicKey(merchantWallet); - if (!merchant) { - ctx.response.status = Status.NotFound; - ctx.response.body = { message: "Merchant not found" }; - return; - } + const amount = parseFloat(amountXlm); + if (isNaN(amount) || amount <= 0) { + ctx.response.status = Status.BadRequest; + ctx.response.body = { + message: "amountXlm must be a positive number", + }; + return; + } - // Find a council covering the merchant's jurisdiction - let councils; - if (payerJurisdiction) { - councils = await councilRepo.findByJurisdictionPair( - payerJurisdiction, - merchant.jurisdictionCountryCode, - ); - if (councils.length === 0) { - ctx.response.status = Status.UnprocessableEntity; + // Look up the merchant + const merchant = await accountRepo.findByPublicKey(merchantWallet); + if (!merchant) { + ctx.response.status = Status.NotFound; + ctx.response.body = { message: "Merchant not found" }; + return; + } + + // Find a council covering the merchant's jurisdiction + let councils; + if (payerJurisdiction) { + councils = await councilRepo.findByJurisdictionPair( + payerJurisdiction, + merchant.jurisdictionCountryCode, + ); + if (councils.length === 0) { + ctx.response.status = Status.UnprocessableEntity; + ctx.response.body = { + message: + `No council available for ${payerJurisdiction} → ${merchant.jurisdictionCountryCode}`, + }; + return; + } + } else { + councils = await councilRepo.findByJurisdiction( + merchant.jurisdictionCountryCode, + ); + if (councils.length === 0) { + ctx.response.status = Status.ServiceUnavailable; + ctx.response.body = { + message: "No council available for this merchant's jurisdiction", + }; + return; + } + } + + // Find a council that has the requested asset channel + let selectedCouncil = null; + let selectedChannel = null; + for (const c of councils) { + const channel = await channelRepo.findByCouncilIdAndAsset( + c.id, + assetCode, + ); + if (channel) { + selectedCouncil = c; + selectedChannel = channel; + break; + } + } + + if (!selectedCouncil || !selectedChannel) { + ctx.response.status = Status.ServiceUnavailable; ctx.response.body = { message: - `No council available for ${payerJurisdiction} → ${merchant.jurisdictionCountryCode}`, + `No ${assetCode} channel available in any council for this jurisdiction`, }; return; } - } else { - councils = await councilRepo.findByJurisdiction( - merchant.jurisdictionCountryCode, - ); - if (councils.length === 0) { + + span.setAttribute("council.id", selectedCouncil.id); + span.setAttribute("channel.id", selectedChannel.id); + + // Pick a privacy provider within the council + const pps = await ppRepo.findActiveByCouncilId(selectedCouncil.id); + if (pps.length === 0) { ctx.response.status = Status.ServiceUnavailable; ctx.response.body = { - message: "No council available for this merchant's jurisdiction", + message: "No privacy provider available in the selected council", }; return; } - } + const pp = pps[Math.floor(Math.random() * pps.length)]; + span.setAttribute("pp.id", pp.id); - // Find a council that has the requested asset channel - let selectedCouncil = null; - let selectedChannel = null; - for (const c of councils) { - const channel = await channelRepo.findByCouncilIdAndAsset( - c.id, - assetCode, - ); - if (channel) { - selectedCouncil = c; - selectedChannel = channel; - break; + // Get merchant's available receive UTXOs (5 for privacy distribution) + const merchantUtxos = await utxoRepo.findAvailable(merchantWallet, 5); + if (merchantUtxos.length === 0) { + ctx.response.status = Status.ServiceUnavailable; + ctx.response.body = { + message: "Merchant has no available receive addresses", + }; + return; } - } - if (!selectedCouncil || !selectedChannel) { - ctx.response.status = Status.ServiceUnavailable; - ctx.response.body = { - message: - `No ${assetCode} channel available in any council for this jurisdiction`, - }; - return; - } + // Reserve the UTXOs so they're not used by concurrent payments + await utxoRepo.reserve(merchantUtxos.map((u) => u.id)); - span.setAttribute("council.id", selectedCouncil.id); - span.setAttribute("channel.id", selectedChannel.id); + const amountStroops = BigInt(Math.round(amount * 1e7)); + span.setAttribute("amount.stroops", amountStroops.toString()); - // Pick a privacy provider within the council - const pps = await ppRepo.findActiveByCouncilId(selectedCouncil.id); - if (pps.length === 0) { - ctx.response.status = Status.ServiceUnavailable; - ctx.response.body = { - message: "No privacy provider available in the selected council", - }; - return; - } - const pp = pps[Math.floor(Math.random() * pps.length)]; - span.setAttribute("pp.id", pp.id); + log.debug("amountStroops", amountStroops.toString()); + log.debug("councilId", selectedCouncil.id); + log.debug("channelId", selectedChannel.id); + log.debug("ppId", pp.id); + log.event("instant payment prepared"); - // Get merchant's available receive UTXOs (5 for privacy distribution) - const merchantUtxos = await utxoRepo.findAvailable(merchantWallet, 5); - if (merchantUtxos.length === 0) { - ctx.response.status = Status.ServiceUnavailable; ctx.response.body = { - message: "Merchant has no available receive addresses", + data: { + council: { + id: selectedCouncil.id, + channelAuthId: selectedCouncil.channelAuthId, + networkPassphrase: STELLAR_NETWORK_PASSPHRASE, + }, + channel: { + id: selectedChannel.id, + assetCode: selectedChannel.assetCode, + assetContractId: selectedChannel.assetContractId, + privacyChannelId: selectedChannel.privacyChannelId, + }, + pp: { + url: pp.url, + publicKey: pp.publicKey, + }, + opex: { + publicKey: merchant.opexPublicKey ?? null, + feePct: merchant.feePct ? Number(merchant.feePct) : null, + }, + merchantUtxos: merchantUtxos.map((u) => ({ + id: u.id, + utxoPublicKey: u.utxoPublicKey, + derivationIndex: u.derivationIndex, + })), + amountStroops: amountStroops.toString(), + }, }; - return; + } catch (error) { + log.error(error, "failed to prepare instant payment"); + ctx.response.status = Status.InternalServerError; + ctx.response.body = { message: "Failed to prepare payment" }; } - - // Reserve the UTXOs so they're not used by concurrent payments - await utxoRepo.reserve(merchantUtxos.map((u) => u.id)); - - const amountStroops = BigInt(Math.round(amount * 1e7)); - span.setAttribute("amount.stroops", amountStroops.toString()); - - LOG.info("Instant payment prepared", { - customerWallet, - merchantWallet, - assetCode, - amountStroops: amountStroops.toString(), - councilId: selectedCouncil.id, - channelId: selectedChannel.id, - ppId: pp.id, - }); - - ctx.response.body = { - data: { - council: { - id: selectedCouncil.id, - channelAuthId: selectedCouncil.channelAuthId, - networkPassphrase: STELLAR_NETWORK_PASSPHRASE, - }, - channel: { - id: selectedChannel.id, - assetCode: selectedChannel.assetCode, - assetContractId: selectedChannel.assetContractId, - privacyChannelId: selectedChannel.privacyChannelId, - }, - pp: { - url: pp.url, - publicKey: pp.publicKey, - }, - opex: { - publicKey: merchant.opexPublicKey ?? null, - feePct: merchant.feePct ? Number(merchant.feePct) : null, - }, - merchantUtxos: merchantUtxos.map((u) => ({ - id: u.id, - utxoPublicKey: u.utxoPublicKey, - derivationIndex: u.derivationIndex, - })), - amountStroops: amountStroops.toString(), - }, - }; - } catch (error) { - LOG.error("Failed to prepare instant payment", { - error: error instanceof Error ? error.message : String(error), - }); - ctx.response.status = Status.InternalServerError; - ctx.response.body = { message: "Failed to prepare payment" }; - } - }); + }); +} diff --git a/src/http/v1/pay/instant-submit.ts b/src/http/v1/pay/instant-submit.ts index 4da6625..b6b10bc 100644 --- a/src/http/v1/pay/instant-submit.ts +++ b/src/http/v1/pay/instant-submit.ts @@ -7,7 +7,7 @@ import { ReceiveUtxoRepository } from "@/persistence/drizzle/repository/receive- import { TransactionRepository } from "@/persistence/drizzle/repository/transaction.repository.ts"; import { PayAccountRepository } from "@/persistence/drizzle/repository/pay-account.repository.ts"; import { getProviderJwt } from "@/core/service/provider-auth.ts"; -import { LOG } from "@/config/logger.ts"; +import type { Logger } from "@/utils/logger/index.ts"; import { withSpan } from "@/core/tracing.ts"; const councilRepo = new CouncilRepository(drizzleClient); @@ -40,189 +40,201 @@ const accountRepo = new PayAccountRepository(drizzleClient); * 4. Record the transaction * 5. Mark merchant UTXOs as SPENT */ -export const submitInstantHandler = (ctx: Context) => - withSpan("P_SubmitInstant", async (span) => { - try { - const body = await ctx.request.body.json().catch(() => ({})); - const { - customerWallet, - merchantWallet, - amountStroops: amountStr, - assetCode: requestedAsset, - description, - operationsMLXDR, - merchantUtxoIds, - } = body; - - if ( - !customerWallet || !merchantWallet || !amountStr || !operationsMLXDR || - !Array.isArray(operationsMLXDR) - ) { - ctx.response.status = Status.BadRequest; - ctx.response.body = { message: "Missing required fields" }; - return; - } +export function handleSubmitInstant( + deps: { log: Logger }, +): (ctx: Context) => Promise { + const log = deps.log.scope("submitInstant"); + + return (ctx) => + withSpan("P_SubmitInstant", async (span) => { + log.info("submitInstant"); + try { + const body = await ctx.request.body.json().catch(() => ({})); + const { + customerWallet, + merchantWallet, + amountStroops: amountStr, + assetCode: requestedAsset, + description, + operationsMLXDR, + merchantUtxoIds, + } = body; + + if ( + !customerWallet || !merchantWallet || !amountStr || + !operationsMLXDR || + !Array.isArray(operationsMLXDR) + ) { + ctx.response.status = Status.BadRequest; + ctx.response.body = { message: "Missing required fields" }; + return; + } - const assetCode = requestedAsset || "XLM"; - const amountStroops = BigInt(amountStr); - span.setAttribute("merchant.public_key", merchantWallet); - span.setAttribute("customer.public_key", customerWallet); - span.setAttribute("asset.code", assetCode); - span.setAttribute("amount.stroops", amountStroops.toString()); - - // Look up merchant to get jurisdiction - const merchant = await accountRepo.findByPublicKey(merchantWallet); - if (!merchant) { - ctx.response.status = Status.NotFound; - ctx.response.body = { message: "Merchant not found" }; - return; - } + const assetCode = requestedAsset || "XLM"; + const amountStroops = BigInt(amountStr); + span.setAttribute("merchant.public_key", merchantWallet); + span.setAttribute("customer.public_key", customerWallet); + span.setAttribute("asset.code", assetCode); + span.setAttribute("amount.stroops", amountStroops.toString()); + + log.debug("merchantWallet", merchantWallet); + log.debug("customerWallet", customerWallet); + log.debug("assetCode", assetCode); + log.debug("amountStroops", amountStroops.toString()); + + // Look up merchant to get jurisdiction + const merchant = await accountRepo.findByPublicKey(merchantWallet); + if (!merchant) { + ctx.response.status = Status.NotFound; + ctx.response.body = { message: "Merchant not found" }; + return; + } - // Find a council covering the merchant's jurisdiction with the requested asset - const councils = await councilRepo.findByJurisdiction( - merchant.jurisdictionCountryCode, - ); - - let selectedCouncil = null; - let selectedChannel = null; - for (const c of councils) { - const channel = await channelRepo.findByCouncilIdAndAsset( - c.id, - assetCode, + // Find a council covering the merchant's jurisdiction with the requested asset + const councils = await councilRepo.findByJurisdiction( + merchant.jurisdictionCountryCode, ); - if (channel) { - selectedCouncil = c; - selectedChannel = channel; - break; + + let selectedCouncil = null; + let selectedChannel = null; + for (const c of councils) { + const channel = await channelRepo.findByCouncilIdAndAsset( + c.id, + assetCode, + ); + if (channel) { + selectedCouncil = c; + selectedChannel = channel; + break; + } } - } - if (!selectedCouncil || !selectedChannel) { - ctx.response.status = Status.ServiceUnavailable; - ctx.response.body = { - message: `No ${assetCode} channel available for this merchant`, - }; - if (Array.isArray(merchantUtxoIds)) { - await utxoRepo.release(merchantUtxoIds); + if (!selectedCouncil || !selectedChannel) { + ctx.response.status = Status.ServiceUnavailable; + ctx.response.body = { + message: `No ${assetCode} channel available for this merchant`, + }; + if (Array.isArray(merchantUtxoIds)) { + await utxoRepo.release(merchantUtxoIds); + } + return; } - return; - } - span.setAttribute("council.id", selectedCouncil.id); - span.setAttribute("channel.id", selectedChannel.id); - - // Pick a PP - const pps = await ppRepo.findActiveByCouncilId(selectedCouncil.id); - if (pps.length === 0) { - ctx.response.status = Status.ServiceUnavailable; - ctx.response.body = { message: "No privacy provider available" }; - if (Array.isArray(merchantUtxoIds)) { - await utxoRepo.release(merchantUtxoIds); + span.setAttribute("council.id", selectedCouncil.id); + span.setAttribute("channel.id", selectedChannel.id); + + // Pick a PP + const pps = await ppRepo.findActiveByCouncilId(selectedCouncil.id); + if (pps.length === 0) { + ctx.response.status = Status.ServiceUnavailable; + ctx.response.body = { message: "No privacy provider available" }; + if (Array.isArray(merchantUtxoIds)) { + await utxoRepo.release(merchantUtxoIds); + } + return; } - return; - } - const pp = pps[Math.floor(Math.random() * pps.length)]; - span.setAttribute("pp.id", pp.id); - - // Authenticate with provider-platform server-side - const providerJwt = await getProviderJwt(pp.url); - - // Submit the bundle to provider-platform - const bundleRes = await fetch( - `${pp.url}/api/v1/providers/${pp.publicKey}/bundles`, - { - method: "POST", - headers: { - "Content-Type": "application/json", - "Authorization": `Bearer ${providerJwt}`, + const pp = pps[Math.floor(Math.random() * pps.length)]; + span.setAttribute("pp.id", pp.id); + + // Authenticate with provider-platform server-side + log.event("authenticating with provider-platform"); + const providerJwt = await getProviderJwt(pp.url, { log }); + + // Submit the bundle to provider-platform + log.event("submitting bundle to provider-platform"); + const bundleRes = await fetch( + `${pp.url}/api/v1/providers/${pp.publicKey}/bundles`, + { + method: "POST", + headers: { + "Content-Type": "application/json", + "Authorization": `Bearer ${providerJwt}`, + }, + body: JSON.stringify({ + operationsMLXDR, + channelContractId: selectedChannel.privacyChannelId, + }), }, - body: JSON.stringify({ - operationsMLXDR, - channelContractId: selectedChannel.privacyChannelId, - }), - }, - ); - - if (!bundleRes.ok) { - const errBody = await bundleRes.text().catch(() => ""); - LOG.error("Provider-platform bundle submission failed", { - status: bundleRes.status, - body: errBody, - }); - ctx.response.status = Status.BadGateway; - ctx.response.body = { - message: "Payment processing failed — provider rejected the bundle", - }; - if (Array.isArray(merchantUtxoIds)) { - await utxoRepo.release(merchantUtxoIds); + ); + + if (!bundleRes.ok) { + const errBody = await bundleRes.text().catch(() => ""); + log.debug("status", bundleRes.status); + log.debug("body", errBody); + log.error( + new Error(`HTTP ${bundleRes.status}`), + "provider-platform bundle submission failed", + ); + ctx.response.status = Status.BadGateway; + ctx.response.body = { + message: "Payment processing failed — provider rejected the bundle", + }; + if (Array.isArray(merchantUtxoIds)) { + await utxoRepo.release(merchantUtxoIds); + } + return; } - return; - } - const bundleData = await bundleRes.json().catch(() => ({})); - const bundleId = bundleData?.data?.operationsBundleId ?? - bundleData?.operationsBundleId ?? null; - if (bundleId) span.setAttribute("bundle.id", bundleId); + const bundleData = await bundleRes.json().catch(() => ({})); + const bundleId = bundleData?.data?.operationsBundleId ?? + bundleData?.operationsBundleId ?? null; + if (bundleId) span.setAttribute("bundle.id", bundleId); - // Mark merchant UTXOs as SPENT - if (Array.isArray(merchantUtxoIds) && merchantUtxoIds.length > 0) { - await utxoRepo.markSpent(merchantUtxoIds); - } + // Mark merchant UTXOs as SPENT + if (Array.isArray(merchantUtxoIds) && merchantUtxoIds.length > 0) { + await utxoRepo.markSpent(merchantUtxoIds); + } - // Record merchant IN transaction - const inTx = await txRepo.create({ - walletPublicKey: merchantWallet, - direction: "IN", - status: "COMPLETED", - method: "CRYPTO_INSTANT", - amountStroops, - feeStroops: 0n, - counterparty: customerWallet, - description: description ?? null, - bundleId, - completedAt: new Date(), - }); - - // Record customer OUT transaction only if they have a pay-platform account - let outTxId: string | null = null; - const customerAccount = await accountRepo.findByPublicKey(customerWallet); - if (customerAccount) { - const outTx = await txRepo.create({ - walletPublicKey: customerWallet, - direction: "OUT", + // Record merchant IN transaction + const inTx = await txRepo.create({ + walletPublicKey: merchantWallet, + direction: "IN", status: "COMPLETED", method: "CRYPTO_INSTANT", amountStroops, feeStroops: 0n, - counterparty: merchantWallet, + counterparty: customerWallet, description: description ?? null, bundleId, completedAt: new Date(), }); - outTxId = outTx.id; - } - LOG.info("Instant payment completed", { - customerWallet, - merchantWallet, - assetCode, - amountStroops: amountStroops.toString(), - bundleId, - inTxId: inTx.id, - outTxId, - }); - - ctx.response.body = { - data: { - transactionId: inTx.id, - bundleId, - status: "COMPLETED", - }, - }; - } catch (error) { - LOG.error("Failed to submit instant payment", { - error: error instanceof Error ? error.message : String(error), - }); - ctx.response.status = Status.InternalServerError; - ctx.response.body = { message: "Failed to process payment" }; - } - }); + // Record customer OUT transaction only if they have a pay-platform account + let outTxId: string | null = null; + const customerAccount = await accountRepo.findByPublicKey( + customerWallet, + ); + if (customerAccount) { + const outTx = await txRepo.create({ + walletPublicKey: customerWallet, + direction: "OUT", + status: "COMPLETED", + method: "CRYPTO_INSTANT", + amountStroops, + feeStroops: 0n, + counterparty: merchantWallet, + description: description ?? null, + bundleId, + completedAt: new Date(), + }); + outTxId = outTx.id; + } + + log.debug("bundleId", bundleId); + log.debug("inTxId", inTx.id); + log.debug("outTxId", outTxId); + log.event("instant payment completed"); + + ctx.response.body = { + data: { + transactionId: inTx.id, + bundleId, + status: "COMPLETED", + }, + }; + } catch (error) { + log.error(error, "failed to submit instant payment"); + ctx.response.status = Status.InternalServerError; + ctx.response.body = { message: "Failed to process payment" }; + } + }); +} diff --git a/src/http/v1/pay/routes.ts b/src/http/v1/pay/routes.ts index fffca19..b247a1c 100644 --- a/src/http/v1/pay/routes.ts +++ b/src/http/v1/pay/routes.ts @@ -1,27 +1,30 @@ import { Router } from "@oak/oak"; -import { prepareInstantHandler } from "@/http/v1/pay/instant-prepare.ts"; -import { submitInstantHandler } from "@/http/v1/pay/instant-submit.ts"; -import { executeInstantHandler } from "@/http/v1/pay/instant-execute.ts"; +import type { Logger } from "@/utils/logger/index.ts"; +import { handlePrepareInstant } from "@/http/v1/pay/instant-prepare.ts"; +import { handleSubmitInstant } from "@/http/v1/pay/instant-submit.ts"; +import { handleExecuteInstant } from "@/http/v1/pay/instant-execute.ts"; -const payRouter = new Router(); +export function buildPayRouter(deps: { log: Logger }): Router { + const payRouter = new Router(); -/** - * POST /pay/instant/prepare — returns council config, merchant receive UTXOs, - * OpEx address, and fee info so the frontend can build the payment. - * Public endpoint — the customer isn't authenticated with pay-platform. - */ -payRouter.post("/pay/instant/prepare", prepareInstantHandler); + /** + * POST /pay/instant/prepare — returns council config, merchant receive UTXOs, + * OpEx address, and fee info so the frontend can build the payment. + * Public endpoint — the customer isn't authenticated with pay-platform. + */ + payRouter.post("/pay/instant/prepare", handlePrepareInstant(deps)); -/** - * POST /pay/instant/submit — receives a frontend-built MLXDR bundle - * and forwards it to provider-platform. - */ -payRouter.post("/pay/instant/submit", submitInstantHandler); + /** + * POST /pay/instant/submit — receives a frontend-built MLXDR bundle + * and forwards it to provider-platform. + */ + payRouter.post("/pay/instant/submit", handleSubmitInstant(deps)); -/** - * POST /pay/instant/execute — instant payment: customer paid to OpEx, - * pay-platform verifies, deposits to channel, builds MLXDR, submits bundle. - */ -payRouter.post("/pay/instant/execute", executeInstantHandler); + /** + * POST /pay/instant/execute — instant payment: customer paid to OpEx, + * pay-platform verifies, deposits to channel, builds MLXDR, submits bundle. + */ + payRouter.post("/pay/instant/execute", handleExecuteInstant(deps)); -export default payRouter; + return payRouter; +} diff --git a/src/http/v1/transaction/balance.ts b/src/http/v1/transaction/balance.ts index 3d0a1f5..da3ab50 100644 --- a/src/http/v1/transaction/balance.ts +++ b/src/http/v1/transaction/balance.ts @@ -2,6 +2,7 @@ import { type Context, Status } from "@oak/oak"; import { drizzleClient } from "@/persistence/drizzle/config.ts"; import { TransactionRepository } from "@/persistence/drizzle/repository/transaction.repository.ts"; import type { JwtSessionData } from "@/http/middleware/auth/index.ts"; +import type { Logger } from "@/utils/logger/index.ts"; const txRepo = new TransactionRepository(drizzleClient); @@ -10,19 +11,32 @@ const txRepo = new TransactionRepository(drizzleClient); * * Returns the authenticated user's balance in stroops and XLM. */ -export const getBalanceHandler = async (ctx: Context) => { - try { - const session = ctx.state.session as JwtSessionData; - const balanceStroops = await txRepo.getBalance(session.sub); +export function handleGetBalance( + deps: { log: Logger }, +): (ctx: Context) => Promise { + const log = deps.log.scope("getBalance"); - ctx.response.body = { - data: { - balanceStroops: balanceStroops.toString(), - balanceXlm: (Number(balanceStroops) / 1e7).toFixed(7), - }, - }; - } catch (_error) { - ctx.response.status = Status.InternalServerError; - ctx.response.body = { message: "Failed to get balance" }; - } -}; + return async (ctx) => { + log.info("getBalance"); + try { + const session = ctx.state.session as JwtSessionData; + log.debug("accountId", session.sub); + + log.event("fetching account balance"); + const balanceStroops = await txRepo.getBalance(session.sub); + log.debug("balanceStroops", balanceStroops.toString()); + + ctx.response.body = { + data: { + balanceStroops: balanceStroops.toString(), + balanceXlm: (Number(balanceStroops) / 1e7).toFixed(7), + }, + }; + log.event("balance response assembled"); + } catch (error) { + log.error(error, "get balance failed"); + ctx.response.status = Status.InternalServerError; + ctx.response.body = { message: "Failed to get balance" }; + } + }; +} diff --git a/src/http/v1/transaction/list.ts b/src/http/v1/transaction/list.ts index 3aa3f00..3a82b50 100644 --- a/src/http/v1/transaction/list.ts +++ b/src/http/v1/transaction/list.ts @@ -2,6 +2,7 @@ import { type Context, Status } from "@oak/oak"; import { drizzleClient } from "@/persistence/drizzle/config.ts"; import { TransactionRepository } from "@/persistence/drizzle/repository/transaction.repository.ts"; import type { JwtSessionData } from "@/http/middleware/auth/index.ts"; +import type { Logger } from "@/utils/logger/index.ts"; const txRepo = new TransactionRepository(drizzleClient); @@ -17,40 +18,56 @@ const txRepo = new TransactionRepository(drizzleClient); * limit — number (default 50, max 100) * offset — number (default 0) */ -export const listTransactionsHandler = async (ctx: Context) => { - try { - const session = ctx.state.session as JwtSessionData; - const params = ctx.request.url.searchParams; - const direction = params.get("direction") as "IN" | "OUT" | null; - const limit = Math.min( - parseInt(params.get("limit") ?? "50", 10) || 50, - 100, - ); - const offset = parseInt(params.get("offset") ?? "0", 10) || 0; +export function handleListTransactions( + deps: { log: Logger }, +): (ctx: Context) => Promise { + const log = deps.log.scope("listTransactions"); - const rows = await txRepo.findByWallet(session.sub, { - direction: direction ?? undefined, - limit, - offset, - }); + return async (ctx) => { + log.info("listTransactions"); + try { + const session = ctx.state.session as JwtSessionData; + const params = ctx.request.url.searchParams; + const direction = params.get("direction") as "IN" | "OUT" | null; + const limit = Math.min( + parseInt(params.get("limit") ?? "50", 10) || 50, + 100, + ); + const offset = parseInt(params.get("offset") ?? "0", 10) || 0; - ctx.response.body = { - data: rows.map((tx) => ({ - id: tx.id, - direction: tx.direction, - status: tx.status, - method: tx.method, - amountStroops: tx.amountStroops.toString(), - amountXlm: (Number(tx.amountStroops) / 1e7).toFixed(7), - feeStroops: tx.feeStroops.toString(), - counterparty: tx.counterparty, - description: tx.description, - createdAt: tx.createdAt.toISOString(), - completedAt: tx.completedAt?.toISOString() ?? null, - })), - }; - } catch (_error) { - ctx.response.status = Status.InternalServerError; - ctx.response.body = { message: "Failed to list transactions" }; - } -}; + log.debug("accountId", session.sub); + log.debug("direction", direction); + log.debug("limit", limit); + log.debug("offset", offset); + + log.event("querying transaction history"); + const rows = await txRepo.findByWallet(session.sub, { + direction: direction ?? undefined, + limit, + offset, + }); + log.debug("rowCount", rows.length); + + ctx.response.body = { + data: rows.map((tx) => ({ + id: tx.id, + direction: tx.direction, + status: tx.status, + method: tx.method, + amountStroops: tx.amountStroops.toString(), + amountXlm: (Number(tx.amountStroops) / 1e7).toFixed(7), + feeStroops: tx.feeStroops.toString(), + counterparty: tx.counterparty, + description: tx.description, + createdAt: tx.createdAt.toISOString(), + completedAt: tx.completedAt?.toISOString() ?? null, + })), + }; + log.event("transaction list response assembled"); + } catch (error) { + log.error(error, "list transactions failed"); + ctx.response.status = Status.InternalServerError; + ctx.response.body = { message: "Failed to list transactions" }; + } + }; +} diff --git a/src/http/v1/transaction/routes.ts b/src/http/v1/transaction/routes.ts index 86927d2..dd985d8 100644 --- a/src/http/v1/transaction/routes.ts +++ b/src/http/v1/transaction/routes.ts @@ -1,18 +1,20 @@ import { Router } from "@oak/oak"; +import type { Logger } from "@/utils/logger/index.ts"; import { jwtMiddleware } from "@/http/middleware/auth/index.ts"; -import { getBalanceHandler } from "@/http/v1/transaction/balance.ts"; -import { listTransactionsHandler } from "@/http/v1/transaction/list.ts"; +import { handleGetBalance } from "@/http/v1/transaction/balance.ts"; +import { handleListTransactions } from "@/http/v1/transaction/list.ts"; -const transactionRouter = new Router(); - -/** GET /transactions/balance — user's balance (sum of completed IN - OUT). */ -transactionRouter.get( - "/transactions/balance", - jwtMiddleware, - getBalanceHandler, -); - -/** GET /transactions — user's transaction history (with direction filter). */ -transactionRouter.get("/transactions", jwtMiddleware, listTransactionsHandler); - -export default transactionRouter; +export function buildTransactionRouter(deps: { log: Logger }): Router { + const transactionRouter = new Router(); + transactionRouter.get( + "/transactions/balance", + jwtMiddleware(deps), + handleGetBalance(deps), + ); + transactionRouter.get( + "/transactions", + jwtMiddleware(deps), + handleListTransactions(deps), + ); + return transactionRouter; +} diff --git a/src/http/v1/utxo/available.ts b/src/http/v1/utxo/available.ts index 3065513..1d80f97 100644 --- a/src/http/v1/utxo/available.ts +++ b/src/http/v1/utxo/available.ts @@ -2,6 +2,7 @@ import { type RouterContext, Status } from "@oak/oak"; import { drizzleClient } from "@/persistence/drizzle/config.ts"; import { ReceiveUtxoRepository } from "@/persistence/drizzle/repository/receive-utxo.repository.ts"; import { PayAccountRepository } from "@/persistence/drizzle/repository/pay-account.repository.ts"; +import type { Logger } from "@/utils/logger/index.ts"; const utxoRepo = new ReceiveUtxoRepository(drizzleClient); const accountRepo = new PayAccountRepository(drizzleClient); @@ -18,39 +19,58 @@ const accountRepo = new PayAccountRepository(drizzleClient); * Query params: * count — number of UTXOs to return (default 5, max 20) */ -export const getAvailableHandler = async (ctx: RouterContext) => { - const walletPublicKey = ctx.params.walletPublicKey; - const countParam = ctx.request.url.searchParams.get("count"); - const count = Math.min(Math.max(parseInt(countParam ?? "5", 10) || 5, 1), 20); +export function handleGetAvailable( + deps: { log: Logger }, +): (ctx: RouterContext) => Promise { + const log = deps.log.scope("getAvailableUtxos"); - const account = await accountRepo.findByPublicKey(walletPublicKey); - if (!account) { - ctx.response.status = Status.NotFound; - ctx.response.body = { message: "Account not found" }; - return; - } + return async (ctx) => { + log.info("getAvailableUtxos"); + const walletPublicKey = ctx.params.walletPublicKey; + const countParam = ctx.request.url.searchParams.get("count"); + const count = Math.min( + Math.max(parseInt(countParam ?? "5", 10) || 5, 1), + 20, + ); - const available = await utxoRepo.findAvailable(walletPublicKey, count); - if (available.length === 0) { - ctx.response.status = Status.ServiceUnavailable; - ctx.response.body = { - message: "No receive addresses available for this merchant", - }; - return; - } + log.debug("walletPublicKey", walletPublicKey); + log.debug("count", count); + + log.event("looking up merchant account"); + const account = await accountRepo.findByPublicKey(walletPublicKey); + if (!account) { + log.event("merchant account not found"); + ctx.response.status = Status.NotFound; + ctx.response.body = { message: "Account not found" }; + return; + } - ctx.response.body = { - data: { - merchant: { - walletPublicKey: account.walletPublicKey, - displayName: account.displayName, - jurisdictionCountryCode: account.jurisdictionCountryCode, + log.event("fetching available receive UTXOs"); + const available = await utxoRepo.findAvailable(walletPublicKey, count); + log.debug("availableCount", available.length); + if (available.length === 0) { + log.event("no receive addresses available"); + ctx.response.status = Status.ServiceUnavailable; + ctx.response.body = { + message: "No receive addresses available for this merchant", + }; + return; + } + + ctx.response.body = { + data: { + merchant: { + walletPublicKey: account.walletPublicKey, + displayName: account.displayName, + jurisdictionCountryCode: account.jurisdictionCountryCode, + }, + utxos: available.map((u) => ({ + id: u.id, + utxoPublicKey: u.utxoPublicKey, + derivationIndex: u.derivationIndex, + })), }, - utxos: available.map((u) => ({ - id: u.id, - utxoPublicKey: u.utxoPublicKey, - derivationIndex: u.derivationIndex, - })), - }, + }; + log.event("available UTXOs response assembled"); }; -}; +} diff --git a/src/http/v1/utxo/post.ts b/src/http/v1/utxo/post.ts index 0fd8877..8e9dabc 100644 --- a/src/http/v1/utxo/post.ts +++ b/src/http/v1/utxo/post.ts @@ -1,7 +1,7 @@ import { type Context, Status } from "@oak/oak"; import { drizzleClient } from "@/persistence/drizzle/config.ts"; import { ReceiveUtxoRepository } from "@/persistence/drizzle/repository/receive-utxo.repository.ts"; -import { LOG } from "@/config/logger.ts"; +import type { Logger } from "@/utils/logger/index.ts"; import type { JwtSessionData } from "@/http/middleware/auth/index.ts"; const utxoRepo = new ReceiveUtxoRepository(drizzleClient); @@ -17,67 +17,71 @@ const utxoRepo = new ReceiveUtxoRepository(drizzleClient); * * Idempotent: if the user already has UTXOs, returns 200 with the count. */ -export const postUtxosHandler = async (ctx: Context) => { - try { - const session = ctx.state.session as JwtSessionData; - const walletPublicKey = session.sub; +export function handlePostUtxos( + deps: { log: Logger }, +): (ctx: Context) => Promise { + const log = deps.log.scope("postUtxos"); - const existing = await utxoRepo.countByWallet(walletPublicKey); - if (existing > 0) { - ctx.response.status = Status.OK; - ctx.response.body = { - message: "Receive UTXOs already generated", - data: { count: existing }, - }; - return; - } + return async (ctx) => { + log.info("postUtxos"); + try { + const session = ctx.state.session as JwtSessionData; + const walletPublicKey = session.sub; + log.debug("walletPublicKey", walletPublicKey); - const body = await ctx.request.body.json().catch(() => ({})); - const { utxos } = body; + const existing = await utxoRepo.countByWallet(walletPublicKey); + if (existing > 0) { + ctx.response.status = Status.OK; + ctx.response.body = { + message: "Receive UTXOs already generated", + data: { count: existing }, + }; + return; + } - if (!Array.isArray(utxos) || utxos.length === 0) { - ctx.response.status = Status.BadRequest; - ctx.response.body = { message: "utxos array is required" }; - return; - } + const body = await ctx.request.body.json().catch(() => ({})); + const { utxos } = body; - for (const u of utxos) { - if ( - typeof u.utxoPublicKey !== "string" || - typeof u.derivationIndex !== "number" - ) { + if (!Array.isArray(utxos) || utxos.length === 0) { ctx.response.status = Status.BadRequest; - ctx.response.body = { - message: - "Each utxo must have utxoPublicKey (string) and derivationIndex (number)", - }; + ctx.response.body = { message: "utxos array is required" }; return; } - } - const rows = await utxoRepo.bulkCreate( - utxos.map((u: { utxoPublicKey: string; derivationIndex: number }) => ({ - walletPublicKey, - utxoPublicKey: u.utxoPublicKey, - derivationIndex: u.derivationIndex, - })), - ); + for (const u of utxos) { + if ( + typeof u.utxoPublicKey !== "string" || + typeof u.derivationIndex !== "number" + ) { + ctx.response.status = Status.BadRequest; + ctx.response.body = { + message: + "Each utxo must have utxoPublicKey (string) and derivationIndex (number)", + }; + return; + } + } + + const rows = await utxoRepo.bulkCreate( + utxos.map((u: { utxoPublicKey: string; derivationIndex: number }) => ({ + walletPublicKey, + utxoPublicKey: u.utxoPublicKey, + derivationIndex: u.derivationIndex, + })), + ); - LOG.info("Receive UTXOs stored", { - walletPublicKey, - count: rows.length, - }); + log.debug("count", rows.length); + log.event("receive UTXOs stored"); - ctx.response.status = Status.Created; - ctx.response.body = { - message: "Receive UTXOs stored", - data: { count: rows.length }, - }; - } catch (error) { - LOG.error("Failed to store receive UTXOs", { - error: error instanceof Error ? error.message : String(error), - }); - ctx.response.status = Status.InternalServerError; - ctx.response.body = { message: "Failed to store receive UTXOs" }; - } -}; + ctx.response.status = Status.Created; + ctx.response.body = { + message: "Receive UTXOs stored", + data: { count: rows.length }, + }; + } catch (error) { + log.error(error, "failed to store receive UTXOs"); + ctx.response.status = Status.InternalServerError; + ctx.response.body = { message: "Failed to store receive UTXOs" }; + } + }; +} diff --git a/src/http/v1/utxo/routes.ts b/src/http/v1/utxo/routes.ts index 8f509d3..79570d2 100644 --- a/src/http/v1/utxo/routes.ts +++ b/src/http/v1/utxo/routes.ts @@ -1,14 +1,20 @@ import { Router } from "@oak/oak"; +import type { Logger } from "@/utils/logger/index.ts"; import { jwtMiddleware } from "@/http/middleware/auth/index.ts"; -import { postUtxosHandler } from "@/http/v1/utxo/post.ts"; -import { getAvailableHandler } from "@/http/v1/utxo/available.ts"; +import { handlePostUtxos } from "@/http/v1/utxo/post.ts"; +import { handleGetAvailable } from "@/http/v1/utxo/available.ts"; -const utxoRouter = new Router(); +export function buildUtxoRouter(deps: { log: Logger }): Router { + const utxoRouter = new Router(); -/** POST /utxo/receive — store pre-generated receive UTXOs (called at onboarding). */ -utxoRouter.post("/utxo/receive", jwtMiddleware, postUtxosHandler); + /** POST /utxo/receive — store pre-generated receive UTXOs (called at onboarding). */ + utxoRouter.post("/utxo/receive", jwtMiddleware(deps), handlePostUtxos(deps)); -/** GET /utxo/receive/:walletPublicKey/available — fetch available receive UTXOs for a merchant (used by POS). */ -utxoRouter.get("/utxo/receive/:walletPublicKey/available", getAvailableHandler); + /** GET /utxo/receive/:walletPublicKey/available — fetch available receive UTXOs for a merchant (used by POS). */ + utxoRouter.get( + "/utxo/receive/:walletPublicKey/available", + handleGetAvailable(deps), + ); -export default utxoRouter; + return utxoRouter; +} diff --git a/src/http/v1/v1.routes.ts b/src/http/v1/v1.routes.ts index de03370..90474db 100644 --- a/src/http/v1/v1.routes.ts +++ b/src/http/v1/v1.routes.ts @@ -1,34 +1,49 @@ import { Router } from "@oak/oak"; +import type { Logger } from "@/utils/logger/index.ts"; import healthRouter from "@/http/v1/health/routes.ts"; -import waitlistRouter from "@/http/v1/waitlist/routes.ts"; -import authRouter from "@/http/v1/auth/routes.ts"; -import accountRouter from "@/http/v1/account/routes.ts"; -import adminRouter from "@/http/v1/admin/routes.ts"; -import utxoRouter from "@/http/v1/utxo/routes.ts"; -import transactionRouter from "@/http/v1/transaction/routes.ts"; -import payRouter from "@/http/v1/pay/routes.ts"; +import { buildWaitlistRouter } from "@/http/v1/waitlist/routes.ts"; +import { buildAuthRouter } from "@/http/v1/auth/routes.ts"; +import { buildAccountRouter } from "@/http/v1/account/routes.ts"; +import { buildAdminRouter } from "@/http/v1/admin/routes.ts"; +import { buildUtxoRouter } from "@/http/v1/utxo/routes.ts"; +import { buildTransactionRouter } from "@/http/v1/transaction/routes.ts"; +import { buildPayRouter } from "@/http/v1/pay/routes.ts"; -const apiRouter = new Router(); +export function buildApiRouter(deps: { log: Logger }): Router { + const apiRouter = new Router(); -apiRouter.use("/api/v1", healthRouter.routes(), healthRouter.allowedMethods()); -apiRouter.use("/api/v1", authRouter.routes(), authRouter.allowedMethods()); -apiRouter.use( - "/api/v1", - accountRouter.routes(), - accountRouter.allowedMethods(), -); -apiRouter.use("/api/v1", adminRouter.routes(), adminRouter.allowedMethods()); -apiRouter.use("/api/v1", utxoRouter.routes(), utxoRouter.allowedMethods()); -apiRouter.use( - "/api/v1", - transactionRouter.routes(), - transactionRouter.allowedMethods(), -); -apiRouter.use("/api/v1", payRouter.routes(), payRouter.allowedMethods()); -apiRouter.use( - "/api/v1", - waitlistRouter.routes(), - waitlistRouter.allowedMethods(), -); + const authRouter = buildAuthRouter(deps); + const accountRouter = buildAccountRouter(deps); + const adminRouter = buildAdminRouter(deps); + const utxoRouter = buildUtxoRouter(deps); + const payRouter = buildPayRouter(deps); + const transactionRouter = buildTransactionRouter(deps); + const waitlistRouter = buildWaitlistRouter(deps); -export default apiRouter; + apiRouter.use( + "/api/v1", + healthRouter.routes(), + healthRouter.allowedMethods(), + ); + apiRouter.use("/api/v1", authRouter.routes(), authRouter.allowedMethods()); + apiRouter.use( + "/api/v1", + accountRouter.routes(), + accountRouter.allowedMethods(), + ); + apiRouter.use("/api/v1", adminRouter.routes(), adminRouter.allowedMethods()); + apiRouter.use("/api/v1", utxoRouter.routes(), utxoRouter.allowedMethods()); + apiRouter.use( + "/api/v1", + transactionRouter.routes(), + transactionRouter.allowedMethods(), + ); + apiRouter.use("/api/v1", payRouter.routes(), payRouter.allowedMethods()); + apiRouter.use( + "/api/v1", + waitlistRouter.routes(), + waitlistRouter.allowedMethods(), + ); + + return apiRouter; +} diff --git a/src/http/v1/waitlist/discord-notify.ts b/src/http/v1/waitlist/discord-notify.ts index 3f7f64d..254e443 100644 --- a/src/http/v1/waitlist/discord-notify.ts +++ b/src/http/v1/waitlist/discord-notify.ts @@ -1,3 +1,5 @@ +import type { Logger } from "@/utils/logger/index.ts"; + /** * Fire-and-forget Discord webhook notification for waitlist requests. * Logs a warning and skips if DISCORD_WEBHOOK_URL is not set — the @@ -8,12 +10,12 @@ export function notifyDiscord( email: string, wallet: string | null, source: string, + deps: { log: Logger }, ): void { + const log = deps.log.scope("discordNotify"); const webhookUrl = Deno.env.get("DISCORD_WEBHOOK_URL"); if (!webhookUrl) { - console.warn( - "[waitlist] DISCORD_WEBHOOK_URL unset — skipping Discord notification", - ); + log.event("DISCORD_WEBHOOK_URL unset — skipping Discord notification"); return; } @@ -34,6 +36,6 @@ export function notifyDiscord( }], }), }).catch((err) => { - console.warn("[waitlist] Discord notification failed:", err.message); + log.error(err, "Discord notification failed"); }); } diff --git a/src/http/v1/waitlist/routes.ts b/src/http/v1/waitlist/routes.ts index 1023588..ea630f2 100644 --- a/src/http/v1/waitlist/routes.ts +++ b/src/http/v1/waitlist/routes.ts @@ -1,4 +1,5 @@ import { Router, Status } from "@oak/oak"; +import type { Logger } from "@/utils/logger/index.ts"; import { drizzleClient } from "@/persistence/drizzle/config.ts"; import { WaitlistRequestRepository } from "@/persistence/drizzle/repository/waitlist-request.repository.ts"; import { notifyDiscord } from "@/http/v1/waitlist/discord-notify.ts"; @@ -8,43 +9,46 @@ const SOURCE = "moonlight-pay"; const waitlistRepo = new WaitlistRequestRepository(drizzleClient); -const waitlistRouter = new Router(); +let injectedRepo: WaitlistRequestRepository | null = null; /** Allow tests to inject a different repository instance. */ export function setWaitlistRepoForTests(repo: WaitlistRequestRepository): void { - Object.assign(waitlistRouter, { _repo: repo }); + injectedRepo = repo; } function getRepo(): WaitlistRequestRepository { - return (waitlistRouter as unknown as { _repo?: WaitlistRequestRepository }) - ._repo ?? waitlistRepo; + return injectedRepo ?? waitlistRepo; } -waitlistRouter.post("/waitlist", async (ctx) => { - const body = await ctx.request.body.json().catch(() => null); - const email = body?.email; - const walletPublicKey = body?.walletPublicKey ?? null; - - if ( - typeof email !== "string" || !EMAIL_RE.test(email) || email.length > 254 - ) { - ctx.response.status = Status.BadRequest; - ctx.response.body = { message: "Invalid email" }; - return; - } - - const { isNew } = await getRepo().upsert({ - email, - walletPublicKey: typeof walletPublicKey === "string" - ? walletPublicKey - : null, - source: SOURCE, +export function buildWaitlistRouter(deps: { log: Logger }): Router { + const waitlistRouter = new Router(); + + waitlistRouter.post("/waitlist", async (ctx) => { + const body = await ctx.request.body.json().catch(() => null); + const email = body?.email; + const walletPublicKey = body?.walletPublicKey ?? null; + + if ( + typeof email !== "string" || !EMAIL_RE.test(email) || email.length > 254 + ) { + ctx.response.status = Status.BadRequest; + ctx.response.body = { message: "Invalid email" }; + return; + } + + const { isNew } = await getRepo().upsert({ + email, + walletPublicKey: typeof walletPublicKey === "string" + ? walletPublicKey + : null, + source: SOURCE, + }); + + notifyDiscord(email, walletPublicKey, SOURCE, deps); + + ctx.response.status = isNew ? Status.Created : Status.OK; + ctx.response.body = { message: "Added to waitlist" }; }); - notifyDiscord(email, walletPublicKey, SOURCE); - - ctx.response.status = isNew ? Status.Created : Status.OK; - ctx.response.body = { message: "Added to waitlist" }; -}); - -export default waitlistRouter; + return waitlistRouter; +} diff --git a/src/main.ts b/src/main.ts index 81fe499..93045ab 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1,28 +1,47 @@ import { Application } from "@oak/oak"; -import apiV1 from "@/http/v1/v1.routes.ts"; +import { buildApiRouter } from "@/http/v1/v1.routes.ts"; import { appendRequestIdMiddleware } from "@/http/middleware/append-request-id.ts"; import { appendResponseHeadersMiddleware } from "@/http/middleware/append-response-headers.ts"; import { corsMiddleware } from "@/http/middleware/cors.ts"; import { traceContextMiddleware } from "@/http/middleware/trace-context.ts"; -import { PORT } from "@/config/env.ts"; -import { LOG } from "@/config/logger.ts"; +import { MODE, PORT, SERVICE_AUTH_SECRET } from "@/config/env.ts"; +import { createLogger } from "@/config/logger.ts"; async function bootstrap() { + const rootLog = createLogger(); + const log = rootLog.scope("bootstrap"); + log.info("bootstrap"); + + // Dev-mode notice: SERVICE_AUTH_SECRET unset means a random secret is in + // use and JWTs reset on restart. Production already throws (env.ts). + if ( + MODE !== "production" && + (!SERVICE_AUTH_SECRET || SERVICE_AUTH_SECRET.trim().length === 0) + ) { + log.event( + "SERVICE_AUTH_SECRET unset — using random secret (dev only, JWTs reset on restart)", + ); + } + + const deps = { log: rootLog }; + try { const app = new Application(); app.use(corsMiddleware); app.use(traceContextMiddleware); - app.use(appendRequestIdMiddleware); + app.use(appendRequestIdMiddleware(deps)); app.use(appendResponseHeadersMiddleware); + const apiV1 = buildApiRouter(deps); app.use(apiV1.routes()); app.use(apiV1.allowedMethods()); - LOG.info(`Pay Platform running on http://localhost:${PORT}`); + log.debug("port", PORT); + log.event(`Pay Platform running on http://localhost:${PORT}`); const shutdown = () => { - LOG.info("Shutting down server..."); + log.event("shutting down server"); Deno.exit(0); }; @@ -31,9 +50,7 @@ async function bootstrap() { await app.listen({ port: Number(PORT) }); } catch (error) { - LOG.error("Failed to start server", { - error: error instanceof Error ? error.message : String(error), - }); + log.error(error, "failed to start server"); Deno.exit(1); } } diff --git a/src/utils/error/log-and-throw.ts b/src/utils/error/log-and-throw.ts index 6447194..73317f1 100644 --- a/src/utils/error/log-and-throw.ts +++ b/src/utils/error/log-and-throw.ts @@ -1,6 +1,6 @@ -import { LOG } from "@/config/logger.ts"; +import type { Logger } from "@/utils/logger/index.ts"; -export function logAndThrow(error: Error): never { - LOG.error(error.message, { error }); +export function logAndThrow(log: Logger, error: Error): never { + log.error(error, error.message); throw error; } diff --git a/src/utils/logger/index.ts b/src/utils/logger/index.ts index 6b71e80..5623024 100644 --- a/src/utils/logger/index.ts +++ b/src/utils/logger/index.ts @@ -1,57 +1,231 @@ +// Draft Logger module — TypeScript port of github.com/AquiGorka/go-logger. +// Lives at src/utils/logger/index.ts in each backend repo. + import chalk from "chalk"; -export enum LogLevel { - FATAL = 0, - ERROR = 1, - WARN = 2, - INFO = 3, - DEBUG = 4, - TRACE = 5, +export enum Level { + Debug = 0, + Info = 1, + Event = 2, + Disabled = 3, +} + +export interface Logger { + info(msg: string): void; + event(msg: string): void; + debug(key: string, value: unknown): void; + error(err: unknown, msg: string): void; + scope(name: string): Logger; } -export class Logger { - private logLevel: LogLevel; +export interface Writer { + write(line: string): void; +} + +export interface LoggerOptions { + /** Custom stdout writer. Replaces console.log. Useful for tests. */ + writer?: Writer; + /** Opt-in file path for JSON-formatted records. Created if missing. */ + file?: string; +} + +interface Record { + ts: string; + level: "debug" | "info" | "event" | "error"; + scope: string; + msg?: string; + key?: string; + value?: unknown; + error?: string; +} + +type Format = (r: Record) => string; + +interface Sink { + writer: Writer; + format: Format; +} - constructor(logLevel: LogLevel) { - this.logLevel = logLevel; +export function parseLevel(s: string | undefined): Level { + switch ((s ?? "").toLowerCase()) { + case "debug": + return Level.Debug; + case "info": + return Level.Info; + case "event": + return Level.Event; + default: + return Level.Disabled; } +} + +const stdoutWriter: Writer = { + write: (line) => console.log(line), +}; - private format(...args: unknown[]): string { - return args - .map((arg) => { - if (typeof arg === "string") return arg; - try { - return chalk.cyan(JSON.stringify(arg)); - } catch (error) { - return chalk.cyan(`[Unstringifiable: ${(error as Error).message}]`); - } - }) - .join(" "); +class FileWriter implements Writer { + private file: Deno.FsFile; + constructor(path: string) { + const dir = path.substring(0, path.lastIndexOf("/")); + if (dir) Deno.mkdirSync(dir, { recursive: true }); + this.file = Deno.openSync(path, { + append: true, + create: true, + write: true, + }); + } + write(line: string): void { + this.file.writeSync(new TextEncoder().encode(line + "\n")); } +} - private log(level: LogLevel, color: typeof chalk.blue, ...args: unknown[]) { - if (this.logLevel < level) return; - const timestamp = new Date().toISOString(); - const prefix = chalk.gray(`[${timestamp}::${LogLevel[level]}]`); - console.log(`${prefix} ${color(this.format(...args))}`); +function stringify(v: unknown): string { + if (typeof v === "string") return v; + if (v instanceof Error) return v.message; + try { + return JSON.stringify(v); + } catch (err) { + return `[Unstringifiable: ${(err as Error).message}]`; } +} + +function humanFormat(colored: boolean): Format { + const grayLb = colored ? chalk.gray : (s: string) => s; + const greenLb = colored ? chalk.green : (s: string) => s; + const whiteLb = colored ? chalk.white : (s: string) => s; + const cyanLb = colored ? chalk.cyan : (s: string) => s; + const redLb = colored ? chalk.red : (s: string) => s; - trace(...args: unknown[]) { - this.log(LogLevel.TRACE, chalk.white, ...args); + return (r) => { + const ts = grayLb(`[${r.ts}]`); + switch (r.level) { + case "info": + return `${ts} ${greenLb("INF")} [${r.scope}] ${r.msg}`; + case "event": + return `${ts} ${whiteLb("EVT")} -${r.msg} (${r.scope})`; + case "debug": + return `${ts} ${cyanLb("DBG")} ${r.key}: ${ + stringify(r.value) + } (${r.scope})`; + case "error": + return `${ts} ${redLb("ERR")} [${r.scope}] ${r.msg} error="${r.error}"`; + } + }; +} + +const jsonFormat: Format = (r) => { + // Stable schema. Only the fields relevant to each level are emitted. + const out: Record = { + ts: r.ts, + level: r.level, + scope: r.scope, + }; + if (r.msg !== undefined) out.msg = r.msg; + 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; + try { + return JSON.stringify(out); + } catch (err) { + return JSON.stringify({ + ts: r.ts, + level: r.level, + scope: r.scope, + msg: `[unserializable record: ${(err as Error).message}]`, + }); } - debug(...args: unknown[]) { - this.log(LogLevel.DEBUG, chalk.green, ...args); +}; + +function safeJsonValue(v: unknown): unknown { + // BigInt + circular refs would break JSON.stringify. Coerce to string when + // we can't keep the structured value. + if (typeof v === "bigint") return v.toString(); + if (v instanceof Error) return { message: v.message, name: v.name }; + if (v === null || typeof v !== "object") return v; + try { + JSON.stringify(v); + return v; + } catch { + return stringify(v); } - info(...args: unknown[]) { - this.log(LogLevel.INFO, chalk.blue, ...args); +} + +class LoggerImpl implements Logger { + constructor( + private readonly level: Level, + private readonly sinks: Sink[], + private readonly scopePath: string, + ) {} + + info(msg: string): void { + if (this.level > Level.Info) return; + this.emit({ ts: now(), level: "info", scope: this.scopePath, msg }); + } + + event(msg: string): void { + if (this.level > Level.Event) return; + this.emit({ ts: now(), level: "event", scope: this.scopePath, msg }); } - warn(...args: unknown[]) { - this.log(LogLevel.WARN, chalk.yellow, ...args); + + debug(key: string, value: unknown): void { + if (this.level > Level.Debug) return; + this.emit({ ts: now(), level: "debug", scope: this.scopePath, key, value }); } - error(...args: unknown[]) { - this.log(LogLevel.ERROR, chalk.red, ...args); + + error(err: unknown, msg: string): void { + // ERR always emits regardless of level (matches go-logger / zerolog). + const detail = err instanceof Error ? err.message : String(err); + this.emit({ + ts: now(), + level: "error", + scope: this.scopePath, + msg, + error: detail, + }); } - fatal(...args: unknown[]) { - this.log(LogLevel.FATAL, chalk.bgRed.white, ...args); + + scope(name: string): Logger { + return new LoggerImpl(this.level, this.sinks, `${this.scopePath}.${name}`); } + + private emit(r: Record): void { + for (const sink of this.sinks) { + sink.writer.write(sink.format(r)); + } + } +} + +function now(): string { + return new Date().toISOString(); +} + +export function newLogger(level: Level, opts: LoggerOptions = {}): Logger { + const sinks: Sink[] = []; + + // stdout sink — always present. Human format. Colored when TTY (and only + // when caller did not pass a custom writer; tests get plain output). + const consoleWriter = opts.writer ?? stdoutWriter; + const colored = opts.writer === undefined && Deno.stdout.isTerminal(); + sinks.push({ writer: consoleWriter, format: humanFormat(colored) }); + + // file sink — opt-in. JSON format. + if (opts.file !== undefined) { + sinks.push({ writer: new FileWriter(opts.file), format: jsonFormat }); + } + + return new LoggerImpl(level, sinks, "main"); +} + +class NoopLogger implements Logger { + info(): void {} + event(): void {} + debug(): void {} + error(): void {} + scope(): Logger { + return this; + } +} + +export function newNoop(): Logger { + return new NoopLogger(); } diff --git a/tests/integration/api/account.test.ts b/tests/integration/api/account.test.ts index 8e2752f..83561c0 100644 --- a/tests/integration/api/account.test.ts +++ b/tests/integration/api/account.test.ts @@ -4,11 +4,12 @@ * Run with: deno test --allow-all --no-check --config tests/deno.json tests/integration/api/account.test.ts */ import { assertEquals, assertExists } from "@std/assert"; +import { newNoop } from "@/utils/logger/index.ts"; import { createMockContext } from "../../test_app.ts"; import { _TEST_WALLET_KEYPAIR } from "../../mock_env.ts"; import { ensureInitialized, resetDb } from "../../pglite_db.ts"; -import { postAccountHandler } from "@/http/v1/account/post.ts"; -import { getMeHandler, patchMeHandler } from "@/http/v1/account/me.ts"; +import { handlePostAccount } from "@/http/v1/account/post.ts"; +import { handleGetMe, handlePatchMe } from "@/http/v1/account/me.ts"; const walletPublicKey = _TEST_WALLET_KEYPAIR.publicKey(); const session = { @@ -34,7 +35,7 @@ Deno.test("POST /account - creates a new account", async () => { body: VALID_BODY, state: { session }, }); - await postAccountHandler(ctx); + await handlePostAccount({ log: newNoop() })(ctx); const res = getResponse(); assertEquals(res.status, 201); @@ -55,7 +56,7 @@ Deno.test("POST /account - is idempotent (returns existing on second call)", asy body: VALID_BODY, state: { session }, }); - await postAccountHandler(ctx1.ctx); + await handlePostAccount({ log: newNoop() })(ctx1.ctx); assertEquals(ctx1.getResponse().status, 201); // Second call should return existing @@ -64,7 +65,7 @@ Deno.test("POST /account - is idempotent (returns existing on second call)", asy body: { ...VALID_BODY, email: "different@example.com" }, state: { session }, }); - await postAccountHandler(ctx2.ctx); + await handlePostAccount({ log: newNoop() })(ctx2.ctx); const res = ctx2.getResponse(); assertEquals(res.status, 200); @@ -81,7 +82,7 @@ Deno.test("POST /account - rejects missing email", async () => { body: { jurisdictionCountryCode: "ES" }, state: { session }, }); - await postAccountHandler(ctx); + await handlePostAccount({ log: newNoop() })(ctx); assertEquals(getResponse().status, 400); }); @@ -95,7 +96,7 @@ Deno.test("POST /account - rejects invalid email", async () => { body: { ...VALID_BODY, email: "not-an-email" }, state: { session }, }); - await postAccountHandler(ctx); + await handlePostAccount({ log: newNoop() })(ctx); assertEquals(getResponse().status, 400); }); @@ -109,7 +110,7 @@ Deno.test("POST /account - rejects invalid jurisdiction", async () => { body: { ...VALID_BODY, jurisdictionCountryCode: "spain" }, state: { session }, }); - await postAccountHandler(ctx); + await handlePostAccount({ log: newNoop() })(ctx); assertEquals(getResponse().status, 400); }); @@ -123,7 +124,7 @@ Deno.test("POST /account - normalizes jurisdiction to uppercase", async () => { body: { ...VALID_BODY, jurisdictionCountryCode: "es" }, state: { session }, }); - await postAccountHandler(ctx); + await handlePostAccount({ log: newNoop() })(ctx); const res = getResponse(); // Lowercase fails the validation regex (only [A-Z]{2}), so 400 is correct. @@ -140,13 +141,13 @@ Deno.test("GET /account/me - returns the authenticated wallet's account", async body: VALID_BODY, state: { session }, }); - await postAccountHandler(createCtx.ctx); + await handlePostAccount({ log: newNoop() })(createCtx.ctx); const { ctx, getResponse } = createMockContext({ method: "GET", state: { session }, }); - await getMeHandler(ctx); + await handleGetMe({ log: newNoop() })(ctx); const res = getResponse(); assertEquals(res.status, 200); @@ -162,7 +163,7 @@ Deno.test("GET /account/me - returns 404 if no account exists", async () => { method: "GET", state: { session }, }); - await getMeHandler(ctx); + await handleGetMe({ log: newNoop() })(ctx); assertEquals(getResponse().status, 404); }); @@ -176,14 +177,14 @@ Deno.test("PATCH /account/me - updates jurisdiction", async () => { body: VALID_BODY, state: { session }, }); - await postAccountHandler(createCtx.ctx); + await handlePostAccount({ log: newNoop() })(createCtx.ctx); const { ctx, getResponse } = createMockContext({ method: "PATCH", body: { jurisdictionCountryCode: "AR" }, state: { session }, }); - await patchMeHandler(ctx); + await handlePatchMe({ log: newNoop() })(ctx); const res = getResponse(); assertEquals(res.status, 200); @@ -201,14 +202,14 @@ Deno.test("PATCH /account/me - updates email and displayName", async () => { body: VALID_BODY, state: { session }, }); - await postAccountHandler(createCtx.ctx); + await handlePostAccount({ log: newNoop() })(createCtx.ctx); const { ctx, getResponse } = createMockContext({ method: "PATCH", body: { email: "alice2@example.com", displayName: "Alice 2" }, state: { session }, }); - await patchMeHandler(ctx); + await handlePatchMe({ log: newNoop() })(ctx); const res = getResponse(); assertEquals(res.status, 200); @@ -225,7 +226,7 @@ Deno.test("PATCH /account/me - returns 404 if no account exists", async () => { body: { displayName: "x" }, state: { session }, }); - await patchMeHandler(ctx); + await handlePatchMe({ log: newNoop() })(ctx); assertEquals(getResponse().status, 404); }); @@ -239,14 +240,14 @@ Deno.test("PATCH /account/me - rejects empty body", async () => { body: VALID_BODY, state: { session }, }); - await postAccountHandler(createCtx.ctx); + await handlePostAccount({ log: newNoop() })(createCtx.ctx); const { ctx, getResponse } = createMockContext({ method: "PATCH", body: {}, state: { session }, }); - await patchMeHandler(ctx); + await handlePatchMe({ log: newNoop() })(ctx); assertEquals(getResponse().status, 400); }); @@ -260,14 +261,14 @@ Deno.test("PATCH /account/me - rejects invalid email", async () => { body: VALID_BODY, state: { session }, }); - await postAccountHandler(createCtx.ctx); + await handlePostAccount({ log: newNoop() })(createCtx.ctx); const { ctx, getResponse } = createMockContext({ method: "PATCH", body: { email: "not-an-email" }, state: { session }, }); - await patchMeHandler(ctx); + await handlePatchMe({ log: newNoop() })(ctx); assertEquals(getResponse().status, 400); }); diff --git a/tests/integration/api/auth.test.ts b/tests/integration/api/auth.test.ts index b105fd0..41bf0bd 100644 --- a/tests/integration/api/auth.test.ts +++ b/tests/integration/api/auth.test.ts @@ -4,19 +4,20 @@ * Run with: deno test --allow-all --no-check --config tests/deno.json tests/integration/api/auth.test.ts */ import { assertEquals, assertExists } from "@std/assert"; +import { newNoop } from "@/utils/logger/index.ts"; import { Buffer } from "buffer"; import { createMockContext } from "../../test_app.ts"; import { _TEST_WALLET_KEYPAIR } from "../../mock_env.ts"; import { ensureInitialized, resetDb } from "../../pglite_db.ts"; -import { postChallengeHandler } from "@/http/v1/auth/challenge.ts"; -import { postVerifyHandler } from "@/http/v1/auth/verify.ts"; +import { handlePostChallenge } from "@/http/v1/auth/challenge.ts"; +import { handlePostVerify } from "@/http/v1/auth/verify.ts"; async function getNonce(publicKey: string): Promise { const { ctx, getResponse } = createMockContext({ method: "POST", body: { publicKey }, }); - await postChallengeHandler(ctx); + await handlePostChallenge({ log: newNoop() })(ctx); const res = getResponse(); assertEquals(res.status, 200); return res.body.data.nonce as string; @@ -56,7 +57,7 @@ Deno.test("POST /auth/challenge - returns a nonce for a valid public key", async method: "POST", body: { publicKey: _TEST_WALLET_KEYPAIR.publicKey() }, }); - await postChallengeHandler(ctx); + await handlePostChallenge({ log: newNoop() })(ctx); const res = getResponse(); assertEquals(res.status, 200); @@ -71,7 +72,7 @@ Deno.test("POST /auth/challenge - rejects missing publicKey", async () => { method: "POST", body: {}, }); - await postChallengeHandler(ctx); + await handlePostChallenge({ log: newNoop() })(ctx); const res = getResponse(); assertEquals(res.status, 400); @@ -85,7 +86,7 @@ Deno.test("POST /auth/challenge - rejects invalid public key format", async () = method: "POST", body: { publicKey: "not-a-valid-key" }, }); - await postChallengeHandler(ctx); + await handlePostChallenge({ log: newNoop() })(ctx); const res = getResponse(); assertEquals(res.status, 400); @@ -103,7 +104,7 @@ Deno.test("POST /auth/verify - returns a JWT for a valid SEP-53 signature", asyn method: "POST", body: { nonce, signature, publicKey }, }); - await postVerifyHandler(ctx); + await handlePostVerify({ log: newNoop() })(ctx); const res = getResponse(); assertEquals(res.status, 200); @@ -121,7 +122,7 @@ Deno.test("POST /auth/verify - rejects an invalid signature", async () => { method: "POST", body: { nonce, signature: "AAAA", publicKey }, }); - await postVerifyHandler(ctx); + await handlePostVerify({ log: newNoop() })(ctx); const res = getResponse(); assertEquals(res.status, 401); @@ -139,7 +140,7 @@ Deno.test("POST /auth/verify - rejects nonce that was never issued", async () => publicKey: _TEST_WALLET_KEYPAIR.publicKey(), }, }); - await postVerifyHandler(ctx); + await handlePostVerify({ log: newNoop() })(ctx); const res = getResponse(); assertEquals(res.status, 401); diff --git a/tests/integration/api/waitlist.test.ts b/tests/integration/api/waitlist.test.ts index 02e4235..810b90e 100644 --- a/tests/integration/api/waitlist.test.ts +++ b/tests/integration/api/waitlist.test.ts @@ -3,11 +3,13 @@ import { createMockContext } from "../../test_app.ts"; import { drizzleClient, ensureInitialized, resetDb } from "../../pglite_db.ts"; import { waitlistRequest } from "@/persistence/drizzle/entity/waitlist-request.entity.ts"; import { WaitlistRequestRepository } from "@/persistence/drizzle/repository/waitlist-request.repository.ts"; +import { newNoop } from "@/utils/logger/index.ts"; -const { default: waitlistRouter, setWaitlistRepoForTests } = await import( +const { buildWaitlistRouter, setWaitlistRepoForTests } = await import( "@/http/v1/waitlist/routes.ts" ); +const waitlistRouter = buildWaitlistRouter({ log: newNoop() }); const routes = [...waitlistRouter]; const waitlistRoute = routes.find( (r) => r.path === "/waitlist" && r.methods.includes("POST"),