@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.
- package/CHANGELOG.md +28 -0
- package/README.md +7 -3
- package/agents/AGENTS.md +2 -0
- package/agents/CLAUDE.md +2 -0
- package/package.json +2 -2
- package/skills/idea2prototype/SKILL.md +4 -2
- package/skills/idea2prototype/scripts/idea2prototype.mjs +272 -79
- 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 +44 -12
- package/skills/idea2prototype/templates/features/api-crud/test.tmpl.mjs +21 -0
- 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/upload/route.tmpl.mjs +22 -10
- package/skills/idea2prototype/templates/features/upload/test.tmpl.mjs +27 -0
- 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
|
@@ -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) {
|
|
@@ -38,7 +68,9 @@ export default [
|
|
|
38
68
|
POST: async ({ body }) => {
|
|
39
69
|
const bad = requireObject(body);
|
|
40
70
|
if (bad) return bad;
|
|
41
|
-
|
|
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
|
|
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
|
+
});
|
|
@@ -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
|
}
|
|
@@ -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
|
}
|