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
10 changes: 10 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -2,3 +2,13 @@ DATABASE_URL="postgresql://postgres:postgres@localhost:5432/locations_api"
DIRECT_DATABASE_URL="postgresql://postgres:postgres@localhost:5432/locations_api"
PORT="8080"
PAGE_SIZE="10"
REQUEST_BODY_LIMIT="16kb"
# TRUST_PROXY="loopback, linklocal, uniquelocal"
RATE_LIMIT_WINDOW_MS="60000"
RATE_LIMIT_MAX_REQUESTS="120"
RATE_LIMIT_BURST_WINDOW_MS="10000"
RATE_LIMIT_BURST_MAX_REQUESTS="30"
SEARCH_RATE_LIMIT_WINDOW_MS="60000"
SEARCH_RATE_LIMIT_MAX_REQUESTS="30"
SEARCH_RATE_LIMIT_BURST_WINDOW_MS="10000"
SEARCH_RATE_LIMIT_BURST_MAX_REQUESTS="10"
8 changes: 8 additions & 0 deletions .githooks/pre-commit
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
#!/bin/sh
set -eu

if [ "${SKIP_GIT_HOOKS:-0}" = "1" ]; then
exit 0
fi

pnpm hooks:pre-commit
8 changes: 8 additions & 0 deletions .githooks/pre-push
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
#!/bin/sh
set -eu

if [ "${SKIP_GIT_HOOKS:-0}" = "1" ]; then
exit 0
fi

pnpm hooks:pre-push
30 changes: 30 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ Compatibility-first REST API for Tanzania location data backed by PostgreSQL and
- Local and test environments use a direct PostgreSQL `DATABASE_URL`.
- Production can use either a direct PostgreSQL `DATABASE_URL` or a Prisma Accelerate `DATABASE_URL`.
- If `DATABASE_URL` points at Prisma Accelerate, also provide `DIRECT_DATABASE_URL` so migrations can talk to Postgres directly.
- Legacy environments that already use `DIRECT_URL` are still accepted as a fallback for direct Postgres access.

4. Apply the checked-in schema and seed deterministic fixture data.

Expand Down Expand Up @@ -64,6 +65,27 @@ pnpm test:ci
pnpm openapi:json
```

## Runtime Protection

- API routes are protected by per-IP rate limits with both sustained and burst thresholds
- `/search` has a stricter limit than the rest of the API because it is the easiest expensive endpoint to abuse
- Request bodies are capped with `REQUEST_BODY_LIMIT`, even though the public API is mostly read-only
- Rate limiting keys off Express `req.ip`; if you deploy behind a trusted proxy/load balancer, set `TRUST_PROXY` so Express resolves the real client IP correctly
- All limits are configurable with environment variables:

```bash
REQUEST_BODY_LIMIT=16kb
TRUST_PROXY="loopback, linklocal, uniquelocal"
RATE_LIMIT_WINDOW_MS=60000
RATE_LIMIT_MAX_REQUESTS=120
RATE_LIMIT_BURST_WINDOW_MS=10000
RATE_LIMIT_BURST_MAX_REQUESTS=30
SEARCH_RATE_LIMIT_WINDOW_MS=60000
SEARCH_RATE_LIMIT_MAX_REQUESTS=30
SEARCH_RATE_LIMIT_BURST_WINDOW_MS=10000
SEARCH_RATE_LIMIT_BURST_MAX_REQUESTS=10
```

## Migration Behavior

- `pnpm db:migrate` is the supported entrypoint for schema changes in this repo
Expand All @@ -72,6 +94,7 @@ pnpm openapi:json
- Prefer `pnpm db:migrate` over calling `prisma migrate deploy` directly
- `DATABASE_URL` may point at direct Postgres or Prisma Accelerate
- If `DATABASE_URL` points at Prisma Accelerate, `pnpm db:migrate` still requires a direct Postgres URL in `DIRECT_DATABASE_URL`
- `DIRECT_URL` remains supported as a legacy alias for `DIRECT_DATABASE_URL`

## Testing

Expand Down Expand Up @@ -154,6 +177,13 @@ Additional filters:
- `.github/dependabot.yml` opens weekly update PRs for npm packages and GitHub Actions
- `.github/workflows/ci.yml` validates every PR against Postgres on Node `22.13.0`

## Git Hooks

- `pnpm prepare` and `pnpm hooks:install` configure `core.hooksPath` to `.githooks`
- Pre-commit runs `pnpm hooks:pre-commit` (`lint` + `typecheck`)
- Pre-push runs `pnpm hooks:pre-push`, which first builds the app, then creates a temporary Postgres database and runs `pnpm test:ci`
- Pre-push requires `DIRECT_DATABASE_URL` or legacy `DIRECT_URL` to be a direct PostgreSQL URL
- Pre-push refuses non-local databases by default; set `ALLOW_REMOTE_PREPUSH_DB=1` only if you intentionally want hook verification against a remote direct Postgres instance
## License

This project is licensed under the CopyLeft License. See [LICENSE](./LICENSE).
4 changes: 4 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,13 @@
},
"scripts": {
"dev": "tsx watch server.ts",
"prepare": "tsx scripts/install-git-hooks.ts",
"generate": "prisma generate",
"db:migrate": "tsx scripts/migrate.ts",
"db:seed": "prisma db seed",
"hooks:install": "tsx scripts/install-git-hooks.ts",
"hooks:pre-commit": "pnpm lint && pnpm typecheck",
"hooks:pre-push": "pnpm build && tsx scripts/run-pre-push-checks.ts",
"lint": "pnpm generate && eslint server.ts \"src/**/*.ts\" \"tests/**/*.ts\" \"scripts/**/*.ts\" \"prisma/**/*.ts\"",
"typecheck": "pnpm generate && tsc --noEmit",
"build:ci": "pnpm generate && pnpm lint && pnpm typecheck && pnpm build",
Expand Down
1 change: 1 addition & 0 deletions prisma.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ export default defineConfig({
datasource: {
url:
process.env.DIRECT_DATABASE_URL ??
process.env.DIRECT_URL ??
process.env.DATABASE_URL ??
'postgresql://postgres:postgres@localhost:5432/locations_api',
},
Expand Down
17 changes: 17 additions & 0 deletions scripts/install-git-hooks.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import { execFileSync } from 'node:child_process';
import { existsSync } from 'node:fs';
import path from 'node:path';

const repoRoot = process.cwd();
const gitDir = path.join(repoRoot, '.git');

if (!existsSync(gitDir)) {
process.exit(0);
}

execFileSync('git', ['config', 'core.hooksPath', '.githooks'], {
cwd: repoRoot,
stdio: 'inherit',
});

console.log('Configured git hooks path to .githooks');
2 changes: 1 addition & 1 deletion scripts/migrate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ const pnpmCommand = process.platform === 'win32' ? 'pnpm.cmd' : 'pnpm';
const directDatabaseUrl = config.directDatabaseUrl;

if (!directDatabaseUrl) {
throw new Error('db:migrate requires DIRECT_DATABASE_URL when DATABASE_URL uses Prisma Accelerate.');
throw new Error('db:migrate requires DIRECT_DATABASE_URL or legacy DIRECT_URL when DATABASE_URL uses Prisma Accelerate.');
}

function runPrisma(args: string[]) {
Expand Down
119 changes: 119 additions & 0 deletions scripts/run-pre-push-checks.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
import { execFileSync } from 'node:child_process';
import { randomUUID } from 'node:crypto';
import dotenv from 'dotenv';
import { Pool } from 'pg';

const pnpmCommand = process.platform === 'win32' ? 'pnpm.cmd' : 'pnpm';

dotenv.config();

function resolveDirectDatabaseUrl() {
const candidate = process.env.DIRECT_DATABASE_URL ?? process.env.DIRECT_URL;

if (!candidate) {
throw new Error('Set DIRECT_DATABASE_URL or legacy DIRECT_URL in your shell or .env before pushing.');
}

if (candidate.startsWith('prisma://') || candidate.startsWith('prisma+postgres://')) {
throw new Error('Pre-push checks require DIRECT_DATABASE_URL or DIRECT_URL to point at direct PostgreSQL, not Prisma Accelerate.');
}

return new URL(candidate);
}

function isLocalDatabaseHost(hostname: string) {
return hostname === 'localhost' || hostname === '127.0.0.1' || hostname === '::1';
}

function tempDatabaseUrl(baseUrl: URL, databaseName: string) {
const next = new URL(baseUrl.toString());
next.pathname = `/${databaseName}`;

return next.toString();
}

function adminDatabaseUrl(baseUrl: URL) {
const next = new URL(baseUrl.toString());
next.pathname = '/postgres';

return next.toString();
}

function quoteIdentifier(value: string) {
return `"${value.replaceAll('"', '""')}"`;
}

function toError(error: unknown) {
if (error instanceof Error) {
return error;
}

return new Error(String(error));
}

function runPnpm(args: string[], env: NodeJS.ProcessEnv) {
execFileSync(pnpmCommand, args, {
env,
stdio: 'inherit',
});
}

async function dropTemporaryDatabase(pool: Pool, databaseName: string) {
await pool.query(
`SELECT pg_terminate_backend(pid)
FROM pg_stat_activity
WHERE datname = $1
AND pid <> pg_backend_pid()`,
[databaseName],
);
await pool.query(`DROP DATABASE IF EXISTS ${quoteIdentifier(databaseName)}`);
}

async function main() {
const directUrl = resolveDirectDatabaseUrl();

if (!isLocalDatabaseHost(directUrl.hostname) && process.env.ALLOW_REMOTE_PREPUSH_DB !== '1') {
throw new Error('Pre-push checks refuse to use non-local databases by default. Set ALLOW_REMOTE_PREPUSH_DB=1 if you really want that.');
}

const originalDatabase = directUrl.pathname.replace(/^\//, '') || 'locations_api';
const tempDatabaseName = `${originalDatabase}_prepush_${randomUUID().replace(/-/g, '').slice(0, 8)}`;
const isolatedDatabaseUrl = tempDatabaseUrl(directUrl, tempDatabaseName);
const adminPool = new Pool({
connectionString: adminDatabaseUrl(directUrl),
});

let primaryError: unknown;

try {
await adminPool.query(`CREATE DATABASE ${quoteIdentifier(tempDatabaseName)}`);

runPnpm(['test:ci'], {
...process.env,
DATABASE_URL: isolatedDatabaseUrl,
DIRECT_DATABASE_URL: isolatedDatabaseUrl,
NODE_ENV: 'test',
});
} catch (error) {
primaryError = error;
}

try {
await dropTemporaryDatabase(adminPool, tempDatabaseName);
} catch (cleanupError) {
if (primaryError) {
console.error('Failed to drop temporary pre-push database after the primary failure.');
console.error(cleanupError);
} else {
throw cleanupError;
}
} finally {
await adminPool.end();
}

if (primaryError) {
throw toError(primaryError);
}
}

await main();
18 changes: 16 additions & 2 deletions src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,13 +7,24 @@ import config from './config.js';
import { checkDatabaseConnection } from './db/prisma.js';
import { setupSwagger } from './docs/swagger.js';
import { errorHandler } from './middleware/errorHandler.js';
import { createRateLimiter } from './middleware/rateLimit.js';
import {
apiCompatibilityHeaders,
attachRequestContext,
} from './middleware/requestContext.js';
import routes from './routes.js';

const app = express();
const apiRateLimiter = createRateLimiter({
...config.rateLimit,
name: 'api',
});
const searchRateLimiter = createRateLimiter({
...config.searchRateLimit,
name: 'search',
});

app.set('trust proxy', config.trustProxy);

morgan.token('request-id', (req) => (req as Request).requestId ?? '-');

Expand All @@ -34,8 +45,11 @@ app.disable('x-powered-by');
app.use(attachRequestContext);
app.use(morgan(logFormatter));

app.use(express.json());
app.use(express.urlencoded({ extended: true }));
app.use(express.json({ limit: config.requestBodyLimit }));
app.use(express.urlencoded({ extended: true, limit: config.requestBodyLimit }));

app.use(['/v1', '/api', '/openapi.json', '/api-docs'], apiRateLimiter);
app.use(['/v1/search', '/api/search'], searchRateLimiter);

app.get('/health', async (_: Request, res: Response) => {
const database = await checkDatabaseConnection({ logErrors: false });
Expand Down
70 changes: 65 additions & 5 deletions src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,28 +7,88 @@ function isAccelerateUrl(url: string) {
return url.startsWith('prisma://') || url.startsWith('prisma+postgres://');
}

function parseTrustProxy(value?: string) {
if (!value) {
return false;
}

const trimmed = value.trim();

if (trimmed === 'true') {
return true;
}

if (trimmed === 'false') {
return false;
}

if (/^\d+$/.test(trimmed)) {
return Number(trimmed);
}

if (trimmed.includes(',')) {
return trimmed
.split(',')
.map((entry) => entry.trim())
.filter(Boolean);
}

return trimmed;
}

const envSchema = z.object({
DATABASE_URL: z.string().min(1, 'DATABASE_URL is required'),
DATABASE_URL: z.string().min(1, 'DATABASE_URL cannot be empty').optional(),
DIRECT_DATABASE_URL: z.string().min(1, 'DIRECT_DATABASE_URL cannot be empty').optional(),
DIRECT_URL: z.string().min(1, 'DIRECT_URL cannot be empty').optional(),
NODE_ENV: z.enum(['development', 'test', 'production']).default('development'),
PAGE_SIZE: z.coerce.number().int().positive().max(100).default(10),
PORT: z.coerce.number().int().positive().default(8080),
REQUEST_BODY_LIMIT: z.string().trim().min(1).default('16kb'),
TRUST_PROXY: z.string().trim().min(1).optional(),
RATE_LIMIT_WINDOW_MS: z.coerce.number().int().positive().default(60_000),
RATE_LIMIT_MAX_REQUESTS: z.coerce.number().int().positive().default(120),
RATE_LIMIT_BURST_WINDOW_MS: z.coerce.number().int().positive().default(10_000),
RATE_LIMIT_BURST_MAX_REQUESTS: z.coerce.number().int().positive().default(30),
SEARCH_RATE_LIMIT_WINDOW_MS: z.coerce.number().int().positive().default(60_000),
SEARCH_RATE_LIMIT_MAX_REQUESTS: z.coerce.number().int().positive().default(30),
SEARCH_RATE_LIMIT_BURST_WINDOW_MS: z.coerce.number().int().positive().default(10_000),
SEARCH_RATE_LIMIT_BURST_MAX_REQUESTS: z.coerce.number().int().positive().default(10),
});

const env = envSchema.parse(process.env);
const usesAccelerate = isAccelerateUrl(env.DATABASE_URL);
const directDatabaseUrl = env.DIRECT_DATABASE_URL ?? (usesAccelerate ? undefined : env.DATABASE_URL);
const databaseUrl = env.DATABASE_URL ?? env.DIRECT_DATABASE_URL ?? env.DIRECT_URL;

if (!databaseUrl) {
throw new Error('DATABASE_URL is required. DIRECT_DATABASE_URL or legacy DIRECT_URL may be used as a fallback.');
}

const usesAccelerate = isAccelerateUrl(databaseUrl);
const directDatabaseUrl = env.DIRECT_DATABASE_URL ?? env.DIRECT_URL ?? (usesAccelerate ? undefined : databaseUrl);

if (env.NODE_ENV !== 'production' && !directDatabaseUrl) {
throw new Error('Non-production requires a direct PostgreSQL URL via DIRECT_DATABASE_URL or DATABASE_URL.');
throw new Error('Non-production requires a direct PostgreSQL URL via DIRECT_DATABASE_URL, DIRECT_URL, or DATABASE_URL.');
}

const config = {
databaseUrl: env.DATABASE_URL,
databaseUrl,
directDatabaseUrl,
nodeEnv: env.NODE_ENV,
pageSize: env.PAGE_SIZE,
port: env.PORT,
requestBodyLimit: env.REQUEST_BODY_LIMIT,
trustProxy: parseTrustProxy(env.TRUST_PROXY),
rateLimit: {
burstMaxRequests: env.RATE_LIMIT_BURST_MAX_REQUESTS,
burstWindowMs: env.RATE_LIMIT_BURST_WINDOW_MS,
maxRequests: env.RATE_LIMIT_MAX_REQUESTS,
windowMs: env.RATE_LIMIT_WINDOW_MS,
},
searchRateLimit: {
burstMaxRequests: env.SEARCH_RATE_LIMIT_BURST_MAX_REQUESTS,
burstWindowMs: env.SEARCH_RATE_LIMIT_BURST_WINDOW_MS,
maxRequests: env.SEARCH_RATE_LIMIT_MAX_REQUESTS,
windowMs: env.SEARCH_RATE_LIMIT_WINDOW_MS,
},
usesAccelerate,
};

Expand Down
Loading