@superbuilders/primer-tives 1.1.2 → 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 +407 -359
- package/dist/client/choice-state.d.ts +9 -0
- package/dist/client/choice-state.d.ts.map +1 -0
- package/dist/client/consumed.d.ts +3 -0
- package/dist/client/consumed.d.ts.map +1 -0
- package/dist/client/create.d.ts +20 -0
- package/dist/client/create.d.ts.map +1 -0
- package/dist/client/extended-text-state.d.ts +9 -0
- package/dist/client/extended-text-state.d.ts.map +1 -0
- package/dist/client/feedback-state.d.ts +9 -0
- package/dist/client/feedback-state.d.ts.map +1 -0
- package/dist/client/index.d.ts +4 -0
- package/dist/client/index.d.ts.map +1 -0
- package/dist/client/index.js +1074 -0
- package/dist/client/index.js.map +25 -0
- package/dist/client/match-state.d.ts +9 -0
- package/dist/client/match-state.d.ts.map +1 -0
- package/dist/client/observation-state.d.ts +7 -0
- package/dist/client/observation-state.d.ts.map +1 -0
- package/dist/client/order-state.d.ts +9 -0
- package/dist/client/order-state.d.ts.map +1 -0
- package/dist/client/pci-state.d.ts +7 -0
- package/dist/client/pci-state.d.ts.map +1 -0
- package/dist/client/session-context.d.ts +20 -0
- package/dist/client/session-context.d.ts.map +1 -0
- package/dist/client/session.d.ts +18 -0
- package/dist/client/session.d.ts.map +1 -0
- package/dist/client/text-entry-state.d.ts +9 -0
- package/dist/client/text-entry-state.d.ts.map +1 -0
- package/dist/client/transport.d.ts +47 -0
- package/dist/client/transport.d.ts.map +1 -0
- package/dist/client/types.d.ts +144 -0
- package/dist/client/types.d.ts.map +1 -0
- package/dist/contracts/content.d.ts +20 -0
- package/dist/contracts/content.d.ts.map +1 -0
- package/dist/contracts/index.d.ts +8 -0
- package/dist/contracts/index.d.ts.map +1 -0
- package/dist/contracts/index.js +326 -0
- package/dist/contracts/index.js.map +12 -0
- package/dist/contracts/pci-schemas.d.ts +25 -0
- package/dist/contracts/pci-schemas.d.ts.map +1 -0
- package/dist/contracts/pci.d.ts +38 -0
- 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 +118 -0
- package/dist/contracts/types.d.ts.map +1 -0
- package/dist/contracts/validation.d.ts +92 -0
- package/dist/contracts/validation.d.ts.map +1 -0
- package/dist/errors.d.ts +23 -0
- package/dist/errors.d.ts.map +1 -0
- package/dist/errors.js +48 -0
- package/dist/errors.js.map +10 -0
- package/dist/grade-level.d.ts +5 -0
- package/dist/grade-level.d.ts.map +1 -0
- package/dist/grade-level.js +7 -0
- package/dist/grade-level.js.map +10 -0
- package/dist/logger.d.ts +8 -0
- package/dist/logger.d.ts.map +1 -0
- package/dist/logger.js +2 -0
- package/dist/logger.js.map +9 -0
- package/dist/server/create-server.d.ts +44 -0
- package/dist/server/create-server.d.ts.map +1 -0
- package/dist/server/exchange.d.ts +22 -0
- package/dist/server/exchange.d.ts.map +1 -0
- package/dist/server/hints.d.ts +25 -0
- package/dist/server/hints.d.ts.map +1 -0
- package/dist/server/index.d.ts +5 -0
- package/dist/server/index.d.ts.map +1 -0
- package/dist/server/index.js +516 -0
- package/dist/server/index.js.map +15 -0
- package/dist/server/students.d.ts +12 -0
- package/dist/server/students.d.ts.map +1 -0
- package/dist/subject.d.ts +6 -0
- package/dist/subject.d.ts.map +1 -0
- package/dist/subject.js +7 -0
- package/dist/subject.js.map +10 -0
- package/package.json +14 -6
package/README.md
CHANGED
|
@@ -2,113 +2,97 @@
|
|
|
2
2
|
|
|
3
3
|
TypeScript SDK for the Primer adaptive learning engine.
|
|
4
4
|
|
|
5
|
-
The package exposes **two explicit subpaths**:
|
|
6
|
-
|
|
7
|
-
- `@superbuilders/primer-tives/server` — runs on your backend, authenticates with your Primer `sk_...` secret key, and starts student sessions.
|
|
8
|
-
- `@superbuilders/primer-tives/client` — runs in the browser and drives the Primer lesson state machine.
|
|
9
|
-
|
|
10
|
-
There is **no root export**. You must choose the side of the wire you are on.
|
|
11
|
-
|
|
12
5
|
```sh
|
|
13
6
|
bun add @superbuilders/primer-tives
|
|
14
7
|
```
|
|
15
8
|
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
## Entrypoints
|
|
19
|
-
|
|
20
|
-
| Import | Runs on | Wraps |
|
|
21
|
-
|---|---|---|
|
|
22
|
-
| `@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` |
|
|
23
|
-
| `@superbuilders/primer-tives/client` | the browser | `POST /api/v0/advance` |
|
|
24
|
-
|
|
25
|
-
## Supported student flows
|
|
26
|
-
|
|
27
|
-
Primer intentionally supports **two** backend-authenticated student flows.
|
|
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.
|
|
28
10
|
|
|
29
|
-
|
|
11
|
+
## Subpaths
|
|
30
12
|
|
|
31
|
-
|
|
13
|
+
The package has **no root export**. Every symbol lives at exactly one subpath. Pick the subpath that owns the thing you need.
|
|
32
14
|
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
1. At each session start, call `exchangeTimebackStudentForAccessToken(sourcedId)`.
|
|
44
|
-
2. Primer verifies the learner against Timeback on **every call**.
|
|
45
|
-
3. Primer resolves or provisions the frontend-owned Primer student row behind the scenes.
|
|
46
|
-
4. The call returns both the stable Primer `studentId` and a short-lived `accessToken`.
|
|
47
|
-
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 |
|
|
48
24
|
|
|
49
|
-
|
|
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`).
|
|
50
26
|
|
|
51
27
|
## End-to-end examples
|
|
52
28
|
|
|
53
|
-
### Native/manual flow
|
|
29
|
+
### Native/manual student flow
|
|
54
30
|
|
|
55
31
|
```ts
|
|
56
|
-
// ── your backend ─────────────────────────────────────────────────────
|
|
57
32
|
import * as errors from "@superbuilders/errors"
|
|
33
|
+
import * as logger from "@superbuilders/slog"
|
|
34
|
+
import { createPrimerServer } from "@superbuilders/primer-tives/server"
|
|
58
35
|
import {
|
|
59
|
-
createPrimerServer,
|
|
60
36
|
ErrBadRequest,
|
|
61
37
|
ErrConflict,
|
|
62
38
|
ErrInvalidSecretKey,
|
|
63
39
|
ErrStudentNotFound
|
|
64
|
-
} from "@superbuilders/primer-tives/
|
|
40
|
+
} from "@superbuilders/primer-tives/errors"
|
|
65
41
|
|
|
66
42
|
const primer = createPrimerServer({
|
|
67
43
|
origin: "https://sb-primer.vercel.app",
|
|
68
|
-
secretKey: process.env.PRIMER_DEMO_SECRET_KEY
|
|
69
|
-
logger
|
|
44
|
+
secretKey: process.env.PRIMER_DEMO_SECRET_KEY ?? "",
|
|
45
|
+
logger
|
|
70
46
|
})
|
|
71
47
|
|
|
72
48
|
// One time per user.
|
|
73
49
|
const createResult = await errors.try(primer.createStudent())
|
|
74
50
|
if (createResult.error) {
|
|
75
51
|
if (errors.is(createResult.error, ErrInvalidSecretKey)) {
|
|
76
|
-
|
|
52
|
+
logger.error("primer secret key invalid", { error: createResult.error })
|
|
53
|
+
throw errors.wrap(createResult.error, "primer client init")
|
|
77
54
|
}
|
|
78
55
|
if (errors.is(createResult.error, ErrConflict)) {
|
|
79
|
-
|
|
56
|
+
logger.error("primer frontend not provisioned", { error: createResult.error })
|
|
57
|
+
throw errors.wrap(createResult.error, "primer frontend provisioning")
|
|
80
58
|
}
|
|
81
|
-
|
|
59
|
+
logger.error("primer create student failed", { error: createResult.error })
|
|
60
|
+
throw errors.wrap(createResult.error, "primer create student")
|
|
82
61
|
}
|
|
83
62
|
const studentId = createResult.data
|
|
84
|
-
// persist `studentId` alongside your own user record
|
|
63
|
+
// persist `studentId` alongside your own user record.
|
|
85
64
|
|
|
86
65
|
// Required before the first session for native students.
|
|
87
|
-
const hintsResult = await errors.try(
|
|
88
|
-
primer.setStudentHints(studentId, { gradeLevel: "3" })
|
|
89
|
-
)
|
|
66
|
+
const hintsResult = await errors.try(primer.setStudentHints(studentId, { gradeLevel: "3" }))
|
|
90
67
|
if (hintsResult.error) {
|
|
91
68
|
if (errors.is(hintsResult.error, ErrStudentNotFound)) {
|
|
92
|
-
|
|
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")
|
|
93
74
|
}
|
|
94
75
|
if (errors.is(hintsResult.error, ErrBadRequest)) {
|
|
95
|
-
|
|
76
|
+
logger.error("primer hint payload rejected", { error: hintsResult.error })
|
|
77
|
+
throw errors.wrap(hintsResult.error, "primer set hints")
|
|
96
78
|
}
|
|
97
|
-
|
|
79
|
+
logger.error("primer set hints failed", { error: hintsResult.error })
|
|
80
|
+
throw errors.wrap(hintsResult.error, "primer set hints")
|
|
98
81
|
}
|
|
99
82
|
|
|
100
83
|
// Every session start.
|
|
101
|
-
const tokenResult = await errors.try(
|
|
102
|
-
primer.exchangeStudentForAccessToken(studentId)
|
|
103
|
-
)
|
|
84
|
+
const tokenResult = await errors.try(primer.exchangeStudentForAccessToken(studentId))
|
|
104
85
|
if (tokenResult.error) {
|
|
105
86
|
if (errors.is(tokenResult.error, ErrInvalidSecretKey)) {
|
|
106
|
-
|
|
87
|
+
logger.error("primer secret key invalid", { error: tokenResult.error })
|
|
88
|
+
throw errors.wrap(tokenResult.error, "primer token exchange")
|
|
107
89
|
}
|
|
108
90
|
if (errors.is(tokenResult.error, ErrStudentNotFound)) {
|
|
109
|
-
|
|
91
|
+
logger.error("primer student id stale", { studentId, error: tokenResult.error })
|
|
92
|
+
throw errors.wrap(tokenResult.error, "primer token exchange")
|
|
110
93
|
}
|
|
111
|
-
|
|
94
|
+
logger.error("primer token exchange failed", { error: tokenResult.error })
|
|
95
|
+
throw errors.wrap(tokenResult.error, "primer token exchange")
|
|
112
96
|
}
|
|
113
97
|
|
|
114
98
|
const { accessToken, expiresInSeconds } = tokenResult.data
|
|
@@ -117,58 +101,62 @@ const { accessToken, expiresInSeconds } = tokenResult.data
|
|
|
117
101
|
### Live-authoritative Timeback flow
|
|
118
102
|
|
|
119
103
|
```ts
|
|
120
|
-
// ── your backend ─────────────────────────────────────────────────────
|
|
121
104
|
import * as errors from "@superbuilders/errors"
|
|
105
|
+
import * as logger from "@superbuilders/slog"
|
|
106
|
+
import { createPrimerServer } from "@superbuilders/primer-tives/server"
|
|
122
107
|
import {
|
|
123
|
-
createPrimerServer,
|
|
124
108
|
ErrConflict,
|
|
125
109
|
ErrInvalidSecretKey,
|
|
126
110
|
ErrStudentNotFound,
|
|
127
111
|
ErrTimebackUnavailable,
|
|
128
112
|
ErrUnsupportedGrade
|
|
129
|
-
} from "@superbuilders/primer-tives/
|
|
113
|
+
} from "@superbuilders/primer-tives/errors"
|
|
130
114
|
|
|
131
115
|
const primer = createPrimerServer({
|
|
132
116
|
origin: "https://sb-primer.vercel.app",
|
|
133
|
-
secretKey: process.env.PRIMER_DEMO_SECRET_KEY
|
|
134
|
-
logger
|
|
117
|
+
secretKey: process.env.PRIMER_DEMO_SECRET_KEY ?? "",
|
|
118
|
+
logger
|
|
135
119
|
})
|
|
136
120
|
|
|
137
121
|
const sessionResult = await errors.try(
|
|
138
|
-
primer.exchangeTimebackStudentForAccessToken(
|
|
122
|
+
primer.exchangeTimebackStudentForAccessToken(sourcedId)
|
|
139
123
|
)
|
|
140
124
|
if (sessionResult.error) {
|
|
141
125
|
if (errors.is(sessionResult.error, ErrInvalidSecretKey)) {
|
|
142
|
-
|
|
126
|
+
logger.error("primer secret key invalid", { error: sessionResult.error })
|
|
127
|
+
throw errors.wrap(sessionResult.error, "primer timeback exchange")
|
|
143
128
|
}
|
|
144
129
|
if (errors.is(sessionResult.error, ErrStudentNotFound)) {
|
|
145
|
-
|
|
130
|
+
logger.error("timeback sourcedId not found upstream", {
|
|
131
|
+
sourcedId,
|
|
132
|
+
error: sessionResult.error
|
|
133
|
+
})
|
|
134
|
+
throw errors.wrap(sessionResult.error, "primer timeback exchange")
|
|
146
135
|
}
|
|
147
136
|
if (errors.is(sessionResult.error, ErrUnsupportedGrade)) {
|
|
148
|
-
|
|
137
|
+
logger.error("timeback grade outside supported range", { error: sessionResult.error })
|
|
138
|
+
throw errors.wrap(sessionResult.error, "primer timeback exchange")
|
|
149
139
|
}
|
|
150
140
|
if (errors.is(sessionResult.error, ErrConflict)) {
|
|
151
|
-
|
|
141
|
+
logger.error("primer frontend not provisioned for routing", { error: sessionResult.error })
|
|
142
|
+
throw errors.wrap(sessionResult.error, "primer timeback exchange")
|
|
152
143
|
}
|
|
153
144
|
if (errors.is(sessionResult.error, ErrTimebackUnavailable)) {
|
|
154
|
-
|
|
145
|
+
logger.error("timeback authority temporarily unavailable", { error: sessionResult.error })
|
|
146
|
+
throw errors.wrap(sessionResult.error, "primer timeback exchange")
|
|
155
147
|
}
|
|
156
|
-
|
|
148
|
+
logger.error("primer timeback exchange failed", { error: sessionResult.error })
|
|
149
|
+
throw errors.wrap(sessionResult.error, "primer timeback exchange")
|
|
157
150
|
}
|
|
158
151
|
|
|
159
152
|
const { studentId, accessToken, expiresInSeconds } = sessionResult.data
|
|
160
|
-
// persist `studentId` if you want a stable Primer foreign key
|
|
161
|
-
// use `accessToken` for this session only
|
|
162
153
|
```
|
|
163
154
|
|
|
164
155
|
### Browser flow
|
|
165
156
|
|
|
166
157
|
```ts
|
|
167
|
-
|
|
168
|
-
import {
|
|
169
|
-
create,
|
|
170
|
-
type PrimerState
|
|
171
|
-
} from "@superbuilders/primer-tives/client"
|
|
158
|
+
import { create } from "@superbuilders/primer-tives/client"
|
|
159
|
+
import type { PrimerState } from "@superbuilders/primer-tives/client"
|
|
172
160
|
|
|
173
161
|
const client = create({
|
|
174
162
|
accessToken,
|
|
@@ -198,10 +186,9 @@ while (state.phase !== "completed" && state.phase !== "fatal") {
|
|
|
198
186
|
case "errored":
|
|
199
187
|
if (state.retriable) {
|
|
200
188
|
state = await state.retry()
|
|
201
|
-
|
|
202
|
-
throw state.error
|
|
189
|
+
break
|
|
203
190
|
}
|
|
204
|
-
|
|
191
|
+
throw state.error
|
|
205
192
|
}
|
|
206
193
|
}
|
|
207
194
|
```
|
|
@@ -211,29 +198,17 @@ while (state.phase !== "completed" && state.phase !== "fatal") {
|
|
|
211
198
|
# `/server`
|
|
212
199
|
|
|
213
200
|
```ts
|
|
214
|
-
import {
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
type GradeLevel,
|
|
223
|
-
GRADE_LEVELS,
|
|
224
|
-
type PrimerLogger,
|
|
225
|
-
ErrBadRequest,
|
|
226
|
-
ErrConflict,
|
|
227
|
-
ErrExternalAuthorityRequired,
|
|
228
|
-
ErrInvalidSecretKey,
|
|
229
|
-
ErrJsonParse,
|
|
230
|
-
ErrNetwork,
|
|
231
|
-
ErrServerError,
|
|
232
|
-
ErrStudentNotFound,
|
|
233
|
-
ErrTimebackUnavailable,
|
|
234
|
-
ErrTimeout,
|
|
235
|
-
ErrUnsupportedGrade
|
|
201
|
+
import { createPrimerServer } from "@superbuilders/primer-tives/server"
|
|
202
|
+
import type {
|
|
203
|
+
PrimerServer,
|
|
204
|
+
PrimerServerConfig,
|
|
205
|
+
SessionToken,
|
|
206
|
+
TimebackSession,
|
|
207
|
+
PlacementHints,
|
|
208
|
+
PlacementHintsResult
|
|
236
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"
|
|
237
212
|
```
|
|
238
213
|
|
|
239
214
|
## `createPrimerServer(config)`
|
|
@@ -244,13 +219,13 @@ interface PrimerServerConfig {
|
|
|
244
219
|
readonly secretKey: string // your sk_… key
|
|
245
220
|
readonly fetch?: typeof globalThis.fetch // override (tests, proxies, instrumentation)
|
|
246
221
|
readonly abort?: AbortController // wired into every request signal
|
|
247
|
-
readonly logger: PrimerLogger // required debug/info/warn/error logger
|
|
222
|
+
readonly logger: PrimerLogger // required debug/info/warn/error logger
|
|
248
223
|
}
|
|
249
224
|
|
|
250
225
|
function createPrimerServer(config: PrimerServerConfig): PrimerServer
|
|
251
226
|
```
|
|
252
227
|
|
|
253
|
-
Returns a `PrimerServer` with exactly
|
|
228
|
+
Returns a `PrimerServer` with exactly four methods:
|
|
254
229
|
|
|
255
230
|
```ts
|
|
256
231
|
interface PrimerServer {
|
|
@@ -263,213 +238,113 @@ interface PrimerServer {
|
|
|
263
238
|
|
|
264
239
|
## Method reference
|
|
265
240
|
|
|
266
|
-
### `createStudent()
|
|
267
|
-
|
|
268
|
-
Provision a new **frontend-owned Primer student** and return its stable `studentId`.
|
|
269
|
-
|
|
270
|
-
Use this only for the native/manual flow.
|
|
271
|
-
|
|
272
|
-
```ts
|
|
273
|
-
const studentId = await primer.createStudent()
|
|
274
|
-
```
|
|
241
|
+
### `createStudent()`
|
|
275
242
|
|
|
276
|
-
|
|
243
|
+
Provision a new frontend-owned Primer student and return its stable `studentId`. Use only for the native/manual flow.
|
|
277
244
|
|
|
278
|
-
|
|
279
|
-
- Persist the returned `studentId` in your own database.
|
|
280
|
-
- **Before the first session**, call `setStudentHints(studentId, { gradeLevel })`. Native students are created without a `gradeLevel`, and `/advance` requires one.
|
|
281
|
-
- Use that stored `studentId` with `exchangeStudentForAccessToken(studentId)` at each session start.
|
|
282
|
-
- `logger` is required. Pass any object implementing `debug/info/warn/error`; `console` is acceptable for basic integrations.
|
|
245
|
+
Persist the returned `studentId` in your own database. Before the student's first session, call `setStudentHints(studentId, { gradeLevel })`.
|
|
283
246
|
|
|
284
|
-
### `setStudentHints(studentId, hints)
|
|
247
|
+
### `setStudentHints(studentId, hints)`
|
|
285
248
|
|
|
286
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.
|
|
287
250
|
|
|
288
251
|
```ts
|
|
289
|
-
const { gradeLevel } = await primer.setStudentHints(studentId, {
|
|
290
|
-
gradeLevel: "3"
|
|
291
|
-
})
|
|
252
|
+
const { gradeLevel } = await primer.setStudentHints(studentId, { gradeLevel: "3" })
|
|
292
253
|
```
|
|
293
254
|
|
|
294
|
-
-
|
|
295
|
-
-
|
|
296
|
-
- 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.
|
|
297
257
|
- Not needed for Timeback-linked students: `exchangeTimebackStudentForAccessToken` syncs the grade level from the authority on every call.
|
|
298
|
-
- Throws `ErrStudentNotFound` if `studentId` is unknown; `ErrBadRequest` if the payload fails validation (e.g
|
|
299
|
-
|
|
300
|
-
### `exchangeStudentForAccessToken(studentId): Promise<SessionToken>`
|
|
301
|
-
|
|
302
|
-
Mint a short-lived access token for an existing **native/manual** Primer student.
|
|
303
|
-
|
|
304
|
-
```ts
|
|
305
|
-
const { accessToken, expiresInSeconds } =
|
|
306
|
-
await primer.exchangeStudentForAccessToken(studentId)
|
|
307
|
-
```
|
|
308
|
-
|
|
309
|
-
Call this at **every session start**.
|
|
258
|
+
- Throws `ErrStudentNotFound` if `studentId` is unknown; `ErrBadRequest` if the payload fails validation (e.g. unsupported `gradeLevel`).
|
|
310
259
|
|
|
311
|
-
|
|
260
|
+
### `exchangeStudentForAccessToken(studentId)`
|
|
312
261
|
|
|
313
|
-
-
|
|
314
|
-
- If `studentId` belongs to a Timeback-linked student, the method throws `ErrExternalAuthorityRequired`.
|
|
315
|
-
- Tokens are short-lived (typically 15 minutes).
|
|
262
|
+
Mint a short-lived access token for an existing native/manual Primer student. Call at every session start.
|
|
316
263
|
|
|
317
|
-
|
|
264
|
+
If `studentId` belongs to a Timeback-linked student, throws `ErrExternalAuthorityRequired`. Tokens are short-lived (typically 15 minutes).
|
|
318
265
|
|
|
319
|
-
|
|
266
|
+
### `exchangeTimebackStudentForAccessToken(sourcedId)`
|
|
320
267
|
|
|
321
|
-
|
|
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`.
|
|
322
269
|
|
|
323
|
-
|
|
324
|
-
- a short-lived `accessToken`
|
|
325
|
-
- `expiresInSeconds`
|
|
326
|
-
|
|
327
|
-
```ts
|
|
328
|
-
const { studentId, accessToken, expiresInSeconds } =
|
|
329
|
-
await primer.exchangeTimebackStudentForAccessToken(sourcedId)
|
|
330
|
-
```
|
|
331
|
-
|
|
332
|
-
Use this at **every Timeback session start**.
|
|
333
|
-
|
|
334
|
-
Operational rule:
|
|
335
|
-
|
|
336
|
-
- Call this at every Timeback session start.
|
|
337
|
-
- Persist `studentId` if you need a stable Primer foreign key in your own system.
|
|
338
|
-
- 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.
|
|
339
271
|
|
|
340
272
|
## Return types
|
|
341
273
|
|
|
342
|
-
### `SessionToken`
|
|
343
|
-
|
|
344
274
|
```ts
|
|
345
275
|
interface SessionToken {
|
|
346
276
|
readonly accessToken: string
|
|
347
277
|
readonly expiresInSeconds: number
|
|
348
278
|
}
|
|
349
|
-
```
|
|
350
|
-
|
|
351
|
-
### `TimebackSession`
|
|
352
279
|
|
|
353
|
-
```ts
|
|
354
280
|
interface TimebackSession {
|
|
355
281
|
readonly studentId: string
|
|
356
282
|
readonly accessToken: string
|
|
357
283
|
readonly expiresInSeconds: number
|
|
358
284
|
}
|
|
359
|
-
```
|
|
360
285
|
|
|
361
|
-
### `PlacementHints`
|
|
362
|
-
|
|
363
|
-
```ts
|
|
364
286
|
interface PlacementHints {
|
|
365
287
|
readonly gradeLevel?: GradeLevel
|
|
366
288
|
}
|
|
367
|
-
```
|
|
368
289
|
|
|
369
|
-
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.
|
|
370
|
-
|
|
371
|
-
### `PlacementHintsResult`
|
|
372
|
-
|
|
373
|
-
```ts
|
|
374
290
|
interface PlacementHintsResult {
|
|
375
291
|
readonly studentId: string
|
|
376
292
|
readonly gradeLevel: GradeLevel | null
|
|
377
293
|
}
|
|
378
294
|
```
|
|
379
295
|
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
## Error sentinels (`/server`)
|
|
383
|
-
|
|
384
|
-
All `/server` methods throw sentinel-wrapped `Error`s. Use `errors.try()` and `errors.is()` from `@superbuilders/errors`.
|
|
385
|
-
|
|
386
|
-
| Sentinel | Raised when |
|
|
387
|
-
|---|---|
|
|
388
|
-
| `ErrInvalidSecretKey` | HTTP 401 — missing, malformed, or unknown `sk_` |
|
|
389
|
-
| `ErrStudentNotFound` | HTTP 404 — native `studentId` unknown on this frontend, or Timeback `sourcedId` unknown upstream |
|
|
390
|
-
| `ErrUnsupportedGrade` | HTTP 400 — Timeback returned a grade outside Primer's supported range |
|
|
391
|
-
| `ErrTimebackUnavailable` | HTTP 502 — Timeback OneRoster endpoint failed during live exchange |
|
|
392
|
-
| `ErrExternalAuthorityRequired` | HTTP 409 — attempted native/manual exchange for a Timeback-linked student |
|
|
393
|
-
| `ErrConflict` | HTTP 409 — frontend is not provisioned for routing/content |
|
|
394
|
-
| `ErrBadRequest` | HTTP 400 — validation failure |
|
|
395
|
-
| `ErrServerError` | HTTP 5xx |
|
|
396
|
-
| `ErrJsonParse` | Success response body was not valid JSON or had the wrong shape |
|
|
397
|
-
| `ErrNetwork` | fetch() rejected (DNS, connection, TLS, etc.) |
|
|
398
|
-
| `ErrTimeout` | fetch() aborted (your `AbortController` or `TimeoutError`) |
|
|
399
|
-
|
|
400
|
-
### Recommended error-handling pattern
|
|
401
|
-
|
|
402
|
-
```ts
|
|
403
|
-
import * as errors from "@superbuilders/errors"
|
|
404
|
-
import {
|
|
405
|
-
createPrimerServer,
|
|
406
|
-
ErrExternalAuthorityRequired,
|
|
407
|
-
ErrInvalidSecretKey,
|
|
408
|
-
ErrStudentNotFound
|
|
409
|
-
} from "@superbuilders/primer-tives/server"
|
|
410
|
-
|
|
411
|
-
const primer = createPrimerServer({
|
|
412
|
-
origin: "https://sb-primer.vercel.app",
|
|
413
|
-
secretKey: process.env.PRIMER_DEMO_SECRET_KEY!,
|
|
414
|
-
logger: console
|
|
415
|
-
})
|
|
416
|
-
|
|
417
|
-
const result = await errors.try(
|
|
418
|
-
primer.exchangeStudentForAccessToken(studentId)
|
|
419
|
-
)
|
|
420
|
-
if (result.error) {
|
|
421
|
-
if (errors.is(result.error, ErrInvalidSecretKey)) {
|
|
422
|
-
// rotate or fix your sk_
|
|
423
|
-
}
|
|
424
|
-
if (errors.is(result.error, ErrStudentNotFound)) {
|
|
425
|
-
// your stored studentId is stale or wrong for this frontend
|
|
426
|
-
}
|
|
427
|
-
if (errors.is(result.error, ErrExternalAuthorityRequired)) {
|
|
428
|
-
// this student must authenticate through live Timeback exchange
|
|
429
|
-
}
|
|
430
|
-
throw result.error
|
|
431
|
-
}
|
|
432
|
-
|
|
433
|
-
const { accessToken } = result.data
|
|
434
|
-
```
|
|
296
|
+
`gradeLevel` in `PlacementHintsResult` is `null` only on native students who have never had one set.
|
|
435
297
|
|
|
436
298
|
## Rules of the road
|
|
437
299
|
|
|
438
|
-
-
|
|
439
|
-
-
|
|
440
|
-
-
|
|
441
|
-
-
|
|
442
|
-
- **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.
|
|
443
304
|
|
|
444
305
|
---
|
|
445
306
|
|
|
446
307
|
# `/client`
|
|
447
308
|
|
|
448
309
|
```ts
|
|
449
|
-
import {
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
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
|
|
460
333
|
} from "@superbuilders/primer-tives/client"
|
|
461
334
|
```
|
|
462
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
|
+
|
|
463
338
|
## `create(config)`
|
|
464
339
|
|
|
465
340
|
```ts
|
|
466
341
|
function create<const Pcis extends PciId>(config: Config<Pcis>): Client<Pcis>
|
|
467
342
|
|
|
468
343
|
interface Config<Pcis extends PciId = PciId> {
|
|
469
|
-
readonly accessToken: string
|
|
470
|
-
readonly supportedPcis: readonly Pcis[]
|
|
471
|
-
readonly origin: string
|
|
472
|
-
readonly subject: SubjectScope
|
|
344
|
+
readonly accessToken: string
|
|
345
|
+
readonly supportedPcis: readonly Pcis[]
|
|
346
|
+
readonly origin: string
|
|
347
|
+
readonly subject: SubjectScope
|
|
473
348
|
readonly fetch?: typeof globalThis.fetch
|
|
474
349
|
readonly abort?: AbortController
|
|
475
350
|
readonly logger?: PrimerLogger
|
|
@@ -480,46 +355,23 @@ interface Client<Pcis extends PciId = PciId> {
|
|
|
480
355
|
}
|
|
481
356
|
```
|
|
482
357
|
|
|
483
|
-
`create()` does a
|
|
484
|
-
|
|
485
|
-
### `subject` scope
|
|
486
|
-
|
|
487
|
-
| Value | Behavior |
|
|
488
|
-
|---|---|
|
|
489
|
-
| `"math"` / `"vocabulary"` / `"science"` | Restrict drill selection to courses of that subject |
|
|
490
|
-
| `"all"` | No filter; drills from any subject the frontend is bound to |
|
|
491
|
-
|
|
492
|
-
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.
|
|
493
359
|
|
|
494
360
|
## `PrimerState` — the state machine
|
|
495
361
|
|
|
496
|
-
`start()` returns a `PrimerState` union. Each phase has its own shape and action methods.
|
|
497
|
-
|
|
498
362
|
```ts
|
|
499
363
|
type PrimerState =
|
|
500
|
-
| ObservationState
|
|
501
|
-
| InteractionState
|
|
502
|
-
| FeedbackState
|
|
503
|
-
| CompletedState
|
|
504
|
-
| ErroredState
|
|
505
|
-
| FatalState
|
|
506
|
-
```
|
|
507
|
-
|
|
508
|
-
### `observation`
|
|
509
|
-
|
|
510
|
-
Server wants you to show a stimulus and the student to indicate they're done reading.
|
|
511
|
-
|
|
512
|
-
```ts
|
|
513
|
-
interface ObservationState {
|
|
514
|
-
readonly phase: "observation"
|
|
515
|
-
readonly stimulus: RendererStimulus | null
|
|
516
|
-
advance(): Promise<PrimerState>
|
|
517
|
-
}
|
|
364
|
+
| ObservationState // .advance()
|
|
365
|
+
| InteractionState // .submit…() / .timeout()
|
|
366
|
+
| FeedbackState // .advance()
|
|
367
|
+
| CompletedState // terminal
|
|
368
|
+
| ErroredState // .retry() — .retriable: boolean
|
|
369
|
+
| FatalState // terminal
|
|
518
370
|
```
|
|
519
371
|
|
|
520
|
-
###
|
|
372
|
+
### Interaction action methods
|
|
521
373
|
|
|
522
|
-
|
|
374
|
+
`InteractionState` is a discriminated union over `kind`:
|
|
523
375
|
|
|
524
376
|
| `kind` | method |
|
|
525
377
|
|---|---|
|
|
@@ -531,31 +383,31 @@ Server wants an answer. `InteractionState` is a discriminated union over `kind`:
|
|
|
531
383
|
| `match` | `submitMatch(pairs: MatchPair[])` |
|
|
532
384
|
| `portable-custom` | `submit(value: PciValue<K>)` |
|
|
533
385
|
|
|
534
|
-
|
|
386
|
+
Every `InteractionState` shape also exposes `timeout(): Promise<PrimerState>`.
|
|
535
387
|
|
|
536
|
-
|
|
388
|
+
`MatchPair`, `PciValue<K>` come from `/contracts`.
|
|
537
389
|
|
|
538
|
-
|
|
390
|
+
### `FeedbackState`
|
|
539
391
|
|
|
540
392
|
```ts
|
|
541
|
-
interface FeedbackState {
|
|
393
|
+
interface FeedbackState extends NonSerializable {
|
|
542
394
|
readonly phase: "feedback"
|
|
543
395
|
readonly stimulus: RendererStimulus | null
|
|
544
396
|
readonly interaction: RendererInteraction
|
|
545
397
|
readonly submission: RendererSubmission
|
|
546
398
|
readonly isCorrect: boolean
|
|
547
|
-
readonly feedbackContent: ContentInline[]
|
|
548
|
-
readonly review: InteractionReview | null
|
|
399
|
+
readonly feedbackContent: ContentInline[]
|
|
400
|
+
readonly review: InteractionReview | null
|
|
549
401
|
advance(): Promise<PrimerState>
|
|
550
402
|
}
|
|
551
403
|
```
|
|
552
404
|
|
|
553
|
-
|
|
405
|
+
`RendererStimulus`, `RendererInteraction`, `RendererSubmission`, `ContentInline`, `InteractionReview` all come from `/contracts`.
|
|
554
406
|
|
|
555
|
-
|
|
407
|
+
### `ErroredState`
|
|
556
408
|
|
|
557
409
|
```ts
|
|
558
|
-
interface ErroredState {
|
|
410
|
+
interface ErroredState extends NonSerializable {
|
|
559
411
|
readonly phase: "errored"
|
|
560
412
|
readonly error: Error // sentinel-wrapped
|
|
561
413
|
readonly retriable: boolean
|
|
@@ -563,109 +415,237 @@ interface ErroredState {
|
|
|
563
415
|
}
|
|
564
416
|
```
|
|
565
417
|
|
|
566
|
-
|
|
418
|
+
`errored.retriable === false` for client-validation errors (e.g. wrapped `ErrInvalidSubmission`) and for non-recoverable transports. Those must be fixed, not retried.
|
|
567
419
|
|
|
568
|
-
|
|
420
|
+
### `FatalState`
|
|
421
|
+
|
|
422
|
+
Unrecoverable failure (bad request, invalid token, expired token, unsupported PCI, forbidden):
|
|
569
423
|
|
|
570
424
|
```ts
|
|
571
|
-
interface FatalState {
|
|
425
|
+
interface FatalState extends NonSerializable {
|
|
572
426
|
readonly phase: "fatal"
|
|
573
427
|
readonly error: Error
|
|
574
428
|
readonly retriable: false
|
|
575
429
|
}
|
|
576
430
|
```
|
|
577
431
|
|
|
578
|
-
|
|
432
|
+
## PCI render props
|
|
579
433
|
|
|
580
|
-
|
|
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`.
|
|
581
435
|
|
|
582
436
|
```ts
|
|
583
|
-
|
|
584
|
-
|
|
437
|
+
type PciPendingRenderProps<K extends PciId> = {
|
|
438
|
+
mode: "pending"
|
|
439
|
+
properties: PciProps<K>
|
|
440
|
+
onValueChange: (value: PciValue<K> | null) => void
|
|
585
441
|
}
|
|
586
|
-
```
|
|
587
442
|
|
|
588
|
-
|
|
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
|
+
}
|
|
589
449
|
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
| { type: "text"; value: string }
|
|
593
|
-
| { type: "italic"; value: string }
|
|
450
|
+
type PciRenderProps<K extends PciId> = PciPendingRenderProps<K> | PciSubmittedRenderProps<K>
|
|
451
|
+
```
|
|
594
452
|
|
|
595
|
-
|
|
596
|
-
| ContentSpan
|
|
597
|
-
| { type: "latex"; value: string }
|
|
453
|
+
## Behavioral notes
|
|
598
454
|
|
|
599
|
-
|
|
600
|
-
|
|
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`.
|
|
601
459
|
|
|
602
|
-
|
|
460
|
+
---
|
|
603
461
|
|
|
604
|
-
|
|
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.
|
|
605
465
|
|
|
606
466
|
```ts
|
|
607
|
-
|
|
608
|
-
|
|
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"
|
|
482
|
+
|
|
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"
|
|
609
528
|
```
|
|
610
529
|
|
|
611
|
-
##
|
|
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.
|
|
612
533
|
|
|
613
534
|
```ts
|
|
614
|
-
|
|
535
|
+
import * as errors from "@superbuilders/errors"
|
|
536
|
+
import * as logger from "@superbuilders/slog"
|
|
537
|
+
import { RendererSubmissionSchema } from "@superbuilders/primer-tives/contracts"
|
|
615
538
|
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
}
|
|
621
|
-
"urn:primer:pci:fraction-addition": {
|
|
622
|
-
props: FractionAdditionProps
|
|
623
|
-
value: FractionAdditionSubmission
|
|
624
|
-
}
|
|
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")
|
|
625
543
|
}
|
|
626
|
-
|
|
627
|
-
type PciId = keyof PciRegistry
|
|
628
|
-
type PciProps<K extends PciId> = PciRegistry[K]["props"]
|
|
629
|
-
type PciValue<K extends PciId> = PciRegistry[K]["value"]
|
|
544
|
+
const submission = result.data
|
|
630
545
|
```
|
|
631
546
|
|
|
632
|
-
|
|
547
|
+
## `validateSubmissionForInteraction(interaction, submission)`
|
|
633
548
|
|
|
634
|
-
|
|
549
|
+
Optional semantic validator. Checks a submission against the interaction it claims to answer (cardinality bounds, identifier membership, duplicate rules, PCI payload shape).
|
|
635
550
|
|
|
636
551
|
```ts
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
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[] }
|
|
560
|
+
```
|
|
642
561
|
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
562
|
+
`submissionValidationMessage(failure)` joins `failure.issues` with `"; "` for a single string.
|
|
563
|
+
|
|
564
|
+
```ts
|
|
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"
|
|
572
|
+
|
|
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))
|
|
648
577
|
}
|
|
578
|
+
```
|
|
649
579
|
|
|
650
|
-
|
|
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.
|
|
581
|
+
|
|
582
|
+
---
|
|
583
|
+
|
|
584
|
+
# `/errors`
|
|
585
|
+
|
|
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.
|
|
587
|
+
|
|
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"
|
|
651
617
|
```
|
|
652
618
|
|
|
653
|
-
##
|
|
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 |
|
|
654
634
|
|
|
655
|
-
|
|
635
|
+
## Client `errored.error`
|
|
656
636
|
|
|
657
637
|
| Sentinel | Raised when |
|
|
658
638
|
|---|---|
|
|
659
|
-
| `ErrNetwork` | fetch() rejected |
|
|
660
|
-
| `ErrTimeout` | fetch() aborted |
|
|
639
|
+
| `ErrNetwork` | `fetch()` rejected |
|
|
640
|
+
| `ErrTimeout` | `fetch()` aborted |
|
|
661
641
|
| `ErrServerError` | HTTP 5xx |
|
|
662
642
|
| `ErrServiceUnavailable` | HTTP 502/503/504 |
|
|
663
643
|
| `ErrRateLimited` | HTTP 429 |
|
|
664
644
|
| `ErrConflict` | HTTP 409 |
|
|
665
|
-
| `ErrJsonParse` |
|
|
645
|
+
| `ErrJsonParse` | success body wasn't valid JSON |
|
|
666
646
|
| `ErrInvalidSubmission` | client-side validation rejected the submission |
|
|
667
647
|
|
|
668
|
-
|
|
648
|
+
## Client `fatal.error`
|
|
669
649
|
|
|
670
650
|
| Sentinel | Raised when |
|
|
671
651
|
|---|---|
|
|
@@ -676,16 +656,56 @@ type PciRenderProps<K extends PciId> = PciPendingRenderProps<K> | PciSubmittedRe
|
|
|
676
656
|
| `ErrNotFound` | HTTP 404 |
|
|
677
657
|
| `ErrUnsupportedPci` | HTTP 422 or a frame asks for a PCI not in `supportedPcis` |
|
|
678
658
|
|
|
679
|
-
|
|
659
|
+
## Thrown directly by `create()`
|
|
680
660
|
|
|
681
661
|
| Sentinel | Raised when |
|
|
682
662
|
|---|---|
|
|
683
663
|
| `ErrMalformedAccessToken` | token doesn't start with `eyJ` or lacks two dots |
|
|
684
664
|
| `ErrNotSerializable` | you called `JSON.stringify()` on a live `PrimerState` (don't) |
|
|
685
665
|
|
|
686
|
-
##
|
|
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(...)`.
|
|
687
669
|
|
|
688
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`
|
|
705
|
+
|
|
706
|
+
```ts
|
|
707
|
+
import type { PrimerLogger } from "@superbuilders/primer-tives/logger"
|
|
708
|
+
|
|
689
709
|
interface PrimerLogger {
|
|
690
710
|
debug(message: string, attributes?: Record<string, unknown>): void
|
|
691
711
|
info(message: string, attributes?: Record<string, unknown>): void
|
|
@@ -694,12 +714,40 @@ interface PrimerLogger {
|
|
|
694
714
|
}
|
|
695
715
|
```
|
|
696
716
|
|
|
697
|
-
|
|
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`.
|
|
698
718
|
|
|
699
|
-
|
|
719
|
+
---
|
|
720
|
+
|
|
721
|
+
# `/grade-level`
|
|
722
|
+
|
|
723
|
+
```ts
|
|
724
|
+
import { GRADE_LEVELS } from "@superbuilders/primer-tives/grade-level"
|
|
725
|
+
import type { GradeLevel } from "@superbuilders/primer-tives/grade-level"
|
|
726
|
+
|
|
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]
|
|
729
|
+
```
|
|
700
730
|
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
731
|
+
Used as the `gradeLevel` field on `PlacementHints` / `PlacementHintsResult`.
|
|
732
|
+
|
|
733
|
+
---
|
|
734
|
+
|
|
735
|
+
# `/subject`
|
|
736
|
+
|
|
737
|
+
```ts
|
|
738
|
+
import { SUBJECTS } from "@superbuilders/primer-tives/subject"
|
|
739
|
+
import type { Subject, SubjectScope } from "@superbuilders/primer-tives/subject"
|
|
740
|
+
|
|
741
|
+
const SUBJECTS = ["math", "vocabulary", "science"] as const
|
|
742
|
+
type Subject = (typeof SUBJECTS)[number]
|
|
743
|
+
type SubjectScope = Subject | "all"
|
|
744
|
+
```
|
|
745
|
+
|
|
746
|
+
Used as the `subject` field on the client `Config`.
|
|
747
|
+
|
|
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`.
|