Skip to content
Esc
↑↓navigate↵open⌘Jpreview
On this page

External files and CSP

Uploaded files, remote images, external APIs, browser security policy, and resource execution limits in Charming apps.

Charming gives each app a content security policy (CSP), a browser rule that limits where scripts, images, fonts, media, frames, and network requests can come from. The app manifest can add remote image origins to img-src, embedded-site origins to frame-src, and the origins the app’s browser code calls to connect-src. It cannot replace the policy or add origins to other directives.

Choose a supported path

Need Where it lives Supported app path Browser policy
An app-owned image Charming asset storage Upload it, keep its key, and render it with window.charming.assets.load(key) The returned data: URL is allowed by img-src
Another app-owned file Charming asset storage Use env.assets, upload_asset, or window.charming.assets; keep the key rather than a signed URL Fetches use Charming’s fixed connect-src
App-owned audio or video Charming asset storage Upload it, keep its key, and set <audio> or <video> src to window.charming.assets.getUrl(key) Charming’s fixed media-src allows it at the standalone app URL
App-owned script, module, or worker Charming asset storage Upload a .js or .mjs file, then pass window.charming.assets.getUrl(key) to import(), a <script src>, or new Worker() script-src 'self' allows it at the standalone app URL, where the file is served from the app’s own origin
A remote image A public HTTPS origin List the exact origin, and any other domain it redirects to, in permissions.browser["img-src"], then call window.charming.images.load(url) The listed origin joins img-src; load() returns an allowed data: URL
An embedded video or other site The provider’s public HTTPS embed origin List the exact origin in permissions.browser["frame-src"], then set an <iframe> src to the provider’s embed URL The listed origin joins frame-src at the standalone app URL; chat embeds block it
A public external API The API provider’s public HTTPS origin Call it from an app route after declaring the network capability and exact server fetch origins Server egress policy applies; this does not change browser connect-src
A browser call to a public API or stream The provider’s public HTTPS or WSS origin List the exact origin in permissions.browser["connect-src"], then call it from ui with fetch, EventSource, or WebSocket The listed origin joins connect-src; the server must still allow the request with CORS
A font Google Fonts, system fonts, or Charming asset storage Prefer system fonts. Google Fonts is the fixed remote font source. An uploaded font URL is a standalone-only option Fixed style-src and font-src; no manifest font grant
Camera or microphone input The visitor’s device Call the standard browser API directly on the web; the viewer allows the device for that app, and no import or claim is needed Permissions Policy and iframe delegation apply, not media-src

Use Charming asset storage for files the app owns. Store the asset key, not the URL. env.assets.url(key) returns a signed URL that expires after 24 hours. window.charming.assets.getUrl(key) uses the asset token issued with the current page, which has the same lifetime; reload the app to get a new browser token after it expires. Use assets.load(key) for an uploaded image that must render inside a chat embed.

Server and browser APIs. Use env in server routes and window.charming in browser code.

env.assets.url(key) returns https://charm.ing/app/<app-id>/assets/<key> with signed query parameters. At the standalone app URL, window.charming.assets.getUrl(key) returns the same path on the app’s own origin, https://<app-id>.apps.charmingusercontent.com; in a chat embed it returns the charm.ing form. Treat the complete URL as opaque: do not construct it, remove its query, or move it to another app. Charming has no /files or /models route.

Use a remote host only when that host should remain the source of the file. Charming does not make a third-party URL stable. For remote images, choose a provider URL that is meant for long-term use and declare only its exact origin.

Least-privilege setup

Least-privilege manifest

This manifest grants two image origins to the browser and one API origin to backend fetch. The two lists are separate because they protect different execution environments.

{
  "$schema": "https://charm.ing/schema/app-manifest/2026-07-31.json",
  "id": "book-covers",
  "meta": { "name": "Book covers" },
  "capabilities": {
    "imports": ["charming:storage/[email protected]", "charming:network/[email protected]"]
  },
  "permissions": {
    "browser": {
      "img-src": ["https://covers.openlibrary.org", "https://archive.org"]
    },
    "server": {
      "fetch": ["https://openlibrary.org"]
    }
  }
}

Both image origins are listed because a cover request crosses both domains: Open Library stores its cover files at the Internet Archive, so https://covers.openlibrary.org redirects to https://archive.org, which redirects again to one of its numbered download nodes. Listing https://archive.org covers those nodes, because a redirect may land on a subdomain of a listed origin.

Each entry must be a canonical exact HTTPS origin: scheme, hostname, and optional non-default port only. Do not include a trailing slash, path, query, fragment, credentials, wildcard, uppercase hostname, or explicit default port. Known local names and literal loopback, private, link-local, or IPv6 addresses are rejected. Image requests resolve DNS and refuse non-public destinations. The two lists then differ on redirects. Every image hop is checked again and may land on a listed img-src origin, or on a subdomain of one under the same scheme and port; a hop to any other domain is refused. Backend fetch is stricter: every hop must match a listed permissions.server.fetch origin exactly, and a redirect onto a subdomain of one fails with network_blocked. List each domain the chain passes through.

Do not list an origin in both places unless the UI loads images from it and the backend also calls its API.

Browser connections

This manifest lets the app’s browser code call one API over HTTPS and hold one WebSocket open. Browser code needs no capability import for this.

{
  "$schema": "https://charm.ing/schema/app-manifest/2026-07-31.json",
  "id": "live-prices",
  "meta": { "name": "Live prices" },
  "permissions": {
    "browser": {
      "connect-src": ["https://api.example.com", "wss://stream.example.com"]
    }
  }
}
const quote = await fetch('https://api.example.com/quote?symbol=ACME').then((r) => r.json());
const events = new EventSource('https://api.example.com/events');
const socket = new WebSocket('wss://stream.example.com/prices');

List https:// origins for fetch, XHR, and EventSource, and wss:// origins for WebSocket. Declare each scheme you use: https://stream.example.com does not grant wss://stream.example.com, and wss://stream.example.com does not grant https://stream.example.com. Each entry follows the same exact-origin rules as img-src, with wss: also accepted. Plain http:// and ws:// are rejected, and so is any Charming app origin, including another app’s, with invalid_connect_origins.

The browser enforces the list. A call to an origin that is not listed fails as a network error and raises a securitypolicyviolation event. The remote server still decides whether to answer: fetch and EventSource need CORS headers that allow the app’s origin, while a WebSocket server sees the app’s origin in the Origin header. In a browser that sends no Fetch Metadata, such as Safari before 16.4, the app runs in a null-origin frame and sends Origin: null, so the server must allow * or null there. Browser code is visible to anyone who opens the app, so call only public endpoints this way. For an endpoint that needs an API key, call it from a route with sealed env.fetch.

The list applies to the app’s web page. A chat host adds its own policy, which blocks these origins inside an inline embed, so provide an Open in web path for features that depend on them. A change to the list takes effect the next time the app loads.

Uploaded image

const file = fileInput.files[0];
const uploaded = await window.charming.assets.upload(file, { key: 'cover.jpg' });
image.src = await window.charming.assets.load(uploaded.key);

A file picker needs no browser capability. Uploading from the UI requires app run access. Reading and listing require app read access. See Data storage for backend file methods, limits, and accepted file types.

For a PDF, text file, or other download, ask assets.getUrl(key) for a signed URL from the current page and open it with window.charming.openLink(url). Do not save the signed URL in app storage.

Remote image

const remoteUrl = 'https://covers.openlibrary.org/b/isbn/9780140328721-L.jpg';
image.src = await window.charming.images.load(remoteUrl);

images.load() checks the declared origin on Charming’s server, checks every redirect hop against the same list, accepts only image responses, caps the response size, and returns a data: URL. images.proxy() returns a Charming proxy URL that works in the standalone app but is blocked by the outer policy in Claude and ChatGPT embeds.

Embedded site

Declare the origin of the provider’s embed URL:

{
  "$schema": "https://charm.ing/schema/app-manifest/2026-07-31.json",
  "id": "lecture-notes",
  "meta": { "name": "Lecture notes" },
  "permissions": {
    "browser": {
      "frame-src": ["https://www.youtube.com"]
    }
  }
}

Then frame the embed URL from ui:

const player = document.createElement('iframe');
player.src = 'https://www.youtube.com/embed/VIDEO_ID';
player.title = 'Lecture video';
player.allow = 'encrypted-media; picture-in-picture';
player.allowFullscreen = true;
document.getElementById('app').append(player);

Use the provider’s embed URL, not a watch page or a course page: most sites refuse to be framed anywhere else. Each origin follows the same exact-origin rules as img-src, and a subdomain needs its own entry, so https://www.youtube.com and https://www.youtube-nocookie.com are separate grants. Origins you do not list stay blocked, and the browser reports a CSP violation for them.

When an app declares a frame origin, its document at the standalone app URL sends Referrer-Policy: strict-origin. The embedded site receives the app’s origin, such as https://<app-id>.apps.charmingusercontent.com/, with no path, query, or token; YouTube refuses to play without it. An app that declares no frame origin sends no referrer.

The browser loads the embedded site directly. Charming does not proxy it or check it again after publication, so list only providers you trust with the visitor’s request and anything you put in the embed URL. Keep a visible link to the video, opened with window.charming.openLink(url), for chat embeds and providers that refuse framing.

External API

Call external APIs from a route handler, not directly from ui:

const response = await fetch('https://openlibrary.org/search.json?q=ursula+le+guin');
if (!response.ok) throw new Error('book_search_failed');
return await response.json();

Backend fetch needs both charming:network/[email protected] and a non-empty permissions.server.fetch list. Claimed apps can use it. Calls to an undeclared origin fail with network_blocked. For an API key, use charming:secrets/[email protected] and sealed env.fetch; never put a key in ui or source.

App API responses

App-authored responses from /app/:id/api/*, including legacy default.fetch responses, carry a host-enforced Content-Security-Policy: sandbox; default-src 'none'; base-uri 'none'; form-action 'none' and X-Content-Type-Options: nosniff. App-authored headers cannot relax these restrictions. Opening an HTML, SVG, or XHTML API response as a browser document does not execute its scripts. Put interactive UI in the app’s ui source.

The policy preserves API response bodies, content types, statuses, and streaming delivery for fetch clients. It also applies to app-authored error and redirect responses; redirects retain their destination, whose own response determines its browser policy.

CSP directive map

permissions.browser["img-src"], permissions.browser["frame-src"], and permissions.browser["connect-src"] are the only manifest fields that widen the browser CSP. permissions.server.fetch controls backend egress, not connect-src.

Resource or operation CSP directive What an app can configure
Images, CSS background images, icons img-src Exact remote HTTPS origins through permissions.browser["img-src"]
Browser fetch, XHR, EventSource, WebSocket, runtime calls, and asset reads connect-src Exact remote HTTPS and WSS origins through permissions.browser["connect-src"]. Charming supplies its app and relay origins and, on the web, the app’s own origin. Never another app’s origin
Inline app code, external scripts, modules, and worker fallback script-src Nothing to configure. On the web: the app’s own uploaded scripts and each cdnjs, unpkg, or jsDelivr package version that ui names by URL, plus 'unsafe-eval' for eval and new Function. In a chat embed: none of these. Charming always supplies the inline ui, the exact URL of its pinned UnoCSS runtime, and blob: for host-owned modules
App CSS and stylesheets style-src No origins. Put CSS in styles; Charming supplies inline styles and Google Fonts CSS
Font bytes font-src No origins. Charming supplies its app origin and Google Fonts bytes
Nested frames frame-src Exact remote HTTPS origins through permissions.browser["frame-src"], at the standalone app URL. Charming also supplies the app’s own origin and Charming’s app origin, never another app’s origin
Audio and video files media-src No origins. Charming supplies the app’s own origin, its asset origin, blob:, and data:
Dedicated workers worker-src, falling back to script-src when absent No origins. On the web: a worker started from the app’s own uploaded file. In a chat embed: none
WebAssembly script-src plus a WebAssembly execution source Nothing to configure. On the web: 'wasm-unsafe-eval' and 'unsafe-eval'. In a chat embed: neither

On the web, the policy arrives as the Content-Security-Policy response header of the app’s own origin. In a chat embed, it arrives as a <meta> tag in the app document. The renderer emits default-src, script-src, style-src, font-src, connect-src, frame-src, img-src, and media-src. worker-src is not emitted, and the manifest has no fields for media-src or worker-src. A permissive origin that appears in a host-owned directive is not a public dependency contract. On the web, the app’s own origin and the three CDNs listed above are the supported script origins. In a chat embed, do not treat the origin of Charming’s pinned UnoCSS runtime as permission to import another package from that CDN.

Direct browser and chat embeds

The standalone app URL and a chat embed do not have the same browser authority.

Standalone app. Every app opened in a browser runs on its own per-app secure origin. Camera, microphone, location, screen capture, clipboard reading, and MIDI reach the app’s frame only after the viewer allows them for that app: Charming finds these calls when the app is published, lists them in the Permissions Policy, and adds each allowed one to the frame’s iframe permissions. Other browser capabilities use the delivery rule in the browser features guide. The visitor still controls each browser prompt.

Chat embed. Charming’s production MCP renderer uses an opaque srcdoc frame and does not pass the per-app capability origin into the viewer. Camera, microphone, display capture, geolocation, and similar delegated APIs are blocked there. Provide an Open in web path. File pickers and uploaded images do not need those device permissions and work in embeds when they use the runtime paths above.

Audio and video. At the standalone app URL, <audio> and <video> play the app’s uploaded assets from window.charming.assets.getUrl(key), a blob: URL the app creates, such as a MediaRecorder recording, and a data: URL. Media from any other origin stays blocked, and the manifest cannot add one. In a chat embed, Charming’s viewer policy blocks inline playback, so provide an Open in web path. A camera or microphone stream is a device API result, not a media file, so media-src does not govern it.

Embedded sites. At the standalone app URL, an <iframe> can load any origin listed in permissions.browser["frame-src"]. A chat embed runs the app in an opaque frame whose policy lists no embedded-site origins and sends no referrer, so the embed does not load there. The null-origin fallback frame used by browsers that send no Fetch Metadata, such as Safari before 16.4, sends no referrer either, so providers such as YouTube refuse to play. Provide an Open in web path or a link to the video.

Scripts, eval, and WebAssembly. On the web, an app runs at its own origin, and its policy allows eval, new Function, WebAssembly, its own uploaded scripts, and the cdnjs, unpkg, and jsDelivr package versions its ui names. Scripts from any other origin or package stay blocked. A chat embed allows none of these, and neither does the null-origin frame Charming falls back to in a browser that sends no Fetch Metadata, such as Safari before 16.4. An app that uses them needs an Open in web path, or should inline the library as described in Use JavaScript modules and libraries.

CDN script URLs. Charming reads the CDN URLs written in ui and allows one package version for each. Write each URL in full as a string, with the exact package version:

  • jsDelivr: https://cdn.jsdelivr.net/npm/<package>@<x.y.z>/<file>
  • unpkg: https://unpkg.com/<package>@<x.y.z>/<file>
  • cdnjs: https://cdnjs.cloudflare.com/ajax/libs/<library>/<version>/<file>

The URL https://cdn.jsdelivr.net/npm/[email protected]/dist/leaflet.js allows every file under https://cdn.jsdelivr.net/npm/[email protected]/, whether the app loads it with a <script> element or import(). It allows no other Leaflet version and no other package. Create and update reject a ui that names a CDN URL without an exact version, such as a version range, a tag like @latest, a missing version, a jsDelivr /gh/ or /combine/ path, or a segment filled in from a variable. A URL built at runtime from pieces is not read, so the browser blocks it. A module that imports a different package, such as a jsDelivr +esm file with dependencies, has those imports blocked unless ui also names each imported package at its exact version; prefer a build that bundles its dependencies.

The app has no <head> to edit, so load a CDN script from ui: create a <script> element, set src to the exact-version URL and integrity to its digest, set crossOrigin = 'anonymous', append it, and wait for its load event before using the library. Listen for its error event too, and show fallback UI when it fires: in chat, or in the fallback frame, the script is blocked and nothing else reports the failure. A script from these CDNs runs with the same window.charming authority as the rest of the UI. It can read the page’s render, asset, frame, and login-state tokens, so a compromised script can act as the viewer on that app. The exact version keeps the package from changing under the app; integrity also protects against the CDN serving different bytes. import() cannot carry an integrity digest, so a module loaded that way relies on the version alone.

Uploaded scripts and workers. Upload a .js or .mjs file with upload_asset, or with POST /app/<app-id>/assets and the app token. Both store a .js or .mjs key as text/javascript when the upload names no type, or when a sourceUrl host serves it as text/plain or application/octet-stream. Only a caller who can edit the app may upload JavaScript, because every later visitor runs it with their own credentials. upload_asset from a user without edit access fails with forbidden, and window.charming.assets.upload() of a script fails with 401, including for the owner. At the standalone app URL, load it with await import(window.charming.assets.getUrl(key)) or a <script type="module"> whose src is that URL, or start a dedicated worker with new Worker(window.charming.assets.getUrl(key), { type: 'module' }). Each URL carries the asset token in its query, so a relative import './other.mjs' inside an uploaded module fails with 403: bundle the code into one file, or import each file through getUrl(key). Only the app’s own origin serves the file as JavaScript. The charm.ing URL that env.assets.url(key) and uploads return, and every chat embed, get it as a plain-text download, which fetch() and assets.load() can still read. Another app’s page cannot run it, and it cannot register a service worker. The script runs with the same window.charming authority as the rest of the UI.

An MCP host also applies its own outer CSP. The browser enforces both policies, and the narrower rule wins. assets.load() and images.load() return data: image URLs to survive that intersection. A CSP entry in the app manifest cannot weaken the host’s policy.

Validation and errors

Manifest schemas use additionalProperties: false. The existing contract rejects dependencies; the explicit ESM contract accepts separate server/client npm maps. Unknown keys such as csp, script-src, or a browser permission other than img-src, frame-src, and connect-src fail create or update with invalid_manifest_schema (HTTP status 422 on the HTTP API).

A ui that names a cdnjs, unpkg, or jsDelivr URL without an exact package version fails create or update with unpinned_cdn_script (HTTP status 422 on the HTTP API). The error’s unpinnedUrls lists each URL to fix. An update is rejected only for a URL it adds; one the stored ui already names, or one in a restored version, is not rejected.

Malformed exact origins also fail with invalid_manifest_schema. A syntactically exact known-local or literal private origin fails with invalid_image_hosts, invalid_connect_origins, invalid_fetch_hosts, or, for frame-src, invalid_manifest_schema. An origin on a Charming app host, such as another app’s https://<app-id>.apps.charmingusercontent.com, fails the same way in every list: an app cannot frame, load from, or call another app’s origin. Image loading also resolves DNS and returns an unsafe-URL error for a non-public destination. Origins listed under permissions.server.fetch without the network capability are stored but inactive; create or update returns a warning and backend fetch stays blocked.

At runtime, images.load() rejects when the request, access check, or image response-type check fails. assets.load() converts any successfully served asset to a data: URL; use it as an image source only for an image asset. Treat a rejection or failed element render as a failed load and show a fallback; plain error messages are not a stable branching contract. External proxy failures carry optional error.imageFailure metadata with a safe HTTPS origin, a failure category, and the upstream HTTP status when known. Charming records these failures in app activity and the current-revision get_app summary even when the app catches the rejection. The shell displays a dismissible request-failure notice; the app keeps control of alternate sources and fallback content. See Image loading for the metadata and notice behavior.

A raw resource blocked by CSP raises a browser securitypolicyviolation event, which Charming reports as a diag_report event in the app activity log.

Current limitations

window.charming.deps is undefined. Charming does not provide deps.load, deps.url, a package catalog, or dependency-scoped CSP. The explicit ESM contract resolves declared npm packages during a background build. External files and browser origin permissions do not install dependencies.

  • Scripts and modules. On the web, an app can load its own uploaded scripts and a script from the cdnjs, unpkg, or jsDelivr package versions its ui names, and every other script source is blocked. Chat embeds block all external and uploaded scripts, so prefer inlining. The existing contract runs ui as one inline classic script; the explicit ESM contract accepts declared imports and serves the compiled module. If a task needs a small self-contained browser library, follow Use JavaScript modules and libraries to review, pin, and inline its UMD or IIFE bytes. Inlined code receives the same window.charming authority as the rest of the UI.
  • Workers. At the standalone app URL, an app can start a dedicated worker from its own uploaded .js or .mjs file. Chat embeds and the fallback frame cannot. An upload cannot be registered as a service worker, shared workers are not a supported contract, and there is no manifest grant. The blob: allowance exists for Charming’s host-owned modules and is not a promise that blob: workers work across hosts.
  • WebAssembly and models. application/wasm uploads are rejected, and Charming has no model loader or same-origin model endpoint. On the web, an app can compile WebAssembly from bytes it already holds, such as bytes inlined in ui or embedded in a library script from an allowed CDN. It can fetch a separate .wasm file from a CDN only after the app lists that CDN’s origin in permissions.browser["connect-src"]. A chat embed blocks WebAssembly execution. A generic JSON or binary asset remains a file; storing it does not install a model runtime.
  • MediaPipe. MediaPipe Tasks needs script or module loading, WebAssembly execution, model files, and often workers. On the web, the scripts and WebAssembly execution are allowed, and the app can fetch its .wasm and model files once it lists their origins in permissions.browser["connect-src"], but .wasm uploads are rejected. In a chat embed, none of it runs. Do not claim that adding a CDN origin or a manifest dependency enables it.

Was this page helpful?