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
20 changes: 18 additions & 2 deletions .env.example
Original file line number Diff line number Diff line change
@@ -1,8 +1,24 @@
# ── Required ────────────────────────────────────────────────────────────────────

# PostgreSQL connection string (required)
DATABASE_URL="postgresql://postgres:postgres@localhost:5432/guildpass"

# Redis connection string (required)
REDIS_URL="redis://localhost:6379"

# ── Optional (safe defaults shown) ──────────────────────────────────────────────

# Port the HTTP server listens on (default: 3000, range 1–65535)
PORT=3000

# Runtime environment: development | test | production (default: development)
NODE_ENV=development

# Contracts (fill after deployment)
MEMBERSHIP_NFT_ADDRESS=""
# ── Contracts (fill after deployment) ───────────────────────────────────────────

# EVM chain ID the API operates on (default: 31337 for local Anvil/Hardhat)
CHAIN_ID=31337

# Deployed MembershipNFT contract address (0x-prefixed, 40 hex chars). Leave
# empty to disable on-chain checks during local development.
MEMBERSHIP_NFT_ADDRESS=""
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -141,6 +141,8 @@ This project uses ESLint to maintain code quality.

See [`.env.example`](./.env.example) for all required variables.

All environment variables are validated at startup by `apps/access-api/src/config.ts`. The server will refuse to start and print a clear error if any required variable is missing or malformed (invalid URL, port out of range, bad chain ID, or invalid contract address).

---

## Deferred Areas (Intentionally Not Implemented)
Expand Down
6 changes: 6 additions & 0 deletions apps/access-api/jest.config.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
module.exports = {
preset: 'ts-jest',
testEnvironment: 'node',
roots: ['<rootDir>/src'],
testMatch: ['**/*.test.ts'],
};
72 changes: 72 additions & 0 deletions apps/access-api/src/config.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
import { loadConfig } from '../config';

const VALID: NodeJS.ProcessEnv = {
DATABASE_URL: 'postgresql://user:pass@localhost:5432/db',
REDIS_URL: 'redis://localhost:6379',
PORT: '3000',
NODE_ENV: 'test',
CHAIN_ID: '1',
MEMBERSHIP_NFT_ADDRESS: '',
};

describe('loadConfig', () => {
it('returns a valid config for a complete env', () => {
const cfg = loadConfig(VALID);
expect(cfg.databaseUrl).toBe(VALID.DATABASE_URL);
expect(cfg.redisUrl).toBe(VALID.REDIS_URL);
expect(cfg.port).toBe(3000);
expect(cfg.nodeEnv).toBe('test');
expect(cfg.chainId).toBe(1);
expect(cfg.membershipNftAddress).toBeNull();
});

it('applies defaults for PORT, NODE_ENV, and CHAIN_ID when omitted', () => {
const cfg = loadConfig({ DATABASE_URL: VALID.DATABASE_URL, REDIS_URL: VALID.REDIS_URL });
expect(cfg.port).toBe(3000);
expect(cfg.nodeEnv).toBe('development');
expect(cfg.chainId).toBe(31337);
});

it('parses a valid MEMBERSHIP_NFT_ADDRESS', () => {
const cfg = loadConfig({ ...VALID, MEMBERSHIP_NFT_ADDRESS: '0xabc123AB23456789abcdef01234567890ABCDEF01'.slice(0, 42) });
// any valid 42-char address
const addr = '0x' + 'a'.repeat(40);
const cfg2 = loadConfig({ ...VALID, MEMBERSHIP_NFT_ADDRESS: addr });
expect(cfg2.membershipNftAddress).toBe(addr);
});

it('throws when DATABASE_URL is missing', () => {
const env = { ...VALID };
delete env.DATABASE_URL;
expect(() => loadConfig(env)).toThrow('DATABASE_URL');
});

it('throws when REDIS_URL is missing', () => {
const env = { ...VALID };
delete env.REDIS_URL;
expect(() => loadConfig(env)).toThrow('REDIS_URL');
});

it('throws for an invalid DATABASE_URL', () => {
expect(() => loadConfig({ ...VALID, DATABASE_URL: 'not-a-url' })).toThrow('DATABASE_URL');
});

it('throws for an invalid REDIS_URL', () => {
expect(() => loadConfig({ ...VALID, REDIS_URL: 'not-a-url' })).toThrow('REDIS_URL');
});

it('throws for a PORT out of range', () => {
expect(() => loadConfig({ ...VALID, PORT: '99999' })).toThrow('PORT');
expect(() => loadConfig({ ...VALID, PORT: '0' })).toThrow('PORT');
expect(() => loadConfig({ ...VALID, PORT: 'abc' })).toThrow('PORT');
});

it('throws for an invalid CHAIN_ID', () => {
expect(() => loadConfig({ ...VALID, CHAIN_ID: '0' })).toThrow('CHAIN_ID');
expect(() => loadConfig({ ...VALID, CHAIN_ID: 'mainnet' })).toThrow('CHAIN_ID');
});

it('throws for a malformed MEMBERSHIP_NFT_ADDRESS', () => {
expect(() => loadConfig({ ...VALID, MEMBERSHIP_NFT_ADDRESS: '0xinvalid' })).toThrow('MEMBERSHIP_NFT_ADDRESS');
});
});
65 changes: 65 additions & 0 deletions apps/access-api/src/config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
const EVM_ADDRESS_RE = /^0x[0-9a-fA-F]{40}$/;

function requireEnv(env: NodeJS.ProcessEnv, key: string): string {
const value = env[key];
if (!value) throw new Error(`Missing required environment variable: ${key}`);
return value;
}

function parseUrl(key: string, value: string): string {
try {
new URL(value);
} catch {
throw new Error(`Invalid URL for ${key}: "${value}"`);
}
return value;
}

function parsePort(key: string, raw: string): number {
const port = parseInt(raw, 10);
if (isNaN(port) || port < 1 || port > 65535) {
throw new Error(`Invalid port for ${key}: "${raw}"`);
}
return port;
}

function parseChainId(raw: string): number {
const id = parseInt(raw, 10);
if (isNaN(id) || id < 1) throw new Error(`Invalid CHAIN_ID: "${raw}"`);
return id;
}

function parseContractAddress(key: string, value: string): string {
if (!EVM_ADDRESS_RE.test(value)) {
throw new Error(`Invalid EVM address for ${key}: "${value}"`);
}
return value;
}

export interface Config {
databaseUrl: string;
redisUrl: string;
port: number;
nodeEnv: string;
membershipNftAddress: string | null;
chainId: number;
}

export function loadConfig(env: NodeJS.ProcessEnv = process.env): Config {
const databaseUrl = parseUrl('DATABASE_URL', requireEnv(env, 'DATABASE_URL'));
const redisUrl = parseUrl('REDIS_URL', requireEnv(env, 'REDIS_URL'));
const port = parsePort('PORT', env.PORT ?? '3000');
const nodeEnv = env.NODE_ENV ?? 'development';
const chainId = parseChainId(env.CHAIN_ID ?? '31337');

const rawAddress = env.MEMBERSHIP_NFT_ADDRESS;
const membershipNftAddress =
rawAddress && rawAddress !== ''
? parseContractAddress('MEMBERSHIP_NFT_ADDRESS', rawAddress)
: null;

return { databaseUrl, redisUrl, port, nodeEnv, chainId, membershipNftAddress };
}

// Singleton — evaluated once at startup; throws if config is invalid.
export const config: Config = loadConfig();
4 changes: 2 additions & 2 deletions apps/access-api/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import Fastify from 'fastify';
import swagger from '@fastify/swagger';
import swaggerUi from '@fastify/swagger-ui';
import { config } from './config';
import { registerRoutes } from './routes';

const server = Fastify({ logger: true });
Expand All @@ -22,8 +23,7 @@ async function main() {

registerRoutes(server);

const port = parseInt(process.env.PORT || '3000', 10);
await server.listen({ port, host: '0.0.0.0' });
await server.listen({ port: config.port, host: '0.0.0.0' });
}

main().catch((err) => {
Expand Down
3 changes: 2 additions & 1 deletion apps/access-api/src/services/prisma.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
import { PrismaClient } from '@prisma/client';
import { config } from '../config';

let prisma: PrismaClient | null = null;

export function getPrisma(): PrismaClient {
if (!prisma) {
prisma = new PrismaClient();
prisma = new PrismaClient({ datasources: { db: { url: config.databaseUrl } } });
}
return prisma;
}