@ahmed-3m/idea2prototype 0.9.1 → 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +15 -0
- package/README.md +4 -2
- package/package.json +2 -2
- package/skills/idea2prototype/SKILL.md +2 -0
- package/skills/idea2prototype/scripts/idea2prototype.mjs +158 -27
- package/skills/idea2prototype/templates/api/.idea2prototype.json +1 -1
- package/skills/idea2prototype/templates/api/server.mjs +37 -6
- package/skills/idea2prototype/templates/cli/.idea2prototype.json +1 -1
- package/skills/idea2prototype/templates/features/api-crud/route.tmpl.mjs +40 -10
- package/skills/idea2prototype/templates/features/auth-local/route.tmpl.mjs +44 -10
- package/skills/idea2prototype/templates/features/rate-limit/route.tmpl.mjs +4 -2
- package/skills/idea2prototype/templates/features/rate-limit/test.tmpl.mjs +22 -2
- package/skills/idea2prototype/templates/features/worker-jobs/jobs.tmpl.mjs +23 -3
- package/skills/idea2prototype/templates/library/.idea2prototype.json +1 -1
- package/skills/idea2prototype/templates/web/.idea2prototype.json +1 -1
- package/skills/idea2prototype/templates/web/server.mjs +36 -5
- package/skills/idea2prototype/templates/worker/.idea2prototype.json +1 -1
- package/skills/idea2prototype/templates/worker/worker.mjs +23 -3
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,20 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 1.0.0
|
|
4
|
+
|
|
5
|
+
First stable release. The scaffold contract — route objects, check/verify verdicts, `add`-module shapes — is frozen as-is; 1.x commits to additive changes only.
|
|
6
|
+
|
|
7
|
+
- Stress: every finding of the 2026-10-20 stress test (#49–#60) triaged and resolved. Data stores that fail to parse are quarantined (`data/<name>.json.corrupt-<ts>`, original bytes preserved) instead of silently wiped; single-writer and stateless-token semantics documented in the README; `ci.yml` runs with minimal permissions; README status line pinned by CI.
|
|
8
|
+
- Fix: `verify` (judge mode) discovers tests by node:test's own conventions (`*.test.{js,mjs,cjs}`, `test-*.js`, `*-test.js`/`*_test.js`, `test.js`, and everything under a `test/` dir, at any depth) — no more false PASS when failing tests live outside `tests/*.test.mjs` — and never reads or executes anything under `uploads/`, `data/`, `dist/`, `coverage/`, or `node_modules/`. The verdict header names its mode, boot-skip names the real cause, and an empty directory fails.
|
|
9
|
+
- Fix: `new` validates everything before writing a single file — reserved Windows device names (CON, NUL, COM1–9, …) and a 200-char cap apply to the final directory name AND every path component that will be created; quotes/backslashes/control characters in `--name`/`--idea` die instead of poisoning the JSON manifest mid-scaffold.
|
|
10
|
+
- Fix: the CLI never silently ignores input it doesn't understand — unknown flags, empty or flag-like flag values, and extra positional arguments die with the allowed list on every command; nested `new sub/proj` (missing parents created) works while an unusable parent dies with a readable message; a hung test run reports a `tests timed out` verdict on every supported Node (22's file-level runner timeout and 24's spawn-budget timeout both covered).
|
|
11
|
+
- Fix: `rateLimit({...})` instances own their buckets — independently configured wrappers no longer drain one shared budget (#54).
|
|
12
|
+
- Fix: the server templates answer oversize bodies with 413 before destroying the socket (up to 2× the limit drained so the 413 can be delivered; beyond that destroyed to bound the cost), turn contract-violating handler returns into clean 500s, clamp non-standard statuses, and send `null` for `[status]`-only returns. Both web and api templates are covered by live e2e tests.
|
|
13
|
+
- Fix: the package identity is scoped everywhere in-repo — `package.json` is `@ahmed-3m/idea2prototype` (what the release pipeline actually publishes); `publish.yml` no longer rewrites the name at publish time; tarball/global installs and the engine's registry-pin detection agree (pinned by a consistency test).
|
|
14
|
+
- CI: SHA-pinned actions upgraded to current majors (checkout v7.0.1, setup-node v7.1.0) plus weekly Dependabot bump-PRs so the pins stop rotting; nothing auto-merges.
|
|
15
|
+
- Benchmark: the 10-spec paired benchmark set is complete (this release archives specs 5–6 and 8–10: quiz, pomodoro, echo chat, habit tracker, word-counter CLI; spec 7 landed in 0.9.0). Across n=10: hand-written LOC −10%…−64% on 7 of 8 build-type specs (spec 6 at parity) and generated tests 2–3× the control's (parity on spec 6), full-depth verify vs. the control's recurring boot-skip; wall-clock split honestly, with the from-scratch arm faster on several small builds. All rubrics pre-registered; all judges mechanical.
|
|
16
|
+
- Tests: engine + consistency suite 46 → 69.
|
|
17
|
+
|
|
3
18
|
## 0.9.1
|
|
4
19
|
|
|
5
20
|
- Fix: scaffolds made through a published install (`npx`, `npm i -g`) now pin registry coordinates in the check script (`npx -y @ahmed-3m/idea2prototype@<scaffold-version> check`) instead of an absolute path into the volatile npx cache — `npm run check` survives `npm cache clean` and runs on a second machine. Checkouts (repo clone, plugin cache) still pin the stable local engine path; the exact-version guarantee is unchanged either way. Template placeholder renamed `__IDEA2PROTOTYPE_BIN__` → `__IDEA2PROTOTYPE_CMD__` to match the broader contract.
|
package/README.md
CHANGED
|
@@ -15,7 +15,7 @@ Idea2Prototype check — PASS (3/3)
|
|
|
15
15
|
boot PASS GET /api/health -> 200, GET / -> 200 in 45ms
|
|
16
16
|
```
|
|
17
17
|
|
|
18
|
-
**Status:**
|
|
18
|
+
**Status:** v1.0.0 · actively maintained · 1.x — the check/verify contract and template shapes are stable; new capabilities arrive as `add` modules.
|
|
19
19
|
|
|
20
20
|
That verdict is the whole idea: an agent building a prototype should **never generate boilerplate it didn't need to, and never read logs it didn't need to**. The syntax step covers server code (`node --check`) and every `.js` under `public/`, recursively (parsed as both ESM and classic script — only code broken in both modes fails).
|
|
21
21
|
|
|
@@ -54,6 +54,8 @@ Idea2Prototype moves all three to deterministic code: `new` scaffolds a running,
|
|
|
54
54
|
|
|
55
55
|
Templates: `web` (static frontend + health + auto-loading routes), `api` (JSON API + route manifest), `cli` (arg-parsed tool), `worker` (persistent job queue with retries), `library` (importable module: `createStore` + `memoAsync`). All Node >= 18.17, built-in `node:test`, zero installs — ever.
|
|
56
56
|
|
|
57
|
+
**JSON stores are single-writer.** Every `data/*.json` store is an in-memory list persisted on write by ONE server process — run two servers on the same project dir and each will clobber the other's acknowledged writes on its next persist (last writer wins the whole file). A store that fails to parse on first read is never silently wiped: it is quarantined as `data/<name>.json.corrupt-<timestamp>` (the original bytes are preserved for recovery) and the process starts empty with a loud stderr note. `auth-local` sessions are stateless HMAC tokens — logout clears the browser cookie only; a copied token stays valid until it expires.
|
|
58
|
+
|
|
57
59
|
## Quickstart
|
|
58
60
|
|
|
59
61
|
**Any agent or plain shell — npx straight off npmjs, no clone needed:**
|
|
@@ -111,7 +113,7 @@ The generated CRUD test then needs a session instead of anonymous POSTs — regi
|
|
|
111
113
|
|
|
112
114
|
## Benchmarks
|
|
113
115
|
|
|
114
|
-
`benchmarks/specs.md` is a pre-registered protocol: paired 2-arm design (same agent with vs. without the harness), blind functional judges, a symmetric verifier (`i2p verify`), and hypotheses stated up front. Results land in that file as they're measured — failures included.
|
|
116
|
+
`benchmarks/specs.md` is a pre-registered protocol: paired 2-arm design (same agent with vs. without the harness), blind functional judges, a symmetric verifier (`i2p verify`), and hypotheses stated up front. Results land in that file as they're measured — failures included. Ten specs are archived. Specs 1–2 (web app, API) favor the harness (23% fewer hand-written LOC, 2× tests, zero runner-compat failures — the control's from-scratch `npm test` broke on Node 24/Windows); spec 3 (micro-CLI) is an honest loss on speed and LOC — fixed overhead dominates tiny builds — with reliability and function at parity; spec 4 (expense tracker) wrote ~43% fewer hand-written LOC with 3× tests; spec 7 (bookmarks API + tag filter) is the strongest result at ~64%. Across the set the pattern holds: hand-written LOC −10%…−64% on 7 of 8 build-type specs (spec 6 at parity), generated tests 2–3× the control's, full-depth verify against the control's recurring boot-skip — while the from-scratch arm stays faster on several small builds, recorded as such.
|
|
115
117
|
|
|
116
118
|
## Design rules
|
|
117
119
|
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ahmed-3m/idea2prototype",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "Turn ideas into tested prototypes
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "Turn ideas into tested prototypes \u2014 token-lean scaffolds + one-command verification for any coding agent. Zero dependencies.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
7
7
|
"author": "ahmed-3m",
|
|
@@ -92,4 +92,6 @@ Then `check` — the anon-401/authed-201 contract and the rest of the CRUD suite
|
|
|
92
92
|
- `check` exits non-zero on failure, prints at most 3 failure notes — fix those, re-run. A trailing `note` line about scaffold/engine version drift is informational, not a failure. `check --json` gives the same verdict machine-readable. `verify [dir]` runs the same verdict on any Node project (benchmark/judge mode).
|
|
93
93
|
- If the user asks for dependencies (a framework, a database), ask once, then add via `npm install` and extend the tests accordingly.
|
|
94
94
|
- Projects are plain Node — `npm start` (web/api), `node cli.mjs help` (cli), `import ... from './index.mjs'` (library); no other tooling assumed.
|
|
95
|
+
- JSON stores (`data/*.json`) are single-writer: one server process per project dir — a second server's persist clobbers the first's acknowledged writes. A store that fails to parse on first read is quarantined (`data/<name>.json.corrupt-<ts>`, original bytes preserved) and the process starts empty with a loud stderr note — never a silent wipe.
|
|
96
|
+
- `auth-local` session tokens are stateless HMAC: logout clears the browser cookie only, and a token copied before logout stays valid until it expires — production-ish apps should shorten the TTL or add a server-side revocation list (the route's docblock says the same).
|
|
95
97
|
- Templates are single-purpose: no cross-template composition exists. If a task needs background jobs inside a web/api project, `add worker-jobs` brings the worker template's queue in as a feature — no need to scaffold both. Feature composition (`auth-local` × `api-crud`, `rate-limit`, `cors`, `sse` streaming, `worker-jobs`) is supported; template composition is not.
|
|
@@ -18,7 +18,7 @@ import { spawnSync } from 'node:child_process';
|
|
|
18
18
|
import { basename, dirname, extname, join, relative, resolve, sep } from 'node:path';
|
|
19
19
|
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
20
20
|
|
|
21
|
-
const VERSION = '0.
|
|
21
|
+
const VERSION = '1.0.0';
|
|
22
22
|
const PKG_NAME = '@ahmed-3m/idea2prototype';
|
|
23
23
|
const HERE = dirname(fileURLToPath(import.meta.url));
|
|
24
24
|
const TEMPLATES_DIR = join(HERE, '..', 'templates');
|
|
@@ -49,6 +49,7 @@ async function cmdNew(rest) {
|
|
|
49
49
|
const opts = parseFlags(rest, ['template', 'name', 'idea']);
|
|
50
50
|
const dir = opts._[0];
|
|
51
51
|
if (!dir) die('usage: idea2prototype new <dir> [--template web|api|cli|worker|library] [--name "..."] [--idea "..."]');
|
|
52
|
+
checkPositionalCount(opts, 1, 'new takes one directory name (new <dir>)');
|
|
52
53
|
|
|
53
54
|
const template = opts.template ?? 'web';
|
|
54
55
|
const templateDir = join(TEMPLATES_DIR, template);
|
|
@@ -60,10 +61,47 @@ async function cmdNew(rest) {
|
|
|
60
61
|
if (!/^[a-z0-9][a-z0-9-_]*$/.test(slug)) {
|
|
61
62
|
die(`invalid directory name "${slug}" — use letters, digits, dashes, underscores`);
|
|
62
63
|
}
|
|
64
|
+
// Windows reserved device names (CON, NUL, COM1-9, ...) create directories
|
|
65
|
+
// PowerShell cannot even enter. Reject on every platform so a scaffold
|
|
66
|
+
// stays portable, not just usable where it was created.
|
|
67
|
+
const RESERVED = /^(con|prn|aux|nul|com[1-9]|lpt[1-9])$/;
|
|
68
|
+
if (RESERVED.test(slug)) {
|
|
69
|
+
die(`"${slug}" is a reserved Windows device name — pick a different directory name`);
|
|
70
|
+
}
|
|
71
|
+
if (slug.length > 200) {
|
|
72
|
+
die(`directory name too long (${slug.length} chars, max 200) — nested scaffold files can exceed Windows MAX_PATH`);
|
|
73
|
+
}
|
|
74
|
+
// Same portability bar for every component `new` may CREATE: without this,
|
|
75
|
+
// `new con/proj` scaffolds inside a reserved-name dir via the back door.
|
|
76
|
+
for (const part of dir.split(/[\\/]+/).filter(Boolean).slice(0, -1)) {
|
|
77
|
+
const component = part.toLowerCase();
|
|
78
|
+
if (RESERVED.test(component)) {
|
|
79
|
+
die(`"${part}" is a reserved Windows device name — pick a different directory name`);
|
|
80
|
+
}
|
|
81
|
+
if (component.length > 200) {
|
|
82
|
+
die(`directory name too long (${component.length} chars, max 200) — nested scaffold files can exceed Windows MAX_PATH`);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
63
85
|
const name = (opts.name ?? basename(resolve(dir))).replace(/[<>]/g, '');
|
|
64
86
|
const idea = opts.idea ?? '(describe the idea in one sentence)';
|
|
87
|
+
// --name/--idea land verbatim in the JSON manifest and generated files; a
|
|
88
|
+
// quote or backslash would poison the manifest AFTER all files are on disk.
|
|
89
|
+
// Reject before writing anything.
|
|
90
|
+
for (const [flag, value] of [['--name', name], ['--idea', idea]]) {
|
|
91
|
+
if (/["\\\u0000-\u001f]/.test(value)) {
|
|
92
|
+
die(`invalid ${flag} value — quotes, backslashes, and control characters are not allowed`);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
65
95
|
|
|
66
96
|
const target = resolve(process.cwd(), dir);
|
|
97
|
+
// cp(recursive) creates missing intermediate parents, so nested `new sub/proj`
|
|
98
|
+
// is a supported input — create them here, where an unusable path (a file in
|
|
99
|
+
// the prefix, no permissions) dies with a readable message instead of ENOENT.
|
|
100
|
+
try {
|
|
101
|
+
await mkdir(dirname(target), { recursive: true });
|
|
102
|
+
} catch (err) {
|
|
103
|
+
die(`cannot create "${dir}" — parent path does not exist or is not a directory${err?.code ? ` (${err.code})` : ''}`);
|
|
104
|
+
}
|
|
67
105
|
if (existsSync(target) && readdirSyncSafe(target).length > 0) {
|
|
68
106
|
die(`"${target}" exists and is not empty — idea2prototype never overwrites existing files`);
|
|
69
107
|
}
|
|
@@ -192,6 +230,7 @@ const FEATURES = {
|
|
|
192
230
|
async function cmdAdd(rest) {
|
|
193
231
|
const feature = rest[0]?.startsWith('--') ? undefined : rest[0];
|
|
194
232
|
const opts = parseFlags(rest.slice(feature ? 1 : 0), ['resource']);
|
|
233
|
+
checkPositionalCount(opts, 0, 'add takes one feature name (add <feature>)');
|
|
195
234
|
const available = Object.keys(FEATURES).join(', ');
|
|
196
235
|
if (!feature) die(`usage: idea2prototype add <feature> — available: ${available}`);
|
|
197
236
|
const def = FEATURES[feature];
|
|
@@ -236,32 +275,41 @@ async function cmdAdd(rest) {
|
|
|
236
275
|
// check / verify — the compact verdict
|
|
237
276
|
// ---------------------------------------------------------------------------
|
|
238
277
|
async function cmdCheck(rest) {
|
|
239
|
-
const opts = parseFlags(rest, []);
|
|
278
|
+
const opts = parseFlags(rest, [], ['json']);
|
|
279
|
+
checkPositionalCount(opts, 0, 'check takes no arguments — run it from inside the project');
|
|
240
280
|
await loadMarker(); // must run inside a idea2prototype project
|
|
241
|
-
return runChecks(process.cwd(), 'json' in opts);
|
|
281
|
+
return runChecks(process.cwd(), 'json' in opts, 'check');
|
|
242
282
|
}
|
|
243
283
|
|
|
244
284
|
// verify: run the same verdict on ANY Node project — used to judge projects
|
|
245
285
|
// this tool never scaffolded (benchmark control arm, external code).
|
|
246
286
|
async function cmdVerify(rest) {
|
|
247
|
-
const
|
|
287
|
+
const opts = parseFlags(rest, [], ['json']);
|
|
288
|
+
checkPositionalCount(opts, 1, 'verify takes one optional directory (verify [dir])');
|
|
289
|
+
const dir = opts._[0];
|
|
248
290
|
const target = resolve(process.cwd(), dir ?? '.');
|
|
249
291
|
if (!existsSync(target) || !statSync(target).isDirectory()) {
|
|
250
292
|
die(`no such directory: ${target}`);
|
|
251
293
|
}
|
|
252
|
-
return runChecks(target,
|
|
294
|
+
return runChecks(target, 'json' in opts, 'verify');
|
|
253
295
|
}
|
|
254
296
|
|
|
255
|
-
async function runChecks(projectDir, asJson) {
|
|
297
|
+
async function runChecks(projectDir, asJson, mode = 'check') {
|
|
256
298
|
const marker = await readMarker(projectDir); // null for foreign projects (verify mode)
|
|
257
299
|
const steps = [];
|
|
258
300
|
const syntax = await stepSyntax(projectDir);
|
|
259
301
|
steps.push(syntax);
|
|
260
302
|
const tests = await stepTests(projectDir, syntax.status === 'pass');
|
|
261
303
|
steps.push(tests);
|
|
262
|
-
const boot = await stepBoot(projectDir, tests
|
|
304
|
+
const boot = await stepBoot(projectDir, tests, marker);
|
|
263
305
|
steps.push(boot);
|
|
264
306
|
|
|
307
|
+
// An empty directory is not a project: judge mode must not hand out a
|
|
308
|
+
// green verdict for a dir with nothing to check (no sources, no tests).
|
|
309
|
+
if (!marker && tests.status === 'skip' && steps[0].files === 0) {
|
|
310
|
+
steps.push({ name: 'project', status: 'fail', detail: 'no Node project found (no sources parsed, no test files)' });
|
|
311
|
+
}
|
|
312
|
+
|
|
265
313
|
const failed = steps.filter((s) => s.status === 'fail');
|
|
266
314
|
const summary = failed.length === 0 ? 'PASS' : 'FAIL';
|
|
267
315
|
const passedCount = steps.filter((s) => s.status === 'pass').length;
|
|
@@ -278,7 +326,7 @@ async function runChecks(projectDir, asJson) {
|
|
|
278
326
|
}, null, 2));
|
|
279
327
|
} else {
|
|
280
328
|
SAY(``);
|
|
281
|
-
SAY(`Idea2Prototype
|
|
329
|
+
SAY(`Idea2Prototype ${mode} — ${summary} (${passedCount}/${steps.length} passed) [${projectDir}]`);
|
|
282
330
|
for (const s of steps) {
|
|
283
331
|
const icon = s.status === 'pass' ? 'PASS ' : s.status === 'fail' ? 'FAIL ' : 'SKIP ';
|
|
284
332
|
SAY(` ${s.name.padEnd(7)} ${icon} ${s.detail}`);
|
|
@@ -329,8 +377,8 @@ async function stepSyntax(projectDir) {
|
|
|
329
377
|
detail = `${serverFiles.length} files parse clean`;
|
|
330
378
|
}
|
|
331
379
|
return failures.length
|
|
332
|
-
? { name: 'syntax', status: 'fail', detail, notes: failures.slice(0, 3) }
|
|
333
|
-
: { name: 'syntax', status: 'pass', detail };
|
|
380
|
+
? { name: 'syntax', status: 'fail', detail, notes: failures.slice(0, 3), files: total }
|
|
381
|
+
: { name: 'syntax', status: 'pass', detail, files: total };
|
|
334
382
|
}
|
|
335
383
|
|
|
336
384
|
// Browser JS can't take the plain `node --check <file>` path: an .js file
|
|
@@ -349,22 +397,60 @@ function parsesClean(source, inputType) {
|
|
|
349
397
|
return res.status === 0;
|
|
350
398
|
}
|
|
351
399
|
|
|
400
|
+
// Test discovery for judge mode, mirroring `node --test`'s own default
|
|
401
|
+
// conventions: **/*.test.{js,mjs,cjs}, test.js, test-*.*, *-test.*/_*_test.*
|
|
402
|
+
// basenames at any depth, plus EVERY .js/.mjs/.cjs under a `test/` directory
|
|
403
|
+
// (any depth) — so projects this tool never scaffolded are judged by their
|
|
404
|
+
// real tests instead of silently passing with none found.
|
|
405
|
+
// Untrusted (uploads/) and generated (dist/, coverage/, data/, public/)
|
|
406
|
+
// trees are never entered: the syntax step already refuses to READ uploads,
|
|
407
|
+
// and discovery must not hand an uploaded *.test.js to `node --test` to
|
|
408
|
+
// EXECUTE. Dot-dirs and node_modules are skipped the same way.
|
|
409
|
+
const TEST_DIR_SKIP = new Set(['node_modules', 'public', 'data', 'uploads', 'dist', 'coverage']);
|
|
410
|
+
const TEST_BASENAME = /^(test(?:-.+)?|.+[._-]test)\.(js|mjs|cjs)$/;
|
|
411
|
+
|
|
412
|
+
async function discoverTestFiles(projectDir) {
|
|
413
|
+
const found = new Set();
|
|
414
|
+
const JS = /\.(js|mjs|cjs)$/;
|
|
415
|
+
async function walk(dir, depth, underTestDir) {
|
|
416
|
+
if (depth > 6) return;
|
|
417
|
+
let entries;
|
|
418
|
+
try {
|
|
419
|
+
entries = await readdir(dir, { withFileTypes: true });
|
|
420
|
+
} catch {
|
|
421
|
+
return;
|
|
422
|
+
}
|
|
423
|
+
for (const entry of entries) {
|
|
424
|
+
if (entry.name.startsWith('.') || TEST_DIR_SKIP.has(entry.name)) continue;
|
|
425
|
+
const full = join(dir, entry.name);
|
|
426
|
+
if (entry.isDirectory()) {
|
|
427
|
+
await walk(full, depth + 1, underTestDir || entry.name === 'test');
|
|
428
|
+
} else if (underTestDir ? JS.test(entry.name) : TEST_BASENAME.test(entry.name)) {
|
|
429
|
+
found.add(full);
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
await walk(projectDir, 0, false);
|
|
434
|
+
return [...found].sort();
|
|
435
|
+
}
|
|
436
|
+
|
|
352
437
|
async function stepTests(projectDir, syntaxPassed) {
|
|
353
|
-
const testDir = join(projectDir, 'tests');
|
|
354
438
|
if (!syntaxPassed) return { name: 'tests', status: 'skip', detail: 'skipped (syntax failed)' };
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
439
|
+
const testFiles = (await discoverTestFiles(projectDir)).map((f) => relative(projectDir, f));
|
|
440
|
+
if (!testFiles.length) {
|
|
441
|
+
return {
|
|
442
|
+
name: 'tests',
|
|
443
|
+
status: 'skip',
|
|
444
|
+
detail: 'no test files found',
|
|
445
|
+
notes: ['looked for node:test conventions (*.test.{js,mjs,cjs}, test-*.js, *-test.js, *_test.js, test.js, and every .js/.mjs/.cjs under a test/ directory), skipping node_modules, uploads, data, dist, coverage, public — if your tests live elsewhere, this verdict is syntax-only'],
|
|
446
|
+
};
|
|
360
447
|
}
|
|
361
|
-
if (!testFiles.length) return { name: 'tests', status: 'skip', detail: 'no *.test.mjs files' };
|
|
362
448
|
|
|
363
449
|
// Pass explicit file paths: glob args to `node --test` are Node >= 21 only.
|
|
364
450
|
// Strip NODE_TEST_CONTEXT so a check spawned from inside a test runner
|
|
365
451
|
// doesn't put the child into child-reporter mode (no TAP summary).
|
|
366
452
|
const env = Object.fromEntries(Object.entries(process.env).filter(([k]) => k !== 'NODE_TEST_CONTEXT'));
|
|
367
|
-
const fileArgs = testFiles.map((f) => join('
|
|
453
|
+
const fileArgs = testFiles.map((f) => f.split(sep).join('/'));
|
|
368
454
|
// Serial execution trades wall-clock for determinism — scale the spawn
|
|
369
455
|
// budget with the file count so slow-but-passing suites aren't killed
|
|
370
456
|
// (90s floor, 30s per file, 5-minute ceiling).
|
|
@@ -396,6 +482,23 @@ async function stepTests(projectDir, syntaxPassed) {
|
|
|
396
482
|
res = spawnSync(process.execPath, ['--test', ...fileArgs], spawnOpts);
|
|
397
483
|
out = capture(res);
|
|
398
484
|
}
|
|
485
|
+
// A hung test run surfaces two ways, by Node version. 22's runner enforces
|
|
486
|
+
// --test-timeout at the FILE level: a top-level infinite loop is killed by
|
|
487
|
+
// the runner itself (~15s, "testTimeoutFailure" + "# cancelled 1", clean
|
|
488
|
+
// exit). 24's runner never fires there, so the spawn budget is the only
|
|
489
|
+
// bound (ETIMEDOUT, no summary). Both must read as a timeout verdict —
|
|
490
|
+
// never "? failed of ?" or spawn plumbing.
|
|
491
|
+
const fileTimeoutMs = /test timed out after (\d+)ms/.exec(out)?.[1];
|
|
492
|
+
if (res.error?.code === 'ETIMEDOUT' || fileTimeoutMs !== undefined) {
|
|
493
|
+
return {
|
|
494
|
+
name: 'tests',
|
|
495
|
+
status: 'fail',
|
|
496
|
+
detail: res.error?.code === 'ETIMEDOUT'
|
|
497
|
+
? `tests timed out after ${Math.round(spawnOpts.timeout / 1000)}s — no summary produced`
|
|
498
|
+
: `tests timed out — the runner's own timeout killed the blocked test (after ${fileTimeoutMs}ms)`,
|
|
499
|
+
notes: ['a test file blocked past every bound — infinite loop, hung worker, or slower than the per-test budget?'],
|
|
500
|
+
};
|
|
501
|
+
}
|
|
399
502
|
const counts = {};
|
|
400
503
|
for (const m of out.matchAll(/^# (tests|pass|fail|skipped|cancelled) (\d+)$/gm)) counts[m[1]] = Number(m[2]);
|
|
401
504
|
|
|
@@ -416,8 +519,14 @@ async function stepTests(projectDir, syntaxPassed) {
|
|
|
416
519
|
};
|
|
417
520
|
}
|
|
418
521
|
|
|
419
|
-
async function stepBoot(projectDir,
|
|
420
|
-
if (
|
|
522
|
+
async function stepBoot(projectDir, testsStep, marker) {
|
|
523
|
+
if (testsStep.status === 'fail') return { name: 'boot', status: 'skip', detail: 'skipped (tests failed)' };
|
|
524
|
+
if (testsStep.status === 'skip') {
|
|
525
|
+
// Name the actual cause — a syntax failure is why nothing ran, not
|
|
526
|
+
// "no tests found" ("skipped (…)" details unwrap to their inner cause).
|
|
527
|
+
const cause = testsStep.detail.replace(/^skipped \((.*)\)$/, '$1');
|
|
528
|
+
return { name: 'boot', status: 'skip', detail: `skipped (${cause})` };
|
|
529
|
+
}
|
|
421
530
|
// Library projects have no server to probe — boot = import the public API.
|
|
422
531
|
if (marker?.template === 'library') return bootLibrary(projectDir);
|
|
423
532
|
return bootServer(projectDir, marker);
|
|
@@ -510,7 +619,9 @@ async function bootServer(projectDir, marker) {
|
|
|
510
619
|
// ---------------------------------------------------------------------------
|
|
511
620
|
// list / help
|
|
512
621
|
// ---------------------------------------------------------------------------
|
|
513
|
-
async function cmdList() {
|
|
622
|
+
async function cmdList(rest) {
|
|
623
|
+
const opts = parseFlags(rest, []);
|
|
624
|
+
checkPositionalCount(opts, 0, 'list takes no arguments');
|
|
514
625
|
const templates = (await readdir(TEMPLATES_DIR, { withFileTypes: true }))
|
|
515
626
|
.filter((d) => d.isDirectory() && d.name !== 'features')
|
|
516
627
|
.map((d) => d.name);
|
|
@@ -521,8 +632,10 @@ async function cmdList() {
|
|
|
521
632
|
// map — compact repo map (paths + LOC + exports) so an agent can decide what
|
|
522
633
|
// to read instead of loading whole files into context.
|
|
523
634
|
async function cmdMap(rest) {
|
|
524
|
-
const
|
|
525
|
-
|
|
635
|
+
const opts = parseFlags(rest, [], ['json']);
|
|
636
|
+
checkPositionalCount(opts, 1, 'map takes one optional directory (map [dir])');
|
|
637
|
+
const dir = opts._[0];
|
|
638
|
+
const asJson = 'json' in opts;
|
|
526
639
|
const root = resolve(process.cwd(), dir ?? '.');
|
|
527
640
|
if (!existsSync(root) || !statSync(root).isDirectory()) die(`no such directory: ${root}`);
|
|
528
641
|
|
|
@@ -594,14 +707,25 @@ const withTimeout = (promise, ms, label) => {
|
|
|
594
707
|
return Promise.race([promise, timeout]).finally(() => clearTimeout(timer));
|
|
595
708
|
};
|
|
596
709
|
|
|
597
|
-
function parseFlags(args, valueFlags) {
|
|
710
|
+
function parseFlags(args, valueFlags, booleanFlags = []) {
|
|
598
711
|
const out = { _: [] };
|
|
712
|
+
const allowed = [
|
|
713
|
+
...valueFlags.map((f) => `--${f} <value>`),
|
|
714
|
+
...booleanFlags.map((f) => `--${f}`),
|
|
715
|
+
].join(', ') || 'none';
|
|
599
716
|
for (let i = 0; i < args.length; i++) {
|
|
600
717
|
const arg = args[i];
|
|
601
718
|
if (arg.startsWith('--')) {
|
|
602
719
|
const key = arg.slice(2);
|
|
603
|
-
if (valueFlags.includes(key))
|
|
604
|
-
|
|
720
|
+
if (valueFlags.includes(key)) {
|
|
721
|
+
const val = args[++i];
|
|
722
|
+
if (val === undefined || val === '' || val.startsWith('-')) die(`flag "${arg}" needs a value (allowed: ${allowed})`);
|
|
723
|
+
out[key] = val;
|
|
724
|
+
} else if (booleanFlags.includes(key)) {
|
|
725
|
+
out[key] = true;
|
|
726
|
+
} else {
|
|
727
|
+
die(`unknown flag "${arg}" (allowed: ${allowed})`);
|
|
728
|
+
}
|
|
605
729
|
} else {
|
|
606
730
|
out._.push(arg);
|
|
607
731
|
}
|
|
@@ -609,6 +733,13 @@ function parseFlags(args, valueFlags) {
|
|
|
609
733
|
return out;
|
|
610
734
|
}
|
|
611
735
|
|
|
736
|
+
// Positionals beyond what a command consumes die instead of being silently
|
|
737
|
+
// ignored — `check ./proj` that quietly ignores ./proj is a silent wrong answer.
|
|
738
|
+
function checkPositionalCount(opts, max, what) {
|
|
739
|
+
const extra = opts._[max];
|
|
740
|
+
if (extra !== undefined) die(`unexpected argument "${extra}" — ${what}`);
|
|
741
|
+
}
|
|
742
|
+
|
|
612
743
|
// Canonical marker reader: null when absent or unparseable.
|
|
613
744
|
async function readMarker(projectDir) {
|
|
614
745
|
try {
|
|
@@ -705,7 +836,7 @@ try {
|
|
|
705
836
|
case 'check': await cmdCheck(rest); break;
|
|
706
837
|
case 'verify': await cmdVerify(rest); break;
|
|
707
838
|
case 'map': await cmdMap(rest); break;
|
|
708
|
-
case 'list': await cmdList(); break;
|
|
839
|
+
case 'list': await cmdList(rest); break;
|
|
709
840
|
case 'version': case '--version': case '-v': SAY(VERSION); break;
|
|
710
841
|
case 'help': case undefined: case '--help': case '-h': help(); break;
|
|
711
842
|
default: die(`unknown command "${command}" — run: idea2prototype help`);
|
|
@@ -15,7 +15,7 @@ async function loadApiRoutes() {
|
|
|
15
15
|
try {
|
|
16
16
|
files = await readdir(ROUTES_DIR);
|
|
17
17
|
} catch {
|
|
18
|
-
return routes;
|
|
18
|
+
return routes;
|
|
19
19
|
}
|
|
20
20
|
for (const file of files.filter((f) => f.endsWith('.mjs')).sort()) {
|
|
21
21
|
const mod = await import(pathToFileURL(join(ROUTES_DIR, file)).href);
|
|
@@ -41,22 +41,42 @@ function matchRoute(route, url) {
|
|
|
41
41
|
function readRawBody(req, limit = 10e6) {
|
|
42
42
|
return new Promise((resolve, reject) => {
|
|
43
43
|
let size = 0;
|
|
44
|
+
let tooLarge = false;
|
|
44
45
|
const chunks = [];
|
|
45
46
|
req.on('data', (chunk) => {
|
|
46
47
|
size += chunk.length;
|
|
48
|
+
// Oversize policy: drain up to 2x limit so a clean 413 can still be
|
|
49
|
+
// delivered at end(); past 2x, destroy the socket to bound the cost of
|
|
50
|
+
// absorbing junk — those clients see a connection reset, deliberately.
|
|
47
51
|
if (size > limit) {
|
|
48
|
-
|
|
49
|
-
|
|
52
|
+
tooLarge = true;
|
|
53
|
+
if (size > limit * 2) {
|
|
54
|
+
req.destroy();
|
|
55
|
+
const err = new Error('body too large');
|
|
56
|
+
err.code = 'BODY_TOO_LARGE';
|
|
57
|
+
reject(err);
|
|
58
|
+
}
|
|
50
59
|
} else {
|
|
51
60
|
chunks.push(chunk);
|
|
52
61
|
}
|
|
53
62
|
});
|
|
54
|
-
req.on('end', () =>
|
|
63
|
+
req.on('end', () => {
|
|
64
|
+
if (tooLarge) {
|
|
65
|
+
const err = new Error('body too large');
|
|
66
|
+
err.code = 'BODY_TOO_LARGE';
|
|
67
|
+
reject(err);
|
|
68
|
+
} else {
|
|
69
|
+
resolve(Buffer.concat(chunks));
|
|
70
|
+
}
|
|
71
|
+
});
|
|
55
72
|
req.on('error', reject);
|
|
56
73
|
});
|
|
57
74
|
}
|
|
58
75
|
|
|
59
76
|
function sendJson(res, status, payload, headers = {}) {
|
|
77
|
+
// Handlers are user code — a bogus status must degrade to a clean 500, not
|
|
78
|
+
// throw inside writeHead and leak Node internals ("Invalid status code: b").
|
|
79
|
+
if (!Number.isInteger(status) || status < 100 || status > 599) status = 500;
|
|
60
80
|
if (status === 204 || status === 304) {
|
|
61
81
|
res.writeHead(status, headers);
|
|
62
82
|
return res.end();
|
|
@@ -65,7 +85,9 @@ function sendJson(res, status, payload, headers = {}) {
|
|
|
65
85
|
res.writeHead(status, { 'content-length': payload.length, ...headers });
|
|
66
86
|
return res.end(payload);
|
|
67
87
|
}
|
|
68
|
-
|
|
88
|
+
// [status] with no payload serializes as null — JSON.stringify(undefined)
|
|
89
|
+
// returns undefined and Buffer.byteLength would throw on it.
|
|
90
|
+
const data = JSON.stringify(payload ?? null);
|
|
69
91
|
res.writeHead(status, {
|
|
70
92
|
'content-type': 'application/json; charset=utf-8',
|
|
71
93
|
'content-length': Buffer.byteLength(data),
|
|
@@ -94,7 +116,10 @@ export async function start(port = Number(process.env.PORT ?? 3000)) {
|
|
|
94
116
|
try {
|
|
95
117
|
raw = await readRawBody(req);
|
|
96
118
|
} catch (err) {
|
|
97
|
-
|
|
119
|
+
if (err.code === 'BODY_TOO_LARGE') {
|
|
120
|
+
return sendJson(res, 413, { error: 'body too large' });
|
|
121
|
+
}
|
|
122
|
+
return sendJson(res, 400, { error: String(err?.message ?? err) });
|
|
98
123
|
}
|
|
99
124
|
if ((req.headers['content-type'] ?? '').includes('multipart/form-data')) {
|
|
100
125
|
ctx.rawBody = raw;
|
|
@@ -111,6 +136,12 @@ export async function start(port = Number(process.env.PORT ?? 3000)) {
|
|
|
111
136
|
// ctx.res (e.g. Server-Sent Events) returns nothing — the stream
|
|
112
137
|
// stays open past this request; don't try to send a JSON response.
|
|
113
138
|
if (res.headersSent || res.writableEnded) return;
|
|
139
|
+
// Contract-violating returns (a bare string, undefined, ...) must be
|
|
140
|
+
// a clean 500 — destructuring them would leak parser plumbing
|
|
141
|
+
// ("result is not iterable") into the response.
|
|
142
|
+
if (!Array.isArray(result)) {
|
|
143
|
+
return sendJson(res, 500, { error: 'handler returned an invalid result (expected [status, payload, headers?])' });
|
|
144
|
+
}
|
|
114
145
|
const [status, payload, headers] = result;
|
|
115
146
|
return sendJson(res, status, payload, headers);
|
|
116
147
|
}
|
|
@@ -1,26 +1,56 @@
|
|
|
1
1
|
// Idea2Prototype feature: api-crud — resource "{{resource}}"
|
|
2
2
|
// Contract: handlers return [status, payload]; the server sends payload as JSON.
|
|
3
3
|
// Persistence: JSON file at data/{{resource}}.json (created on first write).
|
|
4
|
-
import { mkdir, readFile, writeFile } from 'node:fs/promises';
|
|
4
|
+
import { mkdir, readFile, rename, writeFile } from 'node:fs/promises';
|
|
5
5
|
import { join } from 'node:path';
|
|
6
6
|
import { randomUUID } from 'node:crypto';
|
|
7
7
|
|
|
8
8
|
const STORE_PATH = join(process.cwd(), 'data', '{{resource}}.json');
|
|
9
|
-
let
|
|
9
|
+
let ready = null; // memoized cold load: concurrent first callers share ONE array
|
|
10
10
|
|
|
11
|
-
|
|
12
|
-
if (
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
11
|
+
function load() {
|
|
12
|
+
if (!ready) {
|
|
13
|
+
ready = (async () => {
|
|
14
|
+
let cache = null;
|
|
15
|
+
let raw = null;
|
|
16
|
+
try {
|
|
17
|
+
raw = await readFile(STORE_PATH, 'utf8');
|
|
18
|
+
} catch (err) {
|
|
19
|
+
if (err?.code !== 'ENOENT') console.error(`[{{resource}}] could not read data store (${err?.code ?? err}) — starting empty`);
|
|
20
|
+
}
|
|
21
|
+
if (raw !== null) {
|
|
22
|
+
try {
|
|
23
|
+
cache = JSON.parse(raw);
|
|
24
|
+
} catch {
|
|
25
|
+
cache = null;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
if (raw === null || cache === null || !Array.isArray(cache)) {
|
|
29
|
+
// A store that exists but is corrupt (unparseable, or not a list) must
|
|
30
|
+
// never be silently wiped — the next persist() would overwrite data
|
|
31
|
+
// that may still be recoverable. Quarantine it, say so loudly, start
|
|
32
|
+
// empty. If even the quarantine rename fails, do NOT pretend it is
|
|
33
|
+
// safe: the original file WILL be overwritten by the next persist.
|
|
34
|
+
if (raw !== null) {
|
|
35
|
+
const quarantine = `${STORE_PATH}.corrupt-${Date.now().toString(36)}`;
|
|
36
|
+
try {
|
|
37
|
+
await rename(STORE_PATH, quarantine);
|
|
38
|
+
console.error(`[{{resource}}] data store is not a valid JSON list — quarantined as ${quarantine}; starting empty`);
|
|
39
|
+
} catch (err) {
|
|
40
|
+
console.error(`[{{resource}}] data store is corrupt AND could not be quarantined (${err?.code ?? err}) — the next write will overwrite it`);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
cache = [];
|
|
44
|
+
}
|
|
45
|
+
return cache;
|
|
46
|
+
})();
|
|
17
47
|
}
|
|
18
|
-
return
|
|
48
|
+
return ready;
|
|
19
49
|
}
|
|
20
50
|
|
|
21
51
|
async function persist() {
|
|
22
52
|
await mkdir(join(process.cwd(), 'data'), { recursive: true });
|
|
23
|
-
await writeFile(STORE_PATH, JSON.stringify(
|
|
53
|
+
await writeFile(STORE_PATH, JSON.stringify(await load(), null, 2));
|
|
24
54
|
}
|
|
25
55
|
|
|
26
56
|
function requireObject(body) {
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
// Compose into other routes: import { requireAuth } from './auth.mjs'
|
|
4
4
|
// methods: { GET: requireAuth(async (ctx) => [200, { hello: ctx.user.username }]) }
|
|
5
5
|
import { createHmac, randomUUID, scryptSync, timingSafeEqual } from 'node:crypto';
|
|
6
|
-
import { mkdir, readFile, writeFile } from 'node:fs/promises';
|
|
6
|
+
import { mkdir, readFile, rename, writeFile } from 'node:fs/promises';
|
|
7
7
|
import { join } from 'node:path';
|
|
8
8
|
|
|
9
9
|
const USERS_FILE = join(process.cwd(), 'data', 'users.json');
|
|
@@ -11,22 +11,51 @@ const SECRET_FILE = join(process.cwd(), 'data', '.auth-secret');
|
|
|
11
11
|
const COOKIE_NAME = 'i2p_session';
|
|
12
12
|
const SESSION_TTL_MS = 24 * 60 * 60 * 1000;
|
|
13
13
|
|
|
14
|
-
let
|
|
14
|
+
let usersReady = null; // memoized cold load: concurrent first callers share ONE array
|
|
15
15
|
let secretCache = null;
|
|
16
16
|
|
|
17
|
-
|
|
18
|
-
if (
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
17
|
+
function loadUsers() {
|
|
18
|
+
if (!usersReady) {
|
|
19
|
+
usersReady = (async () => {
|
|
20
|
+
let usersCache = null;
|
|
21
|
+
let raw = null;
|
|
22
|
+
try {
|
|
23
|
+
raw = await readFile(USERS_FILE, 'utf8');
|
|
24
|
+
} catch (err) {
|
|
25
|
+
if (err?.code !== 'ENOENT') console.error(`[auth] could not read users store (${err?.code ?? err}) — starting empty`);
|
|
26
|
+
}
|
|
27
|
+
if (raw !== null) {
|
|
28
|
+
try {
|
|
29
|
+
usersCache = JSON.parse(raw);
|
|
30
|
+
} catch {
|
|
31
|
+
usersCache = null;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
if (raw === null || usersCache === null || !Array.isArray(usersCache)) {
|
|
35
|
+
// A users store that exists but is corrupt must never be silently
|
|
36
|
+
// wiped — the next register would overwrite every account. Quarantine,
|
|
37
|
+
// say so, start empty (accounts can be re-registered; the old file is
|
|
38
|
+
// preserved). If the rename itself fails, say THAT loudly too.
|
|
39
|
+
if (raw !== null) {
|
|
40
|
+
const quarantine = `${USERS_FILE}.corrupt-${Date.now().toString(36)}`;
|
|
41
|
+
try {
|
|
42
|
+
await rename(USERS_FILE, quarantine);
|
|
43
|
+
console.error(`[auth] users store is not a valid JSON list — quarantined as ${quarantine}; starting empty`);
|
|
44
|
+
} catch (err) {
|
|
45
|
+
console.error(`[auth] users store is corrupt AND could not be quarantined (${err?.code ?? err}) — the next write will overwrite it`);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
usersCache = [];
|
|
49
|
+
}
|
|
50
|
+
return usersCache;
|
|
51
|
+
})();
|
|
23
52
|
}
|
|
24
|
-
return
|
|
53
|
+
return usersReady;
|
|
25
54
|
}
|
|
26
55
|
|
|
27
56
|
async function persistUsers() {
|
|
28
57
|
await mkdir(join(process.cwd(), 'data'), { recursive: true });
|
|
29
|
-
await writeFile(USERS_FILE, JSON.stringify(
|
|
58
|
+
await writeFile(USERS_FILE, JSON.stringify(await loadUsers(), null, 2));
|
|
30
59
|
}
|
|
31
60
|
|
|
32
61
|
async function getSecret() {
|
|
@@ -147,6 +176,11 @@ export default [
|
|
|
147
176
|
{
|
|
148
177
|
path: '/api/auth/logout',
|
|
149
178
|
methods: {
|
|
179
|
+
// Clears the BROWSER cookie only. Session tokens are stateless HMAC —
|
|
180
|
+
// there is no server-side session store, so a token copied before
|
|
181
|
+
// logout keeps authenticating until it expires (SESSION_TTL_MS).
|
|
182
|
+
// Anything user-facing on top of this demo should shorten the TTL or
|
|
183
|
+
// add a server-side revocation list.
|
|
150
184
|
POST: async () => [200, { ok: true }, { 'set-cookie': clearedCookie() }],
|
|
151
185
|
},
|
|
152
186
|
},
|
|
@@ -3,9 +3,7 @@
|
|
|
3
3
|
// Compose into any route handler: import { rateLimit } from './ratelimit.mjs'
|
|
4
4
|
// methods: { GET: rateLimit({ max: 10, windowMs: 60_000 })(async (ctx) => [200, { ok: true }]) }
|
|
5
5
|
|
|
6
|
-
const buckets = new Map(); // key -> { count, resetAt }
|
|
7
6
|
const CLEANUP_INTERVAL_MS = 60_000;
|
|
8
|
-
let lastCleanup = 0;
|
|
9
7
|
|
|
10
8
|
/**
|
|
11
9
|
* Wrap a route handler with a per-key fixed-window limiter.
|
|
@@ -15,8 +13,12 @@ let lastCleanup = 0;
|
|
|
15
13
|
* e.g. keyFn: (ctx) => ctx.req.headers['x-forwarded-for'] ?? 'local'.
|
|
16
14
|
* Over-limit requests get 429 + Retry-After; allowed ones pass
|
|
17
15
|
* through with X-RateLimit-* headers merged into the response.
|
|
16
|
+
* Each rateLimit({...}) instance owns its OWN buckets: independently
|
|
17
|
+
* configured wrappers never drain each other's budget.
|
|
18
18
|
*/
|
|
19
19
|
export function rateLimit({ max = 60, windowMs = 60_000, keyFn } = {}) {
|
|
20
|
+
const buckets = new Map(); // key -> { count, resetAt }
|
|
21
|
+
let lastCleanup = 0;
|
|
20
22
|
return (handler) => async (ctx) => {
|
|
21
23
|
const key = keyFn ? keyFn(ctx) : (ctx.req?.socket?.remoteAddress ?? 'local');
|
|
22
24
|
const now = Date.now();
|
|
@@ -3,8 +3,8 @@ import assert from 'node:assert/strict';
|
|
|
3
3
|
import { start } from '../server.mjs';
|
|
4
4
|
import { rateLimit } from '../routes/ratelimit.mjs';
|
|
5
5
|
|
|
6
|
-
// NOTE:
|
|
7
|
-
//
|
|
6
|
+
// NOTE: each rateLimit({...}) instance owns its own buckets (the map lives
|
|
7
|
+
// inside the factory) — that independence is pinned by the third test.
|
|
8
8
|
|
|
9
9
|
test('GET /api/ping allows max then returns 429 with limit headers', async (t) => {
|
|
10
10
|
const app = await start(0);
|
|
@@ -46,3 +46,23 @@ test('rateLimit: custom keyFn isolates clients into separate buckets', async ()
|
|
|
46
46
|
const retryAfter = Number(second[2]['Retry-After']);
|
|
47
47
|
assert.ok(retryAfter >= 59 && retryAfter <= 60, `unexpected Retry-After ${retryAfter}`);
|
|
48
48
|
});
|
|
49
|
+
|
|
50
|
+
test('rateLimit: independently configured wrappers never share budget', async () => {
|
|
51
|
+
// exhaust a tight limiter, then prove a loose limiter's FIRST request
|
|
52
|
+
// still sees a full budget (module-level buckets used to leak the count
|
|
53
|
+
// across wrappers — cross-route lockout)
|
|
54
|
+
const tight = rateLimit({ max: 1, windowMs: 60_000, keyFn: (ctx) => ctx.key })(
|
|
55
|
+
async () => [200, { which: 'tight' }],
|
|
56
|
+
);
|
|
57
|
+
const loose = rateLimit({ max: 50, windowMs: 60_000, keyFn: (ctx) => ctx.key })(
|
|
58
|
+
async () => [200, { which: 'loose' }],
|
|
59
|
+
);
|
|
60
|
+
|
|
61
|
+
await tight({ key: 'carol', req: {} }); // tight budget spent
|
|
62
|
+
assert.equal((await tight({ key: 'carol', req: {} }))[0], 429);
|
|
63
|
+
|
|
64
|
+
const [status, , headers] = await loose({ key: 'carol', req: {} }); // same client, other route
|
|
65
|
+
assert.equal(status, 200); // NOT locked out by the tight wrapper
|
|
66
|
+
assert.equal(headers['X-RateLimit-Limit'], '50');
|
|
67
|
+
assert.equal(headers['X-RateLimit-Remaining'], '49'); // full budget, not 48
|
|
68
|
+
});
|
|
@@ -19,11 +19,31 @@ let ready = null; // memoized cold load: concurrent first callers share ONE arra
|
|
|
19
19
|
function load() {
|
|
20
20
|
if (!ready) {
|
|
21
21
|
ready = (async () => {
|
|
22
|
+
let raw = null;
|
|
22
23
|
try {
|
|
23
|
-
|
|
24
|
+
raw = await readFile(STORE_PATH, 'utf8');
|
|
24
25
|
} catch (err) {
|
|
25
|
-
if (err?.code !== 'ENOENT') {
|
|
26
|
-
|
|
26
|
+
if (err?.code !== 'ENOENT') console.error(`[jobs] could not read store (${err?.code ?? err}) — starting empty`);
|
|
27
|
+
}
|
|
28
|
+
if (raw !== null) {
|
|
29
|
+
try {
|
|
30
|
+
jobs = JSON.parse(raw);
|
|
31
|
+
} catch {
|
|
32
|
+
jobs = null;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
if (raw === null || jobs === null || !Array.isArray(jobs)) {
|
|
36
|
+
// A store that exists but is corrupt must never be silently wiped —
|
|
37
|
+
// the next persist would overwrite recoverable job history. Quarantine
|
|
38
|
+
// it, say so loudly, start empty.
|
|
39
|
+
if (raw !== null) {
|
|
40
|
+
const quarantine = `${STORE_PATH}.corrupt-${Date.now().toString(36)}`;
|
|
41
|
+
try {
|
|
42
|
+
await rename(STORE_PATH, quarantine);
|
|
43
|
+
console.error(`[jobs] store is not a valid JSON list — quarantined as ${quarantine}; starting empty`);
|
|
44
|
+
} catch (err) {
|
|
45
|
+
console.error(`[jobs] store is corrupt AND could not be quarantined (${err?.code ?? err}) — the next write will overwrite it`);
|
|
46
|
+
}
|
|
27
47
|
}
|
|
28
48
|
jobs = [];
|
|
29
49
|
}
|
|
@@ -52,22 +52,42 @@ function matchRoute(route, url) {
|
|
|
52
52
|
function readRawBody(req, limit = 10e6) {
|
|
53
53
|
return new Promise((resolve, reject) => {
|
|
54
54
|
let size = 0;
|
|
55
|
+
let tooLarge = false;
|
|
55
56
|
const chunks = [];
|
|
56
57
|
req.on('data', (chunk) => {
|
|
57
58
|
size += chunk.length;
|
|
59
|
+
// Oversize policy: drain up to 2x limit so a clean 413 can still be
|
|
60
|
+
// delivered at end(); past 2x, destroy the socket to bound the cost of
|
|
61
|
+
// absorbing junk — those clients see a connection reset, deliberately.
|
|
58
62
|
if (size > limit) {
|
|
59
|
-
|
|
60
|
-
|
|
63
|
+
tooLarge = true;
|
|
64
|
+
if (size > limit * 2) {
|
|
65
|
+
req.destroy();
|
|
66
|
+
const err = new Error('body too large');
|
|
67
|
+
err.code = 'BODY_TOO_LARGE';
|
|
68
|
+
reject(err);
|
|
69
|
+
}
|
|
61
70
|
} else {
|
|
62
71
|
chunks.push(chunk);
|
|
63
72
|
}
|
|
64
73
|
});
|
|
65
|
-
req.on('end', () =>
|
|
74
|
+
req.on('end', () => {
|
|
75
|
+
if (tooLarge) {
|
|
76
|
+
const err = new Error('body too large');
|
|
77
|
+
err.code = 'BODY_TOO_LARGE';
|
|
78
|
+
reject(err);
|
|
79
|
+
} else {
|
|
80
|
+
resolve(Buffer.concat(chunks));
|
|
81
|
+
}
|
|
82
|
+
});
|
|
66
83
|
req.on('error', reject);
|
|
67
84
|
});
|
|
68
85
|
}
|
|
69
86
|
|
|
70
87
|
function sendJson(res, status, payload, headers = {}) {
|
|
88
|
+
// Handlers are user code — a bogus status must degrade to a clean 500, not
|
|
89
|
+
// throw inside writeHead and leak Node internals ("Invalid status code: b").
|
|
90
|
+
if (!Number.isInteger(status) || status < 100 || status > 599) status = 500;
|
|
71
91
|
if (status === 204 || status === 304) {
|
|
72
92
|
res.writeHead(status, headers);
|
|
73
93
|
return res.end();
|
|
@@ -76,7 +96,9 @@ function sendJson(res, status, payload, headers = {}) {
|
|
|
76
96
|
res.writeHead(status, { 'content-length': payload.length, ...headers });
|
|
77
97
|
return res.end(payload);
|
|
78
98
|
}
|
|
79
|
-
|
|
99
|
+
// [status] with no payload serializes as null — JSON.stringify(undefined)
|
|
100
|
+
// returns undefined and Buffer.byteLength would throw on it.
|
|
101
|
+
const data = JSON.stringify(payload ?? null);
|
|
80
102
|
res.writeHead(status, {
|
|
81
103
|
'content-type': 'application/json; charset=utf-8',
|
|
82
104
|
'content-length': Buffer.byteLength(data),
|
|
@@ -105,7 +127,10 @@ export async function start(port = Number(process.env.PORT ?? 3000)) {
|
|
|
105
127
|
try {
|
|
106
128
|
raw = await readRawBody(req);
|
|
107
129
|
} catch (err) {
|
|
108
|
-
|
|
130
|
+
if (err.code === 'BODY_TOO_LARGE') {
|
|
131
|
+
return sendJson(res, 413, { error: 'body too large' });
|
|
132
|
+
}
|
|
133
|
+
return sendJson(res, 400, { error: String(err?.message ?? err) });
|
|
109
134
|
}
|
|
110
135
|
if ((req.headers['content-type'] ?? '').includes('multipart/form-data')) {
|
|
111
136
|
ctx.rawBody = raw;
|
|
@@ -122,6 +147,12 @@ export async function start(port = Number(process.env.PORT ?? 3000)) {
|
|
|
122
147
|
// ctx.res (e.g. Server-Sent Events) returns nothing — the stream
|
|
123
148
|
// stays open past this request; don't try to send a JSON response.
|
|
124
149
|
if (res.headersSent || res.writableEnded) return;
|
|
150
|
+
// Contract-violating returns (a bare string, undefined, ...) must be
|
|
151
|
+
// a clean 500 — destructuring them would leak parser plumbing
|
|
152
|
+
// ("result is not iterable") into the response.
|
|
153
|
+
if (!Array.isArray(result)) {
|
|
154
|
+
return sendJson(res, 500, { error: 'handler returned an invalid result (expected [status, payload, headers?])' });
|
|
155
|
+
}
|
|
125
156
|
const [status, payload, headers] = result;
|
|
126
157
|
return sendJson(res, status, payload, headers);
|
|
127
158
|
}
|
|
@@ -14,11 +14,31 @@ let ready = null; // memoized cold load: concurrent first callers share ONE arra
|
|
|
14
14
|
function load() {
|
|
15
15
|
if (!ready) {
|
|
16
16
|
ready = (async () => {
|
|
17
|
+
let raw = null;
|
|
17
18
|
try {
|
|
18
|
-
|
|
19
|
+
raw = await readFile(STORE_PATH, 'utf8');
|
|
19
20
|
} catch (err) {
|
|
20
|
-
if (err?.code !== 'ENOENT') {
|
|
21
|
-
|
|
21
|
+
if (err?.code !== 'ENOENT') console.error(`[jobs] could not read store (${err?.code ?? err}) — starting empty`);
|
|
22
|
+
}
|
|
23
|
+
if (raw !== null) {
|
|
24
|
+
try {
|
|
25
|
+
jobs = JSON.parse(raw);
|
|
26
|
+
} catch {
|
|
27
|
+
jobs = null;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
if (raw === null || jobs === null || !Array.isArray(jobs)) {
|
|
31
|
+
// A store that exists but is corrupt must never be silently wiped —
|
|
32
|
+
// the next persist would overwrite recoverable job history. Quarantine
|
|
33
|
+
// it, say so loudly, start empty.
|
|
34
|
+
if (raw !== null) {
|
|
35
|
+
const quarantine = `${STORE_PATH}.corrupt-${Date.now().toString(36)}`;
|
|
36
|
+
try {
|
|
37
|
+
await rename(STORE_PATH, quarantine);
|
|
38
|
+
console.error(`[jobs] store is not a valid JSON list — quarantined as ${quarantine}; starting empty`);
|
|
39
|
+
} catch (err) {
|
|
40
|
+
console.error(`[jobs] store is corrupt AND could not be quarantined (${err?.code ?? err}) — the next write will overwrite it`);
|
|
41
|
+
}
|
|
22
42
|
}
|
|
23
43
|
jobs = [];
|
|
24
44
|
}
|