@superbuilders/primer-tives 1.1.3 → 1.2.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/README.md +395 -413
- package/dist/client/choice-state.d.ts +3 -2
- package/dist/client/choice-state.d.ts.map +1 -1
- package/dist/client/create.d.ts +1 -1
- package/dist/client/create.d.ts.map +1 -1
- package/dist/client/extended-text-state.d.ts +3 -2
- package/dist/client/extended-text-state.d.ts.map +1 -1
- package/dist/client/feedback-state.d.ts +5 -3
- package/dist/client/feedback-state.d.ts.map +1 -1
- package/dist/client/index.d.ts +1 -8
- package/dist/client/index.d.ts.map +1 -1
- package/dist/client/index.js +54 -70
- package/dist/client/index.js.map +17 -18
- package/dist/client/match-state.d.ts +3 -2
- package/dist/client/match-state.d.ts.map +1 -1
- package/dist/client/observation-state.d.ts +3 -2
- package/dist/client/observation-state.d.ts.map +1 -1
- package/dist/client/order-state.d.ts +3 -2
- package/dist/client/order-state.d.ts.map +1 -1
- package/dist/client/pci-state.d.ts +3 -2
- package/dist/client/pci-state.d.ts.map +1 -1
- package/dist/client/session-context.d.ts +3 -2
- package/dist/client/session-context.d.ts.map +1 -1
- package/dist/client/session.d.ts +1 -1
- package/dist/client/session.d.ts.map +1 -1
- package/dist/client/text-entry-state.d.ts +3 -2
- package/dist/client/text-entry-state.d.ts.map +1 -1
- package/dist/client/transport.d.ts +4 -3
- package/dist/client/transport.d.ts.map +1 -1
- package/dist/client/types.d.ts +5 -55
- package/dist/client/types.d.ts.map +1 -1
- package/dist/contracts/content.d.ts.map +1 -0
- package/dist/contracts/index.d.ts +4 -0
- package/dist/contracts/index.d.ts.map +1 -1
- package/dist/contracts/index.js +27 -1
- package/dist/contracts/index.js.map +5 -4
- package/dist/contracts/pci.d.ts.map +1 -0
- package/dist/contracts/review.d.ts +55 -0
- package/dist/contracts/review.d.ts.map +1 -0
- package/dist/contracts/types.d.ts +2 -2
- package/dist/contracts/types.d.ts.map +1 -1
- package/dist/errors.d.ts +2 -1
- package/dist/errors.d.ts.map +1 -1
- package/dist/errors.js +3 -1
- package/dist/errors.js.map +3 -3
- package/dist/grade-level.js +7 -0
- package/dist/grade-level.js.map +10 -0
- package/dist/logger.js +2 -0
- package/dist/logger.js.map +9 -0
- package/dist/server/create-server.d.ts +6 -4
- package/dist/server/create-server.d.ts.map +1 -1
- package/dist/server/index.d.ts +0 -4
- package/dist/server/index.d.ts.map +1 -1
- package/dist/server/index.js +5 -19
- package/dist/server/index.js.map +6 -6
- package/dist/subject.js +7 -0
- package/dist/subject.js.map +10 -0
- package/package.json +13 -6
- package/dist/client/content.d.ts.map +0 -1
- package/dist/client/pci.d.ts.map +0 -1
- package/dist/index.d.ts +0 -3
- package/dist/index.d.ts.map +0 -1
- package/dist/index.js +0 -344
- package/dist/index.js.map +0 -12
- /package/dist/{client → contracts}/content.d.ts +0 -0
- /package/dist/{client → contracts}/pci.d.ts +0 -0
package/README.md
CHANGED
|
@@ -6,107 +6,91 @@ TypeScript SDK for the Primer adaptive learning engine.
|
|
|
6
6
|
bun add @superbuilders/primer-tives
|
|
7
7
|
```
|
|
8
8
|
|
|
9
|
-
|
|
9
|
+
`@superbuilders/errors` is installed automatically. The SDK throws sentinel-wrapped errors and the recommended consumer pattern uses `errors.try` / `errors.is` / `errors.wrap` from that library.
|
|
10
10
|
|
|
11
|
-
##
|
|
11
|
+
## Subpaths
|
|
12
12
|
|
|
13
|
-
|
|
14
|
-
|---|---|---|
|
|
15
|
-
| `@superbuilders/primer-tives/server` | your backend | `POST /api/v0/students`, `PATCH /api/v0/students/:id/hints`, `POST /api/v0/auth/exchange`, `POST /api/v0/auth/exchange/timeback` |
|
|
16
|
-
| `@superbuilders/primer-tives/client` | the browser | `POST /api/v0/advance` |
|
|
17
|
-
| `@superbuilders/primer-tives/contracts` | either side | shared interaction/submission types, Zod schemas, optional submission validator |
|
|
18
|
-
| `@superbuilders/primer-tives/errors` | either side | every error sentinel |
|
|
19
|
-
| `@superbuilders/primer-tives` (root) | either side | convenience re-export of `/contracts` + `/errors` |
|
|
13
|
+
Every symbol is exported from exactly one subpath. Import from the subpath that owns the thing you need.
|
|
20
14
|
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
1. Call `createStudent()` once to mint a stable Primer `studentId`.
|
|
32
|
-
2. Persist that `studentId` in your own database alongside your user record.
|
|
33
|
-
3. **Before the student's first session**, call `setStudentHints(studentId, { gradeLevel })` at least once. Native students are created hint-less, and the first `/advance` call fails without a `gradeLevel` on record.
|
|
34
|
-
4. At each session start, call `exchangeStudentForAccessToken(studentId)`.
|
|
35
|
-
5. Hand the returned `accessToken` to the browser SDK.
|
|
36
|
-
|
|
37
|
-
### 2. Live-authoritative Timeback students
|
|
38
|
-
|
|
39
|
-
Use this when **Timeback / OneRoster** remains the live authority for each login.
|
|
40
|
-
|
|
41
|
-
1. At each session start, call `exchangeTimebackStudentForAccessToken(sourcedId)`.
|
|
42
|
-
2. Primer verifies the learner against Timeback on **every call**.
|
|
43
|
-
3. Primer resolves or provisions the frontend-owned Primer student row behind the scenes.
|
|
44
|
-
4. The call returns both the stable Primer `studentId` and a short-lived `accessToken`.
|
|
45
|
-
5. Hand the returned `accessToken` to the browser SDK.
|
|
46
|
-
|
|
47
|
-
Persisting the returned `studentId` is fine if you want a stable local foreign key. Session startup for this flow still begins from `sourcedId`, and the returned `accessToken` is the session credential you pass to the browser SDK.
|
|
15
|
+
| Subpath | What it owns |
|
|
16
|
+
|---|---|
|
|
17
|
+
| `@superbuilders/primer-tives/server` | `createPrimerServer` and the four `PrimerServer` methods, the config and return types |
|
|
18
|
+
| `@superbuilders/primer-tives/client` | `create` (browser SDK), the `PrimerState` discriminated union, every `*State` interface, the PCI render props |
|
|
19
|
+
| `@superbuilders/primer-tives/contracts` | wire-shape types (`RendererInteraction`, `RendererSubmission`, `ContentInline`, PCI base types, review types), Zod schemas, the optional submission validator |
|
|
20
|
+
| `@superbuilders/primer-tives/errors` | every error sentinel (`Err…`) |
|
|
21
|
+
| `@superbuilders/primer-tives/logger` | the `PrimerLogger` interface, accepted by both server and client config |
|
|
22
|
+
| `@superbuilders/primer-tives/grade-level` | `GradeLevel` type and the `GRADE_LEVELS` constant |
|
|
23
|
+
| `@superbuilders/primer-tives/subject` | `Subject`, `SubjectScope`, the `SUBJECTS` constant |
|
|
48
24
|
|
|
49
25
|
## End-to-end examples
|
|
50
26
|
|
|
51
|
-
### Native/manual flow
|
|
27
|
+
### Native/manual student flow
|
|
52
28
|
|
|
53
29
|
```ts
|
|
54
|
-
// ── your backend ─────────────────────────────────────────────────────
|
|
55
30
|
import * as errors from "@superbuilders/errors"
|
|
31
|
+
import * as logger from "@superbuilders/slog"
|
|
32
|
+
import { createPrimerServer } from "@superbuilders/primer-tives/server"
|
|
56
33
|
import {
|
|
57
|
-
createPrimerServer,
|
|
58
34
|
ErrBadRequest,
|
|
59
35
|
ErrConflict,
|
|
60
36
|
ErrInvalidSecretKey,
|
|
61
37
|
ErrStudentNotFound
|
|
62
|
-
} from "@superbuilders/primer-tives/
|
|
38
|
+
} from "@superbuilders/primer-tives/errors"
|
|
63
39
|
|
|
64
40
|
const primer = createPrimerServer({
|
|
65
41
|
origin: "https://sb-primer.vercel.app",
|
|
66
|
-
secretKey: process.env.PRIMER_DEMO_SECRET_KEY
|
|
67
|
-
logger
|
|
42
|
+
secretKey: process.env.PRIMER_DEMO_SECRET_KEY ?? "",
|
|
43
|
+
logger
|
|
68
44
|
})
|
|
69
45
|
|
|
70
46
|
// One time per user.
|
|
71
47
|
const createResult = await errors.try(primer.createStudent())
|
|
72
48
|
if (createResult.error) {
|
|
73
49
|
if (errors.is(createResult.error, ErrInvalidSecretKey)) {
|
|
74
|
-
|
|
50
|
+
logger.error("primer secret key invalid", { error: createResult.error })
|
|
51
|
+
throw errors.wrap(createResult.error, "primer client init")
|
|
75
52
|
}
|
|
76
53
|
if (errors.is(createResult.error, ErrConflict)) {
|
|
77
|
-
|
|
54
|
+
logger.error("primer frontend not provisioned", { error: createResult.error })
|
|
55
|
+
throw errors.wrap(createResult.error, "primer frontend provisioning")
|
|
78
56
|
}
|
|
79
|
-
|
|
57
|
+
logger.error("primer create student failed", { error: createResult.error })
|
|
58
|
+
throw errors.wrap(createResult.error, "primer create student")
|
|
80
59
|
}
|
|
81
60
|
const studentId = createResult.data
|
|
82
|
-
// persist `studentId` alongside your own user record
|
|
61
|
+
// persist `studentId` alongside your own user record.
|
|
83
62
|
|
|
84
63
|
// Required before the first session for native students.
|
|
85
|
-
const hintsResult = await errors.try(
|
|
86
|
-
primer.setStudentHints(studentId, { gradeLevel: "3" })
|
|
87
|
-
)
|
|
64
|
+
const hintsResult = await errors.try(primer.setStudentHints(studentId, { gradeLevel: "3" }))
|
|
88
65
|
if (hintsResult.error) {
|
|
89
66
|
if (errors.is(hintsResult.error, ErrStudentNotFound)) {
|
|
90
|
-
|
|
67
|
+
logger.error("primer student id not found on this frontend", {
|
|
68
|
+
studentId,
|
|
69
|
+
error: hintsResult.error
|
|
70
|
+
})
|
|
71
|
+
throw errors.wrap(hintsResult.error, "primer set hints")
|
|
91
72
|
}
|
|
92
73
|
if (errors.is(hintsResult.error, ErrBadRequest)) {
|
|
93
|
-
|
|
74
|
+
logger.error("primer hint payload rejected", { error: hintsResult.error })
|
|
75
|
+
throw errors.wrap(hintsResult.error, "primer set hints")
|
|
94
76
|
}
|
|
95
|
-
|
|
77
|
+
logger.error("primer set hints failed", { error: hintsResult.error })
|
|
78
|
+
throw errors.wrap(hintsResult.error, "primer set hints")
|
|
96
79
|
}
|
|
97
80
|
|
|
98
81
|
// Every session start.
|
|
99
|
-
const tokenResult = await errors.try(
|
|
100
|
-
primer.exchangeStudentForAccessToken(studentId)
|
|
101
|
-
)
|
|
82
|
+
const tokenResult = await errors.try(primer.exchangeStudentForAccessToken(studentId))
|
|
102
83
|
if (tokenResult.error) {
|
|
103
84
|
if (errors.is(tokenResult.error, ErrInvalidSecretKey)) {
|
|
104
|
-
|
|
85
|
+
logger.error("primer secret key invalid", { error: tokenResult.error })
|
|
86
|
+
throw errors.wrap(tokenResult.error, "primer token exchange")
|
|
105
87
|
}
|
|
106
88
|
if (errors.is(tokenResult.error, ErrStudentNotFound)) {
|
|
107
|
-
|
|
89
|
+
logger.error("primer student id stale", { studentId, error: tokenResult.error })
|
|
90
|
+
throw errors.wrap(tokenResult.error, "primer token exchange")
|
|
108
91
|
}
|
|
109
|
-
|
|
92
|
+
logger.error("primer token exchange failed", { error: tokenResult.error })
|
|
93
|
+
throw errors.wrap(tokenResult.error, "primer token exchange")
|
|
110
94
|
}
|
|
111
95
|
|
|
112
96
|
const { accessToken, expiresInSeconds } = tokenResult.data
|
|
@@ -115,58 +99,62 @@ const { accessToken, expiresInSeconds } = tokenResult.data
|
|
|
115
99
|
### Live-authoritative Timeback flow
|
|
116
100
|
|
|
117
101
|
```ts
|
|
118
|
-
// ── your backend ─────────────────────────────────────────────────────
|
|
119
102
|
import * as errors from "@superbuilders/errors"
|
|
103
|
+
import * as logger from "@superbuilders/slog"
|
|
104
|
+
import { createPrimerServer } from "@superbuilders/primer-tives/server"
|
|
120
105
|
import {
|
|
121
|
-
createPrimerServer,
|
|
122
106
|
ErrConflict,
|
|
123
107
|
ErrInvalidSecretKey,
|
|
124
108
|
ErrStudentNotFound,
|
|
125
109
|
ErrTimebackUnavailable,
|
|
126
110
|
ErrUnsupportedGrade
|
|
127
|
-
} from "@superbuilders/primer-tives/
|
|
111
|
+
} from "@superbuilders/primer-tives/errors"
|
|
128
112
|
|
|
129
113
|
const primer = createPrimerServer({
|
|
130
114
|
origin: "https://sb-primer.vercel.app",
|
|
131
|
-
secretKey: process.env.PRIMER_DEMO_SECRET_KEY
|
|
132
|
-
logger
|
|
115
|
+
secretKey: process.env.PRIMER_DEMO_SECRET_KEY ?? "",
|
|
116
|
+
logger
|
|
133
117
|
})
|
|
134
118
|
|
|
135
119
|
const sessionResult = await errors.try(
|
|
136
|
-
primer.exchangeTimebackStudentForAccessToken(
|
|
120
|
+
primer.exchangeTimebackStudentForAccessToken(sourcedId)
|
|
137
121
|
)
|
|
138
122
|
if (sessionResult.error) {
|
|
139
123
|
if (errors.is(sessionResult.error, ErrInvalidSecretKey)) {
|
|
140
|
-
|
|
124
|
+
logger.error("primer secret key invalid", { error: sessionResult.error })
|
|
125
|
+
throw errors.wrap(sessionResult.error, "primer timeback exchange")
|
|
141
126
|
}
|
|
142
127
|
if (errors.is(sessionResult.error, ErrStudentNotFound)) {
|
|
143
|
-
|
|
128
|
+
logger.error("timeback sourcedId not found upstream", {
|
|
129
|
+
sourcedId,
|
|
130
|
+
error: sessionResult.error
|
|
131
|
+
})
|
|
132
|
+
throw errors.wrap(sessionResult.error, "primer timeback exchange")
|
|
144
133
|
}
|
|
145
134
|
if (errors.is(sessionResult.error, ErrUnsupportedGrade)) {
|
|
146
|
-
|
|
135
|
+
logger.error("timeback grade outside supported range", { error: sessionResult.error })
|
|
136
|
+
throw errors.wrap(sessionResult.error, "primer timeback exchange")
|
|
147
137
|
}
|
|
148
138
|
if (errors.is(sessionResult.error, ErrConflict)) {
|
|
149
|
-
|
|
139
|
+
logger.error("primer frontend not provisioned for routing", { error: sessionResult.error })
|
|
140
|
+
throw errors.wrap(sessionResult.error, "primer timeback exchange")
|
|
150
141
|
}
|
|
151
142
|
if (errors.is(sessionResult.error, ErrTimebackUnavailable)) {
|
|
152
|
-
|
|
143
|
+
logger.error("timeback authority temporarily unavailable", { error: sessionResult.error })
|
|
144
|
+
throw errors.wrap(sessionResult.error, "primer timeback exchange")
|
|
153
145
|
}
|
|
154
|
-
|
|
146
|
+
logger.error("primer timeback exchange failed", { error: sessionResult.error })
|
|
147
|
+
throw errors.wrap(sessionResult.error, "primer timeback exchange")
|
|
155
148
|
}
|
|
156
149
|
|
|
157
150
|
const { studentId, accessToken, expiresInSeconds } = sessionResult.data
|
|
158
|
-
// persist `studentId` if you want a stable Primer foreign key
|
|
159
|
-
// use `accessToken` for this session only
|
|
160
151
|
```
|
|
161
152
|
|
|
162
153
|
### Browser flow
|
|
163
154
|
|
|
164
155
|
```ts
|
|
165
|
-
|
|
166
|
-
import {
|
|
167
|
-
create,
|
|
168
|
-
type PrimerState
|
|
169
|
-
} from "@superbuilders/primer-tives/client"
|
|
156
|
+
import { create } from "@superbuilders/primer-tives/client"
|
|
157
|
+
import type { PrimerState } from "@superbuilders/primer-tives/client"
|
|
170
158
|
|
|
171
159
|
const client = create({
|
|
172
160
|
accessToken,
|
|
@@ -196,10 +184,9 @@ while (state.phase !== "completed" && state.phase !== "fatal") {
|
|
|
196
184
|
case "errored":
|
|
197
185
|
if (state.retriable) {
|
|
198
186
|
state = await state.retry()
|
|
199
|
-
|
|
200
|
-
throw state.error
|
|
187
|
+
break
|
|
201
188
|
}
|
|
202
|
-
|
|
189
|
+
throw state.error
|
|
203
190
|
}
|
|
204
191
|
}
|
|
205
192
|
```
|
|
@@ -209,29 +196,17 @@ while (state.phase !== "completed" && state.phase !== "fatal") {
|
|
|
209
196
|
# `/server`
|
|
210
197
|
|
|
211
198
|
```ts
|
|
212
|
-
import {
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
type GradeLevel,
|
|
221
|
-
GRADE_LEVELS,
|
|
222
|
-
type PrimerLogger,
|
|
223
|
-
ErrBadRequest,
|
|
224
|
-
ErrConflict,
|
|
225
|
-
ErrExternalAuthorityRequired,
|
|
226
|
-
ErrInvalidSecretKey,
|
|
227
|
-
ErrJsonParse,
|
|
228
|
-
ErrNetwork,
|
|
229
|
-
ErrServerError,
|
|
230
|
-
ErrStudentNotFound,
|
|
231
|
-
ErrTimebackUnavailable,
|
|
232
|
-
ErrTimeout,
|
|
233
|
-
ErrUnsupportedGrade
|
|
199
|
+
import { createPrimerServer } from "@superbuilders/primer-tives/server"
|
|
200
|
+
import type {
|
|
201
|
+
PrimerServer,
|
|
202
|
+
PrimerServerConfig,
|
|
203
|
+
SessionToken,
|
|
204
|
+
TimebackSession,
|
|
205
|
+
PlacementHints,
|
|
206
|
+
PlacementHintsResult
|
|
234
207
|
} from "@superbuilders/primer-tives/server"
|
|
208
|
+
import type { PrimerLogger } from "@superbuilders/primer-tives/logger"
|
|
209
|
+
import { GRADE_LEVELS, type GradeLevel } from "@superbuilders/primer-tives/grade-level"
|
|
235
210
|
```
|
|
236
211
|
|
|
237
212
|
## `createPrimerServer(config)`
|
|
@@ -242,13 +217,13 @@ interface PrimerServerConfig {
|
|
|
242
217
|
readonly secretKey: string // your sk_… key
|
|
243
218
|
readonly fetch?: typeof globalThis.fetch // override (tests, proxies, instrumentation)
|
|
244
219
|
readonly abort?: AbortController // wired into every request signal
|
|
245
|
-
readonly logger: PrimerLogger // required debug/info/warn/error logger
|
|
220
|
+
readonly logger: PrimerLogger // required debug/info/warn/error logger
|
|
246
221
|
}
|
|
247
222
|
|
|
248
223
|
function createPrimerServer(config: PrimerServerConfig): PrimerServer
|
|
249
224
|
```
|
|
250
225
|
|
|
251
|
-
Returns a `PrimerServer` with exactly
|
|
226
|
+
Returns a `PrimerServer` with exactly four methods:
|
|
252
227
|
|
|
253
228
|
```ts
|
|
254
229
|
interface PrimerServer {
|
|
@@ -261,213 +236,113 @@ interface PrimerServer {
|
|
|
261
236
|
|
|
262
237
|
## Method reference
|
|
263
238
|
|
|
264
|
-
### `createStudent()
|
|
265
|
-
|
|
266
|
-
Provision a new **frontend-owned Primer student** and return its stable `studentId`.
|
|
267
|
-
|
|
268
|
-
Use this only for the native/manual flow.
|
|
269
|
-
|
|
270
|
-
```ts
|
|
271
|
-
const studentId = await primer.createStudent()
|
|
272
|
-
```
|
|
239
|
+
### `createStudent()`
|
|
273
240
|
|
|
274
|
-
|
|
241
|
+
Provision a new frontend-owned Primer student and return its stable `studentId`. Use only for the native/manual flow.
|
|
275
242
|
|
|
276
|
-
|
|
277
|
-
- Persist the returned `studentId` in your own database.
|
|
278
|
-
- **Before the first session**, call `setStudentHints(studentId, { gradeLevel })`. Native students are created without a `gradeLevel`, and `/advance` requires one.
|
|
279
|
-
- Use that stored `studentId` with `exchangeStudentForAccessToken(studentId)` at each session start.
|
|
280
|
-
- `logger` is required. Pass any object implementing `debug/info/warn/error`; `console` is acceptable for basic integrations.
|
|
243
|
+
Persist the returned `studentId` in your own database. Before the student's first session, call `setStudentHints(studentId, { gradeLevel })`.
|
|
281
244
|
|
|
282
|
-
### `setStudentHints(studentId, hints)
|
|
245
|
+
### `setStudentHints(studentId, hints)`
|
|
283
246
|
|
|
284
247
|
Partial upsert of a native/manual student's placement-routing hints. Omitted fields are left untouched; the server returns the persisted state after upsert.
|
|
285
248
|
|
|
286
249
|
```ts
|
|
287
|
-
const { gradeLevel } = await primer.setStudentHints(studentId, {
|
|
288
|
-
gradeLevel: "3"
|
|
289
|
-
})
|
|
250
|
+
const { gradeLevel } = await primer.setStudentHints(studentId, { gradeLevel: "3" })
|
|
290
251
|
```
|
|
291
252
|
|
|
292
|
-
-
|
|
293
|
-
-
|
|
294
|
-
- Safe to call repeatedly — each call is a partial upsert.
|
|
253
|
+
- Required for native/manual students before their first session. If the browser calls `/advance` without a `gradeLevel` on record, the server returns HTTP 412 `needs_hints` and the SDK surfaces it as `ErrNeedsHints` on a `fatal` state.
|
|
254
|
+
- Safe to call repeatedly. Each call is a partial upsert.
|
|
295
255
|
- Not needed for Timeback-linked students: `exchangeTimebackStudentForAccessToken` syncs the grade level from the authority on every call.
|
|
296
|
-
- Throws `ErrStudentNotFound` if `studentId` is unknown; `ErrBadRequest` if the payload fails validation (e.g
|
|
297
|
-
|
|
298
|
-
### `exchangeStudentForAccessToken(studentId): Promise<SessionToken>`
|
|
299
|
-
|
|
300
|
-
Mint a short-lived access token for an existing **native/manual** Primer student.
|
|
256
|
+
- Throws `ErrStudentNotFound` if `studentId` is unknown; `ErrBadRequest` if the payload fails validation (e.g. unsupported `gradeLevel`).
|
|
301
257
|
|
|
302
|
-
|
|
303
|
-
const { accessToken, expiresInSeconds } =
|
|
304
|
-
await primer.exchangeStudentForAccessToken(studentId)
|
|
305
|
-
```
|
|
258
|
+
### `exchangeStudentForAccessToken(studentId)`
|
|
306
259
|
|
|
307
|
-
Call
|
|
260
|
+
Mint a short-lived access token for an existing native/manual Primer student. Call at every session start.
|
|
308
261
|
|
|
309
|
-
|
|
262
|
+
If `studentId` belongs to a Timeback-linked student, throws `ErrExternalAuthorityRequired`. Tokens are short-lived (typically 15 minutes).
|
|
310
263
|
|
|
311
|
-
|
|
312
|
-
- If `studentId` belongs to a Timeback-linked student, the method throws `ErrExternalAuthorityRequired`.
|
|
313
|
-
- Tokens are short-lived (typically 15 minutes).
|
|
264
|
+
### `exchangeTimebackStudentForAccessToken(sourcedId)`
|
|
314
265
|
|
|
315
|
-
|
|
266
|
+
Live Timeback session start. Verifies the learner against Timeback on every call, resolves or provisions the corresponding Primer student row, and returns the stable `studentId`, the short-lived `accessToken`, and `expiresInSeconds`.
|
|
316
267
|
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
Primer verifies the learner against Timeback on every call, then resolves or provisions the corresponding frontend-owned Primer student and returns:
|
|
320
|
-
|
|
321
|
-
- the stable Primer `studentId`
|
|
322
|
-
- a short-lived `accessToken`
|
|
323
|
-
- `expiresInSeconds`
|
|
324
|
-
|
|
325
|
-
```ts
|
|
326
|
-
const { studentId, accessToken, expiresInSeconds } =
|
|
327
|
-
await primer.exchangeTimebackStudentForAccessToken(sourcedId)
|
|
328
|
-
```
|
|
329
|
-
|
|
330
|
-
Use this at **every Timeback session start**.
|
|
331
|
-
|
|
332
|
-
Operational rule:
|
|
333
|
-
|
|
334
|
-
- Call this at every Timeback session start.
|
|
335
|
-
- Persist `studentId` if you need a stable Primer foreign key in your own system.
|
|
336
|
-
- Use the returned `accessToken` for the active browser session.
|
|
268
|
+
Use at every Timeback session start. Persist `studentId` if you need a stable Primer foreign key.
|
|
337
269
|
|
|
338
270
|
## Return types
|
|
339
271
|
|
|
340
|
-
### `SessionToken`
|
|
341
|
-
|
|
342
272
|
```ts
|
|
343
273
|
interface SessionToken {
|
|
344
274
|
readonly accessToken: string
|
|
345
275
|
readonly expiresInSeconds: number
|
|
346
276
|
}
|
|
347
|
-
```
|
|
348
277
|
|
|
349
|
-
### `TimebackSession`
|
|
350
|
-
|
|
351
|
-
```ts
|
|
352
278
|
interface TimebackSession {
|
|
353
279
|
readonly studentId: string
|
|
354
280
|
readonly accessToken: string
|
|
355
281
|
readonly expiresInSeconds: number
|
|
356
282
|
}
|
|
357
|
-
```
|
|
358
|
-
|
|
359
|
-
### `PlacementHints`
|
|
360
283
|
|
|
361
|
-
```ts
|
|
362
284
|
interface PlacementHints {
|
|
363
285
|
readonly gradeLevel?: GradeLevel
|
|
364
286
|
}
|
|
365
|
-
```
|
|
366
|
-
|
|
367
|
-
Partial shape: pass only the fields you want to set. Today the only hint is `gradeLevel` (the learner's current grade). Re-export `GRADE_LEVELS` enumerates the supported values.
|
|
368
287
|
|
|
369
|
-
### `PlacementHintsResult`
|
|
370
|
-
|
|
371
|
-
```ts
|
|
372
288
|
interface PlacementHintsResult {
|
|
373
289
|
readonly studentId: string
|
|
374
290
|
readonly gradeLevel: GradeLevel | null
|
|
375
291
|
}
|
|
376
292
|
```
|
|
377
293
|
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
## Error sentinels (`/server`)
|
|
381
|
-
|
|
382
|
-
All `/server` methods throw sentinel-wrapped `Error`s. Use `errors.try()` and `errors.is()` from `@superbuilders/errors`.
|
|
383
|
-
|
|
384
|
-
| Sentinel | Raised when |
|
|
385
|
-
|---|---|
|
|
386
|
-
| `ErrInvalidSecretKey` | HTTP 401 — missing, malformed, or unknown `sk_` |
|
|
387
|
-
| `ErrStudentNotFound` | HTTP 404 — native `studentId` unknown on this frontend, or Timeback `sourcedId` unknown upstream |
|
|
388
|
-
| `ErrUnsupportedGrade` | HTTP 400 — Timeback returned a grade outside Primer's supported range |
|
|
389
|
-
| `ErrTimebackUnavailable` | HTTP 502 — Timeback OneRoster endpoint failed during live exchange |
|
|
390
|
-
| `ErrExternalAuthorityRequired` | HTTP 409 — attempted native/manual exchange for a Timeback-linked student |
|
|
391
|
-
| `ErrConflict` | HTTP 409 — frontend is not provisioned for routing/content |
|
|
392
|
-
| `ErrBadRequest` | HTTP 400 — validation failure |
|
|
393
|
-
| `ErrServerError` | HTTP 5xx |
|
|
394
|
-
| `ErrJsonParse` | Success response body was not valid JSON or had the wrong shape |
|
|
395
|
-
| `ErrNetwork` | fetch() rejected (DNS, connection, TLS, etc.) |
|
|
396
|
-
| `ErrTimeout` | fetch() aborted (your `AbortController` or `TimeoutError`) |
|
|
397
|
-
|
|
398
|
-
### Recommended error-handling pattern
|
|
399
|
-
|
|
400
|
-
```ts
|
|
401
|
-
import * as errors from "@superbuilders/errors"
|
|
402
|
-
import {
|
|
403
|
-
createPrimerServer,
|
|
404
|
-
ErrExternalAuthorityRequired,
|
|
405
|
-
ErrInvalidSecretKey,
|
|
406
|
-
ErrStudentNotFound
|
|
407
|
-
} from "@superbuilders/primer-tives/server"
|
|
408
|
-
|
|
409
|
-
const primer = createPrimerServer({
|
|
410
|
-
origin: "https://sb-primer.vercel.app",
|
|
411
|
-
secretKey: process.env.PRIMER_DEMO_SECRET_KEY!,
|
|
412
|
-
logger: console
|
|
413
|
-
})
|
|
414
|
-
|
|
415
|
-
const result = await errors.try(
|
|
416
|
-
primer.exchangeStudentForAccessToken(studentId)
|
|
417
|
-
)
|
|
418
|
-
if (result.error) {
|
|
419
|
-
if (errors.is(result.error, ErrInvalidSecretKey)) {
|
|
420
|
-
// rotate or fix your sk_
|
|
421
|
-
}
|
|
422
|
-
if (errors.is(result.error, ErrStudentNotFound)) {
|
|
423
|
-
// your stored studentId is stale or wrong for this frontend
|
|
424
|
-
}
|
|
425
|
-
if (errors.is(result.error, ErrExternalAuthorityRequired)) {
|
|
426
|
-
// this student must authenticate through live Timeback exchange
|
|
427
|
-
}
|
|
428
|
-
throw result.error
|
|
429
|
-
}
|
|
430
|
-
|
|
431
|
-
const { accessToken } = result.data
|
|
432
|
-
```
|
|
294
|
+
`gradeLevel` in `PlacementHintsResult` is `null` only on native students who have never had one set.
|
|
433
295
|
|
|
434
296
|
## Rules of the road
|
|
435
297
|
|
|
436
|
-
-
|
|
437
|
-
-
|
|
438
|
-
-
|
|
439
|
-
-
|
|
440
|
-
- **Explicit subpaths only.** Import from `/server` or `/client`, never a package root.
|
|
298
|
+
- Native/manual flow: `createStudent()` once, then `exchangeStudentForAccessToken(studentId)` every session.
|
|
299
|
+
- Timeback flow: `exchangeTimebackStudentForAccessToken(sourcedId)` every session.
|
|
300
|
+
- Student ids are stable identifiers, not browser credentials. Always hand the browser a fresh `accessToken` from your backend.
|
|
301
|
+
- Server-only secrets: keep `secretKey` on your backend; never ship it to the browser.
|
|
441
302
|
|
|
442
303
|
---
|
|
443
304
|
|
|
444
305
|
# `/client`
|
|
445
306
|
|
|
446
307
|
```ts
|
|
447
|
-
import {
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
308
|
+
import { create } from "@superbuilders/primer-tives/client"
|
|
309
|
+
import type {
|
|
310
|
+
Client,
|
|
311
|
+
Config,
|
|
312
|
+
PrimerState,
|
|
313
|
+
ObservationState,
|
|
314
|
+
InteractionState,
|
|
315
|
+
ChoiceState,
|
|
316
|
+
TextEntryState,
|
|
317
|
+
ExtendedTextState,
|
|
318
|
+
ExtendedTextSingleState,
|
|
319
|
+
ExtendedTextMultipleState,
|
|
320
|
+
OrderState,
|
|
321
|
+
MatchState,
|
|
322
|
+
PciInteractionState,
|
|
323
|
+
FeedbackState,
|
|
324
|
+
CompletedState,
|
|
325
|
+
ErroredState,
|
|
326
|
+
FatalState,
|
|
327
|
+
NonSerializable,
|
|
328
|
+
PciPendingRenderProps,
|
|
329
|
+
PciSubmittedRenderProps,
|
|
330
|
+
PciRenderProps
|
|
458
331
|
} from "@superbuilders/primer-tives/client"
|
|
459
332
|
```
|
|
460
333
|
|
|
334
|
+
The `/client` subpath owns the **runtime state machine** only. Wire-shape types (`RendererInteraction`, `RendererSubmission`, `ContentInline`, PCI base types, review types) come from `/contracts`.
|
|
335
|
+
|
|
461
336
|
## `create(config)`
|
|
462
337
|
|
|
463
338
|
```ts
|
|
464
339
|
function create<const Pcis extends PciId>(config: Config<Pcis>): Client<Pcis>
|
|
465
340
|
|
|
466
341
|
interface Config<Pcis extends PciId = PciId> {
|
|
467
|
-
readonly accessToken: string
|
|
468
|
-
readonly supportedPcis: readonly Pcis[]
|
|
469
|
-
readonly origin: string
|
|
470
|
-
readonly subject: SubjectScope
|
|
342
|
+
readonly accessToken: string
|
|
343
|
+
readonly supportedPcis: readonly Pcis[]
|
|
344
|
+
readonly origin: string
|
|
345
|
+
readonly subject: SubjectScope
|
|
471
346
|
readonly fetch?: typeof globalThis.fetch
|
|
472
347
|
readonly abort?: AbortController
|
|
473
348
|
readonly logger?: PrimerLogger
|
|
@@ -478,46 +353,23 @@ interface Client<Pcis extends PciId = PciId> {
|
|
|
478
353
|
}
|
|
479
354
|
```
|
|
480
355
|
|
|
481
|
-
`create()` does a
|
|
482
|
-
|
|
483
|
-
### `subject` scope
|
|
484
|
-
|
|
485
|
-
| Value | Behavior |
|
|
486
|
-
|---|---|
|
|
487
|
-
| `"math"` / `"vocabulary"` / `"science"` | Restrict drill selection to courses of that subject |
|
|
488
|
-
| `"all"` | No filter; drills from any subject the frontend is bound to |
|
|
489
|
-
|
|
490
|
-
Per-session. To switch subjects, construct a new client. Student placements are isolated per subject — a student with an in-progress math placement resumes it on a math-scoped reconnect, a new vocabulary-scoped client bootstraps a fresh vocabulary placement, and neither disturbs the other. Curriculum routing is not affected by `subject`.
|
|
356
|
+
`create()` does a structural check on the token (must start with `eyJ` and contain two dots) and throws `ErrMalformedAccessToken` if the shape is wrong. Signature verification happens on the server.
|
|
491
357
|
|
|
492
358
|
## `PrimerState` — the state machine
|
|
493
359
|
|
|
494
|
-
`start()` returns a `PrimerState` union. Each phase has its own shape and action methods.
|
|
495
|
-
|
|
496
360
|
```ts
|
|
497
361
|
type PrimerState =
|
|
498
|
-
| ObservationState
|
|
499
|
-
| InteractionState
|
|
500
|
-
| FeedbackState
|
|
501
|
-
| CompletedState
|
|
502
|
-
| ErroredState
|
|
503
|
-
| FatalState
|
|
362
|
+
| ObservationState // .advance()
|
|
363
|
+
| InteractionState // .submit…() / .timeout()
|
|
364
|
+
| FeedbackState // .advance()
|
|
365
|
+
| CompletedState // terminal
|
|
366
|
+
| ErroredState // .retry() — .retriable: boolean
|
|
367
|
+
| FatalState // terminal
|
|
504
368
|
```
|
|
505
369
|
|
|
506
|
-
###
|
|
370
|
+
### Interaction action methods
|
|
507
371
|
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
```ts
|
|
511
|
-
interface ObservationState {
|
|
512
|
-
readonly phase: "observation"
|
|
513
|
-
readonly stimulus: RendererStimulus | null
|
|
514
|
-
advance(): Promise<PrimerState>
|
|
515
|
-
}
|
|
516
|
-
```
|
|
517
|
-
|
|
518
|
-
### `interaction`
|
|
519
|
-
|
|
520
|
-
Server wants an answer. `InteractionState` is a discriminated union over `kind`:
|
|
372
|
+
`InteractionState` is a discriminated union over `kind`:
|
|
521
373
|
|
|
522
374
|
| `kind` | method |
|
|
523
375
|
|---|---|
|
|
@@ -529,31 +381,31 @@ Server wants an answer. `InteractionState` is a discriminated union over `kind`:
|
|
|
529
381
|
| `match` | `submitMatch(pairs: MatchPair[])` |
|
|
530
382
|
| `portable-custom` | `submit(value: PciValue<K>)` |
|
|
531
383
|
|
|
532
|
-
|
|
384
|
+
Every `InteractionState` shape also exposes `timeout(): Promise<PrimerState>`.
|
|
533
385
|
|
|
534
|
-
|
|
386
|
+
`MatchPair`, `PciValue<K>` come from `/contracts`.
|
|
535
387
|
|
|
536
|
-
|
|
388
|
+
### `FeedbackState`
|
|
537
389
|
|
|
538
390
|
```ts
|
|
539
|
-
interface FeedbackState {
|
|
391
|
+
interface FeedbackState extends NonSerializable {
|
|
540
392
|
readonly phase: "feedback"
|
|
541
393
|
readonly stimulus: RendererStimulus | null
|
|
542
394
|
readonly interaction: RendererInteraction
|
|
543
395
|
readonly submission: RendererSubmission
|
|
544
396
|
readonly isCorrect: boolean
|
|
545
|
-
readonly feedbackContent: ContentInline[]
|
|
546
|
-
readonly review: InteractionReview | null
|
|
397
|
+
readonly feedbackContent: ContentInline[]
|
|
398
|
+
readonly review: InteractionReview | null
|
|
547
399
|
advance(): Promise<PrimerState>
|
|
548
400
|
}
|
|
549
401
|
```
|
|
550
402
|
|
|
551
|
-
|
|
403
|
+
`RendererStimulus`, `RendererInteraction`, `RendererSubmission`, `ContentInline`, `InteractionReview` all come from `/contracts`.
|
|
552
404
|
|
|
553
|
-
|
|
405
|
+
### `ErroredState`
|
|
554
406
|
|
|
555
407
|
```ts
|
|
556
|
-
interface ErroredState {
|
|
408
|
+
interface ErroredState extends NonSerializable {
|
|
557
409
|
readonly phase: "errored"
|
|
558
410
|
readonly error: Error // sentinel-wrapped
|
|
559
411
|
readonly retriable: boolean
|
|
@@ -561,109 +413,238 @@ interface ErroredState {
|
|
|
561
413
|
}
|
|
562
414
|
```
|
|
563
415
|
|
|
564
|
-
|
|
416
|
+
`errored.retriable === false` for client-validation errors (e.g. wrapped `ErrInvalidSubmission`) and for non-recoverable transports. Those must be fixed, not retried.
|
|
417
|
+
|
|
418
|
+
### `FatalState`
|
|
565
419
|
|
|
566
|
-
Unrecoverable failure (bad request, invalid token, expired token, unsupported PCI, forbidden)
|
|
420
|
+
Unrecoverable failure (bad request, invalid token, expired token, unsupported PCI, forbidden):
|
|
567
421
|
|
|
568
422
|
```ts
|
|
569
|
-
interface FatalState {
|
|
423
|
+
interface FatalState extends NonSerializable {
|
|
570
424
|
readonly phase: "fatal"
|
|
571
425
|
readonly error: Error
|
|
572
426
|
readonly retriable: false
|
|
573
427
|
}
|
|
574
428
|
```
|
|
575
429
|
|
|
576
|
-
|
|
430
|
+
## PCI render props
|
|
577
431
|
|
|
578
|
-
|
|
432
|
+
Only the renderer-side render props live here. PCI base types (`PciId`, `PciProps`, `PciValue`, `PciRegistry`, `PciUrn`, plus the per-PCI `*Props`/`*Submission` interfaces) come from `/contracts`.
|
|
579
433
|
|
|
580
434
|
```ts
|
|
581
|
-
|
|
582
|
-
|
|
435
|
+
type PciPendingRenderProps<K extends PciId> = {
|
|
436
|
+
mode: "pending"
|
|
437
|
+
properties: PciProps<K>
|
|
438
|
+
onValueChange: (value: PciValue<K> | null) => void
|
|
583
439
|
}
|
|
440
|
+
|
|
441
|
+
type PciSubmittedRenderProps<K extends PciId> = {
|
|
442
|
+
mode: "submitted"
|
|
443
|
+
properties: PciProps<K>
|
|
444
|
+
submission: PciValue<K>
|
|
445
|
+
review: Extract<InteractionReview<K>, { type: "portable-custom"; pciId: K }> | null
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
type PciRenderProps<K extends PciId> = PciPendingRenderProps<K> | PciSubmittedRenderProps<K>
|
|
584
449
|
```
|
|
585
450
|
|
|
586
|
-
##
|
|
451
|
+
## Behavioral notes
|
|
452
|
+
|
|
453
|
+
- `start()` is idempotent — calling it twice returns the same promise.
|
|
454
|
+
- A returning student resumes wherever the server last placed them. No client-side cursor management.
|
|
455
|
+
- `PrimerState` holds real closures (action methods, pending-promise caches). Don't serialize it; don't store it across reloads. Call `start()` again on boot.
|
|
456
|
+
- `Config.supportedPcis` is a `const` generic; only those URNs flow through `PciInteractionState` at the type level. Mismatch at runtime is a `fatal` with `ErrUnsupportedPci`.
|
|
457
|
+
|
|
458
|
+
---
|
|
459
|
+
|
|
460
|
+
# `/contracts`
|
|
461
|
+
|
|
462
|
+
Wire-shape types, Zod schemas, review types, and an optional submission validator. Safe to import from either side of the wire — backend code lives here too.
|
|
587
463
|
|
|
588
464
|
```ts
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
465
|
+
import {
|
|
466
|
+
ChoiceSubmissionSchema,
|
|
467
|
+
TextEntrySubmissionSchema,
|
|
468
|
+
ExtendedTextSubmissionSchema,
|
|
469
|
+
OrderSubmissionSchema,
|
|
470
|
+
MatchSubmissionSchema,
|
|
471
|
+
MatchPairSchema,
|
|
472
|
+
DivisionRemainderPciSubmissionSchema,
|
|
473
|
+
FractionAdditionPciSubmissionSchema,
|
|
474
|
+
RendererSubmissionSchema,
|
|
475
|
+
validateSubmissionForInteraction,
|
|
476
|
+
submissionValidationMessage,
|
|
477
|
+
blocksToPlainText,
|
|
478
|
+
inlinesToPlainText
|
|
479
|
+
} from "@superbuilders/primer-tives/contracts"
|
|
592
480
|
|
|
593
|
-
type
|
|
594
|
-
|
|
595
|
-
|
|
481
|
+
import type {
|
|
482
|
+
// interaction & submission shapes
|
|
483
|
+
RendererInteraction,
|
|
484
|
+
RendererSubmission,
|
|
485
|
+
StandardRendererInteraction,
|
|
486
|
+
PciInteraction,
|
|
487
|
+
PciSubmission,
|
|
488
|
+
// stimulus shapes
|
|
489
|
+
RendererStimulus,
|
|
490
|
+
BodyStimulus,
|
|
491
|
+
ImageStimulus,
|
|
492
|
+
// choice shapes
|
|
493
|
+
RendererChoice,
|
|
494
|
+
RendererMatchChoice,
|
|
495
|
+
MatchPair,
|
|
496
|
+
// content
|
|
497
|
+
ContentBlock,
|
|
498
|
+
ContentInline,
|
|
499
|
+
ContentSpan,
|
|
500
|
+
// PCI base
|
|
501
|
+
PciId,
|
|
502
|
+
PciUrn,
|
|
503
|
+
PciRegistry,
|
|
504
|
+
PciProps,
|
|
505
|
+
PciValue,
|
|
506
|
+
DivisionRemainderProps,
|
|
507
|
+
DivisionRemainderSubmission,
|
|
508
|
+
FractionAdditionProps,
|
|
509
|
+
FractionAdditionSubmission,
|
|
510
|
+
// review (server emits these, client consumes)
|
|
511
|
+
InteractionReview,
|
|
512
|
+
ChoiceReview,
|
|
513
|
+
TextEntryReview,
|
|
514
|
+
ExtendedTextReview,
|
|
515
|
+
OrderReview,
|
|
516
|
+
MatchReview,
|
|
517
|
+
PciReview,
|
|
518
|
+
ReviewRecordField,
|
|
519
|
+
ReviewRecordFieldBaseType,
|
|
520
|
+
ReviewScalarValue,
|
|
521
|
+
// validation result
|
|
522
|
+
SubmissionValidationResult,
|
|
523
|
+
SubmissionValidationSuccess,
|
|
524
|
+
SubmissionValidationFailure
|
|
525
|
+
} from "@superbuilders/primer-tives/contracts"
|
|
526
|
+
```
|
|
527
|
+
|
|
528
|
+
## Schemas
|
|
529
|
+
|
|
530
|
+
Every `RendererSubmission` variant has a Zod schema; `RendererSubmissionSchema` is the discriminated union over all of them and is the canonical `safeParse`-able wire shape.
|
|
531
|
+
|
|
532
|
+
```ts
|
|
533
|
+
import * as errors from "@superbuilders/errors"
|
|
534
|
+
import * as logger from "@superbuilders/slog"
|
|
535
|
+
import { RendererSubmissionSchema } from "@superbuilders/primer-tives/contracts"
|
|
596
536
|
|
|
597
|
-
|
|
537
|
+
const result = RendererSubmissionSchema.safeParse(unknownPayload)
|
|
538
|
+
if (!result.success) {
|
|
539
|
+
logger.error("submission payload failed schema parse", { issues: result.error.issues })
|
|
540
|
+
throw errors.wrap(result.error, "renderer submission parse")
|
|
541
|
+
}
|
|
542
|
+
const submission = result.data
|
|
598
543
|
```
|
|
599
544
|
|
|
600
|
-
|
|
545
|
+
## `validateSubmissionForInteraction(interaction, submission)`
|
|
601
546
|
|
|
602
|
-
|
|
547
|
+
Optional semantic validator. Checks a submission against the interaction it claims to answer (cardinality bounds, identifier membership, duplicate rules, PCI payload shape).
|
|
603
548
|
|
|
604
549
|
```ts
|
|
605
|
-
|
|
606
|
-
|
|
550
|
+
function validateSubmissionForInteraction(
|
|
551
|
+
interaction: RendererInteraction,
|
|
552
|
+
submission: RendererSubmission
|
|
553
|
+
): SubmissionValidationResult
|
|
554
|
+
|
|
555
|
+
type SubmissionValidationResult =
|
|
556
|
+
| { ok: true; value: RendererSubmission }
|
|
557
|
+
| { ok: false; issues: readonly string[] }
|
|
607
558
|
```
|
|
608
559
|
|
|
609
|
-
|
|
560
|
+
`submissionValidationMessage(failure)` joins `failure.issues` with `"; "` for a single string.
|
|
610
561
|
|
|
611
562
|
```ts
|
|
612
|
-
|
|
563
|
+
import * as errors from "@superbuilders/errors"
|
|
564
|
+
import * as logger from "@superbuilders/slog"
|
|
565
|
+
import {
|
|
566
|
+
submissionValidationMessage,
|
|
567
|
+
validateSubmissionForInteraction
|
|
568
|
+
} from "@superbuilders/primer-tives/contracts"
|
|
569
|
+
import { ErrInvalidSubmission } from "@superbuilders/primer-tives/errors"
|
|
613
570
|
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
}
|
|
619
|
-
"urn:primer:pci:fraction-addition": {
|
|
620
|
-
props: FractionAdditionProps
|
|
621
|
-
value: FractionAdditionSubmission
|
|
622
|
-
}
|
|
571
|
+
const validation = validateSubmissionForInteraction(interaction, submission)
|
|
572
|
+
if (!validation.ok) {
|
|
573
|
+
logger.warn("submission rejected by contract validation", { issues: validation.issues })
|
|
574
|
+
throw errors.wrap(ErrInvalidSubmission, submissionValidationMessage(validation))
|
|
623
575
|
}
|
|
624
|
-
|
|
625
|
-
type PciId = keyof PciRegistry
|
|
626
|
-
type PciProps<K extends PciId> = PciRegistry[K]["props"]
|
|
627
|
-
type PciValue<K extends PciId> = PciRegistry[K]["value"]
|
|
628
576
|
```
|
|
629
577
|
|
|
630
|
-
|
|
578
|
+
Validation is **optional at every layer**. The Primer server grades submissions independently and is the only correctness boundary that matters. The browser SDK's built-in interaction state machines call this validator on your behalf before each submit; if you bypass those state machines and write to the wire yourself, you decide whether to validate.
|
|
631
579
|
|
|
632
|
-
|
|
580
|
+
---
|
|
633
581
|
|
|
634
|
-
|
|
635
|
-
type PciPendingRenderProps<K extends PciId> = {
|
|
636
|
-
mode: "pending"
|
|
637
|
-
properties: PciProps<K>
|
|
638
|
-
onValueChange: (value: PciValue<K> | null) => void
|
|
639
|
-
}
|
|
582
|
+
# `/errors`
|
|
640
583
|
|
|
641
|
-
|
|
642
|
-
mode: "submitted"
|
|
643
|
-
properties: PciProps<K>
|
|
644
|
-
submission: PciValue<K>
|
|
645
|
-
review: Extract<InteractionReview<K>, { type: "portable-custom"; pciId: K }> | null
|
|
646
|
-
}
|
|
584
|
+
Every sentinel `Err…` value used by the SDK lives here and only here. Use `errors.is()` from `@superbuilders/errors` to type-check against them.
|
|
647
585
|
|
|
648
|
-
|
|
649
|
-
|
|
586
|
+
```ts
|
|
587
|
+
import {
|
|
588
|
+
// shared (server + client transport)
|
|
589
|
+
ErrBadRequest,
|
|
590
|
+
ErrConflict,
|
|
591
|
+
ErrJsonParse,
|
|
592
|
+
ErrNetwork,
|
|
593
|
+
ErrServerError,
|
|
594
|
+
ErrTimeout,
|
|
595
|
+
// /server-thrown
|
|
596
|
+
ErrInvalidSecretKey,
|
|
597
|
+
ErrStudentNotFound,
|
|
598
|
+
ErrUnsupportedGrade,
|
|
599
|
+
ErrTimebackUnavailable,
|
|
600
|
+
ErrExternalAuthorityRequired,
|
|
601
|
+
// /client surfaced as `errored`
|
|
602
|
+
ErrServiceUnavailable,
|
|
603
|
+
ErrRateLimited,
|
|
604
|
+
ErrInvalidSubmission,
|
|
605
|
+
// /client surfaced as `fatal`
|
|
606
|
+
ErrInvalidAccessToken,
|
|
607
|
+
ErrTokenExpired,
|
|
608
|
+
ErrForbidden,
|
|
609
|
+
ErrNotFound,
|
|
610
|
+
ErrNeedsHints,
|
|
611
|
+
ErrUnsupportedPci,
|
|
612
|
+
// /client thrown directly by `create()`
|
|
613
|
+
ErrMalformedAccessToken,
|
|
614
|
+
ErrNotSerializable
|
|
615
|
+
} from "@superbuilders/primer-tives/errors"
|
|
616
|
+
```
|
|
617
|
+
|
|
618
|
+
## Server-side method failures
|
|
650
619
|
|
|
651
|
-
|
|
620
|
+
| Sentinel | Raised when |
|
|
621
|
+
|---|---|
|
|
622
|
+
| `ErrInvalidSecretKey` | HTTP 401 — missing, malformed, or unknown `sk_` |
|
|
623
|
+
| `ErrStudentNotFound` | HTTP 404 — native `studentId` unknown on this frontend, or Timeback `sourcedId` unknown upstream |
|
|
624
|
+
| `ErrUnsupportedGrade` | HTTP 400 — Timeback returned a grade outside Primer's supported range |
|
|
625
|
+
| `ErrTimebackUnavailable` | HTTP 502 — Timeback OneRoster endpoint failed during live exchange |
|
|
626
|
+
| `ErrExternalAuthorityRequired` | HTTP 409 — attempted native/manual exchange for a Timeback-linked student |
|
|
627
|
+
| `ErrConflict` | HTTP 409 — frontend not provisioned for routing/content |
|
|
628
|
+
| `ErrBadRequest` | HTTP 400 — validation failure |
|
|
629
|
+
| `ErrServerError` | HTTP 5xx |
|
|
630
|
+
| `ErrJsonParse` | response body was not valid JSON or had the wrong shape |
|
|
631
|
+
| `ErrNetwork` | `fetch()` rejected (DNS, connection, TLS, etc.) |
|
|
632
|
+
| `ErrTimeout` | `fetch()` aborted |
|
|
652
633
|
|
|
653
|
-
|
|
634
|
+
## Client `errored.error`
|
|
654
635
|
|
|
655
636
|
| Sentinel | Raised when |
|
|
656
637
|
|---|---|
|
|
657
|
-
| `ErrNetwork` | fetch() rejected |
|
|
658
|
-
| `ErrTimeout` | fetch() aborted |
|
|
638
|
+
| `ErrNetwork` | `fetch()` rejected |
|
|
639
|
+
| `ErrTimeout` | `fetch()` aborted |
|
|
659
640
|
| `ErrServerError` | HTTP 5xx |
|
|
660
641
|
| `ErrServiceUnavailable` | HTTP 502/503/504 |
|
|
661
642
|
| `ErrRateLimited` | HTTP 429 |
|
|
662
643
|
| `ErrConflict` | HTTP 409 |
|
|
663
|
-
| `ErrJsonParse` |
|
|
644
|
+
| `ErrJsonParse` | success body wasn't valid JSON |
|
|
664
645
|
| `ErrInvalidSubmission` | client-side validation rejected the submission |
|
|
665
646
|
|
|
666
|
-
|
|
647
|
+
## Client `fatal.error`
|
|
667
648
|
|
|
668
649
|
| Sentinel | Raised when |
|
|
669
650
|
|---|---|
|
|
@@ -672,18 +653,59 @@ type PciRenderProps<K extends PciId> = PciPendingRenderProps<K> | PciSubmittedRe
|
|
|
672
653
|
| `ErrTokenExpired` | HTTP 401 with token-expired detail |
|
|
673
654
|
| `ErrForbidden` | HTTP 403 |
|
|
674
655
|
| `ErrNotFound` | HTTP 404 |
|
|
656
|
+
| `ErrNeedsHints` | HTTP 412 — the student has no `gradeLevel` on record; backend must call `setStudentHints` and mint a fresh access token |
|
|
675
657
|
| `ErrUnsupportedPci` | HTTP 422 or a frame asks for a PCI not in `supportedPcis` |
|
|
676
658
|
|
|
677
|
-
|
|
659
|
+
## Thrown directly by `create()`
|
|
678
660
|
|
|
679
661
|
| Sentinel | Raised when |
|
|
680
662
|
|---|---|
|
|
681
663
|
| `ErrMalformedAccessToken` | token doesn't start with `eyJ` or lacks two dots |
|
|
682
664
|
| `ErrNotSerializable` | you called `JSON.stringify()` on a live `PrimerState` (don't) |
|
|
683
665
|
|
|
684
|
-
##
|
|
666
|
+
## Recommended consumer pattern
|
|
667
|
+
|
|
668
|
+
Per `@superbuilders/errors`: log every error before throwing, wrap external errors with terse Go-style context, never bare `throw new Error(...)`.
|
|
669
|
+
|
|
670
|
+
```ts
|
|
671
|
+
import * as errors from "@superbuilders/errors"
|
|
672
|
+
import * as logger from "@superbuilders/slog"
|
|
673
|
+
import {
|
|
674
|
+
ErrExternalAuthorityRequired,
|
|
675
|
+
ErrInvalidSecretKey,
|
|
676
|
+
ErrStudentNotFound
|
|
677
|
+
} from "@superbuilders/primer-tives/errors"
|
|
678
|
+
|
|
679
|
+
const tokenResult = await errors.try(primer.exchangeStudentForAccessToken(studentId))
|
|
680
|
+
if (tokenResult.error) {
|
|
681
|
+
if (errors.is(tokenResult.error, ErrInvalidSecretKey)) {
|
|
682
|
+
logger.error("primer secret key invalid", { error: tokenResult.error })
|
|
683
|
+
throw errors.wrap(tokenResult.error, "primer token exchange")
|
|
684
|
+
}
|
|
685
|
+
if (errors.is(tokenResult.error, ErrStudentNotFound)) {
|
|
686
|
+
logger.error("primer student id stale", { studentId, error: tokenResult.error })
|
|
687
|
+
throw errors.wrap(tokenResult.error, "primer token exchange")
|
|
688
|
+
}
|
|
689
|
+
if (errors.is(tokenResult.error, ErrExternalAuthorityRequired)) {
|
|
690
|
+
logger.error("primer student requires timeback exchange", {
|
|
691
|
+
studentId,
|
|
692
|
+
error: tokenResult.error
|
|
693
|
+
})
|
|
694
|
+
throw errors.wrap(tokenResult.error, "primer token exchange")
|
|
695
|
+
}
|
|
696
|
+
logger.error("primer token exchange failed", { error: tokenResult.error })
|
|
697
|
+
throw errors.wrap(tokenResult.error, "primer token exchange")
|
|
698
|
+
}
|
|
699
|
+
const { accessToken } = tokenResult.data
|
|
700
|
+
```
|
|
701
|
+
|
|
702
|
+
---
|
|
703
|
+
|
|
704
|
+
# `/logger`
|
|
685
705
|
|
|
686
706
|
```ts
|
|
707
|
+
import type { PrimerLogger } from "@superbuilders/primer-tives/logger"
|
|
708
|
+
|
|
687
709
|
interface PrimerLogger {
|
|
688
710
|
debug(message: string, attributes?: Record<string, unknown>): void
|
|
689
711
|
info(message: string, attributes?: Record<string, unknown>): void
|
|
@@ -692,80 +714,40 @@ interface PrimerLogger {
|
|
|
692
714
|
}
|
|
693
715
|
```
|
|
694
716
|
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
## Behavioral notes
|
|
698
|
-
|
|
699
|
-
- **`start()` is idempotent.** Calling it twice returns the same promise.
|
|
700
|
-
- **Retry semantics.** `errored.retry()` re-runs the same intent. `errored.retriable === false` for client-validation errors (e.g. `ErrInvalidSubmission`) — those must be fixed, not retried.
|
|
701
|
-
- **Session resumption.** A returning student resumes wherever the server last placed them. No client-side cursor management.
|
|
702
|
-
- **Live state.** `PrimerState` holds real closures (action methods, pending-promise caches). Don't serialize it; don't store it across reloads. Call `start()` again on boot.
|
|
703
|
-
- **PCI type safety.** `Config.supportedPcis` is a `const` generic; only those URNs flow through `PciInteractionState`/`PciSubmission` at the type level. Mismatch at runtime is a `fatal` with `ErrUnsupportedPci`.
|
|
717
|
+
Required by `PrimerServerConfig`, optional on `Config` (the client config). `@superbuilders/slog` matches this shape directly — `import * as logger from "@superbuilders/slog"` and pass `logger`.
|
|
704
718
|
|
|
705
719
|
---
|
|
706
720
|
|
|
707
|
-
# `/
|
|
708
|
-
|
|
709
|
-
Wire-shape types, Zod schemas, and an optional submission validator. Safe to import from either side of the wire.
|
|
721
|
+
# `/grade-level`
|
|
710
722
|
|
|
711
723
|
```ts
|
|
712
|
-
import {
|
|
713
|
-
|
|
714
|
-
ChoiceSubmissionSchema,
|
|
715
|
-
TextEntrySubmissionSchema,
|
|
716
|
-
ExtendedTextSubmissionSchema,
|
|
717
|
-
OrderSubmissionSchema,
|
|
718
|
-
MatchSubmissionSchema,
|
|
719
|
-
MatchPairSchema,
|
|
720
|
-
DivisionRemainderPciSubmissionSchema,
|
|
721
|
-
FractionAdditionPciSubmissionSchema,
|
|
722
|
-
RendererSubmissionSchema,
|
|
723
|
-
// validator
|
|
724
|
-
validateSubmissionForInteraction,
|
|
725
|
-
submissionValidationMessage,
|
|
726
|
-
// types
|
|
727
|
-
type RendererInteraction,
|
|
728
|
-
type RendererSubmission,
|
|
729
|
-
type StandardRendererInteraction,
|
|
730
|
-
type PciInteraction,
|
|
731
|
-
type PciSubmission,
|
|
732
|
-
type BodyStimulus,
|
|
733
|
-
type ImageStimulus,
|
|
734
|
-
type MatchPair,
|
|
735
|
-
type RendererChoice,
|
|
736
|
-
type RendererMatchChoice,
|
|
737
|
-
type RendererStimulus,
|
|
738
|
-
type SubmissionValidationResult,
|
|
739
|
-
type SubmissionValidationSuccess,
|
|
740
|
-
type SubmissionValidationFailure
|
|
741
|
-
} from "@superbuilders/primer-tives/contracts"
|
|
742
|
-
```
|
|
743
|
-
|
|
744
|
-
## Schemas
|
|
745
|
-
|
|
746
|
-
Every `RendererSubmission` variant has a Zod schema; `RendererSubmissionSchema` is the discriminated union over all of them and is the canonical `safeParse`-able wire shape.
|
|
724
|
+
import { GRADE_LEVELS } from "@superbuilders/primer-tives/grade-level"
|
|
725
|
+
import type { GradeLevel } from "@superbuilders/primer-tives/grade-level"
|
|
747
726
|
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
if (!result.success) { /* … */ }
|
|
751
|
-
const submission = result.data // typed as RendererSubmission
|
|
727
|
+
const GRADE_LEVELS = ["K", "1", "2", "3", "4", "5", "6", "7", "8", "9", "10", "11", "12"] as const
|
|
728
|
+
type GradeLevel = (typeof GRADE_LEVELS)[number]
|
|
752
729
|
```
|
|
753
730
|
|
|
754
|
-
|
|
731
|
+
Used as the `gradeLevel` field on `PlacementHints` / `PlacementHintsResult`.
|
|
755
732
|
|
|
756
|
-
|
|
733
|
+
---
|
|
734
|
+
|
|
735
|
+
# `/subject`
|
|
757
736
|
|
|
758
737
|
```ts
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
submission: RendererSubmission
|
|
762
|
-
): SubmissionValidationResult
|
|
738
|
+
import { SUBJECTS } from "@superbuilders/primer-tives/subject"
|
|
739
|
+
import type { Subject, SubjectScope } from "@superbuilders/primer-tives/subject"
|
|
763
740
|
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
741
|
+
const SUBJECTS = ["math", "vocabulary", "science"] as const
|
|
742
|
+
type Subject = (typeof SUBJECTS)[number]
|
|
743
|
+
type SubjectScope = Subject | "all"
|
|
767
744
|
```
|
|
768
745
|
|
|
769
|
-
|
|
746
|
+
Used as the `subject` field on the client `Config`.
|
|
770
747
|
|
|
771
|
-
|
|
748
|
+
| Value | Behavior |
|
|
749
|
+
|---|---|
|
|
750
|
+
| `"math"` / `"vocabulary"` / `"science"` | restrict drill selection to courses of that subject |
|
|
751
|
+
| `"all"` | no filter; drills from any subject the frontend is bound to |
|
|
752
|
+
|
|
753
|
+
Per-session. To switch subjects, construct a new client. Student placements are isolated per subject — a student with an in-progress math placement resumes it on a math-scoped reconnect, a new vocabulary-scoped client bootstraps a fresh vocabulary placement, and neither disturbs the other. Curriculum routing is not affected by `subject`.
|