@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.
@@ -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");
@@ -25,15 +26,58 @@ var ErrUnsupportedGrade = errors.new("unsupported grade");
25
26
  var ErrTimebackUnavailable = errors.new("timeback unavailable");
26
27
 
27
28
  // src/server/exchange.ts
28
- var EXCHANGE_PATH = "/api/v0/auth/exchange";
29
- function isAbortError(err) {
30
- if (err instanceof DOMException && err.name === "AbortError") {
31
- return true;
29
+ var STUDENT_EXCHANGE_PATH = "/api/v0/auth/exchange";
30
+ var TIMEBACK_EXCHANGE_PATH = "/api/v0/auth/exchange/timeback";
31
+ function isObjectRecord(value) {
32
+ return typeof value === "object" && value !== null;
33
+ }
34
+ function isFiniteNumber(value) {
35
+ return typeof value === "number" && Number.isFinite(value);
36
+ }
37
+ function toExchangeErrorBody(data) {
38
+ if (!isObjectRecord(data)) {
39
+ return {};
40
+ }
41
+ return typeof data.error === "string" ? { error: data.error } : {};
42
+ }
43
+ function parseSessionTokenSuccessBody(data) {
44
+ if (!isObjectRecord(data)) {
45
+ return;
46
+ }
47
+ if (typeof data.access_token !== "string") {
48
+ return;
49
+ }
50
+ if (data.token_type !== "Bearer") {
51
+ return;
32
52
  }
33
- if (err instanceof DOMException && err.name === "TimeoutError") {
34
- return true;
53
+ if (!isFiniteNumber(data.expires_in)) {
54
+ return;
35
55
  }
36
- return false;
56
+ if (typeof data.scope !== "string") {
57
+ return;
58
+ }
59
+ return {
60
+ access_token: data.access_token,
61
+ token_type: data.token_type,
62
+ expires_in: data.expires_in,
63
+ scope: data.scope
64
+ };
65
+ }
66
+ function parseTimebackExchangeSuccessBody(data) {
67
+ const session = parseSessionTokenSuccessBody(data);
68
+ if (!session || !isObjectRecord(data) || typeof data.student_id !== "string") {
69
+ return;
70
+ }
71
+ return {
72
+ student_id: data.student_id,
73
+ access_token: session.access_token,
74
+ token_type: session.token_type,
75
+ expires_in: session.expires_in,
76
+ scope: session.scope
77
+ };
78
+ }
79
+ function isAbortError(err) {
80
+ return err.name === "AbortError" || err.name === "TimeoutError";
37
81
  }
38
82
  function mapBadRequest(body) {
39
83
  if (body.error === "unsupported_grade") {
@@ -51,12 +95,15 @@ function httpSentinel(status, body) {
51
95
  if (status === 404) {
52
96
  return ErrStudentNotFound;
53
97
  }
98
+ if (status === 409) {
99
+ if (body.error === "external_authority_required") {
100
+ return ErrExternalAuthorityRequired;
101
+ }
102
+ return ErrConflict;
103
+ }
54
104
  if (status === 502) {
55
105
  return ErrTimebackUnavailable;
56
106
  }
57
- if (status >= 500 && status < 600) {
58
- return ErrServerError;
59
- }
60
107
  return ErrServerError;
61
108
  }
62
109
  async function readErrorBody(res) {
@@ -68,70 +115,155 @@ async function readErrorBody(res) {
68
115
  if (!parsed.ok) {
69
116
  return {};
70
117
  }
71
- return parsed.data;
118
+ return toExchangeErrorBody(parsed.data);
72
119
  }
73
- async function exchangeToken(config, provider, studentId) {
74
- const log = config.logger;
120
+ async function sendExchangeRequest(config, path, body, logContext) {
121
+ const logger = config.logger;
75
122
  const fetchFn = config.fetch ? config.fetch : globalThis.fetch;
76
123
  const signal = config.abort ? config.abort.signal : undefined;
77
- log?.debug("exchange request", { provider });
78
- const url = `${config.origin}${EXCHANGE_PATH}`;
124
+ const url = `${config.origin}${path}`;
79
125
  const fetchResult = await fetchFn(url, {
80
126
  method: "POST",
81
127
  headers: {
82
128
  "Content-Type": "application/json",
83
129
  Authorization: `Bearer ${config.secretKey}`
84
130
  },
85
- body: JSON.stringify({ provider, student_id: studentId }),
131
+ body: JSON.stringify(body),
86
132
  signal
87
- }).then(function ok(r) {
88
- return { ok: true, response: r };
133
+ }).then(function ok(response) {
134
+ return { ok: true, response };
89
135
  }, function fail(err) {
90
136
  return { ok: false, error: err };
91
137
  });
92
138
  if (!fetchResult.ok) {
93
139
  if (isAbortError(fetchResult.error)) {
94
- log?.error("exchange timeout", { provider });
140
+ logger.error("exchange timeout", { path, ...logContext });
95
141
  throw errors2.wrap(ErrTimeout, fetchResult.error.message);
96
142
  }
97
- log?.error("exchange network error", { provider, error: fetchResult.error });
143
+ logger.error("exchange network error", { path, ...logContext, error: fetchResult.error });
98
144
  throw errors2.wrap(ErrNetwork, fetchResult.error.message);
99
145
  }
100
- const res = fetchResult.response;
101
- if (!res.ok) {
102
- const body = await readErrorBody(res);
103
- const sentinel = httpSentinel(res.status, body);
104
- const detail = body.error ? `${res.status} ${body.error}` : `${res.status}`;
105
- log?.error("exchange http error", { provider, status: res.status, body });
106
- throw errors2.wrap(sentinel, detail);
146
+ return fetchResult.response;
147
+ }
148
+ async function parseSessionTokenSuccess(config, res, logContext) {
149
+ const logger = config.logger;
150
+ const jsonResult = await res.json().then(function ok(data) {
151
+ return { ok: true, data };
152
+ }, function fail(err) {
153
+ return { ok: false, error: err };
154
+ });
155
+ if (!jsonResult.ok) {
156
+ logger.error("exchange json parse failed", { ...logContext, error: jsonResult.error });
157
+ throw errors2.wrap(ErrJsonParse, jsonResult.error.message);
158
+ }
159
+ const body = parseSessionTokenSuccessBody(jsonResult.data);
160
+ if (!body) {
161
+ logger.error("exchange success body had invalid shape", {
162
+ ...logContext,
163
+ body: jsonResult.data
164
+ });
165
+ throw errors2.wrap(ErrJsonParse, "exchange success body had invalid shape");
107
166
  }
167
+ logger.debug("exchange success", {
168
+ ...logContext,
169
+ expiresIn: body.expires_in
170
+ });
171
+ return {
172
+ accessToken: body.access_token,
173
+ expiresInSeconds: body.expires_in
174
+ };
175
+ }
176
+ async function parseTimebackSessionSuccess(config, res, sourcedId) {
177
+ const logger = config.logger;
108
178
  const jsonResult = await res.json().then(function ok(data) {
109
179
  return { ok: true, data };
110
180
  }, function fail(err) {
111
181
  return { ok: false, error: err };
112
182
  });
113
183
  if (!jsonResult.ok) {
114
- log?.error("exchange json parse failed", { provider, error: jsonResult.error });
184
+ logger.error("timeback exchange json parse failed", { sourcedId, error: jsonResult.error });
115
185
  throw errors2.wrap(ErrJsonParse, jsonResult.error.message);
116
186
  }
117
- log?.debug("exchange success", { provider, expiresIn: jsonResult.data.expires_in });
187
+ const body = parseTimebackExchangeSuccessBody(jsonResult.data);
188
+ if (!body) {
189
+ logger.error("timeback exchange success body had invalid shape", {
190
+ sourcedId,
191
+ body: jsonResult.data
192
+ });
193
+ throw errors2.wrap(ErrJsonParse, "timeback exchange success body had invalid shape");
194
+ }
195
+ logger.debug("timeback exchange success", {
196
+ sourcedId,
197
+ studentId: body.student_id,
198
+ expiresIn: body.expires_in
199
+ });
118
200
  return {
119
- accessToken: jsonResult.data.access_token,
120
- expiresInSeconds: jsonResult.data.expires_in
201
+ studentId: body.student_id,
202
+ accessToken: body.access_token,
203
+ expiresInSeconds: body.expires_in
121
204
  };
122
205
  }
206
+ async function exchangeStudent(config, studentId) {
207
+ const logger = config.logger;
208
+ logger.debug("exchange student request", { studentId });
209
+ const res = await sendExchangeRequest(config, STUDENT_EXCHANGE_PATH, { student_id: studentId }, { studentId, exchangeKind: "student_id" });
210
+ if (!res.ok) {
211
+ const body = await readErrorBody(res);
212
+ const sentinel = httpSentinel(res.status, body);
213
+ const detail = body.error ? `${res.status} ${body.error}` : `${res.status}`;
214
+ logger.error("exchange student http error", { status: res.status, body, studentId });
215
+ throw errors2.wrap(sentinel, detail);
216
+ }
217
+ return parseSessionTokenSuccess(config, res, { studentId, exchangeKind: "student_id" });
218
+ }
219
+ async function exchangeTimebackStudent(config, sourcedId) {
220
+ const logger = config.logger;
221
+ logger.debug("exchange timeback student request", { sourcedId });
222
+ const res = await sendExchangeRequest(config, TIMEBACK_EXCHANGE_PATH, { sourced_id: sourcedId }, { sourcedId, exchangeKind: "timeback" });
223
+ if (!res.ok) {
224
+ const body = await readErrorBody(res);
225
+ const sentinel = httpSentinel(res.status, body);
226
+ const detail = body.error ? `${res.status} ${body.error}` : `${res.status}`;
227
+ logger.error("exchange timeback student http error", { status: res.status, body, sourcedId });
228
+ throw errors2.wrap(sentinel, detail);
229
+ }
230
+ return parseTimebackSessionSuccess(config, res, sourcedId);
231
+ }
123
232
 
124
- // src/server/students.ts
233
+ // src/server/hints.ts
125
234
  import * as errors3 from "@superbuilders/errors";
235
+
236
+ // src/grade-level.ts
237
+ var GRADE_LEVELS = ["K", "1", "2", "3", "4", "5", "6", "7", "8", "9", "10", "11", "12"];
238
+
239
+ // src/server/hints.ts
126
240
  var STUDENTS_PATH = "/api/v0/students";
127
- function isAbortError2(err) {
128
- if (err instanceof DOMException && err.name === "AbortError") {
129
- return true;
241
+ function isObjectRecord2(value) {
242
+ return typeof value === "object" && value !== null;
243
+ }
244
+ function toHintsErrorBody(data) {
245
+ if (!isObjectRecord2(data)) {
246
+ return {};
130
247
  }
131
- if (err instanceof DOMException && err.name === "TimeoutError") {
132
- return true;
248
+ return typeof data.error === "string" ? { error: data.error } : {};
249
+ }
250
+ function parseHintsSuccess(data) {
251
+ if (!isObjectRecord2(data)) {
252
+ return;
253
+ }
254
+ if (typeof data.student_id !== "string") {
255
+ return;
256
+ }
257
+ if (data.grade_level !== null && typeof data.grade_level !== "string") {
258
+ return;
133
259
  }
134
- return false;
260
+ return {
261
+ student_id: data.student_id,
262
+ grade_level: data.grade_level
263
+ };
264
+ }
265
+ function isAbortError2(err) {
266
+ return err.name === "AbortError" || err.name === "TimeoutError";
135
267
  }
136
268
  function httpSentinel2(status) {
137
269
  if (status === 400) {
@@ -154,94 +286,235 @@ async function readErrorBody2(res) {
154
286
  if (!parsed.ok) {
155
287
  return {};
156
288
  }
157
- return parsed.data;
289
+ return toHintsErrorBody(parsed.data);
290
+ }
291
+ function buildRequestBody(hints) {
292
+ const body = {};
293
+ if (hints.gradeLevel !== undefined) {
294
+ body.grade_level = hints.gradeLevel;
295
+ }
296
+ return JSON.stringify(body);
158
297
  }
159
- async function sendStudentsRequest(config, path, method, body) {
160
- const log = config.logger;
298
+ async function sendSetHintsRequest(config, studentId, hints) {
299
+ const logger = config.logger;
161
300
  const fetchFn = config.fetch ? config.fetch : globalThis.fetch;
162
301
  const signal = config.abort ? config.abort.signal : undefined;
163
- const url = `${config.origin}${path}`;
302
+ const url = `${config.origin}${STUDENTS_PATH}/${encodeURIComponent(studentId)}/hints`;
164
303
  const fetchResult = await fetchFn(url, {
165
- method,
304
+ method: "PATCH",
166
305
  headers: {
167
- "Content-Type": "application/json",
168
- Authorization: `Bearer ${config.secretKey}`
306
+ Authorization: `Bearer ${config.secretKey}`,
307
+ "Content-Type": "application/json"
169
308
  },
170
- body: JSON.stringify(body),
309
+ body: buildRequestBody(hints),
171
310
  signal
172
- }).then(function ok(r) {
173
- return { ok: true, response: r };
311
+ }).then(function ok(response) {
312
+ return { ok: true, response };
174
313
  }, function fail(err) {
175
314
  return { ok: false, error: err };
176
315
  });
177
316
  if (!fetchResult.ok) {
178
317
  if (isAbortError2(fetchResult.error)) {
179
- log?.error("students timeout", { path, method });
318
+ logger.error("set student hints timeout", { studentId });
180
319
  throw errors3.wrap(ErrTimeout, fetchResult.error.message);
181
320
  }
182
- log?.error("students network error", { path, method, error: fetchResult.error });
321
+ logger.error("set student hints network error", {
322
+ studentId,
323
+ error: fetchResult.error
324
+ });
183
325
  throw errors3.wrap(ErrNetwork, fetchResult.error.message);
184
326
  }
185
327
  return fetchResult.response;
186
328
  }
187
- async function createStudent(config, gradeLevel) {
188
- const log = config.logger;
189
- log?.debug("create student request", { gradeLevel });
190
- const res = await sendStudentsRequest(config, STUDENTS_PATH, "POST", {
191
- grade_level: gradeLevel
329
+ async function parseHintsSuccessResponse(config, res) {
330
+ const logger = config.logger;
331
+ const jsonResult = await res.json().then(function ok(data) {
332
+ return { ok: true, data };
333
+ }, function fail(err) {
334
+ return { ok: false, error: err };
192
335
  });
336
+ if (!jsonResult.ok) {
337
+ logger.error("set hints response parse failed", { error: jsonResult.error });
338
+ throw errors3.wrap(ErrJsonParse, jsonResult.error.message);
339
+ }
340
+ const body = parseHintsSuccess(jsonResult.data);
341
+ if (!body) {
342
+ logger.error("set hints response had invalid shape", { body: jsonResult.data });
343
+ throw errors3.wrap(ErrJsonParse, "set hints success body had invalid shape");
344
+ }
345
+ logger.debug("set hints response success", {
346
+ studentId: body.student_id,
347
+ gradeLevel: body.grade_level
348
+ });
349
+ return {
350
+ studentId: body.student_id,
351
+ gradeLevel: coerceGradeLevel(body.grade_level)
352
+ };
353
+ }
354
+ function coerceGradeLevel(value) {
355
+ if (value === null) {
356
+ return null;
357
+ }
358
+ for (const g of GRADE_LEVELS) {
359
+ if (g === value) {
360
+ return g;
361
+ }
362
+ }
363
+ return null;
364
+ }
365
+ async function setStudentHints(config, studentId, hints) {
366
+ const logger = config.logger;
367
+ logger.debug("set student hints request", { studentId, hints });
368
+ const res = await sendSetHintsRequest(config, studentId, hints);
193
369
  if (!res.ok) {
194
370
  const body = await readErrorBody2(res);
195
371
  const sentinel = httpSentinel2(res.status);
196
372
  const detail = body.error ? `${res.status} ${body.error}` : `${res.status}`;
197
- log?.error("create student http error", { status: res.status, body });
373
+ logger.error("set student hints http error", {
374
+ studentId,
375
+ status: res.status,
376
+ body
377
+ });
198
378
  throw errors3.wrap(sentinel, detail);
199
379
  }
380
+ return parseHintsSuccessResponse(config, res);
381
+ }
382
+
383
+ // src/server/students.ts
384
+ import * as errors4 from "@superbuilders/errors";
385
+ var STUDENTS_PATH2 = "/api/v0/students";
386
+ function isObjectRecord3(value) {
387
+ return typeof value === "object" && value !== null;
388
+ }
389
+ function toStudentsErrorBody(data) {
390
+ if (!isObjectRecord3(data)) {
391
+ return {};
392
+ }
393
+ return typeof data.error === "string" ? { error: data.error } : {};
394
+ }
395
+ function parseStudentIdSuccessBody(data) {
396
+ if (!isObjectRecord3(data)) {
397
+ return;
398
+ }
399
+ if (typeof data.student_id !== "string") {
400
+ return;
401
+ }
402
+ return { student_id: data.student_id };
403
+ }
404
+ function isAbortError3(err) {
405
+ return err.name === "AbortError" || err.name === "TimeoutError";
406
+ }
407
+ function httpSentinel3(status) {
408
+ if (status === 400) {
409
+ return ErrBadRequest;
410
+ }
411
+ if (status === 401) {
412
+ return ErrInvalidSecretKey;
413
+ }
414
+ if (status === 409) {
415
+ return ErrConflict;
416
+ }
417
+ return ErrServerError;
418
+ }
419
+ async function readErrorBody3(res) {
420
+ const parsed = await res.json().then(function ok(data) {
421
+ return { ok: true, data };
422
+ }, function fail() {
423
+ return { ok: false };
424
+ });
425
+ if (!parsed.ok) {
426
+ return {};
427
+ }
428
+ return toStudentsErrorBody(parsed.data);
429
+ }
430
+ async function sendCreateStudentRequest(config) {
431
+ const logger = config.logger;
432
+ const fetchFn = config.fetch ? config.fetch : globalThis.fetch;
433
+ const signal = config.abort ? config.abort.signal : undefined;
434
+ const url = `${config.origin}${STUDENTS_PATH2}`;
435
+ const fetchResult = await fetchFn(url, {
436
+ method: "POST",
437
+ headers: {
438
+ Authorization: `Bearer ${config.secretKey}`
439
+ },
440
+ signal
441
+ }).then(function ok(response) {
442
+ return { ok: true, response };
443
+ }, function fail(err) {
444
+ return { ok: false, error: err };
445
+ });
446
+ if (!fetchResult.ok) {
447
+ if (isAbortError3(fetchResult.error)) {
448
+ logger.error("students timeout", { path: STUDENTS_PATH2 });
449
+ throw errors4.wrap(ErrTimeout, fetchResult.error.message);
450
+ }
451
+ logger.error("students network error", {
452
+ path: STUDENTS_PATH2,
453
+ error: fetchResult.error
454
+ });
455
+ throw errors4.wrap(ErrNetwork, fetchResult.error.message);
456
+ }
457
+ return fetchResult.response;
458
+ }
459
+ async function parseStudentIdSuccess(config, res) {
460
+ const logger = config.logger;
200
461
  const jsonResult = await res.json().then(function ok(data) {
201
462
  return { ok: true, data };
202
463
  }, function fail(err) {
203
464
  return { ok: false, error: err };
204
465
  });
205
466
  if (!jsonResult.ok) {
206
- log?.error("create student json parse failed", { error: jsonResult.error });
207
- throw errors3.wrap(ErrJsonParse, jsonResult.error.message);
467
+ logger.error("student id response parse failed", {
468
+ operation: "create student",
469
+ error: jsonResult.error
470
+ });
471
+ throw errors4.wrap(ErrJsonParse, jsonResult.error.message);
472
+ }
473
+ const body = parseStudentIdSuccessBody(jsonResult.data);
474
+ if (!body) {
475
+ logger.error("student id response had invalid shape", {
476
+ operation: "create student",
477
+ body: jsonResult.data
478
+ });
479
+ throw errors4.wrap(ErrJsonParse, "create student success body had invalid shape");
208
480
  }
209
- log?.debug("create student success", { studentId: jsonResult.data.student_id });
210
- return jsonResult.data.student_id;
481
+ logger.debug("student id response success", {
482
+ operation: "create student",
483
+ studentId: body.student_id
484
+ });
485
+ return body.student_id;
211
486
  }
212
- async function updateStudentGradeLevel(config, studentId, gradeLevel) {
213
- const log = config.logger;
214
- log?.debug("update student grade request", { studentId, gradeLevel });
215
- const res = await sendStudentsRequest(config, `${STUDENTS_PATH}/${encodeURIComponent(studentId)}`, "PATCH", { gradeLevel });
487
+ async function createStudent(config) {
488
+ const logger = config.logger;
489
+ logger.debug("create student request");
490
+ const res = await sendCreateStudentRequest(config);
216
491
  if (!res.ok) {
217
- const body = await readErrorBody2(res);
218
- const sentinel = httpSentinel2(res.status);
492
+ const body = await readErrorBody3(res);
493
+ const sentinel = httpSentinel3(res.status);
219
494
  const detail = body.error ? `${res.status} ${body.error}` : `${res.status}`;
220
- log?.error("update student http error", { status: res.status, body });
221
- throw errors3.wrap(sentinel, detail);
495
+ logger.error("create student http error", { status: res.status, body });
496
+ throw errors4.wrap(sentinel, detail);
222
497
  }
223
- log?.debug("update student success", { studentId });
498
+ return parseStudentIdSuccess(config, res);
224
499
  }
225
500
 
226
501
  // src/server/create-server.ts
227
502
  function createPrimerServer(config) {
228
503
  return {
229
- createNativeStudent(gradeLevel) {
230
- return createStudent(config, gradeLevel);
504
+ createStudent() {
505
+ return createStudent(config);
231
506
  },
232
- updateNativeStudentGradeLevel(studentId, gradeLevel) {
233
- return updateStudentGradeLevel(config, studentId, gradeLevel);
507
+ setStudentHints(studentId, hints) {
508
+ return setStudentHints(config, studentId, hints);
234
509
  },
235
- exchangeNativeStudentForAccessToken(studentId) {
236
- return exchangeToken(config, "native", studentId);
510
+ exchangeStudentForAccessToken(studentId) {
511
+ return exchangeStudent(config, studentId);
237
512
  },
238
513
  exchangeTimebackStudentForAccessToken(sourcedId) {
239
- return exchangeToken(config, "timeback", sourcedId);
514
+ return exchangeTimebackStudent(config, sourcedId);
240
515
  }
241
516
  };
242
517
  }
243
- // src/grade-level.ts
244
- var GRADE_LEVELS = ["K", "1", "2", "3", "4", "5", "6", "7", "8", "9", "10", "11", "12"];
245
518
  export {
246
519
  createPrimerServer,
247
520
  GRADE_LEVELS,
@@ -253,7 +526,9 @@ export {
253
526
  ErrNetwork,
254
527
  ErrJsonParse,
255
528
  ErrInvalidSecretKey,
529
+ ErrExternalAuthorityRequired,
530
+ ErrConflict,
256
531
  ErrBadRequest
257
532
  };
258
533
 
259
- //# debugId=3128BCEC46D85F5764756E2164756E21
534
+ //# debugId=7C586B84376F4E7C64756E2164756E21