Skip to content
Merged
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
69 changes: 69 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
@@ -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-----
10 changes: 9 additions & 1 deletion apps/api/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
133 changes: 133 additions & 0 deletions apps/api/src/app.ts
Original file line number Diff line number Diff line change
@@ -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<Record<string, string>>;
/** Extra Fastify server options (e.g. logger: false for tests). */
serverOptions?: FastifyServerOptions;
}

export async function buildApp(opts: BuildAppOptions = {}): Promise<FastifyInstance> {
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;
}
75 changes: 75 additions & 0 deletions apps/api/src/env.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
/**
* Environment schema and config type.
*
* This is the ONLY place that reads process.env. All other modules read
* fastify.config.<FIELD> 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<typeof EnvSchema>;

/**
* 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;
27 changes: 14 additions & 13 deletions apps/api/src/index.ts
Original file line number Diff line number Diff line change
@@ -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);
}
Loading