@superbuilders/primer-tives 1.1.3 → 1.1.4
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 +393 -411
- 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 +49 -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/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/index.d.ts +0 -4
- package/dist/server/index.d.ts.map +1 -1
- package/dist/server/index.js +4 -19
- package/dist/server/index.js.map +4 -4
- 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,93 @@ 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
|
+
The package has **no root export**. Every symbol lives at exactly one subpath. Pick 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.
|
|
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…`) — only home for them |
|
|
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 |
|
|
46
24
|
|
|
47
|
-
|
|
25
|
+
There is **no convenience re-export**. Importing `ErrInvalidSecretKey` from `/server` or `/client` will not work — it lives at `/errors` only. Same for `PrimerLogger` (`/logger`), grade level constants (`/grade-level`), subjects (`/subject`), and every wire-shape type (`/contracts`).
|
|
48
26
|
|
|
49
27
|
## End-to-end examples
|
|
50
28
|
|
|
51
|
-
### Native/manual flow
|
|
29
|
+
### Native/manual student flow
|
|
52
30
|
|
|
53
31
|
```ts
|
|
54
|
-
// ── your backend ─────────────────────────────────────────────────────
|
|
55
32
|
import * as errors from "@superbuilders/errors"
|
|
33
|
+
import * as logger from "@superbuilders/slog"
|
|
34
|
+
import { createPrimerServer } from "@superbuilders/primer-tives/server"
|
|
56
35
|
import {
|
|
57
|
-
createPrimerServer,
|
|
58
36
|
ErrBadRequest,
|
|
59
37
|
ErrConflict,
|
|
60
38
|
ErrInvalidSecretKey,
|
|
61
39
|
ErrStudentNotFound
|
|
62
|
-
} from "@superbuilders/primer-tives/
|
|
40
|
+
} from "@superbuilders/primer-tives/errors"
|
|
63
41
|
|
|
64
42
|
const primer = createPrimerServer({
|
|
65
43
|
origin: "https://sb-primer.vercel.app",
|
|
66
|
-
secretKey: process.env.PRIMER_DEMO_SECRET_KEY
|
|
67
|
-
logger
|
|
44
|
+
secretKey: process.env.PRIMER_DEMO_SECRET_KEY ?? "",
|
|
45
|
+
logger
|
|
68
46
|
})
|
|
69
47
|
|
|
70
48
|
// One time per user.
|
|
71
49
|
const createResult = await errors.try(primer.createStudent())
|
|
72
50
|
if (createResult.error) {
|
|
73
51
|
if (errors.is(createResult.error, ErrInvalidSecretKey)) {
|
|
74
|
-
|
|
52
|
+
logger.error("primer secret key invalid", { error: createResult.error })
|
|
53
|
+
throw errors.wrap(createResult.error, "primer client init")
|
|
75
54
|
}
|
|
76
55
|
if (errors.is(createResult.error, ErrConflict)) {
|
|
77
|
-
|
|
56
|
+
logger.error("primer frontend not provisioned", { error: createResult.error })
|
|
57
|
+
throw errors.wrap(createResult.error, "primer frontend provisioning")
|
|
78
58
|
}
|
|
79
|
-
|
|
59
|
+
logger.error("primer create student failed", { error: createResult.error })
|
|
60
|
+
throw errors.wrap(createResult.error, "primer create student")
|
|
80
61
|
}
|
|
81
62
|
const studentId = createResult.data
|
|
82
|
-
// persist `studentId` alongside your own user record
|
|
63
|
+
// persist `studentId` alongside your own user record.
|
|
83
64
|
|
|
84
65
|
// Required before the first session for native students.
|
|
85
|
-
const hintsResult = await errors.try(
|
|
86
|
-
primer.setStudentHints(studentId, { gradeLevel: "3" })
|
|
87
|
-
)
|
|
66
|
+
const hintsResult = await errors.try(primer.setStudentHints(studentId, { gradeLevel: "3" }))
|
|
88
67
|
if (hintsResult.error) {
|
|
89
68
|
if (errors.is(hintsResult.error, ErrStudentNotFound)) {
|
|
90
|
-
|
|
69
|
+
logger.error("primer student id not found on this frontend", {
|
|
70
|
+
studentId,
|
|
71
|
+
error: hintsResult.error
|
|
72
|
+
})
|
|
73
|
+
throw errors.wrap(hintsResult.error, "primer set hints")
|
|
91
74
|
}
|
|
92
75
|
if (errors.is(hintsResult.error, ErrBadRequest)) {
|
|
93
|
-
|
|
76
|
+
logger.error("primer hint payload rejected", { error: hintsResult.error })
|
|
77
|
+
throw errors.wrap(hintsResult.error, "primer set hints")
|
|
94
78
|
}
|
|
95
|
-
|
|
79
|
+
logger.error("primer set hints failed", { error: hintsResult.error })
|
|
80
|
+
throw errors.wrap(hintsResult.error, "primer set hints")
|
|
96
81
|
}
|
|
97
82
|
|
|
98
83
|
// Every session start.
|
|
99
|
-
const tokenResult = await errors.try(
|
|
100
|
-
primer.exchangeStudentForAccessToken(studentId)
|
|
101
|
-
)
|
|
84
|
+
const tokenResult = await errors.try(primer.exchangeStudentForAccessToken(studentId))
|
|
102
85
|
if (tokenResult.error) {
|
|
103
86
|
if (errors.is(tokenResult.error, ErrInvalidSecretKey)) {
|
|
104
|
-
|
|
87
|
+
logger.error("primer secret key invalid", { error: tokenResult.error })
|
|
88
|
+
throw errors.wrap(tokenResult.error, "primer token exchange")
|
|
105
89
|
}
|
|
106
90
|
if (errors.is(tokenResult.error, ErrStudentNotFound)) {
|
|
107
|
-
|
|
91
|
+
logger.error("primer student id stale", { studentId, error: tokenResult.error })
|
|
92
|
+
throw errors.wrap(tokenResult.error, "primer token exchange")
|
|
108
93
|
}
|
|
109
|
-
|
|
94
|
+
logger.error("primer token exchange failed", { error: tokenResult.error })
|
|
95
|
+
throw errors.wrap(tokenResult.error, "primer token exchange")
|
|
110
96
|
}
|
|
111
97
|
|
|
112
98
|
const { accessToken, expiresInSeconds } = tokenResult.data
|
|
@@ -115,58 +101,62 @@ const { accessToken, expiresInSeconds } = tokenResult.data
|
|
|
115
101
|
### Live-authoritative Timeback flow
|
|
116
102
|
|
|
117
103
|
```ts
|
|
118
|
-
// ── your backend ─────────────────────────────────────────────────────
|
|
119
104
|
import * as errors from "@superbuilders/errors"
|
|
105
|
+
import * as logger from "@superbuilders/slog"
|
|
106
|
+
import { createPrimerServer } from "@superbuilders/primer-tives/server"
|
|
120
107
|
import {
|
|
121
|
-
createPrimerServer,
|
|
122
108
|
ErrConflict,
|
|
123
109
|
ErrInvalidSecretKey,
|
|
124
110
|
ErrStudentNotFound,
|
|
125
111
|
ErrTimebackUnavailable,
|
|
126
112
|
ErrUnsupportedGrade
|
|
127
|
-
} from "@superbuilders/primer-tives/
|
|
113
|
+
} from "@superbuilders/primer-tives/errors"
|
|
128
114
|
|
|
129
115
|
const primer = createPrimerServer({
|
|
130
116
|
origin: "https://sb-primer.vercel.app",
|
|
131
|
-
secretKey: process.env.PRIMER_DEMO_SECRET_KEY
|
|
132
|
-
logger
|
|
117
|
+
secretKey: process.env.PRIMER_DEMO_SECRET_KEY ?? "",
|
|
118
|
+
logger
|
|
133
119
|
})
|
|
134
120
|
|
|
135
121
|
const sessionResult = await errors.try(
|
|
136
|
-
primer.exchangeTimebackStudentForAccessToken(
|
|
122
|
+
primer.exchangeTimebackStudentForAccessToken(sourcedId)
|
|
137
123
|
)
|
|
138
124
|
if (sessionResult.error) {
|
|
139
125
|
if (errors.is(sessionResult.error, ErrInvalidSecretKey)) {
|
|
140
|
-
|
|
126
|
+
logger.error("primer secret key invalid", { error: sessionResult.error })
|
|
127
|
+
throw errors.wrap(sessionResult.error, "primer timeback exchange")
|
|
141
128
|
}
|
|
142
129
|
if (errors.is(sessionResult.error, ErrStudentNotFound)) {
|
|
143
|
-
|
|
130
|
+
logger.error("timeback sourcedId not found upstream", {
|
|
131
|
+
sourcedId,
|
|
132
|
+
error: sessionResult.error
|
|
133
|
+
})
|
|
134
|
+
throw errors.wrap(sessionResult.error, "primer timeback exchange")
|
|
144
135
|
}
|
|
145
136
|
if (errors.is(sessionResult.error, ErrUnsupportedGrade)) {
|
|
146
|
-
|
|
137
|
+
logger.error("timeback grade outside supported range", { error: sessionResult.error })
|
|
138
|
+
throw errors.wrap(sessionResult.error, "primer timeback exchange")
|
|
147
139
|
}
|
|
148
140
|
if (errors.is(sessionResult.error, ErrConflict)) {
|
|
149
|
-
|
|
141
|
+
logger.error("primer frontend not provisioned for routing", { error: sessionResult.error })
|
|
142
|
+
throw errors.wrap(sessionResult.error, "primer timeback exchange")
|
|
150
143
|
}
|
|
151
144
|
if (errors.is(sessionResult.error, ErrTimebackUnavailable)) {
|
|
152
|
-
|
|
145
|
+
logger.error("timeback authority temporarily unavailable", { error: sessionResult.error })
|
|
146
|
+
throw errors.wrap(sessionResult.error, "primer timeback exchange")
|
|
153
147
|
}
|
|
154
|
-
|
|
148
|
+
logger.error("primer timeback exchange failed", { error: sessionResult.error })
|
|
149
|
+
throw errors.wrap(sessionResult.error, "primer timeback exchange")
|
|
155
150
|
}
|
|
156
151
|
|
|
157
152
|
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
153
|
```
|
|
161
154
|
|
|
162
155
|
### Browser flow
|
|
163
156
|
|
|
164
157
|
```ts
|
|
165
|
-
|
|
166
|
-
import {
|
|
167
|
-
create,
|
|
168
|
-
type PrimerState
|
|
169
|
-
} from "@superbuilders/primer-tives/client"
|
|
158
|
+
import { create } from "@superbuilders/primer-tives/client"
|
|
159
|
+
import type { PrimerState } from "@superbuilders/primer-tives/client"
|
|
170
160
|
|
|
171
161
|
const client = create({
|
|
172
162
|
accessToken,
|
|
@@ -196,10 +186,9 @@ while (state.phase !== "completed" && state.phase !== "fatal") {
|
|
|
196
186
|
case "errored":
|
|
197
187
|
if (state.retriable) {
|
|
198
188
|
state = await state.retry()
|
|
199
|
-
|
|
200
|
-
throw state.error
|
|
189
|
+
break
|
|
201
190
|
}
|
|
202
|
-
|
|
191
|
+
throw state.error
|
|
203
192
|
}
|
|
204
193
|
}
|
|
205
194
|
```
|
|
@@ -209,29 +198,17 @@ while (state.phase !== "completed" && state.phase !== "fatal") {
|
|
|
209
198
|
# `/server`
|
|
210
199
|
|
|
211
200
|
```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
|
|
201
|
+
import { createPrimerServer } from "@superbuilders/primer-tives/server"
|
|
202
|
+
import type {
|
|
203
|
+
PrimerServer,
|
|
204
|
+
PrimerServerConfig,
|
|
205
|
+
SessionToken,
|
|
206
|
+
TimebackSession,
|
|
207
|
+
PlacementHints,
|
|
208
|
+
PlacementHintsResult
|
|
234
209
|
} from "@superbuilders/primer-tives/server"
|
|
210
|
+
import type { PrimerLogger } from "@superbuilders/primer-tives/logger"
|
|
211
|
+
import { GRADE_LEVELS, type GradeLevel } from "@superbuilders/primer-tives/grade-level"
|
|
235
212
|
```
|
|
236
213
|
|
|
237
214
|
## `createPrimerServer(config)`
|
|
@@ -242,13 +219,13 @@ interface PrimerServerConfig {
|
|
|
242
219
|
readonly secretKey: string // your sk_… key
|
|
243
220
|
readonly fetch?: typeof globalThis.fetch // override (tests, proxies, instrumentation)
|
|
244
221
|
readonly abort?: AbortController // wired into every request signal
|
|
245
|
-
readonly logger: PrimerLogger // required debug/info/warn/error logger
|
|
222
|
+
readonly logger: PrimerLogger // required debug/info/warn/error logger
|
|
246
223
|
}
|
|
247
224
|
|
|
248
225
|
function createPrimerServer(config: PrimerServerConfig): PrimerServer
|
|
249
226
|
```
|
|
250
227
|
|
|
251
|
-
Returns a `PrimerServer` with exactly
|
|
228
|
+
Returns a `PrimerServer` with exactly four methods:
|
|
252
229
|
|
|
253
230
|
```ts
|
|
254
231
|
interface PrimerServer {
|
|
@@ -261,213 +238,113 @@ interface PrimerServer {
|
|
|
261
238
|
|
|
262
239
|
## Method reference
|
|
263
240
|
|
|
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.
|
|
241
|
+
### `createStudent()`
|
|
269
242
|
|
|
270
|
-
|
|
271
|
-
const studentId = await primer.createStudent()
|
|
272
|
-
```
|
|
243
|
+
Provision a new frontend-owned Primer student and return its stable `studentId`. Use only for the native/manual flow.
|
|
273
244
|
|
|
274
|
-
|
|
245
|
+
Persist the returned `studentId` in your own database. Before the student's first session, call `setStudentHints(studentId, { gradeLevel })`.
|
|
275
246
|
|
|
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.
|
|
281
|
-
|
|
282
|
-
### `setStudentHints(studentId, hints): Promise<PlacementHintsResult>`
|
|
247
|
+
### `setStudentHints(studentId, hints)`
|
|
283
248
|
|
|
284
249
|
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
250
|
|
|
286
251
|
```ts
|
|
287
|
-
const { gradeLevel } = await primer.setStudentHints(studentId, {
|
|
288
|
-
gradeLevel: "3"
|
|
289
|
-
})
|
|
252
|
+
const { gradeLevel } = await primer.setStudentHints(studentId, { gradeLevel: "3" })
|
|
290
253
|
```
|
|
291
254
|
|
|
292
|
-
-
|
|
293
|
-
-
|
|
294
|
-
- Safe to call repeatedly — each call is a partial upsert.
|
|
255
|
+
- Required for native/manual students before their first session — the first `/advance` call fails server-side without a `gradeLevel` on record.
|
|
256
|
+
- Safe to call repeatedly. Each call is a partial upsert.
|
|
295
257
|
- 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
|
|
258
|
+
- Throws `ErrStudentNotFound` if `studentId` is unknown; `ErrBadRequest` if the payload fails validation (e.g. unsupported `gradeLevel`).
|
|
297
259
|
|
|
298
|
-
### `exchangeStudentForAccessToken(studentId)
|
|
260
|
+
### `exchangeStudentForAccessToken(studentId)`
|
|
299
261
|
|
|
300
|
-
Mint a short-lived access token for an existing
|
|
262
|
+
Mint a short-lived access token for an existing native/manual Primer student. Call at every session start.
|
|
301
263
|
|
|
302
|
-
|
|
303
|
-
const { accessToken, expiresInSeconds } =
|
|
304
|
-
await primer.exchangeStudentForAccessToken(studentId)
|
|
305
|
-
```
|
|
306
|
-
|
|
307
|
-
Call this at **every session start**.
|
|
308
|
-
|
|
309
|
-
Important:
|
|
310
|
-
|
|
311
|
-
- This is for **native/manual** students only.
|
|
312
|
-
- If `studentId` belongs to a Timeback-linked student, the method throws `ErrExternalAuthorityRequired`.
|
|
313
|
-
- Tokens are short-lived (typically 15 minutes).
|
|
264
|
+
If `studentId` belongs to a Timeback-linked student, throws `ErrExternalAuthorityRequired`. Tokens are short-lived (typically 15 minutes).
|
|
314
265
|
|
|
315
|
-
### `exchangeTimebackStudentForAccessToken(sourcedId)
|
|
266
|
+
### `exchangeTimebackStudentForAccessToken(sourcedId)`
|
|
316
267
|
|
|
317
|
-
|
|
268
|
+
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`.
|
|
318
269
|
|
|
319
|
-
|
|
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.
|
|
270
|
+
Use at every Timeback session start. Persist `studentId` if you need a stable Primer foreign key.
|
|
337
271
|
|
|
338
272
|
## Return types
|
|
339
273
|
|
|
340
|
-
### `SessionToken`
|
|
341
|
-
|
|
342
274
|
```ts
|
|
343
275
|
interface SessionToken {
|
|
344
276
|
readonly accessToken: string
|
|
345
277
|
readonly expiresInSeconds: number
|
|
346
278
|
}
|
|
347
|
-
```
|
|
348
|
-
|
|
349
|
-
### `TimebackSession`
|
|
350
279
|
|
|
351
|
-
```ts
|
|
352
280
|
interface TimebackSession {
|
|
353
281
|
readonly studentId: string
|
|
354
282
|
readonly accessToken: string
|
|
355
283
|
readonly expiresInSeconds: number
|
|
356
284
|
}
|
|
357
|
-
```
|
|
358
285
|
|
|
359
|
-
### `PlacementHints`
|
|
360
|
-
|
|
361
|
-
```ts
|
|
362
286
|
interface PlacementHints {
|
|
363
287
|
readonly gradeLevel?: GradeLevel
|
|
364
288
|
}
|
|
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
|
-
|
|
369
|
-
### `PlacementHintsResult`
|
|
370
289
|
|
|
371
|
-
```ts
|
|
372
290
|
interface PlacementHintsResult {
|
|
373
291
|
readonly studentId: string
|
|
374
292
|
readonly gradeLevel: GradeLevel | null
|
|
375
293
|
}
|
|
376
294
|
```
|
|
377
295
|
|
|
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
|
-
```
|
|
296
|
+
`gradeLevel` in `PlacementHintsResult` is `null` only on native students who have never had one set.
|
|
433
297
|
|
|
434
298
|
## Rules of the road
|
|
435
299
|
|
|
436
|
-
-
|
|
437
|
-
-
|
|
438
|
-
-
|
|
439
|
-
-
|
|
440
|
-
- **Explicit subpaths only.** Import from `/server` or `/client`, never a package root.
|
|
300
|
+
- Native/manual flow: `createStudent()` once, then `exchangeStudentForAccessToken(studentId)` every session.
|
|
301
|
+
- Timeback flow: `exchangeTimebackStudentForAccessToken(sourcedId)` every session.
|
|
302
|
+
- Student ids are stable identifiers, not browser credentials. Always hand the browser a fresh `accessToken` from your backend.
|
|
303
|
+
- Server-only secrets: keep `secretKey` on your backend; never ship it to the browser.
|
|
441
304
|
|
|
442
305
|
---
|
|
443
306
|
|
|
444
307
|
# `/client`
|
|
445
308
|
|
|
446
309
|
```ts
|
|
447
|
-
import {
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
310
|
+
import { create } from "@superbuilders/primer-tives/client"
|
|
311
|
+
import type {
|
|
312
|
+
Client,
|
|
313
|
+
Config,
|
|
314
|
+
PrimerState,
|
|
315
|
+
ObservationState,
|
|
316
|
+
InteractionState,
|
|
317
|
+
ChoiceState,
|
|
318
|
+
TextEntryState,
|
|
319
|
+
ExtendedTextState,
|
|
320
|
+
ExtendedTextSingleState,
|
|
321
|
+
ExtendedTextMultipleState,
|
|
322
|
+
OrderState,
|
|
323
|
+
MatchState,
|
|
324
|
+
PciInteractionState,
|
|
325
|
+
FeedbackState,
|
|
326
|
+
CompletedState,
|
|
327
|
+
ErroredState,
|
|
328
|
+
FatalState,
|
|
329
|
+
NonSerializable,
|
|
330
|
+
PciPendingRenderProps,
|
|
331
|
+
PciSubmittedRenderProps,
|
|
332
|
+
PciRenderProps
|
|
458
333
|
} from "@superbuilders/primer-tives/client"
|
|
459
334
|
```
|
|
460
335
|
|
|
336
|
+
The `/client` subpath owns the **runtime state machine** only. Wire-shape types (`RendererInteraction`, `RendererSubmission`, `ContentInline`, PCI base types, review types) come from `/contracts`.
|
|
337
|
+
|
|
461
338
|
## `create(config)`
|
|
462
339
|
|
|
463
340
|
```ts
|
|
464
341
|
function create<const Pcis extends PciId>(config: Config<Pcis>): Client<Pcis>
|
|
465
342
|
|
|
466
343
|
interface Config<Pcis extends PciId = PciId> {
|
|
467
|
-
readonly accessToken: string
|
|
468
|
-
readonly supportedPcis: readonly Pcis[]
|
|
469
|
-
readonly origin: string
|
|
470
|
-
readonly subject: SubjectScope
|
|
344
|
+
readonly accessToken: string
|
|
345
|
+
readonly supportedPcis: readonly Pcis[]
|
|
346
|
+
readonly origin: string
|
|
347
|
+
readonly subject: SubjectScope
|
|
471
348
|
readonly fetch?: typeof globalThis.fetch
|
|
472
349
|
readonly abort?: AbortController
|
|
473
350
|
readonly logger?: PrimerLogger
|
|
@@ -478,46 +355,23 @@ interface Client<Pcis extends PciId = PciId> {
|
|
|
478
355
|
}
|
|
479
356
|
```
|
|
480
357
|
|
|
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`.
|
|
358
|
+
`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
359
|
|
|
492
360
|
## `PrimerState` — the state machine
|
|
493
361
|
|
|
494
|
-
`start()` returns a `PrimerState` union. Each phase has its own shape and action methods.
|
|
495
|
-
|
|
496
362
|
```ts
|
|
497
363
|
type PrimerState =
|
|
498
|
-
| ObservationState
|
|
499
|
-
| InteractionState
|
|
500
|
-
| FeedbackState
|
|
501
|
-
| CompletedState
|
|
502
|
-
| ErroredState
|
|
503
|
-
| FatalState
|
|
364
|
+
| ObservationState // .advance()
|
|
365
|
+
| InteractionState // .submit…() / .timeout()
|
|
366
|
+
| FeedbackState // .advance()
|
|
367
|
+
| CompletedState // terminal
|
|
368
|
+
| ErroredState // .retry() — .retriable: boolean
|
|
369
|
+
| FatalState // terminal
|
|
504
370
|
```
|
|
505
371
|
|
|
506
|
-
###
|
|
372
|
+
### Interaction action methods
|
|
507
373
|
|
|
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`:
|
|
374
|
+
`InteractionState` is a discriminated union over `kind`:
|
|
521
375
|
|
|
522
376
|
| `kind` | method |
|
|
523
377
|
|---|---|
|
|
@@ -529,31 +383,31 @@ Server wants an answer. `InteractionState` is a discriminated union over `kind`:
|
|
|
529
383
|
| `match` | `submitMatch(pairs: MatchPair[])` |
|
|
530
384
|
| `portable-custom` | `submit(value: PciValue<K>)` |
|
|
531
385
|
|
|
532
|
-
|
|
386
|
+
Every `InteractionState` shape also exposes `timeout(): Promise<PrimerState>`.
|
|
533
387
|
|
|
534
|
-
|
|
388
|
+
`MatchPair`, `PciValue<K>` come from `/contracts`.
|
|
535
389
|
|
|
536
|
-
|
|
390
|
+
### `FeedbackState`
|
|
537
391
|
|
|
538
392
|
```ts
|
|
539
|
-
interface FeedbackState {
|
|
393
|
+
interface FeedbackState extends NonSerializable {
|
|
540
394
|
readonly phase: "feedback"
|
|
541
395
|
readonly stimulus: RendererStimulus | null
|
|
542
396
|
readonly interaction: RendererInteraction
|
|
543
397
|
readonly submission: RendererSubmission
|
|
544
398
|
readonly isCorrect: boolean
|
|
545
|
-
readonly feedbackContent: ContentInline[]
|
|
546
|
-
readonly review: InteractionReview | null
|
|
399
|
+
readonly feedbackContent: ContentInline[]
|
|
400
|
+
readonly review: InteractionReview | null
|
|
547
401
|
advance(): Promise<PrimerState>
|
|
548
402
|
}
|
|
549
403
|
```
|
|
550
404
|
|
|
551
|
-
|
|
405
|
+
`RendererStimulus`, `RendererInteraction`, `RendererSubmission`, `ContentInline`, `InteractionReview` all come from `/contracts`.
|
|
552
406
|
|
|
553
|
-
|
|
407
|
+
### `ErroredState`
|
|
554
408
|
|
|
555
409
|
```ts
|
|
556
|
-
interface ErroredState {
|
|
410
|
+
interface ErroredState extends NonSerializable {
|
|
557
411
|
readonly phase: "errored"
|
|
558
412
|
readonly error: Error // sentinel-wrapped
|
|
559
413
|
readonly retriable: boolean
|
|
@@ -561,109 +415,237 @@ interface ErroredState {
|
|
|
561
415
|
}
|
|
562
416
|
```
|
|
563
417
|
|
|
564
|
-
|
|
418
|
+
`errored.retriable === false` for client-validation errors (e.g. wrapped `ErrInvalidSubmission`) and for non-recoverable transports. Those must be fixed, not retried.
|
|
565
419
|
|
|
566
|
-
|
|
420
|
+
### `FatalState`
|
|
421
|
+
|
|
422
|
+
Unrecoverable failure (bad request, invalid token, expired token, unsupported PCI, forbidden):
|
|
567
423
|
|
|
568
424
|
```ts
|
|
569
|
-
interface FatalState {
|
|
425
|
+
interface FatalState extends NonSerializable {
|
|
570
426
|
readonly phase: "fatal"
|
|
571
427
|
readonly error: Error
|
|
572
428
|
readonly retriable: false
|
|
573
429
|
}
|
|
574
430
|
```
|
|
575
431
|
|
|
576
|
-
|
|
432
|
+
## PCI render props
|
|
577
433
|
|
|
578
|
-
|
|
434
|
+
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
435
|
|
|
580
436
|
```ts
|
|
581
|
-
|
|
582
|
-
|
|
437
|
+
type PciPendingRenderProps<K extends PciId> = {
|
|
438
|
+
mode: "pending"
|
|
439
|
+
properties: PciProps<K>
|
|
440
|
+
onValueChange: (value: PciValue<K> | null) => void
|
|
583
441
|
}
|
|
442
|
+
|
|
443
|
+
type PciSubmittedRenderProps<K extends PciId> = {
|
|
444
|
+
mode: "submitted"
|
|
445
|
+
properties: PciProps<K>
|
|
446
|
+
submission: PciValue<K>
|
|
447
|
+
review: Extract<InteractionReview<K>, { type: "portable-custom"; pciId: K }> | null
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
type PciRenderProps<K extends PciId> = PciPendingRenderProps<K> | PciSubmittedRenderProps<K>
|
|
584
451
|
```
|
|
585
452
|
|
|
586
|
-
##
|
|
453
|
+
## Behavioral notes
|
|
454
|
+
|
|
455
|
+
- `start()` is idempotent — calling it twice returns the same promise.
|
|
456
|
+
- A returning student resumes wherever the server last placed them. No client-side cursor management.
|
|
457
|
+
- `PrimerState` holds real closures (action methods, pending-promise caches). Don't serialize it; don't store it across reloads. Call `start()` again on boot.
|
|
458
|
+
- `Config.supportedPcis` is a `const` generic; only those URNs flow through `PciInteractionState` at the type level. Mismatch at runtime is a `fatal` with `ErrUnsupportedPci`.
|
|
459
|
+
|
|
460
|
+
---
|
|
461
|
+
|
|
462
|
+
# `/contracts`
|
|
463
|
+
|
|
464
|
+
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
465
|
|
|
588
466
|
```ts
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
467
|
+
import {
|
|
468
|
+
ChoiceSubmissionSchema,
|
|
469
|
+
TextEntrySubmissionSchema,
|
|
470
|
+
ExtendedTextSubmissionSchema,
|
|
471
|
+
OrderSubmissionSchema,
|
|
472
|
+
MatchSubmissionSchema,
|
|
473
|
+
MatchPairSchema,
|
|
474
|
+
DivisionRemainderPciSubmissionSchema,
|
|
475
|
+
FractionAdditionPciSubmissionSchema,
|
|
476
|
+
RendererSubmissionSchema,
|
|
477
|
+
validateSubmissionForInteraction,
|
|
478
|
+
submissionValidationMessage,
|
|
479
|
+
blocksToPlainText,
|
|
480
|
+
inlinesToPlainText
|
|
481
|
+
} from "@superbuilders/primer-tives/contracts"
|
|
592
482
|
|
|
593
|
-
type
|
|
594
|
-
|
|
595
|
-
|
|
483
|
+
import type {
|
|
484
|
+
// interaction & submission shapes
|
|
485
|
+
RendererInteraction,
|
|
486
|
+
RendererSubmission,
|
|
487
|
+
StandardRendererInteraction,
|
|
488
|
+
PciInteraction,
|
|
489
|
+
PciSubmission,
|
|
490
|
+
// stimulus shapes
|
|
491
|
+
RendererStimulus,
|
|
492
|
+
BodyStimulus,
|
|
493
|
+
ImageStimulus,
|
|
494
|
+
// choice shapes
|
|
495
|
+
RendererChoice,
|
|
496
|
+
RendererMatchChoice,
|
|
497
|
+
MatchPair,
|
|
498
|
+
// content
|
|
499
|
+
ContentBlock,
|
|
500
|
+
ContentInline,
|
|
501
|
+
ContentSpan,
|
|
502
|
+
// PCI base
|
|
503
|
+
PciId,
|
|
504
|
+
PciUrn,
|
|
505
|
+
PciRegistry,
|
|
506
|
+
PciProps,
|
|
507
|
+
PciValue,
|
|
508
|
+
DivisionRemainderProps,
|
|
509
|
+
DivisionRemainderSubmission,
|
|
510
|
+
FractionAdditionProps,
|
|
511
|
+
FractionAdditionSubmission,
|
|
512
|
+
// review (server emits these, client consumes)
|
|
513
|
+
InteractionReview,
|
|
514
|
+
ChoiceReview,
|
|
515
|
+
TextEntryReview,
|
|
516
|
+
ExtendedTextReview,
|
|
517
|
+
OrderReview,
|
|
518
|
+
MatchReview,
|
|
519
|
+
PciReview,
|
|
520
|
+
ReviewRecordField,
|
|
521
|
+
ReviewRecordFieldBaseType,
|
|
522
|
+
ReviewScalarValue,
|
|
523
|
+
// validation result
|
|
524
|
+
SubmissionValidationResult,
|
|
525
|
+
SubmissionValidationSuccess,
|
|
526
|
+
SubmissionValidationFailure
|
|
527
|
+
} from "@superbuilders/primer-tives/contracts"
|
|
528
|
+
```
|
|
529
|
+
|
|
530
|
+
## Schemas
|
|
531
|
+
|
|
532
|
+
Every `RendererSubmission` variant has a Zod schema; `RendererSubmissionSchema` is the discriminated union over all of them and is the canonical `safeParse`-able wire shape.
|
|
533
|
+
|
|
534
|
+
```ts
|
|
535
|
+
import * as errors from "@superbuilders/errors"
|
|
536
|
+
import * as logger from "@superbuilders/slog"
|
|
537
|
+
import { RendererSubmissionSchema } from "@superbuilders/primer-tives/contracts"
|
|
596
538
|
|
|
597
|
-
|
|
539
|
+
const result = RendererSubmissionSchema.safeParse(unknownPayload)
|
|
540
|
+
if (!result.success) {
|
|
541
|
+
logger.error("submission payload failed schema parse", { issues: result.error.issues })
|
|
542
|
+
throw errors.wrap(result.error, "renderer submission parse")
|
|
543
|
+
}
|
|
544
|
+
const submission = result.data
|
|
598
545
|
```
|
|
599
546
|
|
|
600
|
-
|
|
547
|
+
## `validateSubmissionForInteraction(interaction, submission)`
|
|
601
548
|
|
|
602
|
-
|
|
549
|
+
Optional semantic validator. Checks a submission against the interaction it claims to answer (cardinality bounds, identifier membership, duplicate rules, PCI payload shape).
|
|
603
550
|
|
|
604
551
|
```ts
|
|
605
|
-
|
|
606
|
-
|
|
552
|
+
function validateSubmissionForInteraction(
|
|
553
|
+
interaction: RendererInteraction,
|
|
554
|
+
submission: RendererSubmission
|
|
555
|
+
): SubmissionValidationResult
|
|
556
|
+
|
|
557
|
+
type SubmissionValidationResult =
|
|
558
|
+
| { ok: true; value: RendererSubmission }
|
|
559
|
+
| { ok: false; issues: readonly string[] }
|
|
607
560
|
```
|
|
608
561
|
|
|
609
|
-
|
|
562
|
+
`submissionValidationMessage(failure)` joins `failure.issues` with `"; "` for a single string.
|
|
610
563
|
|
|
611
564
|
```ts
|
|
612
|
-
|
|
565
|
+
import * as errors from "@superbuilders/errors"
|
|
566
|
+
import * as logger from "@superbuilders/slog"
|
|
567
|
+
import {
|
|
568
|
+
submissionValidationMessage,
|
|
569
|
+
validateSubmissionForInteraction
|
|
570
|
+
} from "@superbuilders/primer-tives/contracts"
|
|
571
|
+
import { ErrInvalidSubmission } from "@superbuilders/primer-tives/errors"
|
|
613
572
|
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
}
|
|
619
|
-
"urn:primer:pci:fraction-addition": {
|
|
620
|
-
props: FractionAdditionProps
|
|
621
|
-
value: FractionAdditionSubmission
|
|
622
|
-
}
|
|
573
|
+
const validation = validateSubmissionForInteraction(interaction, submission)
|
|
574
|
+
if (!validation.ok) {
|
|
575
|
+
logger.warn("submission rejected by contract validation", { issues: validation.issues })
|
|
576
|
+
throw errors.wrap(ErrInvalidSubmission, submissionValidationMessage(validation))
|
|
623
577
|
}
|
|
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
578
|
```
|
|
629
579
|
|
|
630
|
-
|
|
580
|
+
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
581
|
|
|
632
|
-
|
|
582
|
+
---
|
|
633
583
|
|
|
634
|
-
|
|
635
|
-
type PciPendingRenderProps<K extends PciId> = {
|
|
636
|
-
mode: "pending"
|
|
637
|
-
properties: PciProps<K>
|
|
638
|
-
onValueChange: (value: PciValue<K> | null) => void
|
|
639
|
-
}
|
|
584
|
+
# `/errors`
|
|
640
585
|
|
|
641
|
-
|
|
642
|
-
mode: "submitted"
|
|
643
|
-
properties: PciProps<K>
|
|
644
|
-
submission: PciValue<K>
|
|
645
|
-
review: Extract<InteractionReview<K>, { type: "portable-custom"; pciId: K }> | null
|
|
646
|
-
}
|
|
586
|
+
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
587
|
|
|
648
|
-
|
|
588
|
+
```ts
|
|
589
|
+
import {
|
|
590
|
+
// shared (server + client transport)
|
|
591
|
+
ErrBadRequest,
|
|
592
|
+
ErrConflict,
|
|
593
|
+
ErrJsonParse,
|
|
594
|
+
ErrNetwork,
|
|
595
|
+
ErrServerError,
|
|
596
|
+
ErrTimeout,
|
|
597
|
+
// /server-thrown
|
|
598
|
+
ErrInvalidSecretKey,
|
|
599
|
+
ErrStudentNotFound,
|
|
600
|
+
ErrUnsupportedGrade,
|
|
601
|
+
ErrTimebackUnavailable,
|
|
602
|
+
ErrExternalAuthorityRequired,
|
|
603
|
+
// /client surfaced as `errored`
|
|
604
|
+
ErrServiceUnavailable,
|
|
605
|
+
ErrRateLimited,
|
|
606
|
+
ErrInvalidSubmission,
|
|
607
|
+
// /client surfaced as `fatal`
|
|
608
|
+
ErrInvalidAccessToken,
|
|
609
|
+
ErrTokenExpired,
|
|
610
|
+
ErrForbidden,
|
|
611
|
+
ErrNotFound,
|
|
612
|
+
ErrUnsupportedPci,
|
|
613
|
+
// /client thrown directly by `create()`
|
|
614
|
+
ErrMalformedAccessToken,
|
|
615
|
+
ErrNotSerializable
|
|
616
|
+
} from "@superbuilders/primer-tives/errors"
|
|
649
617
|
```
|
|
650
618
|
|
|
651
|
-
##
|
|
619
|
+
## Server-side method failures
|
|
620
|
+
|
|
621
|
+
| Sentinel | Raised when |
|
|
622
|
+
|---|---|
|
|
623
|
+
| `ErrInvalidSecretKey` | HTTP 401 — missing, malformed, or unknown `sk_` |
|
|
624
|
+
| `ErrStudentNotFound` | HTTP 404 — native `studentId` unknown on this frontend, or Timeback `sourcedId` unknown upstream |
|
|
625
|
+
| `ErrUnsupportedGrade` | HTTP 400 — Timeback returned a grade outside Primer's supported range |
|
|
626
|
+
| `ErrTimebackUnavailable` | HTTP 502 — Timeback OneRoster endpoint failed during live exchange |
|
|
627
|
+
| `ErrExternalAuthorityRequired` | HTTP 409 — attempted native/manual exchange for a Timeback-linked student |
|
|
628
|
+
| `ErrConflict` | HTTP 409 — frontend not provisioned for routing/content |
|
|
629
|
+
| `ErrBadRequest` | HTTP 400 — validation failure |
|
|
630
|
+
| `ErrServerError` | HTTP 5xx |
|
|
631
|
+
| `ErrJsonParse` | response body was not valid JSON or had the wrong shape |
|
|
632
|
+
| `ErrNetwork` | `fetch()` rejected (DNS, connection, TLS, etc.) |
|
|
633
|
+
| `ErrTimeout` | `fetch()` aborted |
|
|
652
634
|
|
|
653
|
-
|
|
635
|
+
## Client `errored.error`
|
|
654
636
|
|
|
655
637
|
| Sentinel | Raised when |
|
|
656
638
|
|---|---|
|
|
657
|
-
| `ErrNetwork` | fetch() rejected |
|
|
658
|
-
| `ErrTimeout` | fetch() aborted |
|
|
639
|
+
| `ErrNetwork` | `fetch()` rejected |
|
|
640
|
+
| `ErrTimeout` | `fetch()` aborted |
|
|
659
641
|
| `ErrServerError` | HTTP 5xx |
|
|
660
642
|
| `ErrServiceUnavailable` | HTTP 502/503/504 |
|
|
661
643
|
| `ErrRateLimited` | HTTP 429 |
|
|
662
644
|
| `ErrConflict` | HTTP 409 |
|
|
663
|
-
| `ErrJsonParse` |
|
|
645
|
+
| `ErrJsonParse` | success body wasn't valid JSON |
|
|
664
646
|
| `ErrInvalidSubmission` | client-side validation rejected the submission |
|
|
665
647
|
|
|
666
|
-
|
|
648
|
+
## Client `fatal.error`
|
|
667
649
|
|
|
668
650
|
| Sentinel | Raised when |
|
|
669
651
|
|---|---|
|
|
@@ -674,16 +656,56 @@ type PciRenderProps<K extends PciId> = PciPendingRenderProps<K> | PciSubmittedRe
|
|
|
674
656
|
| `ErrNotFound` | HTTP 404 |
|
|
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`.
|