Skip to content

Commit 10c7476

Browse files
authored
feat: add API safety gates (#28)
* fix: use Prisma Accelerate in production * chore: align Node support with Accelerate * fix: unblock CI lint and migration flow * chore: add repo git hooks * chore: check build before push * fix: harden pre-push temp database cleanup * fix: allow direct postgres in production * feat: add api safety gates * fix: harden rate limiting and hooks * fix: guard pre-push against remote databases
1 parent fa645bb commit 10c7476

14 files changed

Lines changed: 520 additions & 9 deletions

File tree

.env.example

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,3 +2,13 @@ DATABASE_URL="postgresql://postgres:postgres@localhost:5432/locations_api"
22
DIRECT_DATABASE_URL="postgresql://postgres:postgres@localhost:5432/locations_api"
33
PORT="8080"
44
PAGE_SIZE="10"
5+
REQUEST_BODY_LIMIT="16kb"
6+
# TRUST_PROXY="loopback, linklocal, uniquelocal"
7+
RATE_LIMIT_WINDOW_MS="60000"
8+
RATE_LIMIT_MAX_REQUESTS="120"
9+
RATE_LIMIT_BURST_WINDOW_MS="10000"
10+
RATE_LIMIT_BURST_MAX_REQUESTS="30"
11+
SEARCH_RATE_LIMIT_WINDOW_MS="60000"
12+
SEARCH_RATE_LIMIT_MAX_REQUESTS="30"
13+
SEARCH_RATE_LIMIT_BURST_WINDOW_MS="10000"
14+
SEARCH_RATE_LIMIT_BURST_MAX_REQUESTS="10"

.githooks/pre-commit

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
#!/bin/sh
2+
set -eu
3+
4+
if [ "${SKIP_GIT_HOOKS:-0}" = "1" ]; then
5+
exit 0
6+
fi
7+
8+
pnpm hooks:pre-commit

.githooks/pre-push

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
#!/bin/sh
2+
set -eu
3+
4+
if [ "${SKIP_GIT_HOOKS:-0}" = "1" ]; then
5+
exit 0
6+
fi
7+
8+
pnpm hooks:pre-push

README.md

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ Compatibility-first REST API for Tanzania location data backed by PostgreSQL and
3535
- Local and test environments use a direct PostgreSQL `DATABASE_URL`.
3636
- Production can use either a direct PostgreSQL `DATABASE_URL` or a Prisma Accelerate `DATABASE_URL`.
3737
- If `DATABASE_URL` points at Prisma Accelerate, also provide `DIRECT_DATABASE_URL` so migrations can talk to Postgres directly.
38+
- Legacy environments that already use `DIRECT_URL` are still accepted as a fallback for direct Postgres access.
3839

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

@@ -64,6 +65,27 @@ pnpm test:ci
6465
pnpm openapi:json
6566
```
6667

68+
## Runtime Protection
69+
70+
- API routes are protected by per-IP rate limits with both sustained and burst thresholds
71+
- `/search` has a stricter limit than the rest of the API because it is the easiest expensive endpoint to abuse
72+
- Request bodies are capped with `REQUEST_BODY_LIMIT`, even though the public API is mostly read-only
73+
- 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
74+
- All limits are configurable with environment variables:
75+
76+
```bash
77+
REQUEST_BODY_LIMIT=16kb
78+
TRUST_PROXY="loopback, linklocal, uniquelocal"
79+
RATE_LIMIT_WINDOW_MS=60000
80+
RATE_LIMIT_MAX_REQUESTS=120
81+
RATE_LIMIT_BURST_WINDOW_MS=10000
82+
RATE_LIMIT_BURST_MAX_REQUESTS=30
83+
SEARCH_RATE_LIMIT_WINDOW_MS=60000
84+
SEARCH_RATE_LIMIT_MAX_REQUESTS=30
85+
SEARCH_RATE_LIMIT_BURST_WINDOW_MS=10000
86+
SEARCH_RATE_LIMIT_BURST_MAX_REQUESTS=10
87+
```
88+
6789
## Migration Behavior
6890

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

7699
## Testing
77100

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

180+
## Git Hooks
181+
182+
- `pnpm prepare` and `pnpm hooks:install` configure `core.hooksPath` to `.githooks`
183+
- Pre-commit runs `pnpm hooks:pre-commit` (`lint` + `typecheck`)
184+
- Pre-push runs `pnpm hooks:pre-push`, which first builds the app, then creates a temporary Postgres database and runs `pnpm test:ci`
185+
- Pre-push requires `DIRECT_DATABASE_URL` or legacy `DIRECT_URL` to be a direct PostgreSQL URL
186+
- 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
157187
## License
158188

159189
This project is licensed under the CopyLeft License. See [LICENSE](./LICENSE).

package.json

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,9 +9,13 @@
99
},
1010
"scripts": {
1111
"dev": "tsx watch server.ts",
12+
"prepare": "tsx scripts/install-git-hooks.ts",
1213
"generate": "prisma generate",
1314
"db:migrate": "tsx scripts/migrate.ts",
1415
"db:seed": "prisma db seed",
16+
"hooks:install": "tsx scripts/install-git-hooks.ts",
17+
"hooks:pre-commit": "pnpm lint && pnpm typecheck",
18+
"hooks:pre-push": "pnpm build && tsx scripts/run-pre-push-checks.ts",
1519
"lint": "pnpm generate && eslint server.ts \"src/**/*.ts\" \"tests/**/*.ts\" \"scripts/**/*.ts\" \"prisma/**/*.ts\"",
1620
"typecheck": "pnpm generate && tsc --noEmit",
1721
"build:ci": "pnpm generate && pnpm lint && pnpm typecheck && pnpm build",

prisma.config.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ export default defineConfig({
1010
datasource: {
1111
url:
1212
process.env.DIRECT_DATABASE_URL ??
13+
process.env.DIRECT_URL ??
1314
process.env.DATABASE_URL ??
1415
'postgresql://postgres:postgres@localhost:5432/locations_api',
1516
},

scripts/install-git-hooks.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
import { execFileSync } from 'node:child_process';
2+
import { existsSync } from 'node:fs';
3+
import path from 'node:path';
4+
5+
const repoRoot = process.cwd();
6+
const gitDir = path.join(repoRoot, '.git');
7+
8+
if (!existsSync(gitDir)) {
9+
process.exit(0);
10+
}
11+
12+
execFileSync('git', ['config', 'core.hooksPath', '.githooks'], {
13+
cwd: repoRoot,
14+
stdio: 'inherit',
15+
});
16+
17+
console.log('Configured git hooks path to .githooks');

scripts/migrate.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ const pnpmCommand = process.platform === 'win32' ? 'pnpm.cmd' : 'pnpm';
66
const directDatabaseUrl = config.directDatabaseUrl;
77

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

1212
function runPrisma(args: string[]) {

scripts/run-pre-push-checks.ts

Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
1+
import { execFileSync } from 'node:child_process';
2+
import { randomUUID } from 'node:crypto';
3+
import dotenv from 'dotenv';
4+
import { Pool } from 'pg';
5+
6+
const pnpmCommand = process.platform === 'win32' ? 'pnpm.cmd' : 'pnpm';
7+
8+
dotenv.config();
9+
10+
function resolveDirectDatabaseUrl() {
11+
const candidate = process.env.DIRECT_DATABASE_URL ?? process.env.DIRECT_URL;
12+
13+
if (!candidate) {
14+
throw new Error('Set DIRECT_DATABASE_URL or legacy DIRECT_URL in your shell or .env before pushing.');
15+
}
16+
17+
if (candidate.startsWith('prisma://') || candidate.startsWith('prisma+postgres://')) {
18+
throw new Error('Pre-push checks require DIRECT_DATABASE_URL or DIRECT_URL to point at direct PostgreSQL, not Prisma Accelerate.');
19+
}
20+
21+
return new URL(candidate);
22+
}
23+
24+
function isLocalDatabaseHost(hostname: string) {
25+
return hostname === 'localhost' || hostname === '127.0.0.1' || hostname === '::1';
26+
}
27+
28+
function tempDatabaseUrl(baseUrl: URL, databaseName: string) {
29+
const next = new URL(baseUrl.toString());
30+
next.pathname = `/${databaseName}`;
31+
32+
return next.toString();
33+
}
34+
35+
function adminDatabaseUrl(baseUrl: URL) {
36+
const next = new URL(baseUrl.toString());
37+
next.pathname = '/postgres';
38+
39+
return next.toString();
40+
}
41+
42+
function quoteIdentifier(value: string) {
43+
return `"${value.replaceAll('"', '""')}"`;
44+
}
45+
46+
function toError(error: unknown) {
47+
if (error instanceof Error) {
48+
return error;
49+
}
50+
51+
return new Error(String(error));
52+
}
53+
54+
function runPnpm(args: string[], env: NodeJS.ProcessEnv) {
55+
execFileSync(pnpmCommand, args, {
56+
env,
57+
stdio: 'inherit',
58+
});
59+
}
60+
61+
async function dropTemporaryDatabase(pool: Pool, databaseName: string) {
62+
await pool.query(
63+
`SELECT pg_terminate_backend(pid)
64+
FROM pg_stat_activity
65+
WHERE datname = $1
66+
AND pid <> pg_backend_pid()`,
67+
[databaseName],
68+
);
69+
await pool.query(`DROP DATABASE IF EXISTS ${quoteIdentifier(databaseName)}`);
70+
}
71+
72+
async function main() {
73+
const directUrl = resolveDirectDatabaseUrl();
74+
75+
if (!isLocalDatabaseHost(directUrl.hostname) && process.env.ALLOW_REMOTE_PREPUSH_DB !== '1') {
76+
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.');
77+
}
78+
79+
const originalDatabase = directUrl.pathname.replace(/^\//, '') || 'locations_api';
80+
const tempDatabaseName = `${originalDatabase}_prepush_${randomUUID().replace(/-/g, '').slice(0, 8)}`;
81+
const isolatedDatabaseUrl = tempDatabaseUrl(directUrl, tempDatabaseName);
82+
const adminPool = new Pool({
83+
connectionString: adminDatabaseUrl(directUrl),
84+
});
85+
86+
let primaryError: unknown;
87+
88+
try {
89+
await adminPool.query(`CREATE DATABASE ${quoteIdentifier(tempDatabaseName)}`);
90+
91+
runPnpm(['test:ci'], {
92+
...process.env,
93+
DATABASE_URL: isolatedDatabaseUrl,
94+
DIRECT_DATABASE_URL: isolatedDatabaseUrl,
95+
NODE_ENV: 'test',
96+
});
97+
} catch (error) {
98+
primaryError = error;
99+
}
100+
101+
try {
102+
await dropTemporaryDatabase(adminPool, tempDatabaseName);
103+
} catch (cleanupError) {
104+
if (primaryError) {
105+
console.error('Failed to drop temporary pre-push database after the primary failure.');
106+
console.error(cleanupError);
107+
} else {
108+
throw cleanupError;
109+
}
110+
} finally {
111+
await adminPool.end();
112+
}
113+
114+
if (primaryError) {
115+
throw toError(primaryError);
116+
}
117+
}
118+
119+
await main();

src/app.ts

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,13 +7,24 @@ import config from './config.js';
77
import { checkDatabaseConnection } from './db/prisma.js';
88
import { setupSwagger } from './docs/swagger.js';
99
import { errorHandler } from './middleware/errorHandler.js';
10+
import { createRateLimiter } from './middleware/rateLimit.js';
1011
import {
1112
apiCompatibilityHeaders,
1213
attachRequestContext,
1314
} from './middleware/requestContext.js';
1415
import routes from './routes.js';
1516

1617
const app = express();
18+
const apiRateLimiter = createRateLimiter({
19+
...config.rateLimit,
20+
name: 'api',
21+
});
22+
const searchRateLimiter = createRateLimiter({
23+
...config.searchRateLimit,
24+
name: 'search',
25+
});
26+
27+
app.set('trust proxy', config.trustProxy);
1728

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

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

37-
app.use(express.json());
38-
app.use(express.urlencoded({ extended: true }));
48+
app.use(express.json({ limit: config.requestBodyLimit }));
49+
app.use(express.urlencoded({ extended: true, limit: config.requestBodyLimit }));
50+
51+
app.use(['/v1', '/api', '/openapi.json', '/api-docs'], apiRateLimiter);
52+
app.use(['/v1/search', '/api/search'], searchRateLimiter);
3953

4054
app.get('/health', async (_: Request, res: Response) => {
4155
const database = await checkDatabaseConnection({ logErrors: false });

0 commit comments

Comments
 (0)