@superbuilders/primer-tives 0.8.1 → 1.0.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 CHANGED
@@ -1,64 +1,161 @@
1
1
  # @superbuilders/primer-tives
2
2
 
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.
3
+ TypeScript SDK for the Primer adaptive learning engine.
4
+
5
+ The package exposes **two explicit subpaths**:
6
+
7
+ - `@superbuilders/primer-tives/server` — runs on your backend, authenticates with your Primer `sk_...` secret key, and starts student sessions.
8
+ - `@superbuilders/primer-tives/client` — runs in the browser and drives the Primer lesson state machine.
9
+
10
+ There is **no root export**. You must choose the side of the wire you are on.
4
11
 
5
12
  ```sh
6
13
  bun add @superbuilders/primer-tives
7
14
  ```
8
15
 
9
- Dependency: `@superbuilders/errors` is installed automatically.
16
+ Dependency note: `@superbuilders/errors` is installed automatically and is used for `errors.try()` / `errors.is()`.
10
17
 
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:
18
+ ## Entrypoints
14
19
 
15
20
  | Import | Runs on | Wraps |
16
21
  |---|---|---|
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) |
22
+ | `@superbuilders/primer-tives/server` | your backend | `POST /api/v0/students`, `POST /api/v0/auth/exchange`, `POST /api/v0/auth/exchange/timeback` |
23
+ | `@superbuilders/primer-tives/client` | the browser | `POST /api/v0/advance` |
24
+
25
+ ## Supported student flows
26
+
27
+ Primer intentionally supports **two** backend-authenticated student flows.
28
+
29
+ ### 1. Primer-native / manual students
30
+
31
+ Use this when **your system** owns the learner identity.
32
+
33
+ 1. Call `createStudent()` once to mint a stable Primer `studentId`.
34
+ 2. Persist that `studentId` in your own database alongside your user record.
35
+ 3. At each session start, call `exchangeStudentForAccessToken(studentId)`.
36
+ 4. Hand the returned `accessToken` to the browser SDK.
37
+
38
+ ### 2. Live-authoritative Timeback students
39
+
40
+ Use this when **Timeback / OneRoster** remains the live authority for each login.
19
41
 
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`.
42
+ 1. At each session start, call `exchangeTimebackStudentForAccessToken(sourcedId)`.
43
+ 2. Primer verifies the learner against Timeback on **every call**.
44
+ 3. Primer resolves or provisions the frontend-owned Primer student row behind the scenes.
45
+ 4. The call returns both the stable Primer `studentId` and a short-lived `accessToken`.
46
+ 5. Hand the returned `accessToken` to the browser SDK.
21
47
 
22
- ## Round trip
48
+ Persisting the returned `studentId` is fine if you want a stable local foreign key. Session startup for this flow still begins from `sourcedId`, and the returned `accessToken` is the session credential you pass to the browser SDK.
23
49
 
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.
50
+ ## End-to-end examples
51
+
52
+ ### Native/manual flow
25
53
 
26
54
  ```ts
27
55
  // ── your backend ─────────────────────────────────────────────────────
28
56
  import * as errors from "@superbuilders/errors"
29
- import { createPrimerServer } from "@superbuilders/primer-tives/server"
57
+ import {
58
+ createPrimerServer,
59
+ ErrConflict,
60
+ ErrInvalidSecretKey,
61
+ ErrStudentNotFound
62
+ } from "@superbuilders/primer-tives/server"
30
63
 
31
64
  const primer = createPrimerServer({
32
65
  origin: "https://sb-primer.vercel.app",
33
- secretKey: process.env.PRIMER_CLIENT_SECRET_KEY_DEV
66
+ secretKey: process.env.PRIMER_CLIENT_SECRET_KEY_DEV!,
67
+ logger: console
34
68
  })
35
69
 
36
- // One-time per user: provision a Primer-owned student.
37
- const studentId = await primer.createNativeStudent("4")
38
- // persist `studentId` alongside your user record
70
+ // One time per user.
71
+ const createResult = await errors.try(primer.createStudent())
72
+ if (createResult.error) {
73
+ if (errors.is(createResult.error, ErrInvalidSecretKey)) {
74
+ throw new Error("Primer secret key is invalid")
75
+ }
76
+ if (errors.is(createResult.error, ErrConflict)) {
77
+ throw new Error("This Primer frontend is not provisioned with routable content")
78
+ }
79
+ throw createResult.error
80
+ }
81
+ const studentId = createResult.data
82
+ // persist `studentId` alongside your own user record
39
83
 
40
- // Every session: mint a short-lived access token.
41
- const result = await errors.try(
42
- primer.exchangeNativeStudentForAccessToken(studentId)
84
+ // Every session start.
85
+ const tokenResult = await errors.try(
86
+ primer.exchangeStudentForAccessToken(studentId)
43
87
  )
44
- if (result.error) {
45
- // map ErrInvalidSecretKey / ErrStudentNotFound / ErrServerError etc.
46
- throw result.error
88
+ if (tokenResult.error) {
89
+ if (errors.is(tokenResult.error, ErrInvalidSecretKey)) {
90
+ throw new Error("Primer secret key is invalid")
91
+ }
92
+ if (errors.is(tokenResult.error, ErrStudentNotFound)) {
93
+ throw new Error("Stored Primer student id no longer exists on this frontend")
94
+ }
95
+ throw tokenResult.error
96
+ }
97
+
98
+ const { accessToken, expiresInSeconds } = tokenResult.data
99
+ ```
100
+
101
+ ### Live-authoritative Timeback flow
102
+
103
+ ```ts
104
+ // ── your backend ─────────────────────────────────────────────────────
105
+ import * as errors from "@superbuilders/errors"
106
+ import {
107
+ createPrimerServer,
108
+ ErrConflict,
109
+ ErrInvalidSecretKey,
110
+ ErrStudentNotFound,
111
+ ErrTimebackUnavailable,
112
+ ErrUnsupportedGrade
113
+ } from "@superbuilders/primer-tives/server"
114
+
115
+ const primer = createPrimerServer({
116
+ origin: "https://sb-primer.vercel.app",
117
+ secretKey: process.env.PRIMER_CLIENT_SECRET_KEY_DEV!,
118
+ logger: console
119
+ })
120
+
121
+ const sessionResult = await errors.try(
122
+ primer.exchangeTimebackStudentForAccessToken("student-123")
123
+ )
124
+ if (sessionResult.error) {
125
+ if (errors.is(sessionResult.error, ErrInvalidSecretKey)) {
126
+ throw new Error("Primer secret key is invalid")
127
+ }
128
+ if (errors.is(sessionResult.error, ErrStudentNotFound)) {
129
+ throw new Error("Timeback sourcedId was not found upstream")
130
+ }
131
+ if (errors.is(sessionResult.error, ErrUnsupportedGrade)) {
132
+ throw new Error("Timeback returned a grade Primer does not support")
133
+ }
134
+ if (errors.is(sessionResult.error, ErrConflict)) {
135
+ throw new Error("This Primer frontend is not provisioned with routable content")
136
+ }
137
+ if (errors.is(sessionResult.error, ErrTimebackUnavailable)) {
138
+ throw new Error("Timeback is temporarily unavailable")
139
+ }
140
+ throw sessionResult.error
47
141
  }
48
- const { accessToken, expiresInSeconds } = result.data
49
- // ship `accessToken` to the browser
142
+
143
+ const { studentId, accessToken, expiresInSeconds } = sessionResult.data
144
+ // persist `studentId` if you want a stable Primer foreign key
145
+ // use `accessToken` for this session only
50
146
  ```
51
147
 
148
+ ### Browser flow
149
+
52
150
  ```ts
53
151
  // ── your frontend ────────────────────────────────────────────────────
54
152
  import {
55
153
  create,
56
- ErrRateLimited,
57
154
  type PrimerState
58
155
  } from "@superbuilders/primer-tives/client"
59
156
 
60
157
  const client = create({
61
- accessToken, // from your backend
158
+ accessToken,
62
159
  origin: "https://sb-primer.vercel.app",
63
160
  subject: "math",
64
161
  supportedPcis: [
@@ -103,8 +200,19 @@ import {
103
200
  type PrimerServer,
104
201
  type PrimerServerConfig,
105
202
  type SessionToken,
106
- type GradeLevel,
107
- GRADE_LEVELS
203
+ type TimebackSession,
204
+ type PrimerLogger,
205
+ ErrBadRequest,
206
+ ErrConflict,
207
+ ErrExternalAuthorityRequired,
208
+ ErrInvalidSecretKey,
209
+ ErrJsonParse,
210
+ ErrNetwork,
211
+ ErrServerError,
212
+ ErrStudentNotFound,
213
+ ErrTimebackUnavailable,
214
+ ErrTimeout,
215
+ ErrUnsupportedGrade
108
216
  } from "@superbuilders/primer-tives/server"
109
217
  ```
110
218
 
@@ -112,100 +220,168 @@ import {
112
220
 
113
221
  ```ts
114
222
  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
223
+ readonly origin: string // e.g. https://sb-primer.vercel.app (no trailing slash)
224
+ readonly secretKey: string // your sk_… key
225
+ readonly fetch?: typeof globalThis.fetch // override (tests, proxies, instrumentation)
226
+ readonly abort?: AbortController // wired into every request signal
227
+ readonly logger: PrimerLogger // required debug/info/warn/error logger (console works)
120
228
  }
121
229
 
122
230
  function createPrimerServer(config: PrimerServerConfig): PrimerServer
123
231
  ```
124
232
 
125
- Returns a `PrimerServer` with four methods. Each method throws a sentinel-wrapped `Error` — use `errors.try()` from `@superbuilders/errors` at the call site.
126
-
127
- ### `createNativeStudent(gradeLevel): Promise<string>`
128
-
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**.
233
+ Returns a `PrimerServer` with exactly **three** methods:
130
234
 
131
235
  ```ts
132
- const studentId = await primer.createNativeStudent("5")
236
+ interface PrimerServer {
237
+ createStudent(): Promise<string>
238
+ exchangeStudentForAccessToken(studentId: string): Promise<SessionToken>
239
+ exchangeTimebackStudentForAccessToken(sourcedId: string): Promise<TimebackSession>
240
+ }
133
241
  ```
134
242
 
135
- **Do not call this for Timeback integrations.** Timeback students are provisioned automatically on first `exchangeTimebackStudentForAccessToken`.
243
+ ## Method reference
244
+
245
+ ### `createStudent(): Promise<string>`
136
246
 
137
- ### `updateNativeStudentGradeLevel(studentId, gradeLevel): Promise<void>`
247
+ Provision a new **frontend-owned Primer student** and return its stable `studentId`.
138
248
 
139
- Change a Primer student's grade level.
249
+ Use this only for the native/manual flow.
140
250
 
141
251
  ```ts
142
- await primer.updateNativeStudentGradeLevel(studentId, "6")
252
+ const studentId = await primer.createStudent()
143
253
  ```
144
254
 
145
- **Do not call this for Timeback students.** Grade-level changes flow from the SIS.
255
+ Notes:
146
256
 
147
- ### `exchangeNativeStudentForAccessToken(studentId): Promise<SessionToken>`
257
+ - Call this when your system is the source of truth for learner identity.
258
+ - Persist the returned `studentId` in your own database.
259
+ - Use that stored `studentId` with `exchangeStudentForAccessToken(studentId)` at each session start.
260
+ - `logger` is required. Pass any object implementing `debug/info/warn/error`; `console` is acceptable for basic integrations.
148
261
 
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.
262
+ ### `exchangeStudentForAccessToken(studentId): Promise<SessionToken>`
263
+
264
+ Mint a short-lived access token for an existing **native/manual** Primer student.
150
265
 
151
266
  ```ts
152
- const { accessToken, expiresInSeconds } = await primer.exchangeNativeStudentForAccessToken(studentId)
267
+ const { accessToken, expiresInSeconds } =
268
+ await primer.exchangeStudentForAccessToken(studentId)
153
269
  ```
154
270
 
155
- ### `exchangeTimebackStudentForAccessToken(sourcedId): Promise<SessionToken>`
271
+ Call this at **every session start**.
272
+
273
+ Important:
274
+
275
+ - This is for **native/manual** students only.
276
+ - If `studentId` belongs to a Timeback-linked student, the method throws `ErrExternalAuthorityRequired`.
277
+ - Tokens are short-lived (typically 15 minutes).
278
+
279
+ ### `exchangeTimebackStudentForAccessToken(sourcedId): Promise<TimebackSession>`
280
+
281
+ Perform a **live-authoritative Timeback session start**.
156
282
 
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.
283
+ Primer verifies the learner against Timeback on every call, then resolves or provisions the corresponding frontend-owned Primer student and returns:
284
+
285
+ - the stable Primer `studentId`
286
+ - a short-lived `accessToken`
287
+ - `expiresInSeconds`
158
288
 
159
289
  ```ts
160
- const { accessToken, expiresInSeconds } = await primer.exchangeTimebackStudentForAccessToken(sourcedId)
290
+ const { studentId, accessToken, expiresInSeconds } =
291
+ await primer.exchangeTimebackStudentForAccessToken(sourcedId)
161
292
  ```
162
293
 
163
- ## `SessionToken`
294
+ Use this at **every Timeback session start**.
295
+
296
+ Operational rule:
297
+
298
+ - Call this at every Timeback session start.
299
+ - Persist `studentId` if you need a stable Primer foreign key in your own system.
300
+ - Use the returned `accessToken` for the active browser session.
301
+
302
+ ## Return types
303
+
304
+ ### `SessionToken`
164
305
 
165
306
  ```ts
166
307
  interface SessionToken {
167
- readonly accessToken: string // HS256 JWS — pass to client SDK as Config.accessToken
168
- readonly expiresInSeconds: number // typically 900 (15 min)
308
+ readonly accessToken: string
309
+ readonly expiresInSeconds: number
169
310
  }
170
311
  ```
171
312
 
172
- Hand `accessToken` to your frontend over whatever channel you already use (cookie, HTML response, WebSocket, API response). The browser passes it to `create()`.
173
-
174
- ## `GradeLevel`
313
+ ### `TimebackSession`
175
314
 
176
315
  ```ts
177
- type GradeLevel = "K" | "1" | "2" | "3" | "4" | "5" | "6" | "7" | "8" | "9" | "10" | "11" | "12"
316
+ interface TimebackSession {
317
+ readonly studentId: string
318
+ readonly accessToken: string
319
+ readonly expiresInSeconds: number
320
+ }
178
321
  ```
179
322
 
180
- Exported as both `GradeLevel` and the `GRADE_LEVELS` readonly tuple.
181
-
182
323
  ## Error sentinels (`/server`)
183
324
 
325
+ All `/server` methods throw sentinel-wrapped `Error`s. Use `errors.try()` and `errors.is()` from `@superbuilders/errors`.
326
+
184
327
  | Sentinel | Raised when |
185
328
  |---|---|
186
329
  | `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 |
330
+ | `ErrStudentNotFound` | HTTP 404 — native `studentId` unknown on this frontend, or Timeback `sourcedId` unknown upstream |
331
+ | `ErrUnsupportedGrade` | HTTP 400 — Timeback returned a grade outside Primer's supported range |
332
+ | `ErrTimebackUnavailable` | HTTP 502 — Timeback OneRoster endpoint failed during live exchange |
333
+ | `ErrExternalAuthorityRequired` | HTTP 409attempted native/manual exchange for a Timeback-linked student |
334
+ | `ErrConflict` | HTTP 409 — frontend is not provisioned for routing/content |
335
+ | `ErrBadRequest` | HTTP 400 — validation failure |
191
336
  | `ErrServerError` | HTTP 5xx |
192
- | `ErrJsonParse` | Success response body wasn't valid JSON |
193
- | `ErrNetwork` | fetch() rejected (DNS, connection, TLS) |
337
+ | `ErrJsonParse` | Success response body was not valid JSON or had the wrong shape |
338
+ | `ErrNetwork` | fetch() rejected (DNS, connection, TLS, etc.) |
194
339
  | `ErrTimeout` | fetch() aborted (your `AbortController` or `TimeoutError`) |
195
340
 
196
- All server methods follow the same pattern:
341
+ ### Recommended error-handling pattern
197
342
 
198
343
  ```ts
199
- const result = await errors.try(primer.createNativeStudent("5"))
344
+ import * as errors from "@superbuilders/errors"
345
+ import {
346
+ createPrimerServer,
347
+ ErrExternalAuthorityRequired,
348
+ ErrInvalidSecretKey,
349
+ ErrStudentNotFound
350
+ } from "@superbuilders/primer-tives/server"
351
+
352
+ const primer = createPrimerServer({
353
+ origin: "https://sb-primer.vercel.app",
354
+ secretKey: process.env.PRIMER_CLIENT_SECRET_KEY_DEV!,
355
+ logger: console
356
+ })
357
+
358
+ const result = await errors.try(
359
+ primer.exchangeStudentForAccessToken(studentId)
360
+ )
200
361
  if (result.error) {
201
362
  if (errors.is(result.error, ErrInvalidSecretKey)) {
202
- // rotate the sk_ and retry
363
+ // rotate or fix your sk_
364
+ }
365
+ if (errors.is(result.error, ErrStudentNotFound)) {
366
+ // your stored studentId is stale or wrong for this frontend
367
+ }
368
+ if (errors.is(result.error, ErrExternalAuthorityRequired)) {
369
+ // this student must authenticate through live Timeback exchange
203
370
  }
204
371
  throw result.error
205
372
  }
206
- const studentId = result.data
373
+
374
+ const { accessToken } = result.data
207
375
  ```
208
376
 
377
+ ## Rules of the road
378
+
379
+ - **Native/manual flow:** `createStudent()` once, then `exchangeStudentForAccessToken(studentId)` every session.
380
+ - **Timeback flow:** `exchangeTimebackStudentForAccessToken(sourcedId)` every session.
381
+ - **Student ids are stable identifiers, not browser credentials.** Always hand the browser a fresh `accessToken` from your backend.
382
+ - **Server-only secrets.** Keep `secretKey` on your backend; never ship it to the browser.
383
+ - **Explicit subpaths only.** Import from `/server` or `/client`, never a package root.
384
+
209
385
  ---
210
386
 
211
387
  # `/client`
@@ -15,6 +15,7 @@ var ErrTimeout = errors.new("timeout");
15
15
  var ErrForbidden = errors.new("forbidden");
16
16
  var ErrNotFound = errors.new("not found");
17
17
  var ErrConflict = errors.new("conflict");
18
+ var ErrExternalAuthorityRequired = errors.new("external authority required");
18
19
  var ErrRateLimited = errors.new("rate limited");
19
20
  var ErrServiceUnavailable = errors.new("service unavailable");
20
21
  var ErrNotSerializable = errors.new("PrimerState is live in-memory state and must not be serialized or stored");
@@ -735,11 +736,9 @@ function isRetriableError(err) {
735
736
  }
736
737
  function makeSession(sc) {
737
738
  const log = sc.log;
738
- let currentFrameContext = null;
739
739
  function resolve(result) {
740
740
  switch (result.outcome) {
741
741
  case "advanced":
742
- currentFrameContext = result.frameContext;
743
742
  return fromAdvanced(result.stimulus, result.interaction);
744
743
  case "submitted":
745
744
  return feedbackState(ctx, result.stimulus, result.interaction, result.submission, result.isCorrect, result.feedbackContent, result.review);
@@ -771,32 +770,11 @@ function makeSession(sc) {
771
770
  return state;
772
771
  }
773
772
  async function execute(intent, phase) {
774
- let body;
775
- if (intent.kind === "observation") {
776
- body = {
777
- supportedPcis: sc.supportedPcis,
778
- intent,
779
- subject: sc.subject
780
- };
781
- } else {
782
- if (currentFrameContext === null) {
783
- log?.error("missing frame context for non-observation intent", {
784
- phase,
785
- intentKind: intent.kind
786
- });
787
- return {
788
- phase: "fatal",
789
- error: ErrBadRequest,
790
- retriable: false,
791
- toJSON: poisonToJSON
792
- };
793
- }
794
- body = {
795
- supportedPcis: sc.supportedPcis,
796
- intent: { ...intent, frameContext: currentFrameContext },
797
- subject: sc.subject
798
- };
799
- }
773
+ const body = {
774
+ supportedPcis: sc.supportedPcis,
775
+ intent,
776
+ subject: sc.subject
777
+ };
800
778
  const result = await sc.transport(body);
801
779
  if (!result.ok) {
802
780
  if (isFatalError(result.error)) {
@@ -946,4 +924,4 @@ export {
946
924
  ErrBadRequest
947
925
  };
948
926
 
949
- //# debugId=77F88432A1CED38764756E2164756E21
927
+ //# debugId=03C66069BFA6BE2F64756E2164756E21