diff --git a/src/problem4/.dockerignore b/src/problem4/.dockerignore new file mode 100644 index 0000000000..f589628646 --- /dev/null +++ b/src/problem4/.dockerignore @@ -0,0 +1,6 @@ +node_modules +.git +.gitignore +README.md +Dockerfile +.dockerignore diff --git a/src/problem4/.gitignore b/src/problem4/.gitignore new file mode 100644 index 0000000000..8b452ce701 --- /dev/null +++ b/src/problem4/.gitignore @@ -0,0 +1,2 @@ +# dependencies (bun install) +node_modules diff --git a/src/problem4/Dockerfile b/src/problem4/Dockerfile new file mode 100644 index 0000000000..39a26b63bc --- /dev/null +++ b/src/problem4/Dockerfile @@ -0,0 +1,12 @@ +FROM oven/bun:1.3.11-alpine + +WORKDIR /app + +COPY package.json bun.lock ./ +RUN bun install --frozen-lockfile + +COPY tsconfig.json ./ +COPY sum.ts ./ +COPY tests ./tests + +CMD ["bun", "test"] diff --git a/src/problem4/README.md b/src/problem4/README.md new file mode 100644 index 0000000000..33af4cd73c --- /dev/null +++ b/src/problem4/README.md @@ -0,0 +1,29 @@ +# problem4 + +Three implementations of `sum_to_n` (formula, recursion, loop) with complexity analysis. + +## Assumptions + +The solution is presented under these assumptions: + +- **Negative Integers:** for all negative integers, their absolute values must be taken. + +- **NaN & Infinity:** all inputs are validated against `NaN` or `Infinity` and should return `0` for these. + +## Run Tests with Docker + +Build the image: + +```bash +docker build -t problem4 ./src/problem4 +``` + +Run the test suite: + +```bash +docker run -t --rm problem4 +``` + +The container uses [Bun](https://bun.com) and executes `bun test` by default. + +This project was created using `bun init` in bun v1.3.11. diff --git a/src/problem4/bun.lock b/src/problem4/bun.lock new file mode 100644 index 0000000000..9bb087d0a3 --- /dev/null +++ b/src/problem4/bun.lock @@ -0,0 +1,26 @@ +{ + "lockfileVersion": 1, + "configVersion": 1, + "workspaces": { + "": { + "name": "problem4", + "devDependencies": { + "@types/bun": "latest", + }, + "peerDependencies": { + "typescript": "^5", + }, + }, + }, + "packages": { + "@types/bun": ["@types/bun@1.3.14", "", { "dependencies": { "bun-types": "1.3.14" } }, "sha512-h1hFqFVcvAvD9j9K7ZW7vd82aSA+rTdznZa+5bwvCwqSB1jmmfLcbIWhOLx1/+boy/xmjgCs/OMUL8hRJSmnPw=="], + + "@types/node": ["@types/node@25.8.0", "", { "dependencies": { "undici-types": ">=7.24.0 <7.24.7" } }, "sha512-TCFSk8IZh+iLX1xtksoBVtdmgL+1IX0fC9BeU4QqFSuNdN/K+HUlhqOzEmSYYpZUVsLYcPqc9KX+60iDuninSQ=="], + + "bun-types": ["bun-types@1.3.14", "", { "dependencies": { "@types/node": "*" } }, "sha512-4N0ig0fEomHt5R0KCFWjovxow98rIoRwKolrYdCcknNwMekCXRnWEUvgu5soYV8QXtVsrUD8B95MBOZGPvr6KQ=="], + + "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], + + "undici-types": ["undici-types@7.24.6", "", {}, "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg=="], + } +} diff --git a/src/problem4/package.json b/src/problem4/package.json new file mode 100644 index 0000000000..46247b02e9 --- /dev/null +++ b/src/problem4/package.json @@ -0,0 +1,12 @@ +{ + "name": "problem4", + "module": "index.ts", + "type": "module", + "private": true, + "devDependencies": { + "@types/bun": "latest" + }, + "peerDependencies": { + "typescript": "^5" + } +} diff --git a/src/problem4/sum.ts b/src/problem4/sum.ts new file mode 100644 index 0000000000..e126023dea --- /dev/null +++ b/src/problem4/sum.ts @@ -0,0 +1,80 @@ +const sameSum = [0, 1]; + +const isValidInput = ( + n: number +): boolean => + Number.isInteger(n) && n >= 0; + +const normalizeInput = ( + n: number +): number => + Math.abs(Math.trunc(n)); + +/** + * Base Gateway (Higher-Order Function) + * Wraps a core logic function with validation and normalization. + */ +const base_gateway = ( + coreFormula: (n: number) => number +): ((n: number) => number) => { + return (n: number): number => { + const normalized = normalizeInput(n); + + if (!isValidInput(normalized)) { + // If invalid, safely return the base case (0) without re-invoking the gateway + return 0; + } + + if (sameSum.includes(n)) { + // If 0, 1, the sum, output, will be the same as input + return n; + } + + // Pass the cleaned, normalized input to the actual formula + return coreFormula(normalized); + }; +}; + +const formula = (n: number): number => (n * (n + 1)) / 2; + +const looping = (n: number): number => { + let sum = 0; + for (let i = n; i > 0; i--) { + sum += i; + } + return sum; +} + +const recursion = (n: number): number => { + if (n <= 0) return 0; + return n + recursion(n - 1); +} + +/** + * **Implementation A: Carl Friedrich Gauss's Arithmetic Progression** + * + * **Complexity Analysis** + * - **Time Complexity:** O(1) (Constant Time) — Executes a fixed number of + * arithmetic operations (one addition, one multiplication, one division) + * - **Space Complexity:** O(1) (Constant Space) — No additional memory or + * variables are allocated. + */ +export const sum_to_n_a = base_gateway(formula); + +/** + * **Implementation B: The Recursive Approach** + * + * **Complexity Analysis:** + * - **Time Complexity:** O(n) (Linear Time). The function calls itself n times before hitting the base case. + * - **Space Complexity:** O(n) (Linear Space). Each recursive call adds a new frame to the call stack. + */ +export const sum_to_n_b = base_gateway(recursion); + +/** + * **Implementation C: The Iterative Loop** + * + * **Complexity Analysis:** + * - **Time Complexity:** O(n) (Linear Time). The loop runs exactly n times, so the execution time scales linearly with the size of n. + * - **Space Complexity:** O(1) (Constant Space). It only requires a single variable (sum) to track the running total, occupying minimal, fixed memory. + */ +export const sum_to_n_c = base_gateway(looping); diff --git a/src/problem4/tests/sum.test.ts b/src/problem4/tests/sum.test.ts new file mode 100644 index 0000000000..728451718f --- /dev/null +++ b/src/problem4/tests/sum.test.ts @@ -0,0 +1,81 @@ +import { describe, it, expect } from "bun:test"; +import { sum_to_n_a, sum_to_n_b, sum_to_n_c } from "../sum"; + +// A helper array to easily run the same test cases across all three implementations +const implementations = [ + { + name: "Implementation A (Formula)", + fn: sum_to_n_a, + testRecursiveLimit: false, + }, + { + name: "Implementation B (Recursive)", + fn: sum_to_n_b, + testRecursiveLimit: true, + }, + { + name: "Implementation C (Loop)", + fn: sum_to_n_c, + testRecursiveLimit: false, + }, +]; + +implementations.forEach(({ name, fn, testRecursiveLimit }) => { + describe(`sum_to_n - ${name}`, () => { + // 1. Standard Happy Path Tests + describe("Standard inputs", () => { + it("should correctly sum up to 1", () => { + expect(fn(1)).toBe(1); + }); + + it("should correctly sum up to 5 (1+2+3+4+5)", () => { + expect(fn(5)).toBe(15); + }); + + it("should correctly sum up to 10", () => { + expect(fn(10)).toBe(55); + }); + + it("should correctly sum up to 100", () => { + expect(fn(100)).toBe(5050); + }); + }); + + // 2. Edge Cases (Zero and Negative Numbers) + describe("Edge cases", () => { + it("should return 0 when n is 0", () => { + expect(fn(0)).toBe(0); + }); + + it("should normalize negative integers and correctly sum up to 15", () => { + expect(fn(-5)).toBe(15); + }); + }); + + // 3. Large Inputs & Precision Boundary Tests + describe("Large inputs and safety boundaries", () => { + it("should accurately sum to a moderately large number (n = 200,000)", () => { + // If it's the recursive function, it will throw a stack overflow here. + // We handle this expected architectural limitation gracefully in the test. + if (testRecursiveLimit) { + expect(() => fn(200000)).toThrow(RangeError); + } else { + expect(fn(200000)).toBe(20000100000); + } + }); + + // This test checks the boundary condition mentioned in your prompt + it("should accurately result in a value just under MAX_SAFE_INTEGER", () => { + if (!testRecursiveLimit) { + // For n = 134,217,726, the sum is 9,007,199,122,864,127 + // This is less than MAX_SAFE_INTEGER (9,007,199,254,740,991) + const n = 134217726; + const expectedSum = (n * (n + 1)) / 2; + + expect(fn(n)).toBe(expectedSum); + expect(fn(n)).toBeLessThan(Number.MAX_SAFE_INTEGER); + } + }); + }); + }); +}); diff --git a/src/problem4/tsconfig.json b/src/problem4/tsconfig.json new file mode 100644 index 0000000000..fe0ce4c0b4 --- /dev/null +++ b/src/problem4/tsconfig.json @@ -0,0 +1,30 @@ +{ + "compilerOptions": { + // Environment setup & latest features + "lib": ["ESNext"], + "target": "ESNext", + "module": "Preserve", + "moduleDetection": "force", + "jsx": "react-jsx", + "allowJs": true, + "types": ["bun-types"], + + // Bundler mode + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true, + "noEmit": true, + + // Best practices + "strict": true, + "skipLibCheck": true, + "noFallthroughCasesInSwitch": true, + "noUncheckedIndexedAccess": true, + "noImplicitOverride": true, + + // Some stricter flags (disabled by default) + "noUnusedLocals": false, + "noUnusedParameters": false, + "noPropertyAccessFromIndexSignature": false + } +} diff --git a/src/problem5/.dockerignore b/src/problem5/.dockerignore new file mode 100644 index 0000000000..f32fbac555 --- /dev/null +++ b/src/problem5/.dockerignore @@ -0,0 +1,9 @@ +node_modules +data +*.db +*.db-journal +*.db-wal +*.db-shm +.git +.gitignore +README.md diff --git a/src/problem5/.env.example b/src/problem5/.env.example new file mode 100644 index 0000000000..0566cbdf08 --- /dev/null +++ b/src/problem5/.env.example @@ -0,0 +1,6 @@ +PORT=8000 +HOST=0.0.0.0 +DB_PATH=./data/app.db +RATE_LIMIT_WINDOW_MS=900000 +RATE_LIMIT_MAX=200 +IDEMPOTENCY_TTL_MS=86400000 diff --git a/src/problem5/.gitignore b/src/problem5/.gitignore new file mode 100644 index 0000000000..b9700605ff --- /dev/null +++ b/src/problem5/.gitignore @@ -0,0 +1,6 @@ +node_modules +data/*.db +data/*.db-journal +data/*.db-wal +data/*.db-shm +.env diff --git a/src/problem5/Dockerfile b/src/problem5/Dockerfile new file mode 100644 index 0000000000..16cddc00fd --- /dev/null +++ b/src/problem5/Dockerfile @@ -0,0 +1,18 @@ +FROM oven/bun:1.3.11-alpine + +WORKDIR /app + +COPY package.json bun.lock* ./ +RUN bun install --frozen-lockfile || bun install + +COPY tsconfig.json ./ +COPY src ./src + +RUN mkdir -p /app/data +ENV DB_PATH=/app/data/app.db +ENV PORT=8000 +ENV HOST=0.0.0.0 + +EXPOSE 8000 + +CMD ["bun", "run", "src/index.ts"] diff --git a/src/problem5/Dockerfile.test b/src/problem5/Dockerfile.test new file mode 100644 index 0000000000..f7c69b1992 --- /dev/null +++ b/src/problem5/Dockerfile.test @@ -0,0 +1,15 @@ +FROM oven/bun:1.3.11-alpine + +WORKDIR /app + +COPY package.json bun.lock* ./ +RUN bun install --frozen-lockfile || bun install + +COPY tsconfig.json bunfig.toml ./ +COPY src ./src +COPY tests ./tests + +ENV NODE_ENV=test +ENV CI=true + +CMD ["bun", "test"] diff --git a/src/problem5/README.md b/src/problem5/README.md new file mode 100644 index 0000000000..3d7a777a73 --- /dev/null +++ b/src/problem5/README.md @@ -0,0 +1,284 @@ +# problem5 + +A small CRUD API for a generic `resource` entity, built with **ExpressJS + TypeScript** on the **Bun** runtime and backed by **SQLite** (via Bun's built-in `bun:sqlite`). Input is validated with **Zod**. + +## Resource model + +| field | type | notes | +| ------------- | ------------------------------------------ | -------------------------------------- | +| `id` | string (UUID) | generated server-side | +| `name` | string (1–200) | required | +| `description` | string (≤ 2000) \| null | optional | +| `status` | `"active"` \| `"inactive"` \| `"archived"` | defaults to `"active"` on create | +| `createdAt` | ISO-8601 string | set on create | +| `updatedAt` | ISO-8601 string | refreshed on every successful `PATCH` | + +## Endpoints + +| method | path | description | +| ------ | ----------------- | -------------------------------------------- | +| GET | `/health` | liveness probe | +| POST | `/resources` | create a resource | +| GET | `/resources` | list resources with filters (see below) | +| GET | `/resources/:id` | get a single resource | +| PATCH | `/resources/:id` | partially update a resource | +| DELETE | `/resources/:id` | delete a resource | + +### List filters + +`GET /resources` accepts the following query parameters: + +- `status` — exact match against `active` / `inactive` / `archived` +- `q` — case-insensitive substring search on `name` and `description` +- `limit` — page size, 1–100 (default `20`) +- `offset` — pagination offset (default `0`) + +Response shape: + +```json +{ "items": [...], "total": 0, "limit": 20, "offset": 0 } +``` + +## Configuration + +Environment variables (see `.env.example`): + +| variable | default | description | +| ----------------------- | --------------- | ------------------------------------------------- | +| `PORT` | `8000` | port the HTTP server binds to | +| `HOST` | `0.0.0.0` | interface to bind | +| `DB_PATH` | `./data/app.db` | SQLite file path (created on first boot) | +| `RATE_LIMIT_WINDOW_MS` | `900000` | rate-limit window in milliseconds (15 minutes) | +| `RATE_LIMIT_MAX` | `200` | max requests per IP per window (excluding `/health`) | +| `IDEMPOTENCY_TTL_MS` | `86400000` | how long stored `Idempotency-Key` results are replayable (24h) | + +## Run with Docker + +The simplest path — the SQLite file is persisted to `./data` on the host via a bind mount. + +```bash +docker compose -f ./src/problem5/docker-compose.yml up --build +``` + +The API is then reachable at `http://localhost:8000`. + +To stop: + +```bash +docker compose -f ./src/problem5/docker-compose.yml down +``` + +### Plain Docker (without Compose) + +```bash +docker build -t problem5 ./src/problem5 +docker run --rm -p 8000:8000 -v "$PWD/src/problem5/data:/app/data" problem5 +``` + +## Run locally (without Docker) + +Requires [Bun](https://bun.com) ≥ 1.3. + +```bash +cd src/problem5 +bun install +bun run src/index.ts # or: bun --watch run src/index.ts +``` + +Then in another terminal: + +```bash +curl -s http://localhost:8000/health +``` + +## Quick smoke test + +```bash +# create +curl -s -X POST http://localhost:8000/resources \ + -H 'content-type: application/json' \ + -d '{"name":"first","description":"hello"}' + +# list +curl -s 'http://localhost:8000/resources?status=active&q=fir' + +# get / update / delete (replace :id with the value returned above) +curl -s http://localhost:8000/resources/:id +curl -s -X PATCH http://localhost:8000/resources/:id \ + -H 'content-type: application/json' -d '{"status":"archived"}' +curl -s -X DELETE http://localhost:8000/resources/:id -o /dev/null -w '%{http_code}\n' +``` + +### Idempotency + +`POST /resources` honours the `Idempotency-Key` header. Replaying the same key with the same body returns the original response (with an `Idempotent-Replayed: true` header) instead of creating a duplicate row. Replaying with a different body returns `422`. Keys are kept for `IDEMPOTENCY_TTL_MS` (default 24h) and must match `^[A-Za-z0-9_-]{1,255}$`. + +```bash +KEY="demo-$(uuidgen)" # Run this first + +# 1st call — creates the resource, returns 201 +curl -si -X POST http://localhost:8000/resources \ + -H 'content-type: application/json' \ + -H "Idempotency-Key: $KEY" \ + -d '{"name":"keyed","description":"hello"}' | head -n 12 + +# 2nd call, same key + body — replays the original 201, NO new row +curl -si -X POST http://localhost:8000/resources \ + -H 'content-type: application/json' \ + -H "Idempotency-Key: $KEY" \ + -d '{"name":"keyed","description":"hello"}' | head -n 14 +# look for: Idempotent-Replayed: true + +# 3rd call, same key + DIFFERENT body — 422 mismatch +curl -si -X POST http://localhost:8000/resources \ + -H 'content-type: application/json' \ + -H "Idempotency-Key: $KEY" \ + -d '{"name":"changed"}' | head -n 6 + +# confirm only one "keyed" row exists +curl -s 'http://localhost:8000/resources?q=keyed' | grep -o '"id"' | wc -l +``` + +### Rate limiting + +The limiter is in front of `/resources` (the `/health` probe is excluded) and is configured via `RATE_LIMIT_WINDOW_MS` / `RATE_LIMIT_MAX`. The easiest way to exercise it is to start the server with a tiny budget so a single burst trips it: + +```bash +# in one terminal — 5 requests / 10s window +RATE_LIMIT_MAX=5 RATE_LIMIT_WINDOW_MS=10000 bun run src/index.ts +``` + +```bash +# in another terminal — fire 8 requests, expect the first 5 to be 200 and the rest 429 +for i in $(seq 1 8); do + curl -s -o /dev/null -w "req $i -> %{http_code}\n" http://localhost:8000/resources +done + +# inspect the rate-limit headers on a single request +curl -si http://localhost:8000/resources | grep -i ratelimit +# RateLimit-Policy: 5;w=10 +# RateLimit: limit=5, remaining=4, reset=10 + +# /health is exempt — keeps returning 200 even after the limit trips +curl -s -o /dev/null -w '%{http_code}\n' http://localhost:8000/health +``` + +## Tests + +Unit tests cover every `/resources` route plus the idempotency middleware (28 cases in `tests/resources.test.ts`). They use [supertest](https://github.com/ladjs/supertest) against an in-process Express app. + +### Database isolation + +Tests **never** touch the dev/prod SQLite file. `bun test` sets `NODE_ENV=test` automatically; `src/db/index.ts` checks that flag and forces an in-memory database, ignoring any `DB_PATH` from the environment (with a warning). The preload at `tests/setup.ts` also overrides `DB_PATH=:memory:` as a belt-and-suspenders measure. + +### Run locally + +```bash +cd src/problem5 +bun install +bun test +``` + +### Run in Docker + +The test container uses a separate Dockerfile and does not bind-mount the `data/` directory, so the host SQLite file is never touched. + +```bash +docker compose -f ./src/problem5/docker-compose.test.yml up --build --abort-on-container-exit +docker compose -f ./src/problem5/docker-compose.test.yml down --rmi local +``` + +Or without compose: + +```bash +docker build -f ./src/problem5/Dockerfile.test -t problem5-test ./src/problem5 +docker run --rm problem5-test +``` + +## Project layout + +``` +src/problem5 +├── Dockerfile # runtime image +├── Dockerfile.test # test image (runs `bun test`) +├── docker-compose.yml # runtime compose +├── docker-compose.test.yml # test compose (no host volumes) +├── bunfig.toml # bun config (test preload) +├── package.json +├── tsconfig.json +├── data/ # SQLite file lives here (gitignored, runtime only) +├── src +│ ├── index.ts # server entrypoint +│ ├── app.ts # express app wiring +│ ├── db/index.ts # bun:sqlite + schema bootstrap (forces :memory: under NODE_ENV=test) +│ ├── middleware/errorHandler.ts +│ ├── middleware/rateLimit.ts +│ ├── middleware/idempotency.ts +│ ├── routes/resources.ts # REST routes (Controller) +│ ├── schemas/resource.ts # Zod input schemas +│ └── services/resources.ts # DB CRUD layer +└── tests + ├── setup.ts # preloaded by bun test (in-memory DB, generous limits) + └── resources.test.ts # route + idempotency coverage +``` + +## Error handling + +- Zod validation failures return `400` with `{ error, details }`. +- Unknown resources return `404 { error: "Resource not found" }`. +- Unhandled errors return `500 { error: "Internal Server Error" }` and are logged to stderr. + +## Request logging + +Every request (except `/health`) is logged to stdout via [`morgan`](https://github.com/expressjs/morgan) in this format: + +``` +[] ms +``` + +For example: + +``` +[2026-05-17T06:14:15.538Z] 127.0.0.1 POST /resources 201 30.239 ms +[2026-05-17T06:14:15.556Z] 127.0.0.1 GET /resources?status=active 200 2.150 ms +``` + +Logging is silenced automatically under `NODE_ENV=test`. If the service runs behind a reverse proxy, set Express's `trust proxy` so `req.ip` is the real client address. + +## Graceful shutdown + +`src/index.ts` listens for `SIGTERM` and `SIGINT` and: + +1. Stops accepting new connections (`server.close()`). +2. Lets in-flight requests finish. +3. Closes the SQLite database handle. +4. Exits 0. + +A hard-coded 10-second deadline kicks in if any of those stages hangs — the process force-exits with code 1 so an orchestrator (Docker, Kubernetes, systemd) doesn't end up waiting forever. Shutdown is idempotent: a second signal during draining is ignored. + +Sample output: + +``` +[server] SIGTERM received, draining in-flight requests… +[server] shutdown complete +``` + +## Future Considerations & Note + +**Why Bun.** Bun is chosen as the runtime for its raw performance, its batteries-included standard library (`bun:sqlite`, `Bun.CryptoHasher`, the built-in test runner, native TypeScript execution — no separate `ts-node`/`tsc` build step), and its near-total Node.js compatibility, which keeps the migration path open if the team ever needs to fall back to Node. The same source tree runs unchanged on either runtime with only minor adapter swaps (e.g. `bun:sqlite` → `better-sqlite3`). + +**Storage — move to PostgreSQL.** SQLite is a great fit for a single-node demo and keeps the dev loop frictionless, but it doesn't scale past one writer and can't be shared across horizontally-scaled API instances. In production, this service should be backed by **PostgreSQL** — proper concurrent writers, indexes that survive larger row counts, transactional `SELECT … FOR UPDATE` semantics for the idempotency check, and managed offerings (RDS / Cloud SQL / Neon) with backups and PITR out of the box. The data access layer in `src/services/resources.ts` is small and parameterised, so the swap is mostly a driver change. + +**Shared in-memory state — move to Redis.** Two pieces of state currently live in the API process and would become inconsistent the moment we run more than one replica: + +- The `express-rate-limit` store (per-process counters → users can multiply their quota by the number of replicas). +- The idempotency-key cache (a retry hitting a different replica could create a duplicate). + +Both should move behind **Redis** — `rate-limit-redis` for the limiter and a `SET key value NX PX ` pattern (or a small `EVAL` script) for the idempotency lock and stored response. Redis also gives us first-class TTL handling, so the periodic prune sweep in `src/middleware/idempotency.ts` can go away. + +**Security — CORS and Authorization.** The service currently runs without any cross-origin policy or auth layer, which is fine for a local demo but unacceptable for anything user-facing: + +- **CORS.** Mount the [`cors`](https://github.com/expressjs/cors) middleware with an explicit allow-list of origins (driven by an env var like `CORS_ORIGINS`), restrict methods to the ones the API actually serves, and only enable `credentials: true` when cookie-based sessions are in play. Avoid the `*` wildcard in production. +- **Authorization.** Put every private `/resources` route behind an auth middleware — JWT bearer tokens (verified with a library like `jose`) or an OAuth2/OIDC introspection call against the org's IdP. Pair that with a coarse RBAC check (e.g. `resource:read` / `resource:write` scopes) and scope the data layer to the authenticated principal so users can only see their own rows. Secrets (`JWT_PUBLIC_KEY`, client IDs) belong in env vars, never in the repo. + +Other follow-ups worth flagging: production-grade reliable structured logging with request IDs (e.g. `winston` can determine log severity and have exporters for most database servers), and an OpenAPI spec derived from the Zod schemas. diff --git a/src/problem5/bun.lock b/src/problem5/bun.lock new file mode 100644 index 0000000000..d401631ee4 --- /dev/null +++ b/src/problem5/bun.lock @@ -0,0 +1,262 @@ +{ + "lockfileVersion": 1, + "configVersion": 1, + "workspaces": { + "": { + "name": "problem5", + "dependencies": { + "express": "^4.21.2", + "express-rate-limit": "^8.5.2", + "morgan": "^1.10.1", + "zod": "^3.23.8", + }, + "devDependencies": { + "@types/bun": "latest", + "@types/express": "^4.17.21", + "@types/morgan": "^1.9.10", + "@types/supertest": "^7.2.0", + "supertest": "^7.2.2", + }, + "peerDependencies": { + "typescript": "^5", + }, + }, + }, + "packages": { + "@noble/hashes": ["@noble/hashes@1.8.0", "", {}, "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A=="], + + "@paralleldrive/cuid2": ["@paralleldrive/cuid2@2.3.1", "", { "dependencies": { "@noble/hashes": "^1.1.5" } }, "sha512-XO7cAxhnTZl0Yggq6jOgjiOHhbgcO4NqFqwSmQpjK3b6TEE6Uj/jfSk6wzYyemh3+I0sHirKSetjQwn5cZktFw=="], + + "@types/body-parser": ["@types/body-parser@1.19.6", "", { "dependencies": { "@types/connect": "*", "@types/node": "*" } }, "sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g=="], + + "@types/bun": ["@types/bun@1.3.14", "", { "dependencies": { "bun-types": "1.3.14" } }, "sha512-h1hFqFVcvAvD9j9K7ZW7vd82aSA+rTdznZa+5bwvCwqSB1jmmfLcbIWhOLx1/+boy/xmjgCs/OMUL8hRJSmnPw=="], + + "@types/connect": ["@types/connect@3.4.38", "", { "dependencies": { "@types/node": "*" } }, "sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug=="], + + "@types/cookiejar": ["@types/cookiejar@2.1.5", "", {}, "sha512-he+DHOWReW0nghN24E1WUqM0efK4kI9oTqDm6XmK8ZPe2djZ90BSNdGnIyCLzCPw7/pogPlGbzI2wHGGmi4O/Q=="], + + "@types/express": ["@types/express@4.17.25", "", { "dependencies": { "@types/body-parser": "*", "@types/express-serve-static-core": "^4.17.33", "@types/qs": "*", "@types/serve-static": "^1" } }, "sha512-dVd04UKsfpINUnK0yBoYHDF3xu7xVH4BuDotC/xGuycx4CgbP48X/KF/586bcObxT0HENHXEU8Nqtu6NR+eKhw=="], + + "@types/express-serve-static-core": ["@types/express-serve-static-core@4.19.8", "", { "dependencies": { "@types/node": "*", "@types/qs": "*", "@types/range-parser": "*", "@types/send": "*" } }, "sha512-02S5fmqeoKzVZCHPZid4b8JH2eM5HzQLZWN2FohQEy/0eXTq8VXZfSN6Pcr3F6N9R/vNrj7cpgbhjie6m/1tCA=="], + + "@types/http-errors": ["@types/http-errors@2.0.5", "", {}, "sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg=="], + + "@types/methods": ["@types/methods@1.1.4", "", {}, "sha512-ymXWVrDiCxTBE3+RIrrP533E70eA+9qu7zdWoHuOmGujkYtzf4HQF96b8nwHLqhuf4ykX61IGRIB38CC6/sImQ=="], + + "@types/mime": ["@types/mime@1.3.5", "", {}, "sha512-/pyBZWSLD2n0dcHE3hq8s8ZvcETHtEuF+3E7XVt0Ig2nvsVQXdghHVcEkIWjy9A0wKfTn97a/PSDYohKIlnP/w=="], + + "@types/morgan": ["@types/morgan@1.9.10", "", { "dependencies": { "@types/node": "*" } }, "sha512-sS4A1zheMvsADRVfT0lYbJ4S9lmsey8Zo2F7cnbYjWHP67Q0AwMYuuzLlkIM2N8gAbb9cubhIVFwcIN2XyYCkA=="], + + "@types/node": ["@types/node@25.8.0", "", { "dependencies": { "undici-types": ">=7.24.0 <7.24.7" } }, "sha512-TCFSk8IZh+iLX1xtksoBVtdmgL+1IX0fC9BeU4QqFSuNdN/K+HUlhqOzEmSYYpZUVsLYcPqc9KX+60iDuninSQ=="], + + "@types/qs": ["@types/qs@6.15.1", "", {}, "sha512-GZHUBZR9hckSUhrxmp1nG6NwdpM9fCunJwyThLW1X3AyHgd9IlHb6VANpQQqDr2o/qQp6McZ3y/IA2rVzKzSbw=="], + + "@types/range-parser": ["@types/range-parser@1.2.7", "", {}, "sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ=="], + + "@types/send": ["@types/send@0.17.6", "", { "dependencies": { "@types/mime": "^1", "@types/node": "*" } }, "sha512-Uqt8rPBE8SY0RK8JB1EzVOIZ32uqy8HwdxCnoCOsYrvnswqmFZ/k+9Ikidlk/ImhsdvBsloHbAlewb2IEBV/Og=="], + + "@types/serve-static": ["@types/serve-static@1.15.10", "", { "dependencies": { "@types/http-errors": "*", "@types/node": "*", "@types/send": "<1" } }, "sha512-tRs1dB+g8Itk72rlSI2ZrW6vZg0YrLI81iQSTkMmOqnqCaNr/8Ek4VwWcN5vZgCYWbg/JJSGBlUaYGAOP73qBw=="], + + "@types/superagent": ["@types/superagent@8.1.9", "", { "dependencies": { "@types/cookiejar": "^2.1.5", "@types/methods": "^1.1.4", "@types/node": "*", "form-data": "^4.0.0" } }, "sha512-pTVjI73witn+9ILmoJdajHGW2jkSaOzhiFYF1Rd3EQ94kymLqB9PjD9ISg7WaALC7+dCHT0FGe9T2LktLq/3GQ=="], + + "@types/supertest": ["@types/supertest@7.2.0", "", { "dependencies": { "@types/methods": "^1.1.4", "@types/superagent": "^8.1.0" } }, "sha512-uh2Lv57xvggst6lCqNdFAmDSvoMG7M/HDtX4iUCquxQ5EGPtaPM5PL5Hmi7LCvOG8db7YaCPNJEeoI8s/WzIQw=="], + + "accepts": ["accepts@1.3.8", "", { "dependencies": { "mime-types": "~2.1.34", "negotiator": "0.6.3" } }, "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw=="], + + "array-flatten": ["array-flatten@1.1.1", "", {}, "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg=="], + + "asap": ["asap@2.0.6", "", {}, "sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA=="], + + "asynckit": ["asynckit@0.4.0", "", {}, "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q=="], + + "basic-auth": ["basic-auth@2.0.1", "", { "dependencies": { "safe-buffer": "5.1.2" } }, "sha512-NF+epuEdnUYVlGuhaxbbq+dvJttwLnGY+YixlXlME5KpQ5W3CnXA5cVTneY3SPbPDRkcjMbifrwmFYcClgOZeg=="], + + "body-parser": ["body-parser@1.20.5", "", { "dependencies": { "bytes": "~3.1.2", "content-type": "~1.0.5", "debug": "2.6.9", "depd": "2.0.0", "destroy": "~1.2.0", "http-errors": "~2.0.1", "iconv-lite": "~0.4.24", "on-finished": "~2.4.1", "qs": "~6.15.1", "raw-body": "~2.5.3", "type-is": "~1.6.18", "unpipe": "~1.0.0" } }, "sha512-3grm+/2tUOvu2cjJkvsIxrv/wVpfXQW4PsQHYm7yk4vfpu7Ekl6nEsYBoJUL6qDwZUx8wUhQ8tR2qz+ad9c9OA=="], + + "bun-types": ["bun-types@1.3.14", "", { "dependencies": { "@types/node": "*" } }, "sha512-4N0ig0fEomHt5R0KCFWjovxow98rIoRwKolrYdCcknNwMekCXRnWEUvgu5soYV8QXtVsrUD8B95MBOZGPvr6KQ=="], + + "bytes": ["bytes@3.1.2", "", {}, "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg=="], + + "call-bind-apply-helpers": ["call-bind-apply-helpers@1.0.2", "", { "dependencies": { "es-errors": "^1.3.0", "function-bind": "^1.1.2" } }, "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ=="], + + "call-bound": ["call-bound@1.0.4", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "get-intrinsic": "^1.3.0" } }, "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg=="], + + "combined-stream": ["combined-stream@1.0.8", "", { "dependencies": { "delayed-stream": "~1.0.0" } }, "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg=="], + + "component-emitter": ["component-emitter@1.3.1", "", {}, "sha512-T0+barUSQRTUQASh8bx02dl+DhF54GtIDY13Y3m9oWTklKbb3Wv974meRpeZ3lp1JpLVECWWNHC4vaG2XHXouQ=="], + + "content-disposition": ["content-disposition@0.5.4", "", { "dependencies": { "safe-buffer": "5.2.1" } }, "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ=="], + + "content-type": ["content-type@1.0.5", "", {}, "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA=="], + + "cookie": ["cookie@0.7.2", "", {}, "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w=="], + + "cookie-signature": ["cookie-signature@1.2.2", "", {}, "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg=="], + + "cookiejar": ["cookiejar@2.1.4", "", {}, "sha512-LDx6oHrK+PhzLKJU9j5S7/Y3jM/mUHvD/DeI1WQmJn652iPC5Y4TBzC9l+5OMOXlyTTA+SmVUPm0HQUwpD5Jqw=="], + + "debug": ["debug@2.6.9", "", { "dependencies": { "ms": "2.0.0" } }, "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA=="], + + "delayed-stream": ["delayed-stream@1.0.0", "", {}, "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ=="], + + "depd": ["depd@2.0.0", "", {}, "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw=="], + + "destroy": ["destroy@1.2.0", "", {}, "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg=="], + + "dezalgo": ["dezalgo@1.0.4", "", { "dependencies": { "asap": "^2.0.0", "wrappy": "1" } }, "sha512-rXSP0bf+5n0Qonsb+SVVfNfIsimO4HEtmnIpPHY8Q1UCzKlQrDMfdobr8nJOOsRgWCyMRqeSBQzmWUMq7zvVig=="], + + "dunder-proto": ["dunder-proto@1.0.1", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.1", "es-errors": "^1.3.0", "gopd": "^1.2.0" } }, "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A=="], + + "ee-first": ["ee-first@1.1.1", "", {}, "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow=="], + + "encodeurl": ["encodeurl@2.0.0", "", {}, "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg=="], + + "es-define-property": ["es-define-property@1.0.1", "", {}, "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g=="], + + "es-errors": ["es-errors@1.3.0", "", {}, "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw=="], + + "es-object-atoms": ["es-object-atoms@1.1.1", "", { "dependencies": { "es-errors": "^1.3.0" } }, "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA=="], + + "es-set-tostringtag": ["es-set-tostringtag@2.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "get-intrinsic": "^1.2.6", "has-tostringtag": "^1.0.2", "hasown": "^2.0.2" } }, "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA=="], + + "escape-html": ["escape-html@1.0.3", "", {}, "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow=="], + + "etag": ["etag@1.8.1", "", {}, "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg=="], + + "express": ["express@4.22.2", "", { "dependencies": { "accepts": "~1.3.8", "array-flatten": "1.1.1", "body-parser": "~1.20.5", "content-disposition": "~0.5.4", "content-type": "~1.0.4", "cookie": "~0.7.1", "cookie-signature": "~1.0.6", "debug": "2.6.9", "depd": "2.0.0", "encodeurl": "~2.0.0", "escape-html": "~1.0.3", "etag": "~1.8.1", "finalhandler": "~1.3.1", "fresh": "~0.5.2", "http-errors": "~2.0.0", "merge-descriptors": "1.0.3", "methods": "~1.1.2", "on-finished": "~2.4.1", "parseurl": "~1.3.3", "path-to-regexp": "~0.1.12", "proxy-addr": "~2.0.7", "qs": "~6.15.1", "range-parser": "~1.2.1", "safe-buffer": "5.2.1", "send": "~0.19.0", "serve-static": "~1.16.2", "setprototypeof": "1.2.0", "statuses": "~2.0.1", "type-is": "~1.6.18", "utils-merge": "1.0.1", "vary": "~1.1.2" } }, "sha512-IuL+Elrou2ZvCFHs18/CIzy2Nzvo25nZ1/D2eIZlz7c+QUayAcYoiM2BthCjs+EBHVpjYjcuLDAiCWgeIX3X1Q=="], + + "express-rate-limit": ["express-rate-limit@8.5.2", "", { "dependencies": { "ip-address": "^10.2.0" }, "peerDependencies": { "express": ">= 4.11" } }, "sha512-5Kb34ipNX694DH48vN9irak1Qx30nb0PLYHXfJgw4YEjiC3ZEmZJhwOp+VfiCYwFzvFTdB9QkArYS5kXa2cx2A=="], + + "fast-safe-stringify": ["fast-safe-stringify@2.1.1", "", {}, "sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA=="], + + "finalhandler": ["finalhandler@1.3.2", "", { "dependencies": { "debug": "2.6.9", "encodeurl": "~2.0.0", "escape-html": "~1.0.3", "on-finished": "~2.4.1", "parseurl": "~1.3.3", "statuses": "~2.0.2", "unpipe": "~1.0.0" } }, "sha512-aA4RyPcd3badbdABGDuTXCMTtOneUCAYH/gxoYRTZlIJdF0YPWuGqiAsIrhNnnqdXGswYk6dGujem4w80UJFhg=="], + + "form-data": ["form-data@4.0.5", "", { "dependencies": { "asynckit": "^0.4.0", "combined-stream": "^1.0.8", "es-set-tostringtag": "^2.1.0", "hasown": "^2.0.2", "mime-types": "^2.1.12" } }, "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w=="], + + "formidable": ["formidable@3.5.4", "", { "dependencies": { "@paralleldrive/cuid2": "^2.2.2", "dezalgo": "^1.0.4", "once": "^1.4.0" } }, "sha512-YikH+7CUTOtP44ZTnUhR7Ic2UASBPOqmaRkRKxRbywPTe5VxF7RRCck4af9wutiZ/QKM5nME9Bie2fFaPz5Gug=="], + + "forwarded": ["forwarded@0.2.0", "", {}, "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow=="], + + "fresh": ["fresh@0.5.2", "", {}, "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q=="], + + "function-bind": ["function-bind@1.1.2", "", {}, "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="], + + "get-intrinsic": ["get-intrinsic@1.3.0", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "es-define-property": "^1.0.1", "es-errors": "^1.3.0", "es-object-atoms": "^1.1.1", "function-bind": "^1.1.2", "get-proto": "^1.0.1", "gopd": "^1.2.0", "has-symbols": "^1.1.0", "hasown": "^2.0.2", "math-intrinsics": "^1.1.0" } }, "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ=="], + + "get-proto": ["get-proto@1.0.1", "", { "dependencies": { "dunder-proto": "^1.0.1", "es-object-atoms": "^1.0.0" } }, "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g=="], + + "gopd": ["gopd@1.2.0", "", {}, "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg=="], + + "has-symbols": ["has-symbols@1.1.0", "", {}, "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ=="], + + "has-tostringtag": ["has-tostringtag@1.0.2", "", { "dependencies": { "has-symbols": "^1.0.3" } }, "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw=="], + + "hasown": ["hasown@2.0.3", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg=="], + + "http-errors": ["http-errors@2.0.1", "", { "dependencies": { "depd": "~2.0.0", "inherits": "~2.0.4", "setprototypeof": "~1.2.0", "statuses": "~2.0.2", "toidentifier": "~1.0.1" } }, "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ=="], + + "iconv-lite": ["iconv-lite@0.4.24", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3" } }, "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA=="], + + "inherits": ["inherits@2.0.4", "", {}, "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="], + + "ip-address": ["ip-address@10.2.0", "", {}, "sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA=="], + + "ipaddr.js": ["ipaddr.js@1.9.1", "", {}, "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g=="], + + "math-intrinsics": ["math-intrinsics@1.1.0", "", {}, "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g=="], + + "media-typer": ["media-typer@0.3.0", "", {}, "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ=="], + + "merge-descriptors": ["merge-descriptors@1.0.3", "", {}, "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ=="], + + "methods": ["methods@1.1.2", "", {}, "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w=="], + + "mime": ["mime@2.6.0", "", { "bin": { "mime": "cli.js" } }, "sha512-USPkMeET31rOMiarsBNIHZKLGgvKc/LrjofAnBlOttf5ajRvqiRA8QsenbcooctK6d6Ts6aqZXBA+XbkKthiQg=="], + + "mime-db": ["mime-db@1.52.0", "", {}, "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="], + + "mime-types": ["mime-types@2.1.35", "", { "dependencies": { "mime-db": "1.52.0" } }, "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw=="], + + "morgan": ["morgan@1.10.1", "", { "dependencies": { "basic-auth": "~2.0.1", "debug": "2.6.9", "depd": "~2.0.0", "on-finished": "~2.3.0", "on-headers": "~1.1.0" } }, "sha512-223dMRJtI/l25dJKWpgij2cMtywuG/WiUKXdvwfbhGKBhy1puASqXwFzmWZ7+K73vUPoR7SS2Qz2cI/g9MKw0A=="], + + "ms": ["ms@2.0.0", "", {}, "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="], + + "negotiator": ["negotiator@0.6.3", "", {}, "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg=="], + + "object-inspect": ["object-inspect@1.13.4", "", {}, "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew=="], + + "on-finished": ["on-finished@2.4.1", "", { "dependencies": { "ee-first": "1.1.1" } }, "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg=="], + + "on-headers": ["on-headers@1.1.0", "", {}, "sha512-737ZY3yNnXy37FHkQxPzt4UZ2UWPWiCZWLvFZ4fu5cueciegX0zGPnrlY6bwRg4FdQOe9YU8MkmJwGhoMybl8A=="], + + "once": ["once@1.4.0", "", { "dependencies": { "wrappy": "1" } }, "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w=="], + + "parseurl": ["parseurl@1.3.3", "", {}, "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ=="], + + "path-to-regexp": ["path-to-regexp@0.1.13", "", {}, "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA=="], + + "proxy-addr": ["proxy-addr@2.0.7", "", { "dependencies": { "forwarded": "0.2.0", "ipaddr.js": "1.9.1" } }, "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg=="], + + "qs": ["qs@6.15.2", "", { "dependencies": { "side-channel": "^1.1.0" } }, "sha512-Rzq0KEyX/w/tEybncDgdkZrJgVUsUMk3xjh3t5bv3S1HTAtg+uOYt72+ZfwiQwKdysThkTBdL/rTi6HDmX9Ddw=="], + + "range-parser": ["range-parser@1.2.1", "", {}, "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg=="], + + "raw-body": ["raw-body@2.5.3", "", { "dependencies": { "bytes": "~3.1.2", "http-errors": "~2.0.1", "iconv-lite": "~0.4.24", "unpipe": "~1.0.0" } }, "sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA=="], + + "safe-buffer": ["safe-buffer@5.2.1", "", {}, "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ=="], + + "safer-buffer": ["safer-buffer@2.1.2", "", {}, "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="], + + "send": ["send@0.19.2", "", { "dependencies": { "debug": "2.6.9", "depd": "2.0.0", "destroy": "1.2.0", "encodeurl": "~2.0.0", "escape-html": "~1.0.3", "etag": "~1.8.1", "fresh": "~0.5.2", "http-errors": "~2.0.1", "mime": "1.6.0", "ms": "2.1.3", "on-finished": "~2.4.1", "range-parser": "~1.2.1", "statuses": "~2.0.2" } }, "sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg=="], + + "serve-static": ["serve-static@1.16.3", "", { "dependencies": { "encodeurl": "~2.0.0", "escape-html": "~1.0.3", "parseurl": "~1.3.3", "send": "~0.19.1" } }, "sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA=="], + + "setprototypeof": ["setprototypeof@1.2.0", "", {}, "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw=="], + + "side-channel": ["side-channel@1.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.3", "side-channel-list": "^1.0.0", "side-channel-map": "^1.0.1", "side-channel-weakmap": "^1.0.2" } }, "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw=="], + + "side-channel-list": ["side-channel-list@1.0.1", "", { "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.4" } }, "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w=="], + + "side-channel-map": ["side-channel-map@1.0.1", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.5", "object-inspect": "^1.13.3" } }, "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA=="], + + "side-channel-weakmap": ["side-channel-weakmap@1.0.2", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.5", "object-inspect": "^1.13.3", "side-channel-map": "^1.0.1" } }, "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A=="], + + "statuses": ["statuses@2.0.2", "", {}, "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw=="], + + "superagent": ["superagent@10.3.0", "", { "dependencies": { "component-emitter": "^1.3.1", "cookiejar": "^2.1.4", "debug": "^4.3.7", "fast-safe-stringify": "^2.1.1", "form-data": "^4.0.5", "formidable": "^3.5.4", "methods": "^1.1.2", "mime": "2.6.0", "qs": "^6.14.1" } }, "sha512-B+4Ik7ROgVKrQsXTV0Jwp2u+PXYLSlqtDAhYnkkD+zn3yg8s/zjA2MeGayPoY/KICrbitwneDHrjSotxKL+0XQ=="], + + "supertest": ["supertest@7.2.2", "", { "dependencies": { "cookie-signature": "^1.2.2", "methods": "^1.1.2", "superagent": "^10.3.0" } }, "sha512-oK8WG9diS3DlhdUkcFn4tkNIiIbBx9lI2ClF8K+b2/m8Eyv47LSawxUzZQSNKUrVb2KsqeTDCcjAAVPYaSLVTA=="], + + "toidentifier": ["toidentifier@1.0.1", "", {}, "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA=="], + + "type-is": ["type-is@1.6.18", "", { "dependencies": { "media-typer": "0.3.0", "mime-types": "~2.1.24" } }, "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g=="], + + "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], + + "undici-types": ["undici-types@7.24.6", "", {}, "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg=="], + + "unpipe": ["unpipe@1.0.0", "", {}, "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ=="], + + "utils-merge": ["utils-merge@1.0.1", "", {}, "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA=="], + + "vary": ["vary@1.1.2", "", {}, "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg=="], + + "wrappy": ["wrappy@1.0.2", "", {}, "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ=="], + + "zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], + + "basic-auth/safe-buffer": ["safe-buffer@5.1.2", "", {}, "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g=="], + + "express/cookie-signature": ["cookie-signature@1.0.7", "", {}, "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA=="], + + "morgan/on-finished": ["on-finished@2.3.0", "", { "dependencies": { "ee-first": "1.1.1" } }, "sha512-ikqdkGAAyf/X/gPhXGvfgAytDZtDbr+bkNUJ0N9h5MI/dmdgCs3l6hoHrcUv41sRKew3jIwrp4qQDXiK99Utww=="], + + "send/mime": ["mime@1.6.0", "", { "bin": { "mime": "cli.js" } }, "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg=="], + + "send/ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], + + "superagent/debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], + + "superagent/debug/ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], + } +} diff --git a/src/problem5/bunfig.toml b/src/problem5/bunfig.toml new file mode 100644 index 0000000000..8370a0154a --- /dev/null +++ b/src/problem5/bunfig.toml @@ -0,0 +1,2 @@ +[test] +preload = ["./tests/setup.ts"] diff --git a/src/problem5/docker-compose.test.yml b/src/problem5/docker-compose.test.yml new file mode 100644 index 0000000000..c5dbf713f3 --- /dev/null +++ b/src/problem5/docker-compose.test.yml @@ -0,0 +1,11 @@ +services: + test: + build: + context: . + dockerfile: Dockerfile.test + image: problem5-test + container_name: problem5-test + environment: + NODE_ENV: test + CI: "true" + command: ["bun", "test"] diff --git a/src/problem5/docker-compose.yml b/src/problem5/docker-compose.yml new file mode 100644 index 0000000000..376e90277f --- /dev/null +++ b/src/problem5/docker-compose.yml @@ -0,0 +1,14 @@ +services: + api: + build: . + image: problem5 + container_name: problem5-api + ports: + - "8000:8000" + environment: + PORT: "8000" + HOST: "0.0.0.0" + DB_PATH: "/app/data/app.db" + volumes: + - ./data:/app/data + restart: unless-stopped diff --git a/src/problem5/package.json b/src/problem5/package.json new file mode 100644 index 0000000000..d534ea02be --- /dev/null +++ b/src/problem5/package.json @@ -0,0 +1,28 @@ +{ + "name": "problem5", + "module": "src/index.ts", + "type": "module", + "private": true, + "scripts": { + "start": "bun run src/index.ts", + "dev": "bun --watch run src/index.ts", + "test": "bun test", + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "express": "^4.21.2", + "express-rate-limit": "^8.5.2", + "morgan": "^1.10.1", + "zod": "^3.23.8" + }, + "devDependencies": { + "@types/bun": "latest", + "@types/express": "^4.17.21", + "@types/morgan": "^1.9.10", + "@types/supertest": "^7.2.0", + "supertest": "^7.2.2" + }, + "peerDependencies": { + "typescript": "^5" + } +} diff --git a/src/problem5/src/app.ts b/src/problem5/src/app.ts new file mode 100644 index 0000000000..33e2d953b2 --- /dev/null +++ b/src/problem5/src/app.ts @@ -0,0 +1,25 @@ +import express from "express"; +import { resourcesRouter } from "./routes/resources.ts"; +import { errorHandler, notFoundHandler } from "./middleware/errorHandler.ts"; +import { createRateLimiter } from "./middleware/rateLimit.ts"; +import { createRequestLogger } from "./middleware/requestLogger.ts"; + +export const createApp = (): express.Express => { + const app = express(); + + app.use(createRequestLogger()); + app.use(express.json({ limit: "1mb" })); + + app.get("/health", (_req, res) => { + res.json({ status: "ok" }); + }); + + app.use(createRateLimiter()); + + app.use("/resources", resourcesRouter); + + app.use(notFoundHandler); + app.use(errorHandler); + + return app; +}; diff --git a/src/problem5/src/db/index.ts b/src/problem5/src/db/index.ts new file mode 100644 index 0000000000..739d5ea4f1 --- /dev/null +++ b/src/problem5/src/db/index.ts @@ -0,0 +1,56 @@ +import { Database } from "bun:sqlite"; + +const isTestEnv = process.env.NODE_ENV === "test"; + +const resolveDbPath = (): string => { + if (isTestEnv) { + const configured = process.env.DB_PATH; + if (configured && configured !== ":memory:") { + console.warn( + `[db] NODE_ENV=test — ignoring DB_PATH=${configured} and using an in-memory database to protect dev/prod data`, + ); + } + return ":memory:"; + } + return process.env.DB_PATH ?? "./data/app.db"; +}; + +const DB_PATH = resolveDbPath(); + +const db = new Database(DB_PATH, { create: true }); + +db.run("PRAGMA journal_mode = WAL;"); +db.run("PRAGMA foreign_keys = ON;"); + +db.run(` + CREATE TABLE IF NOT EXISTS resources ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + description TEXT, + status TEXT NOT NULL CHECK (status IN ('active', 'inactive', 'archived')), + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL + ); +`); + +db.run(`CREATE INDEX IF NOT EXISTS idx_resources_status ON resources(status);`); +db.run(`CREATE INDEX IF NOT EXISTS idx_resources_created_at ON resources(created_at);`); + +db.run(` + CREATE TABLE IF NOT EXISTS idempotency_keys ( + key TEXT NOT NULL, + scope TEXT NOT NULL, + request_hash TEXT NOT NULL, + response_status INTEGER, + response_body TEXT, + created_at TEXT NOT NULL, + completed_at TEXT, + PRIMARY KEY (scope, key) + ); +`); + +db.run( + `CREATE INDEX IF NOT EXISTS idx_idempotency_created_at ON idempotency_keys(created_at);`, +); + +export default db; diff --git a/src/problem5/src/index.ts b/src/problem5/src/index.ts new file mode 100644 index 0000000000..b4d7dff5a6 --- /dev/null +++ b/src/problem5/src/index.ts @@ -0,0 +1,46 @@ +import { createApp } from "./app.ts"; +import db from "./db/index.ts"; + +const PORT = Number(process.env.PORT ?? 8000); +const HOST = process.env.HOST ?? "0.0.0.0"; +const SHUTDOWN_TIMEOUT_MS = 10_000; + +const app = createApp(); + +const server = app.listen(PORT, HOST, () => { + console.log(`[server] listening on http://${HOST}:${PORT}`); +}); + +let shuttingDown = false; + +const shutdown = (signal: string): void => { + if (shuttingDown) return; + shuttingDown = true; + console.log(`[server] ${signal} received, draining in-flight requests…`); + + const forceExit = setTimeout(() => { + console.error( + `[server] forced exit after ${SHUTDOWN_TIMEOUT_MS}ms of graceful shutdown`, + ); + process.exit(1); + }, SHUTDOWN_TIMEOUT_MS); + forceExit.unref(); + + server.close((err) => { + if (err) { + console.error("[server] HTTP close error", err); + process.exit(1); + } + try { + db.close(false); + } catch (e) { + console.error("[server] DB close error", e); + } + clearTimeout(forceExit); + console.log("[server] shutdown complete"); + process.exit(0); + }); +}; + +process.on("SIGTERM", () => shutdown("SIGTERM")); +process.on("SIGINT", () => shutdown("SIGINT")); diff --git a/src/problem5/src/middleware/errorHandler.ts b/src/problem5/src/middleware/errorHandler.ts new file mode 100644 index 0000000000..48dd3b9599 --- /dev/null +++ b/src/problem5/src/middleware/errorHandler.ts @@ -0,0 +1,41 @@ +import type { ErrorRequestHandler, RequestHandler } from "express"; +import { ZodError } from "zod"; + +export class HttpError extends Error { + status: number; + details?: unknown; + constructor(status: number, message: string, details?: unknown) { + super(message); + this.status = status; + this.details = details; + } +} + +export const notFoundHandler: RequestHandler = (_req, res) => { + res.status(404).json({ error: "Not Found" }); +}; + +export const errorHandler: ErrorRequestHandler = (err, _req, res, _next) => { + if (err instanceof ZodError) { + res.status(400).json({ + error: "Validation failed", + details: err.flatten(), + }); + return; + } + if (err instanceof HttpError) { + res.status(err.status).json({ error: err.message, details: err.details }); + return; + } + if ( + err instanceof SyntaxError && + "status" in err && + (err as { status?: number }).status === 400 && + "body" in err + ) { + res.status(400).json({ error: "Invalid JSON body" }); + return; + } + console.error("[error]", err); + res.status(500).json({ error: "Internal Server Error" }); +}; diff --git a/src/problem5/src/middleware/idempotency.ts b/src/problem5/src/middleware/idempotency.ts new file mode 100644 index 0000000000..a391cc1eb2 --- /dev/null +++ b/src/problem5/src/middleware/idempotency.ts @@ -0,0 +1,170 @@ +import type { RequestHandler, Response } from "express"; +import db from "../db/index.ts"; + +const DEFAULT_TTL_MS = 24 * 60 * 60 * 1000; +const MAX_KEY_LENGTH = 255; +const KEY_PATTERN = /^[A-Za-z0-9_\-]+$/; + +const envInt = (name: string, fallback: number): number => { + const raw = process.env[name]; + if (raw === undefined || raw === "") return fallback; + const parsed = Number(raw); + return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback; +}; + +type StoredRow = { + request_hash: string; + response_status: number | null; + response_body: string | null; + created_at: string; +}; + +const selectStmt = db.prepare( + `SELECT request_hash, response_status, response_body, created_at + FROM idempotency_keys + WHERE scope = ? AND key = ?`, +); + +const insertPendingStmt = db.prepare( + `INSERT OR IGNORE INTO idempotency_keys + (key, scope, request_hash, created_at) + VALUES ($key, $scope, $hash, $now)`, +); + +const completeStmt = db.prepare( + `UPDATE idempotency_keys + SET response_status = $status, + response_body = $body, + completed_at = $now + WHERE scope = $scope AND key = $key`, +); + +const deleteStmt = db.prepare( + `DELETE FROM idempotency_keys WHERE scope = ? AND key = ?`, +); + +const pruneStmt = db.prepare(`DELETE FROM idempotency_keys WHERE created_at < ?`); + +const hashRequest = (method: string, path: string, body: unknown): string => { + const payload = JSON.stringify({ method, path, body: body ?? null }); + return new Bun.CryptoHasher("blake2b256").update(payload).digest("hex"); +}; + +const captureResponse = ( + res: Response, + onComplete: (status: number, body: string | null) => void, +): void => { + const origJson = res.json.bind(res); + const origSend = res.send.bind(res); + let captured = false; + + const capture = (body: unknown): string | null => { + if (body === undefined) return null; + if (typeof body === "string") return body; + if (Buffer.isBuffer(body)) return body.toString("utf8"); + try { + return JSON.stringify(body); + } catch { + return null; + } + }; + + res.json = (body: unknown) => { + if (!captured) { + captured = true; + onComplete(res.statusCode, capture(body)); + } + return origJson(body); + }; + + res.send = (body: unknown) => { + if (!captured) { + captured = true; + onComplete(res.statusCode, capture(body)); + } + return origSend(body); + }; +}; + +export const createIdempotencyMiddleware = (): RequestHandler => { + const ttlMs = envInt("IDEMPOTENCY_TTL_MS", DEFAULT_TTL_MS); + + return (req, res, next) => { + const raw = req.header("Idempotency-Key"); + if (raw === undefined) { + next(); + return; + } + + const key = raw.trim(); + if (!key || key.length > MAX_KEY_LENGTH || !KEY_PATTERN.test(key)) { + res.status(400).json({ + error: + "Invalid Idempotency-Key header (allowed: 1-255 chars, A-Z, a-z, 0-9, _, -)", + }); + return; + } + + const scope = `${req.method} ${req.baseUrl}${req.path}`; + const requestHash = hashRequest(req.method, scope, req.body); + const now = new Date().toISOString(); + + pruneStmt.run(new Date(Date.now() - ttlMs).toISOString()); + + const insert = insertPendingStmt.run({ + $key: key, + $scope: scope, + $hash: requestHash, + $now: now, + }); + + if (insert.changes === 0) { + const existing = selectStmt.get(scope, key); + if (!existing) { + next(); + return; + } + + if (existing.request_hash !== requestHash) { + res.status(422).json({ + error: + "Idempotency-Key was already used with a different request payload", + }); + return; + } + + if (existing.response_status === null) { + res + .status(409) + .setHeader("Retry-After", "1") + .json({ error: "A request with this Idempotency-Key is still in progress" }); + return; + } + + res.setHeader("Idempotent-Replayed", "true"); + res.status(existing.response_status); + if (existing.response_body === null) { + res.send(); + } else { + res.type("application/json").send(existing.response_body); + } + return; + } + + captureResponse(res, (status, body) => { + if (status >= 500) { + deleteStmt.run(scope, key); + return; + } + completeStmt.run({ + $key: key, + $scope: scope, + $status: status, + $body: body, + $now: new Date().toISOString(), + }); + }); + + next(); + }; +}; diff --git a/src/problem5/src/middleware/rateLimit.ts b/src/problem5/src/middleware/rateLimit.ts new file mode 100644 index 0000000000..8cda9e7b13 --- /dev/null +++ b/src/problem5/src/middleware/rateLimit.ts @@ -0,0 +1,20 @@ +import rateLimit, { type RateLimitRequestHandler } from "express-rate-limit"; + +const DEFAULT_WINDOW_MS = 15 * 60 * 1000; +const DEFAULT_MAX = 200; + +const envInt = (name: string, fallback: number): number => { + const raw = process.env[name]; + if (raw === undefined || raw === "") return fallback; + const parsed = Number(raw); + return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback; +}; + +export const createRateLimiter = (): RateLimitRequestHandler => + rateLimit({ + windowMs: envInt("RATE_LIMIT_WINDOW_MS", DEFAULT_WINDOW_MS), + limit: envInt("RATE_LIMIT_MAX", DEFAULT_MAX), + standardHeaders: "draft-7", + legacyHeaders: false, + message: { error: "Too Many Requests" }, + }); diff --git a/src/problem5/src/middleware/requestLogger.ts b/src/problem5/src/middleware/requestLogger.ts new file mode 100644 index 0000000000..f7f4e2094a --- /dev/null +++ b/src/problem5/src/middleware/requestLogger.ts @@ -0,0 +1,10 @@ +import morgan from "morgan"; +import type { RequestHandler } from "express"; + +const FORMAT = '[:date[iso]] :remote-addr :method :url :status :response-time ms'; + +export const createRequestLogger = (): RequestHandler => + morgan(FORMAT, { + skip: (req) => + process.env.NODE_ENV === "test" || req.url === "/health", + }); diff --git a/src/problem5/src/routes/resources.ts b/src/problem5/src/routes/resources.ts new file mode 100644 index 0000000000..aa44b9e5af --- /dev/null +++ b/src/problem5/src/routes/resources.ts @@ -0,0 +1,48 @@ +import { Router } from "express"; +import { + CreateResourceInput, + ListResourcesQuery, + UpdateResourceInput, +} from "../schemas/resource.ts"; +import { + createResource, + deleteResource, + getResource, + listResources, + updateResource, +} from "../services/resources.ts"; +import { HttpError } from "../middleware/errorHandler.ts"; +import { createIdempotencyMiddleware } from "../middleware/idempotency.ts"; + +export const resourcesRouter: Router = Router(); + +resourcesRouter.post("/", createIdempotencyMiddleware(), (req, res) => { + const input = CreateResourceInput.parse(req.body); + const created = createResource(input); + res.status(201).json(created); +}); + +resourcesRouter.get("/", (req, res) => { + const query = ListResourcesQuery.parse(req.query); + const result = listResources(query); + res.json(result); +}); + +resourcesRouter.get("/:id", (req, res) => { + const resource = getResource(req.params.id); + if (!resource) throw new HttpError(404, "Resource not found"); + res.json(resource); +}); + +resourcesRouter.patch("/:id", (req, res) => { + const input = UpdateResourceInput.parse(req.body); + const updated = updateResource(req.params.id, input); + if (!updated) throw new HttpError(404, "Resource not found"); + res.json(updated); +}); + +resourcesRouter.delete("/:id", (req, res) => { + const deleted = deleteResource(req.params.id); + if (!deleted) throw new HttpError(404, "Resource not found"); + res.status(204).send(); +}); diff --git a/src/problem5/src/schemas/resource.ts b/src/problem5/src/schemas/resource.ts new file mode 100644 index 0000000000..b919a9ba61 --- /dev/null +++ b/src/problem5/src/schemas/resource.ts @@ -0,0 +1,30 @@ +import { z } from "zod"; + +export const ResourceStatus = z.enum(["active", "inactive", "archived"]); +export type ResourceStatus = z.infer; + +export const CreateResourceInput = z.object({ + name: z.string().trim().min(1).max(200), + description: z.string().trim().max(2000).optional(), + status: ResourceStatus.optional().default("active"), +}); +export type CreateResourceInput = z.infer; + +export const UpdateResourceInput = z + .object({ + name: z.string().trim().min(1).max(200).optional(), + description: z.string().trim().max(2000).nullable().optional(), + status: ResourceStatus.optional(), + }) + .refine((v) => Object.keys(v).length > 0, { + message: "At least one field must be provided", + }); +export type UpdateResourceInput = z.infer; + +export const ListResourcesQuery = z.object({ + status: ResourceStatus.optional(), + q: z.string().trim().min(1).max(200).optional(), + limit: z.coerce.number().int().min(1).max(100).optional().default(20), + offset: z.coerce.number().int().min(0).optional().default(0), +}); +export type ListResourcesQuery = z.infer; diff --git a/src/problem5/src/services/resources.ts b/src/problem5/src/services/resources.ts new file mode 100644 index 0000000000..5bbe58942e --- /dev/null +++ b/src/problem5/src/services/resources.ts @@ -0,0 +1,149 @@ +import { randomUUID } from "node:crypto"; +import db from "../db/index.ts"; +import type { + CreateResourceInput, + ListResourcesQuery, + ResourceStatus, + UpdateResourceInput, +} from "../schemas/resource.ts"; + +export type Resource = { + id: string; + name: string; + description: string | null; + status: ResourceStatus; + createdAt: string; + updatedAt: string; +}; + +type ResourceRow = { + id: string; + name: string; + description: string | null; + status: ResourceStatus; + created_at: string; + updated_at: string; +}; + +const mapRow = (row: ResourceRow): Resource => ({ + id: row.id, + name: row.name, + description: row.description, + status: row.status, + createdAt: row.created_at, + updatedAt: row.updated_at, +}); + +export function createResource(input: CreateResourceInput): Resource { + const id = randomUUID(); + const now = new Date().toISOString(); + const row = { + $id: id, + $name: input.name, + $description: input.description ?? null, + $status: input.status, + $created_at: now, + $updated_at: now, + }; + db.prepare( + `INSERT INTO resources (id, name, description, status, created_at, updated_at) + VALUES ($id, $name, $description, $status, $created_at, $updated_at)`, + ).run(row); + return mapRow({ + id: row.$id, + name: row.$name, + description: row.$description, + status: row.$status, + created_at: row.$created_at, + updated_at: row.$updated_at, + }); +} + +export function getResource(id: string): Resource | null { + const row = db + .prepare(`SELECT * FROM resources WHERE id = ?`) + .get(id); + return row ? mapRow(row) : null; +} + +export function listResources(query: ListResourcesQuery): { + items: Resource[]; + total: number; + limit: number; + offset: number; +} { + const where: string[] = []; + const params: Record = {}; + + if (query.status) { + where.push("status = $status"); + params.$status = query.status; + } + if (query.q) { + where.push("(name LIKE $q OR description LIKE $q)"); + params.$q = `%${query.q}%`; + } + + const whereClause = where.length ? `WHERE ${where.join(" AND ")}` : ""; + + const totalRow = db + .prepare<{ count: number }, Record>( + `SELECT COUNT(*) AS count FROM resources ${whereClause}`, + ) + .get(params); + const total = totalRow?.count ?? 0; + + const rows = db + .prepare>( + `SELECT * FROM resources ${whereClause} + ORDER BY created_at DESC + LIMIT $limit OFFSET $offset`, + ) + .all({ ...params, $limit: query.limit, $offset: query.offset }); + + return { + items: rows.map(mapRow), + total, + limit: query.limit, + offset: query.offset, + }; +} + +export function updateResource( + id: string, + input: UpdateResourceInput, +): Resource | null { + const existing = getResource(id); + if (!existing) return null; + + const next: Resource = { + ...existing, + name: input.name ?? existing.name, + description: + input.description === undefined ? existing.description : input.description, + status: input.status ?? existing.status, + updatedAt: new Date().toISOString(), + }; + + db.prepare( + `UPDATE resources + SET name = $name, + description = $description, + status = $status, + updated_at = $updated_at + WHERE id = $id`, + ).run({ + $id: id, + $name: next.name, + $description: next.description, + $status: next.status, + $updated_at: next.updatedAt, + }); + + return next; +} + +export function deleteResource(id: string): boolean { + const result = db.prepare(`DELETE FROM resources WHERE id = ?`).run(id); + return result.changes > 0; +} diff --git a/src/problem5/tests/resources.test.ts b/src/problem5/tests/resources.test.ts new file mode 100644 index 0000000000..c96558b0e9 --- /dev/null +++ b/src/problem5/tests/resources.test.ts @@ -0,0 +1,295 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import request from "supertest"; +import { createApp } from "../src/app.ts"; +import db from "../src/db/index.ts"; + +const app = createApp(); + +const clearDb = (): void => { + db.run("DELETE FROM resources"); + db.run("DELETE FROM idempotency_keys"); +}; + +afterEach(clearDb); + +describe("POST /resources", () => { + test("creates a resource with defaults", async () => { + const res = await request(app) + .post("/resources") + .send({ name: "alpha" }) + .expect(201); + expect(res.body).toMatchObject({ + name: "alpha", + description: null, + status: "active", + }); + expect(res.body.id).toMatch(/^[0-9a-f-]{36}$/); + expect(res.body.createdAt).toBe(res.body.updatedAt); + }); + + test("accepts description and explicit status", async () => { + const res = await request(app) + .post("/resources") + .send({ name: "beta", description: " spaced ", status: "inactive" }) + .expect(201); + expect(res.body.description).toBe("spaced"); + expect(res.body.status).toBe("inactive"); + }); + + test("rejects missing name", async () => { + const res = await request(app).post("/resources").send({}); + expect(res.status).toBe(400); + expect(res.body.error).toBe("Validation failed"); + }); + + test("rejects empty name", async () => { + const res = await request(app).post("/resources").send({ name: " " }); + expect(res.status).toBe(400); + }); + + test("rejects unknown status value", async () => { + const res = await request(app) + .post("/resources") + .send({ name: "x", status: "weird" }); + expect(res.status).toBe(400); + }); + + test("rejects malformed JSON body", async () => { + const res = await request(app) + .post("/resources") + .set("content-type", "application/json") + .send("{not json"); + expect(res.status).toBe(400); + expect(res.body.error).toBe("Invalid JSON body"); + }); +}); + +describe("GET /resources", () => { + test("returns empty page when nothing exists", async () => { + const res = await request(app).get("/resources").expect(200); + expect(res.body).toEqual({ items: [], total: 0, limit: 20, offset: 0 }); + }); + + test("lists all resources with pagination metadata", async () => { + await request(app).post("/resources").send({ name: "a" }); + await request(app).post("/resources").send({ name: "b", status: "archived" }); + + const res = await request(app).get("/resources").expect(200); + expect(res.body).toMatchObject({ total: 2, limit: 20, offset: 0 }); + expect(res.body.items).toHaveLength(2); + }); + + test("filters by status", async () => { + await request(app).post("/resources").send({ name: "active-one" }); + await request(app) + .post("/resources") + .send({ name: "archived-one", status: "archived" }); + + const res = await request(app).get("/resources?status=archived").expect(200); + expect(res.body.total).toBe(1); + expect(res.body.items[0].name).toBe("archived-one"); + }); + + test("filters by q substring on name and description", async () => { + await request(app) + .post("/resources") + .send({ name: "alpha widget", description: "tool" }); + await request(app) + .post("/resources") + .send({ name: "beta", description: "gadget" }); + + const byName = await request(app).get("/resources?q=widget").expect(200); + expect(byName.body.total).toBe(1); + expect(byName.body.items[0].name).toBe("alpha widget"); + + const byDesc = await request(app).get("/resources?q=gadget").expect(200); + expect(byDesc.body.total).toBe(1); + expect(byDesc.body.items[0].name).toBe("beta"); + }); + + test("honours limit and offset", async () => { + for (const name of ["a", "b", "c"]) { + await request(app).post("/resources").send({ name }); + } + const res = await request(app) + .get("/resources?limit=1&offset=1") + .expect(200); + expect(res.body).toMatchObject({ total: 3, limit: 1, offset: 1 }); + expect(res.body.items).toHaveLength(1); + }); + + test("rejects limit above 100", async () => { + const res = await request(app).get("/resources?limit=999"); + expect(res.status).toBe(400); + }); + + test("rejects negative offset", async () => { + const res = await request(app).get("/resources?offset=-1"); + expect(res.status).toBe(400); + }); +}); + +describe("GET /resources/:id", () => { + test("returns the matching resource", async () => { + const create = await request(app).post("/resources").send({ name: "x" }); + const res = await request(app) + .get(`/resources/${create.body.id}`) + .expect(200); + expect(res.body).toEqual(create.body); + }); + + test("404 for unknown id", async () => { + const res = await request(app).get("/resources/does-not-exist"); + expect(res.status).toBe(404); + expect(res.body.error).toBe("Resource not found"); + }); +}); + +describe("PATCH /resources/:id", () => { + test("updates only the provided fields and bumps updatedAt", async () => { + const created = await request(app) + .post("/resources") + .send({ name: "old", description: "keep" }); + + await Bun.sleep(5); + const res = await request(app) + .patch(`/resources/${created.body.id}`) + .send({ name: "new" }) + .expect(200); + + expect(res.body.name).toBe("new"); + expect(res.body.description).toBe("keep"); + expect(res.body.status).toBe("active"); + expect(res.body.createdAt).toBe(created.body.createdAt); + expect(res.body.updatedAt > created.body.updatedAt).toBe(true); + }); + + test("can null out description", async () => { + const created = await request(app) + .post("/resources") + .send({ name: "x", description: "to be removed" }); + const res = await request(app) + .patch(`/resources/${created.body.id}`) + .send({ description: null }) + .expect(200); + expect(res.body.description).toBeNull(); + }); + + test("rejects an empty body", async () => { + const created = await request(app).post("/resources").send({ name: "x" }); + const res = await request(app) + .patch(`/resources/${created.body.id}`) + .send({}); + expect(res.status).toBe(400); + }); + + test("rejects unknown status", async () => { + const created = await request(app).post("/resources").send({ name: "x" }); + const res = await request(app) + .patch(`/resources/${created.body.id}`) + .send({ status: "bogus" }); + expect(res.status).toBe(400); + }); + + test("404 for missing id", async () => { + const res = await request(app) + .patch("/resources/nope") + .send({ name: "x" }); + expect(res.status).toBe(404); + }); +}); + +describe("DELETE /resources/:id", () => { + test("removes the resource and 204s", async () => { + const created = await request(app).post("/resources").send({ name: "x" }); + await request(app).delete(`/resources/${created.body.id}`).expect(204); + await request(app).get(`/resources/${created.body.id}`).expect(404); + }); + + test("404 for missing id", async () => { + await request(app).delete("/resources/nope").expect(404); + }); +}); + +describe("Idempotency on POST /resources", () => { + test("no key → each retry creates a new row", async () => { + const r1 = await request(app).post("/resources").send({ name: "dup" }); + const r2 = await request(app).post("/resources").send({ name: "dup" }); + expect(r1.body.id).not.toBe(r2.body.id); + const list = await request(app).get("/resources"); + expect(list.body.total).toBe(2); + }); + + test("same key + same body → replays original response, no duplicate", async () => { + const key = "demo-key-1"; + const first = await request(app) + .post("/resources") + .set("Idempotency-Key", key) + .send({ name: "once", description: "first try" }); + expect(first.status).toBe(201); + + const second = await request(app) + .post("/resources") + .set("Idempotency-Key", key) + .send({ name: "once", description: "first try" }); + expect(second.status).toBe(201); + expect(second.body.id).toBe(first.body.id); + expect(second.body.createdAt).toBe(first.body.createdAt); + expect(second.headers["idempotent-replayed"]).toBe("true"); + + const list = await request(app).get("/resources"); + expect(list.body.total).toBe(1); + }); + + test("same key + different body → 422 mismatch", async () => { + const key = "demo-key-2"; + await request(app) + .post("/resources") + .set("Idempotency-Key", key) + .send({ name: "a" }); + const mismatch = await request(app) + .post("/resources") + .set("Idempotency-Key", key) + .send({ name: "b" }); + expect(mismatch.status).toBe(422); + expect(mismatch.body.error).toContain("different request payload"); + }); + + test("malformed key → 400", async () => { + const res = await request(app) + .post("/resources") + .set("Idempotency-Key", "bad key!") + .send({ name: "x" }); + expect(res.status).toBe(400); + expect(res.body.error).toContain("Invalid Idempotency-Key"); + }); + + test("different keys → independent creates", async () => { + const a = await request(app) + .post("/resources") + .set("Idempotency-Key", "key-a") + .send({ name: "x" }); + const b = await request(app) + .post("/resources") + .set("Idempotency-Key", "key-b") + .send({ name: "x" }); + expect(a.body.id).not.toBe(b.body.id); + const list = await request(app).get("/resources"); + expect(list.body.total).toBe(2); + }); + + test("validation failures are NOT replayed (4xx not cached as success)", async () => { + const key = "demo-key-3"; + const bad = await request(app) + .post("/resources") + .set("Idempotency-Key", key) + .send({}); + expect(bad.status).toBe(400); + + const retry = await request(app) + .post("/resources") + .set("Idempotency-Key", key) + .send({ name: "now-valid" }); + expect(retry.status).toBe(422); + }); +}); diff --git a/src/problem5/tests/setup.ts b/src/problem5/tests/setup.ts new file mode 100644 index 0000000000..6461c49b24 --- /dev/null +++ b/src/problem5/tests/setup.ts @@ -0,0 +1,4 @@ +process.env.DB_PATH = ":memory:"; +process.env.RATE_LIMIT_MAX = "100000"; +process.env.RATE_LIMIT_WINDOW_MS = "60000"; +process.env.IDEMPOTENCY_TTL_MS = "60000"; diff --git a/src/problem5/tsconfig.json b/src/problem5/tsconfig.json new file mode 100644 index 0000000000..9c6d30de5c --- /dev/null +++ b/src/problem5/tsconfig.json @@ -0,0 +1,24 @@ +{ + "compilerOptions": { + "lib": ["ESNext"], + "target": "ESNext", + "module": "Preserve", + "moduleDetection": "force", + "allowJs": true, + "types": ["bun"], + + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true, + "noEmit": true, + + "strict": true, + "skipLibCheck": true, + "noFallthroughCasesInSwitch": true, + "noUncheckedIndexedAccess": true, + "noImplicitOverride": true, + + "esModuleInterop": true + }, + "include": ["src/**/*.ts", "tests/**/*.ts"] +} diff --git a/src/problem6/DIAGRAM.md b/src/problem6/DIAGRAM.md new file mode 100644 index 0000000000..1f65c79aa6 --- /dev/null +++ b/src/problem6/DIAGRAM.md @@ -0,0 +1,114 @@ +# Scoreboard Module — Flow Diagrams + +These diagrams trace the baseline flows of the Scoreboard Module: + +1. **Action execute** — the synchronous request path from a user action to the live scoreboard broadcast. +2. **Async score flush** — the write-behind worker that drains Redis into the durable `scores` table. + +> Scope: this file reflects **only the core specification** in `ARCHITECTURE.md` §§Overview through Rate Limiting. The features in §Improvement Notes (hash chain, tie-breaking, idempotency keys, velocity detection, rollback, admin APIs, WS graceful degradation, observability) are intentionally **excluded** to keep these diagrams scoped to baseline functionality. + +--- + +## 1. Action Execute Flow + +The happy path runs top-to-bottom. Each check that can fail terminates the request with an HTTP error (401 · 429 · 422 · 500). The WebSocket broadcast runs off Redis Pub/Sub on a separate thread and does **not** block the HTTP response. + +``` +POST /api/action/execute { action_name } + Bearer JWT + │ + ▼ + Auth Middleware + │ + ├── Validate JWT (signature · exp · iat · sub) + │ ├── invalid → 401 Unauthorized [terminate] + │ └── valid → extract user_id from sub + │ + ▼ + Rate Limit Middleware + │ + ├── INCR ratelimit: ← 60 req / 60s per user + │ ├── over limit → 429 Too Many Requests [terminate; Retry-After header] + │ └── within → continue + │ + ▼ + ActionService.execute(user_id, action_name) + │ + ├── SELECT * FROM actions + │ WHERE action_name = ? AND is_active = true + │ ├── not found / inactive → 422 Invalid Action [terminate] + │ └── found → resolve points_awarded server-side + │ + ├── (write-ahead) INSERT into action_log ← synchronous; blocks on failure + │ (user_id, action_id, points_gained) + │ ├── transient failure → retry once after 200ms + │ │ ├── retry ok → proceed to Redis update + │ │ └── retry fails → 500 Internal Error [terminate; no score change applied] + │ ├── hard failure → 500 Internal Error [terminate; no score change applied] + │ └── insert ok → proceed to Redis update + │ + ├── ZINCRBY scoreboard:scores + ├── HSET scoreboard:pending: score + │ + ├── Recompute new top-10: + │ new_top10 = ZREVRANGE scoreboard:scores 0 9 WITHSCORES + │ + ├── GET scoreboard:top10:snapshot → prev_top10 + │ + ├── Diff check: has the ordered set of (user_id, score) pairs changed? + │ ├── YES → SET scoreboard:top10:snapshot + │ │ PUBLISH "leaderboard:update" → Redis Pub/Sub + │ └── NO → skip publish (no broadcast, no snapshot write) + │ + ▼ + 200 OK { user_id, action_name, points_awarded, new_score, rank } + (response returned immediately; not gated on the broadcast) + + + ─────────────────── Pub/Sub side-channel (async, off-thread) ─────────────────── + + Redis channel "leaderboard:update" + │ + ▼ + WebSocket Broadcaster (subscribed to the channel) + │ + └── ws.send(leaderboard_update) → all active WS connections + │ + └── browsers re-render the live scoreboard +``` + +### Notes + +- **Write-ahead audit log.** `INSERT action_log` runs synchronously **before** any Redis state change. If it fails, the request returns 500 and no score is applied — the audit log and the score increment are never decoupled. +- **Top-10 diff.** Two snapshots are equal iff for every position 0–9 both `user_id` and `score` match. Any mismatch triggers a broadcast. +- **Response is not gated on the broadcast.** `PUBLISH` is fire-and-forget; a Pub/Sub failure must not block the HTTP response. The leaderboard self-corrects on the next action execution or on WS reconnect. + +--- + +## 2. Async Score Flush + +Independent of the request path. The flush worker runs every 5 minutes, drains the pending buffer in Redis, and writes accumulated scores to the durable `scores` table. + +``` +Every 5 minutes (flush worker tick) + │ + ▼ + HGETALL scoreboard:pending:* + │ + ├── For each user_id with a pending score (batch size 500): + │ ├── UPDATE scores + │ │ SET score = , updated_at = NOW + │ │ WHERE user_id = + │ │ ├── DB write failed → retry (max 3, exp backoff) + │ │ │ leave pending key for next cycle + │ │ └── DB write ok → DEL scoreboard:pending: + │ └── (next user) + │ + ▼ + Cycle complete; sleep until next tick +``` + +### Notes + +- **Source of truth.** Redis `scoreboard:scores` is the live source for leaderboard reads. The `scores` table is a durable replica, eventually consistent within one flush interval. +- **Atomicity per user.** Each pending key is deleted **only after** its DB write succeeds. A partial flush is safe: entries that failed to write remain queued for the next cycle. +- **Recoverability.** If Redis is lost, the live state is rebuilt by reseeding `scoreboard:scores` from the `scores` table, then replaying `action_log` rows where `created_at > scores.updated_at`. Because the audit log is written synchronously on every execute, no score is permanently lost. diff --git a/src/problem6/IMPROVEMENT.md b/src/problem6/IMPROVEMENT.md new file mode 100644 index 0000000000..800937b4c0 --- /dev/null +++ b/src/problem6/IMPROVEMENT.md @@ -0,0 +1,158 @@ +# Scoreboard Module — Improvement Notes + +**Companion to:** `README.md` + +> Recommendations to the engineering team beyond the baseline specification. These items are deliberately scoped **out** of `README.md` to keep the baseline focused on what must ship in v1. They should be planned as follow-up work once the core module is in production. + +--- + +## Table of Contents + +1. [Idempotency Keys](#1-idempotency-keys) +2. [Velocity Anomaly Detection with Alerting](#2-velocity-anomaly-detection-with-alerting) +3. [Score Rollback Capability](#3-score-rollback-capability) +4. [Redis Failure Runbook](#4-redis-failure-runbook) +5. [Action Management Admin API](#5-action-management-admin-api) +6. [WebSocket Graceful Degradation](#6-websocket-graceful-degradation) +7. [Action Log Integrity via Hash Chain](#7-action-log-integrity-via-hash-chain) +8. [Score Tie-Breaking](#8-score-tie-breaking) +9. [Observability](#9-observability) + +--- + +## 1. Idempotency Keys +Accept an `Idempotency-Key` header on `POST /api/action/execute`. Store seen keys with a short TTL (e.g. 60 seconds) in Redis. If a duplicate key arrives within the window, return the cached response without re-executing the action. This prevents double-increments from client retries or network errors. + +## 2. Velocity Anomaly Detection with Alerting +Track per-user action rates in Redis using a sliding window counter (`velocity:`). If a user exceeds a configurable threshold — for example, more than 100 executions within an hour — flag the account and immediately dispatch an alert to the audit or admin team via **Slack** (or another configured channel such as PagerDuty or email). The alert payload should include: `user_id`, `username`, observed rate, threshold breached, and a deep link to that user's `action_log` entries. Optionally, queue subsequent increments from flagged accounts for manual review rather than applying them in real time. + +## 3. Score Rollback Capability +Implement an admin endpoint `POST /api/admin/scores/rollback` that accepts a `user_id` and an optional `before` timestamp. It recomputes the correct score by summing `points_gained` from `action_log` up to that point and writes the result back through the normal Redis + pending buffer path. This relies entirely on the append-only `action_log` and requires no direct edits to the `scores` table. + +## 4. Redis Failure Runbook +Document a formal recovery procedure: on Redis restart or data loss, reseed `scoreboard:scores` from the `scores` table, then replay any `action_log` entries where `created_at > scores.updated_at` per user. Test this procedure in staging at least once before the first production deployment. + +## 5. Action Management Admin API +Expose a small internal API (`GET/POST/PATCH /api/admin/actions`) to manage the `actions` table — creating new action types, toggling `is_active`, and adjusting `points_awarded`. Gate it behind an admin role claim in the JWT. This allows the product team to tune scoring rules without requiring a code deployment. + +## 6. WebSocket Graceful Degradation +If the Redis Pub/Sub subscription drops, the WebSocket broadcaster should fall back to polling the sorted set on a short interval (e.g. every 3 seconds) and pushing diffs to connected clients. Clients experience marginally higher latency but no disconnection or stale data. + +## 7. Action Log Integrity via Hash Chain + +To make tampering with `action_log` detectable — even by someone with direct database access — each row should carry a cryptographic hash chain, in the style of a blockchain but without decentralisation or a Merkle tree. The result is a linear linked sequence where any mutation, deletion, or insertion of a historical row breaks the chain and is immediately detectable by a verification pass. + +### Schema addition + +Add one column to `action_log`: + +| Column | Type | Notes | +|--------------|-------------|-----------------------------------------------------------------------| +| `entry_hash` | CHAR(64) | SHA-256 hex digest of this row's content combined with the previous row's hash | + +### Hash computation + +When inserting a new `action_log` row, compute its `entry_hash` as: + +``` +entry_hash = SHA-256( + prev_entry_hash // hex string of the immediately preceding row's entry_hash; + // use a known constant (e.g. 64 zero chars) for the first row + || action_log.id // UUID of this row + || action_log.user_id + || action_log.action_id + || action_log.points_gained // stored as string + || action_log.created_at // ISO-8601, microsecond precision +) +``` + +All fields are concatenated as UTF-8 strings with a fixed delimiter (e.g. `|`) to prevent boundary ambiguity. The previous row is the one with the highest `created_at` (or, to handle clock skew safely, the highest auto-incremented sequence number if one is added). The hash is computed in the application layer immediately before the INSERT, within the same write-ahead transaction. + +### Verification + +An audit or admin process can verify the entire chain at any time: + +1. Fetch all `action_log` rows in insertion order. +2. For each row, recompute the expected hash from its fields and the previous row's `entry_hash`. +3. Compare the recomputed hash against the stored `entry_hash`. +4. Any mismatch indicates that the row — or a predecessor — was mutated, deleted, or a row was inserted between two existing ones. + +This verification can be run as a scheduled job (e.g. nightly) and its result exposed as an admin endpoint `GET /api/admin/action-log/verify`, returning the index and `id` of the first broken link if one is found. + +### What this detects + +| Tampering attempt | Detected? | +|------------------------------------------------|-----------| +| Updating a field on an existing row | Yes — that row's hash no longer matches | +| Deleting a row | Yes — the next row's `prev_entry_hash` is now orphaned | +| Inserting a fabricated historical row | Yes — all subsequent hashes break | +| Appending a fabricated future row | Only if the server's insertion path is bypassed; legitimate inserts always use the application layer which enforces the chain | +| Replacing an entire contiguous tail of rows with consistent fake hashes | Not detectable by the chain alone — mitigate by storing a periodic checkpoint hash in a separate, independently-controlled system (e.g. an append-only external log or an admin-held signed checkpoint) | + +### Implementation notes + +- The hash must be computed **inside the same database transaction** as the INSERT so that a concurrent insert cannot slip between the `prev_hash` read and the write. +- Use `SELECT entry_hash FROM action_log ORDER BY created_at DESC LIMIT 1 FOR UPDATE` (or equivalent row-locking) to serialise concurrent inserts and guarantee a stable chain tail. +- The delimiter and field ordering must be frozen in a version-controlled constant. Any future schema change to `action_log` requires a new chain segment starting with a documented genesis hash for that segment. +- The `entry_hash` column should be exposed in the verification API but never in any user-facing or general application response. + +## 8. Score Tie-Breaking + +The Redis sorted set `scoreboard:scores` stores only a single numeric score per member. When two users hold the same score, Redis has no built-in concept of ordering between them — the relative position is undefined and may shift arbitrarily between reads. A deterministic tie-breaking policy must be applied in the application layer every time the leaderboard is constructed or diffed. + +### Tie-breaking rules, in priority order + +1. **Higher score wins.** Primary sort, descending. +2. **If scores are equal — earlier score attainment wins.** The user who reached that score value first is ranked higher, rewarding faster progression. +3. **If score attainment time is also equal — older account wins.** The user with the earlier `users.created_at` is ranked higher, favouring long-standing members of the platform. + +### Implementation + +Redis cannot enforce this ordering natively, so the top-N slice must be post-sorted in application code after `ZREVRANGE scoreboard:scores 0 N WITHSCORES` is fetched. For any group of users sharing the same score, their relative order is resolved by the rules above. + +**Data needed to sort:** + +| Field | Source | +|------------------------|-----------------------------------------------------------| +| `score` | Redis sorted set member score | +| `score_attained_at` | Derived from `action_log` — see below | +| `users.created_at` | Looked up from the `users` table (or a warm cache) | + +**Deriving `score_attained_at`:** This is the `created_at` of the earliest `action_log` row at which the user's cumulative `points_gained` first reached or exceeded their current score. It is not a field that can be trivially maintained in Redis because it depends on the full log history. Two practical approaches: + +- **Precomputed column on `scores` table:** Add a `score_attained_at TIMESTAMP` column to the `scores` table, updated by the flush worker whenever the score increases. The flush worker, which already holds the new score value, can query `action_log` to find the first row at which the cumulative sum reached that value and write the timestamp alongside the score. This is the recommended approach — the computation happens offline in the flush cycle, not in the hot request path. + +- **On-demand query:** At ranking time, for each tied user run `SELECT MIN(created_at) FROM ... WHERE cumulative_sum >= target_score` using a window function over `action_log`. Acceptable for low-concurrency admin views or batch jobs, but too expensive for the real-time leaderboard hot path. + +**`users.created_at` caching:** Account creation timestamps are immutable. They can be cached in Redis with no TTL and lazily populated on first leaderboard appearance (`GET users:created_at:`, fallback to DB read + cache set). + +### Snapshot diff impact + +The tie-breaking sort must be applied **before** the diff comparison against `scoreboard:top10:snapshot`. Two snapshots where the only difference is the resolution of a tie among users already in the top 10 (due to a score change that didn't alter their raw score) should be considered changed and trigger a broadcast, since the visible rank order has shifted. + +### Composite sort key (alternative Redis approach) + +If the team prefers to keep all ranking logic inside Redis and avoid post-sorting, a composite floating-point score can encode both the real score and a fixed tiebreaker offset: + +``` +composite_score = points_gained * SCORE_SCALE + - (score_attained_at_unix_ms / ATTAINED_SCALE) + - (account_created_at_unix_ms / ACCOUNT_SCALE) +``` + +Choose scale constants such that the fractional portion can never overflow into the integer portion (i.e. the tiebreaker offsets are always smaller than the smallest possible `points_gained` increment). This makes `ZREVRANGE` return the correct order natively. The tradeoff is that composite scores are opaque to anyone inspecting Redis directly, and updating `score_attained_at` after the flush requires a `ZADD XX` to rewrite the composite score — adding a Redis write to every flush cycle. + +The application-layer post-sort approach is recommended unless Redis-native ordering is a firm requirement. + +## 9. Observability +Instrument the following metrics from day one: + +- `action.execute.latency_ms` — histogram of end-to-end action execution time +- `action.execute.errors` — counter of failed executions, tagged by `error_code` +- `action.log.write_latency_ms` — histogram of the audit log INSERT time (tracks write-ahead DB performance in isolation) +- `scoreboard.active_ws_connections` — gauge of live WebSocket connections +- `scoreboard.flush.latency_ms` — histogram of flush worker cycle duration +- `scoreboard.flush.errors` — counter of flush failures, tagged by failure type +- `scoreboard.redis.zincrby_latency_ms` — histogram of sorted set write time + +Trace IDs should be propagated through all layers. The `action_log.id` should be emitted as a span attribute so any alert or anomaly can be traced back to the exact audit entry. diff --git a/src/problem6/README.md b/src/problem6/README.md new file mode 100644 index 0000000000..5be207133f --- /dev/null +++ b/src/problem6/README.md @@ -0,0 +1,436 @@ +# Scoreboard Module — API Service Specification + +**Module:** `scoreboard` + +--- + +## Table of Contents + +1. [Overview](#overview) +2. [Architecture Summary](#architecture-summary) +3. [Data Model](#data-model) +4. [API Endpoints](#api-endpoints) +5. [Authentication & Security](#authentication--security) +6. [Real-Time Updates (WebSocket)](#real-time-updates) +7. [Execution Flow](#execution-flow) +8. [Write-Behind Caching Strategy](#write-behind-caching-strategy) +9. [Error Handling](#error-handling) +10. [Rate Limiting](#rate-limiting) +11. [Improvement Notes](./IMPROVEMENT.md) — separate file + +--- + +## Overview + +The **Scoreboard Module** is responsible for: + +- Receiving user action executions via a single secure endpoint and internally resolving any score changes — the client never references scores or point values directly. +- Maintaining a live ranked leaderboard in Redis as the primary read source, with periodic write-behind flushing to the persistent database. +- Broadcasting real-time leaderboard updates to all connected clients over WebSocket. +- Writing an immutable audit log to the database immediately (write-ahead) on every action execution, independently of the deferred score flush. +- Defending against unauthorised, fabricated, or anomalously frequent action submissions. + +--- + +## Architecture Summary + +``` +Client (Browser) + │ + ├── HTTP POST /api/action/execute ← authenticated action execution + ├── GET /api/scores/top ← initial leaderboard fetch (Redis) + └── WS /api/scores/live ← real-time leaderboard stream + │ + ┌────▼──────────────────────────────────┐ + │ API Application Server │ + │ │ + │ ┌──────────────────────────────┐ │ + │ │ Auth Middleware (JWT) │ │ + │ └─────────────┬────────────────┘ │ + │ │ │ + │ ┌─────────────▼────────────────┐ │ + │ │ Rate Limit Middleware │ │ + │ └─────────────┬────────────────┘ │ + │ │ │ + │ ┌─────────────▼────────────────┐ │ + │ │ ActionController │ │ + │ │ ActionService │ │ + │ └──────┬──────────────┬────────┘ │ + │ │ │ │ + │ ┌──────▼──────┐ ┌────▼───────────┐ │ + │ │ ActionLog │ │ ScoreService │ │ + │ │ Repository │ │ (write-behind) │ │ + │ │(write-ahead)│ └────────────────┘ │ + │ └─────────────┘ │ + └────────────────────────────────────────┘ + │ + ┌────────────┴────────────┐ + │ │ + ┌────▼────┐ ┌──────▼────────────────────────┐ + │ DB │ │ Redis │ + │(Postgres│ │ ├─ Sorted set (scores) │ + │ / etc) │ │ ├─ Top-10 snapshot cache │ + │ │ │ ├─ Write-behind buffer │ + │ │ │ ├─ Pub/Sub channel │ + └────┬────┘ │ └─ Rate limit counters │ + │ └────────────────────────────────┘ + │ │ + ┌────▼──────────────┐ ┌────────▼────────┐ + │ action_log table │ │ Flush Worker │ + │ (append-only) │ │ (every 5 min) │ + └───────────────────┘ └────────┬────────┘ + │ + ┌────────▼────────┐ + │ scores table │ + │ (DB, durable) │ + └─────────────────┘ +``` + +--- + +## Data Model + +### `users` table (pre-existing — reference only) + +| Column | Type | Notes | +|--------------|-------------|-------------| +| `id` | UUID (PK) | | +| `username` | VARCHAR(64) | | +| `created_at` | TIMESTAMP | | + +--- + +### `actions` table + +Stores every legitimate action type. The server resolves all score awards from this table — the client never sends point values. + +| Column | Type | Notes | +|-----------------|--------------|-----------------------------------------------------------------------| +| `id` | UUID (PK) | Stable identifier referenced in `action_log.action_id` | +| `action_name` | VARCHAR(64) | Unique, URL-safe slug (e.g. `quiz_complete`, `challenge_win`) | +| `description` | TEXT | Human-readable description for admin tooling | +| `points_awarded`| INT | Points credited to the user on successful execution; must be > 0 | +| `is_active` | BOOLEAN | Soft-disable an action without deleting it; inactive actions are rejected with 422 | +| `created_at` | TIMESTAMP | | +| `updated_at` | TIMESTAMP | | + +> **Index:** Unique index on `action_name` for fast lookup during request processing. + +> **Note to team:** `points_awarded` is the canonical field name for the score value granted by an action. Avoid aliases like `delta`, `score_value`, or `reward` elsewhere in the codebase — use `points_awarded` consistently throughout. + +--- + +### `scores` table + +The durable, persistent record of each user's cumulative score. **This table is not written to on every action.** It is updated by the flush worker on a write-behind schedule (see [Write-Behind Caching Strategy](#write-behind-caching-strategy)). + +| Column | Type | Notes | +|--------------------|-----------|--------------------------------------------------------------------| +| `id` | UUID (PK) | | +| `user_id` | UUID (FK) | References `users.id`; unique constraint (one row per user) | +| `score` | BIGINT | Cumulative score; default 0; only ever written by the flush worker | +| `score_attained_at`| TIMESTAMP | The earliest moment the user's cumulative log sum first reached the current `score` value; set by the flush worker alongside each score update; used as the second tie-breaking key on the leaderboard | +| `updated_at` | TIMESTAMP | Set by the flush worker on each write | + +> **Index:** Index on `(score DESC, score_attained_at ASC, updated_at ASC)` for tie-breaking queries and rollback verification. + +--- + +### `action_log` table (immutable audit log) + +Records every action execution at the moment it occurs — written synchronously (write-ahead) before the HTTP response is returned. This table must never be updated or deleted from; it is append-only by policy and, where the database supports it, by explicit permission grant. + +| Column | Type | Notes | +|-----------------|-----------|------------------------------------------------------------------------| +| `id` | UUID (PK) | Auto-generated | +| `user_id` | UUID (FK) | References `users.id` | +| `action_id` | UUID (FK) | References `actions.id` | +| `points_gained` | INT | Snapshot of `actions.points_awarded` at execution time; preserves history if the action's value is later changed | +| `created_at` | TIMESTAMP | Set at insert time; never updated | + +> **Immutability enforcement:** The application DB user must be granted `INSERT` only on this table — no `UPDATE` or `DELETE`. A database trigger or row-level security policy should reject any modification attempt, making the log tamper-evident. + +> **Rollback use:** A user's correct score can be recomputed at any point in time by summing `points_gained` from `action_log` for that `user_id`. This is the basis for the admin rollback endpoint described in [`IMPROVEMENT.md`](./IMPROVEMENT.md) §3. + +--- + +## API Endpoints + +### 1. `POST /api/action/execute` + +Executes a named action on behalf of the authenticated user. The server internally resolves `points_awarded` for that action and applies it to the user's score in Redis. The client supplies only the action identifier — it has no input into how many points are awarded. + +**Authentication:** Required — Bearer JWT +**Rate Limit:** 60 requests / minute per user + +#### Request Headers + +``` +Authorization: Bearer +Content-Type: application/json +``` + +#### Request Body + +```json +{ + "action_name": "quiz_complete" +} +``` + +`action_name` must match a row in the `actions` table where `is_active = true`. Max 64 characters. + +#### Success Response — `200 OK` + +```json +{ + "user_id": "uuid", + "action_name": "quiz_complete", + "points_awarded": 10, + "new_score": 1420, + "rank": 3 +} +``` + +> `new_score` and `rank` are read directly from Redis immediately after the increment, reflecting the live state. + +#### Error Responses + +| Status | Code | Meaning | +|--------|-----------------------|------------------------------------------------------------| +| 401 | `UNAUTHORIZED` | Missing or invalid JWT | +| 422 | `INVALID_ACTION` | `action_name` missing, unknown, or `is_active = false` | +| 429 | `RATE_LIMIT_EXCEEDED` | Too many requests | +| 500 | `INTERNAL_ERROR` | Unexpected server fault | + +--- + +### 2. `GET /api/scores/top` + +Returns the current top-10 leaderboard snapshot, read directly from the Redis sorted set. No database query is made on this path. + +**Authentication:** Optional + +#### Query Parameters + +| Parameter | Type | Default | Description | +|-----------|------|---------|---------------------------------------| +| `limit` | INT | 10 | Number of entries to return; max 100 | +| `offset` | INT | 0 | Enables full leaderboard pagination | + +#### Success Response — `200 OK` + +```json +{ + "leaderboard": [ + { "rank": 1, "user_id": "uuid", "username": "alice", "score": 9800 }, + { "rank": 2, "user_id": "uuid", "username": "bob", "score": 9400 } + ], + "generated_at": "2026-05-17T10:00:00Z" +} +``` + +--- + +### 3. `WS /api/scores/live` — WebSocket + +Establishes a persistent WebSocket connection. The server pushes a new leaderboard payload to all connected clients whenever the top-10 changes. + +**Authentication:** Optional — pass JWT as query parameter `?token=` if rank context is needed +**Protocol:** `wss://` in all non-local environments + +#### Server → Client: leaderboard update + +```json +{ + "type": "leaderboard_update", + "leaderboard": [ + { "rank": 1, "user_id": "uuid", "username": "alice", "score": 9800 } + ], + "generated_at": "2026-05-17T10:00:00Z" +} +``` + +#### Server → Client: heartbeat ping (every 30 seconds) + +```json +{ "type": "ping", "ts": "2026-05-17T10:00:30Z" } +``` + +#### Client → Server: pong + +```json +{ "type": "pong" } +``` + +Connections that do not respond to two consecutive pings are closed by the server and removed from the active connection registry. + +#### Connection Lifecycle + +- On connect: the server immediately sends the current leaderboard snapshot so the client can render without waiting for the next action. +- On disconnect: the connection is removed from the registry silently. +- Clients are responsible for reconnection with exponential backoff (suggested: initial delay 1s, max 30s, ±20% jitter). + +--- + +## Authentication & Security + +### JWT Validation + +Every `POST /api/action/execute` request must carry a valid signed JWT: + +1. Verify signature using the application's public key (RS256 or HS256). +2. Validate `exp` (expiry) and `iat` (issued-at) claims. +3. Extract `sub` (subject = `user_id`) from the token payload. +4. The `user_id` from the token is the authoritative identity — the request body must never accept a `user_id` field. This prevents submission on behalf of another user. + +### No Client-Controlled Score Values + +The endpoint accepts only `action_name`. All score logic — point values, active status, eligibility — is resolved exclusively server-side by looking up the `actions` table. A tampered or fabricated request cannot influence how many points are awarded. + +### HTTPS / WSS Only + +All HTTP endpoints must be served over TLS. The WebSocket endpoint must use `wss://`. Plain `ws://` is acceptable in local development only and must be disabled in staging and production. + +### CORS + +Restrict `Access-Control-Allow-Origin` to the application's known frontend domains. Wildcards (`*`) are not permitted on authenticated endpoints. + +--- + +## Real-Time Updates + +### WebSocket Broadcast Flow + +See [DIAGRAM.md → Action Execute Flow](./DIAGRAM.md#1-action-execute-flow) for the full request-path diagram, including the Redis Pub/Sub side-channel that drives this broadcast. The semantics below define the diff rule that decides whether a broadcast is published. + +#### Diff Logic + +Two top-10 snapshots are considered **equal** if and only if, for every position 0–9, both the `user_id` and the `score` are identical. Any of the following constitutes a change and must trigger a broadcast: + +- A new `user_id` enters the top 10 (displacing another). +- An existing top-10 member's `score` increases. +- The relative ordering of any two members changes (a score overtake). + +The diff is a shallow ordered comparison of the `[(user_id, score)]` pairs returned by `ZREVRANGE`. It requires no deep equality check — mismatched pair at any index means changed. The comparison should be performed in the application layer immediately after the `ZINCRBY`, before deciding whether to publish. + +### Redis Sorted Set + +The sorted set `scoreboard:scores` is the primary data source for all leaderboard operations. Each **member** is the `user_id` string; the **score** is the cumulative point total. Storing `user_id` as the member is what makes every entry independently identifiable — without it, scores are anonymous values that cannot be attributed to a user, diffed across snapshots, or used to determine rank. + +- **Increment:** `ZINCRBY scoreboard:scores ` +- **Top-N read:** `ZREVRANGE scoreboard:scores 0 9 WITHSCORES` → returns `[(user_id, score), ...]` +- **User rank:** `ZREVRANK scoreboard:scores ` (0-indexed; add 1 for display) +- **User score:** `ZSCORE scoreboard:scores ` + +This gives O(log N) writes and O(log N + K) reads for top-K with no database round-trip on the hot path. + +### Top-10 Snapshot Cache + +After each `ZINCRBY`, the service recomputes the top-10 list and stores it as a serialised snapshot in a separate Redis key: + +``` +SET scoreboard:top10:snapshot +``` + +This snapshot is what `GET /api/scores/top` serves directly, and it is what the broadcaster diffs against before deciding whether to push a WebSocket message (see broadcast flow above). The key has no TTL — it is invalidated and rewritten on every action execution that changes the sorted set. + +--- + +## Write-Behind Caching Strategy + +Redis is the live source of truth for scores. The `scores` table in the database is a durable replica updated asynchronously by a background flush worker every 5 minutes. + +### Design + +See [DIAGRAM.md → Async Score Flush](./DIAGRAM.md#2-async-score-flush) for the flush-worker diagram. In short: every action immediately writes to both `scoreboard:scores` (sorted set) and `scoreboard:pending:` (write-behind buffer); a background worker drains the pending buffer into the `scores` table every 5 minutes and deletes each pending key only after its DB write succeeds. + +### Guarantees and Trade-offs + +| Property | Behaviour | +|------------------------|---------------------------------------------------------------------------------| +| Leaderboard accuracy | Always current — all reads come from the Redis sorted set | +| DB consistency | Eventually consistent; lags up to 5 minutes behind Redis | +| Data durability | `action_log` is written synchronously; any score lost on Redis failure is fully recoverable by replaying the audit log | +| Flush atomicity | Each user's pending key is cleared only after a confirmed DB write; partial flush is safe | +| Audit log integrity | Written write-ahead, independently of the flush cycle — always present | + +### Redis Failure Recovery + +If Redis becomes unavailable: + +1. Incoming action requests should fail fast with 503. +2. On Redis recovery, reseed `scoreboard:scores` from the `scores` table as a baseline, then replay `action_log` entries where `created_at > scores.updated_at` for each user. + +This recovery procedure must be documented as a runbook and tested in staging before launch. + +### Flush Worker Configuration + +| Setting | Default | Notes | +|----------------------|-----------|-----------------------------------------------------| +| `FLUSH_INTERVAL` | 5 minutes | How often the worker runs | +| `FLUSH_BATCH_SIZE` | 500 users | Max pending entries processed per cycle | +| `FLUSH_RETRY_MAX` | 3 | Retries per entry before alerting and moving on | +| `FLUSH_RETRY_DELAY` | 10s (exp.)| Exponential backoff between retries | + +--- + +## Execution Flow + +The numbered steps correspond to the accompanying flow diagram. + +1. User completes an action in the browser. +2. Client sends `POST /api/action/execute` with `{ action_name }` and `Authorization` header. +3. **Auth Middleware** validates the JWT; rejects with 401 on failure. +4. **Rate Limit Middleware** checks the per-user counter in Redis; rejects with 429 if exceeded. +5. **ActionController** extracts `user_id` from the token and `action_name` from the body. +6. **ActionService** looks up `action_name` in the `actions` table (or warm in-memory cache). Returns 422 if not found or `is_active = false`. +7. **ActionLogRepository** inserts a row into `action_log` synchronously. If this fails, the request returns 500 and no score update is applied — the audit log and score increment are never decoupled. +8. **ScoreService** calls `ZINCRBY scoreboard:scores ` in Redis (member = `user_id`, score = cumulative total) and writes the new total to the pending buffer key `scoreboard:pending:`. +9. **ScoreService** reads the new top-10 via `ZREVRANGE scoreboard:scores 0 9 WITHSCORES` and compares the ordered `[(user_id, score)]` pairs against the stored snapshot at `scoreboard:top10:snapshot`. +10. If the top-10 **has changed**: overwrite `scoreboard:top10:snapshot` with the new payload and publish a `leaderboard:update` event to the Redis Pub/Sub channel. If it **has not changed**: skip — no publish, no broadcast. +11. **WebSocket Broadcaster** (subscribed to the channel) fans the event out to all active WebSocket connections. +12. Each connected browser receives `leaderboard_update` and re-renders the scoreboard. +13. The HTTP response `200 OK` is returned with `{ user_id, action_name, points_awarded, new_score, rank }`. +14. *(Async, every 5 minutes)* **Flush Worker** drains `scoreboard:pending:*` into the `scores` table. + +--- + +## Error Handling + +All errors return a consistent JSON envelope: + +```json +{ + "error": { + "code": "RATE_LIMIT_EXCEEDED", + "message": "Too many action executions. Please wait before trying again.", + "retry_after": 42 + } +} +``` + +Additional conventions: + +- If the `action_log` INSERT (step 7) fails, return 500 immediately and do not proceed to the Redis update. A score increment must never exist without a corresponding audit entry. +- Transient DB failures on the audit write should be retried once with a 200ms delay before returning 500. +- Pub/Sub publish failures must not block the HTTP response. Log the failure; the leaderboard self-corrects when the channel recovers or on the next action execution. +- WebSocket clients that miss a push will receive a fresh snapshot on their next reconnect — no leaderboard state is permanently lost. +- Flush worker failures increment `scoreboard.flush.errors` and trigger an alert after `FLUSH_RETRY_MAX` consecutive failures for the same user entry. + +--- + +## Rate Limiting + +| Scope | Limit | Window | Storage | +|---------------------------|--------------|------------|---------------| +| Per authenticated user | 60 requests | 60 seconds | Redis counter | +| Per IP (unauthenticated) | 20 requests | 60 seconds | Redis counter | + +Rate limit counters use the Redis `INCR` + `EXPIRE` pattern, or a sliding window via sorted sets for higher accuracy. All 429 responses include a `Retry-After` header. + +--- + +## Improvement Notes + +Recommendations beyond the baseline — idempotency keys, velocity anomaly detection, score rollback, Redis failure runbook, action management admin API, WebSocket graceful degradation, action log hash chain, score tie-breaking, and observability — live in [`IMPROVEMENT.md`](./IMPROVEMENT.md). \ No newline at end of file