Skip to content

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) <---+
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 astrolabe package, createEngine({ secret }) from astrolabe/server knows every built-in component and look, and astrolabe/server also 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.metadata has page counts, maximum score and element counts for your catalogue.

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.

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.

<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>
  • src loads the view; integrity (the viewHash) makes the player refuse a view that was changed on the way. A view on another origin (a CDN) needs a CORS header such as Access-Control-Allow-Origin, because the player fetches it.
  • endpoint creates an httpChecker. For custom auth headers, set player.checker = httpChecker({ endpoint, headers }).
  • Set player.policy to 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.js contains no answer logic and no engine.

All attributes, properties, methods and events: Player reference.

  • Never send result.key to 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).