@ahmed-3m/idea2prototype 0.9.1 → 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.
@@ -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 cache = null;
9
+ let ready = null; // memoized cold load: concurrent first callers share ONE array
10
10
 
11
- async function load() {
12
- if (cache) return cache;
13
- try {
14
- cache = JSON.parse(await readFile(STORE_PATH, 'utf8'));
15
- } catch {
16
- cache = [];
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 cache;
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(cache, null, 2));
53
+ await writeFile(STORE_PATH, JSON.stringify(await load(), null, 2));
24
54
  }
25
55
 
26
56
  function requireObject(body) {
@@ -38,7 +68,9 @@ export default [
38
68
  POST: async ({ body }) => {
39
69
  const bad = requireObject(body);
40
70
  if (bad) return bad;
41
- 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() };
42
74
  (await load()).push(item);
43
75
  await persist();
44
76
  return [201, item];
@@ -58,7 +90,7 @@ export default [
58
90
  const list = await load();
59
91
  const index = list.findIndex((x) => x.id === params.id);
60
92
  if (index === -1) return [404, { error: 'not found' }];
61
- list[index] = { ...list[index], ...body, id: list[index].id };
93
+ list[index] = { ...list[index], ...body, id: list[index].id, createdAt: list[index].createdAt };
62
94
  await persist();
63
95
  return [200, list[index]];
64
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 {
@@ -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 usersCache = null;
14
+ let usersReady = null; // memoized cold load: concurrent first callers share ONE array
15
15
  let secretCache = null;
16
16
 
17
- async function loadUsers() {
18
- if (usersCache) return usersCache;
19
- try {
20
- usersCache = JSON.parse(await readFile(USERS_FILE, 'utf8'));
21
- } catch {
22
- usersCache = [];
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 usersCache;
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(usersCache, null, 2));
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: both tests share the module-level rate buckets; the second test uses
7
- // custom keys so it is independent of the first test's IP bucket.
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
+ });
@@ -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
  }
@@ -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
- jobs = JSON.parse(await readFile(STORE_PATH, 'utf8'));
24
+ raw = await readFile(STORE_PATH, 'utf8');
24
25
  } catch (err) {
25
- if (err?.code !== 'ENOENT') {
26
- console.error(`[jobs] could not read ${STORE_PATH} (${err?.message ?? err}) — starting empty`);
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
  }
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "tool": "idea2prototype",
3
- "version": "0.9.1",
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": "0.9.1",
3
+ "version": "1.0.1",
4
4
  "template": "web",
5
5
  "name": "{{name}}",
6
6
  "slug": "{{slug}}",
@@ -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
- reject(new Error('body too large'));
60
- req.destroy();
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', () => resolve(Buffer.concat(chunks)));
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
- const data = JSON.stringify(payload);
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
- return sendJson(res, 400, { error: err.message });
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
  }
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "tool": "idea2prototype",
3
- "version": "0.9.1",
3
+ "version": "1.0.1",
4
4
  "template": "worker",
5
5
  "name": "{{name}}",
6
6
  "slug": "{{slug}}",
@@ -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
- jobs = JSON.parse(await readFile(STORE_PATH, 'utf8'));
19
+ raw = await readFile(STORE_PATH, 'utf8');
19
20
  } catch (err) {
20
- if (err?.code !== 'ENOENT') {
21
- console.error(`[jobs] could not read ${STORE_PATH} (${err?.message ?? err}) — starting empty`);
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
  }