server: zone-labels API (Fastify + SQLite) — Phase A

New server/ service classifying zones as free_2h/3h/4h street vs pay_immediate
lots. Public reads; writes require the admin bearer token (timing-safe compare).
@fastify/rate-limit (120/min global, 20/min writes), manual IP denylist +
auto-block on repeated auth failures. SQLite via better-sqlite3. Dockerfile +
compose (loopback-only, mem/cpu capped) + nginx block + README. 9 tests pass.

Deployed live at https://bigbrainparking.mowden.top (behind nginx + certbot).
Not an npm workspace — kept out of the app/CI install to avoid the native dep.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Erik 2026-07-24 18:09:03 +00:00
parent 2434879804
commit c14081d85f
14 changed files with 2306 additions and 0 deletions

7
server/.env.example Normal file
View file

@ -0,0 +1,7 @@
# Copy to .env and set a long random secret (>= 16 chars). This is the admin
# password you paste into the app's Settings → Admin. Generate one with:
# openssl rand -base64 32
BBP_ADMIN_TOKEN=change-me-to-a-long-random-secret
# Optional: comma-separated IPs to reject outright.
# BBP_BLOCKED_IPS=1.2.3.4,5.6.7.8

5
server/.gitignore vendored Normal file
View file

@ -0,0 +1,5 @@
node_modules/
dist/
.env
data/
*.log

23
server/Dockerfile Normal file
View file

@ -0,0 +1,23 @@
# Build stage — compiles TS and builds better-sqlite3's native addon.
FROM node:22-bookworm-slim AS build
WORKDIR /app
RUN apt-get update \
&& apt-get install -y --no-install-recommends python3 make g++ ca-certificates \
&& rm -rf /var/lib/apt/lists/*
COPY package.json ./
RUN npm install --no-audit --no-fund
COPY tsconfig.json ./
COPY src ./src
RUN npm run build && npm prune --omit=dev
# Runtime stage — same base (binary-compatible native addon), no build tools.
FROM node:22-bookworm-slim AS runtime
WORKDIR /app
ENV NODE_ENV=production
COPY --from=build /app/node_modules ./node_modules
COPY --from=build /app/dist ./dist
COPY package.json ./
RUN mkdir -p /data && chown -R node:node /data
USER node
EXPOSE 8090
CMD ["node", "dist/index.js"]

59
server/README.md Normal file
View file

@ -0,0 +1,59 @@
# BigBrainParking zone-labels API
A tiny service that classifies parking zones so the app knows whether a space is
**free for a limit** (`free_2h` / `free_3h` / `free_4h`) or a **pay-immediately**
lot (`pay_immediate`). Public reads; writes require the admin password.
Lives in the monorepo but is **not** an npm workspace (keeps its native
`better-sqlite3` dep out of the app/CI install). Deploy it independently.
## API
| Method | Path | Auth | Notes |
|---|---|---|---|
| GET | `/healthz` | | liveness |
| GET | `/api/labels` | | all labels `{ labels: [...] }` (app bulk-caches) |
| GET | `/api/labels/:zoneId` | | one label, 404 if none |
| PUT | `/api/labels/:zoneId` | admin | upsert `{ kind, zoneName?, customerId?, note? }` |
| DELETE | `/api/labels/:zoneId` | admin | remove |
| GET | `/api/whoami` | admin | `{ admin: true }` — used by the app's "test password" |
Auth: `Authorization: Bearer <BBP_ADMIN_TOKEN>` (timing-safe compare). Reads are
public but rate-limited (~120/min/IP; writes ~20/min). Repeated bad tokens from an
IP auto-block it for a cooldown; `blocked_ips` (DB) + `BBP_BLOCKED_IPS` (env) are a
manual denylist. `zoneId` is the ParkSmarter `ZoneId` (e.g. `113165`).
## Develop
```bash
cd server
npm install
npm test # node --test via tsx
BBP_ADMIN_TOKEN=dev-secret-please-change npm run dev
```
## Deploy (Docker + nginx on the host the CNAME points to)
```bash
cd server
cp .env.example .env
sed -i "s#change-me-to-a-long-random-secret#$(openssl rand -base64 32)#" .env # set the admin secret
docker compose up -d --build
# nginx + TLS (first time)
sudo cp deploy/bigbrainparking.mowden.top.conf /etc/nginx/sites-available/bigbrainparking.mowden.top
sudo ln -s ../sites-available/bigbrainparking.mowden.top /etc/nginx/sites-enabled/
sudo nginx -t && sudo systemctl reload nginx
sudo certbot --nginx -d bigbrainparking.mowden.top
curl https://bigbrainparking.mowden.top/healthz # {"ok":true}
```
The admin secret (from `.env`) is what you paste into the app under
**Settings → Admin**. Rotate by editing `.env` and `docker compose up -d`.
## Update
```bash
git pull && docker compose up -d --build
```

View file

@ -0,0 +1,25 @@
# nginx reverse proxy for the zone-labels API.
# Install: sudo cp this file to /etc/nginx/sites-available/bigbrainparking.mowden.top
# sudo ln -s ../sites-available/bigbrainparking.mowden.top /etc/nginx/sites-enabled/
# sudo nginx -t && sudo systemctl reload nginx
# TLS: sudo certbot --nginx -d bigbrainparking.mowden.top
# (certbot rewrites this file to add the :443 server block + HTTP->HTTPS redirect.)
server {
listen 80;
listen [::]:80;
server_name bigbrainparking.mowden.top;
# Small API; cap request bodies.
client_max_body_size 32k;
location / {
proxy_pass http://127.0.0.1:8097;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_read_timeout 30s;
}
}

23
server/docker-compose.yml Normal file
View file

@ -0,0 +1,23 @@
services:
bbp-labels:
build: .
image: bbp-labels:latest
container_name: bbp-labels
restart: unless-stopped
env_file: .env
environment:
- BBP_DB_PATH=/data/labels.db
- PORT=8090
- HOST=0.0.0.0
# Bound to loopback only — nginx terminates TLS and reverse-proxies to it.
# Host port 8097 (8090 is used by another container); container stays 8090.
ports:
- "127.0.0.1:8097:8090"
volumes:
- bbp-labels-data:/data
# Shared host — keep this service small.
mem_limit: 256m
cpus: 0.5
volumes:
bbp-labels-data:

1700
server/package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

26
server/package.json Normal file
View file

@ -0,0 +1,26 @@
{
"name": "bbp-labels-server",
"version": "0.1.0",
"private": true,
"type": "module",
"description": "BigBrainParking zone-labels API — classifies parking zones (free 2h/3h/4h street vs pay-immediately lots).",
"engines": { "node": ">=22" },
"scripts": {
"build": "tsc -p tsconfig.json",
"start": "node dist/index.js",
"dev": "node --import tsx --watch src/index.ts",
"typecheck": "tsc -p tsconfig.json --noEmit",
"test": "node --import tsx --test test/*.test.ts"
},
"dependencies": {
"@fastify/rate-limit": "^10.2.2",
"better-sqlite3": "^11.8.1",
"fastify": "^5.2.1"
},
"devDependencies": {
"@types/better-sqlite3": "^7.6.12",
"@types/node": "^22.13.1",
"tsx": "^4.19.2",
"typescript": "^5.7.3"
}
}

116
server/src/app.ts Normal file
View file

@ -0,0 +1,116 @@
import Fastify, { type FastifyReply, type FastifyRequest } from 'fastify';
import rateLimit from '@fastify/rate-limit';
import { LabelDb, LABEL_KINDS, isLabelKind, type ZoneLabel } from './db.js';
import { AbuseGuard, safeEqual } from './auth.js';
export interface BuildOptions {
adminToken: string;
/** File path for the SQLite DB, or ':memory:' (tests). Ignored if `db` is given. */
dbPath?: string;
db?: LabelDb;
trustProxy?: boolean;
rateLimitMax?: number;
rateLimitWindow?: string;
writeRateLimitMax?: number;
authFailLimit?: number;
authFailWindowMs?: number;
blockCooldownMs?: number;
seedBlockedIps?: string[];
}
interface ZoneParams {
zoneId: string;
}
interface PutBody {
kind?: unknown;
zoneName?: unknown;
customerId?: unknown;
note?: unknown;
}
const str = (v: unknown): string | null => (v == null ? null : String(v));
export async function buildApp(opts: BuildOptions) {
const db = opts.db ?? new LabelDb(opts.dbPath ?? ':memory:');
if (opts.seedBlockedIps?.length) db.seedBlocked(opts.seedBlockedIps);
const guard = new AbuseGuard(
opts.authFailLimit ?? 8,
opts.authFailWindowMs ?? 15 * 60 * 1000,
opts.blockCooldownMs ?? 60 * 60 * 1000,
);
const app = Fastify({ trustProxy: opts.trustProxy ?? true, logger: false, bodyLimit: 16 * 1024 });
await app.register(rateLimit, {
max: opts.rateLimitMax ?? 120,
timeWindow: opts.rateLimitWindow ?? '1 minute',
});
// Reject blocked IPs (manual denylist + auto-block) before anything else.
app.addHook('onRequest', async (req: FastifyRequest, reply: FastifyReply) => {
if (guard.isBlocked(req.ip, Date.now()) || db.isBlocked(req.ip)) {
return reply.code(403).send({ error: 'forbidden' });
}
});
const requireAdmin = async (req: FastifyRequest, reply: FastifyReply) => {
const hdr = req.headers.authorization ?? '';
const token = /^Bearer\s+(.+)$/i.exec(hdr)?.[1] ?? '';
if (!token || !safeEqual(token, opts.adminToken)) {
const tripped = guard.recordFail(req.ip, Date.now());
return reply.code(401).send({ error: 'unauthorized', blocked: tripped });
}
guard.recordSuccess(req.ip);
};
const writeLimit = {
config: { rateLimit: { max: opts.writeRateLimitMax ?? 20, timeWindow: '1 minute' } },
};
app.get('/healthz', async () => ({ ok: true }));
app.get('/api/whoami', { preHandler: requireAdmin }, async () => ({ admin: true }));
app.get('/api/labels', async () => ({ labels: db.all() }));
app.get('/api/labels/:zoneId', async (req: FastifyRequest<{ Params: ZoneParams }>, reply) => {
const label = db.get(req.params.zoneId);
if (!label) return reply.code(404).send({ error: 'not_found' });
return label;
});
app.put(
'/api/labels/:zoneId',
{ preHandler: requireAdmin, ...writeLimit },
async (req: FastifyRequest<{ Params: ZoneParams; Body: PutBody }>, reply) => {
const body = req.body ?? {};
if (!isLabelKind(body.kind)) {
return reply.code(400).send({ error: 'bad_kind', allowed: LABEL_KINDS });
}
const label: ZoneLabel = {
zoneId: String(req.params.zoneId),
customerId: str(body.customerId),
zoneName: str(body.zoneName),
kind: body.kind,
note: body.note == null ? null : String(body.note).slice(0, 500),
updatedAt: Date.now(),
updatedBy: 'admin',
};
db.upsert(label);
return label;
},
);
app.delete(
'/api/labels/:zoneId',
{ preHandler: requireAdmin, ...writeLimit },
async (req: FastifyRequest<{ Params: ZoneParams }>, reply) => {
if (!db.delete(req.params.zoneId)) return reply.code(404).send({ error: 'not_found' });
return { deleted: true };
},
);
app.addHook('onClose', async () => db.close());
return app;
}

50
server/src/auth.ts Normal file
View file

@ -0,0 +1,50 @@
import { timingSafeEqual } from 'node:crypto';
/** Constant-time string compare (guards the admin token check against timing attacks). */
export function safeEqual(a: string, b: string): boolean {
const ba = Buffer.from(a);
const bb = Buffer.from(b);
if (ba.length !== bb.length) return false;
return timingSafeEqual(ba, bb);
}
/**
* Tracks failed admin-auth attempts per IP and auto-blocks an IP for a cooldown
* after too many failures in a rolling window. In-memory (resets on restart)
* complements the persistent manual denylist in the DB.
*/
export class AbuseGuard {
private fails = new Map<string, number[]>();
private blockedUntil = new Map<string, number>();
constructor(
private readonly limit: number,
private readonly windowMs: number,
private readonly cooldownMs: number,
) {}
isBlocked(ip: string, now: number): boolean {
const until = this.blockedUntil.get(ip);
if (until == null) return false;
if (until > now) return true;
this.blockedUntil.delete(ip);
return false;
}
/** Returns true if this failure just tripped an auto-block. */
recordFail(ip: string, now: number): boolean {
const recent = (this.fails.get(ip) ?? []).filter((t) => now - t < this.windowMs);
recent.push(now);
if (recent.length >= this.limit) {
this.blockedUntil.set(ip, now + this.cooldownMs);
this.fails.delete(ip);
return true;
}
this.fails.set(ip, recent);
return false;
}
recordSuccess(ip: string): void {
this.fails.delete(ip);
}
}

114
server/src/db.ts Normal file
View file

@ -0,0 +1,114 @@
import Database from 'better-sqlite3';
import { mkdirSync } from 'node:fs';
import { dirname } from 'node:path';
export type LabelKind = 'free_2h' | 'free_3h' | 'free_4h' | 'pay_immediate';
export const LABEL_KINDS: LabelKind[] = ['free_2h', 'free_3h', 'free_4h', 'pay_immediate'];
export function isLabelKind(v: unknown): v is LabelKind {
return typeof v === 'string' && (LABEL_KINDS as string[]).includes(v);
}
export interface ZoneLabel {
zoneId: string;
customerId: string | null;
zoneName: string | null;
kind: LabelKind;
note: string | null;
updatedAt: number;
updatedBy: string | null;
}
interface Row {
zone_id: string;
customer_id: string | null;
zone_name: string | null;
kind: string;
note: string | null;
updated_at: number;
updated_by: string | null;
}
const toLabel = (r: Row): ZoneLabel => ({
zoneId: r.zone_id,
customerId: r.customer_id,
zoneName: r.zone_name,
kind: r.kind as LabelKind,
note: r.note,
updatedAt: r.updated_at,
updatedBy: r.updated_by,
});
/** SQLite-backed store for zone labels + a manual IP denylist. */
export class LabelDb {
private db: Database.Database;
constructor(path: string) {
if (path !== ':memory:') mkdirSync(dirname(path), { recursive: true });
this.db = new Database(path);
this.db.pragma('journal_mode = WAL');
this.db.exec(`
CREATE TABLE IF NOT EXISTS zone_labels (
zone_id TEXT PRIMARY KEY,
customer_id TEXT,
zone_name TEXT,
kind TEXT NOT NULL,
note TEXT,
updated_at INTEGER NOT NULL,
updated_by TEXT
);
CREATE TABLE IF NOT EXISTS blocked_ips (
ip TEXT PRIMARY KEY,
reason TEXT,
created_at INTEGER NOT NULL
);
`);
}
all(): ZoneLabel[] {
return (this.db.prepare('SELECT * FROM zone_labels ORDER BY zone_id').all() as Row[]).map(toLabel);
}
get(zoneId: string): ZoneLabel | undefined {
const r = this.db.prepare('SELECT * FROM zone_labels WHERE zone_id = ?').get(zoneId) as Row | undefined;
return r ? toLabel(r) : undefined;
}
upsert(label: ZoneLabel): void {
this.db
.prepare(
`INSERT INTO zone_labels (zone_id, customer_id, zone_name, kind, note, updated_at, updated_by)
VALUES (@zoneId, @customerId, @zoneName, @kind, @note, @updatedAt, @updatedBy)
ON CONFLICT(zone_id) DO UPDATE SET
customer_id = excluded.customer_id,
zone_name = excluded.zone_name,
kind = excluded.kind,
note = excluded.note,
updated_at = excluded.updated_at,
updated_by = excluded.updated_by`,
)
.run(label);
}
delete(zoneId: string): boolean {
return this.db.prepare('DELETE FROM zone_labels WHERE zone_id = ?').run(zoneId).changes > 0;
}
isBlocked(ip: string): boolean {
return !!this.db.prepare('SELECT 1 FROM blocked_ips WHERE ip = ?').get(ip);
}
block(ip: string, reason: string): void {
this.db
.prepare('INSERT OR REPLACE INTO blocked_ips (ip, reason, created_at) VALUES (?, ?, ?)')
.run(ip, reason, Date.now());
}
seedBlocked(ips: string[]): void {
for (const ip of ips) this.block(ip, 'seed');
}
close(): void {
this.db.close();
}
}

34
server/src/index.ts Normal file
View file

@ -0,0 +1,34 @@
import { buildApp } from './app.js';
const adminToken = process.env.BBP_ADMIN_TOKEN ?? '';
if (adminToken.length < 16) {
console.error('FATAL: BBP_ADMIN_TOKEN is missing or shorter than 16 chars.');
process.exit(1);
}
const app = await buildApp({
adminToken,
dbPath: process.env.BBP_DB_PATH ?? '/data/labels.db',
trustProxy: true,
seedBlockedIps: (process.env.BBP_BLOCKED_IPS ?? '')
.split(',')
.map((s) => s.trim())
.filter(Boolean),
});
const port = Number(process.env.PORT ?? 8090);
const host = process.env.HOST ?? '0.0.0.0';
try {
await app.listen({ port, host });
console.log(`bbp-labels listening on ${host}:${port}`);
} catch (err) {
console.error(err);
process.exit(1);
}
for (const sig of ['SIGINT', 'SIGTERM'] as const) {
process.on(sig, () => {
void app.close().then(() => process.exit(0));
});
}

108
server/test/labels.test.ts Normal file
View file

@ -0,0 +1,108 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { buildApp, type BuildOptions } from '../src/app.ts';
const TOKEN = 'test-admin-token-0123456789';
const auth = { authorization: `Bearer ${TOKEN}` };
const make = (o: Partial<BuildOptions> = {}) =>
buildApp({ adminToken: TOKEN, dbPath: ':memory:', ...o });
test('reads are public; writes require the admin token', async () => {
const app = await make();
let r = await app.inject({ method: 'GET', url: '/api/labels' });
assert.equal(r.statusCode, 200);
assert.deepEqual(r.json().labels, []);
r = await app.inject({ method: 'PUT', url: '/api/labels/113165', payload: { kind: 'free_2h' } });
assert.equal(r.statusCode, 401);
r = await app.inject({
method: 'PUT',
url: '/api/labels/113165',
headers: auth,
payload: { kind: 'free_2h', zoneName: 'DL', customerId: 217 },
});
assert.equal(r.statusCode, 200);
assert.equal(r.json().kind, 'free_2h');
assert.equal(r.json().zoneName, 'DL');
assert.equal(r.json().customerId, '217');
r = await app.inject({ method: 'GET', url: '/api/labels/113165' });
assert.equal(r.statusCode, 200);
assert.equal(r.json().kind, 'free_2h');
await app.close();
});
test('upsert replaces the kind', async () => {
const app = await make();
await app.inject({ method: 'PUT', url: '/api/labels/1', headers: auth, payload: { kind: 'free_2h' } });
await app.inject({ method: 'PUT', url: '/api/labels/1', headers: auth, payload: { kind: 'pay_immediate' } });
const r = await app.inject({ method: 'GET', url: '/api/labels/1' });
assert.equal(r.json().kind, 'pay_immediate');
await app.close();
});
test('invalid kind is rejected', async () => {
const app = await make();
const r = await app.inject({ method: 'PUT', url: '/api/labels/1', headers: auth, payload: { kind: 'free_9h' } });
assert.equal(r.statusCode, 400);
assert.equal(r.json().error, 'bad_kind');
await app.close();
});
test('unknown zone → 404 on GET and DELETE', async () => {
const app = await make();
assert.equal((await app.inject({ method: 'GET', url: '/api/labels/nope' })).statusCode, 404);
assert.equal((await app.inject({ method: 'DELETE', url: '/api/labels/nope', headers: auth })).statusCode, 404);
await app.close();
});
test('delete removes the label', async () => {
const app = await make();
await app.inject({ method: 'PUT', url: '/api/labels/9', headers: auth, payload: { kind: 'free_4h' } });
assert.equal((await app.inject({ method: 'DELETE', url: '/api/labels/9', headers: auth })).statusCode, 200);
assert.equal((await app.inject({ method: 'GET', url: '/api/labels/9' })).statusCode, 404);
await app.close();
});
test('whoami reflects the token', async () => {
const app = await make();
assert.equal((await app.inject({ method: 'GET', url: '/api/whoami' })).statusCode, 401);
const ok = await app.inject({ method: 'GET', url: '/api/whoami', headers: auth });
assert.equal(ok.statusCode, 200);
assert.equal(ok.json().admin, true);
await app.close();
});
test('repeated auth failures auto-block the IP (403)', async () => {
const app = await make({ authFailLimit: 3, blockCooldownMs: 60_000 });
for (let i = 0; i < 3; i++) {
await app.inject({
method: 'PUT',
url: '/api/labels/1',
headers: { authorization: 'Bearer wrong' },
payload: { kind: 'free_2h' },
});
}
const r = await app.inject({ method: 'GET', url: '/api/labels' }); // even a public read is now blocked
assert.equal(r.statusCode, 403);
await app.close();
});
test('rate limit returns 429 past the threshold', async () => {
const app = await make({ rateLimitMax: 3, rateLimitWindow: '1 minute' });
let last;
for (let i = 0; i < 5; i++) last = await app.inject({ method: 'GET', url: '/api/labels' });
assert.equal(last!.statusCode, 429);
await app.close();
});
test('seeded IP denylist blocks with 403', async () => {
// app.inject uses 127.0.0.1 as the client IP
const app = await make({ seedBlockedIps: ['127.0.0.1'] });
assert.equal((await app.inject({ method: 'GET', url: '/api/labels' })).statusCode, 403);
await app.close();
});

16
server/tsconfig.json Normal file
View file

@ -0,0 +1,16 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"outDir": "dist",
"rootDir": "src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"declaration": false,
"sourceMap": false,
"forceConsistentCasingInFileNames": true
},
"include": ["src"]
}