@edpire/sdk 0.6.2 → 0.6.3

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.
@@ -0,0 +1,540 @@
1
+ import { RuntimeAnswer } from '@youssefalmia/edpire-runtime';
2
+
3
+ /** Flat per-question answer collected during a custom player session. */
4
+ interface StoredAnswer {
5
+ /** Exercise ID — from the `exerciseId` field on each `FlatStep`. */
6
+ exerciseId: string;
7
+ /** Question ID — from the `questionId` field on each `FlatStep`. */
8
+ questionId: string;
9
+ /** Answers collected from `EdpireQuestion`'s `onAnswersChange` callback. */
10
+ answers: RuntimeAnswer[];
11
+ }
12
+
13
+ /**
14
+ * @edpire/sdk/client — Server-side API client for Edpire.
15
+ *
16
+ * Node.js only. Zero browser dependencies. Uses native fetch (Node 18+).
17
+ *
18
+ * @example
19
+ * ```typescript
20
+ * import { EdpireClient } from "@edpire/sdk/client"
21
+ *
22
+ * const client = new EdpireClient({
23
+ * apiKey: process.env.EDPIRE_API_KEY!,
24
+ * baseUrl: "https://edpire.com",
25
+ * })
26
+ *
27
+ * const assessments = await client.getAssessments({ status: "published" })
28
+ * const result = await client.submit("assessment-id", {
29
+ * learner_ref: "user-123",
30
+ * answers: { exerciseAnswers: [...] },
31
+ * })
32
+ * ```
33
+ */
34
+
35
+ declare class EdpireError extends Error {
36
+ /** HTTP status code from the API response. */
37
+ status: number;
38
+ constructor(status: number, message: string);
39
+ }
40
+ interface EdpireClientOptions {
41
+ /** Your Edpire API key (starts with `edp_live_`). Store server-side only. */
42
+ apiKey: string;
43
+ /** Base URL of the Edpire API. Defaults to `"https://edpire.com"`. */
44
+ baseUrl?: string;
45
+ /** Custom fetch implementation (for testing or edge runtimes). */
46
+ fetch?: typeof globalThis.fetch;
47
+ }
48
+ /** Assessment summary returned by getAssessments() (list). Does not include exercises. */
49
+ interface AssessmentSummary {
50
+ id: string;
51
+ title: string;
52
+ description: string | null;
53
+ type: string;
54
+ status: string;
55
+ share_code: string | null;
56
+ settings: Record<string, unknown>;
57
+ exercise_count?: number;
58
+ created_at: string;
59
+ updated_at: string;
60
+ }
61
+ /** Full assessment returned by getAssessment() (single fetch). Always includes exercises. */
62
+ interface Assessment extends AssessmentSummary {
63
+ exercises: AssessmentExercise[];
64
+ }
65
+ interface AssessmentExercise {
66
+ id: string;
67
+ shared_context: unknown | null;
68
+ questions: AssessmentQuestion[];
69
+ }
70
+ interface AssessmentQuestion {
71
+ id: string;
72
+ content_ast: unknown;
73
+ points: number;
74
+ sequence_number: number;
75
+ }
76
+ interface PaginatedResponse<T> {
77
+ items: T[];
78
+ total: number;
79
+ page: number;
80
+ limit: number;
81
+ }
82
+ interface SubmitOptions {
83
+ /** Your stable user ID for the learner. */
84
+ learner_ref: string;
85
+ /**
86
+ * Assessment answers. Accepts two formats:
87
+ *
88
+ * **Flat array (recommended for custom players):** Pass a `StoredAnswer[]`
89
+ * collected during the session — one entry per question. The client converts
90
+ * it to the nested format automatically using `buildSubmitPayload()`.
91
+ *
92
+ * ```typescript
93
+ * answers: stored // StoredAnswer[] — exerciseId + questionId + RuntimeAnswer[]
94
+ * ```
95
+ *
96
+ * **Nested format (advanced):** The raw `exerciseAnswers` structure expected
97
+ * by the API. Use this if you're constructing the payload manually.
98
+ *
99
+ * ```typescript
100
+ * answers: { exerciseAnswers: [{ exerciseId, questionAnswers: [...] }] }
101
+ * ```
102
+ */
103
+ answers: StoredAnswer[] | {
104
+ exerciseAnswers: Array<{
105
+ exerciseId: string;
106
+ questionAnswers: Array<{
107
+ questionId: string;
108
+ answers: Array<Record<string, unknown>>;
109
+ }>;
110
+ }>;
111
+ };
112
+ /** Set to true to submit against draft assessments (for testing). */
113
+ allow_draft?: boolean;
114
+ /** Optional consumer-side metadata (not stored by Edpire). */
115
+ metadata?: Record<string, unknown>;
116
+ }
117
+ /** Result returned by `EdpireClient.submit()` after a full submission is graded. */
118
+ interface GradeResult {
119
+ submission_id: string;
120
+ score: number;
121
+ max_score: number;
122
+ percentage: number;
123
+ passed: boolean;
124
+ passing_score_percent: number;
125
+ attempt_number: number;
126
+ exercise_results: Array<{
127
+ exercise_id: string;
128
+ total_score: number;
129
+ max_score: number;
130
+ question_results: Array<{
131
+ question_id: string;
132
+ score: number;
133
+ max_score: number;
134
+ correct: boolean;
135
+ points: number;
136
+ feedback: Record<string, unknown>;
137
+ }>;
138
+ }>;
139
+ }
140
+ interface CheckOptions {
141
+ /** Exercise ID containing the question. */
142
+ exercise_id: string;
143
+ /** Question ID to grade. */
144
+ question_id: string;
145
+ /** Learner's answers — pass the RuntimeAnswer[] array from EdpireQuestion's onAnswersChange directly. */
146
+ answers: RuntimeAnswer[];
147
+ /** Your stable user ID for rate limiting. */
148
+ learner_ref: string;
149
+ /**
150
+ * Session ID for rate limiting — generate once per attempt with crypto.randomUUID().
151
+ * If omitted, rate limiting falls back to learner_ref, which accumulates across
152
+ * all attempts by the same learner. Always pass a session_id in production.
153
+ */
154
+ session_id: string;
155
+ /**
156
+ * When true, the feedback includes correct-answer fields:
157
+ * - `correctChoiceIds` for choice/drag-drop questions
158
+ * - `correctPairings` for matching questions
159
+ * - `displayAnswer` for typed blank questions
160
+ *
161
+ * Use this in Duolingo-style flows to reveal the correct answer after the
162
+ * learner has committed their response. Rate limiting (3 checks per question
163
+ * per session) prevents brute-force abuse.
164
+ *
165
+ * Default: false — correct answers are never revealed.
166
+ */
167
+ include_correct_answers?: boolean;
168
+ }
169
+ interface CheckResult {
170
+ correct: boolean;
171
+ score: number;
172
+ max_score: number;
173
+ feedback: Record<string, unknown>;
174
+ }
175
+ interface Submission {
176
+ id: string;
177
+ assessment_id: string;
178
+ learner_id: string | null;
179
+ learner_ref: string | null;
180
+ status: string;
181
+ attempt_number: number;
182
+ score: number;
183
+ max_score: number;
184
+ percentage: number;
185
+ passed: boolean | null;
186
+ started_at: string;
187
+ submitted_at: string | null;
188
+ graded_at: string | null;
189
+ question_results?: Array<{
190
+ question_id: string;
191
+ sequence_number: number;
192
+ points: number;
193
+ result: unknown;
194
+ }>;
195
+ }
196
+ interface Collection {
197
+ id: string;
198
+ name: string;
199
+ slug: string;
200
+ description: string | null;
201
+ status: string;
202
+ item_count: number;
203
+ created_at: string;
204
+ updated_at: string;
205
+ }
206
+ interface CollectionDetail extends Collection {
207
+ items: Array<{
208
+ id: string;
209
+ position: number;
210
+ assessment: Assessment;
211
+ }>;
212
+ }
213
+ interface Webhook {
214
+ id: string;
215
+ url: string;
216
+ events: string[];
217
+ status: string;
218
+ }
219
+ interface WebhookWithSecret extends Webhook {
220
+ secret: string;
221
+ }
222
+ interface EmbedToken {
223
+ token: string;
224
+ expires_at: string;
225
+ assessment_id: string;
226
+ learner_ref: string;
227
+ }
228
+ declare class EdpireClient {
229
+ private apiKey;
230
+ private baseUrl;
231
+ private fetchFn;
232
+ constructor(options: EdpireClientOptions);
233
+ private request;
234
+ private requestPaginated;
235
+ /**
236
+ * List assessments in your org.
237
+ *
238
+ * @param params.status - Filter by status: `"draft"`, `"published"`, or `"archived"`.
239
+ * @param params.ids - Bulk fetch by IDs (max 50). Comma-separated or array.
240
+ * @param params.page - Page number (default 1).
241
+ * @param params.limit - Items per page (default 20, max 100).
242
+ * @returns Paginated list of assessments with exercise counts.
243
+ * @throws {EdpireError} 401/403 for auth issues.
244
+ *
245
+ * @example
246
+ * ```typescript
247
+ * const { items } = await client.getAssessments({ status: "published" })
248
+ * const { items: batch } = await client.getAssessments({ ids: ["id1", "id2"] })
249
+ * ```
250
+ */
251
+ getAssessments(params?: {
252
+ status?: string;
253
+ ids?: string[];
254
+ page?: number;
255
+ limit?: number;
256
+ }): Promise<PaginatedResponse<AssessmentSummary>>;
257
+ /**
258
+ * Fetch a single assessment with its exercises and questions.
259
+ *
260
+ * Returns content for rendering (no answer keys, ever).
261
+ *
262
+ * @param id - Assessment UUID.
263
+ * @returns Assessment with exercises and questions.
264
+ * @throws {EdpireError} 404 if not found, 401/403 for auth issues.
265
+ *
266
+ * @example
267
+ * ```typescript
268
+ * const assessment = await client.getAssessment("abc-123")
269
+ * console.log(assessment.title, assessment.exercises?.length)
270
+ * ```
271
+ */
272
+ getAssessment(id: string): Promise<Assessment>;
273
+ /**
274
+ * Submit a full assessment for grading (headless).
275
+ *
276
+ * Creates a submission record, grades all answers, and returns results
277
+ * synchronously. No Edpire UI involved. Also fires `submission.graded` webhook.
278
+ *
279
+ * @param assessmentId - Assessment UUID.
280
+ * @param options - Learner ref, answers, and optional flags.
281
+ * @returns Graded result with per-question feedback.
282
+ * @throws {EdpireError} 404 (not found), 400 (draft/archived), 409 (max attempts), 422 (grading failed).
283
+ *
284
+ * @example
285
+ * ```typescript
286
+ * const result = await client.submit("assessment-id", {
287
+ * learner_ref: "user-123",
288
+ * answers: {
289
+ * exerciseAnswers: [{
290
+ * exerciseId: "ex-1",
291
+ * questionAnswers: [{
292
+ * questionId: "q-1",
293
+ * answers: [{ nodeId: "n1", value: "selected-choice" }],
294
+ * }],
295
+ * }],
296
+ * },
297
+ * })
298
+ * console.log(result.score, result.max_score, result.passed)
299
+ * ```
300
+ */
301
+ submit(assessmentId: string, options: SubmitOptions): Promise<GradeResult>;
302
+ /**
303
+ * Grade a single question without creating a submission.
304
+ *
305
+ * Designed for Duolingo-style flows: immediate per-question feedback,
306
+ * hearts/lives, practice drills. Stateless — no submission record.
307
+ *
308
+ * @param assessmentId - Assessment UUID.
309
+ * @param options - Exercise ID, question ID, answers, and learner ref.
310
+ * @returns Correctness result with sanitized per-node feedback.
311
+ * @throws {EdpireError} 404 (not found), 429 (rate limit exceeded).
312
+ *
313
+ * @example
314
+ * ```typescript
315
+ * const check = await client.checkQuestion("assessment-id", {
316
+ * exercise_id: "ex-1",
317
+ * question_id: "q-1",
318
+ * answers: [{ nodeId: "n1", value: "typed-answer" }],
319
+ * learner_ref: "user-123",
320
+ * })
321
+ * if (check.correct) { hearts.keep() } else { hearts.lose() }
322
+ * ```
323
+ */
324
+ checkQuestion(assessmentId: string, options: CheckOptions): Promise<CheckResult>;
325
+ /**
326
+ * Fetch a detailed submission with per-question results.
327
+ *
328
+ * @param id - Submission UUID.
329
+ * @returns Full submission with question-level breakdown.
330
+ * @throws {EdpireError} 404 if not found, 401/403 for auth issues.
331
+ */
332
+ getSubmission(id: string): Promise<Submission>;
333
+ /**
334
+ * Fetch all submissions for a learner.
335
+ *
336
+ * @param learnerRef - Your stable user ID (the `learner_ref` you passed during submit).
337
+ * @param params.page - Page number (default 1).
338
+ * @param params.limit - Items per page (default 50, max 100).
339
+ * @returns Paginated list of submissions.
340
+ * @throws {EdpireError} 401/403 for auth issues.
341
+ */
342
+ getLearnerResults(learnerRef: string, params?: {
343
+ page?: number;
344
+ limit?: number;
345
+ }): Promise<PaginatedResponse<Submission>>;
346
+ /**
347
+ * List collections in your org.
348
+ *
349
+ * @param params.page - Page number (default 1).
350
+ * @param params.limit - Items per page (default 20, max 100).
351
+ * @returns Paginated list of collections with item counts.
352
+ */
353
+ getCollections(params?: {
354
+ page?: number;
355
+ limit?: number;
356
+ }): Promise<PaginatedResponse<Collection>>;
357
+ /**
358
+ * Fetch a collection with its assessment items.
359
+ *
360
+ * @param id - Collection UUID.
361
+ * @returns Collection detail with ordered assessment items.
362
+ * @throws {EdpireError} 404 if not found.
363
+ */
364
+ getCollection(id: string): Promise<CollectionDetail>;
365
+ /**
366
+ * Fetch aggregated results across all assessments in a collection.
367
+ *
368
+ * @param id - Collection UUID.
369
+ * @param params.page - Page number (default 1).
370
+ * @param params.limit - Items per page (default 50, max 100).
371
+ * @returns Paginated flat list of submissions with assessment titles.
372
+ */
373
+ getCollectionResults(id: string, params?: {
374
+ page?: number;
375
+ limit?: number;
376
+ }): Promise<PaginatedResponse<Submission & {
377
+ assessment_title: string;
378
+ }>>;
379
+ /**
380
+ * Register a webhook endpoint.
381
+ *
382
+ * The secret is returned once only — store it securely for signature verification.
383
+ *
384
+ * @param url - HTTPS endpoint URL (http://localhost allowed for dev).
385
+ * @param events - Event types to subscribe to (e.g., `["submission.graded"]`).
386
+ * @returns Webhook with secret (shown once).
387
+ * @throws {EdpireError} 400 for invalid URL or events.
388
+ */
389
+ registerWebhook(url: string, events: string[]): Promise<WebhookWithSecret>;
390
+ /**
391
+ * List registered webhooks (secrets not included).
392
+ *
393
+ * @returns Array of webhooks.
394
+ */
395
+ listWebhooks(): Promise<Webhook[]>;
396
+ /**
397
+ * Delete a webhook endpoint.
398
+ *
399
+ * @param id - Webhook UUID.
400
+ * @throws {EdpireError} 404 if not found.
401
+ */
402
+ deleteWebhook(id: string): Promise<void>;
403
+ /**
404
+ * Mint an embed token for browser-side assessment rendering.
405
+ *
406
+ * Use this when you want to embed Edpire's assessment UI in your page
407
+ * via `EdpireAssessment.mount()`. The token is single-use and expires in 1 hour.
408
+ *
409
+ * @param assessmentId - Assessment UUID.
410
+ * @param learnerRef - Your stable user ID.
411
+ * @returns JWT token and expiration timestamp.
412
+ * @throws {EdpireError} 404 if assessment not found.
413
+ */
414
+ mintEmbedToken(assessmentId: string, learnerRef: string): Promise<EmbedToken>;
415
+ }
416
+ /** Minimal interface covering IncomingMessage + connect/Express-style requests. */
417
+ interface ConnectRequest {
418
+ method?: string;
419
+ url?: string;
420
+ headers: Record<string, string | string[] | undefined>;
421
+ on(event: "data", listener: (chunk: Uint8Array) => void): ConnectRequest;
422
+ on(event: "end", listener: () => void): ConnectRequest;
423
+ on(event: "error", listener: (err: Error) => void): ConnectRequest;
424
+ on(event: string, listener: (...args: unknown[]) => void): ConnectRequest;
425
+ }
426
+ /** Minimal interface covering ServerResponse + connect/Express-style responses. */
427
+ interface ConnectResponse {
428
+ statusCode: number;
429
+ setHeader(name: string, value: string | number | string[]): void;
430
+ end(data?: string | Uint8Array): void;
431
+ }
432
+ interface EdpireTokenHandlerOptions {
433
+ /** Your Edpire API key (starts with `edp_live_`). NEVER expose client-side. */
434
+ apiKey: string;
435
+ /** Base URL of the Edpire API. Defaults to `"https://edpire.com"`. */
436
+ baseUrl?: string;
437
+ /**
438
+ * Resolve the learner's stable ID from the incoming request.
439
+ *
440
+ * Read from YOUR auth/session system — **NEVER** trust a value from the
441
+ * request body or query string. Return `null` / `undefined` (or throw) to
442
+ * reject unauthenticated requests with a 401.
443
+ *
444
+ * @example Next.js App Router + Better Auth
445
+ * ```typescript
446
+ * resolveLearner: async (req) => {
447
+ * const session = await auth.api.getSession({ headers: req.headers })
448
+ * return session?.user.id ?? null // null → 401 Unauthorized
449
+ * }
450
+ * ```
451
+ */
452
+ resolveLearner: (req: Request) => string | null | undefined | Promise<string | null | undefined>;
453
+ /**
454
+ * Optionally validate or remap the assessment ID from the request body.
455
+ *
456
+ * If omitted, the handler reads `assessmentId` from the POST body JSON and
457
+ * passes it directly to Edpire. Use this callback to enforce an allow-list,
458
+ * validate the ID belongs to the learner's course, or map your own IDs to
459
+ * Edpire UUIDs.
460
+ *
461
+ * Throw to reject the request (handler returns 403).
462
+ *
463
+ * @example Allow-list
464
+ * ```typescript
465
+ * resolveAssessmentId: (req, id) => {
466
+ * if (!ALLOWED_IDS.includes(id)) throw new Error("Assessment not permitted")
467
+ * return id
468
+ * }
469
+ * ```
470
+ */
471
+ resolveAssessmentId?: (req: Request, fromBody: string) => string | Promise<string>;
472
+ }
473
+ /**
474
+ * Create a Web-standard token-minting request handler for the Embedded Player.
475
+ *
476
+ * Returns a `(req: Request) => Promise<Response>` that:
477
+ * 1. Calls your `resolveLearner` to get the learner ID from YOUR session.
478
+ * 2. Reads `assessmentId` from the POST body (override with `resolveAssessmentId`).
479
+ * 3. Mints a short-lived, single-use embed token via `EdpireClient.mintEmbedToken()`.
480
+ * 4. Returns `{ token }` or an appropriate HTTP error.
481
+ *
482
+ * The returned function is **Web-standard** (use directly as a Next.js App Router
483
+ * route export). For Express / Node.js http / Vite dev middleware, wrap it with
484
+ * `toNodeHandler()`.
485
+ *
486
+ * @example Next.js App Router
487
+ * ```typescript
488
+ * // app/api/edpire/token/route.ts
489
+ * import { createEdpireTokenHandler } from "@edpire/sdk/client"
490
+ * import { auth } from "@/lib/auth"
491
+ *
492
+ * export const POST = createEdpireTokenHandler({
493
+ * apiKey: process.env.EDPIRE_API_KEY!,
494
+ * resolveLearner: async (req) => {
495
+ * const session = await auth.api.getSession({ headers: req.headers })
496
+ * return session?.user.id ?? null // null → 401 Unauthorized
497
+ * },
498
+ * })
499
+ * ```
500
+ *
501
+ * @example Express / Vite dev middleware
502
+ * ```typescript
503
+ * import { createEdpireTokenHandler, toNodeHandler } from "@edpire/sdk/client"
504
+ *
505
+ * const tokenHandler = toNodeHandler(createEdpireTokenHandler({
506
+ * apiKey: process.env.EDPIRE_API_KEY!,
507
+ * resolveLearner: (req) => req.session?.userId ?? null,
508
+ * }))
509
+ * app.post("/api/edpire/token", tokenHandler)
510
+ * ```
511
+ */
512
+ declare function createEdpireTokenHandler(opts: EdpireTokenHandlerOptions): (req: Request) => Promise<Response>;
513
+ /**
514
+ * Adapt a Web-standard token handler (from `createEdpireTokenHandler`) to a
515
+ * Node.js-style connect middleware compatible with Express, Fastify, and Vite's
516
+ * `configureServer` dev middleware.
517
+ *
518
+ * @example Express
519
+ * ```typescript
520
+ * import express from "express"
521
+ * import { createEdpireTokenHandler, toNodeHandler } from "@edpire/sdk/client"
522
+ *
523
+ * const app = express()
524
+ * app.post("/api/edpire/token", toNodeHandler(createEdpireTokenHandler({
525
+ * apiKey: process.env.EDPIRE_API_KEY!,
526
+ * resolveLearner: (req) => req.session?.userId ?? null,
527
+ * })))
528
+ * ```
529
+ *
530
+ * @example Vite dev middleware
531
+ * ```typescript
532
+ * // vite.config.ts (dev only — for production use a real server)
533
+ * configureServer(server) {
534
+ * server.middlewares.use("/api/edpire/token", toNodeHandler(tokenHandler))
535
+ * }
536
+ * ```
537
+ */
538
+ declare function toNodeHandler(handler: (req: Request) => Promise<Response>): (req: ConnectRequest, res: ConnectResponse) => void;
539
+
540
+ export { type Assessment, type AssessmentExercise, type AssessmentQuestion, type AssessmentSummary, type CheckOptions, type CheckResult, type Collection, type CollectionDetail, EdpireClient, type EdpireClientOptions, EdpireError, type EdpireTokenHandlerOptions, type EmbedToken, type GradeResult, type PaginatedResponse, type StoredAnswer, type Submission, type SubmitOptions, type Webhook, type WebhookWithSecret, createEdpireTokenHandler, toNodeHandler };
@@ -2,6 +2,7 @@
2
2
  "name": "edpire-sdk-example-vite-express",
3
3
  "version": "0.1.0",
4
4
  "private": true,
5
+ "type": "module",
5
6
  "scripts": {
6
7
  "dev": "concurrently -k -n SERVER,VITE -c cyan,magenta \"tsx server.ts\" \"vite\"",
7
8
  "build": "vite build && tsc -p tsconfig.server.json",
@@ -4,7 +4,7 @@
4
4
  "noEmit": false,
5
5
  "outDir": "dist-server",
6
6
  "module": "ESNext",
7
- "moduleResolution": "node",
7
+ "moduleResolution": "Bundler",
8
8
  "target": "node18",
9
9
  "allowImportingTsExtensions": false
10
10
  },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@edpire/sdk",
3
- "version": "0.6.2",
3
+ "version": "0.6.3",
4
4
  "description": "Embeddable JS SDK for Edpire assessments",
5
5
  "license": "SEE LICENSE IN LICENSE",
6
6
  "homepage": "https://docs.edpire.com/developer/sdk/overview",
@@ -32,8 +32,9 @@
32
32
  "types": "./dist/react.d.mts"
33
33
  },
34
34
  "./client": {
35
+ "types": "./dist/client.d.mts",
35
36
  "import": "./dist/client.mjs",
36
- "types": "./dist/client.d.mts"
37
+ "default": "./dist/client.mjs"
37
38
  },
38
39
  "./styles/runtime-utilities.css": "./src/styles/runtime-utilities.css",
39
40
  "./styles/shell.css": "./src/styles/shell.css"