Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions .github/workflows/core-ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
6 changes: 6 additions & 0 deletions apps/api/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
143 changes: 134 additions & 9 deletions apps/api/src/app.ts
Original file line number Diff line number Diff line change
@@ -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<string, any>) || {};
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<string, any>) || {};
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<string, any>) || {};
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;
197 changes: 197 additions & 0 deletions apps/api/src/errors/index.ts
Original file line number Diff line number Diff line change
@@ -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;
Loading
Loading