Skip to content

Commit a281da3

Browse files
Merge pull request #17 from CodeForPhilly/feat/api-skeleton
feat(api-skeleton): fastify scaffold with envelope, error mapper, rate limit, idempotency, OpenAPI
2 parents fafbfe2 + 19eef70 commit a281da3

18 files changed

Lines changed: 1691 additions & 26 deletions

.env.example

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
# codeforphilly-rewrite — local development environment
2+
# Copy this file to .env and fill in values for your machine.
3+
# Lines starting with # are comments. Never commit your .env.
4+
5+
# ---------------------------------------------------------------------------
6+
# Core
7+
# ---------------------------------------------------------------------------
8+
9+
# TCP port the Fastify API listens on.
10+
PORT=3001
11+
12+
# Runtime mode. Controls logger format, cookie Secure flag, CORS permissiveness.
13+
# One of: development | test | production
14+
NODE_ENV=development
15+
16+
# ---------------------------------------------------------------------------
17+
# Public gitsheets data repo
18+
# ---------------------------------------------------------------------------
19+
20+
# Absolute path (or relative from repo root) to the codeforphilly-data working tree.
21+
# Clone https://github.com/CodeForPhilly/codeforphilly-data-snapshot as a sibling:
22+
# git clone https://github.com/CodeForPhilly/codeforphilly-data-snapshot ../codeforphilly-data
23+
CFP_DATA_REPO_PATH=../codeforphilly-data
24+
25+
# Git remote URL to push public data commits to. Optional in dev; required in prod.
26+
# This is the production private data repo — the public snapshot is published separately.
27+
# CFP_DATA_REMOTE=git@github.com:CodeForPhilly/codeforphilly-data.git
28+
29+
# ---------------------------------------------------------------------------
30+
# Private storage
31+
# ---------------------------------------------------------------------------
32+
33+
# Which private-storage backend to use. Use 'filesystem' for local dev.
34+
# One of: filesystem | s3
35+
STORAGE_BACKEND=filesystem
36+
37+
# Filesystem backend: path to the directory holding profiles.jsonl and passwords.jsonl.
38+
# Required when STORAGE_BACKEND=filesystem.
39+
CFP_PRIVATE_STORAGE_PATH=./private-storage
40+
41+
# S3 backend — only needed when STORAGE_BACKEND=s3.
42+
# S3_ENDPOINT=https://s3.us-east-1.amazonaws.com
43+
# S3_BUCKET=cfp-private-storage
44+
# S3_REGION=us-east-1
45+
# S3_ACCESS_KEY_ID=AKIAIOSFODNN7EXAMPLE
46+
# S3_SECRET_ACCESS_KEY=wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY
47+
48+
# ---------------------------------------------------------------------------
49+
# Auth
50+
# ---------------------------------------------------------------------------
51+
52+
# GitHub OAuth app credentials. Create one at https://github.com/settings/developers.
53+
# Callback URL for dev: http://localhost:3001/api/auth/github/callback
54+
# GITHUB_OAUTH_CLIENT_ID=Iv1.a1b2c3d4e5f6g7h8
55+
# GITHUB_OAUTH_CLIENT_SECRET=secret_abc123
56+
57+
# HS256 signing key for session JWTs. Generate a random string ≥ 32 chars.
58+
# In production use a securely-generated secret; in dev any 32+ char string works.
59+
CFP_JWT_SIGNING_KEY=change-me-to-a-random-string-at-least-32-chars
60+
61+
# ---------------------------------------------------------------------------
62+
# SAML IdP (Slack integration) — only needed in production
63+
# ---------------------------------------------------------------------------
64+
65+
# PEM-encoded private key for the SAML IdP certificate.
66+
# SAML_PRIVATE_KEY=-----BEGIN RSA PRIVATE KEY-----\n...\n-----END RSA PRIVATE KEY-----
67+
68+
# PEM-encoded certificate matching SAML_PRIVATE_KEY.
69+
# SAML_CERTIFICATE=-----BEGIN CERTIFICATE-----\n...\n-----END CERTIFICATE-----

apps/api/package.json

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,8 +14,16 @@
1414
"dependencies": {
1515
"@aws-sdk/client-s3": "^3.1048.0",
1616
"@cfp/shared": "^0.0.0",
17+
"@fastify/cookie": "^11.0.2",
18+
"@fastify/cors": "^11.2.0",
19+
"@fastify/env": "^6.0.0",
20+
"@fastify/rate-limit": "^10.3.0",
21+
"@fastify/swagger": "^9.7.0",
22+
"@fastify/swagger-ui": "^5.2.6",
1723
"fastify": "^5.8.5",
18-
"gitsheets": "^1.0.3"
24+
"gitsheets": "^1.0.3",
25+
"uuidv7": "^1.2.1",
26+
"zod": "^4.4.3"
1927
},
2028
"devDependencies": {
2129
"@types/node": "^25.8.0",

apps/api/src/app.ts

Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,133 @@
1+
/**
2+
* buildApp() — wires the Fastify application.
3+
*
4+
* Plugin ordering per plans/api-skeleton.md#plugin-order:
5+
* 1. @fastify/env → validates env; populates fastify.config
6+
* 2. @fastify/cors → CORS for dev SPA proxy + future cross-origin consumers
7+
* 3. @fastify/cookie → cookie parsing for session JWTs (auth-jwt-substrate plan)
8+
* 4. trace-id plugin → UUIDv7 traceId on every request
9+
* 5. setErrorHandler → single error mapper for all throws
10+
* 6. store plugin → decorates fastify.store from bootStores()
11+
* 7. rate-limit plugin → in-memory counters keyed per-IP + per-account
12+
* 8. idempotency plugin → in-memory map keyed by personId+key
13+
* 9. @fastify/swagger → OpenAPI 3.1 doc generation
14+
* 10. @fastify/swagger-ui → Swagger UI at /api/_docs
15+
* 11. routes → registered last after all plumbing
16+
*
17+
* Tests can call buildApp() with overrideEnv to inject a test environment
18+
* without requiring real filesystem paths.
19+
*/
20+
import Fastify, { type FastifyInstance, type FastifyServerOptions } from 'fastify';
21+
import fastifyEnv from '@fastify/env';
22+
import fastifyCors from '@fastify/cors';
23+
import fastifyCookie from '@fastify/cookie';
24+
import fastifySwagger from '@fastify/swagger';
25+
import fastifySwaggerUi from '@fastify/swagger-ui';
26+
27+
import { envJsonSchema, type Env } from './env.js';
28+
import { mapError } from './lib/errors.js';
29+
import traceIdPlugin from './plugins/trace-id.js';
30+
import storePlugin from './plugins/store.js';
31+
import rateLimitPlugin from './plugins/rate-limit.js';
32+
import idempotencyPlugin from './plugins/idempotency.js';
33+
import { healthRoutes } from './routes/health.js';
34+
35+
declare module 'fastify' {
36+
interface FastifyInstance {
37+
config: Env;
38+
}
39+
}
40+
41+
export interface BuildAppOptions {
42+
/**
43+
* Override environment variables for testing.
44+
* When provided, @fastify/env still validates the schema but reads from this
45+
* object instead of process.env.
46+
*/
47+
overrideEnv?: Partial<Record<string, string>>;
48+
/** Extra Fastify server options (e.g. logger: false for tests). */
49+
serverOptions?: FastifyServerOptions;
50+
}
51+
52+
export async function buildApp(opts: BuildAppOptions = {}): Promise<FastifyInstance> {
53+
const { overrideEnv, serverOptions = {} } = opts;
54+
55+
// Default logger: pretty in dev, JSON in prod.
56+
// Callers can override via serverOptions.logger.
57+
const defaultLogger: FastifyServerOptions['logger'] =
58+
process.env['NODE_ENV'] === 'production'
59+
? true
60+
: { transport: { target: 'pino-pretty' } };
61+
62+
const fastify = Fastify({
63+
logger: defaultLogger,
64+
pluginTimeout: 30_000, // gitsheets boot runs git operations which can be slow
65+
...serverOptions,
66+
genReqId: () => '', // traceId plugin handles IDs
67+
});
68+
69+
// ----- 1. Env validation -----
70+
await fastify.register(fastifyEnv, {
71+
schema: envJsonSchema,
72+
data: overrideEnv ?? process.env,
73+
dotenv: false,
74+
});
75+
76+
// ----- 2. CORS -----
77+
await fastify.register(fastifyCors, {
78+
origin: fastify.config.NODE_ENV === 'production' ? false : true,
79+
credentials: true,
80+
});
81+
82+
// ----- 3. Cookie parsing -----
83+
await fastify.register(fastifyCookie);
84+
85+
// ----- 4. Trace ID (UUIDv7 on every request) -----
86+
await fastify.register(traceIdPlugin);
87+
88+
// ----- 5. Error mapper -----
89+
fastify.setErrorHandler(mapError);
90+
91+
// ----- 6. Store (boots gitsheets + private-store) -----
92+
await fastify.register(storePlugin);
93+
94+
// ----- 7. Rate limiting -----
95+
await fastify.register(rateLimitPlugin);
96+
97+
// ----- 8. Idempotency -----
98+
await fastify.register(idempotencyPlugin);
99+
100+
// ----- 9-10. OpenAPI / Swagger UI -----
101+
await fastify.register(fastifySwagger, {
102+
openapi: {
103+
openapi: '3.1.0',
104+
info: {
105+
title: 'CodeForPhilly API',
106+
description: 'The codeforphilly.org API. See specs/api/ for the authoritative spec.',
107+
version: '1.0.0',
108+
},
109+
servers: [{ url: '/api', description: 'API base' }],
110+
tags: [{ name: 'health', description: 'Health check' }],
111+
},
112+
prefix: '/api',
113+
});
114+
115+
await fastify.register(fastifySwaggerUi, {
116+
routePrefix: '/api/_docs',
117+
uiConfig: {
118+
docExpansion: 'list',
119+
deepLinking: false,
120+
},
121+
});
122+
123+
// ----- 11. Routes -----
124+
await fastify.register(healthRoutes);
125+
126+
// Serve the OpenAPI JSON at the spec-mandated path /api/_openapi.json
127+
// (swagger-ui also exposes it at /api/_docs/json, but the spec names this path)
128+
fastify.get('/api/_openapi.json', { schema: { hide: true } }, (_req, reply) => {
129+
return reply.send(fastify.swagger());
130+
});
131+
132+
return fastify;
133+
}

apps/api/src/env.ts

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
/**
2+
* Environment schema and config type.
3+
*
4+
* This is the ONLY place that reads process.env. All other modules read
5+
* fastify.config.<FIELD> after @fastify/env has validated and populated it.
6+
*/
7+
import { z } from 'zod';
8+
9+
export const EnvSchema = z.object({
10+
/** TCP port the Fastify server listens on. */
11+
PORT: z.coerce.number().default(3001),
12+
/** Runtime mode — controls logger format, cookie Secure flag, etc. */
13+
NODE_ENV: z.enum(['development', 'test', 'production']).default('development'),
14+
/** Absolute path to the gitsheets public data repo working tree. */
15+
CFP_DATA_REPO_PATH: z.string(),
16+
/** Git remote URL to push public data commits to (optional in dev). */
17+
CFP_DATA_REMOTE: z.string().optional(),
18+
/** Which private-storage backend to use. */
19+
STORAGE_BACKEND: z.enum(['s3', 'filesystem']),
20+
/** Filesystem backend: absolute path to the private-storage directory. */
21+
CFP_PRIVATE_STORAGE_PATH: z.string().optional(),
22+
/** S3 endpoint URL (required when STORAGE_BACKEND=s3). */
23+
S3_ENDPOINT: z.string().optional(),
24+
/** S3 bucket name (required when STORAGE_BACKEND=s3). */
25+
S3_BUCKET: z.string().optional(),
26+
/** S3 region (required when STORAGE_BACKEND=s3). */
27+
S3_REGION: z.string().optional(),
28+
/** S3 access key ID (required when STORAGE_BACKEND=s3). */
29+
S3_ACCESS_KEY_ID: z.string().optional(),
30+
/** S3 secret access key (required when STORAGE_BACKEND=s3). */
31+
S3_SECRET_ACCESS_KEY: z.string().optional(),
32+
/** GitHub OAuth app client ID. */
33+
GITHUB_OAUTH_CLIENT_ID: z.string().optional(),
34+
/** GitHub OAuth app client secret. */
35+
GITHUB_OAUTH_CLIENT_SECRET: z.string().optional(),
36+
/** HS256 signing key for session JWTs — min 32 chars in production. */
37+
CFP_JWT_SIGNING_KEY: z.string().min(1),
38+
/** SAML IdP private key (PEM) for the Slack SAML integration. */
39+
SAML_PRIVATE_KEY: z.string().optional(),
40+
/** SAML IdP certificate (PEM) for the Slack SAML integration. */
41+
SAML_CERTIFICATE: z.string().optional(),
42+
});
43+
44+
export type Env = z.infer<typeof EnvSchema>;
45+
46+
/**
47+
* JSON Schema representation of EnvSchema for @fastify/env.
48+
* @fastify/env expects a JSON Schema object, not a Zod schema.
49+
*/
50+
export const envJsonSchema = {
51+
type: 'object',
52+
required: ['CFP_DATA_REPO_PATH', 'STORAGE_BACKEND', 'CFP_JWT_SIGNING_KEY'],
53+
properties: {
54+
PORT: { type: 'number', default: 3001 },
55+
NODE_ENV: {
56+
type: 'string',
57+
enum: ['development', 'test', 'production'],
58+
default: 'development',
59+
},
60+
CFP_DATA_REPO_PATH: { type: 'string' },
61+
CFP_DATA_REMOTE: { type: 'string' },
62+
STORAGE_BACKEND: { type: 'string', enum: ['s3', 'filesystem'] },
63+
CFP_PRIVATE_STORAGE_PATH: { type: 'string' },
64+
S3_ENDPOINT: { type: 'string' },
65+
S3_BUCKET: { type: 'string' },
66+
S3_REGION: { type: 'string' },
67+
S3_ACCESS_KEY_ID: { type: 'string' },
68+
S3_SECRET_ACCESS_KEY: { type: 'string' },
69+
GITHUB_OAUTH_CLIENT_ID: { type: 'string' },
70+
GITHUB_OAUTH_CLIENT_SECRET: { type: 'string' },
71+
CFP_JWT_SIGNING_KEY: { type: 'string', minLength: 1 },
72+
SAML_PRIVATE_KEY: { type: 'string' },
73+
SAML_CERTIFICATE: { type: 'string' },
74+
},
75+
} as const;

apps/api/src/index.ts

Lines changed: 14 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,21 @@
1-
import Fastify from 'fastify';
1+
/**
2+
* API entry point.
3+
*
4+
* Calls buildApp() to construct the Fastify instance with all plugins and
5+
* routes registered, then starts listening. All configuration is handled
6+
* inside buildApp() via @fastify/env — this file reads nothing from process.env
7+
* directly.
8+
*/
9+
import { buildApp } from './app.js';
210

3-
const PORT = Number(process.env.PORT ?? 3001);
4-
const HOST = process.env.HOST ?? '0.0.0.0';
11+
const fastify = await buildApp();
512

6-
const app = Fastify({
7-
logger:
8-
process.env.NODE_ENV === 'production'
9-
? true
10-
: { transport: { target: 'pino-pretty' } },
11-
});
12-
13-
app.get('/api/health', () => ({ status: 'ok' }));
13+
const PORT = Number(process.env['PORT'] ?? 3001);
14+
const HOST = process.env['HOST'] ?? '0.0.0.0';
1415

1516
try {
16-
await app.listen({ port: PORT, host: HOST });
17+
await fastify.listen({ port: PORT, host: HOST });
1718
} catch (err) {
18-
app.log.error(err);
19+
fastify.log.error(err);
1920
process.exit(1);
2021
}

0 commit comments

Comments
 (0)