@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.
Files changed (48) hide show
  1. package/README.md +239 -814
  2. package/dist/client/auth/access-token.d.ts +10 -0
  3. package/dist/client/auth/access-token.d.ts.map +1 -0
  4. package/dist/client/auth/browser.d.ts +20 -0
  5. package/dist/client/auth/browser.d.ts.map +1 -0
  6. package/dist/client/auth/callback.d.ts +10 -0
  7. package/dist/client/auth/callback.d.ts.map +1 -0
  8. package/dist/client/auth/hosted-popup.d.ts +14 -0
  9. package/dist/client/auth/hosted-popup.d.ts.map +1 -0
  10. package/dist/client/auth/provider.d.ts +14 -0
  11. package/dist/client/auth/provider.d.ts.map +1 -0
  12. package/dist/client/auth/storage.d.ts +9 -0
  13. package/dist/client/auth/storage.d.ts.map +1 -0
  14. package/dist/client/create.d.ts +22 -25
  15. package/dist/client/create.d.ts.map +1 -1
  16. package/dist/client/create.type-test.d.ts +2 -0
  17. package/dist/client/create.type-test.d.ts.map +1 -0
  18. package/dist/client/index.d.ts +1 -1
  19. package/dist/client/index.d.ts.map +1 -1
  20. package/dist/client/index.js +336 -74
  21. package/dist/client/index.js.map +14 -10
  22. package/dist/client/runtime-subject.d.ts +4 -0
  23. package/dist/client/runtime-subject.d.ts.map +1 -0
  24. package/dist/client/session.d.ts +2 -2
  25. package/dist/client/session.d.ts.map +1 -1
  26. package/dist/client/transport.d.ts +6 -4
  27. package/dist/client/transport.d.ts.map +1 -1
  28. package/dist/errors.d.ts +7 -3
  29. package/dist/errors.d.ts.map +1 -1
  30. package/dist/errors.js +14 -6
  31. package/dist/errors.js.map +3 -3
  32. package/dist/subject-pcis.d.ts +11 -5
  33. package/dist/subject-pcis.d.ts.map +1 -1
  34. package/dist/subject-pcis.js +39 -0
  35. package/dist/subject-pcis.js.map +11 -0
  36. package/dist/subject.d.ts +1 -2
  37. package/dist/subject.d.ts.map +1 -1
  38. package/dist/subject.js.map +1 -1
  39. package/dist/version.d.ts +1 -1
  40. package/package.json +2 -6
  41. package/dist/server/create-server.d.ts +0 -17
  42. package/dist/server/create-server.d.ts.map +0 -1
  43. package/dist/server/exchange.d.ts +0 -16
  44. package/dist/server/exchange.d.ts.map +0 -1
  45. package/dist/server/index.d.ts +0 -3
  46. package/dist/server/index.d.ts.map +0 -1
  47. package/dist/server/index.js +0 -173
  48. 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 package gives you two runtime surfaces:
5
+ The public lifecycle is one async call:
6
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.
7
+ ```txt
8
+ create(options) -> Promise<PrimerState>
9
+ ```
9
10
 
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.
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
- The SDK uses sentinel errors from `@superbuilders/errors`. Use `errors.try()` to capture failures and `errors.is()` to classify them.
17
-
18
- ## Package Version
17
+ ## Version
19
18
 
20
- The current SDK version is `2.2.1`.
19
+ The current SDK version is `3.5.0`.
21
20
 
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`.
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. Pick the subpath for the runtime boundary you are on.
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
- | 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 |
48
-
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, ... })`.
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
- Primer stores a secret-keyed SHA-512/256 digest of normalized email, not raw email. The browser never sees your Primer secret key and never sends the email to `/advance`.
37
+ ## Quick Start
62
38
 
63
- ## End-To-End Backend Example
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 { createPrimerServer } from "@superbuilders/primer-tives/server"
69
- import { ErrBadRequest, ErrInvalidSecretKey, ErrNetwork, ErrTimeout } from "@superbuilders/primer-tives/errors"
43
+ import { create } from "@superbuilders/primer-tives/client"
70
44
 
71
- const primer = createPrimerServer({
45
+ const state = await create({
72
46
  origin: "https://primerlearn.dev",
73
- secretKey: process.env.PRIMER_SECRET_KEY,
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
- ## End-To-End Browser Example
54
+ If you already have a Cognito/OIDC access token, pass it directly:
106
55
 
107
56
  ```ts
108
- import * as logger from "@superbuilders/slog"
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
- ## `createPrimerServer(config)`
67
+ Vocabulary has no required PCI capability, so `supportedPcis` can be omitted:
162
68
 
163
69
  ```ts
164
- interface PrimerServerConfig {
165
- readonly origin: string
166
- readonly secretKey: string
167
- readonly fetch?: typeof globalThis.fetch
168
- readonly abort?: AbortController
169
- readonly logger: PrimerLogger
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
- ## `create(config)`
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
- function create<const S extends SubjectScope, const Pcis extends PciId = never>(
285
- config: Config<S, Pcis>
286
- ): Client<PciId>
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
- Config shape:
89
+ ## PrimerOptions
290
90
 
291
91
  ```ts
292
- type Config<S extends SubjectScope, Pcis extends PciId = never> = {
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
- } & PciConfigForSubject<S, Pcis>
101
+ }
300
102
  ```
301
103
 
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. |
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
- `create` performs local checks before any network request:
122
+ Hosted auth storage, callback parsing, popup details, and state handling are SDK internals.
315
123
 
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`.
124
+ ## Subject And PCI Contract
318
125
 
319
- ## `Client`
126
+ `subject` selects content scope and determines required renderer capabilities.
320
127
 
321
- ```ts
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
- `start()` sends the first observation request. It is idempotent: multiple calls return the same pending promise.
130
+ The SDK enforces subject-required PCI capability in TypeScript:
328
131
 
329
- ## Subject Scopes
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
- ```ts
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
- const SUBJECTS = ["math", "vocabulary", "science"] as const
336
- type Subject = (typeof SUBJECTS)[number]
337
- type SubjectScope = Subject | "all"
141
+ ```txt
142
+ required PCIs for subject ⊆ supportedPcis
338
143
  ```
339
144
 
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.
145
+ Order does not matter. Extra supported PCIs are allowed.
348
146
 
349
- ## Required PCIs
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
- import { missingPcisForSubject, requiredPcisForSubject } from "@superbuilders/primer-tives/subject-pcis"
355
-
356
- requiredPcisForSubject("math")
357
- // ["urn:primer:pci:fraction-input"]
150
+ await create({
151
+ origin,
152
+ publishableKey,
153
+ subject: "math",
154
+ logger
155
+ })
358
156
  ```
359
157
 
360
- For math or `all`, pass a `supportedPcis` literal array that includes `"urn:primer:pci:fraction-input"`.
158
+ This passes:
361
159
 
362
160
  ```ts
363
- const client = create({
161
+ await create({
364
162
  origin,
365
- accessToken,
163
+ publishableKey,
366
164
  subject: "math",
367
165
  supportedPcis: ["urn:primer:pci:fraction-input"],
368
166
  logger
369
167
  })
370
168
  ```
371
169
 
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.
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
- # `PrimerState`
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
- `PrimerState` is a discriminated union over `phase`.
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
- Every state is intentionally non-serializable. It contains closures for advancing, submitting, retrying, and deduplicating in-flight calls. Do not store it in localStorage, persist it through JSON, or treat it as data. Keep it in memory and call `client.start()` again after reload.
389
-
390
- ## `ObservationState`
391
-
392
- ```ts
393
- interface ObservationState<Pcis extends PciId = PciId> extends NonSerializable {
394
- readonly phase: "observation"
395
- readonly body: ContentBlock[]
396
- readonly stimulus: RendererStimulus | null
397
- advance(): Promise<PrimerState<Pcis>>
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
- ### Order
490
-
491
- ```ts
492
- interface OrderState<Pcis extends PciId = PciId> extends NonSerializable {
493
- readonly phase: "interaction"
494
- readonly kind: "order"
495
- readonly choices: RendererChoice[]
496
- readonly minChoices: number
497
- readonly maxChoices: number
498
- submitOrder(orderedKeys: string[]): Promise<PrimerState<Pcis>>
499
- timeout(): Promise<PrimerState<Pcis>>
500
- }
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
- Submit identifiers in learner-selected order.
203
+ The state object is live and non-serializable. Do not put it in storage, JSON, or server data.
504
204
 
505
- ### Match
205
+ ## Runtime Loop
506
206
 
507
207
  ```ts
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>>
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
- ```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>>
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
- ```ts
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>>
238
+ if (state.phase === "fatal") {
239
+ throw state.error
577
240
  }
578
241
  ```
579
242
 
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.
243
+ ## PCI Type Safety
581
244
 
582
- ## `CompletedState`
245
+ Portable Custom Interactions are typed by PCI id.
583
246
 
584
247
  ```ts
585
- interface CompletedState extends NonSerializable {
586
- readonly phase: "completed"
587
- }
588
- ```
248
+ import type { PciProps, PciValue } from "@superbuilders/primer-tives/contracts"
589
249
 
590
- Terminal state. There is no action method.
591
-
592
- ## `ErroredState`
593
-
594
- ```ts
595
- interface ErroredState<Pcis extends PciId = PciId> extends NonSerializable {
596
- readonly phase: "errored"
597
- readonly error: Error
598
- readonly retriable: boolean
599
- retry(): Promise<PrimerState<Pcis>>
600
- }
250
+ type FractionProps = PciProps<"urn:primer:pci:fraction-input">
251
+ type FractionValue = PciValue<"urn:primer:pci:fraction-input">
601
252
  ```
602
253
 
603
- `retry()` re-runs the exact same intent that failed. If `retriable` is false, `retry()` resolves to the same errored state.
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
- interface FatalState extends NonSerializable {
609
- readonly phase: "fatal"
610
- readonly error: Error
611
- readonly retriable: false
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
- 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.
616
-
617
- # `/contracts`
265
+ ## Subject PCI Helpers
618
266
 
619
- The contracts subpath contains renderer wire shapes and helpers shared between custom renderers and server-adjacent tooling.
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
- ## Content
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
- Helpers:
275
+ `requiredPcisForSubject(undefined)` returns the all-subject required PCI union.
659
276
 
660
- ```ts
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
- ## Stimulus
279
+ Errors are sentinel values from `@superbuilders/errors`.
668
280
 
669
- ```ts
670
- interface ImageStimulus {
671
- kind: "image"
672
- alt: ContentInline[]
673
- src: string
674
- }
281
+ Common sentinels:
675
282
 
676
- type RendererStimulus = ImageStimulus
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
- | `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:
702
-
703
- ```ts
704
- interface RendererChoice {
705
- identifier: string
706
- content: ContentInline[]
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
- ChoiceSubmissionSchema
737
- TextEntrySubmissionSchema
738
- ExtendedTextSubmissionSchema
739
- OrderSubmissionSchema
740
- MatchSubmissionSchema
741
- FractionInputPciSubmissionSchema
742
- RendererSubmissionSchema
743
- ```
744
-
745
- Always use `safeParse` when parsing arbitrary input:
746
-
747
- ```ts
748
- const parsed = RendererSubmissionSchema.safeParse(payload)
749
- if (!parsed.success) {
750
- throw parsed.error
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
- ## Semantic Validation
327
+ ## Testing
755
328
 
756
- `RendererSubmissionSchema` validates shape. `validateSubmissionForInteraction` validates a submission against a specific interaction.
329
+ Use a custom fetch to test runtime behavior without a live Primer server.
757
330
 
758
331
  ```ts
759
- const validation = validateSubmissionForInteraction(interaction, submission)
760
- if (!validation.ok) {
761
- throw errors.wrap(ErrInvalidSubmission, submissionValidationMessage(validation))
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
- - 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
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
- 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`.
352
+ ## Security Model
804
353
 
805
- # `/errors`
354
+ The browser may hold:
806
355
 
807
- All sentinels are exported from `/errors`.
808
-
809
- ```ts
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
- ## 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`. |
361
+ The publishable key identifies the Primer frontend. It does not authenticate the learner.
880
362
 
881
- # `/logger`
363
+ The access token authenticates the learner. Primer verifies it server-side before touching routing or learning state.
882
364
 
883
- ```ts
884
- import type { PrimerLogger } from "@superbuilders/primer-tives/logger"
365
+ Primer does not need:
885
366
 
886
- interface PrimerLogger {
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
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
- 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"
375
+ ## Final Invariants
898
376
 
899
- const primer = createPrimerServer({ origin, secretKey, logger })
900
- const client = create({ origin, accessToken, subject: "vocabulary", logger })
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
- # `/grade-level`
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.