@edpire/sdk 0.6.10 → 0.6.12

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.
@@ -1,6 +1,4 @@
1
- # Your Edpire API key — get one at https://edpire.com/org/integrations
2
- # NEVER prefix this with VITE_ or NEXT_PUBLIC_ — it must stay server-side only.
1
+ # Your Edpire API key, from edpire.com -> Integrations -> API keys.
2
+ # Server-side only. Never prefix it with NEXT_PUBLIC_, which would publish it
3
+ # in the browser bundle.
3
4
  EDPIRE_API_KEY=edp_live_your_key_here
4
-
5
- # The assessment UUID to embed (find it in the Edpire dashboard)
6
- NEXT_PUBLIC_ASSESSMENT_ID=your-assessment-uuid-here
@@ -1,66 +1,76 @@
1
- # Edpire SDK — Next.js example
2
-
3
- Embeds an Edpire assessment player inside a Next.js App Router page using:
4
- - `createEdpireTokenHandler()` for the server token endpoint (one call, one file)
5
- - `<EdpireAssessmentPlayer>` for the browser component (no useRef/useEffect needed)
6
-
7
- ```
8
- Your browser → POST /api/edpire/token
9
- Next.js server → mintEmbedToken(assessmentId, session.userId) ← API key never leaves the server
10
- Browser → EdpireAssessmentPlayer renders the full assessment UI
11
- ```
12
-
13
- ## Quickstart
14
-
15
- ```bash
16
- cp .env.example .env.local
17
- # Fill in EDPIRE_API_KEY (from edpire.com/org/integrations) and NEXT_PUBLIC_ASSESSMENT_ID
18
- npm install
19
- npm run dev
20
- ```
21
-
22
- Open http://localhost:3000 — the player loads.
23
-
24
- ## How the token endpoint works
25
-
26
- `app/api/edpire/token/route.ts` exports `createEdpireTokenHandler()` directly as the `POST` handler. In production you replace the cookie-reading `resolveLearner` stub with your real auth session:
27
-
28
- ```typescript
29
- // With NextAuth
30
- resolveLearner: async (req) => {
31
- const session = await getServerSession(authOptions)
32
- return session?.user.id ?? null
33
- }
34
-
35
- // With Better Auth
36
- resolveLearner: async (req) => {
37
- const session = await auth.api.getSession({ headers: req.headers })
38
- return session?.user.id ?? null
39
- }
40
- ```
41
-
42
- Returning `null` rejects with 401 — the player never receives a token.
43
-
44
- ## Allowed Origins
45
-
46
- If you see a blank player (no error, no content), your app's origin is not in Edpire's allow-list:
47
-
48
- 1. Go to **edpire.com → Integrations → Security tab → Allowed Embed Origins**
49
- 2. Add `http://localhost:3000` for dev, your production domain for prod
50
- 3. Reload — the player should appear
51
-
52
- See [SDK Troubleshooting](https://docs.edpire.com/developer/sdk/troubleshooting) for more.
53
-
54
- ## Production
55
-
56
- This example works in both `next dev` and `next build && next start`. The `/api/edpire/token` route is a real server-side endpoint in both modes — not a dev-only workaround.
57
-
58
- ## Project structure
59
-
60
- ```
61
- app/
62
- api/edpire/token/route.ts ← server: mints embed tokens
63
- layout.tsx
64
- page.tsx ← client: renders <EdpireAssessmentPlayer>
65
- .env.example
66
- ```
1
+ # Edpire in Next.js
2
+
3
+ A working assessment player in a Next.js App Router app. Three files are the
4
+ whole integration.
5
+
6
+ <table>
7
+ <tr>
8
+ <td width="33%"><img src="docs/screenshots/01-pick.jpg" alt="A list of published assessments, each with a title and an exercise count" /></td>
9
+ <td width="33%"><img src="docs/screenshots/02-play.jpg" alt="An assessment open in the Edpire player" /></td>
10
+ <td width="33%"><img src="docs/screenshots/03-graded.jpg" alt="The same assessment after submitting, with per-question marks and the answer key" /></td>
11
+ </tr>
12
+ <tr>
13
+ <td align="center"><sub><b>1.</b> Pick by title, not by ID</sub></td>
14
+ <td align="center"><sub><b>2.</b> The player</sub></td>
15
+ <td align="center"><sub><b>3.</b> Graded in place</sub></td>
16
+ </tr>
17
+ </table>
18
+
19
+ Everything from screen 2 onward is the SDK. You write screen 1 and the two API
20
+ routes.
21
+
22
+ | File | What it does |
23
+ |---|---|
24
+ | `app/api/edpire/assessments/route.ts` | Lists what is published, so the page can show titles |
25
+ | `app/api/edpire/token/route.ts` | Mints a short-lived token for one learner |
26
+ | `app/page.tsx` | Shows the list, then renders the player |
27
+
28
+ ## Run it
29
+
30
+ ```bash
31
+ cp .env.example .env.local # paste your EDPIRE_API_KEY
32
+ npm install
33
+ npm run dev
34
+ ```
35
+
36
+ Open http://localhost:3000. The page lists your published assessments; click one
37
+ to play it.
38
+
39
+ **Add `http://localhost:3000` to your allow-list first**, at edpire.com under
40
+ Integrations, Origins, Allowed Embed Origins. Without it the player says
41
+ `Origin not allowed` and names the origin to add, and `onError` fires with
42
+ `ORIGIN_NOT_ALLOWED`. Add your production domain before you deploy.
43
+
44
+ ## Why the token endpoint exists
45
+
46
+ Your API key can read and write everything in your organisation, so it cannot go
47
+ in the browser. The endpoint keeps it on the server and hands the browser a
48
+ token instead: single use, one learner, one assessment, expiring in two hours.
49
+
50
+ It also answers *which learner is this?* in a way the browser cannot forge. That
51
+ is `resolveLearner`, and replacing it is the one change you must make:
52
+
53
+ ```typescript
54
+ resolveLearner: async (req) => {
55
+ const session = await auth.api.getSession({ headers: req.headers })
56
+ return session?.user.id ?? null // null returns 401
57
+ }
58
+ ```
59
+
60
+ Until you do, every attempt is recorded against `demo-learner-001`.
61
+
62
+ ## Two things worth knowing
63
+
64
+ **The player fills its container**, so the container needs a real height. Without
65
+ one it collapses to zero and the player renders invisibly. Hence
66
+ `height: 100vh` in `page.tsx`.
67
+
68
+ **Treat `onComplete` as a UI hint.** It is the fastest way to show a score, and it
69
+ is a client callback. Record the authoritative result server-side from the
70
+ [`submission.graded` webhook](https://docs.edpire.com/developer/webhooks).
71
+
72
+ ## Next steps
73
+
74
+ - [Embedded Player](https://docs.edpire.com/developer/sdk/embedded-player), every prop and callback
75
+ - [Webhooks](https://docs.edpire.com/developer/webhooks), for recording results
76
+ - [Troubleshooting](https://docs.edpire.com/developer/sdk/troubleshooting)
@@ -0,0 +1,36 @@
1
+ /**
2
+ * The assessments this learner can play.
3
+ *
4
+ * Your API key is used here, on the server. The browser receives only titles
5
+ * and IDs, neither of which is secret, and shows them for the learner to pick.
6
+ *
7
+ * `status=published` matters: a draft cannot be played.
8
+ */
9
+ import { EdpireClient } from "@edpire/sdk/client"
10
+
11
+ const client = new EdpireClient({ apiKey: process.env.EDPIRE_API_KEY! })
12
+
13
+ export async function GET() {
14
+ try {
15
+ const { items } = await client.getAssessments({ status: "published", limit: 5 })
16
+
17
+ return Response.json({
18
+ assessments: items.map((a) => ({
19
+ id: a.id,
20
+ title: a.title,
21
+ exerciseCount: a.exercise_count ?? null,
22
+ })),
23
+ })
24
+ } catch (err) {
25
+ // "fetch failed" on its own tells the reader nothing, so name the two
26
+ // things it is almost always caused by.
27
+ return Response.json(
28
+ {
29
+ error:
30
+ `Could not list assessments: ${err instanceof Error ? err.message : err}. ` +
31
+ "Check EDPIRE_API_KEY, and that this machine can reach edpire.com.",
32
+ },
33
+ { status: 502 },
34
+ )
35
+ }
36
+ }
@@ -1,11 +1,8 @@
1
1
  /**
2
- * Token-minting endpoint for the Edpire Embedded Player.
2
+ * Mints a short-lived embed token for one learner and one assessment.
3
3
  *
4
- * The browser POSTs { assessmentId } here; this handler resolves the learner
5
- * from the session and returns a short-lived, single-use embed token.
6
- *
7
- * Powered by createEdpireTokenHandler() — one call handles auth checking,
8
- * token minting, and error mapping.
4
+ * The browser POSTs { assessmentId } and receives { token }. Your API key stays
5
+ * here: if it reached the browser it would be readable by anyone.
9
6
  */
10
7
  import { createEdpireTokenHandler } from "@edpire/sdk/client"
11
8
 
@@ -13,26 +10,22 @@ export const POST = createEdpireTokenHandler({
13
10
  apiKey: process.env.EDPIRE_API_KEY!,
14
11
 
15
12
  /**
16
- * Resolve the learner ID from YOUR auth system.
17
- *
18
- * This example reads a `userId` cookie set by a login flow.
19
- * In a real app replace this with your session/auth library:
13
+ * ───────────────────────────────────────────────────────────────────────
14
+ * REPLACE THIS. It decides whose results get recorded.
15
+ * ───────────────────────────────────────────────────────────────────────
20
16
  *
21
- * @example Next.js + NextAuth
22
- * const session = await getServerSession(req, res, authOptions)
23
- * return session?.user?.id ?? null
17
+ * Read the learner from your own session:
24
18
  *
25
- * @example Next.js + Better Auth
26
19
  * const session = await auth.api.getSession({ headers: req.headers })
27
- * return session?.user.id ?? null
20
+ * return session?.user.id ?? null // Better Auth
21
+ *
22
+ * const session = await getServerSession(authOptions)
23
+ * return session?.user.id ?? null // NextAuth
24
+ *
25
+ * Returning null rejects the request with 401 and mints nothing.
28
26
  *
29
- * Returning null → 401 Unauthorized (no token minted).
30
- * Never read the learner ID from the request body — that's an open relay.
27
+ * Never read the learner from the request body. If the browser can name the
28
+ * learner, any learner can submit results as any other.
31
29
  */
32
- resolveLearner: (req) => {
33
- // Demo: read userId from a signed cookie. Replace with your auth session.
34
- const cookie = req.headers.get("cookie") ?? ""
35
- const match = /(?:^|;\s*)userId=([^;]+)/.exec(cookie)
36
- return match ? decodeURIComponent(match[1]) : null
37
- },
30
+ resolveLearner: async () => "demo-learner-001",
38
31
  })
@@ -1,7 +1,7 @@
1
1
  import type { Metadata } from "next"
2
2
 
3
3
  export const metadata: Metadata = {
4
- title: "Edpire SDK — Next.js example",
4
+ title: "Edpire Next.js example",
5
5
  }
6
6
 
7
7
  export default function RootLayout({ children }: { children: React.ReactNode }) {
@@ -1,51 +1,130 @@
1
1
  "use client"
2
2
 
3
3
  /**
4
- * Example page: embed an Edpire assessment player.
4
+ * The whole integration, in two steps.
5
5
  *
6
- * How it works:
7
- * 1. EdpireAssessmentPlayer POSTs { assessmentId } to /api/edpire/token.
8
- * 2. The route handler resolves the learner from the session, mints a token,
9
- * and returns { token }.
10
- * 3. The player fetches the assessment content from Edpire using the token,
11
- * renders the full UI, and calls onComplete when the learner submits.
6
+ * 1. Ask your own server what is published, and show the titles.
7
+ * 2. Render <EdpireAssessmentPlayer> for whichever the learner picks. It fetches
8
+ * a token from /api/edpire/token, loads the assessment, grades the
9
+ * submission and shows per-question feedback.
12
10
  *
13
- * You never see the API key in the browser.
11
+ * Your API key never reaches the browser.
14
12
  */
13
+ import { useEffect, useState } from "react"
15
14
  import { EdpireAssessmentPlayer } from "@edpire/sdk/react"
16
15
 
17
- const ASSESSMENT_ID = process.env.NEXT_PUBLIC_ASSESSMENT_ID
16
+ type Assessment = { id: string; title: string; exerciseCount: number | null }
18
17
 
19
18
  export default function Page() {
20
- if (!ASSESSMENT_ID) {
19
+ const [assessments, setAssessments] = useState<Assessment[] | null>(null)
20
+ const [error, setError] = useState<string | null>(null)
21
+ const [playing, setPlaying] = useState<string | null>(null)
22
+
23
+ function load() {
24
+ setError(null)
25
+ setAssessments(null)
26
+ fetch("/api/edpire/assessments")
27
+ .then((r) => r.json())
28
+ .then((data) => (data.error ? setError(data.error) : setAssessments(data.assessments)))
29
+ .catch((e) => setError(String(e)))
30
+ }
31
+
32
+ useEffect(load, [])
33
+
34
+ if (playing) {
21
35
  return (
22
- <div style={{ padding: 32, fontFamily: "sans-serif" }}>
23
- <strong>Set NEXT_PUBLIC_ASSESSMENT_ID in .env.local</strong>
24
- <p>Copy .env.example → .env.local and fill in your assessment ID.</p>
25
- </div>
36
+ <EdpireAssessmentPlayer
37
+ tokenEndpoint="/api/edpire/token"
38
+ assessmentId={playing}
39
+ onComplete={(result) => {
40
+ // A UI hint. Record the authoritative result server-side from the
41
+ // submission.graded webhook.
42
+ console.log("complete", result.score, "/", result.max_score)
43
+ }}
44
+ onError={(err) => setError(`${err.code}: ${err.message}`)}
45
+ // The player fills its container, so the container needs a real height.
46
+ // Without one it collapses and the player renders invisibly.
47
+ style={{ width: "100%", height: "100vh" }}
48
+ />
26
49
  )
27
50
  }
28
51
 
29
52
  return (
30
- <EdpireAssessmentPlayer
31
- tokenEndpoint="/api/edpire/token"
32
- assessmentId={ASSESSMENT_ID}
33
- onComplete={(result) => {
34
- console.log("Assessment complete!", {
35
- score: result.score,
36
- max: result.max_score,
37
- passed: result.passed,
38
- })
39
- // TODO: redirect to results page, update progress, etc.
40
- }}
41
- onError={(err) => {
42
- console.error("Embed error:", err.code, err.message)
43
- // err.code is one of: TOKEN_INVALID | TOKEN_EXPIRED | TOKEN_USED |
44
- // ORIGIN_NOT_ALLOWED | ASSESSMENT_NOT_FOUND | MAX_ATTEMPTS_REACHED |
45
- // NETWORK_ERROR | UNKNOWN
46
- }}
47
- // The player fills its container. style/className controls the height.
48
- style={{ width: "100%", height: "100vh" }}
49
- />
53
+ <main style={styles.page}>
54
+ <h1 style={styles.title}>Edpire example</h1>
55
+
56
+ {error ? (
57
+ <>
58
+ <p style={styles.error}>{error}</p>
59
+ <button onClick={load} style={styles.retry}>Try again</button>
60
+ </>
61
+ ) : assessments === null ? (
62
+ <p style={styles.muted}>Loading...</p>
63
+ ) : assessments.length === 0 ? (
64
+ <Empty />
65
+ ) : (
66
+ <>
67
+ <p style={styles.muted}>Published in your organisation. Choose one to play it.</p>
68
+ {assessments.map((a) => (
69
+ <button key={a.id} onClick={() => setPlaying(a.id)} style={styles.card}>
70
+ <span style={styles.cardTitle}>{a.title}</span>
71
+ {a.exerciseCount !== null && (
72
+ <span style={styles.cardMeta}>{a.exerciseCount} exercise(s)</span>
73
+ )}
74
+ </button>
75
+ ))}
76
+ </>
77
+ )}
78
+ </main>
50
79
  )
51
80
  }
81
+
82
+ /** A new organisation has nothing published, and the fix is in Edpire. */
83
+ function Empty() {
84
+ return (
85
+ <div style={styles.empty}>
86
+ <p>
87
+ Nothing is published in this organisation yet, so there is nothing to open. Create an
88
+ assessment in Edpire, publish it, then reload. Drafts do not appear here.
89
+ </p>
90
+ <a href="https://docs.edpire.com/quickstart" style={styles.link}>
91
+ docs.edpire.com/quickstart
92
+ </a>
93
+ </div>
94
+ )
95
+ }
96
+
97
+ const styles: Record<string, React.CSSProperties> = {
98
+ page: { maxWidth: 640, margin: "0 auto", padding: 32, fontFamily: "system-ui, sans-serif" },
99
+ title: { fontSize: 24, marginBottom: 4 },
100
+ muted: { color: "#565b66", fontSize: 14 },
101
+ error: { color: "#d23b2b", fontSize: 14, whiteSpace: "pre-wrap" },
102
+ card: {
103
+ display: "flex",
104
+ flexDirection: "column",
105
+ alignItems: "flex-start",
106
+ gap: 4,
107
+ width: "100%",
108
+ padding: 16,
109
+ marginTop: 10,
110
+ border: "1px solid #e7e5e0",
111
+ borderRadius: 12,
112
+ background: "#fff",
113
+ cursor: "pointer",
114
+ textAlign: "left",
115
+ font: "inherit",
116
+ },
117
+ cardTitle: { fontSize: 15, fontWeight: 600 },
118
+ cardMeta: { fontSize: 12, color: "#8d93a0" },
119
+ empty: { marginTop: 12, color: "#565b66", fontSize: 14, lineHeight: 1.6 },
120
+ retry: {
121
+ marginTop: 12,
122
+ padding: "8px 18px",
123
+ border: "1px solid #e7e5e0",
124
+ borderRadius: 999,
125
+ background: "#fff",
126
+ cursor: "pointer",
127
+ font: "inherit",
128
+ },
129
+ link: { color: "#d23b2b" },
130
+ }
@@ -1,8 +1,9 @@
1
1
  import type { NextConfig } from "next"
2
2
 
3
3
  const nextConfig: NextConfig = {
4
- // No special config needed for @edpire/sdk.
5
- // The SDK is a standard ESM package — Next.js resolves it from node_modules.
4
+ // Next 16 writes AGENTS.md and CLAUDE.md into the project on first run. This
5
+ // is a template you are meant to copy from, so keep it to your own files.
6
+ agentRules: false,
6
7
  }
7
8
 
8
9
  export default nextConfig
@@ -8,8 +8,8 @@
8
8
  "start": "next start"
9
9
  },
10
10
  "dependencies": {
11
- "@edpire/sdk": "^0.6.0",
12
- "next": "^15.0.0",
11
+ "@edpire/sdk": "^0.6.10",
12
+ "next": "^16.0.0",
13
13
  "react": "^19.0.0",
14
14
  "react-dom": "^19.0.0"
15
15
  },
@@ -1,10 +1,7 @@
1
- # Your Edpire API key — get one at https://edpire.com/org/integrations
2
- # NEVER prefix with VITE_ — that would expose the key in the browser bundle.
1
+ # Your Edpire API key, from edpire.com -> Integrations -> API keys.
2
+ # Server-side only. Never prefix it with VITE_, which would publish it in the
3
+ # browser bundle.
3
4
  EDPIRE_API_KEY=edp_live_your_key_here
4
5
 
5
- # The assessment UUID to embed (find it in the Edpire dashboard).
6
- # VITE_ prefix is safe here because this is a public ID, not a secret.
7
- VITE_ASSESSMENT_ID=your-assessment-uuid-here
8
-
9
- # Port for the Express server
6
+ # Port for the Express server. Vite serves the page on 5173 and proxies here.
10
7
  PORT=3001
@@ -1,69 +1,89 @@
1
- # Edpire SDK — Vite + Express example
2
-
3
- Embeds an Edpire assessment player using:
4
- - **Vite** for the React frontend (browser)
5
- - **Express** for the token endpoint (server) — works in both dev and production
6
-
7
- ```
8
- Browser → POST /api/edpire/token (via Vite proxy in dev, Express directly in prod)
9
- Express → mintEmbedToken(assessmentId, session.userId) ← API key never leaves the server
10
- Browser → <EdpireAssessmentPlayer> renders the full assessment UI
11
- ```
12
-
13
- This is the production-correct version of the "put it in vite.config.ts" pattern. The Express server runs separately and survives `vite build` + `vite preview`.
14
-
15
- ## Quickstart
16
-
17
- ```bash
18
- cp .env.example .env
19
- # Fill in EDPIRE_API_KEY (from edpire.com/org/integrations) and VITE_ASSESSMENT_ID
20
- npm install
21
- npm run dev
22
- ```
23
-
24
- Two processes start:
25
- - **Express** on port 3001 (token endpoint)
26
- - **Vite** on port 5173 (dev server, proxies /api → port 3001)
27
-
28
- Open http://localhost:5173 — the player loads.
29
-
30
- ## How it differs from vite.config.ts middleware
31
-
32
- The test project at `edpire-sdk-test` put the token endpoint inside a Vite dev-server middleware. That's clean for local dev, but it only runs during `vite dev` — a production build has no `/api/edpire/token` and the embed silently fails.
33
-
34
- This example runs a real Express server that handles the token endpoint in both dev and production. The Vite proxy in dev routes `/api` requests to Express; in production, Express serves everything.
35
-
36
- ## Env variables
37
-
38
- | Variable | Where used | VITE_ prefix? |
39
- |---|---|---|
40
- | `EDPIRE_API_KEY` | Express server | **No** — prefixing it would leak it to the browser bundle |
41
- | `VITE_ASSESSMENT_ID` | Vite/browser | **Yes** — this is a public ID, not a secret |
42
-
43
- This is the key insight: `VITE_` prefixed variables are compiled into the browser bundle. The API key must never be prefixed.
44
-
45
- ## Allowed Origins
46
-
47
- If you see a blank player, your app's origin is not in Edpire's allow-list:
48
-
49
- 1. Go to **edpire.com → Integrations → Security tab → Allowed Embed Origins**
50
- 2. Add `http://localhost:5173` for dev, your production domain for prod
51
- 3. Reload — the player should appear
52
-
53
- ## Production
54
-
55
- ```bash
56
- npm run build # builds Vite frontend → dist/ + compiles server.ts
57
- NODE_ENV=production npm run preview # starts Express serving dist/ + /api/edpire/token
58
- ```
59
-
60
- ## Project structure
61
-
62
- ```
63
- server.ts ← Express: /api/edpire/token endpoint + static files in prod
64
- vite.config.ts ← Vite: dev server, proxies /api → Express
65
- src/
66
- App.tsx ← React: <EdpireAssessmentPlayer>
67
- main.tsx
68
- .env.example
69
- ```
1
+ # Edpire in Vite and Express
2
+
3
+ A working assessment player with a React frontend and an Express backend. Two
4
+ files are the whole integration.
5
+
6
+ <table>
7
+ <tr>
8
+ <td width="33%"><img src="docs/screenshots/01-pick.jpg" alt="A list of published assessments, each with a title and an exercise count" /></td>
9
+ <td width="33%"><img src="docs/screenshots/02-play.jpg" alt="An assessment open in the Edpire player" /></td>
10
+ <td width="33%"><img src="docs/screenshots/03-graded.jpg" alt="The same assessment after submitting, with per-question marks and the answer key" /></td>
11
+ </tr>
12
+ <tr>
13
+ <td align="center"><sub><b>1.</b> Pick by title, not by ID</sub></td>
14
+ <td align="center"><sub><b>2.</b> The player</sub></td>
15
+ <td align="center"><sub><b>3.</b> Graded in place</sub></td>
16
+ </tr>
17
+ </table>
18
+
19
+ Everything from screen 2 onward is the SDK. You write screen 1 and the server.
20
+
21
+ | File | What it does |
22
+ |---|---|
23
+ | `server.ts` | Lists what is published, and mints tokens |
24
+ | `src/App.tsx` | Shows the list, then renders the player |
25
+
26
+ ## Run it
27
+
28
+ ```bash
29
+ cp .env.example .env # paste your EDPIRE_API_KEY
30
+ npm install
31
+ npm run dev
32
+ ```
33
+
34
+ That starts both: Express on 3001, Vite on 5173. Open http://localhost:5173.
35
+ The page lists your published assessments; click one to play it.
36
+
37
+ **Add `http://localhost:5173` to your allow-list first**, at edpire.com under
38
+ Integrations, Origins, Allowed Embed Origins. Without it the player says
39
+ `Origin not allowed` and names the origin to add, and `onError` fires with
40
+ `ORIGIN_NOT_ALLOWED`. Add your production domain before you deploy.
41
+
42
+ Note the port: the browser talks to Vite on **5173**, which proxies `/api` to
43
+ Express. Adding 3001 instead is a common ten-minute detour.
44
+
45
+ ## Why the token endpoint exists
46
+
47
+ Your API key can read and write everything in your organisation, so it cannot go
48
+ in the browser. The endpoint keeps it on the server and hands the browser a
49
+ token instead: single use, one learner, one assessment, expiring in two hours.
50
+
51
+ It also answers *which learner is this?* in a way the browser cannot forge. That
52
+ is `resolveLearner`, and replacing it is the one change you must make:
53
+
54
+ ```typescript
55
+ resolveLearner: async (req) => {
56
+ const sid = parseCookie(req.headers.get("cookie"), "sid")
57
+ return sid ? (await sessionStore.get(sid))?.userId ?? null : null
58
+ }
59
+ ```
60
+
61
+ Until you do, every attempt is recorded against `demo-learner-001`.
62
+
63
+ ## Three things worth knowing
64
+
65
+ **`req` is a Web-standard Request**, not an Express one, so `req.session` and
66
+ `req.cookies` do not exist inside `resolveLearner`. Read the raw cookie header
67
+ and do your own lookup.
68
+
69
+ **Mount the token route before any body parser.** The handler reads the request
70
+ stream itself, and a parser that already drained it makes the request hang.
71
+
72
+ **The player fills its container**, so the container needs a real height. Without
73
+ one it collapses to zero and the player renders invisibly. Hence
74
+ `height: 100vh` in `App.tsx`.
75
+
76
+ ## Production
77
+
78
+ ```bash
79
+ npm run build && npm run preview
80
+ ```
81
+
82
+ Express then serves the built page and the two API routes from one origin, so
83
+ the Vite proxy is no longer involved.
84
+
85
+ ## Next steps
86
+
87
+ - [Embedded Player](https://docs.edpire.com/developer/sdk/embedded-player), every prop and callback
88
+ - [Webhooks](https://docs.edpire.com/developer/webhooks), for recording results
89
+ - [Troubleshooting](https://docs.edpire.com/developer/sdk/troubleshooting)