@superbuilders/primer-tives 1.2.0 → 2.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +744 -533
- package/dist/client/choice-state.d.ts +2 -1
- package/dist/client/choice-state.d.ts.map +1 -1
- package/dist/client/create.d.ts +23 -7
- package/dist/client/create.d.ts.map +1 -1
- package/dist/client/extended-text-state.d.ts +2 -1
- package/dist/client/extended-text-state.d.ts.map +1 -1
- package/dist/client/feedback-state.d.ts +2 -2
- package/dist/client/feedback-state.d.ts.map +1 -1
- package/dist/client/index.js +226 -109
- package/dist/client/index.js.map +21 -17
- package/dist/client/match-state.d.ts +2 -1
- package/dist/client/match-state.d.ts.map +1 -1
- package/dist/client/observation-state.d.ts +2 -1
- package/dist/client/observation-state.d.ts.map +1 -1
- package/dist/client/order-state.d.ts +2 -1
- package/dist/client/order-state.d.ts.map +1 -1
- package/dist/client/pci-state.d.ts +2 -1
- package/dist/client/pci-state.d.ts.map +1 -1
- package/dist/client/session-context.d.ts +1 -1
- package/dist/client/session-context.d.ts.map +1 -1
- package/dist/client/session.d.ts +2 -2
- package/dist/client/session.d.ts.map +1 -1
- package/dist/client/text-entry-state.d.ts +2 -1
- package/dist/client/text-entry-state.d.ts.map +1 -1
- package/dist/client/transport.d.ts +13 -11
- package/dist/client/transport.d.ts.map +1 -1
- package/dist/client/types.d.ts +10 -1
- package/dist/client/types.d.ts.map +1 -1
- package/dist/contracts/index.d.ts +4 -3
- 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/types.d.ts +5 -9
- package/dist/contracts/types.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 +3 -6
- package/dist/errors.d.ts.map +1 -1
- package/dist/errors.js +5 -11
- package/dist/errors.js.map +3 -3
- package/dist/server/create-server.d.ts +3 -33
- package/dist/server/create-server.d.ts.map +1 -1
- package/dist/server/exchange.d.ts +7 -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 +32 -407
- 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 +4 -0
- package/dist/version.d.ts.map +1 -0
- 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,705 +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.0`.
|
|
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 SHA3 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://sb-primer.vercel.app",
|
|
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
|
|
157
|
-
import type
|
|
108
|
+
import * as logger from "@superbuilders/slog"
|
|
109
|
+
import { create, type PrimerState } from "@superbuilders/primer-tives/client"
|
|
158
110
|
|
|
159
111
|
const client = create({
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
"urn:primer:pci:fraction-addition"
|
|
166
|
-
]
|
|
112
|
+
origin: "https://sb-primer.vercel.app",
|
|
113
|
+
accessToken,
|
|
114
|
+
subject: "math",
|
|
115
|
+
supportedPcis: ["urn:primer:pci:fraction-input"],
|
|
116
|
+
logger
|
|
167
117
|
})
|
|
168
118
|
|
|
169
119
|
let state: PrimerState = await client.start()
|
|
170
120
|
|
|
171
121
|
while (state.phase !== "completed" && state.phase !== "fatal") {
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
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
|
+
}
|
|
191
145
|
}
|
|
192
|
-
```
|
|
193
146
|
|
|
194
|
-
|
|
147
|
+
if (state.phase === "fatal") {
|
|
148
|
+
throw state.error
|
|
149
|
+
}
|
|
150
|
+
```
|
|
195
151
|
|
|
196
152
|
# `/server`
|
|
197
153
|
|
|
154
|
+
The server subpath is backend-only. It wraps the token endpoint and hides raw HTTP response details.
|
|
155
|
+
|
|
198
156
|
```ts
|
|
199
157
|
import { createPrimerServer } from "@superbuilders/primer-tives/server"
|
|
200
|
-
import type {
|
|
201
|
-
PrimerServer,
|
|
202
|
-
PrimerServerConfig,
|
|
203
|
-
SessionToken,
|
|
204
|
-
TimebackSession,
|
|
205
|
-
PlacementHints,
|
|
206
|
-
PlacementHintsResult
|
|
207
|
-
} from "@superbuilders/primer-tives/server"
|
|
208
|
-
import type { PrimerLogger } from "@superbuilders/primer-tives/logger"
|
|
209
|
-
import { GRADE_LEVELS, type GradeLevel } from "@superbuilders/primer-tives/grade-level"
|
|
158
|
+
import type { GetTokenInput, PrimerServer, PrimerServerConfig } from "@superbuilders/primer-tives/server"
|
|
210
159
|
```
|
|
211
160
|
|
|
212
161
|
## `createPrimerServer(config)`
|
|
213
162
|
|
|
214
163
|
```ts
|
|
215
164
|
interface PrimerServerConfig {
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
165
|
+
readonly origin: string
|
|
166
|
+
readonly secretKey: string
|
|
167
|
+
readonly fetch?: typeof globalThis.fetch
|
|
168
|
+
readonly abort?: AbortController
|
|
169
|
+
readonly logger: PrimerLogger
|
|
221
170
|
}
|
|
222
171
|
|
|
223
172
|
function createPrimerServer(config: PrimerServerConfig): PrimerServer
|
|
224
173
|
```
|
|
225
174
|
|
|
226
|
-
|
|
175
|
+
Config fields:
|
|
176
|
+
|
|
177
|
+
| Field | Required | Description |
|
|
178
|
+
| --- | --- | --- |
|
|
179
|
+
| `origin` | Yes | Primer deployment origin, for example `https://sb-primer.vercel.app`. |
|
|
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`
|
|
227
186
|
|
|
228
187
|
```ts
|
|
229
188
|
interface PrimerServer {
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
189
|
+
getToken(input: GetTokenInput): Promise<string>
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
type GetTokenInput = {
|
|
193
|
+
readonly verifiedEmail: string
|
|
234
194
|
}
|
|
235
195
|
```
|
|
236
196
|
|
|
237
|
-
|
|
197
|
+
`getToken` returns the browser access token string directly. It does not return a session object because the browser only needs the token.
|
|
238
198
|
|
|
239
|
-
|
|
199
|
+
The SDK sends this wire body:
|
|
240
200
|
|
|
241
|
-
|
|
201
|
+
```json
|
|
202
|
+
{
|
|
203
|
+
"verified_email": "student@example.com"
|
|
204
|
+
}
|
|
205
|
+
```
|
|
206
|
+
|
|
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.
|
|
242
208
|
|
|
243
|
-
|
|
209
|
+
## `verifiedEmail`
|
|
244
210
|
|
|
245
|
-
|
|
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.
|
|
246
212
|
|
|
247
|
-
|
|
213
|
+
Correct usage:
|
|
248
214
|
|
|
249
215
|
```ts
|
|
250
|
-
const
|
|
216
|
+
const accessToken = await primer.getToken({ verifiedEmail: authenticatedUser.email })
|
|
251
217
|
```
|
|
252
218
|
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
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
|
+
```
|
|
257
226
|
|
|
258
|
-
|
|
227
|
+
That treats an untrusted browser payload as identity proof and defeats the security model.
|
|
259
228
|
|
|
260
|
-
|
|
229
|
+
## Server Error Sentinels
|
|
261
230
|
|
|
262
|
-
|
|
231
|
+
Import sentinels from `/errors`, not `/server`.
|
|
263
232
|
|
|
264
|
-
|
|
233
|
+
```ts
|
|
234
|
+
import { ErrInvalidSecretKey, ErrBadRequest } from "@superbuilders/primer-tives/errors"
|
|
235
|
+
```
|
|
265
236
|
|
|
266
|
-
|
|
237
|
+
`getToken` can surface:
|
|
267
238
|
|
|
268
|
-
|
|
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. |
|
|
269
247
|
|
|
270
|
-
|
|
248
|
+
Recommended pattern:
|
|
271
249
|
|
|
272
250
|
```ts
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
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")
|
|
276
267
|
}
|
|
277
268
|
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
readonly accessToken: string
|
|
281
|
-
readonly expiresInSeconds: number
|
|
282
|
-
}
|
|
269
|
+
const accessToken = result.data
|
|
270
|
+
```
|
|
283
271
|
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
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
|
+
```
|
|
280
|
+
|
|
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`.
|
|
287
318
|
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
319
|
+
## `Client`
|
|
320
|
+
|
|
321
|
+
```ts
|
|
322
|
+
interface Client<Pcis extends PciId = PciId> {
|
|
323
|
+
start(): Promise<PrimerState<Pcis>>
|
|
291
324
|
}
|
|
292
325
|
```
|
|
293
326
|
|
|
294
|
-
`
|
|
327
|
+
`start()` sends the first observation request. It is idempotent: multiple calls return the same pending promise.
|
|
295
328
|
|
|
296
|
-
##
|
|
329
|
+
## Subject Scopes
|
|
297
330
|
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
- 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"
|
|
302
334
|
|
|
303
|
-
|
|
335
|
+
const SUBJECTS = ["math", "vocabulary", "science"] as const
|
|
336
|
+
type Subject = (typeof SUBJECTS)[number]
|
|
337
|
+
type SubjectScope = Subject | "all"
|
|
338
|
+
```
|
|
304
339
|
|
|
305
|
-
|
|
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.
|
|
306
352
|
|
|
307
353
|
```ts
|
|
308
|
-
import {
|
|
309
|
-
import type {
|
|
310
|
-
Client,
|
|
311
|
-
Config,
|
|
312
|
-
PrimerState,
|
|
313
|
-
ObservationState,
|
|
314
|
-
InteractionState,
|
|
315
|
-
ChoiceState,
|
|
316
|
-
TextEntryState,
|
|
317
|
-
ExtendedTextState,
|
|
318
|
-
ExtendedTextSingleState,
|
|
319
|
-
ExtendedTextMultipleState,
|
|
320
|
-
OrderState,
|
|
321
|
-
MatchState,
|
|
322
|
-
PciInteractionState,
|
|
323
|
-
FeedbackState,
|
|
324
|
-
CompletedState,
|
|
325
|
-
ErroredState,
|
|
326
|
-
FatalState,
|
|
327
|
-
NonSerializable,
|
|
328
|
-
PciPendingRenderProps,
|
|
329
|
-
PciSubmittedRenderProps,
|
|
330
|
-
PciRenderProps
|
|
331
|
-
} from "@superbuilders/primer-tives/client"
|
|
332
|
-
```
|
|
333
|
-
|
|
334
|
-
The `/client` subpath owns the **runtime state machine** only. Wire-shape types (`RendererInteraction`, `RendererSubmission`, `ContentInline`, PCI base types, review types) come from `/contracts`.
|
|
354
|
+
import { missingPcisForSubject, requiredPcisForSubject } from "@superbuilders/primer-tives/subject-pcis"
|
|
335
355
|
|
|
336
|
-
|
|
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"`.
|
|
361
|
+
|
|
362
|
+
```ts
|
|
363
|
+
const client = create({
|
|
364
|
+
origin,
|
|
365
|
+
accessToken,
|
|
366
|
+
subject: "math",
|
|
367
|
+
supportedPcis: ["urn:primer:pci:fraction-input"],
|
|
368
|
+
logger
|
|
369
|
+
})
|
|
370
|
+
```
|
|
371
|
+
|
|
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.
|
|
373
|
+
|
|
374
|
+
# `PrimerState`
|
|
375
|
+
|
|
376
|
+
`PrimerState` is a discriminated union over `phase`.
|
|
337
377
|
|
|
338
378
|
```ts
|
|
339
|
-
|
|
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`
|
|
340
391
|
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
readonly abort?: AbortController
|
|
348
|
-
readonly logger?: PrimerLogger
|
|
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>>
|
|
349
398
|
}
|
|
399
|
+
```
|
|
350
400
|
|
|
351
|
-
|
|
352
|
-
|
|
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`
|
|
404
|
+
|
|
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
|
|
429
|
+
|
|
430
|
+
```ts
|
|
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>>
|
|
353
442
|
}
|
|
354
443
|
```
|
|
355
444
|
|
|
356
|
-
`
|
|
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.
|
|
357
446
|
|
|
358
|
-
|
|
447
|
+
### Text Entry
|
|
359
448
|
|
|
360
449
|
```ts
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
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
|
+
}
|
|
368
459
|
```
|
|
369
460
|
|
|
370
|
-
|
|
461
|
+
`interaction` may include `expectedLength`, `patternMask`, and `placeholderText` for UI hints.
|
|
371
462
|
|
|
372
|
-
|
|
463
|
+
### Extended Text
|
|
373
464
|
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
465
|
+
Extended text has two cardinalities.
|
|
466
|
+
|
|
467
|
+
```ts
|
|
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
|
+
}
|
|
383
475
|
|
|
384
|
-
|
|
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>>
|
|
484
|
+
}
|
|
485
|
+
```
|
|
385
486
|
|
|
386
|
-
`
|
|
487
|
+
Both include `body`, `stimulus`, and `interaction` fields like every other interaction state.
|
|
387
488
|
|
|
388
|
-
###
|
|
489
|
+
### Order
|
|
389
490
|
|
|
390
491
|
```ts
|
|
391
|
-
interface
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
advance(): Promise<PrimerState>
|
|
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>>
|
|
400
500
|
}
|
|
401
501
|
```
|
|
402
502
|
|
|
403
|
-
|
|
503
|
+
Submit identifiers in learner-selected order.
|
|
404
504
|
|
|
405
|
-
###
|
|
505
|
+
### Match
|
|
406
506
|
|
|
407
507
|
```ts
|
|
408
|
-
interface
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
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>>
|
|
413
522
|
}
|
|
414
523
|
```
|
|
415
524
|
|
|
416
|
-
|
|
525
|
+
Each `RendererMatchChoice` has `matchMin` and `matchMax`. A `matchMax` of `0` means unbounded.
|
|
417
526
|
|
|
418
|
-
###
|
|
527
|
+
### Portable Custom Interaction
|
|
528
|
+
|
|
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
|
+
```
|
|
419
544
|
|
|
420
|
-
|
|
545
|
+
For `urn:primer:pci:fraction-input`, `properties` has:
|
|
421
546
|
|
|
422
547
|
```ts
|
|
423
|
-
interface
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
readonly retriable: false
|
|
548
|
+
interface FractionInputProps {
|
|
549
|
+
form: "whole" | "proper" | "improper" | "mixed"
|
|
550
|
+
requireSimplified: boolean
|
|
427
551
|
}
|
|
428
552
|
```
|
|
429
553
|
|
|
430
|
-
|
|
554
|
+
The submitted value is one of:
|
|
555
|
+
|
|
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
|
+
```
|
|
431
563
|
|
|
432
|
-
|
|
564
|
+
## `FeedbackState`
|
|
433
565
|
|
|
434
566
|
```ts
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
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>>
|
|
439
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.
|
|
581
|
+
|
|
582
|
+
## `CompletedState`
|
|
440
583
|
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
submission: PciValue<K>
|
|
445
|
-
review: Extract<InteractionReview<K>, { type: "portable-custom"; pciId: K }> | null
|
|
584
|
+
```ts
|
|
585
|
+
interface CompletedState extends NonSerializable {
|
|
586
|
+
readonly phase: "completed"
|
|
446
587
|
}
|
|
588
|
+
```
|
|
589
|
+
|
|
590
|
+
Terminal state. There is no action method.
|
|
447
591
|
|
|
448
|
-
|
|
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
|
+
}
|
|
449
601
|
```
|
|
450
602
|
|
|
451
|
-
|
|
603
|
+
`retry()` re-runs the exact same intent that failed. If `retriable` is false, `retry()` resolves to the same errored state.
|
|
452
604
|
|
|
453
|
-
|
|
454
|
-
- A returning student resumes wherever the server last placed them. No client-side cursor management.
|
|
455
|
-
- `PrimerState` holds real closures (action methods, pending-promise caches). Don't serialize it; don't store it across reloads. Call `start()` again on boot.
|
|
456
|
-
- `Config.supportedPcis` is a `const` generic; only those URNs flow through `PciInteractionState` at the type level. Mismatch at runtime is a `fatal` with `ErrUnsupportedPci`.
|
|
605
|
+
## `FatalState`
|
|
457
606
|
|
|
458
|
-
|
|
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.
|
|
459
616
|
|
|
460
617
|
# `/contracts`
|
|
461
618
|
|
|
462
|
-
|
|
619
|
+
The contracts subpath contains renderer wire shapes and helpers shared between custom renderers and server-adjacent tooling.
|
|
463
620
|
|
|
464
621
|
```ts
|
|
465
622
|
import {
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
MatchPairSchema,
|
|
472
|
-
DivisionRemainderPciSubmissionSchema,
|
|
473
|
-
FractionAdditionPciSubmissionSchema,
|
|
474
|
-
RendererSubmissionSchema,
|
|
475
|
-
validateSubmissionForInteraction,
|
|
476
|
-
submissionValidationMessage,
|
|
477
|
-
blocksToPlainText,
|
|
478
|
-
inlinesToPlainText
|
|
623
|
+
RendererSubmissionSchema,
|
|
624
|
+
blocksToPlainText,
|
|
625
|
+
inlinesToPlainText,
|
|
626
|
+
submissionValidationMessage,
|
|
627
|
+
validateSubmissionForInteraction
|
|
479
628
|
} from "@superbuilders/primer-tives/contracts"
|
|
480
|
-
|
|
481
629
|
import type {
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
ContentSpan,
|
|
500
|
-
// PCI base
|
|
501
|
-
PciId,
|
|
502
|
-
PciUrn,
|
|
503
|
-
PciRegistry,
|
|
504
|
-
PciProps,
|
|
505
|
-
PciValue,
|
|
506
|
-
DivisionRemainderProps,
|
|
507
|
-
DivisionRemainderSubmission,
|
|
508
|
-
FractionAdditionProps,
|
|
509
|
-
FractionAdditionSubmission,
|
|
510
|
-
// review (server emits these, client consumes)
|
|
511
|
-
InteractionReview,
|
|
512
|
-
ChoiceReview,
|
|
513
|
-
TextEntryReview,
|
|
514
|
-
ExtendedTextReview,
|
|
515
|
-
OrderReview,
|
|
516
|
-
MatchReview,
|
|
517
|
-
PciReview,
|
|
518
|
-
ReviewRecordField,
|
|
519
|
-
ReviewRecordFieldBaseType,
|
|
520
|
-
ReviewScalarValue,
|
|
521
|
-
// validation result
|
|
522
|
-
SubmissionValidationResult,
|
|
523
|
-
SubmissionValidationSuccess,
|
|
524
|
-
SubmissionValidationFailure
|
|
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
|
|
525
647
|
} from "@superbuilders/primer-tives/contracts"
|
|
526
648
|
```
|
|
527
649
|
|
|
528
|
-
##
|
|
650
|
+
## Content
|
|
529
651
|
|
|
530
|
-
|
|
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:
|
|
531
659
|
|
|
532
660
|
```ts
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
661
|
+
inlinesToPlainText(nodes: ContentInline[]): string
|
|
662
|
+
blocksToPlainText(blocks: ContentBlock[]): string
|
|
663
|
+
```
|
|
664
|
+
|
|
665
|
+
Use these helpers for accessibility labels, alt fallbacks, logging summaries, and non-rich previews.
|
|
536
666
|
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
667
|
+
## Stimulus
|
|
668
|
+
|
|
669
|
+
```ts
|
|
670
|
+
interface ImageStimulus {
|
|
671
|
+
kind: "image"
|
|
672
|
+
alt: ContentInline[]
|
|
673
|
+
src: string
|
|
541
674
|
}
|
|
542
|
-
|
|
675
|
+
|
|
676
|
+
type RendererStimulus = ImageStimulus
|
|
543
677
|
```
|
|
544
678
|
|
|
545
|
-
|
|
679
|
+
`RendererStimulus` is currently image-only, but it is still a discriminated union so renderers stay future-safe.
|
|
546
680
|
|
|
547
|
-
|
|
681
|
+
## Interactions
|
|
548
682
|
|
|
549
683
|
```ts
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
): SubmissionValidationResult
|
|
554
|
-
|
|
555
|
-
type SubmissionValidationResult =
|
|
556
|
-
| { ok: true; value: RendererSubmission }
|
|
557
|
-
| { ok: false; issues: readonly string[] }
|
|
684
|
+
type RendererInteraction<Pcis extends PciId = PciId> =
|
|
685
|
+
| StandardRendererInteraction
|
|
686
|
+
| PciInteraction<Pcis>
|
|
558
687
|
```
|
|
559
688
|
|
|
560
|
-
|
|
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` |
|
|
700
|
+
|
|
701
|
+
Choice objects:
|
|
561
702
|
|
|
562
703
|
```ts
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
} from "@superbuilders/primer-tives/contracts"
|
|
569
|
-
import { ErrInvalidSubmission } from "@superbuilders/primer-tives/errors"
|
|
704
|
+
interface RendererChoice {
|
|
705
|
+
identifier: string
|
|
706
|
+
content: ContentInline[]
|
|
707
|
+
}
|
|
708
|
+
```
|
|
570
709
|
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
710
|
+
Match choice objects:
|
|
711
|
+
|
|
712
|
+
```ts
|
|
713
|
+
interface RendererMatchChoice {
|
|
714
|
+
identifier: string
|
|
715
|
+
content: ContentInline[]
|
|
716
|
+
matchMax: number
|
|
717
|
+
matchMin: number
|
|
575
718
|
}
|
|
576
719
|
```
|
|
577
720
|
|
|
578
|
-
|
|
721
|
+
## Submissions
|
|
579
722
|
|
|
580
|
-
|
|
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
|
+
```
|
|
581
732
|
|
|
582
|
-
|
|
733
|
+
Schemas:
|
|
583
734
|
|
|
584
|
-
|
|
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:
|
|
585
746
|
|
|
586
747
|
```ts
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
ErrJsonParse,
|
|
592
|
-
ErrNetwork,
|
|
593
|
-
ErrServerError,
|
|
594
|
-
ErrTimeout,
|
|
595
|
-
// /server-thrown
|
|
596
|
-
ErrInvalidSecretKey,
|
|
597
|
-
ErrStudentNotFound,
|
|
598
|
-
ErrUnsupportedGrade,
|
|
599
|
-
ErrTimebackUnavailable,
|
|
600
|
-
ErrExternalAuthorityRequired,
|
|
601
|
-
// /client surfaced as `errored`
|
|
602
|
-
ErrServiceUnavailable,
|
|
603
|
-
ErrRateLimited,
|
|
604
|
-
ErrInvalidSubmission,
|
|
605
|
-
// /client surfaced as `fatal`
|
|
606
|
-
ErrInvalidAccessToken,
|
|
607
|
-
ErrTokenExpired,
|
|
608
|
-
ErrForbidden,
|
|
609
|
-
ErrNotFound,
|
|
610
|
-
ErrNeedsHints,
|
|
611
|
-
ErrUnsupportedPci,
|
|
612
|
-
// /client thrown directly by `create()`
|
|
613
|
-
ErrMalformedAccessToken,
|
|
614
|
-
ErrNotSerializable
|
|
615
|
-
} from "@superbuilders/primer-tives/errors"
|
|
748
|
+
const parsed = RendererSubmissionSchema.safeParse(payload)
|
|
749
|
+
if (!parsed.success) {
|
|
750
|
+
throw parsed.error
|
|
751
|
+
}
|
|
616
752
|
```
|
|
617
753
|
|
|
618
|
-
##
|
|
754
|
+
## Semantic Validation
|
|
619
755
|
|
|
620
|
-
|
|
621
|
-
|---|---|
|
|
622
|
-
| `ErrInvalidSecretKey` | HTTP 401 — missing, malformed, or unknown `sk_` |
|
|
623
|
-
| `ErrStudentNotFound` | HTTP 404 — native `studentId` unknown on this frontend, or Timeback `sourcedId` unknown upstream |
|
|
624
|
-
| `ErrUnsupportedGrade` | HTTP 400 — Timeback returned a grade outside Primer's supported range |
|
|
625
|
-
| `ErrTimebackUnavailable` | HTTP 502 — Timeback OneRoster endpoint failed during live exchange |
|
|
626
|
-
| `ErrExternalAuthorityRequired` | HTTP 409 — attempted native/manual exchange for a Timeback-linked student |
|
|
627
|
-
| `ErrConflict` | HTTP 409 — frontend not provisioned for routing/content |
|
|
628
|
-
| `ErrBadRequest` | HTTP 400 — validation failure |
|
|
629
|
-
| `ErrServerError` | HTTP 5xx |
|
|
630
|
-
| `ErrJsonParse` | response body was not valid JSON or had the wrong shape |
|
|
631
|
-
| `ErrNetwork` | `fetch()` rejected (DNS, connection, TLS, etc.) |
|
|
632
|
-
| `ErrTimeout` | `fetch()` aborted |
|
|
633
|
-
|
|
634
|
-
## Client `errored.error`
|
|
756
|
+
`RendererSubmissionSchema` validates shape. `validateSubmissionForInteraction` validates a submission against a specific interaction.
|
|
635
757
|
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
| `ErrRateLimited` | HTTP 429 |
|
|
643
|
-
| `ErrConflict` | HTTP 409 |
|
|
644
|
-
| `ErrJsonParse` | success body wasn't valid JSON |
|
|
645
|
-
| `ErrInvalidSubmission` | client-side validation rejected the submission |
|
|
646
|
-
|
|
647
|
-
## Client `fatal.error`
|
|
758
|
+
```ts
|
|
759
|
+
const validation = validateSubmissionForInteraction(interaction, submission)
|
|
760
|
+
if (!validation.ok) {
|
|
761
|
+
throw errors.wrap(ErrInvalidSubmission, submissionValidationMessage(validation))
|
|
762
|
+
}
|
|
763
|
+
```
|
|
648
764
|
|
|
649
|
-
|
|
650
|
-
|---|---|
|
|
651
|
-
| `ErrBadRequest` | HTTP 400 |
|
|
652
|
-
| `ErrInvalidAccessToken` | HTTP 401 |
|
|
653
|
-
| `ErrTokenExpired` | HTTP 401 with token-expired detail |
|
|
654
|
-
| `ErrForbidden` | HTTP 403 |
|
|
655
|
-
| `ErrNotFound` | HTTP 404 |
|
|
656
|
-
| `ErrNeedsHints` | HTTP 412 — the student has no `gradeLevel` on record; backend must call `setStudentHints` and mint a fresh access token |
|
|
657
|
-
| `ErrUnsupportedPci` | HTTP 422 or a frame asks for a PCI not in `supportedPcis` |
|
|
765
|
+
Checks include:
|
|
658
766
|
|
|
659
|
-
|
|
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
|
|
660
775
|
|
|
661
|
-
|
|
662
|
-
|---|---|
|
|
663
|
-
| `ErrMalformedAccessToken` | token doesn't start with `eyJ` or lacks two dots |
|
|
664
|
-
| `ErrNotSerializable` | you called `JSON.stringify()` on a live `PrimerState` (don't) |
|
|
776
|
+
The built-in browser state objects call this before submitting. Custom renderers that bypass state methods should call it themselves.
|
|
665
777
|
|
|
666
|
-
##
|
|
778
|
+
## Review Types
|
|
667
779
|
|
|
668
|
-
|
|
780
|
+
`FeedbackState.review` is `InteractionReview | null`.
|
|
781
|
+
|
|
782
|
+
```ts
|
|
783
|
+
type InteractionReview<Pcis extends PciId = PciId> =
|
|
784
|
+
| ChoiceReview
|
|
785
|
+
| TextEntryReview
|
|
786
|
+
| ExtendedTextReview
|
|
787
|
+
| OrderReview
|
|
788
|
+
| MatchReview
|
|
789
|
+
| PciReview<Pcis>
|
|
790
|
+
```
|
|
791
|
+
|
|
792
|
+
Review variants:
|
|
793
|
+
|
|
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[]` |
|
|
802
|
+
|
|
803
|
+
`review` is for display and inspection. Correctness already lives on `FeedbackState.isCorrect`.
|
|
804
|
+
|
|
805
|
+
# `/errors`
|
|
806
|
+
|
|
807
|
+
All sentinels are exported from `/errors`.
|
|
669
808
|
|
|
670
809
|
```ts
|
|
671
|
-
import * as errors from "@superbuilders/errors"
|
|
672
|
-
import * as logger from "@superbuilders/slog"
|
|
673
810
|
import {
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
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
|
|
677
830
|
} 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
831
|
```
|
|
701
832
|
|
|
702
|
-
|
|
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`. |
|
|
703
880
|
|
|
704
881
|
# `/logger`
|
|
705
882
|
|
|
@@ -707,16 +884,21 @@ const { accessToken } = tokenResult.data
|
|
|
707
884
|
import type { PrimerLogger } from "@superbuilders/primer-tives/logger"
|
|
708
885
|
|
|
709
886
|
interface PrimerLogger {
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
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
|
|
714
891
|
}
|
|
715
892
|
```
|
|
716
893
|
|
|
717
|
-
|
|
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"
|
|
718
898
|
|
|
719
|
-
|
|
899
|
+
const primer = createPrimerServer({ origin, secretKey, logger })
|
|
900
|
+
const client = create({ origin, accessToken, subject: "vocabulary", logger })
|
|
901
|
+
```
|
|
720
902
|
|
|
721
903
|
# `/grade-level`
|
|
722
904
|
|
|
@@ -728,26 +910,55 @@ const GRADE_LEVELS = ["K", "1", "2", "3", "4", "5", "6", "7", "8", "9", "10", "1
|
|
|
728
910
|
type GradeLevel = (typeof GRADE_LEVELS)[number]
|
|
729
911
|
```
|
|
730
912
|
|
|
731
|
-
|
|
913
|
+
The server SDK no longer accepts a grade argument. Primer asks the learner for selected grade inside the browser runtime if needed.
|
|
732
914
|
|
|
733
|
-
|
|
915
|
+
# Onboarding Grade Selection
|
|
734
916
|
|
|
735
|
-
|
|
917
|
+
Grade selection is not a separate SDK API.
|
|
736
918
|
|
|
737
|
-
|
|
738
|
-
import { SUBJECTS } from "@superbuilders/primer-tives/subject"
|
|
739
|
-
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:
|
|
740
920
|
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
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
|
|
745
952
|
|
|
746
|
-
|
|
953
|
+
The current SDK does not expose:
|
|
747
954
|
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
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
|
|
752
963
|
|
|
753
|
-
|
|
964
|
+
Those omissions are intentional. The public integration point is one backend method, `getToken`, and one browser state machine, `create(...).start()`.
|