@lxpack/validators 0.1.0 → 0.1.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/README.md ADDED
@@ -0,0 +1,127 @@
1
+ # @lxpack/validators
2
+
3
+ [![npm version](https://img.shields.io/npm/v/@lxpack/validators)](https://www.npmjs.com/package/@lxpack/validators)
4
+ [![CI](https://github.com/eddiethedean/lxpack/actions/workflows/ci.yml/badge.svg)](https://github.com/eddiethedean/lxpack/actions/workflows/ci.yml)
5
+ [![License](https://img.shields.io/github/license/eddiethedean/lxpack)](https://github.com/eddiethedean/lxpack/blob/main/LICENSE)
6
+ [![Node.js](https://img.shields.io/badge/node-%3E%3D20-brightgreen)](https://nodejs.org/)
7
+
8
+ Zod schemas and filesystem validation for LXPack course manifests.
9
+
10
+ Part of [LXPack](https://github.com/eddiethedean/lxpack) — an AI-native learning experience compiler and runtime.
11
+
12
+ | Related | Package |
13
+ |---------|---------|
14
+ | CLI | [`@lxpack/cli`](../cli/README.md) |
15
+ | Packaging | [`@lxpack/scorm`](../scorm/README.md) |
16
+ | Runtime | [`@lxpack/runtime`](../runtime/README.md) |
17
+
18
+ ## Install
19
+
20
+ ```bash
21
+ npm install @lxpack/validators
22
+ ```
23
+
24
+ Requires Node.js 20+.
25
+
26
+ ## Usage
27
+
28
+ ```ts
29
+ import {
30
+ validateCourse,
31
+ loadManifest,
32
+ buildRuntimeAssessmentBundle,
33
+ courseManifestSchema,
34
+ type ValidationResult,
35
+ } from "@lxpack/validators";
36
+
37
+ const result: ValidationResult = await validateCourse("/path/to/my-course");
38
+
39
+ if (!result.valid) {
40
+ for (const issue of result.issues) {
41
+ console.error(`${issue.path}: ${issue.message}`);
42
+ }
43
+ } else {
44
+ const { manifest } = result;
45
+ const bundle = await buildRuntimeAssessmentBundle("/path/to/my-course", manifest);
46
+ // bundle.assessments — learner-facing questions (no correct flags)
47
+ // bundle.answerKeys — scoring keys for the runtime (embedded at build time)
48
+ }
49
+ ```
50
+
51
+ ### Load and parse only
52
+
53
+ ```ts
54
+ const loaded = await loadManifest("/path/to/my-course");
55
+ if (Array.isArray(loaded)) {
56
+ // validation issues
57
+ } else {
58
+ const { manifest } = loaded;
59
+ }
60
+ ```
61
+
62
+ ### Schema-only validation
63
+
64
+ ```ts
65
+ const parsed = courseManifestSchema.safeParse(manifestObject);
66
+ ```
67
+
68
+ ### Safe path resolution
69
+
70
+ ```ts
71
+ import { resolveCoursePath, isPathContained } from "@lxpack/validators";
72
+
73
+ const abs = resolveCoursePath(courseDir, "lessons/intro.md");
74
+ isPathContained(courseDir, abs); // true if inside course root
75
+ ```
76
+
77
+ ## Exports
78
+
79
+ | Export | Description |
80
+ |--------|-------------|
81
+ | `validateCourse(dir)` | Parse `course.yaml`, validate schema, check files, symlink containment |
82
+ | `loadManifest(courseDir)` | Load and parse `course.yaml` |
83
+ | `buildRuntimeAssessmentBundle(dir, manifest)` | Load assessments; split learner view vs answer keys |
84
+ | `toLearnerAssessment(assessment)` | Strip `correct` / `explanation` from one assessment |
85
+ | `resolveCoursePath(dir, relativePath)` | Resolve a path safely inside the course directory |
86
+ | `isPathContained(root, target)` | Whether `target` stays under `root` |
87
+ | `courseManifestSchema` | Zod schema for the full course manifest |
88
+ | `lessonSchema`, `assessmentSchema`, … | Strict sub-schemas for manifest sections |
89
+ | `CourseManifest`, `Lesson`, `Assessment`, `LearnerAssessment`, `RuntimeAssessmentBundle` | TypeScript types |
90
+
91
+ ## What gets validated
92
+
93
+ - Manifest shape (lessons, assessments, tracking rules)
94
+ - Lesson types: `markdown` (`file`) and `html` (`path`)
95
+ - Assessment YAML: strict MCQ schemas (`correct` on exactly one choice per question)
96
+ - Duplicate lesson IDs
97
+ - Path containment — referenced files must stay inside the course directory (including via symlinks)
98
+ - On-disk assets: files exist and assessments paths are regular files
99
+
100
+ ## Assessment packaging
101
+
102
+ Author assessments live as YAML under `assessments/` in the course repo. At build/preview time:
103
+
104
+ 1. `buildRuntimeAssessmentBundle()` reads each assessment file.
105
+ 2. **Learner payload** — questions and choices without `correct` or `explanation`.
106
+ 3. **Answer keys** — `questionId → choiceId` map for scoring.
107
+
108
+ The CLI and [`@lxpack/scorm`](../scorm/README.md) embed both in the HTML config JSON. Exported ZIPs **do not** include `assessments/` files, so answer keys are not fetchable as static assets.
109
+
110
+ ## Development
111
+
112
+ From the monorepo root:
113
+
114
+ ```bash
115
+ pnpm --filter @lxpack/validators build
116
+ pnpm --filter @lxpack/validators test
117
+ pnpm --filter @lxpack/validators typecheck
118
+ ```
119
+
120
+ ## Links
121
+
122
+ - [LXPack repository](https://github.com/eddiethedean/lxpack)
123
+ - [Changelog](https://github.com/eddiethedean/lxpack/blob/main/CHANGELOG.md)
124
+
125
+ ## License
126
+
127
+ Apache-2.0
package/dist/index.d.ts CHANGED
@@ -7,7 +7,7 @@ declare const assessmentQuestionSchema: z.ZodEffects<z.ZodObject<{
7
7
  id: z.ZodString;
8
8
  text: z.ZodString;
9
9
  correct: z.ZodOptional<z.ZodBoolean>;
10
- }, "strip", z.ZodTypeAny, {
10
+ }, "strict", z.ZodTypeAny, {
11
11
  id: string;
12
12
  text: string;
13
13
  correct?: boolean | undefined;
@@ -17,7 +17,7 @@ declare const assessmentQuestionSchema: z.ZodEffects<z.ZodObject<{
17
17
  correct?: boolean | undefined;
18
18
  }>, "many">;
19
19
  explanation: z.ZodOptional<z.ZodString>;
20
- }, "strip", z.ZodTypeAny, {
20
+ }, "strict", z.ZodTypeAny, {
21
21
  id: string;
22
22
  prompt: string;
23
23
  choices: {
@@ -128,7 +128,7 @@ declare const assessmentSchema: z.ZodObject<{
128
128
  id: z.ZodString;
129
129
  text: z.ZodString;
130
130
  correct: z.ZodOptional<z.ZodBoolean>;
131
- }, "strip", z.ZodTypeAny, {
131
+ }, "strict", z.ZodTypeAny, {
132
132
  id: string;
133
133
  text: string;
134
134
  correct?: boolean | undefined;
@@ -138,7 +138,7 @@ declare const assessmentSchema: z.ZodObject<{
138
138
  correct?: boolean | undefined;
139
139
  }>, "many">;
140
140
  explanation: z.ZodOptional<z.ZodString>;
141
- }, "strip", z.ZodTypeAny, {
141
+ }, "strict", z.ZodTypeAny, {
142
142
  id: string;
143
143
  prompt: string;
144
144
  choices: {
@@ -379,6 +379,7 @@ interface ValidationResult {
379
379
  manifest?: CourseManifest;
380
380
  issues: ValidationIssue[];
381
381
  }
382
+ declare function isPathContained(rootDir: string, candidatePath: string): boolean;
382
383
  declare function resolveCoursePath(courseDir: string, relativePath: string): {
383
384
  ok: true;
384
385
  path: string;
@@ -386,10 +387,41 @@ declare function resolveCoursePath(courseDir: string, relativePath: string): {
386
387
  ok: false;
387
388
  message: string;
388
389
  };
390
+ declare function assertResolvedPathContained(courseDir: string, resolvedPath: string): {
391
+ ok: true;
392
+ } | {
393
+ ok: false;
394
+ message: string;
395
+ };
389
396
  declare function loadManifest(courseDir: string): Promise<{
390
397
  manifest: CourseManifest;
391
398
  raw: unknown;
392
399
  } | ValidationIssue[]>;
393
400
  declare function validateCourse(courseDir: string): Promise<ValidationResult>;
394
401
 
395
- export { type Assessment, type AssessmentRef, type CourseManifest, type Lesson, type ValidationIssue, type ValidationResult, assessmentQuestionSchema, assessmentRefSchema, assessmentSchema, courseManifestSchema, formatErrorMessage, formatIssuePath, htmlLessonSchema, lessonSchema, loadManifest, markdownLessonSchema, resolveCoursePath, runtimeConfigSchema, trackingSchema, validateCourse };
402
+ interface LearnerChoice {
403
+ id: string;
404
+ text: string;
405
+ }
406
+ interface LearnerQuestion {
407
+ id: string;
408
+ prompt: string;
409
+ choices: LearnerChoice[];
410
+ }
411
+ interface LearnerAssessment {
412
+ id: string;
413
+ title?: string;
414
+ passingScore: number;
415
+ questions: LearnerQuestion[];
416
+ }
417
+ interface RuntimeAssessmentBundle {
418
+ assessments: Record<string, LearnerAssessment>;
419
+ answerKeys: Record<string, Record<string, string>>;
420
+ }
421
+ declare function toLearnerAssessment(assessment: Assessment): {
422
+ learner: LearnerAssessment;
423
+ answerKey: Record<string, string>;
424
+ };
425
+ declare function buildRuntimeAssessmentBundle(courseDir: string, manifest: CourseManifest): Promise<RuntimeAssessmentBundle>;
426
+
427
+ export { type Assessment, type AssessmentRef, type CourseManifest, type LearnerAssessment, type LearnerChoice, type LearnerQuestion, type Lesson, type RuntimeAssessmentBundle, type ValidationIssue, type ValidationResult, assertResolvedPathContained, assessmentQuestionSchema, assessmentRefSchema, assessmentSchema, buildRuntimeAssessmentBundle, courseManifestSchema, formatErrorMessage, formatIssuePath, htmlLessonSchema, isPathContained, lessonSchema, loadManifest, markdownLessonSchema, resolveCoursePath, runtimeConfigSchema, toLearnerAssessment, trackingSchema, validateCourse };
package/dist/index.js CHANGED
@@ -4,13 +4,13 @@ var choiceSchema = z.object({
4
4
  id: z.string().min(1),
5
5
  text: z.string().min(1),
6
6
  correct: z.boolean().optional()
7
- });
7
+ }).strict();
8
8
  var assessmentQuestionSchema = z.object({
9
9
  id: z.string().min(1),
10
10
  prompt: z.string().min(1),
11
11
  choices: z.array(choiceSchema).min(1),
12
12
  explanation: z.string().optional()
13
- }).superRefine((question, ctx) => {
13
+ }).strict().superRefine((question, ctx) => {
14
14
  const correctCount = question.choices.filter((c) => c.correct === true).length;
15
15
  if (correctCount !== 1) {
16
16
  ctx.addIssue({
@@ -65,8 +65,8 @@ var courseManifestSchema = z.object({
65
65
  }).strict();
66
66
 
67
67
  // src/validate.ts
68
- import { existsSync, statSync } from "fs";
69
- import { join, resolve, sep } from "path";
68
+ import { existsSync, realpathSync, statSync } from "fs";
69
+ import { isAbsolute, join, relative, resolve } from "path";
70
70
  import { parse as parseYaml } from "yaml";
71
71
  import { readFile } from "fs/promises";
72
72
  function formatErrorMessage(err) {
@@ -76,20 +76,39 @@ function formatIssuePath(path) {
76
76
  const joined = path.map(String).join(".");
77
77
  return joined || "course.yaml";
78
78
  }
79
+ function isPathContained(rootDir, candidatePath) {
80
+ const root = resolve(rootDir);
81
+ const candidate = resolve(candidatePath);
82
+ const rel = relative(root, candidate);
83
+ if (rel === "") return true;
84
+ return !rel.startsWith("..") && !isAbsolute(rel);
85
+ }
79
86
  function resolveCoursePath(courseDir, relativePath) {
80
87
  if (relativePath.startsWith("/") || /^[a-zA-Z]:\\/.test(relativePath)) {
81
88
  return { ok: false, message: "Absolute paths are not allowed" };
82
89
  }
83
90
  const resolvedDir = resolve(courseDir);
84
91
  const resolvedPath = resolve(resolvedDir, relativePath);
85
- const prefix = `${resolvedDir}${sep}`;
86
- if (!resolvedPath.startsWith(prefix)) {
92
+ if (!isPathContained(resolvedDir, resolvedPath)) {
87
93
  return { ok: false, message: "Path escapes course directory" };
88
94
  }
89
95
  return { ok: true, path: resolvedPath };
90
96
  }
97
+ function assertResolvedPathContained(courseDir, resolvedPath) {
98
+ try {
99
+ const root = realpathSync(resolve(courseDir));
100
+ const target = realpathSync(resolvedPath);
101
+ if (!isPathContained(root, target)) {
102
+ return { ok: false, message: "Path escapes course directory" };
103
+ }
104
+ return { ok: true };
105
+ } catch {
106
+ return { ok: false, message: "Path could not be resolved" };
107
+ }
108
+ }
91
109
  async function loadManifest(courseDir) {
92
- const manifestPath = join(courseDir, "course.yaml");
110
+ const resolvedDir = resolve(courseDir);
111
+ const manifestPath = join(resolvedDir, "course.yaml");
93
112
  if (!existsSync(manifestPath)) {
94
113
  return [
95
114
  {
@@ -149,6 +168,15 @@ async function validateCourse(courseDir) {
149
168
  });
150
169
  continue;
151
170
  }
171
+ const contained = assertResolvedPathContained(resolvedDir, resolved.path);
172
+ if (!contained.ok) {
173
+ issues.push({
174
+ path: `lessons.${lesson.id}.file`,
175
+ message: contained.message,
176
+ severity: "error"
177
+ });
178
+ continue;
179
+ }
152
180
  const stat = statSync(resolved.path);
153
181
  if (!stat.isFile()) {
154
182
  issues.push({
@@ -175,6 +203,15 @@ async function validateCourse(courseDir) {
175
203
  });
176
204
  continue;
177
205
  }
206
+ const contained = assertResolvedPathContained(resolvedDir, resolved.path);
207
+ if (!contained.ok) {
208
+ issues.push({
209
+ path: `lessons.${lesson.id}.path`,
210
+ message: contained.message,
211
+ severity: "error"
212
+ });
213
+ continue;
214
+ }
178
215
  const stat = statSync(resolved.path);
179
216
  if (!stat.isDirectory()) {
180
217
  issues.push({
@@ -191,12 +228,24 @@ async function validateCourse(courseDir) {
191
228
  message: `HTML interaction missing index.html: ${lesson.path}`,
192
229
  severity: "error"
193
230
  });
194
- } else if (!statSync(indexPath).isFile()) {
195
- issues.push({
196
- path: `lessons.${lesson.id}.path`,
197
- message: `index.html is not a file: ${lesson.path}/index.html`,
198
- severity: "error"
199
- });
231
+ } else {
232
+ const indexContained = assertResolvedPathContained(
233
+ resolvedDir,
234
+ indexPath
235
+ );
236
+ if (!indexContained.ok) {
237
+ issues.push({
238
+ path: `lessons.${lesson.id}.path`,
239
+ message: indexContained.message,
240
+ severity: "error"
241
+ });
242
+ } else if (!statSync(indexPath).isFile()) {
243
+ issues.push({
244
+ path: `lessons.${lesson.id}.path`,
245
+ message: `index.html is not a file: ${lesson.path}/index.html`,
246
+ severity: "error"
247
+ });
248
+ }
200
249
  }
201
250
  }
202
251
  }
@@ -228,6 +277,24 @@ async function validateCourse(courseDir) {
228
277
  });
229
278
  continue;
230
279
  }
280
+ const contained = assertResolvedPathContained(resolvedDir, resolved.path);
281
+ if (!contained.ok) {
282
+ issues.push({
283
+ path: `assessments.${ref.id}.file`,
284
+ message: contained.message,
285
+ severity: "error"
286
+ });
287
+ continue;
288
+ }
289
+ const assessmentStat = statSync(resolved.path);
290
+ if (!assessmentStat.isFile()) {
291
+ issues.push({
292
+ path: `assessments.${ref.id}.file`,
293
+ message: `Assessment path is not a file: ${ref.file}`,
294
+ severity: "error"
295
+ });
296
+ continue;
297
+ }
231
298
  try {
232
299
  const content = await readFile(resolved.path, "utf-8");
233
300
  const raw = parseYaml(content);
@@ -259,13 +326,18 @@ async function validateCourse(courseDir) {
259
326
  }
260
327
  }
261
328
  }
262
- const lessonIds = new Set(manifest.lessons.map((l) => l.id));
263
- if (lessonIds.size !== manifest.lessons.length) {
264
- issues.push({
265
- path: "lessons",
266
- message: "Duplicate lesson IDs detected",
267
- severity: "error"
268
- });
329
+ const lessonIdCounts = /* @__PURE__ */ new Map();
330
+ for (const lesson of manifest.lessons) {
331
+ lessonIdCounts.set(lesson.id, (lessonIdCounts.get(lesson.id) ?? 0) + 1);
332
+ }
333
+ for (const [id, count] of lessonIdCounts) {
334
+ if (count > 1) {
335
+ issues.push({
336
+ path: "lessons",
337
+ message: `Duplicate lesson ID: ${id}`,
338
+ severity: "error"
339
+ });
340
+ }
269
341
  }
270
342
  return {
271
343
  valid: issues.filter((i) => i.severity === "error").length === 0,
@@ -273,19 +345,77 @@ async function validateCourse(courseDir) {
273
345
  issues
274
346
  };
275
347
  }
348
+
349
+ // src/assessments.ts
350
+ import { readFile as readFile2 } from "fs/promises";
351
+ import { parse as parseYaml2 } from "yaml";
352
+ function toLearnerAssessment(assessment) {
353
+ const answerKey = {};
354
+ const questions = assessment.questions.map((q) => {
355
+ const correct = q.choices.find((c) => c.correct === true);
356
+ if (correct) {
357
+ answerKey[q.id] = correct.id;
358
+ }
359
+ return {
360
+ id: q.id,
361
+ prompt: q.prompt,
362
+ choices: q.choices.map((c) => ({ id: c.id, text: c.text }))
363
+ };
364
+ });
365
+ return {
366
+ learner: {
367
+ id: assessment.id,
368
+ title: assessment.title,
369
+ passingScore: assessment.passingScore,
370
+ questions
371
+ },
372
+ answerKey
373
+ };
374
+ }
375
+ async function buildRuntimeAssessmentBundle(courseDir, manifest) {
376
+ const assessments = {};
377
+ const answerKeys = {};
378
+ for (const ref of manifest.assessments ?? []) {
379
+ const resolved = resolveCoursePath(courseDir, ref.file);
380
+ if (!resolved.ok) {
381
+ throw new Error(resolved.message);
382
+ }
383
+ const content = await readFile2(resolved.path, "utf-8");
384
+ const raw = parseYaml2(content);
385
+ const parsed = assessmentSchema.safeParse(raw);
386
+ if (!parsed.success) {
387
+ throw new Error(
388
+ `Invalid assessment ${ref.file}: ${parsed.error.issues.map((i) => i.message).join("; ")}`
389
+ );
390
+ }
391
+ if (parsed.data.id !== ref.id) {
392
+ throw new Error(
393
+ `Assessment file id "${parsed.data.id}" does not match manifest ref "${ref.id}"`
394
+ );
395
+ }
396
+ const { learner, answerKey } = toLearnerAssessment(parsed.data);
397
+ assessments[ref.id] = learner;
398
+ answerKeys[ref.id] = answerKey;
399
+ }
400
+ return { assessments, answerKeys };
401
+ }
276
402
  export {
403
+ assertResolvedPathContained,
277
404
  assessmentQuestionSchema,
278
405
  assessmentRefSchema,
279
406
  assessmentSchema,
407
+ buildRuntimeAssessmentBundle,
280
408
  courseManifestSchema,
281
409
  formatErrorMessage,
282
410
  formatIssuePath,
283
411
  htmlLessonSchema,
412
+ isPathContained,
284
413
  lessonSchema,
285
414
  loadManifest,
286
415
  markdownLessonSchema,
287
416
  resolveCoursePath,
288
417
  runtimeConfigSchema,
418
+ toLearnerAssessment,
289
419
  trackingSchema,
290
420
  validateCourse
291
421
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lxpack/validators",
3
- "version": "0.1.0",
3
+ "version": "0.1.1",
4
4
  "description": "Course manifest validation for LXPack",
5
5
  "license": "Apache-2.0",
6
6
  "repository": {