Browse the docs
Reference
Isolation and security model
How fuast keeps capsule code away from the platform and from other apps. Process split, isolates, transactions, app origins, storage hardening, rate limits, and red-team checks.
fuast runs code that agents write, for many teams, on shared machines. The model assumes any capsule may be hostile and keeps it away from the platform, other apps, and other workspaces.
The rule
No capsule code ever runs in the platform process. The platform never imports, evaluates, or string-splices capsule code or capsule arguments. This holds for top-level bundle code too, not only for handlers, and for code that would run at build time: the platform never bundles uploaded sources itself.
Two processes
| Process | Runs | Holds |
|---|---|---|
| Platform (Bun) | Accounts, workspaces, sharing, deploys, the app gateway, WebSockets, storage routes, email, schedules | Platform database, file storage credentials, email credentials |
| Runtime (Node 24 + isolated-vm) | Capsule server code inside V8 isolates, each app's SQLite database | App databases. No platform secrets. |
They talk over an internal HTTP API protected by a shared secret. In production the runtime refuses to start with the default secret. The runtime binds to 127.0.0.1 by default and never echoes the secret in its responses.
Deploy path
- The CLI uploads source files, not built code. The platform checks every file name before anything touches disk: no
.., absolute paths, drive letters, backslashes, NUL or control characters, dot-directories, case-insensitive duplicates, or.env*/bunfig.toml-style files. Only.ts .tsx .js .jsx .mjs .json .md .svg .txt .css .htmland.gitignoreare accepted, at most 1 MiB per file, 1000 files and 20 MiB per deploy.package.json,package-lock.jsonandjsconfig.jsonare dropped from every upload: theirbrowser,importsandexportsfields are resolver configuration, and capsules need none. - Uploads that import with
with { type: "macro" }orassert { type: "macro" }(any quoting, escapes, or comments) are refused with422 macro_import. Bun macros run code at bundle time. Every other import attribute excepttype: "json"(text,file,base64,dataurl,binary,sqlite,toml,napi, …) is refused with422 import_attribute: those loaders inline raw bytes. - The bundle is built and type checked with
tsc-rsin a short-livedbunchild process (apps/platform/src/deploy/build-worker.ts), never in the platform. The child gets a fresh temp directory holding only the uploaded files, an environment with onlyPATHand a privateHOME/TMPDIR(no platform secrets), macros forced off (Bun.build({ macros: false })), a hard timeout (FUAST_BUILD_TIMEOUT_MS, default 120 s) that kills its whole process group, capped output, and a limit on concurrent builds (FUAST_BUILD_CONCURRENCY, default 2). The platform validates the shape and size of what comes back. Type errors block the deploy unless the owner passes--no-typecheck. - The build reads only what the capsule is allowed to import. The contract plugin resolves every capsule import itself (extensions,
indexfiles) and hands Bun the realpath, so nopackage.jsonmapping,tsconfigpathsorextends, or other resolver feature applies. Containment is checked on the realpath, so a symlink cannot point outside the capsule. AnonLoadguard refuses every module whose realpath is outside the capsule, the generated entry, the fuast runtime sources and the Preact package, reads the bytes itself, and picks the loader from the file extension (.ts .tsx .js .jsx .mjs .jsononly), so no import attribute can change how a file is read.tsc-rsruns withnoResolveon a generated file list, so a type-only import of a file outside the capsule is never read. - In the Docker image the build child also runs as a separate unprivileged user.
FUAST_BUILD_USER=fuastbuild(set by the Dockerfile) makes the platform, which runs as root, start the worker throughsetpriv --reuid --regid --clear-groups --no-new-privs(or spawnuid/gidwhensetprivis missing) with only its temp directory chowned to it.start.shmakesFUAST_DATA_DIRmode0700, so app databases, deploy env files andplatform.sqliteare unreadable to the build, and so is/proc/<pid>/environof every other process. A configured but unusable build user fails builds closed. WithoutFUAST_BUILD_USER(macOS development, non-root installs) builds run as the platform user and steps 1 to 4 are the fence. Concurrent builds share the one build user. A mount namespace (bubblewrap) is not used, because Docker's default seccomp profile blocks the unprivileged user namespaces it needs. - The platform asks the runtime to describe the bundle. The runtime evaluates it inside an isolate and returns a manifest: schema, handler names and kinds, tools, endpoints, schedules, auth flags, name, and favicon. The platform only ever sees that JSON.
- Schema changes are planned against stored rows and applied by the runtime.
Inside an isolate
- One isolate per app deploy, cached (at most 64 at once, least recently used evicted). Each call gets a fresh context, so no state leaks between calls or users.
- No
process,require, filesystem, sockets, Node built-ins, or platform memory. Onlyctxand a guarded globalfetch. - Limits per call: 5 s CPU, 128 MB memory, 10 s wall clock.
- Values cross into the isolate as copies or function arguments. They are never pasted into source text, so arguments containing
$-patterns,__proto__, or code cannot change what runs. - Database operations are synchronous host calls into the runtime's SQLite. A whole handler runs inside one SQLite transaction:
BEGIN IMMEDIATEfor writers, with a per-app writer lock, andBEGINfor readers. A throw, timeout, or limit error rolls back. - Asynchronous host calls (fetch, email, storage, team tables, workspace members) go through the platform bridge, which checks that the call belongs to the app and its workspace.
Outbound fetch
fetch from a handler is SSRF-guarded: requests to loopback, private, link-local, and cloud metadata addresses are refused, including through redirects and DNS that resolves to them. The onGuestUpgrade hook cannot fetch.
Data separation
- Each app has its own SQLite file owned by the runtime. Handlers can reach only their own app's tables.
- Team tables are scoped to the app's workspace by the platform, never by capsule input.
- Storage keys are scoped to the app. Quotas are per workspace.
ctx.workspace.members()answers only for signed-in members of the app's workspace and for schedules.
App origins
With FUAST_APPS_DOMAIN set, every app is served from its own origin, https://<slug>.<apps domain>/. The app origin gets its own session cookie through a one-time code redirect from the platform. Platform cookies are host-only and never reach app origins, so app JavaScript cannot call platform APIs as the viewer or read another app's session.
- The handshake is bound to the browser that started it.
/__fuast/auth/sign-inon the app origin sets a short-lived state cookie, the state travels through the platform's authorize step and is stored with the one-time code, and the callback accepts the code only when the browser's state cookie matches. Codes are single use and expire after 60 seconds. A code minted for someone else's account cannot sign a victim in (login CSRF), so guest data is only ever moved into an account by the browser that signed in. - On
httpsapp origins the app session, guest and state cookies use the__Host-prefix (Secure,Path=/, noDomain), so a sibling app cannot overwrite or plant them by setting a cookie for the whole apps domain. Plainhttp://<slug>.localhostkeeps unprefixed names. - Add the apps domain to the Public Suffix List (or use a registrable domain of its own, separate from the dashboard's), so browsers treat each
<slug>.<apps domain>as its own site. Do not serve apps under a subdomain of the dashboard's domain. - Production refuses to start without
FUAST_APPS_DOMAIN(or withFUAST_APPS_DOMAIN=off). Path mode (/a/<slug>/on the platform origin) does not give this separation; enable it in production only withFUAST_ALLOW_SAME_ORIGIN_APPS=1, and only when every app on the platform is trusted. The platform prints a warning at start. - When the web app proxies app subdomains to the platform (
x-fuast-base: rootplusx-forwarded-host), the platform believes those headers only if the request carriesx-fuast-proxy-secret: $FUAST_TRUSTED_PROXY_SECRET. Without that variable they are accepted only from loopback peers. The secret header is stripped before endpoint handlers see the request.
Identity
- Accounts verify their email before joining a domain workspace. Free-mail addresses (a list of several hundred providers in
apps/platform/src/data/free-email-domains.txt, extendable withFUAST_PERSONAL_DOMAINS) always get a workspace of one. - A member an admin removes is recorded. Verifying the address again does not put them back. An admin re-admits them with
DELETE /api/workspace/removed/<email>. - Google sign-in ID tokens are verified against Google's published keys, checking audience, issuer, and expiry. When a Google sign-in claims an account that was registered with a password but never verified, every session, app session and API token of that account is revoked and its password is replaced before linking, so whoever pre-registered the address loses access.
- Revoking a session or token, signing out, removing a member, and narrowing an app's sharing close the affected live WebSockets at once (close code 4403).
- Identity tokens (
getIdentity().token) are bound to the credential they were minted from: the app session (app origins), the platform session, or the personal API token. They stop working the moment that credential ends (app sign-out, session revocation, token revocation), and a WebSocket opened with one closes with it. - Signing out of an app is a
POSTwith an origin check. AGETon the sign-out URL only shows a confirm button. - Sign-in
returnTovalues must be a single-slash path with no whitespace, control characters or backslashes. - App-scoped deploy tokens (
fuast token create --app) can deploy, archive, restore and roll back their app and read its status (manifest, usage, logs, deploys). They are not an identity in the app and cannot change sharing, env, domains or data, or terminate the app. - Guest sessions are HttpOnly per-app cookies issued and verified by the platform. Guest ids are opaque and are not credentials.
X-Fuast-Tokenon endpoint requests is verified and stripped before the handler runs.Authorizationpasses through untouched for the app's own use.- The CLI approval page is a POST with an origin check and an explicit confirm step.
Storage hardening
- Uploads of executables, installers, scripts, HTML, and JavaScript are rejected with
415 blocked_type, by declared type and by magic bytes. - Every download carries
X-Content-Type-Options: nosniffand a sandboxingContent-Security-Policy. HTML and JavaScript content is served as anapplication/octet-streamattachment. - 25 MiB per file, 1 GiB per workspace. The quota is reserved atomically before the file is written, so concurrent uploads cannot overshoot it.
Rate limits and quotas
- Per-app daily quotas on requests (100000), mutations (20000), and email (100), returning 429 with
fuast_quota_exceeded. Email also has a per-workspace daily cap across all its apps (FUAST_QUOTA_WORKSPACE_EMAIL, default 300). - Sharing invites: at most 50 new addresses per call (
FUAST_MAX_INVITES_PER_CALL) and 200 invitation emails per workspace per day (FUAST_QUOTA_INVITES). - Rate limits on
/api/call, uploads, email, sign-in, sign-up, CLI login, deploys, and WebSocket upgrades (FUAST_WS_UPGRADES_PER_MINper address, default 300). - Live queries: identical subscriptions (same query, arguments and viewer) share one execution, re-runs after writes are coalesced to at most
FUAST_WS_RERUNS_PER_SEC(20) per subscription, and re-runs count against a dailyrerunsbucket per app (FUAST_QUOTA_RERUNS, 1000000). At mostFUAST_WS_MAX_APP_SUBSCRIPTIONS(5000) live queries per app andFUAST_WS_MAX_PER_IP(200) sockets per client address. - Rate-limit keys use the socket address. Proxy headers are trusted only with
FUAST_TRUST_PROXY=1.
Inspection
/__fuast/* routes on hosted apps answer only to the app owner and workspace admins. fuast dev binds to 127.0.0.1 and leaves them open locally.
Red-team checks
scripts/ci-redteam.ts deploys hostile capsules and must fail to leak on every CI run. It covers reading platform env and files, escaping the isolate from handlers and from top-level bundle code, prototype pollution and $-patterns through arguments, cross-app and cross-workspace data access, SSRF through fetch, the runtime echoing its secret, Bun macros at build time (six spellings, checked against both the upload pre-scan and the sandboxed build), hostile upload file names, and build-time host file reads (package.json browser/imports/exports mappings, loader import attributes such as text and file, .. and absolute imports, a tsconfig extends, and type-only imports read by the typecheck), each checked against a canary file that must never appear in a bundle, error, or diagnostic.
tests/security/ holds regression tests for the platform-level findings: macro uploads and file names, the app sign-in handshake, __Host- cookies, trusted proxy headers, Google sign-in over an unverified account (with a fake Google), removed members, socket revocation, app-token scope, invite and email caps, storage quota races, open redirects, sign-out, the production start checks, build-time file reads (build-file-read.test.ts), identity tokens bound to their credential (identity-token-binding.test.ts), and live-query re-run amplification (realtime-amplification.test.ts).
Report security issues to support@fuast.com with "security" in the subject.