diff --git a/.env.example b/.env.example index 07d25c6..07aa230 100644 --- a/.env.example +++ b/.env.example @@ -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="" diff --git a/README.md b/README.md index a618b35..04b4e5e 100644 --- a/README.md +++ b/README.md @@ -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) diff --git a/apps/access-api/jest.config.js b/apps/access-api/jest.config.js new file mode 100644 index 0000000..17d8ced --- /dev/null +++ b/apps/access-api/jest.config.js @@ -0,0 +1,6 @@ +module.exports = { + preset: 'ts-jest', + testEnvironment: 'node', + roots: ['/src'], + testMatch: ['**/*.test.ts'], +}; diff --git a/apps/access-api/src/config.test.ts b/apps/access-api/src/config.test.ts new file mode 100644 index 0000000..86038b3 --- /dev/null +++ b/apps/access-api/src/config.test.ts @@ -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'); + }); +}); diff --git a/apps/access-api/src/config.ts b/apps/access-api/src/config.ts new file mode 100644 index 0000000..a547dab --- /dev/null +++ b/apps/access-api/src/config.ts @@ -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(); diff --git a/apps/access-api/src/index.ts b/apps/access-api/src/index.ts index 4d52467..cdaf243 100644 --- a/apps/access-api/src/index.ts +++ b/apps/access-api/src/index.ts @@ -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 }); @@ -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) => { diff --git a/apps/access-api/src/services/prisma.ts b/apps/access-api/src/services/prisma.ts index 0d5d63d..2df0881 100644 --- a/apps/access-api/src/services/prisma.ts +++ b/apps/access-api/src/services/prisma.ts @@ -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; }