Open-source infrastructure for SCORM, xAPI, and cmi5 — with built-in LRS, webhooks, and AI analytics. Headless, API-first runtime engine. Embed it in a Node app, run it as a Docker container, or pair it with
@scormflow/react. Vendor-neutral REST API, end-to-end typed.
Existing open-source SCORM projects are either runtime libraries (no persistence) or full LMS forks (everything you don't want). ScormFlow is the missing middle: a small, embeddable engine that handles parsing, runtime APIs, tracking, and analytics — and stops there. You compose the LMS on top.
- Pluggable everywhere — storage (local / S3 / R2 / MinIO), auth (API key / JWT), database (Postgres / SQLite), AI provider (BYO key).
- Standards-first — SCORM 1.2, SCORM 2004, xAPI, and cmi5 share one normalized event model.
- Headless — every UI feature in the ecosystem is built on the public REST API. No private endpoints.
- Zero third-party SCORM libraries — clean-room implementation of the SCORM JS APIs. Any spec-compliant course works.
git clone https://github.com/scormflow/scorm-engine.git
cd scorm-engine
cp .env.example .env
docker compose -f docker/docker-compose.yml upThe engine is now live on http://localhost:3000. Liveness: GET /healthz. Readiness (database + storage reachable, 503 otherwise): GET /readyz. Both are also served inside the API as /api/v1/health and /api/v1/ready.
npm install @scormflow/serverimport { buildApp, loadEnv } from '@scormflow/server';
const env = loadEnv();
const app = await buildApp({ env });
await app.listen({ port: 3000 });Full spec: openapi/openapi.yaml.
POST /api/v1/courses Upload + register a SCORM package
POST /api/v1/courses/validate Validate a package without registering (full report)
GET /api/v1/courses List courses for the tenant
GET /api/v1/courses/:id Course metadata + SCO tree
GET /api/v1/courses/:id/content/* Stream an extracted package file (attempt tokens get their pinned release)
GET /api/v1/courses/:id/launch-url Signed iframe URL to an attempt's SCO (?attemptId=&scoId=)
GET /api/v1/courses/:id/versions Releases, newest first (the course changelog)
POST /api/v1/courses/:id/versions Publish a new release (multipart; label / bump / releaseNotes / activate)
GET /api/v1/courses/:id/versions/diff Metadata + SCO + asset diff (?from=&to=, defaults to live vs previous)
GET /api/v1/courses/:id/versions/:number One release
PATCH /api/v1/courses/:id/versions/:number Edit its release notes
POST /api/v1/courses/:id/rollback Make an existing release live again ({ version })
POST /api/v1/courses/:id/attempts Start or resume a learner attempt
GET /api/v1/attempts/:id Attempt state (objectives, interactions)
GET /api/v1/attempts/:id/runtime Rehydrate CMI for a (re)launched SCO
POST /api/v1/attempts/:id/commit Batched SetValue commit (+ terminate; optional idempotent `seq`)
POST /api/v1/attempts/:id/sync Replay an offline commit queue (applied / duplicate / conflict)
POST /api/v1/auth/attempt-token Mint a short-lived, attempt-scoped JWT
GET /api/v1/runtime.js Drop-in SCORM 1.2 / 2004 browser bridge (hydrated with ?attemptId&token)
GET /api/v1/play/:attemptId Hydrated iframe player page (?replay=1 for session capture)
GET /api/v1/analytics/overview
GET /api/v1/analytics/trends Daily started/completed/passed/failed series (?days=30&courseId=)
GET /api/v1/analytics/courses/:id
GET /api/v1/analytics/courses/:id/scores Score histogram + mean/median (?bins=10)
GET /api/v1/analytics/learners/:id
GET /api/v1/xapi/about LRS version discovery (unauthenticated)
POST /api/v1/xapi/statements Store one statement or a batch
PUT /api/v1/xapi/statements?statementId= Store a statement under a known id
GET /api/v1/xapi/statements Query statements (filters + paging)
GET /api/v1/xapi/activities/state Fetch a state doc, or list state ids
PUT /api/v1/xapi/activities/state Replace a state document
POST /api/v1/xapi/activities/state Merge into a state document
DELETE /api/v1/xapi/activities/state Delete one or all state documents
GET /api/v1/xapi/activities/profile Fetch an activity profile, or list ids
PUT /api/v1/xapi/activities/profile Replace an activity profile document
POST /api/v1/xapi/activities/profile Merge into an activity profile document
DELETE /api/v1/xapi/activities/profile Delete an activity profile document
GET /api/v1/xapi/agents/profile Fetch an agent profile, or list ids
PUT /api/v1/xapi/agents/profile Replace an agent profile document
POST /api/v1/xapi/agents/profile Merge into an agent profile document
DELETE /api/v1/xapi/agents/profile Delete an agent profile document
GET /api/v1/xapi/activities Canonical Activity for an IRI
GET /api/v1/xapi/agents Person view of one agent
POST /api/v1/cmi5/courses Import a cmi5 course (ZIP or bare cmi5.xml)
POST /api/v1/cmi5/courses/validate Validate a course structure without importing
GET /api/v1/cmi5/courses List cmi5 courses
GET /api/v1/cmi5/courses/:id Course, AUs, and block structure
DELETE /api/v1/cmi5/courses/:id Delete a course and its registrations
POST /api/v1/cmi5/registrations Register a learner (actor or learnerId)
GET /api/v1/cmi5/registrations/:id Per-AU progress + course satisfaction
POST /api/v1/cmi5/registrations/:id/launch Launch an AU → launch URL
POST /api/v1/cmi5/registrations/:id/waive Waive an AU's moveOn requirement
GET /api/v1/cmi5/learner-preferences Read a learner's cmi5LearnerPreferences
PUT /api/v1/cmi5/learner-preferences Set language / audio preferences
POST /api/v1/cmi5/fetch One-time fetch URL → LRS auth token (AU-facing)
GET /api/v1/cmi5/content/:token/* Engine-hosted AU files (AU-facing)
POST /api/v1/webhooks Register an endpoint (returns its secret)
GET /api/v1/webhooks List endpoints
GET /api/v1/webhooks/event-types Event types you can subscribe to
PATCH /api/v1/webhooks/:id Change url / events / enabled
DELETE /api/v1/webhooks/:id Delete an endpoint and its delivery log
POST /api/v1/webhooks/:id/rotate-secret Replace the signing secret
POST /api/v1/webhooks/:id/test Send a signed webhook.test event
GET /api/v1/webhooks/:id/deliveries Delivery log (filter by status / event)
POST /api/v1/webhooks/deliveries/:id/retry Re-send a delivery, incl. dead-lettered ones
GET /api/v1/events/stream Live engine events over SSE (`?types=` to filter)
GET /api/v1/plugins Installed plugins
GET /api/v1/plugins/analytics/activity Live per-minute event counts (analytics plugin)
GET /api/v1/certificates List certificates (filter by learner / course / status)
GET /api/v1/certificates/:id One certificate
GET /api/v1/certificates/:id/pdf The certificate PDF (`?download=1` to save)
POST /api/v1/certificates/:id/revoke Revoke (verification then reports it invalid)
GET /api/v1/certificates/verify/:serial Public check of a printed certificate code
GET /api/v1/attempts/:id/certificate The attempt's certificate (attempt token OK)
GET /api/v1/attempts/:id/certificate/pdf Its PDF (attempt token via header or `?token=`)
POST /api/v1/attempts/:id/certificate Issue now (idempotent; 409 if not eligible)
GET /api/v1/ai/providers AI provider configs (keys never returned)
PUT /api/v1/ai/providers/:feature Set provider/model/key for default|insights|summary|recommendations
DELETE /api/v1/ai/providers/:feature Remove a config
POST /api/v1/ai/providers/test Check a provider + key without storing it
POST /api/v1/ai/insights Insights / summary / recommendations (course or tenant)
GET /api/v1/ai/usage This month's tokens, cost and cap status per config
GET /api/v1/ai/calls AI call audit log (`?format=csv` to export)
Auth: X-API-Key: <api-key> for tenant calls (the LRS included); a short-lived,
attempt-scoped JWT for the browser player's runtime calls. Every LRS request
except /xapi/about must also send X-Experience-API-Version: 1.0.3.
POST /courses/validate and the scormflow CLI run the same validator, which
never extracts or stores anything:
npx @scormflow/server validate course.zip # human-readable report
npx @scormflow/server validate *.zip --json --strict # CI: exit 1 on warnings too| Category | Checks |
|---|---|
| structure | readable ZIP, imsmanifest.xml at the root (spots a course zipped inside its folder), unsafe .. paths, OS junk files |
| manifest | well-formed XML, version detection, duplicate identifiers, items pointing at undeclared resources, broken dependencies, default organization, 1.2 mastery score range |
| resources | every SCO launch file present (query strings, %20 and xml:base resolved), <file> entries present, case-only mismatches that break on Linux / S3 |
| runtime | content looks for the right API (API vs API_1484_11), no SCORM 1.2 data model elements in a 2004 package or vice versa (version-agnostic wrappers are recognised) |
| accessibility | <html lang>, <img alt>, <title> in HTML pages |
| assets | files over 50 MiB, packages over 500 MiB, images over 1 MiB, BMP / TIFF, Flash / Shockwave / Silverlight / Java applets |
Each issue has a stable code, a severity (error, warning, info), a
category and an optional path. Only errors make a package invalid. The CLI
also validates cmi5 packages (cmi5.xml + AU launch files) and exits 0 valid,
1 invalid, 2 on usage or I/O errors. In code: validateScormPackage(buffer, { accept: 'any' }).
Courses are released like software. The first upload is release 1.0;
POST /courses/:id/versions publishes the next one (1.1, or 2.0 with
bump=major, or any label you pass) under its own storage prefix, so every
release stays launchable:
curl -X POST $ENGINE/api/v1/courses/$COURSE/versions -H "X-API-Key: $KEY" \
-F file=@course-v2.zip -F releaseNotes="New quiz, fixed module 3 typo"
curl "$ENGINE/api/v1/courses/$COURSE/versions/diff" -H "X-API-Key: $KEY" # live vs previous
curl -X POST $ENGINE/api/v1/courses/$COURSE/rollback -H "X-API-Key: $KEY" \
-H "content-type: application/json" -d '{"version":1}'- Pinned attempts. An attempt remembers the release it launched on; its
content,
/playlaunch file and SCO hrefs keep coming from that release after a publish or rollback. New attempts get the live release. - Diffs compare metadata, the SCO list (by manifest identifier) and every
file (by SHA-256, hashed while the package is extracted), and list
breakingChanges— removed SCOs, SCOs launching a different file, a SCORM edition change. - Rollback only moves the course's live pointer; nothing is re-uploaded.
activate=falsestages a release without making it live. - Events:
course.version.published,course.version.rolled_back. - Courses uploaded before versioning get a baseline release
1.0(no file inventory, so its asset diff isnull) the first time you publish to them.
Send statements with attachment bodies as multipart/mixed: the statement JSON
first, then one part per body with Content-Transfer-Encoding: binary and
X-Experience-API-Hash: <sha2>. The LRS checks each body against its hash and
against the statements' attachments list (bodies may be omitted only for
attachments that carry a fileUrl), then stores it content-addressed under
tenants/<id>/xapi-attachments/. GET /xapi/statements?attachments=true
returns the same multipart/mixed shape. Signature attachments (JWS
RS256/384/512) must sign the statement they are attached to and are verified
when they embed an x5c certificate.
When SCORM content sets cmi.exit (cmi.core.exit in 1.2) to suspend and
then terminates, the commit returns suspended: true and the attempt stays
IN_PROGRESS: the session time is banked as total_time, and the next
POST /courses/:id/attempts for that learner resumes the same attempt with
entry: "resume" and its location and suspend_data intact. Any other exit
finalizes the attempt, and the next start opens a new one. The /play page
seeds entry and the learner id / name, so content can tell a resume from a
first launch.
scoId is a SCO's manifest identifier everywhere it is public — the course's
scos[].identifier / primaryScoId, the scoId of POST /courses/:id/attempts,
/play?scoId=, launch-url?scoId= and the scoId in attempt events. Attempts
resume per SCO, and an unknown SCO is a 404 sco_not_found. (A SCO row id is
also accepted when starting an attempt.)
GET /courses/:id/launch-url?attemptId=… returns { launchUrl, token, expiresAt, scoId } for an SDK player iframe. The token rides in ?token=; the content
route turns it into the sf_attempt cookie for the SCO's relative assets
(SameSite=None; Secure; Partitioned over HTTPS, so cross-site embeds work).
The engine plays the cmi5 LMS role end to end. launch abandons any session the
learner left open on that AU, opens a new one, writes LMS.LaunchData, records
launched, and returns the AU URL with endpoint, fetch, actor,
registration and activityId attached. The AU redeems the fetch URL once for
an auth token and sends it to the built-in LRS as Authorization: Basic ….
Statements sent under that token are checked against the cmi5 statement rules
before they are stored — initialized first, nothing after terminated, no
LMS-only verbs, matching actor / registration / session id, the cmi5 and moveon
categories, completed / passed once per registration, masteryScore checks,
no moveOn verbs in Browse / Review mode — and a violation is a 403. A token
also only reaches its own learner and registration. When every AU in a block
meets its moveOn criterion the engine records satisfied for that block, and
when every AU in the course does, for the course.
Learner preferences (languagePreference, audioPreference) are stored as the
cmi5LearnerPreferences Agent Profile document, where AUs read them through the
LRS. Set them with PUT /cmi5/learner-preferences, or pass
learnerPreferences when creating a registration.
Session tokens and hosted-content links live for JWT_ATTEMPT_TTL_SECONDS.
Register an endpoint with the events it wants (or *):
curl -X POST $ENGINE/api/v1/webhooks -H "X-API-Key: $KEY" -H "content-type: application/json" \
-d '{"url":"https://example.com/hooks/scormflow","events":["course.completed","cmi5.course.satisfied"]}'Events: attempt.started, attempt.suspended, attempt.terminated, course.completed,
course.passed, course.failed, score.updated (SCORM attempts — each fires on
the transition, not on every commit); objective.mastered (a SCORM objective's
success status first becomes passed, matched by objective id);
certificate.issued, certificate.revoked; course.version.published,
course.version.rolled_back; cmi5.session.terminated, cmi5.session.abandoned, cmi5.au.waived,
cmi5.block.satisfied, cmi5.course.satisfied; xapi.statement.stored (once
per newly stored LRS statement — including cmi5 and LMS-issued ones, never for a
duplicate resend; data.statement is the stored statement); and webhook.test.
Each delivery is a JSON POST of { id, type, createdAt, data } with
ScormFlow-Signature: t=<unix>,v1=<hex> — HMAC-SHA256 over "<t>.<raw body>"
with the endpoint secret. Verify it against the raw body before trusting the
event (verifySignature here, or verifyWebhookSignature in @scormflow/sdk).
A 2xx acknowledges it. Anything else — including redirects and timeouts — is
retried with exponential backoff (WEBHOOK_RETRY_BASE_SECONDS × 2ⁿ, capped at 6 h)
until WEBHOOK_MAX_ATTEMPTS, then the delivery is parked as FAILED (the
dead-letter state) and can be re-sent from the delivery log. Delivery is
at-least-once: dedupe on the event id.
Retries are picked up by an in-process poller (WEBHOOK_WORKER_INTERVAL_MS) that
the server entrypoint starts; when embedding, pass startWebhookWorker: true to
buildApp. Deliveries are claimed with a lease, so several engine instances can
share one database. Endpoint URLs on localhost / private / link-local addresses
are refused unless WEBHOOK_ALLOW_PRIVATE_URLS=true; this check does not resolve
DNS, so pair it with egress filtering if tenants are untrusted.
Services never call webhooks (or anything else) directly: they publish to the engine's event bus, which stamps each event with an id and timestamp and hands it to two kinds of receivers. Subscribers do work — the webhook dispatcher is one — and each event reaches one engine instance. Listeners observe — the live stream below is one — and run on every instance.
EVENT_BUS_DRIVER=memory (the default) keeps events in-process.
EVENT_BUS_DRIVER=redis (with REDIS_URL, Redis 6.2+) carries them over a Redis
stream: subscribers read through a consumer group, so a cluster sends each
webhook once; an event whose subscriber fails stays pending, is retried once it
has been idle for a minute, and after five deliveries is copied to
<stream>:dead. The Redis client is built in (RESP over node:net/node:tls),
so there is no extra dependency. Embedders can attach their own subscribers:
const app = await buildApp({ env });
app.events.subscribe('crm-sync', async (event) => {
if (event.type === 'course.completed') await crm.markComplete(event.data.attemptId);
}); // idempotent on event.id — redelivery is possible with RedisGET /api/v1/events/stream (API key) is a Server-Sent Events feed of the
tenant's events, each message carrying the same { id, type, createdAt, data }
a webhook receives — handy for dashboards that don't want a public endpoint.
Filter with ?types=course.completed,certificate.issued. Events are not
replayed after a reconnect (webhooks are the durable channel); idle streams get
a : ping comment every EVENT_STREAM_HEARTBEAT_SECONDS.
curl -N $ENGINE/api/v1/events/stream -H "X-API-Key: $KEY"A plugin bundles event handlers and API routes behind one name, using only the context the engine hands it — the bus, database, storage, env and a tagged logger:
import { analyticsPlugin, buildApp, definePlugin } from '@scormflow/server';
const slack = definePlugin({
name: 'slack',
setup(ctx) {
// Work: once per event across a cluster (bus name `plugin:slack:notify`).
ctx.subscribe('notify', (e) => postToSlack(`${e.data.learnerId} finished`), { types: ['course.completed'] });
// Routes: mounted at /api/v1/plugins/slack, API key required, rate limited.
ctx.routes((r) => r.get('/status', async (req) => ({ tenant: req.tenantId })));
ctx.onClose(() => slackClient.close());
},
});
const app = await buildApp({ env, plugins: [analyticsPlugin(), slack] });ctx.listen observes events on every instance instead. Plugins are set up in
order while the app is built; a setup that throws fails buildApp, and names
must be unique and URL-safe. GET /api/v1/plugins lists what is installed.
The built-in analyticsPlugin({ retentionMinutes }) (default 60) counts each
tenant's events per minute; GET /api/v1/plugins/analytics/activity?minutes=15
returns totals by type plus a minute-by-minute timeline. Counts are in memory
(every instance counts every event on a Redis bus) and restart from zero with
the process. Docker users without code turn built-ins on with PLUGINS=analytics.
The first time an attempt is completed and not failed (SCORM 1.2 passed /
completed; SCORM 2004 completion_status=completed with any success_status
but failed), the engine issues a certificate for it and emits
certificate.issued. Each attempt gets at most one. The learner's name, the
course title and the score (scaled × 100, else raw within min/max) are
snapshotted at issue time, and the PDF — one landscape A4 page in the
standard Helvetica fonts, with no PDF library involved — is rendered on demand
from them. Issuing never fails a commit; if it errors, the error is logged and
POST /attempts/:id/certificate issues it later (the same call backfills
attempts that finished before certificates were turned on).
Every certificate prints a serial like 7KQ2-M9XD-4TPA. GET /certificates/verify/:serial needs no credentials and returns only what is
printed, plus whether it has been revoked; set CERTIFICATE_VERIFY_BASE_URL to
your own verification page to have its link (<base>/<serial>) printed too.
A learner-facing page can fetch /attempts/:id/certificate[/pdf] with the
attempt's player token. Turn the feature off with CERTIFICATES_ENABLED=false.
Set AI_MASTER_KEY and each tenant can plug in its own model — OpenAI,
Anthropic, Gemini, Mistral, Groq, Together, Azure OpenAI, AWS Bedrock, a local
Ollama, any OpenAI-compatible endpoint, or your own HTTP service.
The engine never bundles or proxies a default model.
curl -X PUT $ENGINE/api/v1/ai/providers/default -H "X-API-Key: $KEY" \
-H 'content-type: application/json' \
-d '{"provider":"anthropic","model":"claude-sonnet-5-5","apiKey":"sk-ant-…","monthlyTokenCap":2000000}'
curl -X POST $ENGINE/api/v1/ai/insights -H "X-API-Key: $KEY" \
-H 'content-type: application/json' -d '{"kind":"insights","courseId":"<id>"}'- Grounded in aggregates, not people. The model sees outcome rates, where
unfinished attempts stopped (
lesson_location), the most-failed objectives and most-missed questions (with common wrong answers) over the latest 500 attempts — never learner ids, names or attempt ids. Course insights also get the session-replay journey of the latest 100 attempts: share of attempts reaching each location in course order, dwell, interactions, drop-offs, and the steepest falls in reach ("reach falls 50% after slide-08"). - PII redaction. Emails, card numbers, SSNs, IPs and phone numbers in the
prompt (course text, typed answers, your question) become
[EMAIL]etc. before it leaves. Turn it off per config (redactPii: false) for local models. - Keys encrypted at rest. Envelope encryption: a random AES-256-GCM data
key per stored key, wrapped by a per-tenant key derived from
AI_MASTER_KEY. Keys are write-only; responses show onlykeyHint.POST /ai/providers/testchecks a key without storing it. - Per-feature scoping. Configs for
insights,summaryandrecommendationsoverridedefault, each with its own key and budget. - Cost controls. A monthly token cap per config (429
ai_budget_exceededonce spent),ai.budget.alertatalertThresholdand at the cap, and anenabledkill-switch. GiveinputCostPerMTok/outputCostPerMTokto get USD costs. The cap is checked before each call, so concurrent calls can overshoot it slightly. - Audit. Every call — succeeded, failed or blocked — is logged with the
prompt's SHA-256, provider, model, tokens and cost (
GET /ai/calls, CSV export).
Provider URLs on private networks (a local Ollama) need AI_ALLOW_PRIVATE_URLS=true.
| Provider | baseUrl |
apiKey |
|---|---|---|
azure_openai |
resource endpoint, https://<resource>.openai.azure.com (v1 API) |
resource key; model is the deployment name |
bedrock |
https://bedrock-runtime.<region>.amazonaws.com (Converse API) |
Bedrock API key, or IAM ACCESS_KEY_ID:SECRET[:SESSION_TOKEN] (SigV4-signed) |
groq, together |
optional | required |
custom_http |
the exact URL to POST to | optional bearer token |
custom_http sends { model, system, prompt, maxTokens } and expects
{ text, inputTokens?, outputTokens?, model? } — put it in front of an AI
gateway or an in-house model service.
GET /attempts/:id/replay reconstructs what a learner did — launched →
slide 2 → answered q1 wrong → tab hidden 4 min → suspended → resumed → passed
— as an ordered timeline. Nothing extra needs recording for the basic version:
every commit's CMI state is already in the commit log, and diffing consecutive
commits recovers location, status, score, progress, interaction, objective and
suspend / resume changes.
For what happens between commits, a player can opt in to capture and post
batches to POST /attempts/:id/replay/events with its attempt token:
page.hidden / page.visible, idle.start / idle.end, navigation,
pointer (x / y relative to the content frame), media.play|pause|ended, or
any content-defined name such as quiz.skipped. Events carry a client id, so
a retried batch is stored once.
- Visits. Per-location enter / leave times and active seconds — suspended stretches, hidden tabs, idle time and silences over 30 minutes don't count.
- Engagement score. 0–100 from progress (0.4), focus — share of open time the tab was visible and not idle (0.3), and interactivity — interactions per active minute (0.3). Components without data are left out and the rest reweighted, so a course without capture is scored on what is known.
- Course heatmap.
GET /courses/:id/heatmapfolds the latest attempts (default 50, at most 200) into per-location reach, dwell, interactions, drop-offs (where unfinished attempts stopped) and a 10 × 10 pointer grid.
Without the SDK, add replay=1 (and pointer=1 for click positions) to the
/play/:attemptId URL or the hydrated runtime.js URL, and the served bridge
captures the same events itself — tab visibility, idle stretches, navigation
the moment the location changes, and media playback inside engine-hosted
(same-origin) content — and drains them on Terminate. Hosts that configure
window.__SCORMFLOW__ themselves set replay: { pointer?, idleMs?, flushMs? }.
Timelines are rebuilt on read; nothing is precomputed. Turn capture off with
REPLAY_CAPTURE_ENABLED=false.
src/
├── parser/ imsmanifest.xml + ZIP extraction [done]
├── runtime/ SCORM 1.2 + 2004 CMI model + session [done]
├── tracking/ Attempt + progress persistence [done]
├── xapi/ Built-in LRS (statements, documents, lookups) [done]
├── cmi5/ cmi5 course structure, launch, statement rules [done]
├── events/ Event bus: memory + Redis Streams transports [done]
├── webhooks/ Endpoints, signing, delivery + retry worker [done]
├── certificates/ Completion certificates + PDF writer [done]
├── plugins/ Plugin host + built-in analytics plugin [done]
├── ai/ BYO-AI insights, key vault, PII redaction [done]
├── storage/ Storage adapters: local disk, S3 / R2 / MinIO [done]
├── analytics/ Analytics + reporting [done]
├── http/ Fastify routes + runtime bridge [done]
├── auth/ API keys + attempt JWTs [done]
├── services/ Course / attempt / runtime services [done]
├── db/ Prisma client [done]
└── server.ts Entrypoint
Database: PostgreSQL (production) or SQLite (dev). ORM: Prisma. Schema in prisma/schema.prisma.
.env (see .env.example for the full list):
Checkboxes reflect the actual state of main.
- Repo init, TypeScript strict mode, Fastify app skeleton
- Prisma schema (Tenant, ApiKey, Course, Sco, Learner, Attempt, Interaction, Objective, CommitLog; xAPI, cmi5 + webhook tables)
- Pluggable
StorageAdapterinterface + local disk driver - SCORM ZIP extraction (yauzl)
-
imsmanifest.xmlparser (fast-xml-parser) - SCORM 1.2 / 2004 version detection
- Course ingestion service (upload → extract → parse → persist)
- Course management routes (upload, list, get, delete)
- Package validation endpoint (
POST /courses/validate) - Package validation engine: launch / resource files, identifier integrity, runtime API + CMI version checks, accessibility, asset size and format;
scormflow validateCLI (SCORM + cmi5) - API key auth + tenant scoping (
requireApiKeypreHandler) - OpenAPI 3.1 spec as source of truth
- Dockerfile + docker-compose (engine + Postgres)
- S3 / R2 / MinIO storage driver (dependency-free SigV4, multipart uploads)
- Readiness probe that checks the database and storage
- JWT issuance for runtime / player tokens (
POST /auth/attempt-token) - Attempt lifecycle routes (start/resume, read-back, terminate-on-commit)
- CMI data model + value validation (SCORM 1.2)
- CMI data model + value validation (SCORM 2004)
- Runtime API routes (batched commit + runtime rehydration)
- Resume support (suspend_data, lesson_location persistence; exit=suspend keeps the attempt open across sessions)
- Basic analytics endpoints (course / learner / overview)
- Standalone iframe player route (
GET /play/:attemptId) - Drop-in runtime bridge (
GET /runtime.js) - Multi-tenant rate limiting + isolated storage prefixes
- End-to-end test: upload → launch → track → resume → complete
- xAPI 1.0.3 Statement API (store, query, void, paging)
- xAPI 1.0.3 State API (documents, merge, ETag preconditions)
- xAPI 1.0.3 Activity Profile + Agent Profile APIs
- xAPI 1.0.3 Activity + Agent (Person) lookups
- xAPI attachments (multipart statement upload,
attachments=trueretrieval, JWS signature checks) - Built-in LRS, tenant-scoped and queryable
- cmi5 course structure import (
cmi5.xml, ZIP or structure-only) - cmi5 launch + registration management (LaunchData, one-time fetch URL, session tokens)
- cmi5 session lifecycle (initialized → completed / passed / failed / terminated / abandoned)
- cmi5 statement rules enforced by the LRS; moveOn evaluation, waive, course
satisfied - cmi5 block-level
satisfiedstatements andcmi5LearnerPreferencesagent profile - Webhook system (signed payloads, retries, dead-letter, delivery log)
- Webhook events for xAPI statements and SCORM objectives (
objective.mastered) - Offline sync queue + reconciliation (
POST /attempts/:id/sync, idempotentseqreplay, conflict reporting) - Certificate engine (PDF generation on completion, public verification, revocation)
- Event bus (in-process or Redis Streams; consumer-group work delivery, retries, dead-letter stream)
- Live event stream (
GET /events/stream, Server-Sent Events) - Plugin system (
plugins: [analyticsPlugin(), …], routes under/plugins/:name,PLUGINSenv for built-ins) -
certificatePlugin()— certificates are still built in, toggled byCERTIFICATES_ENABLED
- AI analytics layer with BYO provider + key (OpenAI / Anthropic / Gemini / Mistral / Ollama / OpenAI-compatible)
- AI providers, phase 2: Groq, Together, Azure OpenAI, AWS Bedrock (API key or SigV4), custom HTTP endpoint
- PII redaction pass for AI prompts
- AI key encryption at rest, per-feature configs, monthly token caps + alerts, kill-switch, call audit log
- Session replay (event timeline from commits + opt-in capture, course heatmaps, engagement scoring)
- Git-style course versioning (releases, changelog, metadata/SCO/asset diffs, rollback, pinned attempts)
- Universal Learning Content Gateway (SCORM / xAPI / cmi5 / H5P / HTML / video / PDF)
- Compliance dashboards (audit log, attestation, certification expiry)
- Learning data warehouse export (BigQuery / Snowflake / Redshift / S3 Parquet)
pnpm install # or npm install
pnpm db:push # sync prisma/schema.prisma to DATABASE_URL (+ regenerate client)
pnpm dev # tsx watch
pnpm test # vitest
pnpm openapi:lint # validate OpenAPI spec
pnpm build # tsc → dist/Requires Node ≥ 20.
The engine is not deployed anywhere yet, so there are no migration files:
prisma/schema.prisma is the source of truth and every schema change (such as
the Certificate or CourseVersion tables) reaches your database with:
npm run db:push # = prisma db push: create/alter tables to match the schema, regenerate the clientdb push may drop data when a change is destructive (it asks first), so it is
for development databases only. Before the first real deployment, switch to
migrations once:
npm run db:migrate:init # = prisma migrate dev --name init: writes prisma/migrations/<ts>_init
npm run db:migrate # later schema changes: prisma migrate dev --name <change>
npm run db:migrate:deploy # on the server / in CI: apply pending migrationsCommit prisma/migrations/ from then on; it ships in the npm package and CI
already runs prisma migrate deploy against it.
Issues and PRs welcome. Before opening a PR:
- Run
pnpm typecheck && pnpm test && pnpm openapi:lint. - Match the existing style (Biome / ESLint settings in repo).
- For new endpoints, update
openapi/openapi.yaml— it's the contract the client repo depends on.
See SECURITY.md for vulnerability disclosure.
MIT — see LICENSE.
scorm-engine-client— TypeScript SDK, iframe player, and React components.scorm-engine-demo— reference LMS built on both repos (planned).
