@superbuilders/primer-tives 1.2.0 → 2.2.0

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