@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 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.0 · actively maintained · 1.x — the check/verify contract and template shapes are stable; new capabilities arrive as `add` modules.
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, ...body, author: user.username };
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.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(), ...body, author: user.username };
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.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
- 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'],
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
- 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
- }
530
- // Library projects have no server to probe — boot = import the public API.
531
- if (marker?.template === 'library') return bootLibrary(projectDir);
532
- return bootServer(projectDir, marker);
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 in finally once started — a leaked listener hangs the process forever
557
- // The import ran but no app handle came back to close: the module may hold
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
- // redirect: 'manual' — never follow a redirect off the probe target; the
569
- // 3xx itself counts as serving (foreign ok-range is 200–399).
570
- const probe = async (path) =>
571
- withTimeout(fetch(`http://127.0.0.1:${app.port}${path}`, { redirect: 'manual' }), 10_000, `${path} request`);
572
- let res = await probe('/api/health');
573
- let detail = `GET /api/health -> ${res.status}`;
574
- // Foreign projects (verify mode) may not implement our template's health
575
- // route — a server that starts and serves its own surface still boots.
576
- let rootProbed = false;
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
- // #18: a health route alone can certify an app whose every real route
594
- // 500s — also probe the root. 4xx on / is legitimate (API-only projects),
595
- // a 5xx root is a real defect. Symmetric for scaffolded and foreign
596
- // projects; a request error here surfaces via the outer catch.
597
- if (!rootProbed) {
598
- const root = await probe('/');
599
- detail += `, GET / -> ${root.status}`;
600
- if (root.status >= 500) {
601
- return { name: 'boot', status: 'fail', detail: `${detail} — root serves an error` };
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
  // ---------------------------------------------------------------------------
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "tool": "idea2prototype",
3
- "version": "1.0.0",
3
+ "version": "1.0.1",
4
4
  "template": "api",
5
5
  "name": "{{name}}",
6
6
  "slug": "{{slug}}",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "tool": "idea2prototype",
3
- "version": "1.0.0",
3
+ "version": "1.0.1",
4
4
  "template": "cli",
5
5
  "name": "{{name}}",
6
6
  "slug": "{{slug}}",
@@ -68,7 +68,9 @@ export default [
68
68
  POST: async ({ body }) => {
69
69
  const bad = requireObject(body);
70
70
  if (bad) return bad;
71
- const item = { id: randomUUID(), createdAt: new Date().toISOString(), ...body };
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
- const MIME = {
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
- '.pdf': 'application/pdf',
25
- '.zip': 'application/zip',
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
- if (!full.startsWith(UPLOAD_DIR)) return [400, { error: 'bad path' }];
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
- return [200, data, { 'content-type': MIME[extname(full).toLowerCase()] ?? 'application/octet-stream' }];
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
  }
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "tool": "idea2prototype",
3
- "version": "1.0.0",
3
+ "version": "1.0.1",
4
4
  "template": "library",
5
5
  "name": "{{name}}",
6
6
  "slug": "{{slug}}",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "tool": "idea2prototype",
3
- "version": "1.0.0",
3
+ "version": "1.0.1",
4
4
  "template": "web",
5
5
  "name": "{{name}}",
6
6
  "slug": "{{slug}}",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "tool": "idea2prototype",
3
- "version": "1.0.0",
3
+ "version": "1.0.1",
4
4
  "template": "worker",
5
5
  "name": "{{name}}",
6
6
  "slug": "{{slug}}",