Reference frontend
The reference frontend is three workspaces under front/, decided in
ADR 0008 and planned in
docs/plans/phase-5-reference-frontend.md:
| Workspace | Package | What it is |
|---|---|---|
front/web |
@ulams/web |
Astro 5 app, server-rendered on every request (output: "server", Node adapter), port 4321 |
front/sdk |
@ulams/sdk |
Framework-free TypeScript API client on fetch; runs on Node and in the browser |
front/ui |
@ulams/ui |
UI catalogue: Astro components and web components described by JSON Schemas, plus the renderer |
Pages are HTML rendered on the server with zero client JavaScript by default. Interactivity comes
from four small vanilla web components (<ulams-quiz>, <ulams-h5p>, <ulams-video>,
<ulams-progress>), bundled only on pages that use them. There is no React and no CSS-in-JS.
What learners see is described in Learners; this page covers the code.
front/web
Section titled “front/web”Directoryfront/web/src
- middleware.ts tenant, CSRF check, session, security headers
Directorypages/
- index.astro landing (tenant document) or platform landing
- courses/[id].astro
Directorylearn/[courseId]/ lesson player and finish page
- …
- events/, account/, login.astro, logout.ts, tenants.astro, platform.astro
- pl/index.astro, zh/index.astro platform landing in Polish and Chinese
- bff/[…path].ts backend-for-frontend
- h5p/[…path].ts same-origin H5P proxy
- lti/launch.ts LTI landing
- healthz.ts
Directorylib/ config, tenant, session, BFF rules, data cache, view models, page documents
- …
Directorydocs/ landing documents per theme (coffee, oncall, nightsky, gravity, poland, ulam, platform); docs/i18n/ the platform landing in Polish and Chinese
- …
- layouts/Base.astro
Configuration
Section titled “Configuration”All settings are runtime environment variables read when the server starts (astro:env/server,
never inlined into the build). Defaults are in src/lib/config.ts and front/web/.env.example:
| Variable | Default | Meaning |
|---|---|---|
ULAMS_TENANT_HOSTS |
{slug}.app.localhost=>http://{slug}.localhost |
Host rules <front host pattern>=><API URL template>, comma separated, first match wins |
ULAMS_ADMIN_URL |
http://{slug}.admin.localhost |
Tenant admin URL (demo badge) |
ULAMS_PLATFORM_HOSTS |
app.localhost,localhost,127.0.0.1 |
Hosts that serve the platform landing instead of a tenant |
ULAMS_DEFAULT_TENANT |
coffee |
Tenant for hosts without a rule (for example a LAN IP); empty shows the tenant picker |
ULAMS_DEMO_TENANTS |
gravity,poland,ulam,coffee,oncall,nightsky |
Demo tenants listed on the platform landing |
ULAMS_LANDING_STATUS |
final |
What the platform landing shows for roadmap items: final (also used for any other value) renders everything as delivered, actual keeps the honest Coming and Partial labels. Run actual (or confirm everything shipped) before a public launch; see Environment |
ULAMS_CACHE_TTL |
45 |
Seconds public API data stays fresh in the server cache (minimum 5) |
ULAMS_WARM_TENANTS |
gravity,poland,ulam,coffee,oncall,nightsky |
Tenants whose public data is fetched at server start |
DEMO_STUDENT_EMAIL, DEMO_STUDENT_PASSWORD |
student1@{slug}.ulams.app, empty |
Fallback login when a tenant has no demo mode; never sent to the browser |
The platform landing’s stories
Section titled “The platform landing’s stories”Besides the demo cards and the comparison table, the platform landing has three animated stories built
from catalogue components: LivingCourseStory (a changed source updates the lesson that cites it),
BuilderStory (a document becomes a cited course in the studio) and WorkflowShowcase (tabs for
Claude Code, Claude with the MCP server, the CLI, REST and the studio). They are played by a small
timeline player (front/ui/src/elements/story.ts): elements carry data-at, data-until, data-type
and data-count in milliseconds, the player toggles classes and text, and CSS does the moving
(transforms and opacity). Stories play only while visible, pause on hover or focus (tabs), have a Pause
control, show their final frame with prefers-reduced-motion or without JavaScript, and give screen
readers a text description or a plain transcript. The commands of the workflow tabs live in
front/web/src/data/workflows.json together with their true status (the Claude Code, MCP and CLI tabs are
labelled Coming until the CLI is released, roadmap item M6). ULAMS_LANDING_STATUS only changes how that status is displayed.
The white-label section (WhiteLabelStory) uses the same player for a four-step story (brand, subdomain site, one
course per source, per-course analytics) and a numbered list. Its steps carry a status and a today caption that
ULAMS_LANDING_STATUS=final removes; in actual mode the analytics step and the Living Course sync line read Coming.
Landing languages
Section titled “Landing languages”The platform landing is also served in Polish (/pl/) and Simplified Chinese (/zh/); / stays English. The routes are
Astro i18n routing (i18n in astro.config.mjs) and exist on a platform host only: a tenant host answers 404 there.
Each language is its own landing document (src/docs/i18n/platform.pl.json, platform.zh.json) with translated data files
(src/data/i18n/), and a unit test keeps the three documents parallel: same components, order, ids, links, icons and
statuses, untouched commands and code, and every product name still in the text. The fixed strings of the catalogue
components (badges, accessible names, buttons) follow the page language.
Each page sets <html lang>, a canonical URL and hreflang alternates (with x-default for English). The header has a
language switcher (EN, PL, 中文), separate from the six navigation links; it keeps the section you are reading. Chinese
uses the system font stack (PingFang SC, Hiragino Sans GB, Microsoft YaHei, Noto Sans SC) instead of a web font.
ULAMS_LANDING_STATUS works the same in all three languages. See ADR 0096.
All variables of every app are in Environment variables. The production image is described in Container images.
Tenant resolution
Section titled “Tenant resolution”src/middleware.ts runs on every request:
- Reads the host from
X-Forwarded-Host, thenHost. - Refuses state-changing requests (
POST,PUT,PATCH,DELETE) whoseOrigin(orSec-Fetch-Site) is not this site, with 403. Astro’s own origin check is off because it compares against the Node listen address behind the proxy. - Sets
locals.platformfor platform hosts; otherwise resolveslocals.tenantwithtenantForHost(), which usesresolveTenant()from@ulams/sdk/tenantand falls back toULAMS_DEFAULT_TENANT. - On
/learn,/bffand/accountit ensures a session (below). - Adds
X-Content-Type-Options: nosniff,Referrer-PolicyandCache-Control: private, no-cacheon HTML.
The server then calls tenant.apiUrl directly (through Caddy in development). Public data
(settings, courses, course detail, tutors, events, products) is cached in memory per tenant,
stale-while-revalidate: fresh for ULAMS_CACHE_TTL, served stale for up to 30 minutes while it
refreshes, and warmed at start. Per-learner data (program, progress) is cached briefly per token and
dropped after progress writes. Measuring and tuning this is covered in
Performance.
Session and BFF
Section titled “Session and BFF”The API token lives only in the httpOnly, SameSite=Lax cookie ulams_session. How it gets there:
/loginposts the form to itself; the server callsapi.auth.login()(one-month token) or, for “Continue as the demo student”, the demo session, and sets the cookie.- On session routes without a cookie, the middleware calls
demoStudentSession(): demo login, else password login with the server-side fallback account. One demo token per tenant is cached and shared, because every demo visitor is the same seeded student. /lti/launchsets it after an LTI launch (below).
/bff/[...path] is the only way browser code reaches the API. It forwards a request to
<tenant API><path> with Authorization: Bearer <cookie token> only if it matches the allow-list in
src/lib/bff.ts:
export const BFF_RULES: BffRule[] = [ { method: "GET", pattern: /^\/api\/profile\/me$/ }, { method: "GET", pattern: /^\/api\/courses\/progress\/\d+$/ }, { method: "PUT", pattern: /^\/api\/courses\/progress\/\d+\/ping$/ }, { method: "PATCH", pattern: /^\/api\/courses\/progress\/\d+$/, writesProgress: true }, { method: "POST", pattern: /^\/api\/courses\/progress\/\d+\/h5p$/, writesProgress: true }, { method: "GET", pattern: /^\/api\/quiz-attempts$/ }, // … quiz attempts and answers];Anything else gets 404, no cookie gets 401, an unreachable API 502. On a 401 from the API (tokens
are wiped by the hourly demo reset) the BFF logs the demo student in again once and retries. Rules
marked writesProgress drop the cached progress. Responses are Cache-Control: no-store.
To let an island call a new endpoint, add a rule: the BFF is deliberately not an open proxy. The guide is Adding a web page.
H5P proxy
Section titled “H5P proxy”/h5p/[...path] proxies to <tenant API>/h5p/<path>, which Caddy sends to the H5P service. The
service only allows framing from the hosts in its frame-ancestors list; served through the front’s
own origin the player page counts as 'self'. The proxy passes bytes through unchanged, forwards
Authorization and a small set of response headers, rewrites Location to the front origin, and on
embed/* HTML pages narrows the embed’s allowedOrigins to the front origin. No H5P code is bundled
into the front (ADR 0003).
LTI landing
Section titled “LTI landing”/lti/launch?code=…&course=… is the target of LTI_TOOL_LANDING_URL when another LMS launches a
course. The server posts the one-time code to POST /api/lti/tool/exchange, stores the returned token
in the session cookie for 8 hours and redirects to /learn/<course> (303). A missing code gives 400,
an expired or invalid one 401 with a plain-text message. See
Authentication.
Health check
Section titled “Health check”GET /healthz answers ok with Cache-Control: no-store. It does not call the API, so it reports
that the Node server is up, not that the tenant API is reachable.
@ulams/sdk
Section titled “@ulams/sdk”front/sdk exports createClient and helpers from @ulams/sdk, plus subpaths @ulams/sdk/types,
@ulams/sdk/h5p and @ulams/sdk/tenant. It has no runtime dependencies.
Client
Section titled “Client”Every method returns the unwrapped data of the API’s {success, message, data} envelope and throws
ApiError (with status, path, body; status 0 means the API was unreachable) otherwise.
import { ApiError, createClient } from "@ulams/sdk";
const api = createClient({ baseUrl: "http://coffee.localhost", // or "/bff" in the browser behind the BFF token: process.env.TOKEN, // omit in the browser when a server adds it timeoutMs: 15_000, // default});
const { token, expires_at } = await api.auth.login("student1@coffee.ulams.app", "secret"); // remember_me onconst learner = api.withToken(token);
const courses = await api.courses.list({ per_page: 12 }); // { data, meta }const program = await learner.courses.program(1); // 403 without accessconst progress = await learner.progress.course(1);
try { await learner.progress.ping(7);} catch (e) { if (e instanceof ApiError && e.status === 401) { // log in again }}The client groups are auth, settings, courses, progress, quiz, events, consultations
and products; read front/sdk/src/client.ts for the methods. raw() returns the whole envelope (for pagination meta), request() any documented
path.
Request paths are typed as ApiPath, the keys of paths in src/generated/openapi.ts, generated
by openapi-typescript from the l5-swagger spec. The spec documents almost no response bodies, so
response types in src/types.ts are hand-written (marked TODO until the spec is complete). See
API reference and OpenAPI and SDK.
Sessions
Section titled “Sessions”import { createClient, demoStudentSession } from "@ulams/sdk";
const session = await demoStudentSession(createClient({ baseUrl: "http://coffee.localhost" }), { fallback: { email: "student1@coffee.ulams.app", password: process.env.DEMO_STUDENT_PASSWORD! },});session.token; // Passport tokensession.expires_at; // ISO datesession.via; // "demo" (POST /api/demo/login) or "password" (fallback)Tenant
Section titled “Tenant”import { resolveTenant } from "@ulams/sdk/tenant";
resolveTenant("coffee.app.localhost:4321");// { slug: "coffee", apiUrl: "http://coffee.localhost", adminUrl: "http://coffee.admin.localhost" }
resolveTenant("academy.example.com", { pattern: "{slug}.example.com=>https://{slug}.api.example.com", adminUrlTemplate: "https://{slug}.admin.example.com",});The host rules are the same implementation the admin and the legacy front use
(front/src/lib/tenant/resolveApiUrl.ts, re-exported until the legacy front is removed).
Topics
Section titled “Topics”import { completionPercent, flattenTopics, resumeTopic, topicKind, topicNeighbours } from "@ulams/sdk";
topicKind("Ulams\\TopicTypes\\Models\\TopicContent\\Video"); // "video"const next = resumeTopic(program, progress); // first unfinished topicconst percent = completionPercent(program, progress); // 0–100const { previous, next: following, index, total } = topicNeighbours(program, topicId);topicKind maps topicable classes to richtext, video, audio, image, pdf, oembed, h5p,
scorm, liascript, lti, cmi5, quiz, project or unknown.
import { h5pEmbedOrigin, h5pEmbedPlayUrl, isCompletingStatement, isH5PEmbedMessage } from "@ulams/sdk/h5p";
const src = h5pEmbedPlayUrl(tenant.apiUrl, 42, { language: "en", hideActions: true });const origin = h5pEmbedOrigin(tenant.apiUrl);
window.addEventListener("message", (event) => { if (event.origin !== origin || !isH5PEmbedMessage(event.data)) return; if (event.data.type === "ulams-h5p:xapi" && isCompletingStatement(event.data.statement)) { // mark the topic complete }});The embed protocol (ulams-h5p:ready, loaded, resize, xapi, saved, error from the frame;
token, style, save to it) is documented in
api/h5p/README.md.
@ulams/ui
Section titled “@ulams/ui”The catalogue is one registry, front/ui/src/registry.ts. Each entry has a description written for
a model, a category, whether it is interactive, whether it takes children, a JSON Schema for its
props (flat, explicit enums, defaults, safe-link format) and a plain-text fallback. The
UI catalogue reference is generated from it.
| Export | What |
|---|---|
@ulams/ui/Render.astro |
<Render doc={doc} data={dataModel} /> renders a page document |
@ulams/ui/render-core |
prepare(), validateDocument(), resolveBindings(), getPointer(): framework-free |
@ulams/ui/registry |
registry, FORMATS, THEMES, ICONS |
@ulams/ui/schema |
validate() (a JSON Schema subset) and isSafeHref() |
@ulams/ui/components/*, @ulams/ui/elements/* |
The Astro components and web components |
@ulams/ui/styles/* |
Base styles, font fallbacks, theme presets (coffee, oncall, nightsky, gravity, poland, ulam, platform) |
---import Render from "@ulams/ui/Render.astro";import doc from "../docs/coffee.json";import { getSiteModel } from "../lib/data.ts";const site = await getSiteModel(Astro.locals.tenant!); // data model for {"$data": "/pointer"} bindings---<Render doc={doc} data={site} />Documents, bindings, validation and fallbacks are described in
Generative UI. yarn workspace @ulams/ui catalogue prints the registry
as JSON.