eklavya 1.45.0 → 1.47.0

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.
Files changed (36) hide show
  1. package/README.md +11 -1
  2. package/dist/artifacts.js +17 -0
  3. package/dist/artifacts.js.map +1 -1
  4. package/dist/assets/artifact-template.html +3 -0
  5. package/dist/assets/dashboard.html +476 -30
  6. package/dist/assets/tutor/SKILL.md +2 -1
  7. package/dist/assets/tutor/references/writing-mcq.md +11 -1
  8. package/dist/cli.js +44 -12
  9. package/dist/cli.js.map +1 -1
  10. package/dist/dashboard.js +201 -47
  11. package/dist/dashboard.js.map +1 -1
  12. package/dist/hooks/checkpoint-quiz.js +3 -2
  13. package/dist/hooks/checkpoint-quiz.js.map +1 -1
  14. package/dist/hooks/memory-lib.js +1 -1
  15. package/dist/hooks/memory-lib.js.map +1 -1
  16. package/dist/hooks/stop-quiz-check.js +3 -2
  17. package/dist/hooks/stop-quiz-check.js.map +1 -1
  18. package/dist/memory/recall.js +1 -1
  19. package/dist/migrations/019_attempt_corrections.sql +41 -0
  20. package/dist/plugin/.claude-plugin/plugin.json +1 -1
  21. package/dist/plugin/agents/explainer.md +11 -6
  22. package/dist/plugin/skills/tutor/SKILL.md +2 -1
  23. package/dist/plugin/skills/tutor/references/writing-mcq.md +11 -1
  24. package/dist/store.js +142 -14
  25. package/dist/store.js.map +1 -1
  26. package/dist/telemetry-send.js +9 -7
  27. package/dist/telemetry-send.js.map +1 -1
  28. package/dist/tools/get_learner_profile.js +5 -3
  29. package/dist/tools/get_learner_profile.js.map +1 -1
  30. package/dist/tools/get_session_quiz_plan.js +1 -1
  31. package/dist/tools/get_session_quiz_plan.js.map +1 -1
  32. package/dist/tools/record_attempt.js +48 -8
  33. package/dist/tools/record_attempt.js.map +1 -1
  34. package/dist/user-skill/eklavya/SKILL.md +7 -3
  35. package/dist/user-skill/eklavya-artifacts/SKILL.md +12 -4
  36. package/package.json +1 -1
package/dist/dashboard.js CHANGED
@@ -9,8 +9,9 @@
9
9
  * Deliberately a static page plus JSON endpoints: no framework, no build
10
10
  * step, same rule the landing page follows. It binds to loopback only — the
11
11
  * data never leaves the machine, and that is a promise the landing page makes
12
- * on Eklavya's behalf. The one write, a setting, also needs the page's token
13
- * (see `postSettings`).
12
+ * on Eklavya's behalf. It is read-only except the routes in `WRITES`, and every
13
+ * one of those goes through `acceptWrite`: loopback Origin, JSON, the page's
14
+ * per-start token and a size cap.
14
15
  *
15
16
  * The endpoint ships mostly *flat rows* — every attempt, every logged context,
16
17
  * one row per day — and lets the page derive the views. One aggregate query per
@@ -25,13 +26,14 @@ import { spawn } from 'node:child_process';
25
26
  import path from 'node:path';
26
27
  import { fileURLToPath } from 'node:url';
27
28
  import { decayedScore, isKnown, isOwed, MS_PER_DAY } from './srs.js';
28
- import { GLOBAL_PROJECT, levelStanding, PASSING_GRADE, projectKey } from './store.js';
29
+ import { correctionStates, correctionTarget, CorrectionError, GLOBAL_PROJECT, levelStanding, PASSING_GRADE, projectKey, recordRetry, } from './store.js';
29
30
  import { randomBytes, timingSafeEqual } from 'node:crypto';
30
31
  import { loadConfig, loadGlobalConfig, DEFAULT_CONFIG, configFileProblem, isGlobalOnlyKey, mainRepoRoot, readConfigFile, normalizeLegacyKeys, } from './config.js';
31
32
  import { applySetting, knownKeys, SETTING_RULES, valueAt } from './config-path.js';
32
33
  import { dashboardPort, dbPath, DEFAULT_PORT, eklavyaHome, globalConfigPath, projectConfigPath } from './paths.js';
33
34
  import { ownVersion } from './dashboard-daemon.js';
34
35
  import { artifactThumb, listArtifacts, resolveArtifact } from './artifacts.js';
36
+ import { LIMITS } from './tools/types.js';
35
37
  import { DELIVERED, NOT_HELPER_RECEIPT, readTotals, receiptTotals, totalsDelivery } from './memory/store.js';
36
38
  import { ESTIMATOR, savingsFrom, savingsLine } from './memory/tokens.js';
37
39
  import { queueDepth } from './memory/worker.js';
@@ -74,6 +76,13 @@ const RECEIPT_LIMIT = 200;
74
76
  const MEMORY_SESSION_LIMIT = 500;
75
77
  /** Longest narrative excerpt a timeline row carries; the detail page has it all. */
76
78
  const SNIPPET = 150;
79
+ /**
80
+ * A correction row (`retry_of`, migration 019) is a pick made on this page
81
+ * after the explainer showed the answer, not a question asked: no count here
82
+ * includes it. The miss it corrects is counted once, as `corrected` instead of
83
+ * missed or skipped, by joining that row as `fix`.
84
+ */
85
+ const NOT_CORRECTION = 'retry_of IS NULL';
77
86
  /**
78
87
  * Two timestamp shapes live in this database and both have to parse.
79
88
  * `attempts.ts` defaults to SQLite's `2026-09-05 21:04:00` — UTC, no zone
@@ -445,7 +454,7 @@ export function projectInventory(db) {
445
454
  COALESCE(SUM(grade >= ${PASSING_GRADE}), 0) AS passed,
446
455
  COALESCE(SUM(outcome IN ('declined','dont_know')), 0) AS skipped,
447
456
  min(ts) AS first, max(ts) AS last
448
- FROM attempts GROUP BY repo, session_id, concept_id`)) {
457
+ FROM attempts WHERE ${NOT_CORRECTION} GROUP BY repo, session_id, concept_id`)) {
449
458
  const p = at(r.repo, 'attempts');
450
459
  p.learning.answers += r.n;
451
460
  p.learning.passed += r.passed;
@@ -574,14 +583,15 @@ export function dashboardState(db) {
574
583
  // One row per calendar day per project, split three ways. `grade >= 3` is the
575
584
  // same pass line the level ladder uses, so the chart and the promotion agree.
576
585
  const daily = db
577
- .prepare(`SELECT date(ts) AS day,
578
- NULLIF(trim(COALESCE(repo, '')), '') AS repo,
579
- sum(CASE WHEN grade >= ? THEN 1 ELSE 0 END) AS passed,
580
- sum(CASE WHEN grade < ? AND (outcome IS NULL OR outcome = 'answered') THEN 1 ELSE 0 END) AS missed,
581
- sum(CASE WHEN outcome IN ('declined','dont_know') THEN 1 ELSE 0 END) AS skipped
582
- FROM attempts
583
- WHERE ts >= date('now', ?)
584
- GROUP BY day, repo
586
+ .prepare(`SELECT date(a.ts) AS day,
587
+ NULLIF(trim(COALESCE(a.repo, '')), '') AS repo,
588
+ sum(CASE WHEN a.grade >= ? THEN 1 ELSE 0 END) AS passed,
589
+ sum(CASE WHEN a.grade < ? AND (a.outcome IS NULL OR a.outcome = 'answered') AND fix.id IS NULL THEN 1 ELSE 0 END) AS missed,
590
+ sum(CASE WHEN a.outcome IN ('declined','dont_know') AND fix.id IS NULL THEN 1 ELSE 0 END) AS skipped,
591
+ count(fix.id) AS corrected
592
+ FROM attempts a LEFT JOIN attempts fix ON fix.retry_of = a.id
593
+ WHERE a.${NOT_CORRECTION} AND a.ts >= date('now', ?)
594
+ GROUP BY 1, 2
585
595
  ORDER BY day`)
586
596
  .all(PASSING_GRADE, PASSING_GRADE, `-${TIMELINE_DAYS} days`);
587
597
  // A null repo is the pre-migration-003 bucket. It is kept, not dropped:
@@ -595,18 +605,21 @@ export function dashboardState(db) {
595
605
  min(a.ts) AS first_active,
596
606
  max(a.ts) AS last_active
597
607
  FROM attempts a
608
+ WHERE a.${NOT_CORRECTION}
598
609
  GROUP BY repo
599
610
  ORDER BY last_active DESC`)
600
611
  .all(PASSING_GRADE);
601
612
  const conceptRows = db
602
613
  .prepare(`SELECT c.id, c.slug, c.name, c.domain, c.description, c.tier, c.source,
603
614
  m.score, m.ease, m.interval_d, m.reps, m.next_review, m.last_seen,
604
- (SELECT count(*) FROM attempts a WHERE a.concept_id = c.id) AS attempts,
605
- (SELECT count(*) FROM attempts a WHERE a.concept_id = c.id AND a.grade >= ${PASSING_GRADE}) AS passed,
606
- (SELECT count(*) FROM attempts a WHERE a.concept_id = c.id
615
+ (SELECT count(*) FROM attempts a WHERE a.concept_id = c.id AND a.${NOT_CORRECTION}) AS attempts,
616
+ (SELECT count(*) FROM attempts a WHERE a.concept_id = c.id AND a.${NOT_CORRECTION} AND a.grade >= ${PASSING_GRADE}) AS passed,
617
+ (SELECT count(*) FROM attempts a WHERE a.concept_id = c.id AND a.${NOT_CORRECTION}
607
618
  AND a.outcome IN ('declined','dont_know')) AS skipped,
608
- (SELECT min(a.ts) FROM attempts a WHERE a.concept_id = c.id) AS first_asked,
609
- (SELECT a.grade FROM attempts a WHERE a.concept_id = c.id ORDER BY a.id DESC LIMIT 1) AS last_grade,
619
+ (SELECT count(*) FROM attempts a WHERE a.concept_id = c.id AND a.retry_of IS NOT NULL) AS corrected,
620
+ (SELECT min(a.ts) FROM attempts a WHERE a.concept_id = c.id AND a.${NOT_CORRECTION}) AS first_asked,
621
+ -- The backlog rule's grade (OWED_SQL in store.ts): a correction does not clear it.
622
+ (SELECT a.grade FROM attempts a WHERE a.concept_id = c.id AND a.${NOT_CORRECTION} ORDER BY a.id DESC LIMIT 1) AS last_grade,
610
623
  (SELECT sc.context FROM session_concepts sc
611
624
  WHERE sc.concept_id = c.id AND sc.context IS NOT NULL
612
625
  ORDER BY sc.ts DESC LIMIT 1) AS last_context,
@@ -690,6 +703,7 @@ export function dashboardState(db) {
690
703
  attempts: row.attempts,
691
704
  passed: row.passed,
692
705
  skipped: row.skipped,
706
+ corrected: row.corrected,
693
707
  last_grade: row.last_grade,
694
708
  mastered,
695
709
  owed,
@@ -712,8 +726,14 @@ export function dashboardState(db) {
712
726
  const attempts = db
713
727
  .prepare(`SELECT a.id, c.slug, c.name, c.domain, a.session_id, a.question, a.answer, a.feedback,
714
728
  a.grade, a.difficulty AS tier, a.outcome, a.format, a.options, a.ts,
715
- NULLIF(trim(COALESCE(a.repo, '')), '') AS repo, a.level
729
+ NULLIF(trim(COALESCE(a.repo, '')), '') AS repo, a.level,
730
+ -- A miss corrected from its explainer: when, and on which try.
731
+ fix.ts AS corrected_at,
732
+ CASE WHEN fix.id IS NULL THEN NULL
733
+ ELSE (SELECT count(*) FROM attempt_retries r WHERE r.attempt_id = a.id) END AS corrected_try
716
734
  FROM attempts a JOIN concepts c ON c.id = a.concept_id
735
+ LEFT JOIN attempts fix ON fix.retry_of = a.id
736
+ WHERE a.${NOT_CORRECTION}
717
737
  ORDER BY a.id DESC
718
738
  LIMIT ?`)
719
739
  .all(ATTEMPT_LIMIT);
@@ -737,12 +757,14 @@ export function dashboardState(db) {
737
757
  });
738
758
  const allTime = db
739
759
  .prepare(`SELECT count(*) AS answers,
740
- sum(CASE WHEN grade >= ? THEN 1 ELSE 0 END) AS passed,
741
- sum(CASE WHEN grade < ? AND (outcome IS NULL OR outcome = 'answered') THEN 1 ELSE 0 END) AS missed,
742
- sum(CASE WHEN outcome IN ('declined','dont_know') THEN 1 ELSE 0 END) AS skipped,
743
- count(DISTINCT date(ts)) AS active_days,
744
- min(ts) AS first_answer
745
- FROM attempts`)
760
+ sum(CASE WHEN a.grade >= ? THEN 1 ELSE 0 END) AS passed,
761
+ sum(CASE WHEN a.grade < ? AND (a.outcome IS NULL OR a.outcome = 'answered') AND fix.id IS NULL THEN 1 ELSE 0 END) AS missed,
762
+ sum(CASE WHEN a.outcome IN ('declined','dont_know') AND fix.id IS NULL THEN 1 ELSE 0 END) AS skipped,
763
+ count(fix.id) AS corrected,
764
+ count(DISTINCT date(a.ts)) AS active_days,
765
+ min(a.ts) AS first_answer
766
+ FROM attempts a LEFT JOIN attempts fix ON fix.retry_of = a.id
767
+ WHERE a.${NOT_CORRECTION}`)
746
768
  .get(PASSING_GRADE, PASSING_GRADE);
747
769
  const sessionCount = db.prepare('SELECT count(DISTINCT session_id) AS n FROM session_concepts').get().n;
748
770
  const memory = memorySummary(db);
@@ -767,6 +789,7 @@ export function dashboardState(db) {
767
789
  */
768
790
  cursor: [
769
791
  allTime.answers,
792
+ allTime.corrected,
770
793
  logged.length,
771
794
  sessionCount,
772
795
  memory.captured,
@@ -796,6 +819,7 @@ export function dashboardState(db) {
796
819
  passed: allTime.passed ?? 0,
797
820
  missed: allTime.missed ?? 0,
798
821
  skipped: allTime.skipped ?? 0,
822
+ corrected: allTime.corrected,
799
823
  mastered: concepts.filter((c) => c.mastered).length,
800
824
  due: concepts.filter((c) => c.due).length,
801
825
  touched: concepts.filter((c) => c.seen).length,
@@ -834,9 +858,20 @@ export function dashboardState(db) {
834
858
  memory_sessions: memorySessions(db),
835
859
  // The third workflow. Read from the files themselves on every load
836
860
  // (`artifacts.ts`): there is no table to fall out of step with the disk.
837
- artifacts: listArtifacts(),
861
+ artifacts: artifactRows(db),
838
862
  };
839
863
  }
864
+ /**
865
+ * `listArtifacts`, plus whether each explainer can still be corrected:
866
+ * `open`, `done`, or null for a page with no attempt, a plain artifact, or an
867
+ * attempt recorded without its right answer. One query for all of them.
868
+ */
869
+ function artifactRows(db) {
870
+ const rows = listArtifacts();
871
+ const ids = rows.filter((r) => r.kind === 'explainer' && r.attempt !== null).map((r) => r.attempt);
872
+ const states = correctionStates(db, ids);
873
+ return rows.map((r) => ({ ...r, correction: (r.kind === 'explainer' && states.get(r.attempt)) || null }));
874
+ }
840
875
  /**
841
876
  * The platform's URL opener, as a command and its arguments.
842
877
  *
@@ -1070,6 +1105,61 @@ export function updateSetting(db, body) {
1070
1105
  // Room for the largest value `SETTING_RULES` accepts — 100 list lines of 500
1071
1106
  // characters, JSON-escaped — so the page refuses nothing the CLI would take.
1072
1107
  const MAX_SETTINGS_BODY = 256 * 1024;
1108
+ /** Each refusal a correction can meet: its status and the words the modal shows. */
1109
+ const CORRECTION_ERRORS = {
1110
+ not_found: { status: 404, error: 'No such question.' },
1111
+ not_correctable: { status: 409, error: 'This answer cannot be corrected: it was recorded without its right answer, or it was not a miss.' },
1112
+ already_corrected: { status: 409, error: 'Already corrected.' },
1113
+ not_an_option: { status: 400, error: 'That is not one of the options.' },
1114
+ };
1115
+ function correctionError(code) {
1116
+ const e = CORRECTION_ERRORS[code];
1117
+ return { status: e.status, body: { error: e.error, code } };
1118
+ }
1119
+ /**
1120
+ * `GET /api/attempts/correction?id=`: the missed question the correction modal
1121
+ * shows. Never the right answer -- the server grades a pick, the page does not.
1122
+ */
1123
+ export function correctionState(db, id) {
1124
+ const t = correctionTarget(db, id);
1125
+ if (!t)
1126
+ return correctionError('not_found');
1127
+ if (!t.correctable)
1128
+ return correctionError('not_correctable');
1129
+ const { correct: _answer, correctable: _c, ...shown } = t;
1130
+ return { status: 200, body: shown };
1131
+ }
1132
+ /** `POST /api/attempts/retry`: `{ attempt_id, picked }`, one pick on the correction modal. */
1133
+ export function retryAttempt(db, body) {
1134
+ const bad = (error) => ({ status: 400, body: { error } });
1135
+ if (!body || typeof body !== 'object' || Array.isArray(body))
1136
+ return bad('Expected a JSON object.');
1137
+ const { attempt_id: id, picked } = body;
1138
+ if (typeof id !== 'number' || !Number.isSafeInteger(id) || id < 1)
1139
+ return bad('attempt_id is a positive integer.');
1140
+ if (typeof picked !== 'string' || picked.length > LIMITS.option)
1141
+ return bad('picked is the option label, as text.');
1142
+ try {
1143
+ return { status: 200, body: recordRetry(db, id, picked, new Date()) };
1144
+ }
1145
+ catch (err) {
1146
+ if (err instanceof CorrectionError)
1147
+ return correctionError(err.code);
1148
+ throw err;
1149
+ }
1150
+ }
1151
+ /**
1152
+ * Every route that writes, and nothing else does. Exact paths, no parameters:
1153
+ * a write carries its target in its JSON body. A new write is one row here and
1154
+ * one handler; `acceptWrite` gives it the same guard as the rest, and the
1155
+ * table-driven test in `dashboard.test.ts` runs every guard against it.
1156
+ */
1157
+ export const WRITES = {
1158
+ '/api/settings': { handler: updateSetting, maxBytes: MAX_SETTINGS_BODY },
1159
+ // An option label is at most LIMITS.option characters; 16 KiB holds one
1160
+ // JSON-escaped with room to spare.
1161
+ '/api/attempts/retry': { handler: retryAttempt, maxBytes: 16 * 1024 },
1162
+ };
1073
1163
  /**
1074
1164
  * What a browser may do with anything this server sends. The page is one file
1075
1165
  * with an inline script and inline styles, a `data:` favicon, and same-origin
@@ -1108,10 +1198,67 @@ const ARTIFACT_CSP = [
1108
1198
  "style-src 'unsafe-inline' https://fonts.googleapis.com",
1109
1199
  'font-src https://fonts.gstatic.com',
1110
1200
  'img-src data: blob:',
1111
- "frame-ancestors 'none'",
1201
+ // The dashboard's explainer viewer frames it (`#/artifacts/view/`); the
1202
+ // sandbox above still keeps the framed page off the API.
1203
+ "frame-ancestors 'self'",
1112
1204
  "base-uri 'none'",
1113
1205
  "form-action 'none'",
1114
1206
  ].join('; ');
1207
+ /**
1208
+ * The same page framed in the dashboard's viewer (`?embed`). The dashboard
1209
+ * makes no request to any other host, and a framed page's requests are the
1210
+ * dashboard's, so the web fonts go: the template's links are cut out
1211
+ * (`withoutWebFonts`), and this CSP refuses any that an agent added.
1212
+ */
1213
+ const ARTIFACT_EMBED_CSP = ARTIFACT_CSP
1214
+ .replace(" https://fonts.googleapis.com", '')
1215
+ .replace('font-src https://fonts.gstatic.com', "font-src 'none'");
1216
+ /** An artifact without its Google Fonts links; it falls back to the system faces in the token stacks. */
1217
+ export function withoutWebFonts(html) {
1218
+ return html.replace(/<link\b[^>]*\bhref="https:\/\/fonts\.(?:googleapis|gstatic)\.com[^>]*>\s*/gi, '');
1219
+ }
1220
+ /**
1221
+ * Added to every page the viewer frames, so the window is the one scrollbar:
1222
+ * the page stops scrolling itself and reports its height, which the viewer
1223
+ * gives the frame. The height is the body's bottom edge, which shrinks when the
1224
+ * window widens; `scrollHeight` cannot drop below the frame's own height. Pages
1225
+ * on disk carry `main{min-height:100vh}`, and 100vh is the frame, so that floor
1226
+ * goes too. Past the viewer's 20000px cap the page scrolls itself again.
1227
+ */
1228
+ export const EMBED_SCRIPT = `<script>
1229
+ (function () {
1230
+ if (parent === window || window.__eklavyaEmbed) return; window.__eklavyaEmbed = 1;
1231
+ var root = document.documentElement, body = document.body, last = 0, css = document.createElement('style');
1232
+ root.classList.add('framed');
1233
+ css.textContent = 'html,body{overflow-y:hidden !important}.framed main{min-height:0 !important}';
1234
+ (document.head || root).appendChild(css);
1235
+ function post() {
1236
+ var h = Math.ceil(body.getBoundingClientRect().bottom + scrollY + parseFloat(getComputedStyle(body).marginBottom));
1237
+ css.disabled = h > 20000;
1238
+ if (h !== last) { last = h; parent.postMessage({ type: 'eklavya:height', h: h }, '*'); }
1239
+ }
1240
+ post();
1241
+ addEventListener('DOMContentLoaded', post);
1242
+ addEventListener('load', post);
1243
+ if (window.ResizeObserver) new ResizeObserver(post).observe(body);
1244
+ if (document.fonts) document.fonts.ready.then(post);
1245
+ addEventListener('message', function (e) {
1246
+ var d = e.data;
1247
+ if (e.source === parent && d && d.type === 'eklavya:mode' && (d.mode === 'ink' || d.mode === 'paper')) root.setAttribute('data-mode', d.mode);
1248
+ });
1249
+ })();
1250
+ </script>`;
1251
+ /**
1252
+ * The page as the viewer frames it: no web fonts, and `EMBED_SCRIPT` just
1253
+ * before the last `</body>` (or at the end). A 1.46.0 page carries its own
1254
+ * height report, inside the script that also holds its HTML button; renaming
1255
+ * its message keeps that button and takes the stale height out of the race.
1256
+ */
1257
+ export function embedHtml(html) {
1258
+ const page = withoutWebFonts(html).replace(/type:'eklavya:height'/g, "type:'eklavya:height-1.46'");
1259
+ const at = page.toLowerCase().lastIndexOf('</body>');
1260
+ return at < 0 ? page + EMBED_SCRIPT : page.slice(0, at) + EMBED_SCRIPT + page.slice(at);
1261
+ }
1115
1262
  /**
1116
1263
  * What makes the page installable as an app (Chrome and Edge "Install",
1117
1264
  * Safari "Add to Dock"), so it opens from Spotlight or the Dock in its own
@@ -1178,17 +1325,19 @@ export function startDashboard(db, opts = {}) {
1178
1325
  const host = opts.host ?? '127.0.0.1';
1179
1326
  const wanted = opts.port ?? dashboardPort();
1180
1327
  const assets = path.join(moduleDir, 'assets');
1181
- // Per server start. Every write must carry it (see `postSettings`).
1328
+ // Per server start. Every write must carry it (see `acceptWrite`).
1182
1329
  const token = randomBytes(24).toString('hex');
1183
1330
  const json = (res, status, body) => send(res, status, 'application/json', JSON.stringify(body));
1184
1331
  /**
1185
- * The one mutating route, and why the loopback check alone is not enough for
1186
- * it: a sandboxed frame on any page sends `Origin: null`, which the read
1187
- * routes accept. So a write also needs a real loopback `Origin`, a JSON
1188
- * content type (which a cross-origin form cannot send without a preflight
1189
- * this server never grants), and the token only this page was served.
1332
+ * The guard every write goes through, and why the loopback check alone is
1333
+ * not enough for one: a sandboxed frame on any page sends `Origin: null`,
1334
+ * which the read routes accept. So a write also needs a real loopback
1335
+ * `Origin`, a JSON content type (which a cross-origin form cannot send
1336
+ * without a preflight this server never grants), and the token only this
1337
+ * page was served. Only then is the body read, capped, parsed and handed to
1338
+ * the route's handler.
1190
1339
  */
1191
- const postSettings = (req, res) => {
1340
+ const acceptWrite = (req, res, route) => {
1192
1341
  const origin = req.headers.origin;
1193
1342
  if (!origin || origin === 'null')
1194
1343
  return json(res, 403, { error: 'A write needs a loopback Origin.' });
@@ -1200,14 +1349,14 @@ export function startDashboard(db, opts = {}) {
1200
1349
  if (sent.length !== want.length || !timingSafeEqual(sent, want)) {
1201
1350
  return json(res, 403, { error: 'Stale or missing dashboard token. Reload the page.' });
1202
1351
  }
1203
- if (Number(req.headers['content-length'] ?? 0) > MAX_SETTINGS_BODY) {
1352
+ if (Number(req.headers['content-length'] ?? 0) > route.maxBytes) {
1204
1353
  return json(res, 413, { error: 'Request too large.' });
1205
1354
  }
1206
1355
  const chunks = [];
1207
1356
  let size = 0;
1208
1357
  req.on('data', (c) => {
1209
1358
  size += c.length;
1210
- if (size > MAX_SETTINGS_BODY) {
1359
+ if (size > route.maxBytes) {
1211
1360
  // Answer first and drop the connection only once the answer is out,
1212
1361
  // so the browser reads the 413 instead of a reset.
1213
1362
  res.on('finish', () => req.destroy());
@@ -1229,7 +1378,7 @@ export function startDashboard(db, opts = {}) {
1229
1378
  catch {
1230
1379
  return json(res, 400, { error: 'Not valid JSON.' });
1231
1380
  }
1232
- const out = updateSetting(db, body);
1381
+ const out = route.handler(db, body);
1233
1382
  json(res, out.status, out.body);
1234
1383
  });
1235
1384
  };
@@ -1238,8 +1387,7 @@ export function startDashboard(db, opts = {}) {
1238
1387
  // A page the developer happens to have open can point a hostname it
1239
1388
  // controls at 127.0.0.1 and fetch from here -- DNS rebinding -- and the
1240
1389
  // same-origin policy does not help, because the page's origin *is* that
1241
- // hostname. The risk is a write to settings (`postSettings` adds a token
1242
- // for that) and that this payload contains the developer's prompts, code and project
1390
+ // hostname. The risk is a write (`acceptWrite` adds a token for those) and that this payload contains the developer's prompts, code and project
1243
1391
  // history, and a hostile page would be reading all of it.
1244
1392
  //
1245
1393
  // The check is the standard one: the request has to have been addressed to
@@ -1249,14 +1397,14 @@ export function startDashboard(db, opts = {}) {
1249
1397
  }
1250
1398
  /* c8 ignore next -- a server-side request always carries its url; the fallback is for the type */
1251
1399
  const url = new URL(req.url ?? '/', `http://${host}`);
1252
- // One route writes: a setting, through the same `applySetting` the CLI
1253
- // uses. Everything else reads, and any other method is refused rather
1254
- // than answered as a GET.
1255
- if (url.pathname === '/api/settings' && req.method === 'POST')
1256
- return postSettings(req, res);
1400
+ // The routes in `WRITES` take a POST, through `acceptWrite`. Everything
1401
+ // else reads, and any other method is refused rather than answered as a GET.
1402
+ const write = WRITES[url.pathname];
1403
+ if (write && req.method === 'POST')
1404
+ return acceptWrite(req, res, write);
1257
1405
  if (req.method !== 'GET' && req.method !== 'HEAD') {
1258
- return send(res, 405, 'text/plain', 'Only /api/settings accepts a write; everything else here is read-only.\n', {
1259
- allow: url.pathname === '/api/settings' ? 'GET, HEAD, POST' : 'GET, HEAD',
1406
+ return send(res, 405, 'text/plain', `This route is read-only. Writes: ${Object.keys(WRITES).join(', ')}.\n`, {
1407
+ allow: write ? 'GET, HEAD, POST' : 'GET, HEAD',
1260
1408
  });
1261
1409
  }
1262
1410
  try {
@@ -1299,6 +1447,10 @@ export function startDashboard(db, opts = {}) {
1299
1447
  per: Number(g('per')) || MEMORY_PER,
1300
1448
  })));
1301
1449
  }
1450
+ if (url.pathname === '/api/attempts/correction') {
1451
+ const out = correctionState(db, Number(url.searchParams.get('id')));
1452
+ return send(res, out.status, 'application/json', JSON.stringify(out.body));
1453
+ }
1302
1454
  if (url.pathname === '/api/memory/entry') {
1303
1455
  const entry = memoryEntry(db, Number(url.searchParams.get('id')));
1304
1456
  if (!entry)
@@ -1327,8 +1479,10 @@ export function startDashboard(db, opts = {}) {
1327
1479
  'cache-control': 'private, max-age=3600',
1328
1480
  });
1329
1481
  }
1330
- return send(res, 200, 'text/html; charset=utf-8', fs.readFileSync(file), {
1331
- 'content-security-policy': ARTIFACT_CSP,
1482
+ const embed = url.searchParams.has('embed');
1483
+ return send(res, 200, 'text/html; charset=utf-8', embed ? embedHtml(fs.readFileSync(file, 'utf8')) : fs.readFileSync(file), {
1484
+ 'content-security-policy': embed ? ARTIFACT_EMBED_CSP : ARTIFACT_CSP,
1485
+ 'x-frame-options': 'SAMEORIGIN',
1332
1486
  });
1333
1487
  }
1334
1488
  if (url.pathname === '/manifest.webmanifest') {