@edpire/sdk 0.6.0 → 0.6.2
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/bin/create.js +61 -0
- package/examples/nextjs/.env.example +6 -0
- package/examples/nextjs/README.md +66 -0
- package/examples/nextjs/app/api/edpire/token/route.ts +38 -0
- package/examples/nextjs/app/globals.css +9 -0
- package/examples/nextjs/app/layout.tsx +13 -0
- package/examples/nextjs/app/page.tsx +51 -0
- package/examples/nextjs/next.config.ts +8 -0
- package/examples/nextjs/package.json +22 -0
- package/examples/nextjs/tsconfig.json +20 -0
- package/examples/vite-express/.env.example +10 -0
- package/examples/vite-express/README.md +69 -0
- package/examples/vite-express/index.html +16 -0
- package/examples/vite-express/package.json +31 -0
- package/examples/vite-express/server.ts +84 -0
- package/examples/vite-express/src/App.tsx +56 -0
- package/examples/vite-express/src/main.tsx +9 -0
- package/examples/vite-express/tsconfig.json +17 -0
- package/examples/vite-express/tsconfig.server.json +12 -0
- package/examples/vite-express/vite.config.ts +18 -0
- package/package.json +7 -2
- package/dist/client.d.mts +0 -540
package/bin/create.js
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
'use strict'
|
|
3
|
+
|
|
4
|
+
const { cpSync, existsSync } = require('node:fs')
|
|
5
|
+
const { resolve } = require('node:path')
|
|
6
|
+
|
|
7
|
+
const TEMPLATES = ['nextjs', 'vite-express']
|
|
8
|
+
const [, , template, dest] = process.argv
|
|
9
|
+
|
|
10
|
+
function usage() {
|
|
11
|
+
console.error('Usage: create-edpire-app <template> <directory>')
|
|
12
|
+
console.error()
|
|
13
|
+
console.error('Templates:')
|
|
14
|
+
TEMPLATES.forEach((t) => console.error(` ${t}`))
|
|
15
|
+
console.error()
|
|
16
|
+
console.error('Example:')
|
|
17
|
+
console.error(' npx --package=@edpire/sdk create-edpire-app nextjs ./my-app')
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
if (!template || !TEMPLATES.includes(template)) {
|
|
21
|
+
console.error(template ? `Unknown template: "${template}"` : 'Template required.')
|
|
22
|
+
console.error()
|
|
23
|
+
usage()
|
|
24
|
+
process.exit(1)
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
if (!dest) {
|
|
28
|
+
console.error('Destination directory required.')
|
|
29
|
+
console.error()
|
|
30
|
+
usage()
|
|
31
|
+
process.exit(1)
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const src = resolve(__dirname, '..', 'examples', template)
|
|
35
|
+
|
|
36
|
+
if (!existsSync(src)) {
|
|
37
|
+
console.error(`Template source not found: ${src}`)
|
|
38
|
+
console.error('Try reinstalling @edpire/sdk.')
|
|
39
|
+
process.exit(1)
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
if (existsSync(dest)) {
|
|
43
|
+
console.error(`Directory already exists: "${dest}"`)
|
|
44
|
+
console.error('Choose a different destination or remove the existing directory.')
|
|
45
|
+
process.exit(1)
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
try {
|
|
49
|
+
cpSync(src, dest, { recursive: true })
|
|
50
|
+
} catch (err) {
|
|
51
|
+
console.error(`Failed to copy template: ${err.message}`)
|
|
52
|
+
process.exit(1)
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
console.log(`✓ Created "${dest}" from the ${template} template.`)
|
|
56
|
+
console.log()
|
|
57
|
+
console.log('Next steps:')
|
|
58
|
+
console.log(` cd ${dest}`)
|
|
59
|
+
console.log(' cp .env.example .env # fill in EDPIRE_API_KEY')
|
|
60
|
+
console.log(' npm install')
|
|
61
|
+
console.log(' npm run dev')
|
|
@@ -0,0 +1,6 @@
|
|
|
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.
|
|
3
|
+
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
|
|
@@ -0,0 +1,66 @@
|
|
|
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 → Settings → Integrations → Allowed 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
|
+
```
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Token-minting endpoint for the Edpire Embedded Player.
|
|
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.
|
|
9
|
+
*/
|
|
10
|
+
import { createEdpireTokenHandler } from "@edpire/sdk/client"
|
|
11
|
+
|
|
12
|
+
export const POST = createEdpireTokenHandler({
|
|
13
|
+
apiKey: process.env.EDPIRE_API_KEY!,
|
|
14
|
+
|
|
15
|
+
/**
|
|
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:
|
|
20
|
+
*
|
|
21
|
+
* @example Next.js + NextAuth
|
|
22
|
+
* const session = await getServerSession(req, res, authOptions)
|
|
23
|
+
* return session?.user?.id ?? null
|
|
24
|
+
*
|
|
25
|
+
* @example Next.js + Better Auth
|
|
26
|
+
* const session = await auth.api.getSession({ headers: req.headers })
|
|
27
|
+
* return session?.user.id ?? null
|
|
28
|
+
*
|
|
29
|
+
* Returning null → 401 Unauthorized (no token minted).
|
|
30
|
+
* Never read the learner ID from the request body — that's an open relay.
|
|
31
|
+
*/
|
|
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
|
+
},
|
|
38
|
+
})
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import type { Metadata } from "next"
|
|
2
|
+
|
|
3
|
+
export const metadata: Metadata = {
|
|
4
|
+
title: "Edpire SDK — Next.js example",
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
export default function RootLayout({ children }: { children: React.ReactNode }) {
|
|
8
|
+
return (
|
|
9
|
+
<html lang="en">
|
|
10
|
+
<body style={{ margin: 0 }}>{children}</body>
|
|
11
|
+
</html>
|
|
12
|
+
)
|
|
13
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
"use client"
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Example page: embed an Edpire assessment player.
|
|
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.
|
|
12
|
+
*
|
|
13
|
+
* You never see the API key in the browser.
|
|
14
|
+
*/
|
|
15
|
+
import { EdpireAssessmentPlayer } from "@edpire/sdk/react"
|
|
16
|
+
|
|
17
|
+
const ASSESSMENT_ID = process.env.NEXT_PUBLIC_ASSESSMENT_ID
|
|
18
|
+
|
|
19
|
+
export default function Page() {
|
|
20
|
+
if (!ASSESSMENT_ID) {
|
|
21
|
+
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>
|
|
26
|
+
)
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
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
|
+
/>
|
|
50
|
+
)
|
|
51
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "edpire-sdk-example-nextjs",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"private": true,
|
|
5
|
+
"scripts": {
|
|
6
|
+
"dev": "next dev",
|
|
7
|
+
"build": "next build",
|
|
8
|
+
"start": "next start"
|
|
9
|
+
},
|
|
10
|
+
"dependencies": {
|
|
11
|
+
"@edpire/sdk": "^0.6.0",
|
|
12
|
+
"next": "^15.0.0",
|
|
13
|
+
"react": "^19.0.0",
|
|
14
|
+
"react-dom": "^19.0.0"
|
|
15
|
+
},
|
|
16
|
+
"devDependencies": {
|
|
17
|
+
"@types/node": "^20.0.0",
|
|
18
|
+
"@types/react": "^19.0.0",
|
|
19
|
+
"@types/react-dom": "^19.0.0",
|
|
20
|
+
"typescript": "^5.0.0"
|
|
21
|
+
}
|
|
22
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"target": "ES2017",
|
|
4
|
+
"lib": ["dom", "dom.iterable", "esnext"],
|
|
5
|
+
"allowJs": true,
|
|
6
|
+
"skipLibCheck": true,
|
|
7
|
+
"strict": true,
|
|
8
|
+
"noEmit": true,
|
|
9
|
+
"esModuleInterop": true,
|
|
10
|
+
"module": "esnext",
|
|
11
|
+
"moduleResolution": "bundler",
|
|
12
|
+
"resolveJsonModule": true,
|
|
13
|
+
"isolatedModules": true,
|
|
14
|
+
"jsx": "preserve",
|
|
15
|
+
"incremental": true,
|
|
16
|
+
"plugins": [{ "name": "next" }]
|
|
17
|
+
},
|
|
18
|
+
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
|
|
19
|
+
"exclude": ["node_modules"]
|
|
20
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
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.
|
|
3
|
+
EDPIRE_API_KEY=edp_live_your_key_here
|
|
4
|
+
|
|
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
|
|
10
|
+
PORT=3001
|
|
@@ -0,0 +1,69 @@
|
|
|
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 → Settings → Integrations → Allowed 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
|
+
```
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="UTF-8" />
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
6
|
+
<title>Edpire SDK — Vite + Express example</title>
|
|
7
|
+
<style>
|
|
8
|
+
* { box-sizing: border-box; }
|
|
9
|
+
body { margin: 0; }
|
|
10
|
+
</style>
|
|
11
|
+
</head>
|
|
12
|
+
<body>
|
|
13
|
+
<div id="root"></div>
|
|
14
|
+
<script type="module" src="/src/main.tsx"></script>
|
|
15
|
+
</body>
|
|
16
|
+
</html>
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "edpire-sdk-example-vite-express",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"private": true,
|
|
5
|
+
"scripts": {
|
|
6
|
+
"dev": "concurrently -k -n SERVER,VITE -c cyan,magenta \"tsx server.ts\" \"vite\"",
|
|
7
|
+
"build": "vite build && tsc -p tsconfig.server.json",
|
|
8
|
+
"preview": "tsx server.ts",
|
|
9
|
+
"type-check": "tsc --noEmit"
|
|
10
|
+
},
|
|
11
|
+
"dependencies": {
|
|
12
|
+
"@edpire/sdk": "^0.6.0",
|
|
13
|
+
"cors": "^2.8.5",
|
|
14
|
+
"express": "^4.18.2",
|
|
15
|
+
"react": "^19.0.0",
|
|
16
|
+
"react-dom": "^19.0.0"
|
|
17
|
+
},
|
|
18
|
+
"devDependencies": {
|
|
19
|
+
"@types/cors": "^2.8.17",
|
|
20
|
+
"@types/express": "^4.17.21",
|
|
21
|
+
"@types/node": "^20.0.0",
|
|
22
|
+
"@types/react": "^19.0.0",
|
|
23
|
+
"@types/react-dom": "^19.0.0",
|
|
24
|
+
"@vitejs/plugin-react": "^4.0.0",
|
|
25
|
+
"concurrently": "^8.2.2",
|
|
26
|
+
"dotenv": "^16.3.1",
|
|
27
|
+
"tsx": "^4.7.0",
|
|
28
|
+
"typescript": "^5.0.0",
|
|
29
|
+
"vite": "^5.0.0"
|
|
30
|
+
}
|
|
31
|
+
}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Express server — serves the token endpoint + static files in production.
|
|
3
|
+
*
|
|
4
|
+
* In development: Vite dev server runs separately (port 5173) and proxies
|
|
5
|
+
* /api requests here (port 3001). Run `npm run dev` to start both.
|
|
6
|
+
*
|
|
7
|
+
* In production: this server serves the Vite build output (dist/) and the
|
|
8
|
+
* /api/edpire/token endpoint. Run `npm run build` then `npm run preview`.
|
|
9
|
+
*/
|
|
10
|
+
import "dotenv/config"
|
|
11
|
+
import express from "express"
|
|
12
|
+
import cors from "cors"
|
|
13
|
+
import path from "node:path"
|
|
14
|
+
import { fileURLToPath } from "node:url"
|
|
15
|
+
import { createEdpireTokenHandler, toNodeHandler } from "@edpire/sdk/client"
|
|
16
|
+
|
|
17
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url))
|
|
18
|
+
const PORT = Number(process.env.PORT ?? 3001)
|
|
19
|
+
const isDev = process.env.NODE_ENV !== "production"
|
|
20
|
+
|
|
21
|
+
const app = express()
|
|
22
|
+
|
|
23
|
+
// In dev, the Vite dev server (port 5173) calls this server for /api requests.
|
|
24
|
+
// Allow cross-origin requests from localhost in development.
|
|
25
|
+
if (isDev) {
|
|
26
|
+
app.use(cors({ origin: /localhost/ }))
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// ── Token endpoint ─────────────────────────────────────────────────────────────
|
|
30
|
+
//
|
|
31
|
+
// The browser POSTs { assessmentId } here and receives { token }.
|
|
32
|
+
// The learner ID is resolved from the server session — never from the body.
|
|
33
|
+
//
|
|
34
|
+
// toNodeHandler() adapts the Web-standard createEdpireTokenHandler() response
|
|
35
|
+
// to Express's (req, res) signature.
|
|
36
|
+
const edpireTokenHandler = toNodeHandler(
|
|
37
|
+
createEdpireTokenHandler({
|
|
38
|
+
apiKey: process.env.EDPIRE_API_KEY!,
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Resolve the learner ID from YOUR session.
|
|
42
|
+
*
|
|
43
|
+
* This example reads a `userId` cookie set by your login flow.
|
|
44
|
+
* Replace with your real session middleware in production:
|
|
45
|
+
*
|
|
46
|
+
* @example express-session
|
|
47
|
+
* resolveLearner: (req) => req.session?.userId ?? null
|
|
48
|
+
*
|
|
49
|
+
* @example JWT
|
|
50
|
+
* resolveLearner: (req) => {
|
|
51
|
+
* const { userId } = jwt.verify(req.cookies.token, JWT_SECRET)
|
|
52
|
+
* return userId ?? null
|
|
53
|
+
* }
|
|
54
|
+
*
|
|
55
|
+
* Returning null → 401 (no token minted).
|
|
56
|
+
*/
|
|
57
|
+
resolveLearner: (req) => {
|
|
58
|
+
const cookie = req.headers.get("cookie") ?? ""
|
|
59
|
+
const match = /(?:^|;\s*)userId=([^;]+)/.exec(cookie)
|
|
60
|
+
return match ? decodeURIComponent(match[1]) : null
|
|
61
|
+
},
|
|
62
|
+
}),
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
app.post("/api/edpire/token", edpireTokenHandler)
|
|
66
|
+
|
|
67
|
+
// ── Static files (production only) ────────────────────────────────────────────
|
|
68
|
+
if (!isDev) {
|
|
69
|
+
const distPath = path.join(__dirname, "dist")
|
|
70
|
+
app.use(express.static(distPath))
|
|
71
|
+
// SPA fallback — send index.html for all non-API routes
|
|
72
|
+
app.get(/^(?!\/api).*$/, (_req, res) => {
|
|
73
|
+
res.sendFile(path.join(distPath, "index.html"))
|
|
74
|
+
})
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
app.listen(PORT, () => {
|
|
78
|
+
if (isDev) {
|
|
79
|
+
console.log(`[server] Token endpoint ready at http://localhost:${PORT}/api/edpire/token`)
|
|
80
|
+
console.log(`[server] Start Vite dev server with: npm run dev`)
|
|
81
|
+
} else {
|
|
82
|
+
console.log(`[server] Production server at http://localhost:${PORT}`)
|
|
83
|
+
}
|
|
84
|
+
})
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Edpire SDK — Vite + Express example
|
|
3
|
+
*
|
|
4
|
+
* How it works:
|
|
5
|
+
* 1. App.tsx renders <EdpireAssessmentPlayer> which POSTs { assessmentId }
|
|
6
|
+
* to /api/edpire/token (proxied to the Express server in dev).
|
|
7
|
+
* 2. The Express server (server.ts) resolves the learner from the session,
|
|
8
|
+
* mints a token via EdpireClient, and returns { token }.
|
|
9
|
+
* 3. The player fetches assessment content from Edpire using the token and
|
|
10
|
+
* renders the full UI.
|
|
11
|
+
*
|
|
12
|
+
* The secret EDPIRE_API_KEY never touches the browser.
|
|
13
|
+
*/
|
|
14
|
+
import { EdpireAssessmentPlayer } from "@edpire/sdk/react"
|
|
15
|
+
|
|
16
|
+
// VITE_ASSESSMENT_ID is safe to expose in the browser (it's a public ID, not a secret).
|
|
17
|
+
const ASSESSMENT_ID = import.meta.env.VITE_ASSESSMENT_ID as string | undefined
|
|
18
|
+
|
|
19
|
+
export default function App() {
|
|
20
|
+
if (!ASSESSMENT_ID) {
|
|
21
|
+
return (
|
|
22
|
+
<div style={{ padding: 32, fontFamily: "sans-serif" }}>
|
|
23
|
+
<strong>Set VITE_ASSESSMENT_ID in .env</strong>
|
|
24
|
+
<p>Copy .env.example → .env and fill in your assessment ID.</p>
|
|
25
|
+
<p>
|
|
26
|
+
<em>Note: EDPIRE_API_KEY must NOT have the VITE_ prefix — that would leak it to the
|
|
27
|
+
browser. Only the assessment ID is prefixed.</em>
|
|
28
|
+
</p>
|
|
29
|
+
</div>
|
|
30
|
+
)
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
return (
|
|
34
|
+
<EdpireAssessmentPlayer
|
|
35
|
+
tokenEndpoint="/api/edpire/token"
|
|
36
|
+
assessmentId={ASSESSMENT_ID}
|
|
37
|
+
onComplete={(result) => {
|
|
38
|
+
console.log("Assessment complete!", {
|
|
39
|
+
score: result.score,
|
|
40
|
+
max: result.max_score,
|
|
41
|
+
passed: result.passed,
|
|
42
|
+
})
|
|
43
|
+
// TODO: redirect to results page, update progress, etc.
|
|
44
|
+
}}
|
|
45
|
+
onError={(err) => {
|
|
46
|
+
console.error("Embed error:", err.code, err.message)
|
|
47
|
+
// Common errors:
|
|
48
|
+
// ORIGIN_NOT_ALLOWED → add localhost to Allowed Origins in the dashboard
|
|
49
|
+
// TOKEN_INVALID → check EDPIRE_API_KEY in .env
|
|
50
|
+
// ASSESSMENT_NOT_FOUND → check VITE_ASSESSMENT_ID in .env
|
|
51
|
+
}}
|
|
52
|
+
// The player fills its container. Set a height here or via CSS.
|
|
53
|
+
style={{ width: "100%", height: "100vh" }}
|
|
54
|
+
/>
|
|
55
|
+
)
|
|
56
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"target": "ES2020",
|
|
4
|
+
"useDefineForClassFields": true,
|
|
5
|
+
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
|
6
|
+
"module": "ESNext",
|
|
7
|
+
"skipLibCheck": true,
|
|
8
|
+
"moduleResolution": "bundler",
|
|
9
|
+
"allowImportingTsExtensions": true,
|
|
10
|
+
"isolatedModules": true,
|
|
11
|
+
"moduleDetection": "force",
|
|
12
|
+
"noEmit": true,
|
|
13
|
+
"jsx": "react-jsx",
|
|
14
|
+
"strict": true
|
|
15
|
+
},
|
|
16
|
+
"include": ["src"]
|
|
17
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { defineConfig } from "vite"
|
|
2
|
+
import react from "@vitejs/plugin-react"
|
|
3
|
+
|
|
4
|
+
export default defineConfig({
|
|
5
|
+
plugins: [react()],
|
|
6
|
+
|
|
7
|
+
server: {
|
|
8
|
+
port: 5173,
|
|
9
|
+
proxy: {
|
|
10
|
+
// In development, proxy /api requests to the Express server.
|
|
11
|
+
// This keeps the secret EDPIRE_API_KEY server-side (never in the browser bundle).
|
|
12
|
+
"/api": {
|
|
13
|
+
target: "http://localhost:3001",
|
|
14
|
+
changeOrigin: true,
|
|
15
|
+
},
|
|
16
|
+
},
|
|
17
|
+
},
|
|
18
|
+
})
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@edpire/sdk",
|
|
3
|
-
"version": "0.6.
|
|
3
|
+
"version": "0.6.2",
|
|
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",
|
|
@@ -38,9 +38,14 @@
|
|
|
38
38
|
"./styles/runtime-utilities.css": "./src/styles/runtime-utilities.css",
|
|
39
39
|
"./styles/shell.css": "./src/styles/shell.css"
|
|
40
40
|
},
|
|
41
|
+
"bin": {
|
|
42
|
+
"create-edpire-app": "bin/create.js"
|
|
43
|
+
},
|
|
41
44
|
"files": [
|
|
42
45
|
"dist",
|
|
43
|
-
"src/styles"
|
|
46
|
+
"src/styles",
|
|
47
|
+
"examples",
|
|
48
|
+
"bin"
|
|
44
49
|
],
|
|
45
50
|
"dependencies": {
|
|
46
51
|
"@dnd-kit/core": "^6.3.1",
|
package/dist/client.d.mts
DELETED
|
@@ -1,540 +0,0 @@
|
|
|
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 };
|