diff --git a/src/problem4/.gitignore b/src/problem4/.gitignore new file mode 100644 index 0000000000..1ce1fe20d8 --- /dev/null +++ b/src/problem4/.gitignore @@ -0,0 +1,4 @@ +node_modules/ +dist/ +logs/*.jsonl +!logs/.gitkeep diff --git a/src/problem4/AUDIT-LOG-SAMPLE.jsonl b/src/problem4/AUDIT-LOG-SAMPLE.jsonl new file mode 100644 index 0000000000..be14640c2c --- /dev/null +++ b/src/problem4/AUDIT-LOG-SAMPLE.jsonl @@ -0,0 +1,3 @@ +{"sequence":1,"timestamp":"2026-08-03T14:45:23.605Z","traceId":"2315985c-14c5-4efc-9ed5-8bd9913fb17a","action":"SUM_TO_N","status":"SUCCESS","input":100,"result":5050,"durationMs":0,"inputHash":"ad57366865126e55649ecb23ae1d48887544976efea46a48eb5d85a6eeb4d306","outputHash":"3f95b1b8a32c2c0251dfdbc3c8a30aab6d6e680cf0ef03e8af84a65dff0c4a85","error":null,"previousHash":null,"recordHash":"2391ef1857dbea8762c44c4a9601f3e3c6e4c2e895419af0594bf8e192f65454"} +{"sequence":2,"timestamp":"2026-08-03T14:45:23.606Z","traceId":"58437f1b-deed-449b-94cf-57a4bc524145","action":"SUM_TO_N","status":"SUCCESS","input":100,"result":5050,"durationMs":0,"inputHash":"ad57366865126e55649ecb23ae1d48887544976efea46a48eb5d85a6eeb4d306","outputHash":"3f95b1b8a32c2c0251dfdbc3c8a30aab6d6e680cf0ef03e8af84a65dff0c4a85","error":null,"previousHash":"2391ef1857dbea8762c44c4a9601f3e3c6e4c2e895419af0594bf8e192f65454","recordHash":"e02ac73c838b10c50a89643236797a0a45d2027e7bdd5d10b7556b0ba0e98f47"} +{"sequence":3,"timestamp":"2026-08-03T14:45:23.606Z","traceId":"b829134f-6999-48d6-8e3c-6081ccb4cf35","action":"SUM_TO_N","status":"SUCCESS","input":100,"result":5050,"durationMs":0,"inputHash":"ad57366865126e55649ecb23ae1d48887544976efea46a48eb5d85a6eeb4d306","outputHash":"3f95b1b8a32c2c0251dfdbc3c8a30aab6d6e680cf0ef03e8af84a65dff0c4a85","error":null,"previousHash":"e02ac73c838b10c50a89643236797a0a45d2027e7bdd5d10b7556b0ba0e98f47","recordHash":"729ebcf3f9e52a1f0b6025d856ea42f6ecc45d048117c1fb39f8d2128ab6b025"} diff --git a/src/problem4/DEEP-STRESS-REPORT.md b/src/problem4/DEEP-STRESS-REPORT.md new file mode 100644 index 0000000000..af1e2fe37a --- /dev/null +++ b/src/problem4/DEEP-STRESS-REPORT.md @@ -0,0 +1,75 @@ +# Problem 4 — Deep stress-test report + +## Result + +```text +Status: STRESS_TEST_PASS +Assertions: 1,197,832 +Maximum safe-result input: 134,217,727 +Elapsed: 1,307 ms +Suites: 13 +``` + +## Coverage executed + +- Fixed positive, negative, zero, and known-result cases. +- Exhaustive verification of all three functions from `-12,000` through `12,000`. +- 8,000 deterministic random inputs through all three functions. +- 200,000 deterministic random inputs across the complete safe-result domain for solutions A and C. +- 8,192 values immediately below the safe-result boundary, checked with both signs. +- Exact maximum safe input executed through solution B despite its linear complexity. +- First overflowing result rejected by all three functions. +- Values surrounding powers of two. +- Recurrence and sign-symmetry metamorphic properties. +- Invalid values: `NaN`, infinities, decimals, and unsafe integers. +- 315 sequential success/failure audit records. +- 600 concurrent records written by eight Node.js processes. +- Unique `traceId` verification for concurrent records. +- Audit chain verification after concurrent writes. +- Seven tamper/corruption scenarios. +- Confirmation that audit records contain no `algorithm` field. + +## Suite timings + +| Suite | Assertions | Elapsed | +|---|---:|---:| +| Fixed examples and signed edges | 36 | 1 ms | +| Exhaustive all-functions range | 96,004 | 96 ms | +| Random moderate range for all functions | 32,000 | 22 ms | +| Full safe-domain random test for A and C | 600,000 | 165 ms | +| Safe-result boundary sweep | 49,154 | 129 ms | +| Result-overflow rejection | 3 | 111 ms | +| Power-of-two neighborhoods | 672 | 2 ms | +| Metamorphic identities for all functions | 18,000 | 23 ms | +| Full-domain metamorphic identities for A and C | 400,000 | 144 ms | +| Invalid runtime inputs | 18 | 1 ms | +| Sequential success and failure audit chain | 733 | 112 ms | +| Concurrent multi-process audit chain | 1,203 | 497 ms | +| Audit tamper and corruption detection | 9 | 4 ms | + +## Tamper scenarios detected + +1. Modified result without recomputing the record hash. +2. Broken `previousHash`. +3. Deleted middle record. +4. Reordered records. +5. Duplicated record. +6. Malformed JSON. +7. Truncated final record. + +## Commands + +```bat +npm test +npm run stress +``` + +`npm run stress` executes the deep suite. `npm run stress:quick` keeps the +smaller suite for fast local checks. + +## Boundary of this test + +No finite test can prove correctness for every possible runtime environment. +This suite combines exhaustive ranges, independent `BigInt` oracles, +full-domain deterministic sampling, mathematical invariants, exact boundary +execution, concurrent audit writes, and deliberate log corruption. diff --git a/src/problem4/README.md b/src/problem4/README.md new file mode 100644 index 0000000000..7dd92e3b0d --- /dev/null +++ b/src/problem4/README.md @@ -0,0 +1,134 @@ +# Problem 4 — Three functions with audit log + +All three challenge functions remain pure and have explanatory comments +directly above them. + +Each function has a corresponding audited service wrapper: + +```text +executeSumAWithAudit +executeSumBWithAudit +executeSumCWithAudit +``` + +The audit log does not contain an `algorithm` field. + +## Run one function + +```bat +cd /d D:\99\code-challenge\src\problem4 +npm install + +npm start -- a 100 +npm start -- b 100 +npm start -- c 100 +``` + +Each command prints the result directly: + +```text +5050 +``` + +Each function call appends one record to: + +```text +logs/problem4-audit.jsonl +``` + +After running A, B, and C, the log contains three records. + +## Generate three audit records at once + +```bat +npm run audit:sample -- 100 +``` + +## Audit fields + +```text +sequence +timestamp +traceId +action +status +input +result +durationMs +inputHash +outputHash +error +previousHash +recordHash +``` + +There is no `algorithm` field. + +## Verify audit integrity + +```bat +npm run audit:verify +``` + +Expected after the sample command: + +```text +AUDIT_OK records=3 +``` + +## Test + +```bat +npm test +``` + +Expected result: + +```text +PASS +``` + + +## Stress test + +Run correctness, boundary, invalid-input, and audit-chain stress checks: + +```bat +npm run stress +``` + +Expected output: + +```text +STRESS_TEST_PASS +assertions=... +auditRecords=300 +safeBoundary=134217727 +elapsedMs=... +``` + +The stress test does not benchmark or compare algorithms. + + +## Deep stress test + +```bat +npm run stress +``` + +This runs exhaustive, random full-domain, exact-boundary, overflow, +metamorphic, invalid-input, sequential audit, concurrent multi-process audit, +and tamper-detection checks. + +The verified output from the packaged run is documented in: + +```text +DEEP-STRESS-REPORT.md +deep-stress-result.json +``` + +For a faster local check: + +```bat +npm run stress:quick +``` diff --git a/src/problem4/STRESS-RESULTS.md b/src/problem4/STRESS-RESULTS.md new file mode 100644 index 0000000000..0d60020cd2 --- /dev/null +++ b/src/problem4/STRESS-RESULTS.md @@ -0,0 +1,73 @@ +# Problem 4 — Stress-test results + +## Verification status + +```text +Status: PASSED +Assertions: 1,869,698 +Maximum safe-result input: 134,217,727 +Node.js: v22.16.0 +Version 5.8.3 +``` + +## Test coverage + +The automated stress suite verifies: + +1. fixed examples, zero, and signed edge cases; +2. independent iterative accumulation for every input from `-100,000` to `100,000`; +3. 250,000 deterministic random inputs checked against a `BigInt` oracle; +4. 4,096 values at both positive and negative safe-result boundaries; +5. powers of two and neighboring values; +6. recurrence and sign metamorphic identities; +7. invalid runtime inputs; +8. warmed, multi-round performance benchmarks with rotating execution order. + +## Benchmark + +The benchmark uses varying valid inputs, 40,000 warm-up calls per +implementation, 9 measured rounds, and 120,000 calls per round. + +| Rank | Implementation | Median | Operations/second | Relative | +|---:|---|---:|---:|---:| +| 1 | `sum_to_n_a` — Gauss formula | 1.574 ms | 76,220,723 | 1.00× | +| 2 | `sum_to_n_c` — Binary block decomposition | 24.352 ms | 4,927,689 | 15.47× | +| 3 | `sum_to_n_b` — Matrix exponentiation | 192.863 ms | 622,202 | 122.50× | + +## Best algorithm + +### `sum_to_n_a` — Gauss formula + +It is the best implementation for this exact problem because it has: + +```text +Time: O(1) +Space: O(1) +``` + +It also has the smallest implementation surface and the lowest measured median +runtime in this stress test. + +### Why not B? + +`sum_to_n_b` is algorithmically interesting and demonstrates matrix +exponentiation, but it performs many fixed-size matrix multiplications. Its +`O(log |n|)` complexity cannot beat a direct `O(1)` identity for this problem. + +### Why not C? + +`sum_to_n_c` is more lightweight than matrix exponentiation and demonstrates +binary decomposition, but it still inspects `O(log |n|)` blocks. It is useful +as an alternative algorithm, not as the production choice. + +## Recommendation + +Keep all three functions because the task explicitly requests three unique +implementations, but treat `sum_to_n_a` as the preferred implementation. + +```ts +export const sum_to_n = sum_to_n_a; +``` + +Benchmark timing is environment-dependent. The correctness result and +asymptotic recommendation do not depend on the benchmark machine. diff --git a/src/problem4/audit-sample.ts b/src/problem4/audit-sample.ts new file mode 100644 index 0000000000..aae95e7c9b --- /dev/null +++ b/src/problem4/audit-sample.ts @@ -0,0 +1,18 @@ +import { + executeSumAWithAudit, + executeSumBWithAudit, + executeSumCWithAudit, +} from "./service"; + +const input = process.argv[2] ?? "100"; + +const resultA = + executeSumAWithAudit(input).result; +const resultB = + executeSumBWithAudit(input).result; +const resultC = + executeSumCWithAudit(input).result; + +console.log(resultA); +console.log(resultB); +console.log(resultC); diff --git a/src/problem4/audit.ts b/src/problem4/audit.ts new file mode 100644 index 0000000000..a367fe2ca0 --- /dev/null +++ b/src/problem4/audit.ts @@ -0,0 +1,257 @@ +const { + createHash, + randomUUID, +} = require("node:crypto"); + +const { + appendFileSync, + existsSync, + mkdirSync, + readFileSync, + rmSync, + statSync, +} = require("node:fs"); + +const { + dirname, + resolve, +} = require("node:path"); + +export const DEFAULT_AUDIT_FILE: string = resolve( + process.cwd(), + "logs", + "problem4-audit.jsonl", +); + +export interface AuditRecord { + sequence: number; + timestamp: string; + traceId: string; + action: "SUM_TO_N"; + status: "SUCCESS" | "FAILED"; + input: number | string; + result: number | null; + durationMs: number; + inputHash: string; + outputHash: string | null; + error: string | null; + previousHash: string | null; + recordHash: string; +} + +type UnsignedAuditRecord = Omit< + AuditRecord, + "recordHash" +>; + +function sha256(value: string): string { + return createHash("sha256") + .update(value, "utf8") + .digest("hex"); +} + +function canonical( + record: UnsignedAuditRecord, +): string { + return JSON.stringify({ + sequence: record.sequence, + timestamp: record.timestamp, + traceId: record.traceId, + action: record.action, + status: record.status, + input: record.input, + result: record.result, + durationMs: record.durationMs, + inputHash: record.inputHash, + outputHash: record.outputHash, + error: record.error, + previousHash: record.previousHash, + }); +} + +function readRecords( + file: string, +): AuditRecord[] { + if (!existsSync(file)) { + return []; + } + + const content = String( + readFileSync(file, "utf8"), + ).trim(); + + if (!content) { + return []; + } + + return content + .split(/\r?\n/) + .filter(Boolean) + .map( + ( + line: string, + index: number, + ) => { + try { + return JSON.parse( + line, + ) as AuditRecord; + } catch { + throw new Error( + `Invalid audit JSON at line ${index + 1}`, + ); + } + }, + ); +} + +function sleep(milliseconds: number): void { + const buffer = new SharedArrayBuffer(4); + const view = new Int32Array(buffer); + + Atomics.wait( + view, + 0, + 0, + milliseconds, + ); +} + +function acquireAuditLock( + file: string, +): () => void { + mkdirSync( + dirname(file), + { recursive: true }, + ); + + const lockDirectory = + `${file}.lock`; + + const timeoutAt = + Date.now() + 15_000; + + while (true) { + try { + mkdirSync(lockDirectory); + + return () => { + rmSync( + lockDirectory, + { + recursive: true, + force: true, + }, + ); + }; + } catch (error) { + const code = + ( + error as { + code?: string; + } + ).code; + + if (code !== "EEXIST") { + throw error; + } + + try { + const ageMilliseconds = + Date.now() - + statSync( + lockDirectory, + ).mtimeMs; + + if ( + ageMilliseconds > + 30_000 + ) { + rmSync( + lockDirectory, + { + recursive: true, + force: true, + }, + ); + + continue; + } + } catch { + // The lock disappeared between the checks; retry immediately. + continue; + } + + if ( + Date.now() >= timeoutAt + ) { + throw new Error( + `Timed out waiting for audit lock: ${lockDirectory}`, + ); + } + + sleep(2); + } + } +} + +export function appendAudit( + file: string, + data: Omit< + UnsignedAuditRecord, + "sequence" | "previousHash" + >, +): AuditRecord { + const releaseLock = + acquireAuditLock(file); + + try { + const existing = + readRecords(file); + + const previous = + existing.length > 0 + ? existing[ + existing.length - 1 + ] + : undefined; + + const unsigned: + UnsignedAuditRecord = { + sequence: + existing.length + 1, + ...data, + previousHash: + previous?.recordHash ?? + null, + }; + + const record: + AuditRecord = { + ...unsigned, + recordHash: sha256( + canonical(unsigned), + ), + }; + + appendFileSync( + file, + `${JSON.stringify(record)}\n`, + "utf8", + ); + + return record; + } finally { + releaseLock(); + } +} + +export function createTraceId(): string { + return randomUUID(); +} + +export function hashValue( + value: unknown, +): string { + return sha256(String(value)); +} diff --git a/src/problem4/benchmark.ts b/src/problem4/benchmark.ts new file mode 100644 index 0000000000..c042311e9b --- /dev/null +++ b/src/problem4/benchmark.ts @@ -0,0 +1,39 @@ +import { + sum_to_n_a, + sum_to_n_b, + sum_to_n_c, +} from "./index"; + +function benchmark( + name: string, + implementation: (n: number) => number, + input: number, + iterations: number, +): void { + let checksum = 0; + const startedAt = performance.now(); + + for (let iteration = 0; iteration < iterations; iteration += 1) { + checksum += implementation(input); + } + + const elapsed = performance.now() - startedAt; + + console.log( + [ + name.padEnd(14), + `total=${elapsed.toFixed(3)}ms`, + `avg=${(elapsed / iterations).toFixed(6)}ms`, + `checksum=${checksum}`, + ].join(" "), + ); +} + +const input = 100_000_000; +const iterations = 20_000; + +console.log(`Input: ${input}`); +console.log(`Iterations: ${iterations}`); +benchmark("Gauss", sum_to_n_a, input, iterations); +benchmark("Matrix", sum_to_n_b, input, iterations); +benchmark("Binary blocks", sum_to_n_c, input, iterations); diff --git a/src/problem4/concurrency-worker.ts b/src/problem4/concurrency-worker.ts new file mode 100644 index 0000000000..694c0633e8 --- /dev/null +++ b/src/problem4/concurrency-worker.ts @@ -0,0 +1,61 @@ +import { + executeSumAWithAudit, + executeSumBWithAudit, + executeSumCWithAudit, +} from "./service"; + +const choice = + process.argv[2]?.toLowerCase(); + +const start = + Number(process.argv[3]); + +const count = + Number(process.argv[4]); + +const auditFile = + process.argv[5]; + +if ( + !["a", "b", "c"].includes(choice) || + !Number.isSafeInteger(start) || + !Number.isSafeInteger(count) || + count < 1 || + !auditFile +) { + throw new Error( + "Invalid concurrency-worker arguments", + ); +} + +for ( + let offset = 0; + offset < count; + offset += 1 +) { + const input = + start + offset; + + switch (choice) { + case "a": + executeSumAWithAudit( + input, + auditFile, + ); + break; + + case "b": + executeSumBWithAudit( + input, + auditFile, + ); + break; + + case "c": + executeSumCWithAudit( + input, + auditFile, + ); + break; + } +} diff --git a/src/problem4/deep-stress-result.json b/src/problem4/deep-stress-result.json new file mode 100644 index 0000000000..c9204a6325 --- /dev/null +++ b/src/problem4/deep-stress-result.json @@ -0,0 +1,85 @@ +{ + "status": "STRESS_TEST_PASS", + "assertions": 1197832, + "maximumSafeN": 134217727, + "elapsedMs": 1307, + "suites": [ + { + "name": "Fixed examples and signed edges", + "assertions": 36, + "elapsedMs": 1 + }, + { + "name": "Exhaustive all-functions range", + "assertions": 96004, + "elapsedMs": 96 + }, + { + "name": "Random moderate range for all functions", + "assertions": 32000, + "elapsedMs": 22 + }, + { + "name": "Full safe-domain random test for A and C", + "assertions": 600000, + "elapsedMs": 165 + }, + { + "name": "Safe-result boundary sweep", + "assertions": 49154, + "elapsedMs": 129 + }, + { + "name": "Result-overflow rejection", + "assertions": 3, + "elapsedMs": 111 + }, + { + "name": "Power-of-two neighborhoods", + "assertions": 672, + "elapsedMs": 2 + }, + { + "name": "Metamorphic identities for all functions", + "assertions": 18000, + "elapsedMs": 23 + }, + { + "name": "Full-domain metamorphic identities for A and C", + "assertions": 400000, + "elapsedMs": 144 + }, + { + "name": "Invalid runtime inputs", + "assertions": 18, + "elapsedMs": 1 + }, + { + "name": "Sequential success and failure audit chain", + "assertions": 733, + "elapsedMs": 112 + }, + { + "name": "Concurrent multi-process audit chain", + "assertions": 1203, + "elapsedMs": 497 + }, + { + "name": "Audit tamper and corruption detection", + "assertions": 9, + "elapsedMs": 4 + } + ], + "coverage": { + "exhaustiveAllFunctions": "-12,000..12,000", + "randomAllFunctions": 8000, + "randomFullSafeDomainAandC": 200000, + "boundaryWindowEachSide": 8192, + "exactMaximumForB": true, + "overflowRejectionAllFunctions": true, + "sequentialAuditRecords": 315, + "concurrentAuditRecords": 600, + "tamperScenarios": 7, + "algorithmFieldInAudit": false + } +} diff --git a/src/problem4/deep-stress.ts b/src/problem4/deep-stress.ts new file mode 100644 index 0000000000..6a35f88ff2 --- /dev/null +++ b/src/problem4/deep-stress.ts @@ -0,0 +1,1283 @@ +import { + sum_to_n_a, + sum_to_n_b, + sum_to_n_c, +} from "./index"; + +import { + executeSumAWithAudit, + executeSumBWithAudit, + executeSumCWithAudit, +} from "./service"; + +import { + verifyAuditLog, +} from "./verify-audit"; + +const { + copyFileSync, + mkdtempSync, + readFileSync, + rmSync, + writeFileSync, +} = require("node:fs"); + +const { + tmpdir, +} = require("node:os"); + +const { + join, +} = require("node:path"); + +const { + spawn, +} = require("node:child_process"); + +type SumFunction = ( + n: number, +) => number; + +interface SuiteResult { + name: string; + assertions: number; + elapsedMs: number; +} + +const implementations: + ReadonlyArray< + readonly [ + string, + SumFunction, + ] + > = [ + [ + "sum_to_n_a", + sum_to_n_a, + ], + [ + "sum_to_n_b", + sum_to_n_b, + ], + [ + "sum_to_n_c", + sum_to_n_c, + ], + ]; + +const fastImplementations: + ReadonlyArray< + readonly [ + string, + SumFunction, + ] + > = [ + [ + "sum_to_n_a", + sum_to_n_a, + ], + [ + "sum_to_n_c", + sum_to_n_c, + ], + ]; + +const maximumSafeN = + Math.floor( + ( + Math.sqrt( + 1 + + 8 * + Number.MAX_SAFE_INTEGER, + ) - + 1 + ) / + 2, + ); + +let assertions = 0; +const suites: SuiteResult[] = []; + +function assert( + condition: boolean, + message: string, +): void { + assertions += 1; + + if (!condition) { + throw new Error(message); + } +} + +function assertEqual( + actual: unknown, + expected: unknown, + label: string, +): void { + assert( + actual === expected, + `${label}: actual=${String(actual)}, expected=${String(expected)}`, + ); +} + +function bigintOracle( + n: number, +): number { + const magnitude = + BigInt(Math.abs(n)); + + const positive = + ( + magnitude * + (magnitude + 1n) + ) / 2n; + + const signed = + n < 0 + ? -positive + : positive; + + const result = + Number(signed); + + assert( + Number.isSafeInteger(result), + `Oracle produced unsafe result for n=${n}`, + ); + + return result; +} + +function runSuite( + name: string, + operation: () => void, +): void { + const before = assertions; + const startedAt = Date.now(); + + operation(); + + suites.push({ + name, + assertions: + assertions - before, + elapsedMs: + Date.now() - startedAt, + }); +} + +async function runAsyncSuite( + name: string, + operation: () => Promise, +): Promise { + const before = assertions; + const startedAt = Date.now(); + + await operation(); + + suites.push({ + name, + assertions: + assertions - before, + elapsedMs: + Date.now() - startedAt, + }); +} + +function verifyAll( + input: number, +): void { + const expected = + bigintOracle(input); + + for ( + const [ + name, + implementation, + ] + of implementations + ) { + assertEqual( + implementation(input), + expected, + `${name}(${input})`, + ); + } +} + +function verifyFast( + input: number, +): void { + const expected = + bigintOracle(input); + + for ( + const [ + name, + implementation, + ] + of fastImplementations + ) { + assertEqual( + implementation(input), + expected, + `${name}(${input})`, + ); + } +} + +function createRandom( + seed: number, +): () => number { + let state = seed >>> 0; + + return () => { + state ^= state << 13; + state ^= state >>> 17; + state ^= state << 5; + + return state >>> 0; + }; +} + +function expectFailure( + operation: () => unknown, + label: string, +): void { + let failed = false; + + try { + operation(); + } catch { + failed = true; + } + + assert( + failed, + `${label} should fail`, + ); +} + +function runWorker( + choice: "a" | "b" | "c", + start: number, + count: number, + auditFile: string, +): Promise { + return new Promise( + ( + resolvePromise, + rejectPromise, + ) => { + const workerFile = + join( + __dirname, + "concurrency-worker.js", + ); + + const child = spawn( + process.execPath, + [ + workerFile, + choice, + String(start), + String(count), + auditFile, + ], + { + stdio: [ + "ignore", + "ignore", + "pipe", + ], + }, + ); + + let errorOutput = ""; + + child.stderr.setEncoding( + "utf8", + ); + + child.stderr.on( + "data", + ( + chunk: string, + ) => { + errorOutput += chunk; + }, + ); + + child.on( + "error", + ( + error: Error, + ) => { + rejectPromise(error); + }, + ); + + child.on( + "exit", + ( + code: number | null, + ) => { + if (code === 0) { + resolvePromise(); + return; + } + + rejectPromise( + new Error( + `Worker ${choice} failed with code ${String(code)}: ${errorOutput}`, + ), + ); + }, + ); + }, + ); +} + +function readAuditLines( + file: string, +): string[] { + return String( + readFileSync( + file, + "utf8", + ), + ) + .split(/\r?\n/) + .filter(Boolean); +} + +async function main(): Promise { + const totalStartedAt = + Date.now(); + + runSuite( + "Fixed examples and signed edges", + () => { + const cases: + ReadonlyArray< + readonly [ + number, + number, + ] + > = [ + [0, 0], + [1, 1], + [2, 3], + [5, 15], + [10, 55], + [13, 91], + [100, 5050], + [-1, -1], + [-2, -3], + [-4, -10], + [-13, -91], + [-100, -5050], + ]; + + for ( + const [ + input, + expected, + ] + of cases + ) { + for ( + const [ + name, + implementation, + ] + of implementations + ) { + assertEqual( + implementation(input), + expected, + `${name} fixed input=${input}`, + ); + } + } + }, + ); + + runSuite( + "Exhaustive all-functions range", + () => { + for ( + let input = -12_000; + input <= 12_000; + input += 1 + ) { + verifyAll(input); + } + }, + ); + + runSuite( + "Random moderate range for all functions", + () => { + const nextRandom = + createRandom( + 0x9e3779b9, + ); + + for ( + let sample = 0; + sample < 8_000; + sample += 1 + ) { + const magnitude = + nextRandom() % 5_001; + + const input = + (nextRandom() & 1) === 0 + ? magnitude + : -magnitude; + + verifyAll(input); + } + }, + ); + + runSuite( + "Full safe-domain random test for A and C", + () => { + const nextRandom = + createRandom( + 0x243f6a88, + ); + + for ( + let sample = 0; + sample < 200_000; + sample += 1 + ) { + const magnitude = + nextRandom() % + ( + maximumSafeN + 1 + ); + + const input = + (nextRandom() & 1) === 0 + ? magnitude + : -magnitude; + + verifyFast(input); + } + }, + ); + + runSuite( + "Safe-result boundary sweep", + () => { + const windowSize = 8_192; + + for ( + let offset = 0; + offset < windowSize; + offset += 1 + ) { + const input = + maximumSafeN - offset; + + verifyFast(input); + verifyFast(-input); + } + + // Solution B is intentionally linear, but the exact maximum valid + // input is still executed once to prove boundary correctness. + const expected = + bigintOracle( + maximumSafeN, + ); + + assertEqual( + sum_to_n_b( + maximumSafeN, + ), + expected, + "sum_to_n_b maximum safe boundary", + ); + }, + ); + + runSuite( + "Result-overflow rejection", + () => { + const overflowInput = + maximumSafeN + 1; + + for ( + const [ + name, + implementation, + ] + of implementations + ) { + expectFailure( + () => + implementation( + overflowInput, + ), + `${name} overflow input`, + ); + } + }, + ); + + runSuite( + "Power-of-two neighborhoods", + () => { + for ( + let power = 1; + power <= 65_536; + power *= 2 + ) { + for ( + const delta + of [ + -2, + -1, + 0, + 1, + 2, + ] + ) { + const input = + power + delta; + + if (input < 0) { + continue; + } + + verifyAll(input); + verifyAll(-input); + } + } + }, + ); + + runSuite( + "Metamorphic identities for all functions", + () => { + const nextRandom = + createRandom( + 0xb7e15162, + ); + + for ( + let sample = 0; + sample < 3_000; + sample += 1 + ) { + const n = + 1 + + ( + nextRandom() % + 5_000 + ); + + for ( + const [ + name, + implementation, + ] + of implementations + ) { + const current = + implementation(n); + + const previous = + implementation( + n - 1, + ); + + const negative = + implementation(-n); + + assertEqual( + current - previous, + n, + `${name} recurrence n=${n}`, + ); + + assertEqual( + negative, + -current, + `${name} sign symmetry n=${n}`, + ); + } + } + }, + ); + + runSuite( + "Full-domain metamorphic identities for A and C", + () => { + const nextRandom = + createRandom( + 0xdeadbeef, + ); + + for ( + let sample = 0; + sample < 100_000; + sample += 1 + ) { + const n = + 1 + + ( + nextRandom() % + maximumSafeN + ); + + for ( + const [ + name, + implementation, + ] + of fastImplementations + ) { + const current = + implementation(n); + + assertEqual( + current - + implementation( + n - 1, + ), + n, + `${name} full recurrence n=${n}`, + ); + + assertEqual( + implementation(-n), + -current, + `${name} full sign n=${n}`, + ); + } + } + }, + ); + + runSuite( + "Invalid runtime inputs", + () => { + const invalidInputs = [ + Number.NaN, + Number.POSITIVE_INFINITY, + Number.NEGATIVE_INFINITY, + 1.5, + -2.25, + Number.MAX_SAFE_INTEGER + 1, + ]; + + for ( + const invalid + of invalidInputs + ) { + for ( + const [ + name, + implementation, + ] + of implementations + ) { + expectFailure( + () => + implementation( + invalid, + ), + `${name} invalid=${String(invalid)}`, + ); + } + } + }, + ); + + runSuite( + "Sequential success and failure audit chain", + () => { + const directory: string = + mkdtempSync( + join( + tmpdir(), + "problem4-sequential-audit-", + ), + ); + + const file: string = + join( + directory, + "audit.jsonl", + ); + + try { + for ( + let input = 1; + input <= 100; + input += 1 + ) { + const expected = + bigintOracle(input); + + assertEqual( + executeSumAWithAudit( + input, + file, + ).result, + expected, + `audit A input=${input}`, + ); + + assertEqual( + executeSumBWithAudit( + input, + file, + ).result, + expected, + `audit B input=${input}`, + ); + + assertEqual( + executeSumCWithAudit( + input, + file, + ).result, + expected, + `audit C input=${input}`, + ); + } + + const invalidValues = [ + "invalid", + "", + "1.5", + "Infinity", + "NaN", + ]; + + for ( + const invalid + of invalidValues + ) { + expectFailure( + () => + executeSumAWithAudit( + invalid, + file, + ), + `audit A invalid=${invalid}`, + ); + + expectFailure( + () => + executeSumBWithAudit( + invalid, + file, + ), + `audit B invalid=${invalid}`, + ); + + expectFailure( + () => + executeSumCWithAudit( + invalid, + file, + ), + `audit C invalid=${invalid}`, + ); + } + + const expectedRecords = + 300 + + invalidValues.length * 3; + + assertEqual( + verifyAuditLog(file), + expectedRecords, + "sequential audit chain count", + ); + + const records = + readAuditLines(file).map( + ( + line: string, + ) => + JSON.parse(line), + ); + + assertEqual( + records.length, + expectedRecords, + "sequential audit line count", + ); + + assertEqual( + records.filter( + ( + record: { + status: string; + }, + ) => + record.status === + "FAILED", + ).length, + invalidValues.length * 3, + "failed audit count", + ); + + for ( + const record + of records + ) { + assertEqual( + "algorithm" in record, + false, + "algorithm must not appear in audit", + ); + } + } finally { + rmSync( + directory, + { + recursive: true, + force: true, + }, + ); + } + }, + ); + + await runAsyncSuite( + "Concurrent multi-process audit chain", + async () => { + const directory: string = + mkdtempSync( + join( + tmpdir(), + "problem4-concurrent-audit-", + ), + ); + + const file: string = + join( + directory, + "audit.jsonl", + ); + + try { + const workers: + Array> = []; + + const choices: + ReadonlyArray< + "a" | "b" | "c" + > = [ + "a", + "b", + "c", + "a", + "b", + "c", + "a", + "c", + ]; + + choices.forEach( + ( + choice, + workerIndex, + ) => { + workers.push( + runWorker( + choice, + 1 + + workerIndex * + 100, + 75, + file, + ), + ); + }, + ); + + await Promise.all( + workers, + ); + + const expectedRecords = + choices.length * 75; + + assertEqual( + verifyAuditLog(file), + expectedRecords, + "concurrent audit chain count", + ); + + const records = + readAuditLines(file).map( + ( + line: string, + ) => + JSON.parse(line), + ); + + assertEqual( + records.length, + expectedRecords, + "concurrent audit line count", + ); + + const traceIds = + new Set( + records.map( + ( + record: { + traceId: string; + }, + ) => + record.traceId, + ), + ); + + assertEqual( + traceIds.size, + expectedRecords, + "concurrent traceId uniqueness", + ); + + for ( + const record + of records + ) { + assertEqual( + "algorithm" in record, + false, + "concurrent audit algorithm field", + ); + + assertEqual( + record.status, + "SUCCESS", + "concurrent audit status", + ); + } + } finally { + rmSync( + directory, + { + recursive: true, + force: true, + }, + ); + } + }, + ); + + runSuite( + "Audit tamper and corruption detection", + () => { + const directory: string = + mkdtempSync( + join( + tmpdir(), + "problem4-tamper-audit-", + ), + ); + + const original: string = + join( + directory, + "original.jsonl", + ); + + try { + for ( + let input = 1; + input <= 12; + input += 1 + ) { + executeSumAWithAudit( + input, + original, + ); + } + + assertEqual( + verifyAuditLog(original), + 12, + "original audit validity", + ); + + const originalLines = + readAuditLines(original); + + const cases: + Array< + readonly [ + string, + ( + lines: string[], + ) => string[], + ] + > = [ + [ + "modified-result", + ( + lines, + ) => { + const records = + [...lines]; + + const first = + JSON.parse( + records[0] ?? + "{}", + ); + + first.result += 1; + records[0] = + JSON.stringify(first); + + return records; + }, + ], + [ + "broken-previous-hash", + ( + lines, + ) => { + const records = + [...lines]; + + const second = + JSON.parse( + records[1] ?? + "{}", + ); + + second.previousHash = + "0".repeat(64); + + records[1] = + JSON.stringify(second); + + return records; + }, + ], + [ + "deleted-middle-line", + ( + lines, + ) => + lines.filter( + ( + _line, + index, + ) => + index !== 5, + ), + ], + [ + "reordered-lines", + ( + lines, + ) => { + const records = + [...lines]; + + const first = + records[0]; + + records[0] = + records[1] ?? + ""; + + records[1] = + first ?? + ""; + + return records; + }, + ], + [ + "duplicated-line", + ( + lines, + ) => [ + ...lines, + lines[ + lines.length - 1 + ] ?? "", + ], + ], + [ + "malformed-json", + ( + lines, + ) => [ + ...lines, + "{\"broken\":", + ], + ], + [ + "truncated-record", + ( + lines, + ) => { + const records = + [...lines]; + + const lastIndex = + records.length - 1; + + records[lastIndex] = + ( + records[ + lastIndex + ] ?? "" + ).slice( + 0, + -7, + ); + + return records; + }, + ], + ]; + + for ( + const [ + name, + mutate, + ] + of cases + ) { + const target: string = + join( + directory, + `${name}.jsonl`, + ); + + const changed = + mutate( + originalLines, + ); + + writeFileSync( + target, + `${changed.join("\n")}\n`, + "utf8", + ); + + expectFailure( + () => + verifyAuditLog( + target, + ), + `tamper case ${name}`, + ); + } + + const copied: string = + join( + directory, + "valid-copy.jsonl", + ); + + copyFileSync( + original, + copied, + ); + + assertEqual( + verifyAuditLog(copied), + 12, + "valid copied audit", + ); + } finally { + rmSync( + directory, + { + recursive: true, + force: true, + }, + ); + } + }, + ); + + const elapsedMs = + Date.now() - + totalStartedAt; + + const report = { + status: + "STRESS_TEST_PASS", + assertions, + maximumSafeN, + elapsedMs, + suites, + coverage: { + exhaustiveAllFunctions: + "-12,000..12,000", + randomAllFunctions: + 8_000, + randomFullSafeDomainAandC: + 200_000, + boundaryWindowEachSide: + 8_192, + exactMaximumForB: + true, + overflowRejectionAllFunctions: + true, + sequentialAuditRecords: + 315, + concurrentAuditRecords: + 600, + tamperScenarios: + 7, + algorithmFieldInAudit: + false, + }, + }; + + console.log( + "STRESS_TEST_PASS", + ); + console.log( + `assertions=${assertions}`, + ); + console.log( + `maximumSafeN=${maximumSafeN}`, + ); + console.log( + `elapsedMs=${elapsedMs}`, + ); + console.log( + `suites=${suites.length}`, + ); + console.log( + `DEEP_STRESS_JSON=${JSON.stringify(report)}`, + ); +} + +main().catch( + ( + error: unknown, + ) => { + console.error( + error instanceof Error + ? error.stack ?? + error.message + : String(error), + ); + + process.exitCode = 1; + }, +); diff --git a/src/problem4/index.test.ts b/src/problem4/index.test.ts new file mode 100644 index 0000000000..e5f3846c74 --- /dev/null +++ b/src/problem4/index.test.ts @@ -0,0 +1,183 @@ +import { + sum_to_n_a, + sum_to_n_b, + sum_to_n_c, +} from "./index"; + +import { + executeSumAWithAudit, + executeSumBWithAudit, + executeSumCWithAudit, +} from "./service"; + +import { + verifyAuditLog, +} from "./verify-audit"; + +const { + mkdtempSync, + readFileSync, + rmSync, +} = require("node:fs"); + +const { + tmpdir, +} = require("node:os"); + +const { + join, +} = require("node:path"); + +function assertEqual( + actual: unknown, + expected: unknown, + label: string, +): void { + if (actual !== expected) { + throw new Error( + `${label}: actual=${String(actual)}, expected=${String(expected)}`, + ); + } +} + +function oracle( + n: number, +): number { + const magnitude = + BigInt(Math.abs(n)); + + const positive = + ( + magnitude * + (magnitude + 1n) + ) / 2n; + + return Number( + n < 0 + ? -positive + : positive, + ); +} + +const implementations = [ + sum_to_n_a, + sum_to_n_b, + sum_to_n_c, +] as const; + +for ( + let n = -20_000; + n <= 20_000; + n += 1 +) { + const expected = oracle(n); + + for ( + const implementation + of implementations + ) { + assertEqual( + implementation(n), + expected, + `n=${n}`, + ); + } +} + +const directory: string = + mkdtempSync( + join( + tmpdir(), + "problem4-audit-", + ), + ); + +const file: string = + join( + directory, + "audit.jsonl", + ); + +try { + assertEqual( + executeSumAWithAudit( + 100, + file, + ).result, + 5050, + "function A", + ); + + assertEqual( + executeSumBWithAudit( + 100, + file, + ).result, + 5050, + "function B", + ); + + assertEqual( + executeSumCWithAudit( + 100, + file, + ).result, + 5050, + "function C", + ); + + assertEqual( + verifyAuditLog(file), + 3, + "audit record count", + ); + + const records = String( + readFileSync(file, "utf8"), + ) + .trim() + .split(/\r?\n/) + .map( + (line: string) => + JSON.parse(line), + ); + + assertEqual( + records.length, + 3, + "three audit lines", + ); + + for ( + const record + of records + ) { + assertEqual( + "algorithm" in record, + false, + "algorithm field must not exist", + ); + + assertEqual( + record.action, + "SUM_TO_N", + "audit action", + ); + + assertEqual( + record.status, + "SUCCESS", + "audit status", + ); + } +} finally { + rmSync( + directory, + { + recursive: true, + force: true, + }, + ); +} + +console.log("PASS"); diff --git a/src/problem4/index.ts b/src/problem4/index.ts new file mode 100644 index 0000000000..74ed63001a --- /dev/null +++ b/src/problem4/index.ts @@ -0,0 +1,101 @@ +/** + * Problem 4: Three ways to sum to n. + * + * Negative-input convention: + * sum_to_n(-4) = -1 + -2 + -3 + -4 = -10 + */ + +function assertSafeInteger(n: number): void { + if (!Number.isSafeInteger(n)) { + throw new TypeError("n must be a safe integer"); + } +} + +function applySign(n: number, positiveResult: number): number { + if (!Number.isSafeInteger(positiveResult)) { + throw new RangeError( + "The result must be smaller than Number.MAX_SAFE_INTEGER", + ); + } + + return n < 0 ? -positiveResult : positiveResult; +} + +/** + * Solution A: arithmetic-series formula. + * + * Uses n(n + 1) / 2 and divides the even factor before multiplication + * to avoid an unnecessarily large intermediate value. + * + * Time complexity: O(1) + * Space complexity: O(1) + */ +export function sum_to_n_a(n: number): number { + assertSafeInteger(n); + + const magnitude = Math.abs(n); + const positiveResult = + magnitude % 2 === 0 + ? (magnitude / 2) * (magnitude + 1) + : magnitude * ((magnitude + 1) / 2); + + return applySign(n, positiveResult); +} + +/** + * Solution B: iterative accumulation. + * + * Adds every integer from 1 through |n|. + * + * Time complexity: O(|n|) + * Space complexity: O(1) + */ +export function sum_to_n_b(n: number): number { + assertSafeInteger(n); + + const magnitude = Math.abs(n); + let positiveResult = 0; + + for (let value = 1; value <= magnitude; value += 1) { + positiveResult += value; + } + + return applySign(n, positiveResult); +} + +/** + * Solution C: divide-and-conquer recurrence. + * + * Uses: + * S(2k) = 2S(k) + k² + * S(2k + 1) = 2S(k) + k² + 2k + 1 + * + * Time complexity: O(log |n|) + * Space complexity: O(log |n|) + */ +function binarySum(magnitude: number): number { + if (magnitude === 0) { + return 0; + } + + if (magnitude === 1) { + return 1; + } + + const half = Math.floor(magnitude / 2); + const halfResult = binarySum(half); + const evenResult = 2 * halfResult + half * half; + + return magnitude % 2 === 0 + ? evenResult + : evenResult + 2 * half + 1; +} + +export function sum_to_n_c(n: number): number { + assertSafeInteger(n); + + return applySign( + n, + binarySum(Math.abs(n)), + ); +} diff --git a/src/problem4/logs/.gitkeep b/src/problem4/logs/.gitkeep new file mode 100644 index 0000000000..e69de29bb2 diff --git a/src/problem4/node-shims.d.ts b/src/problem4/node-shims.d.ts new file mode 100644 index 0000000000..030bdba27d --- /dev/null +++ b/src/problem4/node-shims.d.ts @@ -0,0 +1,15 @@ +interface NodeRequire { + (name: string): any; + main: any; +} + +declare const require: NodeRequire; +declare const module: any; +declare const __dirname: string; + +declare const process: { + argv: string[]; + cwd(): string; + execPath: string; + exitCode?: number; +}; diff --git a/src/problem4/package-lock.json b/src/problem4/package-lock.json new file mode 100644 index 0000000000..b4e56660b8 --- /dev/null +++ b/src/problem4/package-lock.json @@ -0,0 +1,29 @@ +{ + "name": "problem4-advanced-algorithms", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "problem4-advanced-algorithms", + "version": "1.0.0", + "devDependencies": { + "typescript": "^5.8.3" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + } + } +} diff --git a/src/problem4/package.json b/src/problem4/package.json new file mode 100644 index 0000000000..3c783c8f93 --- /dev/null +++ b/src/problem4/package.json @@ -0,0 +1,17 @@ +{ + "name": "problem4-three-functions-audit", + "version": "1.0.0", + "private": true, + "scripts": { + "build": "tsc", + "test": "npm run build && node dist/index.test.js", + "start": "npm run build && node dist/run.js", + "audit:sample": "npm run build && node dist/audit-sample.js", + "audit:verify": "npm run build && node dist/verify-audit.js", + "stress": "npm run build && node dist/deep-stress.js", + "stress:quick": "npm run build && node dist/stress-test.js" + }, + "devDependencies": { + "typescript": "^5.8.3" + } +} diff --git a/src/problem4/run.ts b/src/problem4/run.ts new file mode 100644 index 0000000000..7f5497fd47 --- /dev/null +++ b/src/problem4/run.ts @@ -0,0 +1,51 @@ +import { + executeSumAWithAudit, + executeSumBWithAudit, + executeSumCWithAudit, +} from "./service"; + +const choice = + process.argv[2]?.toLowerCase(); +const input = process.argv[3]; + +try { + let result: number; + + switch (choice) { + case "a": + result = + executeSumAWithAudit( + input, + ).result; + break; + + case "b": + result = + executeSumBWithAudit( + input, + ).result; + break; + + case "c": + result = + executeSumCWithAudit( + input, + ).result; + break; + + default: + throw new Error( + "Usage: npm start -- a|b|c ", + ); + } + + console.log(result); +} catch (error) { + console.error( + error instanceof Error + ? error.message + : String(error), + ); + + process.exitCode = 1; +} diff --git a/src/problem4/service.ts b/src/problem4/service.ts new file mode 100644 index 0000000000..c053d84c38 --- /dev/null +++ b/src/problem4/service.ts @@ -0,0 +1,147 @@ +import { + sum_to_n_a, + sum_to_n_b, + sum_to_n_c, +} from "./index"; + +import { + appendAudit, + createTraceId, + DEFAULT_AUDIT_FILE, + hashValue, +} from "./audit"; + +type SumFunction = ( + n: number, +) => number; + +export interface AuditedResult { + result: number; + traceId: string; +} + +function parseInput( + input: unknown, +): number { + const parsed = + typeof input === "number" + ? input + : typeof input === "string" && + input.trim() !== "" + ? Number(input) + : Number.NaN; + + if (!Number.isSafeInteger(parsed)) { + throw new TypeError( + "n must be a safe integer", + ); + } + + return parsed; +} + +function executeWithAudit( + input: unknown, + implementation: SumFunction, + file: string, +): AuditedResult { + const traceId = createTraceId(); + const startedAt = Date.now(); + + const rawInput: number | string = + typeof input === "number" || + typeof input === "string" + ? input + : String(input); + + try { + const n = parseInput(input); + const result = implementation(n); + + appendAudit(file, { + timestamp: + new Date().toISOString(), + traceId, + action: "SUM_TO_N", + status: "SUCCESS", + input: n, + result, + durationMs: + Date.now() - startedAt, + inputHash: hashValue(n), + outputHash: + hashValue(result), + error: null, + }); + + return { + result, + traceId, + }; + } catch (error) { + const message = + error instanceof Error + ? error.message + : String(error); + + appendAudit(file, { + timestamp: + new Date().toISOString(), + traceId, + action: "SUM_TO_N", + status: "FAILED", + input: rawInput, + result: null, + durationMs: + Date.now() - startedAt, + inputHash: + hashValue(rawInput), + outputHash: null, + error: message, + }); + + throw error; + } +} + +/** + * Executes solution A and writes one audit record. + */ +export function executeSumAWithAudit( + input: unknown, + file: string = DEFAULT_AUDIT_FILE, +): AuditedResult { + return executeWithAudit( + input, + sum_to_n_a, + file, + ); +} + +/** + * Executes solution B and writes one audit record. + */ +export function executeSumBWithAudit( + input: unknown, + file: string = DEFAULT_AUDIT_FILE, +): AuditedResult { + return executeWithAudit( + input, + sum_to_n_b, + file, + ); +} + +/** + * Executes solution C and writes one audit record. + */ +export function executeSumCWithAudit( + input: unknown, + file: string = DEFAULT_AUDIT_FILE, +): AuditedResult { + return executeWithAudit( + input, + sum_to_n_c, + file, + ); +} diff --git a/src/problem4/stress-test.ts b/src/problem4/stress-test.ts new file mode 100644 index 0000000000..39cffb2714 --- /dev/null +++ b/src/problem4/stress-test.ts @@ -0,0 +1,358 @@ +import { + sum_to_n_a, + sum_to_n_b, + sum_to_n_c, +} from "./index"; + +import { + executeSumAWithAudit, + executeSumBWithAudit, + executeSumCWithAudit, +} from "./service"; + +import { + verifyAuditLog, +} from "./verify-audit"; + +const { + mkdtempSync, + readFileSync, + rmSync, +} = require("node:fs"); + +const { + tmpdir, +} = require("node:os"); + +const { + join, +} = require("node:path"); + +type SumFunction = ( + n: number, +) => number; + +const implementations: + ReadonlyArray = [ + sum_to_n_a, + sum_to_n_b, + sum_to_n_c, + ]; + +let assertions = 0; + +function assertEqual( + actual: unknown, + expected: unknown, + label: string, +): void { + assertions += 1; + + if (actual !== expected) { + throw new Error( + `${label}: actual=${String(actual)}, expected=${String(expected)}`, + ); + } +} + +function bigintOracle( + n: number, +): number { + const magnitude = + BigInt(Math.abs(n)); + + const positive = + ( + magnitude * + (magnitude + 1n) + ) / 2n; + + return Number( + n < 0 + ? -positive + : positive, + ); +} + +function verifyAll( + input: number, +): void { + const expected = + bigintOracle(input); + + for ( + const implementation + of implementations + ) { + assertEqual( + implementation(input), + expected, + `input=${input}`, + ); + } +} + +function createRandom( + seed: number, +): () => number { + let state = seed >>> 0; + + return () => { + state ^= state << 13; + state ^= state >>> 17; + state ^= state << 5; + + return state >>> 0; + }; +} + +const startedAt = Date.now(); + +/** + * Exhaustive signed range for all three implementations. + */ +for ( + let input = -5_000; + input <= 5_000; + input += 1 +) { + verifyAll(input); +} + +/** + * Deterministic random verification for all three implementations. + */ +const nextRandom = + createRandom(0x9e3779b9); + +for ( + let sample = 0; + sample < 10_000; + sample += 1 +) { + const magnitude = + nextRandom() % 10_001; + + const input = + (nextRandom() & 1) === 0 + ? magnitude + : -magnitude; + + verifyAll(input); +} + +/** + * Large but practical inputs for all three implementations. + */ +for ( + const input + of [ + -1_000_000, + -100_000, + 100_000, + 1_000_000, + ] +) { + verifyAll(input); +} + +/** + * Safe-result boundary checks for the non-linear implementations. + * + * Solution B intentionally walks every integer, so using it at the + * Number.MAX_SAFE_INTEGER boundary would make the stress test impractical. + */ +const maximumSafeN = + Math.floor( + ( + Math.sqrt( + 1 + + 8 * + Number.MAX_SAFE_INTEGER, + ) - + 1 + ) / + 2, + ); + +for ( + const input + of [ + maximumSafeN - 1, + maximumSafeN, + -(maximumSafeN - 1), + -maximumSafeN, + ] +) { + const expected = + bigintOracle(input); + + assertEqual( + sum_to_n_a(input), + expected, + `boundary A input=${input}`, + ); + + assertEqual( + sum_to_n_c(input), + expected, + `boundary C input=${input}`, + ); +} + +/** + * Invalid-input checks for all three implementations. + */ +for ( + const invalid + of [ + Number.NaN, + Number.POSITIVE_INFINITY, + Number.NEGATIVE_INFINITY, + 1.5, + -2.25, + Number.MAX_SAFE_INTEGER + 1, + ] +) { + for ( + const implementation + of implementations + ) { + let failed = false; + + try { + implementation(invalid); + } catch { + failed = true; + } + + assertEqual( + failed, + true, + `invalid=${String(invalid)}`, + ); + } +} + +/** + * Audit stress: + * 100 calls per function create 300 chained records. + */ +const auditDirectory: string = + mkdtempSync( + join( + tmpdir(), + "problem4-stress-audit-", + ), + ); + +const auditFile: string = + join( + auditDirectory, + "audit.jsonl", + ); + +try { + for ( + let input = 1; + input <= 100; + input += 1 + ) { + const expected = + bigintOracle(input); + + assertEqual( + executeSumAWithAudit( + input, + auditFile, + ).result, + expected, + `audit A input=${input}`, + ); + + assertEqual( + executeSumBWithAudit( + input, + auditFile, + ).result, + expected, + `audit B input=${input}`, + ); + + assertEqual( + executeSumCWithAudit( + input, + auditFile, + ).result, + expected, + `audit C input=${input}`, + ); + } + + assertEqual( + verifyAuditLog(auditFile), + 300, + "audit chain count", + ); + + const records = String( + readFileSync( + auditFile, + "utf8", + ), + ) + .trim() + .split(/\r?\n/) + .map( + (line: string) => + JSON.parse(line), + ); + + assertEqual( + records.length, + 300, + "audit line count", + ); + + for ( + const record + of records + ) { + assertEqual( + "algorithm" in record, + false, + "algorithm field must not exist", + ); + + assertEqual( + record.status, + "SUCCESS", + "audit status", + ); + } +} finally { + rmSync( + auditDirectory, + { + recursive: true, + force: true, + }, + ); +} + +const elapsedMs = + Date.now() - startedAt; + +console.log("STRESS_TEST_PASS"); +console.log( + `assertions=${assertions}`, +); +console.log( + `auditRecords=300`, +); +console.log( + `safeBoundary=${maximumSafeN}`, +); +console.log( + `elapsedMs=${elapsedMs}`, +); diff --git a/src/problem4/tsconfig.json b/src/problem4/tsconfig.json new file mode 100644 index 0000000000..0de5f67d55 --- /dev/null +++ b/src/problem4/tsconfig.json @@ -0,0 +1,16 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "CommonJS", + "moduleResolution": "Node", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "noEmitOnError": true, + "outDir": "dist" + }, + "include": [ + "*.ts", + "*.d.ts" + ] +} diff --git a/src/problem4/verify-audit.ts b/src/problem4/verify-audit.ts new file mode 100644 index 0000000000..61bd817007 --- /dev/null +++ b/src/problem4/verify-audit.ts @@ -0,0 +1,132 @@ +import { + AuditRecord, + DEFAULT_AUDIT_FILE, +} from "./audit"; + +const { + createHash, +} = require("node:crypto"); + +const { + existsSync, + readFileSync, +} = require("node:fs"); + +const { + resolve, +} = require("node:path"); + +type UnsignedAuditRecord = Omit< + AuditRecord, + "recordHash" +>; + +function sha256(value: string): string { + return createHash("sha256") + .update(value, "utf8") + .digest("hex"); +} + +function canonical( + record: UnsignedAuditRecord, +): string { + return JSON.stringify({ + sequence: record.sequence, + timestamp: record.timestamp, + traceId: record.traceId, + action: record.action, + status: record.status, + input: record.input, + result: record.result, + durationMs: record.durationMs, + inputHash: record.inputHash, + outputHash: record.outputHash, + error: record.error, + previousHash: record.previousHash, + }); +} + +export function verifyAuditLog( + file: string = DEFAULT_AUDIT_FILE, +): number { + if (!existsSync(file)) { + throw new Error( + `Audit file not found: ${file}`, + ); + } + + const lines: string[] = String( + readFileSync(file, "utf8"), + ) + .split(/\r?\n/) + .filter(Boolean); + + let previousHash: string | null = + null; + + lines.forEach( + ( + line: string, + index: number, + ) => { + const record = JSON.parse( + line, + ) as AuditRecord; + + const expectedSequence = + index + 1; + + if ( + record.sequence !== + expectedSequence + ) { + throw new Error( + `Invalid sequence at line ${expectedSequence}`, + ); + } + + if ( + record.previousHash !== + previousHash + ) { + throw new Error( + `Broken audit chain at line ${expectedSequence}`, + ); + } + + const { + recordHash, + ...unsigned + } = record; + + const expectedHash = sha256( + canonical(unsigned), + ); + + if ( + recordHash !== + expectedHash + ) { + throw new Error( + `Audit record was modified at line ${expectedSequence}`, + ); + } + + previousHash = recordHash; + }, + ); + + return lines.length; +} + +if (require.main === module) { + const file = process.argv[2] + ? resolve(process.argv[2]) + : DEFAULT_AUDIT_FILE; + + const count = verifyAuditLog(file); + + console.log( + `AUDIT_OK records=${count}`, + ); +} diff --git a/src/problem5/.env.example b/src/problem5/.env.example new file mode 100644 index 0000000000..35379cdd69 --- /dev/null +++ b/src/problem5/.env.example @@ -0,0 +1,3 @@ +PORT=3000 +DATABASE_PATH=./data/problem5.sqlite +JSON_LIMIT=32kb diff --git a/src/problem5/.gitignore b/src/problem5/.gitignore new file mode 100644 index 0000000000..b91fd62c98 --- /dev/null +++ b/src/problem5/.gitignore @@ -0,0 +1,7 @@ +node_modules/ +dist/ +.env +data/*.sqlite +data/*.sqlite-shm +data/*.sqlite-wal +!data/.gitkeep diff --git a/src/problem5/Dockerfile b/src/problem5/Dockerfile new file mode 100644 index 0000000000..51907d220f --- /dev/null +++ b/src/problem5/Dockerfile @@ -0,0 +1,18 @@ +FROM node:22-bookworm-slim AS build +WORKDIR /app +COPY package*.json ./ +RUN npm install +COPY tsconfig.json ./ +COPY src ./src +COPY test ./test +RUN npm run build + +FROM node:22-bookworm-slim +WORKDIR /app +ENV NODE_ENV=production +COPY package*.json ./ +RUN npm install --omit=dev +COPY --from=build /app/dist ./dist +RUN mkdir -p /app/data +EXPOSE 3000 +CMD ["node", "dist/src/server.js"] diff --git a/src/problem5/README.md b/src/problem5/README.md new file mode 100644 index 0000000000..228138a212 --- /dev/null +++ b/src/problem5/README.md @@ -0,0 +1,247 @@ +# Problem 5 — A Crude Server + +A CRUD backend built with **ExpressJS**, **TypeScript**, and **SQLite**. + +The resource is a task. Audit and trouble logs are persisted in the same +SQLite database and exposed through read-only endpoints. + +## Requirements + +```text +Node.js >= 22.18.0 +npm +``` + +The application uses Node.js `node:sqlite`; no external database server is +required. + +## Install and run + +```bat +cd /d D:\99\code-challenge\src\problem5 + +copy .env.example .env +npm install +npm run db:migrate +npm run db:seed +npm test +npm run dev +``` + +Server: + +```text +http://localhost:3000 +``` + +## Task CRUD endpoints + +```text +POST /api/v1/tasks +GET /api/v1/tasks +GET /api/v1/tasks/:id +PATCH /api/v1/tasks/:id +DELETE /api/v1/tasks/:id +``` + +List filters: + +```text +search +status +priority +dueAfter +dueBefore +sortBy +order +limit +offset +``` + +## Audit log + +Every successful `CREATE`, `UPDATE`, and `DELETE` is written inside the same +database transaction as the task mutation. + +SQLite table: + +```text +task_audit_logs +``` + +List audit records: + +```http +GET /api/v1/audit-logs +``` + +Filters: + +```text +taskId +traceId +action=CREATE|UPDATE|DELETE +limit +offset +``` + +Example: + +```http +GET /api/v1/audit-logs?taskId=&limit=20 +``` + +Details: + +```http +GET /api/v1/audit-logs/:id +``` + +Audit response contains: + +```json +{ + "id": 3, + "traceId": "manual-delete-001", + "action": "DELETE", + "taskId": "task-id", + "before": { + "title": "Build Problem 5" + }, + "after": null, + "createdAt": "2026-08-03T15:00:00.000Z" +} +``` + +## Trouble log + +Validation errors, invalid JSON, resource-not-found errors, unknown routes, +and unexpected server errors are persisted automatically. + +SQLite table: + +```text +trouble_logs +``` + +List trouble records: + +```http +GET /api/v1/trouble-logs +``` + +Filters: + +```text +requestId +method +errorCode +statusCode +limit +offset +``` + +Examples: + +```http +GET /api/v1/trouble-logs?errorCode=VALIDATION_ERROR +GET /api/v1/trouble-logs?statusCode=404 +GET /api/v1/trouble-logs?requestId=manual-invalid-001 +``` + +Details: + +```http +GET /api/v1/trouble-logs/:id +``` + +Trouble response contains: + +```json +{ + "id": 1, + "requestId": "manual-invalid-001", + "method": "POST", + "path": "/api/v1/tasks", + "statusCode": 400, + "errorCode": "VALIDATION_ERROR", + "message": "title must contain between 1 and 120 characters", + "details": null, + "stack": "...", + "durationMs": 2, + "createdAt": "2026-08-03T15:00:00.000Z" +} +``` + +The stack is stored for review but is never returned in normal API error +responses. + +## Database migrations + +Tables: + +```text +schema_migrations +tasks +task_audit_logs +trouble_logs +``` + +`npm run db:migrate` applies migration 2 to an existing database created by the +previous version, so the current `data/problem5.sqlite` does not need to be +deleted. + +## Tests + +```bat +npm test +``` + +The tests verify: + +```text +CRUD persistence +3 audit records for create/update/delete +validation trouble logging +unknown-route trouble logging +audit-log API +trouble-log API +``` + +## Stress test + +```bat +npm run stress +``` + +Expected counters: + +```text +creates=250 +updates=100 +deletes=50 +auditRecords=400 +troubleRecords=50 +``` + +## Request collection + +Use: + +```text +requests.http +``` + +It includes CRUD, audit-log, trouble-log, and error-generation requests. + +## Exact source locations + +```text +src/problem5/src/database.ts +src/problem5/src/http/middleware.ts +src/problem5/src/http/observability.routes.ts +src/problem5/src/http/observability.validation.ts +src/problem5/src/repositories/observability.repository.ts +src/problem5/src/services/observability.service.ts +src/problem5/src/domain/observability.ts +``` diff --git a/src/problem5/data/.gitkeep b/src/problem5/data/.gitkeep new file mode 100644 index 0000000000..e69de29bb2 diff --git a/src/problem5/package-lock.json b/src/problem5/package-lock.json new file mode 100644 index 0000000000..a675121bbe --- /dev/null +++ b/src/problem5/package-lock.json @@ -0,0 +1,1524 @@ +{ + "name": "problem5-crude-server", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "problem5-crude-server", + "version": "1.0.0", + "dependencies": { + "dotenv": "^16.5.0", + "express": "^5.1.0" + }, + "devDependencies": { + "@types/express": "^5.0.3", + "@types/node": "^22.15.0", + "tsx": "^4.19.4", + "typescript": "^5.8.3" + }, + "engines": { + "node": ">=22.18.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz", + "integrity": "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.1.tgz", + "integrity": "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.1.tgz", + "integrity": "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.1.tgz", + "integrity": "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.1.tgz", + "integrity": "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.1.tgz", + "integrity": "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.1.tgz", + "integrity": "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.1.tgz", + "integrity": "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.1.tgz", + "integrity": "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.1.tgz", + "integrity": "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.1.tgz", + "integrity": "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.1.tgz", + "integrity": "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.1.tgz", + "integrity": "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.1.tgz", + "integrity": "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.1.tgz", + "integrity": "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.1.tgz", + "integrity": "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.1.tgz", + "integrity": "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.1.tgz", + "integrity": "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.1.tgz", + "integrity": "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.1.tgz", + "integrity": "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.1.tgz", + "integrity": "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.1.tgz", + "integrity": "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.1.tgz", + "integrity": "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.1.tgz", + "integrity": "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.1.tgz", + "integrity": "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.1.tgz", + "integrity": "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@types/body-parser": { + "version": "1.19.6", + "resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.6.tgz", + "integrity": "sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/connect": "*", + "@types/node": "*" + } + }, + "node_modules/@types/connect": { + "version": "3.4.38", + "resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.38.tgz", + "integrity": "sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/express": { + "version": "5.0.6", + "resolved": "https://registry.npmjs.org/@types/express/-/express-5.0.6.tgz", + "integrity": "sha512-sKYVuV7Sv9fbPIt/442koC7+IIwK5olP1KWeD88e/idgoJqDm3JV/YUiPwkoKK92ylff2MGxSz1CSjsXelx0YA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/body-parser": "*", + "@types/express-serve-static-core": "^5.0.0", + "@types/serve-static": "^2" + } + }, + "node_modules/@types/express-serve-static-core": { + "version": "5.1.3", + "resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-5.1.3.tgz", + "integrity": "sha512-dPfW8NFiOF4wOHc7+N/QSxlY9cfSsenewGbAz8C8U/MULPd/YZ27LvJUIlzaXie7e6Ove9YunJGgC9tbHD2cKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*", + "@types/qs": "*", + "@types/range-parser": "*", + "@types/send": "*" + } + }, + "node_modules/@types/http-errors": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@types/http-errors/-/http-errors-2.0.5.tgz", + "integrity": "sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "22.20.1", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.20.1.tgz", + "integrity": "sha512-EANqOCF9QFyra+4pfxUcX9STKJpCLjMbObVzljIJomAWSnuSIEAvyzEU53GaajbXJEgdh0iEcPL+DGvpUd4k1Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/@types/qs": { + "version": "6.15.1", + "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.15.1.tgz", + "integrity": "sha512-GZHUBZR9hckSUhrxmp1nG6NwdpM9fCunJwyThLW1X3AyHgd9IlHb6VANpQQqDr2o/qQp6McZ3y/IA2rVzKzSbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/range-parser": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/@types/range-parser/-/range-parser-1.2.7.tgz", + "integrity": "sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/send": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@types/send/-/send-1.2.1.tgz", + "integrity": "sha512-arsCikDvlU99zl1g69TcAB3mzZPpxgw0UQnaHeC1Nwb015xp8bknZv5rIfri9xTOcMuaVgvabfIRA7PSZVuZIQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/serve-static": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-2.2.0.tgz", + "integrity": "sha512-8mam4H1NHLtu7nmtalF7eyBH14QyOASmcxHhSfEoRyr0nP/YdoesEtU+uSRvMe96TW/HPTtkoKqQLl53N7UXMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/http-errors": "*", + "@types/node": "*" + } + }, + "node_modules/accepts": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", + "integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==", + "license": "MIT", + "dependencies": { + "mime-types": "^3.0.0", + "negotiator": "^1.0.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/body-parser": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.3.0.tgz", + "integrity": "sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw==", + "license": "MIT", + "dependencies": { + "bytes": "^3.1.2", + "content-type": "^2.0.0", + "debug": "^4.4.3", + "http-errors": "^2.0.1", + "iconv-lite": "^0.7.2", + "on-finished": "^2.4.1", + "qs": "^6.15.2", + "raw-body": "^3.0.2", + "type-is": "^2.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/body-parser/node_modules/content-type": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.0.0.tgz", + "integrity": "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/content-disposition": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.1.0.tgz", + "integrity": "sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/content-type": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie-signature": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz", + "integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==", + "license": "MIT", + "engines": { + "node": ">=6.6.0" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/dotenv": { + "version": "16.6.1", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz", + "integrity": "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", + "license": "MIT" + }, + "node_modules/encodeurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", + "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/esbuild": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz", + "integrity": "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.28.1", + "@esbuild/android-arm": "0.28.1", + "@esbuild/android-arm64": "0.28.1", + "@esbuild/android-x64": "0.28.1", + "@esbuild/darwin-arm64": "0.28.1", + "@esbuild/darwin-x64": "0.28.1", + "@esbuild/freebsd-arm64": "0.28.1", + "@esbuild/freebsd-x64": "0.28.1", + "@esbuild/linux-arm": "0.28.1", + "@esbuild/linux-arm64": "0.28.1", + "@esbuild/linux-ia32": "0.28.1", + "@esbuild/linux-loong64": "0.28.1", + "@esbuild/linux-mips64el": "0.28.1", + "@esbuild/linux-ppc64": "0.28.1", + "@esbuild/linux-riscv64": "0.28.1", + "@esbuild/linux-s390x": "0.28.1", + "@esbuild/linux-x64": "0.28.1", + "@esbuild/netbsd-arm64": "0.28.1", + "@esbuild/netbsd-x64": "0.28.1", + "@esbuild/openbsd-arm64": "0.28.1", + "@esbuild/openbsd-x64": "0.28.1", + "@esbuild/openharmony-arm64": "0.28.1", + "@esbuild/sunos-x64": "0.28.1", + "@esbuild/win32-arm64": "0.28.1", + "@esbuild/win32-ia32": "0.28.1", + "@esbuild/win32-x64": "0.28.1" + } + }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "license": "MIT" + }, + "node_modules/etag": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/express": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", + "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==", + "license": "MIT", + "dependencies": { + "accepts": "^2.0.0", + "body-parser": "^2.2.1", + "content-disposition": "^1.0.0", + "content-type": "^1.0.5", + "cookie": "^0.7.1", + "cookie-signature": "^1.2.1", + "debug": "^4.4.0", + "depd": "^2.0.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "finalhandler": "^2.1.0", + "fresh": "^2.0.0", + "http-errors": "^2.0.0", + "merge-descriptors": "^2.0.0", + "mime-types": "^3.0.0", + "on-finished": "^2.4.1", + "once": "^1.4.0", + "parseurl": "^1.3.3", + "proxy-addr": "^2.0.7", + "qs": "^6.14.0", + "range-parser": "^1.2.1", + "router": "^2.2.0", + "send": "^1.1.0", + "serve-static": "^2.2.0", + "statuses": "^2.0.1", + "type-is": "^2.0.1", + "vary": "^1.1.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/finalhandler": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz", + "integrity": "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "on-finished": "^2.4.1", + "parseurl": "^1.3.3", + "statuses": "^2.0.1" + }, + "engines": { + "node": ">= 18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/forwarded": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fresh": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz", + "integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "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" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/http-errors": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", + "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", + "license": "MIT", + "dependencies": { + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" + }, + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/iconv-lite": { + "version": "0.7.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.3.tgz", + "integrity": "sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, + "node_modules/ipaddr.js": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/is-promise": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz", + "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==", + "license": "MIT" + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/media-typer": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.1.tgz", + "integrity": "sha512-yz3xRaG20c6/BOzvYoDaGtPmGscs7YivItZEEqe6GbwNfHuxu9YNmvnEkMzKldAGY4/80pRcQRZSEnhquk9XuQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/merge-descriptors": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz", + "integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/mime-db": { + "version": "1.54.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", + "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz", + "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==", + "license": "MIT", + "dependencies": { + "mime-db": "^1.54.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/negotiator": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz", + "integrity": "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/on-finished": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "license": "MIT", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/path-to-regexp": { + "version": "8.4.2", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.4.2.tgz", + "integrity": "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/proxy-addr": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", + "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", + "license": "MIT", + "dependencies": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/qs": { + "version": "6.15.3", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.3.tgz", + "integrity": "sha512-O9gl3zCl5h5blw1KGUzQKhA5oUXSl8rwUIM5o0S3nCXMliSvy5Dzx7/DJcI+SwgICv+IneSZwhBh1oSyEHA71A==", + "license": "BSD-3-Clause", + "dependencies": { + "es-define-property": "^1.0.1", + "side-channel": "^1.1.1" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/range-parser": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.3.0.tgz", + "integrity": "sha512-hek2mFQpPuI4E1BBKrSto+BU3e3x4xuarsbiwr3+lf7p44juvFMV0XFWQAP3xUyqXA4RrXLIoaSUGbSt056ZMw==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/raw-body": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.2.tgz", + "integrity": "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "http-errors": "~2.0.1", + "iconv-lite": "~0.7.0", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/router": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz", + "integrity": "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "depd": "^2.0.0", + "is-promise": "^4.0.0", + "parseurl": "^1.3.3", + "path-to-regexp": "^8.0.0" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT" + }, + "node_modules/send": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/send/-/send-1.2.1.tgz", + "integrity": "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.3", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "fresh": "^2.0.0", + "http-errors": "^2.0.1", + "mime-types": "^3.0.2", + "ms": "^2.1.3", + "on-finished": "^2.4.1", + "range-parser": "^1.2.1", + "statuses": "^2.0.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/serve-static": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.1.tgz", + "integrity": "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==", + "license": "MIT", + "dependencies": { + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "parseurl": "^1.3.3", + "send": "^1.2.0" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", + "license": "ISC" + }, + "node_modules/side-channel": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz", + "integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4", + "side-channel-list": "^1.0.1", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz", + "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "license": "MIT", + "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" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "license": "MIT", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/tsx": { + "version": "4.23.5", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.23.5.tgz", + "integrity": "sha512-rw55FUaqOoI7RvlQwLbhO4nSDApnQ4/CykPuiQ/EPvtrX3WA9Ig55jIt9VvbBJbzJuj12ueRu4PMZ2SxPVbihg==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "~0.28.0" + }, + "bin": { + "tsx": "dist/cli.mjs" + }, + "engines": { + "node": ">=18.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + } + }, + "node_modules/type-is": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.1.0.tgz", + "integrity": "sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA==", + "license": "MIT", + "dependencies": { + "content-type": "^2.0.0", + "media-typer": "^1.1.0", + "mime-types": "^3.0.0" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/type-is/node_modules/content-type": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.0.0.tgz", + "integrity": "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "license": "ISC" + } + } +} diff --git a/src/problem5/package.json b/src/problem5/package.json new file mode 100644 index 0000000000..a80251b8ef --- /dev/null +++ b/src/problem5/package.json @@ -0,0 +1,29 @@ +{ + "name": "problem5-crude-server", + "version": "1.0.0", + "private": true, + "description": "ExpressJS TypeScript CRUD server with SQLite, audit logs, and trouble logs", + "main": "dist/src/server.js", + "engines": { + "node": ">=22.18.0" + }, + "scripts": { + "dev": "tsx watch src/server.ts", + "build": "tsc -p tsconfig.json", + "start": "node dist/src/server.js", + "db:migrate": "npm run build && node dist/src/scripts/migrate.js", + "db:seed": "npm run build && node dist/src/scripts/seed.js", + "test": "npm run build && node --test dist/test/core.test.js dist/test/api.test.js", + "stress": "npm run build && node dist/test/stress.js" + }, + "dependencies": { + "express": "^5.1.0", + "dotenv": "^16.5.0" + }, + "devDependencies": { + "@types/express": "^5.0.3", + "@types/node": "^22.15.0", + "tsx": "^4.19.4", + "typescript": "^5.8.3" + } +} diff --git a/src/problem5/requests.http b/src/problem5/requests.http new file mode 100644 index 0000000000..d543dd4f96 --- /dev/null +++ b/src/problem5/requests.http @@ -0,0 +1,60 @@ +@baseUrl = http://localhost:3000 +@taskId = replace-with-created-task-id + +### Health +GET {{baseUrl}}/health + +### Create task +POST {{baseUrl}}/api/v1/tasks +Content-Type: application/json +X-Request-Id: manual-create-001 + +{ + "title": "Build Problem 5", + "description": "ExpressJS CRUD API", + "status": "TODO", + "priority": "HIGH" +} + +### List tasks +GET {{baseUrl}}/api/v1/tasks?status=TODO&limit=20&offset=0 + +### Task details +GET {{baseUrl}}/api/v1/tasks/{{taskId}} + +### Update task +PATCH {{baseUrl}}/api/v1/tasks/{{taskId}} +Content-Type: application/json +X-Request-Id: manual-update-001 + +{ + "status": "DONE" +} + +### Delete task +DELETE {{baseUrl}}/api/v1/tasks/{{taskId}} +X-Request-Id: manual-delete-001 + +### Audit logs +GET {{baseUrl}}/api/v1/audit-logs?taskId={{taskId}}&limit=20&offset=0 + +### Audit log details +GET {{baseUrl}}/api/v1/audit-logs/1 + +### Trouble logs +GET {{baseUrl}}/api/v1/trouble-logs?limit=20&offset=0 + +### Filter trouble logs +GET {{baseUrl}}/api/v1/trouble-logs?errorCode=VALIDATION_ERROR&statusCode=400 + +### Trouble log details +GET {{baseUrl}}/api/v1/trouble-logs/1 + +### Generate validation trouble log +POST {{baseUrl}}/api/v1/tasks +Content-Type: application/json +X-Request-Id: manual-invalid-001 + +{ + "title": "" +} diff --git a/src/problem5/src/app.ts b/src/problem5/src/app.ts new file mode 100644 index 0000000000..6ab8493c5f --- /dev/null +++ b/src/problem5/src/app.ts @@ -0,0 +1,84 @@ +import express, { + type Express, + type Request, + type Response, +} from "express"; +import { Database } from "./database"; +import { + createErrorHandler, + createNotFoundHandler, + requestContext, +} from "./http/middleware"; +import { + createAuditLogRouter, + createTroubleLogRouter, +} from "./http/observability.routes"; +import { createTaskRouter } from "./http/task.routes"; +import { ObservabilityRepository } from "./repositories/observability.repository"; +import { TaskRepository } from "./repositories/task.repository"; +import { ObservabilityService } from "./services/observability.service"; +import { TaskService } from "./services/task.service"; + +export interface AppResources { + app: Express; + database: Database; + repository: TaskRepository; + observabilityRepository: ObservabilityRepository; +} + +export interface CreateAppOptions { + databasePath: string; + jsonLimit?: string; +} + +export function createApp(options: CreateAppOptions): AppResources { + const database = new Database(options.databasePath); + const repository = new TaskRepository(database); + const observabilityRepository = + new ObservabilityRepository(database); + + const taskService = new TaskService(repository); + const observabilityService = + new ObservabilityService(observabilityRepository); + + const app = express(); + + app.disable("x-powered-by"); + + // Must run before express.json so malformed JSON also receives a requestId + // and is written to trouble_logs. + app.use(requestContext); + app.use( + express.json({ + limit: options.jsonLimit ?? "32kb", + }), + ); + + app.get("/health", (_request: Request, response: Response) => { + response.json({ + status: "ok", + service: "problem5-crude-server", + timestamp: new Date().toISOString(), + }); + }); + + app.use("/api/v1/tasks", createTaskRouter(taskService)); + app.use( + "/api/v1/audit-logs", + createAuditLogRouter(observabilityService), + ); + app.use( + "/api/v1/trouble-logs", + createTroubleLogRouter(observabilityService), + ); + + app.use(createNotFoundHandler(observabilityService)); + app.use(createErrorHandler(observabilityService)); + + return { + app, + database, + repository, + observabilityRepository, + }; +} diff --git a/src/problem5/src/config.ts b/src/problem5/src/config.ts new file mode 100644 index 0000000000..d99476e52b --- /dev/null +++ b/src/problem5/src/config.ts @@ -0,0 +1,28 @@ +import "dotenv/config"; +import { resolve } from "node:path"; + +export interface AppConfig { + port: number; + databasePath: string; + jsonLimit: string; +} + +function parsePort(value: string | undefined): number { + const port = Number(value ?? "3000"); + + if (!Number.isInteger(port) || port < 0 || port > 65_535) { + throw new Error("PORT must be an integer between 0 and 65535"); + } + + return port; +} + +export function loadConfig(): AppConfig { + return { + port: parsePort(process.env.PORT), + databasePath: process.env.DATABASE_PATH + ? resolve(process.env.DATABASE_PATH) + : resolve(process.cwd(), "data", "problem5.sqlite"), + jsonLimit: process.env.JSON_LIMIT ?? "32kb", + }; +} diff --git a/src/problem5/src/database.ts b/src/problem5/src/database.ts new file mode 100644 index 0000000000..109700fb5a --- /dev/null +++ b/src/problem5/src/database.ts @@ -0,0 +1,151 @@ +import { mkdirSync } from "node:fs"; +import { dirname } from "node:path"; +import { DatabaseSync } from "node:sqlite"; + +export class Database { + private readonly connection: DatabaseSync; + + public constructor(path: string) { + if (path !== ":memory:") { + mkdirSync(dirname(path), { recursive: true }); + } + + this.connection = new DatabaseSync(path); + this.connection.exec("PRAGMA foreign_keys = ON"); + this.connection.exec("PRAGMA busy_timeout = 5000"); + + if (path !== ":memory:") { + this.connection.exec("PRAGMA journal_mode = WAL"); + } + + this.migrate(); + } + + public prepare(sql: string): ReturnType { + return this.connection.prepare(sql); + } + + public exec(sql: string): void { + this.connection.exec(sql); + } + + public transaction(operation: () => T): T { + this.connection.exec("BEGIN IMMEDIATE"); + + try { + const result = operation(); + this.connection.exec("COMMIT"); + return result; + } catch (error) { + this.connection.exec("ROLLBACK"); + throw error; + } + } + + public close(): void { + this.connection.close(); + } + + private hasMigration(version: number): boolean { + const row = this.connection + .prepare("SELECT version FROM schema_migrations WHERE version = ?") + .get(version) as { version: number } | undefined; + + return row !== undefined; + } + + private recordMigration(version: number): void { + this.connection + .prepare( + "INSERT INTO schema_migrations(version, applied_at) VALUES (?, ?)", + ) + .run(version, new Date().toISOString()); + } + + private migrate(): void { + this.connection.exec(` + CREATE TABLE IF NOT EXISTS schema_migrations ( + version INTEGER PRIMARY KEY, + applied_at TEXT NOT NULL + ); + `); + + if (!this.hasMigration(1)) { + this.transaction(() => { + this.connection.exec(` + CREATE TABLE IF NOT EXISTS tasks ( + id TEXT PRIMARY KEY, + title TEXT NOT NULL CHECK(length(title) BETWEEN 1 AND 120), + description TEXT, + status TEXT NOT NULL CHECK(status IN ('TODO', 'IN_PROGRESS', 'DONE')), + priority TEXT NOT NULL CHECK(priority IN ('LOW', 'MEDIUM', 'HIGH')), + due_date TEXT, + version INTEGER NOT NULL DEFAULT 1 CHECK(version >= 1), + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL + ); + + CREATE INDEX IF NOT EXISTS idx_tasks_status + ON tasks(status); + CREATE INDEX IF NOT EXISTS idx_tasks_priority + ON tasks(priority); + CREATE INDEX IF NOT EXISTS idx_tasks_due_date + ON tasks(due_date); + CREATE INDEX IF NOT EXISTS idx_tasks_created_at + ON tasks(created_at); + + CREATE TABLE IF NOT EXISTS task_audit_logs ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + trace_id TEXT NOT NULL, + action TEXT NOT NULL + CHECK(action IN ('CREATE', 'UPDATE', 'DELETE')), + task_id TEXT NOT NULL, + before_json TEXT, + after_json TEXT, + created_at TEXT NOT NULL + ); + + CREATE INDEX IF NOT EXISTS idx_task_audit_task_id + ON task_audit_logs(task_id); + CREATE INDEX IF NOT EXISTS idx_task_audit_trace_id + ON task_audit_logs(trace_id); + CREATE INDEX IF NOT EXISTS idx_task_audit_created_at + ON task_audit_logs(created_at); + `); + + this.recordMigration(1); + }); + } + + if (!this.hasMigration(2)) { + this.transaction(() => { + this.connection.exec(` + CREATE TABLE IF NOT EXISTS trouble_logs ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + request_id TEXT NOT NULL, + method TEXT NOT NULL, + path TEXT NOT NULL, + status_code INTEGER NOT NULL, + error_code TEXT NOT NULL, + message TEXT NOT NULL, + details_json TEXT, + stack TEXT, + duration_ms INTEGER NOT NULL CHECK(duration_ms >= 0), + created_at TEXT NOT NULL + ); + + CREATE INDEX IF NOT EXISTS idx_trouble_request_id + ON trouble_logs(request_id); + CREATE INDEX IF NOT EXISTS idx_trouble_error_code + ON trouble_logs(error_code); + CREATE INDEX IF NOT EXISTS idx_trouble_status_code + ON trouble_logs(status_code); + CREATE INDEX IF NOT EXISTS idx_trouble_created_at + ON trouble_logs(created_at); + `); + + this.recordMigration(2); + }); + } + } +} diff --git a/src/problem5/src/domain/observability.ts b/src/problem5/src/domain/observability.ts new file mode 100644 index 0000000000..d27e6629b1 --- /dev/null +++ b/src/problem5/src/domain/observability.ts @@ -0,0 +1,67 @@ +export const AUDIT_ACTIONS = [ + "CREATE", + "UPDATE", + "DELETE", +] as const; + +export type AuditAction = (typeof AUDIT_ACTIONS)[number]; + +export interface AuditLog { + id: number; + traceId: string; + action: AuditAction; + taskId: string; + before: unknown | null; + after: unknown | null; + createdAt: string; +} + +export interface AuditLogFilters { + taskId?: string; + traceId?: string; + action?: AuditAction; + limit: number; + offset: number; +} + +export interface TroubleLog { + id: number; + requestId: string; + method: string; + path: string; + statusCode: number; + errorCode: string; + message: string; + details: unknown | null; + stack: string | null; + durationMs: number; + createdAt: string; +} + +export interface TroubleLogFilters { + requestId?: string; + method?: string; + errorCode?: string; + statusCode?: number; + limit: number; + offset: number; +} + +export interface TroubleLogInput { + requestId: string; + method: string; + path: string; + statusCode: number; + errorCode: string; + message: string; + details?: unknown; + stack?: string; + durationMs: number; +} + +export interface ListResult { + items: T[]; + total: number; + limit: number; + offset: number; +} diff --git a/src/problem5/src/domain/task.ts b/src/problem5/src/domain/task.ts new file mode 100644 index 0000000000..625c1a18d1 --- /dev/null +++ b/src/problem5/src/domain/task.ts @@ -0,0 +1,52 @@ +export const TASK_STATUSES = ["TODO", "IN_PROGRESS", "DONE"] as const; +export const TASK_PRIORITIES = ["LOW", "MEDIUM", "HIGH"] as const; + +export type TaskStatus = (typeof TASK_STATUSES)[number]; +export type TaskPriority = (typeof TASK_PRIORITIES)[number]; + +export interface Task { + id: string; + title: string; + description: string | null; + status: TaskStatus; + priority: TaskPriority; + dueDate: string | null; + version: number; + createdAt: string; + updatedAt: string; +} + +export interface CreateTaskInput { + title: string; + description?: string | null; + status?: TaskStatus; + priority?: TaskPriority; + dueDate?: string | null; +} + +export interface UpdateTaskInput { + title?: string; + description?: string | null; + status?: TaskStatus; + priority?: TaskPriority; + dueDate?: string | null; +} + +export interface TaskFilters { + search?: string; + status?: TaskStatus; + priority?: TaskPriority; + dueAfter?: string; + dueBefore?: string; + sortBy: "title" | "status" | "priority" | "dueDate" | "createdAt" | "updatedAt"; + order: "asc" | "desc"; + limit: number; + offset: number; +} + +export interface TaskListResult { + items: Task[]; + total: number; + limit: number; + offset: number; +} diff --git a/src/problem5/src/errors.ts b/src/problem5/src/errors.ts new file mode 100644 index 0000000000..e25a898f00 --- /dev/null +++ b/src/problem5/src/errors.ts @@ -0,0 +1,20 @@ +export type ErrorDetails = Record | readonly unknown[]; + +export class AppError extends Error { + public readonly statusCode: number; + public readonly code: string; + public readonly details: ErrorDetails | undefined; + + public constructor( + statusCode: number, + code: string, + message: string, + details?: ErrorDetails, + ) { + super(message); + this.name = "AppError"; + this.statusCode = statusCode; + this.code = code; + this.details = details; + } +} diff --git a/src/problem5/src/http/middleware.ts b/src/problem5/src/http/middleware.ts new file mode 100644 index 0000000000..89a0ef2d9d --- /dev/null +++ b/src/problem5/src/http/middleware.ts @@ -0,0 +1,197 @@ +import { randomUUID } from "node:crypto"; +import type { + ErrorRequestHandler, + NextFunction, + Request, + RequestHandler, + Response, +} from "express"; +import type { TroubleLogInput } from "../domain/observability"; +import { AppError } from "../errors"; +import { ObservabilityService } from "../services/observability.service"; + +export function requestContext( + request: Request, + response: Response, + next: NextFunction, +): void { + const incoming = request.header("x-request-id"); + const normalized = incoming?.trim(); + const requestId = + normalized && normalized.length <= 128 + ? normalized + : randomUUID(); + + response.locals.requestId = requestId; + response.locals.startedAt = Date.now(); + response.setHeader("x-request-id", requestId); + next(); +} + +interface NormalizedError { + statusCode: number; + code: string; + message: string; + details?: unknown; + stack?: string; +} + +function addStack( + normalized: NormalizedError, + stack: string | undefined, +): NormalizedError { + if (stack !== undefined) { + normalized.stack = stack; + } + + return normalized; +} + +function normalizeError(error: unknown): NormalizedError { + if (error instanceof SyntaxError && "body" in error) { + return addStack( + { + statusCode: 400, + code: "INVALID_JSON", + message: "Request body contains invalid JSON", + }, + error.stack, + ); + } + + if (error instanceof AppError) { + const normalized: NormalizedError = { + statusCode: error.statusCode, + code: error.code, + message: error.message, + }; + + if (error.details !== undefined) { + normalized.details = error.details; + } + + return addStack(normalized, error.stack); + } + + if (error instanceof Error) { + return addStack( + { + statusCode: 500, + code: "INTERNAL_SERVER_ERROR", + message: "An unexpected error occurred", + }, + error.stack, + ); + } + + return { + statusCode: 500, + code: "INTERNAL_SERVER_ERROR", + message: "An unexpected error occurred", + }; +} + +function durationFrom(response: Response): number { + const startedAt = response.locals.startedAt; + + return typeof startedAt === "number" + ? Math.max(0, Date.now() - startedAt) + : 0; +} + +function recordTroubleSafely( + service: ObservabilityService, + request: Request, + response: Response, + error: NormalizedError, +): void { + try { + const trouble: TroubleLogInput = { + requestId: + typeof response.locals.requestId === "string" + ? response.locals.requestId + : randomUUID(), + method: request.method, + path: request.originalUrl, + statusCode: error.statusCode, + errorCode: error.code, + message: error.message, + durationMs: durationFrom(response), + }; + + if (error.details !== undefined) { + trouble.details = error.details; + } + + if (error.stack !== undefined) { + trouble.stack = error.stack; + } + + service.recordTrouble(trouble); + } catch (loggingError) { + console.error("Trouble log write failed", loggingError); + } +} + +export function createNotFoundHandler( + service: ObservabilityService, +): RequestHandler { + return ( + request: Request, + response: Response, + ): void => { + const error: NormalizedError = { + statusCode: 404, + code: "ROUTE_NOT_FOUND", + message: `No route matches ${request.method} ${request.originalUrl}`, + }; + + recordTroubleSafely( + service, + request, + response, + error, + ); + + response.status(error.statusCode).json({ + error: { + code: error.code, + message: error.message, + requestId: response.locals.requestId, + }, + }); + }; +} + +export function createErrorHandler( + service: ObservabilityService, +): ErrorRequestHandler { + return ( + error: unknown, + request: Request, + response: Response, + _next: NextFunction, + ): void => { + const normalized = normalizeError(error); + + recordTroubleSafely( + service, + request, + response, + normalized, + ); + + if (normalized.statusCode >= 500) { + console.error(error); + } + + response.status(normalized.statusCode).json({ + error: { + code: normalized.code, + message: normalized.message, + details: normalized.details, + requestId: response.locals.requestId, + }, + }); + }; +} diff --git a/src/problem5/src/http/observability.routes.ts b/src/problem5/src/http/observability.routes.ts new file mode 100644 index 0000000000..3ff48facc8 --- /dev/null +++ b/src/problem5/src/http/observability.routes.ts @@ -0,0 +1,77 @@ +import { + Router, + type Request, + type Response, +} from "express"; +import { ObservabilityService } from "../services/observability.service"; +import { + parseAuditLogFilters, + parseLogId, + parseTroubleLogFilters, +} from "./observability.validation"; + +export function createAuditLogRouter( + service: ObservabilityService, +): Router { + const router = Router(); + + router.get("/", (request: Request, response: Response) => { + const result = service.listAuditLogs( + parseAuditLogFilters( + request.query as Record, + ), + ); + + response.json({ + data: result.items, + meta: { + total: result.total, + limit: result.limit, + offset: result.offset, + }, + }); + }); + + router.get("/:id", (request: Request, response: Response) => { + response.json({ + data: service.getAuditLog( + parseLogId(request.params.id), + ), + }); + }); + + return router; +} + +export function createTroubleLogRouter( + service: ObservabilityService, +): Router { + const router = Router(); + + router.get("/", (request: Request, response: Response) => { + const result = service.listTroubleLogs( + parseTroubleLogFilters( + request.query as Record, + ), + ); + + response.json({ + data: result.items, + meta: { + total: result.total, + limit: result.limit, + offset: result.offset, + }, + }); + }); + + router.get("/:id", (request: Request, response: Response) => { + response.json({ + data: service.getTroubleLog( + parseLogId(request.params.id), + ), + }); + }); + + return router; +} diff --git a/src/problem5/src/http/observability.validation.ts b/src/problem5/src/http/observability.validation.ts new file mode 100644 index 0000000000..8e4c514c25 --- /dev/null +++ b/src/problem5/src/http/observability.validation.ts @@ -0,0 +1,161 @@ +import { + AUDIT_ACTIONS, + type AuditAction, + type AuditLogFilters, + type TroubleLogFilters, +} from "../domain/observability"; +import { AppError } from "../errors"; + +function singleQueryValue( + value: unknown, + field: string, +): string | undefined { + if (value === undefined) { + return undefined; + } + + if (typeof value !== "string") { + throw new AppError( + 400, + "VALIDATION_ERROR", + `${field} must appear once`, + ); + } + + const normalized = value.trim(); + return normalized === "" ? undefined : normalized; +} + +function parseInteger( + value: string | undefined, + fallback: number, + field: string, + minimum: number, + maximum: number, +): number { + if (value === undefined) { + return fallback; + } + + if (!/^\d+$/.test(value)) { + throw new AppError( + 400, + "VALIDATION_ERROR", + `${field} must be an integer`, + ); + } + + const parsed = Number(value); + + if ( + !Number.isSafeInteger(parsed) || + parsed < minimum || + parsed > maximum + ) { + throw new AppError( + 400, + "VALIDATION_ERROR", + `${field} must be between ${minimum} and ${maximum}`, + ); + } + + return parsed; +} + +export function parseLogId(value: unknown): number { + if (typeof value !== "string") { + throw new AppError( + 400, + "VALIDATION_ERROR", + "Log id is required", + ); + } + + return parseInteger(value, 0, "id", 1, Number.MAX_SAFE_INTEGER); +} + +export function parseAuditLogFilters( + query: Record, +): AuditLogFilters { + const actionValue = singleQueryValue(query.action, "action")?.toUpperCase(); + + if ( + actionValue !== undefined && + !AUDIT_ACTIONS.includes(actionValue as AuditAction) + ) { + throw new AppError( + 400, + "VALIDATION_ERROR", + "Invalid audit action", + { allowed: AUDIT_ACTIONS }, + ); + } + + const filters: AuditLogFilters = { + limit: parseInteger( + singleQueryValue(query.limit, "limit"), + 20, + "limit", + 1, + 100, + ), + offset: parseInteger( + singleQueryValue(query.offset, "offset"), + 0, + "offset", + 0, + 1_000_000, + ), + }; + + const taskId = singleQueryValue(query.taskId, "taskId"); + const traceId = singleQueryValue(query.traceId, "traceId"); + + if (taskId) filters.taskId = taskId; + if (traceId) filters.traceId = traceId; + if (actionValue) filters.action = actionValue as AuditAction; + + return filters; +} + +export function parseTroubleLogFilters( + query: Record, +): TroubleLogFilters { + const filters: TroubleLogFilters = { + limit: parseInteger( + singleQueryValue(query.limit, "limit"), + 20, + "limit", + 1, + 100, + ), + offset: parseInteger( + singleQueryValue(query.offset, "offset"), + 0, + "offset", + 0, + 1_000_000, + ), + }; + + const requestId = singleQueryValue(query.requestId, "requestId"); + const method = singleQueryValue(query.method, "method")?.toUpperCase(); + const errorCode = singleQueryValue(query.errorCode, "errorCode"); + const statusCodeValue = singleQueryValue(query.statusCode, "statusCode"); + + if (requestId) filters.requestId = requestId; + if (method) filters.method = method; + if (errorCode) filters.errorCode = errorCode; + + if (statusCodeValue !== undefined) { + filters.statusCode = parseInteger( + statusCodeValue, + 0, + "statusCode", + 100, + 599, + ); + } + + return filters; +} diff --git a/src/problem5/src/http/task.routes.ts b/src/problem5/src/http/task.routes.ts new file mode 100644 index 0000000000..9fc901319b --- /dev/null +++ b/src/problem5/src/http/task.routes.ts @@ -0,0 +1,70 @@ +import { Router, type Request, type Response } from "express"; +import { AppError } from "../errors"; +import { TaskService } from "../services/task.service"; +import { + parseCreateTask, + parseTaskFilters, + parseUpdateTask, +} from "./validation"; + +function idFrom(request: Request): string { + const id = request.params.id; + + if (typeof id !== "string" || id.trim() === "") { + throw new AppError(400, "VALIDATION_ERROR", "Task id is required"); + } + + return id; +} + +export function createTaskRouter(service: TaskService): Router { + const router = Router(); + + router.post("/", (request: Request, response: Response) => { + const task = service.create( + parseCreateTask(request.body), + response.locals.requestId, + ); + + response + .status(201) + .location(`/api/v1/tasks/${task.id}`) + .json({ data: task }); + }); + + router.get("/", (request: Request, response: Response) => { + const result = service.list( + parseTaskFilters(request.query as Record), + ); + + response.json({ + data: result.items, + meta: { + total: result.total, + limit: result.limit, + offset: result.offset, + }, + }); + }); + + router.get("/:id", (request: Request, response: Response) => { + response.json({ data: service.get(idFrom(request)) }); + }); + + router.patch("/:id", (request: Request, response: Response) => { + const task = service.update( + idFrom(request), + parseUpdateTask(request.body), + response.locals.requestId, + ); + + response.json({ data: task }); + }); + + router.delete("/:id", (request: Request, response: Response) => { + service.delete(idFrom(request), response.locals.requestId); + response.status(204).send(); + }); + + return router; +} diff --git a/src/problem5/src/http/validation.ts b/src/problem5/src/http/validation.ts new file mode 100644 index 0000000000..438ea0f7fa --- /dev/null +++ b/src/problem5/src/http/validation.ts @@ -0,0 +1,275 @@ +import { AppError } from "../errors"; +import { + TASK_PRIORITIES, + TASK_STATUSES, + type CreateTaskInput, + type TaskFilters, + type TaskPriority, + type TaskStatus, + type UpdateTaskInput, +} from "../domain/task"; + +const CREATE_FIELDS = new Set([ + "title", + "description", + "status", + "priority", + "dueDate", +]); + +const UPDATE_FIELDS = CREATE_FIELDS; + +function requireObject(value: unknown): Record { + if (!value || typeof value !== "object" || Array.isArray(value)) { + throw new AppError(400, "VALIDATION_ERROR", "Request body must be an object"); + } + + return value as Record; +} + +function rejectUnknownFields( + object: Record, + allowed: ReadonlySet, +): void { + const unknown = Object.keys(object).filter((key) => !allowed.has(key)); + + if (unknown.length > 0) { + throw new AppError(400, "VALIDATION_ERROR", "Unknown request fields", { + unknownFields: unknown, + }); + } +} + +function parseTitle(value: unknown): string { + if (typeof value !== "string") { + throw new AppError(400, "VALIDATION_ERROR", "title must be a string"); + } + + const title = value.trim(); + + if (title.length < 1 || title.length > 120) { + throw new AppError( + 400, + "VALIDATION_ERROR", + "title must contain between 1 and 120 characters", + ); + } + + return title; +} + +function parseDescription(value: unknown): string | null { + if (value === null) { + return null; + } + + if (typeof value !== "string") { + throw new AppError( + 400, + "VALIDATION_ERROR", + "description must be a string or null", + ); + } + + const description = value.trim(); + + if (description.length > 2_000) { + throw new AppError( + 400, + "VALIDATION_ERROR", + "description must not exceed 2000 characters", + ); + } + + return description || null; +} + +function parseStatus(value: unknown): TaskStatus { + if (typeof value !== "string" || !TASK_STATUSES.includes(value as TaskStatus)) { + throw new AppError(400, "VALIDATION_ERROR", "Invalid task status", { + allowed: TASK_STATUSES, + }); + } + + return value as TaskStatus; +} + +function parsePriority(value: unknown): TaskPriority { + if ( + typeof value !== "string" || + !TASK_PRIORITIES.includes(value as TaskPriority) + ) { + throw new AppError(400, "VALIDATION_ERROR", "Invalid task priority", { + allowed: TASK_PRIORITIES, + }); + } + + return value as TaskPriority; +} + +function parseDate(value: unknown, field: string): string | null { + if (value === null) { + return null; + } + + if (typeof value !== "string" || value.trim() === "") { + throw new AppError(400, "VALIDATION_ERROR", `${field} must be an ISO date`); + } + + const date = new Date(value); + + if (Number.isNaN(date.getTime())) { + throw new AppError(400, "VALIDATION_ERROR", `${field} must be an ISO date`); + } + + return date.toISOString(); +} + +export function parseCreateTask(body: unknown): CreateTaskInput { + const object = requireObject(body); + rejectUnknownFields(object, CREATE_FIELDS); + + if (!("title" in object)) { + throw new AppError(400, "VALIDATION_ERROR", "title is required"); + } + + return { + title: parseTitle(object.title), + description: + "description" in object ? parseDescription(object.description) : null, + status: "status" in object ? parseStatus(object.status) : "TODO", + priority: + "priority" in object ? parsePriority(object.priority) : "MEDIUM", + dueDate: "dueDate" in object ? parseDate(object.dueDate, "dueDate") : null, + }; +} + +export function parseUpdateTask(body: unknown): UpdateTaskInput { + const object = requireObject(body); + rejectUnknownFields(object, UPDATE_FIELDS); + + if (Object.keys(object).length === 0) { + throw new AppError( + 400, + "VALIDATION_ERROR", + "At least one field must be provided", + ); + } + + const result: UpdateTaskInput = {}; + + if ("title" in object) result.title = parseTitle(object.title); + if ("description" in object) { + result.description = parseDescription(object.description); + } + if ("status" in object) result.status = parseStatus(object.status); + if ("priority" in object) result.priority = parsePriority(object.priority); + if ("dueDate" in object) result.dueDate = parseDate(object.dueDate, "dueDate"); + + return result; +} + +function singleQueryValue(value: unknown, field: string): string | undefined { + if (value === undefined) { + return undefined; + } + + if (typeof value !== "string") { + throw new AppError(400, "VALIDATION_ERROR", `${field} must appear once`); + } + + return value; +} + +function parseNonNegativeInteger( + value: string | undefined, + fallback: number, + field: string, + maximum: number, +): number { + if (value === undefined) { + return fallback; + } + + if (!/^\d+$/.test(value)) { + throw new AppError( + 400, + "VALIDATION_ERROR", + `${field} must be a non-negative integer`, + ); + } + + const parsed = Number(value); + + if (!Number.isSafeInteger(parsed) || parsed > maximum) { + throw new AppError( + 400, + "VALIDATION_ERROR", + `${field} must not exceed ${maximum}`, + ); + } + + return parsed; +} + +export function parseTaskFilters(query: Record): TaskFilters { + const search = singleQueryValue(query.search, "search")?.trim(); + const statusValue = singleQueryValue(query.status, "status"); + const priorityValue = singleQueryValue(query.priority, "priority"); + const dueAfterValue = singleQueryValue(query.dueAfter, "dueAfter"); + const dueBeforeValue = singleQueryValue(query.dueBefore, "dueBefore"); + const sortByValue = singleQueryValue(query.sortBy, "sortBy") ?? "createdAt"; + const orderValue = singleQueryValue(query.order, "order")?.toLowerCase() ?? "desc"; + + const allowedSorts = [ + "title", + "status", + "priority", + "dueDate", + "createdAt", + "updatedAt", + ] as const; + + if (!allowedSorts.includes(sortByValue as TaskFilters["sortBy"])) { + throw new AppError(400, "VALIDATION_ERROR", "Invalid sortBy", { + allowed: allowedSorts, + }); + } + + if (orderValue !== "asc" && orderValue !== "desc") { + throw new AppError(400, "VALIDATION_ERROR", "order must be asc or desc"); + } + + const filters: TaskFilters = { + sortBy: sortByValue as TaskFilters["sortBy"], + order: orderValue, + limit: parseNonNegativeInteger( + singleQueryValue(query.limit, "limit"), + 20, + "limit", + 100, + ), + offset: parseNonNegativeInteger( + singleQueryValue(query.offset, "offset"), + 0, + "offset", + 1_000_000, + ), + }; + + if (search) filters.search = search; + if (statusValue !== undefined) filters.status = parseStatus(statusValue); + if (priorityValue !== undefined) { + filters.priority = parsePriority(priorityValue); + } + if (dueAfterValue !== undefined) { + const dueAfter = parseDate(dueAfterValue, "dueAfter"); + if (dueAfter !== null) filters.dueAfter = dueAfter; + } + if (dueBeforeValue !== undefined) { + const dueBefore = parseDate(dueBeforeValue, "dueBefore"); + if (dueBefore !== null) filters.dueBefore = dueBefore; + } + + return filters; +} diff --git a/src/problem5/src/repositories/observability.repository.ts b/src/problem5/src/repositories/observability.repository.ts new file mode 100644 index 0000000000..78d9ed1d2a --- /dev/null +++ b/src/problem5/src/repositories/observability.repository.ts @@ -0,0 +1,268 @@ +import { Database } from "../database"; +import type { + AuditLog, + AuditLogFilters, + ListResult, + TroubleLog, + TroubleLogFilters, + TroubleLogInput, +} from "../domain/observability"; + +interface AuditLogRow { + id: number; + trace_id: string; + action: AuditLog["action"]; + task_id: string; + before_json: string | null; + after_json: string | null; + created_at: string; +} + +interface TroubleLogRow { + id: number; + request_id: string; + method: string; + path: string; + status_code: number; + error_code: string; + message: string; + details_json: string | null; + stack: string | null; + duration_ms: number; + created_at: string; +} + +function parseJson(value: string | null): unknown | null { + return value === null ? null : JSON.parse(value); +} + +function mapAuditLog(row: AuditLogRow): AuditLog { + return { + id: Number(row.id), + traceId: row.trace_id, + action: row.action, + taskId: row.task_id, + before: parseJson(row.before_json), + after: parseJson(row.after_json), + createdAt: row.created_at, + }; +} + +function mapTroubleLog(row: TroubleLogRow): TroubleLog { + return { + id: Number(row.id), + requestId: row.request_id, + method: row.method, + path: row.path, + statusCode: Number(row.status_code), + errorCode: row.error_code, + message: row.message, + details: parseJson(row.details_json), + stack: row.stack, + durationMs: Number(row.duration_ms), + createdAt: row.created_at, + }; +} + +export class ObservabilityRepository { + public constructor(private readonly database: Database) {} + + public listAuditLogs( + filters: AuditLogFilters, + ): ListResult { + const conditions: string[] = []; + const parameters: Array = []; + + if (filters.taskId) { + conditions.push("task_id = ?"); + parameters.push(filters.taskId); + } + + if (filters.traceId) { + conditions.push("trace_id = ?"); + parameters.push(filters.traceId); + } + + if (filters.action) { + conditions.push("action = ?"); + parameters.push(filters.action); + } + + const where = + conditions.length > 0 + ? `WHERE ${conditions.join(" AND ")}` + : ""; + + const countRow = this.database + .prepare(` + SELECT COUNT(*) AS total + FROM task_audit_logs + ${where} + `) + .get(...parameters) as { total: number }; + + const rows = this.database + .prepare(` + SELECT * + FROM task_audit_logs + ${where} + ORDER BY id DESC + LIMIT ? OFFSET ? + `) + .all( + ...parameters, + filters.limit, + filters.offset, + ) as unknown as AuditLogRow[]; + + return { + items: rows.map(mapAuditLog), + total: Number(countRow.total), + limit: filters.limit, + offset: filters.offset, + }; + } + + public findAuditLogById(id: number): AuditLog | null { + const row = this.database + .prepare(` + SELECT * + FROM task_audit_logs + WHERE id = ? + `) + .get(id) as AuditLogRow | undefined; + + return row ? mapAuditLog(row) : null; + } + + public writeTroubleLog(input: TroubleLogInput): TroubleLog { + const createdAt = new Date().toISOString(); + const result = this.database + .prepare(` + INSERT INTO trouble_logs( + request_id, + method, + path, + status_code, + error_code, + message, + details_json, + stack, + duration_ms, + created_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + `) + .run( + input.requestId, + input.method, + input.path, + input.statusCode, + input.errorCode, + input.message, + input.details === undefined + ? null + : JSON.stringify(input.details), + input.stack ?? null, + input.durationMs, + createdAt, + ); + + return { + id: Number(result.lastInsertRowid), + requestId: input.requestId, + method: input.method, + path: input.path, + statusCode: input.statusCode, + errorCode: input.errorCode, + message: input.message, + details: input.details ?? null, + stack: input.stack ?? null, + durationMs: input.durationMs, + createdAt, + }; + } + + public listTroubleLogs( + filters: TroubleLogFilters, + ): ListResult { + const conditions: string[] = []; + const parameters: Array = []; + + if (filters.requestId) { + conditions.push("request_id = ?"); + parameters.push(filters.requestId); + } + + if (filters.method) { + conditions.push("method = ?"); + parameters.push(filters.method); + } + + if (filters.errorCode) { + conditions.push("error_code = ?"); + parameters.push(filters.errorCode); + } + + if (filters.statusCode !== undefined) { + conditions.push("status_code = ?"); + parameters.push(filters.statusCode); + } + + const where = + conditions.length > 0 + ? `WHERE ${conditions.join(" AND ")}` + : ""; + + const countRow = this.database + .prepare(` + SELECT COUNT(*) AS total + FROM trouble_logs + ${where} + `) + .get(...parameters) as { total: number }; + + const rows = this.database + .prepare(` + SELECT * + FROM trouble_logs + ${where} + ORDER BY id DESC + LIMIT ? OFFSET ? + `) + .all( + ...parameters, + filters.limit, + filters.offset, + ) as unknown as TroubleLogRow[]; + + return { + items: rows.map(mapTroubleLog), + total: Number(countRow.total), + limit: filters.limit, + offset: filters.offset, + }; + } + + public findTroubleLogById(id: number): TroubleLog | null { + const row = this.database + .prepare(` + SELECT * + FROM trouble_logs + WHERE id = ? + `) + .get(id) as TroubleLogRow | undefined; + + return row ? mapTroubleLog(row) : null; + } + + public countTroubleLogs(): number { + const row = this.database + .prepare(` + SELECT COUNT(*) AS total + FROM trouble_logs + `) + .get() as { total: number }; + + return Number(row.total); + } +} diff --git a/src/problem5/src/repositories/task.repository.ts b/src/problem5/src/repositories/task.repository.ts new file mode 100644 index 0000000000..20489b85ab --- /dev/null +++ b/src/problem5/src/repositories/task.repository.ts @@ -0,0 +1,253 @@ +import { randomUUID } from "node:crypto"; +import { Database } from "../database"; +import type { + CreateTaskInput, + Task, + TaskFilters, + TaskListResult, + UpdateTaskInput, +} from "../domain/task"; + +interface TaskRow { + id: string; + title: string; + description: string | null; + status: Task["status"]; + priority: Task["priority"]; + due_date: string | null; + version: number; + created_at: string; + updated_at: string; +} + +const SORT_COLUMNS: Record = { + title: "title", + status: "status", + priority: "priority", + dueDate: "due_date", + createdAt: "created_at", + updatedAt: "updated_at", +}; + +function mapTask(row: TaskRow): Task { + return { + id: row.id, + title: row.title, + description: row.description, + status: row.status, + priority: row.priority, + dueDate: row.due_date, + version: row.version, + createdAt: row.created_at, + updatedAt: row.updated_at, + }; +} + +export class TaskRepository { + public constructor(private readonly database: Database) {} + + public create(input: CreateTaskInput, traceId: string): Task { + const now = new Date().toISOString(); + const task: Task = { + id: randomUUID(), + title: input.title, + description: input.description ?? null, + status: input.status ?? "TODO", + priority: input.priority ?? "MEDIUM", + dueDate: input.dueDate ?? null, + version: 1, + createdAt: now, + updatedAt: now, + }; + + return this.database.transaction(() => { + this.database + .prepare(` + INSERT INTO tasks( + id, title, description, status, priority, due_date, + version, created_at, updated_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) + `) + .run( + task.id, + task.title, + task.description, + task.status, + task.priority, + task.dueDate, + task.version, + task.createdAt, + task.updatedAt, + ); + + this.writeAudit(traceId, "CREATE", task.id, null, task); + return task; + }); + } + + public list(filters: TaskFilters): TaskListResult { + const conditions: string[] = []; + const parameters: Array = []; + + if (filters.search) { + conditions.push("(title LIKE ? ESCAPE '\\' OR description LIKE ? ESCAPE '\\')"); + const escaped = filters.search.replace(/[\\%_]/g, "\\$&"); + const pattern = `%${escaped}%`; + parameters.push(pattern, pattern); + } + + if (filters.status) { + conditions.push("status = ?"); + parameters.push(filters.status); + } + + if (filters.priority) { + conditions.push("priority = ?"); + parameters.push(filters.priority); + } + + if (filters.dueAfter) { + conditions.push("due_date IS NOT NULL AND due_date >= ?"); + parameters.push(filters.dueAfter); + } + + if (filters.dueBefore) { + conditions.push("due_date IS NOT NULL AND due_date <= ?"); + parameters.push(filters.dueBefore); + } + + const where = conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : ""; + const sortColumn = SORT_COLUMNS[filters.sortBy]; + const sortOrder = filters.order.toUpperCase(); + + const countRow = this.database + .prepare(`SELECT COUNT(*) AS total FROM tasks ${where}`) + .get(...parameters) as { total: number }; + + const rows = this.database + .prepare(` + SELECT * FROM tasks + ${where} + ORDER BY ${sortColumn} ${sortOrder}, id ASC + LIMIT ? OFFSET ? + `) + .all(...parameters, filters.limit, filters.offset) as unknown as TaskRow[]; + + return { + items: rows.map(mapTask), + total: Number(countRow.total), + limit: filters.limit, + offset: filters.offset, + }; + } + + public findById(id: string): Task | null { + const row = this.database + .prepare("SELECT * FROM tasks WHERE id = ?") + .get(id) as TaskRow | undefined; + + return row ? mapTask(row) : null; + } + + public update(id: string, input: UpdateTaskInput, traceId: string): Task | null { + return this.database.transaction(() => { + const existing = this.findById(id); + + if (!existing) { + return null; + } + + const updated: Task = { + ...existing, + ...input, + description: + input.description === undefined ? existing.description : input.description, + dueDate: input.dueDate === undefined ? existing.dueDate : input.dueDate, + version: existing.version + 1, + updatedAt: new Date().toISOString(), + }; + + const result = this.database + .prepare(` + UPDATE tasks + SET title = ?, description = ?, status = ?, priority = ?, due_date = ?, + version = ?, updated_at = ? + WHERE id = ? AND version = ? + `) + .run( + updated.title, + updated.description, + updated.status, + updated.priority, + updated.dueDate, + updated.version, + updated.updatedAt, + id, + existing.version, + ); + + if (Number(result.changes) !== 1) { + throw new Error("Concurrent update detected"); + } + + this.writeAudit(traceId, "UPDATE", id, existing, updated); + return updated; + }); + } + + public delete(id: string, traceId: string): boolean { + return this.database.transaction(() => { + const existing = this.findById(id); + + if (!existing) { + return false; + } + + const result = this.database + .prepare("DELETE FROM tasks WHERE id = ?") + .run(id); + + if (Number(result.changes) !== 1) { + throw new Error("Task could not be deleted"); + } + + this.writeAudit(traceId, "DELETE", id, existing, null); + return true; + }); + } + + public countAuditLogs(taskId?: string): number { + const row = taskId + ? (this.database + .prepare("SELECT COUNT(*) AS total FROM task_audit_logs WHERE task_id = ?") + .get(taskId) as { total: number }) + : (this.database + .prepare("SELECT COUNT(*) AS total FROM task_audit_logs") + .get() as { total: number }); + + return Number(row.total); + } + + private writeAudit( + traceId: string, + action: "CREATE" | "UPDATE" | "DELETE", + taskId: string, + before: Task | null, + after: Task | null, + ): void { + this.database + .prepare(` + INSERT INTO task_audit_logs( + trace_id, action, task_id, before_json, after_json, created_at + ) VALUES (?, ?, ?, ?, ?, ?) + `) + .run( + traceId, + action, + taskId, + before ? JSON.stringify(before) : null, + after ? JSON.stringify(after) : null, + new Date().toISOString(), + ); + } +} diff --git a/src/problem5/src/scripts/migrate.ts b/src/problem5/src/scripts/migrate.ts new file mode 100644 index 0000000000..ce29fd3d64 --- /dev/null +++ b/src/problem5/src/scripts/migrate.ts @@ -0,0 +1,8 @@ +import { Database } from "../database"; +import { loadConfig } from "../config"; + +const config = loadConfig(); +const database = new Database(config.databasePath); +database.close(); + +console.log(`Database is ready: ${config.databasePath}`); diff --git a/src/problem5/src/scripts/seed.ts b/src/problem5/src/scripts/seed.ts new file mode 100644 index 0000000000..24488a6252 --- /dev/null +++ b/src/problem5/src/scripts/seed.ts @@ -0,0 +1,44 @@ +import { randomUUID } from "node:crypto"; +import { loadConfig } from "../config"; +import { Database } from "../database"; +import { TaskRepository } from "../repositories/task.repository"; + +const config = loadConfig(); +const database = new Database(config.databasePath); +const repository = new TaskRepository(database); + +const existing = repository.list({ + sortBy: "createdAt", + order: "desc", + limit: 1, + offset: 0, +}); + +if (existing.total === 0) { + repository.create( + { + title: "Review the CRUD API", + description: "Try the filters, update the task, then delete it.", + status: "TODO", + priority: "HIGH", + dueDate: new Date(Date.now() + 86_400_000).toISOString(), + }, + randomUUID(), + ); + + repository.create( + { + title: "Read README.md", + status: "DONE", + priority: "MEDIUM", + dueDate: null, + }, + randomUUID(), + ); + + console.log("Seeded 2 tasks"); +} else { + console.log("Seed skipped because tasks already exist"); +} + +database.close(); diff --git a/src/problem5/src/server.ts b/src/problem5/src/server.ts new file mode 100644 index 0000000000..3f2289f7e2 --- /dev/null +++ b/src/problem5/src/server.ts @@ -0,0 +1,30 @@ +import { createApp } from "./app"; +import { loadConfig } from "./config"; + +const config = loadConfig(); +const { app, database } = createApp({ + databasePath: config.databasePath, + jsonLimit: config.jsonLimit, +}); + +const server = app.listen(config.port, () => { + const address = server.address(); + const port = typeof address === "object" && address ? address.port : config.port; + + console.log(`Problem 5 server listening on http://localhost:${port}`); + console.log(`SQLite database: ${config.databasePath}`); +}); + +function shutdown(signal: string): void { + console.log(`${signal} received; shutting down`); + + server.close(() => { + database.close(); + process.exit(0); + }); + + setTimeout(() => process.exit(1), 10_000).unref(); +} + +process.once("SIGINT", () => shutdown("SIGINT")); +process.once("SIGTERM", () => shutdown("SIGTERM")); diff --git a/src/problem5/src/services/observability.service.ts b/src/problem5/src/services/observability.service.ts new file mode 100644 index 0000000000..b29a994daf --- /dev/null +++ b/src/problem5/src/services/observability.service.ts @@ -0,0 +1,53 @@ +import type { + AuditLogFilters, + TroubleLogFilters, + TroubleLogInput, +} from "../domain/observability"; +import { AppError } from "../errors"; +import { ObservabilityRepository } from "../repositories/observability.repository"; + +export class ObservabilityService { + public constructor( + private readonly repository: ObservabilityRepository, + ) {} + + public listAuditLogs(filters: AuditLogFilters) { + return this.repository.listAuditLogs(filters); + } + + public getAuditLog(id: number) { + const log = this.repository.findAuditLogById(id); + + if (!log) { + throw new AppError( + 404, + "AUDIT_LOG_NOT_FOUND", + "Audit log was not found", + ); + } + + return log; + } + + public recordTrouble(input: TroubleLogInput): void { + this.repository.writeTroubleLog(input); + } + + public listTroubleLogs(filters: TroubleLogFilters) { + return this.repository.listTroubleLogs(filters); + } + + public getTroubleLog(id: number) { + const log = this.repository.findTroubleLogById(id); + + if (!log) { + throw new AppError( + 404, + "TROUBLE_LOG_NOT_FOUND", + "Trouble log was not found", + ); + } + + return log; + } +} diff --git a/src/problem5/src/services/task.service.ts b/src/problem5/src/services/task.service.ts new file mode 100644 index 0000000000..7fb19fa638 --- /dev/null +++ b/src/problem5/src/services/task.service.ts @@ -0,0 +1,84 @@ +import { randomUUID } from "node:crypto"; +import type { + CreateTaskInput, + TaskFilters, + UpdateTaskInput, +} from "../domain/task"; +import { AppError } from "../errors"; +import { TaskRepository } from "../repositories/task.repository"; + +export class TaskService { + public constructor(private readonly repository: TaskRepository) {} + + public create( + input: CreateTaskInput, + traceId: string = randomUUID(), + ) { + return this.repository.create(input, traceId); + } + + public list(filters: TaskFilters) { + if ( + filters.dueAfter && + filters.dueBefore && + filters.dueAfter > filters.dueBefore + ) { + throw new AppError( + 400, + "VALIDATION_ERROR", + "dueAfter must be earlier than or equal to dueBefore", + ); + } + + return this.repository.list(filters); + } + + public get(id: string) { + const task = this.repository.findById(id); + + if (!task) { + throw new AppError( + 404, + "TASK_NOT_FOUND", + "Task was not found", + ); + } + + return task; + } + + public update( + id: string, + input: UpdateTaskInput, + traceId: string = randomUUID(), + ) { + const task = this.repository.update( + id, + input, + traceId, + ); + + if (!task) { + throw new AppError( + 404, + "TASK_NOT_FOUND", + "Task was not found", + ); + } + + return task; + } + + public delete( + id: string, + traceId: string = randomUUID(), + ): void { + if (!this.repository.delete(id, traceId)) { + throw new AppError( + 404, + "TASK_NOT_FOUND", + "Task was not found", + ); + } + } +} diff --git a/src/problem5/test/api.test.ts b/src/problem5/test/api.test.ts new file mode 100644 index 0000000000..0a3e17bdd9 --- /dev/null +++ b/src/problem5/test/api.test.ts @@ -0,0 +1,207 @@ +import assert from "node:assert/strict"; +import { mkdtempSync, rmSync } from "node:fs"; +import type { Server } from "node:http"; +import type { AddressInfo } from "node:net"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { after, before, test } from "node:test"; +import { createApp } from "../src/app"; + +const directory = mkdtempSync(join(tmpdir(), "problem5-api-")); +const databasePath = join(directory, "test.sqlite"); +const resources = createApp({ databasePath }); +let server: Server; +let baseUrl = ""; +let taskId = ""; + +before(async () => { + await new Promise((resolve) => { + server = resources.app.listen(0, "127.0.0.1", () => { + const address = server.address() as AddressInfo; + baseUrl = `http://127.0.0.1:${address.port}`; + resolve(); + }); + }); +}); + +after(async () => { + await new Promise((resolve, reject) => { + server.close((error?: Error) => { + if (error) { + reject(error); + return; + } + + resolve(); + }); + }); + + resources.database.close(); + rmSync(directory, { recursive: true, force: true }); +}); + +async function json(response: Response): Promise { + return response.status === 204 ? null : response.json(); +} + +test("health endpoint", async () => { + const response = await fetch(`${baseUrl}/health`); + + assert.equal(response.status, 200); + assert.equal((await json(response)).status, "ok"); +}); + +test("create a task", async () => { + const response = await fetch(`${baseUrl}/api/v1/tasks`, { + method: "POST", + headers: { + "content-type": "application/json", + }, + body: JSON.stringify({ + title: "Write integration tests", + description: "Exercise every CRUD endpoint", + status: "TODO", + priority: "HIGH", + }), + }); + + assert.equal(response.status, 201); + + const body = await json(response); + taskId = body.data.id; + + assert.equal(body.data.title, "Write integration tests"); + assert.equal( + response.headers.get("location"), + `/api/v1/tasks/${taskId}`, + ); +}); + +test("list and filter tasks", async () => { + const response = await fetch( + `${baseUrl}/api/v1/tasks?status=TODO&priority=HIGH&search=integration`, + ); + const body = await json(response); + + assert.equal(response.status, 200); + assert.equal(body.meta.total, 1); + assert.equal(body.data[0].id, taskId); +}); + +test("get task details", async () => { + const response = await fetch( + `${baseUrl}/api/v1/tasks/${taskId}`, + ); + + assert.equal(response.status, 200); + assert.equal((await json(response)).data.id, taskId); +}); + +test("update task details", async () => { + const response = await fetch( + `${baseUrl}/api/v1/tasks/${taskId}`, + { + method: "PATCH", + headers: { + "content-type": "application/json", + }, + body: JSON.stringify({ + status: "DONE", + priority: "MEDIUM", + }), + }, + ); + + const body = await json(response); + + assert.equal(response.status, 200); + assert.equal(body.data.status, "DONE"); + assert.equal(body.data.version, 2); +}); + +test("invalid payload creates trouble log", async () => { + const response = await fetch(`${baseUrl}/api/v1/tasks`, { + method: "POST", + headers: { + "content-type": "application/json", + "x-request-id": "invalid-payload-test", + }, + body: JSON.stringify({ + title: "", + unexpected: true, + }), + }); + + const body = await json(response); + + assert.equal(response.status, 400); + assert.equal(body.error.code, "VALIDATION_ERROR"); + assert.equal(body.error.requestId, "invalid-payload-test"); + + const logsResponse = await fetch( + `${baseUrl}/api/v1/trouble-logs?requestId=invalid-payload-test`, + ); + const logsBody = await json(logsResponse); + + assert.equal(logsResponse.status, 200); + assert.equal(logsBody.meta.total, 1); + assert.equal(logsBody.data[0].errorCode, "VALIDATION_ERROR"); + assert.equal(logsBody.data[0].statusCode, 400); +}); + +test("unknown route creates trouble log", async () => { + const response = await fetch(`${baseUrl}/missing-route`, { + headers: { + "x-request-id": "missing-route-test", + }, + }); + + assert.equal(response.status, 404); + + const logsResponse = await fetch( + `${baseUrl}/api/v1/trouble-logs?requestId=missing-route-test`, + ); + const logsBody = await json(logsResponse); + + assert.equal(logsBody.meta.total, 1); + assert.equal(logsBody.data[0].errorCode, "ROUTE_NOT_FOUND"); +}); + +test("delete task and expose three audit records", async () => { + const deleted = await fetch( + `${baseUrl}/api/v1/tasks/${taskId}`, + { + method: "DELETE", + }, + ); + + assert.equal(deleted.status, 204); + + const missing = await fetch( + `${baseUrl}/api/v1/tasks/${taskId}`, + ); + + assert.equal(missing.status, 404); + assert.equal( + (await json(missing)).error.code, + "TASK_NOT_FOUND", + ); + + const auditResponse = await fetch( + `${baseUrl}/api/v1/audit-logs?taskId=${taskId}&limit=10`, + ); + const auditBody = await json(auditResponse); + + assert.equal(auditResponse.status, 200); + assert.equal(auditBody.meta.total, 3); + + const actions = auditBody.data + .map((item: { action: string }) => item.action) + .sort(); + + assert.deepEqual(actions, [ + "CREATE", + "DELETE", + "UPDATE", + ]); +}); diff --git a/src/problem5/test/core.test.ts b/src/problem5/test/core.test.ts new file mode 100644 index 0000000000..b9bec079ad --- /dev/null +++ b/src/problem5/test/core.test.ts @@ -0,0 +1,93 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; +import { Database } from "../src/database"; +import { ObservabilityRepository } from "../src/repositories/observability.repository"; +import { TaskRepository } from "../src/repositories/task.repository"; +import { ObservabilityService } from "../src/services/observability.service"; +import { TaskService } from "../src/services/task.service"; + +const taskFilters = { + sortBy: "createdAt" as const, + order: "desc" as const, + limit: 20, + offset: 0, +}; + +test("CRUD audit and trouble logs persist in SQLite", () => { + const database = new Database(":memory:"); + const taskRepository = new TaskRepository(database); + const observabilityRepository = + new ObservabilityRepository(database); + + const taskService = new TaskService(taskRepository); + const observabilityService = + new ObservabilityService(observabilityRepository); + + try { + const created = taskService.create( + { + title: "Build API", + description: "Persistence test", + status: "TODO", + priority: "HIGH", + dueDate: null, + }, + "trace-create", + ); + + const updated = taskService.update( + created.id, + { + status: "IN_PROGRESS", + }, + "trace-update", + ); + + assert.equal(updated.version, 2); + assert.equal(taskService.list(taskFilters).total, 1); + + taskService.delete(created.id, "trace-delete"); + + const auditLogs = observabilityService.listAuditLogs({ + taskId: created.id, + limit: 20, + offset: 0, + }); + + assert.equal(auditLogs.total, 3); + assert.equal(auditLogs.items[0]?.action, "DELETE"); + assert.equal(auditLogs.items[1]?.action, "UPDATE"); + assert.equal(auditLogs.items[2]?.action, "CREATE"); + + observabilityService.recordTrouble({ + requestId: "request-test", + method: "POST", + path: "/api/v1/tasks", + statusCode: 400, + errorCode: "VALIDATION_ERROR", + message: "title is required", + details: { + field: "title", + }, + durationMs: 3, + }); + + const troubleLogs = observabilityService.listTroubleLogs({ + requestId: "request-test", + limit: 20, + offset: 0, + }); + + assert.equal(troubleLogs.total, 1); + assert.equal( + troubleLogs.items[0]?.errorCode, + "VALIDATION_ERROR", + ); + assert.deepEqual( + troubleLogs.items[0]?.details, + { field: "title" }, + ); + } finally { + database.close(); + } +}); diff --git a/src/problem5/test/stress.ts b/src/problem5/test/stress.ts new file mode 100644 index 0000000000..dcc9826ee4 --- /dev/null +++ b/src/problem5/test/stress.ts @@ -0,0 +1,444 @@ +import assert from "node:assert/strict"; +import { mkdtempSync, rmSync } from "node:fs"; +import type { Server } from "node:http"; +import type { AddressInfo } from "node:net"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { createApp } from "../src/app"; + +interface CreatedTaskResponse { + data: { + id: string; + }; +} + +interface ListResponse { + meta: { + total: number; + }; +} + +function delay(milliseconds: number): Promise { + return new Promise((resolve) => { + setTimeout(resolve, milliseconds); + }); +} + +/** + * Retries only transport-level failures. HTTP error responses are returned + * normally and validated by the caller. + */ +async function fetchWithRetry( + url: string, + init: RequestInit, + maximumAttempts = 4, +): Promise { + let lastError: unknown; + + for ( + let attempt = 1; + attempt <= maximumAttempts; + attempt += 1 + ) { + try { + return await fetch(url, init); + } catch (error) { + lastError = error; + + if (attempt === maximumAttempts) { + break; + } + + await delay(25 * 2 ** (attempt - 1)); + } + } + + throw new Error( + `Transport failure after ${maximumAttempts} attempts: ${url}`, + { + cause: lastError, + }, + ); +} + +async function expectJson( + url: string, + init: RequestInit, + expectedStatus: number, +): Promise { + const response = await fetchWithRetry(url, init); + const text = await response.text(); + + if (response.status !== expectedStatus) { + throw new Error( + [ + `${init.method ?? "GET"} ${url}`, + `expected HTTP ${expectedStatus}`, + `received HTTP ${response.status}`, + `body=${text}`, + ].join("; "), + ); + } + + try { + return JSON.parse(text) as T; + } catch (error) { + throw new Error( + `Response is not valid JSON: ${init.method ?? "GET"} ${url}`, + { + cause: error, + }, + ); + } +} + +async function expectStatus( + url: string, + init: RequestInit, + expectedStatus: number, +): Promise { + const response = await fetchWithRetry(url, init); + + if (response.status !== expectedStatus) { + const body = await response.text(); + + throw new Error( + [ + `${init.method ?? "GET"} ${url}`, + `expected HTTP ${expectedStatus}`, + `received HTTP ${response.status}`, + `body=${body}`, + ].join("; "), + ); + } + + // Consume the body so the connection can be reused cleanly. + await response.arrayBuffer(); +} + +/** + * Runs bounded concurrency and waits for every worker before throwing. + * + * The old Promise.all implementation failed fast. Its finally block then + * closed the server while other requests were still in flight, producing + * secondary ECONNREFUSED errors that hid the original failure. + */ +async function mapWithConcurrency( + items: readonly T[], + concurrency: number, + operation: ( + item: T, + index: number, + ) => Promise, +): Promise { + const results = new Array(items.length); + const errors: Error[] = []; + let nextIndex = 0; + + async function worker(): Promise { + while (true) { + const index = nextIndex; + nextIndex += 1; + + if (index >= items.length) { + return; + } + + const item = items[index]; + + if (item === undefined) { + errors.push( + new Error(`Missing work item at index ${index}`), + ); + continue; + } + + try { + results[index] = await operation(item, index); + } catch (error) { + errors.push( + new Error( + `Stress operation failed at index ${index}`, + { + cause: error, + }, + ), + ); + } + } + } + + const workerCount = Math.min( + Math.max(1, concurrency), + items.length, + ); + + await Promise.all( + Array.from( + { length: workerCount }, + () => worker(), + ), + ); + + if (errors.length > 0) { + throw new AggregateError( + errors, + `${errors.length} stress operations failed`, + ); + } + + return results; +} + +async function waitUntilListening( + server: Server, +): Promise { + await new Promise((resolve, reject) => { + const onListening = (): void => { + server.off("error", onError); + resolve(); + }; + + const onError = (error: Error): void => { + server.off("listening", onListening); + reject(error); + }; + + server.once("listening", onListening); + server.once("error", onError); + }); +} + +async function closeServer( + server: Server, +): Promise { + await new Promise((resolve, reject) => { + server.close((error?: Error) => { + if (error) { + reject(error); + return; + } + + resolve(); + }); + + server.closeIdleConnections(); + }); +} + +async function main(): Promise { + const directory = mkdtempSync( + join(tmpdir(), "problem5-stress-"), + ); + + const resources = createApp({ + databasePath: join(directory, "stress.sqlite"), + }); + + // A larger backlog helps on Windows when many clients connect quickly. + const server = resources.app.listen({ + port: 0, + host: "127.0.0.1", + backlog: 1_024, + }); + + try { + await waitUntilListening(server); + + const address = server.address(); + + if ( + address === null || + typeof address === "string" + ) { + throw new Error( + "Stress server did not expose a TCP address", + ); + } + + const { port } = address as AddressInfo; + const baseUrl = `http://127.0.0.1:${port}`; + const startedAt = Date.now(); + + // Confirm that the server is reachable before starting the load. + await expectJson<{ status: string }>( + `${baseUrl}/health`, + { + method: "GET", + }, + 200, + ); + + const createIndexes = Array.from( + { length: 250 }, + (_, index) => index, + ); + + const createdIds = await mapWithConcurrency( + createIndexes, + 25, + async (index) => { + const body = await expectJson( + `${baseUrl}/api/v1/tasks`, + { + method: "POST", + headers: { + "content-type": "application/json", + "x-request-id": `stress-create-${index}`, + }, + body: JSON.stringify({ + title: `Stress task ${index}`, + status: + index % 2 === 0 + ? "TODO" + : "IN_PROGRESS", + priority: + index % 3 === 0 + ? "HIGH" + : "MEDIUM", + }), + }, + 201, + ); + + return body.data.id; + }, + ); + + await mapWithConcurrency( + createdIds.slice(0, 100), + 20, + async (id, index) => { + await expectJson( + `${baseUrl}/api/v1/tasks/${id}`, + { + method: "PATCH", + headers: { + "content-type": "application/json", + "x-request-id": `stress-update-${index}`, + }, + body: JSON.stringify({ + status: "DONE", + }), + }, + 200, + ); + }, + ); + + await mapWithConcurrency( + createdIds.slice(0, 50), + 10, + async (id, index) => { + await expectStatus( + `${baseUrl}/api/v1/tasks/${id}`, + { + method: "DELETE", + headers: { + "x-request-id": `stress-delete-${index}`, + }, + }, + 204, + ); + }, + ); + + const invalidIndexes = Array.from( + { length: 50 }, + (_, index) => index, + ); + + await mapWithConcurrency( + invalidIndexes, + 20, + async (index) => { + await expectJson( + `${baseUrl}/api/v1/tasks`, + { + method: "POST", + headers: { + "content-type": "application/json", + "x-request-id": `stress-invalid-${index}`, + }, + body: JSON.stringify({ + title: "", + }), + }, + 400, + ); + }, + ); + + const auditBody = await expectJson( + `${baseUrl}/api/v1/audit-logs?limit=1`, + { + method: "GET", + }, + 200, + ); + + const troubleBody = await expectJson( + `${baseUrl}/api/v1/trouble-logs?errorCode=VALIDATION_ERROR&limit=1`, + { + method: "GET", + }, + 200, + ); + + assert.equal( + auditBody.meta.total, + 400, + "Expected 250 creates + 100 updates + 50 deletes", + ); + + assert.equal( + troubleBody.meta.total, + 50, + "Expected one trouble record for each invalid request", + ); + + assert.equal( + resources.observabilityRepository.countTroubleLogs(), + 50, + ); + + console.log("STRESS_TEST_PASS"); + console.log("creates=250"); + console.log("updates=100"); + console.log("deletes=50"); + console.log("auditRecords=400"); + console.log("troubleRecords=50"); + console.log("peakConcurrency=25"); + console.log(`elapsedMs=${Date.now() - startedAt}`); + } finally { + await closeServer(server); + resources.database.close(); + + rmSync(directory, { + recursive: true, + force: true, + }); + } +} + +main().catch((error: unknown) => { + if (error instanceof AggregateError) { + console.error(error.message); + + error.errors.forEach( + ( + childError: unknown, + index: number, + ) => { + console.error( + `[${index + 1}]`, + childError, + ); + }, + ); + } else { + console.error(error); + } + + process.exitCode = 1; +}); diff --git a/src/problem5/tsconfig.json b/src/problem5/tsconfig.json new file mode 100644 index 0000000000..ffc049b212 --- /dev/null +++ b/src/problem5/tsconfig.json @@ -0,0 +1,21 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "CommonJS", + "moduleResolution": "Node", + "rootDir": ".", + "outDir": "dist", + "strict": true, + "noUncheckedIndexedAccess": true, + "exactOptionalPropertyTypes": true, + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "skipLibCheck": true, + "noEmitOnError": true, + "sourceMap": true + }, + "include": [ + "src/**/*.ts", + "test/**/*.ts" + ] +} diff --git a/src/problem6/README.md b/src/problem6/README.md new file mode 100644 index 0000000000..e1f19f5496 --- /dev/null +++ b/src/problem6/README.md @@ -0,0 +1,789 @@ +# Problem 6 — Secure Real-Time Scoreboard Module + +## 1. Purpose + +This document specifies a backend module that: + +1. accepts authorised action-completion notifications; +2. increases the authenticated user's score; +3. prevents replayed or forged score updates; +4. returns the top 10 users; +5. pushes leaderboard changes to connected browsers in near real time. + +The specification is intended for a backend engineering team to implement. + +--- + +## 2. Critical security principle + +**The client must never be allowed to submit an arbitrary score or score +increment.** + +The browser may report that an action was completed, but the API service must +only apply a score change after validating evidence issued by a trusted +server-side component. + +Accepted evidence is one of the following: + +- a short-lived, signed action receipt issued by a trusted Action Service; or +- a server-to-server completion event authenticated with mTLS or a request + signature; or +- an authoritative action-completion record that the Scoreboard Module can + verify in a trusted database. + +A request such as this must not be supported: + +```json +{ + "userId": "another-user", + "score": 99999999, + "increment": 1000000 +} +``` + +The user identity comes from the authenticated access token, and the score +delta comes from server-side action rules. + +--- + +## 3. Scope + +### In scope + +- Submit an authorised action completion. +- Apply a server-controlled score increment. +- Make updates idempotent. +- Persist score history. +- Return the top 10 leaderboard. +- Broadcast live leaderboard updates. +- Store audit and trouble logs. +- Recover reliably from publish or cache failures. + +### Out of scope + +- The business logic that decides whether the underlying user action is + complete. +- User registration and login. +- UI design. +- Rewards, badges, seasons, tournaments, and score expiration. + +--- + +## 4. Proposed architecture + +### Main components + +| Component | Responsibility | +|---|---| +| HTTP Controller | Validates request shape and authentication context. | +| Completion Proof Validator | Verifies signed receipt, expiry, audience, user, action, and nonce. | +| Score Command Service | Applies idempotency and calculates the server-owned score delta. | +| Score Repository | Writes score event and current score in one database transaction. | +| Outbox Repository | Stores a reliable event in the same transaction. | +| Outbox Publisher | Publishes committed score changes asynchronously. | +| Leaderboard Projection | Updates the top-score read model or Redis sorted set. | +| Leaderboard Query Service | Returns the current top 10. | +| Realtime Hub | Sends leaderboard snapshots over Server-Sent Events. | +| Audit Log | Records successful score-changing operations. | +| Trouble Log | Records rejected requests and unexpected failures. | + +### Recommended infrastructure + +```text +PostgreSQL + Source of truth for score events, totals, outbox, audit, and trouble logs. + +Redis + Optional fast leaderboard projection and pub/sub transport. + +SSE + Recommended for browser live updates because the data flow is server-to-client. +``` + +The module can initially run as one ExpressJS application. The internal +boundaries should remain explicit so the publisher or realtime component can be +split into separate processes later. + +--- + +## 5. Execution flow + +The rendered flow is available at: + +```text +diagrams/score-update-flow.svg +diagrams/score-update-flow.png +``` + +Mermaid source: + +```mermaid +sequenceDiagram + autonumber + participant UI as Browser + participant AS as Trusted Action Service + participant API as API Service + participant DB as PostgreSQL + participant OW as Outbox Worker + participant LB as Leaderboard Projection + participant RT as SSE Hub + + UI->>AS: Complete action + AS-->>UI: Signed completion receipt + UI->>API: POST /api/v1/score-events + API->>API: Authenticate user + API->>API: Verify signature, exp, aud, sub and nonce + API->>API: Resolve score delta from server rules + API->>DB: BEGIN transaction + API->>DB: Insert score_event with unique receipt jti + API->>DB: Update user_score + API->>DB: Insert outbox_event and audit_log + API->>DB: COMMIT + API-->>UI: Updated score + OW->>DB: Read unpublished outbox event + OW->>LB: Update leaderboard projection + OW->>RT: Publish leaderboard.updated + RT-->>UI: SSE top-10 snapshot +``` + +### Detailed processing rules + +1. The user completes an action. +2. A trusted Action Service produces a signed, short-lived completion receipt. +3. The browser calls the score endpoint with its access token, receipt, and + idempotency key. +4. The API derives `userId` from the access token. +5. The API validates the receipt signature and claims. +6. The API verifies that the receipt user matches the authenticated user. +7. The API rejects an expired, reused, malformed, or unauthorised receipt. +8. The API resolves the score delta from server-side configuration. +9. A database transaction inserts the immutable score event. +10. The transaction updates the user's current score. +11. The transaction inserts an outbox event and audit record. +12. The API commits and returns the new score. +13. A background publisher updates the leaderboard projection. +14. The realtime hub sends the new top 10 to connected clients. + +--- + +## 6. API specification + +Base path: + +```text +/api/v1 +``` + +### 6.1 Submit an action completion + +```http +POST /api/v1/score-events +Authorization: Bearer +Idempotency-Key: +Content-Type: application/json +``` + +Request: + +```json +{ + "actionReceipt": "" +} +``` + +The receipt should contain claims equivalent to: + +```json +{ + "iss": "trusted-action-service", + "aud": "scoreboard-api", + "sub": "user-id", + "jti": "unique-action-instance-id", + "actionType": "DAILY_CHALLENGE", + "iat": 1785770000, + "exp": 1785770060 +} +``` + +The receipt must not contain a client-controlled numeric score delta. The API +maps `actionType` to a score rule. + +Successful response: + +```http +200 OK +``` + +```json +{ + "data": { + "eventId": "a7c5e79c-2a7c-47d2-86e8-715151210402", + "userId": "0be81df1-d3e0-41c0-8693-6fdf988249d0", + "appliedDelta": 20, + "score": 1240, + "leaderboardVersion": 381, + "applied": true + } +} +``` + +Repeated request with the same valid idempotency key and same payload: + +```http +200 OK +``` + +The API returns the original response with: + +```json +{ + "applied": false +} +``` + +The score must not be increased again. + +Errors: + +| Status | Code | Meaning | +|---:|---|---| +| 400 | `VALIDATION_ERROR` | Invalid request shape. | +| 401 | `UNAUTHENTICATED` | Missing or invalid access token. | +| 403 | `INVALID_ACTION_RECEIPT` | Receipt invalid or does not belong to the user. | +| 409 | `IDEMPOTENCY_CONFLICT` | Same key reused with different request content. | +| 409 | `RECEIPT_ALREADY_USED` | Receipt `jti` already consumed by a different request. | +| 429 | `RATE_LIMITED` | Request rate exceeded. | +| 500 | `INTERNAL_SERVER_ERROR` | Unexpected error. | +| 503 | `DEPENDENCY_UNAVAILABLE` | Required dependency temporarily unavailable. | + +### 6.2 Get top leaderboard + +```http +GET /api/v1/leaderboard?limit=10 +``` + +Rules: + +- Default and maximum limit is 10. +- Public user profiles must expose only approved display data. +- Ties use deterministic secondary ordering: + `score DESC, reached_score_at ASC, user_id ASC`. + +Response: + +```json +{ + "data": { + "version": 381, + "generatedAt": "2026-08-03T16:00:00.000Z", + "entries": [ + { + "rank": 1, + "userId": "user-1", + "displayName": "Player One", + "score": 9820 + } + ] + } +} +``` + +### 6.3 Subscribe to live leaderboard updates + +```http +GET /api/v1/leaderboard/stream +Accept: text/event-stream +Last-Event-ID: 380 +``` + +Example SSE event: + +```text +id: 381 +event: leaderboard.updated +data: {"version":381,"entries":[{"rank":1,"userId":"user-1","score":9820}]} +``` + +Rules: + +- Send the current full top-10 snapshot immediately after connection. +- Send heartbeat comments every 15–30 seconds. +- Support reconnect using `Last-Event-ID`. +- If a client misses events, send the newest full snapshot rather than replaying + every historical ranking. +- Apply connection limits per user and IP. + +--- + +## 7. Data model + +### `user_scores` + +Stores the current score for fast reads. + +```sql +CREATE TABLE user_scores ( + user_id UUID PRIMARY KEY, + score BIGINT NOT NULL DEFAULT 0 CHECK (score >= 0), + version BIGINT NOT NULL DEFAULT 0, + reached_score_at TIMESTAMPTZ NOT NULL, + updated_at TIMESTAMPTZ NOT NULL +); + +CREATE INDEX idx_user_scores_leaderboard + ON user_scores(score DESC, reached_score_at ASC, user_id ASC); +``` + +### `score_events` + +Immutable source of score changes. + +```sql +CREATE TABLE score_events ( + id UUID PRIMARY KEY, + user_id UUID NOT NULL, + receipt_jti TEXT NOT NULL UNIQUE, + idempotency_key TEXT NOT NULL, + request_hash TEXT NOT NULL, + action_type TEXT NOT NULL, + score_delta BIGINT NOT NULL CHECK (score_delta > 0), + score_after BIGINT NOT NULL, + receipt_hash TEXT NOT NULL, + created_at TIMESTAMPTZ NOT NULL, + + UNIQUE(user_id, idempotency_key) +); +``` + +### `outbox_events` + +Provides reliable event publication. + +```sql +CREATE TABLE outbox_events ( + id UUID PRIMARY KEY, + aggregate_type TEXT NOT NULL, + aggregate_id TEXT NOT NULL, + event_type TEXT NOT NULL, + payload JSONB NOT NULL, + attempts INTEGER NOT NULL DEFAULT 0, + available_at TIMESTAMPTZ NOT NULL, + published_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL +); +``` + +### `audit_logs` + +Records successful security-sensitive changes. + +Suggested fields: + +```text +id +trace_id +request_id +user_id +action +score_event_id +receipt_jti +input_hash +result_hash +created_at +``` + +Do not store the raw access token or raw signed receipt. + +### `trouble_logs` + +Records rejected and failed requests. + +Suggested fields: + +```text +id +trace_id +request_id +user_id +method +path +status_code +error_code +message +details_json +duration_ms +created_at +``` + +Sensitive receipt contents and secrets must be redacted. + +--- + +## 8. Transaction and consistency rules + +The following operations must occur in one database transaction: + +1. insert `score_events`; +2. update `user_scores`; +3. insert `outbox_events`; +4. insert `audit_logs`. + +If any operation fails, roll back all four. + +The database is the source of truth. Redis is a disposable projection. + +### Idempotency + +The implementation must enforce both: + +```text +UNIQUE(receipt_jti) +UNIQUE(user_id, idempotency_key) +``` + +The stored `request_hash` prevents reuse of the same idempotency key with a +different request. + +### Concurrency + +Lock or atomically update the user's score row: + +```sql +UPDATE user_scores +SET + score = score + :delta, + version = version + 1, + reached_score_at = NOW(), + updated_at = NOW() +WHERE user_id = :user_id +RETURNING score, version; +``` + +If a row may not exist, use `INSERT ... ON CONFLICT ... DO UPDATE`. + +--- + +## 9. Leaderboard projection + +### Baseline implementation + +Read the top 10 directly from PostgreSQL using the leaderboard index. + +Suitable when: + +- traffic is moderate; +- leaderboard updates are not extremely frequent; +- operational simplicity is preferred. + +### Scalable implementation + +Use a Redis sorted set: + +```text +Key: leaderboard:global +Member: userId +Score: current score +``` + +The outbox worker updates Redis after the database transaction commits. + +Tie-breaking cannot rely only on a Redis numeric score without additional +encoding. Recommended options: + +1. retrieve more than 10 candidates from Redis and apply deterministic ordering + from PostgreSQL; or +2. maintain a dedicated leaderboard projection table with explicit rank fields. + +A periodic reconciliation job must rebuild or verify the projection from +PostgreSQL. + +--- + +## 10. Security requirements + +### Authentication + +- Require a valid user access token. +- Derive the user ID from the verified token. +- Never trust `userId` from request JSON. + +### Action receipt validation + +Validate: + +```text +signature +issuer +audience +subject/user +action type +issued-at time +expiry time +unique jti/nonce +supported signing key id +``` + +Signing keys must be rotated and retrieved from a trusted key set. + +### Replay prevention + +- Store every consumed receipt `jti`. +- Apply a unique database constraint. +- Keep receipt expiry short. +- Reject duplicated receipt use even if it arrives concurrently. + +### Score integrity + +- Score rules are configured server-side. +- The receipt selects an action type, not a numeric score. +- Set a maximum delta per action. +- Set daily and hourly score velocity limits. +- Flag abnormal patterns for review. + +### Transport and infrastructure + +- TLS is mandatory. +- Use mTLS or signed requests for server-to-server calls. +- Apply rate limits by user, IP, and action type. +- Protect private log endpoints with administrator permissions. +- Redact tokens, receipts, keys, and personal data from logs. + +--- + +## 11. Realtime delivery + +SSE is preferred because leaderboard updates are one-way. + +The realtime hub may run in-process for the initial version. For multiple API +instances, use Redis Pub/Sub, Redis Streams, NATS, or Kafka so every instance +receives the update. + +The event payload should contain the full top-10 snapshot because: + +- only 10 entries are required; +- reconnect logic is simpler; +- clients do not need to reproduce ranking rules; +- missed intermediate events do not matter. + +Target: + +```text +Committed score to browser update: P95 < 1 second +``` + +--- + +## 12. Failure handling + +| Failure | Required behaviour | +|---|---| +| Receipt verification fails | Return 403 and write trouble log. | +| Duplicate receipt | Return deterministic duplicate response; do not increment. | +| Database transaction fails | Roll back and return 500/503. | +| Outbox publish fails | Keep event unpublished and retry with backoff. | +| Redis unavailable | Continue writing PostgreSQL; query PostgreSQL fallback. | +| SSE client disconnects | Clean up connection and allow reconnect. | +| Malformed request | Return 400 with request ID and write trouble log. | +| Unexpected exception | Return generic 500; store internal details in trouble log. | + +Outbox retry policy: + +```text +exponential backoff +maximum attempt threshold +dead-letter state after repeated failures +operator alert +manual replay capability +``` + +--- + +## 13. Observability + +### Structured logs + +Every request should include: + +```text +timestamp +level +service +request_id +trace_id +user_id +route +method +status_code +duration_ms +error_code +``` + +### Metrics + +```text +score_updates_total{result} +score_update_duration_ms +receipt_validation_failures_total{reason} +duplicate_receipts_total +idempotency_conflicts_total +leaderboard_query_duration_ms +leaderboard_publish_lag_ms +outbox_pending_total +outbox_publish_failures_total +sse_connections_active +sse_delivery_failures_total +``` + +### Alerts + +- sustained receipt-validation failures; +- unusual score velocity; +- outbox backlog above threshold; +- leaderboard projection drift; +- elevated 5xx rate; +- SSE delivery lag above target. + +--- + +## 14. Non-functional requirements + +| Area | Target | +|---|---| +| Score update response | P95 < 200 ms excluding external proof lookup. | +| Leaderboard query | P95 < 100 ms. | +| Live update latency | P95 < 1 second after commit. | +| Availability | 99.9% initial target. | +| Durability | No committed score event may be lost. | +| Consistency | Score event and total updated atomically. | +| Security | No client-controlled score delta. | +| Auditability | Every accepted update traceable to one receipt and request. | + +--- + +## 15. Test plan + +### Unit tests + +- receipt claim validation; +- action-to-delta mapping; +- idempotency hash comparison; +- score bounds; +- ranking tie-break rules; +- error mapping and redaction. + +### Integration tests + +- valid receipt increases score once; +- duplicate receipt does not increase score; +- same idempotency key returns original response; +- conflicting idempotency payload returns 409; +- expired or wrong-user receipt returns 403; +- transaction rollback leaves no partial data; +- outbox event exists after successful update; +- audit record exists after successful update; +- trouble record exists after rejected update; +- leaderboard returns deterministic top 10. + +### Concurrency tests + +- hundreds of simultaneous duplicate receipt submissions; +- simultaneous valid actions for the same user; +- simultaneous valid actions for different users; +- no lost updates; +- exactly one score event per receipt. + +### Realtime tests + +- initial snapshot on connect; +- update delivered after commit; +- reconnect using `Last-Event-ID`; +- multiple API instances receive the same event; +- disconnected clients are cleaned up. + +### Security tests + +- forged receipt; +- altered receipt payload; +- wrong audience or issuer; +- expired receipt; +- replayed receipt; +- another user's receipt; +- arbitrary score and user fields injected into JSON; +- rate-limit enforcement. + +--- + +## 16. Acceptance criteria + +The module is ready when: + +1. a valid authorised completion increases the correct user's score once; +2. a forged or replayed request cannot increase a score; +3. the top 10 endpoint returns deterministic rankings; +4. connected clients receive a new snapshot after a committed score update; +5. database failure cannot leave partial score state; +6. publish failure cannot lose the committed update; +7. accepted changes produce audit records; +8. rejected or failed requests produce trouble records; +9. integration, concurrency, and security tests pass; +10. secrets and raw credentials never appear in logs. + +--- + +## 17. Additional improvement comments + +### 17.1 Use server-to-server completion events where possible + +The strongest design is for the trusted Action Service to call the Scoreboard +Module directly. A browser-carried signed receipt is acceptable, but direct +server communication reduces client manipulation opportunities. + +### 17.2 Consider event sourcing only when justified + +`score_events` already provides an immutable ledger. Full event sourcing is not +required for the initial implementation. Keep `user_scores` as a transactional +projection and periodically reconcile it against the event ledger. + +### 17.3 Add seasons without rewriting the module + +Introduce a `leaderboard_id` or `season_id` in `score_events` and `user_scores` +before supporting multiple leaderboards. Do not hard-code one global board into +all repository interfaces. + +### 17.4 Add abuse detection + +Record score velocity and action diversity. Generate a security review event +when a user exceeds plausible thresholds instead of silently accepting every +individually valid receipt. + +### 17.5 Protect administrative log APIs + +Audit and trouble logs may contain operational or personal data. Expose them +only through authenticated administrator endpoints with pagination, retention, +and redaction policies. + +### 17.6 Define score limits + +Use `BIGINT`, reject arithmetic overflow, and define a maximum score. Never rely +on JavaScript `number` for unbounded integer values without explicit safe-range +checks. + +### 17.7 Prefer full snapshots for top 10 updates + +Sending the complete top 10 on each change is simpler and safer than sending +incremental rank movements. The payload is small and clients remain stateless. + +--- + +## 18. Suggested implementation order + +1. Database schema and migrations. +2. Receipt validator interface and test signing keys. +3. Transactional score command service. +4. Idempotency and replay constraints. +5. Leaderboard query endpoint. +6. Outbox worker. +7. SSE endpoint. +8. Audit and trouble logs. +9. Reconciliation job. +10. Load, concurrency, and security tests. diff --git a/src/problem6/diagrams/score-update-flow.dot b/src/problem6/diagrams/score-update-flow.dot new file mode 100644 index 0000000000..e7096f2841 --- /dev/null +++ b/src/problem6/diagrams/score-update-flow.dot @@ -0,0 +1,60 @@ +digraph ScoreboardFlow { + graph [ + rankdir=LR, + bgcolor="white", + pad="0.3", + nodesep="0.45", + ranksep="0.7", + fontname="Arial", + fontsize=18, + label="Secure Real-Time Scoreboard — Execution Flow", + labelloc="t" + ]; + + node [ + shape=box, + style="rounded,filled", + fillcolor="#F7F9FC", + color="#40566F", + fontname="Arial", + fontsize=11, + margin="0.16,0.10" + ]; + + edge [ + color="#40566F", + fontname="Arial", + fontsize=9, + arrowsize=0.75 + ]; + + browser [label="Browser\nAuthenticated user"]; + action [label="Trusted Action Service\nIssues signed receipt"]; + api [label="API Controller\nPOST /score-events"]; + auth [label="Auth + Receipt Validator\nSignature • exp • aud • sub • jti"]; + command [label="Score Command Service\nServer-owned action → delta"]; + tx [label="PostgreSQL Transaction\n1. score_event\n2. user_score\n3. outbox\n4. audit"]; + response [label="Return updated score\nIdempotent response"]; + outbox [label="Outbox Worker\nRetry until published"]; + projection [label="Leaderboard Projection\nPostgreSQL or Redis"]; + sse [label="SSE Hub\nleaderboard.updated"]; + top10 [label="Browser Scoreboard\nLive top 10"]; + trouble [label="Trouble Log\nRejected or failed request", fillcolor="#FFF4F2", color="#9B3D32"]; + + browser -> action [label="1. Complete action"]; + action -> browser [label="2. Signed receipt"]; + browser -> api [label="3. Bearer token + receipt\n+ Idempotency-Key"]; + api -> auth [label="4. Validate"]; + auth -> command [label="5. Authorised claims"]; + command -> tx [label="6. Apply once"]; + tx -> response [label="7. Commit"]; + response -> browser [label="8. New score"]; + tx -> outbox [label="9. Unpublished event"]; + outbox -> projection [label="10. Update top scores"]; + projection -> sse [label="11. Publish snapshot"]; + sse -> top10 [label="12. Live update"]; + browser -> top10 [style=invis]; + + auth -> trouble [label="Invalid / replay / expired", color="#9B3D32", fontcolor="#9B3D32"]; + tx -> trouble [label="Failure", color="#9B3D32", fontcolor="#9B3D32"]; +} diff --git a/src/problem6/diagrams/score-update-flow.mmd b/src/problem6/diagrams/score-update-flow.mmd new file mode 100644 index 0000000000..c6af69c2e2 --- /dev/null +++ b/src/problem6/diagrams/score-update-flow.mmd @@ -0,0 +1,26 @@ +sequenceDiagram + autonumber + participant UI as Browser + participant AS as Trusted Action Service + participant API as API Service + participant DB as PostgreSQL + participant OW as Outbox Worker + participant LB as Leaderboard Projection + participant RT as SSE Hub + + UI->>AS: Complete action + AS-->>UI: Signed completion receipt + UI->>API: POST /api/v1/score-events + API->>API: Authenticate user + API->>API: Verify signature, expiry, audience, user and nonce + API->>API: Resolve score delta from server rules + API->>DB: BEGIN transaction + API->>DB: Insert score event with unique receipt jti + API->>DB: Update current user score + API->>DB: Insert outbox event and audit log + API->>DB: COMMIT + API-->>UI: Return updated score + OW->>DB: Read unpublished outbox event + OW->>LB: Update leaderboard projection + OW->>RT: Publish leaderboard.updated + RT-->>UI: SSE top-10 snapshot diff --git a/src/problem6/diagrams/score-update-flow.png b/src/problem6/diagrams/score-update-flow.png new file mode 100644 index 0000000000..2ec6bb6d17 Binary files /dev/null and b/src/problem6/diagrams/score-update-flow.png differ diff --git a/src/problem6/diagrams/score-update-flow.svg b/src/problem6/diagrams/score-update-flow.svg new file mode 100644 index 0000000000..ec116c3209 --- /dev/null +++ b/src/problem6/diagrams/score-update-flow.svg @@ -0,0 +1,201 @@ + + + + + + +ScoreboardFlow + +Secure Real-Time Scoreboard — Execution Flow + + +browser + +Browser +Authenticated user + + + +action + +Trusted Action Service +Issues signed receipt + + + +browser->action + + +1. Complete action + + + +api + +API Controller +POST /score-events + + + +browser->api + + +3. Bearer token + receipt ++ Idempotency-Key + + + +top10 + +Browser Scoreboard +Live top 10 + + + + +action->browser + + +2. Signed receipt + + + +auth + +Auth + Receipt Validator +Signature • exp • aud • sub • jti + + + +api->auth + + +4. Validate + + + +command + +Score Command Service +Server-owned action → delta + + + +auth->command + + +5. Authorised claims + + + +trouble + +Trouble Log +Rejected or failed request + + + +auth->trouble + + +Invalid / replay / expired + + + +tx + +PostgreSQL Transaction +1. score_event +2. user_score +3. outbox +4. audit + + + +command->tx + + +6. Apply once + + + +response + +Return updated score +Idempotent response + + + +tx->response + + +7. Commit + + + +outbox + +Outbox Worker +Retry until published + + + +tx->outbox + + +9. Unpublished event + + + +tx->trouble + + +Failure + + + +response->browser + + +8. New score + + + +projection + +Leaderboard Projection +PostgreSQL or Redis + + + +outbox->projection + + +10. Update top scores + + + +sse + +SSE Hub +leaderboard.updated + + + +projection->sse + + +11. Publish snapshot + + + +sse->top10 + + +12. Live update + + + diff --git a/src/problem6/openapi.yaml b/src/problem6/openapi.yaml new file mode 100644 index 0000000000..18ecb45bea --- /dev/null +++ b/src/problem6/openapi.yaml @@ -0,0 +1,204 @@ +openapi: 3.1.0 +info: + title: Secure Real-Time Scoreboard API + version: 1.0.0 + description: API contract proposed by the Problem 6 architecture specification. +servers: + - url: /api/v1 +paths: + /score-events: + post: + summary: Apply one authorised action completion + operationId: createScoreEvent + security: + - bearerAuth: [] + parameters: + - in: header + name: Idempotency-Key + required: true + schema: + type: string + format: uuid + requestBody: + required: true + content: + application/json: + schema: + type: object + additionalProperties: false + required: + - actionReceipt + properties: + actionReceipt: + type: string + minLength: 1 + responses: + "200": + description: Score applied or an idempotent prior response returned + content: + application/json: + schema: + $ref: "#/components/schemas/ScoreEventResponse" + "400": + $ref: "#/components/responses/Error" + "401": + $ref: "#/components/responses/Error" + "403": + $ref: "#/components/responses/Error" + "409": + $ref: "#/components/responses/Error" + "429": + $ref: "#/components/responses/Error" + "500": + $ref: "#/components/responses/Error" + "503": + $ref: "#/components/responses/Error" + + /leaderboard: + get: + summary: Return the current top 10 + operationId: getLeaderboard + parameters: + - in: query + name: limit + schema: + type: integer + minimum: 1 + maximum: 10 + default: 10 + responses: + "200": + description: Current leaderboard snapshot + content: + application/json: + schema: + $ref: "#/components/schemas/LeaderboardResponse" + + /leaderboard/stream: + get: + summary: Subscribe to live leaderboard snapshots over SSE + operationId: streamLeaderboard + parameters: + - in: header + name: Last-Event-ID + required: false + schema: + type: string + responses: + "200": + description: Server-Sent Events stream + content: + text/event-stream: + schema: + type: string + +components: + securitySchemes: + bearerAuth: + type: http + scheme: bearer + bearerFormat: JWT + + schemas: + ScoreEventResponse: + type: object + required: + - data + properties: + data: + type: object + required: + - eventId + - userId + - appliedDelta + - score + - leaderboardVersion + - applied + properties: + eventId: + type: string + format: uuid + userId: + type: string + format: uuid + appliedDelta: + type: integer + format: int64 + score: + type: integer + format: int64 + leaderboardVersion: + type: integer + format: int64 + applied: + type: boolean + + LeaderboardEntry: + type: object + required: + - rank + - userId + - displayName + - score + properties: + rank: + type: integer + minimum: 1 + userId: + type: string + displayName: + type: string + score: + type: integer + format: int64 + + LeaderboardResponse: + type: object + required: + - data + properties: + data: + type: object + required: + - version + - generatedAt + - entries + properties: + version: + type: integer + format: int64 + generatedAt: + type: string + format: date-time + entries: + type: array + maxItems: 10 + items: + $ref: "#/components/schemas/LeaderboardEntry" + + ErrorResponse: + type: object + required: + - error + properties: + error: + type: object + required: + - code + - message + - requestId + properties: + code: + type: string + message: + type: string + requestId: + type: string + + responses: + Error: + description: Error response + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse"