HTML + backend (answers stay on the server)
The server compiles the source once, keeps the answer key, and serves only the public view. The browser sends answers to two JSON endpoints and gets verdicts back. Nothing in the browser can reveal the answers.
author source (with answers) | engine.compile(source, { salt }) v view (public HTML) ------> CDN / your server ------> <astl-player src=... endpoint=...> key (private JSON) -----> your database | ^ | POST {endpoint}/check, /submit +---- engine.grade(key, answers) <---+1. Compile
Section titled “1. Compile”import { createEngine } from '@astrolabe/engine';import { builtInDefinitions } from '@astrolabe/components/definitions';
const engine = createEngine({ components: builtInDefinitions, // or only some, e.g. [gapDefinition] secret: process.env.ASTL_SECRET!, // >= 16 bytes; keep it stable, it determines every view});
const result = await engine.compile(source, { salt: exerciseId });if (!result.ok) { for (const issue of result.issues) console.error(engine.describe(issue, 'en'));} else { await db.saveVersion({ viewHash: result.viewHash, view: result.view, key: result.key, metadata: result.metadata }); await cdn.put(`/v/${result.viewHash}.html`, result.view); // immutable: cache forever}- With the
astrolabepackage,createEngine({ secret })fromastrolabe/serverknows every built-in component and look, andastrolabe/serveralso has the grading handler below (quick start). salt(e.g. the exercise id) makes opaque ids unique per exercise.- Compiling the same source with the same secret and salt gives the same view and
viewHash. result.metadatahas page counts, maximum score and element counts for your catalogue.
2. Grade
Section titled “2. Grade”Implement the grading protocol: POST {endpoint}/check and POST {endpoint}/submit. With
@astrolabe/server it is one handler on the standard Fetch API:
import { createGradingHandler, createMemoryAttemptStore } from '@astrolabe/server';
const attempts = createMemoryAttemptStore(); // replace with your database (AttemptStore interface)const grading = createGradingHandler({ engine, attempts });
// Start an attempt for a student: practice or test policy, from @astrolabe/core.const attempt = await attempts.create({ key: version.key, policy: TEST_POLICY });
// Hono / Workers / Deno / Bun: anything that speaks Request -> Response. Authorise first (does this student own// attempt :id?), then pass the attempt you authorised.app.post('/api/attempts/:id/:action', (c) => grading(c.req.raw, { attemptId: c.req.param('id') }));The host says which attempt a request is for: the handler takes the attempt id only from its second argument (ids
match [A-Za-z0-9_-]{1,128}, others get 404) and reads nothing but the action, the final path segment (check or
submit; anything else is 404), from the URL. So extra path segments cannot point it at another attempt than the
one your middleware authorised.
The handler refuses check under a test policy (403 check_not_allowed), counts practice checks against maxChecks
(403 checks_exhausted), closes checks at the policy's deadline, accepts one submit (409 attempt_closed after it; a
submit up to 30 seconds after the deadline still counts, because the player hands in when its countdown ends) and
returns only what the policy reveals. It accepts Content-Type: application/json bodies of at most 256 KiB only, and
every response is no-store. Store records that do not validate (policy, key, counters, the requested id) and store
or grading failures end in a generic 500 internal_error; pass onError(error, request) to log them.
Plug your own storage in by implementing AttemptStore (an AttemptRecord is { id, key, policy, checksUsed, submitted, createdAt }):
interface AttemptStore { get(id: string): Promise<AttemptRecord | null>; /** Count one check and return the record after counting. */ recordCheck(id: string): Promise<AttemptRecord | null>; /** Close the attempt; false when it was already submitted. */ recordSubmit(id: string, submission: { answers: Answers; response: SubmitResponse }): Promise<boolean>;}Make both writes atomic per attempt (for example UPDATE ... RETURNING, or an update conditional on submitted = false), so parallel requests can neither exceed maxChecks nor submit twice.
Not on JavaScript? Store the key as JSON and port grade (about 200 lines); the conformance fixtures in
packages/components/src/*/fixtures tell you when your port is right.
Run it on your platform
Section titled “Run it on your platform”The handler is (request: Request, context: { attemptId }) => Promise<Response>, so it needs no adapter where the
platform speaks the Fetch API. In each snippet, engine and grading are the ones from above; authorise the request
before you call grading.
Node (node:http, as in examples/node-server): turn the request into a Request and write the Response back.
import { createServer } from 'node:http';import { Readable } from 'node:stream';
function toRequest(req) { const headers = new Headers(); for (const [name, value] of Object.entries(req.headers)) { if (value !== undefined) headers.set(name, Array.isArray(value) ? value.join(', ') : value); } // The body is streamed as it arrives; the handler stops reading at its size limit. return new Request(new URL(req.url, 'http://localhost'), { method: req.method, headers, body: req.method === 'GET' || req.method === 'HEAD' ? undefined : Readable.toWeb(req), duplex: 'half', });}
createServer(async (req, res) => { const api = /^\/api\/attempts\/([^/]+)\/([^/]+)$/.exec(new URL(req.url, 'http://localhost').pathname); if (api === null) return res.writeHead(404).end('Not found'); // A real host authorises here: is the signed-in student the owner of attempt api[1]? const response = await grading(toRequest(req), { attemptId: api[1] }); res.writeHead(response.status, Object.fromEntries(response.headers)); res.end(Buffer.from(await response.arrayBuffer()));}).listen(8787);Hono (Node, Bun, Deno, Workers):
import { Hono } from 'hono';
const app = new Hono();app.post('/api/attempts/:id/:action', (c) => grading(c.req.raw, { attemptId: c.req.param('id') }));export default app;Cloudflare Workers: the secret comes from the environment, so create the engine and the handler on the first
request. The memory store lives only as long as one isolate; keep attempts in D1, KV or a Durable Object behind your
own AttemptStore.
import { createEngine } from '@astrolabe/engine';import { builtInDefinitions } from '@astrolabe/components/definitions';import { createGradingHandler, createMemoryAttemptStore, type GradingHandler } from '@astrolabe/server';
let grading: GradingHandler | undefined;
export default { async fetch(request: Request, env: { ASTL_SECRET: string }): Promise<Response> { const api = /^\/api\/attempts\/([^/]+)\/([^/]+)$/.exec(new URL(request.url).pathname); if (api === null) return new Response('Not found', { status: 404 }); grading ??= createGradingHandler({ engine: createEngine({ components: builtInDefinitions, secret: env.ASTL_SECRET }), attempts: createMemoryAttemptStore(), // per isolate only: replace with a durable AttemptStore }); // Authorise first: does the signed-in student own attempt api[1]? return grading(request, { attemptId: api[1] ?? '' }); },};Deno and Bun serve Request to Response functions directly: Deno.serve(handler) or
Bun.serve({ fetch: handler }), where handler routes to grading like the Workers snippet.
3. Play
Section titled “3. Play”<script type="module" src="https://cdn.jsdelivr.net/npm/astrolabe@0.1/dist/cdn/astrolabe-player.js"></script><astl-player locale="en" src="https://cdn.example.com/v/3b1f....html" integrity="3b1f..." endpoint="/api/attempts/123"></astl-player>srcloads the view;integrity(theviewHash) makes the player refuse a view that was changed on the way. A view on another origin (a CDN) needs a CORS header such asAccess-Control-Allow-Origin, because the player fetches it.endpointcreates anhttpChecker. For custom auth headers, setplayer.checker = httpChecker({ endpoint, headers }).- Set
player.policyto the attempt's policy so the player shows the right buttons ("Check" in practice, "Hand in" in tests). The server stays authoritative either way. astrolabe-player.jscontains no answer logic and no engine.
All attributes, properties, methods and events: Player reference.
Security checklist
Section titled “Security checklist”- Never send
result.keyto the browser. - Authorise
{endpoint}per student and attempt (cookie or token); the protocol leaves that to you. - Serve views from a separate origin or with a strict CSP; views contain no scripts, but defence in depth helps.
- Rate-limit
check, and enforce deadlines on the server (the player's timer is only a display).
The full list, with secrets, salts, integrity and CSP: Security checklist.
Runnable example: examples/node-server in the repository (pnpm dev:server starts it).