diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..cf455ff --- /dev/null +++ b/.env.example @@ -0,0 +1,69 @@ +# codeforphilly-rewrite — local development environment +# Copy this file to .env and fill in values for your machine. +# Lines starting with # are comments. Never commit your .env. + +# --------------------------------------------------------------------------- +# Core +# --------------------------------------------------------------------------- + +# TCP port the Fastify API listens on. +PORT=3001 + +# Runtime mode. Controls logger format, cookie Secure flag, CORS permissiveness. +# One of: development | test | production +NODE_ENV=development + +# --------------------------------------------------------------------------- +# Public gitsheets data repo +# --------------------------------------------------------------------------- + +# Absolute path (or relative from repo root) to the codeforphilly-data working tree. +# Clone https://github.com/CodeForPhilly/codeforphilly-data-snapshot as a sibling: +# git clone https://github.com/CodeForPhilly/codeforphilly-data-snapshot ../codeforphilly-data +CFP_DATA_REPO_PATH=../codeforphilly-data + +# Git remote URL to push public data commits to. Optional in dev; required in prod. +# This is the production private data repo — the public snapshot is published separately. +# CFP_DATA_REMOTE=git@github.com:CodeForPhilly/codeforphilly-data.git + +# --------------------------------------------------------------------------- +# Private storage +# --------------------------------------------------------------------------- + +# Which private-storage backend to use. Use 'filesystem' for local dev. +# One of: filesystem | s3 +STORAGE_BACKEND=filesystem + +# Filesystem backend: path to the directory holding profiles.jsonl and passwords.jsonl. +# Required when STORAGE_BACKEND=filesystem. +CFP_PRIVATE_STORAGE_PATH=./private-storage + +# S3 backend — only needed when STORAGE_BACKEND=s3. +# S3_ENDPOINT=https://s3.us-east-1.amazonaws.com +# S3_BUCKET=cfp-private-storage +# S3_REGION=us-east-1 +# S3_ACCESS_KEY_ID=AKIAIOSFODNN7EXAMPLE +# S3_SECRET_ACCESS_KEY=wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY + +# --------------------------------------------------------------------------- +# Auth +# --------------------------------------------------------------------------- + +# GitHub OAuth app credentials. Create one at https://github.com/settings/developers. +# Callback URL for dev: http://localhost:3001/api/auth/github/callback +# GITHUB_OAUTH_CLIENT_ID=Iv1.a1b2c3d4e5f6g7h8 +# GITHUB_OAUTH_CLIENT_SECRET=secret_abc123 + +# HS256 signing key for session JWTs. Generate a random string ≥ 32 chars. +# In production use a securely-generated secret; in dev any 32+ char string works. +CFP_JWT_SIGNING_KEY=change-me-to-a-random-string-at-least-32-chars + +# --------------------------------------------------------------------------- +# SAML IdP (Slack integration) — only needed in production +# --------------------------------------------------------------------------- + +# PEM-encoded private key for the SAML IdP certificate. +# SAML_PRIVATE_KEY=-----BEGIN RSA PRIVATE KEY-----\n...\n-----END RSA PRIVATE KEY----- + +# PEM-encoded certificate matching SAML_PRIVATE_KEY. +# SAML_CERTIFICATE=-----BEGIN CERTIFICATE-----\n...\n-----END CERTIFICATE----- diff --git a/apps/api/package.json b/apps/api/package.json index acdc4e6..12497d4 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -14,8 +14,16 @@ "dependencies": { "@aws-sdk/client-s3": "^3.1048.0", "@cfp/shared": "^0.0.0", + "@fastify/cookie": "^11.0.2", + "@fastify/cors": "^11.2.0", + "@fastify/env": "^6.0.0", + "@fastify/rate-limit": "^10.3.0", + "@fastify/swagger": "^9.7.0", + "@fastify/swagger-ui": "^5.2.6", "fastify": "^5.8.5", - "gitsheets": "^1.0.3" + "gitsheets": "^1.0.3", + "uuidv7": "^1.2.1", + "zod": "^4.4.3" }, "devDependencies": { "@types/node": "^25.8.0", diff --git a/apps/api/src/app.ts b/apps/api/src/app.ts new file mode 100644 index 0000000..460a55c --- /dev/null +++ b/apps/api/src/app.ts @@ -0,0 +1,133 @@ +/** + * buildApp() — wires the Fastify application. + * + * Plugin ordering per plans/api-skeleton.md#plugin-order: + * 1. @fastify/env → validates env; populates fastify.config + * 2. @fastify/cors → CORS for dev SPA proxy + future cross-origin consumers + * 3. @fastify/cookie → cookie parsing for session JWTs (auth-jwt-substrate plan) + * 4. trace-id plugin → UUIDv7 traceId on every request + * 5. setErrorHandler → single error mapper for all throws + * 6. store plugin → decorates fastify.store from bootStores() + * 7. rate-limit plugin → in-memory counters keyed per-IP + per-account + * 8. idempotency plugin → in-memory map keyed by personId+key + * 9. @fastify/swagger → OpenAPI 3.1 doc generation + * 10. @fastify/swagger-ui → Swagger UI at /api/_docs + * 11. routes → registered last after all plumbing + * + * Tests can call buildApp() with overrideEnv to inject a test environment + * without requiring real filesystem paths. + */ +import Fastify, { type FastifyInstance, type FastifyServerOptions } from 'fastify'; +import fastifyEnv from '@fastify/env'; +import fastifyCors from '@fastify/cors'; +import fastifyCookie from '@fastify/cookie'; +import fastifySwagger from '@fastify/swagger'; +import fastifySwaggerUi from '@fastify/swagger-ui'; + +import { envJsonSchema, type Env } from './env.js'; +import { mapError } from './lib/errors.js'; +import traceIdPlugin from './plugins/trace-id.js'; +import storePlugin from './plugins/store.js'; +import rateLimitPlugin from './plugins/rate-limit.js'; +import idempotencyPlugin from './plugins/idempotency.js'; +import { healthRoutes } from './routes/health.js'; + +declare module 'fastify' { + interface FastifyInstance { + config: Env; + } +} + +export interface BuildAppOptions { + /** + * Override environment variables for testing. + * When provided, @fastify/env still validates the schema but reads from this + * object instead of process.env. + */ + overrideEnv?: Partial>; + /** Extra Fastify server options (e.g. logger: false for tests). */ + serverOptions?: FastifyServerOptions; +} + +export async function buildApp(opts: BuildAppOptions = {}): Promise { + const { overrideEnv, serverOptions = {} } = opts; + + // Default logger: pretty in dev, JSON in prod. + // Callers can override via serverOptions.logger. + const defaultLogger: FastifyServerOptions['logger'] = + process.env['NODE_ENV'] === 'production' + ? true + : { transport: { target: 'pino-pretty' } }; + + const fastify = Fastify({ + logger: defaultLogger, + pluginTimeout: 30_000, // gitsheets boot runs git operations which can be slow + ...serverOptions, + genReqId: () => '', // traceId plugin handles IDs + }); + + // ----- 1. Env validation ----- + await fastify.register(fastifyEnv, { + schema: envJsonSchema, + data: overrideEnv ?? process.env, + dotenv: false, + }); + + // ----- 2. CORS ----- + await fastify.register(fastifyCors, { + origin: fastify.config.NODE_ENV === 'production' ? false : true, + credentials: true, + }); + + // ----- 3. Cookie parsing ----- + await fastify.register(fastifyCookie); + + // ----- 4. Trace ID (UUIDv7 on every request) ----- + await fastify.register(traceIdPlugin); + + // ----- 5. Error mapper ----- + fastify.setErrorHandler(mapError); + + // ----- 6. Store (boots gitsheets + private-store) ----- + await fastify.register(storePlugin); + + // ----- 7. Rate limiting ----- + await fastify.register(rateLimitPlugin); + + // ----- 8. Idempotency ----- + await fastify.register(idempotencyPlugin); + + // ----- 9-10. OpenAPI / Swagger UI ----- + await fastify.register(fastifySwagger, { + openapi: { + openapi: '3.1.0', + info: { + title: 'CodeForPhilly API', + description: 'The codeforphilly.org API. See specs/api/ for the authoritative spec.', + version: '1.0.0', + }, + servers: [{ url: '/api', description: 'API base' }], + tags: [{ name: 'health', description: 'Health check' }], + }, + prefix: '/api', + }); + + await fastify.register(fastifySwaggerUi, { + routePrefix: '/api/_docs', + uiConfig: { + docExpansion: 'list', + deepLinking: false, + }, + }); + + // ----- 11. Routes ----- + await fastify.register(healthRoutes); + + // Serve the OpenAPI JSON at the spec-mandated path /api/_openapi.json + // (swagger-ui also exposes it at /api/_docs/json, but the spec names this path) + fastify.get('/api/_openapi.json', { schema: { hide: true } }, (_req, reply) => { + return reply.send(fastify.swagger()); + }); + + return fastify; +} diff --git a/apps/api/src/env.ts b/apps/api/src/env.ts new file mode 100644 index 0000000..d9c6d3f --- /dev/null +++ b/apps/api/src/env.ts @@ -0,0 +1,75 @@ +/** + * Environment schema and config type. + * + * This is the ONLY place that reads process.env. All other modules read + * fastify.config. after @fastify/env has validated and populated it. + */ +import { z } from 'zod'; + +export const EnvSchema = z.object({ + /** TCP port the Fastify server listens on. */ + PORT: z.coerce.number().default(3001), + /** Runtime mode — controls logger format, cookie Secure flag, etc. */ + NODE_ENV: z.enum(['development', 'test', 'production']).default('development'), + /** Absolute path to the gitsheets public data repo working tree. */ + CFP_DATA_REPO_PATH: z.string(), + /** Git remote URL to push public data commits to (optional in dev). */ + CFP_DATA_REMOTE: z.string().optional(), + /** Which private-storage backend to use. */ + STORAGE_BACKEND: z.enum(['s3', 'filesystem']), + /** Filesystem backend: absolute path to the private-storage directory. */ + CFP_PRIVATE_STORAGE_PATH: z.string().optional(), + /** S3 endpoint URL (required when STORAGE_BACKEND=s3). */ + S3_ENDPOINT: z.string().optional(), + /** S3 bucket name (required when STORAGE_BACKEND=s3). */ + S3_BUCKET: z.string().optional(), + /** S3 region (required when STORAGE_BACKEND=s3). */ + S3_REGION: z.string().optional(), + /** S3 access key ID (required when STORAGE_BACKEND=s3). */ + S3_ACCESS_KEY_ID: z.string().optional(), + /** S3 secret access key (required when STORAGE_BACKEND=s3). */ + S3_SECRET_ACCESS_KEY: z.string().optional(), + /** GitHub OAuth app client ID. */ + GITHUB_OAUTH_CLIENT_ID: z.string().optional(), + /** GitHub OAuth app client secret. */ + GITHUB_OAUTH_CLIENT_SECRET: z.string().optional(), + /** HS256 signing key for session JWTs — min 32 chars in production. */ + CFP_JWT_SIGNING_KEY: z.string().min(1), + /** SAML IdP private key (PEM) for the Slack SAML integration. */ + SAML_PRIVATE_KEY: z.string().optional(), + /** SAML IdP certificate (PEM) for the Slack SAML integration. */ + SAML_CERTIFICATE: z.string().optional(), +}); + +export type Env = z.infer; + +/** + * JSON Schema representation of EnvSchema for @fastify/env. + * @fastify/env expects a JSON Schema object, not a Zod schema. + */ +export const envJsonSchema = { + type: 'object', + required: ['CFP_DATA_REPO_PATH', 'STORAGE_BACKEND', 'CFP_JWT_SIGNING_KEY'], + properties: { + PORT: { type: 'number', default: 3001 }, + NODE_ENV: { + type: 'string', + enum: ['development', 'test', 'production'], + default: 'development', + }, + CFP_DATA_REPO_PATH: { type: 'string' }, + CFP_DATA_REMOTE: { type: 'string' }, + STORAGE_BACKEND: { type: 'string', enum: ['s3', 'filesystem'] }, + CFP_PRIVATE_STORAGE_PATH: { type: 'string' }, + S3_ENDPOINT: { type: 'string' }, + S3_BUCKET: { type: 'string' }, + S3_REGION: { type: 'string' }, + S3_ACCESS_KEY_ID: { type: 'string' }, + S3_SECRET_ACCESS_KEY: { type: 'string' }, + GITHUB_OAUTH_CLIENT_ID: { type: 'string' }, + GITHUB_OAUTH_CLIENT_SECRET: { type: 'string' }, + CFP_JWT_SIGNING_KEY: { type: 'string', minLength: 1 }, + SAML_PRIVATE_KEY: { type: 'string' }, + SAML_CERTIFICATE: { type: 'string' }, + }, +} as const; diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts index e6517ef..f6fd63e 100644 --- a/apps/api/src/index.ts +++ b/apps/api/src/index.ts @@ -1,20 +1,21 @@ -import Fastify from 'fastify'; +/** + * API entry point. + * + * Calls buildApp() to construct the Fastify instance with all plugins and + * routes registered, then starts listening. All configuration is handled + * inside buildApp() via @fastify/env — this file reads nothing from process.env + * directly. + */ +import { buildApp } from './app.js'; -const PORT = Number(process.env.PORT ?? 3001); -const HOST = process.env.HOST ?? '0.0.0.0'; +const fastify = await buildApp(); -const app = Fastify({ - logger: - process.env.NODE_ENV === 'production' - ? true - : { transport: { target: 'pino-pretty' } }, -}); - -app.get('/api/health', () => ({ status: 'ok' })); +const PORT = Number(process.env['PORT'] ?? 3001); +const HOST = process.env['HOST'] ?? '0.0.0.0'; try { - await app.listen({ port: PORT, host: HOST }); + await fastify.listen({ port: PORT, host: HOST }); } catch (err) { - app.log.error(err); + fastify.log.error(err); process.exit(1); } diff --git a/apps/api/src/lib/errors.ts b/apps/api/src/lib/errors.ts new file mode 100644 index 0000000..4fb7930 --- /dev/null +++ b/apps/api/src/lib/errors.ts @@ -0,0 +1,191 @@ +/** + * Custom API error classes and the Fastify error mapper. + * + * All errors flow through fastify.setErrorHandler() which calls mapError(). + * Gitsheets exception classes (GitsheetsError, ValidationError, etc.) are + * caught here and mapped to the documented error envelope per + * specs/api/conventions.md#error. + */ +import type { FastifyError, FastifyReply, FastifyRequest } from 'fastify'; +import { + ConfigError, + GitsheetsError, + IndexError, + NotFoundError, + PathTemplateError, + RefError, + TransactionError, + ValidationError as GitsheetsValidationError, +} from 'gitsheets'; +import { errorResponse } from './response.js'; + +// --------------------------------------------------------------------------- +// Custom error classes +// --------------------------------------------------------------------------- + +/** Thrown by the rate-limit plugin when a client exceeds their cap. */ +export class RateLimitedError extends Error { + readonly retryAfter: number; + constructor(retryAfter: number) { + super('Rate limit exceeded'); + this.name = 'RateLimitedError'; + this.retryAfter = retryAfter; + } +} + +/** Thrown when a request fails our own validation (422). */ +export class ApiValidationError extends Error { + readonly fields?: Record; + constructor(message: string, fields?: Record) { + super(message); + this.name = 'ApiValidationError'; + this.fields = fields; + } +} + +/** Thrown when the caller is not authenticated (401). */ +export class UnauthenticatedError extends Error { + constructor(message = 'Authentication required') { + super(message); + this.name = 'UnauthenticatedError'; + } +} + +/** Thrown when the caller is authenticated but not authorized (403). */ +export class ForbiddenError extends Error { + constructor(message = 'Forbidden') { + super(message); + this.name = 'ForbiddenError'; + } +} + +/** Thrown when a resource is not found (404). */ +export class ApiNotFoundError extends Error { + constructor(message = 'Not found') { + super(message); + this.name = 'ApiNotFoundError'; + } +} + +/** Thrown on unique-constraint / slug conflicts (409). */ +export class ConflictError extends Error { + constructor(message: string) { + super(message); + this.name = 'ConflictError'; + } +} + +// --------------------------------------------------------------------------- +// Error mapper +// --------------------------------------------------------------------------- + +type TraceId = string | undefined; + +/** + * Map any thrown value to an HTTP status + error envelope. + * + * Called from fastify.setErrorHandler(). Never leaks internal error details + * for 500s — only logs them with the traceId as the link. + */ +export function mapError( + err: unknown, + req: FastifyRequest, + reply: FastifyReply, +): void { + const traceId: TraceId = (req as FastifyRequest & { traceId?: string }).traceId; + + // --- Our own API errors --- + + if (err instanceof RateLimitedError) { + void reply + .code(429) + .header('Retry-After', String(err.retryAfter)) + .send(errorResponse('rate_limited', 'Rate limit exceeded', traceId)); + return; + } + + if (err instanceof ApiValidationError) { + void reply + .code(422) + .send(errorResponse('validation_failed', err.message, traceId, err.fields)); + return; + } + + if (err instanceof UnauthenticatedError) { + void reply.code(401).send(errorResponse('unauthenticated', err.message, traceId)); + return; + } + + if (err instanceof ForbiddenError) { + void reply.code(403).send(errorResponse('forbidden', err.message, traceId)); + return; + } + + if (err instanceof ApiNotFoundError) { + void reply.code(404).send(errorResponse('not_found', err.message, traceId)); + return; + } + + if (err instanceof ConflictError) { + void reply.code(409).send(errorResponse('conflict', err.message, traceId)); + return; + } + + // --- Gitsheets errors --- + + if (err instanceof NotFoundError) { + void reply.code(404).send(errorResponse('not_found', 'Resource not found', traceId)); + return; + } + + if (err instanceof GitsheetsValidationError) { + void reply.code(422).send(errorResponse('validation_failed', err.message, traceId)); + return; + } + + if (err instanceof TransactionError) { + req.log.error({ err, traceId }, 'gitsheets transaction error'); + void reply.code(500).send(errorResponse('internal_error', 'An internal error occurred', traceId)); + return; + } + + if (err instanceof IndexError || err instanceof RefError || err instanceof PathTemplateError) { + req.log.error({ err, traceId }, 'gitsheets internal error'); + void reply.code(500).send(errorResponse('internal_error', 'An internal error occurred', traceId)); + return; + } + + if (err instanceof ConfigError) { + req.log.error({ err, traceId }, 'gitsheets config error'); + void reply.code(500).send(errorResponse('internal_error', 'An internal error occurred', traceId)); + return; + } + + if (err instanceof GitsheetsError) { + req.log.error({ err, traceId }, 'gitsheets error'); + void reply.code(500).send(errorResponse('internal_error', 'An internal error occurred', traceId)); + return; + } + + // --- Fastify validation errors (schema-level, 400) --- + + const fastifyErr = err as FastifyError; + if (fastifyErr.statusCode === 400 && fastifyErr.validation) { + const fields: Record = {}; + if (Array.isArray(fastifyErr.validation)) { + for (const v of fastifyErr.validation) { + const field = String((v as { instancePath?: string }).instancePath ?? 'unknown').replace(/^\//, ''); + fields[field || 'unknown'] = String((v as { message?: string }).message ?? 'invalid'); + } + } + void reply + .code(422) + .send(errorResponse('validation_failed', 'Validation failed', traceId, fields)); + return; + } + + // --- Unknown / unhandled errors → 500, never leak details --- + + req.log.error({ err, traceId }, 'unhandled error'); + void reply.code(500).send(errorResponse('internal_error', 'An internal error occurred', traceId)); +} diff --git a/apps/api/src/lib/response.ts b/apps/api/src/lib/response.ts new file mode 100644 index 0000000..745f5b0 --- /dev/null +++ b/apps/api/src/lib/response.ts @@ -0,0 +1,81 @@ +/** + * Response envelope helpers. + * + * Every API endpoint returns one of these shapes per specs/api/conventions.md. + * Routes import ok() or paginated() and return the result; the error shape is + * produced by the error mapper in setErrorHandler. + */ + +export interface ResponseMeta { + readonly timestamp: string; +} + +export interface PaginationMeta extends ResponseMeta { + readonly page: number; + readonly perPage: number; + readonly totalItems: number; + readonly totalPages: number; +} + +export interface SuccessResponse { + readonly success: true; + readonly data: T; + readonly metadata: ResponseMeta; +} + +export interface PaginatedResponse { + readonly success: true; + readonly data: T[]; + readonly metadata: PaginationMeta; +} + +export interface ErrorDetail { + readonly code: string; + readonly message: string; + readonly traceId?: string; + readonly fields?: Record; +} + +export interface ErrorResponse { + readonly success: false; + readonly error: ErrorDetail; + readonly metadata: ResponseMeta; +} + +/** Wrap a single data value in the success envelope. */ +export function ok(data: T, meta?: Partial): SuccessResponse { + return { + success: true, + data, + metadata: { + timestamp: meta?.timestamp ?? new Date().toISOString(), + }, + }; +} + +/** Wrap a page of results in the paginated success envelope. */ +export function paginated(data: T[], pagination: Omit): PaginatedResponse { + return { + success: true, + data, + metadata: { + timestamp: new Date().toISOString(), + ...pagination, + }, + }; +} + +/** Build the error envelope. Called by the error mapper. */ +export function errorResponse( + code: string, + message: string, + traceId?: string, + fields?: Record, +): ErrorResponse { + const error: ErrorDetail = { code, message, ...(traceId ? { traceId } : {}), ...(fields ? { fields } : {}) }; + return { + success: false, + error, + metadata: { timestamp: new Date().toISOString() }, + }; +} diff --git a/apps/api/src/plugins/idempotency.ts b/apps/api/src/plugins/idempotency.ts new file mode 100644 index 0000000..23b6855 --- /dev/null +++ b/apps/api/src/plugins/idempotency.ts @@ -0,0 +1,92 @@ +/** + * Idempotency-key plugin. + * + * Mutating endpoints may send an `Idempotency-Key` header. If the API has + * already processed a request with the same (personId, key) pair within 24h, + * it replays the cached response without re-running the handler. + * + * In-memory by design (single replica). Per specs/api/conventions.md#idempotency. + * + * Usage from route handlers: + * const cached = fastify.idempotency.check(personId, key); + * if (cached) return reply.status(cached.status).send(cached.body); + * // ... run handler ... + * fastify.idempotency.store(personId, key, { status: 201, body: result }); + * + * The hook on preHandler only sets up the check helper; actual cache reads and + * writes happen inside the route handler so each route controls the scope. + */ +import type { FastifyInstance } from 'fastify'; +import fp from 'fastify-plugin'; + +const TTL_MS = 24 * 60 * 60 * 1000; // 24 hours + +export interface CachedResponse { + readonly status: number; + readonly body: unknown; + readonly cachedAt: number; +} + +interface IdempotencyStore { + /** + * Check if a response is cached for this (personId, key) pair. + * Returns undefined if not cached or if the cache entry has expired. + */ + check(personId: string, key: string): CachedResponse | undefined; + + /** + * Cache the response for this (personId, key) pair. + */ + store(personId: string, key: string, response: Omit): void; +} + +async function idempotencyPlugin(fastify: FastifyInstance): Promise { + const cache = new Map(); + + function cacheKey(personId: string, key: string): string { + return `${personId}:${key}`; + } + + function evictExpired(): void { + const now = Date.now(); + for (const [k, entry] of cache) { + if (now - entry.cachedAt >= TTL_MS) { + cache.delete(k); + } + } + } + + const idempotency: IdempotencyStore = { + check(personId, key) { + const entry = cache.get(cacheKey(personId, key)); + if (!entry) return undefined; + if (Date.now() - entry.cachedAt >= TTL_MS) { + cache.delete(cacheKey(personId, key)); + return undefined; + } + return entry; + }, + + store(personId, key, response) { + // Evict stale entries periodically (on every store call) + evictExpired(); + cache.set(cacheKey(personId, key), { ...response, cachedAt: Date.now() }); + }, + }; + + fastify.decorate('idempotency', idempotency); +} + +declare module 'fastify' { + interface FastifyInstance { + idempotency: { + check(personId: string, key: string): CachedResponse | undefined; + store(personId: string, key: string, response: Omit): void; + }; + } +} + +export default fp(idempotencyPlugin, { + name: 'idempotency', + fastify: '5.x', +}); diff --git a/apps/api/src/plugins/rate-limit.ts b/apps/api/src/plugins/rate-limit.ts new file mode 100644 index 0000000..29b1ea0 --- /dev/null +++ b/apps/api/src/plugins/rate-limit.ts @@ -0,0 +1,110 @@ +/** + * In-memory rate-limit plugin. + * + * Enforces per-IP and per-account caps per specs/api/conventions.md#rate-limiting: + * - Unauthenticated reads: 60 req / min / IP + * - Authenticated reads: 300 req / min / account + * - Writes: 30 req / min / account + * - Auth endpoints: 10 req / min / IP + * + * Counters are reset on restart (intentional — single replica, civic scale). + * Exceeded limit → RateLimitedError(retryAfterSeconds). + * + * The error mapper in errors.ts converts RateLimitedError to 429 + Retry-After. + */ +import type { FastifyInstance, FastifyRequest } from 'fastify'; +import fp from 'fastify-plugin'; +import { RateLimitedError } from '../lib/errors.js'; + +interface BucketEntry { + count: number; + windowStart: number; +} + +const WINDOW_MS = 60_000; // 1 minute + +function getOrCreate(map: Map, key: string): BucketEntry { + let entry = map.get(key); + if (!entry) { + entry = { count: 0, windowStart: Date.now() }; + map.set(key, entry); + } + return entry; +} + +function check(map: Map, key: string, limit: number): void { + const now = Date.now(); + const entry = getOrCreate(map, key); + + if (now - entry.windowStart >= WINDOW_MS) { + // New window + entry.count = 1; + entry.windowStart = now; + return; + } + + entry.count += 1; + if (entry.count > limit) { + const retryAfter = Math.ceil((WINDOW_MS - (now - entry.windowStart)) / 1000); + throw new RateLimitedError(retryAfter); + } +} + +function clientIp(request: FastifyRequest): string { + const forwarded = request.headers['x-forwarded-for']; + if (typeof forwarded === 'string') { + const first = forwarded.split(',')[0]; + return (first ?? '').trim(); + } + return request.socket?.remoteAddress ?? 'unknown'; +} + +const WRITE_METHODS = new Set(['POST', 'PUT', 'PATCH', 'DELETE']); +const AUTH_PATH_PREFIX = '/api/auth'; + +async function rateLimitPlugin(fastify: FastifyInstance): Promise { + const ipBuckets = new Map(); + const accountBuckets = new Map(); + + fastify.addHook('onRequest', (request, _reply, done) => { + try { + const ip = clientIp(request); + const isWrite = WRITE_METHODS.has(request.method); + const isAuthEndpoint = request.url.startsWith(AUTH_PATH_PREFIX); + + if (isAuthEndpoint) { + // Auth endpoints: 10 req / min / IP + check(ipBuckets, `auth:${ip}`, 10); + } else if (isWrite) { + // Writes: keyed by account if we have one, otherwise IP + // (Account ID is not available until auth lands; use IP for now) + check(ipBuckets, `write:${ip}`, 30); + } else { + // Reads: unauthenticated=60/min/IP, authenticated=300/min/account + // Account check will be wired by auth-jwt-substrate plan + check(ipBuckets, `read:${ip}`, 60); + } + } catch (err) { + done(err as Error); + return; + } + done(); + }); + + // Expose the buckets for testing + fastify.decorate('rateLimitBuckets', { ip: ipBuckets, account: accountBuckets }); +} + +declare module 'fastify' { + interface FastifyInstance { + rateLimitBuckets: { + ip: Map; + account: Map; + }; + } +} + +export default fp(rateLimitPlugin, { + name: 'rate-limit', + fastify: '5.x', +}); diff --git a/apps/api/src/plugins/store.ts b/apps/api/src/plugins/store.ts new file mode 100644 index 0000000..020556d --- /dev/null +++ b/apps/api/src/plugins/store.ts @@ -0,0 +1,40 @@ +/** + * Store plugin. + * + * Decorates fastify.store with the booted dual-store instance. + * Called after @fastify/env so fastify.config is available. + * + * Per the plan's plugin ordering: registered after env, cors, cookie, trace-id, + * and the logger/error-handler setup. + */ +import type { FastifyInstance } from 'fastify'; +import fp from 'fastify-plugin'; +import { bootStores } from '../store/boot.js'; +import type { Store } from '../store/store.js'; + +declare module 'fastify' { + interface FastifyInstance { + store: Store; + } +} + +async function storePlugin(fastify: FastifyInstance): Promise { + const store = await bootStores({ + CFP_DATA_REPO_PATH: fastify.config.CFP_DATA_REPO_PATH, + STORAGE_BACKEND: fastify.config.STORAGE_BACKEND, + CFP_PRIVATE_STORAGE_PATH: fastify.config.CFP_PRIVATE_STORAGE_PATH, + S3_ENDPOINT: fastify.config.S3_ENDPOINT, + S3_BUCKET: fastify.config.S3_BUCKET, + S3_ACCESS_KEY_ID: fastify.config.S3_ACCESS_KEY_ID, + S3_SECRET_ACCESS_KEY: fastify.config.S3_SECRET_ACCESS_KEY, + S3_REGION: fastify.config.S3_REGION, + }); + + fastify.decorate('store', store); +} + +export default fp(storePlugin, { + name: 'store', + fastify: '5.x', + dependencies: ['@fastify/env'], +}); diff --git a/apps/api/src/plugins/trace-id.ts b/apps/api/src/plugins/trace-id.ts new file mode 100644 index 0000000..62d311d --- /dev/null +++ b/apps/api/src/plugins/trace-id.ts @@ -0,0 +1,32 @@ +/** + * Trace-ID plugin. + * + * Generates a UUIDv7 traceId for every incoming request and decorates + * request.traceId with it. Every log line from pino includes the traceId + * via the logger's mixin config in app.ts. + * + * Per specs/api/conventions.md#logging-and-trace-ids. + */ +import type { FastifyInstance } from 'fastify'; +import fp from 'fastify-plugin'; +import { uuidv7 } from 'uuidv7'; + +declare module 'fastify' { + interface FastifyRequest { + traceId: string; + } +} + +async function traceIdPlugin(fastify: FastifyInstance): Promise { + fastify.decorateRequest('traceId', ''); + + fastify.addHook('onRequest', (request, _reply, done) => { + request.traceId = uuidv7(); + done(); + }); +} + +export default fp(traceIdPlugin, { + name: 'trace-id', + fastify: '5.x', +}); diff --git a/apps/api/src/routes/health.ts b/apps/api/src/routes/health.ts new file mode 100644 index 0000000..cbe3490 --- /dev/null +++ b/apps/api/src/routes/health.ts @@ -0,0 +1,96 @@ +/** + * Health check endpoint. + * + * GET /api/health → { success: true, data: { status: 'ok' }, metadata: { timestamp } } + * + * Returns 200 if the server is up. No auth required. No rate limiting applied + * (but the global rate-limit hook still runs; this counts toward the IP cap). + */ +import type { FastifyInstance } from 'fastify'; +import { ok } from '../lib/response.js'; + +export async function healthRoutes(fastify: FastifyInstance): Promise { + fastify.get( + '/api/health', + { + schema: { + tags: ['health'], + summary: 'Health check', + description: 'Returns ok when the server is running.', + response: { + 200: { + type: 'object', + properties: { + success: { type: 'boolean' }, + data: { + type: 'object', + properties: { + status: { type: 'string' }, + }, + }, + metadata: { + type: 'object', + properties: { + timestamp: { type: 'string' }, + }, + }, + }, + }, + }, + }, + }, + () => { + return ok({ status: 'ok' }); + }, + ); + + // Stub route for testing validation errors + fastify.post( + '/api/_test/validation-error', + { + schema: { + hide: true, + body: { + type: 'object', + properties: { + trigger: { type: 'string' }, + }, + }, + }, + }, + async () => { + const { ApiValidationError } = await import('../lib/errors.js'); + throw new ApiValidationError('Test validation failed', { field: 'required' }); + }, + ); + + // Stub route for testing unknown/500 errors + fastify.post( + '/api/_test/internal-error', + { schema: { hide: true } }, + async () => { + throw new Error('Deliberate internal error — should not leak to client'); + }, + ); + + // Stub route for testing idempotency + fastify.post( + '/api/_test/idempotency', + { schema: { hide: true } }, + async (request, reply) => { + const idempotencyKey = request.headers['idempotency-key']; + if (typeof idempotencyKey === 'string' && idempotencyKey.length > 0) { + const personId = 'test-person'; + const cached = request.server.idempotency.check(personId, idempotencyKey); + if (cached) { + return reply.code(cached.status).send(cached.body); + } + + const body = ok({ echoed: idempotencyKey, at: new Date().toISOString() }); + request.server.idempotency.store(personId, idempotencyKey, { status: 200, body }); + return reply.code(200).send(body); + } + return ok({ echoed: null }); + }, + ); +} diff --git a/apps/api/tests/api-skeleton.test.ts b/apps/api/tests/api-skeleton.test.ts new file mode 100644 index 0000000..0562f8d --- /dev/null +++ b/apps/api/tests/api-skeleton.test.ts @@ -0,0 +1,268 @@ +/** + * Tests for the api-skeleton plan validation criteria. + * + * Covers: + * - GET /api/health returns envelope-wrapped { status: 'ok' } + * - ValidationError surfaces as 422 validation_failed with expected shape + * - Unknown Error surfaces as 500 internal_error with no message leak + * - traceId appears in error responses + * - Per-IP rate limit: 61 anonymous reads → 429 with Retry-After + * - Idempotency-Key: repeat POST returns cached response + * - /api/_openapi.json returns a valid OpenAPI 3.1 document + * - /api/_docs renders (200 response) + * - Booting with invalid config throws + */ +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; + +import type { FastifyInstance } from 'fastify'; +import { buildApp } from '../src/app.js'; +import { createFullDataRepo, createPrivateStorageDir } from './helpers/test-full-repo.js'; + +// --------------------------------------------------------------------------- +// Test fixtures +// --------------------------------------------------------------------------- + +let dataRepo: { path: string; cleanup: () => Promise }; +let privateStore: { path: string; cleanup: () => Promise }; +let app: FastifyInstance | undefined; + +async function buildTestApp( + overrides: Partial> = {}, + dataPath = dataRepo.path, + privatePath = privateStore.path, +): Promise { + return buildApp({ + serverOptions: { logger: false }, + overrideEnv: { + CFP_DATA_REPO_PATH: dataPath, + STORAGE_BACKEND: 'filesystem', + CFP_PRIVATE_STORAGE_PATH: privatePath, + CFP_JWT_SIGNING_KEY: 'test-jwt-signing-key-at-least-32-chars!!', + NODE_ENV: 'test', + ...overrides, + }, + }); +} + +beforeEach(async () => { + dataRepo = await createFullDataRepo(); + privateStore = await createPrivateStorageDir(); + app = await buildTestApp(); +}); + +afterEach(async () => { + if (app) { + await app.close(); + app = undefined; + } + await dataRepo.cleanup(); + await privateStore.cleanup(); +}); + +// --------------------------------------------------------------------------- +// GET /api/health +// --------------------------------------------------------------------------- + +describe('GET /api/health', () => { + it('returns 200 with the success envelope', async () => { + const res = await app!.inject({ method: 'GET', url: '/api/health' }); + expect(res.statusCode).toBe(200); + + const body = res.json<{ success: boolean; data: { status: string }; metadata: { timestamp: string } }>(); + expect(body.success).toBe(true); + expect(body.data.status).toBe('ok'); + expect(typeof body.metadata.timestamp).toBe('string'); + // Timestamp should be ISO 8601 + expect(new Date(body.metadata.timestamp).toISOString()).toBe(body.metadata.timestamp); + }); +}); + +// --------------------------------------------------------------------------- +// Error mapper +// --------------------------------------------------------------------------- + +describe('error mapper', () => { + it('ValidationError → 422 validation_failed with field details and traceId', async () => { + const res = await app!.inject({ method: 'POST', url: '/api/_test/validation-error' }); + expect(res.statusCode).toBe(422); + + const body = res.json<{ + success: boolean; + error: { code: string; message: string; traceId: string; fields?: Record }; + metadata: { timestamp: string }; + }>(); + expect(body.success).toBe(false); + expect(body.error.code).toBe('validation_failed'); + expect(typeof body.error.message).toBe('string'); + // traceId must be present + expect(typeof body.error.traceId).toBe('string'); + expect(body.error.traceId!.length).toBeGreaterThan(0); + }); + + it('unknown Error → 500 internal_error with no message leaked', async () => { + const res = await app!.inject({ method: 'POST', url: '/api/_test/internal-error' }); + expect(res.statusCode).toBe(500); + + const body = res.json<{ + success: boolean; + error: { code: string; message: string; traceId: string }; + metadata: { timestamp: string }; + }>(); + expect(body.success).toBe(false); + expect(body.error.code).toBe('internal_error'); + // Must not leak the actual error message + expect(body.error.message).not.toContain('Deliberate'); + expect(body.error.message).not.toContain('should not leak'); + // traceId must be present + expect(typeof body.error.traceId).toBe('string'); + expect(body.error.traceId!.length).toBeGreaterThan(0); + }); + + it('traceId in error response matches UUIDv7 format', async () => { + const res = await app!.inject({ method: 'POST', url: '/api/_test/internal-error' }); + const body = res.json<{ error: { traceId: string } }>(); + const traceId = body.error.traceId; + // UUIDv7 format: 8-4-4-4-12 hex, version nibble = 7 + expect(traceId).toMatch(/^[0-9a-f]{8}-[0-9a-f]{4}-7[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i); + }); +}); + +// --------------------------------------------------------------------------- +// Rate limiting +// --------------------------------------------------------------------------- + +describe('rate limiting', () => { + it('61 anonymous reads from the same IP → 429 with Retry-After on the 61st', async () => { + // Make 60 reads — all should succeed + for (let i = 0; i < 60; i++) { + const res = await app!.inject({ + method: 'GET', + url: '/api/health', + remoteAddress: '10.0.0.1', + }); + expect(res.statusCode, `Request ${i + 1} should succeed`).toBe(200); + } + + // The 61st should be rate-limited + const res = await app!.inject({ + method: 'GET', + url: '/api/health', + remoteAddress: '10.0.0.1', + }); + expect(res.statusCode).toBe(429); + + const body = res.json<{ success: boolean; error: { code: string } }>(); + expect(body.success).toBe(false); + expect(body.error.code).toBe('rate_limited'); + + // Retry-After header must be present + const retryAfter = res.headers['retry-after']; + expect(retryAfter).toBeDefined(); + expect(Number(retryAfter)).toBeGreaterThan(0); + }); +}); + +// --------------------------------------------------------------------------- +// Idempotency +// --------------------------------------------------------------------------- + +describe('idempotency', () => { + it('repeat POST with same Idempotency-Key returns the cached response', async () => { + const key = 'test-idempotency-key-abc123'; + + const first = await app!.inject({ + method: 'POST', + url: '/api/_test/idempotency', + headers: { 'idempotency-key': key }, + }); + expect(first.statusCode).toBe(200); + const firstBody = first.json<{ data: { echoed: string; at: string } }>(); + + // Second request with the same key — should return identical body + const second = await app!.inject({ + method: 'POST', + url: '/api/_test/idempotency', + headers: { 'idempotency-key': key }, + }); + expect(second.statusCode).toBe(200); + const secondBody = second.json<{ data: { echoed: string; at: string } }>(); + + // The `at` timestamp must be frozen — both responses are the same cached copy + expect(secondBody.data.echoed).toBe(firstBody.data.echoed); + expect(secondBody.data.at).toBe(firstBody.data.at); + + // Different key → fresh response (different `at` possible but same shape) + const third = await app!.inject({ + method: 'POST', + url: '/api/_test/idempotency', + headers: { 'idempotency-key': 'a-different-key' }, + }); + expect(third.statusCode).toBe(200); + const thirdBody = third.json<{ data: { at: string } }>(); + + // The `at` may be same or later, but must be a valid timestamp + expect(new Date(thirdBody.data.at).getTime()).toBeGreaterThanOrEqual(0); + }); +}); + +// --------------------------------------------------------------------------- +// OpenAPI / Swagger UI +// --------------------------------------------------------------------------- + +describe('OpenAPI', () => { + it('/api/_openapi.json returns a valid OpenAPI 3.1 document', async () => { + const res = await app!.inject({ method: 'GET', url: '/api/_openapi.json' }); + expect(res.statusCode).toBe(200); + + const doc = res.json<{ + openapi: string; + info: { title: string; version: string }; + paths: Record; + }>(); + + expect(doc.openapi).toMatch(/^3\.1/); + expect(typeof doc.info.title).toBe('string'); + expect(typeof doc.paths).toBe('object'); + }); + + it('/api/_docs renders Swagger UI (200 or 3xx)', async () => { + const res = await app!.inject({ method: 'GET', url: '/api/_docs' }); + // Swagger UI may redirect to /api/_docs/ — check not 4xx/5xx + expect(res.statusCode).toBeLessThan(400); + }); +}); + +// --------------------------------------------------------------------------- +// Env validation (integration — separate app instance) +// --------------------------------------------------------------------------- + +describe('env validation', () => { + it('throws on missing CFP_DATA_REPO_PATH', async () => { + await expect( + buildApp({ + serverOptions: { logger: false }, + overrideEnv: { + // CFP_DATA_REPO_PATH intentionally omitted + STORAGE_BACKEND: 'filesystem', + CFP_PRIVATE_STORAGE_PATH: '/tmp/test', + CFP_JWT_SIGNING_KEY: 'test-jwt-signing-key-at-least-32-chars!!', + NODE_ENV: 'test', + }, + }), + ).rejects.toThrow(); + }); + + it('throws on invalid STORAGE_BACKEND value', async () => { + await expect( + buildApp({ + serverOptions: { logger: false }, + overrideEnv: { + CFP_DATA_REPO_PATH: '/tmp/nonexistent', + STORAGE_BACKEND: 'invalid-backend', + CFP_JWT_SIGNING_KEY: 'test-jwt-signing-key-at-least-32-chars!!', + NODE_ENV: 'test', + }, + }), + ).rejects.toThrow(); + }); +}); diff --git a/apps/api/tests/helpers/test-full-repo.ts b/apps/api/tests/helpers/test-full-repo.ts new file mode 100644 index 0000000..2357fd0 --- /dev/null +++ b/apps/api/tests/helpers/test-full-repo.ts @@ -0,0 +1,91 @@ +/** + * Test helper: create a full gitsheets data repo. + * + * Creates a temporary git repo with all the .gitsheets/*.toml sheet configs + * required by openPublicStore(). Path templates match specs/behaviors/storage.md. + * + * Used by api-skeleton tests (and any future tests) that boot the full app + * via buildApp() and need a real gitsheets-backed data repo. + */ +import { execFile } from 'node:child_process'; +import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { promisify } from 'node:util'; + +const execFileAsync = promisify(execFile); + +/** Sheet configs matching specs/behaviors/storage.md#sheet-layout. */ +const SHEET_CONFIGS: Record = { + 'people': `[gitsheet]\nroot = 'people'\npath = '\${{ slug }}'\n`, + 'projects': `[gitsheet]\nroot = 'projects'\npath = '\${{ slug }}'\n`, + 'project-memberships': `[gitsheet]\nroot = 'project-memberships'\npath = '\${{ projectSlug }}/\${{ personSlug }}'\n`, + 'project-updates': `[gitsheet]\nroot = 'project-updates'\npath = '\${{ projectSlug }}/\${{ number }}'\n`, + 'project-buzz': `[gitsheet]\nroot = 'project-buzz'\npath = '\${{ projectSlug }}/\${{ slug }}'\n`, + 'help-wanted-roles': `[gitsheet]\nroot = 'help-wanted-roles'\npath = '\${{ projectSlug }}/\${{ id }}'\n`, + 'help-wanted-interest': `[gitsheet]\nroot = 'help-wanted-interest'\npath = '\${{ roleId }}/\${{ personSlug }}'\n`, + 'tags': `[gitsheet]\nroot = 'tags'\npath = '\${{ namespace }}/\${{ slug }}'\n`, + 'tag-assignments': `[gitsheet]\nroot = 'tag-assignments'\npath = '\${{ tagId }}/\${{ taggableType }}/\${{ taggableId }}'\n`, + 'slug-history': `[gitsheet]\nroot = 'slug-history'\npath = '\${{ entityType }}/\${{ oldSlug }}'\n`, + 'revocations': `[gitsheet]\nroot = 'revocations'\npath = '\${{ jti }}'\n`, +}; + +export interface FullTestRepo { + readonly path: string; + readonly cleanup: () => Promise; +} + +/** + * Create a full gitsheets data repo in a temp directory, with all sheet + * configs required by openPublicStore(). + * + * This is the data-repo analog of createTestRepo() from test-repo.ts — but + * for full-app tests where the store plugin boots the real openPublicStore(). + */ +export async function createFullDataRepo(): Promise { + const dir = await mkdtemp(join(tmpdir(), 'cfp-full-data-')); + const git = (...args: string[]) => execFileAsync('git', args, { cwd: dir }); + + await git('init', '-b', 'main'); + await git('config', 'user.email', 'test@cfp.test'); + await git('config', 'user.name', 'cfp test'); + await git('config', 'commit.gpgsign', 'false'); + await git('config', 'core.hooksPath', '/dev/null'); + await git('commit', '--allow-empty', '-m', 'initial'); + + // Write all sheet configs + await mkdir(join(dir, '.gitsheets'), { recursive: true }); + for (const [name, config] of Object.entries(SHEET_CONFIGS)) { + await writeFile(join(dir, '.gitsheets', `${name}.toml`), config); + } + await git('add', '.gitsheets'); + await git('commit', '-m', 'chore: add all gitsheets sheet configs'); + + let cleaned = false; + return { + path: dir, + cleanup: async () => { + if (cleaned) return; + cleaned = true; + await rm(dir, { recursive: true, force: true }); + }, + }; +} + +/** + * Create a temp directory for the filesystem private store. + */ +export async function createPrivateStorageDir(): Promise<{ path: string; cleanup: () => Promise }> { + const dir = await mkdtemp(join(tmpdir(), 'cfp-private-')); + await mkdir(dir, { recursive: true }); + + let cleaned = false; + return { + path: dir, + cleanup: async () => { + if (cleaned) return; + cleaned = true; + await rm(dir, { recursive: true, force: true }); + }, + }; +} diff --git a/apps/api/vitest.config.ts b/apps/api/vitest.config.ts index 8363e16..4ab2a38 100644 --- a/apps/api/vitest.config.ts +++ b/apps/api/vitest.config.ts @@ -4,5 +4,7 @@ export default defineConfig({ test: { environment: 'node', include: ['tests/**/*.test.ts'], + testTimeout: 30_000, + hookTimeout: 30_000, }, }); diff --git a/package-lock.json b/package-lock.json index 50cc06e..8c03cd5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -30,8 +30,16 @@ "dependencies": { "@aws-sdk/client-s3": "^3.1048.0", "@cfp/shared": "^0.0.0", + "@fastify/cookie": "^11.0.2", + "@fastify/cors": "^11.2.0", + "@fastify/env": "^6.0.0", + "@fastify/rate-limit": "^10.3.0", + "@fastify/swagger": "^9.7.0", + "@fastify/swagger-ui": "^5.2.6", "fastify": "^5.8.5", - "gitsheets": "^1.0.3" + "gitsheets": "^1.0.3", + "uuidv7": "^1.2.1", + "zod": "^4.4.3" }, "devDependencies": { "@types/node": "^25.8.0", @@ -2029,6 +2037,22 @@ } } }, + "node_modules/@fastify/accept-negotiator": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/@fastify/accept-negotiator/-/accept-negotiator-2.0.1.tgz", + "integrity": "sha512-/c/TW2bO/v9JeEgoD/g1G5GxGeCF1Hafdf79WPmUlgYiBXummY0oX3VVq4yFkKKVBKDNlaDUYoab7g38RpPqCQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT" + }, "node_modules/@fastify/ajv-compiler": { "version": "4.0.5", "resolved": "https://registry.npmjs.org/@fastify/ajv-compiler/-/ajv-compiler-4.0.5.tgz", @@ -2050,6 +2074,66 @@ "fast-uri": "^3.0.0" } }, + "node_modules/@fastify/cookie": { + "version": "11.0.2", + "resolved": "https://registry.npmjs.org/@fastify/cookie/-/cookie-11.0.2.tgz", + "integrity": "sha512-GWdwdGlgJxyvNv+QcKiGNevSspMQXncjMZ1J8IvuDQk0jvkzgWWZFNC2En3s+nHndZBGV8IbLwOI/sxCZw/mzA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "cookie": "^1.0.0", + "fastify-plugin": "^5.0.0" + } + }, + "node_modules/@fastify/cors": { + "version": "11.2.0", + "resolved": "https://registry.npmjs.org/@fastify/cors/-/cors-11.2.0.tgz", + "integrity": "sha512-LbLHBuSAdGdSFZYTLVA3+Ch2t+sA6nq3Ejc6XLAKiQ6ViS2qFnvicpj0htsx03FyYeLs04HfRNBsz/a8SvbcUw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "fastify-plugin": "^5.0.0", + "toad-cache": "^3.7.0" + } + }, + "node_modules/@fastify/env": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/@fastify/env/-/env-6.0.0.tgz", + "integrity": "sha512-b7FoRMdwZMF6vdhq0OHdj+xX6ggyeUq7rx+CZXnsAAgVhFbVQ7GZXb9pEzLaF3/tG8U489ZMD+Qh0RPKuNb5sQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "env-schema": "^7.0.0", + "fastify-plugin": "^5.0.0" + } + }, "node_modules/@fastify/error": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/@fastify/error/-/error-4.2.0.tgz", @@ -2140,6 +2224,137 @@ "ipaddr.js": "^2.1.0" } }, + "node_modules/@fastify/rate-limit": { + "version": "10.3.0", + "resolved": "https://registry.npmjs.org/@fastify/rate-limit/-/rate-limit-10.3.0.tgz", + "integrity": "sha512-eIGkG9XKQs0nyynatApA3EVrojHOuq4l6fhB4eeCk4PIOeadvOJz9/4w3vGI44Go17uaXOWEcPkaD8kuKm7g6Q==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "@lukeed/ms": "^2.0.2", + "fastify-plugin": "^5.0.0", + "toad-cache": "^3.7.0" + } + }, + "node_modules/@fastify/send": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/@fastify/send/-/send-4.1.0.tgz", + "integrity": "sha512-TMYeQLCBSy2TOFmV95hQWkiTYgC/SEx7vMdV+wnZVX4tt8VBLKzmH8vV9OzJehV0+XBfg+WxPMt5wp+JBUKsVw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "@lukeed/ms": "^2.0.2", + "escape-html": "~1.0.3", + "fast-decode-uri-component": "^1.0.1", + "http-errors": "^2.0.0", + "mime": "^3" + } + }, + "node_modules/@fastify/static": { + "version": "9.1.3", + "resolved": "https://registry.npmjs.org/@fastify/static/-/static-9.1.3.tgz", + "integrity": "sha512-aXrYtsiryLhRxRNaxNqsn7FUISeb7rB9q4eHUPIot5aeQBLNahnz1m6thzm7JWC1poSGXS9XrX8DvuMivp2hkQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "@fastify/accept-negotiator": "^2.0.0", + "@fastify/send": "^4.0.0", + "content-disposition": "^1.0.1", + "fastify-plugin": "^5.0.0", + "fastq": "^1.17.1", + "glob": "^13.0.0" + } + }, + "node_modules/@fastify/static/node_modules/glob": { + "version": "13.0.6", + "resolved": "https://registry.npmjs.org/glob/-/glob-13.0.6.tgz", + "integrity": "sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw==", + "license": "BlueOak-1.0.0", + "dependencies": { + "minimatch": "^10.2.2", + "minipass": "^7.1.3", + "path-scurry": "^2.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@fastify/swagger": { + "version": "9.7.0", + "resolved": "https://registry.npmjs.org/@fastify/swagger/-/swagger-9.7.0.tgz", + "integrity": "sha512-Vp1SC1GC2Hrkd3faFILv86BzUNyFz5N4/xdExqtCgkGASOzn/x+eMe4qXIGq7cdT6wif/P/oa6r1Ruqx19paZA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "fastify-plugin": "^5.0.0", + "json-schema-resolver": "^3.0.0", + "openapi-types": "^12.1.3", + "rfdc": "^1.3.1", + "yaml": "^2.4.2" + } + }, + "node_modules/@fastify/swagger-ui": { + "version": "5.2.6", + "resolved": "https://registry.npmjs.org/@fastify/swagger-ui/-/swagger-ui-5.2.6.tgz", + "integrity": "sha512-OMnms0O5s9wb6wis/K5nlrAMLsgUbr1GA8uphM41IasWe3AFdgxz6r/3bA9HTxlDNUYc2FGGKeqMp3ntxmSiNA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "@fastify/static": "^9.1.2", + "fastify-plugin": "^5.0.0", + "openapi-types": "^12.1.3", + "rfdc": "^1.3.1", + "yaml": "^2.4.1" + } + }, "node_modules/@floating-ui/core": { "version": "1.7.5", "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.5.tgz", @@ -2398,6 +2613,15 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@lukeed/ms": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/@lukeed/ms/-/ms-2.0.2.tgz", + "integrity": "sha512-9I2Zn6+NJLfaGoz9jN3lpwDgAYvfGeNYdbAIjJOqzs4Tpc+VU3Jqq4IofSUBKajiDS8k9fZIg18/z13mpk1bsA==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/@modelcontextprotocol/sdk": { "version": "1.29.0", "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.29.0.tgz", @@ -6666,6 +6890,25 @@ "node": ">=6" } }, + "node_modules/env-schema": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/env-schema/-/env-schema-7.0.0.tgz", + "integrity": "sha512-b8rdAwdFpekDxNAUNuT6KbV/QEX/pTBs0gjehEPmBUUteh9zBDm9zxFSQt55D29odo3rJt4feoWRqn4U/tzicw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "ajv": "^8.12.0" + } + }, "node_modules/error-ex": { "version": "1.3.4", "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.4.tgz", @@ -7406,6 +7649,22 @@ "toad-cache": "^3.7.0" } }, + "node_modules/fastify-plugin": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/fastify-plugin/-/fastify-plugin-5.1.0.tgz", + "integrity": "sha512-FAIDA8eovSt5qcDgcBvDuX/v0Cjz0ohGhENZ/wpc3y+oZCY2afZ9Baqql3g/lC+OHRnciQol4ww7tuthOb9idw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT" + }, "node_modules/fastq": { "version": "1.20.1", "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz", @@ -8915,6 +9174,23 @@ "dequal": "^2.0.3" } }, + "node_modules/json-schema-resolver": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/json-schema-resolver/-/json-schema-resolver-3.0.0.tgz", + "integrity": "sha512-HqMnbz0tz2DaEJ3ntsqtx3ezzZyDE7G56A/pPY/NGmrPu76UzsWquOpHFRAf5beTNXoH2LU5cQePVvRli1nchA==", + "license": "MIT", + "dependencies": { + "debug": "^4.1.1", + "fast-uri": "^3.0.5", + "rfdc": "^1.1.4" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/Eomm/json-schema-resolver?sponsor=1" + } + }, "node_modules/json-schema-traverse": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", @@ -10305,6 +10581,18 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/mime": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-3.0.0.tgz", + "integrity": "sha512-jSCU7/VB1loIWBZe14aEYHU/+1UMEHoaO7qxCOVJOw9GgH72VAWppxNcjU+x9a2k3GSIBXNKxXQFqRvvZ7vr3A==", + "license": "MIT", + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=10.0.0" + } + }, "node_modules/mime-db": { "version": "1.52.0", "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", @@ -10381,6 +10669,15 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/minipass": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", + "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==", + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, "node_modules/mkdirp": { "version": "0.5.6", "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.6.tgz", @@ -10718,6 +11015,12 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/openapi-types": { + "version": "12.1.3", + "resolved": "https://registry.npmjs.org/openapi-types/-/openapi-types-12.1.3.tgz", + "integrity": "sha512-N4YtSYJqghVu4iek2ZUvcN/0aqH1kRDuNqzcycDxhOUpg7GdvLa2F3DgS6yBNhInhv2r/6I0Flkn7CqL8+nIcw==", + "license": "MIT" + }, "node_modules/optionator": { "version": "0.9.4", "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", @@ -10993,6 +11296,31 @@ "node": ">=8" } }, + "node_modules/path-scurry": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-2.0.2.tgz", + "integrity": "sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==", + "license": "BlueOak-1.0.0", + "dependencies": { + "lru-cache": "^11.0.0", + "minipass": "^7.1.2" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/path-scurry/node_modules/lru-cache": { + "version": "11.3.6", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.3.6.tgz", + "integrity": "sha512-Gf/KoL3C/MlI7Bt0PGI9I+TeTC/I6r/csU58N4BSNc4lppLBeKsOdFYkK+dX0ABDUMJNfCHTyPpzwwO21Awd3A==", + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, "node_modules/path-to-regexp": { "version": "6.3.0", "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-6.3.0.tgz", @@ -13319,6 +13647,15 @@ "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", "license": "MIT" }, + "node_modules/uuidv7": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/uuidv7/-/uuidv7-1.2.1.tgz", + "integrity": "sha512-4kPkK3/XTQW9Hbm4CaqfICn+kY9LJtDVEOfgsRRra/+n2Ofg4NqzRFceAkxvQ/Ud/6BpHOPzj8cirqM7TzTN5Q==", + "license": "Apache-2.0", + "bin": { + "uuidv7": "cli.js" + } + }, "node_modules/validate-npm-package-name": { "version": "7.0.2", "resolved": "https://registry.npmjs.org/validate-npm-package-name/-/validate-npm-package-name-7.0.2.tgz", @@ -13759,6 +14096,21 @@ "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", "license": "ISC" }, + "node_modules/yaml": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.0.tgz", + "integrity": "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==", + "license": "ISC", + "bin": { + "yaml": "bin.mjs" + }, + "engines": { + "node": ">= 14.6" + }, + "funding": { + "url": "https://github.com/sponsors/eemeli" + } + }, "node_modules/yargs": { "version": "17.7.2", "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz", diff --git a/plans/api-skeleton.md b/plans/api-skeleton.md index dbdbf75..06bc31b 100644 --- a/plans/api-skeleton.md +++ b/plans/api-skeleton.md @@ -1,5 +1,5 @@ --- -status: planned +status: done depends: [storage-foundation] specs: - specs/api/conventions.md @@ -7,6 +7,7 @@ upstream-specs: # gitsheets defines its own error class hierarchy; the API maps those to the response envelope - gitsheets:specs/api/errors.md issues: [] +pr: 17 --- # Plan: API skeleton @@ -107,16 +108,16 @@ In-memory `Map` with a 24h TTL. Mutating endpoints ## Validation -- [ ] `GET /api/health` returns `{success:true, data:{status:'ok'}, metadata:{...timestamp}}` exactly per envelope spec -- [ ] Booting with an invalid `STORAGE_BACKEND` exits 1 with a Zod error printed -- [ ] An intentionally-thrown `ValidationError` from a stub route surfaces as `422 validation_failed` with the expected error shape -- [ ] An unknown thrown Error surfaces as `500 internal_error` with no error message leaked -- [ ] `traceId` appears in both the error response (when error) and the access log line -- [ ] Per-IP rate limit kicks in: 61 anonymous reads from the same IP within a minute → 429 with `Retry-After` -- [ ] Repeat POST with the same `Idempotency-Key` returns the cached response (verified by a stub route) -- [ ] `/api/_openapi.json` returns a valid OpenAPI 3.1 document; `/api/_docs` renders Swagger UI -- [ ] `.env.example` exists at the repo root with one entry per `EnvSchema` field (deferred from [`workspace`](workspace.md)) -- [ ] CI passes type-check + tests +- [x] `GET /api/health` returns `{success:true, data:{status:'ok'}, metadata:{...timestamp}}` exactly per envelope spec +- [x] Booting with an invalid `STORAGE_BACKEND` exits 1 with a Zod error printed +- [x] An intentionally-thrown `ValidationError` from a stub route surfaces as `422 validation_failed` with the expected error shape +- [x] An unknown thrown Error surfaces as `500 internal_error` with no error message leaked +- [x] `traceId` appears in both the error response (when error) and the access log line +- [x] Per-IP rate limit kicks in: 61 anonymous reads from the same IP within a minute → 429 with `Retry-After` +- [x] Repeat POST with the same `Idempotency-Key` returns the cached response (verified by a stub route) +- [x] `/api/_openapi.json` returns a valid OpenAPI 3.1 document; `/api/_docs` renders Swagger UI +- [x] `.env.example` exists at the repo root with one entry per `EnvSchema` field (deferred from [`workspace`](workspace.md)) +- [x] CI passes type-check + tests ## Risks / unknowns @@ -124,3 +125,16 @@ In-memory `Map` with a 24h TTL. Mutating endpoints - **Rate-limit counters survive restart?** No — in-memory, intentional. Acceptable at single-replica civic scale. ## Notes + +- `@fastify/env` requires a JSON Schema object (not a Zod schema) passed as `schema`. We maintain both `EnvSchema` (Zod, for TypeScript types and runtime validation in code) and `envJsonSchema` (JSON Schema, for the `@fastify/env` plugin). Keeping them in sync is a per-PR review concern. +- `@fastify/swagger` does NOT expose the OpenAPI document at a URL by default — it populates `fastify.swagger()`. The spec-mandated `/api/_openapi.json` URL is a manual route added after swagger registration that calls `fastify.swagger()`. The swagger-ui also serves the doc at `/api/_docs/json`. +- Fastify's default `pluginTimeout` (avvio) is 10s. In the worktree test environment, git operations during `openPublicStore()` exceed that. We set `pluginTimeout: 30_000` in `buildApp()` (not just tests) since a slow git cold-read could also time out in production on a cold start against a large data repo. +- Vitest's default test timeout (5s) also needed to be raised to 30s for the same reason. This is set in `apps/api/vitest.config.ts`. +- The "traceId appears in access log" criterion is verified structurally (pino logger receives the traceId via request decorators) but not via a log-line assertion in the tests. Log assertion would require capturing pino output, which is complex for the upside. The functional test verifies traceId in error responses, which exercises the same code path. +- Rate-limit account-based caps (300 reads/min/account, 30 writes/min/account) are stubbed to the IP-based limit until auth-jwt-substrate lands and `request.person` is available. The plugin has the hook points for account-based keying. +- `/_test/*` stub routes (validation-error, internal-error, idempotency) are `{ schema: { hide: true } }` so they don't appear in the OpenAPI doc but exist in the running app. These exist only for testing and should be removed or guarded in production (future follow-up). + +## Follow-ups + +- Issue [#18](https://github.com/CodeForPhilly/codeforphilly-ng/issues/18) — remove or guard `/_test/*` stub routes in production (they test error/idempotency behavior but shouldn't be exposed in prod) +- Deferred to [`auth-jwt-substrate`](auth-jwt-substrate.md) — wire account-based rate limit caps (300 reads/min, 30 writes/min/account) once `request.person` is available from the JWT plugin diff --git a/plans/auth-jwt-substrate.md b/plans/auth-jwt-substrate.md index 822bc5b..51dfbf5 100644 --- a/plans/auth-jwt-substrate.md +++ b/plans/auth-jwt-substrate.md @@ -44,6 +44,15 @@ HS256 with `CFP_JWT_SIGNING_KEY`. Access JWT: 15 min, `{ sub: personId, jti, acc Helpers in `apps/api/src/auth/cookies.ts` to set/clear consistently. +### Account-based rate-limit wiring + +The api-skeleton plan's rate-limit plugin stubs account-based caps to IP-based limits because `request.person` isn't available yet. Once session middleware decorates requests with `request.session.person`, the rate-limit plugin should be updated to: + +- Authenticated reads: key on `account:`, limit 300/min +- Writes: key on `write-account:`, limit 30/min + +Update `apps/api/src/plugins/rate-limit.ts` to check `request.session?.person` and switch keys accordingly. + ### Session middleware (`apps/api/src/auth/middleware.ts`) Decorates every request with `request.session: SessionContext`: @@ -91,6 +100,7 @@ The HTTP-facing OAuth endpoints (`/api/auth/github/start`, `/callback`) exist bu - [ ] `GET /api/auth/sessions` lists non-revoked sessions with metadata; current session marked `current:true` - [ ] `POST /api/auth/sessions/:jti/revoke` with `:jti` == current's returns 409 `cannot_revoke_current_session` - [ ] Revocation sweeper deletes expired `revocations` records +- [ ] Account-based rate limits wired: authenticated reads key on `account:` (300/min), writes key on `write-account:` (30/min) — update `apps/api/src/plugins/rate-limit.ts` to use `request.session.person` (deferred from api-skeleton) - [ ] OAuth endpoints return 501 `oauth_not_yet_wired` (placeholder) - [ ] Tests cover all of the above using `mintSessionFor` + `createTestRepo` + `createTestPrivateStore`