@ahmed-3m/idea2prototype 1.0.0 → 1.0.1
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 +13 -0
- package/README.md +4 -2
- package/agents/AGENTS.md +2 -0
- package/agents/CLAUDE.md +2 -0
- package/package.json +1 -1
- package/skills/idea2prototype/SKILL.md +2 -2
- package/skills/idea2prototype/scripts/idea2prototype.mjs +122 -60
- package/skills/idea2prototype/templates/api/.idea2prototype.json +1 -1
- package/skills/idea2prototype/templates/cli/.idea2prototype.json +1 -1
- package/skills/idea2prototype/templates/features/api-crud/route.tmpl.mjs +4 -2
- package/skills/idea2prototype/templates/features/api-crud/test.tmpl.mjs +21 -0
- package/skills/idea2prototype/templates/features/upload/route.tmpl.mjs +22 -10
- package/skills/idea2prototype/templates/features/upload/test.tmpl.mjs +27 -0
- package/skills/idea2prototype/templates/library/.idea2prototype.json +1 -1
- package/skills/idea2prototype/templates/web/.idea2prototype.json +1 -1
- package/skills/idea2prototype/templates/worker/.idea2prototype.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,18 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 1.0.1
|
|
4
|
+
|
|
5
|
+
Fixes for the critical + high findings of the 2026-10-20 repo review:
|
|
6
|
+
|
|
7
|
+
- Security: `add api-crud` refuses resource names another feature owns (`users`, `auth`, `jobs`, `uploads`, `events`, `sse`, `ping`, `ratelimit`, `cors`, `cors-ping`, `health`). `--resource users` used to share `data/users.json` with `auth-local` — anonymous `GET /api/users` served every salt + hash, and an anonymous POST could plant a login-capable account.
|
|
8
|
+
- Security: `upload` serves only passive types inline (text, csv, json, raster images); everything else — including html, svg, js — comes back as `application/octet-stream` + `content-disposition: attachment`, and every response carries `x-content-type-options: nosniff` + a sandboxing CSP. Uploaded HTML used to render on the app's own origin (stored XSS).
|
|
9
|
+
- Fix: `verify`/`check` boot the server even when no tests exist — a `start()` that throws used to verify PASS on syntax alone. Boot still skips after a syntax failure (structured `cause` on the tests step, no string coupling).
|
|
10
|
+
- Fix: `verify` accepts a foreign `start()` that returns a bare `http.Server` (port via `address()`, callback-style close) and never lets a throwing/hanging `close()` replace the verdict — it crashed with `app.close(...).catch is not a function` before printing anything.
|
|
11
|
+
- Fix: boot runs with the project as cwd (as the tests step already did), so `verify <dir>` from elsewhere boots the same app as `verify` from inside — scaffolds used to boot with no routes and no `public/`, foreign servers failed on cwd-relative reads.
|
|
12
|
+
- Fix: `api-crud` POST ignores client-sent `id`/`createdAt` (two POSTs with the same `id` created duplicates, the second unreachable); PUT keeps `createdAt` too. SKILL.md/README composition snippets follow the same body-first pattern.
|
|
13
|
+
- Tests: regression test per finding above (engine suite + the generated template tests that every scaffold runs). The review's seventh finding — `rateLimit()` sharing one bucket map — was fixed independently on main and shipped in 1.0.0.
|
|
14
|
+
- Fix: the upload route's path guard checks the separator boundary (`uploads` + `/`) — a sibling directory sharing the prefix (`uploads-x/`) used to pass the traversal check (flagged in review; served inert either way).
|
|
15
|
+
|
|
3
16
|
## 1.0.0
|
|
4
17
|
|
|
5
18
|
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.
|
package/README.md
CHANGED
|
@@ -15,7 +15,9 @@ Idea2Prototype check — PASS (3/3)
|
|
|
15
15
|
boot PASS GET /api/health -> 200, GET / -> 200 in 45ms
|
|
16
16
|
```
|
|
17
17
|
|
|
18
|
-
**Status:** v1.0.
|
|
18
|
+
**Status:** v1.0.1 · actively maintained · 1.x — the check/verify contract and template shapes are stable; new capabilities arrive as `add` modules.
|
|
19
|
+
|
|
20
|
+
> **Idea still fuzzy?** Start one stage earlier with [brainstorm2idea](https://github.com/ahmed-3m/brainstorm2idea) — a Socratic dialogue turns raw thought into a machine-linted `brief.md`, then hands off directly into `i2p new`.
|
|
19
21
|
|
|
20
22
|
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
23
|
|
|
@@ -94,7 +96,7 @@ import { requireAuth } from './auth.mjs';
|
|
|
94
96
|
|
|
95
97
|
POST: requireAuth(async ({ body, user }) => {
|
|
96
98
|
// ...original handler body...
|
|
97
|
-
const item = { id, createdAt,
|
|
99
|
+
const item = { ...body, id, createdAt, author: user.username }; // body first: server-owned fields win
|
|
98
100
|
// ...persist and return [201, item]...
|
|
99
101
|
}),
|
|
100
102
|
```
|
package/agents/AGENTS.md
CHANGED
|
@@ -4,6 +4,8 @@ Copy this file into any project where your coding agent should build with idea2p
|
|
|
4
4
|
|
|
5
5
|
You have `idea2prototype` (`i2p`) available — a zero-dependency prototype tool. Use it instead of hand-writing scaffolding. If the npm package isn't installed or available, run the engine directly instead of `npx @ahmed-3m/idea2prototype`: `node <path-to-idea2prototype-repo>/skills/idea2prototype/scripts/idea2prototype.mjs <command>`.
|
|
6
6
|
|
|
7
|
+
If starting from a fuzzy or unformed idea, load the `brainstorm2idea` skill and run `/b2i "<raw idea>"` to generate an approved `brief.md`, then run `b2i handoff <dir> --brief brief.md` (or `npx @ahmed-3m/brainstorm2idea handoff <dir>`) to scaffold directly into `idea2prototype new`.
|
|
8
|
+
|
|
7
9
|
## Commands
|
|
8
10
|
|
|
9
11
|
- `npx @ahmed-3m/idea2prototype new <dir> --template web|api|cli|worker|library` — scaffold a running, tested project (~0 tokens vs 10–30k hand-written)
|
package/agents/CLAUDE.md
CHANGED
|
@@ -4,6 +4,8 @@ Copy this file into any project where your coding agent should build with idea2p
|
|
|
4
4
|
|
|
5
5
|
You have `idea2prototype` (`i2p`) available — a zero-dependency prototype tool. Use it instead of hand-writing scaffolding. If the npm package isn't installed or available, run the engine directly instead of `npx @ahmed-3m/idea2prototype`: `node <path-to-idea2prototype-repo>/skills/idea2prototype/scripts/idea2prototype.mjs <command>`.
|
|
6
6
|
|
|
7
|
+
If starting from a fuzzy or unformed idea, load the `brainstorm2idea` skill and run `/b2i "<raw idea>"` to generate an approved `brief.md`, then run `b2i handoff <dir> --brief brief.md` (or `npx @ahmed-3m/brainstorm2idea handoff <dir>`) to scaffold directly into `idea2prototype new`.
|
|
8
|
+
|
|
7
9
|
## Commands
|
|
8
10
|
|
|
9
11
|
- `npx @ahmed-3m/idea2prototype new <dir> --template web|api|cli|worker|library` — scaffold a running, tested project (~0 tokens vs 10–30k hand-written)
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ahmed-3m/idea2prototype",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.1",
|
|
4
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",
|
|
@@ -18,7 +18,7 @@ node "<skill-base>/scripts/idea2prototype.mjs" <command>
|
|
|
18
18
|
`<skill-base>` is this skill's own directory (shown at the top of this skill when loaded). Commands:
|
|
19
19
|
|
|
20
20
|
- `new <dir> --template web|api|cli|worker|library --name "..." --idea "..."` — scaffold a project
|
|
21
|
-
- `add api-crud --resource <name>` — add a JSON-file CRUD resource + tests (web/api)
|
|
21
|
+
- `add api-crud --resource <name>` — add a JSON-file CRUD resource + tests (web/api). Names another feature owns are refused (`users`, `auth`, `jobs`, `uploads`, `events`, `ping`, …) — e.g. use `members`, not `users`, next to `auth-local`
|
|
22
22
|
- `add auth-local` — register/login/logout/me accounts: scrypt hashes, HMAC-signed cookies, `requireAuth` helper (web/api)
|
|
23
23
|
- `add upload` — multipart file uploads with fetch-back endpoint + tests (web/api)
|
|
24
24
|
- `add rate-limit` — per-key fixed-window limiter: `rateLimit({ max, windowMs, keyFn })` wraps any handler, 429 + Retry-After + X-RateLimit-* headers, `GET /api/ping` demo (web/api)
|
|
@@ -52,7 +52,7 @@ import { requireAuth } from './auth.mjs';
|
|
|
52
52
|
POST: requireAuth(async ({ body, user }) => {
|
|
53
53
|
const bad = requireObject(body);
|
|
54
54
|
if (bad) return bad;
|
|
55
|
-
const item = { id: randomUUID(), createdAt: new Date().toISOString(),
|
|
55
|
+
const item = { ...body, id: randomUUID(), createdAt: new Date().toISOString(), author: user.username };
|
|
56
56
|
(await load()).push(item);
|
|
57
57
|
await persist();
|
|
58
58
|
return [201, item];
|
|
@@ -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 = '1.0.
|
|
21
|
+
const VERSION = '1.0.1';
|
|
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');
|
|
@@ -227,6 +227,25 @@ const FEATURES = {
|
|
|
227
227
|
},
|
|
228
228
|
};
|
|
229
229
|
|
|
230
|
+
// api-crud resource names that would collide with another feature's data
|
|
231
|
+
// store, route file, or URL path — in EITHER add order, so a file-exists
|
|
232
|
+
// check alone can't catch them. "users" is the dangerous one: a CRUD resource
|
|
233
|
+
// over data/users.json serves auth-local's salts + hashes to anonymous GETs
|
|
234
|
+
// and lets anonymous POSTs plant login-capable accounts.
|
|
235
|
+
const RESERVED_RESOURCES = new Map([
|
|
236
|
+
['users', 'auth-local stores accounts in data/users.json'],
|
|
237
|
+
['auth', 'auth-local owns routes/auth.mjs and /api/auth/*'],
|
|
238
|
+
['jobs', 'worker-jobs owns data/jobs.json, routes/jobs.mjs and /api/jobs'],
|
|
239
|
+
['uploads', 'upload owns routes/uploads.mjs and /api/uploads'],
|
|
240
|
+
['events', 'sse owns /api/events'],
|
|
241
|
+
['sse', 'sse owns routes/sse.mjs'],
|
|
242
|
+
['ping', 'rate-limit owns /api/ping'],
|
|
243
|
+
['ratelimit', 'rate-limit owns routes/ratelimit.mjs'],
|
|
244
|
+
['cors', 'cors owns routes/cors.mjs'],
|
|
245
|
+
['cors-ping', 'cors owns /api/cors-ping'],
|
|
246
|
+
['health', 'the server answers /api/health itself'],
|
|
247
|
+
]);
|
|
248
|
+
|
|
230
249
|
async function cmdAdd(rest) {
|
|
231
250
|
const feature = rest[0]?.startsWith('--') ? undefined : rest[0];
|
|
232
251
|
const opts = parseFlags(rest.slice(feature ? 1 : 0), ['resource']);
|
|
@@ -246,6 +265,9 @@ async function cmdAdd(rest) {
|
|
|
246
265
|
if (!/^[a-z][a-z0-9-]*$/.test(resource)) {
|
|
247
266
|
die(`invalid resource name "${resource}" — lowercase letters, digits, dashes`);
|
|
248
267
|
}
|
|
268
|
+
if (RESERVED_RESOURCES.has(resource)) {
|
|
269
|
+
die(`resource name "${resource}" is reserved — ${RESERVED_RESOURCES.get(resource)}; pick another name`);
|
|
270
|
+
}
|
|
249
271
|
opts.resource = resource;
|
|
250
272
|
}
|
|
251
273
|
|
|
@@ -435,14 +457,15 @@ async function discoverTestFiles(projectDir) {
|
|
|
435
457
|
}
|
|
436
458
|
|
|
437
459
|
async function stepTests(projectDir, syntaxPassed) {
|
|
438
|
-
if (!syntaxPassed) return { name: 'tests', status: 'skip', detail: 'skipped (syntax failed)' };
|
|
460
|
+
if (!syntaxPassed) return { name: 'tests', status: 'skip', detail: 'skipped (syntax failed)', cause: 'syntax' };
|
|
439
461
|
const testFiles = (await discoverTestFiles(projectDir)).map((f) => relative(projectDir, f));
|
|
440
462
|
if (!testFiles.length) {
|
|
441
463
|
return {
|
|
442
464
|
name: 'tests',
|
|
443
465
|
status: 'skip',
|
|
444
466
|
detail: 'no test files found',
|
|
445
|
-
|
|
467
|
+
cause: 'no-tests',
|
|
468
|
+
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 covers syntax + boot only'],
|
|
446
469
|
};
|
|
447
470
|
}
|
|
448
471
|
|
|
@@ -521,15 +544,26 @@ async function stepTests(projectDir, syntaxPassed) {
|
|
|
521
544
|
|
|
522
545
|
async function stepBoot(projectDir, testsStep, marker) {
|
|
523
546
|
if (testsStep.status === 'fail') return { name: 'boot', status: 'skip', detail: 'skipped (tests failed)' };
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
//
|
|
531
|
-
|
|
532
|
-
|
|
547
|
+
// A syntax failure is why nothing ran — importing broken code proves nothing.
|
|
548
|
+
if (testsStep.cause === 'syntax') return { name: 'boot', status: 'skip', detail: 'skipped (syntax failed)' };
|
|
549
|
+
// "No tests found" is NOT a reason to skip boot: a project without tests is
|
|
550
|
+
// exactly the one whose only remaining evidence is whether it starts (a
|
|
551
|
+
// server that throws in start() used to verify PASS on syntax alone).
|
|
552
|
+
//
|
|
553
|
+
// Boot runs in THIS process, so the project's cwd-relative paths (the
|
|
554
|
+
// templates' ROOT/routes/data, a foreign readFileSync('config.json')) must
|
|
555
|
+
// resolve against the project — as they do for the tests step (cwd:
|
|
556
|
+
// projectDir) and for `npm start`. Otherwise `verify <dir>` from elsewhere
|
|
557
|
+
// boots a different app than `verify` from inside it.
|
|
558
|
+
const previousCwd = process.cwd();
|
|
559
|
+
process.chdir(projectDir);
|
|
560
|
+
try {
|
|
561
|
+
// Library projects have no server to probe — boot = import the public API.
|
|
562
|
+
if (marker?.template === 'library') return await bootLibrary(projectDir);
|
|
563
|
+
return await bootServer(projectDir, marker);
|
|
564
|
+
} finally {
|
|
565
|
+
process.chdir(previousCwd);
|
|
566
|
+
}
|
|
533
567
|
}
|
|
534
568
|
|
|
535
569
|
async function bootLibrary(projectDir) {
|
|
@@ -553,67 +587,95 @@ async function bootServer(projectDir, marker) {
|
|
|
553
587
|
if (!existsSync(serverPath)) return { name: 'boot', status: 'skip', detail: 'no server.mjs' };
|
|
554
588
|
|
|
555
589
|
const started = Date.now();
|
|
556
|
-
let app; // closed
|
|
557
|
-
// The import ran but no app handle came back
|
|
590
|
+
let app; // closed below once started — a leaked listener hangs the process forever
|
|
591
|
+
// The import ran but no closable app handle came back: the module may hold
|
|
558
592
|
// its own listeners (foreign entry points often listen at import time).
|
|
559
593
|
// Mark the step so runChecks exits explicitly after printing the verdict —
|
|
560
594
|
// those handles are unreachable from here and would hang the process.
|
|
561
595
|
const leakedImport = { unreachableHandles: true };
|
|
596
|
+
let result;
|
|
562
597
|
try {
|
|
563
598
|
const mod = await withTimeout(import(pathToFileURL(serverPath).href), 15_000, 'server import');
|
|
564
599
|
if (typeof mod.start !== 'function') {
|
|
565
600
|
return { name: 'boot', status: 'skip', detail: 'server.mjs does not export start()', ...leakedImport };
|
|
566
601
|
}
|
|
567
602
|
app = await withTimeout(mod.start(0), 10_000, 'server start');
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
if (res.status === 404) {
|
|
578
|
-
res = await probe('/');
|
|
579
|
-
detail = `GET /api/health -> 404, GET / -> ${res.status}`;
|
|
580
|
-
rootProbed = true;
|
|
581
|
-
}
|
|
582
|
-
const body = await res.json().catch(() => ({}));
|
|
583
|
-
|
|
584
|
-
// Scaffolded projects follow the template contract (health returns
|
|
585
|
-
// {"status":"ok"}); foreign projects only have to start and serve.
|
|
586
|
-
const ok = marker
|
|
587
|
-
? res.status === 200 && body.status === 'ok'
|
|
588
|
-
: res.status >= 200 && res.status < 400;
|
|
589
|
-
if (!ok) {
|
|
590
|
-
return { name: 'boot', status: 'fail', detail: `${detail} ${JSON.stringify(body)}` };
|
|
591
|
-
}
|
|
603
|
+
result = await probeServer(app, marker, started);
|
|
604
|
+
} catch (err) {
|
|
605
|
+
result = { name: 'boot', status: 'fail', detail: String(err?.message ?? err) };
|
|
606
|
+
}
|
|
607
|
+
// Not a finally: a close() that throws or never settles must not replace
|
|
608
|
+
// the verdict (foreign handles are untrusted) — it only flags the exit.
|
|
609
|
+
if (!(await closeApp(app))) Object.assign(result, leakedImport);
|
|
610
|
+
return result;
|
|
611
|
+
}
|
|
592
612
|
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
613
|
+
// start(0) may hand back the template contract ({ port, close() }) or, in
|
|
614
|
+
// foreign projects, a bare listening http.Server — read the port from either.
|
|
615
|
+
function appPort(app) {
|
|
616
|
+
return app?.port ?? app?.address?.()?.port ?? app?.server?.address?.()?.port;
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
// Close whatever start() returned: a promise-returning close() (template
|
|
620
|
+
// contract) or a callback-style http.Server close(cb). Returns false when
|
|
621
|
+
// nothing closable came back or the close failed/hung — the caller then
|
|
622
|
+
// exits explicitly instead of hanging on handles it cannot reach.
|
|
623
|
+
async function closeApp(app) {
|
|
624
|
+
if (typeof app?.close !== 'function') return false;
|
|
625
|
+
try {
|
|
626
|
+
app.closeAllConnections?.(); // bare http.Server: drop keep-alive sockets so close(cb) settles
|
|
627
|
+
await withTimeout(new Promise((resolve, reject) => {
|
|
628
|
+
const ret = app.close((err) => (err ? reject(err) : resolve()));
|
|
629
|
+
if (typeof ret?.then === 'function') ret.then(resolve, reject);
|
|
630
|
+
}), 5_000, 'server close');
|
|
631
|
+
return true;
|
|
632
|
+
} catch {
|
|
633
|
+
return false;
|
|
634
|
+
}
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
async function probeServer(app, marker, started) {
|
|
638
|
+
const port = appPort(app);
|
|
639
|
+
if (!Number.isInteger(port)) {
|
|
640
|
+
return { name: 'boot', status: 'fail', detail: 'start() returned no port — expected { port, close() } or a listening http.Server' };
|
|
641
|
+
}
|
|
642
|
+
// redirect: 'manual' — never follow a redirect off the probe target; the
|
|
643
|
+
// 3xx itself counts as serving (foreign ok-range is 200–399).
|
|
644
|
+
const probe = async (path) =>
|
|
645
|
+
withTimeout(fetch(`http://127.0.0.1:${port}${path}`, { redirect: 'manual' }), 10_000, `${path} request`);
|
|
646
|
+
let res = await probe('/api/health');
|
|
647
|
+
let detail = `GET /api/health -> ${res.status}`;
|
|
648
|
+
// Foreign projects (verify mode) may not implement our template's health
|
|
649
|
+
// route — a server that starts and serves its own surface still boots.
|
|
650
|
+
let rootProbed = false;
|
|
651
|
+
if (res.status === 404) {
|
|
652
|
+
res = await probe('/');
|
|
653
|
+
detail = `GET /api/health -> 404, GET / -> ${res.status}`;
|
|
654
|
+
rootProbed = true;
|
|
655
|
+
}
|
|
656
|
+
const body = await res.json().catch(() => ({}));
|
|
657
|
+
|
|
658
|
+
// Scaffolded projects follow the template contract (health returns
|
|
659
|
+
// {"status":"ok"}); foreign projects only have to start and serve.
|
|
660
|
+
const ok = marker
|
|
661
|
+
? res.status === 200 && body.status === 'ok'
|
|
662
|
+
: res.status >= 200 && res.status < 400;
|
|
663
|
+
if (!ok) {
|
|
664
|
+
return { name: 'boot', status: 'fail', detail: `${detail} ${JSON.stringify(body)}` };
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
// #18: a health route alone can certify an app whose every real route
|
|
668
|
+
// 500s — also probe the root. 4xx on / is legitimate (API-only projects),
|
|
669
|
+
// a 5xx root is a real defect. Symmetric for scaffolded and foreign
|
|
670
|
+
// projects; a request error here surfaces via bootServer's catch.
|
|
671
|
+
if (!rootProbed) {
|
|
672
|
+
const root = await probe('/');
|
|
673
|
+
detail += `, GET / -> ${root.status}`;
|
|
674
|
+
if (root.status >= 500) {
|
|
675
|
+
return { name: 'boot', status: 'fail', detail: `${detail} — root serves an error` };
|
|
603
676
|
}
|
|
604
|
-
return { name: 'boot', status: 'pass', detail: `${detail} in ${Date.now() - started}ms` };
|
|
605
|
-
} catch (err) {
|
|
606
|
-
// Import or start() threw before app was assigned → nothing was closed.
|
|
607
|
-
// (Probe failures after a successful start still run the finally close.)
|
|
608
|
-
return {
|
|
609
|
-
name: 'boot',
|
|
610
|
-
status: 'fail',
|
|
611
|
-
detail: String(err?.message ?? err),
|
|
612
|
-
...(app ? {} : leakedImport),
|
|
613
|
-
};
|
|
614
|
-
} finally {
|
|
615
|
-
if (typeof app?.close === 'function') await app.close().catch(() => {});
|
|
616
677
|
}
|
|
678
|
+
return { name: 'boot', status: 'pass', detail: `${detail} in ${Date.now() - started}ms` };
|
|
617
679
|
}
|
|
618
680
|
|
|
619
681
|
// ---------------------------------------------------------------------------
|
|
@@ -68,7 +68,9 @@ export default [
|
|
|
68
68
|
POST: async ({ body }) => {
|
|
69
69
|
const bad = requireObject(body);
|
|
70
70
|
if (bad) return bad;
|
|
71
|
-
|
|
71
|
+
// body first: the server owns id + createdAt — a client-sent id would
|
|
72
|
+
// otherwise duplicate (or shadow) an existing item
|
|
73
|
+
const item = { ...body, id: randomUUID(), createdAt: new Date().toISOString() };
|
|
72
74
|
(await load()).push(item);
|
|
73
75
|
await persist();
|
|
74
76
|
return [201, item];
|
|
@@ -88,7 +90,7 @@ export default [
|
|
|
88
90
|
const list = await load();
|
|
89
91
|
const index = list.findIndex((x) => x.id === params.id);
|
|
90
92
|
if (index === -1) return [404, { error: 'not found' }];
|
|
91
|
-
list[index] = { ...list[index], ...body, id: list[index].id };
|
|
93
|
+
list[index] = { ...list[index], ...body, id: list[index].id, createdAt: list[index].createdAt };
|
|
92
94
|
await persist();
|
|
93
95
|
return [200, list[index]];
|
|
94
96
|
},
|
|
@@ -40,6 +40,27 @@ test('{{resource}} CRUD roundtrip', async () => {
|
|
|
40
40
|
}
|
|
41
41
|
});
|
|
42
42
|
|
|
43
|
+
test('{{resource}} ignores client-sent id and createdAt', async () => {
|
|
44
|
+
const app = await start(0);
|
|
45
|
+
const base = `http://127.0.0.1:${app.port}/api/{{resource}}`;
|
|
46
|
+
try {
|
|
47
|
+
const spoof = { id: 'client-chosen', createdAt: '1999-01-01T00:00:00.000Z', title: 'spoof' };
|
|
48
|
+
const a = await (await fetch(base, { method: 'POST', headers: JSON_HEADERS, body: JSON.stringify(spoof) })).json();
|
|
49
|
+
const b = await (await fetch(base, { method: 'POST', headers: JSON_HEADERS, body: JSON.stringify(spoof) })).json();
|
|
50
|
+
assert.notEqual(a.id, 'client-chosen');
|
|
51
|
+
assert.notEqual(a.id, b.id, 'two creates must never share an id');
|
|
52
|
+
assert.notEqual(a.createdAt, spoof.createdAt);
|
|
53
|
+
|
|
54
|
+
const put = await (await fetch(`${base}/${a.id}`, { method: 'PUT', headers: JSON_HEADERS, body: JSON.stringify(spoof) })).json();
|
|
55
|
+
assert.equal(put.id, a.id);
|
|
56
|
+
assert.equal(put.createdAt, a.createdAt);
|
|
57
|
+
|
|
58
|
+
for (const item of [a, b]) await fetch(`${base}/${item.id}`, { method: 'DELETE' });
|
|
59
|
+
} finally {
|
|
60
|
+
await app.close();
|
|
61
|
+
}
|
|
62
|
+
});
|
|
63
|
+
|
|
43
64
|
test('{{resource}} rejects non-object bodies', async () => {
|
|
44
65
|
const app = await start(0);
|
|
45
66
|
try {
|
|
@@ -1,28 +1,31 @@
|
|
|
1
1
|
// Idea2Prototype feature: upload — multipart/form-data file uploads with a
|
|
2
2
|
// zero-dependency parser. Files land in uploads/, served back by id.
|
|
3
3
|
import { mkdir, readFile, stat, writeFile } from 'node:fs/promises';
|
|
4
|
-
import { extname, join } from 'node:path';
|
|
4
|
+
import { basename, extname, join, sep } from 'node:path';
|
|
5
5
|
import { randomUUID } from 'node:crypto';
|
|
6
6
|
|
|
7
7
|
const UPLOAD_DIR = join(process.cwd(), 'uploads');
|
|
8
8
|
const MAX_FILE_BYTES = 5 * 1024 * 1024;
|
|
9
9
|
|
|
10
|
-
|
|
10
|
+
// Uploads are served from the app's own origin, so anything a browser would
|
|
11
|
+
// EXECUTE (html, svg, js) must never come back as active content — that is
|
|
12
|
+
// stored XSS riding the user's session. Only these passive types render
|
|
13
|
+
// inline; everything else is sent as application/octet-stream + attachment.
|
|
14
|
+
// Every response also carries nosniff + a sandboxing CSP (belt and braces).
|
|
15
|
+
const INLINE_MIME = {
|
|
11
16
|
'.txt': 'text/plain; charset=utf-8',
|
|
12
17
|
'.md': 'text/plain; charset=utf-8',
|
|
13
18
|
'.json': 'application/json; charset=utf-8',
|
|
14
19
|
'.csv': 'text/csv; charset=utf-8',
|
|
15
|
-
'.html': 'text/html; charset=utf-8',
|
|
16
|
-
'.js': 'text/javascript; charset=utf-8',
|
|
17
|
-
'.css': 'text/css; charset=utf-8',
|
|
18
20
|
'.png': 'image/png',
|
|
19
21
|
'.jpg': 'image/jpeg',
|
|
20
22
|
'.jpeg': 'image/jpeg',
|
|
21
23
|
'.gif': 'image/gif',
|
|
22
|
-
'.svg': 'image/svg+xml',
|
|
23
24
|
'.webp': 'image/webp',
|
|
24
|
-
|
|
25
|
-
|
|
25
|
+
};
|
|
26
|
+
const SAFE_HEADERS = {
|
|
27
|
+
'x-content-type-options': 'nosniff',
|
|
28
|
+
'content-security-policy': "default-src 'none'; sandbox",
|
|
26
29
|
};
|
|
27
30
|
|
|
28
31
|
// Parse a multipart/form-data buffer into parts. Minimal per RFC 7578: each
|
|
@@ -82,7 +85,8 @@ export default [
|
|
|
82
85
|
methods: {
|
|
83
86
|
GET: async ({ params }) => {
|
|
84
87
|
const full = join(UPLOAD_DIR, params.name);
|
|
85
|
-
|
|
88
|
+
// sep-boundary: without it, a sibling like uploads-x/ would pass the prefix check
|
|
89
|
+
if (!full.startsWith(UPLOAD_DIR + sep)) return [400, { error: 'bad path' }];
|
|
86
90
|
try {
|
|
87
91
|
const fileStat = await stat(full);
|
|
88
92
|
if (!fileStat.isFile()) return [404, { error: 'not found' }];
|
|
@@ -90,7 +94,15 @@ export default [
|
|
|
90
94
|
return [404, { error: 'not found' }];
|
|
91
95
|
}
|
|
92
96
|
const data = await readFile(full);
|
|
93
|
-
|
|
97
|
+
const inline = INLINE_MIME[extname(full).toLowerCase()];
|
|
98
|
+
return [200, data, inline
|
|
99
|
+
? { ...SAFE_HEADERS, 'content-type': inline }
|
|
100
|
+
: {
|
|
101
|
+
...SAFE_HEADERS,
|
|
102
|
+
'content-type': 'application/octet-stream',
|
|
103
|
+
// re-sanitized: files dropped into uploads/ by hand skip safeName()
|
|
104
|
+
'content-disposition': `attachment; filename="${basename(full).replace(/[^a-zA-Z0-9._-]/g, '_')}"`,
|
|
105
|
+
}];
|
|
94
106
|
},
|
|
95
107
|
},
|
|
96
108
|
},
|
|
@@ -26,6 +26,29 @@ test('upload roundtrip: multipart POST then fetch back', async () => {
|
|
|
26
26
|
}
|
|
27
27
|
});
|
|
28
28
|
|
|
29
|
+
// Stored XSS guard: an uploaded page must never render as active content on
|
|
30
|
+
// the app's own origin — it comes back as an inert download instead.
|
|
31
|
+
test('uploaded html/svg is served as an attachment, never as active content', async () => {
|
|
32
|
+
const app = await start(0);
|
|
33
|
+
const base = `http://127.0.0.1:${app.port}`;
|
|
34
|
+
try {
|
|
35
|
+
for (const [name, type] of [['evil.html', 'text/html'], ['evil.svg', 'image/svg+xml']]) {
|
|
36
|
+
const form = new FormData();
|
|
37
|
+
form.append('file', new Blob(['<script>alert(1)</script>'], { type }), name);
|
|
38
|
+
const info = await (await fetch(`${base}/api/uploads`, { method: 'POST', body: form })).json();
|
|
39
|
+
const fetched = await fetch(`${base}${info.url}`);
|
|
40
|
+
assert.equal(fetched.status, 200);
|
|
41
|
+
assert.equal(fetched.headers.get('content-type'), 'application/octet-stream');
|
|
42
|
+
assert.match(fetched.headers.get('content-disposition') ?? '', /^attachment; filename="[\w.-]+"$/);
|
|
43
|
+
assert.equal(fetched.headers.get('x-content-type-options'), 'nosniff');
|
|
44
|
+
assert.match(fetched.headers.get('content-security-policy') ?? '', /sandbox/);
|
|
45
|
+
await fetched.arrayBuffer();
|
|
46
|
+
}
|
|
47
|
+
} finally {
|
|
48
|
+
await app.close();
|
|
49
|
+
}
|
|
50
|
+
});
|
|
51
|
+
|
|
29
52
|
test('upload rejects non-multipart and unknown ids', async () => {
|
|
30
53
|
const app = await start(0);
|
|
31
54
|
const base = `http://127.0.0.1:${app.port}`;
|
|
@@ -42,6 +65,10 @@ test('upload rejects non-multipart and unknown ids', async () => {
|
|
|
42
65
|
|
|
43
66
|
const traversal = await fetch(`${base}/api/uploads/..%2Fserver.mjs`);
|
|
44
67
|
assert.ok(traversal.status === 400 || traversal.status === 404);
|
|
68
|
+
|
|
69
|
+
// a sibling directory sharing the prefix (uploads-x) must not pass the guard
|
|
70
|
+
const sibling = await fetch(`${base}/api/uploads/..%2Fuploads-x%2Fevil.html`);
|
|
71
|
+
assert.equal(sibling.status, 400);
|
|
45
72
|
} finally {
|
|
46
73
|
await app.close();
|
|
47
74
|
}
|