@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.
- package/AGENTS.md +3 -1
- package/README.md +7 -2
- package/agents/class-crunchlabs.md +8 -4
- package/agents/course-bundle-import.md +89 -10
- package/agents/group-sharing.md +7 -0
- package/agents/lesson-materials.md +21 -4
- package/dist/chunks/course-authoring-contracts-C4Hqba-e.js.map +1 -1
- package/dist/chunks/{course-bundle-zip-5KUe3C5H.js → course-bundle-zip-CGR1Ci0Z.js} +3 -2
- package/dist/chunks/course-bundle-zip-CGR1Ci0Z.js.map +1 -0
- package/dist/chunks/{workbook-photo-service-CjcB0y8Y.js → workbook-photo-service-DHP5ja82.js} +103 -46
- package/dist/chunks/workbook-photo-service-DHP5ja82.js.map +1 -0
- package/dist/course-authoring-contracts.d.ts +1 -1
- package/dist/course-authoring-contracts.d.ts.map +1 -1
- package/dist/course-bundle-contracts.d.ts +2 -0
- package/dist/course-bundle-contracts.d.ts.map +1 -1
- package/dist/course-bundle-manifest-v2.d.ts +24 -1
- package/dist/course-bundle-manifest-v2.d.ts.map +1 -1
- package/dist/course-bundle-models.d.ts +88 -0
- package/dist/course-bundle-models.d.ts.map +1 -1
- package/dist/course-bundle-service.d.ts +153 -7
- package/dist/course-bundle-service.d.ts.map +1 -1
- package/dist/course-bundle.d.ts +17 -0
- package/dist/course-bundle.d.ts.map +1 -1
- package/dist/document-converter.d.ts +8 -0
- package/dist/document-converter.d.ts.map +1 -1
- package/dist/group-sharing-contracts.d.ts +4 -0
- package/dist/group-sharing-contracts.d.ts.map +1 -1
- package/dist/group-sharing-reader.d.ts.map +1 -1
- package/dist/hearth-class-crunchlabs.d.ts +3 -2
- package/dist/hearth-class-crunchlabs.d.ts.map +1 -1
- package/dist/index.d.ts +2 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +865 -134
- package/dist/index.js.map +1 -1
- package/dist/ingestion.js +1 -1
- package/dist/lesson-assets.d.ts +19 -0
- package/dist/lesson-assets.d.ts.map +1 -1
- package/dist/manifest.json +1803 -145
- package/dist/node.js +1 -1
- package/dist/smrt-knowledge.json +573 -17
- package/dist/sources/class-crunchlabs.d.ts +7 -0
- package/dist/sources/class-crunchlabs.d.ts.map +1 -1
- package/dist/submission-service.d.ts +24 -4
- package/dist/submission-service.d.ts.map +1 -1
- package/package.json +8 -8
- package/dist/chunks/course-bundle-zip-5KUe3C5H.js.map +0 -1
- 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;
|
|
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
|
|
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
|
|
35
|
-
|
|
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
|
|
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
|
|
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. `
|
|
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
|
|
264
|
-
|
|
265
|
-
|
|
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
|
-
|
|
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
|
package/agents/group-sharing.md
CHANGED
|
@@ -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
|
|
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
|
|
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-
|
|
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":";;AAEA,IAAM,MAAM;AACZ,IAAM,MAAM,OAAO;AA2CZ,IAAM,uBAAqD,OAAO,OACvE;CACE,iBAAiB,MAAM;CACvB,YAAY;CACZ,eAAe,KAAK;CACpB,eAAe,MAAM;CACrB,qBAAqB;CACrB,iBAAiB,KAAK;CACtB,cAAc;CACd,iBAAiB;CACjB,cAAc;CACd,kBAAkB,MAAM;CACxB,YAAY;CACZ,oBAAoB;CACpB,kBAAkB;CAClB,kBAAkB,KAAK;CACvB,qBAAqB;CACrB,mBAAmB,IAAI;AACzB,CACF;AAGO,IAAM,kCAAkC;AAExC,IAAM,iCAAiC;AAEvC,IAAM,mCAAmC;AAEzC,IAAM,8BAA8B;AAGpC,SAAS,0BACd,WAAwC,CAAC,GACrB;CACpB,MAAM,MAAM,EAAE,GAAG,qBAAqB;CACtC,KAAA,MAAW,OAAO,OAAO,KAAK,GAAG,GAAsC;EACrE,MAAM,QAAQ,SAAS;EACvB,IACE,UAAU,KAAA,KACV,OAAO,cAAc,KAAK,KAC1B,SAAS,KACT,SAAS,qBAAqB,MAE9B,IAAI,OAAO;CACf;CACA,OAAO;AACT;AAkDO,IAAM,2BAAN,cAAuC,qBAAqB;CACjE,YACW,QACA,OACT;EACA,MAAM,yBAAyB,0BAA0B,OAAM,EAAG;EAHzD,KAAA,SAAA;EACA,KAAA,QAAA;CAGX;CAJW;CACA;AAIb;AACO,IAAM,4BAAN,cAAwC,qBAAqB;CAClE,cAAc;EACZ,MAAM,2BAA2B,qCAAqC;CACxE;AACF;AAmDO,IAAM,kDACX,IAAI,IAAI;CAAC;CAAyB;CAAoB;AAAmB,CAAC;AAErE,IAAM,2BAA2B;CACtC;CACA;CACA;CACA;CACA;CACA;CACA;AACF;AAIO,IAAM,uBAAuB;CAClC;CACA;CACA;CACA;CACA;AACF;;;ACpNA,IAAM,WAAW;AACjB,IAAM,oBAAoB;AAC1B,IAAM,cAAc;AACpB,IAAM,YAAY;AAClB,IAAM,YAAY;AAClB,IAAM,eAAe;AACrB,IAAM,aAAa;AACnB,IAAM,cAAc;AAcpB,SAAS,OAAO;CACd,MAAM,MAAO,OAAO,YAAY,eAAe,QAAQ;CAGvD,IAAI,CAAC,KAAK,MAAM,IAAI,MAAM,6CAA6C;CACvE,OAAO;EACL,MAAM,IAAI,WAAW;EACrB,QAAQ,IAAI,aAAa;CAC3B;AACF;AAEO,SAAS,UAAU,OAAoC;CAC5D,OAAO,KAAK,CAAA,CAAE,OAAO,WAAW,QAAQ,CAAA,CAAE,OAAO,KAAK,CAAA,CAAE,OAAO,KAAK;AACtE;AAEA,IAAI;AACG,SAAS,MAAM,OAA2B;CAC/C,IAAI,CAAC,UAAU;EACb,2BAAW,IAAI,YAAY,GAAG;EAC9B,KAAA,IAAS,IAAI,GAAG,IAAI,KAAK,KAAK;GAC5B,IAAI,IAAI;GACR,KAAA,IAAS,IAAI,GAAG,IAAI,GAAG,KAAK,IAAI,IAAI,IAAI,aAAc,MAAM,IAAK,MAAM;GACvE,SAAS,KAAK,MAAM;EACtB;CACF;CACA,IAAI,MAAM;CACV,KAAA,IAAS,IAAI,GAAG,IAAI,MAAM,QAAQ,KAChC,MAAO,UAAU,MAAM,MAAM,MAAM,OAAoB,QAAQ;CACjE,QAAQ,MAAM,gBAAgB;AAChC;AAEA,IAAM,UAAU,QAAmC,UAA0B;CAC3E,MAAM,IAAI,yBAAyB,QAAQ,KAAK;AAClD;AAEA,IAAM,YAEJ;AAEK,SAAS,mBAAmB,OAAwB;CACzD,OAAO,CAAC,yEAAyE,KAC/E,KACF;AACF;AAQO,SAAS,oBACd,KACA,SAA6B,sBACS;CACtC,IAAI,OAAO,QAAQ,YAAY,IAAI,WAAW,GAC5C,OAAO,OAAO,cAAc;CAC9B,IAAI,CAAC,mBAAmB,GAAG,KAAK,UAAU,KAAK,GAAG,GAChD,OAAO,OAAO,cAAc;CAC9B,MAAM,OAAO,IAAI,WAAW,MAAM,GAAG;CACrC,IACE,OAAO,WAAW,IAAI,IAAI,OAAO,gBACjC,KAAK,WAAW,GAAG,KACnB,aAAa,KAAK,IAAI,KACtB,KAAK,SAAS,GAAG,GAEjB,OAAO,OAAO,cAAc;CAC9B,MAAM,YAAY,KAAK,SAAS,GAAG;CACnC,MAAM,YAAY,YAAY,KAAK,MAAM,GAAG,EAAE,IAAI,KAAA,CAAM,MAAM,GAAG;CACjE,KAAA,MAAW,WAAW,UACpB,IACE,YAAY,MACZ,YAAY,OACZ,YAAY,QACZ,OAAO,WAAW,OAAO,IAAI,OAAO,iBAEpC,OAAO,OAAO,cAAc;CAChC,IAAI,SAAS,SAAS,OAAO,cAAc,OAAO,OAAO,eAAe;CACxE,OAAO;EAAE,MAAM,SAAS,KAAK,GAAG;EAAG;CAAU;AAC/C;AAGO,IAAM,iBAAiB,SAC5B,KAAK,UAAU,KAAK,CAAA,CAAE,YAAY;AAapC,SAAS,cAAc,OAAmB,MAAwB;CAChE,MAAM,MAAM,KAAK,IAAI,GAAG,MAAM,SAAS,YAAY,WAAW;CAC9D,MAAM,aAAuB,CAAC;CAC9B,KAAA,IAAS,IAAI,MAAM,SAAS,WAAW,KAAK,KAAK,KAAK;EACpD,IAAI,KAAK,UAAU,GAAG,IAAI,MAAM,UAAU;EAG1C,IAAI,IAAI,YAAY,KAAK,UAAU,IAAI,IAAI,IAAI,MAAM,MAAM,QACzD,WAAW,KAAK,CAAC;CACrB;CACA,IAAI,WAAW,WAAW,GACxB,OAAO,OACL,MAAM,UAAU,KAAK,KAAK,UAAU,GAAG,IAAI,MAAM,YAC7C,cACA,WACN;CACF,IAAI,WAAW,SAAS,GAAG,OAAO,OAAO,sBAAsB;CAC/D,OAAO,WAAW;AACpB;AAQO,SAAS,eACd,OACA,SAA6B,sBAC7B,YACmB;CACnB,IAAI,EAAE,iBAAiB,aAAa,OAAO,OAAO,WAAW;CAC7D,IAAI,MAAM,SAAS,OAAO,iBAAiB,OAAO,OAAO,mBAAmB;CAC5E,IAAI,MAAM,SAAS,WAAW,OAAO,OAAO,WAAW;CACvD,MAAM,QAAQ;CACd,MAAM,OAAO,IAAI,SAAS,MAAM,QAAQ,MAAM,YAAY,MAAM,UAAU;CAC1E,MAAM,OAAO,cAAc,OAAO,IAAI;CACtC,IAAI,QAAQ,MAAM,KAAK,UAAU,OAAO,IAAI,IAAI,MAAM,mBACpD,OAAO,OAAO,mBAAmB;CACnC,MAAM,OAAO,KAAK,UAAU,OAAO,GAAG,IAAI;CAC1C,MAAM,SAAS,KAAK,UAAU,OAAO,GAAG,IAAI;CAC5C,MAAM,cAAc,KAAK,UAAU,OAAO,GAAG,IAAI;CACjD,MAAM,UAAU,KAAK,UAAU,OAAO,IAAI,IAAI;CAC9C,MAAM,SAAS,KAAK,UAAU,OAAO,IAAI,IAAI;CAC7C,MAAM,WAAW,KAAK,UAAU,OAAO,IAAI,IAAI;CAC/C,IAAI,YAAY,SAAU,WAAW,cAAc,aAAa,YAC9D,OAAO,OAAO,mBAAmB;CACnC,IAAI,SAAS,KAAK,WAAW,KAAK,gBAAgB,SAChD,OAAO,OAAO,YAAY;CAC5B,IAAI,YAAY,GAAG,OAAO,OAAO,cAAc;CAC/C,IAAI,UAAU,OAAO,YAAY,OAAO,OAAO,kBAAkB;CACjE,IACE,SAAS,UAAU,gBACnB,WAAW,WAAW,MAEtB,OAAO,OAAO,2BAA2B;CAE3C,MAAM,UAAU,IAAI,YAAY,SAAS,EAAE,OAAO,KAAK,CAAC;CACxD,MAAM,UAA0B,CAAC;CACjC,MAAM,wBAAQ,IAAI,IAAY;CAC9B,IAAI,KAAK;CACT,KAAA,IAAS,IAAI,GAAG,IAAI,SAAS,KAAK;EAChC,IAAI,KAAK,eAAe,QAAQ,KAAK,UAAU,IAAI,IAAI,MAAM,aAC3D,OAAO,OAAO,2BAA2B;EAC3C,MAAM,SAAS,KAAK,UAAU,KAAK,GAAG,IAAI;EAC1C,MAAM,QAAQ,KAAK,UAAU,KAAK,GAAG,IAAI;EACzC,MAAM,SAAS,KAAK,UAAU,KAAK,IAAI,IAAI;EAC3C,MAAM,MAAM,KAAK,UAAU,KAAK,IAAI,IAAI;EACxC,MAAM,aAAa,KAAK,UAAU,KAAK,IAAI,IAAI;EAC/C,MAAM,OAAO,KAAK,UAAU,KAAK,IAAI,IAAI;EACzC,MAAM,aAAa,KAAK,UAAU,KAAK,IAAI,IAAI;EAC/C,MAAM,cAAc,KAAK,UAAU,KAAK,IAAI,IAAI;EAChD,MAAM,gBAAgB,KAAK,UAAU,KAAK,IAAI,IAAI;EAClD,MAAM,YAAY,KAAK,UAAU,KAAK,IAAI,IAAI;EAC9C,MAAM,WAAW,KAAK,UAAU,KAAK,IAAI,IAAI;EAC7C,MAAM,cAAc,KAAK,UAAU,KAAK,IAAI,IAAI;EAChD,MAAM,OAAO,KAAK,eAAe,aAAa,cAAc;EAC5D,IAAI,OAAO,MAAM,OAAO,OAAO,2BAA2B;EAC1D,MAAM,UAAU,MAAM,SACpB,KAAK,cACL,KAAK,eAAe,UACtB;EACA,IAAI;EACJ,IAAI;GACF,UAAU,QAAQ,OAAO,OAAO;EAClC,QAAQ;GACN,OAAO,OAAO,cAAc;EAC9B;EACA,MAAM,EAAE,MAAM,cAAc,oBAAoB,SAAS,MAAM;EAC/D,IAAI,QAAS,QAAwB,WAAW,IAC9C,OAAO,OAAO,mBAAmB,IAAI;EACvC,IAAI,WAAW,KAAK,WAAW,GAAG,OAAO,OAAO,sBAAsB,IAAI;EAC1E,IACE,eAAe,cACf,SAAS,cACT,gBAAgB,YAEhB,OAAO,OAAO,qBAAqB,IAAI;EACzC,IAAI,cAAc,GAAG,OAAO,OAAO,cAAc,IAAI;EAErD,IADa,WAAW,MACX,GAAG;GACd,MAAM,OAAQ,aAAa,KAAM;GACjC,IAAI,SAAS,OAAU,OAAO,OAAO,iBAAiB,IAAI;GAC1D,IAAI,SAAS,KAAK,SAAS,SAAY,SAAS,OAC9C,OAAO,OAAO,iBAAiB,IAAI;EACvC,OAAA,IAAW,WAAW,MACpB,OAAO,OAAO,iBAAiB,IAAI;EAErC,IAAI,cAAc,SAAS,KAAK,aAAa,IAC3C,OAAO,OAAO,iBAAiB,IAAI;EACrC,MAAM,MAAM,cAAc,IAAI;EAC9B,IAAI,MAAM,IAAI,GAAG,GAAG,OAAO,OAAO,kBAAkB,IAAI;EACxD,MAAM,IAAI,GAAG;EACb,IAAI,WAAW,KAAK,eAAe,MACjC,OAAO,OAAO,iBAAiB,IAAI;EACrC,IACE,OAAO,OAAO,mBACd,WAAW,MACV,eAAe,KAAK,OAAO,aAAa,OAAO,sBAEhD,OAAO,OAAO,qBAAqB,IAAI;EACzC,QAAQ,KAAK;GACX,MAAM;GACN;GACA;GACA;GACA;GACA;GACA;GACA;EACF,CAAC;EACD,KAAK;CACP;CACA,IAAI,OAAO,MAAM,OAAO,OAAO,2BAA2B;CAI1D,MAAM,UAAgE,CAAC;CACvE,MAAM,SAA0C,CAAC;CACjD,KAAA,MAAW,SAAS,SAAS;EAC3B,IAAI,MAAM,WAAW;EACrB,IAAI,MAAM,cAAc,aAAa,UACnC,OAAO,OAAO,sBAAsB,MAAM,IAAI;EAChD,IAAI,KAAK,UAAU,MAAM,aAAa,IAAI,MAAM,WAC9C,OAAO,OAAO,yBAAyB,MAAM,IAAI;EAEnD,IADc,KAAK,UAAU,MAAM,cAAc,GAAG,IAChD,IAAS,MACX,OAAO,OAAO,mBAAmB,MAAM,IAAI;EAC7C,IAAI,KAAK,UAAU,MAAM,cAAc,GAAG,IAAI,MAAM,MAAM,QACxD,OAAO,OAAO,yBAAyB,MAAM,IAAI;EACnD,MAAM,aAAa,KAAK,UAAU,MAAM,cAAc,IAAI,IAAI;EAC9D,MAAM,cAAc,KAAK,UAAU,MAAM,cAAc,IAAI,IAAI;EAC/D,MAAM,QAAQ,MAAM,cAAc,aAAa,aAAa;EAC5D,MAAM,MAAM,QAAQ,MAAM;EAC1B,IAAI,MAAM,UAAU,OAAO,OAAO,sBAAsB,MAAM,IAAI;EAClE,MAAM,QAAQ,MAAM,SAClB,MAAM,cAAc,YACpB,MAAM,cAAc,aAAa,UACnC;EACA,IACE,MAAM,WAAW,MAAM,QAAQ,UAC/B,CAAC,MAAM,OAAO,GAAG,MAAM,MAAM,MAAM,QAAQ,EAAE,GAE7C,OAAO,OAAO,yBAAyB,MAAM,IAAI;EACnD,QAAQ,KAAK;GAAE,GAAG;GAAO;GAAO;EAAI,CAAC;EACrC,OAAO,KAAK;GAAC,MAAM;GAAa;GAAK,MAAM;EAAI,CAAC;CAClD;CACA,OAAO,MAAM,GAAG,MAAM,EAAE,KAAK,EAAE,EAAE;CACjC,KAAA,IAAS,IAAI,GAAG,IAAI,OAAO,QAAQ,KACjC,IACG,OAAO,EAAC,CAA+B,KACvC,OAAO,IAAI,EAAC,CAA+B,IAE5C,OAAO,OACL,uBACC,OAAO,EAAC,CAA+B,EAC1C;CAGJ,IAAI,WAAW;CACf,KAAA,MAAW,SAAS,SAClB,IAAI,MAAM,QAAQ,OAAO,eAAe,YAAY,MAAM;CAC5D,IAAI,WAAW,OAAO,eAAe,OAAO,OAAO,iBAAiB;CAEpE,MAAM,EAAE,SAAS,KAAK;CACtB,MAAM,MAAyB,CAAC;CAChC,IAAI,QAAQ;CACZ,aAAa,GAAG,QAAQ,MAAM;CAC9B,KAAA,MAAW,SAAS,CAAC,GAAG,OAAO,CAAA,CAAE,MAAM,GAAG,MACxC,EAAE,OAAO,EAAE,OAAO,KAAK,EAAE,OAAO,EAAE,OAAO,IAAI,CAC/C,GAAG;EACD,IAAI,MAAM,OAAO,OAAO,eAAe;GACrC,IAAI,KAAK;IACP,MAAM,MAAM;IACZ,MAAM,MAAM;IACZ,OAAO;IACP,QAAQ;IACR,SAAS;GACX,CAAC;GACD,aAAa,IAAI,QAAQ,QAAQ,MAAM;GACvC;EACF;EACA,MAAM,OAAO,MAAM,SAAS,MAAM,OAAO,MAAM,GAAG;EAClD,IAAI;EACJ,IAAI,MAAM,WAAW,GACnB,WAAW,OAAO,KAAK,IAAI;OAE3B,IAAI;GACF,WAAW,KAAK,eAAe,MAAM,EACnC,iBAAiB,KAAK,IAAI,MAAM,MAAM,CAAC,EACzC,CAAC;EACH,SAAS,OAAO;GACd,OAAO,OACJ,MAA4B,SAAS,yBAClC,kBACA,kBACJ,MAAM,IACR;EACF;EAEF,IAAI,SAAS,WAAW,MAAM,MAC5B,OAAO,OAAO,iBAAiB,MAAM,IAAI;EAC3C,IAAI,MAAM,QAAQ,MAAM,MAAM,KAAK,OAAO,OAAO,WAAW,MAAM,IAAI;EACtE,SAAS,SAAS;EAClB,IAAI,QAAQ,OAAO,eAAe,OAAO,OAAO,iBAAiB;EACjE,IAAI,KAAK;GACP,MAAM,MAAM;GACZ,MAAM,SAAS;GACf,OAAO;GACP,QAAQ,UAAU,QAAQ;GAC1B,SAAS;EACX,CAAC;EACD,aAAa,IAAI,QAAQ,QAAQ,MAAM;CACzC;CACA,OAAO;AACT;AAGO,SAAS,YACd,OACA,SAA6B,sBAC7B,YACmB;CACnB,IAAI,CAAC,MAAM,QAAQ,KAAK,KAAK,MAAM,WAAW,GAC5C,OAAO,OAAO,cAAc;CAC9B,IAAI,MAAM,SAAS,OAAO,YAAY,OAAO,OAAO,kBAAkB;CACtE,MAAM,wBAAQ,IAAI,IAAY;CAC9B,MAAM,MAAyB,CAAC;CAChC,IAAI,QAAQ;CACZ,IAAI,cAAc;CAClB,aAAa,GAAG,MAAM,MAAM;CAC5B,KAAA,MAAW,QAAQ,OAAO;EACxB,MAAM,EAAE,MAAM,cAAc,oBAAoB,MAAM,MAAM,MAAM;EAClE,IAAI,aAAa,EAAE,KAAK,iBAAiB,aACvC,OAAO,OAAO,iBAAiB,IAAI;EACrC,MAAM,MAAM,cAAc,IAAI;EAC9B,IAAI,MAAM,IAAI,GAAG,GAAG,OAAO,OAAO,kBAAkB,IAAI;EACxD,MAAM,IAAI,GAAG;EACb,SAAS,KAAK,MAAM;EACpB,IAAI,QAAQ,OAAO,iBAAiB,OAAO,OAAO,mBAAmB;EACrE,IAAI,KAAK,MAAM,SAAS,OAAO,eAC7B,IAAI,KAAK;GACP;GACA,MAAM,KAAK,MAAM;GACjB,OAAO;GACP,QAAQ;GACR,SAAS;EACX,CAAC;OACI;GAEL,eAAe,KAAK,MAAM;GAC1B,IAAI,cAAc,OAAO,eAAe,OAAO,OAAO,iBAAiB;GACvE,IAAI,KAAK;IACP;IACA,MAAM,KAAK,MAAM;IACjB,OAAO,KAAK;IACZ,QAAQ,UAAU,KAAK,KAAK;IAC5B,SAAS;GACX,CAAC;EACH;EACA,aAAa,IAAI,QAAQ,MAAM,MAAM;CACvC;CACA,OAAO,IAAI,MAAM,GAAG,MAAO,EAAE,OAAO,EAAE,OAAO,KAAK,EAAE,OAAO,EAAE,OAAO,IAAI,CAAE;AAC5E"}
|