@happyvertical/smrt-education 0.0.0-stage → 0.56.2
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 +32 -0
- package/CLAUDE.md +1 -0
- package/LICENSE +7 -0
- package/README.md +139 -2
- package/agents/catalog-service.md +45 -0
- package/agents/learner-service.md +101 -0
- package/agents/source-import.md +63 -0
- package/agents/submission-service.md +122 -0
- package/dist/__smrt-register__.d.ts +2 -0
- package/dist/__smrt-register__.d.ts.map +1 -0
- package/dist/catalog-service.d.ts +50 -0
- package/dist/catalog-service.d.ts.map +1 -0
- package/dist/catalog.d.ts +72 -0
- package/dist/catalog.d.ts.map +1 -0
- package/dist/collections.d.ts +70 -0
- package/dist/collections.d.ts.map +1 -0
- package/dist/course-source.d.ts +25 -0
- package/dist/course-source.d.ts.map +1 -0
- package/dist/index.d.ts +24 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +24733 -0
- package/dist/index.js.map +1 -0
- package/dist/learner-service.d.ts +95 -0
- package/dist/learner-service.d.ts.map +1 -0
- package/dist/learners.d.ts +58 -0
- package/dist/learners.d.ts.map +1 -0
- package/dist/lesson-assets.d.ts +12 -0
- package/dist/lesson-assets.d.ts.map +1 -0
- package/dist/manifest.json +9484 -0
- package/dist/recipes.d.ts +50 -0
- package/dist/recipes.d.ts.map +1 -0
- package/dist/smrt-knowledge.json +3600 -0
- package/dist/source-import-service.d.ts +50 -0
- package/dist/source-import-service.d.ts.map +1 -0
- package/dist/sources/easy-peasy-parser.d.ts +23 -0
- package/dist/sources/easy-peasy-parser.d.ts.map +1 -0
- package/dist/submission-service.d.ts +110 -0
- package/dist/submission-service.d.ts.map +1 -0
- package/dist/submissions.d.ts +70 -0
- package/dist/submissions.d.ts.map +1 -0
- package/package.json +64 -3
package/AGENTS.md
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
# smrt-education
|
|
2
|
+
|
|
3
|
+
Catalog objects are optional-tenant scoped and have no generated REST, CLI, MCP,
|
|
4
|
+
or AI surface. Applications use `CatalogService`, supplying their actor-bound
|
|
5
|
+
authorization decision and the trusted `TenantCollection.withTenantCatalogReadScope`
|
|
6
|
+
adapter from smrt-users. That read scope may widen only an allowlisted catalog
|
|
7
|
+
collection's `list` to self, ancestors, and globals; it never authorizes a get,
|
|
8
|
+
write, raw query, sibling, or descendant read.
|
|
9
|
+
|
|
10
|
+
`personal-use-copy` courses and all `answer-key` lesson assets are private
|
|
11
|
+
family material. Do not publish them, promote them, or expose them through a
|
|
12
|
+
learner-facing result. `LessonAsset` is the sole owner join for lesson files;
|
|
13
|
+
do not add asset-id fields to lesson subclasses.
|
|
14
|
+
|
|
15
|
+
Learner objects require exact tenant ownership and close generated surfaces.
|
|
16
|
+
`LearnerService` owns actor/resource authorization and transactional planning and
|
|
17
|
+
start-progress; see [learner contract](agents/learner-service.md). Guardian
|
|
18
|
+
relationships require a host decision about their type and effective terms.
|
|
19
|
+
Never expose direct collection writes as learner handlers, reset progress on a
|
|
20
|
+
retry, or treat an unscored nullable decimal as zero.
|
|
21
|
+
|
|
22
|
+
Submission capture, guardian grading and private PDF packet splitting use
|
|
23
|
+
`SubmissionService`; see [submission contract](agents/submission-service.md).
|
|
24
|
+
Keep asset metadata on the same transaction and compensate only newly planned
|
|
25
|
+
files after rollback; never expose source URIs or accept learner grade patches.
|
|
26
|
+
|
|
27
|
+
Validation: `pnpm --filter @happyvertical/smrt-education test`,
|
|
28
|
+
`test:postgres`, `typecheck`, and `build`.
|
|
29
|
+
|
|
30
|
+
Source import: [host boundary and transaction contract](agents/source-import.md).
|
|
31
|
+
`SourceImportService` requires host opt-in, matching active actor/tenant and
|
|
32
|
+
explicit licensing authorization; never add a system/global write bypass.
|
package/CLAUDE.md
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
@AGENTS.md
|
package/LICENSE
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
Copyright <2025> <Happy Vertical Corporation>
|
|
2
|
+
|
|
3
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
|
|
4
|
+
|
|
5
|
+
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
|
|
6
|
+
|
|
7
|
+
THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,140 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @happyvertical/smrt-education
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Preview education catalog models and authorized catalog reads for s-m-r-t.
|
|
4
|
+
The package represents courses, ordered sections, lessons, and lesson-owned files.
|
|
5
|
+
It supplies catalog structure and authorized learner planning/progress workflows.
|
|
6
|
+
Applications authenticate actors and decide guardian permissions.
|
|
7
|
+
|
|
8
|
+
## Install
|
|
9
|
+
|
|
10
|
+
```sh
|
|
11
|
+
pnpm add @happyvertical/smrt-education
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
Use the package with the core, users, profiles, tenancy, and assets packages.
|
|
15
|
+
Apply application migrations before using the catalog collections.
|
|
16
|
+
Runtime catalog operations do not create application schema.
|
|
17
|
+
|
|
18
|
+
## Models and collections
|
|
19
|
+
|
|
20
|
+
| Model | Purpose |
|
|
21
|
+
| --- | --- |
|
|
22
|
+
| `CourseSource` | Host-managed adapter policy and sharing ceiling |
|
|
23
|
+
| `Course` | Course metadata, license, status, and optional owning tenant |
|
|
24
|
+
| `ExternalCourse` | External course subtype sharing the Course table |
|
|
25
|
+
| `AuthoredCourse` | Authored course subtype sharing the Course table |
|
|
26
|
+
| `CourseSection` | Ordered section belonging to a course |
|
|
27
|
+
| `Lesson` | Ordered lesson belonging to a section |
|
|
28
|
+
| `Reading`, `Video`, `Worksheet`, `Exercise` | Lesson subtypes |
|
|
29
|
+
| `LessonAsset` | Lesson-owned file join with a relationship such as printable or answer-key |
|
|
30
|
+
| `Learner` | A required User/Person identity and guarded settings in one family tenant |
|
|
31
|
+
| `Curriculum` | A learner plan for a distinct period |
|
|
32
|
+
| `Enrollment` | One course in a curriculum |
|
|
33
|
+
| `LessonProgress` | One lesson in an enrollment, with nullable score and separate history |
|
|
34
|
+
|
|
35
|
+
Each exported model has a corresponding exported collection.
|
|
36
|
+
Course and lesson subtypes use single-table inheritance.
|
|
37
|
+
Sections and lessons sort by `sortOrder`, with identifier ordering for ties.
|
|
38
|
+
Lesson files belong in `LessonAsset`; lesson subtypes do not carry asset-id fields.
|
|
39
|
+
|
|
40
|
+
## Basic integration
|
|
41
|
+
|
|
42
|
+
1. Create the course, section, lesson, and lesson-asset collections with the
|
|
43
|
+
application's database executor.
|
|
44
|
+
2. Supply those collections, a request-bound `TenantCollection`, and the host's
|
|
45
|
+
actor-bound permission resolver to `CatalogService`.
|
|
46
|
+
3. Enable tenancy and establish the authenticated actor's active tenant context.
|
|
47
|
+
4. Call `findVisible` with that tenant's canonical identifier and the actor's
|
|
48
|
+
user identifier to list the ordinary catalog.
|
|
49
|
+
5. Call `projectCourse` with the same context and a visible course identifier
|
|
50
|
+
to obtain its ordered sections, lessons, and allowed asset joins.
|
|
51
|
+
|
|
52
|
+
The [catalog service integration tests](src/__tests__/catalog-service.test.ts)
|
|
53
|
+
provide executable examples of collection setup, `withTenant`, and service reads.
|
|
54
|
+
See the [integration contract](agents/catalog-service.md) for host responsibilities.
|
|
55
|
+
The authorizer must make a trusted server-side permission decision for the actor;
|
|
56
|
+
a caller-supplied boolean is not a substitute for authentication or authorization.
|
|
57
|
+
|
|
58
|
+
## Privacy and ownership
|
|
59
|
+
|
|
60
|
+
Ordinary catalog reads can include the active tenant, its ancestors, and globals.
|
|
61
|
+
The supplied tenancy capability widens only the allowlisted catalog list reads.
|
|
62
|
+
It does not grant generic get, write, raw query, sibling, or descendant access.
|
|
63
|
+
|
|
64
|
+
Personal-use copies require an owning tenant and cannot be published.
|
|
65
|
+
Their owner and license kind cannot be changed after creation.
|
|
66
|
+
`includePrivate` requires a separate host grant and returns only the active
|
|
67
|
+
owner's personal copies, never an ancestor's or global private copy.
|
|
68
|
+
The shared Tenant model has no family/group discriminator; hosts provision
|
|
69
|
+
family tenants and authorize their guardian workflows.
|
|
70
|
+
|
|
71
|
+
Learner projections remove worksheet answer URLs and answer-key asset joins.
|
|
72
|
+
`includeAnswerKeys` requires a separate host permission.
|
|
73
|
+
Every projected child must have the selected course's exact owning tenant.
|
|
74
|
+
Stored inconsistent section, lesson, and asset links are excluded.
|
|
75
|
+
Generated REST, CLI, MCP, and AI operations are closed for these models.
|
|
76
|
+
|
|
77
|
+
## Learner workflows
|
|
78
|
+
|
|
79
|
+
Create `LearnerService` with the application's root database executor and trusted
|
|
80
|
+
learner/catalog authorizers. In the authenticated actor's exact `withTenant`
|
|
81
|
+
context, an authorized guardian can `createCurriculum` and `enroll`; a learner
|
|
82
|
+
can `getLearner`, `startLesson`, and `listProgress` for their own work.
|
|
83
|
+
Guardian access requires a same-tenant directed `ProfileRelationship` plus the
|
|
84
|
+
host's permission decision. Sibling membership does not grant access.
|
|
85
|
+
|
|
86
|
+
Repeated starts preserve the existing progress, including attempts and scores.
|
|
87
|
+
A new curriculum/year creates a distinct enrollment and fresh progress history.
|
|
88
|
+
An unscored lesson has `bestScore: null`, distinct from zero. The service does
|
|
89
|
+
not accept score or completion patches; `SubmissionService` owns guardian grading.
|
|
90
|
+
Use `withTransaction` and its callback service to compose atomic operations.
|
|
91
|
+
Results are plain data views, so no transaction-bound model handle escapes.
|
|
92
|
+
|
|
93
|
+
The [learner integration contract](agents/learner-service.md) describes required
|
|
94
|
+
identity provisioning, catalog permissions, supported workflows and limitations.
|
|
95
|
+
The [executable learner workflows](src/__tests__/learner-workflows.ts) test this
|
|
96
|
+
integration on both SQLite and PostgreSQL.
|
|
97
|
+
|
|
98
|
+
## Submissions and worksheet packets
|
|
99
|
+
|
|
100
|
+
`SubmissionService` captures tenant-owned uploads, reviews or attests work with
|
|
101
|
+
optional guardian scores, signs authorized asset claims, and splits private
|
|
102
|
+
PDF packets by page range. It reuses learner ownership and transaction checks.
|
|
103
|
+
See the [submission integration contract](agents/submission-service.md) for
|
|
104
|
+
inputs, retry rules, signing requirements and external-storage compensation.
|
|
105
|
+
|
|
106
|
+
## Source imports
|
|
107
|
+
|
|
108
|
+
`SourceImportService` imports Easy Peasy pages as private family pointers after
|
|
109
|
+
explicit host terms acceptance. Hosts inject the page fetcher and authorize the
|
|
110
|
+
active actor, family/group destination and licensing evidence. Re-import refreshes
|
|
111
|
+
URLs in place without deleting lessons or learner history. No lesson text or PDFs
|
|
112
|
+
are mirrored. See the [source integration contract](agents/source-import.md) and
|
|
113
|
+
[source behavior matrix](../../docs/test-matrix/3967-education-sources.md).
|
|
114
|
+
|
|
115
|
+
## Recipes
|
|
116
|
+
|
|
117
|
+
`EducationCatalogRecipe` declares `education.catalog`; `EducationLearnerRecipe`
|
|
118
|
+
declares `education.learner`.
|
|
119
|
+
Its course navigation and model declarations support application composition.
|
|
120
|
+
A recipe does not grant permission to expose model CRUD or private material.
|
|
121
|
+
|
|
122
|
+
## Database support and validation
|
|
123
|
+
|
|
124
|
+
SQLite and PostgreSQL exercise catalog persistence and child ownership guards.
|
|
125
|
+
The real ancestor hierarchy integration is exercised on SQLite.
|
|
126
|
+
DuckDB tests cover Course-only persistence: existing core DDL gates reject
|
|
127
|
+
catalog child update-cascade and Asset/Tenant self-referential foreign keys.
|
|
128
|
+
Use SQLite or PostgreSQL for full catalog schemas; those gates remain intact.
|
|
129
|
+
|
|
130
|
+
Run the package's `test`, `test:postgres`, `typecheck`, and `build` scripts.
|
|
131
|
+
The [behavior matrix](../../docs/test-matrix/3966-education-catalog.md) records
|
|
132
|
+
regressions, dialect coverage, and the existing DuckDB setup limitations.
|
|
133
|
+
|
|
134
|
+
## Related documentation
|
|
135
|
+
|
|
136
|
+
- [Package agent guidance](AGENTS.md)
|
|
137
|
+
- [Core persistence](../core/README.md)
|
|
138
|
+
- [Users and tenant hierarchy](../users/README.md)
|
|
139
|
+
- [Tenant isolation](../tenancy/README.md)
|
|
140
|
+
- [Assets](../assets/README.md)
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
# Catalog service integration
|
|
2
|
+
|
|
3
|
+
`EducationCatalogRecipe` is a cookbook declaration, not permission to expose
|
|
4
|
+
model CRUD. Hosts construct `CatalogService` with a `CourseCollection`, their
|
|
5
|
+
request-bound `TenantCollection`, and a permission resolver adapter. They call
|
|
6
|
+
`findVisible({ tenantId, actorUserId })` for a learner projection and pass
|
|
7
|
+
`includePrivate: true` only after a guardian-level
|
|
8
|
+
`education.catalog.read-private` grant. The service enters
|
|
9
|
+
`TenantCollection.withTenantCatalogReadScope` for the Course collection; that
|
|
10
|
+
scope is limited to `list`, active family ancestors, and global rows.
|
|
11
|
+
|
|
12
|
+
Private copies are returned only when their tenant exactly matches the active
|
|
13
|
+
request tenant, even with the private-read grant. Ancestor and global private
|
|
14
|
+
rows are excluded. Projection also excludes each section, lesson, and asset
|
|
15
|
+
join whose tenant differs from the selected course, including malformed stored
|
|
16
|
+
links that predate the model save guards.
|
|
17
|
+
|
|
18
|
+
The shared Tenant model has no family/group kind discriminator. Hosts provision
|
|
19
|
+
family tenants and authorize guardian authoring for those tenants; the catalog
|
|
20
|
+
enforces a non-null owning tenant, immutable personal-copy tenant/license, and
|
|
21
|
+
prohibits `published` personal copies on create and update. Hierarchy position
|
|
22
|
+
is not a family classification. This is a new package with no deployed catalog
|
|
23
|
+
rows to migrate. If a host imports invalid published personal copies, correct
|
|
24
|
+
them to `draft` or `archived` under the original owner before normal saves; never
|
|
25
|
+
relabel or promote them. Read ownership checks remain enforced for invalid rows.
|
|
26
|
+
|
|
27
|
+
The host resolves sections, lessons, and LessonAsset rows only after selecting
|
|
28
|
+
an authorized visible course, preserving the course tenant and excluding
|
|
29
|
+
`answer-key` relationships from learner projections. Guardian authoring and
|
|
30
|
+
asset linking use host handlers that make the same permission decision; no
|
|
31
|
+
generated REST, CLI, MCP, or AI operation may bypass those handlers.
|
|
32
|
+
|
|
33
|
+
Ownership checks use SQL tenant identity predicates so native UUID driver values
|
|
34
|
+
cannot reject an unchanged owner. Course-only persistence is exercised on DuckDB;
|
|
35
|
+
full catalog child/asset schemas inherit core's current DuckDB rejection of
|
|
36
|
+
ON UPDATE CASCADE and self-referential foreign keys. Use SQLite or PostgreSQL for
|
|
37
|
+
those schemas; no schema gate is relaxed by this package.
|
|
38
|
+
|
|
39
|
+
Private-course visibility is a mandatory collection SQL predicate applied before
|
|
40
|
+
`limit` and `offset`, including selected-column reads. Caller options cannot
|
|
41
|
+
replace that predicate. Video constructor options are reapplied after subtype
|
|
42
|
+
field initialization, matching Worksheet. LessonAsset's explicit natural key is
|
|
43
|
+
`(tenant_id, lesson_id, asset_id, relationship)`; same-owner and null-owner retries
|
|
44
|
+
retain one join identity. Core's cross-owner guard remains in place. A hostile
|
|
45
|
+
stored join cannot reserve the legitimate owner's key or be overwritten by it.
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
# Learner service boundary
|
|
2
|
+
|
|
3
|
+
`Learner`, `Curriculum`, `Enrollment`, and `LessonProgress` require an exact
|
|
4
|
+
owning tenant. Their generated API, CLI, MCP, and AI operations are closed.
|
|
5
|
+
The corresponding collections and model saves are trusted server persistence
|
|
6
|
+
APIs, not request handlers. Hosts must not wrap them in generic learner CRUD.
|
|
7
|
+
|
|
8
|
+
## Identity and authority
|
|
9
|
+
|
|
10
|
+
Provision a Person Profile, a linked User, and an active Membership before
|
|
11
|
+
creating a Learner with explicit `tenantId`, `userId`, and `profileId`. User
|
|
12
|
+
email is optional. Learner identity cannot be reassigned after creation.
|
|
13
|
+
Guardianship is a directed `ProfileRelationship`, not a Learner `parentId`.
|
|
14
|
+
The shared Tenant has no family discriminator: “family” means a tenant that
|
|
15
|
+
the host has provisioned and authorized for this purpose.
|
|
16
|
+
|
|
17
|
+
Construct `LearnerService(db, authorizeLearner, authorizeCatalog)` with one
|
|
18
|
+
root database executor and trusted server-side authorizers. Enable tenancy
|
|
19
|
+
and establish `withTenant({ tenantId, userId }, callback)` from authenticated
|
|
20
|
+
credentials. Every request must match that active actor and tenant exactly;
|
|
21
|
+
missing context, spoofed identifiers, and super-admin bypass contexts fail.
|
|
22
|
+
Both the actor and learner must hold active memberships in that exact tenant.
|
|
23
|
+
A learner's User must still link to the learner's Person Profile.
|
|
24
|
+
|
|
25
|
+
For a different actor, supply `guardianRelationshipId`. The service checks
|
|
26
|
+
that this relationship is owned by the active tenant and directed from the
|
|
27
|
+
actor's User Profile to the learner's Profile. `authorizeLearner` receives
|
|
28
|
+
`own`, `action`, and the verified `relationshipTypeId`; the host must check
|
|
29
|
+
that the type represents guardianship, its terms are effective, and that
|
|
30
|
+
its permission policy allows the action. An arbitrary relationship is not a
|
|
31
|
+
guardian grant. Missing relationships and host denials/exceptions fail closed.
|
|
32
|
+
Same-tenant membership alone never grants sibling access.
|
|
33
|
+
|
|
34
|
+
## Supported workflow
|
|
35
|
+
|
|
36
|
+
- `getLearner` returns authorized learner metadata and parsed settings.
|
|
37
|
+
- `createCurriculum` creates a **planned** curriculum for an authorized guardian.
|
|
38
|
+
A learner cannot plan their own curriculum. Title is required.
|
|
39
|
+
- `enroll` requires that guardian authority and a matching tenant curriculum.
|
|
40
|
+
It returns the existing enrollment for the same curriculum/course unchanged.
|
|
41
|
+
- `startLesson` allows the learner or authorized guardian to create their own
|
|
42
|
+
enrollment's lesson progress in `in_progress`. It derives learner identity
|
|
43
|
+
from the validated enrollment. Repeated starts return existing state without
|
|
44
|
+
resetting counters, scores, completion, or timestamps.
|
|
45
|
+
- `listProgress` reads only the validated learner/enrollment and lessons still
|
|
46
|
+
visible in that enrollment's course. Inconsistent persisted links fail
|
|
47
|
+
closed or are omitted from the list.
|
|
48
|
+
|
|
49
|
+
Service results are data-only views, with no persistence or database methods.
|
|
50
|
+
Extra request fields are never model patches: a learner cannot set score,
|
|
51
|
+
attempt count, or completion status through this API. Curriculum activation,
|
|
52
|
+
completion transitions and dashboard aggregation are not supported by this
|
|
53
|
+
base service. SubmissionService extends its owning transaction/auth boundary for
|
|
54
|
+
capture and guardian grading; see [submission contract](submission-service.md).
|
|
55
|
+
Direct model writes require trusted authority.
|
|
56
|
+
|
|
57
|
+
Catalog visibility and child ownership reuse `CatalogService` on the same
|
|
58
|
+
executor. Learner operations request private-inclusive catalog access, so the
|
|
59
|
+
host catalog authorizer must permit both `education.catalog.read` and
|
|
60
|
+
`education.catalog.read-private` for the supported enrollment workflow.
|
|
61
|
+
Private copies remain restricted to the exact owner. The service constructs a
|
|
62
|
+
whitelisted catalog request; caller options and answer-key flags are discarded.
|
|
63
|
+
Answer keys are never requested or returned. A catalog grant does not grant learner authorization.
|
|
64
|
+
|
|
65
|
+
## Transactions and history
|
|
66
|
+
|
|
67
|
+
`withTransaction(async transaction => ...)` composes service operations on the
|
|
68
|
+
callback's transaction-bound service. Use that service for every operation in
|
|
69
|
+
the unit. Throwing rolls back its curriculum, enrollment, and progress writes.
|
|
70
|
+
The callback's service closes when the transaction ends and rejects later calls.
|
|
71
|
+
A failed mutation poisons the unit even if the callback catches its rejection;
|
|
72
|
+
queued mutations are cancelled. Callback failure stops pending work and waits for
|
|
73
|
+
already-running mutations to settle before rollback releases the executor. On
|
|
74
|
+
success, already-enqueued mutations finish before commit. Do not retain the
|
|
75
|
+
callback's service beyond the callback. The constructor
|
|
76
|
+
accepts a root database; callers composing work use this method instead of
|
|
77
|
+
passing an unmanaged transaction handle.
|
|
78
|
+
|
|
79
|
+
PostgreSQL locks the parent curriculum/enrollment row before find-or-create.
|
|
80
|
+
Embedded writes use the core transaction queue. Concurrent mutations inside
|
|
81
|
+
one callback transaction are ordered on that connection, so duplicate starts
|
|
82
|
+
within a `Promise.all` also return the same row. Database natural-key constraints
|
|
83
|
+
arbitrate enrollment `(tenant_id, curriculum_id, course_id)` and progress
|
|
84
|
+
`(tenant_id, enrollment_id, lesson_id)` identities. Explicit storage conflict
|
|
85
|
+
keys include the tenant prefix because core does not add it to explicit keys.
|
|
86
|
+
The logical keys required by #3968 remain curriculum/course and enrollment/lesson:
|
|
87
|
+
immutable parent ownership and parent-tenant checks reject any second tenant
|
|
88
|
+
for the same parent. The storage prefix cannot create a second valid logical row. Ordinary repeated and concurrent calls
|
|
89
|
+
return one row; an aborted workflow can be retried. No in-memory process lock
|
|
90
|
+
is used for PostgreSQL concurrency.
|
|
91
|
+
|
|
92
|
+
A new year is a new curriculum and enrollment, producing independent progress.
|
|
93
|
+
Unscored `bestScore` persists as null; score zero is a real score. Scores are
|
|
94
|
+
nullable decimals in [0,1]; counters are nonnegative safe integers. Settings
|
|
95
|
+
are stored as JSON text and accessed through guarded `getSettings`/`setSettings`.
|
|
96
|
+
|
|
97
|
+
The shared executable workflows in `src/__tests__/learner-workflows.ts` run
|
|
98
|
+
against SQLite and PostgreSQL, including actor/resource/context denial,
|
|
99
|
+
concurrent creation, rollback, history preservation, and zero/null scores.
|
|
100
|
+
Full DuckDB schemas remain blocked by existing upstream DDL restrictions;
|
|
101
|
+
no full learner workflow support on DuckDB is claimed.
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
# Source import integration
|
|
2
|
+
|
|
3
|
+
`SourceImportService` is a server-side host boundary, with no generated REST,
|
|
4
|
+
CLI, MCP or AI operation. Recipes remain metadata only. Provision a closed
|
|
5
|
+
`CourseSource` in the owning tenant, then construct the service with the root
|
|
6
|
+
SQL database and an actor-bound `SourceImportAuthorizer`. Enable tenancy and
|
|
7
|
+
call within `withTenant({ tenantId, userId })`; the request must match both.
|
|
8
|
+
Missing context, a different actor/tenant and super-admin bypass fail closed.
|
|
9
|
+
|
|
10
|
+
Register Easy Peasy only after the host operator accepts its terms, using
|
|
11
|
+
`registerEasyPeasy({ termsAccepted: true, fetchPage })`. Do not derive that
|
|
12
|
+
boolean from an HTTP request. `fetchPage(url, { redirect: 'error' })` must reject
|
|
13
|
+
redirects, bound time/size and return `{ html, url }`. It fetches the page only,
|
|
14
|
+
never a PDF. The service checks the approved HTTPS URL before fetching, checks
|
|
15
|
+
the response URL, limits HTML to 2 million characters, then parses the complete
|
|
16
|
+
page before any write. There is no automatic registration or network access. Structural h1–h6 headings delimit day content, including trailing Resources
|
|
17
|
+
sections; ordinary non-lesson bold instructions do not. Supported
|
|
18
|
+
heading blocks are h1–h6 or a lesson marker as the first bold run in a
|
|
19
|
+
paragraph/list item.
|
|
20
|
+
Recognizable markers outside that layout, including multiple markers in one
|
|
21
|
+
paragraph, fail closed rather than silently assigning a PDF to the wrong day.
|
|
22
|
+
|
|
23
|
+
The authorizer must verify the authenticated actor's management grant and the
|
|
24
|
+
provisioned tenant's family/group classification. Tenant has no built-in kind;
|
|
25
|
+
neither hierarchy position nor client input establishes a family. Authorization
|
|
26
|
+
is required before fetch and again inside the transaction; callbacks must be
|
|
27
|
+
read-only decisions, since a rolled-back contention retry can call them again.
|
|
28
|
+
|
|
29
|
+
`sharing` is a ceiling: private permits private-family import only; group and
|
|
30
|
+
global permit private-family or group import. Group imports additionally require
|
|
31
|
+
nonblank host-recorded `config.licenseEvidence` and an explicit host grant that
|
|
32
|
+
validates that permission. Global/null destinations are unsupported and rejected;
|
|
33
|
+
there is no global/system bypass. Widening a source never promotes existing
|
|
34
|
+
imports. Private imports are draft `personal-use-copy` courses and retain the
|
|
35
|
+
catalog's immutable owner/license and no-publication protection. A host may
|
|
36
|
+
create a new licensed group source and import there after receiving permission.
|
|
37
|
+
|
|
38
|
+
Use a stable `courseRef` independent of source URLs. Identities derive from the
|
|
39
|
+
owning tenant, source row ID, course reference and lesson day; retain the source
|
|
40
|
+
row when updating policy. Changes to titles/page/PDF URLs update in place.
|
|
41
|
+
Missing days are retained, not deleted, so enrollment, progress, submission and
|
|
42
|
+
asset foreign keys remain valid. A changed lesson kind fails the whole import
|
|
43
|
+
rather than replacing a row. Only title and pointer fields are refreshed; body,
|
|
44
|
+
answer keys, learner state and manually authored settings are never replaced.
|
|
45
|
+
The packet URL is returned as a source link and is not downloaded or mirrored.
|
|
46
|
+
|
|
47
|
+
All source policy rechecks and catalog mutations use collections bound to the
|
|
48
|
+
same SQL transaction. Embedded writes use the public write queue; PostgreSQL
|
|
49
|
+
serializes an import identity with a transaction-scoped advisory lock. Only
|
|
50
|
+
rolled-back busy/serialization/deadlock errors retry (at most three retries),
|
|
51
|
+
without repeating fetch. Connection-invalidated and other failures propagate.
|
|
52
|
+
Do not wrap the service in a caller transaction if the caller expects a durable
|
|
53
|
+
commit before returning; any outer transaction remains the caller's boundary.
|
|
54
|
+
|
|
55
|
+
SQLite and PostgreSQL integration tests exercise real transactions and rollback.
|
|
56
|
+
Full catalog children remain unavailable on DuckDB due to core's existing
|
|
57
|
+
ON UPDATE CASCADE/self-referential schema limitations documented in
|
|
58
|
+
[catalog-service.md](catalog-service.md); no alternate schema is substituted.
|
|
59
|
+
Tests use synthetic local HTML and an injected fetch callback, never the network.
|
|
60
|
+
|
|
61
|
+
Generic “Printable” and “Printables” labels identify lesson PDFs; course packets
|
|
62
|
+
require explicit packet/whole-course/complete-course wording. Only actual href
|
|
63
|
+
attributes supply pointers; data-href metadata does not.
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
# Submission and paper workflow
|
|
2
|
+
|
|
3
|
+
Construct `SubmissionService(db, authorizeLearner, authorizeCatalog, {storage,
|
|
4
|
+
storeOptions?, signAsset?})` from trusted server configuration. It extends
|
|
5
|
+
`LearnerService`: the same active actor/tenant, memberships, linked person,
|
|
6
|
+
directed guardian relationship and host permission checks apply. The host must
|
|
7
|
+
allow `education.learner.submit`, `education.learner.grade` or
|
|
8
|
+
`education.learner.packet` as appropriate. Grade and packet actions always reject
|
|
9
|
+
the learner's own identity, even if the host authorizer returns true.
|
|
10
|
+
|
|
11
|
+
The protected transaction/auth hooks are internal owning-service extension
|
|
12
|
+
points. They are not handler APIs. All public service results remain data-only.
|
|
13
|
+
Use the callback's service in `withTransaction`; escaped services are closed.
|
|
14
|
+
Submission operations share its serial queue, poison-on-failure behavior and
|
|
15
|
+
PostgreSQL enrollment lock. No runtime schema creation occurs.
|
|
16
|
+
|
|
17
|
+
## Capture and grading
|
|
18
|
+
|
|
19
|
+
`captureSubmission` takes the learner request, `enrollmentId`, `lessonId`, a
|
|
20
|
+
nonempty stable `requestKey`, and `pages` containing `name`, `mimeType` and Buffer
|
|
21
|
+
`data`. Each upload becomes a fresh Asset owned by the learner's profile and
|
|
22
|
+
exact tenant, joined solely through `SubmissionAsset`. Up to 100 uploads of
|
|
23
|
+
25 MiB each are accepted (JPEG, PNG, WebP, or PDF). It atomically creates the
|
|
24
|
+
submission/asset metadata and moves only that enrollment's progress to
|
|
25
|
+
`submitted`, incrementing attempts once. Caller status, score, grader, asset ID,
|
|
26
|
+
and answer-key flags are never patches. Equal request-key/content retries return
|
|
27
|
+
the existing submission; altered retries fail. Submitted/review/complete/skipped
|
|
28
|
+
progress rejects new captures. Content includes ordered filenames, MIME types,
|
|
29
|
+
bytes and notes. Hosts should impose tighter request/body limits as needed.
|
|
30
|
+
|
|
31
|
+
`reviewSubmission` accepts an existing authorized submission and optional score
|
|
32
|
+
and feedback. `attestLesson` creates/reuses an Attestation for a matching progress
|
|
33
|
+
item without requiring uploads. Both require a verified guardian and create a
|
|
34
|
+
new guardian Grade, set submission to `graded` and progress to `complete`.
|
|
35
|
+
Omitted/null score is unscored; zero is a real score. The latest guardian grade
|
|
36
|
+
wins over auto/AI/instructor proposals, and its score replaces progress's score.
|
|
37
|
+
Review history is retained. ExternalCompletion is available for trusted server
|
|
38
|
+
persistence; phase-zero learner handlers cannot forge reported scores. Online
|
|
39
|
+
submissions and automatic grading are not implemented.
|
|
40
|
+
|
|
41
|
+
`getSubmission` returns only submission metadata, page asset IDs and the selected
|
|
42
|
+
grade. It does not include catalog answers, extracted answers, storage paths,
|
|
43
|
+
model objects, or database handles.
|
|
44
|
+
|
|
45
|
+
## Storage and signed routes
|
|
46
|
+
|
|
47
|
+
The service creates an AssetRuntime bound to the operation's transaction. It
|
|
48
|
+
requires a resolved URI whose filename retains the fresh asset UUID and MIME
|
|
49
|
+
extension; custom resolvers may choose its provider/directory but must route
|
|
50
|
+
that URI to the corresponding unique file. Shared existing locators are
|
|
51
|
+
rejected before preservation or cleanup registration. It plans and persists
|
|
52
|
+
that unique asset URI before writing, registering that new
|
|
53
|
+
asset for cleanup before the provider can partially write. After rollback,
|
|
54
|
+
files are removed through the runtime store. Before deletion, the root database
|
|
55
|
+
must confirm that no asset owner committed: uncertain commit/read failures never
|
|
56
|
+
cause committed bytes to be removed. Only this operation's newly created assets
|
|
57
|
+
are candidates; pre-existing files are never adopted into its cleanup ledger.
|
|
58
|
+
|
|
59
|
+
Database metadata is atomic; external storage uses compensation, not a database
|
|
60
|
+
transaction. If cleanup cannot complete, `SubmissionStorageCleanupError` contains
|
|
61
|
+
fresh asset IDs and the original cause. Operators must reconcile those IDs in
|
|
62
|
+
the configured `education-page/<id>.<extension>` storage namespace (custom
|
|
63
|
+
resolvers must retain their routing audit). A successful retry uses fresh IDs
|
|
64
|
+
and cannot delete or reuse orphaned bytes. Process termination may also leave
|
|
65
|
+
unreferenced files; hosts need storage orphan reconciliation. Do not suppress
|
|
66
|
+
cleanup failures or claim crash-safe distributed atomicity.
|
|
67
|
+
|
|
68
|
+
`signSubmissionAsset` verifies the submission/enrollment/learner ownership and
|
|
69
|
+
its noun join, then verifies the Asset's tenant and learner profile before
|
|
70
|
+
calling the configured trusted signer. Claims contain actor, tenant, learner,
|
|
71
|
+
enrollment, submission, asset and a five-minute expiry; no static source URI is
|
|
72
|
+
returned. The host signs every claim and its serving endpoint must verify the
|
|
73
|
+
signature, expiry, current actor/tenant and object authorization before serving.
|
|
74
|
+
A bearer URL must never become a cross-family or sibling access bypass.
|
|
75
|
+
|
|
76
|
+
## Personal worksheet packets
|
|
77
|
+
|
|
78
|
+
`splitWorksheetPacket` takes the guardian learner request, exact-owned
|
|
79
|
+
`personal-use-copy` `courseId`, PDF bytes and ranges `{lessonId, firstPage,
|
|
80
|
+
lastPage}`. Page bounds are inclusive and one-based. Ranges must be in bounds,
|
|
81
|
+
disjoint and target distinct Worksheet lessons in that private course. The
|
|
82
|
+
50 MiB / 100-range limits are enforced before PDF parsing. `pdf-lib` extracts
|
|
83
|
+
actual pages into independent PDFs and records private printable LessonAssets.
|
|
84
|
+
All resulting metadata commits together; storage failure compensates new PDFs.
|
|
85
|
+
The source packet is not retained. No shared/global course is accepted and no
|
|
86
|
+
answer-key join is created. A successful repeated packet upload intentionally
|
|
87
|
+
adds another printable version; a failed upload can be retried safely.
|
|
88
|
+
|
|
89
|
+
Shared integration workflows run on SQLite and real PostgreSQL. Full DuckDB
|
|
90
|
+
workflow support remains outside the existing supported schema boundary.
|
|
91
|
+
|
|
92
|
+
## Private asset boundary
|
|
93
|
+
|
|
94
|
+
Captured pages and split packet pages use `Asset.accessMode: 'private'` from their
|
|
95
|
+
initial insert. Ordinary generated Asset REST/MCP operations cannot list, hydrate,
|
|
96
|
+
update or delete them, including through the parent STI model. The service opens
|
|
97
|
+
an exact tenant/actor/learner-owner/asset-ID capability only after its normal learner
|
|
98
|
+
relationship checks. A host's signed serving route must repeat authorization and
|
|
99
|
+
open that exact capability before loading the asset. See the owning
|
|
100
|
+
[private asset contract](../../assets/agents/private-assets.md).
|
|
101
|
+
|
|
102
|
+
Storage must be private (including provider ACLs). Public HTTP(S) source locators
|
|
103
|
+
are rejected; a hidden URL alone cannot protect publicly accessible bytes. Apply
|
|
104
|
+
the manifest migration adding `assets.access_mode` before deploying this runtime;
|
|
105
|
+
legacy ordinary assets retain the public default. Runtime creates no schema.
|
|
106
|
+
|
|
107
|
+
Private lesson files must already be private Assets in the same tenant. Saving
|
|
108
|
+
an answer-key join, or any join under a personal-use-copy course, requires the
|
|
109
|
+
host's exact actor/owner/asset capability; a public asset is never automatically
|
|
110
|
+
promoted. Packet splitting retains this capability while creating each join.
|
|
111
|
+
Persisted course tenant/license and section/lesson parent identity cannot change;
|
|
112
|
+
create new structure and explicitly re-author its joins instead. Mandatory
|
|
113
|
+
write predicates enforce this even for natural-key adoption and concurrent writes.
|
|
114
|
+
Ordinary content and metadata refresh remains supported. This
|
|
115
|
+
prevents existing public material being reclassified by moving its parent.
|
|
116
|
+
|
|
117
|
+
For pre-existing invalid joins, an authorized host must inventory private-material
|
|
118
|
+
joins and remove invalid associations, recreate the bytes in private storage as a
|
|
119
|
+
new privately classified Asset, then re-author the join under its exact capability.
|
|
120
|
+
Do not merely hide the URI, mutate access_mode with raw SQL, or delete shared
|
|
121
|
+
public bytes. This repair requires explicit storage/ownership authorization;
|
|
122
|
+
normal runtime does not migrate existing records.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"__smrt-register__.d.ts","sourceRoot":"","sources":["../src/__smrt-register__.ts"],"names":[],"mappings":""}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import type { TenantCollection } from '@happyvertical/smrt-users';
|
|
2
|
+
import type { Course, CourseSection, Lesson } from './catalog.js';
|
|
3
|
+
import type { CourseCollection, CourseSectionCollection, LessonAssetCollection, LessonCollection } from './collections.js';
|
|
4
|
+
import type { LessonAsset } from './lesson-assets.js';
|
|
5
|
+
/** The application-owned decision that permits a catalog view for one tenant. */
|
|
6
|
+
export type TenantCatalogReadScope = TenantCollection['withTenantCatalogReadScope'];
|
|
7
|
+
export type CatalogActorContext = Parameters<Parameters<TenantCatalogReadScope>[0]['authorize']>[0];
|
|
8
|
+
export type CatalogAuthorizer = (request: Omit<CatalogActorContext, 'mode'> & {
|
|
9
|
+
action: 'education.catalog.read' | 'education.catalog.read-private' | 'education.catalog.answer-key.read' | 'education.catalog.manage';
|
|
10
|
+
}) => boolean | Promise<boolean>;
|
|
11
|
+
export interface CatalogReadRequest {
|
|
12
|
+
tenantId: string;
|
|
13
|
+
actorUserId: string;
|
|
14
|
+
includePrivate?: boolean;
|
|
15
|
+
options?: Omit<Parameters<CourseCollection['list']>[0], 'where'>;
|
|
16
|
+
}
|
|
17
|
+
export interface CatalogProjection {
|
|
18
|
+
course: Course;
|
|
19
|
+
sections: Array<{
|
|
20
|
+
section: CourseSection;
|
|
21
|
+
lessons: Array<{
|
|
22
|
+
lesson: Lesson;
|
|
23
|
+
assets: LessonAsset[];
|
|
24
|
+
}>;
|
|
25
|
+
}>;
|
|
26
|
+
}
|
|
27
|
+
export declare class CatalogAuthorizationError extends Error {
|
|
28
|
+
constructor();
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* The only package-level family → group → global catalog read boundary.
|
|
32
|
+
* Generated surfaces are closed; callers must supply both an application
|
|
33
|
+
* authorization decision and the narrowly-scoped tenancy capability.
|
|
34
|
+
*/
|
|
35
|
+
export declare class CatalogService {
|
|
36
|
+
private readonly courses;
|
|
37
|
+
private readonly sections;
|
|
38
|
+
private readonly lessons;
|
|
39
|
+
private readonly assets;
|
|
40
|
+
private readonly tenants;
|
|
41
|
+
private readonly authorize;
|
|
42
|
+
constructor(courses: CourseCollection, sections: CourseSectionCollection, lessons: LessonCollection, assets: LessonAssetCollection, tenants: Pick<TenantCollection, 'withTenantCatalogReadScope'>, authorize: CatalogAuthorizer);
|
|
43
|
+
findVisible(request: CatalogReadRequest): Promise<Course[]>;
|
|
44
|
+
/** Project one already-visible course without exposing answer-key assets by default. */
|
|
45
|
+
projectCourse(request: CatalogReadRequest & {
|
|
46
|
+
courseId: string;
|
|
47
|
+
includeAnswerKeys?: boolean;
|
|
48
|
+
}): Promise<CatalogProjection | null>;
|
|
49
|
+
}
|
|
50
|
+
//# sourceMappingURL=catalog-service.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"catalog-service.d.ts","sourceRoot":"","sources":["../src/catalog-service.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,2BAA2B,CAAC;AAClE,OAAO,KAAK,EAAE,MAAM,EAAE,aAAa,EAAE,MAAM,EAAE,MAAM,cAAc,CAAC;AAClE,OAAO,KAAK,EACV,gBAAgB,EAChB,uBAAuB,EACvB,qBAAqB,EACrB,gBAAgB,EACjB,MAAM,kBAAkB,CAAC;AAC1B,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,oBAAoB,CAAC;AAEtD,iFAAiF;AACjF,MAAM,MAAM,sBAAsB,GAChC,gBAAgB,CAAC,4BAA4B,CAAC,CAAC;AACjD,MAAM,MAAM,mBAAmB,GAAG,UAAU,CAC1C,UAAU,CAAC,sBAAsB,CAAC,CAAC,CAAC,CAAC,CAAC,WAAW,CAAC,CACnD,CAAC,CAAC,CAAC,CAAC;AAEL,MAAM,MAAM,iBAAiB,GAAG,CAC9B,OAAO,EAAE,IAAI,CAAC,mBAAmB,EAAE,MAAM,CAAC,GAAG;IAC3C,MAAM,EACF,wBAAwB,GACxB,gCAAgC,GAChC,mCAAmC,GACnC,0BAA0B,CAAC;CAChC,KACE,OAAO,GAAG,OAAO,CAAC,OAAO,CAAC,CAAC;AAEhC,MAAM,WAAW,kBAAkB;IACjC,QAAQ,EAAE,MAAM,CAAC;IACjB,WAAW,EAAE,MAAM,CAAC;IACpB,cAAc,CAAC,EAAE,OAAO,CAAC;IACzB,OAAO,CAAC,EAAE,IAAI,CAAC,UAAU,CAAC,gBAAgB,CAAC,MAAM,CAAC,CAAC,CAAC,CAAC,CAAC,EAAE,OAAO,CAAC,CAAC;CAClE;AAED,MAAM,WAAW,iBAAiB;IAChC,MAAM,EAAE,MAAM,CAAC;IACf,QAAQ,EAAE,KAAK,CAAC;QACd,OAAO,EAAE,aAAa,CAAC;QACvB,OAAO,EAAE,KAAK,CAAC;YAAE,MAAM,EAAE,MAAM,CAAC;YAAC,MAAM,EAAE,WAAW,EAAE,CAAA;SAAE,CAAC,CAAC;KAC3D,CAAC,CAAC;CACJ;AAED,qBAAa,yBAA0B,SAAQ,KAAK;;CAKnD;AAUD;;;;GAIG;AACH,qBAAa,cAAc;IAEvB,OAAO,CAAC,QAAQ,CAAC,OAAO;IACxB,OAAO,CAAC,QAAQ,CAAC,QAAQ;IACzB,OAAO,CAAC,QAAQ,CAAC,OAAO;IACxB,OAAO,CAAC,QAAQ,CAAC,MAAM;IACvB,OAAO,CAAC,QAAQ,CAAC,OAAO;IAIxB,OAAO,CAAC,QAAQ,CAAC,SAAS;gBART,OAAO,EAAE,gBAAgB,EACzB,QAAQ,EAAE,uBAAuB,EACjC,OAAO,EAAE,gBAAgB,EACzB,MAAM,EAAE,qBAAqB,EAC7B,OAAO,EAAE,IAAI,CAC5B,gBAAgB,EAChB,4BAA4B,CAC7B,EACgB,SAAS,EAAE,iBAAiB;IAEzC,WAAW,CAAC,OAAO,EAAE,kBAAkB,GAAG,OAAO,CAAC,MAAM,EAAE,CAAC;IAuDjE,wFAAwF;IAClF,aAAa,CACjB,OAAO,EAAE,kBAAkB,GAAG;QAC5B,QAAQ,EAAE,MAAM,CAAC;QACjB,iBAAiB,CAAC,EAAE,OAAO,CAAC;KAC7B,GACA,OAAO,CAAC,iBAAiB,GAAG,IAAI,CAAC;CA+DrC"}
|