diff --git a/.github/workflows/core-ci.yml b/.github/workflows/core-ci.yml index e0e68f9..ae0e3f4 100644 --- a/.github/workflows/core-ci.yml +++ b/.github/workflows/core-ci.yml @@ -43,11 +43,11 @@ jobs: - name: Install dependencies run: pnpm install --frozen-lockfile - - name: Typecheck - run: pnpm typecheck - - name: Build run: pnpm build + - name: Typecheck + run: pnpm typecheck + - name: Test run: pnpm test \ No newline at end of file diff --git a/apps/api/package.json b/apps/api/package.json index 4b8d94e..246bbeb 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -14,6 +14,12 @@ "prisma:studio": "prisma studio" }, "dependencies": { + "@guildpass/capability-token": "workspace:*", + "@guildpass/circuit-breaker": "workspace:*", + "@guildpass/idempotency": "workspace:*", + "@guildpass/log-redaction": "workspace:*", + "@guildpass/rate-limit": "workspace:*", + "@guildpass/retry-policy": "workspace:*", "@guildpass/shared-types": "workspace:*", "@prisma/client": "^6.4.0", "fastify": "^4.29.1", diff --git a/apps/api/src/app.ts b/apps/api/src/app.ts index 3425ee2..9f5b0a9 100644 --- a/apps/api/src/app.ts +++ b/apps/api/src/app.ts @@ -1,20 +1,145 @@ -import Fastify from "fastify"; +import Fastify, { type FastifyInstance } from "fastify"; import { correlationPlugin } from "./plugins/correlation.js"; +import { errorHandlerPlugin } from "./errors/index.js"; +import { redactedLoggerPlugin } from "./observability/logging.js"; +import { healthRoutes } from "./observability/health.js"; +import { rateLimitPlugin } from "./plugins/rate-limit.js"; +import { idempotencyPlugin } from "./plugins/idempotency.js"; +import { requireScopes } from "./security/auth.js"; +import { + StellarTransactionExecutor, + type StellarExecutorOptions, +} from "./stellar/transaction-handler.js"; -export function buildApp() { +export interface AppOptions { + authSecret?: string; + enableRateLimit?: boolean; + rateLimitMax?: number; + rateLimitWindowMs?: number; + stellarExecutor?: StellarTransactionExecutor; + stellarExecutorOptions?: StellarExecutorOptions; +} + +export function buildApp(options: AppOptions = {}): FastifyInstance { const app = Fastify({ - logger: true + logger: false, // Can be set to true in production or via env + disableRequestLogging: true, // We use redactedLoggerPlugin for structured safe logging }); + // 1. Correlation & Tracing app.register(correlationPlugin); - app.get("/health", async () => { - return { - status: "ok", - service: "guildpass-core-api" - }; - }); + // 2. Structured Redacted Logging + app.register(redactedLoggerPlugin); + + // 3. Centralized Sanitized Error Handling + app.register(errorHandlerPlugin); + + // 4. Rate Limiting + if (options.enableRateLimit !== false) { + app.register(rateLimitPlugin, { + limit: options.rateLimitMax ?? 100, + windowMs: options.rateLimitWindowMs ?? 60000, + skip: (req) => req.url === "/health" || req.url === "/ready", + }); + } + + // 5. Idempotency Support for Mutations + app.register(idempotencyPlugin); + + // 6. Observability: Liveness & Readiness Probes + app.register(healthRoutes); + + // 7. Stellar Transaction Executor + const stellarExecutor = + options.stellarExecutor || + new StellarTransactionExecutor(options.stellarExecutorOptions); + app.decorate("stellarExecutor", stellarExecutor); + + // 8. Hardened Core V2 Domain Mutation Routes + // Route: Issue Pass (Requires "pass:issue" scope) + app.post( + "/api/v2/passes/issue", + { + preHandler: [ + requireScopes(["pass:issue"], { secret: options.authSecret }), + ], + }, + async (request, reply) => { + const body = (request.body as Record) || {}; + const guildId = body.guildId || "default-guild"; + const passType = body.passType || "standard"; + + return reply.status(201).send({ + success: true, + passId: `pass_${Date.now()}`, + guildId, + passType, + recipient: request.auth?.subject, + issuedAt: Date.now(), + }); + } + ); + + // Route: Revoke Pass (Requires "pass:revoke" scope) + app.post( + "/api/v2/passes/revoke", + { + preHandler: [ + requireScopes(["pass:revoke"], { secret: options.authSecret }), + ], + }, + async (request, reply) => { + const body = (request.body as Record) || {}; + const passId = body.passId; + + return reply.status(200).send({ + success: true, + passId, + status: "revoked", + revokedBy: request.auth?.subject, + revokedAt: Date.now(), + }); + } + ); + + // Route: Resilient Stellar Transaction Submission (Requires "stellar:submit" scope) + app.post( + "/api/v2/stellar/submit", + { + preHandler: [ + requireScopes(["stellar:submit"], { secret: options.authSecret }), + ], + }, + async (request, reply) => { + const body = (request.body as Record) || {}; + const mockFailures = body.mockFailures ?? 0; + let failureCount = 0; + + const result = await stellarExecutor.submitTransaction(async () => { + if (failureCount < mockFailures) { + failureCount++; + throw { + txResultCode: "tx_bad_seq", + message: "Mock sequence error", + }; + } + return { + txHash: `0x${Buffer.from(request.correlationId).toString("hex").padEnd(64, "0").slice(0, 64)}`, + ledger: 123456, + }; + }); + + return reply.status(200).send({ + success: true, + txHash: result.txHash, + ledger: result.ledger, + attempts: result.attempts, + }); + } + ); return app; } +export default buildApp; diff --git a/apps/api/src/errors/index.ts b/apps/api/src/errors/index.ts new file mode 100644 index 0000000..6f9b4d2 --- /dev/null +++ b/apps/api/src/errors/index.ts @@ -0,0 +1,197 @@ +import type { FastifyError, FastifyInstance, FastifyPluginAsync, FastifyReply, FastifyRequest } from "fastify"; +import { redact } from "@guildpass/log-redaction"; + +/** + * Base domain error for GuildPass Core. + */ +export class GuildPassError extends Error { + public readonly code: string; + public readonly statusCode: number; + public readonly isOperational: boolean; + public readonly details?: unknown; + + constructor( + message: string, + code = "INTERNAL_ERROR", + statusCode = 500, + isOperational = true, + details?: unknown + ) { + super(message); + this.name = this.constructor.name; + this.code = code; + this.statusCode = statusCode; + this.isOperational = isOperational; + this.details = details; + Error.captureStackTrace(this, this.constructor); + } +} + +export class AuthenticationError extends GuildPassError { + constructor(message = "Authentication required", details?: unknown) { + super(message, "AUTH_UNAUTHORIZED", 401, true, details); + } +} + +export class AuthorizationError extends GuildPassError { + constructor(message = "Insufficient permissions for this operation", details?: unknown) { + super(message, "AUTH_FORBIDDEN", 403, true, details); + } +} + +export class ValidationError extends GuildPassError { + constructor(message = "Invalid request payload or parameters", details?: unknown) { + super(message, "VALIDATION_FAILED", 400, true, details); + } +} + +export class NotFoundError extends GuildPassError { + constructor(message = "Requested resource not found", details?: unknown) { + super(message, "RESOURCE_NOT_FOUND", 404, true, details); + } +} + +export class ConflictError extends GuildPassError { + constructor(message = "Resource state conflict", details?: unknown) { + super(message, "RESOURCE_CONFLICT", 409, true, details); + } +} + +export class RateLimitExceededError extends GuildPassError { + public readonly retryAfterMs?: number; + + constructor(message = "Rate limit exceeded. Please retry later.", retryAfterMs?: number) { + super(message, "RATE_LIMIT_EXCEEDED", 429, true, { retryAfterMs }); + this.retryAfterMs = retryAfterMs; + } +} + +export class IdempotencyConflictError extends GuildPassError { + constructor(message = "Idempotency conflict detected", details?: unknown) { + super(message, "IDEMPOTENCY_CONFLICT", 409, true, details); + } +} + +export class StellarTransactionError extends GuildPassError { + public readonly isRetryable: boolean; + public readonly txResultCode?: string; + + constructor( + message: string, + options: { + isRetryable: boolean; + txResultCode?: string; + statusCode?: number; + details?: unknown; + } + ) { + super( + message, + "STELLAR_TRANSACTION_ERROR", + options.statusCode ?? (options.isRetryable ? 503 : 400), + true, + options.details + ); + this.isRetryable = options.isRetryable; + this.txResultCode = options.txResultCode; + } +} + +export class InternalServerError extends GuildPassError { + constructor(message = "An unexpected error occurred", details?: unknown) { + super(message, "INTERNAL_ERROR", 500, false, details); + } +} + +export interface PublicErrorPayload { + code: string; + message: string; + correlationId?: string; + details?: unknown; +} + +/** + * Sanitizes any error before returning it to the caller. + * Never leaks database connection strings, stack traces, private keys, or internal secrets. + */ +export function sanitizeError( + err: unknown, + correlationId?: string +): { statusCode: number; payload: { error: PublicErrorPayload } } { + if (err instanceof GuildPassError && err.isOperational) { + const sanitizedDetails = err.details !== undefined ? redact(err.details) : undefined; + return { + statusCode: err.statusCode, + payload: { + error: { + code: err.code, + message: err.message, + correlationId, + ...(sanitizedDetails !== undefined ? { details: sanitizedDetails } : {}), + }, + }, + }; + } + + // Handle Fastify schema validation errors + if (err && typeof err === "object" && "validation" in err && (err as any).statusCode === 400) { + const fastifyErr = err as FastifyError; + return { + statusCode: 400, + payload: { + error: { + code: "VALIDATION_FAILED", + message: fastifyErr.message || "Request validation failed", + correlationId, + details: redact(fastifyErr.validation), + }, + }, + }; + } + + // Any unhandled internal or infrastructure error is masked + return { + statusCode: 500, + payload: { + error: { + code: "INTERNAL_ERROR", + message: "An internal server error occurred", + correlationId, + }, + }, + }; +} + +/** + * Fastify plugin attaching structured, sanitized error handling across all routes. + */ +export const errorHandlerPlugin: FastifyPluginAsync = async (fastify: FastifyInstance) => { + fastify.setErrorHandler((error: FastifyError | Error, request: FastifyRequest, reply: FastifyReply) => { + const correlationId = request.correlationId; + const { statusCode, payload } = sanitizeError(error, correlationId); + + // Log the error securely with redaction + if (request.log) { + const logPayload = redact({ + correlationId, + errorName: error.name, + errorMessage: error.message, + errorCode: payload.error.code, + statusCode, + stack: statusCode >= 500 ? error.stack : undefined, + }); + + if (statusCode >= 500) { + request.log.error(logPayload, "Unhandled server error caught by error handler"); + } else { + request.log.warn(logPayload, "Client operational error caught by error handler"); + } + } + + reply.status(statusCode).send(payload); + }); +}; + +(errorHandlerPlugin as any)[Symbol.for("skip-override")] = true; + +export default errorHandlerPlugin; diff --git a/apps/api/src/observability/health.ts b/apps/api/src/observability/health.ts new file mode 100644 index 0000000..13d736f --- /dev/null +++ b/apps/api/src/observability/health.ts @@ -0,0 +1,77 @@ +import type { FastifyInstance, FastifyPluginAsync } from "fastify"; + +export interface ReadinessCheck { + name: string; + check: () => Promise | boolean; +} + +export interface HealthPluginOptions { + serviceName?: string; + version?: string; + readinessChecks?: ReadinessCheck[]; +} + +export const healthRoutes: FastifyPluginAsync = async ( + fastify: FastifyInstance, + options: HealthPluginOptions = {} +) => { + const serviceName = options.serviceName || "guildpass-core-api"; + const version = options.version || "2.0.0"; + const readinessChecks = options.readinessChecks || []; + + // Liveness probe: returns 200 if process is running and event loop is responsive + fastify.get("/health", async (_request, reply) => { + return reply.status(200).send({ + status: "ok", + service: serviceName, + }); + }); + + // Readiness probe: verifies internal subsystems, memory limits, and external dependencies + fastify.get("/ready", async (_request, reply) => { + const memory = process.memoryUsage(); + const heapUsedMb = Math.round(memory.heapUsed / 1024 / 1024); + const heapTotalMb = Math.round(memory.heapTotal / 1024 / 1024); + const memoryHealthy = heapUsedMb < 1500; // Under 1.5GB + + const checkResults: Record = { + memory: { + status: memoryHealthy ? "pass" : "fail", + }, + }; + + let allReady = memoryHealthy; + + for (const item of readinessChecks) { + try { + const passed = await item.check(); + checkResults[item.name] = { status: passed ? "pass" : "fail" }; + if (!passed) allReady = false; + } catch (err) { + checkResults[item.name] = { + status: "fail", + error: err instanceof Error ? err.message : "Check failed", + }; + allReady = false; + } + } + + const statusCode = allReady ? 200 : 503; + return reply.status(statusCode).send({ + status: allReady ? "ready" : "degraded", + service: serviceName, + version, + timestamp: new Date().toISOString(), + metrics: { + heapUsedMb, + heapTotalMb, + uptimeSeconds: Math.floor(process.uptime()), + }, + checks: checkResults, + }); + }); +}; + +(healthRoutes as any)[Symbol.for("skip-override")] = true; + +export default healthRoutes; diff --git a/apps/api/src/observability/logging.ts b/apps/api/src/observability/logging.ts new file mode 100644 index 0000000..2f96b44 --- /dev/null +++ b/apps/api/src/observability/logging.ts @@ -0,0 +1,88 @@ +import type { FastifyInstance, FastifyPluginAsync, FastifyRequest, FastifyReply } from "fastify"; +import { redact, DEFAULT_REDACT_KEYS } from "@guildpass/log-redaction"; + +export interface RedactedLoggerOptions { + customRedactKeys?: string[]; + enableRequestLogging?: boolean; + enableResponseLogging?: boolean; +} + +/** + * Sanitizes headers by redacting sensitive values like authorization tokens and api keys. + */ +export function sanitizeHeaders( + headers: Record, + customKeys?: string[] +): Record { + return redact(headers, { + redactKeys: [...DEFAULT_REDACT_KEYS, ...(customKeys || [])], + }) as Record; +} + +/** + * Sanitizes an arbitrary object (e.g. payload, parameters) before logging. + */ +export function sanitizeLogData(data: T, customKeys?: string[]): T { + return redact(data, { + redactKeys: [...DEFAULT_REDACT_KEYS, ...(customKeys || [])], + }); +} + +/** + * Fastify plugin enforcing structured, redacted logging across request/response lifecycle. + */ +export const redactedLoggerPlugin: FastifyPluginAsync = async ( + fastify: FastifyInstance, + options: RedactedLoggerOptions = {} +) => { + const customKeys = options.customRedactKeys || []; + const logRequests = options.enableRequestLogging !== false; + const logResponses = options.enableResponseLogging !== false; + + fastify.addHook("onRequest", async (request: FastifyRequest) => { + if (!logRequests || !request.log) return; + + request.log.info( + { + correlationId: request.correlationId, + method: request.method, + url: request.url, + ip: request.ip, + headers: sanitizeHeaders(request.headers as Record, customKeys), + }, + "Incoming HTTP request" + ); + }); + + fastify.addHook("preHandler", async (request: FastifyRequest) => { + if (request.body && request.log) { + const sanitizedBody = sanitizeLogData(request.body, customKeys); + request.log.debug( + { + correlationId: request.correlationId, + body: sanitizedBody, + }, + "Request body payload (redacted)" + ); + } + }); + + fastify.addHook("onResponse", async (request: FastifyRequest, reply: FastifyReply) => { + if (!logResponses || !request.log) return; + + request.log.info( + { + correlationId: request.correlationId, + method: request.method, + url: request.url, + statusCode: reply.statusCode, + responseTimeMs: reply.elapsedTime, + }, + "HTTP request completed" + ); + }); +}; + +(redactedLoggerPlugin as any)[Symbol.for("skip-override")] = true; + +export default redactedLoggerPlugin; diff --git a/apps/api/src/plugins/idempotency.ts b/apps/api/src/plugins/idempotency.ts new file mode 100644 index 0000000..33c4c7b --- /dev/null +++ b/apps/api/src/plugins/idempotency.ts @@ -0,0 +1,148 @@ +import type { FastifyInstance, FastifyPluginAsync, FastifyReply, FastifyRequest } from "fastify"; +import { + type IdempotencyStore, + InMemoryIdempotencyStore, + IdempotencyEngine, +} from "@guildpass/idempotency"; +import { IdempotencyConflictError, ValidationError } from "../errors/index.js"; + +export interface IdempotencyPluginOptions { + store?: IdempotencyStore; + ttlMs?: number; + headerName?: string; + enforceOnMutations?: boolean; +} + +export const DEFAULT_IDEMPOTENCY_HEADER = "idempotency-key"; +export const DEFAULT_IDEMPOTENCY_TTL_MS = 24 * 60 * 60 * 1000; // 24 hours + +export interface IdempotencyContext { + key: string; + fingerprint: string; + acquired: boolean; +} + +export const idempotencyPlugin: FastifyPluginAsync = async ( + fastify: FastifyInstance, + options: IdempotencyPluginOptions = {} +) => { + const store = options.store ?? new InMemoryIdempotencyStore(); + const ttlMs = options.ttlMs ?? DEFAULT_IDEMPOTENCY_TTL_MS; + const primaryHeader = (options.headerName || DEFAULT_IDEMPOTENCY_HEADER).toLowerCase(); + + fastify.decorate("idempotencyStore", store); + + // Pre-handler hook to inspect idempotency key and intercept replays + fastify.addHook("preHandler", async (request: FastifyRequest, reply: FastifyReply) => { + const method = request.method.toUpperCase(); + const isMutation = ["POST", "PUT", "PATCH", "DELETE"].includes(method); + + if (!isMutation) { + return; + } + + const rawHeader = + request.headers[primaryHeader] || request.headers["x-idempotency-key"]; + const key = Array.isArray(rawHeader) ? rawHeader[0] : rawHeader; + + if (!key) { + if (options.enforceOnMutations) { + throw new ValidationError( + `Missing required idempotency key header (${primaryHeader}) for mutation operation.` + ); + } + return; + } + + const trimmedKey = key.trim(); + if (trimmedKey.length === 0 || trimmedKey.length > 256) { + throw new ValidationError( + "Idempotency key must be a non-empty string of maximum 256 characters." + ); + } + + // Generate canonical fingerprint of the mutating request + const fingerprint = IdempotencyEngine.generateFingerprint({ + method, + url: request.url, + body: request.body ?? {}, + }); + + const acquireResult = await store.acquire<{ + statusCode: number; + body: unknown; + }>({ + key: trimmedKey, + fingerprint, + ttlMs, + }); + + if (acquireResult.type === "conflict") { + throw new IdempotencyConflictError( + "Idempotency key has already been used with a different request payload or endpoint." + ); + } + + if (acquireResult.type === "in_flight") { + throw new IdempotencyConflictError( + "A request with this idempotency key is currently processing. Please wait for completion." + ); + } + + if (acquireResult.type === "completed") { + const cached = acquireResult.result; + reply.header("Idempotent-Replay", "true"); + reply.header("x-idempotency-key", trimmedKey); + reply.status(cached.statusCode).send(cached.body); + return reply; + } + + // Acquired successfully + (request as any).idempotencyContext = { + key: trimmedKey, + fingerprint, + acquired: true, + } satisfies IdempotencyContext; + }); + + // onSend hook to cache successful mutation responses or release lock on failure + fastify.addHook("onSend", async (request: FastifyRequest, reply: FastifyReply, payload: unknown) => { + const ctx = (request as any).idempotencyContext as IdempotencyContext | undefined; + if (!ctx || !ctx.acquired) { + return payload; + } + + const statusCode = reply.statusCode; + + // Successful mutations (2xx) are stored for replay + if (statusCode >= 200 && statusCode < 300) { + let parsedBody: unknown = payload; + if (typeof payload === "string") { + try { + parsedBody = JSON.parse(payload); + } catch { + parsedBody = payload; + } + } + + await store.complete( + ctx.key, + ctx.fingerprint, + { + statusCode, + body: parsedBody, + }, + ttlMs + ); + } else { + // On error (4xx/5xx), release the lock so the client can retry + await store.release(ctx.key, ctx.fingerprint); + } + + return payload; + }); +}; + +(idempotencyPlugin as any)[Symbol.for("skip-override")] = true; + +export default idempotencyPlugin; diff --git a/apps/api/src/plugins/rate-limit.ts b/apps/api/src/plugins/rate-limit.ts new file mode 100644 index 0000000..624740c --- /dev/null +++ b/apps/api/src/plugins/rate-limit.ts @@ -0,0 +1,62 @@ +import type { FastifyInstance, FastifyPluginAsync, FastifyReply, FastifyRequest } from "fastify"; +import { RateLimiter, InMemoryRateLimitStore, type RateLimitStore } from "@guildpass/rate-limit"; +import { RateLimitExceededError } from "../errors/index.js"; + +export interface RateLimitPluginOptions { + limit?: number; + windowMs?: number; + store?: RateLimitStore; + keyGenerator?: (request: FastifyRequest) => string; + skip?: (request: FastifyRequest) => boolean; +} + +export const rateLimitPlugin: FastifyPluginAsync = async ( + fastify: FastifyInstance, + options: RateLimitPluginOptions = {} +) => { + const limit = options.limit ?? 100; + const windowMs = options.windowMs ?? 60000; + const store = options.store ?? new InMemoryRateLimitStore(); + const keyGenerator = + options.keyGenerator ?? + ((req: FastifyRequest) => { + if (req.auth?.subject) { + return `auth:${req.auth.subject}`; + } + return `ip:${req.ip || "127.0.0.1"}`; + }); + + const limiter = new RateLimiter({ + limit, + windowMs, + store, + }); + + fastify.decorate("rateLimiter", limiter); + + fastify.addHook("preHandler", async (request: FastifyRequest, reply: FastifyReply) => { + if (options.skip && options.skip(request)) { + return; + } + + const key = keyGenerator(request); + const decision = await limiter.check(key); + + reply.header("X-RateLimit-Limit", decision.limit.toString()); + reply.header("X-RateLimit-Remaining", decision.remaining.toString()); + reply.header("X-RateLimit-Reset", Math.ceil(decision.resetAt / 1000).toString()); + + if (!decision.allowed) { + const retryAfterSec = Math.ceil((decision.retryAfterMs ?? windowMs) / 1000); + reply.header("Retry-After", retryAfterSec.toString()); + throw new RateLimitExceededError( + `Rate limit exceeded. Please retry after ${retryAfterSec} second(s).`, + decision.retryAfterMs ?? windowMs + ); + } + }); +}; + +(rateLimitPlugin as any)[Symbol.for("skip-override")] = true; + +export default rateLimitPlugin; diff --git a/apps/api/src/security/auth.ts b/apps/api/src/security/auth.ts new file mode 100644 index 0000000..e72de4c --- /dev/null +++ b/apps/api/src/security/auth.ts @@ -0,0 +1,142 @@ +import type { FastifyReply, FastifyRequest } from "fastify"; +import { + type CapabilityPayload, + verifyToken, +} from "@guildpass/capability-token"; +import { AuthenticationError, AuthorizationError } from "../errors/index.js"; + +export interface AuthOptions { + secret?: string; + audience?: string; + requiredScopes?: string[]; + anyScopes?: string[]; +} + +export const DEFAULT_AUTH_SECRET = + process.env.GUILDPASS_AUTH_SECRET || "guildpass-production-auth-secret-key-32b"; +export const DEFAULT_AUDIENCE = "guildpass-core"; + +/** + * Checks if current scopes array contains all required scopes. + */ +export function checkAllScopes(currentScopes: string[], requiredScopes: string[]): boolean { + return requiredScopes.every((scope) => currentScopes.includes(scope)); +} + +/** + * Checks if current scopes array contains at least one of the specified scopes. + */ +export function checkAnyScope(currentScopes: string[], anyScopes: string[]): boolean { + return anyScopes.some((scope) => currentScopes.includes(scope)); +} + +/** + * Extracts a token string from the request Authorization header or x-api-key header. + */ +export function extractToken(request: FastifyRequest): string | null { + const authHeader = request.headers.authorization; + if (authHeader) { + const [scheme, token] = authHeader.split(" "); + if (scheme?.toLowerCase() === "bearer" && token) { + return token.trim(); + } + } + + const apiKeyHeader = request.headers["x-api-key"]; + if (typeof apiKeyHeader === "string" && apiKeyHeader.trim().length > 0) { + return apiKeyHeader.trim(); + } + + return null; +} + +/** + * Authenticates the incoming request, verifying the capability token and decorating request.auth. + */ +export async function authenticateRequest( + request: FastifyRequest, + options: AuthOptions = {} +): Promise { + const token = extractToken(request); + if (!token) { + throw new AuthenticationError("Authentication token is missing. Please provide a Bearer token or API key."); + } + + const secret = options.secret || DEFAULT_AUTH_SECRET; + const audience = options.audience || DEFAULT_AUDIENCE; + + const verification = verifyToken(token, { + secret, + audience, + }); + + if (!verification.valid || !verification.payload) { + throw new AuthenticationError( + verification.reason || "Invalid or expired capability token" + ); + } + + const payload = verification.payload; + + // Decorate the request context + request.auth = { + subject: payload.subject, + audience: payload.audience, + scopes: payload.scopes, + issuedAt: payload.issuedAt, + expiresAt: payload.expiresAt, + }; + + return payload; +} + +/** + * Pre-handler hook requiring the request to be authenticated. + */ +export function requireAuth(options: AuthOptions = {}) { + return async (request: FastifyRequest, _reply: FastifyReply) => { + if (!request.auth) { + await authenticateRequest(request, options); + } + }; +} + +/** + * Pre-handler hook enforcing that the caller holds ALL specified scopes. + * Serves as the primary mutation authorization boundary. + */ +export function requireScopes(scopes: string[], options: AuthOptions = {}) { + return async (request: FastifyRequest, _reply: FastifyReply) => { + if (!request.auth) { + await authenticateRequest(request, options); + } + + const currentScopes = request.auth?.scopes || []; + if (!checkAllScopes(currentScopes, scopes)) { + const missing = scopes.filter((s) => !currentScopes.includes(s)); + throw new AuthorizationError( + `Missing required authorization scope(s): ${missing.join(", ")}`, + { requiredScopes: scopes, missingScopes: missing } + ); + } + }; +} + +/** + * Pre-handler hook enforcing that the caller holds at least one of the specified scopes. + */ +export function requireAnyScope(scopes: string[], options: AuthOptions = {}) { + return async (request: FastifyRequest, _reply: FastifyReply) => { + if (!request.auth) { + await authenticateRequest(request, options); + } + + const currentScopes = request.auth?.scopes || []; + if (!checkAnyScope(currentScopes, scopes)) { + throw new AuthorizationError( + `Requires at least one of the following scope(s): ${scopes.join(", ")}`, + { requiredScopes: scopes } + ); + } + }; +} diff --git a/apps/api/src/stellar/transaction-handler.ts b/apps/api/src/stellar/transaction-handler.ts new file mode 100644 index 0000000..2a756a8 --- /dev/null +++ b/apps/api/src/stellar/transaction-handler.ts @@ -0,0 +1,254 @@ +import { CircuitBreaker, CircuitBreakerRejectedError } from "@guildpass/circuit-breaker"; +import { retry, type RetryOptions, RetryExhaustedError } from "@guildpass/retry-policy"; +import { StellarTransactionError } from "../errors/index.js"; + +export type StellarErrorCategory = "TRANSIENT" | "PERMANENT" | "UNKNOWN"; + +export interface StellarErrorClassification { + category: StellarErrorCategory; + isRetryable: boolean; + code: string; + message: string; +} + +/** + * Known permanent Stellar / Soroban transaction error result codes. + * Retrying these will NEVER succeed and only wastes network and compute resources. + */ +export const PERMANENT_STELLAR_RESULT_CODES = new Set([ + "tx_bad_auth", + "tx_bad_auth_extra", + "tx_insufficient_balance", + "tx_insufficient_fee", + "tx_missing_operation", + "tx_too_early", + "tx_too_late", + "op_underfunded", + "op_no_trust", + "op_not_authorized", + "op_line_full", + "op_no_issuer", + "op_no_destination", + "op_already_exists", + "op_invalid_limit", + "op_src_no_trust", + "op_src_not_authorized", +]); + +/** + * Known transient error codes that are safe to retry after a delay. + */ +export const TRANSIENT_STELLAR_RESULT_CODES = new Set([ + "tx_bad_seq", // Can retry after refreshing source account sequence + "tx_internal_error", + "tx_soroban_internal_error", +]); + +/** + * Classifies a raw error from a Stellar RPC or Horizon call into a structured classification. + */ +export function classifyStellarError(error: unknown): StellarErrorClassification { + if (!error) { + return { + category: "UNKNOWN", + isRetryable: false, + code: "STELLAR_UNKNOWN", + message: "An unknown Stellar error occurred", + }; + } + + if (error instanceof StellarTransactionError) { + return { + category: error.isRetryable ? "TRANSIENT" : "PERMANENT", + isRetryable: error.isRetryable, + code: error.txResultCode || "STELLAR_TRANSACTION_ERROR", + message: error.message, + }; + } + + const errObj = error as Record; + const txCode = + errObj.txResultCode || + errObj.resultCode || + errObj.response?.data?.extras?.result_codes?.transaction; + const opCodes: string[] = + errObj.opResultCodes || + errObj.response?.data?.extras?.result_codes?.operations || + []; + + // Check permanent transaction-level codes + if (txCode && PERMANENT_STELLAR_RESULT_CODES.has(txCode)) { + return { + category: "PERMANENT", + isRetryable: false, + code: txCode, + message: `Permanent Stellar transaction failure: ${txCode}`, + }; + } + + // Check permanent operation-level codes + for (const opCode of opCodes) { + if (PERMANENT_STELLAR_RESULT_CODES.has(opCode)) { + return { + category: "PERMANENT", + isRetryable: false, + code: opCode, + message: `Permanent Stellar operation failure: ${opCode}`, + }; + } + } + + // Check transient transaction-level codes + if (txCode && TRANSIENT_STELLAR_RESULT_CODES.has(txCode)) { + return { + category: "TRANSIENT", + isRetryable: true, + code: txCode, + message: `Transient Stellar transaction failure: ${txCode}`, + }; + } + + // Check HTTP status codes (e.g. 429, 502, 503, 504) + const status = errObj.status || errObj.statusCode || errObj.response?.status; + if (status === 429 || status === 502 || status === 503 || status === 504) { + return { + category: "TRANSIENT", + isRetryable: true, + code: `HTTP_${status}`, + message: `Stellar RPC returned transient HTTP status ${status}`, + }; + } + + // Check standard network timeout/connection errors + const message = String(errObj.message || ""); + if ( + message.includes("ETIMEDOUT") || + message.includes("ECONNRESET") || + message.includes("timeout") || + message.includes("NetworkError") || + message.includes("fetch failed") + ) { + return { + category: "TRANSIENT", + isRetryable: true, + code: "NETWORK_TIMEOUT", + message: `Stellar RPC connection error: ${message}`, + }; + } + + return { + category: "UNKNOWN", + isRetryable: false, + code: "STELLAR_ERROR", + message: message || "Unclassified Stellar error", + }; +} + +export interface StellarExecutorOptions { + circuitBreaker?: CircuitBreaker; + retryOptions?: RetryOptions; +} + +export interface StellarTxResult { + success: true; + txHash: string; + ledger?: number; + data?: T; + attempts: number; +} + +/** + * Robust executor for Stellar transactions providing circuit breaker protection + * and classified retry handling. + */ +export class StellarTransactionExecutor { + public readonly circuitBreaker: CircuitBreaker; + public readonly retryOptions: RetryOptions; + + constructor(options: StellarExecutorOptions = {}) { + this.circuitBreaker = + options.circuitBreaker || + new CircuitBreaker({ + failureThreshold: 3, + cooldownMs: 15000, + halfOpenProbeLimit: 1, + isFailure: (reason: unknown) => { + const classification = classifyStellarError(reason); + // Circuit breaker trips only on transient infra/RPC failures, not user-induced permanent errors + return classification.isRetryable; + }, + }); + + this.retryOptions = options.retryOptions || { + maxAttempts: 3, + initialDelay: 300, + maxDelay: 3000, + backoffMultiplier: 2, + jitter: { enabled: false }, // predictable for tests/runtime + isRetryable: (reason: unknown) => { + const classification = classifyStellarError(reason); + return classification.isRetryable; + }, + }; + } + + /** + * Executes a transaction submission within the circuit breaker and retry policy. + */ + async submitTransaction( + operation: () => Promise<{ txHash: string; ledger?: number; data?: T }> + ): Promise> { + try { + return await this.circuitBreaker.execute(async () => { + let attemptsCount = 0; + try { + const result = await retry(async (meta: { attempt: number }) => { + attemptsCount = meta.attempt; + return await operation(); + }, this.retryOptions); + + return { + success: true, + txHash: result.txHash, + ledger: result.ledger, + data: result.data, + attempts: attemptsCount, + }; + } catch (error) { + if (error instanceof RetryExhaustedError) { + const classification = classifyStellarError(error.cause); + throw new StellarTransactionError( + `Stellar transaction failed after ${error.attempts} attempt(s): ${classification.message}`, + { + isRetryable: classification.isRetryable, + txResultCode: classification.code, + statusCode: 503, + details: { cause: classification.message }, + } + ); + } + + const classification = classifyStellarError(error); + throw new StellarTransactionError(classification.message, { + isRetryable: classification.isRetryable, + txResultCode: classification.code, + statusCode: classification.isRetryable ? 503 : 400, + details: { code: classification.code }, + }); + } + }); + } catch (cbError) { + if (cbError instanceof CircuitBreakerRejectedError) { + throw new StellarTransactionError( + "Stellar network RPC is currently unavailable (circuit breaker OPEN). Please retry shortly.", + { + isRetryable: true, + statusCode: 503, + txResultCode: "CIRCUIT_BREAKER_OPEN", + } + ); + } + throw cbError; + } + } +} diff --git a/apps/api/src/test/hardening.test.ts b/apps/api/src/test/hardening.test.ts new file mode 100644 index 0000000..5b968ce --- /dev/null +++ b/apps/api/src/test/hardening.test.ts @@ -0,0 +1,381 @@ +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; +import { issueToken } from "@guildpass/capability-token"; +import { buildApp } from "../app.js"; +import { + GuildPassError, + AuthenticationError, + AuthorizationError, + RateLimitExceededError, + StellarTransactionError, + sanitizeError, +} from "../errors/index.js"; +import { + classifyStellarError, + StellarTransactionExecutor, +} from "../stellar/transaction-handler.js"; +import { redact } from "@guildpass/log-redaction"; +import { CircuitBreaker } from "@guildpass/circuit-breaker"; + +const TEST_SECRET = "test-secret-at-least-32-bytes-long-for-hmac"; + +function createValidToken(scopes: string[], subject = "user_test_123", ttl = 3600): string { + return issueToken({ + subject, + audience: "guildpass-core", + scopes, + }, { + secret: TEST_SECRET, + ttl, + }); +} + +describe("GuildPass Core V2 Production Hardening", () => { + describe("1. Mutation Authorization Boundaries", () => { + it("rejects unauthenticated mutation requests with 401 AUTH_UNAUTHORIZED", async () => { + const app = buildApp({ authSecret: TEST_SECRET }); + const res = await app.inject({ + method: "POST", + url: "/api/v2/passes/issue", + payload: { guildId: "alpha", passType: "vip" }, + }); + + assert.equal(res.statusCode, 401); + const body = res.json(); + assert.equal(body.error.code, "AUTH_UNAUTHORIZED"); + assert.ok(body.error.correlationId); + }); + + it("rejects requests with invalid/tampered token with 401", async () => { + const app = buildApp({ authSecret: TEST_SECRET }); + const res = await app.inject({ + method: "POST", + url: "/api/v2/passes/issue", + headers: { + authorization: "Bearer invalid.token.payload", + }, + payload: { guildId: "alpha" }, + }); + + assert.equal(res.statusCode, 401); + const body = res.json(); + assert.equal(body.error.code, "AUTH_UNAUTHORIZED"); + }); + + it("rejects authenticated caller lacking required scope with 403 AUTH_FORBIDDEN", async () => { + const app = buildApp({ authSecret: TEST_SECRET }); + const tokenWithoutScope = createValidToken(["profile:read"]); + + const res = await app.inject({ + method: "POST", + url: "/api/v2/passes/issue", + headers: { + authorization: `Bearer ${tokenWithoutScope}`, + }, + payload: { guildId: "alpha" }, + }); + + assert.equal(res.statusCode, 403); + const body = res.json(); + assert.equal(body.error.code, "AUTH_FORBIDDEN"); + assert.ok(body.error.message.includes("pass:issue")); + }); + + it("authorizes mutation when valid token contains required scope", async () => { + const app = buildApp({ authSecret: TEST_SECRET }); + const tokenWithScope = createValidToken(["pass:issue"]); + + const res = await app.inject({ + method: "POST", + url: "/api/v2/passes/issue", + headers: { + authorization: `Bearer ${tokenWithScope}`, + }, + payload: { guildId: "alpha", passType: "founder" }, + }); + + assert.equal(res.statusCode, 201); + const body = res.json(); + assert.equal(body.success, true); + assert.equal(body.guildId, "alpha"); + assert.equal(body.recipient, "user_test_123"); + }); + }); + + describe("2. Idempotency on Mutations", () => { + it("returns fresh result on first call and cached result on replay with Idempotent-Replay header", async () => { + const app = buildApp({ authSecret: TEST_SECRET }); + const token = createValidToken(["pass:issue"]); + const idempotencyKey = "idemp_test_key_001"; + const payload = { guildId: "guild-omega", passType: "pass-1" }; + + // First call: executes mutation + const res1 = await app.inject({ + method: "POST", + url: "/api/v2/passes/issue", + headers: { + authorization: `Bearer ${token}`, + "idempotency-key": idempotencyKey, + }, + payload, + }); + + assert.equal(res1.statusCode, 201); + const body1 = res1.json(); + assert.equal(body1.success, true); + assert.equal(res1.headers["idempotent-replay"], undefined); + + // Second call: exact same key and payload -> replayed from store + const res2 = await app.inject({ + method: "POST", + url: "/api/v2/passes/issue", + headers: { + authorization: `Bearer ${token}`, + "idempotency-key": idempotencyKey, + }, + payload, + }); + + assert.equal(res2.statusCode, 201); + const body2 = res2.json(); + assert.deepEqual(body1, body2); + assert.equal(res2.headers["idempotent-replay"], "true"); + }); + + it("rejects idempotency key reuse with different payload with 409 IDEMPOTENCY_CONFLICT", async () => { + const app = buildApp({ authSecret: TEST_SECRET }); + const token = createValidToken(["pass:issue"]); + const idempotencyKey = "idemp_test_conflict_002"; + + // First call + const res1 = await app.inject({ + method: "POST", + url: "/api/v2/passes/issue", + headers: { + authorization: `Bearer ${token}`, + "idempotency-key": idempotencyKey, + }, + payload: { guildId: "first-guild" }, + }); + assert.equal(res1.statusCode, 201); + + // Second call with altered payload + const res2 = await app.inject({ + method: "POST", + url: "/api/v2/passes/issue", + headers: { + authorization: `Bearer ${token}`, + "idempotency-key": idempotencyKey, + }, + payload: { guildId: "DIFFERENT-guild-payload" }, + }); + + assert.equal(res2.statusCode, 409); + const body2 = res2.json(); + assert.equal(body2.error.code, "IDEMPOTENCY_CONFLICT"); + }); + }); + + describe("3. Sensitive Data Redaction & Error Sanitization", () => { + it("masks sensitive fields in objects deterministically", () => { + const sensitiveInput = { + authorization: "Bearer secret-token-xyz", + privateKey: "SXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX", + seedPhrase: "twelve secret recovery words here for stellar account", + safeParam: "public-guild-123", + }; + + const sanitized = redact(sensitiveInput); + assert.equal((sanitized as any).authorization, "[REDACTED]"); + assert.equal((sanitized as any).privateKey, "[REDACTED]"); + assert.equal((sanitized as any).seedPhrase, "[REDACTED]"); + assert.equal((sanitized as any).safeParam, "public-guild-123"); + }); + + it("masks internal error details and stack traces from public responses", () => { + const internalError = new Error("Database connection failed: postgres://user:password123@db.internal:5432"); + const { statusCode, payload } = sanitizeError(internalError, "trace-abc-123"); + + assert.equal(statusCode, 500); + assert.equal(payload.error.code, "INTERNAL_ERROR"); + assert.equal(payload.error.message, "An internal server error occurred"); + assert.equal(payload.error.correlationId, "trace-abc-123"); + assert.equal((payload.error as any).stack, undefined); + assert.ok(!JSON.stringify(payload).includes("password123")); + }); + }); + + describe("4. Stellar Transaction Failure & Retry Resilience", () => { + it("correctly classifies permanent vs transient Stellar errors", () => { + const permanentAuth = classifyStellarError({ txResultCode: "tx_bad_auth" }); + assert.equal(permanentAuth.category, "PERMANENT"); + assert.equal(permanentAuth.isRetryable, false); + + const permanentUnderfunded = classifyStellarError({ opResultCodes: ["op_underfunded"] }); + assert.equal(permanentUnderfunded.category, "PERMANENT"); + assert.equal(permanentUnderfunded.isRetryable, false); + + const transientSeq = classifyStellarError({ txResultCode: "tx_bad_seq" }); + assert.equal(transientSeq.category, "TRANSIENT"); + assert.equal(transientSeq.isRetryable, true); + + const transient503 = classifyStellarError({ status: 503 }); + assert.equal(transient503.category, "TRANSIENT"); + assert.equal(transient503.isRetryable, true); + }); + + it("retries transient Stellar errors and succeeds if transient issue clears", async () => { + const executor = new StellarTransactionExecutor({ + retryOptions: { + maxAttempts: 3, + initialDelay: 10, + maxDelay: 50, + jitter: { enabled: false }, + }, + }); + + let callCount = 0; + const result = await executor.submitTransaction(async () => { + callCount++; + if (callCount < 3) { + throw { txResultCode: "tx_bad_seq", message: "Sequence mismatch" }; + } + return { txHash: "0xstellar_success_hash", ledger: 5000 }; + }); + + assert.equal(result.success, true); + assert.equal(result.txHash, "0xstellar_success_hash"); + assert.equal(result.attempts, 3); + assert.equal(callCount, 3); + }); + + it("immediately fails permanent Stellar errors without retrying", async () => { + const executor = new StellarTransactionExecutor(); + let callCount = 0; + + await assert.rejects(async () => { + await executor.submitTransaction(async () => { + callCount++; + throw { txResultCode: "tx_bad_auth", message: "Bad auth" }; + }); + }, (err: any) => { + assert.ok(err instanceof StellarTransactionError); + assert.equal(err.isRetryable, false); + assert.equal(err.txResultCode, "tx_bad_auth"); + return true; + }); + + // Permanent error must not be retried! + assert.equal(callCount, 1); + }); + + it("circuit breaker trips after consecutive transient failures, fast-failing new submissions", async () => { + const circuitBreaker = new CircuitBreaker({ + failureThreshold: 2, + cooldownMs: 60000, + halfOpenProbeLimit: 1, + isFailure: (err: unknown) => classifyStellarError(err).isRetryable, + }); + + const executor = new StellarTransactionExecutor({ + circuitBreaker, + retryOptions: { maxAttempts: 1, initialDelay: 10 }, + }); + + // Failure 1 + await assert.rejects(() => + executor.submitTransaction(async () => { + throw { status: 503, message: "Horizon 503" }; + }) + ); + + // Failure 2 -> triggers circuit breaker OPEN + await assert.rejects(() => + executor.submitTransaction(async () => { + throw { status: 503, message: "Horizon 503" }; + }) + ); + + assert.equal(circuitBreaker.getSnapshot().state, "OPEN"); + + // Submission 3: Fast-fails immediately with CIRCUIT_BREAKER_OPEN without invoking operation + let attempted = false; + await assert.rejects(async () => { + await executor.submitTransaction(async () => { + attempted = true; + return { txHash: "never_reached" }; + }); + }, (err: any) => { + assert.ok(err instanceof StellarTransactionError); + assert.equal(err.txResultCode, "CIRCUIT_BREAKER_OPEN"); + return true; + }); + + assert.equal(attempted, false); + }); + }); + + describe("5. Rate Limiting", () => { + it("allows requests within threshold and returns 429 when rate limit exceeded", async () => { + const app = buildApp({ + authSecret: TEST_SECRET, + enableRateLimit: true, + rateLimitMax: 3, + rateLimitWindowMs: 60000, + }); + + const token = createValidToken(["pass:issue"]); + const headers = { + authorization: `Bearer ${token}`, + }; + + // Calls 1, 2, 3 succeed + for (let i = 0; i < 3; i++) { + const res = await app.inject({ + method: "POST", + url: "/api/v2/passes/issue", + headers, + payload: { guildId: "alpha" }, + }); + assert.equal(res.statusCode, 201); + assert.ok(res.headers["x-ratelimit-remaining"]); + } + + // Call 4 exceeds limit + const res4 = await app.inject({ + method: "POST", + url: "/api/v2/passes/issue", + headers, + payload: { guildId: "alpha" }, + }); + + assert.equal(res4.statusCode, 429); + const body4 = res4.json(); + assert.equal(body4.error.code, "RATE_LIMIT_EXCEEDED"); + assert.ok(res4.headers["retry-after"]); + }); + }); + + describe("6. Observability: Liveness & Readiness Signals", () => { + it("GET /health returns 200 with service metadata", async () => { + const app = buildApp(); + const res = await app.inject({ method: "GET", url: "/health" }); + + assert.equal(res.statusCode, 200); + const body = res.json(); + assert.equal(body.status, "ok"); + assert.equal(body.service, "guildpass-core-api"); + }); + + it("GET /ready returns 200 with memory and subsystem checks", async () => { + const app = buildApp(); + const res = await app.inject({ method: "GET", url: "/ready" }); + + assert.equal(res.statusCode, 200); + const body = res.json(); + assert.equal(body.status, "ready"); + assert.ok(body.checks.memory); + assert.equal(body.checks.memory.status, "pass"); + }); + }); +}); diff --git a/apps/api/src/types/fastify.d.ts b/apps/api/src/types/fastify.d.ts index 73f45e3..32ceed0 100644 --- a/apps/api/src/types/fastify.d.ts +++ b/apps/api/src/types/fastify.d.ts @@ -1,7 +1,32 @@ import "fastify"; +import type { RateLimiter } from "@guildpass/rate-limit"; +import type { IdempotencyStore } from "@guildpass/idempotency"; + +export interface RequestAuthContext { + subject: string; + audience: string; + scopes: string[]; + issuedAt?: number; + expiresAt?: number; + isApiKey?: boolean; +} + +export interface RequestIdempotencyContext { + key: string; + fingerprint: string; + acquired: boolean; +} declare module "fastify" { interface FastifyRequest { correlationId: string; + auth?: RequestAuthContext; + idempotencyKey?: string; + idempotencyContext?: RequestIdempotencyContext; + } + + interface FastifyInstance { + rateLimiter?: RateLimiter; + idempotencyStore?: IdempotencyStore; } } diff --git a/packages/idempotency/tsconfig.json b/packages/idempotency/tsconfig.json index 33fd27f..c99ec7b 100644 --- a/packages/idempotency/tsconfig.json +++ b/packages/idempotency/tsconfig.json @@ -2,10 +2,7 @@ "extends": "../../tsconfig.base.json", "compilerOptions": { "outDir": "dist", - "baseUrl": ".", - "paths": { - "@guildpass/canonical-json": ["../canonical-json/src/index.ts"] - } + "rootDir": "src" }, "include": ["src/**/*.ts"] } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index fd8003b..3139516 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -17,6 +17,24 @@ importers: apps/api: dependencies: + '@guildpass/capability-token': + specifier: workspace:* + version: link:../../packages/capability-token + '@guildpass/circuit-breaker': + specifier: workspace:* + version: link:../../packages/circuit-breaker + '@guildpass/idempotency': + specifier: workspace:* + version: link:../../packages/idempotency + '@guildpass/log-redaction': + specifier: workspace:* + version: link:../../packages/log-redaction + '@guildpass/rate-limit': + specifier: workspace:* + version: link:../../packages/rate-limit + '@guildpass/retry-policy': + specifier: workspace:* + version: link:../../packages/retry-policy '@guildpass/shared-types': specifier: workspace:* version: link:../../packages/shared-types