@happyvertical/smrt-education 0.59.0 → 0.60.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 (47) hide show
  1. package/AGENTS.md +3 -1
  2. package/README.md +7 -2
  3. package/agents/class-crunchlabs.md +8 -4
  4. package/agents/course-bundle-import.md +89 -10
  5. package/agents/group-sharing.md +7 -0
  6. package/agents/lesson-materials.md +21 -4
  7. package/dist/chunks/course-authoring-contracts-C4Hqba-e.js.map +1 -1
  8. package/dist/chunks/{course-bundle-zip-5KUe3C5H.js → course-bundle-zip-CGR1Ci0Z.js} +3 -2
  9. package/dist/chunks/course-bundle-zip-CGR1Ci0Z.js.map +1 -0
  10. package/dist/chunks/{workbook-photo-service-CjcB0y8Y.js → workbook-photo-service-DHP5ja82.js} +103 -46
  11. package/dist/chunks/workbook-photo-service-DHP5ja82.js.map +1 -0
  12. package/dist/course-authoring-contracts.d.ts +1 -1
  13. package/dist/course-authoring-contracts.d.ts.map +1 -1
  14. package/dist/course-bundle-contracts.d.ts +2 -0
  15. package/dist/course-bundle-contracts.d.ts.map +1 -1
  16. package/dist/course-bundle-manifest-v2.d.ts +24 -1
  17. package/dist/course-bundle-manifest-v2.d.ts.map +1 -1
  18. package/dist/course-bundle-models.d.ts +88 -0
  19. package/dist/course-bundle-models.d.ts.map +1 -1
  20. package/dist/course-bundle-service.d.ts +153 -7
  21. package/dist/course-bundle-service.d.ts.map +1 -1
  22. package/dist/course-bundle.d.ts +17 -0
  23. package/dist/course-bundle.d.ts.map +1 -1
  24. package/dist/document-converter.d.ts +8 -0
  25. package/dist/document-converter.d.ts.map +1 -1
  26. package/dist/group-sharing-contracts.d.ts +4 -0
  27. package/dist/group-sharing-contracts.d.ts.map +1 -1
  28. package/dist/group-sharing-reader.d.ts.map +1 -1
  29. package/dist/hearth-class-crunchlabs.d.ts +3 -2
  30. package/dist/hearth-class-crunchlabs.d.ts.map +1 -1
  31. package/dist/index.d.ts +2 -2
  32. package/dist/index.d.ts.map +1 -1
  33. package/dist/index.js +865 -134
  34. package/dist/index.js.map +1 -1
  35. package/dist/ingestion.js +1 -1
  36. package/dist/lesson-assets.d.ts +19 -0
  37. package/dist/lesson-assets.d.ts.map +1 -1
  38. package/dist/manifest.json +1803 -145
  39. package/dist/node.js +1 -1
  40. package/dist/smrt-knowledge.json +573 -17
  41. package/dist/sources/class-crunchlabs.d.ts +7 -0
  42. package/dist/sources/class-crunchlabs.d.ts.map +1 -1
  43. package/dist/submission-service.d.ts +24 -4
  44. package/dist/submission-service.d.ts.map +1 -1
  45. package/package.json +8 -8
  46. package/dist/chunks/course-bundle-zip-5KUe3C5H.js.map +0 -1
  47. package/dist/chunks/workbook-photo-service-CjcB0y8Y.js.map +0 -1
package/AGENTS.md CHANGED
@@ -60,7 +60,9 @@ id and layout, never text; ambiguous matches stay unattached; source-keyed repla
60
60
  Course bundles (zip with a closed manifest v1 or v2, or a file set; Word/PowerPoint members render through an injected `DocumentConverter`, fail closed; v2 carries material roles, audience, lesson pages, course members and YouTube references) use
61
61
  [CourseBundleImportService](agents/course-bundle-import.md). Parse only with the
62
62
  bounded reader; refuse, never trim, hostile input; unsupported members stay
63
- visible; proposals only, nothing auto-published.
63
+ visible; nothing auto-published. `attachCourse` makes one family draft course per
64
+ unit key (parts merge in any order, mismatches fail closed); review choices never
65
+ open keys, guidance or an editable original to a learner.
64
66
 
65
67
  Class CrunchLabs (a download read into a manifest v2 bundle; an operator-only
66
68
  global catalog provider until the operator's single public switch):
package/README.md CHANGED
@@ -130,7 +130,10 @@ call happens in the package; see [tutor contract](agents/tutor-context.md).
130
130
  `CourseBundleImportService` imports a course zip (optional closed `manifest.json`)
131
131
  or file set under hard size, ratio, path and entry bounds, keeps unsupported and
132
132
  duplicate members as append-only evidence, hands accepted members to ingestion and
133
- proposes sections and lessons through `CourseGenerationReviewService`; see
133
+ proposes sections and lessons through `CourseGenerationReviewService`.
134
+ `attachCourse` turns an import (or each part of a unit split across bundles, in
135
+ any order) into one family course with lessons, materials, pages and videos;
136
+ `chooseMaterialAudience` records review choices before attach; see
134
137
  [bundle import](agents/course-bundle-import.md).
135
138
  `requestGeneration` also accepts a structured `brief` (subject, category, area,
136
139
  grade, objective, a `course` or one-`lesson` target and a per-request interest
@@ -185,7 +188,9 @@ schema migration. `resolveBookPageActivity` matches book key and page key to a
185
188
 
186
189
  Materials carry an audience defaulted by role; learners see only learner
187
190
  material (`listLessonMaterials`, `listCourseMaterials`, `signLessonMaterial`,
188
- `signCourseMaterial`, `setMaterialAudience`, `addLessonVideo`).
191
+ `signCourseMaterial`, `setMaterialAudience`, `addLessonVideo`). A material view
192
+ carries its MIME type, `rendition` (`original` / `rendered`), the paired
193
+ material and an optional thumbnail (sign it with `thumbnail: true`).
189
194
 
190
195
  Renames: `WorkbookService` → `LessonWorkService`, `WorkbookActivity` →
191
196
  `LessonActivity`, `Workbook{Attempt,AssessmentRevision,FeedbackRelease,FeedbackDelivery,CompletionDecision}`
@@ -31,9 +31,9 @@ files, playlists, pageCounter?, receiveMember? })` re-checks
31
31
  `canAddClassCrunchLabsUnit(actor, policy, listing)`: visibility is derived from the
32
32
  host's own `listing.publicListing` (operator unless the literal `true`), never from
33
33
  the record or any caller-supplied visibility or licence; an operator-only listing
34
- is refused to every other family, and the course actor must be the catalog actor, builds the bundle and imports each part. Then propose
35
- structure and `applyMaterials` per part as for any bundle. Courses are
36
- `personal-use-copy` (`build.licenseKind`).
34
+ is refused to every other family, and the course actor must be the catalog actor, builds the bundle and imports each part. Then store each
35
+ part's accepted members and `attachCourse` every part (any order): they merge
36
+ into one family course. Courses are `personal-use-copy` (`build.licenseKind`).
37
37
 
38
38
  ## Reading a download
39
39
 
@@ -74,7 +74,11 @@ skipped) or `memoryClassCrunchLabsSource(files)`. It never reads video files.
74
74
  `build.parts` (default 90 MiB each; the first part carries the course members and
75
75
  any lesson that fits beside them, others go to the first part with room; if no
76
76
  lesson fits beside the course members the build refuses with `too_large`); `build.bundle`
77
- and `build.manifest` are the first part.
77
+ and `build.manifest` are the first part. Every part's manifest names the unit
78
+ (`course.key` = `build.unitKey`, `classCrunchLabsUnitKey(unit)`, and `course.title`),
79
+ its `part` (`index`/`count`) and each lesson's unit-wide `order` (the lesson
80
+ number), so `attachCourse` merges the parts in lesson order whatever order they
81
+ arrive in.
78
82
 
79
83
  The `report` lists lessons, members, parts, pages, matched and unmatched videos,
80
84
  duplicates, ignored files and warnings. Fixtures in tests are synthetic files
@@ -59,6 +59,7 @@ listener never affects the import.
59
59
  | `maxOfficeMembers` (Word/PowerPoint members rendered per bundle) | 50 |
60
60
  | `maxRenderedBytes` (PDF accepted back from the converter) | 50 MiB |
61
61
  | `conversionTimeoutMs` (one Office member) | 60 000 |
62
+ | `maxThumbnailBytes` (first-page image accepted back from the converter) | 5 MiB |
62
63
  | archive nesting | 1: a zip inside a zip is never expanded |
63
64
 
64
65
  A lesson whose combined text members exceed `maxLessonBodyChars` refuses the
@@ -126,7 +127,11 @@ authorization and before the evidence is written:
126
127
  asset: "Open in PowerPoint" for decks) and `rendered` (`{ bytes, sha256, mediaType:
127
128
  'application/pdf', converterId }`, store as a private asset beside the original:
128
129
  the pages for "Present"/reading). The member row records `renderedSha256` and
129
- `renderedSizeBytes`; bytes are still not retained here.
130
+ `renderedSizeBytes`; bytes are still not retained here. A converter may also
131
+ return a first-page image (`DocumentConversionResult.thumbnail`); the importer
132
+ keeps it only when it is a PNG/JPEG/WebP within `maxThumbnailBytes` and hands it
133
+ over as `rendered.thumbnail` (`{ bytes, sha256, mediaType }`), else null. An
134
+ unusable thumbnail never fails the member. Gotenberg draws none, so it is null.
130
135
 
131
136
  **Fail closed.** Any converter problem records that member `unsupported` with a
132
137
  reason and writes the rest of the bundle normally: `converter_unavailable` (none
@@ -230,6 +235,12 @@ the same way: an unknown key anywhere is `manifest_unknown_key` (with its path i
230
235
  `manifest_video`), `step` an integer 0-9999. They become `LessonExternalVideo`
231
236
  rows; nothing is downloaded.
232
237
  - **Course members** become `CourseAsset` rows, not tied to any lesson.
238
+ - **Unit and parts** (SMRT #4632): `course.key` (a manifest key) names the unit
239
+ and `course.title` its course title; `part: { index, count }` (1-50) marks one
240
+ bundle of a unit split across several, and requires `course.key`
241
+ (`manifest_invalid` otherwise). Sections and lessons may carry a unit-wide
242
+ `order` (0-9999); without one the position in this manifest is used, and
243
+ the order counts as implicit when parts are merged.
233
244
 
234
245
  Guardian-audience text (teacher guides, guidance, keys) never enters a proposed
235
246
  lesson body; only learner-audience text members do. Under v2 `role` on the member
@@ -244,6 +255,62 @@ rendering. A duplicate member resolves to the earlier identical member's bytes
244
255
  deck (hand `bytes` to the host as a private asset: "Open in PowerPoint") and its
245
256
  rendered pages (`rendered`: "Present").
246
257
 
258
+ ### Attaching a family course (SMRT #4632)
259
+
260
+ `attachCourse({ ...actor, importId, bundle, assets, courseId?, title?,
261
+ licenseKind? })` (`education.course.author`, operation `attach-course-bundle`) is
262
+ the published path from an import to a course: in one transaction it creates (or
263
+ finds) the family course, its sections and lessons, and writes the materials as
264
+ `applyMaterials` does. `assets` maps member path to `{ original, rendered?,
265
+ thumbnail? }` asset ids the host stored from `receiveMember`.
266
+
267
+ - **Course**: a non-authored family draft (`sourceKey` `course-bundle`,
268
+ `sourceRef` the unit key, `licenseKind` default `personal-use-copy`, title from
269
+ the request, `course.title`, subject or first section). Ids derive from (family,
270
+ unit key), sections and lessons from (course, key), so every part of a unit and
271
+ every retry find the same rows. Lessons keep the manifest key in `externalRef`,
272
+ `order` as `sortOrder` and, on creation, learner-audience text as `body` (after
273
+ review choices). Nothing is released or published.
274
+ - **Unit key**: `course.key`, else `bundle:<bundleIdentity>` (a one-part unit).
275
+ - **Parts**: one `CourseBundleAttachment` per import (unique per import and per
276
+ (course, part)). Attach parts in any order; the result says `part.attached` and
277
+ `part.complete`. Fails closed and writes nothing: a `courseId` of another unit or
278
+ another part count is `unit-mismatch`; a second import for an attached part is
279
+ `part-conflict`. A replay is `replayed: true` and writes nothing new.
280
+ - **Shared sections and lessons**: parts that share a key must agree, and the
281
+ result never depends on arrival order. `CourseBundlePlacement`
282
+ (`course_bundle_placements`) records each key's title and whether its order was
283
+ declared. Another title, two different declared orders, or a declared order
284
+ against an implicit one is `part-conflict` (nothing written). Between implicit
285
+ positions the part with the lowest index decides; a lower part arriving later
286
+ re-sorts the row. An existing row with no placement is `part-conflict`.
287
+ - **Concurrency**: every attach transaction takes, in order, the import row lock
288
+ (shared with `chooseMaterialAudience` and `applyMaterials`) and the unit lock: a
289
+ `CourseBundleUnit` row (`course_bundle_units`, one per family and unit key)
290
+ created if absent and then locked before anything that decides the outcome
291
+ (attachments, the course, placements, choices) is read. On PostgreSQL a
292
+ concurrent first create of the same unit waits and adopts the winner's row,
293
+ so two first attaches can never both pass the checks.
294
+ - **Zero renderings**: with no converter, Word/PowerPoint members are recorded
295
+ `converter_unavailable` and listed in `skipped`; no rendered or original join,
296
+ page or thumbnail is written for them, and nothing is faked. `retryRendering`
297
+ later, then attach again: the replay adds the newly accepted materials.
298
+ - `readImport` reports `attachment` (course, unit key, part) once attached.
299
+
300
+ ### Audience before and after attach
301
+
302
+ `chooseMaterialAudience({ ...actor, importId, path | video: { lessonKey, videoId },
303
+ audience })` (`education.course.author`, operation `choose-course-bundle-audience`)
304
+ records a review choice (`course_bundle_audience_choices`, one per import and
305
+ target; a later choice replaces it) within the role's bounds: keys and assessment
306
+ guidance never become learner material (`invalid-audience`), and a path must be a
307
+ declared v2 material of the import. `readImport` lists `audienceChoices`.
308
+ `attachCourse` and `applyMaterials` apply them (a slides choice applies to the
309
+ rendered pages; the original deck stays guardian). After attach a choice is
310
+ `already-attached`: change the row with `SubmissionService.setMaterialAudience`.
311
+ On a re-attach an existing row keeps its audience (the family's choice), except an
312
+ original, which is repaired to guardian.
313
+
247
314
  ### Applying materials
248
315
 
249
316
  `applyMaterials({ ...actor, importId, bundle, courseId, lessons, assets })`
@@ -251,18 +318,23 @@ rendered pages (`rendered`: "Present").
251
318
  rows in one transaction. The host has stored each accepted member as a private
252
319
  asset and created the course's lessons: `lessons` maps manifest lesson key to the
253
320
  `Lesson` id, `assets` maps member path to `{ original, rendered }` asset ids.
254
- Slides join both the original deck and the rendered PDF; the original join is always
321
+ Slides join both the original deck (`rendition: 'original'`) and the rendered PDF
322
+ (`rendition: 'rendered'`, `renditionOfAssetId` the original); the original join is always
255
323
  guardian-only whatever the deck's declared audience (the audience applies to the
256
- rendered PDF), and a join written earlier with a wider audience is repaired on
257
- re-apply (`repaired`); any other Word/PowerPoint
324
+ rendered PDF; the model refuses a learner original), and a join written earlier with a
325
+ wider audience is repaired on re-apply (`repaired`); any other Word/PowerPoint
258
326
  member joins only its rendered PDF, so a learner is never handed an editable
259
- original. `LessonAsset`/`CourseAsset` saves keep their private-asset rule (guardian-
327
+ original. `assets[path].thumbnail` is recorded on the rendered join (or a plain
328
+ file's own join); rows written before this release get their rendition and
329
+ thumbnail filled in on re-apply (`updated`). `LessonAsset`/`CourseAsset` saves keep their private-asset rule (guardian-
260
330
  only roles and personal-use-copy courses need private assets). It targets
261
331
  family-owned courses; an authored course is `authored-course` because authored
262
332
  material changes through course authoring. Idempotent: an identical row is
263
- `unchanged`, a row with other details is `CourseBundleMaterialsError('conflict')`,
264
- a missing asset or lesson is `asset-missing`/`lesson-missing`; any error writes
265
- nothing.
333
+ `unchanged`, a row with other details (order, title, rendition, a video's title or
334
+ step) is `CourseBundleMaterialsError('conflict')`; audience is not compared (see
335
+ above). A missing asset or lesson is `asset-missing`/`lesson-missing`, a thumbnail
336
+ under another owner than its material `asset-owner`; any error writes nothing. A
337
+ member named twice (material and page range) is listed once in `skipped`.
266
338
 
267
339
  ### Retrying a failed rendering
268
340
 
@@ -301,12 +373,19 @@ unique tenant/import/path; ordinal, sha256, size, media type, kind, status,
301
373
  reason, duplicate-of, role, audience, section and lesson key) and
302
374
  `CourseBundleRendering` (`course_bundle_renderings`: a deliberate retry's success). Hashes and placements only:
303
375
  bytes stay with the host's ingestion receipts. Rewrites and deletes throw.
304
- Normal manifest migrations create the tables and add `audience` (NULL on older rows).
376
+ `CourseBundleAttachment` (`course_bundle_attachments`) and the mutable
377
+ `CourseBundleAudienceChoice` (`course_bundle_audience_choices`) and
378
+ `CourseBundlePlacement` (`course_bundle_placements`) and `CourseBundleUnit`
379
+ (`course_bundle_units`) belong to the attach path. Normal manifest migrations create the tables and add `audience` (NULL
380
+ on older rows) and, on `lesson_assets`/`course_assets`, `rendition`,
381
+ `rendition_of_asset_id` and `thumbnail_asset_id` (additive; older joins read as a
382
+ file as supplied with no thumbnail).
305
383
 
306
384
  ## Not in this release
307
385
 
308
386
  - Material rows for authored courses: a proposal still carries lesson text only;
309
- authored course material goes through `CourseMaterialInput` and release.
387
+ authored course material goes through `CourseMaterialInput` and release. The
388
+ family path is `attachCourse`; `proposeStructure` stays the authored-draft path.
310
389
  - Ingestion's receipt contract is unchanged: there is no `application/zip` part
311
390
  type or `parentEvidenceId` in ingestion. The education side unpacks and the
312
391
  host receives members one by one, so archive-to-member lineage lives in
@@ -300,3 +300,10 @@ The gate is `authoredVisibilityPermitsGroups(db, tenantId, releaseCourseId)`.
300
300
  Filed loose work is attributed to its current filing placement (target lesson,
301
301
  enrollment and course, hence subject) under the same consent, offered-metadata and
302
302
  pin-lineage rules as routed work; unplaced loose work stays unknown metadata.
303
+
304
+ Shared work also carries `courseTitle`, `lessonTitle` and `pageLabel` (#4619) so a
305
+ supervisor can relate samples to the family's program without reading family
306
+ courses. They are read from the same resolved course, placement and gates as
307
+ `courseId`/`subject`: only for items the policy shares, null where the course is
308
+ unknown, and the lesson title only when the lesson belongs to that course. Filed
309
+ work names its placement's page; routed work reads the lesson's first page.
@@ -20,20 +20,37 @@ actor/owner/asset capability.
20
20
  `CourseAsset` holds course-level material (supply list, posters) with the same
21
21
  roles, audience and private-asset rule, plus an optional title.
22
22
 
23
+ ## Rendition and thumbnail (SMRT #4632)
24
+
25
+ `LessonAsset` and `CourseAsset` carry `rendition` (`''` a file as supplied,
26
+ `original` an editable Word/PowerPoint file, `rendered` the PDF rendered from
27
+ one), `renditionOfAssetId` (a rendering's original) and `thumbnailAssetId` (an
28
+ optional first-page image, null when no converter drew one). The model refuses
29
+ an `original` with a learner audience: the learner gets the rendering, never the
30
+ editable file. A new thumbnail must be an asset of the same family, and private
31
+ under the host's capability (same owner as the material) whenever the material
32
+ must be private.
33
+
23
34
  ## Learner never sees guardian material
24
35
 
25
36
  `listLessonMaterials({enrollmentId, lessonId})` and `listCourseMaterials({enrollmentId})`
26
37
  return `LessonMaterialView`s (`id`, `kind` file or external-video, `scope`, role,
27
- audience, title, order, `assetId` or provider/videoId/`embedUrl`). The learner
38
+ audience, title, order, `assetId` or provider/videoId/`embedUrl`, and for files
39
+ `mimeType` from the stored asset, `rendition`, `pairedMaterialId` (the other half
40
+ of an original/rendered pair, only when this actor sees both) and
41
+ `thumbnailAssetId`). Hosts read these instead of guessing by file extension. The learner
28
42
  themself sees only learner-audience rows; an authorized guardian sees all. The
29
43
  catalog projection without answer keys (every learner-facing catalog read) applies
30
44
  the same rule. `signLessonMaterial`/`signCourseMaterial` sign only a file in the
31
45
  actor's own list (claim scope `lesson:<id>` / `course:<id>`); a guessed id of
32
- guardian material is denied. `signLessonPrintable` applies the same audience
33
- rule to the learner. Storage URIs never appear.
46
+ guardian material is denied; `thumbnail: true` signs the thumbnail of a visible
47
+ material instead. `signLessonPrintable` applies the same audience rule to the learner. Storage URIs never appear.
34
48
 
35
49
  `setMaterialAudience({enrollmentId, scope, kind, materialId, audience})` lets a
36
- guardian with plan authority change a family-owned row within the role's bounds.
50
+ guardian with plan authority change a family-owned row within the role's bounds
51
+ (an `original` stays guardian). Bundle courses (`sourceKey` `course-bundle`) are
52
+ family-owned, so this is the after-attach path; before attach use
53
+ `CourseBundleImportService.chooseMaterialAudience`.
37
54
  Authored courses change audience through course authoring (`CourseMaterialInput.audience`,
38
55
  carried to the released join); shared or other families' rows are denied.
39
56
 
@@ -1 +1 @@
1
- {"version":3,"file":"course-authoring-contracts-C4Hqba-e.js","names":[],"sources":["../../src/course-authoring-contracts.ts"],"sourcesContent":["import type { DatabaseInterface } from '@happyvertical/sql';\nimport type {\n CourseLicenseKind,\n CourseModality,\n CourseVisibility,\n} from './catalog.js';\nimport type {\n LessonAssetRelationship,\n MaterialAudience,\n} from './lesson-assets.js';\nimport type {\n LessonLayoutParams,\n LessonQuestionRefInput,\n} from './question-contracts.js';\n\n/** Authorizer actions. The host maps roles; the service never infers a role. */\nexport type CourseAuthoringAction =\n | 'education.course.read-draft'\n | 'education.course.author'\n | 'education.course.generate'\n | 'education.course.review'\n | 'education.course.release'\n | 'education.course.assign'\n | 'education.course.archive'\n | 'education.course.set-visibility'\n | 'education.course.set-cover';\n\nexport const COURSE_AUTHORING_ACTIONS: readonly CourseAuthoringAction[] = [\n 'education.course.read-draft',\n 'education.course.author',\n 'education.course.generate',\n 'education.course.review',\n 'education.course.release',\n 'education.course.assign',\n 'education.course.archive',\n 'education.course.set-visibility',\n 'education.course.set-cover',\n];\n\n/** Which service operation asked, so a host can deny agents specific operations. */\nexport type CourseAuthoringOperation =\n | 'create'\n | 'save-draft'\n | 'append-lesson'\n | 'read-draft'\n | 'release'\n | 'archive'\n | 'unarchive'\n | 'set-visibility'\n | 'set-cover'\n | 'discard'\n | 'pin-enrollment'\n | 'upgrade-enrollment'\n | 'set-auto-upgrade'\n | 'auto-upgrade'\n | 'read-enrollment'\n | 'create-question'\n | 'save-question'\n | 'read-question'\n | 'resolve-question-revisions'\n | 'create-objective'\n | 'save-objective'\n | 'link-objectives'\n | 'read-worksheet'\n | 'request-generation'\n | 'run-generation'\n | 'cancel-generation'\n | 'accept-proposal'\n | 'read-generation'\n | 'record-review'\n | 'read-review'\n | 'resolve-finding'\n | 'note-context-change'\n | 'list-courses'\n | 'list-upgrade-choices'\n | 'list-auto-upgrade-skips'\n | 'read-released-lesson'\n | 'attach-printable'\n | 'preserve-question-extraction'\n | 'read-question-extraction'\n | 'apply-question-extraction'\n | 'import-course-bundle'\n | 'read-course-bundle'\n | 'retry-course-bundle-rendering'\n | 'apply-course-bundle-materials';\n\n/** Trusted identity supplied by the host session, never by a browser form. */\nexport interface CourseAuthoringActor {\n tenantId: string;\n actorUserId: string;\n /** Agent calls: the guardian whose delegation the host re-validates per check. */\n delegatingGuardianUserId?: string;\n /**\n * Host-supplied opaque delegation id (1-200 printable characters), required\n * with, and only with, `delegatingGuardianUserId`. Recorded on every decision\n * and passed to every authorizer call; it grants nothing by itself.\n */\n delegationId?: string;\n}\n\n/**\n * A richer authorizer answer. `reason` (at most 200 characters) and `code` (a\n * short token, at most 64 characters of `[a-z0-9._-]`) are recorded on an\n * auto-upgrade skip when `allowed` is false; invalid values are dropped, never\n * thrown. A plain `boolean` stays valid everywhere.\n */\nexport interface CourseAuthorizationDecision {\n allowed: boolean;\n reason?: string;\n code?: string;\n /**\n * `education.course.assign` only: the assignment-setting value the host relied\n * on (1-200 printable characters). It becomes the recorded policy basis for pin,\n * upgrade and opt-in rows, replacing the caller-supplied one; if the caller also\n * sent one it must be equal (`CoursePolicyBasisMismatchError`). Ignored for other\n * actions. An invalid value is a validation error and nothing is written.\n */\n policyBasis?: string;\n}\n\n/** Result of a host delegation check; a bare boolean is accepted as well. */\nexport type CourseDelegationDecision =\n | boolean\n | { allowed: boolean; reason?: string; code?: string };\n\n/**\n * Host port that verifies a delegation (the agent may act for the guardian under\n * this id) on the supplied transaction executor. Without it every delegated\n * request is refused; it runs before the host authorizer and never grants by\n * itself.\n */\nexport type CourseDelegationVerifier = (\n executor: DatabaseInterface,\n request: {\n tenantId: string;\n actorUserId: string;\n delegatingGuardianUserId: string;\n delegationId: string;\n action: CourseAuthoringAction;\n operation: CourseAuthoringOperation;\n courseId?: string;\n enrollmentId?: string;\n learnerId?: string;\n /** Pin and upgrade only: the target release and its materialized course. */\n releaseId?: string;\n releaseCourseId?: string;\n },\n) => CourseDelegationDecision | Promise<CourseDelegationDecision>;\n\nexport type CourseAuthoringAuthorizer = (\n request: CourseAuthoringActor & {\n action: CourseAuthoringAction;\n operation: CourseAuthoringOperation;\n /** The active service executor; host policy reads must share its transaction. */\n executor: DatabaseInterface;\n courseId?: string;\n enrollmentId?: string;\n learnerId?: string;\n /** Pin and upgrade only: the target release and its materialized course. */\n releaseId?: string;\n releaseCourseId?: string;\n },\n) =>\n | boolean\n | CourseAuthorizationDecision\n | Promise<boolean | CourseAuthorizationDecision>;\n\nexport type CourseProvenance =\n | 'supplied'\n | 'generated'\n | 'edited'\n | 'extracted';\nexport const COURSE_PROVENANCES: readonly CourseProvenance[] = [\n 'supplied',\n 'generated',\n 'edited',\n 'extracted',\n];\nexport type CourseLessonType = 'reading' | 'video' | 'worksheet' | 'exercise';\nexport type CourseLessonCompletion = 'attest' | 'submit' | 'score' | 'external';\n\n/** Hook supplied by #4516; the service never treats an absent gate as a pass. */\nexport type ReleaseReviewGate = (\n executor: DatabaseInterface,\n request: { tenantId: string; courseId: string; revisionId: string },\n) =>\n | { current: boolean; reviewId?: string; reason?: string }\n | Promise<{ current: boolean; reviewId?: string; reason?: string }>;\n\n/** Hook supplied by #4516/variants: report review/variant rows that reference a draft. */\nexport type CourseReferenceGuard = (\n executor: DatabaseInterface,\n request: { tenantId: string; courseId: string },\n) =>\n | { referenced: boolean; kinds?: readonly string[] }\n | Promise<{ referenced: boolean; kinds?: readonly string[] }>;\n\n/** Copies an upload (for example an ingestion item) to an education-owned asset. */\nexport interface CourseMaterialSource {\n kind: string;\n ref: string;\n}\nexport interface CourseMaterialCopyRequest {\n tenantId: string;\n actorUserId: string;\n courseId: string;\n source: CourseMaterialSource;\n relationship: LessonAssetRelationship;\n}\nexport interface CourseMaterialCopyPort {\n /** Must return an asset owned by the same tenant, independent of the source's expiry. */\n copy(\n executor: DatabaseInterface,\n request: CourseMaterialCopyRequest,\n ): Promise<{ assetId: string }>;\n}\n\nexport interface CourseMaterialInput {\n /** Uploaded material to copy; mutually exclusive with `assetId`. */\n source?: CourseMaterialSource;\n /** Reuse an asset already copied into this course by an earlier revision. */\n assetId?: string;\n relationship: LessonAssetRelationship;\n licenseKind?: CourseLicenseKind;\n /** Who it is for; omitted means the role default (`LESSON_ASSET_DEFAULT_AUDIENCE`). */\n audience?: MaterialAudience;\n}\nexport interface CourseMaterialView {\n assetId: string;\n relationship: LessonAssetRelationship;\n /** Stated audience; absent means the role default. */\n audience?: MaterialAudience;\n licenseKind: CourseLicenseKind;\n provenance: {\n source: CourseMaterialSource | null;\n copiedAt: string;\n copiedByUserId: string;\n };\n}\n\nexport interface CourseLessonInput {\n /** Stable cross-version identity; omit for a new lesson. */\n lessonKey?: string;\n title: string;\n body?: string;\n lessonType?: CourseLessonType;\n completionRule?: CourseLessonCompletion;\n estimatedMinutes?: number;\n difficulty?: number;\n modality?: CourseModality;\n externalRef?: string;\n externalUrl?: string;\n licenseKind?: CourseLicenseKind;\n provenance?: CourseProvenance;\n fieldProvenance?: Record<string, CourseProvenance>;\n materials?: CourseMaterialInput[];\n /** Ordered immutable question revisions; each must exist in this tenant. */\n questions?: LessonQuestionRefInput[];\n /** Flat render parameters folded into the worksheet version id. */\n layoutParams?: LessonLayoutParams;\n}\nexport interface CourseSectionInput {\n sectionKey: string;\n title: string;\n lessons: CourseLessonInput[];\n}\nexport interface CourseDraftInput {\n title: string;\n summary?: string;\n subject?: string;\n theme?: string;\n levelMin?: number;\n levelMax?: number;\n objectives?: string[];\n sections: CourseSectionInput[];\n provenance: CourseProvenance;\n fieldProvenance?: Record<string, CourseProvenance>;\n}\n\nexport interface CourseLessonRevisionView {\n id: string;\n lessonKey: string;\n parentRevisionId: string | null;\n title: string;\n body: string;\n lessonType: CourseLessonType;\n completionRule: CourseLessonCompletion;\n estimatedMinutes: number;\n difficulty: number;\n modality: CourseModality;\n externalRef: string;\n externalUrl: string;\n licenseKind: CourseLicenseKind;\n provenance: CourseProvenance;\n fieldProvenance: Record<string, CourseProvenance>;\n materials: CourseMaterialView[];\n questions: LessonQuestionRefInput[];\n layoutParams: LessonLayoutParams;\n}\n/** Alias for hosts: a lesson revision as read by the authoring views. */\nexport type LessonRevisionView = CourseLessonRevisionView;\nexport interface CourseRevisionView {\n id: string;\n courseId: string;\n parentRevisionId: string | null;\n sequence: number;\n title: string;\n summary: string;\n subject: string;\n theme: string;\n levelMin: number;\n levelMax: number;\n objectives: string[];\n licenseKind: CourseLicenseKind;\n provenance: CourseProvenance;\n fieldProvenance: Record<string, CourseProvenance>;\n authorUserId: string;\n delegatingGuardianUserId: string | null;\n delegationId: string | null;\n recordedAt: string;\n sections: Array<{\n sectionKey: string;\n title: string;\n lessons: CourseLessonRevisionView[];\n }>;\n}\nexport interface CourseHeadView {\n courseId: string;\n /** The expected-head token for the next save. */\n headRevisionId: string;\n version: number;\n}\n/** Result of `appendLesson`: the new revision and, unless skipped, its release. */\nexport type CourseAppendLessonView = Readonly<{\n courseId: string;\n /** The appended revision; the new draft head. */\n revisionId: string;\n version: number;\n lessonKey: string;\n /** The release of that revision; null when `release: false` was requested. */\n release: CourseReleaseView | null;\n}>;\n\nexport type CourseReleaseView = Readonly<{\n id: string;\n courseId: string;\n revisionId: string;\n releaseNumber: number;\n releaseCourseId: string;\n /** The cover snapshotted onto this release; null when the course had none. */\n coverAssetId: string | null;\n reviewId: string | null;\n actorUserId: string;\n delegatingGuardianUserId: string | null;\n delegationId: string | null;\n /** The host setting the release relied on (for example an auto-approve); null if none. */\n policyBasis: string | null;\n recordedAt: string;\n lessons: ReadonlyArray<{\n lessonKey: string;\n lessonRevisionId: string;\n lessonId: string;\n sectionId: string;\n }>;\n}>;\nexport type CourseLifecycleState =\n | 'draft'\n | 'released'\n | 'archived'\n | 'discarded';\nexport type CourseStateView = Readonly<{\n courseId: string;\n title: string;\n state: CourseLifecycleState;\n licenseKind: CourseLicenseKind;\n /** Personal-use material makes the course tenant-private and never group-shareable. */\n groupShareable: boolean;\n /** `family` unless set otherwise (see `setVisibility`). */\n visibility: CourseVisibility;\n /** The education-owned cover image asset, or null (hosts render a fallback). */\n coverAssetId: string | null;\n headRevisionId: string | null;\n releases: ReadonlyArray<{\n id: string;\n releaseNumber: number;\n revisionId: string;\n releaseCourseId: string;\n }>;\n}>;\n/** Largest page a list read returns; the default is `DEFAULT_PAGE_SIZE`. */\nexport const MAX_PAGE_SIZE = 100;\nexport const DEFAULT_PAGE_SIZE = 50;\n\n/** Opaque, deterministic pagination: pass back `nextCursor` unchanged. */\nexport interface CoursePageRequest {\n limit?: number;\n cursor?: string;\n}\nexport type CoursePage<T> = Readonly<{\n items: ReadonlyArray<T>;\n /** Null on the last page. */\n nextCursor: string | null;\n}>;\n\nexport type CourseArchiveDecisionView = Readonly<{\n action: 'archive' | 'unarchive';\n sequence: number;\n actorUserId: string;\n delegatingGuardianUserId: string | null;\n delegationId: string | null;\n recordedAt: string;\n}>;\n/** One authored course in `listCourses`. */\nexport type CourseSummaryView = Readonly<{\n courseId: string;\n title: string;\n state: CourseLifecycleState;\n createdAt: string;\n /**\n * Effective licence: the most restrictive of the head revision and every release's\n * materialized course, so a restriction recorded by any release never loosens.\n * `CourseStateView.licenseKind` reads the head alone and may be looser.\n */\n licenseKind: CourseLicenseKind;\n /**\n * The group-sharing rule applied to this course: false for personal-use and\n * false until the course has a release (nothing to share).\n */\n groupShareable: boolean;\n visibility: CourseVisibility;\n coverAssetId: string | null;\n /** Number of releases (0 for a draft-only course). */\n releaseCount: number;\n /** The draft head; `revisionId` is the expected-head token for the next save. */\n head: Readonly<{\n revisionId: string;\n sequence: number;\n version: number;\n }> | null;\n latestRelease: Readonly<{\n id: string;\n revisionId: string;\n releaseNumber: number;\n releaseCourseId: string;\n /** The cover snapshotted onto that release. */\n coverAssetId: string | null;\n recordedAt: string;\n }> | null;\n archive: Readonly<{\n archived: boolean;\n latestDecision: CourseArchiveDecisionView | null;\n }>;\n}>;\n\nexport type CourseUpgradeChoiceView = Readonly<{\n id: string;\n courseId: string;\n scope: 'course' | 'enrollment';\n /** Null for a course-level choice. */\n enrollmentId: string | null;\n enabled: boolean;\n kind: 'opt-in' | 'opt-out';\n sequence: number;\n actorUserId: string;\n delegatingGuardianUserId: string | null;\n delegationId: string | null;\n policyBasis: string;\n recordedAt: string;\n}>;\n\nexport type AutoUpgradeSkipView = Readonly<{\n id: string;\n enrollmentId: string;\n learnerId: string;\n courseId: string;\n releaseId: string;\n optInScope: 'course' | 'enrollment';\n optInActorUserId: string;\n optInDelegatingGuardianUserId: string | null;\n optInDelegationId: string | null;\n policyBasis: string;\n /** The authorizer's reason when it gave one, else `not-authorized`. */\n reason: string;\n /** The authorizer's short code, or an empty string. */\n code: string;\n recordedAt: string;\n}>;\n\nexport type EnrollmentPinKind = 'initial' | 'explicit' | 'auto';\nexport type EnrollmentPinView = Readonly<{\n enrollmentId: string;\n /** The enrollment's learner, from the enrollment row; hosts never read `enrollments`. */\n learnerId: string;\n /** The authored course identity (`AuthoredCourse` id) the pin follows. */\n courseId: string;\n releaseId: string;\n /** The materialized release `Course` id the enrollment is created on. */\n releaseCourseId: string;\n releaseNumber: number;\n kind: EnrollmentPinKind;\n autoUpgrade: boolean;\n /** Assignment-setting value the host relied on for the current pin. */\n policyBasis: string;\n history: ReadonlyArray<{\n releaseId: string;\n releaseCourseId: string;\n previousReleaseId: string | null;\n kind: EnrollmentPinKind;\n sequence: number;\n actorUserId: string | null;\n delegatingGuardianUserId: string | null;\n delegationId: string | null;\n policyBasis: string;\n /** For automatic moves: the scope of the opt-in followed. */\n optInScope: 'course' | 'enrollment' | null;\n recordedAt: string;\n }>;\n}>;\n/** Result of `getEnrollmentRelease`; identity fields repeat the pin's for convenience. */\nexport type EnrollmentReleaseView = Readonly<{\n learnerId: string;\n courseId: string;\n releaseCourseId: string;\n pin: EnrollmentPinView;\n lessons: EffectiveLessonView[];\n}>;\n/** Result of `setAutoUpgrade`; `learnerId` is null for a course-level choice. */\nexport type SetAutoUpgradeResult = Readonly<{\n courseId: string;\n enrollmentId: string | null;\n learnerId: string | null;\n enabled: boolean;\n policyBasis: string;\n}>;\nexport type EffectiveLessonView = Readonly<{\n lessonKey: string;\n lessonId: string;\n releaseId: string;\n /** True when a started lesson stays on an older release than the current pin. */\n pinnedToOlderRelease: boolean;\n}>;\n\nexport class CourseAuthoringError extends Error {\n constructor(\n readonly code: string,\n message: string,\n ) {\n super(message);\n this.name = new.target.name;\n }\n}\nexport class CourseAuthoringAuthorizationError extends CourseAuthoringError {\n constructor() {\n super('not-authorized', 'Course authoring access is not authorized.');\n }\n}\nexport class CourseAuthoringValidationError extends CourseAuthoringError {\n constructor(message: string) {\n super('invalid-request', message);\n }\n}\nexport class CourseNotFoundError extends CourseAuthoringError {\n constructor() {\n super('course-not-found', 'Course was not found.');\n }\n}\nexport class CourseDiscardedError extends CourseAuthoringError {\n constructor() {\n super('course-discarded', 'Course draft was discarded.');\n }\n}\nexport class CourseArchivedError extends CourseAuthoringError {\n constructor() {\n super('course-archived', 'Course is archived; unarchive it first.');\n }\n}\n/**\n * `appendLesson` was refused for the course's state: it has no release yet\n * (`not-released`), its head holds changes that are not released\n * (`unreleased-changes`), or the lesson key or section is already taken\n * (`duplicate-lesson`).\n */\nexport class CourseAppendRefusedError extends CourseAuthoringError {\n constructor(\n readonly reason: 'not-released' | 'unreleased-changes' | 'duplicate-lesson',\n ) {\n super('append-refused', `Lesson append refused: ${reason}.`);\n }\n}\nexport class CourseStaleHeadError extends CourseAuthoringError {\n constructor(readonly currentHeadRevisionId: string) {\n super('stale-head', 'The draft head moved; reload and retry.');\n }\n}\n/** The caller and the assign authorizer named different policy bases. */\nexport class CoursePolicyBasisMismatchError extends CourseAuthoringError {\n constructor() {\n super(\n 'policy-basis-mismatch',\n 'The supplied policy basis differs from the one the authorizer relied on.',\n );\n }\n}\nexport class CourseRequestKeyConflictError extends CourseAuthoringError {\n constructor() {\n super(\n 'request-key-conflict',\n 'The request key was already used for a different request.',\n );\n }\n}\nexport class ReviewGateRequiredError extends CourseAuthoringError {\n constructor() {\n super(\n 'review-gate-required',\n 'Release requires a configured review gate; none is configured.',\n );\n }\n}\nexport class ReviewNotCurrentError extends CourseAuthoringError {\n constructor(readonly reason?: string) {\n super(\n 'review-not-current',\n 'Release requires a current review of this exact revision.',\n );\n }\n}\nexport class CourseRevisionAlreadyReleasedError extends CourseAuthoringError {\n constructor(readonly releaseId: string) {\n super('revision-already-released', 'This revision is already released.');\n }\n}\nexport class CourseDiscardRefusedError extends CourseAuthoringError {\n constructor(readonly references: readonly string[]) {\n super(\n 'discard-refused',\n 'Only drafts with no release, enrollment, attempt, review or variant can be discarded.',\n );\n }\n}\nexport class EnrollmentPinConflictError extends CourseAuthoringError {\n constructor(\n message = 'Enrollment pin request conflicts with current state.',\n ) {\n super('enrollment-pin-conflict', message);\n }\n}\nexport class CourseMaterialCopyError extends CourseAuthoringError {\n constructor(\n message = 'Course material could not be copied to an owned asset.',\n ) {\n super('material-copy-failed', message);\n }\n}\n\n/** Result of `setVisibility`/`setCover`: the course state after the (maybe unchanged) write. */\nexport type CourseVisibilityResult = CourseStateView;\nexport type CourseCoverResult = CourseStateView;\n\n/** A personal-use-copy course can never be shared with groups or listed publicly. */\nexport class CourseVisibilityRefusedError extends CourseAuthoringError {\n constructor(readonly visibility: CourseVisibility) {\n super(\n 'visibility-refused',\n 'A personal-use course can only be family-visible.',\n );\n }\n}\n/** The cover asset is missing, foreign, not an image, an answer key or unreadable. */\nexport class CourseCoverInvalidError extends CourseAuthoringError {\n constructor(readonly reason: string) {\n super(\n 'cover-invalid',\n 'The cover must be a readable education-owned image.',\n );\n }\n}\n"],"mappings":";AA2BO,IAAM,2BAA6D;CACxE;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;AACF;AAuIO,IAAM,qBAAkD;CAC7D;CACA;CACA;CACA;AACF;AAqNO,IAAM,gBAAgB;AACtB,IAAM,oBAAoB;AAuJ1B,IAAM,uBAAN,cAAmC,MAAM;CAC9C,YACW,MACT,SACA;EACA,MAAM,OAAO;EAHJ,KAAA,OAAA;EAIT,KAAK,OAAO,IAAA,OAAW;CACzB;CALW;AAMb;AACO,IAAM,oCAAN,cAAgD,qBAAqB;CAC1E,cAAc;EACZ,MAAM,kBAAkB,4CAA4C;CACtE;AACF;AACO,IAAM,iCAAN,cAA6C,qBAAqB;CACvE,YAAY,SAAiB;EAC3B,MAAM,mBAAmB,OAAO;CAClC;AACF;AACO,IAAM,sBAAN,cAAkC,qBAAqB;CAC5D,cAAc;EACZ,MAAM,oBAAoB,uBAAuB;CACnD;AACF;AACO,IAAM,uBAAN,cAAmC,qBAAqB;CAC7D,cAAc;EACZ,MAAM,oBAAoB,6BAA6B;CACzD;AACF;AACO,IAAM,sBAAN,cAAkC,qBAAqB;CAC5D,cAAc;EACZ,MAAM,mBAAmB,yCAAyC;CACpE;AACF;AAOO,IAAM,2BAAN,cAAuC,qBAAqB;CACjE,YACW,QACT;EACA,MAAM,kBAAkB,0BAA0B,OAAM,EAAG;EAFlD,KAAA,SAAA;CAGX;CAHW;AAIb;AACO,IAAM,uBAAN,cAAmC,qBAAqB;CAC7D,YAAqB,uBAA+B;EAClD,MAAM,cAAc,yCAAyC;EAD1C,KAAA,wBAAA;CAErB;CAFqB;AAGvB;AAEO,IAAM,iCAAN,cAA6C,qBAAqB;CACvE,cAAc;EACZ,MACE,yBACA,0EACF;CACF;AACF;AACO,IAAM,gCAAN,cAA4C,qBAAqB;CACtE,cAAc;EACZ,MACE,wBACA,2DACF;CACF;AACF;AACO,IAAM,0BAAN,cAAsC,qBAAqB;CAChE,cAAc;EACZ,MACE,wBACA,gEACF;CACF;AACF;AACO,IAAM,wBAAN,cAAoC,qBAAqB;CAC9D,YAAqB,QAAiB;EACpC,MACE,sBACA,2DACF;EAJmB,KAAA,SAAA;CAKrB;CALqB;AAMvB;AACO,IAAM,qCAAN,cAAiD,qBAAqB;CAC3E,YAAqB,WAAmB;EACtC,MAAM,6BAA6B,oCAAoC;EADpD,KAAA,YAAA;CAErB;CAFqB;AAGvB;AACO,IAAM,4BAAN,cAAwC,qBAAqB;CAClE,YAAqB,YAA+B;EAClD,MACE,mBACA,uFACF;EAJmB,KAAA,aAAA;CAKrB;CALqB;AAMvB;AACO,IAAM,6BAAN,cAAyC,qBAAqB;CACnE,YACE,UAAU,wDACV;EACA,MAAM,2BAA2B,OAAO;CAC1C;AACF;AACO,IAAM,0BAAN,cAAsC,qBAAqB;CAChE,YACE,UAAU,0DACV;EACA,MAAM,wBAAwB,OAAO;CACvC;AACF;AAOO,IAAM,+BAAN,cAA2C,qBAAqB;CACrE,YAAqB,YAA8B;EACjD,MACE,sBACA,mDACF;EAJmB,KAAA,aAAA;CAKrB;CALqB;AAMvB;AAEO,IAAM,0BAAN,cAAsC,qBAAqB;CAChE,YAAqB,QAAgB;EACnC,MACE,iBACA,qDACF;EAJmB,KAAA,SAAA;CAKrB;CALqB;AAMvB"}
1
+ {"version":3,"file":"course-authoring-contracts-C4Hqba-e.js","names":[],"sources":["../../src/course-authoring-contracts.ts"],"sourcesContent":["import type { DatabaseInterface } from '@happyvertical/sql';\nimport type {\n CourseLicenseKind,\n CourseModality,\n CourseVisibility,\n} from './catalog.js';\nimport type {\n LessonAssetRelationship,\n MaterialAudience,\n} from './lesson-assets.js';\nimport type {\n LessonLayoutParams,\n LessonQuestionRefInput,\n} from './question-contracts.js';\n\n/** Authorizer actions. The host maps roles; the service never infers a role. */\nexport type CourseAuthoringAction =\n | 'education.course.read-draft'\n | 'education.course.author'\n | 'education.course.generate'\n | 'education.course.review'\n | 'education.course.release'\n | 'education.course.assign'\n | 'education.course.archive'\n | 'education.course.set-visibility'\n | 'education.course.set-cover';\n\nexport const COURSE_AUTHORING_ACTIONS: readonly CourseAuthoringAction[] = [\n 'education.course.read-draft',\n 'education.course.author',\n 'education.course.generate',\n 'education.course.review',\n 'education.course.release',\n 'education.course.assign',\n 'education.course.archive',\n 'education.course.set-visibility',\n 'education.course.set-cover',\n];\n\n/** Which service operation asked, so a host can deny agents specific operations. */\nexport type CourseAuthoringOperation =\n | 'create'\n | 'save-draft'\n | 'append-lesson'\n | 'read-draft'\n | 'release'\n | 'archive'\n | 'unarchive'\n | 'set-visibility'\n | 'set-cover'\n | 'discard'\n | 'pin-enrollment'\n | 'upgrade-enrollment'\n | 'set-auto-upgrade'\n | 'auto-upgrade'\n | 'read-enrollment'\n | 'create-question'\n | 'save-question'\n | 'read-question'\n | 'resolve-question-revisions'\n | 'create-objective'\n | 'save-objective'\n | 'link-objectives'\n | 'read-worksheet'\n | 'request-generation'\n | 'run-generation'\n | 'cancel-generation'\n | 'accept-proposal'\n | 'read-generation'\n | 'record-review'\n | 'read-review'\n | 'resolve-finding'\n | 'note-context-change'\n | 'list-courses'\n | 'list-upgrade-choices'\n | 'list-auto-upgrade-skips'\n | 'read-released-lesson'\n | 'attach-printable'\n | 'preserve-question-extraction'\n | 'read-question-extraction'\n | 'apply-question-extraction'\n | 'import-course-bundle'\n | 'read-course-bundle'\n | 'retry-course-bundle-rendering'\n | 'apply-course-bundle-materials'\n | 'attach-course-bundle'\n | 'choose-course-bundle-audience';\n\n/** Trusted identity supplied by the host session, never by a browser form. */\nexport interface CourseAuthoringActor {\n tenantId: string;\n actorUserId: string;\n /** Agent calls: the guardian whose delegation the host re-validates per check. */\n delegatingGuardianUserId?: string;\n /**\n * Host-supplied opaque delegation id (1-200 printable characters), required\n * with, and only with, `delegatingGuardianUserId`. Recorded on every decision\n * and passed to every authorizer call; it grants nothing by itself.\n */\n delegationId?: string;\n}\n\n/**\n * A richer authorizer answer. `reason` (at most 200 characters) and `code` (a\n * short token, at most 64 characters of `[a-z0-9._-]`) are recorded on an\n * auto-upgrade skip when `allowed` is false; invalid values are dropped, never\n * thrown. A plain `boolean` stays valid everywhere.\n */\nexport interface CourseAuthorizationDecision {\n allowed: boolean;\n reason?: string;\n code?: string;\n /**\n * `education.course.assign` only: the assignment-setting value the host relied\n * on (1-200 printable characters). It becomes the recorded policy basis for pin,\n * upgrade and opt-in rows, replacing the caller-supplied one; if the caller also\n * sent one it must be equal (`CoursePolicyBasisMismatchError`). Ignored for other\n * actions. An invalid value is a validation error and nothing is written.\n */\n policyBasis?: string;\n}\n\n/** Result of a host delegation check; a bare boolean is accepted as well. */\nexport type CourseDelegationDecision =\n | boolean\n | { allowed: boolean; reason?: string; code?: string };\n\n/**\n * Host port that verifies a delegation (the agent may act for the guardian under\n * this id) on the supplied transaction executor. Without it every delegated\n * request is refused; it runs before the host authorizer and never grants by\n * itself.\n */\nexport type CourseDelegationVerifier = (\n executor: DatabaseInterface,\n request: {\n tenantId: string;\n actorUserId: string;\n delegatingGuardianUserId: string;\n delegationId: string;\n action: CourseAuthoringAction;\n operation: CourseAuthoringOperation;\n courseId?: string;\n enrollmentId?: string;\n learnerId?: string;\n /** Pin and upgrade only: the target release and its materialized course. */\n releaseId?: string;\n releaseCourseId?: string;\n },\n) => CourseDelegationDecision | Promise<CourseDelegationDecision>;\n\nexport type CourseAuthoringAuthorizer = (\n request: CourseAuthoringActor & {\n action: CourseAuthoringAction;\n operation: CourseAuthoringOperation;\n /** The active service executor; host policy reads must share its transaction. */\n executor: DatabaseInterface;\n courseId?: string;\n enrollmentId?: string;\n learnerId?: string;\n /** Pin and upgrade only: the target release and its materialized course. */\n releaseId?: string;\n releaseCourseId?: string;\n },\n) =>\n | boolean\n | CourseAuthorizationDecision\n | Promise<boolean | CourseAuthorizationDecision>;\n\nexport type CourseProvenance =\n | 'supplied'\n | 'generated'\n | 'edited'\n | 'extracted';\nexport const COURSE_PROVENANCES: readonly CourseProvenance[] = [\n 'supplied',\n 'generated',\n 'edited',\n 'extracted',\n];\nexport type CourseLessonType = 'reading' | 'video' | 'worksheet' | 'exercise';\nexport type CourseLessonCompletion = 'attest' | 'submit' | 'score' | 'external';\n\n/** Hook supplied by #4516; the service never treats an absent gate as a pass. */\nexport type ReleaseReviewGate = (\n executor: DatabaseInterface,\n request: { tenantId: string; courseId: string; revisionId: string },\n) =>\n | { current: boolean; reviewId?: string; reason?: string }\n | Promise<{ current: boolean; reviewId?: string; reason?: string }>;\n\n/** Hook supplied by #4516/variants: report review/variant rows that reference a draft. */\nexport type CourseReferenceGuard = (\n executor: DatabaseInterface,\n request: { tenantId: string; courseId: string },\n) =>\n | { referenced: boolean; kinds?: readonly string[] }\n | Promise<{ referenced: boolean; kinds?: readonly string[] }>;\n\n/** Copies an upload (for example an ingestion item) to an education-owned asset. */\nexport interface CourseMaterialSource {\n kind: string;\n ref: string;\n}\nexport interface CourseMaterialCopyRequest {\n tenantId: string;\n actorUserId: string;\n courseId: string;\n source: CourseMaterialSource;\n relationship: LessonAssetRelationship;\n}\nexport interface CourseMaterialCopyPort {\n /** Must return an asset owned by the same tenant, independent of the source's expiry. */\n copy(\n executor: DatabaseInterface,\n request: CourseMaterialCopyRequest,\n ): Promise<{ assetId: string }>;\n}\n\nexport interface CourseMaterialInput {\n /** Uploaded material to copy; mutually exclusive with `assetId`. */\n source?: CourseMaterialSource;\n /** Reuse an asset already copied into this course by an earlier revision. */\n assetId?: string;\n relationship: LessonAssetRelationship;\n licenseKind?: CourseLicenseKind;\n /** Who it is for; omitted means the role default (`LESSON_ASSET_DEFAULT_AUDIENCE`). */\n audience?: MaterialAudience;\n}\nexport interface CourseMaterialView {\n assetId: string;\n relationship: LessonAssetRelationship;\n /** Stated audience; absent means the role default. */\n audience?: MaterialAudience;\n licenseKind: CourseLicenseKind;\n provenance: {\n source: CourseMaterialSource | null;\n copiedAt: string;\n copiedByUserId: string;\n };\n}\n\nexport interface CourseLessonInput {\n /** Stable cross-version identity; omit for a new lesson. */\n lessonKey?: string;\n title: string;\n body?: string;\n lessonType?: CourseLessonType;\n completionRule?: CourseLessonCompletion;\n estimatedMinutes?: number;\n difficulty?: number;\n modality?: CourseModality;\n externalRef?: string;\n externalUrl?: string;\n licenseKind?: CourseLicenseKind;\n provenance?: CourseProvenance;\n fieldProvenance?: Record<string, CourseProvenance>;\n materials?: CourseMaterialInput[];\n /** Ordered immutable question revisions; each must exist in this tenant. */\n questions?: LessonQuestionRefInput[];\n /** Flat render parameters folded into the worksheet version id. */\n layoutParams?: LessonLayoutParams;\n}\nexport interface CourseSectionInput {\n sectionKey: string;\n title: string;\n lessons: CourseLessonInput[];\n}\nexport interface CourseDraftInput {\n title: string;\n summary?: string;\n subject?: string;\n theme?: string;\n levelMin?: number;\n levelMax?: number;\n objectives?: string[];\n sections: CourseSectionInput[];\n provenance: CourseProvenance;\n fieldProvenance?: Record<string, CourseProvenance>;\n}\n\nexport interface CourseLessonRevisionView {\n id: string;\n lessonKey: string;\n parentRevisionId: string | null;\n title: string;\n body: string;\n lessonType: CourseLessonType;\n completionRule: CourseLessonCompletion;\n estimatedMinutes: number;\n difficulty: number;\n modality: CourseModality;\n externalRef: string;\n externalUrl: string;\n licenseKind: CourseLicenseKind;\n provenance: CourseProvenance;\n fieldProvenance: Record<string, CourseProvenance>;\n materials: CourseMaterialView[];\n questions: LessonQuestionRefInput[];\n layoutParams: LessonLayoutParams;\n}\n/** Alias for hosts: a lesson revision as read by the authoring views. */\nexport type LessonRevisionView = CourseLessonRevisionView;\nexport interface CourseRevisionView {\n id: string;\n courseId: string;\n parentRevisionId: string | null;\n sequence: number;\n title: string;\n summary: string;\n subject: string;\n theme: string;\n levelMin: number;\n levelMax: number;\n objectives: string[];\n licenseKind: CourseLicenseKind;\n provenance: CourseProvenance;\n fieldProvenance: Record<string, CourseProvenance>;\n authorUserId: string;\n delegatingGuardianUserId: string | null;\n delegationId: string | null;\n recordedAt: string;\n sections: Array<{\n sectionKey: string;\n title: string;\n lessons: CourseLessonRevisionView[];\n }>;\n}\nexport interface CourseHeadView {\n courseId: string;\n /** The expected-head token for the next save. */\n headRevisionId: string;\n version: number;\n}\n/** Result of `appendLesson`: the new revision and, unless skipped, its release. */\nexport type CourseAppendLessonView = Readonly<{\n courseId: string;\n /** The appended revision; the new draft head. */\n revisionId: string;\n version: number;\n lessonKey: string;\n /** The release of that revision; null when `release: false` was requested. */\n release: CourseReleaseView | null;\n}>;\n\nexport type CourseReleaseView = Readonly<{\n id: string;\n courseId: string;\n revisionId: string;\n releaseNumber: number;\n releaseCourseId: string;\n /** The cover snapshotted onto this release; null when the course had none. */\n coverAssetId: string | null;\n reviewId: string | null;\n actorUserId: string;\n delegatingGuardianUserId: string | null;\n delegationId: string | null;\n /** The host setting the release relied on (for example an auto-approve); null if none. */\n policyBasis: string | null;\n recordedAt: string;\n lessons: ReadonlyArray<{\n lessonKey: string;\n lessonRevisionId: string;\n lessonId: string;\n sectionId: string;\n }>;\n}>;\nexport type CourseLifecycleState =\n | 'draft'\n | 'released'\n | 'archived'\n | 'discarded';\nexport type CourseStateView = Readonly<{\n courseId: string;\n title: string;\n state: CourseLifecycleState;\n licenseKind: CourseLicenseKind;\n /** Personal-use material makes the course tenant-private and never group-shareable. */\n groupShareable: boolean;\n /** `family` unless set otherwise (see `setVisibility`). */\n visibility: CourseVisibility;\n /** The education-owned cover image asset, or null (hosts render a fallback). */\n coverAssetId: string | null;\n headRevisionId: string | null;\n releases: ReadonlyArray<{\n id: string;\n releaseNumber: number;\n revisionId: string;\n releaseCourseId: string;\n }>;\n}>;\n/** Largest page a list read returns; the default is `DEFAULT_PAGE_SIZE`. */\nexport const MAX_PAGE_SIZE = 100;\nexport const DEFAULT_PAGE_SIZE = 50;\n\n/** Opaque, deterministic pagination: pass back `nextCursor` unchanged. */\nexport interface CoursePageRequest {\n limit?: number;\n cursor?: string;\n}\nexport type CoursePage<T> = Readonly<{\n items: ReadonlyArray<T>;\n /** Null on the last page. */\n nextCursor: string | null;\n}>;\n\nexport type CourseArchiveDecisionView = Readonly<{\n action: 'archive' | 'unarchive';\n sequence: number;\n actorUserId: string;\n delegatingGuardianUserId: string | null;\n delegationId: string | null;\n recordedAt: string;\n}>;\n/** One authored course in `listCourses`. */\nexport type CourseSummaryView = Readonly<{\n courseId: string;\n title: string;\n state: CourseLifecycleState;\n createdAt: string;\n /**\n * Effective licence: the most restrictive of the head revision and every release's\n * materialized course, so a restriction recorded by any release never loosens.\n * `CourseStateView.licenseKind` reads the head alone and may be looser.\n */\n licenseKind: CourseLicenseKind;\n /**\n * The group-sharing rule applied to this course: false for personal-use and\n * false until the course has a release (nothing to share).\n */\n groupShareable: boolean;\n visibility: CourseVisibility;\n coverAssetId: string | null;\n /** Number of releases (0 for a draft-only course). */\n releaseCount: number;\n /** The draft head; `revisionId` is the expected-head token for the next save. */\n head: Readonly<{\n revisionId: string;\n sequence: number;\n version: number;\n }> | null;\n latestRelease: Readonly<{\n id: string;\n revisionId: string;\n releaseNumber: number;\n releaseCourseId: string;\n /** The cover snapshotted onto that release. */\n coverAssetId: string | null;\n recordedAt: string;\n }> | null;\n archive: Readonly<{\n archived: boolean;\n latestDecision: CourseArchiveDecisionView | null;\n }>;\n}>;\n\nexport type CourseUpgradeChoiceView = Readonly<{\n id: string;\n courseId: string;\n scope: 'course' | 'enrollment';\n /** Null for a course-level choice. */\n enrollmentId: string | null;\n enabled: boolean;\n kind: 'opt-in' | 'opt-out';\n sequence: number;\n actorUserId: string;\n delegatingGuardianUserId: string | null;\n delegationId: string | null;\n policyBasis: string;\n recordedAt: string;\n}>;\n\nexport type AutoUpgradeSkipView = Readonly<{\n id: string;\n enrollmentId: string;\n learnerId: string;\n courseId: string;\n releaseId: string;\n optInScope: 'course' | 'enrollment';\n optInActorUserId: string;\n optInDelegatingGuardianUserId: string | null;\n optInDelegationId: string | null;\n policyBasis: string;\n /** The authorizer's reason when it gave one, else `not-authorized`. */\n reason: string;\n /** The authorizer's short code, or an empty string. */\n code: string;\n recordedAt: string;\n}>;\n\nexport type EnrollmentPinKind = 'initial' | 'explicit' | 'auto';\nexport type EnrollmentPinView = Readonly<{\n enrollmentId: string;\n /** The enrollment's learner, from the enrollment row; hosts never read `enrollments`. */\n learnerId: string;\n /** The authored course identity (`AuthoredCourse` id) the pin follows. */\n courseId: string;\n releaseId: string;\n /** The materialized release `Course` id the enrollment is created on. */\n releaseCourseId: string;\n releaseNumber: number;\n kind: EnrollmentPinKind;\n autoUpgrade: boolean;\n /** Assignment-setting value the host relied on for the current pin. */\n policyBasis: string;\n history: ReadonlyArray<{\n releaseId: string;\n releaseCourseId: string;\n previousReleaseId: string | null;\n kind: EnrollmentPinKind;\n sequence: number;\n actorUserId: string | null;\n delegatingGuardianUserId: string | null;\n delegationId: string | null;\n policyBasis: string;\n /** For automatic moves: the scope of the opt-in followed. */\n optInScope: 'course' | 'enrollment' | null;\n recordedAt: string;\n }>;\n}>;\n/** Result of `getEnrollmentRelease`; identity fields repeat the pin's for convenience. */\nexport type EnrollmentReleaseView = Readonly<{\n learnerId: string;\n courseId: string;\n releaseCourseId: string;\n pin: EnrollmentPinView;\n lessons: EffectiveLessonView[];\n}>;\n/** Result of `setAutoUpgrade`; `learnerId` is null for a course-level choice. */\nexport type SetAutoUpgradeResult = Readonly<{\n courseId: string;\n enrollmentId: string | null;\n learnerId: string | null;\n enabled: boolean;\n policyBasis: string;\n}>;\nexport type EffectiveLessonView = Readonly<{\n lessonKey: string;\n lessonId: string;\n releaseId: string;\n /** True when a started lesson stays on an older release than the current pin. */\n pinnedToOlderRelease: boolean;\n}>;\n\nexport class CourseAuthoringError extends Error {\n constructor(\n readonly code: string,\n message: string,\n ) {\n super(message);\n this.name = new.target.name;\n }\n}\nexport class CourseAuthoringAuthorizationError extends CourseAuthoringError {\n constructor() {\n super('not-authorized', 'Course authoring access is not authorized.');\n }\n}\nexport class CourseAuthoringValidationError extends CourseAuthoringError {\n constructor(message: string) {\n super('invalid-request', message);\n }\n}\nexport class CourseNotFoundError extends CourseAuthoringError {\n constructor() {\n super('course-not-found', 'Course was not found.');\n }\n}\nexport class CourseDiscardedError extends CourseAuthoringError {\n constructor() {\n super('course-discarded', 'Course draft was discarded.');\n }\n}\nexport class CourseArchivedError extends CourseAuthoringError {\n constructor() {\n super('course-archived', 'Course is archived; unarchive it first.');\n }\n}\n/**\n * `appendLesson` was refused for the course's state: it has no release yet\n * (`not-released`), its head holds changes that are not released\n * (`unreleased-changes`), or the lesson key or section is already taken\n * (`duplicate-lesson`).\n */\nexport class CourseAppendRefusedError extends CourseAuthoringError {\n constructor(\n readonly reason: 'not-released' | 'unreleased-changes' | 'duplicate-lesson',\n ) {\n super('append-refused', `Lesson append refused: ${reason}.`);\n }\n}\nexport class CourseStaleHeadError extends CourseAuthoringError {\n constructor(readonly currentHeadRevisionId: string) {\n super('stale-head', 'The draft head moved; reload and retry.');\n }\n}\n/** The caller and the assign authorizer named different policy bases. */\nexport class CoursePolicyBasisMismatchError extends CourseAuthoringError {\n constructor() {\n super(\n 'policy-basis-mismatch',\n 'The supplied policy basis differs from the one the authorizer relied on.',\n );\n }\n}\nexport class CourseRequestKeyConflictError extends CourseAuthoringError {\n constructor() {\n super(\n 'request-key-conflict',\n 'The request key was already used for a different request.',\n );\n }\n}\nexport class ReviewGateRequiredError extends CourseAuthoringError {\n constructor() {\n super(\n 'review-gate-required',\n 'Release requires a configured review gate; none is configured.',\n );\n }\n}\nexport class ReviewNotCurrentError extends CourseAuthoringError {\n constructor(readonly reason?: string) {\n super(\n 'review-not-current',\n 'Release requires a current review of this exact revision.',\n );\n }\n}\nexport class CourseRevisionAlreadyReleasedError extends CourseAuthoringError {\n constructor(readonly releaseId: string) {\n super('revision-already-released', 'This revision is already released.');\n }\n}\nexport class CourseDiscardRefusedError extends CourseAuthoringError {\n constructor(readonly references: readonly string[]) {\n super(\n 'discard-refused',\n 'Only drafts with no release, enrollment, attempt, review or variant can be discarded.',\n );\n }\n}\nexport class EnrollmentPinConflictError extends CourseAuthoringError {\n constructor(\n message = 'Enrollment pin request conflicts with current state.',\n ) {\n super('enrollment-pin-conflict', message);\n }\n}\nexport class CourseMaterialCopyError extends CourseAuthoringError {\n constructor(\n message = 'Course material could not be copied to an owned asset.',\n ) {\n super('material-copy-failed', message);\n }\n}\n\n/** Result of `setVisibility`/`setCover`: the course state after the (maybe unchanged) write. */\nexport type CourseVisibilityResult = CourseStateView;\nexport type CourseCoverResult = CourseStateView;\n\n/** A personal-use-copy course can never be shared with groups or listed publicly. */\nexport class CourseVisibilityRefusedError extends CourseAuthoringError {\n constructor(readonly visibility: CourseVisibility) {\n super(\n 'visibility-refused',\n 'A personal-use course can only be family-visible.',\n );\n }\n}\n/** The cover asset is missing, foreign, not an image, an answer key or unreadable. */\nexport class CourseCoverInvalidError extends CourseAuthoringError {\n constructor(readonly reason: string) {\n super(\n 'cover-invalid',\n 'The cover must be a readable education-owned image.',\n );\n }\n}\n"],"mappings":";AA2BO,IAAM,2BAA6D;CACxE;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;AACF;AAyIO,IAAM,qBAAkD;CAC7D;CACA;CACA;CACA;AACF;AAqNO,IAAM,gBAAgB;AACtB,IAAM,oBAAoB;AAuJ1B,IAAM,uBAAN,cAAmC,MAAM;CAC9C,YACW,MACT,SACA;EACA,MAAM,OAAO;EAHJ,KAAA,OAAA;EAIT,KAAK,OAAO,IAAA,OAAW;CACzB;CALW;AAMb;AACO,IAAM,oCAAN,cAAgD,qBAAqB;CAC1E,cAAc;EACZ,MAAM,kBAAkB,4CAA4C;CACtE;AACF;AACO,IAAM,iCAAN,cAA6C,qBAAqB;CACvE,YAAY,SAAiB;EAC3B,MAAM,mBAAmB,OAAO;CAClC;AACF;AACO,IAAM,sBAAN,cAAkC,qBAAqB;CAC5D,cAAc;EACZ,MAAM,oBAAoB,uBAAuB;CACnD;AACF;AACO,IAAM,uBAAN,cAAmC,qBAAqB;CAC7D,cAAc;EACZ,MAAM,oBAAoB,6BAA6B;CACzD;AACF;AACO,IAAM,sBAAN,cAAkC,qBAAqB;CAC5D,cAAc;EACZ,MAAM,mBAAmB,yCAAyC;CACpE;AACF;AAOO,IAAM,2BAAN,cAAuC,qBAAqB;CACjE,YACW,QACT;EACA,MAAM,kBAAkB,0BAA0B,OAAM,EAAG;EAFlD,KAAA,SAAA;CAGX;CAHW;AAIb;AACO,IAAM,uBAAN,cAAmC,qBAAqB;CAC7D,YAAqB,uBAA+B;EAClD,MAAM,cAAc,yCAAyC;EAD1C,KAAA,wBAAA;CAErB;CAFqB;AAGvB;AAEO,IAAM,iCAAN,cAA6C,qBAAqB;CACvE,cAAc;EACZ,MACE,yBACA,0EACF;CACF;AACF;AACO,IAAM,gCAAN,cAA4C,qBAAqB;CACtE,cAAc;EACZ,MACE,wBACA,2DACF;CACF;AACF;AACO,IAAM,0BAAN,cAAsC,qBAAqB;CAChE,cAAc;EACZ,MACE,wBACA,gEACF;CACF;AACF;AACO,IAAM,wBAAN,cAAoC,qBAAqB;CAC9D,YAAqB,QAAiB;EACpC,MACE,sBACA,2DACF;EAJmB,KAAA,SAAA;CAKrB;CALqB;AAMvB;AACO,IAAM,qCAAN,cAAiD,qBAAqB;CAC3E,YAAqB,WAAmB;EACtC,MAAM,6BAA6B,oCAAoC;EADpD,KAAA,YAAA;CAErB;CAFqB;AAGvB;AACO,IAAM,4BAAN,cAAwC,qBAAqB;CAClE,YAAqB,YAA+B;EAClD,MACE,mBACA,uFACF;EAJmB,KAAA,aAAA;CAKrB;CALqB;AAMvB;AACO,IAAM,6BAAN,cAAyC,qBAAqB;CACnE,YACE,UAAU,wDACV;EACA,MAAM,2BAA2B,OAAO;CAC1C;AACF;AACO,IAAM,0BAAN,cAAsC,qBAAqB;CAChE,YACE,UAAU,0DACV;EACA,MAAM,wBAAwB,OAAO;CACvC;AACF;AAOO,IAAM,+BAAN,cAA2C,qBAAqB;CACrE,YAAqB,YAA8B;EACjD,MACE,sBACA,mDACF;EAJmB,KAAA,aAAA;CAKrB;CALqB;AAMvB;AAEO,IAAM,0BAAN,cAAsC,qBAAqB;CAChE,YAAqB,QAAgB;EACnC,MACE,iBACA,qDACF;EAJmB,KAAA,SAAA;CAKrB;CALqB;AAMvB"}
@@ -17,7 +17,8 @@ var COURSE_BUNDLE_LIMITS = Object.freeze({
17
17
  maxLessonBodyChars: 1e5,
18
18
  maxOfficeMembers: 50,
19
19
  maxRenderedBytes: 50 * MiB,
20
- conversionTimeoutMs: 6e4
20
+ conversionTimeoutMs: 6e4,
21
+ maxThumbnailBytes: 5 * MiB
21
22
  });
22
23
  var COURSE_BUNDLE_MAX_ARCHIVE_DEPTH = 1;
23
24
  var COURSE_BUNDLE_MANIFEST_VERSION = 1;
@@ -320,4 +321,4 @@ function readFileSet(files, limits = COURSE_BUNDLE_LIMITS, onProgress) {
320
321
  //#endregion
321
322
  export { resolveCourseBundleLimits as _, readZipArchive as a, COURSE_BUNDLE_MANIFEST_PATH as c, COURSE_BUNDLE_MAX_ARCHIVE_DEPTH as d, COURSE_BUNDLE_PAGE_ROLES as f, CourseBundleRefusedError as g, CourseBundleNotFoundError as h, readFileSet as i, COURSE_BUNDLE_MANIFEST_VERSION as l, COURSE_BUNDLE_STAGES as m, isWellFormedString as n, sha256Hex as o, COURSE_BUNDLE_RETRYABLE_REASONS as p, normalizeBundlePath as r, COURSE_BUNDLE_LIMITS as s, crc32 as t, COURSE_BUNDLE_MANIFEST_VERSION_2 as u };
322
323
 
323
- //# sourceMappingURL=course-bundle-zip-5KUe3C5H.js.map
324
+ //# sourceMappingURL=course-bundle-zip-CGR1Ci0Z.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"course-bundle-zip-CGR1Ci0Z.js","names":[],"sources":["../../src/course-bundle-contracts.ts","../../src/course-bundle-zip.ts"],"sourcesContent":["import { CourseAuthoringError } from './course-authoring-contracts.js';\n\nconst KiB = 1024;\nconst MiB = 1024 * KiB;\n\n/**\n * Hard ceilings for untrusted course bundles (SMRT #4578). A host may lower any\n * of them per call (`resolveCourseBundleLimits`) but never raise them. Declared\n * sizes in an archive are never trusted: every cap is enforced on the bytes\n * actually produced.\n */\nexport interface CourseBundleLimits {\n /** Largest accepted archive (or total input of a plain file set), in bytes. */\n maxArchiveBytes: number;\n /** Most entries (files and directories) in one archive or file set. */\n maxEntries: number;\n /** Largest single inflated member. A larger honest member is recorded refused. */\n maxEntryBytes: number;\n /** Largest sum of inflated members. */\n maxTotalBytes: number;\n /** Largest inflated/compressed ratio for a member above `ratioFloorBytes`. */\n maxCompressionRatio: number;\n /** Members at or below this inflated size skip the ratio check. */\n ratioFloorBytes: number;\n /** Longest normalised path, in UTF-8 bytes. */\n maxPathBytes: number;\n /** Longest path segment, in UTF-8 bytes. */\n maxSegmentBytes: number;\n /** Deepest directory path (segments, including the file name). */\n maxPathDepth: number;\n /** Largest `manifest.json`. */\n maxManifestBytes: number;\n /** Most lessons one bundle may propose (matches the proposal schema). */\n maxLessons: number;\n /** Longest text member used as a lesson body, in characters. */\n maxLessonBodyChars: number;\n /** Most Word/PowerPoint members rendered per bundle; later ones are recorded unsupported. */\n maxOfficeMembers: number;\n /** Largest rendered PDF accepted back from the converter, in bytes. */\n maxRenderedBytes: number;\n /** Wall-clock budget for rendering one Office member, in milliseconds. */\n conversionTimeoutMs: number;\n /** Largest first-page thumbnail accepted back from the converter, in bytes. */\n maxThumbnailBytes: number;\n}\n\nexport const COURSE_BUNDLE_LIMITS: Readonly<CourseBundleLimits> = Object.freeze(\n {\n maxArchiveBytes: 100 * MiB,\n maxEntries: 200,\n maxEntryBytes: 50 * MiB,\n maxTotalBytes: 200 * MiB,\n maxCompressionRatio: 100,\n ratioFloorBytes: 64 * KiB,\n maxPathBytes: 512,\n maxSegmentBytes: 255,\n maxPathDepth: 8,\n maxManifestBytes: 256 * KiB,\n maxLessons: 50,\n maxLessonBodyChars: 100_000,\n maxOfficeMembers: 50,\n maxRenderedBytes: 50 * MiB,\n conversionTimeoutMs: 60_000,\n maxThumbnailBytes: 5 * MiB,\n },\n);\n\n/** Nested archives are never expanded: depth is one, and a zip member is unsupported evidence. */\nexport const COURSE_BUNDLE_MAX_ARCHIVE_DEPTH = 1;\n/** The original manifest schema version (page roles, no materials). */\nexport const COURSE_BUNDLE_MANIFEST_VERSION = 1;\n/** Manifest version 2: material roles and audience, lesson pages, course members, videos. */\nexport const COURSE_BUNDLE_MANIFEST_VERSION_2 = 2;\n/** Manifest location: the archive (or file set) root. */\nexport const COURSE_BUNDLE_MANIFEST_PATH = 'manifest.json';\n\n/** Clamp a host override to the hard ceilings; invalid values fall back to the ceiling. */\nexport function resolveCourseBundleLimits(\n override: Partial<CourseBundleLimits> = {},\n): CourseBundleLimits {\n const out = { ...COURSE_BUNDLE_LIMITS };\n for (const key of Object.keys(out) as Array<keyof CourseBundleLimits>) {\n const value = override[key];\n if (\n value !== undefined &&\n Number.isSafeInteger(value) &&\n value >= 1 &&\n value <= COURSE_BUNDLE_LIMITS[key]\n )\n out[key] = value;\n }\n return out;\n}\n\nexport type CourseBundleRefusalReason =\n | 'archive_too_large'\n | 'not_a_zip'\n | 'truncated'\n | 'ambiguous_end_record'\n | 'zip64_unsupported'\n | 'multi_disk'\n | 'too_many_entries'\n | 'invalid_central_directory'\n | 'invalid_entry'\n | 'unsupported_method'\n | 'encrypted_entry'\n | 'symlink_entry'\n | 'special_entry'\n | 'invalid_path'\n | 'path_too_deep'\n | 'duplicate_name'\n | 'entry_out_of_range'\n | 'overlapping_entries'\n | 'local_header_mismatch'\n | 'compression_ratio'\n | 'total_too_large'\n | 'size_mismatch'\n | 'bad_crc'\n | 'inflate_failed'\n | 'empty_bundle'\n | 'manifest_invalid'\n | 'manifest_version'\n | 'manifest_unknown_key'\n /** v2: a member role that is not a material role. */\n | 'manifest_role'\n /** v2: a learner audience on a guardian-only role, or an unknown audience. */\n | 'manifest_audience'\n /** v2: a page range that is malformed or names a member that cannot be paged. */\n | 'manifest_pages'\n /** v2: a video reference that is not an allowlisted YouTube id or URL. */\n | 'manifest_video'\n | 'manifest_missing_member'\n | 'manifest_duplicate_member'\n | 'duplicate_key'\n | 'too_many_lessons'\n | 'lesson_too_large';\n\n/**\n * The whole bundle is refused: it is hostile, damaged or outside the contract.\n * `reason` is stable; `entry` names the offending member path when one applies\n * (untrusted text, never rendered as markup).\n */\nexport class CourseBundleRefusedError extends CourseAuthoringError {\n constructor(\n readonly reason: CourseBundleRefusalReason,\n readonly entry?: string,\n ) {\n super('course-bundle-refused', `Course bundle refused: ${reason}.`);\n }\n}\nexport class CourseBundleNotFoundError extends CourseAuthoringError {\n constructor() {\n super('course-bundle-not-found', 'Course bundle import was not found.');\n }\n}\n\n/** What the bytes of a bundle member turned out to be (by content, never by name alone). */\nexport type CourseBundleMemberKind =\n | 'pdf'\n | 'image'\n | 'text'\n /** Word (`.docx`): rendered to PDF by the converter, text read by the SDK processor. */\n | 'docx'\n /** PowerPoint (`.pptx`): the deck is kept as the original and as rendered pages. */\n | 'pptx'\n /** Excel (`.xlsx`): rendered to PDF by the converter; no text reader. */\n | 'xlsx'\n | 'manifest'\n | 'unsupported';\nexport type CourseBundleMemberStatus =\n /** Supported, unique content: handed to extraction and eligible for a lesson. */\n | 'accepted'\n /** Identical bytes to an earlier accepted member: evidence only, never extracted twice. */\n | 'duplicate'\n /** Visible but not understood (unknown type, nested archive, unreadable text). */\n | 'unsupported'\n /** Understood and refused (encrypted PDF, oversize member). */\n | 'refused'\n /** The bundle manifest itself. */\n | 'metadata';\nexport type CourseBundleMemberReason =\n | 'unsupported_type'\n | 'nested_archive'\n | 'unreadable_text'\n | 'encrypted_pdf'\n | 'entry_too_large'\n | 'duplicate_content'\n /** The OOXML package failed the bounded zip checks (bomb, bad paths, damaged). */\n | 'unsafe_office'\n /** More Office members than `maxOfficeMembers`. */\n | 'office_limit'\n /** No `DocumentConverter` is configured: the member cannot be rendered. */\n | 'converter_unavailable'\n /** The converter could not be reached or failed (server side). */\n | 'converter_failed'\n | 'converter_timeout'\n | 'converter_too_large'\n /** The converter refused the file, or returned something that is not a PDF. */\n | 'converter_rejected';\n\n/**\n * Converter outcomes that a deliberate retry may repair. A bundle that was\n * never rendered or whose converter was unreachable can be rendered later;\n * a file the converter rejected, or a limit, cannot be fixed by asking again.\n */\nexport const COURSE_BUNDLE_RETRYABLE_REASONS: ReadonlySet<CourseBundleMemberReason> =\n new Set(['converter_unavailable', 'converter_failed', 'converter_timeout']);\n\nexport const COURSE_BUNDLE_PAGE_ROLES = [\n 'exercise',\n 'example',\n 'instructions',\n 'cover',\n 'answer_key',\n 'mixed',\n 'unknown',\n] as const;\nexport type CourseBundlePageRole = (typeof COURSE_BUNDLE_PAGE_ROLES)[number];\n\n/** Named progress stages, in order. */\nexport const COURSE_BUNDLE_STAGES = [\n 'unpack',\n 'classify',\n 'render',\n 'extract',\n 'propose',\n] as const;\nexport type CourseBundleStage = (typeof COURSE_BUNDLE_STAGES)[number];\n/** One progress event. A stage emits `completed: 0` first and `completed === total` last. */\nexport interface CourseBundleProgress {\n stage: CourseBundleStage;\n completed: number;\n total: number;\n}\n/** Observer only: a throwing listener never changes the import. */\nexport type CourseBundleProgressListener = (\n event: CourseBundleProgress,\n) => void;\n\n/** A bundle supplied as one zip archive or as a plain set of files. */\nexport type CourseBundleInput =\n | { archive: Uint8Array }\n | { files: ReadonlyArray<{ path: string; bytes: Uint8Array }> };\n","/**\n * Bounded zip reader for untrusted course bundles (SMRT #4578). Reads the End\n * Of Central Directory and central directory, validates every offset and size\n * against the real buffer, inflates with a hard output cap and checks CRC32.\n * Stored and deflate only; zip64, multi-disk, encryption and symlinks are\n * refused with typed reasons. Nothing here touches the filesystem.\n */\nimport {\n COURSE_BUNDLE_LIMITS,\n type CourseBundleLimits,\n type CourseBundleRefusalReason,\n CourseBundleRefusedError,\n} from './course-bundle-contracts.js';\n\nconst SIG_EOCD = 0x06054b50;\nconst SIG_ZIP64_LOCATOR = 0x07064b50;\nconst SIG_CENTRAL = 0x02014b50;\nconst SIG_LOCAL = 0x04034b50;\nconst EOCD_SIZE = 22;\nconst CENTRAL_SIZE = 46;\nconst LOCAL_SIZE = 30;\nconst MAX_COMMENT = 0xffff;\n\n/** One member read from an archive or file set, before classification. */\nexport interface RawBundleMember {\n /** Normalised, validated path. */\n path: string;\n size: number;\n /** Null only when the member was refused as oversize and never inflated. */\n bytes: Uint8Array | null;\n sha256: string | null;\n /** Set when the member was understood but not inflated. */\n refusal: 'entry_too_large' | null;\n}\n\nfunction node() {\n const get = (typeof process !== 'undefined' && process.getBuiltinModule) as\n | ((id: string) => unknown)\n | false;\n if (!get) throw new Error('Course bundle import requires a Node server');\n return {\n zlib: get('node:zlib') as typeof import('node:zlib'),\n crypto: get('node:crypto') as typeof import('node:crypto'),\n };\n}\n\nexport function sha256Hex(bytes: Uint8Array | string): string {\n return node().crypto.createHash('sha256').update(bytes).digest('hex');\n}\n\nlet crcTable: Uint32Array | undefined;\nexport function crc32(bytes: Uint8Array): number {\n if (!crcTable) {\n crcTable = new Uint32Array(256);\n for (let n = 0; n < 256; n++) {\n let c = n;\n for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;\n crcTable[n] = c >>> 0;\n }\n }\n let crc = 0xffffffff;\n for (let i = 0; i < bytes.length; i++)\n crc = (crcTable[(crc ^ bytes[i]) & 0xff] as number) ^ (crc >>> 8);\n return (crc ^ 0xffffffff) >>> 0;\n}\n\nconst refuse = (reason: CourseBundleRefusalReason, entry?: string): never => {\n throw new CourseBundleRefusedError(reason, entry);\n};\n\nconst FORBIDDEN =\n // biome-ignore lint/suspicious/noControlCharactersInRegex: refusing control characters in names\n /[\\u0000-\\u001f\\u007f-\\u009f\\u200b-\\u200f\\u2028-\\u202e\\u2066-\\u2069\\ufeff]/;\n/** True when the string has no lone UTF-16 surrogates. */\nexport function isWellFormedString(value: string): boolean {\n return !/[\\ud800-\\udbff](?![\\udc00-\\udfff])|(?<![\\ud800-\\udbff])[\\udc00-\\udfff]/.test(\n value,\n );\n}\n\n/**\n * Normalise and validate an entry or file name. Backslashes become `/`; empty,\n * `.` and `..` segments, absolute and drive-letter paths, `:` anywhere, NUL and\n * other control or bidi characters, over-long or over-deep paths are refused.\n * A trailing `/` (directory) is stripped and reported.\n */\nexport function normalizeBundlePath(\n raw: unknown,\n limits: CourseBundleLimits = COURSE_BUNDLE_LIMITS,\n): { path: string; directory: boolean } {\n if (typeof raw !== 'string' || raw.length === 0)\n return refuse('invalid_path');\n if (!isWellFormedString(raw) || FORBIDDEN.test(raw))\n return refuse('invalid_path');\n const text = raw.replaceAll('\\\\', '/');\n if (\n Buffer.byteLength(text) > limits.maxPathBytes ||\n text.startsWith('/') ||\n /^[A-Za-z]:/.test(text) ||\n text.includes(':')\n )\n return refuse('invalid_path');\n const directory = text.endsWith('/');\n const segments = (directory ? text.slice(0, -1) : text).split('/');\n for (const segment of segments)\n if (\n segment === '' ||\n segment === '.' ||\n segment === '..' ||\n Buffer.byteLength(segment) > limits.maxSegmentBytes\n )\n return refuse('invalid_path');\n if (segments.length > limits.maxPathDepth) return refuse('path_too_deep');\n return { path: segments.join('/'), directory };\n}\n\n/** Case- and normalisation-insensitive identity used to refuse colliding names. */\nexport const bundleNameKey = (path: string) =>\n path.normalize('NFC').toLowerCase();\n\ninterface CentralEntry {\n name: string;\n rawName: Uint8Array;\n method: number;\n crc: number;\n compressed: number;\n size: number;\n localOffset: number;\n directory: boolean;\n}\n\nfunction findEndRecord(bytes: Uint8Array, view: DataView): number {\n const low = Math.max(0, bytes.length - EOCD_SIZE - MAX_COMMENT);\n const candidates: number[] = [];\n for (let i = bytes.length - EOCD_SIZE; i >= low; i--) {\n if (view.getUint32(i, true) !== SIG_EOCD) continue;\n // The comment must run exactly to the end of the file, so a record forged\n // inside a comment can only count if it also fits; two fits are ambiguous.\n if (i + EOCD_SIZE + view.getUint16(i + 20, true) === bytes.length)\n candidates.push(i);\n }\n if (candidates.length === 0)\n return refuse(\n bytes.length >= 4 && view.getUint32(0, true) === SIG_LOCAL\n ? 'truncated'\n : 'not_a_zip',\n );\n if (candidates.length > 1) return refuse('ambiguous_end_record');\n return candidates[0] as number;\n}\n\n/**\n * Read every member of a zip archive. Throws `CourseBundleRefusedError` for a\n * hostile or damaged archive. A member whose honest declared size exceeds\n * `maxEntryBytes` is returned `entry_too_large` (visible, never inflated);\n * every other violation refuses the whole archive.\n */\nexport function readZipArchive(\n input: Uint8Array,\n limits: CourseBundleLimits = COURSE_BUNDLE_LIMITS,\n onProgress?: (completed: number, total: number) => void,\n): RawBundleMember[] {\n if (!(input instanceof Uint8Array)) return refuse('not_a_zip');\n if (input.length > limits.maxArchiveBytes) return refuse('archive_too_large');\n if (input.length < EOCD_SIZE) return refuse('not_a_zip');\n const bytes = input;\n const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);\n const eocd = findEndRecord(bytes, view);\n if (eocd >= 20 && view.getUint32(eocd - 20, true) === SIG_ZIP64_LOCATOR)\n return refuse('zip64_unsupported');\n const disk = view.getUint16(eocd + 4, true);\n const cdDisk = view.getUint16(eocd + 6, true);\n const entriesDisk = view.getUint16(eocd + 8, true);\n const entries = view.getUint16(eocd + 10, true);\n const cdSize = view.getUint32(eocd + 12, true);\n const cdOffset = view.getUint32(eocd + 16, true);\n if (entries === 0xffff || cdSize === 0xffffffff || cdOffset === 0xffffffff)\n return refuse('zip64_unsupported');\n if (disk !== 0 || cdDisk !== 0 || entriesDisk !== entries)\n return refuse('multi_disk');\n if (entries === 0) return refuse('empty_bundle');\n if (entries > limits.maxEntries) return refuse('too_many_entries');\n if (\n cdSize < entries * CENTRAL_SIZE ||\n cdOffset + cdSize !== eocd // central directory must end exactly at the end record\n )\n return refuse('invalid_central_directory');\n\n const decoder = new TextDecoder('utf-8', { fatal: true });\n const central: CentralEntry[] = [];\n const names = new Set<string>();\n let at = cdOffset;\n for (let n = 0; n < entries; n++) {\n if (at + CENTRAL_SIZE > eocd || view.getUint32(at, true) !== SIG_CENTRAL)\n return refuse('invalid_central_directory');\n const madeBy = view.getUint16(at + 4, true);\n const flags = view.getUint16(at + 8, true);\n const method = view.getUint16(at + 10, true);\n const crc = view.getUint32(at + 16, true);\n const compressed = view.getUint32(at + 20, true);\n const size = view.getUint32(at + 24, true);\n const nameLength = view.getUint16(at + 28, true);\n const extraLength = view.getUint16(at + 30, true);\n const commentLength = view.getUint16(at + 32, true);\n const diskStart = view.getUint16(at + 34, true);\n const external = view.getUint32(at + 38, true);\n const localOffset = view.getUint32(at + 42, true);\n const next = at + CENTRAL_SIZE + nameLength + extraLength + commentLength;\n if (next > eocd) return refuse('invalid_central_directory');\n const rawName = bytes.subarray(\n at + CENTRAL_SIZE,\n at + CENTRAL_SIZE + nameLength,\n );\n let decoded: string;\n try {\n decoded = decoder.decode(rawName);\n } catch {\n return refuse('invalid_path');\n }\n const { path, directory } = normalizeBundlePath(decoded, limits);\n if (flags & (0x1 | 0x40 | 0x2000) || method === 99)\n return refuse('encrypted_entry', path);\n if (method !== 0 && method !== 8) return refuse('unsupported_method', path);\n if (\n compressed === 0xffffffff ||\n size === 0xffffffff ||\n localOffset === 0xffffffff\n )\n return refuse('zip64_unsupported', path);\n if (diskStart !== 0) return refuse('multi_disk', path);\n const host = madeBy >>> 8;\n if (host === 3) {\n const type = (external >>> 16) & 0o170000;\n if (type === 0o120000) return refuse('symlink_entry', path);\n if (type !== 0 && type !== 0o100000 && type !== 0o040000)\n return refuse('special_entry', path);\n } else if (external & 0x400) {\n return refuse('symlink_entry', path); // DOS reparse point\n }\n if (directory && (size !== 0 || compressed > 2))\n return refuse('invalid_entry', path);\n const key = bundleNameKey(path);\n if (names.has(key)) return refuse('duplicate_name', path);\n names.add(key);\n if (method === 0 && compressed !== size)\n return refuse('size_mismatch', path);\n if (\n size > limits.ratioFloorBytes &&\n method === 8 &&\n (compressed === 0 || size / compressed > limits.maxCompressionRatio)\n )\n return refuse('compression_ratio', path);\n central.push({\n name: path,\n rawName,\n method,\n crc,\n compressed,\n size,\n localOffset,\n directory,\n });\n at = next;\n }\n if (at !== eocd) return refuse('invalid_central_directory');\n\n // Local headers and data ranges: inside the file, before the central\n // directory, naming the same entry, never overlapping each other.\n const located: Array<CentralEntry & { start: number; end: number }> = [];\n const ranges: Array<[number, number, string]> = [];\n for (const entry of central) {\n if (entry.directory) continue;\n if (entry.localOffset + LOCAL_SIZE > cdOffset)\n return refuse('entry_out_of_range', entry.name);\n if (view.getUint32(entry.localOffset, true) !== SIG_LOCAL)\n return refuse('local_header_mismatch', entry.name);\n const flags = view.getUint16(entry.localOffset + 6, true);\n if (flags & (0x1 | 0x40 | 0x2000))\n return refuse('encrypted_entry', entry.name);\n if (view.getUint16(entry.localOffset + 8, true) !== entry.method)\n return refuse('local_header_mismatch', entry.name);\n const nameLength = view.getUint16(entry.localOffset + 26, true);\n const extraLength = view.getUint16(entry.localOffset + 28, true);\n const start = entry.localOffset + LOCAL_SIZE + nameLength + extraLength;\n const end = start + entry.compressed;\n if (end > cdOffset) return refuse('entry_out_of_range', entry.name);\n const local = bytes.subarray(\n entry.localOffset + LOCAL_SIZE,\n entry.localOffset + LOCAL_SIZE + nameLength,\n );\n if (\n local.length !== entry.rawName.length ||\n !local.every((b, i) => b === entry.rawName[i])\n )\n return refuse('local_header_mismatch', entry.name);\n located.push({ ...entry, start, end });\n ranges.push([entry.localOffset, end, entry.name]);\n }\n ranges.sort((a, b) => a[0] - b[0]);\n for (let i = 1; i < ranges.length; i++)\n if (\n (ranges[i] as [number, number, string])[0] <\n (ranges[i - 1] as [number, number, string])[1]\n )\n return refuse(\n 'overlapping_entries',\n (ranges[i] as [number, number, string])[2],\n );\n\n // Declared totals are checked up front, actual output again while inflating.\n let declared = 0;\n for (const entry of located)\n if (entry.size <= limits.maxEntryBytes) declared += entry.size;\n if (declared > limits.maxTotalBytes) return refuse('total_too_large');\n\n const { zlib } = node();\n const out: RawBundleMember[] = [];\n let total = 0;\n onProgress?.(0, located.length);\n for (const entry of [...located].sort((a, b) =>\n a.name < b.name ? -1 : a.name > b.name ? 1 : 0,\n )) {\n if (entry.size > limits.maxEntryBytes) {\n out.push({\n path: entry.name,\n size: entry.size,\n bytes: null,\n sha256: null,\n refusal: 'entry_too_large',\n });\n onProgress?.(out.length, located.length);\n continue;\n }\n const data = bytes.subarray(entry.start, entry.end);\n let inflated: Buffer;\n if (entry.method === 0) {\n inflated = Buffer.from(data);\n } else {\n try {\n inflated = zlib.inflateRawSync(data, {\n maxOutputLength: Math.max(entry.size, 1),\n });\n } catch (error) {\n return refuse(\n (error as { code?: string }).code === 'ERR_BUFFER_TOO_LARGE'\n ? 'size_mismatch'\n : 'inflate_failed',\n entry.name,\n );\n }\n }\n if (inflated.length !== entry.size)\n return refuse('size_mismatch', entry.name);\n if (crc32(inflated) !== entry.crc) return refuse('bad_crc', entry.name);\n total += inflated.length;\n if (total > limits.maxTotalBytes) return refuse('total_too_large');\n out.push({\n path: entry.name,\n size: inflated.length,\n bytes: inflated,\n sha256: sha256Hex(inflated),\n refusal: null,\n });\n onProgress?.(out.length, located.length);\n }\n return out;\n}\n\n/** Validate a plain file set the same way an archive is: paths, caps, duplicates. */\nexport function readFileSet(\n files: ReadonlyArray<{ path: string; bytes: Uint8Array }>,\n limits: CourseBundleLimits = COURSE_BUNDLE_LIMITS,\n onProgress?: (completed: number, total: number) => void,\n): RawBundleMember[] {\n if (!Array.isArray(files) || files.length === 0)\n return refuse('empty_bundle');\n if (files.length > limits.maxEntries) return refuse('too_many_entries');\n const names = new Set<string>();\n const out: RawBundleMember[] = [];\n let total = 0;\n let memberTotal = 0;\n onProgress?.(0, files.length);\n for (const file of files) {\n const { path, directory } = normalizeBundlePath(file?.path, limits);\n if (directory || !(file.bytes instanceof Uint8Array))\n return refuse('invalid_entry', path);\n const key = bundleNameKey(path);\n if (names.has(key)) return refuse('duplicate_name', path);\n names.add(key);\n total += file.bytes.length;\n if (total > limits.maxArchiveBytes) return refuse('archive_too_large');\n if (file.bytes.length > limits.maxEntryBytes) {\n out.push({\n path,\n size: file.bytes.length,\n bytes: null,\n sha256: null,\n refusal: 'entry_too_large',\n });\n } else {\n // Same member-byte bound as an archive's inflated total.\n memberTotal += file.bytes.length;\n if (memberTotal > limits.maxTotalBytes) return refuse('total_too_large');\n out.push({\n path,\n size: file.bytes.length,\n bytes: file.bytes,\n sha256: sha256Hex(file.bytes),\n refusal: null,\n });\n }\n onProgress?.(out.length, files.length);\n }\n return out.sort((a, b) => (a.path < b.path ? -1 : a.path > b.path ? 1 : 0));\n}\n"],"mappings":"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"}