@superbuilders/primer-tives 0.6.0 → 0.8.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (73) hide show
  1. package/README.md +280 -628
  2. package/dist/client/choice-state.d.ts.map +1 -0
  3. package/dist/client/consumed.d.ts.map +1 -0
  4. package/dist/client/content.d.ts.map +1 -0
  5. package/dist/{client.d.ts → client/create.d.ts} +4 -4
  6. package/dist/client/create.d.ts.map +1 -0
  7. package/dist/client/extended-text-state.d.ts.map +1 -0
  8. package/dist/client/feedback-state.d.ts.map +1 -0
  9. package/dist/{index.d.ts → client/index.d.ts} +7 -7
  10. package/dist/{index.d.ts.map → client/index.d.ts.map} +1 -1
  11. package/dist/{index.js → client/index.js} +50 -24
  12. package/dist/client/index.js.map +24 -0
  13. package/dist/client/match-state.d.ts.map +1 -0
  14. package/dist/client/observation-state.d.ts.map +1 -0
  15. package/dist/client/order-state.d.ts.map +1 -0
  16. package/dist/client/pci-state.d.ts.map +1 -0
  17. package/dist/client/pci.d.ts.map +1 -0
  18. package/dist/{session-context.d.ts → client/session-context.d.ts} +4 -7
  19. package/dist/client/session-context.d.ts.map +1 -0
  20. package/dist/{session.d.ts → client/session.d.ts} +3 -2
  21. package/dist/client/session.d.ts.map +1 -0
  22. package/dist/client/text-entry-state.d.ts.map +1 -0
  23. package/dist/{transport.d.ts → client/transport.d.ts} +3 -3
  24. package/dist/client/transport.d.ts.map +1 -0
  25. package/dist/client/types.d.ts.map +1 -0
  26. package/dist/errors.d.ts +5 -1
  27. package/dist/errors.d.ts.map +1 -1
  28. package/dist/grade-level.d.ts +5 -0
  29. package/dist/grade-level.d.ts.map +1 -0
  30. package/dist/logger.d.ts +8 -0
  31. package/dist/logger.d.ts.map +1 -0
  32. package/dist/server/create-server.d.ts +42 -0
  33. package/dist/server/create-server.d.ts.map +1 -0
  34. package/dist/server/exchange.d.ts +17 -0
  35. package/dist/server/exchange.d.ts.map +1 -0
  36. package/dist/server/index.d.ts +8 -0
  37. package/dist/server/index.d.ts.map +1 -0
  38. package/dist/server/index.js +259 -0
  39. package/dist/server/index.js.map +14 -0
  40. package/dist/server/students.d.ts +14 -0
  41. package/dist/server/students.d.ts.map +1 -0
  42. package/dist/subject.d.ts +1 -1
  43. package/dist/subject.d.ts.map +1 -1
  44. package/package.json +8 -4
  45. package/dist/choice-state.d.ts.map +0 -1
  46. package/dist/client.d.ts.map +0 -1
  47. package/dist/consumed.d.ts.map +0 -1
  48. package/dist/content.d.ts.map +0 -1
  49. package/dist/extended-text-state.d.ts.map +0 -1
  50. package/dist/feedback-state.d.ts.map +0 -1
  51. package/dist/index.js.map +0 -24
  52. package/dist/match-state.d.ts.map +0 -1
  53. package/dist/observation-state.d.ts.map +0 -1
  54. package/dist/order-state.d.ts.map +0 -1
  55. package/dist/pci-state.d.ts.map +0 -1
  56. package/dist/pci.d.ts.map +0 -1
  57. package/dist/session-context.d.ts.map +0 -1
  58. package/dist/session.d.ts.map +0 -1
  59. package/dist/text-entry-state.d.ts.map +0 -1
  60. package/dist/transport.d.ts.map +0 -1
  61. package/dist/types.d.ts.map +0 -1
  62. /package/dist/{choice-state.d.ts → client/choice-state.d.ts} +0 -0
  63. /package/dist/{consumed.d.ts → client/consumed.d.ts} +0 -0
  64. /package/dist/{content.d.ts → client/content.d.ts} +0 -0
  65. /package/dist/{extended-text-state.d.ts → client/extended-text-state.d.ts} +0 -0
  66. /package/dist/{feedback-state.d.ts → client/feedback-state.d.ts} +0 -0
  67. /package/dist/{match-state.d.ts → client/match-state.d.ts} +0 -0
  68. /package/dist/{observation-state.d.ts → client/observation-state.d.ts} +0 -0
  69. /package/dist/{order-state.d.ts → client/order-state.d.ts} +0 -0
  70. /package/dist/{pci-state.d.ts → client/pci-state.d.ts} +0 -0
  71. /package/dist/{pci.d.ts → client/pci.d.ts} +0 -0
  72. /package/dist/{text-entry-state.d.ts → client/text-entry-state.d.ts} +0 -0
  73. /package/dist/{types.d.ts → client/types.d.ts} +0 -0
package/README.md CHANGED
@@ -1,21 +1,6 @@
1
1
  # @superbuilders/primer-tives
2
2
 
3
- Client SDK for the Primer adaptive learning engine.
4
-
5
- > **Breaking change**: Auth is a server-minted Primer JWT access token. `create()` takes `accessToken` (not `publishableKey`), and `client.start()` is parameterless — the student identity lives in the token's `sub` claim. Mint tokens on your backend via `POST /api/v0/auth/exchange` with your `sk_` and either a Primer-minted `student_id` (provider `native`) or a Timeback OneRoster `sourcedId` (provider `timeback`). The exchange body now uses `student_id` in place of the old `sourced_id`.
6
-
7
- This package turns the Primer HTTP protocol into a strongly typed, server-driven state machine. Your app renders the current frame, calls the method that is valid for that frame, and the server decides what comes next.
8
-
9
- The SDK does **not** know about routines, curricula, storage schemas, or routing rules. It only knows how to:
10
-
11
- - start a student session
12
- - submit answers or timeouts
13
- - normalize transport failures into sentinel errors plus `retriable` semantics
14
- - expose the current step as an ergonomic TypeScript union
15
- - expose interaction-native review data for feedback rendering
16
- - preserve full type safety for portable custom interactions (PCIs)
17
-
18
- ## Install
3
+ TypeScript SDK for the Primer adaptive learning engine. Wraps the raw HTTP protocol in two tiny, fully typed surfaces — one for your backend, one for the browser — so it's mechanically impossible to misuse.
19
4
 
20
5
  ```sh
21
6
  bun add @superbuilders/primer-tives
@@ -23,38 +8,63 @@ bun add @superbuilders/primer-tives
23
8
 
24
9
  Dependency: `@superbuilders/errors` is installed automatically.
25
10
 
26
- ## What you get
11
+ ## Two entrypoints
12
+
13
+ The package ships two subpaths. There is no root export — you must pick the side of the wire you're on:
27
14
 
28
- - **Single entry point**: `create(config)`
29
- - **Single wire endpoint**: `POST ${origin}/api/v0/advance`
30
- - **Single runtime model**: `PrimerState`
31
- - **Six phases/kinds to handle**:
32
- - phases: `observation`, `interaction`, `feedback`, `completed`, `errored`, `fatal`
33
- - interaction kinds: `choice`, `text-entry`, `extended-text`, `order`, `match`, `portable-custom`
34
- - **Typed PCI submissions** driven by the `PciRegistry`
35
- - **Live in-memory state objects** with action methods like `advance()`, `submitChoice()`, `submitOrder()`, `submitMatch()`, `submit()`, `timeout()`, and `retry()`
15
+ | Import | Runs on | Wraps |
16
+ |---|---|---|
17
+ | `@superbuilders/primer-tives/server` | your backend | `POST /api/v0/auth/exchange`, `POST /api/v0/students`, `PATCH /api/v0/students/{id}` |
18
+ | `@superbuilders/primer-tives/client` | the browser | `POST /api/v0/advance` (the lesson state machine) |
36
19
 
37
- ## Quick start
20
+ No route strings, no `fetch()` calls, no snake_case wire bodies on your side. Both surfaces normalize transport failures into sentinel errors from `@superbuilders/errors`.
21
+
22
+ ## Round trip
23
+
24
+ Your backend provisions a student (once), exchanges a `sk_` for a short-lived access token (each session), and hands the token to your frontend. Your frontend passes the token to `create()` and drives the returned state machine.
38
25
 
39
26
  ```ts
27
+ // ── your backend ─────────────────────────────────────────────────────
40
28
  import * as errors from "@superbuilders/errors"
29
+ import { createPrimerServer } from "@superbuilders/primer-tives/server"
30
+
31
+ const primer = createPrimerServer({
32
+ origin: "https://sb-primer.vercel.app",
33
+ secretKey: process.env.PRIMER_CLIENT_SECRET_KEY_DEV
34
+ })
35
+
36
+ // One-time per user: provision a Primer-owned student.
37
+ const studentId = await primer.createNativeStudent("4")
38
+ // persist `studentId` alongside your user record
39
+
40
+ // Every session: mint a short-lived access token.
41
+ const result = await errors.try(
42
+ primer.exchangeNativeStudentForAccessToken(studentId)
43
+ )
44
+ if (result.error) {
45
+ // map ErrInvalidSecretKey / ErrStudentNotFound / ErrServerError etc.
46
+ throw result.error
47
+ }
48
+ const { accessToken, expiresInSeconds } = result.data
49
+ // ship `accessToken` to the browser
50
+ ```
51
+
52
+ ```ts
53
+ // ── your frontend ────────────────────────────────────────────────────
41
54
  import {
42
55
  create,
43
56
  ErrRateLimited,
44
- type PrimerState,
45
- } from "@superbuilders/primer-tives"
57
+ type PrimerState
58
+ } from "@superbuilders/primer-tives/client"
46
59
 
47
- // Mint accessToken on your backend via POST /api/v0/auth/exchange
48
- // (sk_ + { provider, student_id }) then pass it here. Tokens are short-lived
49
- // (15 min default).
50
60
  const client = create({
51
- accessToken: "<primer JWS minted by /auth/exchange>",
61
+ accessToken, // from your backend
52
62
  origin: "https://sb-primer.vercel.app",
63
+ subject: "math",
53
64
  supportedPcis: [
54
65
  "urn:primer:pci:division-remainder",
55
- "urn:primer:pci:fraction-addition",
56
- ],
57
- subject: "math",
66
+ "urn:primer:pci:fraction-addition"
67
+ ]
58
68
  })
59
69
 
60
70
  let state: PrimerState = await client.start()
@@ -65,569 +75,281 @@ while (state.phase !== "completed" && state.phase !== "fatal") {
65
75
  renderStimulus(state.stimulus)
66
76
  state = await state.advance()
67
77
  break
68
-
69
78
  case "interaction":
70
- renderStimulus(state.stimulus)
71
-
72
- switch (state.kind) {
73
- case "choice":
74
- state = await state.submitChoice(["option-a"])
75
- break
76
-
77
- case "text-entry":
78
- state = await state.submitText("42")
79
- break
80
-
81
- case "extended-text":
82
- if (state.cardinality === "single") {
83
- state = await state.submitText("answer")
84
- } else {
85
- state = await state.submitTexts(["first", "second"])
86
- }
87
- break
88
-
89
- case "order":
90
- state = await state.submitOrder(["choice-1", "choice-2", "choice-3"])
91
- break
92
-
93
- case "match":
94
- state = await state.submitMatch([
95
- { source: "left-1", target: "right-2" },
96
- { source: "left-2", target: "right-1" },
97
- ])
98
- break
99
-
100
- case "portable-custom":
101
- switch (state.pciId) {
102
- case "urn:primer:pci:division-remainder":
103
- state = await state.submit({ quotient: "3", remainder: "1" })
104
- break
105
- case "urn:primer:pci:fraction-addition":
106
- state = await state.submit({ numerator: "5", denominator: "6" })
107
- break
108
- }
109
- break
110
- }
79
+ state = await runInteraction(state)
111
80
  break
112
-
113
81
  case "feedback":
114
- renderFeedback({
115
- correct: state.isCorrect,
116
- content: state.feedbackContent,
117
- review: state.review,
118
- interaction: state.interaction,
119
- submission: state.submission,
120
- })
82
+ renderFeedback(state.feedbackContent, state.isCorrect)
121
83
  state = await state.advance()
122
84
  break
123
-
124
85
  case "errored":
125
- if (!state.retriable) {
86
+ if (state.retriable) {
87
+ state = await state.retry()
88
+ } else {
126
89
  throw state.error
127
90
  }
128
- if (errors.is(state.error, ErrRateLimited)) {
129
- await delay(1000)
130
- }
131
- state = await state.retry()
132
91
  break
133
92
  }
134
93
  }
135
94
  ```
136
95
 
137
- Both `state.phase` and `state.kind` are discriminated unions, so TypeScript narrows automatically inside each branch.
138
-
139
- ## Mental model
140
-
141
- Primer is **server-authored**.
142
-
143
- - The client sends an **intent**: advance, submit, or timeout.
144
- - The server evaluates that intent and returns the next frame.
145
- - The SDK wraps that frame in a typed state object.
146
- - Your UI renders the state and calls the next valid method.
96
+ ---
147
97
 
148
- In other words, your code does not calculate progression locally. The server owns progression; the SDK owns transport, typing, and ergonomics.
149
-
150
- ## Configuration
98
+ # `/server`
151
99
 
152
100
  ```ts
153
- interface Config<Pcis extends PciId = PciId> {
154
- readonly accessToken: string
155
- readonly supportedPcis: readonly Pcis[]
156
- readonly origin: string
157
- readonly subject: Subject | "all"
158
- readonly fetch?: typeof globalThis.fetch
159
- readonly abort?: AbortController
160
- readonly logger?: PrimerLogger
161
- }
162
-
163
- type Subject = "math" | "vocabulary"
101
+ import {
102
+ createPrimerServer,
103
+ type PrimerServer,
104
+ type PrimerServerConfig,
105
+ type SessionToken,
106
+ type GradeLevel,
107
+ GRADE_LEVELS
108
+ } from "@superbuilders/primer-tives/server"
164
109
  ```
165
110
 
166
- | Field | Required | Description |
167
- |---|---|---|
168
- | `accessToken` | yes | Primer JWT minted by your backend via `POST /api/v0/auth/exchange`. Sent as `Authorization: Bearer <jwt>`. Short-lived (15 min default). |
169
- | `supportedPcis` | yes | PCI URNs the renderer can handle. Use `[]` if none |
170
- | `origin` | yes | Full Primer API base URL, for example `https://sb-primer.vercel.app` |
171
- | `subject` | yes | Subject scope for this session. `"math"` or `"vocabulary"` restricts drill selection to courses of that subject; `"all"` disables the filter |
172
- | `fetch` | no | Custom fetch implementation. Defaults to `globalThis.fetch` |
173
- | `abort` | no | `AbortController` whose `signal` is passed to every request |
174
- | `logger` | no | Structured logger with `debug`, `info`, `warn`, and `error` methods |
175
-
176
- `create()` does a cheap structural check on the token (must start with `eyJ` and contain exactly two dots) and throws `ErrMalformedAccessToken` if the shape is wrong. Signature verification happens on the server.
177
-
178
- ### What `subject` controls
179
-
180
- `subject` is sent on every `/advance` request and narrows the set of drill courses the server can serve:
181
-
182
- - `"math"` / `"vocabulary"` — only drills whose `drill_courses.subject` matches are eligible. The bootstrap router, trigger-driven re-routes, and active-frame lookups all respect the scope.
183
- - `"all"` — no filter; the server treats the session as federated and may serve drills from any subject the frontend is bound to.
184
-
185
- The scope is **per-session**. To switch subjects, construct a new client with a different `subject`; you cannot mutate it on an existing client. Student placements are isolated per subject: a student with an in-progress math placement resumes it when a math-scoped client reconnects, and a vocabulary-scoped client will bootstrap a new placement in that subject without disturbing the math one. If a student submits or times out under a scope that does not match their current placement, the server reports no active frame and the client's next observation bootstraps a fresh placement in the declared subject.
186
-
187
- Curriculum routing is not affected by `subject` in this release — only drill selection is scoped.
188
-
189
- ### Credentials
190
-
191
- Each integration has one credential pair:
192
-
193
- - **`client_id`** — your Primer frontend ID (a UUIDv7). Not currently required in request bodies; it's embedded in the `sk_` record server-side and surfaced as the `tenant_id` claim on minted JWTs.
194
- - **`client_secret`** — your `sk_` secret key. Sent as `Authorization: Bearer sk_…` on both `/api/v0/students` and `/api/v0/auth/exchange`. Stored only as a SHA-256 hash at rest — the raw value is returned exactly once at generation time.
195
-
196
- Never expose `sk_` keys to the browser. Use them only from your backend.
197
-
198
- ### Minting a student (native provider)
199
-
200
- If your integration does not front a Timeback OneRoster tenant, mint a Primer-native student for each of your users and persist the returned `student_id`. Your users exist in your system; Primer just needs a stable UUID to attach session state to.
111
+ ## `createPrimerServer(config)`
201
112
 
202
- ```sh
203
- curl -X POST "$ORIGIN/api/v0/students" \
204
- -H "Authorization: Bearer $PRIMER_SECRET_KEY" \
205
- -H "Content-Type: application/json" \
206
- -d '{"grade_level":"9"}'
207
- ```
208
-
209
- Response:
113
+ ```ts
114
+ interface PrimerServerConfig {
115
+ readonly origin: string // e.g. https://sb-primer.vercel.app (no trailing slash)
116
+ readonly secretKey: string // your sk_… key
117
+ readonly fetch?: typeof globalThis.fetch // override (defaults to globalThis.fetch)
118
+ readonly abort?: AbortController // wired into every request signal
119
+ readonly logger?: PrimerLogger // optional structured logger
120
+ }
210
121
 
211
- ```json
212
- { "student_id": "019d3e6f-5f6d-7000-8000-..." }
122
+ function createPrimerServer(config: PrimerServerConfig): PrimerServer
213
123
  ```
214
124
 
215
- - `grade_level` is required. Valid values: `"K"`, `"1"`, `"2"`, …, `"12"`. Primer routes lessons by grade, so it must be set at mint time.
216
- - The returned `student_id` is owned by the frontend the `sk_` belongs to. Another frontend's `sk_` cannot exchange for it.
217
- - Store the `student_id` in your own database keyed by your user. It does not change.
218
-
219
- ### Minting an access token
125
+ Returns a `PrimerServer` with four methods. Each method throws a sentinel-wrapped `Error` use `errors.try()` from `@superbuilders/errors` at the call site.
220
126
 
221
- `POST /api/v0/auth/exchange` takes your `sk_` bearer and a body describing which student you want a token for.
127
+ ### `createNativeStudent(gradeLevel): Promise<string>`
222
128
 
223
- **Native** (you minted the student via `POST /api/v0/students` above):
129
+ Provision a new Primer-owned student. Returns the `studentId` string — persist it in your own database keyed by your user. Call this **once per user**.
224
130
 
225
- ```sh
226
- curl -X POST "$ORIGIN/api/v0/auth/exchange" \
227
- -H "Authorization: Bearer $PRIMER_SECRET_KEY" \
228
- -H "Content-Type: application/json" \
229
- -d '{"provider":"native","student_id":"<primer-uuid-you-stored>"}'
131
+ ```ts
132
+ const studentId = await primer.createNativeStudent("5")
230
133
  ```
231
134
 
232
- **Timeback** (`student_id` carries a OneRoster `sourcedId`):
135
+ **Do not call this for Timeback integrations.** Timeback students are provisioned automatically on first `exchangeTimebackStudentForAccessToken`.
233
136
 
234
- ```sh
235
- curl -X POST "$ORIGIN/api/v0/auth/exchange" \
236
- -H "Authorization: Bearer $PRIMER_SECRET_KEY" \
237
- -H "Content-Type: application/json" \
238
- -d '{"provider":"timeback","student_id":"<oneroster-sourced-id>"}'
239
- ```
137
+ ### `updateNativeStudentGradeLevel(studentId, gradeLevel): Promise<void>`
240
138
 
241
- The request body is `.strict()` — both `provider` and `student_id` are required and the field names are snake_case. A successful response is:
139
+ Change a Primer student's grade level.
242
140
 
243
141
  ```ts
244
- {
245
- access_token: string, // HS256 JWS, use as Bearer for /api/v0/advance
246
- token_type: "Bearer",
247
- expires_in: 900, // seconds (15 min default)
248
- scope: "frame:advance",
249
- }
142
+ await primer.updateNativeStudentGradeLevel(studentId, "6")
250
143
  ```
251
144
 
252
- Server-side flow:
253
-
254
- 1. **Authenticate the `sk_`.** SHA-256 the bearer, look up `iam_frontend_secret_keys.key_hash` with `status = 'active'`. The matching `frontend_id` + `key_id` are captured for the token claims.
255
- 2. **Resolve the student.**
256
- - `provider: "native"` — join `iam_frontend_students` on `(frontend_id, student_id)`. If no row exists, return `404 student_not_found`. This stops one frontend from minting tokens against another frontend's UUIDs.
257
- - `provider: "timeback"` — look up `iam_student_timeback_identities` by `sourced_id`. If a row exists, reuse the linked `iam_students.id`. Otherwise call Timeback OneRoster, map the grade, and insert `iam_students` + `iam_student_timeback_identities` rows in a single transaction. If OneRoster returns 404, return `404 student_not_found`.
258
- 3. **Mint the JWS.** HS256 with claims `{ sub: <iam_students.id>, tenant_id: <frontend_id>, client_id: <key_id>, scope: "frame:advance", iss: "primer", aud: "primer-api", iat, exp, jti }`. Signed but not encrypted — tamper-proof, not opaque.
259
-
260
- Error responses:
145
+ **Do not call this for Timeback students.** Grade-level changes flow from the SIS.
261
146
 
262
- | Status | Body | Cause |
263
- | ------ | ---------------------------- | --------------------------------------------------------------------- |
264
- | 401 | `invalid_request` | Missing/malformed `sk_` bearer |
265
- | 400 | `invalid_request` | Body failed schema validation |
266
- | 404 | `student_not_found` | Native: no junction row. Timeback: OneRoster returned 404. |
267
- | 400 | `unsupported_grade` | Timeback: OneRoster returned a grade we don't route. |
268
- | 502 | `timeback_unavailable` | Timeback OneRoster / Cognito failure. |
147
+ ### `exchangeNativeStudentForAccessToken(studentId): Promise<SessionToken>`
269
148
 
270
- ### Local dev (seeded fixtures)
149
+ Mint a short-lived access token for an existing Primer-owned student. Call this **every session start**; tokens expire in 15 minutes by default.
271
150
 
272
- The Primer repo ships a seed script that populates a self-contained dev tenant so SDK integrators can develop against a real `/api/v0/auth/exchange` without any Timeback/OneRoster credentials. The seed is idempotent: every run tears down and re-creates the `Primer Dev` frontend, re-hashes the current `env.PRIMER_SECRET_KEY`, deletes any previous dev students owned by this frontend, and mints a fresh one with a random UUIDv7.
273
-
274
- Run the seed:
275
-
276
- ```sh
277
- bun db:seed
151
+ ```ts
152
+ const { accessToken, expiresInSeconds } = await primer.exchangeNativeStudentForAccessToken(studentId)
278
153
  ```
279
154
 
280
- This invokes `src/db/scripts/seed/index.ts`, which chains:
281
-
282
- - `seedFrontend()` — inserts `iam_frontends { name: "Primer Dev" }` and `iam_frontend_secret_keys { key_hash: sha256(env.PRIMER_SECRET_KEY), key_preview, name: "dev-seed", status: "active" }`.
283
- - `seedTestCourse(frontendId)` — re-inserts `iam_students { id: HARDCODED_USER.studentId }` (stable dev UUID `019d0000-0000-7000-8000-000000000001`) and an `iam_frontend_students { frontend_id, student_id }` native-ownership row, then binds the frontend to the dev curriculum course.
284
-
285
- The dev environment is native-first; the seed does **not** create a `iam_student_timeback_identities` row. If you want to exercise the timeback exchange path against the sandbox, hit it with a real OneRoster `sourcedId` (the first call will provision the junction).
286
-
287
- Three fixture values a client needs:
155
+ ### `exchangeTimebackStudentForAccessToken(sourcedId): Promise<SessionToken>`
288
156
 
289
- | Fixture | Value | Source |
290
- | ------------------ | ------------------------------------------------ | -------------------------------------------------------------------------------- |
291
- | Frontend name | `Primer Dev` | `DEV_FRONTEND_NAME` in `src/db/scripts/seed/constants.ts` |
292
- | Secret key (`sk_`) | whatever `env.PRIMER_SECRET_KEY` is at seed time | hashed at rest; the raw value is unrecoverable after seeding, so keep it in `.env` |
293
- | Dev student id | `019d0000-0000-7000-8000-000000000001` | `HARDCODED_USER.studentId` in `src/lib/auth/hardcoded-user.ts` |
157
+ Mint a short-lived access token by OneRoster `sourcedId`. The server contacts the Timeback API to verify identity and grade, auto-provisions the Primer student row on first use, and returns the token. Subsequent exchanges for the same `sourcedId` reuse the existing row.
294
158
 
295
- Mint a native access token off the seeded student:
296
-
297
- ```sh
298
- curl -X POST "$ORIGIN/api/v0/auth/exchange" \
299
- -H "Authorization: Bearer $PRIMER_SECRET_KEY" \
300
- -H "Content-Type: application/json" \
301
- -d '{"provider":"native","student_id":"019d0000-0000-7000-8000-000000000001"}'
302
- ```
303
-
304
- Expected response:
305
-
306
- ```json
307
- {
308
- "access_token": "eyJ...",
309
- "token_type": "Bearer",
310
- "expires_in": 900,
311
- "scope": "frame:advance"
312
- }
159
+ ```ts
160
+ const { accessToken, expiresInSeconds } = await primer.exchangeTimebackStudentForAccessToken(sourcedId)
313
161
  ```
314
162
 
315
- Hand `access_token` to `create({ accessToken, origin, supportedPcis })` and you're up. The repo's root `/` page does this automatically — opening it in the browser after `bun db:seed` renders the SDK against the seeded student.
316
-
317
- **Integration contract.** The seed script is the contract. As long as it runs cleanly, nothing on the client side has to change when the Primer auth model evolves — drop the DB, re-run `bun db:seed`, and the same `sk_` keeps working. If a future schema change breaks this guarantee, it breaks the seed first; fix the seed and the contract holds.
318
-
319
- ### Logger interface
163
+ ## `SessionToken`
320
164
 
321
165
  ```ts
322
- interface PrimerLogger {
323
- debug(message: string, attributes?: Record<string, unknown>): void
324
- info(message: string, attributes?: Record<string, unknown>): void
325
- warn(message: string, attributes?: Record<string, unknown>): void
326
- error(message: string, attributes?: Record<string, unknown>): void
166
+ interface SessionToken {
167
+ readonly accessToken: string // HS256 JWS pass to client SDK as Config.accessToken
168
+ readonly expiresInSeconds: number // typically 900 (15 min)
327
169
  }
328
170
  ```
329
171
 
330
- ## Wire protocol
331
-
332
- Every request is a `POST` to:
333
-
334
- ```txt
335
- ${origin}/api/v0/advance
336
- ```
172
+ Hand `accessToken` to your frontend over whatever channel you already use (cookie, HTML response, WebSocket, API response). The browser passes it to `create()`.
337
173
 
338
- The path constant is exported as:
174
+ ## `GradeLevel`
339
175
 
340
176
  ```ts
341
- const ADVANCE_PATH = "/api/v0/advance"
177
+ type GradeLevel = "K" | "1" | "2" | "3" | "4" | "5" | "6" | "7" | "8" | "9" | "10" | "11" | "12"
342
178
  ```
343
179
 
344
- ### Headers
180
+ Exported as both `GradeLevel` and the `GRADE_LEVELS` readonly tuple.
345
181
 
346
- ```txt
347
- Authorization: Bearer <primer jwt>
348
- Content-Type: application/json
349
- ```
182
+ ## Error sentinels (`/server`)
350
183
 
351
- ### Request body
184
+ | Sentinel | Raised when |
185
+ |---|---|
186
+ | `ErrInvalidSecretKey` | HTTP 401 — missing, malformed, or unknown `sk_` |
187
+ | `ErrStudentNotFound` | HTTP 404 — student doesn't exist (native) or sourced ID unknown (timeback) |
188
+ | `ErrUnsupportedGrade` | HTTP 400 — Timeback user's grade is outside K–12 |
189
+ | `ErrTimebackUnavailable` | HTTP 502 — Timeback OneRoster endpoint failed |
190
+ | `ErrBadRequest` | HTTP 400 (non-grade) — validation failure |
191
+ | `ErrServerError` | HTTP 5xx |
192
+ | `ErrJsonParse` | Success response body wasn't valid JSON |
193
+ | `ErrNetwork` | fetch() rejected (DNS, connection, TLS) |
194
+ | `ErrTimeout` | fetch() aborted (your `AbortController` or `TimeoutError`) |
195
+
196
+ All server methods follow the same pattern:
352
197
 
353
198
  ```ts
354
- interface WireRequestBody<Pcis extends PciId = PciId> {
355
- supportedPcis: readonly PciId[]
356
- intent: WireIntent<Pcis>
357
- subject: Subject | "all"
199
+ const result = await errors.try(primer.createNativeStudent("5"))
200
+ if (result.error) {
201
+ if (errors.is(result.error, ErrInvalidSecretKey)) {
202
+ // rotate the sk_ and retry
203
+ }
204
+ throw result.error
358
205
  }
206
+ const studentId = result.data
359
207
  ```
360
208
 
361
- The student identity is **not** in the body. The server reads it from the verified JWT's `sub` claim.
209
+ ---
362
210
 
363
- The `subject` field is required on every request and validated server-side. A missing or invalid value is rejected with `400 invalid_request`.
211
+ # `/client`
364
212
 
365
213
  ```ts
366
- type WireIntent<Pcis extends PciId = PciId> =
367
- | { kind: "observation" }
368
- | { kind: "interaction"; submission: RendererSubmission<Pcis> }
369
- | { kind: "timeout" }
214
+ import {
215
+ create,
216
+ type Client,
217
+ type Config,
218
+ type PrimerState,
219
+ type PrimerLogger,
220
+ type Subject,
221
+ type SubjectScope,
222
+ SUBJECTS,
223
+ ErrRateLimited,
224
+ // …sentinels + every state/interaction/content/PCI type
225
+ } from "@superbuilders/primer-tives/client"
370
226
  ```
371
227
 
372
- ### Response outcomes
373
-
374
- Conceptually, the server returns one of three outcomes:
228
+ ## `create(config)`
375
229
 
376
230
  ```ts
377
- type WireResult<Pcis extends PciId = PciId> =
378
- | {
379
- outcome: "advanced"
380
- stimulus: RendererStimulus | null
381
- interaction: RendererInteraction<Pcis> | null
382
- }
383
- | {
384
- outcome: "submitted"
385
- stimulus: RendererStimulus | null
386
- interaction: RendererInteraction<Pcis>
387
- submission: RendererSubmission<Pcis>
388
- isCorrect: boolean
389
- feedbackContent: ContentInline[]
390
- review: InteractionReview<Pcis> | null
391
- }
392
- | { outcome: "completed" }
231
+ function create<const Pcis extends PciId>(config: Config<Pcis>): Client<Pcis>
232
+
233
+ interface Config<Pcis extends PciId = PciId> {
234
+ readonly accessToken: string // JWS from /server
235
+ readonly supportedPcis: readonly Pcis[] // PCI URNs this renderer handles ([] if none)
236
+ readonly origin: string // same origin you gave /server
237
+ readonly subject: SubjectScope // "math" | "vocabulary" | "science" | "all"
238
+ readonly fetch?: typeof globalThis.fetch
239
+ readonly abort?: AbortController
240
+ readonly logger?: PrimerLogger
241
+ }
242
+
243
+ interface Client<Pcis extends PciId = PciId> {
244
+ start(): Promise<PrimerState<Pcis>> // idempotent
245
+ }
393
246
  ```
394
247
 
395
- ### Important notes
248
+ `create()` does a cheap 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.
396
249
 
397
- - `origin` must be the **full base URL**. The SDK constructs requests as `${origin}${ADVANCE_PATH}`.
398
- - `supportedPcis` is sent on every request so the server knows which portable custom interactions the client can render.
399
- - The client also uses `supportedPcis` as an inbound safety check: if the server returns a PCI frame the client did not advertise support for, the SDK returns a fatal `ErrUnsupportedPci` state.
250
+ ### `subject` scope
400
251
 
401
- ## State machine
252
+ | Value | Behavior |
253
+ |---|---|
254
+ | `"math"` / `"vocabulary"` / `"science"` | Restrict drill selection to courses of that subject |
255
+ | `"all"` | No filter; drills from any subject the frontend is bound to |
402
256
 
403
- `PrimerState` is a discriminated union on `phase`:
257
+ 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`.
404
258
 
405
- ```txt
406
- observation -> interaction -> feedback -> observation -> ... -> completed
407
- | \ | \ | \
408
- v \ v \ v \
409
- errored fatal errored fatal errored fatal
259
+ ## `PrimerState` — the state machine
410
260
 
411
- errored -> retry() -> replays exact failed action
412
- interaction timeout -> same transport path, intent kind "timeout"
261
+ `start()` returns a `PrimerState` union. Each phase has its own shape and action methods.
262
+
263
+ ```ts
264
+ type PrimerState =
265
+ | ObservationState // advance()
266
+ | InteractionState // submit…() / timeout()
267
+ | FeedbackState // advance()
268
+ | CompletedState // terminal
269
+ | ErroredState // retry() — retriable:boolean
270
+ | FatalState // terminal
413
271
  ```
414
272
 
415
273
  ### `observation`
416
274
 
417
- Display-only frame. Render `state.stimulus`, then call `advance()`.
275
+ Server wants you to show a stimulus and the student to indicate they're done reading.
418
276
 
419
277
  ```ts
420
- state.phase === "observation"
421
- state.stimulus // RendererStimulus | null
422
- state.advance() // Promise<PrimerState>
278
+ interface ObservationState {
279
+ readonly phase: "observation"
280
+ readonly stimulus: RendererStimulus | null
281
+ advance(): Promise<PrimerState>
282
+ }
423
283
  ```
424
284
 
425
285
  ### `interaction`
426
286
 
427
- Student must respond. Discriminate on `state.kind`.
287
+ Server wants an answer. `InteractionState` is a discriminated union over `kind`:
428
288
 
429
- All interaction states also expose:
430
-
431
- - `state.stimulus`
432
- - `state.interaction`
433
- - `timeout()`
434
-
435
- #### Interaction kinds
289
+ | `kind` | method |
290
+ |---|---|
291
+ | `choice` | `submitChoice(selectedKeys: string[])` |
292
+ | `text-entry` | `submitText(value: string)` |
293
+ | `extended-text` (single) | `submitText(value: string)` |
294
+ | `extended-text` (multiple) | `submitTexts(values: string[])` |
295
+ | `order` | `submitOrder(orderedKeys: string[])` |
296
+ | `match` | `submitMatch(pairs: MatchPair[])` |
297
+ | `portable-custom` | `submit(value: PciValue<K>)` |
436
298
 
437
- | `state.kind` | Submit method | Key fields |
438
- |---|---|---|
439
- | `"choice"` | `submitChoice(selectedKeys: string[])` | `options`, `minChoices`, `maxChoices` |
440
- | `"text-entry"` | `submitText(value: string)` | `interaction.base`, `interaction.expectedLength?`, `interaction.patternMask?`, `interaction.placeholderText?` |
441
- | `"extended-text"` + `cardinality: "single"` | `submitText(value: string)` | `interaction.format`, `interaction.expectedLength?`, `interaction.expectedLines?`, `interaction.patternMask?`, `interaction.placeholderText?` |
442
- | `"extended-text"` + `cardinality: "multiple"` | `submitTexts(values: string[])` | `minStrings`, `maxStrings`, plus the same metadata as single-cardinality extended text |
443
- | `"order"` | `submitOrder(orderedKeys: string[])` | `choices`, `minChoices`, `maxChoices`, `interaction.shuffle` |
444
- | `"match"` | `submitMatch(pairs: Array<{ source: string; target: string }>)` | `sourceChoices`, `targetChoices`, `minAssociations`, `maxAssociations`, `interaction.shuffle` |
445
- | `"portable-custom"` | `submit(value: PciValue<K>)` | `pciId`, `properties` |
299
+ All six shapes also expose `timeout(): Promise<PrimerState>`.
446
300
 
447
301
  ### `feedback`
448
302
 
449
- The server has evaluated the submission. Render feedback, then call `advance()`.
450
-
451
- ```ts
452
- state.phase === "feedback"
453
- state.stimulus // RendererStimulus | null
454
- state.interaction // RendererInteraction<Pcis>
455
- state.submission // RendererSubmission<Pcis>
456
- state.isCorrect // boolean
457
- state.feedbackContent // ContentInline[]
458
- state.review // InteractionReview<Pcis> | null
459
- state.advance() // Promise<PrimerState>
460
- ```
461
-
462
- `review` is interaction-native feedback data when available:
303
+ Server has graded the submission and returned feedback content.
463
304
 
464
305
  ```ts
465
- type InteractionReview<Pcis extends PciId = PciId> =
466
- | { type: "choice"; correctKeys: string[] }
467
- | { type: "text-entry"; correctValue: ReviewScalarValue | null }
468
- | { type: "extended-text"; correctValues: ReviewScalarValue[] }
469
- | { type: "order"; correctOrder: string[] }
470
- | { type: "match"; correctPairs: Array<{ source: string; target: string }> }
471
- | {
472
- type: "portable-custom"
473
- pciId: Pcis
474
- fields: Array<{
475
- fieldIdentifier: string
476
- baseType: "identifier" | "string" | "integer" | "float" | "pair"
477
- value: ReviewScalarValue | null
478
- }>
479
- }
480
-
481
- type ReviewScalarValue =
482
- | { kind: "identifier"; value: string }
483
- | { kind: "string"; value: string }
484
- | { kind: "integer"; value: number }
485
- | { kind: "float"; value: number }
486
- | { kind: "pair"; source: string; target: string }
306
+ interface FeedbackState {
307
+ readonly phase: "feedback"
308
+ readonly stimulus: RendererStimulus | null
309
+ readonly interaction: RendererInteraction
310
+ readonly submission: RendererSubmission
311
+ readonly isCorrect: boolean
312
+ readonly feedbackContent: ContentInline[] // server-provided, localized feedback
313
+ readonly review: InteractionReview | null // correct answers etc., if available
314
+ advance(): Promise<PrimerState>
315
+ }
487
316
  ```
488
317
 
489
- Notes:
490
-
491
- - `null` means the server did not provide review data.
492
- - order review preserves ordering directly.
493
- - match review preserves directional `{ source, target }` pairs directly.
494
- - PCI review uses record fields and can carry `pair` values without flattening them into strings.
495
- - `feedback.advance()` uses the same observation intent as `observation.advance()`.
496
-
497
- ### `completed`
498
-
499
- Session finished successfully. No further actions.
500
-
501
318
  ### `errored`
502
319
 
503
- Recoverable or user-correctable failure.
320
+ Transport or validation failure that might be recoverable.
504
321
 
505
322
  ```ts
506
- state.phase === "errored"
507
- state.error // Error sentinel
508
- state.retriable // boolean
509
- state.retry() // Promise<PrimerState>
323
+ interface ErroredState {
324
+ readonly phase: "errored"
325
+ readonly error: Error // sentinel-wrapped
326
+ readonly retriable: boolean
327
+ retry(): Promise<PrimerState> // no-op on non-retriable
328
+ }
510
329
  ```
511
330
 
512
- Caller guidance:
513
-
514
- - switch on sentinel identity with `errors.is(...)`
515
- - check `state.retriable` before calling `retry()`
516
- - non-retriable errored states keep the original sentinel but do not replay the failed action
517
-
518
331
  ### `fatal`
519
332
 
520
- Permanent failure. Session cannot recover.
333
+ Unrecoverable failure (bad request, invalid token, expired token, unsupported PCI, forbidden).
521
334
 
522
335
  ```ts
523
- state.phase === "fatal"
524
- state.error // Error sentinel
525
- ```
526
-
527
- Fatal states can come from:
528
-
529
- - malformed request semantics on the server (`400`)
530
- - missing, invalid, or expired access token (`401`)
531
- - forbidden (`403`)
532
- - missing resource (`404`)
533
- - unsupported PCI (`422`)
534
- - a successful response that still contains a `portable-custom` frame whose `pciId` is not in `supportedPcis`
535
-
536
- ## Interaction payloads
537
-
538
- ```ts
539
- type PciSubmission<Pcis extends PciId = PciId> = {
540
- [K in Pcis]: {
541
- type: "portable-custom"
542
- pciId: K
543
- value: PciValue<K>
544
- }
545
- }[Pcis]
546
-
547
- type RendererSubmission<Pcis extends PciId = PciId> =
548
- | { type: "choice"; selectedKeys: string[] }
549
- | { type: "text-entry"; value: string }
550
- | { type: "extended-text"; values: string[] }
551
- | { type: "order"; orderedKeys: string[] }
552
- | { type: "match"; pairs: Array<{ source: string; target: string }> }
553
- | PciSubmission<Pcis>
336
+ interface FatalState {
337
+ readonly phase: "fatal"
338
+ readonly error: Error
339
+ readonly retriable: false
340
+ }
554
341
  ```
555
342
 
556
- Notes:
557
-
558
- - single-cardinality extended text is still submitted as `type: "extended-text"` with a one-element `values` array
559
- - match submissions are directional `{ source, target }` pairs
560
- - portable custom submissions carry both `pciId` and a typed PCI `value`
561
-
562
- ## Client-side validation
563
-
564
- The SDK performs **selective** client-side validation before sending some submissions.
565
-
566
- | Interaction kind | Validation performed |
567
- |---|---|
568
- | `choice` | min selections, max selections, duplicate keys, unknown option identifiers |
569
- | `text-entry` | none |
570
- | `extended-text` + `single` | none |
571
- | `extended-text` + `multiple` | `minStrings`, `maxStrings` |
572
- | `order` | min selections, max selections, duplicate keys, unknown choice identifiers |
573
- | `match` | `minAssociations`, `maxAssociations`, unknown source/target identifiers, and per-choice `matchMin` / `matchMax` caps on both sides |
574
- | `portable-custom` | no outbound value validation |
575
-
576
- If validation fails:
577
-
578
- - the submit call resolves to an `errored` state
579
- - `state.error` will match `ErrInvalidSubmission`
580
- - `state.retriable` will be `false`
581
- - the original interaction object is still usable for a corrected resubmission
582
- - `retry()` returns the same errored state instead of replaying the invalid payload
343
+ ### `completed`
583
344
 
584
- ## Error sentinels
345
+ Terminal. Session is done.
585
346
 
586
347
  ```ts
587
- import * as errors from "@superbuilders/errors"
588
- import {
589
- ErrInvalidSubmission,
590
- ErrNetwork,
591
- ErrRateLimited,
592
- ErrUnsupportedPci,
593
- } from "@superbuilders/primer-tives"
594
-
595
- if (errors.is(state.error, ErrNetwork)) {
596
- // handle offline / DNS / CORS / fetch failure
348
+ interface CompletedState {
349
+ readonly phase: "completed"
597
350
  }
598
351
  ```
599
352
 
600
- ### Surfaced as `errored`
601
-
602
- | Sentinel | Meaning | `state.retriable` |
603
- |---|---|---|
604
- | `ErrNetwork` | fetch failed before a response was received | `true` |
605
- | `ErrTimeout` | request was aborted or timed out | `true` |
606
- | `ErrRateLimited` | `429` | `true` |
607
- | `ErrServiceUnavailable` | `502`, `503`, or `504` | `true` |
608
- | `ErrServerError` | any other unhandled HTTP error status | `true` |
609
- | `ErrJsonParse` | response body was not valid JSON | `true` |
610
- | `ErrConflict` | `409`, or a local conflicting in-flight action | `true` |
611
- | `ErrInvalidSubmission` | client-side validation failed | `false` |
612
-
613
- ### Permanent: surfaced as `fatal`
614
-
615
- | Sentinel | Meaning |
616
- |---|---|
617
- | `ErrBadRequest` | `400` |
618
- | `ErrInvalidAccessToken` | `401` with an invalid signature / unknown key / malformed claims |
619
- | `ErrTokenExpired` | `401` with an `exp` claim in the past |
620
- | `ErrForbidden` | `403` |
621
- | `ErrNotFound` | `404` |
622
- | `ErrUnsupportedPci` | server rejected the request with `422`, or the server returned a PCI frame the client did not advertise in `supportedPcis` |
623
-
624
- ### Thrown directly
625
-
626
- | Sentinel | Meaning |
627
- |---|---|
628
- | `ErrMalformedAccessToken` | `create()` received a token whose shape is not a JWS (must start with `eyJ` and contain two dots) |
629
- | `ErrNotSerializable` | code attempted to serialize a live `PrimerState` |
630
-
631
353
  ## Content format
632
354
 
633
355
  ```ts
@@ -642,177 +364,107 @@ type ContentInline =
642
364
  type ContentBlock = { type: "paragraph"; children: ContentInline[] }
643
365
  ```
644
366
 
645
- ### Helpers
367
+ All three inline variants share the uniform `{ type, value: string }` shape. `ContentSpan` covers HTML-ish rich-text formatting. `latex` is for inline math expressions (render via Temml).
368
+
369
+ Helpers:
646
370
 
647
371
  ```ts
648
372
  inlinesToPlainText(nodes: ContentInline[]): string
649
373
  blocksToPlainText(blocks: ContentBlock[]): string
650
374
  ```
651
375
 
652
- - `inlinesToPlainText()` strips formatting and concatenates inline values
653
- - `blocksToPlainText()` flattens block content with newlines between paragraphs
654
-
655
- Typical use cases:
656
-
657
- - accessibility labels
658
- - plain-text fallbacks
659
- - analytics or logging output that should ignore inline formatting
660
-
661
- ## Stimulus model
376
+ ## PCI system (Portable Custom Interactions)
662
377
 
663
378
  ```ts
664
- interface BodyStimulus {
665
- type: "body"
666
- body: ContentBlock[]
667
- }
668
-
669
- interface ImageStimulus {
670
- type: "image"
671
- description: ContentInline[]
672
- src: string
673
- }
674
-
675
- type RendererStimulus = BodyStimulus | ImageStimulus
676
- ```
677
-
678
- A frame can also have `stimulus: null`.
679
-
680
- ## PCI system
681
-
682
- Portable Custom Interactions let Primer serve domain-specific input types while preserving compile-time type safety.
379
+ type PciUrn = "urn:primer:pci:division-remainder" | "urn:primer:pci:fraction-addition"
683
380
 
684
- ### Registry
685
-
686
- ```ts
687
- interface PciRegistry {
381
+ type PciRegistry = {
688
382
  "urn:primer:pci:division-remainder": {
689
- props: { dividend: number; divisor: number }
690
- value: { quotient: string; remainder: string }
383
+ props: DivisionRemainderProps
384
+ value: DivisionRemainderSubmission
691
385
  }
692
386
  "urn:primer:pci:fraction-addition": {
693
- props: {
694
- left: { numerator: number; denominator: number }
695
- right: { numerator: number; denominator: number }
696
- }
697
- value: { numerator: string; denominator: string }
387
+ props: FractionAdditionProps
388
+ value: FractionAdditionSubmission
698
389
  }
699
390
  }
700
- ```
701
391
 
702
- ### Type helpers
703
-
704
- ```ts
705
- type PciUrn = `urn:primer:pci:${string}`
706
- type PciId = keyof PciRegistry & string
392
+ type PciId = keyof PciRegistry
707
393
  type PciProps<K extends PciId> = PciRegistry[K]["props"]
708
394
  type PciValue<K extends PciId> = PciRegistry[K]["value"]
709
395
  ```
710
396
 
711
- ### PCI renderer props
712
-
713
- ```ts
714
- type PciRenderProps<K extends PciId> =
715
- | {
716
- mode: "pending"
717
- properties: PciProps<K>
718
- onValueChange: (value: PciValue<K> | null) => void
719
- }
720
- | {
721
- mode: "submitted"
722
- properties: PciProps<K>
723
- submission: PciValue<K>
724
- review: Extract<InteractionReview<K>, { type: "portable-custom"; pciId: K }> | null
725
- }
726
- ```
727
-
728
- ### Adding a PCI
397
+ Declare the PCI URNs your renderer can handle via `Config.supportedPcis`. Frames requiring an unsupported PCI resolve to `fatal` with `ErrUnsupportedPci`.
729
398
 
730
- 1. Add a new entry to `PciRegistry` in `src/pci.ts`
731
- 2. Use the URN format `urn:primer:pci:<name>`
732
- 3. Add that PCI URN to `supportedPcis` when creating the client
733
- 4. Make sure your renderer knows how to render and collect a `PciValue` for that PCI
734
-
735
- ## Import guide
736
-
737
- Everything is exported from the package root:
399
+ ### Renderer props
738
400
 
739
401
  ```ts
740
- import {
741
- ADVANCE_PATH,
742
- ErrConflict,
743
- ErrInvalidAccessToken,
744
- ErrInvalidSubmission,
745
- ErrMalformedAccessToken,
746
- ErrNetwork,
747
- ErrTimeout,
748
- ErrTokenExpired,
749
- ErrUnsupportedPci,
750
- SUBJECTS,
751
- blocksToPlainText,
752
- create,
753
- inlinesToPlainText,
754
- } from "@superbuilders/primer-tives"
755
-
756
- import type {
757
- ChoiceState,
758
- Client,
759
- Config,
760
- ContentBlock,
761
- ContentInline,
762
- FeedbackState,
763
- MatchState,
764
- ObservationState,
765
- OrderState,
766
- PciId,
767
- PciProps,
768
- PciRenderProps,
769
- PciValue,
770
- PrimerLogger,
771
- PrimerState,
772
- RendererInteraction,
773
- RendererStimulus,
774
- RendererSubmission,
775
- Subject,
776
- SubjectScope,
777
- } from "@superbuilders/primer-tives"
778
- ```
779
-
780
- ## Behavioral notes
781
-
782
- ### States are not serializable
783
-
784
- `PrimerState` is live in-memory state that contains closures. Every state object has a poisoned `toJSON()` that throws `ErrNotSerializable`.
402
+ type PciPendingRenderProps<K extends PciId> = {
403
+ mode: "pending"
404
+ properties: PciProps<K>
405
+ onValueChange: (value: PciValue<K> | null) => void
406
+ }
785
407
 
786
- Do **not**:
408
+ type PciSubmittedRenderProps<K extends PciId> = {
409
+ mode: "submitted"
410
+ properties: PciProps<K>
411
+ submission: PciValue<K>
412
+ review: Extract<InteractionReview<K>, { type: "portable-custom"; pciId: K }> | null
413
+ }
787
414
 
788
- - `JSON.stringify(state)`
789
- - persist a state object to storage
790
- - send a state object over the network
791
- - treat a state object as a durable snapshot
415
+ type PciRenderProps<K extends PciId> = PciPendingRenderProps<K> | PciSubmittedRenderProps<K>
416
+ ```
792
417
 
793
- Instead, keep it in memory and render it immediately.
418
+ ## Error sentinels (`/client`)
794
419
 
795
- ### Repeated actions are deduplicated narrowly
420
+ ### Surfaced as `errored`
796
421
 
797
- State objects memoize only true re-entry of the same in-flight action.
422
+ | Sentinel | Raised when |
423
+ |---|---|
424
+ | `ErrNetwork` | fetch() rejected |
425
+ | `ErrTimeout` | fetch() aborted |
426
+ | `ErrServerError` | HTTP 5xx |
427
+ | `ErrServiceUnavailable` | HTTP 502/503/504 |
428
+ | `ErrRateLimited` | HTTP 429 |
429
+ | `ErrConflict` | HTTP 409 |
430
+ | `ErrJsonParse` | Success response body wasn't valid JSON |
431
+ | `ErrInvalidSubmission` | client-side validation rejected the submission |
432
+
433
+ ### Surfaced as `fatal`
434
+
435
+ | Sentinel | Raised when |
436
+ |---|---|
437
+ | `ErrBadRequest` | HTTP 400 |
438
+ | `ErrInvalidAccessToken` | HTTP 401 |
439
+ | `ErrTokenExpired` | HTTP 401 with token-expired detail |
440
+ | `ErrForbidden` | HTTP 403 |
441
+ | `ErrNotFound` | HTTP 404 |
442
+ | `ErrUnsupportedPci` | HTTP 422 or a frame asks for a PCI not in `supportedPcis` |
798
443
 
799
- - calling `advance()` twice on the same observation state returns the same promise
800
- - calling the same submit method twice with the same payload returns the same promise
801
- - calling `retry()` twice on the same errored state returns the same promise
802
- - submit and timeout do **not** alias to each other anymore
803
- - conflicting in-flight actions surface `ErrConflict` instead of silently reusing the wrong promise
444
+ ### Thrown directly by `create()`
804
445
 
805
- ### `start()` is memoized per client instance
446
+ | Sentinel | Raised when |
447
+ |---|---|
448
+ | `ErrMalformedAccessToken` | token doesn't start with `eyJ` or lacks two dots |
449
+ | `ErrNotSerializable` | you called `JSON.stringify()` on a live `PrimerState` (don't) |
806
450
 
807
- A `Client` instance memoizes the first `start()` call and returns the same promise on later calls. In practice, create a fresh client instance per independently managed session.
451
+ ## Logger interface
808
452
 
809
- ## Build and publish
453
+ ```ts
454
+ interface PrimerLogger {
455
+ debug(message: string, attributes?: Record<string, unknown>): void
456
+ info(message: string, attributes?: Record<string, unknown>): void
457
+ warn(message: string, attributes?: Record<string, unknown>): void
458
+ error(message: string, attributes?: Record<string, unknown>): void
459
+ }
460
+ ```
810
461
 
811
- The package is built as browser-targeted ESM from `src/index.ts`, emits declaration files, and publishes `dist/` plus this README.
462
+ Same shape on both sides. Plug in your slog/pino/console adapter.
812
463
 
813
- Useful package scripts:
464
+ ## Behavioral notes
814
465
 
815
- ```sh
816
- bun run build
817
- bun run typecheck
818
- ```
466
+ - **`start()` is idempotent.** Calling it twice returns the same promise.
467
+ - **Retry semantics.** `errored.retry()` re-runs the same intent. `errored.retriable === false` for client-validation errors (e.g. `ErrInvalidSubmission`) — those must be fixed, not retried.
468
+ - **Session resumption.** A returning student resumes wherever the server last placed them. No client-side cursor management.
469
+ - **Live state.** `PrimerState` holds real closures (action methods, pending-promise caches). Don't serialize it; don't store it across reloads. Call `start()` again on boot.
470
+ - **PCI type safety.** `Config.supportedPcis` is a `const` generic; only those URNs flow through `PciInteractionState`/`PciSubmission` at the type level. Mismatch at runtime is a `fatal` with `ErrUnsupportedPci`.