Files
Oleg Isonen 7cab28b633 fix: Allow Form Resources to reach sibling API hosts (#6623)
A Form on `site.example.com` can now send a Resource request to
`api.example.com`. Cloudflare fetches use public routing, while requests
back into the same app are stopped even when they arrive through a
different hostname.

## Checklist

- [x] Enable public `fetch()` routing in the self-hosted Cloudflare
Worker and Pages templates.
- [x] Narrow Cloudflare Form Resource hostname checks to site hostnames,
including published aliases; keep the Node transport policy intact.
- [x] Explain the routing and recursion boundary in code comments.
- [x] Add focused regression tests and verify they fail before the fix
and pass afterward.
- [x] Reject Cloudflare Resource requests that re-enter the app through
an alias, and verify both Worker and Pages paths.
- [x] Run relevant package tests, typechecks, lint, and fixture builds.
- [x] Review the authoritative `docs/` content. No user guidance
currently describes this hostname policy, so no docs change is needed.

The `global_fetch_strictly_public` flag affects all Cloudflare `fetch()`
calls in the generated app. It routes same-zone requests through the
public front door. The known site hostnames are blocked before fetch; a
reserved request marker carrying the originating project ID stops direct
reentry through any additional self-hosted alias while allowing other
projects. The marker is set on every redirect hop.

Verification: SDK suite (914 tests), CLI prebuild suite (143 tests), SDK
and CLI typechecks, published types, lint, package boundaries,
formatting, full fixture pipeline, and both Cloudflare fixture
typechecks/builds passed. The new marker test failed before the fix and
passed afterward. Local Pages returned 508 for a marked request and 200
for an ordinary request. Local Worker returned 508 for a marked request;
an ordinary local request hit a fixture-only `assets/process` module
error. The optional local fixture-size check lacked the unrelated React
Router Netlify built client artifact; the prior PR CI fixture-size check
passed.

Staging test: moved from #6621 to `frm.staging`. The Builder deployed at
https://frm.staging.webstudio.is/ and the Cloudflare template artifact
build and validation passed.

- [x] Publish the test site from staging and verify a Resource call to a
sibling API hostname, plus recursive-call protection.

Live staging verification: Published `ee-trpc-test-ivan.wstd.work` from
Builder commit `35d22fc8a`. A temporary Resource to `api.wstd.work`
produced that host's HTTP 401 response in published page data,
confirming the request reached the sibling host. A marked request
initially returned 200 because the SaaS publish Worker uses a separate
entrypoint. The matching guard is in webstudio-is/webstudio-saas#673;
deploying that branch made the marked request return 508 while an
ordinary request returned 200. The temporary Resource and binding were
removed and the clean build was republished with the SaaS fix.

## Resource response limit follow-up

- [x] Enforce 1 MiB (1,048,576 decoded bytes) for all HTTP Resource
responses, including page Resources, dependencies, Form actions, and
cache hits; reject before cache writes.
- [x] Cancel oversized responses and report `RESPONSE_TOO_LARGE` without
retrying. Show: “Resource response exceeds 1 MiB. Request fewer fields
or use pagination.”
- [x] Verify exact boundaries, streamed overflow, compressed responses,
cache behavior, and Form/loader error propagation with regression tests.
- [x] Update Resource and Form documentation to explain the limit and
recovery.
- [x] Monitor CI after pushing the verified implementation. All checks
passed for `f59b770d45112581514eaa8bc42f57b7f27d290b`, including all ten
browser E2E jobs, visual regression, Linux/Windows fixtures, and staging
builds/deployments.

Verified locally: all 926 SDK tests, SDK typecheck and published types,
lint, package boundaries, Form browser boundary, formatting, full
fixture pipeline, and Cloudflare React Router fixture typecheck/build
passed. All 12 new tests and the updated transport test failed against
the previous implementation; disabling only the retry guard also
reproduced a duplicate attempt. A local Cloudflare runtime test with
gzip accepted exactly 1 MiB of decoded data and rejected one byte over.
The full fixture sync pulled an unrelated saved-project text change; the
original fixture source was restored and regenerated, leaving no fixture
diff.

The response limit is enforced before JSON parsing and before Resource
cache writes, and checked again when loading cache hits. Oversized
responses return `RESPONSE_TOO_LARGE` with `retryable: false`; the graph
honors that flag. System and Email Resources retain their own limits.
The implementation adds 59 net non-test code lines plus 5 documentation
lines, below the 70–100 estimate. Browser E2E passed in CI. Agent
evaluations were not run; they require separate permission and were not
needed for this transport change.


## Simplification follow-up

- [x] Reuse Content Engine's bounded byte reader for Resource bodies and
Form uploads; remove duplicate accumulation and cancellation loops
(estimate: about 40 fewer production lines).
- [x] Preserve response limits, request limits, cancellation, error
messages, and retry behavior; run existing SDK and Content Engine
coverage plus package/build checks.
- [x] Review documentation impact; behavior and approved copy should
remain unchanged.
- [x] Push the simplification and verify CI. All checks passed for
`755ec4210a7858fe298ad4d3a725a0167fb5bb26`, including all ten browser
E2E jobs and staging deployment checks.

The simplification removes 37 net non-test lines, reducing the
response-limit implementation from 59 added lines to 22. Content Engine
now exposes its existing byte-stream module directly and accepts a
caller-provided limit error. Resource bodies and Form uploads reuse that
reader. Native Response construction also replaces redundant
status/header copying. All existing tests remain.

Verification: 926 SDK tests and 655 Content Engine tests passed, along
with both packages' typechecks/builds, published types, lint, package
boundaries, Form browser boundary, formatting, the full fixture
pipeline, and a Cloudflare fixture build. Local Cloudflare runtime
checks passed for gzip at/over the limit and empty 204/205/304
responses. The generated fixture vendor change is limited to the shared
reader's optional error argument. Documentation and approved copy remain
accurate and unchanged.

## Response handling cleanup

- [x] Share bounded response reconstruction between protected fetches
and the cache; remove stale encoding/length headers after decoding
(estimate: 5–10 fewer non-test lines).
- [x] Reproduce the protected-response header bug, then verify the fix,
limits, empty responses, and existing SDK coverage.
- [x] Review documentation and comments for accurate scope; preserve
approved user-facing copy.
- [x] Run relevant checks, regenerate fixtures, push, and monitor CI.

The shared response reader fixes stale compression and length headers in
protected fetches and removes four net production lines (slightly below
the 5–10 estimate). All 927 SDK tests, types, published types, lint,
package boundaries, and Form browser boundary passed. The new regression
test failed on the old code and passed after the fix. Cloudflare runtime
checks passed for decoded bodies and empty 204/205/304 responses. The
existing Resource/Form documentation remains accurate; approved copy is
unchanged.

The reentry marker now carries the originating project ID. A different
project can serve the request; the same project rejects direct reentry
through any hostname. This is cooperative loop detection, not a sandbox
for arbitrary code.

- [x] Reproduce and fix overflow and redirect hangs when a response has
an unread clone; preserve stream cancellation and error handling for all
shared-reader callers.

The clone regressions failed before the fixes and passed afterward.
Cancellation still starts immediately and releases the reader lock, but
no longer waits for an unread tee branch. Both overflow rejection and
redirect following are covered. All 929 SDK tests and 655 Content Engine
tests passed, along with both typechecks and lint. A local Cloudflare
runtime also rejected a cloned oversized response promptly. Combined
with response reconstruction reuse, this cleanup removes five net
production lines. Fixture regeneration changes only the bundled
cancellation logic.

Final verification for `7761f67d6d1e0a853a6ef239f482ebaf9d70c1ae`: all
CI checks passed, including all ten browser E2E shards, visual
regression, package/Builder/CLI tests, size checks, Linux and Windows
fixtures, and staging deployment workflows. Full fixture regeneration
and the Cloudflare fixture build also passed locally. No agent
evaluations were run; they require separate permission and were not
needed for these fixes.

## Scope reentry protection to the project

- [x] Replace the global marker with the originating project ID and
compare it with generated project metadata before handling requests,
including cached requests.
- [x] Cover unrelated sites, same-project aliases, and redirects with
regression tests; demonstrate failure before the fix.
- [x] Review documentation and clarify that this is cooperative loop
detection, not a sandbox for untrusted code.
- [x] Regenerate fixtures, run relevant checks, push, and monitor CI.
Estimate: 20–30 implementation lines across both repositories, plus
generated metadata and tests.

Implementation: reuse the project ID already exported by the generated
home-page module; no new metadata file or package. The generated
Cloudflare Resource adapter passes the same ID to the SDK factory, which
now requires it. Guards compare that ID before rendering or cache
lookup. SDK and generated-adapter regression tests cover another project
returning 200 and an unknown same-project alias returning 508. Hosted
Worker tests verify the guard runs before the cache and permits another
project's cached response. The original guard and a blanket-guard
mutation fail the regressions. A local Cloudflare runtime reproduced the
expected 200/508 behavior. No user-facing copy or documentation changes
are needed; code comments state the cooperative boundary. The fix adds
19 implementation lines across both repos, below the 20–30 estimate,
plus tests/configuration and regenerated fixtures. Existing deployments
require republishing with the updated sender and guard to use
project-specific markers.

Final project-specific reentry verification: 930 SDK tests, 143 CLI
prebuild tests, and 41 hosted-template tests passed. SDK/CLI types,
published SDK types, lint, package boundaries, full fixture
regeneration, both Cloudflare fixture builds, and the Pages function
bundle passed. The actual Pages function returned 200 for another
project's marker and 508 for its own marker through an unknown alias in
a local Cloudflare runtime. All CI checks passed for Builder
`433f9a359d469ca3669e383c4516c3e805ea22a1`, including all ten E2E shards
and staging workflows; companion SaaS
`3e28e7e33813dd968f33caac35dbbba52a3847d3` checks and preview deployment
also passed. Agent evaluations were not run because they require
separate approval; no evaluation changes were needed.
2026-10-30 22:42:27 +01:00
..

Content engine

The content engine compiles file-based Assets into a portable query database. JSON and Markdown documents can reference other Assets without embedding their complete source in that database.

Document references

A reference is an object with exactly one $ref property whose value is a URI reference:

{
  "author": { "$ref": "./authors/ada.json" }
}

References can appear at any depth in a JSON document. In Markdown, references can appear at any depth in YAML frontmatter:

---
title: Building with Markdown
author:
  $ref: ./authors/ada.json
---

Article body.

An object with additional properties is ordinary content, not a reference. The $ref spelling is familiar from JSON Schema, but this syntax does not implement JSON Schema resolution, identifiers, anchors, or schema evaluation.

Reference paths use standard URI-reference resolution relative to the source document. The fragment selects which representation of the target to insert:

Reference Selected target value
./author.json Complete JSON value
./author.json#/profile/name Value at the JSON Pointer /profile/name
./article.md Original Markdown source, including frontmatter
./article.md#body Markdown body without frontmatter
./article.md#frontmatter Markdown frontmatter as an object

JSON Pointer escaping follows RFC 6901. For example, #/a~1b selects the property named a/b. JSON Pointer fragments apply only to JSON documents; #body and #frontmatter apply only to Markdown documents.

Authored URLs identify Assets during compilation. They do not permit arbitrary runtime network requests. Every target must match a document in the compiled Asset catalog.

Resolution behavior

At build time, the content engine:

  1. Parses bounded JSON sources and Markdown frontmatter.
  2. Resolves each static URI reference to a document identity and revision.
  3. Rejects missing targets, duplicate identities, revision conflicts, and dependency cycles.
  4. Stores the validated dependency graph with searchable metadata while source payloads remain in Asset storage.

At runtime, the server compares the query's filter, sort, and output property paths with the graph's reference locations. If a reference can affect the query, the server resolves the relevant source documents before executing the query; otherwise it uses the normal query path without loading graph sources. Non-reference filters first narrow the candidate sources. The server follows only root references that intersect the query, preserves unselected sibling markers, and computes the selected references' complete downstream dependency closure. It loads independent documents concurrently, loads shared storage content once, validates storage identities, revisions, formats, and payload limits, and assembles dependencies before their consumers. A resolved value replaces its exact { "$ref": "..." } marker.

Filtering, sorting, pagination, and projection therefore operate on resolved values when their property paths intersect a reference. Only properties selected by the Assets query are returned. Resolution cannot expose an unselected property merely because it exists in a fetched document. markdown-body-ref content selection is also graph-backed. Compilation retains the Markdown document identity instead of its source bytes, and runtime hydration fetches only the documents selected after filtering, sorting, and pagination. The resolved query result continues to expose the body at content.text. Whole-document and range content modes retain their embedded-content behavior.

Document source caches are keyed by storage reference, revision, and format, so content from one identity cannot be combined silently with different graph metadata. Published runtime assets must also point at the graph node's exact storage reference before the source is fetched. Resolution is bounded by document-count, per-document byte, aggregate-byte, and concurrency limits. A missing document, invalid representation, stale identity, limit violation, or cancellation fails the query instead of returning a partially assembled graph.

Assets without a document graph keep the existing embedded-content behavior and do not require migration.