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
3 changes: 2 additions & 1 deletion apps/access-api/src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import { buildPinoHttp } from './observability/logger';
import { registry, metrics } from './observability/metrics';
import { registerRoutes } from './routes';
import { getPrisma } from './services/prisma';
import { unauthorized } from './errors';

// --------------------------------------------------------------------------
// Helper: normalise a Fastify route URL into a stable label
Expand Down Expand Up @@ -146,7 +147,7 @@ export async function buildApp(): Promise<FastifyInstance> {
if (metricsToken) {
const auth = _req.headers.authorization ?? '';
if (auth !== `Bearer ${metricsToken}`) {
return reply.code(401).send({ error: 'Unauthorized' });
return reply.code(401).send(unauthorized('Invalid or missing metrics token'));
}
}
const output = await registry.metrics();
Expand Down
44 changes: 44 additions & 0 deletions apps/access-api/src/errors.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import type { ApiErrorResponse } from '@guildpass/shared-types';

/** Standard error payload that every error response uses. */
export interface ErrorPayload {
statusCode: number;
code: string;
message: string;
details?: string | Record<string, unknown>;
}

/** Build a standardised error response envelope. */
export function createApiError(payload: ErrorPayload): ApiErrorResponse {
return {
error: payload.code,
code: payload.code,
message: payload.message,
statusCode: payload.statusCode,
...(payload.details !== undefined ? { details: payload.details } : {}),
};
}

export function notFound(message: string, details?: string | Record<string, unknown>): ApiErrorResponse {
return createApiError({ statusCode: 404, code: 'NOT_FOUND', message, details });
}

export function validationError(message: string, details?: string | Record<string, unknown>): ApiErrorResponse {
return createApiError({ statusCode: 400, code: 'VALIDATION_ERROR', message, details });
}

export function unauthorized(message: string): ApiErrorResponse {
return createApiError({ statusCode: 401, code: 'UNAUTHORIZED', message });
}

export function internalError(message: string): ApiErrorResponse {
return createApiError({ statusCode: 500, code: 'INTERNAL_ERROR', message });
}

export function conflict(message: string): ApiErrorResponse {
return createApiError({ statusCode: 409, code: 'CONFLICT', message });
}

export function expired(message: string): ApiErrorResponse {
return createApiError({ statusCode: 410, code: 'EXPIRED', message });
}
16 changes: 8 additions & 8 deletions apps/access-api/src/routes.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import type { FastifyInstance } from 'fastify';
import { getMemberService } from './services/memberService';
import { getPrisma } from './services/prisma';
import { notFound, validationError } from './errors';

/**
* Register all business routes on the Fastify instance.
Expand All @@ -10,8 +11,8 @@ export async function registerRoutes(app: FastifyInstance): Promise<void> {
const prisma = getPrisma();
const memberService = getMemberService(prisma);

// GET /v1/memberships/:wallet — list membership communities for a wallet
app.get('/v1/communities/:communityId/memberships/:wallet', async (request, reply) => {
// GET /v1/communities/:communityId/memberships/:wallet — list membership communities for a wallet
app.get('/v1/communities/:communityId/memberships/:wallet', async (request) => {
const { communityId, wallet } = request.params as { communityId: string; wallet: string };
const result = await memberService.getMembershipsByWallet(wallet, communityId);
return result;
Expand All @@ -22,12 +23,11 @@ export async function registerRoutes(app: FastifyInstance): Promise<void> {
const { communityId, wallet } = request.params as { communityId: string; wallet: string };
const result = await memberService.getProfileByWallet(wallet, communityId);
if (!result) {
return reply.status(404).send({ error: 'Member not found' });
return reply.status(404).send(notFound('Member not found'));
}
return result;
});


// POST /v1/access/check — check access for wallet/resource
app.post('/v1/access/check', async (request, reply) => {
const body = request.body as {
Expand All @@ -36,16 +36,16 @@ export async function registerRoutes(app: FastifyInstance): Promise<void> {
resource: string;
};
if (!body?.wallet || !body?.communityId || !body?.resource) {
return reply.status(400).send({
error: 'Missing required fields: wallet, communityId, resource',
});
return reply.status(400).send(
validationError('Missing required fields: wallet, communityId, resource'),
);
}
const result = await memberService.checkAccess(body);
return result;
});

// GET /v1/communities/:communityId/members — list members for admin
app.get('/v1/communities/:communityId/members', async (request, reply) => {
app.get('/v1/communities/:communityId/members', async (request) => {
const { communityId } = request.params as { communityId: string };
const role = (request.query as { role?: string })?.role;
const result = await memberService.listMembersForAdmin(communityId, role as "admin" | "member" | "contributor" | undefined);
Expand Down
49 changes: 39 additions & 10 deletions apps/access-api/test/routes.integration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,31 @@ import { API_CONTRACT } from '../../../packages/shared-types/src/apiContract';
*
* These tests create a Fastify instance with mocked services —
* no network binding, no Prisma, no workspace deps required.
*
* Error responses use the standardised {@link ApiErrorResponse} envelope:
* { error, code, message, statusCode, details? }
*/

type MembershipState = 'active' | 'expired' | 'suspended' | 'invited';

// --- Error envelope helpers (mirrors access-api/src/errors.ts) ---
interface ErrorPayload {
statusCode: number;
code: string;
message: string;
details?: string | Record<string, unknown>;
}

function apiError(payload: ErrorPayload) {
return {
error: payload.code,
code: payload.code,
message: payload.message,
statusCode: payload.statusCode,
...(payload.details !== undefined ? { details: payload.details } : {}),
};
}

// --- Mock service factory ---
function createMockMemberService(overrides: Record<string, jest.Mock> = {}) {
return {
Expand All @@ -20,7 +41,7 @@ function createMockMemberService(overrides: Record<string, jest.Mock> = {}) {
};
}

// --- Build test app with mocked services ---
// --- Build test app with mocked services (uses standard error envelope) ---
async function buildTestApp(mockService: ReturnType<typeof createMockMemberService>): Promise<FastifyInstance> {
const app = Fastify();

Expand All @@ -29,7 +50,7 @@ async function buildTestApp(mockService: ReturnType<typeof createMockMemberServi
return { status: 'ok', timestamp: new Date().toISOString() };
});

// Register routes with mocked service
// Register routes with mocked service + standardised error envelope
app.get('/v1/memberships/:wallet', async (request) => {
const { wallet } = request.params as { wallet: string };
return mockService.getMembershipsByWallet(wallet);
Expand All @@ -39,7 +60,7 @@ async function buildTestApp(mockService: ReturnType<typeof createMockMemberServi
const { wallet } = request.params as { wallet: string };
const result = await mockService.getProfileByWallet(wallet);
if (!result) {
return reply.status(404).send({ error: 'Member not found' });
return reply.status(404).send(apiError({ statusCode: 404, code: 'NOT_FOUND', message: 'Member not found' }));
}
return result;
});
Expand All @@ -51,9 +72,9 @@ async function buildTestApp(mockService: ReturnType<typeof createMockMemberServi
resource: string;
};
if (!body?.wallet || !body?.communityId || !body?.resource) {
return reply.status(400).send({
error: 'Missing required fields: wallet, communityId, resource',
});
return reply.status(400).send(
apiError({ statusCode: 400, code: 'VALIDATION_ERROR', message: 'Missing required fields: wallet, communityId, resource' }),
);
}
return mockService.checkAccess(body);
});
Expand Down Expand Up @@ -153,7 +174,7 @@ describe('GET /v1/members/:wallet', () => {
await app.close();
});

test('returns 404 when member not found', async () => {
test('returns 404 with standardised error envelope when member not found', async () => {
const mock = createMockMemberService({
getProfileByWallet: jest.fn().mockResolvedValue(null),
});
Expand All @@ -165,7 +186,11 @@ describe('GET /v1/members/:wallet', () => {
});

expect(response.statusCode).toBe(404);
expect(response.json().error).toBe('Member not found');
const body = response.json();
expect(body.error).toBe('NOT_FOUND');
expect(body.code).toBe('NOT_FOUND');
expect(body.message).toBe('Member not found');
expect(body.statusCode).toBe(404);

await app.close();
});
Expand Down Expand Up @@ -222,7 +247,7 @@ describe('POST /v1/access/check', () => {
await app.close();
});

test('returns 400 when required fields are missing', async () => {
test('returns 400 with standardised error envelope when required fields are missing', async () => {
const mock = createMockMemberService();
const app = await buildTestApp(mock);

Expand All @@ -233,7 +258,11 @@ describe('POST /v1/access/check', () => {
});

expect(response.statusCode).toBe(400);
expect(response.json().error).toMatch(/Missing required fields/);
const body = response.json();
expect(body.error).toBe('VALIDATION_ERROR');
expect(body.code).toBe('VALIDATION_ERROR');
expect(body.message).toMatch(/Missing required fields/);
expect(body.statusCode).toBe(400);

await app.close();
});
Expand Down
10 changes: 10 additions & 0 deletions packages/sdk-lite/src/errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,23 +9,33 @@ export class GuildPassApiError extends Error {
/** HTTP status code returned by the API (0 when the request never reached it). */
public readonly statusCode: number;

/** Machine-readable error code from the API envelope (e.g. `NOT_FOUND`, `VALIDATION_ERROR`). */
public readonly code?: string;

/** Request path relative to the client's base URL (e.g. `/v1/access/check`). */
public readonly path: string;

/** Raw response body as a string, truncated for safety. Empty when unavailable. */
public readonly responseBody: string;

/** Optional details payload from the API error envelope. */
public readonly details?: string | Record<string, unknown>;

constructor(params: {
statusCode: number;
path: string;
message: string;
responseBody?: string;
code?: string;
details?: string | Record<string, unknown>;
}) {
super(params.message);
this.name = 'GuildPassApiError';
this.statusCode = params.statusCode;
this.code = params.code;
this.path = params.path;
this.responseBody = params.responseBody ?? '';
this.details = params.details;
// Preserve correct prototype chain when targeting older runtimes.
Object.setPrototypeOf(this, GuildPassApiError.prototype);
}
Expand Down
37 changes: 36 additions & 1 deletion packages/sdk-lite/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,15 @@ export interface GuildPassClientOptions {
/** Maximum characters of a response body retained on a {@link GuildPassApiError}. */
const MAX_RESPONSE_BODY_CHARS = 500;

/** Shape of the standardised API error envelope. */
interface ApiErrorEnvelope {
error: string;
code: string;
message: string;
statusCode: number;
details?: string | Record<string, unknown>;
}

export class GuildPassClient {
private readonly baseUrl: string;
private readonly token: string | undefined;
Expand Down Expand Up @@ -187,11 +196,14 @@ export class GuildPassClient {
body.length > MAX_RESPONSE_BODY_CHARS
? `${body.slice(0, MAX_RESPONSE_BODY_CHARS)}…[truncated]`
: body;
const { message, code, details } = parseErrorEnvelope(body);
throw new GuildPassApiError({
statusCode: res.status,
path,
message: buildHttpErrorMessage(res.status, res.statusText, body),
message: message ?? buildHttpErrorMessage(res.status, res.statusText, body),
responseBody: truncated,
code: code ?? String(res.status),
details,
});
}

Expand Down Expand Up @@ -235,6 +247,29 @@ async function safeReadText(res: Response): Promise<string> {
}
}

/** Attempt to parse the standardised API error envelope from a response body. */
function parseErrorEnvelope(body: string): {
message?: string;
code?: string;
details?: string | Record<string, unknown>;
} {
try {
const parsed = JSON.parse(body) as Partial<ApiErrorEnvelope>;
// Only trust the envelope when both `error` and `message` are present
// (avoids treating unrelated JSON as an error envelope).
if (typeof parsed.error === 'string' && typeof parsed.message === 'string') {
return {
message: parsed.message,
code: parsed.error,
details: parsed.details,
};
}
} catch {
// Not JSON — caller will fall back to raw-body message building.
}
return {};
}

function buildHttpErrorMessage(
status: number,
statusText: string,
Expand Down
32 changes: 32 additions & 0 deletions packages/shared-types/src/apiContract.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,9 @@ export const API_CONTRACT = {
{ communityId: 'community-1', state: 'active', expiresAt: null },
],
},
errorResponse: {
404: { error: 'NOT_FOUND', code: 'NOT_FOUND', message: 'Wallet not found', statusCode: 404 },
},
},
memberProfileByWallet: {
method: 'GET',
Expand All @@ -22,6 +25,10 @@ export const API_CONTRACT = {
membership: { state: 'active', expiresAt: null },
roles: ['admin'],
},
errorResponse: {
400: { error: 'VALIDATION_ERROR', code: 'VALIDATION_ERROR', message: 'Validation failed', statusCode: 400, details: 'wallet query parameter is required' },
404: { error: 'NOT_FOUND', code: 'NOT_FOUND', message: 'Member not found', statusCode: 404 },
},
},
accessCheck: {
method: 'POST',
Expand All @@ -38,6 +45,9 @@ export const API_CONTRACT = {
code: 'ALLOW',
membershipState: 'active',
},
errorResponse: {
400: { error: 'VALIDATION_ERROR', code: 'VALIDATION_ERROR', message: 'Validation failed', statusCode: 400, details: 'Missing required fields: wallet' },
},
},
communityMembers: {
method: 'GET',
Expand All @@ -61,7 +71,29 @@ export const API_CONTRACT = {
},
],
},
errorResponse: {
404: { error: 'NOT_FOUND', code: 'NOT_FOUND', message: 'Community not found', statusCode: 404 },
},
},
} as const;

export type ApiContract = typeof API_CONTRACT;

/**
* Standardised error envelope returned by every access-api endpoint.
*
* SDK consumers: catch `GuildPassApiError` to access these fields programmatically.
* API consumers: check `error`/`code` for machine-readable error classification.
*/
export interface ApiErrorResponse {
/** Machine-readable error identifier (e.g. `NOT_FOUND`, `VALIDATION_ERROR`). */
error: string;
/** HTTP status phrase (e.g. `NOT_FOUND`). Mirrors `error` for backward compatibility. */
code: string;
/** Human-readable description suitable for developer logs or UI hints. */
message: string;
/** HTTP status code (e.g. 404). */
statusCode: number;
/** Optional machine- or human-readable detail payload. */
details?: string | Record<string, unknown>;
}
Loading