@superbuilders/primer-tives 2.0.0 → 2.2.1
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 +742 -540
- package/dist/client/create.d.ts +22 -6
- package/dist/client/create.d.ts.map +1 -1
- package/dist/client/index.js +116 -54
- package/dist/client/index.js.map +14 -11
- package/dist/client/session.d.ts +1 -1
- package/dist/client/session.d.ts.map +1 -1
- package/dist/client/transport.d.ts +4 -5
- package/dist/client/transport.d.ts.map +1 -1
- package/dist/contracts/index.d.ts +3 -2
- package/dist/contracts/index.d.ts.map +1 -1
- package/dist/contracts/index.js +42 -36
- package/dist/contracts/index.js.map +6 -5
- package/dist/contracts/pci-schemas.d.ts +24 -20
- package/dist/contracts/pci-schemas.d.ts.map +1 -1
- package/dist/contracts/pci.d.ts +26 -27
- package/dist/contracts/pci.d.ts.map +1 -1
- package/dist/contracts/validation.d.ts +34 -23
- package/dist/contracts/validation.d.ts.map +1 -1
- package/dist/errors.d.ts +2 -6
- package/dist/errors.d.ts.map +1 -1
- package/dist/errors.js +3 -11
- package/dist/errors.js.map +3 -3
- package/dist/server/create-server.d.ts +4 -33
- package/dist/server/create-server.d.ts.map +1 -1
- package/dist/server/exchange.d.ts +8 -14
- package/dist/server/exchange.d.ts.map +1 -1
- package/dist/server/index.d.ts +1 -3
- package/dist/server/index.d.ts.map +1 -1
- package/dist/server/index.js +70 -415
- package/dist/server/index.js.map +6 -9
- package/dist/subject-pcis.d.ts +13 -0
- package/dist/subject-pcis.d.ts.map +1 -0
- package/dist/version.d.ts +1 -1
- package/package.json +5 -1
- package/dist/server/hints.d.ts +0 -25
- package/dist/server/hints.d.ts.map +0 -1
- package/dist/server/students.d.ts +0 -12
- package/dist/server/students.d.ts.map +0 -1
package/README.md
CHANGED
|
@@ -1,714 +1,882 @@
|
|
|
1
1
|
# @superbuilders/primer-tives
|
|
2
2
|
|
|
3
|
-
TypeScript SDK for the Primer adaptive learning
|
|
3
|
+
TypeScript SDK primitives for the Primer adaptive learning runtime.
|
|
4
|
+
|
|
5
|
+
The package gives you two runtime surfaces:
|
|
6
|
+
|
|
7
|
+
- A **server SDK** for trusted backend code. It exchanges an already-verified learner email for a short-lived Primer browser token.
|
|
8
|
+
- A **browser SDK** for interactive UI code. It drives Primer's `/advance` state machine and returns typed states for observations, interactions, feedback, completion, retriable errors, and fatal errors.
|
|
9
|
+
|
|
10
|
+
Primer intentionally does not expose a public student-management lifecycle through this SDK. Your app proves identity; Primer resolves the internal student and routing state behind the token.
|
|
4
11
|
|
|
5
12
|
```sh
|
|
6
13
|
bun add @superbuilders/primer-tives
|
|
7
14
|
```
|
|
8
15
|
|
|
9
|
-
|
|
16
|
+
The SDK uses sentinel errors from `@superbuilders/errors`. Use `errors.try()` to capture failures and `errors.is()` to classify them.
|
|
10
17
|
|
|
11
|
-
##
|
|
18
|
+
## Package Version
|
|
12
19
|
|
|
13
|
-
|
|
20
|
+
The current SDK version is `2.2.1`.
|
|
14
21
|
|
|
15
|
-
|
|
16
|
-
|---|---|
|
|
17
|
-
| `@superbuilders/primer-tives/server` | `createPrimerServer` and the four `PrimerServer` methods, the config and return types |
|
|
18
|
-
| `@superbuilders/primer-tives/client` | `create` (browser SDK), the `PrimerState` discriminated union, every `*State` interface, the PCI render props |
|
|
19
|
-
| `@superbuilders/primer-tives/contracts` | wire-shape types (`RendererInteraction`, `RendererSubmission`, `ContentInline`, PCI base types, review types), Zod schemas, the optional submission validator |
|
|
20
|
-
| `@superbuilders/primer-tives/errors` | every error sentinel (`Err…`) |
|
|
21
|
-
| `@superbuilders/primer-tives/logger` | the `PrimerLogger` interface, accepted by both server and client config |
|
|
22
|
-
| `@superbuilders/primer-tives/grade-level` | `GradeLevel` type and the `GRADE_LEVELS` constant |
|
|
23
|
-
| `@superbuilders/primer-tives/subject` | `Subject`, `SubjectScope`, the `SUBJECTS` constant |
|
|
22
|
+
The browser SDK sends `SDK_VERSION` as `X-Primer-SDK-Version` on `/api/v0/advance`. If the server requires a newer SDK, the browser state becomes `fatal` with `ErrSdkUpgradeRequired`.
|
|
24
23
|
|
|
25
|
-
##
|
|
24
|
+
## Entrypoints
|
|
26
25
|
|
|
27
|
-
|
|
26
|
+
There is no package-root export. Pick the subpath for the runtime boundary you are on.
|
|
28
27
|
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
28
|
+
| Subpath | Runtime | Owns |
|
|
29
|
+
| --- | --- | --- |
|
|
30
|
+
| `@superbuilders/primer-tives/server` | Backend only | `createPrimerServer`, `PrimerServer`, `PrimerServerConfig`, `GetTokenInput` |
|
|
31
|
+
| `@superbuilders/primer-tives/client` | Browser | `create`, `Client`, `Config`, `PrimerState`, every state interface, PCI render props |
|
|
32
|
+
| `@superbuilders/primer-tives/contracts` | Shared | Content, stimulus, interaction, submission, review, PCI types, Zod schemas, validation helpers |
|
|
33
|
+
| `@superbuilders/primer-tives/errors` | Shared | Every SDK error sentinel |
|
|
34
|
+
| `@superbuilders/primer-tives/logger` | Shared | `PrimerLogger` interface |
|
|
35
|
+
| `@superbuilders/primer-tives/subject` | Shared | `Subject`, `SubjectScope`, `SUBJECTS` |
|
|
36
|
+
| `@superbuilders/primer-tives/subject-pcis` | Shared | Required PCI helpers for subject scopes |
|
|
37
|
+
| `@superbuilders/primer-tives/grade-level` | Shared | `GradeLevel`, `GRADE_LEVELS` |
|
|
39
38
|
|
|
40
|
-
|
|
41
|
-
origin: "https://sb-primer.vercel.app",
|
|
42
|
-
secretKey: process.env.PRIMER_DEMO_SECRET_KEY ?? "",
|
|
43
|
-
logger
|
|
44
|
-
})
|
|
39
|
+
## Architecture
|
|
45
40
|
|
|
46
|
-
|
|
47
|
-
const createResult = await errors.try(primer.createStudent())
|
|
48
|
-
if (createResult.error) {
|
|
49
|
-
if (errors.is(createResult.error, ErrInvalidSecretKey)) {
|
|
50
|
-
logger.error("primer secret key invalid", { error: createResult.error })
|
|
51
|
-
throw errors.wrap(createResult.error, "primer client init")
|
|
52
|
-
}
|
|
53
|
-
if (errors.is(createResult.error, ErrConflict)) {
|
|
54
|
-
logger.error("primer frontend not provisioned", { error: createResult.error })
|
|
55
|
-
throw errors.wrap(createResult.error, "primer frontend provisioning")
|
|
56
|
-
}
|
|
57
|
-
logger.error("primer create student failed", { error: createResult.error })
|
|
58
|
-
throw errors.wrap(createResult.error, "primer create student")
|
|
59
|
-
}
|
|
60
|
-
const studentId = createResult.data
|
|
61
|
-
// persist `studentId` alongside your own user record.
|
|
62
|
-
|
|
63
|
-
// Required before the first session for native students.
|
|
64
|
-
const hintsResult = await errors.try(primer.setStudentHints(studentId, { gradeLevel: "3" }))
|
|
65
|
-
if (hintsResult.error) {
|
|
66
|
-
if (errors.is(hintsResult.error, ErrStudentNotFound)) {
|
|
67
|
-
logger.error("primer student id not found on this frontend", {
|
|
68
|
-
studentId,
|
|
69
|
-
error: hintsResult.error
|
|
70
|
-
})
|
|
71
|
-
throw errors.wrap(hintsResult.error, "primer set hints")
|
|
72
|
-
}
|
|
73
|
-
if (errors.is(hintsResult.error, ErrBadRequest)) {
|
|
74
|
-
logger.error("primer hint payload rejected", { error: hintsResult.error })
|
|
75
|
-
throw errors.wrap(hintsResult.error, "primer set hints")
|
|
76
|
-
}
|
|
77
|
-
logger.error("primer set hints failed", { error: hintsResult.error })
|
|
78
|
-
throw errors.wrap(hintsResult.error, "primer set hints")
|
|
79
|
-
}
|
|
41
|
+
Primer's runtime has three identities in play:
|
|
80
42
|
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
throw errors.wrap(tokenResult.error, "primer token exchange")
|
|
87
|
-
}
|
|
88
|
-
if (errors.is(tokenResult.error, ErrStudentNotFound)) {
|
|
89
|
-
logger.error("primer student id stale", { studentId, error: tokenResult.error })
|
|
90
|
-
throw errors.wrap(tokenResult.error, "primer token exchange")
|
|
91
|
-
}
|
|
92
|
-
logger.error("primer token exchange failed", { error: tokenResult.error })
|
|
93
|
-
throw errors.wrap(tokenResult.error, "primer token exchange")
|
|
94
|
-
}
|
|
43
|
+
| Identity | Owner | Where it lives |
|
|
44
|
+
| --- | --- | --- |
|
|
45
|
+
| Host user | Your app | Your auth/user tables |
|
|
46
|
+
| Verified email | Your backend proves it; Primer hashes it | Sent only from trusted backend to Primer |
|
|
47
|
+
| Primer student | Primer | Internal DB, scoped to a Primer frontend |
|
|
95
48
|
|
|
96
|
-
|
|
97
|
-
|
|
49
|
+
The SDK only asks your backend for `verifiedEmail`. That name is deliberate. It means your backend has already verified email ownership through your own auth system. Primer does not send a verification email in this flow.
|
|
50
|
+
|
|
51
|
+
The backend token flow is:
|
|
52
|
+
|
|
53
|
+
1. Your app authenticates a user.
|
|
54
|
+
2. Your app verifies the user's email.
|
|
55
|
+
3. Your backend calls `primer.getToken({ verifiedEmail: user.email })` with a Primer `sk_...` secret key.
|
|
56
|
+
4. Primer normalizes and hashes the email server-side.
|
|
57
|
+
5. Primer resolves or creates the internal frontend-scoped student.
|
|
58
|
+
6. Primer returns a short-lived browser access token.
|
|
59
|
+
7. Your browser UI passes that token to `create({ accessToken, ... })`.
|
|
60
|
+
|
|
61
|
+
Primer stores a secret-keyed SHA-512/256 digest of normalized email, not raw email. The browser never sees your Primer secret key and never sends the email to `/advance`.
|
|
98
62
|
|
|
99
|
-
|
|
63
|
+
## End-To-End Backend Example
|
|
100
64
|
|
|
101
65
|
```ts
|
|
102
66
|
import * as errors from "@superbuilders/errors"
|
|
103
67
|
import * as logger from "@superbuilders/slog"
|
|
104
68
|
import { createPrimerServer } from "@superbuilders/primer-tives/server"
|
|
105
|
-
import {
|
|
106
|
-
ErrConflict,
|
|
107
|
-
ErrInvalidSecretKey,
|
|
108
|
-
ErrStudentNotFound,
|
|
109
|
-
ErrTimebackUnavailable,
|
|
110
|
-
ErrUnsupportedGrade
|
|
111
|
-
} from "@superbuilders/primer-tives/errors"
|
|
69
|
+
import { ErrBadRequest, ErrInvalidSecretKey, ErrNetwork, ErrTimeout } from "@superbuilders/primer-tives/errors"
|
|
112
70
|
|
|
113
71
|
const primer = createPrimerServer({
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
72
|
+
origin: "https://primerlearn.dev",
|
|
73
|
+
secretKey: process.env.PRIMER_SECRET_KEY,
|
|
74
|
+
logger
|
|
117
75
|
})
|
|
118
76
|
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
)
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
throw errors.wrap(sessionResult.error, "primer timeback exchange")
|
|
145
|
-
}
|
|
146
|
-
logger.error("primer timeback exchange failed", { error: sessionResult.error })
|
|
147
|
-
throw errors.wrap(sessionResult.error, "primer timeback exchange")
|
|
77
|
+
async function getPrimerAccessToken(user: { email: string; emailVerified: boolean }): Promise<string> {
|
|
78
|
+
if (!user.emailVerified) {
|
|
79
|
+
logger.error("user email not verified")
|
|
80
|
+
throw errors.new("email not verified")
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const result = await errors.try(primer.getToken({ verifiedEmail: user.email }))
|
|
84
|
+
if (result.error) {
|
|
85
|
+
if (errors.is(result.error, ErrInvalidSecretKey)) {
|
|
86
|
+
logger.error("primer secret key invalid", { error: result.error })
|
|
87
|
+
throw errors.wrap(result.error, "primer token")
|
|
88
|
+
}
|
|
89
|
+
if (errors.is(result.error, ErrBadRequest)) {
|
|
90
|
+
logger.error("primer token request rejected", { error: result.error })
|
|
91
|
+
throw errors.wrap(result.error, "primer token")
|
|
92
|
+
}
|
|
93
|
+
if (errors.is(result.error, ErrNetwork) || errors.is(result.error, ErrTimeout)) {
|
|
94
|
+
logger.error("primer token transport failed", { error: result.error })
|
|
95
|
+
throw errors.wrap(result.error, "primer token")
|
|
96
|
+
}
|
|
97
|
+
logger.error("primer token failed", { error: result.error })
|
|
98
|
+
throw errors.wrap(result.error, "primer token")
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
return result.data
|
|
148
102
|
}
|
|
149
|
-
|
|
150
|
-
const { studentId, accessToken, expiresInSeconds } = sessionResult.data
|
|
151
103
|
```
|
|
152
104
|
|
|
153
|
-
|
|
105
|
+
## End-To-End Browser Example
|
|
154
106
|
|
|
155
107
|
```ts
|
|
156
|
-
import { create } from "@superbuilders/primer-tives/client"
|
|
157
|
-
import type { PrimerState } from "@superbuilders/primer-tives/client"
|
|
158
108
|
import * as logger from "@superbuilders/slog"
|
|
109
|
+
import { create, type PrimerState } from "@superbuilders/primer-tives/client"
|
|
159
110
|
|
|
160
111
|
const client = create({
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
"urn:primer:pci:fraction-addition"
|
|
167
|
-
],
|
|
168
|
-
logger
|
|
112
|
+
origin: "https://primerlearn.dev",
|
|
113
|
+
accessToken,
|
|
114
|
+
subject: "math",
|
|
115
|
+
supportedPcis: ["urn:primer:pci:fraction-input"],
|
|
116
|
+
logger
|
|
169
117
|
})
|
|
170
118
|
|
|
171
119
|
let state: PrimerState = await client.start()
|
|
172
120
|
|
|
173
121
|
while (state.phase !== "completed" && state.phase !== "fatal") {
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
122
|
+
switch (state.phase) {
|
|
123
|
+
case "observation": {
|
|
124
|
+
renderFrame(state.body, state.stimulus)
|
|
125
|
+
state = await state.advance()
|
|
126
|
+
break
|
|
127
|
+
}
|
|
128
|
+
case "interaction": {
|
|
129
|
+
state = await renderAndSubmitInteraction(state)
|
|
130
|
+
break
|
|
131
|
+
}
|
|
132
|
+
case "feedback": {
|
|
133
|
+
renderFeedback(state.feedbackContent, state.isCorrect, state.review)
|
|
134
|
+
state = await state.advance()
|
|
135
|
+
break
|
|
136
|
+
}
|
|
137
|
+
case "errored": {
|
|
138
|
+
if (state.retriable) {
|
|
139
|
+
state = await state.retry()
|
|
140
|
+
break
|
|
141
|
+
}
|
|
142
|
+
throw state.error
|
|
143
|
+
}
|
|
144
|
+
}
|
|
193
145
|
}
|
|
194
|
-
```
|
|
195
146
|
|
|
196
|
-
|
|
147
|
+
if (state.phase === "fatal") {
|
|
148
|
+
throw state.error
|
|
149
|
+
}
|
|
150
|
+
```
|
|
197
151
|
|
|
198
152
|
# `/server`
|
|
199
153
|
|
|
154
|
+
The server subpath is backend-only. It wraps the token endpoint and hides raw HTTP response details.
|
|
155
|
+
|
|
200
156
|
```ts
|
|
201
157
|
import { createPrimerServer } from "@superbuilders/primer-tives/server"
|
|
202
|
-
import type {
|
|
203
|
-
PrimerServer,
|
|
204
|
-
PrimerServerConfig,
|
|
205
|
-
SessionToken,
|
|
206
|
-
TimebackSession,
|
|
207
|
-
PlacementHints,
|
|
208
|
-
PlacementHintsResult
|
|
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"
|
|
158
|
+
import type { GetTokenInput, PrimerServer, PrimerServerConfig } from "@superbuilders/primer-tives/server"
|
|
212
159
|
```
|
|
213
160
|
|
|
214
161
|
## `createPrimerServer(config)`
|
|
215
162
|
|
|
216
163
|
```ts
|
|
217
164
|
interface PrimerServerConfig {
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
165
|
+
readonly origin: string
|
|
166
|
+
readonly secretKey: string
|
|
167
|
+
readonly fetch?: typeof globalThis.fetch
|
|
168
|
+
readonly abort?: AbortController
|
|
169
|
+
readonly logger: PrimerLogger
|
|
223
170
|
}
|
|
224
171
|
|
|
225
172
|
function createPrimerServer(config: PrimerServerConfig): PrimerServer
|
|
226
173
|
```
|
|
227
174
|
|
|
228
|
-
|
|
175
|
+
Config fields:
|
|
176
|
+
|
|
177
|
+
| Field | Required | Description |
|
|
178
|
+
| --- | --- | --- |
|
|
179
|
+
| `origin` | Yes | Primer deployment origin, for example `https://primerlearn.dev`. |
|
|
180
|
+
| `secretKey` | Yes | Primer `sk_...` frontend secret. Keep it on the backend. |
|
|
181
|
+
| `fetch` | No | Custom fetch for tests, proxies, tracing, or platform adapters. Defaults to `globalThis.fetch`. |
|
|
182
|
+
| `abort` | No | `AbortController`; its signal is passed to every server SDK request. |
|
|
183
|
+
| `logger` | Yes | Structured logger implementing `debug`, `info`, `warn`, `error`. |
|
|
184
|
+
|
|
185
|
+
## `PrimerServer`
|
|
229
186
|
|
|
230
187
|
```ts
|
|
231
188
|
interface PrimerServer {
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
189
|
+
getToken(input: GetTokenInput): Promise<string>
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
type GetTokenInput = {
|
|
193
|
+
readonly verifiedEmail: string
|
|
236
194
|
}
|
|
237
195
|
```
|
|
238
196
|
|
|
239
|
-
|
|
197
|
+
`getToken` returns the browser access token string directly. It does not return a session object because the browser only needs the token.
|
|
240
198
|
|
|
241
|
-
|
|
199
|
+
The SDK sends this wire body:
|
|
242
200
|
|
|
243
|
-
|
|
201
|
+
```json
|
|
202
|
+
{
|
|
203
|
+
"verified_email": "student@example.com"
|
|
204
|
+
}
|
|
205
|
+
```
|
|
244
206
|
|
|
245
|
-
|
|
207
|
+
The raw API response includes token metadata and internal student identity, but the SDK deliberately returns only `access_token`. That keeps the public SDK focused on the one value the app has to pass to the browser runtime.
|
|
246
208
|
|
|
247
|
-
|
|
209
|
+
## `verifiedEmail`
|
|
248
210
|
|
|
249
|
-
|
|
211
|
+
`verifiedEmail` must be server-trusted. The SDK does not validate that the user owns this address. It only validates that Primer accepts the request shape.
|
|
212
|
+
|
|
213
|
+
Correct usage:
|
|
250
214
|
|
|
251
215
|
```ts
|
|
252
|
-
const
|
|
216
|
+
const accessToken = await primer.getToken({ verifiedEmail: authenticatedUser.email })
|
|
253
217
|
```
|
|
254
218
|
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
219
|
+
Only do this after your own auth system has verified `authenticatedUser.email`.
|
|
220
|
+
|
|
221
|
+
Incorrect usage:
|
|
222
|
+
|
|
223
|
+
```ts
|
|
224
|
+
const accessToken = await primer.getToken({ verifiedEmail: request.body.email })
|
|
225
|
+
```
|
|
259
226
|
|
|
260
|
-
|
|
227
|
+
That treats an untrusted browser payload as identity proof and defeats the security model.
|
|
261
228
|
|
|
262
|
-
|
|
229
|
+
## Server Error Sentinels
|
|
263
230
|
|
|
264
|
-
|
|
231
|
+
Import sentinels from `/errors`, not `/server`.
|
|
265
232
|
|
|
266
|
-
|
|
233
|
+
```ts
|
|
234
|
+
import { ErrInvalidSecretKey, ErrBadRequest } from "@superbuilders/primer-tives/errors"
|
|
235
|
+
```
|
|
267
236
|
|
|
268
|
-
|
|
237
|
+
`getToken` can surface:
|
|
269
238
|
|
|
270
|
-
|
|
239
|
+
| Sentinel | Raised when |
|
|
240
|
+
| --- | --- |
|
|
241
|
+
| `ErrInvalidSecretKey` | Primer returned HTTP 401. The `sk_...` key is missing, malformed, inactive, or unknown. |
|
|
242
|
+
| `ErrBadRequest` | Primer returned HTTP 400. The request body was invalid, usually a malformed email. |
|
|
243
|
+
| `ErrServerError` | Primer returned another unexpected non-2xx status. |
|
|
244
|
+
| `ErrJsonParse` | Primer returned 2xx but the response body was not valid JSON or lacked `access_token`. |
|
|
245
|
+
| `ErrNetwork` | `fetch` rejected before a response arrived. |
|
|
246
|
+
| `ErrTimeout` | Fetch was aborted. |
|
|
271
247
|
|
|
272
|
-
|
|
248
|
+
Recommended pattern:
|
|
273
249
|
|
|
274
250
|
```ts
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
251
|
+
import * as errors from "@superbuilders/errors"
|
|
252
|
+
import * as logger from "@superbuilders/slog"
|
|
253
|
+
import { ErrBadRequest, ErrInvalidSecretKey } from "@superbuilders/primer-tives/errors"
|
|
254
|
+
|
|
255
|
+
const result = await errors.try(primer.getToken({ verifiedEmail }))
|
|
256
|
+
if (result.error) {
|
|
257
|
+
if (errors.is(result.error, ErrInvalidSecretKey)) {
|
|
258
|
+
logger.error("primer secret key invalid", { error: result.error })
|
|
259
|
+
throw errors.wrap(result.error, "primer token")
|
|
260
|
+
}
|
|
261
|
+
if (errors.is(result.error, ErrBadRequest)) {
|
|
262
|
+
logger.error("primer token request rejected", { error: result.error })
|
|
263
|
+
throw errors.wrap(result.error, "primer token")
|
|
264
|
+
}
|
|
265
|
+
logger.error("primer token failed", { error: result.error })
|
|
266
|
+
throw errors.wrap(result.error, "primer token")
|
|
278
267
|
}
|
|
279
268
|
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
readonly accessToken: string
|
|
283
|
-
readonly expiresInSeconds: number
|
|
284
|
-
}
|
|
269
|
+
const accessToken = result.data
|
|
270
|
+
```
|
|
285
271
|
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
272
|
+
# `/client`
|
|
273
|
+
|
|
274
|
+
The client subpath owns the browser runtime state machine.
|
|
275
|
+
|
|
276
|
+
```ts
|
|
277
|
+
import { create } from "@superbuilders/primer-tives/client"
|
|
278
|
+
import type { Client, Config, PrimerState } from "@superbuilders/primer-tives/client"
|
|
279
|
+
```
|
|
289
280
|
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
281
|
+
## `create(config)`
|
|
282
|
+
|
|
283
|
+
```ts
|
|
284
|
+
function create<const S extends SubjectScope, const Pcis extends PciId = never>(
|
|
285
|
+
config: Config<S, Pcis>
|
|
286
|
+
): Client<PciId>
|
|
287
|
+
```
|
|
288
|
+
|
|
289
|
+
Config shape:
|
|
290
|
+
|
|
291
|
+
```ts
|
|
292
|
+
type Config<S extends SubjectScope, Pcis extends PciId = never> = {
|
|
293
|
+
readonly accessToken: string
|
|
294
|
+
readonly subject: S
|
|
295
|
+
readonly origin: string
|
|
296
|
+
readonly fetch?: typeof globalThis.fetch
|
|
297
|
+
readonly abort?: AbortController
|
|
298
|
+
readonly logger: PrimerLogger
|
|
299
|
+
} & PciConfigForSubject<S, Pcis>
|
|
300
|
+
```
|
|
301
|
+
|
|
302
|
+
Fields:
|
|
303
|
+
|
|
304
|
+
| Field | Required | Description |
|
|
305
|
+
| --- | --- | --- |
|
|
306
|
+
| `accessToken` | Yes | Token string returned by `primer.getToken`. |
|
|
307
|
+
| `subject` | Yes | `"math"`, `"vocabulary"`, `"science"`, or `"all"`. |
|
|
308
|
+
| `origin` | Yes | Primer deployment origin. |
|
|
309
|
+
| `supportedPcis` | Conditionally | Literal array of PCI URNs your renderer supports. Required when the chosen subject can emit required PCIs. |
|
|
310
|
+
| `fetch` | No | Custom fetch implementation. |
|
|
311
|
+
| `abort` | No | `AbortController`; signal passed to `/advance`. |
|
|
312
|
+
| `logger` | Yes | Structured logger. |
|
|
313
|
+
|
|
314
|
+
`create` performs local checks before any network request:
|
|
315
|
+
|
|
316
|
+
- `accessToken` must look like a JWS: starts with `eyJ` and has two dots. Otherwise it throws `ErrMalformedAccessToken`.
|
|
317
|
+
- `supportedPcis` must include all PCIs required by the selected subject. Missing support throws `ErrMissingRequiredPci` immediately and logs `renderer missing required pcis`.
|
|
318
|
+
|
|
319
|
+
## `Client`
|
|
320
|
+
|
|
321
|
+
```ts
|
|
322
|
+
interface Client<Pcis extends PciId = PciId> {
|
|
323
|
+
start(): Promise<PrimerState<Pcis>>
|
|
293
324
|
}
|
|
294
325
|
```
|
|
295
326
|
|
|
296
|
-
`
|
|
327
|
+
`start()` sends the first observation request. It is idempotent: multiple calls return the same pending promise.
|
|
297
328
|
|
|
298
|
-
##
|
|
329
|
+
## Subject Scopes
|
|
299
330
|
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
- Server-only secrets: keep `secretKey` on your backend; never ship it to the browser.
|
|
331
|
+
```ts
|
|
332
|
+
import { SUBJECTS } from "@superbuilders/primer-tives/subject"
|
|
333
|
+
import type { Subject, SubjectScope } from "@superbuilders/primer-tives/subject"
|
|
304
334
|
|
|
305
|
-
|
|
335
|
+
const SUBJECTS = ["math", "vocabulary", "science"] as const
|
|
336
|
+
type Subject = (typeof SUBJECTS)[number]
|
|
337
|
+
type SubjectScope = Subject | "all"
|
|
338
|
+
```
|
|
306
339
|
|
|
307
|
-
|
|
340
|
+
| Scope | Behavior |
|
|
341
|
+
| --- | --- |
|
|
342
|
+
| `"math"` | Runtime is scoped to math content and math-required PCIs. |
|
|
343
|
+
| `"vocabulary"` | Runtime is scoped to vocabulary content. |
|
|
344
|
+
| `"science"` | Runtime is scoped to science content. |
|
|
345
|
+
| `"all"` | Runtime can draw from all subjects available to the Primer frontend. |
|
|
346
|
+
|
|
347
|
+
To change subjects, create a new client. Placement state is server-owned and subject-scoped; the browser does not manage cursors.
|
|
348
|
+
|
|
349
|
+
## Required PCIs
|
|
350
|
+
|
|
351
|
+
Math currently requires the fraction-input PCI. Vocabulary and science currently require none.
|
|
308
352
|
|
|
309
353
|
```ts
|
|
310
|
-
import {
|
|
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
|
|
333
|
-
} from "@superbuilders/primer-tives/client"
|
|
334
|
-
```
|
|
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`.
|
|
354
|
+
import { missingPcisForSubject, requiredPcisForSubject } from "@superbuilders/primer-tives/subject-pcis"
|
|
337
355
|
|
|
338
|
-
|
|
356
|
+
requiredPcisForSubject("math")
|
|
357
|
+
// ["urn:primer:pci:fraction-input"]
|
|
358
|
+
```
|
|
359
|
+
|
|
360
|
+
For math or `all`, pass a `supportedPcis` literal array that includes `"urn:primer:pci:fraction-input"`.
|
|
339
361
|
|
|
340
362
|
```ts
|
|
341
|
-
|
|
363
|
+
const client = create({
|
|
364
|
+
origin,
|
|
365
|
+
accessToken,
|
|
366
|
+
subject: "math",
|
|
367
|
+
supportedPcis: ["urn:primer:pci:fraction-input"],
|
|
368
|
+
logger
|
|
369
|
+
})
|
|
370
|
+
```
|
|
342
371
|
|
|
343
|
-
|
|
344
|
-
readonly accessToken: string
|
|
345
|
-
readonly supportedPcis: readonly Pcis[]
|
|
346
|
-
readonly origin: string
|
|
347
|
-
readonly subject: SubjectScope
|
|
348
|
-
readonly fetch?: typeof globalThis.fetch
|
|
349
|
-
readonly abort?: AbortController
|
|
350
|
-
readonly logger: PrimerLogger
|
|
351
|
-
}
|
|
372
|
+
The array values are not sent to the server. They exist so TypeScript and client-side runtime preflight can prove that the renderer has declared support for required PCI URNs before `/advance` is called.
|
|
352
373
|
|
|
353
|
-
|
|
354
|
-
|
|
374
|
+
# `PrimerState`
|
|
375
|
+
|
|
376
|
+
`PrimerState` is a discriminated union over `phase`.
|
|
377
|
+
|
|
378
|
+
```ts
|
|
379
|
+
type PrimerState<Pcis extends PciId = PciId> =
|
|
380
|
+
| ObservationState<Pcis>
|
|
381
|
+
| InteractionState<Pcis>
|
|
382
|
+
| FeedbackState<Pcis>
|
|
383
|
+
| CompletedState
|
|
384
|
+
| ErroredState<Pcis>
|
|
385
|
+
| FatalState
|
|
386
|
+
```
|
|
387
|
+
|
|
388
|
+
Every state is intentionally non-serializable. It contains closures for advancing, submitting, retrying, and deduplicating in-flight calls. Do not store it in localStorage, persist it through JSON, or treat it as data. Keep it in memory and call `client.start()` again after reload.
|
|
389
|
+
|
|
390
|
+
## `ObservationState`
|
|
391
|
+
|
|
392
|
+
```ts
|
|
393
|
+
interface ObservationState<Pcis extends PciId = PciId> extends NonSerializable {
|
|
394
|
+
readonly phase: "observation"
|
|
395
|
+
readonly body: ContentBlock[]
|
|
396
|
+
readonly stimulus: RendererStimulus | null
|
|
397
|
+
advance(): Promise<PrimerState<Pcis>>
|
|
355
398
|
}
|
|
356
399
|
```
|
|
357
400
|
|
|
358
|
-
|
|
401
|
+
Render `body` and `stimulus`, then call `advance()` when the learner is ready to continue. Observation frames have no interaction to submit.
|
|
402
|
+
|
|
403
|
+
## `InteractionState`
|
|
359
404
|
|
|
360
|
-
|
|
405
|
+
`InteractionState` is a union over `kind`.
|
|
406
|
+
|
|
407
|
+
| `kind` | State type | Submit method |
|
|
408
|
+
| --- | --- | --- |
|
|
409
|
+
| `choice` | `ChoiceState` | `submitChoice(selectedKeys: string[])` |
|
|
410
|
+
| `text-entry` | `TextEntryState` | `submitText(value: string)` |
|
|
411
|
+
| `extended-text` single | `ExtendedTextSingleState` | `submitText(value: string)` |
|
|
412
|
+
| `extended-text` multiple | `ExtendedTextMultipleState` | `submitTexts(values: string[])` |
|
|
413
|
+
| `order` | `OrderState` | `submitOrder(orderedKeys: string[])` |
|
|
414
|
+
| `match` | `MatchState` | `submitMatch(pairs: MatchPair[])` |
|
|
415
|
+
| `portable-custom` | `PciInteractionState` | `submit(value: PciValue<K>)` |
|
|
416
|
+
|
|
417
|
+
Every interaction state includes:
|
|
418
|
+
|
|
419
|
+
- `phase: "interaction"`
|
|
420
|
+
- `body`
|
|
421
|
+
- `stimulus`
|
|
422
|
+
- `interaction`
|
|
423
|
+
- a typed submit method
|
|
424
|
+
- `timeout(): Promise<PrimerState<Pcis>>`
|
|
425
|
+
|
|
426
|
+
Interaction submissions are validated client-side before transport. Invalid payloads return an `errored` state with `ErrInvalidSubmission`; they are not sent to the server.
|
|
427
|
+
|
|
428
|
+
### Choice
|
|
361
429
|
|
|
362
430
|
```ts
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
431
|
+
interface ChoiceState<Pcis extends PciId = PciId> extends NonSerializable {
|
|
432
|
+
readonly phase: "interaction"
|
|
433
|
+
readonly kind: "choice"
|
|
434
|
+
readonly body: ContentBlock[]
|
|
435
|
+
readonly stimulus: RendererStimulus | null
|
|
436
|
+
readonly interaction: Extract<StandardRendererInteraction, { type: "choice" }>
|
|
437
|
+
readonly options: RendererChoice[]
|
|
438
|
+
readonly maxChoices: number
|
|
439
|
+
readonly minChoices: number
|
|
440
|
+
submitChoice(selectedKeys: string[]): Promise<PrimerState<Pcis>>
|
|
441
|
+
timeout(): Promise<PrimerState<Pcis>>
|
|
442
|
+
}
|
|
370
443
|
```
|
|
371
444
|
|
|
372
|
-
|
|
445
|
+
Use `minChoices` and `maxChoices` to decide whether to auto-submit on click or require a Submit button. Primer's grade onboarding prompt is currently delivered as a normal single-choice interaction.
|
|
373
446
|
|
|
374
|
-
|
|
447
|
+
### Text Entry
|
|
375
448
|
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
449
|
+
```ts
|
|
450
|
+
interface TextEntryState<Pcis extends PciId = PciId> extends NonSerializable {
|
|
451
|
+
readonly phase: "interaction"
|
|
452
|
+
readonly kind: "text-entry"
|
|
453
|
+
readonly body: ContentBlock[]
|
|
454
|
+
readonly stimulus: RendererStimulus | null
|
|
455
|
+
readonly interaction: Extract<StandardRendererInteraction, { type: "text-entry" }>
|
|
456
|
+
submitText(value: string): Promise<PrimerState<Pcis>>
|
|
457
|
+
timeout(): Promise<PrimerState<Pcis>>
|
|
458
|
+
}
|
|
459
|
+
```
|
|
385
460
|
|
|
386
|
-
|
|
461
|
+
`interaction` may include `expectedLength`, `patternMask`, and `placeholderText` for UI hints.
|
|
387
462
|
|
|
388
|
-
|
|
463
|
+
### Extended Text
|
|
389
464
|
|
|
390
|
-
|
|
465
|
+
Extended text has two cardinalities.
|
|
391
466
|
|
|
392
467
|
```ts
|
|
393
|
-
interface
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
468
|
+
interface ExtendedTextSingleState<Pcis extends PciId = PciId> extends NonSerializable {
|
|
469
|
+
readonly phase: "interaction"
|
|
470
|
+
readonly kind: "extended-text"
|
|
471
|
+
readonly cardinality: "single"
|
|
472
|
+
submitText(value: string): Promise<PrimerState<Pcis>>
|
|
473
|
+
timeout(): Promise<PrimerState<Pcis>>
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
interface ExtendedTextMultipleState<Pcis extends PciId = PciId> extends NonSerializable {
|
|
477
|
+
readonly phase: "interaction"
|
|
478
|
+
readonly kind: "extended-text"
|
|
479
|
+
readonly cardinality: "multiple"
|
|
480
|
+
readonly minStrings: number
|
|
481
|
+
readonly maxStrings: number
|
|
482
|
+
submitTexts(values: string[]): Promise<PrimerState<Pcis>>
|
|
483
|
+
timeout(): Promise<PrimerState<Pcis>>
|
|
403
484
|
}
|
|
404
485
|
```
|
|
405
486
|
|
|
406
|
-
|
|
487
|
+
Both include `body`, `stimulus`, and `interaction` fields like every other interaction state.
|
|
407
488
|
|
|
408
|
-
|
|
409
|
-
- `stimulus: RendererStimulus | null` — a discriminated union over media kinds (currently `{ kind: "image", alt, src }`).
|
|
410
|
-
- `interaction: RendererInteraction | null` — the question, when present.
|
|
489
|
+
### Order
|
|
411
490
|
|
|
412
|
-
|
|
491
|
+
```ts
|
|
492
|
+
interface OrderState<Pcis extends PciId = PciId> extends NonSerializable {
|
|
493
|
+
readonly phase: "interaction"
|
|
494
|
+
readonly kind: "order"
|
|
495
|
+
readonly choices: RendererChoice[]
|
|
496
|
+
readonly minChoices: number
|
|
497
|
+
readonly maxChoices: number
|
|
498
|
+
submitOrder(orderedKeys: string[]): Promise<PrimerState<Pcis>>
|
|
499
|
+
timeout(): Promise<PrimerState<Pcis>>
|
|
500
|
+
}
|
|
501
|
+
```
|
|
502
|
+
|
|
503
|
+
Submit identifiers in learner-selected order.
|
|
413
504
|
|
|
414
|
-
###
|
|
505
|
+
### Match
|
|
415
506
|
|
|
416
507
|
```ts
|
|
417
|
-
interface
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
508
|
+
interface MatchPair {
|
|
509
|
+
source: string
|
|
510
|
+
target: string
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
interface MatchState<Pcis extends PciId = PciId> extends NonSerializable {
|
|
514
|
+
readonly phase: "interaction"
|
|
515
|
+
readonly kind: "match"
|
|
516
|
+
readonly sourceChoices: RendererMatchChoice[]
|
|
517
|
+
readonly targetChoices: RendererMatchChoice[]
|
|
518
|
+
readonly minAssociations: number
|
|
519
|
+
readonly maxAssociations: number
|
|
520
|
+
submitMatch(pairs: MatchPair[]): Promise<PrimerState<Pcis>>
|
|
521
|
+
timeout(): Promise<PrimerState<Pcis>>
|
|
422
522
|
}
|
|
423
523
|
```
|
|
424
524
|
|
|
425
|
-
|
|
525
|
+
Each `RendererMatchChoice` has `matchMin` and `matchMax`. A `matchMax` of `0` means unbounded.
|
|
526
|
+
|
|
527
|
+
### Portable Custom Interaction
|
|
426
528
|
|
|
427
|
-
|
|
529
|
+
```ts
|
|
530
|
+
type PciInteractionState<Pcis extends PciId = PciId> = {
|
|
531
|
+
[K in Pcis]: NonSerializable & {
|
|
532
|
+
readonly phase: "interaction"
|
|
533
|
+
readonly kind: "portable-custom"
|
|
534
|
+
readonly body: ContentBlock[]
|
|
535
|
+
readonly stimulus: RendererStimulus | null
|
|
536
|
+
readonly interaction: PciInteraction<K>
|
|
537
|
+
readonly pciId: K
|
|
538
|
+
readonly properties: PciProps<K>
|
|
539
|
+
submit(value: PciValue<K>): Promise<PrimerState<Pcis>>
|
|
540
|
+
timeout(): Promise<PrimerState<Pcis>>
|
|
541
|
+
}
|
|
542
|
+
}[Pcis]
|
|
543
|
+
```
|
|
428
544
|
|
|
429
|
-
|
|
545
|
+
For `urn:primer:pci:fraction-input`, `properties` has:
|
|
430
546
|
|
|
431
547
|
```ts
|
|
432
|
-
interface
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
readonly retriable: false
|
|
548
|
+
interface FractionInputProps {
|
|
549
|
+
form: "whole" | "proper" | "improper" | "mixed"
|
|
550
|
+
requireSimplified: boolean
|
|
436
551
|
}
|
|
437
552
|
```
|
|
438
553
|
|
|
439
|
-
|
|
554
|
+
The submitted value is one of:
|
|
440
555
|
|
|
441
|
-
|
|
556
|
+
```ts
|
|
557
|
+
type FractionInputSubmission =
|
|
558
|
+
| { form: "whole"; whole: string }
|
|
559
|
+
| { form: "proper"; numerator: string; denominator: string }
|
|
560
|
+
| { form: "improper"; numerator: string; denominator: string }
|
|
561
|
+
| { form: "mixed"; whole: string; numerator: string; denominator: string }
|
|
562
|
+
```
|
|
563
|
+
|
|
564
|
+
## `FeedbackState`
|
|
442
565
|
|
|
443
566
|
```ts
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
567
|
+
interface FeedbackState<Pcis extends PciId = PciId> extends NonSerializable {
|
|
568
|
+
readonly phase: "feedback"
|
|
569
|
+
readonly body: ContentBlock[]
|
|
570
|
+
readonly stimulus: RendererStimulus | null
|
|
571
|
+
readonly interaction: RendererInteraction<Pcis>
|
|
572
|
+
readonly submission: RendererSubmission<Pcis>
|
|
573
|
+
readonly isCorrect: boolean
|
|
574
|
+
readonly feedbackContent: ContentInline[]
|
|
575
|
+
readonly review: InteractionReview<Pcis> | null
|
|
576
|
+
advance(): Promise<PrimerState<Pcis>>
|
|
448
577
|
}
|
|
578
|
+
```
|
|
579
|
+
|
|
580
|
+
Feedback state is returned after a successful interaction submission. Render the submitted frame, the submitted value, correctness, server feedback, and optional review data. Call `advance()` when the learner is ready to continue.
|
|
449
581
|
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
582
|
+
## `CompletedState`
|
|
583
|
+
|
|
584
|
+
```ts
|
|
585
|
+
interface CompletedState extends NonSerializable {
|
|
586
|
+
readonly phase: "completed"
|
|
455
587
|
}
|
|
588
|
+
```
|
|
456
589
|
|
|
457
|
-
|
|
590
|
+
Terminal state. There is no action method.
|
|
591
|
+
|
|
592
|
+
## `ErroredState`
|
|
593
|
+
|
|
594
|
+
```ts
|
|
595
|
+
interface ErroredState<Pcis extends PciId = PciId> extends NonSerializable {
|
|
596
|
+
readonly phase: "errored"
|
|
597
|
+
readonly error: Error
|
|
598
|
+
readonly retriable: boolean
|
|
599
|
+
retry(): Promise<PrimerState<Pcis>>
|
|
600
|
+
}
|
|
458
601
|
```
|
|
459
602
|
|
|
460
|
-
|
|
603
|
+
`retry()` re-runs the exact same intent that failed. If `retriable` is false, `retry()` resolves to the same errored state.
|
|
461
604
|
|
|
462
|
-
|
|
463
|
-
- A returning student resumes wherever the server last placed them. No client-side cursor management.
|
|
464
|
-
- `PrimerState` holds real closures (action methods, pending-promise caches). Don't serialize it; don't store it across reloads. Call `start()` again on boot.
|
|
465
|
-
- `Config.supportedPcis` is a `const` generic; only those URNs flow through `PciInteractionState` at the type level. Mismatch at runtime is a `fatal` with `ErrUnsupportedPci`.
|
|
605
|
+
## `FatalState`
|
|
466
606
|
|
|
467
|
-
|
|
607
|
+
```ts
|
|
608
|
+
interface FatalState extends NonSerializable {
|
|
609
|
+
readonly phase: "fatal"
|
|
610
|
+
readonly error: Error
|
|
611
|
+
readonly retriable: false
|
|
612
|
+
}
|
|
613
|
+
```
|
|
614
|
+
|
|
615
|
+
Fatal means the browser cannot recover by retrying the current intent. Examples include invalid token, expired token, forbidden request, unsupported PCI, or SDK upgrade required.
|
|
468
616
|
|
|
469
617
|
# `/contracts`
|
|
470
618
|
|
|
471
|
-
|
|
619
|
+
The contracts subpath contains renderer wire shapes and helpers shared between custom renderers and server-adjacent tooling.
|
|
472
620
|
|
|
473
621
|
```ts
|
|
474
622
|
import {
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
MatchPairSchema,
|
|
481
|
-
DivisionRemainderPciSubmissionSchema,
|
|
482
|
-
FractionAdditionPciSubmissionSchema,
|
|
483
|
-
RendererSubmissionSchema,
|
|
484
|
-
validateSubmissionForInteraction,
|
|
485
|
-
submissionValidationMessage,
|
|
486
|
-
blocksToPlainText,
|
|
487
|
-
inlinesToPlainText
|
|
623
|
+
RendererSubmissionSchema,
|
|
624
|
+
blocksToPlainText,
|
|
625
|
+
inlinesToPlainText,
|
|
626
|
+
submissionValidationMessage,
|
|
627
|
+
validateSubmissionForInteraction
|
|
488
628
|
} from "@superbuilders/primer-tives/contracts"
|
|
489
|
-
|
|
490
629
|
import type {
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
// PCI base
|
|
509
|
-
PciId,
|
|
510
|
-
PciUrn,
|
|
511
|
-
PciRegistry,
|
|
512
|
-
PciProps,
|
|
513
|
-
PciValue,
|
|
514
|
-
DivisionRemainderProps,
|
|
515
|
-
DivisionRemainderSubmission,
|
|
516
|
-
FractionAdditionProps,
|
|
517
|
-
FractionAdditionSubmission,
|
|
518
|
-
// review (server emits these, client consumes)
|
|
519
|
-
InteractionReview,
|
|
520
|
-
ChoiceReview,
|
|
521
|
-
TextEntryReview,
|
|
522
|
-
ExtendedTextReview,
|
|
523
|
-
OrderReview,
|
|
524
|
-
MatchReview,
|
|
525
|
-
PciReview,
|
|
526
|
-
ReviewRecordField,
|
|
527
|
-
ReviewRecordFieldBaseType,
|
|
528
|
-
ReviewScalarValue,
|
|
529
|
-
// validation result
|
|
530
|
-
SubmissionValidationResult,
|
|
531
|
-
SubmissionValidationSuccess,
|
|
532
|
-
SubmissionValidationFailure
|
|
630
|
+
ContentBlock,
|
|
631
|
+
ContentInline,
|
|
632
|
+
ContentSpan,
|
|
633
|
+
ImageStimulus,
|
|
634
|
+
InteractionReview,
|
|
635
|
+
MatchPair,
|
|
636
|
+
PciId,
|
|
637
|
+
PciInteraction,
|
|
638
|
+
PciProps,
|
|
639
|
+
PciSubmission,
|
|
640
|
+
PciValue,
|
|
641
|
+
RendererChoice,
|
|
642
|
+
RendererInteraction,
|
|
643
|
+
RendererMatchChoice,
|
|
644
|
+
RendererStimulus,
|
|
645
|
+
RendererSubmission,
|
|
646
|
+
StandardRendererInteraction
|
|
533
647
|
} from "@superbuilders/primer-tives/contracts"
|
|
534
648
|
```
|
|
535
649
|
|
|
536
|
-
##
|
|
650
|
+
## Content
|
|
537
651
|
|
|
538
|
-
|
|
652
|
+
```ts
|
|
653
|
+
type ContentSpan = { type: "text"; value: string } | { type: "italic"; value: string }
|
|
654
|
+
type ContentInline = ContentSpan | { type: "latex"; value: string }
|
|
655
|
+
type ContentBlock = { type: "paragraph"; children: ContentInline[] }
|
|
656
|
+
```
|
|
657
|
+
|
|
658
|
+
Helpers:
|
|
539
659
|
|
|
540
660
|
```ts
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
661
|
+
inlinesToPlainText(nodes: ContentInline[]): string
|
|
662
|
+
blocksToPlainText(blocks: ContentBlock[]): string
|
|
663
|
+
```
|
|
544
664
|
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
665
|
+
Use these helpers for accessibility labels, alt fallbacks, logging summaries, and non-rich previews.
|
|
666
|
+
|
|
667
|
+
## Stimulus
|
|
668
|
+
|
|
669
|
+
```ts
|
|
670
|
+
interface ImageStimulus {
|
|
671
|
+
kind: "image"
|
|
672
|
+
alt: ContentInline[]
|
|
673
|
+
src: string
|
|
549
674
|
}
|
|
550
|
-
|
|
675
|
+
|
|
676
|
+
type RendererStimulus = ImageStimulus
|
|
551
677
|
```
|
|
552
678
|
|
|
553
|
-
|
|
679
|
+
`RendererStimulus` is currently image-only, but it is still a discriminated union so renderers stay future-safe.
|
|
554
680
|
|
|
555
|
-
|
|
681
|
+
## Interactions
|
|
556
682
|
|
|
557
683
|
```ts
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
684
|
+
type RendererInteraction<Pcis extends PciId = PciId> =
|
|
685
|
+
| StandardRendererInteraction
|
|
686
|
+
| PciInteraction<Pcis>
|
|
687
|
+
```
|
|
688
|
+
|
|
689
|
+
Standard interactions:
|
|
690
|
+
|
|
691
|
+
| Type | Key fields |
|
|
692
|
+
| --- | --- |
|
|
693
|
+
| `choice` | `prompt`, `options`, `shuffle`, `minChoices`, `maxChoices` |
|
|
694
|
+
| `text-entry` | `prompt`, `base`, `expectedLength`, `patternMask`, `placeholderText` |
|
|
695
|
+
| `extended-text` single | `prompt`, `format`, `expectedLines`, `expectedLength`, `patternMask`, `placeholderText` |
|
|
696
|
+
| `extended-text` multiple | single fields plus `minStrings`, `maxStrings` |
|
|
697
|
+
| `order` | `prompt`, `choices`, `shuffle`, `minChoices`, `maxChoices` |
|
|
698
|
+
| `match` | `prompt`, `sourceChoices`, `targetChoices`, `shuffle`, `minAssociations`, `maxAssociations` |
|
|
699
|
+
| `portable-custom` | `prompt`, `pciId`, `properties` |
|
|
562
700
|
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
701
|
+
Choice objects:
|
|
702
|
+
|
|
703
|
+
```ts
|
|
704
|
+
interface RendererChoice {
|
|
705
|
+
identifier: string
|
|
706
|
+
content: ContentInline[]
|
|
707
|
+
}
|
|
566
708
|
```
|
|
567
709
|
|
|
568
|
-
|
|
710
|
+
Match choice objects:
|
|
569
711
|
|
|
570
712
|
```ts
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
}
|
|
577
|
-
|
|
713
|
+
interface RendererMatchChoice {
|
|
714
|
+
identifier: string
|
|
715
|
+
content: ContentInline[]
|
|
716
|
+
matchMax: number
|
|
717
|
+
matchMin: number
|
|
718
|
+
}
|
|
719
|
+
```
|
|
720
|
+
|
|
721
|
+
## Submissions
|
|
722
|
+
|
|
723
|
+
```ts
|
|
724
|
+
type RendererSubmission<Pcis extends PciId = PciId> =
|
|
725
|
+
| { type: "choice"; selectedKeys: string[] }
|
|
726
|
+
| { type: "text-entry"; value: string }
|
|
727
|
+
| { type: "extended-text"; values: string[] }
|
|
728
|
+
| { type: "order"; orderedKeys: string[] }
|
|
729
|
+
| { type: "match"; pairs: MatchPair[] }
|
|
730
|
+
| PciSubmission<Pcis>
|
|
731
|
+
```
|
|
732
|
+
|
|
733
|
+
Schemas:
|
|
734
|
+
|
|
735
|
+
```ts
|
|
736
|
+
ChoiceSubmissionSchema
|
|
737
|
+
TextEntrySubmissionSchema
|
|
738
|
+
ExtendedTextSubmissionSchema
|
|
739
|
+
OrderSubmissionSchema
|
|
740
|
+
MatchSubmissionSchema
|
|
741
|
+
FractionInputPciSubmissionSchema
|
|
742
|
+
RendererSubmissionSchema
|
|
743
|
+
```
|
|
744
|
+
|
|
745
|
+
Always use `safeParse` when parsing arbitrary input:
|
|
746
|
+
|
|
747
|
+
```ts
|
|
748
|
+
const parsed = RendererSubmissionSchema.safeParse(payload)
|
|
749
|
+
if (!parsed.success) {
|
|
750
|
+
throw parsed.error
|
|
751
|
+
}
|
|
752
|
+
```
|
|
753
|
+
|
|
754
|
+
## Semantic Validation
|
|
578
755
|
|
|
756
|
+
`RendererSubmissionSchema` validates shape. `validateSubmissionForInteraction` validates a submission against a specific interaction.
|
|
757
|
+
|
|
758
|
+
```ts
|
|
579
759
|
const validation = validateSubmissionForInteraction(interaction, submission)
|
|
580
760
|
if (!validation.ok) {
|
|
581
|
-
|
|
582
|
-
throw errors.wrap(ErrInvalidSubmission, submissionValidationMessage(validation))
|
|
761
|
+
throw errors.wrap(ErrInvalidSubmission, submissionValidationMessage(validation))
|
|
583
762
|
}
|
|
584
763
|
```
|
|
585
764
|
|
|
586
|
-
|
|
765
|
+
Checks include:
|
|
587
766
|
|
|
588
|
-
|
|
767
|
+
- submission type matches interaction type
|
|
768
|
+
- choice identifiers exist
|
|
769
|
+
- choice cardinality is within `minChoices` and `maxChoices`
|
|
770
|
+
- duplicate choice/order identifiers are rejected
|
|
771
|
+
- extended-text value count matches cardinality bounds
|
|
772
|
+
- match source/target identifiers exist
|
|
773
|
+
- match min/max usage bounds hold
|
|
774
|
+
- portable-custom `pciId` matches and payload parses against the PCI schema
|
|
589
775
|
|
|
590
|
-
|
|
776
|
+
The built-in browser state objects call this before submitting. Custom renderers that bypass state methods should call it themselves.
|
|
591
777
|
|
|
592
|
-
|
|
778
|
+
## Review Types
|
|
779
|
+
|
|
780
|
+
`FeedbackState.review` is `InteractionReview | null`.
|
|
593
781
|
|
|
594
782
|
```ts
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
ErrTimeout,
|
|
603
|
-
// /server-thrown
|
|
604
|
-
ErrInvalidSecretKey,
|
|
605
|
-
ErrStudentNotFound,
|
|
606
|
-
ErrUnsupportedGrade,
|
|
607
|
-
ErrTimebackUnavailable,
|
|
608
|
-
ErrExternalAuthorityRequired,
|
|
609
|
-
// /client surfaced as `errored`
|
|
610
|
-
ErrServiceUnavailable,
|
|
611
|
-
ErrRateLimited,
|
|
612
|
-
ErrInvalidSubmission,
|
|
613
|
-
// /client surfaced as `fatal`
|
|
614
|
-
ErrInvalidAccessToken,
|
|
615
|
-
ErrTokenExpired,
|
|
616
|
-
ErrForbidden,
|
|
617
|
-
ErrNotFound,
|
|
618
|
-
ErrNeedsHints,
|
|
619
|
-
ErrUnsupportedPci,
|
|
620
|
-
// /client thrown directly by `create()`
|
|
621
|
-
ErrMalformedAccessToken,
|
|
622
|
-
ErrNotSerializable
|
|
623
|
-
} from "@superbuilders/primer-tives/errors"
|
|
783
|
+
type InteractionReview<Pcis extends PciId = PciId> =
|
|
784
|
+
| ChoiceReview
|
|
785
|
+
| TextEntryReview
|
|
786
|
+
| ExtendedTextReview
|
|
787
|
+
| OrderReview
|
|
788
|
+
| MatchReview
|
|
789
|
+
| PciReview<Pcis>
|
|
624
790
|
```
|
|
625
791
|
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
| Sentinel | Raised when |
|
|
629
|
-
|---|---|
|
|
630
|
-
| `ErrInvalidSecretKey` | HTTP 401 — missing, malformed, or unknown `sk_` |
|
|
631
|
-
| `ErrStudentNotFound` | HTTP 404 — native `studentId` unknown on this frontend, or Timeback `sourcedId` unknown upstream |
|
|
632
|
-
| `ErrUnsupportedGrade` | HTTP 400 — Timeback returned a grade outside Primer's supported range |
|
|
633
|
-
| `ErrTimebackUnavailable` | HTTP 502 — Timeback OneRoster endpoint failed during live exchange |
|
|
634
|
-
| `ErrExternalAuthorityRequired` | HTTP 409 — attempted native/manual exchange for a Timeback-linked student |
|
|
635
|
-
| `ErrConflict` | HTTP 409 — frontend not provisioned for routing/content |
|
|
636
|
-
| `ErrBadRequest` | HTTP 400 — validation failure |
|
|
637
|
-
| `ErrServerError` | HTTP 5xx |
|
|
638
|
-
| `ErrJsonParse` | response body was not valid JSON or had the wrong shape |
|
|
639
|
-
| `ErrNetwork` | `fetch()` rejected (DNS, connection, TLS, etc.) |
|
|
640
|
-
| `ErrTimeout` | `fetch()` aborted |
|
|
641
|
-
|
|
642
|
-
## Client `errored.error`
|
|
643
|
-
|
|
644
|
-
| Sentinel | Raised when |
|
|
645
|
-
|---|---|
|
|
646
|
-
| `ErrNetwork` | `fetch()` rejected |
|
|
647
|
-
| `ErrTimeout` | `fetch()` aborted |
|
|
648
|
-
| `ErrServerError` | HTTP 5xx |
|
|
649
|
-
| `ErrServiceUnavailable` | HTTP 502/503/504 |
|
|
650
|
-
| `ErrRateLimited` | HTTP 429 |
|
|
651
|
-
| `ErrConflict` | HTTP 409 |
|
|
652
|
-
| `ErrJsonParse` | success body wasn't valid JSON |
|
|
653
|
-
| `ErrInvalidSubmission` | client-side validation rejected the submission |
|
|
654
|
-
|
|
655
|
-
## Client `fatal.error`
|
|
792
|
+
Review variants:
|
|
656
793
|
|
|
657
|
-
|
|
|
658
|
-
|
|
659
|
-
| `
|
|
660
|
-
| `
|
|
661
|
-
| `
|
|
662
|
-
| `
|
|
663
|
-
| `
|
|
664
|
-
| `
|
|
665
|
-
| `ErrSdkUpgradeRequired` | the SDK's version is older than the server's minimum-supported version; bump `@superbuilders/primer-tives` to the latest |
|
|
666
|
-
| `ErrUnsupportedPci` | HTTP 422 or a frame asks for a PCI not in `supportedPcis` |
|
|
667
|
-
|
|
668
|
-
## Thrown directly by `create()`
|
|
794
|
+
| Type | Data |
|
|
795
|
+
| --- | --- |
|
|
796
|
+
| `choice` | `correctKeys: string[]` |
|
|
797
|
+
| `text-entry` | `correctValue: ReviewScalarValue | null` |
|
|
798
|
+
| `extended-text` | `correctValues: ReviewScalarValue[]` |
|
|
799
|
+
| `order` | `correctOrder: string[]` |
|
|
800
|
+
| `match` | `correctPairs: MatchPair[]` |
|
|
801
|
+
| `portable-custom` | `pciId`, `fields: ReviewRecordField[]` |
|
|
669
802
|
|
|
670
|
-
|
|
671
|
-
|---|---|
|
|
672
|
-
| `ErrMalformedAccessToken` | token doesn't start with `eyJ` or lacks two dots |
|
|
673
|
-
| `ErrNotSerializable` | you called `JSON.stringify()` on a live `PrimerState` (don't) |
|
|
803
|
+
`review` is for display and inspection. Correctness already lives on `FeedbackState.isCorrect`.
|
|
674
804
|
|
|
675
|
-
|
|
805
|
+
# `/errors`
|
|
676
806
|
|
|
677
|
-
|
|
807
|
+
All sentinels are exported from `/errors`.
|
|
678
808
|
|
|
679
809
|
```ts
|
|
680
|
-
import * as errors from "@superbuilders/errors"
|
|
681
|
-
import * as logger from "@superbuilders/slog"
|
|
682
810
|
import {
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
811
|
+
ErrBadRequest,
|
|
812
|
+
ErrConflict,
|
|
813
|
+
ErrForbidden,
|
|
814
|
+
ErrInvalidAccessToken,
|
|
815
|
+
ErrInvalidSecretKey,
|
|
816
|
+
ErrInvalidSubmission,
|
|
817
|
+
ErrJsonParse,
|
|
818
|
+
ErrMalformedAccessToken,
|
|
819
|
+
ErrMissingRequiredPci,
|
|
820
|
+
ErrNetwork,
|
|
821
|
+
ErrNotFound,
|
|
822
|
+
ErrNotSerializable,
|
|
823
|
+
ErrRateLimited,
|
|
824
|
+
ErrSdkUpgradeRequired,
|
|
825
|
+
ErrServerError,
|
|
826
|
+
ErrServiceUnavailable,
|
|
827
|
+
ErrTimeout,
|
|
828
|
+
ErrTokenExpired,
|
|
829
|
+
ErrUnsupportedPci
|
|
686
830
|
} from "@superbuilders/primer-tives/errors"
|
|
687
|
-
|
|
688
|
-
const tokenResult = await errors.try(primer.exchangeStudentForAccessToken(studentId))
|
|
689
|
-
if (tokenResult.error) {
|
|
690
|
-
if (errors.is(tokenResult.error, ErrInvalidSecretKey)) {
|
|
691
|
-
logger.error("primer secret key invalid", { error: tokenResult.error })
|
|
692
|
-
throw errors.wrap(tokenResult.error, "primer token exchange")
|
|
693
|
-
}
|
|
694
|
-
if (errors.is(tokenResult.error, ErrStudentNotFound)) {
|
|
695
|
-
logger.error("primer student id stale", { studentId, error: tokenResult.error })
|
|
696
|
-
throw errors.wrap(tokenResult.error, "primer token exchange")
|
|
697
|
-
}
|
|
698
|
-
if (errors.is(tokenResult.error, ErrExternalAuthorityRequired)) {
|
|
699
|
-
logger.error("primer student requires timeback exchange", {
|
|
700
|
-
studentId,
|
|
701
|
-
error: tokenResult.error
|
|
702
|
-
})
|
|
703
|
-
throw errors.wrap(tokenResult.error, "primer token exchange")
|
|
704
|
-
}
|
|
705
|
-
logger.error("primer token exchange failed", { error: tokenResult.error })
|
|
706
|
-
throw errors.wrap(tokenResult.error, "primer token exchange")
|
|
707
|
-
}
|
|
708
|
-
const { accessToken } = tokenResult.data
|
|
709
831
|
```
|
|
710
832
|
|
|
711
|
-
|
|
833
|
+
## Server SDK Errors
|
|
834
|
+
|
|
835
|
+
| Sentinel | Meaning |
|
|
836
|
+
| --- | --- |
|
|
837
|
+
| `ErrInvalidSecretKey` | Secret key rejected by Primer. |
|
|
838
|
+
| `ErrBadRequest` | Token request was malformed. |
|
|
839
|
+
| `ErrServerError` | Primer returned an unexpected non-2xx status. |
|
|
840
|
+
| `ErrJsonParse` | Successful response did not parse or lacked token shape. |
|
|
841
|
+
| `ErrNetwork` | Fetch rejected before response. |
|
|
842
|
+
| `ErrTimeout` | Fetch aborted. |
|
|
843
|
+
|
|
844
|
+
## Browser Transport Errors
|
|
845
|
+
|
|
846
|
+
These become `ErroredState` unless classified as fatal:
|
|
847
|
+
|
|
848
|
+
| Sentinel | Meaning |
|
|
849
|
+
| --- | --- |
|
|
850
|
+
| `ErrNetwork` | Fetch rejected. |
|
|
851
|
+
| `ErrTimeout` | Fetch aborted. |
|
|
852
|
+
| `ErrServerError` | HTTP 5xx not otherwise mapped. |
|
|
853
|
+
| `ErrServiceUnavailable` | HTTP 502, 503, or 504. |
|
|
854
|
+
| `ErrRateLimited` | HTTP 429. |
|
|
855
|
+
| `ErrConflict` | HTTP 409. |
|
|
856
|
+
| `ErrJsonParse` | Successful response body failed to parse. |
|
|
857
|
+
| `ErrInvalidSubmission` | Local semantic validation rejected the submission. |
|
|
858
|
+
|
|
859
|
+
## Browser Fatal Errors
|
|
860
|
+
|
|
861
|
+
These become `FatalState`:
|
|
862
|
+
|
|
863
|
+
| Sentinel | Meaning |
|
|
864
|
+
| --- | --- |
|
|
865
|
+
| `ErrBadRequest` | HTTP 400. |
|
|
866
|
+
| `ErrInvalidAccessToken` | HTTP 401. |
|
|
867
|
+
| `ErrTokenExpired` | HTTP 401 with token-expired detail. |
|
|
868
|
+
| `ErrForbidden` | HTTP 403. |
|
|
869
|
+
| `ErrNotFound` | HTTP 404. |
|
|
870
|
+
| `ErrSdkUpgradeRequired` | Server requires a newer SDK. |
|
|
871
|
+
| `ErrUnsupportedPci` | Server returned a portable-custom interaction whose `pciId` is not declared in `supportedPcis`. |
|
|
872
|
+
|
|
873
|
+
## Direct Client Construction Errors
|
|
874
|
+
|
|
875
|
+
| Sentinel | Meaning |
|
|
876
|
+
| --- | --- |
|
|
877
|
+
| `ErrMalformedAccessToken` | Token string does not look like a JWS. |
|
|
878
|
+
| `ErrMissingRequiredPci` | `create` was called for a subject whose required PCI URNs are not present in `supportedPcis`. |
|
|
879
|
+
| `ErrNotSerializable` | `JSON.stringify` was called on live `PrimerState`. |
|
|
712
880
|
|
|
713
881
|
# `/logger`
|
|
714
882
|
|
|
@@ -716,16 +884,21 @@ const { accessToken } = tokenResult.data
|
|
|
716
884
|
import type { PrimerLogger } from "@superbuilders/primer-tives/logger"
|
|
717
885
|
|
|
718
886
|
interface PrimerLogger {
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
887
|
+
debug(message: string, attributes?: Record<string, unknown>): void
|
|
888
|
+
info(message: string, attributes?: Record<string, unknown>): void
|
|
889
|
+
warn(message: string, attributes?: Record<string, unknown>): void
|
|
890
|
+
error(message: string, attributes?: Record<string, unknown>): void
|
|
723
891
|
}
|
|
724
892
|
```
|
|
725
893
|
|
|
726
|
-
|
|
894
|
+
The same logger shape is used by server and browser SDKs. `@superbuilders/slog` matches it directly:
|
|
895
|
+
|
|
896
|
+
```ts
|
|
897
|
+
import * as logger from "@superbuilders/slog"
|
|
727
898
|
|
|
728
|
-
|
|
899
|
+
const primer = createPrimerServer({ origin, secretKey, logger })
|
|
900
|
+
const client = create({ origin, accessToken, subject: "vocabulary", logger })
|
|
901
|
+
```
|
|
729
902
|
|
|
730
903
|
# `/grade-level`
|
|
731
904
|
|
|
@@ -737,26 +910,55 @@ const GRADE_LEVELS = ["K", "1", "2", "3", "4", "5", "6", "7", "8", "9", "10", "1
|
|
|
737
910
|
type GradeLevel = (typeof GRADE_LEVELS)[number]
|
|
738
911
|
```
|
|
739
912
|
|
|
740
|
-
|
|
913
|
+
The server SDK no longer accepts a grade argument. Primer asks the learner for selected grade inside the browser runtime if needed.
|
|
741
914
|
|
|
742
|
-
|
|
915
|
+
# Onboarding Grade Selection
|
|
743
916
|
|
|
744
|
-
|
|
917
|
+
Grade selection is not a separate SDK API.
|
|
745
918
|
|
|
746
|
-
|
|
747
|
-
import { SUBJECTS } from "@superbuilders/primer-tives/subject"
|
|
748
|
-
import type { Subject, SubjectScope } from "@superbuilders/primer-tives/subject"
|
|
919
|
+
If the learner's email profile has no selected grade yet, the first `/advance` response is a normal `choice` interaction:
|
|
749
920
|
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
921
|
+
- `phase: "interaction"`
|
|
922
|
+
- `kind: "choice"`
|
|
923
|
+
- `minChoices: 1`
|
|
924
|
+
- `maxChoices: 1`
|
|
925
|
+
- options with identifiers from `GRADE_LEVELS`
|
|
926
|
+
|
|
927
|
+
Your renderer should treat it like any other single-choice interaction. Submit the selected option through `submitChoice([grade])`. The server captures that answer, stores the selected grade on the hashed email profile, bootstraps placement, and returns real content.
|
|
928
|
+
|
|
929
|
+
This design keeps the SDK small:
|
|
930
|
+
|
|
931
|
+
- no grade argument to `getToken`
|
|
932
|
+
- no server-side student creation method
|
|
933
|
+
- no separate onboarding endpoint
|
|
934
|
+
- no browser special case beyond rendering a normal choice interaction
|
|
935
|
+
|
|
936
|
+
# Integration Checklist
|
|
937
|
+
|
|
938
|
+
Use this as the minimum correct integration:
|
|
939
|
+
|
|
940
|
+
1. Backend imports `createPrimerServer` from `/server`.
|
|
941
|
+
2. Backend keeps `secretKey` server-only.
|
|
942
|
+
3. Backend calls `getToken` only for an authenticated user with a verified email.
|
|
943
|
+
4. Backend returns the token string to the browser.
|
|
944
|
+
5. Browser imports `create` from `/client`.
|
|
945
|
+
6. Browser passes `accessToken`, `origin`, `subject`, `logger`, and required `supportedPcis`.
|
|
946
|
+
7. Browser calls `start()` once per runtime mount.
|
|
947
|
+
8. Browser renders by switching on `state.phase`.
|
|
948
|
+
9. Browser never serializes `PrimerState`.
|
|
949
|
+
10. Browser recreates the client after reload or subject switch.
|
|
950
|
+
|
|
951
|
+
# What This SDK Does Not Do
|
|
754
952
|
|
|
755
|
-
|
|
953
|
+
The current SDK does not expose:
|
|
756
954
|
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
955
|
+
- raw student creation
|
|
956
|
+
- student ids as public integration inputs
|
|
957
|
+
- SIS-specific identity-provider flows
|
|
958
|
+
- host-app email verification
|
|
959
|
+
- Primer-sent email verification links
|
|
960
|
+
- a grade-level argument on token creation
|
|
961
|
+
- persistent browser sessions
|
|
962
|
+
- root package exports
|
|
761
963
|
|
|
762
|
-
|
|
964
|
+
Those omissions are intentional. The public integration point is one backend method, `getToken`, and one browser state machine, `create(...).start()`.
|