Commit Graph
9 Commits
Author SHA1 Message Date
wyucandClaude Opus 5.5 cd7d6a1a59 fix(providers): pin caller-chosen provider requests and keep upstream detail out of errors (#1704)
* fix(pdf): pin verify-pdf-provider probes to the strict provider transport

The MinerU Cloud and self-hosted connectivity probes validated a
caller-supplied base URL once and then issued a plain fetch that resolved
DNS again, so a rebinding hostname could pass validation and connect to a
loopback or private address. The response also echoed the target's status,
its 401/403 body, and per-errno connection errors.

Both probes now go through providerFetch with reject-redirects and the
operator address policy (the same ALLOW_LOCAL_NETWORKS policy the route
already validates against), so the connect address is pinned to the vetted
DNS answers. A 3xx still maps to REDIRECT_NOT_ALLOWED. Authentication
failures return a fixed message, every other connection failure returns a
single generic message, and the success payload no longer includes the
target status. Details are logged server-side only.

Tests drive the real route, guard and pinned transport against loopback
servers, covering rebinding, auth body suppression, identical
refused/not-found/timeout answers, redirect refusal and the managed path.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(pdf): pin self-hosted MinerU parsing to the strict provider transport

Self-hosted MinerU parsing validated a caller-supplied base URL once and
then posted the document with a plain fetch that re-resolved DNS and
followed redirects, so a public host could hand the upload (and API key)
to a loopback or private address.

The /file_parse request now goes through providerFetch under the operator
address policy with redirects refused. Transport failures, policy blocks
and redirects collapse into one fixed message; an unknown error status is
reported without its body (the missing-dependency classification stays),
a non-JSON body no longer surfaces the parser's input snippet, and the
empty-result error no longer lists response keys. MinerU Cloud control
plane, upload and ZIP errors likewise stop quoting raw response bodies.

Tests drive the real parse route and pinned transport against loopback
servers: rebinding, redirect refusal, body suppression, the multipart
upload shape and the managed path.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(tts): pin the Azure voice list request to the strict provider transport

The voice list request validated the caller-supplied base URL once and then
fetched it with a plain fetch that resolved DNS again. It also mirrored the
target's HTTP status as the route's own status and returned the full error
body, plus any JSON the target answered on success.

The request now goes through providerFetch under the operator address
policy with redirects refused and a 20s deadline. Non-2xx answers return a
fixed 502 (authentication failures get their own fixed message), only a
JSON array is returned as the voice list, and transport failures share one
fixed 500 message. Details are logged server-side only.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(providers): pin model-list probing to the strict provider transport

Model discovery validated the caller-supplied base URL and models URL once
and then issued a plain fetch per candidate, which resolved DNS again. Non-2xx
answers carried up to 512 bytes of the provider's body into the route
response, and a non-JSON success body surfaced the parser's input snippet.

fetchModels now defaults to providerFetch under the operator address policy
with redirects refused (a refused hop keeps the REDIRECT_NOT_ALLOWED
contract), and accepts an injected transport for tests. Error bodies are
never read; the route keeps its 401/404/403 contracts, reports other HTTP
failures by status class only, answers a non-JSON list and every transport
failure with fixed messages, and returns 400 for a malformed request body.

The rejected-redirect detector is shared from the transport module instead
of being copied per caller.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(media): run image/video provider requests on the strict provider transport

The image and video routes validated a caller-supplied provider base URL
once, then every adapter issued plain fetch calls that resolved DNS again.
Adapter errors carried full non-2xx bodies into the route responses, the
auth-only probes returned the 401/403 body, and connectivity failures
echoed the transport error text.

Adapters now take a `fetchImpl` from their config and use it for every
provider request (submit, poll, download and connectivity probe). The
adapters are also imported by the settings UI, so they cannot import the
server transport; every server caller (the four routes, classroom media
generation and the agent runtime tools) injects mediaProviderFetch, which
is providerFetch under the operator address policy with redirects refused.

Connectivity results are fixed text: authentication failures, redirects,
other HTTP statuses and transport failures each have one message, and no
provider body is read. The generation routes log the adapter error and
answer a fixed message, keeping the content-safety classification. A
malformed Kling key is still reported before any request.

The rejected-redirect detector moves to a dependency-free module so the
browser-bundled probe helper can share it with the server transport.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(llm): pin LLM calls to a client-supplied base URL

resolveModel validated a client-supplied base URL once and then handed the
AI SDK a redirect-validating fetch whose only dispatcher was the
timeout-only agent, so connect-time DNS was resolved again without the
guard. verify-model also echoed the provider's error message (which can
carry its response body) and distinguished errno classes.

A client-supplied base URL now gets providerFetch under the operator
address policy with redirects refused. The pinned dispatcher accepts
headers/body timeouts, and this path uses the same 15-minute budget as the
default LLM dispatcher; the transport's body normalization and streaming
are unchanged. Operator-configured endpoints keep the existing
redirect-validating transport.

verify-model now classifies failures by the provider's HTTP status only
(401/403, 404, 429, other status class) and answers every transport or
parse failure with one fixed message.

Tests stream a real SSE chat completion through the pinned path and check
that deltas arrive before the server finishes, plus rebinding, redirect
refusal and the managed path.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(pdf): accept only official AliDocMind endpoints from clients

AliDocMind requests go through the vendor SDK, which builds its own HTTPS
agent and resolves the endpoint itself, so a client-supplied endpoint that
passed the URL guard could not be pinned to the validated address. The
credential check also returned the SDK's error code and message, which
told a refused port from a TLS failure or a timeout.

When the provider is not server-managed, verify-pdf-provider, parse-pdf and
extract-document (document and media paths) now accept a client endpoint
only when it is an official docmind-api.<region>.aliyuncs.com host over
https with no port, path or credentials, and answer INVALID_URL before any
SDK call otherwise. The accepted endpoint is passed on as the normalized
host. Server-managed endpoints are unchanged. Credential verification
failures now return fixed messages and log the SDK detail.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(agent-runtime): download provider result URLs under the strict public policy

The agent-runtime image and video tools downloaded the URL a provider
returned with a plain fetch, validating each redirect hop with the operator
policy but connecting without pinning, and accepting http. A data: URL
from an adapter that inlines its result (the OpenRouter video adapter) was
rejected instead of decoded.

The classroom download helper's policy is extracted into
fetchProviderResultUrl: data: URLs are decoded locally, anything else must
be https and pass the strict public policy (allowLocalNetworks false,
regardless of ALLOW_LOCAL_NETWORKS), and the request goes through
providerFetch, which re-validates redirect hops under the same policy and
pins connect-time DNS. The agent-runtime tools and the classroom helper
share it; the existing bounded reads and content-type checks are unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* docs(changelog): note pinned provider transports and body-free provider errors

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(providers): keep vendor endpoint rules out of provider-neutral modules

The provider-neutrality guard keeps vendor knowledge out of the capability
routes and model resolution. The AliDocMind endpoint rule now sits behind
provider-neutral helpers (checkClientDocumentExtractorBaseUrl and
checkClientMediaExtractorBaseUrl), which apply the official-endpoint rule
to extractors whose SDK cannot be pinned and the URL guard to the rest.
The pinned LLM transport policy moves out of resolve-model into its own
module. Behavior is unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(providers): hold IP-literal request hosts to the transport address policy

The pinned dispatcher judges hostnames in its connect-time lookup, but Node
never runs that lookup for an IP-literal host, so a request to a loopback or
private IP reached it without the local-network opt-in. The provider transport
now checks an IP-literal origin against the same policy before connecting,
so it enforces the policy on its own rather than relying on every caller to
validate the URL first.

Tests that reached loopback IP literals without the opt-in now set it, as a
self-hosted deployment would; cloud metadata stays refused under every policy.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(providers): let operator-configured local providers connect without the opt-in

Image/video providers and self-hosted MinerU moved to the pinned transport
under the operator address policy for every base URL, so a server-managed
endpoint on a local network (for example a local Lemonade image server or a
MinerU container) stopped working unless ALLOW_LOCAL_NETWORKS was set.

A server-managed base URL is operator configuration: it now runs with local
networks allowed, still pinned and still refusing redirects, while cloud
metadata and reserved ranges stay refused. Caller-supplied base URLs keep the
operator policy. Media routes pick the transport by the provider's managed
flag; server-internal media generation uses the managed transport; document
extraction carries a `managed` flag to the MinerU parsers and the PDF
verification probe.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(llm): pin every caller-chosen endpoint and keep transport detail out of errors

An unmanaged provider picked by the caller without a base URL fell back to its
catalog default (for example a localhost Ollama or Lemonade endpoint) on the
operator transport, which neither validated nor pinned the origin. The pinned
client transport is now chosen whenever the caller picked the model or sent a
base URL, and the effective endpoint (client URL or catalog default) is
validated under the operator policy first. A model the operator selected
through MODEL_ROUTES or DEFAULT_MODEL keeps the operator transport.

Routes relay LLM error messages, and the AI SDK builds them from the fetch
failure cause and from the provider's error body. On the caller-chosen
transport a failed request now surfaces a fixed reason ("connection failed",
"request timed out" or "redirects are not allowed") with the system error
logged server-side, and an HTTP error response reaches the SDK with an empty
body and the standard reason phrase, keeping its status and headers for retry
and status classification.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(pdf): keep MinerU Cloud envelope text out of errors and refuse query/fragment base URLs

MinerU Cloud errors quoted the endpoint's envelope `msg`, a failed row's
`err_msg` and the batch id, and the parse routes relay error messages. They now
report the context with the HTTP status or, for a rejected request, the numeric
code only; the endpoint text is logged server-side.

Provider paths are appended to a base URL as text, so a client base URL
ending in `?` or `#` (or carrying a query) absorbs the fixed path and leaves
the request target to the caller. Client-supplied provider base URLs are now
refused when they contain a query string or fragment, across the LLM, media,
document extraction, PDF verification, model probe and Azure voice routes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(media): bound provider data: URL results before decoding them

`fetchProviderResultUrl` decoded a provider-returned `data:` URL in full and
left the size check to the caller's body reader, so an oversized payload was
materialized first. Callers now pass their byte limit, and the payload size is
estimated from the encoded length (base64: 3/4 less padding; percent-encoded:
at least one byte per three characters) and refused over the limit before any
buffer is built, then checked exactly after decoding.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* docs(changelog): describe provider transport behavior changes accurately

Note which base URLs refuse redirects (MinerU Cloud API roots still follow
validated, pinned hops), the query/fragment base URL refusal, IP-literal hosts
under the address policy, validation of an unmanaged LLM provider's built-in
default, server-configured local providers working without the opt-in, HTTPS
for agent-runtime video and poster downloads, pinned requests bypassing the
environment proxy, and the fixed LLM and MinerU Cloud error text.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(llm): bound the logged error body and map out-of-range statuses to 502

An error response from a caller-chosen LLM endpoint was read in full only
to log 500 characters, and a status of 600-999 (passed through by the
transport) made the replacement Response constructor throw. Read at most
1 KB (or 1 s) of the body before cancelling it, and report statuses above
599 as 502.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(pdf): hold MinerU Cloud redirect hops to the operator policy for a managed root

A server-managed MinerU Cloud API root runs with local networks allowed,
and the same policy applied to every redirect it answered with, so a hop
to a private address was followed without ALLOW_LOCAL_NETWORKS.

The provider transport now takes a separate address policy for redirect
hops (`redirectAllowLocalNetworks`): each hop is validated and pinned on
a dispatcher for that policy. A managed MinerU Cloud root keeps local
access for itself while its hops use the operator policy.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(providers): match server-configured providers by own keys only

Provider ids come from requests and were looked up on plain config
objects, so an id such as `constructor` or `toString` read an inherited
property and counted as server-configured (and its key, base URL and
models resolved from that property). All per-provider config lookups now
go through one helper that accepts only the section's own keys.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(audio): let server-configured TTS/ASR endpoints reach local networks

Server-configured image/video, MinerU and LLM providers on a local
network work without ALLOW_LOCAL_NETWORKS, but a server-configured
TTS/ASR or voice-registration endpoint given as a loopback or private IP
literal (for example a VoxCPM server at 127.0.0.1:8000) was refused
unless the opt-in was set.

The routes and the server-side narration, classroom TTS and voice-clone
paths now mark server-configured providers as `managed`; their endpoint
requests run with local networks allowed, still pinned, with cloud
metadata and reserved ranges refused, and redirect hops held to the
operator policy. Client-supplied endpoints keep the strict public
policy, and an unmanaged provider's catalog default keeps the operator
policy. Provider-returned result URLs are unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* docs(changelog): server-configured TTS/ASR local endpoints and config id lookups

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 18:01:22 +08:00
Yizuki_Ameandwyuc 54d626bc61 fix(export): surface video render rejection reasons (#1414)
* fix(export): surface video render rejection reasons

* fix(export): keep render diagnostics out of user toasts

---------

Co-authored-by: wyuc <wang-yc24@mails.tsinghua.edu.cn>
2026-09-21 18:49:25 +08:00
XHaoandwyuc e14c3a1d12 fix(api): validate pdfContent input (#1578)
Co-authored-by: wyuc <wang-yc24@mails.tsinghua.edu.cn>
2026-09-18 12:36:29 +08:00
wyuc 68aed2932a fix(proxy-media): connect only to addresses the SSRF guard validated (#1488)
* fix(proxy-media): connect only to addresses the SSRF guard validated

The route validated a URL with validateUrlForSSRF and then handed the
hostname to fetch(), which resolved it again at connect time. An
attacker-controlled name could answer a public address to the guard and an
internal address (loopback, RFC1918, link-local, cloud metadata) to the
socket, and every redirect hop repeated the same validate-then-refetch gap.

Add a shared policy-aware pinned dispatcher
(lib/server/pinned-dispatcher.ts) whose connect.lookup resolves all
addresses, runs the same local-network policy as validateUrlForSSRF over
every answer (cloud metadata is always blocked, even with
ALLOW_LOCAL_NETWORKS=true), and passes only the vetted list to the
connection. proxy-media creates one dispatcher per request and uses it on
every hop, mapping a connect-time refusal to 403 INVALID_URL instead of a
generic 500. The agent-runtime pinned agent now reuses the same builder
with its strict assertSafeIp policy unchanged.

The other routes that validate with validateUrlForSSRF and then hand the
URL to provider SDKs (parse-pdf, transcription, generate/*, verify-*,
probe-models, azure-voices) are not covered; that is a follow-up.

Tests: new tests/server/proxy-media-rebinding.test.ts covers the two-answer
rebind, the ALLOW_LOCAL_NETWORKS opt-in through the pinned path, metadata
refusal under the opt-in, and a redirect-hop rebind.

* fix(proxy-media): destroy the per-request dispatcher and align non-unicast policy across layers

Follow-up hardening for the proxy-media SSRF pin.

1. Handler hang: the route awaited `dispatcher.close()` in `finally`.
   undici's `close()` drains in-flight requests, and the response body is
   intentionally left unread on the 30x (`redirect: 'manual'`), non-2xx,
   oversize and connect-refusal paths, so an upstream that keeps its body open
   never completes and the POST never settles. Tear the pool down with a
   non-awaited `destroy()` instead; the response blob is already in memory by
   the time `finally` runs. Regression tests drive a loopback server that
   answers 404, a 30x, and an oversize 200 while trickling its body forever and
   assert the route settles in well under a second.

2. Headline rebinding test sensitivity: install an attacker-controlled global
   dispatcher (`setGlobalDispatcher` plus a lookup that always answers
   loopback) in `beforeEach` so the unpinned fetch path really reaches the
   loopback server. Removing the route's dispatcher now fails on
   `internal.requests() === 0` rather than on an unrelated DNS resolution
   error. The positive control also asserts the pinned connect lookup was
   invoked, so a runtime that silently ignores `init.dispatcher` goes red.

3. Non-unicast policy parity: `validateUrlForSSRF` only classified IP-literal
   URLs through `isPrivateIP`, so a literal in CGNAT 100.64/10 or an IANA
   reserved/special-use range (240/4, 198.18/15, the TEST-NET blocks,
   192.0.0.0/24, multicast, broadcast) passed the URL layer while the same
   address reached through a hostname was refused at connect time. Both layers
   now share `isNeverAllowedRange`, applied to IP-literal URLs and to every
   resolved answer, with or without `ALLOW_LOCAL_NETWORKS`.
   `connectionAddressBlockReason` drops its blunt `range() !== 'unicast'` test
   so the two layers agree exactly, while private, loopback and link-local
   answers keep following the opt-in.

   Behaviour change: a hostname resolving into CGNAT 100.64/10 or an IANA
   reserved/special-use range is now refused (403 INVALID_URL) where it was
   previously proxied, including under `ALLOW_LOCAL_NETWORKS=true`, since these
   ranges are never legitimate proxy targets.

4. Do not echo an unparseable connect-time answer in the refusal. Return the
   generic block message instead of `Unable to classify network address:
   <value>`; the route relays that message to the client in a 403 body.

Tests: new trickle-body regression tests and CGNAT/reserved cases for both
literals and resolved answers, plus a `connectionAddressBlockReason` parity
suite. One existing fixture is updated from the IANA documentation prefix
2001:db8::/32 (reserved) to a real public prefix 2001:4860::/32.

* fix(ssrf-guard): let the local-network opt-in cover CGNAT and decode local-use NAT64 prefixes
2026-09-13 18:00:56 +02:00
wyuc dfebbcf33f perf(classroom): speed up classroom loading (media hydration, sidebar thumbnails, media range requests) (#1276)
* perf(classroom): defer non-priority media blob hydration off the load path

Entering a classroom awaited the full mediaFiles restore — one object URL
per restored image/video blob — before the loading gate opened. For
video-heavy courses that is hundreds of MB of IndexedDB materialization
blocking first paint.

Split the restore into two phases:

- The awaited phase now builds metadata-complete task entries but only
  creates object URLs for failed rows (none needed) and for media
  referenced by the scene the classroom opens on (persisted cursor, else
  the first scene). buildRestoredMediaTasks gains an optional
  shouldHydrateBlob predicate, defaulting to eager hydration so existing
  callers are unchanged.
- Remaining blob-backed records hydrate in the background, chunked over
  requestIdleCallback (setTimeout fallback). Each deferred task stays
  'done' — so generation resume never re-runs it — but carries no
  objectUrl yet, which the media resolution state machine already renders
  as a pending skeleton until the URL lands.

Background hydration is guarded per record: a task that was replaced
(classroom switch, regeneration, retry) or that belongs to another stage
is skipped and its freshly minted URLs are revoked immediately.

* perf(classroom): lazy-render slide thumbnails in the playback sidebar

The playback scene sidebar mounted a full SlideCanvas for every slide
scene the moment the classroom opened (and every off-screen video element
opened a preload="metadata" fetch), because SlideThumbnail's existing
visible prop was never passed.

Extract the editor nav rail's near-viewport IntersectionObserver hook to
lib/hooks/use-near-viewport.ts and gate the sidebar's slide thumbnails
through it: only scenes within 200px of the viewport render the live
canvas; the rest show SlideThumbnail's existing placeholder until scrolled
near. The placeholder keeps the same box size, so gating never shifts
layout.

The shared hook now starts hidden instead of eager: with the previous
eager-initial state, opening a long deck still mounted every canvas for a
frame before the observer could flip off-screen items off. The observer's
guaranteed initial delivery flips near-viewport items within a frame; the
no-IntersectionObserver fallback (e.g. jsdom) defers to a microtask so
the effect never synchronously re-renders.

* perf(media): lazy image decoding and HTTP Range support for classroom media

Renderer: BaseImageElement's <img> now carries loading="lazy" and
decoding="async", so thumbnail-heavy surfaces (playback sidebar, editor
nav rail, course cards) no longer fetch and decode every slide image up
front. In-viewport images are unaffected — the browser fetches them
immediately. slideToPng forces eager loading inside its permanently
off-screen snapshot tree, where lazy images would otherwise never fetch
and exports would capture blank slides.

Server: GET /api/classroom-media/[classroomId]/[...path] now answers
single byte-range requests with 206 Partial Content (Content-Range,
Accept-Ranges, correct Content-Length), enabling progressive playback and
seeking for hosted video/audio instead of downloading whole files. Suffix
ranges are supported; unsatisfiable ranges get 416 with the full size;
unsupported units or multi-range sets fall back to the plain 200 full-body
response, which is always a legal answer. Existing caching headers are
kept on every response shape.

* chore(renderer): bump version to 0.1.4 for image lazy-loading change

* fix(classroom): guard deferred hydration against restarted tasks and bound idle waits

A deferred record is only ever 'done' without an objectUrl; a task that
regeneration or retry restarted passes through pending/generating, so skip
those instead of attaching stale persisted bytes. Also give the idle
scheduling a timeout so a busy main thread cannot starve hydration.

* fix(classroom): stop superseded deferred hydration before minting URLs

A restore epoch captured at apply time now gates each idle chunk: loading
another classroom or reloading the same one invalidates any older hydration
loop, so it neither keeps scheduling work for an abandoned classroom nor
attaches bytes read by an older load to a newer load's tasks.

* fix(classroom): keep 416s uncached and classify element-keyed media as priority

A 416 with public immutable caching can poison the media URL for later
valid requests, so range errors now send Cache-Control: no-store. Priority
classification also collects the opening scene's media element ids, since
task lookup binds records keyed stage:<elementId> even when the slide slot
carries a different opaque ref.

* fix(classroom): tie deferred hydration liveness to the classroom load token

The restore epoch only advanced when the next load reached apply, so an
abandoned classroom kept hydrating during the next load's storage/network
phase. Compose the epoch with the load's isCurrent (load token plus effect
cleanup) so navigation stops the loop at the next idle boundary.

* fix(classroom): classify legacy-recovered media before deferring it

Legacy singleton video recovery assigns a placeholderRef only at the end of
the task build, so a record keyed by an allocated id without placeholderRef
was deferred even when it backs the opening scene's gen_vid_* element. Run a
metadata-only build first to learn each record's effective ref and classify
against it, keeping the first visible page's legacy video eager.

* fix(action): wait for deferred video bytes before starting play_video

executePlayVideo treated status done as immediately playable, but a deferred
restore is done without an objectUrl: the renderer shows a skeleton, no
<video> exists, and the later hydration never retriggers play, leaving the
action stuck until the safety timeout. Readiness now follows the renderer's
contract (only done-with-bytes is playable) at the initial check, the
subscription exit, and the post-subscription recheck; the failed skip applies
whether or not a wait happened.

* fix(classroom): include the stage whiteboard in priority media refs

The stage-level whiteboard stays open across standalone classroom switches,
so its media can be visible before any scene is. Classifying it as deferred
left visible whiteboard media pending behind idle hydration chunks.
2026-08-30 03:48:04 +08:00
Yizuki_Ameandwyuc 7c2aaafc91 fix(ssrf): harden provider redirects and ISATAP detection (#928)
* fix(ssrf): detect private IPv4 in ISATAP addresses

* fix(ssrf): reject redirects in provider probes

---------

Co-authored-by: wyuc <wang-yc24@mails.tsinghua.edu.cn>
2026-07-16 14:46:59 +08:00
wyucandClaude Opus 4.8 7c19ab8f1e feat(dsl): JSON Schema artifacts + pure validators (#787 Part B-1) (#817)
* feat(dsl): add ACTION_TYPES set + isActionType guard

A frozen set of every valid ActionType plus a pure membership guard,
mirroring the PPT_ELEMENT_TYPES / isPPTElementType idiom in guards.ts.
Used by the new validators; useful standalone for narrowing untrusted
action.type values.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(dsl): build-time JSON Schema codegen for stage/scene/action

TS types stay the single source of truth; ts-json-schema-generator (a
devDependency, build-only) emits dist/schema/{stage,scene,action}.schema.json,
shipped under the new ./schema/* export. Serves non-TS / bring-your-own-validator
consumers without adding a runtime dependency.

JSON Schema is not generic, so schema-roots.ts provides a concrete
Scene<Action, SceneContent> entry point (kept internal, not re-exported).
schema.test.ts compiles the generated schema with ajv (devDep) to prove the
artifact is real and self-contained on a clean checkout.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(dsl): add pure structural validators (stage/scene/action)

validateStage / validateScene / validateAction: hand-written, zero-dependency,
fail-loud structural checks layered on the existing guards. They verify
discriminants, required fields, and known nested discriminants — a gate for
untrusted input (LLM/agent output) and persistence boundaries. Exhaustive
per-field validation is delegated to the shipped JSON Schema. Error-collecting
ValidationResult reports every issue with a JSON-pointer-ish path.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* docs(dsl): document the runtime schema + validator layer

Add a Runtime layer section covering the two complementary consumption modes
(schema-as-data via @openmaic/dsl/schema/* and the zero-dep validate* gate),
the validate.ts module row, the build:schema step, and tick the JSON Schema
roadmap box.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(dsl): harden validators per cross-review (exhaustiveness, discriminant agreement)

Addresses cross-review findings on the validator layer:

- Add compile-time exhaustiveness assertions for ACTION_TYPES and SCENE_TYPES.
  `satisfies` only proved each entry is valid, not that the tuple covers the
  whole union — a newly-added Action/Scene variant could silently be rejected
  by the validators. The assertion now fails the build on drift.
- validateScene cross-checks that scene.type agrees with content.type; a
  slide-typed scene carrying quiz content (or vice versa) is now rejected.
- Move SCENE_TYPES + add an isSceneType guard to stage.ts, next to SceneType,
  matching the PPT_ELEMENT_TYPES / isPPTElementType idiom in guards.ts; validate.ts
  reuses them instead of re-encoding the union locally.
- Make the validators' scope honest in the doc comment + README: they check the
  structural envelope, known discriminants, and scene/content agreement; they do
  NOT exhaustively check per-variant fields, and app-side interactive/pbl content
  is validated only at the envelope level (exhaustive checks → the JSON Schema).
- Note the contract-owned content scope in schema-roots.ts.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* perf(dsl): reuse one schema generator + harden direct-invocation guard

Addresses cross-review findings on the codegen path:

- gen-schema.mjs built a fresh ts-json-schema-generator (full TS program parse)
  per root type; build one generator over the tsconfig program and reuse it for
  every createSchema call — parses once, identical per-type output.
- Harden the `invoked directly` check to compare realpaths, so a symlinked
  invocation (bin shim / pnpm link) still runs main() instead of silently
  emitting no schema.
- schema.test.ts generates all roots once at module scope, then only compiles
  per case — no repeated heavy codegen.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(dsl): bind scene schema discriminants + pin node-20-compatible generator

Addresses round-2 cross-review (codex) findings:

- scene.schema.json now ties scene `type` to the matching `content.type`.
  SerializedScene became a discriminated union (SlideScene | QuizScene via
  `Omit<Scene<…>, 'type'> & { type }`), so the generator emits a const-narrowed
  discriminant per branch. Previously the schema accepted e.g.
  { type: 'quiz', content: { type: 'slide', … } } even though validateScene
  rejects it — the schema is now consistent with the validator. Locked with a test.
- Pin ts-json-schema-generator to ~2.4.0. ^2.3.0 resolved to 2.9.0, which
  declares `node >=22`, conflicting with the repo's `engines.node >=20.9.0`;
  2.4.x supports node >=18, keeping the build runnable on every supported Node.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* style(dsl): prettier formatting + drop unused ts-expect-error for green CI

- Apply repo Prettier (`pnpm check`) formatting to the new/edited files.
- Remove the `@ts-expect-error` on the gen-schema.mjs import: the root
  `tsc --noEmit` (which compiles test files; allowJs infers the .mjs exports)
  flagged it as an unused directive (TS2578). Plain comment retained.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor(dsl): align validators + schema to the public Scene type (review)

Addresses cosarah's review: the validator, the JSON Schema, and the public TS
type had drifted into three different notions of a valid scene. Realign them so
the TS types stay the single source of truth, and position the two runtime
layers honestly.

- schema-roots.ts: revert SerializedScene to the plain default Scene<Action,
  SceneContent>. The schema no longer encodes a stricter type/content binding
  that the public Scene type doesn't express (the constraint had effectively
  moved into a hidden internal type).
- validate.ts: drop the scene.type<->content.type agreement check (the public
  Scene does not bind them, so neither does the validator); restrict content to
  the contract-owned kinds (slide/quiz), matching SceneContent and the schema,
  so validator and schema no longer disagree on interactive/pbl content.
- Reposition docs (validate.ts header + README): the JSON Schema is the
  authoritative, exhaustive per-field validator (it checks variant fields like
  an action's elementId) for trust boundaries; validate* are a cheap,
  zero-dep structural pre-check and a strict subset of the schema — not a
  separate or stricter gate.
- Tests updated to lock the realigned behavior.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor(dsl): bind scene type<->content in the public Scene contract (review)

cosarah's review showed the scene type<->content agreement is a real, load-bearing
invariant: consumers branch on `scene.type` and then read `scene.content` as the
matching shape (e.g. `stage-api-canvas` casts `content as SlideContent` after a
`scene.type === 'slide'` check; `complete-summary` counts by `scene.type` and reads
`content as QuizContent`). A validator/schema that blesses mismatched pairs is unsafe.
So tighten the *exported* contract rather than loosening the runtime checks to match
a hole — and make the pure validators the authoritative, relied-upon boundary.

DSL contract:
- `Scene` becomes a distributive discriminated type binding `type` to `content['type']`
  (`SceneCore<TAction> & { type: TContent['type']; content: TContent }` distributed over
  TContent). TS itself now rejects mismatched scenes; generic widening still works.
  Extract `SceneCore` for the kind-independent fields.
- `validateScene` re-establishes the type<->content agreement check; scene `type` is
  the contract's slide/quiz; `scene.schema.json` (from the spelled-out discriminated
  `SerializedScene`) binds `type` to content per branch. Type, validator, and schema
  now describe the same thing.
- `validateAction` checks each variant's required fields (e.g. spotlight.elementId,
  discussion.topic) — closes the false-positive gap. A test pins the hand map to the
  generated schema (the TS-derived source of truth) so it cannot drift.
- Reposition docs: `validate*` is the zero-dep in-process boundary producers rely on;
  the JSON Schema is the cross-language mirror + exhaustive value checker.

App consumers (the sites the discriminated Scene exposes):
- Add `ScenePatch` (non-distributive partial) + `makeScene(core, content)` helper to
  lib/types/stage.ts — one isolated cast, type derived from content. Patch-typed
  signatures (`updateScene`, `applyScenePatchInSync`, regenerate plan) and scene
  rebuilds (`stage-api-scene`, store `updateScene`, `migrateScene`, stage-storage
  deserialize) route through these. Closes two latent desync foot-guns.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(dsl): validate action field types + migrate scene.update; bidirectional drift-guard (review)

Round-2 review of the contract-tightening change (codex + Claude):

- validateAction now checks each variant-required field's TYPE, not just presence
  (`{ type:'spotlight', elementId: 123 }` is rejected — elementId must be a string).
  ACTION_REQUIRED_FIELDS carries a runtime kind per field.
- The schema lockstep test is now bidirectional and type-aware: it asserts a
  fully-typed action is accepted (catches the map OVER-listing an optional field),
  every required field flags when missing (under-listing), and flags when present
  but mis-typed — all keyed off the generated schema's field names + types.
- Migrate the public `stageApi.scene.update()` to ScenePatch + makeScene (the
  create() path was done last commit; update() still did a raw spread that could
  desync type<->content — masked from tsc only because StageStore.setState is typed
  `any`). Now both public scene-construction paths rebind type to content.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(api): keep scene.create's params.type authoritative over content.type (review)

cosarah's review: `CreateSceneParams` exposes an independent `type` plus
`content?: Partial<SceneContent>`, and after switching create() to makeScene()
(which derives the scene kind from content.type), a call like
`create({ type: 'slide', content: { type: 'interactive', ... } })` would silently
become an interactive scene — params.type ignored — and mix slide defaults into it.

Reject a `content.type` that disagrees with `params.type`, and pin the merged
content's `type` to `params.type` so a partial content override can't flip the
scene's discriminant. params.type stays authoritative. Adds a test both ways.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-01 01:04:34 +08:00
86c8e0c543 feat: inline external assets at export for offline classrooms (#596) (#613)
* feat(export): add external-asset reference scanner for HTML inlining

* feat(export): add cached asset fetcher and data-uri encoder

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* feat(export): recursively inline url() refs inside stylesheets

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* feat(export): orchestrate inlining of links, scripts, images, and inline css

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* refactor(export): drop dead escapeAttr, harden importmap skip in scanner

* feat(export): inline importmap modules incl three/addons graph closure

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* refactor(export): extract shared module to break inline-assets import cycle

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* feat(export): inline interactive-scene assets into .maic.zip at export

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* feat(export): inline interactive assets in Resource Pack export too

* docs: document offline export asset inlining

* style: prettier formatting

* fix(test): resolve tsc type errors in export-classroom-inline test

* fix(export): keep importmap prefix fallback on partial failure, chunk base64, preserve media attr, log pptx inline failures

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* perf(export): parallelize asset fetching, share fetcher across scenes, promise-cache

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(export): retry transient asset fetches, bound concurrency, warn user on partial inline

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* perf(export): inline only woff2 fonts, drop woff/ttf siblings to halve size

Within @font-face blocks that have a woff2 url(), rewrite sibling woff/ttf/otf/eot
refs to url(about:invalid) instead of inlining them. Falls back to full inline for
ttf-only blocks. Drops katex output from ~1.79MB to ~0.71MB (~3x fewer font fetches).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* i18n(export): translate partial-inline warning and surface failed asset hosts

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* feat(proxy-media): follow redirects with per-hop SSRF revalidation (#398)

Replace blanket redirect rejection with a bounded 5-hop follow loop that
re-validates every Location target through validateUrlForSSRF before
fetching, preventing redirect-to-internal SSRF while allowing CDN
redirects (e.g. cdn.tailwindcss.com 302→/3.4.17).

* feat(export): route asset inlining through proxy-media to bypass CORS

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(export): keep importmap prefix fallback, forward proxy 4xx, cap proxy size, re-export FetchAsset

* style: prettier formatting for proxy-media + proxied-fetch

* fix(export): report CSS-nested asset failures in inline report

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-authored-by: 杨慎 <117187635+cosarah@users.noreply.github.com>
2026-05-31 18:36:39 +08:00
Helck 679130a6b9 feat(ai): add Xiaomi MiMo Token Plan support (#578) 2026-05-17 15:24:30 +08:00