@superbuilders/primer-tives 2.2.1 → 3.5.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 +239 -814
- package/dist/client/auth/access-token.d.ts +10 -0
- package/dist/client/auth/access-token.d.ts.map +1 -0
- package/dist/client/auth/browser.d.ts +20 -0
- package/dist/client/auth/browser.d.ts.map +1 -0
- package/dist/client/auth/callback.d.ts +10 -0
- package/dist/client/auth/callback.d.ts.map +1 -0
- package/dist/client/auth/hosted-popup.d.ts +14 -0
- package/dist/client/auth/hosted-popup.d.ts.map +1 -0
- package/dist/client/auth/provider.d.ts +14 -0
- package/dist/client/auth/provider.d.ts.map +1 -0
- package/dist/client/auth/storage.d.ts +9 -0
- package/dist/client/auth/storage.d.ts.map +1 -0
- package/dist/client/create.d.ts +22 -25
- package/dist/client/create.d.ts.map +1 -1
- package/dist/client/create.type-test.d.ts +2 -0
- package/dist/client/create.type-test.d.ts.map +1 -0
- package/dist/client/index.d.ts +1 -1
- package/dist/client/index.d.ts.map +1 -1
- package/dist/client/index.js +336 -74
- package/dist/client/index.js.map +14 -10
- package/dist/client/runtime-subject.d.ts +4 -0
- package/dist/client/runtime-subject.d.ts.map +1 -0
- package/dist/client/session.d.ts +2 -2
- package/dist/client/session.d.ts.map +1 -1
- package/dist/client/transport.d.ts +6 -4
- package/dist/client/transport.d.ts.map +1 -1
- package/dist/errors.d.ts +7 -3
- package/dist/errors.d.ts.map +1 -1
- package/dist/errors.js +14 -6
- package/dist/errors.js.map +3 -3
- package/dist/subject-pcis.d.ts +11 -5
- package/dist/subject-pcis.d.ts.map +1 -1
- package/dist/subject-pcis.js +39 -0
- package/dist/subject-pcis.js.map +11 -0
- package/dist/subject.d.ts +1 -2
- package/dist/subject.d.ts.map +1 -1
- package/dist/subject.js.map +1 -1
- package/dist/version.d.ts +1 -1
- package/package.json +2 -6
- package/dist/server/create-server.d.ts +0 -17
- package/dist/server/create-server.d.ts.map +0 -1
- package/dist/server/exchange.d.ts +0 -16
- package/dist/server/exchange.d.ts.map +0 -1
- package/dist/server/index.d.ts +0 -3
- package/dist/server/index.d.ts.map +0 -1
- package/dist/server/index.js +0 -173
- package/dist/server/index.js.map +0 -12
package/README.md
CHANGED
|
@@ -2,378 +2,178 @@
|
|
|
2
2
|
|
|
3
3
|
TypeScript SDK primitives for the Primer adaptive learning runtime.
|
|
4
4
|
|
|
5
|
-
The
|
|
5
|
+
The public lifecycle is one async call:
|
|
6
6
|
|
|
7
|
-
|
|
8
|
-
|
|
7
|
+
```txt
|
|
8
|
+
create(options) -> Promise<PrimerState>
|
|
9
|
+
```
|
|
9
10
|
|
|
10
|
-
|
|
11
|
+
`create` resolves learner auth, opens the first Primer frame, and returns a live learning state. There is no public auth result union, client wrapper, `start()` method, `snapshot()` API, or branded state type.
|
|
11
12
|
|
|
12
13
|
```sh
|
|
13
14
|
bun add @superbuilders/primer-tives
|
|
14
15
|
```
|
|
15
16
|
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
## Package Version
|
|
17
|
+
## Version
|
|
19
18
|
|
|
20
|
-
The current SDK version is `
|
|
19
|
+
The current SDK version is `3.5.0`.
|
|
21
20
|
|
|
22
|
-
The
|
|
21
|
+
The SDK sends `X-Primer-SDK-Version: 3.5.0` on `/api/v0/advance`.
|
|
23
22
|
|
|
24
23
|
## Entrypoints
|
|
25
24
|
|
|
26
|
-
There is no package-root export.
|
|
27
|
-
|
|
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` |
|
|
38
|
-
|
|
39
|
-
## Architecture
|
|
40
|
-
|
|
41
|
-
Primer's runtime has three identities in play:
|
|
25
|
+
There is no package-root export.
|
|
42
26
|
|
|
43
|
-
|
|
|
44
|
-
| --- | --- |
|
|
45
|
-
|
|
|
46
|
-
|
|
|
47
|
-
|
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
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, ... })`.
|
|
27
|
+
| Subpath | Owns |
|
|
28
|
+
| --- | --- |
|
|
29
|
+
| `@superbuilders/primer-tives/client` | `create`, `PrimerOptions`, `PrimerState`, state interfaces, PCI render props |
|
|
30
|
+
| `@superbuilders/primer-tives/contracts` | Content, stimulus, interaction, submission, review, PCI types, validation helpers |
|
|
31
|
+
| `@superbuilders/primer-tives/errors` | SDK error sentinels |
|
|
32
|
+
| `@superbuilders/primer-tives/logger` | `PrimerLogger` interface |
|
|
33
|
+
| `@superbuilders/primer-tives/subject` | `Subject`, `SUBJECTS` |
|
|
34
|
+
| `@superbuilders/primer-tives/subject-pcis` | Subject-required PCI helpers |
|
|
35
|
+
| `@superbuilders/primer-tives/grade-level` | `GradeLevel`, `GRADE_LEVELS` |
|
|
60
36
|
|
|
61
|
-
|
|
37
|
+
## Quick Start
|
|
62
38
|
|
|
63
|
-
|
|
39
|
+
Math requires the fraction-input PCI capability:
|
|
64
40
|
|
|
65
41
|
```ts
|
|
66
|
-
import * as errors from "@superbuilders/errors"
|
|
67
42
|
import * as logger from "@superbuilders/slog"
|
|
68
|
-
import {
|
|
69
|
-
import { ErrBadRequest, ErrInvalidSecretKey, ErrNetwork, ErrTimeout } from "@superbuilders/primer-tives/errors"
|
|
43
|
+
import { create } from "@superbuilders/primer-tives/client"
|
|
70
44
|
|
|
71
|
-
const
|
|
45
|
+
const state = await create({
|
|
72
46
|
origin: "https://primerlearn.dev",
|
|
73
|
-
|
|
47
|
+
publishableKey: "pk_...",
|
|
48
|
+
subject: "math",
|
|
49
|
+
supportedPcis: ["urn:primer:pci:fraction-input"],
|
|
74
50
|
logger
|
|
75
51
|
})
|
|
76
|
-
|
|
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
|
|
102
|
-
}
|
|
103
52
|
```
|
|
104
53
|
|
|
105
|
-
|
|
54
|
+
If you already have a Cognito/OIDC access token, pass it directly:
|
|
106
55
|
|
|
107
56
|
```ts
|
|
108
|
-
|
|
109
|
-
import { create, type PrimerState } from "@superbuilders/primer-tives/client"
|
|
110
|
-
|
|
111
|
-
const client = create({
|
|
57
|
+
const state = await create({
|
|
112
58
|
origin: "https://primerlearn.dev",
|
|
59
|
+
publishableKey: "pk_...",
|
|
113
60
|
accessToken,
|
|
114
61
|
subject: "math",
|
|
115
62
|
supportedPcis: ["urn:primer:pci:fraction-input"],
|
|
116
63
|
logger
|
|
117
64
|
})
|
|
118
|
-
|
|
119
|
-
let state: PrimerState = await client.start()
|
|
120
|
-
|
|
121
|
-
while (state.phase !== "completed" && state.phase !== "fatal") {
|
|
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
|
-
}
|
|
145
|
-
}
|
|
146
|
-
|
|
147
|
-
if (state.phase === "fatal") {
|
|
148
|
-
throw state.error
|
|
149
|
-
}
|
|
150
|
-
```
|
|
151
|
-
|
|
152
|
-
# `/server`
|
|
153
|
-
|
|
154
|
-
The server subpath is backend-only. It wraps the token endpoint and hides raw HTTP response details.
|
|
155
|
-
|
|
156
|
-
```ts
|
|
157
|
-
import { createPrimerServer } from "@superbuilders/primer-tives/server"
|
|
158
|
-
import type { GetTokenInput, PrimerServer, PrimerServerConfig } from "@superbuilders/primer-tives/server"
|
|
159
65
|
```
|
|
160
66
|
|
|
161
|
-
|
|
67
|
+
Vocabulary has no required PCI capability, so `supportedPcis` can be omitted:
|
|
162
68
|
|
|
163
69
|
```ts
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
}
|
|
171
|
-
|
|
172
|
-
function createPrimerServer(config: PrimerServerConfig): PrimerServer
|
|
173
|
-
```
|
|
174
|
-
|
|
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`
|
|
186
|
-
|
|
187
|
-
```ts
|
|
188
|
-
interface PrimerServer {
|
|
189
|
-
getToken(input: GetTokenInput): Promise<string>
|
|
190
|
-
}
|
|
191
|
-
|
|
192
|
-
type GetTokenInput = {
|
|
193
|
-
readonly verifiedEmail: string
|
|
194
|
-
}
|
|
195
|
-
```
|
|
196
|
-
|
|
197
|
-
`getToken` returns the browser access token string directly. It does not return a session object because the browser only needs the token.
|
|
198
|
-
|
|
199
|
-
The SDK sends this wire body:
|
|
200
|
-
|
|
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.
|
|
208
|
-
|
|
209
|
-
## `verifiedEmail`
|
|
210
|
-
|
|
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:
|
|
214
|
-
|
|
215
|
-
```ts
|
|
216
|
-
const accessToken = await primer.getToken({ verifiedEmail: authenticatedUser.email })
|
|
217
|
-
```
|
|
218
|
-
|
|
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
|
-
```
|
|
226
|
-
|
|
227
|
-
That treats an untrusted browser payload as identity proof and defeats the security model.
|
|
228
|
-
|
|
229
|
-
## Server Error Sentinels
|
|
230
|
-
|
|
231
|
-
Import sentinels from `/errors`, not `/server`.
|
|
232
|
-
|
|
233
|
-
```ts
|
|
234
|
-
import { ErrInvalidSecretKey, ErrBadRequest } from "@superbuilders/primer-tives/errors"
|
|
235
|
-
```
|
|
236
|
-
|
|
237
|
-
`getToken` can surface:
|
|
238
|
-
|
|
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. |
|
|
247
|
-
|
|
248
|
-
Recommended pattern:
|
|
249
|
-
|
|
250
|
-
```ts
|
|
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")
|
|
267
|
-
}
|
|
268
|
-
|
|
269
|
-
const accessToken = result.data
|
|
270
|
-
```
|
|
271
|
-
|
|
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"
|
|
70
|
+
const state = await create({
|
|
71
|
+
origin: "https://primerlearn.dev",
|
|
72
|
+
publishableKey: "pk_...",
|
|
73
|
+
subject: "vocabulary",
|
|
74
|
+
logger
|
|
75
|
+
})
|
|
279
76
|
```
|
|
280
77
|
|
|
281
|
-
|
|
78
|
+
Omitting `subject` means the SDK asks Primer for the internal all-subject runtime scope. Because that can include any subject, the required PCI set is the union of all subject-required PCIs:
|
|
282
79
|
|
|
283
80
|
```ts
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
81
|
+
const state = await create({
|
|
82
|
+
origin: "https://primerlearn.dev",
|
|
83
|
+
publishableKey: "pk_...",
|
|
84
|
+
supportedPcis: ["urn:primer:pci:fraction-input"],
|
|
85
|
+
logger
|
|
86
|
+
})
|
|
287
87
|
```
|
|
288
88
|
|
|
289
|
-
|
|
89
|
+
## PrimerOptions
|
|
290
90
|
|
|
291
91
|
```ts
|
|
292
|
-
type
|
|
293
|
-
readonly accessToken: string
|
|
294
|
-
readonly subject: S
|
|
92
|
+
type PrimerOptions<S extends Subject | undefined = undefined, Pcis extends PciId = never> = {
|
|
295
93
|
readonly origin: string
|
|
94
|
+
readonly publishableKey: string
|
|
95
|
+
readonly accessToken?: string
|
|
96
|
+
readonly subject?: S
|
|
97
|
+
readonly supportedPcis: subject-dependent
|
|
296
98
|
readonly fetch?: typeof globalThis.fetch
|
|
297
99
|
readonly abort?: AbortController
|
|
298
100
|
readonly logger: PrimerLogger
|
|
299
|
-
}
|
|
101
|
+
}
|
|
300
102
|
```
|
|
301
103
|
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
|
305
|
-
|
|
|
306
|
-
| `accessToken` |
|
|
307
|
-
| `subject` |
|
|
308
|
-
| `
|
|
309
|
-
| `
|
|
310
|
-
| `
|
|
311
|
-
| `
|
|
312
|
-
|
|
104
|
+
| Field | Meaning |
|
|
105
|
+
| --- | --- |
|
|
106
|
+
| `origin` | Primer deployment origin. |
|
|
107
|
+
| `publishableKey` | Public Primer frontend key. |
|
|
108
|
+
| `accessToken` | Optional Cognito/OIDC learner access token. When present, hosted auth is skipped. |
|
|
109
|
+
| `subject` | Optional content scope. Omitted means internal all-subject scope. Public callers do not pass `"all"`. |
|
|
110
|
+
| `supportedPcis` | Renderer PCI capabilities. Required or optional based on `subject`. |
|
|
111
|
+
| `fetch` | Optional fetch override for tests/instrumentation. |
|
|
112
|
+
| `abort` | Optional abort controller for runtime requests. |
|
|
113
|
+
| `logger` | Structured logger implementing `debug`, `info`, `warn`, and `error`. |
|
|
114
|
+
|
|
115
|
+
The presence or absence of `accessToken` selects auth behavior:
|
|
116
|
+
|
|
117
|
+
| Shape | Behavior |
|
|
118
|
+
| --- | --- |
|
|
119
|
+
| `accessToken` present | Use provided access token, then call `/api/v0/advance`. |
|
|
120
|
+
| `accessToken` absent | Resolve a token through Primer-hosted auth, then call `/api/v0/advance`. |
|
|
313
121
|
|
|
314
|
-
|
|
122
|
+
Hosted auth storage, callback parsing, popup details, and state handling are SDK internals.
|
|
315
123
|
|
|
316
|
-
|
|
317
|
-
- `supportedPcis` must include all PCIs required by the selected subject. Missing support throws `ErrMissingRequiredPci` immediately and logs `renderer missing required pcis`.
|
|
124
|
+
## Subject And PCI Contract
|
|
318
125
|
|
|
319
|
-
|
|
126
|
+
`subject` selects content scope and determines required renderer capabilities.
|
|
320
127
|
|
|
321
|
-
|
|
322
|
-
interface Client<Pcis extends PciId = PciId> {
|
|
323
|
-
start(): Promise<PrimerState<Pcis>>
|
|
324
|
-
}
|
|
325
|
-
```
|
|
128
|
+
`supportedPcis` declares which Portable Custom Interactions the host renderer can render. It may be a superset of the required PCIs.
|
|
326
129
|
|
|
327
|
-
|
|
130
|
+
The SDK enforces subject-required PCI capability in TypeScript:
|
|
328
131
|
|
|
329
|
-
|
|
132
|
+
| Public subject option | Required `supportedPcis` |
|
|
133
|
+
| --- | --- |
|
|
134
|
+
| omitted | union of all subject-required PCIs, currently `"urn:primer:pci:fraction-input"` |
|
|
135
|
+
| `"math"` | `"urn:primer:pci:fraction-input"` |
|
|
136
|
+
| `"vocabulary"` | none |
|
|
137
|
+
| `"science"` | none |
|
|
330
138
|
|
|
331
|
-
|
|
332
|
-
import { SUBJECTS } from "@superbuilders/primer-tives/subject"
|
|
333
|
-
import type { Subject, SubjectScope } from "@superbuilders/primer-tives/subject"
|
|
139
|
+
The check is a subset check:
|
|
334
140
|
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
type SubjectScope = Subject | "all"
|
|
141
|
+
```txt
|
|
142
|
+
required PCIs for subject ⊆ supportedPcis
|
|
338
143
|
```
|
|
339
144
|
|
|
340
|
-
|
|
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.
|
|
145
|
+
Order does not matter. Extra supported PCIs are allowed.
|
|
348
146
|
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
Math currently requires the fraction-input PCI. Vocabulary and science currently require none.
|
|
147
|
+
This fails at compile time:
|
|
352
148
|
|
|
353
149
|
```ts
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
150
|
+
await create({
|
|
151
|
+
origin,
|
|
152
|
+
publishableKey,
|
|
153
|
+
subject: "math",
|
|
154
|
+
logger
|
|
155
|
+
})
|
|
358
156
|
```
|
|
359
157
|
|
|
360
|
-
|
|
158
|
+
This passes:
|
|
361
159
|
|
|
362
160
|
```ts
|
|
363
|
-
|
|
161
|
+
await create({
|
|
364
162
|
origin,
|
|
365
|
-
|
|
163
|
+
publishableKey,
|
|
366
164
|
subject: "math",
|
|
367
165
|
supportedPcis: ["urn:primer:pci:fraction-input"],
|
|
368
166
|
logger
|
|
369
167
|
})
|
|
370
168
|
```
|
|
371
169
|
|
|
372
|
-
|
|
170
|
+
If `subject` is a broad dynamic `Subject` value, TypeScript requires the union of possible subject-required PCIs. This lets renderer wrappers pass their full supported PCI registry once and get subject-level safety for free.
|
|
373
171
|
|
|
374
|
-
|
|
172
|
+
The runtime still protects the trust boundary. If the server returns a PCI not declared in `supportedPcis`, the SDK returns `FatalState` with `ErrUnsupportedPci`.
|
|
375
173
|
|
|
376
|
-
|
|
174
|
+
## PrimerState
|
|
175
|
+
|
|
176
|
+
`create` returns a `PrimerState` only after auth and the first runtime request have succeeded.
|
|
377
177
|
|
|
378
178
|
```ts
|
|
379
179
|
type PrimerState<Pcis extends PciId = PciId> =
|
|
@@ -385,580 +185,205 @@ type PrimerState<Pcis extends PciId = PciId> =
|
|
|
385
185
|
| FatalState
|
|
386
186
|
```
|
|
387
187
|
|
|
388
|
-
|
|
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>>
|
|
398
|
-
}
|
|
399
|
-
```
|
|
400
|
-
|
|
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>>
|
|
442
|
-
}
|
|
443
|
-
```
|
|
444
|
-
|
|
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.
|
|
446
|
-
|
|
447
|
-
### Text Entry
|
|
448
|
-
|
|
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
|
-
```
|
|
460
|
-
|
|
461
|
-
`interaction` may include `expectedLength`, `patternMask`, and `placeholderText` for UI hints.
|
|
462
|
-
|
|
463
|
-
### Extended Text
|
|
464
|
-
|
|
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
|
-
}
|
|
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>>
|
|
484
|
-
}
|
|
485
|
-
```
|
|
486
|
-
|
|
487
|
-
Both include `body`, `stimulus`, and `interaction` fields like every other interaction state.
|
|
188
|
+
The state machine is structural and discriminated by `phase`, then by `kind` for interactions. Each state exposes only transitions valid from that state.
|
|
488
189
|
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
}
|
|
190
|
+
```txt
|
|
191
|
+
observation -> advance
|
|
192
|
+
feedback -> advance
|
|
193
|
+
choice -> submitChoice / timeout
|
|
194
|
+
text-entry -> submitText / timeout
|
|
195
|
+
extended-text -> submitText or submitTexts / timeout
|
|
196
|
+
order -> submitOrder / timeout
|
|
197
|
+
match -> submitMatch / timeout
|
|
198
|
+
portable-custom -> submit / timeout
|
|
199
|
+
completed -> no transition
|
|
200
|
+
fatal -> no transition
|
|
501
201
|
```
|
|
502
202
|
|
|
503
|
-
|
|
203
|
+
The state object is live and non-serializable. Do not put it in storage, JSON, or server data.
|
|
504
204
|
|
|
505
|
-
|
|
205
|
+
## Runtime Loop
|
|
506
206
|
|
|
507
207
|
```ts
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
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>>
|
|
522
|
-
}
|
|
523
|
-
```
|
|
524
|
-
|
|
525
|
-
Each `RendererMatchChoice` has `matchMin` and `matchMax`. A `matchMax` of `0` means unbounded.
|
|
526
|
-
|
|
527
|
-
### Portable Custom Interaction
|
|
208
|
+
let state = await create({
|
|
209
|
+
origin,
|
|
210
|
+
publishableKey,
|
|
211
|
+
subject: "math",
|
|
212
|
+
supportedPcis: ["urn:primer:pci:fraction-input"],
|
|
213
|
+
logger
|
|
214
|
+
})
|
|
528
215
|
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
216
|
+
while (state.phase !== "completed" && state.phase !== "fatal") {
|
|
217
|
+
switch (state.phase) {
|
|
218
|
+
case "observation":
|
|
219
|
+
renderFrame(state.body, state.stimulus)
|
|
220
|
+
state = await state.advance()
|
|
221
|
+
break
|
|
222
|
+
case "interaction":
|
|
223
|
+
state = await renderAndSubmitInteraction(state)
|
|
224
|
+
break
|
|
225
|
+
case "feedback":
|
|
226
|
+
renderFeedback(state.feedbackContent, state.isCorrect, state.review)
|
|
227
|
+
state = await state.advance()
|
|
228
|
+
break
|
|
229
|
+
case "errored":
|
|
230
|
+
if (state.retriable) {
|
|
231
|
+
state = await state.retry()
|
|
232
|
+
break
|
|
233
|
+
}
|
|
234
|
+
throw state.error
|
|
541
235
|
}
|
|
542
|
-
}[Pcis]
|
|
543
|
-
```
|
|
544
|
-
|
|
545
|
-
For `urn:primer:pci:fraction-input`, `properties` has:
|
|
546
|
-
|
|
547
|
-
```ts
|
|
548
|
-
interface FractionInputProps {
|
|
549
|
-
form: "whole" | "proper" | "improper" | "mixed"
|
|
550
|
-
requireSimplified: boolean
|
|
551
236
|
}
|
|
552
|
-
```
|
|
553
|
-
|
|
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
|
-
```
|
|
563
|
-
|
|
564
|
-
## `FeedbackState`
|
|
565
237
|
|
|
566
|
-
|
|
567
|
-
|
|
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>>
|
|
238
|
+
if (state.phase === "fatal") {
|
|
239
|
+
throw state.error
|
|
577
240
|
}
|
|
578
241
|
```
|
|
579
242
|
|
|
580
|
-
|
|
243
|
+
## PCI Type Safety
|
|
581
244
|
|
|
582
|
-
|
|
245
|
+
Portable Custom Interactions are typed by PCI id.
|
|
583
246
|
|
|
584
247
|
```ts
|
|
585
|
-
|
|
586
|
-
readonly phase: "completed"
|
|
587
|
-
}
|
|
588
|
-
```
|
|
248
|
+
import type { PciProps, PciValue } from "@superbuilders/primer-tives/contracts"
|
|
589
249
|
|
|
590
|
-
|
|
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
|
-
}
|
|
250
|
+
type FractionProps = PciProps<"urn:primer:pci:fraction-input">
|
|
251
|
+
type FractionValue = PciValue<"urn:primer:pci:fraction-input">
|
|
601
252
|
```
|
|
602
253
|
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
## `FatalState`
|
|
254
|
+
When the state is narrowed to a PCI id, `submit` accepts only that PCI's value type.
|
|
606
255
|
|
|
607
256
|
```ts
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
257
|
+
if (state.phase === "interaction" && state.kind === "portable-custom") {
|
|
258
|
+
if (state.pciId === "urn:primer:pci:fraction-input") {
|
|
259
|
+
const value = readFractionInput(state.properties)
|
|
260
|
+
state = await state.submit(value)
|
|
261
|
+
}
|
|
612
262
|
}
|
|
613
263
|
```
|
|
614
264
|
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
# `/contracts`
|
|
265
|
+
## Subject PCI Helpers
|
|
618
266
|
|
|
619
|
-
|
|
267
|
+
Use `@superbuilders/primer-tives/subject-pcis` when tooling needs the same contract as the SDK.
|
|
620
268
|
|
|
621
269
|
```ts
|
|
622
|
-
import {
|
|
623
|
-
RendererSubmissionSchema,
|
|
624
|
-
blocksToPlainText,
|
|
625
|
-
inlinesToPlainText,
|
|
626
|
-
submissionValidationMessage,
|
|
627
|
-
validateSubmissionForInteraction
|
|
628
|
-
} from "@superbuilders/primer-tives/contracts"
|
|
629
|
-
import type {
|
|
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
|
|
647
|
-
} from "@superbuilders/primer-tives/contracts"
|
|
648
|
-
```
|
|
270
|
+
import { requiredPcisForSubject } from "@superbuilders/primer-tives/subject-pcis"
|
|
649
271
|
|
|
650
|
-
|
|
651
|
-
|
|
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[] }
|
|
272
|
+
const required = requiredPcisForSubject("math")
|
|
656
273
|
```
|
|
657
274
|
|
|
658
|
-
|
|
275
|
+
`requiredPcisForSubject(undefined)` returns the all-subject required PCI union.
|
|
659
276
|
|
|
660
|
-
|
|
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.
|
|
277
|
+
## Errors
|
|
666
278
|
|
|
667
|
-
|
|
279
|
+
Errors are sentinel values from `@superbuilders/errors`.
|
|
668
280
|
|
|
669
|
-
|
|
670
|
-
interface ImageStimulus {
|
|
671
|
-
kind: "image"
|
|
672
|
-
alt: ContentInline[]
|
|
673
|
-
src: string
|
|
674
|
-
}
|
|
281
|
+
Common sentinels:
|
|
675
282
|
|
|
676
|
-
|
|
677
|
-
```
|
|
678
|
-
|
|
679
|
-
`RendererStimulus` is currently image-only, but it is still a discriminated union so renderers stay future-safe.
|
|
680
|
-
|
|
681
|
-
## Interactions
|
|
682
|
-
|
|
683
|
-
```ts
|
|
684
|
-
type RendererInteraction<Pcis extends PciId = PciId> =
|
|
685
|
-
| StandardRendererInteraction
|
|
686
|
-
| PciInteraction<Pcis>
|
|
687
|
-
```
|
|
688
|
-
|
|
689
|
-
Standard interactions:
|
|
690
|
-
|
|
691
|
-
| Type | Key fields |
|
|
283
|
+
| Sentinel | Meaning |
|
|
692
284
|
| --- | --- |
|
|
693
|
-
| `
|
|
694
|
-
| `
|
|
695
|
-
| `
|
|
696
|
-
| `
|
|
697
|
-
| `
|
|
698
|
-
| `
|
|
699
|
-
| `
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
```
|
|
709
|
-
|
|
710
|
-
Match choice objects:
|
|
711
|
-
|
|
712
|
-
```ts
|
|
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:
|
|
285
|
+
| `ErrAuthUnavailable` | Hosted auth needs browser APIs that are unavailable. |
|
|
286
|
+
| `ErrAuthConfigInvalid` | Hosted auth internal URL/config state was invalid. |
|
|
287
|
+
| `ErrAuthCallbackInvalid` | Hosted auth callback was malformed or rejected. |
|
|
288
|
+
| `ErrAuthStateMismatch` | Callback state did not match stored state. |
|
|
289
|
+
| `ErrAuthPopupBlocked` | Browser blocked the hosted auth popup. |
|
|
290
|
+
| `ErrAuthCancelled` | Hosted auth popup was closed or timed out. |
|
|
291
|
+
| `ErrMalformedAccessToken` | Access token is not JWS-shaped. |
|
|
292
|
+
| `ErrInvalidAccessToken` | Server rejected the access token. |
|
|
293
|
+
| `ErrTokenExpired` | Server rejected an expired token. |
|
|
294
|
+
| `ErrUnsupportedPci` | Server returned an undeclared PCI. |
|
|
295
|
+
| `ErrInvalidSubmission` | Submission did not match the active interaction. |
|
|
296
|
+
| `ErrSdkUpgradeRequired` | Server requires a newer SDK version. |
|
|
297
|
+
| `ErrNetwork` | Fetch failed before an HTTP response. |
|
|
298
|
+
| `ErrTimeout` | Request was aborted. |
|
|
299
|
+
| `ErrJsonParse` | Server returned invalid JSON. |
|
|
734
300
|
|
|
735
301
|
```ts
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
if (
|
|
750
|
-
|
|
302
|
+
import * as errors from "@superbuilders/errors"
|
|
303
|
+
import { ErrAuthPopupBlocked, ErrTokenExpired } from "@superbuilders/primer-tives/errors"
|
|
304
|
+
|
|
305
|
+
const result = await errors.try(
|
|
306
|
+
create({
|
|
307
|
+
origin,
|
|
308
|
+
publishableKey,
|
|
309
|
+
subject: "math",
|
|
310
|
+
supportedPcis: ["urn:primer:pci:fraction-input"],
|
|
311
|
+
logger
|
|
312
|
+
})
|
|
313
|
+
)
|
|
314
|
+
if (result.error) {
|
|
315
|
+
if (errors.is(result.error, ErrAuthPopupBlocked)) {
|
|
316
|
+
renderStartButtonAgain()
|
|
317
|
+
return
|
|
318
|
+
}
|
|
319
|
+
if (errors.is(result.error, ErrTokenExpired)) {
|
|
320
|
+
renderSignInAgain()
|
|
321
|
+
return
|
|
322
|
+
}
|
|
323
|
+
throw result.error
|
|
751
324
|
}
|
|
752
325
|
```
|
|
753
326
|
|
|
754
|
-
##
|
|
327
|
+
## Testing
|
|
755
328
|
|
|
756
|
-
|
|
329
|
+
Use a custom fetch to test runtime behavior without a live Primer server.
|
|
757
330
|
|
|
758
331
|
```ts
|
|
759
|
-
const
|
|
760
|
-
|
|
761
|
-
|
|
332
|
+
const fetchMock: typeof globalThis.fetch = async function fetchPrimer() {
|
|
333
|
+
return new Response(
|
|
334
|
+
JSON.stringify({
|
|
335
|
+
outcome: "advanced",
|
|
336
|
+
frame: { body: [], stimulus: null, interaction: null }
|
|
337
|
+
}),
|
|
338
|
+
{ status: 200, headers: { "Content-Type": "application/json" } }
|
|
339
|
+
)
|
|
762
340
|
}
|
|
763
|
-
```
|
|
764
|
-
|
|
765
|
-
Checks include:
|
|
766
341
|
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
The built-in browser state objects call this before submitting. Custom renderers that bypass state methods should call it themselves.
|
|
777
|
-
|
|
778
|
-
## Review Types
|
|
779
|
-
|
|
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>
|
|
342
|
+
const state = await create({
|
|
343
|
+
origin: "https://primer.test",
|
|
344
|
+
publishableKey: "pk_test",
|
|
345
|
+
accessToken: "eyJ.test.token",
|
|
346
|
+
subject: "vocabulary",
|
|
347
|
+
fetch: fetchMock,
|
|
348
|
+
logger
|
|
349
|
+
})
|
|
790
350
|
```
|
|
791
351
|
|
|
792
|
-
|
|
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`.
|
|
352
|
+
## Security Model
|
|
804
353
|
|
|
805
|
-
|
|
354
|
+
The browser may hold:
|
|
806
355
|
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
import {
|
|
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
|
|
830
|
-
} from "@superbuilders/primer-tives/errors"
|
|
356
|
+
```txt
|
|
357
|
+
publishable key
|
|
358
|
+
Cognito/OIDC access token
|
|
831
359
|
```
|
|
832
360
|
|
|
833
|
-
|
|
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`. |
|
|
361
|
+
The publishable key identifies the Primer frontend. It does not authenticate the learner.
|
|
880
362
|
|
|
881
|
-
|
|
363
|
+
The access token authenticates the learner. Primer verifies it server-side before touching routing or learning state.
|
|
882
364
|
|
|
883
|
-
|
|
884
|
-
import type { PrimerLogger } from "@superbuilders/primer-tives/logger"
|
|
365
|
+
Primer does not need:
|
|
885
366
|
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
367
|
+
```txt
|
|
368
|
+
learner email
|
|
369
|
+
verified email
|
|
370
|
+
Primer learner JWT
|
|
371
|
+
frontend secret key for learner runtime
|
|
372
|
+
game backend token exchange
|
|
892
373
|
```
|
|
893
374
|
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
```ts
|
|
897
|
-
import * as logger from "@superbuilders/slog"
|
|
375
|
+
## Final Invariants
|
|
898
376
|
|
|
899
|
-
|
|
900
|
-
|
|
377
|
+
```txt
|
|
378
|
+
create is the only public lifecycle operation
|
|
379
|
+
create returns Promise<PrimerState>
|
|
380
|
+
accessToken present skips hosted auth
|
|
381
|
+
accessToken absent uses hosted auth
|
|
382
|
+
subject is optional; omitted means internal all-subject scope
|
|
383
|
+
subject determines required renderer PCI capabilities
|
|
384
|
+
supportedPcis declares renderer PCI capabilities
|
|
385
|
+
PrimerState is the learning state machine
|
|
386
|
+
only valid state variants expose learning transitions
|
|
901
387
|
```
|
|
902
388
|
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
```ts
|
|
906
|
-
import { GRADE_LEVELS } from "@superbuilders/primer-tives/grade-level"
|
|
907
|
-
import type { GradeLevel } from "@superbuilders/primer-tives/grade-level"
|
|
908
|
-
|
|
909
|
-
const GRADE_LEVELS = ["K", "1", "2", "3", "4", "5", "6", "7", "8", "9", "10", "11", "12"] as const
|
|
910
|
-
type GradeLevel = (typeof GRADE_LEVELS)[number]
|
|
911
|
-
```
|
|
912
|
-
|
|
913
|
-
The server SDK no longer accepts a grade argument. Primer asks the learner for selected grade inside the browser runtime if needed.
|
|
914
|
-
|
|
915
|
-
# Onboarding Grade Selection
|
|
916
|
-
|
|
917
|
-
Grade selection is not a separate SDK API.
|
|
918
|
-
|
|
919
|
-
If the learner's email profile has no selected grade yet, the first `/advance` response is a normal `choice` interaction:
|
|
920
|
-
|
|
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
|
|
952
|
-
|
|
953
|
-
The current SDK does not expose:
|
|
954
|
-
|
|
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
|
|
963
|
-
|
|
964
|
-
Those omissions are intentional. The public integration point is one backend method, `getToken`, and one browser state machine, `create(...).start()`.
|
|
389
|
+
Keep these concepts separate. The publishable key is not learner auth. The access token is not content authorization. PCI support is not negotiated implicitly. The state object is not serializable.
|