ChatMessage gains toolCalls and an opaque providerData; providers may
yield providerData once per turn. The assistant loop appends one
assistant message per model turn. Anthropic replays its own raw blocks
from providerData (no in-memory map) or maps text + tool_use blocks;
OpenAI maps toolCalls to tool_calls.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
env/headers are sealed into secretCipher with the BYOK AES-GCM/HKDF box.
The admin GET returns masked values; a mask posted back for the same
server and key keeps the stored value, any other mask is a 400. The chat
and the test button use the stored values. upgradeAiData seals plaintext
v1 rows.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
0002_ai_mentor_prefs.sql adds ai_mentor_prefs(user_id, course_id, off).
upgradeAiData copies v1 config_json.mentorOff toggles into it once
(marked ai_data_v11 in schema_migrations); the settings repo awaits the
upgrade before first use and aiFeature starts it at construction.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
New packages/feature-ai/migrations/0001_ai_message_citations.sql adds
ai_messages.citations_json and folds v1 'sources' rows into the assistant
row before them. The feature writes and reads the column directly.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Landscape images (imported video frames, slide pages) would lose most of the
picture in a 3:4 crop, so the tile keeps its monogram; portrait and
near-square images (w/h <= 1.05) fade in over it, cropped from the centre.
The header nav follows the mockup's segmented-control style: muted text, no
underline, the current area raised in accent.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
- Critical 1 (CSRF): core wrap refuses every POST/PUT/PATCH/DELETE whose Origin
(Referer as fallback) is not publicUrl's origin or STC_ALLOWED_ORIGINS, and
any body that is not application/json (multipart only on declared upload
routes). Better Auth routes go through the same guard; only declared
identity-provider callbacks (SAML ACS/SLO, OAuth callbacks) are exempt.
Better Auth trustedOrigins use the same list. Session cookie stays Lax
(Strict breaks OAuth/OIDC/SAML returns); documented.
- Important 4 / learner minor 4: the first-owner claim and the role row are
one transaction guarded by "no owner yet"; only a unique violation means
learner; any other error fails the sign-up with 500 and removes the
half-created user. Serialised per DB handle (Bun's SQLite connection).
- Important 2 / learner minor 12: code zips stream entry by entry (large
files stored in 256 KB chunks with a data descriptor and incremental
CRC-32), so memory stays at one chunk; the cap is now 1 GiB (ZIP32).
- Important 3 / learner minor 10: the e2e runs the production wiring
(productFeatures order, AI drafts through the real sink) against a
restricted course and asserts 404/401 for learner and anonymous on
course, lesson, media, cover, code, zip, notes, progress, attempts, AI
chat, library, search and AI status; a real Range request (206, 416);
forged writes (foreign Origin, text/plain, no Origin) are 403 and the MCP
test command never runs.
- Minors 7-9: compose passes every documented variable; the health check
uses STC_PORT; productFeatures checks the studio-before-AI order.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Dockerfile on oven/bun:1.3 with ffmpeg, git, unzip and python3-pymupdf, a
production install, non-root user, /data volume and a health check;
docker-compose.yml (project stc) with an optional postgres:17 profile and
read-only course and import mounts; docs/operations.md with configuration,
mounts, security headers and the measured idle memory (101.4 MiB for app plus
Postgres after 5 minutes idle).
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Contracts and core:
- Feature gains auth and stop; FeatureFactory; core composeServer builds the
context, runs the factories, then the job queue from the union of their jobs
(R8/R14). startServer uses it; main.ts composes learner, import, studio, AI.
- AppConfig/loadConfig own the layout and settings: courseDirs include
<dataDir>/courses and <dataDir>/worktrees, studioReposDir, cacheDir,
importRoots, uploadMaxMb, aiAllowedBaseUrls, transcribeUrl, python.
resolveConfig derives the same for tests; cachePaths shares the importer's
cache layout.
- canView: the global author role views every course (R14); the learner,
studio and AI packages all call it.
- migrate(db, packages): core first, then packages in name order.
- Media: nosniff on every response, a sandbox CSP on svg/html/js; a Range on
an empty file is 416.
- courseUrl/lessonUrl and the lesson route patterns live in @stc/contracts.
Packages:
- import, studio and AI read AppConfig instead of process.env; the importer
refuses ids used by studio/repos.
- learner migrations run through core migrate; library covers fall back to
the importer's cached candidate behind a canView-checked route.
- studio preview renders through the learner LessonView (anchors honoured,
draft URLs); AI drafts go to the studio draft branch for studio courses (as
the requesting user) and to <dataDir>/drafts otherwise, and the generator
page says which.
Web: tsconfig paths for @stc/<feature>/web browser entries; features.ts
imports them by package name; nav.roles hides links by role.
Tests: compose, config, canView, media headers, migrations order, covers,
preview, nav, SPA bundle without server code, and an end-to-end flow on
SQLite and Postgres.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
The proxy, its internal server, Unix socket and NO_PROXY changes are gone; main.ts serves the HTML
import directly again. apps/web/index.html carries the SPA policy as <meta http-equiv=CSP> ahead of every
script (script-src 'self' plus the hash of Bun's dev-server helper, no unsafe-inline/eval; no
frame-ancestors, which meta cannot carry). Dev HMR connects under it. The runner frame keeps its header CSP.
Tests: the served index.html (prod and dev, in a subprocess) has the meta policy and every inline script
is hash-covered; the XSS browser test fails with the sanitizer disabled and passes with it, and the meta
CSP alone blocks unsanitized handlers (with a no-CSP control); no source assigns proxy env vars, and the
feature leaves them untouched at runtime. Contract request 5 asks core for frame-ancestors/X-Frame-Options.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
spaWithCsp proxies GET and HEAD only (405 otherwise, no body buffering) and forwards only accept,
if-none-match, if-modified-since and range. The internal server listens on a Unix socket in a private
0700 temp dir. Bun 1.3.14 ignores proxy: false for this hop, and over a socket with a proxy set it sends
an absolute-form target that breaks asset routing, so localhost, 127.0.0.1 and ::1 are appended to
NO_PROXY/no_proxy (existing entries kept). accept-encoding is no longer forced; fetch decompresses and
the encoding headers are dropped.
Test: in a subprocess with HTTP(S)_PROXY and ALL_PROXY set to a dead proxy, the SPA and an asset load
with the CSP, POST gets 405, and the upstream sees no Cookie, Authorization or X-Forwarded-For.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
C1: lesson HTML goes through a sanitize-html allowlist on the fully rendered output (no on* attributes, no
style, no svg/math/iframe/object/embed/form; URLs limited to http(s), mailto, relative and anchors). Raw tags
outside the allowlist are escaped as text. The SPA document is served with a CSP (script-src 'self', no inline
script) through spaWithCsp in main.ts; contract request §5 asks core to own it.
C2: exercises run in <iframe sandbox="allow-scripts"> (opaque origin, no cookies) loaded from
/api/learner/runner-frame, whose CSP allows only its own script (by hash) and blob: workers, and connect-src
'none'. postMessage only, both sides check event.source; the timeout removes the frame. The worker stubs now
also cover Worker, SharedWorker and caches.
I1: learner visibility calls core canView with per-course authorship only, like /api/media (contract request §6).
I2: Tab and Shift+Tab move focus; Enter keeps the previous line's indentation.
Tests: XSS unit cases, headless-Chrome checks for the exploit lesson and for fetch, import, Cache.add, nested
Worker, img, EventSource, WebSocket, XHR and sendBeacon from learner and test code (plus a negative control),
visibility on both dialects, SPA and runner-frame CSP headers.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Tasks 10-12 of the WP-A plan in one commit (the route modules import each other):
- header extras portalled into the Phase 0 header: course switcher, search, Reading/Work, account
- course frame with outline sidebar (mobile overlay, Escape closes), progress, gating locks
- library: featured course, continue row, New and collection rows, course list, filters, search
- LessonView: text (track switcher, folds, ordered hint tiers), video (resume, done near the end,
timestamp notes, transcript), companion decks rendered with pdf.js (thumbs, arrows, fullscreen,
per-slide notes), code files (tree, tabs, copy, zip, command), exercises (Web Worker runner or
local command with optional pasted evidence), floating action bar
- notes list, account (email/password, providers, SSO by email) and owner admin pages
- pdf.js worker served from /api/learner/pdf.worker.mjs
- browser entry @stc/feature-learner/web (the package index also carries the server half)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
- Learner links go through lessonUrl/courseUrl (src/web/urls.ts).
- Generation, the studio course list and file listing require an authoring
right and core canView, so a global author needs a grant for a restricted
course. Contract requests 8 (global author in canView) and 9
(STC_AI_ALLOWED_BASE_URLS in AppConfig).
Co-Authored-By: Claude Opus 5.5 <[email protected]>
read_file and read_lesson URL-decode, posix-normalise, reject .. and absolute
paths, resolve through CourseRepository.resolvePath (symlinks followed), then
guard the real course-relative path and compare real paths with every
manifest browser.solution. Hidden files are refused too.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
STC_SECRET, database URLs and API keys are no longer inherited by MCP child
processes. Test: a fake server reports its environment.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
- A user's own key goes only to https://api.anthropic.com, https://api.openai.com/v1
or an origin listed in STC_AI_ALLOWED_BASE_URLS / the server config's
allowedBaseUrls; anything else is a 400, and a stored key whose origin was
later removed stops being used.
- User-scope provider calls never follow redirects, and their errors reach the
user only as the HTTP status plus a fixed message; the full text is logged.
- The admin's server-scope provider may still use any base URL.
- Admin UI: allowed origins field; MCP stdio env editable, HTTP headers kept.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
C1: writes lstat every path component and refuse symlinks (dangling included),
check the parent's real path after mkdir, and open with O_NOFOLLOW.
I1: only https://, ssh:// and git@host: remotes; protocol.file/ext/fd=never on
every fetch and push; global/system git config ignored.
I2: STC_UPLOAD_MAX_MB cap (default 100) by Content-Length and while streaming;
extension allowlist (415); stcourse.yaml refused; 409 unless overwrite=true;
sandboxing CSP on draft raw files.
I3: owner-only visibility/source.kind rule checked on the merged result at
publish and sync, and at resolve; publish also validates the merged tree.
I4: bare repos in <dataDir>/studio/repos/<id>.git; imported courses read-only.
I5: list status comes from the bare repo; drafts are created lazily.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Spec 9.2 has the assistant retrieve from the learner's notes and progress;
progress was missing. Reads progressRepo(db).get for the signed-in user.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Retrieval now reads <dataDir>/cache/decks/<courseId>/<deckId>/text.json and
<dataDir>/cache/transcripts/<courseId>/<lessonId>.json (skipped when the lesson
has a transcript file). DraftSink returns { location?, branch?, commit? }, so
the studio's createDraftWriter(ctx) needs no adapter; generateDraft computes
paths itself and reports the draft branch.
Co-Authored-By: Claude Opus 5.5 <[email protected]>