Repository navigation
Expand file tree
/
Copy pathserver.js
More file actions
798 lines (767 loc) · 38.7 KB
/
Copy pathserver.js
File metadata and controls
798 lines (767 loc) · 38.7 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
import express from 'express';
import next from 'next';
import fs from 'fs/promises';
import path from 'path';
// The REAL anonymiser, not a dev copy of it. If /issues is going to be
// reviewed locally, it has to be reviewed through the same function that
// decides what a student may see in production.
import { publicReport, rankReports, visibleToStudent } from './functions/_shared/issue-reports.ts';
// Both lesson-id routes below resolve paths through this. It lives in lib/ so
// scripts/test-lesson-solution-parity.mjs can compare the real dev behaviour
// against the Pages Function rather than a reimplementation of it.
import { resolveLessonDir as resolveLessonDirIn, readLessonSolution } from './lib/lesson-solution-fs.mjs';
import { DEFAULT_WEIGHTS } from './lib/grading-weights.ts';
import { ATTEMPT_CAPS } from './functions/_shared/pa-pseudocode.generated.ts';
import { COUNT_SINCE } from './lib/attempt-cap.ts';
import { onRequestGet as attemptRevealGet } from './functions/api/attempt-reveal.ts';
import { onRequestGet as quizRevealGet } from './functions/api/quiz-reveal.ts';
import { onRequestPost as submissionsPost } from './functions/api/lesson-submissions/index.ts';
// Who the dev auth stub pretends to be. `DEV_ROLE=student npm run dev` is the
// only way to see the student half of anything here — the real session comes
// from a JWT this server does not issue.
//
// The stubs are GATED, not just "not in production": they register only when
// SHCODE_ENABLE_DEV_STUBS=1 is set AND the server is not running under
// NODE_ENV=production. Fail-closed — a stub that must be switched on cannot
// ship by accident, and a production boot cannot be talked into serving them
// by forgetting an env var. `npm run dev` sets the flag for you (package.json).
const DEV_EMAIL = 'dev@local';
const DEV_ROLE = process.env.DEV_ROLE === 'student' ? 'student' : process.env.DEV_ROLE === 'admin' ? 'admin' : 'teacher';
const DEV_STUBS_ENABLED =
process.env.NODE_ENV !== 'production' && process.env.SHCODE_ENABLE_DEV_STUBS === '1';
const dev = process.env.NODE_ENV !== 'production';
const app = next({ dev });
const handle = app.getRequestHandler();
// Two routes join a client-supplied id onto a filesystem path (/api/grade and
// /api/lesson-solution) and neither had any validation, which made both
// traversable. The charset check and the realpath containment behind it now
// live in lib/lesson-solution-fs.mjs, so the test can exercise the same guard
// the server runs.
const LESSONS_ROOT = () => path.join(process.cwd(), 'lessons');
const resolveLessonDir = (id) => resolveLessonDirIn(id, LESSONS_ROOT());
// Extract the body of a named function by balancing braces. Returns just
// the code between the opening { and matching closing }, or null if the
// function isn't found. Used so requirements can scope a pattern match to
// "inside draw()" or "inside setup()" rather than anywhere in the file.
function extractFunctionBody(src, name) {
const re = new RegExp(`function\\s+${name}\\s*\\([^)]*\\)\\s*\\{`);
const m = src.match(re);
if (!m || m.index === undefined) return null;
let depth = 1;
let i = m.index + m[0].length;
const bodyStart = i;
while (i < src.length && depth > 0) {
const c = src[i];
if (c === '{') depth++;
else if (c === '}') {
depth--;
if (depth === 0) return src.slice(bodyStart, i);
}
i++;
}
return null;
}
// Strip JS line (//) and block (/* */) comments so regex autograder can't
// be tricked by answer code commented out in the starter. Respects strings
// so commented tokens inside "// not a comment" aren't dropped. Naive: no
// template-literal or regex-literal handling, which is fine for student code.
function stripJsComments(src) {
let out = '';
let i = 0;
const n = src.length;
let inStr = null; // '"' | "'" | '`' when inside a string, else null
while (i < n) {
const c = src[i];
const next = src[i + 1];
if (inStr) {
out += c;
if (c === '\\' && i + 1 < n) { out += src[i + 1]; i += 2; continue; }
if (c === inStr) inStr = null;
i++;
continue;
}
if (c === '"' || c === "'" || c === '`') { inStr = c; out += c; i++; continue; }
if (c === '/' && next === '/') {
while (i < n && src[i] !== '\n') i++;
continue;
}
if (c === '/' && next === '*') {
i += 2;
while (i < n && !(src[i] === '*' && src[i + 1] === '/')) i++;
i += 2;
continue;
}
out += c;
i++;
}
return out;
}
app.prepare().catch((err) => {
// `output: 'export'` refuses a production server(boot) by design — the built
// site is served by Cloudflare Pages from out/. A `NODE_ENV=production node
// server.js` boot would otherwise crash BEFORE the dev-stub gate is even
// registered, which is exactly the boot a probe (or an accident) would try.
// Log it and keep going: the Express server below still starts, the catch-all
// answers 500 for pages, and — the point — every stubbed /api/* route stays
// shut behind the gate either way.
console.error('[dev-stub-gate] app.prepare() failed to start a handler:', err.message);
}).then(async () => {
const server = express();
// ---- public/_headers, for the one prefix that cannot work without it ----
//
// Cloudflare Pages reads public/_headers; this Express server does not, so
// without this the local preview and production differ on exactly the thing
// that makes the B-rep kernel loadable at all.
//
// The preview iframe is sandboxed WITHOUT allow-same-origin, which puts it in
// an OPAQUE origin, so its fetches carry `Origin: null`. A classic
// <script src> is no-cors and loads fine -- which is why JSCAD never needed
// this -- but an ES module is always fetched in CORS mode, and so is the wasm
// an emscripten module pulls in. Missing header, and the kernel is blocked
// before its wasm is even requested.
//
// Failing only in dev would be bad enough; the reverse is worse. Whichever way
// round it went, the difference would be found by a person, in a classroom.
// Kept deliberately narrow to the same prefix public/_headers grants, and
// NOT applied to /api/*, which stays same-origin and cookie-gated.
server.use('/reshape/kernel', (_req, res, next) => {
res.setHeader('Access-Control-Allow-Origin', '*');
res.setHeader('Cross-Origin-Resource-Policy', 'cross-origin');
next();
});
// ---- gated dev stubs: fail-closed, explicit opt-in ----------------------
// Everything between this if and its closing brace exists only to make
// local review possible without wrangler/D1. It registers ONLY when
// SHCODE_ENABLE_DEV_STUBS=1 and NODE_ENV is not 'production'; otherwise a
// probe to any stubbed route answers 410 Gone and nothing below runs.
// `npm run dev` sets the flag (package.json), so the interactive flow is
// unchanged. Fail-closed: a stub that must be switched ON cannot ship by
// accident, and NODE_ENV=production cannot be served the stubs by forgetting
// a var — the flag alone is not sufficient.
if (!DEV_STUBS_ENABLED) {
server.use('/api/*', (_req, res) => {
res.status(410).json({ error: 'Dev stubs disabled (SHCODE_ENABLE_DEV_STUBS=1 to enable)' });
});
}
if (DEV_STUBS_ENABLED) {
// ---- Dev-only auth + lesson-state stubs --------------------------------
// The real /api/auth/* and /api/lesson-state* are Cloudflare Pages
// Functions (functions/api/**), which this local Express server does NOT
// emulate. Without these stubs every lesson past the first in a module
// renders "Lesson locked" because the client sees role=null. Production
// (Cloudflare Pages) ignores this file entirely — dev convenience only.
// A `dev_student=<name>` cookie (set by a test harness on its browser
// context) gives that browser its own progress, so several headless
// students can walk a module on one server without unlocking each other's
// lessons. No cookie means the single shared DEV_EMAIL identity.
const devIdentity = (req) => {
const m = /(?:^|;\s*)dev_student=([^;]+)/.exec(req.headers.cookie || '');
return m ? decodeURIComponent(m[1]) : DEV_EMAIL;
};
// DEV_REAL_DB=1: the real tries handlers over an in-memory SQLite with a fake AI grader
// (scripts/dev-demo-api.mjs). Mounted before the stubs below, so these routes win.
if (process.env.DEV_REAL_DB === '1') {
const { mountDemoApi } = await import('./scripts/dev-demo-api.mjs');
mountDemoApi({ server, express, devIdentity, role: DEV_ROLE, root: process.cwd() });
}
server.get('/api/me', (req, res) => {
res.json({ email: devIdentity(req), role: DEV_ROLE });
});
server.post('/api/auth/login', (req, res) => {
res.json({ email: devIdentity(req), role: DEV_ROLE });
});
server.post('/api/auth/logout', (_req, res) => {
res.json({ ok: true });
});
// Class-scoped student data. The real routes are
// functions/api/my-enrollments.ts and my-due-dates.ts and both need D1; this
// server has no binding, so it answers the well-formed empty case. Without
// them both 404, and lib/due-dates.ts THROWS on a non-OK response -- so every
// page in dev ran its date logic through a caught exception, and HeaderNav +
// AnnouncementBanner ate a failed fetch on every load.
server.get('/api/my-enrollments', (_req, res) => {
res.json({ enrollments: [] });
});
server.get('/api/my-due-dates', (_req, res) => {
res.json({ classes: [], overrides: [], dueWaivers: [] });
});
// Real route: functions/api/my-grading-weights.ts. Needs D1 (enrollments +
// class_grading_weights); this server has no binding, so it answers the
// well-formed no-override case -- the curriculum defaults, same shape as a
// student with no class_grading_weights row anywhere.
server.get('/api/my-grading-weights', (_req, res) => {
res.json({ weights: DEFAULT_WEIGHTS });
});
// Written-answer drafts. Real route: functions/api/lesson-drafts/[lessonId].ts
// (D1 table lesson_drafts, one row per student+lesson). Held in memory here,
// keyed the same way. A missing draft answers 200 {response:null,updatedAt:null},
// like production (a 404 was a console error on every first visit).
const devDrafts = new Map(); // `${identity}\u0000${lessonId}` -> {response, updatedAt}
const draftKey = (req) => `${devIdentity(req)}\u0000${req.params.lessonId}`;
server.get('/api/lesson-drafts/:lessonId', (req, res) => {
const row = devDrafts.get(draftKey(req));
if (!row) return res.json({ response: null, updatedAt: null });
res.json(row);
});
server.post('/api/lesson-drafts/:lessonId', express.json({ limit: '1mb' }), (req, res) => {
const { response } = req.body || {};
if (typeof response !== 'string') {
return res.status(400).json({ error: 'response (string) required' });
}
const updatedAt = Date.now();
devDrafts.set(draftKey(req), { response, updatedAt });
res.json({ ok: true, updatedAt });
});
server.delete('/api/lesson-drafts/:lessonId', (req, res) => {
devDrafts.delete(draftKey(req));
res.json({ ok: true });
});
// Grading targets. Real route: functions/api/grade-written.ts onRequestGet.
// No OLLAMA/Workers-AI credentials exist in dev, so the honest answer is an
// empty list -- GraderPicker's hasGraderChoice() renders nothing below two
// available targets, which is the correct unconfigured-dev surface.
server.get('/api/grade-written', (_req, res) => {
res.json({ graders: [], fallback: null });
});
// Version-control commit pool. Real routes: functions/api/commits/index.ts
// (GET ?lessonId= -> { commits }, POST -> { commit }) and commits/[id].ts
// (DELETE). Held in memory, keyed by identity+lesson the way the D1 table is.
// Without this listCommits() THREW an ApiError on every code lab, because
// lib/commits-api.ts rejects any non-OK response.
const devCommits = new Map(); // `${identity}\u0000${lessonId}` -> ApiCommit[]
const commitKey = (req, lessonId) => `${devIdentity(req)}\u0000${lessonId}`;
server.get('/api/commits', (req, res) => {
const lessonId = req.query.lessonId;
if (typeof lessonId !== 'string' || !lessonId) {
return res.status(400).json({ error: 'lessonId required' });
}
res.json({ commits: devCommits.get(commitKey(req, lessonId)) ?? [] });
});
server.post('/api/commits', express.json({ limit: '4mb' }), (req, res) => {
const { id, lessonId, message, files, changedFileIds } = req.body || {};
if (typeof id !== 'string' || typeof lessonId !== 'string') {
return res.status(400).json({ error: 'id and lessonId required' });
}
const commit = {
id,
lessonId,
message: typeof message === 'string' ? message : '',
files: files && typeof files === 'object' ? files : {},
changedFileIds: Array.isArray(changedFileIds) ? changedFileIds : [],
createdAt: Date.now(),
authoredByEmail: devIdentity(req),
};
const key = commitKey(req, lessonId);
// Newest first, matching the real route's ORDER BY created_at DESC.
devCommits.set(key, [commit, ...(devCommits.get(key) ?? [])]);
res.json({ commit });
});
server.delete('/api/commits/:id', (req, res) => {
for (const [key, list] of devCommits) {
const next = list.filter((c) => c.id !== req.params.id);
if (next.length !== list.length) {
devCommits.set(key, next);
return res.json({ ok: true });
}
}
res.status(404).json({ error: 'Not found' });
});
// Teacher gates (migrations/0016_lesson_modes.sql). Held in memory rather
// than D1 because this server does not have a D1 binding; the real routes
// are functions/api/classes/[id]/lesson-modes.ts and my-lesson-modes.ts.
// POST here is dev-only and matches the real POST's body shape so the client
// and the browser gate exercise the same path.
const devLessonModes = { classDefault: null, lessons: {} };
server.get('/api/my-lesson-modes', (_req, res) => {
res.json(devLessonModes);
});
server.post('/api/dev/lesson-modes', express.json(), (req, res) => {
const { lessonId, mode } = req.body || {};
if (lessonId === '*') devLessonModes.classDefault = mode ?? null;
else if (typeof lessonId === 'string') {
if (mode) devLessonModes.lessons[lessonId] = mode;
else delete devLessonModes.lessons[lessonId];
}
res.json({ ok: true, ...devLessonModes });
});
// Held in memory for the life of the process so `DEV_ROLE=student` walks a
// module the way a student does: Submit turns the lesson green and the next
// one unlocks after navigation. Before this, POST was a no-op and GET always
// returned {}, so every lesson past the first read "Lesson locked" the
// moment the page reloaded. Restarting the server is the reset.
const devLessonStates = new Map();
const devStateFor = (req) => {
const id = devIdentity(req);
if (!devLessonStates.has(id)) devLessonStates.set(id, { states: {}, scores: {} });
return devLessonStates.get(id);
};
server.get('/api/lesson-state', (req, res) => {
res.json({ ...devStateFor(req), role: DEV_ROLE });
});
server.post('/api/lesson-state/:lessonId', express.json(), (req, res) => {
const st = devStateFor(req);
const { lessonId } = req.params;
const { state, score } = req.body || {};
if (state === 'completed') {
st.states[lessonId] = 'completed';
// A capped part keeps the BEST score, as the real route does
// (functions/api/lesson-state/[lessonId].ts); anything else keeps the better of old and new.
if (ATTEMPT_CAPS[lessonId] !== undefined) {
// On a capped part the score is derived from the counted submission rows
// and the browser's number is ignored, exactly as the real route does.
const me = devIdentity(req);
const counted = devSubmissions
.filter((r) => r.studentEmail === me && r.lessonId === lessonId
&& r.submittedAt >= COUNT_SINCE && !(r.gradeJson && r.gradeJson.gradingFailed === true)
&& typeof r.score === 'number')
.map((r) => r.score);
if (counted.length) {
const best = Math.max(...counted);
st.scores[lessonId] = typeof st.scores[lessonId] === 'number' ? Math.max(st.scores[lessonId], best) : best;
}
} else if (typeof score === 'number') {
// Uncapped scores are best-of too (the real route: MAX(stored, new); null never erases).
st.scores[lessonId] = typeof st.scores[lessonId] === 'number' ? Math.max(st.scores[lessonId], score) : score;
}
} else if (state === 'started' && !st.states[lessonId]) {
st.states[lessonId] = 'started';
}
res.json({ ok: true });
});
server.delete('/api/lesson-state/:lessonId', (req, res) => {
const st = devStateFor(req);
delete st.states[req.params.lessonId];
delete st.scores[req.params.lessonId];
res.json({ ok: true });
});
// Submit records a submission before it marks the lesson complete and
// refuses to complete when that POST fails (LessonWorkspace.confirmSubmit),
// so without this stub no graded lesson can ever turn green in dev. The
// real route is functions/api/lesson-submissions.ts.
const devSubmissions = [];
server.get('/api/lesson-submissions', (req, res) => {
const lessonId = req.query.lessonId;
// Filter by the requesting student, like the production route
// (functions/api/lesson-submissions/index.ts) does. Without this, one
// student's summative submission is served to every other dev identity
// and WrittenGrader's server-side already-submitted lock goes global —
// measured 2026-09-18: a student who never submitted 2.7.2 saw "Submitted"
// because adv-attack's answer was in the list.
const me = devIdentity(req);
res.json({
submissions: devSubmissions.filter(
(s) => (!lessonId || s.lessonId === lessonId) && s.studentEmail === me,
),
});
});
// The REAL handlers for lesson-submissions POST, quiz-reveal and attempt-reveal
// run here over a D1-shaped view of devSubmissions, so the dev server exercises
// the same counting, scoring and gating production does (a stub that
// re-implemented them would pass while the route was wrong). Only the SQL these
// three routes issue is understood; anything else throws, which the handlers
// that wrap it already tolerate (the due-date lookup).
// The teacher's per-class release (migrations/0032_solution_releases.sql). The dev
// server has no teacher UI for it, so a test sets it here: POST
// /api/dev/solution-release { lessonId, releaseAt } (epoch ms; omit or null to take
// it back). Absent = not released, as in production.
const devReleases = [];
server.post('/api/dev/solution-release', express.json(), (req, res) => {
const { lessonId, releaseAt } = req.body || {};
if (typeof lessonId !== 'string') return res.status(400).json({ error: 'lessonId required' });
const i = devReleases.findIndex((r) => r.lessonId === lessonId);
if (i >= 0) devReleases.splice(i, 1);
if (typeof releaseAt === 'number') devReleases.push({ lessonId, releaseAt });
res.json({ ok: true, releases: devReleases });
});
const devDb = (email) => ({
prepare(sql) {
let args = [];
const q = {
bind(...a) { args = a; return q; },
async all() {
if (/FROM lesson_submissions WHERE student_email = \? AND lesson_id = \?/.test(sql)) {
const rows = devSubmissions
.filter((r) => r.studentEmail === args[0] && r.lessonId === args[1])
.map((r) => ({
grade_json: r.gradeJson === undefined ? null : JSON.stringify(r.gradeJson),
submitted_at: r.submittedAt,
score: r.score ?? null,
possible: r.possible ?? null,
}))
.sort((a, b) => a.submitted_at - b.submitted_at);
return { results: rows };
}
if (/FROM class_solution_releases r\s+JOIN enrollments e/.test(sql)) {
// studentReleaseStatus: the dev student is enrolled in one dev class, and
// devReleases (set through POST /api/dev/solution-release) are its rows.
return { results: devReleases.map((r) => ({ class_id: 'dev-class', scope: 'lesson', scope_id: r.lessonId, release_at: r.releaseAt, enrolled_at: 0 })) };
}
if (/FROM enrollments e JOIN classes c/.test(sql)) {
// mayReadAnswer's enrollment check: the dev student is treated as
// enrolled, so the dev server can walk a capped part to the reveal.
return { results: [{ ok: 1 }] };
}
throw new Error('dev D1: unsupported all(): ' + sql);
},
async first() {
if (/SELECT id FROM lesson_submissions/.test(sql)) {
const r = devSubmissions.find((x) => x.studentEmail === args[0] && x.lessonId === args[1]);
return r ? { id: r.id } : null;
}
throw new Error('dev D1: unsupported first(): ' + sql);
},
async run() {
if (/INSERT INTO lesson_submissions[\s\S]*SELECT/.test(sql)) {
// The capped insert (functions/_shared/attempts.ts insertCounted): the
// row, then the count's bind values. One synchronous block, so it is
// as atomic here as the single statement is in D1.
const [id, studentEmail, lessonId, response, gradeJson, score, possible, submittedAt, , email, lid, since, cap] = args;
const spent = devSubmissions.filter((r) => r.studentEmail === email && r.lessonId === lid
&& r.submittedAt >= since && !(r.gradeJson && r.gradeJson.gradingFailed === true)).length;
if (spent >= cap) return { success: true, meta: { changes: 0 } };
devSubmissions.push({ id, studentEmail, lessonId, response, gradeJson: gradeJson ? JSON.parse(gradeJson) : undefined, score, possible, submittedAt });
return { success: true, meta: { changes: 1 } };
}
if (/INSERT INTO lesson_submissions/.test(sql)) {
const [id, studentEmail, lessonId, response, gradeJson, score, possible, submittedAt] = args;
devSubmissions.push({
id, studentEmail, lessonId, response,
gradeJson: gradeJson ? JSON.parse(gradeJson) : undefined,
score, possible, submittedAt,
});
return { success: true, meta: { changes: 1 } };
}
throw new Error('dev D1: unsupported run(): ' + sql);
},
};
return q;
},
});
const runRoute = (handler) => async (req, res) => {
const email = devIdentity(req);
const url = `http://localhost${req.originalUrl}`;
const request = new Request(url, {
method: req.method,
headers: { 'content-type': 'application/json' },
body: req.method === 'GET' ? undefined : JSON.stringify(req.body ?? {}),
});
const out = await handler({ request, env: { DB: devDb(email) }, params: {}, data: { email, role: DEV_ROLE } });
res.status(out.status).type('application/json').send(await out.text());
};
server.post('/api/lesson-submissions', express.json({ limit: '1mb' }), runRoute(submissionsPost));
server.get('/api/quiz-reveal', runRoute(quizRevealGet));
server.get('/api/attempt-reveal', runRoute(attemptRevealGet));
// The student gradebook on /progress. The real route is
// functions/api/my-gradebook.ts, reading lesson_state + lesson_submissions
// out of D1, which this server does not have. Without a stub the page shows
// its "could not load" card in dev and the table is unreviewable locally,
// so this serves a fixture that hits every status the component renders --
// including `pending` (grader outage) and a teacher override with feedback,
// the two states that are hardest to produce on purpose against real data.
server.get('/api/my-gradebook', async (_req, res) => {
const day = 86400000;
const now = Date.now();
let ids = [];
try {
const raw = await fs.readFile(path.join(process.cwd(), 'public', 'lessons-manifest.json'), 'utf8');
ids = JSON.parse(raw).lessons.filter((l) => l.type === 'assignment').slice(0, 5).map((l) => l.id);
} catch {
ids = [];
}
const base = {
state: null, score: null, submittedScore: null, possible: null,
late: false, pending: false, completedAt: null, submittedAt: null,
teacherFeedback: null, teacherReviewedAt: null,
};
const fixtures = [
{ ...base, state: 'completed', score: 100, completedAt: now - 6 * day, due: now - 7 * day + day },
{ ...base, state: 'completed', score: 90, submittedScore: 18, possible: 20, completedAt: now - day, late: true, due: now - 3 * day },
{ ...base, state: 'completed', score: 75, submittedScore: 15, possible: 20, completedAt: now - 2 * day,
teacherFeedback: 'Nice work on the nested loop. Next time give the counter a clearer name than i, and add a comment above draw() saying what it animates.',
teacherReviewedAt: now - day, due: now - 2 * day },
{ ...base, pending: true, submittedAt: now - 3600000, due: now + 2 * day },
{ ...base, late: true, due: now - day },
];
const cells = {};
const dueDates = {};
ids.forEach((id, i) => {
const { due, ...cell } = fixtures[i];
cells[id] = cell;
if (due) dueDates[id] = due;
});
res.json({ cells, dueDates });
});
// Reference solutions (admin/teacher "View solution" button). Dev reads the
// lesson straight from disk; the Pages Function serves the generated map.
// Both must answer with the same shape, so this mirrors
// functions/api/lesson-solution/[id].ts.
//
// It used to read solution.js and nothing else, so every lesson using the
// solution/ DIRECTORY form 404'd locally while working in production --
// 1.3.19, 7.1.1 and 1.6.1. That is the form CLAUDE.md documents for an
// assignment grading more than one file, and it is also how a diagram
// lesson stores its reference chart, so "no solution" in the browser meant
// "not implemented in dev" rather than anything about the lesson.
server.get('/api/lesson-solution/:id', async (req, res) => {
// NOT decodeURIComponent(req.params.id) -- Express has ALREADY decoded the
// param, so decoding again turns %252e%252e into .. after the router has
// stopped looking. CLAUDE.md's always-decode rule is about Pages Functions,
// which do not decode for you. Express does.
const found = await readLessonSolution(req.params.id, LESSONS_ROOT());
if (!found) return res.status(404).json({ error: 'No solution for this lesson' });
res.json(found);
});
// Scope express.json() to the Express-owned route only. Applying it globally
// consumes the request body stream, which breaks Next App Router route
// handlers (they need to read the raw body themselves).
server.post('/api/grade', express.json(), async (req, res) => {
const { lessonId, files } = req.body;
try {
// Same traversal guard as /api/lesson-solution. lessonId arrives in a
// JSON body here rather than the path, which makes it MORE attacker-
// shaped, not less -- nothing upstream normalises it at all.
const lessonDir = await resolveLessonDir(lessonId);
if (!lessonDir) return res.status(404).json({ error: 'No such lesson' });
const meta = JSON.parse(
await fs.readFile(path.join(lessonDir, 'lesson.json'), 'utf8')
);
const passingScore = meta.grading?.passingScore || 0;
const results = (meta.requirements || []).map((r) => {
const type = r.type || 'regex';
let passed = false;
// Default flags is '' (case-sensitive). Set "flags": "i" explicitly
// in lesson.json if you want case-insensitive matching — moSHion
// identifiers like Canvas / Sprite / kb / world are case-sensitive
// so the default must be strict.
const flags = r.flags ?? '';
if (type === 'regex') {
const raw = files?.[r.file] || '';
const content = r.stripComments === false ? raw : stripJsComments(raw);
const regex = new RegExp(r.pattern, flags);
passed = regex.test(content);
} else if (type === 'inFunction') {
// Scope the pattern to the named function's body. Use this for
// checks like "background() must be called inside draw()".
const raw = files?.[r.file] || '';
const content = stripJsComments(raw);
const body = extractFunctionBody(content, r.function || 'draw');
if (body !== null) {
const regex = new RegExp(r.pattern, flags);
passed = regex.test(body);
}
}
// output and function types are handled client-side via Web Worker
const points = r.points || 0;
return {
id: r.id,
title: r.title,
status: passed ? 'passed' : 'failed',
messages: passed ? [] : [r.description],
pointsEarned: passed ? points : 0,
pointsPossible: points,
};
});
const totalScore = results.reduce((sum, r) => sum + r.pointsEarned, 0);
const totalPossible = results.reduce((sum, r) => sum + r.pointsPossible, 0);
res.json({
results,
totalScore,
totalPossible,
passed: totalScore >= passingScore,
passingScore,
});
} catch {
res.json({ results: [], totalScore: 0, totalPossible: 0, passed: false, passingScore: 0 });
}
});
// ---- Dev-only issue-report + upload stubs ------------------------------
// functions/api/issue-reports/** and functions/api/uploads/** are Pages
// Functions with D1 and R2 bindings, so like the auth stubs above they do
// not run here. Held in memory: restarting the server empties the queue,
// which is what you want when reviewing the form rather than the data.
//
// These mirror the real routes' SHAPES, not their security. The real ones
// sniff magic bytes, enforce quotas, check upload ownership, and gate on
// session role; none of that is repeated here, because there is no session
// and no other user to protect anything from. Do not read this as a second
// implementation to keep in sync -- it exists so the Report an issue button
// and /teacher/issues can be clicked through without wrangler.
const devIssues = [];
const devUploads = new Map();
let devIssueSeq = 0;
let devUploadSeq = 0;
// key `${reportId}|${email}` -> 1 | -1. The real store is
// migrations/0021_issue_report_votes.sql, keyed the same way so one person
// can hold at most one vote per report.
const devVotes = new Map();
function devTally(reportId) {
let up = 0;
let down = 0;
let myVote = 0;
for (const [key, vote] of devVotes) {
const [id, email] = key.split('|');
if (Number(id) !== reportId) continue;
if (vote === 1) up++;
else down++;
if (email === DEV_EMAIL) myVote = vote;
}
return { up, down, myVote };
}
server.post('/api/uploads', express.raw({ type: '*/*', limit: '4mb' }), (req, res) => {
if (!req.body || !req.body.length) return res.status(400).json({ error: 'That file is empty.' });
devUploadSeq++;
// Shaped like the real 32-hex CSPRNG id so isUploadId() would accept it.
const id = devUploadSeq.toString(16).padStart(32, 'a');
const type = req.headers['content-type'] || 'image/png';
devUploads.set(id, { buf: req.body, type });
res.status(201).json({ id, url: '/uploads/' + id + '.png', filename: 'dev', contentType: type, bytes: req.body.length });
});
server.delete('/api/uploads/:id', (req, res) => {
const had = devUploads.delete(req.params.id);
console.log(' [dev] upload delete ' + req.params.id + (had ? ' -> gone' : ' -> was not there'));
if (!had) return res.status(404).json({ error: 'Not found' });
res.json({ deleted: req.params.id });
});
server.get('/uploads/:name', (req, res) => {
const item = devUploads.get(String(req.params.name).split('.')[0]);
if (!item) return res.status(404).send('Not found');
res.set('Content-Type', item.type).set('X-Content-Type-Options', 'nosniff').send(item.buf);
});
server.get('/api/issue-reports', (req, res) => {
const sorted = [...devIssues].sort((a, b) => b.created_at - a.created_at);
if (req.query.format === 'md' && DEV_ROLE === 'student') {
return res.status(403).json({ error: 'Staff only' });
}
if (req.query.format === 'md') {
const lines = ['# Issue reports (dev server)', ''];
for (const r of sorted) {
lines.push('## #' + r.id + ' [' + r.kind.toUpperCase() + '] ' + (r.title || ''), '', r.message, '');
}
return res
.set('Content-Type', 'text/markdown; charset=utf-8')
.set('Content-Disposition', 'attachment; filename="issue-reports-dev.md"')
.send(lines.join('\n'));
}
if (DEV_ROLE === 'student') {
// context_json is a string in D1; the dev store already holds it parsed,
// so re-serialise before handing it to the real publicReport().
const rows = sorted.map((r) => ({ ...r, context_json: r.context ? JSON.stringify(r.context) : null }));
const visible = rows.filter((r) => visibleToStudent(r, DEV_EMAIL));
return res.json({ reports: rankReports(visible.map((r) => publicReport(r, devTally(r.id), DEV_EMAIL))) });
}
res.json({ reports: sorted.map((r) => { const t = devTally(r.id); return { ...r, ...t, score: t.up - t.down }; }) });
});
server.post('/api/issue-reports/:id/vote', express.json(), (req, res) => {
const id = Number(req.params.id);
const report = devIssues.find((x) => x.id === id);
if (!report) return res.status(404).json({ error: 'Report not found' });
const { vote } = req.body || {};
if (vote !== 1 && vote !== -1 && vote !== 0) {
return res.status(400).json({ error: 'vote must be one of: 1, -1, 0' });
}
const key = id + '|' + DEV_EMAIL;
if (vote === 0) devVotes.delete(key);
else devVotes.set(key, vote);
const t = devTally(id);
res.json({ ok: true, id, up: t.up, down: t.down, score: t.up - t.down, myVote: vote });
});
server.post('/api/issue-reports', express.json({ limit: '1mb' }), (req, res) => {
const { kind, title, message, context, screenshotId, reporterEmail, createdAt } = req.body || {};
if (!['bug', 'quirk', 'enhancement'].includes(kind)) {
return res.status(400).json({ error: 'kind must be one of: bug, quirk, enhancement' });
}
// Same two guards the real route applies, because they are the two the
// form can actually trip and you want to see the error rendering.
if (typeof title !== 'string' || title.trim().length < 3) {
return res.status(400).json({ error: 'Please give this a short title (at least 3 characters).' });
}
if (typeof message !== 'string' || message.trim().length < 3) {
return res.status(400).json({ error: 'Please describe the issue (at least 3 characters).' });
}
devIssueSeq++;
devIssues.push({
id: devIssueSeq,
// Overridable so a seed script can spread reporters and ages across
// fake data -- the real route always derives these from the session
// and the clock, so a normal POST from the actual form never sends them.
reporter_email: typeof reporterEmail === 'string' && reporterEmail ? reporterEmail : 'dev@local',
kind,
title: title.trim(),
message: message.trim(),
status: 'open',
triaged_by: null,
triaged_at: null,
context: context ?? null,
screenshot_id: screenshotId ?? null,
screenshot_shared: 0,
withdrawn_at: null,
resolution_note: null,
created_at: typeof createdAt === 'number' && Number.isFinite(createdAt) ? createdAt : Date.now(),
});
console.log(' [dev] issue #' + devIssueSeq + ' [' + kind + '] ' + title.trim());
res.status(201).json({ id: devIssueSeq, ok: true });
});
server.post('/api/issue-reports/:id/status', express.json(), (req, res) => {
const r = devIssues.find((x) => x.id === Number(req.params.id));
if (!r) return res.status(404).json({ error: 'Report not found' });
const { status, note } = req.body || {};
if (!['open', 'in-progress', 'fixed', 'deferred'].includes(status)) {
return res.status(400).json({ error: 'status must be one of: open, in-progress, fixed, deferred' });
}
r.status = status;
r.triaged_by = 'dev@local';
r.triaged_at = Date.now();
// Mirrors the real route: presence of the key decides whether to touch
// the note, not its value -- a plain status flip must leave it alone.
if (Object.prototype.hasOwnProperty.call(req.body || {}, 'note')) {
const trimmed = typeof note === 'string' ? note.trim() : '';
r.resolution_note = trimmed.length ? trimmed : null;
}
res.json({ ok: true, id: r.id, status, resolution_note: r.resolution_note });
});
server.post('/api/issue-reports/:id/withdraw', express.json(), (req, res) => {
const r = devIssues.find((x) => x.id === Number(req.params.id));
if (!r) return res.status(404).json({ error: 'Report not found' });
const { withdrawn } = req.body || {};
if (typeof withdrawn !== 'boolean') {
return res.status(400).json({ error: 'withdrawn must be a boolean' });
}
// Dev stub has one identity (DEV_EMAIL) and no other reporter to be
// blocked from withdrawing someone else's report, unlike the real route.
r.withdrawn_at = withdrawn ? Date.now() : null;
res.json({ ok: true, id: r.id, withdrawn });
});
server.post('/api/issue-reports/:id/share-screenshot', express.json(), (req, res) => {
if (DEV_ROLE === 'student') return res.status(403).json({ error: 'Staff only' });
const r = devIssues.find((x) => x.id === Number(req.params.id));
if (!r) return res.status(404).json({ error: 'Report not found' });
const { shared } = req.body || {};
if (typeof shared !== 'boolean') {
return res.status(400).json({ error: 'shared must be a boolean' });
}
if (!r.screenshot_id) return res.status(400).json({ error: 'This report has no screenshot to share.' });
r.screenshot_shared = shared ? 1 : 0;
res.json({ ok: true, id: r.id, shared });
});
server.delete('/api/issue-reports/:id', (req, res) => {
const i = devIssues.findIndex((x) => x.id === Number(req.params.id));
if (i === -1) return res.status(404).json({ error: 'Report not found' });
const [gone] = devIssues.splice(i, 1);
let screenshotDeleted = false;
if (gone.screenshot_id) screenshotDeleted = devUploads.delete(gone.screenshot_id);
console.log(' [dev] issue #' + gone.id + ' deleted' + (screenshotDeleted ? ' (+ screenshot)' : ''));
res.json({ deleted: gone.id, screenshotDeleted });
});
} // end DEV_STUBS_ENABLED
server.all('*', (req, res) => {
return handle(req, res);
});
const port = process.env.PORT || 3002;
// `listen` RETURNS the underlying http.Server whose address() reports the
// ACTUAL bound port (the express app itself has none) — PORT=0 picks an
// ephemeral port, which scripts/test-dev-stub-gate.mjs relies on so it can
// never collide with a dev server someone already has running.
const httpServer = server.listen(port, () => {
console.log(`> Ready on http://localhost:${httpServer.address().port}`);
});
});