tsn-node-kit
v0.1.31
Published
Identity capture, account provisioning, and xAPI relay for TSN activity nodes.
Readme
tsn-node-kit
Identity capture, account provisioning, and xAPI relay for TSN activity nodes.
Flow is always: Batcher → Catcher → Relay. Never skip steps, never reorder.
Install
npm install tsn-node-kitRequires socket.io-client >= 4 as a peer dependency for XApiRelay.
Batcher — CodeBatcher
Accepts account information from the LMS and provisions it for the Catcher. Two intake modes:
LMS push (open event or singular login — same shape, count varies):
import { CodeBatcher, type LmsPayload } from 'tsn-node-kit';
const batcher = new CodeBatcher({ nodeId: 'kiosk-01', queuePath: '/data/claims.json', moodle: null });
// LMS sends accounts + whitelist together
const whitelist = batcher.receiveLmsPayload({
accounts: [{ code: 'A1B2', eventId: '101', eventName: 'Demo Day' }],
whitelist: ['https://tsn.teamsteamnation.org/activities/station-complete'],
});
relay.setWhitelist(whitelist); // pass whitelist to relay immediately
const account = batcher.next(); // dispense next account from pool
if (account) catcher.captureAccount(account, whitelist); // direct pass — still goes through catcherMoodle pull — returns accounts + whitelist together, auto-populates the pool:
batcher.setMoodleConfig({ endpoint, apiKey, eventCode, eventName, courseId: 104721 });
const { accounts, whitelist } = await batcher.getAccounts(50, { name: 'Dallas Camp June 2026' });
// whitelist contains all activity IDs the LMS says are valid for this batch
// multiple items supported — one per allowed activity
relay.setWhitelist(whitelist);updatePlaceholder(code, data) — attach pre-reg data (name, email, etc.) to a placeholder before the user scans. Does not claim the code or generate a tsn_id.
claim(code, data) — fire-and-forget association (email, etc.). Queues to disk on failure, retried via sync().
markUsed(code) — called by the relay when a code is picked up. Removes from pool and notifies LMS (action=code_used). Wire it: relay.on('code:used', code => batcher.markUsed(code)).
markUsed(code) — called by the relay when a code is picked up by a new user. Removes the code from the pool and notifies the LMS (action=code_used). Wire it: relay.on('code:used', code => batcher.markUsed(code)).
Catcher — CodeCaptureService
Stateless passthrough. Signals user change to the relay — when a new identity arrives, the relay flushes any queued statements for the previous user before starting the new session. The caller holds the whitelist between provisioning and capture, and passes it in at call time.
import { CodeCaptureService, type IdentifiedPayload } from 'tsn-node-kit';
const catcher = new CodeCaptureService();
// Wire to relay — new identity triggers flush of previous user's queue, then switches context
catcher.on('user:identified', (payload: IdentifiedPayload) => relay.receive(payload));
catcher.on('error', (err: Error) => console.error(err.message));
// Code from user (QR scan or manual entry) — JSON string + whitelist from batcher
catcher.capture('{"code":"A1B2","eventId":"101","eventName":"Demo Day"}', whitelist);
// Code from Batcher (single instance login — no user input needed)
catcher.captureAccount({ code: 'A1B2', eventId: '101', eventName: 'Demo Day' }, whitelist);No stored state — whitelist and account are passed in at call time, emitted, gone.
Relay — XApiRelay
Holds actor (from Catcher) and whitelist (from Batcher). Accepts activity posts, validates whitelist, builds and validates xAPI statements, then posts to LRS or forwards upstream.
import { XApiRelay, validateStatement } from 'tsn-node-kit';
import { io } from 'socket.io-client';
const socket = io(process.env.UPSTREAM_URL!);
const relay = new XApiRelay(socket, {
nodeId: process.env.NODE_ID!,
whitelist: [],
homepage: 'https://tsn.teamsteamnation.org',
endOfChain: true, // false = forward via socket; true = post directly to LRS
lrs: {
endpoint: process.env.LRS_ENDPOINT!,
key: process.env.LRS_KEY!,
secret: process.env.LRS_SECRET!,
},
});
// Wire catcher → relay (socket mode)
catcher.on('user:identified', (payload) => relay.receive(payload));
// Wire relay → batcher (code tracking + LMS notification)
relay.on('code:used', (code: string) => batcher.markUsed(code));
// Activity posts verb + activity ID — relay checks whitelist, builds + validates statement
relay.postActivity(
{ id: 'http://adlnet.gov/expapi/verbs/completed', display: { 'en-US': 'completed' } },
{ id: 'https://tsn.teamsteamnation.org/activities/station-complete' },
);endOfChain: true — relay posts directly to LRS using lrs config. Enabled immediately on construction — no upstream toggle needed.endOfChain: false — relay forwards via socket; waits for xapi:lrs:set event from upstream before posting.
If LRS is unreachable or socket is disconnected, statements queue in memory and flush when connection restores.
Socket server mode — call relay.serveSocket(port) to accept socket.io connections from downstream nodes (browsers, kiosks). On connect the server emits xapi:lrs:set to enable the client relay, then receives xapi:statement events and posts to LRS:
// relay-server.js — run alongside your frontend
const relay = new XApiRelay(null, {
nodeId: 'garage-relay', whitelist: [], endOfChain: true,
lrs: { endpoint: process.env.LRS_ENDPOINT!, key: process.env.LRS_KEY!, secret: process.env.LRS_SECRET! },
});
relay.serveSocket(4300);
// ws://localhost:4300 — connect VITE_XAPI_SOCKET_URL hereHTTP server mode — call relay.serve(port) to accept plain HTTP POST instead of socket:
// Relay server (Node process — runs on-site or in cloud)
const relay = new XApiRelay(null, {
nodeId: 'garage-relay', whitelist: [], homepage: 'https://tsn.teamsteamnation.org',
endOfChain: true, lrs: { endpoint: LRS_ENDPOINT, key: LRS_KEY, secret: LRS_SECRET },
});
relay.serve(3100);
// POST /identify { account, whitelist } — from catcher
// POST /activity { verb, object } — from any activity node
// GET /health — { actor, queued }Activity nodes (browser or otherwise) just fetch:
fetch('http://localhost:3100/activity', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ verb: VERB_COMPLETED, object: { id: activityId } }),
});relay.receive() is a no-op if the same user ID arrives again. A different user triggers a flush of the previous user's queued statements before switching context. The enrollment whitelist in the IdentifiedPayload (course IRIs from the LMS) is carried for reference — it does not update the relay's activity filter. Use setWhitelist() or constructor config to change the filter.
relay.clearActor() — flush the current user's queue and clear actor context. Call when a user explicitly logs out.
Whitelist matching is exact — object.id must equal a whitelist entry exactly. Activity IRIs and course enrollment IRIs are different namespaces; configure the relay's whitelist with activity-level IRIs only.
Statements that fail validation are dropped with a console error — they never reach the LRS.
Statement validation — validateStatement
Also exported standalone for use by reporting tools or upstream nodes:
import { validateStatement, XApiValidationError } from 'tsn-node-kit';
try {
validateStatement(stmt);
} catch (err) {
if (err instanceof XApiValidationError) { /* invalid */ }
}Validates: UUID id, Agent actor with account (homePage + name), verb id, object id, Activity objectType, timestamp.
On-site provisioning UI — batcher.serve()
Spins up a mobile-friendly web page staffers can hit from any phone or browser on the same network. No install required.
const batcher = new CodeBatcher({
nodeId: 'kiosk-01',
queuePath: '/data/claims.json',
moodle: {
endpoint: process.env.MOODLE_ENDPOINT!,
apiKey: process.env.MOODLE_API_KEY!,
eventCode: 'CAMP2026',
eventName: 'Dallas Camp June 2026',
},
});
batcher.serve(3000);
// [CodeBatcher] provisioning UI → http://localhost:3000
// [CodeBatcher] on your network → http://192.168.1.42:3000Staffers open the URL, pick a course from the dropdown, enter a count, and tap Provision Codes. Each generated code appears as a large card with a copy button.
An optional onProvisioned callback fires after each successful provision — use it to auto-pipe single-instance accounts through the catcher. Pass courseUrl in the options object to show an Open Course button in the UI after single-instance provisioning:
batcher.serve(
3000,
payload => {
if (payload.accounts.length === 1) {
const account = batcher.next();
if (account) catcher.captureAccount(account, payload.whitelist);
}
},
{ courseUrl: process.env.GARAGE_URL ?? 'http://localhost:5173' },
);When exactly 1 code is provisioned and courseUrl is set, the UI shows a green Open Course button. Tapping it navigates to the activity node in the same tab.
Throws if no Moodle config is set. Auth is not yet implemented.
E2E test
e2e/run.ts exercises the full Batcher → Catcher → Relay chain against a live Moodle + LRS.
# install tsx if needed
npm install -g tsx
# set credentials
export MOODLE_ENDPOINT=https://<host>/local/tsn_extauth/api.php
export MOODLE_API_KEY=<key>
export MOODLE_EVENT_CODE=<event_code>
export MOODLE_EVENT_NAME="<event_name>"
export MOODLE_COURSE_ID=<id>
export LRS_ENDPOINT=https://<host>/xapi
export LRS_KEY=<key>
export LRS_SECRET=<secret>
export BATCH_SIZE=3 # optional, default 3
npx tsx e2e/run.tsThe script provisions BATCH_SIZE codes from Moodle, wires the returned whitelist to the relay, and posts a completed statement for each code to the LRS via endOfChain: true.
Moodle API
Endpoint: POST https://<host>/local/tsn_extauth/api.php — Auth: X-Api-Key: <key>
| Action | Params | Response |
|---|---|---|
| generate | action, role=student, count, event_code, event_name, course_id? | { ok, accounts: [{code, event_id, event_name}], whitelist: [activityId, ...] } |
| update_placeholder | JSON body { code, data } | { ok } |
| code_used | JSON body { code } | { ok } |
| claim_code | JSON body { code, data } | { ok } |
| get_courses | action | { ok, courses: [{id, fullname, shortname, category}] } |
| get_events | action | { ok, events: [{event_code, event_name}] } |
