@superbuilders/primer-tives 0.6.0 → 0.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +281 -629
- package/dist/client/choice-state.d.ts.map +1 -0
- package/dist/client/consumed.d.ts.map +1 -0
- package/dist/client/content.d.ts.map +1 -0
- package/dist/{client.d.ts → client/create.d.ts} +4 -4
- package/dist/client/create.d.ts.map +1 -0
- package/dist/client/extended-text-state.d.ts.map +1 -0
- package/dist/client/feedback-state.d.ts.map +1 -0
- package/dist/{index.d.ts → client/index.d.ts} +7 -7
- package/dist/{index.d.ts.map → client/index.d.ts.map} +1 -1
- package/dist/{index.js → client/index.js} +50 -24
- package/dist/client/index.js.map +24 -0
- package/dist/client/match-state.d.ts.map +1 -0
- package/dist/client/observation-state.d.ts.map +1 -0
- package/dist/client/order-state.d.ts.map +1 -0
- package/dist/client/pci-state.d.ts.map +1 -0
- package/dist/client/pci.d.ts.map +1 -0
- package/dist/{session-context.d.ts → client/session-context.d.ts} +4 -7
- package/dist/client/session-context.d.ts.map +1 -0
- package/dist/{session.d.ts → client/session.d.ts} +3 -2
- package/dist/client/session.d.ts.map +1 -0
- package/dist/client/text-entry-state.d.ts.map +1 -0
- package/dist/{transport.d.ts → client/transport.d.ts} +3 -3
- package/dist/client/transport.d.ts.map +1 -0
- package/dist/client/types.d.ts.map +1 -0
- package/dist/errors.d.ts +5 -1
- package/dist/errors.d.ts.map +1 -1
- package/dist/grade-level.d.ts +5 -0
- package/dist/grade-level.d.ts.map +1 -0
- package/dist/logger.d.ts +8 -0
- package/dist/logger.d.ts.map +1 -0
- package/dist/server/create-server.d.ts +42 -0
- package/dist/server/create-server.d.ts.map +1 -0
- package/dist/server/exchange.d.ts +17 -0
- package/dist/server/exchange.d.ts.map +1 -0
- package/dist/server/index.d.ts +8 -0
- package/dist/server/index.d.ts.map +1 -0
- package/dist/server/index.js +259 -0
- package/dist/server/index.js.map +14 -0
- package/dist/server/students.d.ts +14 -0
- package/dist/server/students.d.ts.map +1 -0
- package/dist/subject.d.ts +1 -1
- package/dist/subject.d.ts.map +1 -1
- package/package.json +8 -4
- package/dist/choice-state.d.ts.map +0 -1
- package/dist/client.d.ts.map +0 -1
- package/dist/consumed.d.ts.map +0 -1
- package/dist/content.d.ts.map +0 -1
- package/dist/extended-text-state.d.ts.map +0 -1
- package/dist/feedback-state.d.ts.map +0 -1
- package/dist/index.js.map +0 -24
- package/dist/match-state.d.ts.map +0 -1
- package/dist/observation-state.d.ts.map +0 -1
- package/dist/order-state.d.ts.map +0 -1
- package/dist/pci-state.d.ts.map +0 -1
- package/dist/pci.d.ts.map +0 -1
- package/dist/session-context.d.ts.map +0 -1
- package/dist/session.d.ts.map +0 -1
- package/dist/text-entry-state.d.ts.map +0 -1
- package/dist/transport.d.ts.map +0 -1
- package/dist/types.d.ts.map +0 -1
- /package/dist/{choice-state.d.ts → client/choice-state.d.ts} +0 -0
- /package/dist/{consumed.d.ts → client/consumed.d.ts} +0 -0
- /package/dist/{content.d.ts → client/content.d.ts} +0 -0
- /package/dist/{extended-text-state.d.ts → client/extended-text-state.d.ts} +0 -0
- /package/dist/{feedback-state.d.ts → client/feedback-state.d.ts} +0 -0
- /package/dist/{match-state.d.ts → client/match-state.d.ts} +0 -0
- /package/dist/{observation-state.d.ts → client/observation-state.d.ts} +0 -0
- /package/dist/{order-state.d.ts → client/order-state.d.ts} +0 -0
- /package/dist/{pci-state.d.ts → client/pci-state.d.ts} +0 -0
- /package/dist/{pci.d.ts → client/pci.d.ts} +0 -0
- /package/dist/{text-entry-state.d.ts → client/text-entry-state.d.ts} +0 -0
- /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
|
-
|
|
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
|
-
##
|
|
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
|
-
|
|
29
|
-
|
|
30
|
-
-
|
|
31
|
-
-
|
|
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
|
-
|
|
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://primer.example.com",
|
|
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
|
|
52
|
-
origin: "https://
|
|
61
|
+
accessToken, // from your backend
|
|
62
|
+
origin: "https://primer.example.com",
|
|
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
|
-
|
|
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 (
|
|
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
|
-
|
|
96
|
+
---
|
|
138
97
|
|
|
139
|
-
|
|
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.
|
|
147
|
-
|
|
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
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
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
|
-
|
|
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.
|
|
111
|
+
## `createPrimerServer(config)`
|
|
177
112
|
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
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.
|
|
201
|
-
|
|
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://primer.example.com (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
|
-
|
|
212
|
-
{ "student_id": "019d3e6f-5f6d-7000-8000-..." }
|
|
122
|
+
function createPrimerServer(config: PrimerServerConfig): PrimerServer
|
|
213
123
|
```
|
|
214
124
|
|
|
215
|
-
|
|
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.
|
|
125
|
+
Returns a `PrimerServer` with four methods. Each method throws a sentinel-wrapped `Error` — use `errors.try()` from `@superbuilders/errors` at the call site.
|
|
218
126
|
|
|
219
|
-
###
|
|
127
|
+
### `createNativeStudent(gradeLevel): Promise<string>`
|
|
220
128
|
|
|
221
|
-
|
|
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**.
|
|
222
130
|
|
|
223
|
-
|
|
224
|
-
|
|
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
|
|
135
|
+
**Do not call this for Timeback integrations.** Timeback students are provisioned automatically on first `exchangeTimebackStudentForAccessToken`.
|
|
233
136
|
|
|
234
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
261
|
-
|
|
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. |
|
|
145
|
+
**Do not call this for Timeback students.** Grade-level changes flow from the SIS.
|
|
269
146
|
|
|
270
|
-
###
|
|
147
|
+
### `exchangeNativeStudentForAccessToken(studentId): Promise<SessionToken>`
|
|
271
148
|
|
|
272
|
-
|
|
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.
|
|
273
150
|
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
```sh
|
|
277
|
-
bun db:seed
|
|
151
|
+
```ts
|
|
152
|
+
const { accessToken, expiresInSeconds } = await primer.exchangeNativeStudentForAccessToken(studentId)
|
|
278
153
|
```
|
|
279
154
|
|
|
280
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
323
|
-
|
|
324
|
-
|
|
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
|
-
|
|
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
|
-
|
|
174
|
+
## `GradeLevel`
|
|
339
175
|
|
|
340
176
|
```ts
|
|
341
|
-
|
|
177
|
+
type GradeLevel = "K" | "1" | "2" | "3" | "4" | "5" | "6" | "7" | "8" | "9" | "10" | "11" | "12"
|
|
342
178
|
```
|
|
343
179
|
|
|
344
|
-
|
|
180
|
+
Exported as both `GradeLevel` and the `GRADE_LEVELS` readonly tuple.
|
|
345
181
|
|
|
346
|
-
|
|
347
|
-
Authorization: Bearer <primer jwt>
|
|
348
|
-
Content-Type: application/json
|
|
349
|
-
```
|
|
182
|
+
## Error sentinels (`/server`)
|
|
350
183
|
|
|
351
|
-
|
|
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
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
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
|
-
|
|
209
|
+
---
|
|
362
210
|
|
|
363
|
-
|
|
211
|
+
# `/client`
|
|
364
212
|
|
|
365
213
|
```ts
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
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
|
-
|
|
373
|
-
|
|
374
|
-
Conceptually, the server returns one of three outcomes:
|
|
228
|
+
## `create(config)`
|
|
375
229
|
|
|
376
230
|
```ts
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
412
|
-
|
|
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
|
-
|
|
275
|
+
Server wants you to show a stimulus and the student to indicate they're done reading.
|
|
418
276
|
|
|
419
277
|
```ts
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
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
|
-
|
|
287
|
+
Server wants an answer. `InteractionState` is a discriminated union over `kind`:
|
|
428
288
|
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
- `
|
|
433
|
-
- `
|
|
434
|
-
|
|
435
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
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
|
-
|
|
320
|
+
Transport or validation failure that might be recoverable.
|
|
504
321
|
|
|
505
322
|
```ts
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
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
|
-
|
|
333
|
+
Unrecoverable failure (bad request, invalid token, expired token, unsupported PCI, forbidden).
|
|
521
334
|
|
|
522
335
|
```ts
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
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
|
-
|
|
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
|
-
|
|
345
|
+
Terminal. Session is done.
|
|
585
346
|
|
|
586
347
|
```ts
|
|
587
|
-
|
|
588
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
685
|
-
|
|
686
|
-
```ts
|
|
687
|
-
interface PciRegistry {
|
|
381
|
+
type PciRegistry = {
|
|
688
382
|
"urn:primer:pci:division-remainder": {
|
|
689
|
-
props:
|
|
690
|
-
value:
|
|
383
|
+
props: DivisionRemainderProps
|
|
384
|
+
value: DivisionRemainderSubmission
|
|
691
385
|
}
|
|
692
386
|
"urn:primer:pci:fraction-addition": {
|
|
693
|
-
props:
|
|
694
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
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
|
-
|
|
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
|
-
|
|
789
|
-
|
|
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
|
-
|
|
418
|
+
## Error sentinels (`/client`)
|
|
794
419
|
|
|
795
|
-
###
|
|
420
|
+
### Surfaced as `errored`
|
|
796
421
|
|
|
797
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
451
|
+
## Logger interface
|
|
808
452
|
|
|
809
|
-
|
|
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
|
-
|
|
462
|
+
Same shape on both sides. Plug in your slog/pino/console adapter.
|
|
812
463
|
|
|
813
|
-
|
|
464
|
+
## Behavioral notes
|
|
814
465
|
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
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`.
|