Browse the docs
Reference
Security
The fuast threat model in one page. What the platform guarantees, what your capsule must do itself, app origins and cookies, secrets, endpoints, agent tools, and how to report a vulnerability.
This page is the short threat model for people who build and share fuast apps. It says what fuast protects for you, what your capsule code must do itself, and where the edges are. The mechanics behind it are in isolation.
What fuast guarantees
- Deploy builds read only your capsule. The upload is bundled in a separate, unprivileged process that cannot follow imports,
package.jsonmappings, symlinks, or loader attributes outside the capsule. See isolation. - Capsule code never runs in the platform. Server code runs in a V8 isolate in a separate runtime process, one per app deploy, with a CPU, memory, and time budget. A capsule cannot read platform data, another app's database, or another workspace's files.
- Identity comes from fuast. Handlers read the caller from
ctx.auth, which the platform fills from a verified session, guest cookie, or token. A capsule cannot forge it, and a client cannot pass it in. - Sharing is enforced on the server. Queries, mutations, actions, the WebSocket, the HTTP call API, and storage routes all check the app's sharing mode and
requireSignInbefore your code runs. See teams. - Each app has its own origin. Hosted apps run at
https://<slug>.<apps domain>/with their own session cookie. Platform cookies never reach an app. See app origins. - Secrets stay on the server.
ctx.envexists only in handlers. Env values never reach client bundles,shared/code, inspection routes, or deploy listings for people who cannot manage the app. - Stored files are served safely. Uploads of executables, HTML, and JavaScript are refused. Files are served with
X-Content-Type-Options: nosniffand a sandboxingContent-Security-Policy. See storage. - Outbound fetch cannot reach private networks. Handler
fetchrefuses loopback, private, and link-local addresses, including after redirects. - Inspection is private. Hosted
/__fuast/*routes (manifest, rows, logs, usage, storage) answer only the app owner and workspace admins. - Other sites cannot frame your app. App pages are served with
frame-ancestors, so a hostile page cannot overlay your app and trick a signed-in person into clicking. See embedding. - State-changing routes check
Origin. Platform and app routes that change data reject cross-site requests.fuast loginapproval is a POST with an explicit confirm page. - Accounts are verified. An account joins its company workspace only after verifying its email address. Google sign-in tokens are verified against Google's keys.
- Abuse is bounded. Per-IP rate limits on sign-in, calls, uploads, and deploys, and daily quotas per app. A whole IPv6
/64counts as one address, so a client cannot get a fresh allowance by moving to a neighbouring address. See limits. - Apps without an owner are fenced in. An app deployed without an account is a preview: reachable only through its preview link, guests only, no email or workspace data, small quotas, and gone after 72 hours unless someone claims it. See unclaimed previews.
- Claiming an app does not hand it your workspace, and neither does deploying to it. A claimed app stays cut off from email, team tables, the member list, and unattended runs until you trust it explicitly. See restricted apps.
What your capsule must do
fuast identifies the caller. It does not decide which rows that caller may see or change. That is your code.
- Authorize every row. Filter user-owned reads by
ctx.auth.userIdthrough an index, and re-check ownership in every mutation beforeupdate,replace, ordelete. An id from the client is a request, not a permission. - Never take identity as an argument. No
userId, email, or name parameters for "who is calling". Usectx.auth.requireIdentity()orctx.auth.requireSignedIn(). - Validate every argument. TypeScript types are not checked at runtime for positional handler arguments. Check types, lengths, and ranges in the handler, ideally with a helper in
shared/. - Protect machine-called endpoints yourself. Endpoints run for any caller, even on private apps, so webhooks work. Check a secret from
ctx.envor verify the sender's signature. See endpoint credentials. - Keep secrets in
ctx.env. Never inclient/,shared/, tool descriptions, or log messages. Anything in the client bundle is public to everyone who can open the app. - Do side effects last. A rollback undoes database writes only. Email, storage writes, team table writes, and
fetchcalls stay done if the handler throws afterwards. - Render user content as text. Preact escapes strings. Do not put user content into
dangerouslySetInnerHTML,href="javascript:...", or inline styles you build from strings. - Treat tool results as untrusted. Text other people wrote can try to steer a browser agent that reads it. Return only the fields an agent needs. See agent tools.
- Pick the narrowest sharing mode. Apps start
private.publiclets anyone with the link in, as a guest unlessrequireSignInis set.
App origins and cookies
Each hosted app has its own origin, so one app's JavaScript cannot read another app's pages or call the platform's account APIs as the signed-in person.
- The platform session cookie is host-only on the platform origin, with the
__Host-prefix on https, so no app can overwrite it even when apps share the platform's domain. It is never sent to an app origin, and the platform API does not answer on app hosts. - An app's session cookie is minted on the app's origin through a one-time code redirect at sign-in. It is HttpOnly, host-only, and
SameSite=Lax. - Guest cookies are signed, HttpOnly, and scoped to one app.
- Identity tokens from
getIdentity()are scoped to one app and to the sign-in they came from. Signing out of the app, revoking the session, or revoking the personal token they were minted through ends them at once. Send them only to your app's own origin.
Every app shares the apps domain with every other app. Until that domain is on the Public Suffix List, browsers treat all apps on it as the same site. Two consequences:
SameSitecookies do not separate one app from another. A request fromother.<apps domain>to your app counts as same-site. fuast's own routes checkOriginfor this reason. Do the same on any writable endpoint that relies on cookies.- A page on a sibling subdomain can set a cookie for the parent domain. If you set your own cookies, use the
__Host-prefix withSecure; Path=/and noDomain, which browsers refuse to let other subdomains overwrite.
An app on a custom domain gets the same protections on that domain: its own session cookie through the same handshake (the platform hands the one-time code only to a domain verified for that app), __Host- cookies, and Origin checks against the domain the request arrived on. A custom domain is not under the shared apps domain, so other apps there are cross-site to it (two custom domains under the same registrable domain, like a.acme.com and b.acme.com, are still same-site to each other).
Self-hosted platforms without a wildcard domain serve apps under /a/<slug>/ on the platform origin. In that mode apps share an origin with the platform and with each other, so any app can act as its viewers on the platform. A production platform refuses to start in path mode unless the operator sets FUAST_ALLOW_SAME_ORIGIN_APPS=1. Use it only when every app on the platform is trusted.
Embedding
An app page can be shown in an <iframe> only by pages fuast trusts for that app. The platform sets the header from the app's sharing mode. There is nothing to configure in the capsule.
| App | May be framed by | Headers on the app's pages |
|---|---|---|
private or workspace |
Its own origin only | Content-Security-Policy: frame-ancestors 'self' and X-Frame-Options: SAMEORIGIN |
public |
Its own origin and the fuast site it is hosted on (https://fuast.com for hosted apps) |
Content-Security-Policy: frame-ancestors 'self' https://fuast.com |
| fuast's own pages on the app origin (sign-out confirmation, the access wall, error pages) | Nobody | frame-ancestors 'none' and X-Frame-Options: DENY |
- No other site can frame an app, public or not. To show a fuast app inside another product, link to it.
- Making an app
publicis what lets the fuast site embed it. This is how the live demo on the fuast.com homepage works: two frames of the publiclive-demoapp. Switching the app back toprivateorworkspaceends it with the next page load. - Sign-in never happens in a frame. The fuast sign-in, sign-up, and dashboard pages are served with
frame-ancestors 'none', so an embedded app that needs an account must send the person to a top-level window. - A framed app is the same app: same origin, same cookies, same
Originchecks. On hosted fuast the site and its apps are the same site, so the guest and session cookies (SameSite=Lax) work inside the frame. A browser that blocks them only costs a guest their identity between reloads. Do not rely on a guest id for anything that matters. See thelive-demotemplate, which scopes its data by a room id instead. - The headers cover pages. Endpoints you write answer whatever you return, so add your own
Content-Security-Policyheader to an endpoint that returns HTML.
Self-hosted platforms: the embedding site is FUAST_BASE_URL, plus any origins in FUAST_ALLOWED_ORIGINS.
Local development
fuast dev runs your server code in-process with Bun, with full access to your machine, because it is your code. Do not run a capsule you have not read.
npx @revnu/fuast starts Bun with an empty config file shipped in the package, so a bunfig.toml in the directory you run it from (with a preload, for example) is not loaded. bunx @revnu/fuast and bun x @revnu/fuast are started by Bun itself, which reads ./bunfig.toml before fuast runs: do not run fuast with Bun in a directory you do not trust.
fuast dev binds to 127.0.0.1, and its inspection routes are open there. --host 0.0.0.0 exposes your rows, logs, and files to your network.
Tokens
fuast loginsaves a CLI session in~/.fuast/config.jsonwith file mode600.- A personal token from
fuast token create --name <name>can do anything you can. An app token (--app <slug>) can only deploy and inspect that app. Prefer app tokens in CI, and store them asFUAST_TOKENsecrets. fuast token revoke <id>ends a token immediately.fuast logoutends the saved CLI session.- Resetting your password ends everything that can act as you: every session, every API token (personal and app deploy tokens), and every agent connected through MCP. If you think a token or a connection leaked, reset your password, then create new tokens and connect your agents again.
- CLI and deploy tokens never authenticate callers of your endpoints.
MCP and OAuth
Agents connect to fuast through the MCP server at /mcp. A connected agent acts for a person, so the server is built on these rules.
How it is reached:
- Bearer tokens only. The caller is the
Authorization: Bearerheader and nothing else. Cookies are never read on/mcp, so a web page cannot drive it with your dashboard session. - The dashboard host only.
/mcp,/oauth/*, and the OAuth metadata answer on the platform's own host. On an app subdomain or a custom domain those paths belong to the app. - Other websites are refused. A request without an
Originheader comes from a program, not a browser, and is allowed. The platform's own origin is allowed. Any other web origin gets403. CORS is off, because Claude, ChatGPT, Claude Code, Cursor, and VS Code call the server from their own servers or from a desktop process. An operator can list origins inFUAST_MCP_ALLOWED_ORIGINS. A listed origin gets CORS headers and must send a bearer token: a page can never use the server anonymously from its visitors' addresses. - Without credentials a caller gets the docs, the templates,
check_app, and preview deploys, plus the preview it deployed through that preview's edit token. All of it is rate limited per client address. Everything else answers401.
Tokens:
- Access and refresh tokens are opaque random strings, stored as SHA-256 hashes. Access tokens last 1 hour.
- Audience-bound. A token is issued for the MCP resource (
<platform>/mcp) and accepted there only. The platform API (/api/*), the operator API, and every app refuse it, including asX-Fuast-Token. - The other direction is closed too.
/mcpaccepts its own access tokens and fuast API tokens fromfuast token create. It does not accept browser sessions, the sessionfuast loginsaves, app sessions, identity tokens, or the operator token. - An OAuth token acts as you only through the MCP tools, and only within the scopes you approved:
apps:read,apps:write,apps:share. A sign-in that asks for no scope getsapps:read. A call that needs more answers403and you are asked again. - An app deploy token stays inside its app. It can deploy, roll back, and read the status of that app, as on the API.
The OAuth flow:
- Public clients with PKCE. There are no client secrets.
S256is required on every authorization. - Redirect URIs are matched exactly. They must be
https, orhttpon a loopback address, where the port may differ (RFC 8252). No wildcards. - A redirect URI is never an address of the platform. Anyone can deploy an app at
<slug>.<apps domain>or connect a custom domain, so "sends you back to claude.fuast.com" would read as trustworthy while the code went to whoever owns that app. The dashboard host, everything on the apps domain, the platform's own hostnames, and every custom domain that is live for an app are refused, at registration and again on every authorization. - Nothing redirects until you answer. Anyone can register a client with any address, so the authorization endpoint never sends a browser to a client by itself. Every problem with a request is shown on a fuast page. The only redirects to a client are the two answers of the consent screen, Approve and Cancel.
- Authorization codes work once, for 60 seconds, and only with the client, the redirect URI, and the PKCE challenge of the request that produced them. A code presented a second time ends the connection it created.
- Refresh tokens rotate. Each use returns a new token and ends the old one. An old token used again means two parties hold it, so the whole connection is ended.
- Every authorization response carries
iss(RFC 9207), which lets a client detect a response from a different authorization server (mix-up). - The consent screen is shown every time. It is never framed, runs no scripts, and its form carries a CSRF token bound to your session and to that one request.
- The consent screen separates what fuast checked from what the app says. Where you will be sent back to is checked, and shown in a box. An app's name is its own choice: it is shown in quotes with "This name was chosen by the app itself" beside it, and never in the heading. Only a client whose metadata document is published on a well-known host (
claude.ai,chatgpt.com) is shown as "Verified:", and what is verified is that host. A dynamically registered client is never shown as verified. - Client metadata documents are fetched carefully.
httpsonly, private and internal addresses refused, no redirects, at most 64 KiB. The document must name the URL it was fetched from. - Dynamic registration is capped at 30 clients per client address per day. Registrations that were never used, or whose last connection ended long ago, are removed.
What a connected agent can and cannot do:
- The same permissions as you, checked by the same code as the dashboard and the CLI. An app you cannot see answers like one that does not exist.
call_appruns a handler as you, or as a guest on a public app. It never runs as another person, so it cannot be used to read someone else's rows through your app's handlers.- No tool lifts a restriction. A restricted app stays restricted through every MCP tool, deploys included.
deploy_appandupdate_app_fileswork on it like any other deploy by someone who may manage the app, and their results say that workspace features are still off and name the action that turns them on (fuast trust <slug>or the dashboard button). There is no trust tool. A preview's edit token stops working at the claim, so a deploy without an account can never reach a restricted app. - A caller gets a few builds at a time.
check_app,deploy_app, andupdate_app_fileseach hold a build process. An account may run 3 at once, a client address without an account 2. Hanging up on a build does not free its place until the build has ended. Batched JSON-RPC requests are refused whole. - Tool results never carry secrets. Env values are write-only,
.envfiles are stripped from source, and anything shaped like a fuast credential is redacted from log output and from the audit trail. The one secret a tool returns is the edit token of a preview the same caller just deployed. - Changes are recorded. Every deploy, share, env change, rollback, delete, and mutation or action run through MCP is written to an audit trail with the client's name, and so is every connect, disconnect, and token misuse. Deploys and other changes also appear in the app's logs. You see your connections and revoke them under API tokens > Connected agents in the dashboard.
- A password reset ends every connection, along with the account's sessions and API tokens. So does removing a member from a workspace.
A platform operator can turn the whole surface off with FUAST_MCP=off. The endpoints then answer 404 and no MCP token is accepted anywhere.
Unclaimed previews
An agent can deploy before anyone has an account. The app goes online as an unclaimed preview, and a person later claims it. See deploy without an account. This is hosting for code that nobody has put their name to, on the same domain as every other app, so a preview is deliberately less than an app.
Three secrets, one per ability. Each is at least 192 random bits, stored only as a hash, compared in constant time, and never written to a log:
| Secret | Where it lives | What it allows |
|---|---|---|
| Preview key | In the preview link | Look at the preview. Nothing else. |
| Claim token | In the claim link | Become the owner, together with a verified sign-in. Once. |
| Edit token | ~/.fuast/previews.json on the agent's machine |
Redeploy the preview, read its inspection routes, call its handlers as a guest, delete it. |
The edit token can produce the claim link again, and the claim link can open the preview. Nothing leads the other way: a preview link never leads to the claim link, and neither link leads to the edit token. That is why claiming has its own secret instead of "preview link plus sign-in": preview links are passed around so people can look, and looking must not be enough to take the app.
The CLI keeps the edit token in the home directory and never reads one from a project folder. A repository can contain any file, so a state file in the working tree could point fuast deploy at someone else's preview and send your source and .env.fuast.server there. A .fuast/unclaimed.json in a project is ignored with a warning. For the same reason a logged-in fuast deploy never claims a preview by itself: you choose --claim or --new.
What fuast guarantees about a preview:
- It is not public. Without the preview key the address answers exactly like an address with no app, on every route. The preview is in no listing, and no account can open, name, or manage it through the platform API.
- The first thing a visitor sees is fuast's page, not the app. It says the app is an unclaimed preview deployed by an anonymous agent, how long ago, when it is deleted, and never to enter passwords or payment details. Nothing on that page comes from the app, not even its name. It cannot be framed and sends no referrer. Only pressing "Open preview" there, a same-origin form post, lets the browser in. A link cannot skip the page, and neither can another site or another app.
- Access is a host-only cookie. It is
__Host-,HttpOnly, and scoped to that one preview's origin until the preview expires. The key leaves the address bar as soon as the cookie is set. - Capsule code never sees a secret. The key is removed from the URL before a handler or endpoint runs, and the cookie and the token headers are removed from the request an endpoint receives.
- The address is random. The agent cannot choose it, so a preview cannot sit at a name that imitates a brand. Whoever claims the app picks the real address under the normal rules. Addresses that start with
preview-are reserved: no account can create an app or an alias that looks like a preview. - Everyone inside is a guest. No fuast account is ever identified to preview code, even if the visitor is signed in to fuast or sends a token. Sign-in is off.
- It has no workspace and cannot send mail.
ctx.email.send,ctx.team, andctx.workspace.members()fail with "Claim this app to use …". Stored files count against the preview's own small allowance. - Nothing runs unattended. Schedules and delayed jobs do not run in a preview. Jobs queued in a preview are cancelled when it is claimed.
- It is bounded. A client address can create a few previews a day, the platform holds a fixed number at once, each has small quotas and a daily cap on outbound
fetch, and each is deleted 72 hours after its first deploy. Redeploying does not extend that. A preview stops serving the moment it expires. See limits. - It can be reported. The first page has a "Report this preview" link. Reports from three different addresses take the preview offline until fuast has looked at it.
Claiming:
- Needs a signed-in account with a verified email, and the request must come from the fuast site itself. Other sites and other apps cannot make your browser claim something.
- Happens once. The first account wins, and everyone after is told the app is already claimed.
- Makes the app a private app in the claimer's workspace, with the rows and files it already has, under that workspace's quotas. Who claimed it and when is written to the app's log.
- Ends the preview. The preview link, the claim link, and the edit token stop working, and the preview's limits no longer apply. The agent's next deploy is told who claimed the app and to run
fuast login. - Does not trust the app. It stays restricted: no email, team tables, or member list, and no schedules or jobs, until the owner allows them. Jobs queued before the claim are cancelled and never run.
What this means for you:
- Treat a claim link like an invitation to own the app. Give it to the person the app is for, not to a channel.
- Before you claim, look. The claim page names the app and links to its preview, so you can try it first. After the claim it is yours and private, and it uses your quotas.
- Trust it only if your own agent built it. Leave "Let this app use my workspace" unticked for a link someone sent you. A trusted app can email as your workspace and read and change team tables that your other apps share.
- A preview is not a place for real data. Anyone with the preview link can use it as a guest, and it is deleted after 72 hours if nobody claims it.
- Do not paste the edit token anywhere. If it leaks, delete the preview (
DELETE /api/deploy/unclaimedwith the token) or claim the app, which ends the token.
Self-hosted platforms can turn previews off with FUAST_UNCLAIMED_DEPLOYS=off. Previews exist only where apps have their own origins (FUAST_APPS_DOMAIN).
Restricted apps
A restricted app is cut off from its workspace. It runs, it is private to its owner, and it has the normal limits, but:
ctx.email.send,ctx.team, andctx.workspace.members()fail with "This app is restricted and cannot …" (coderestricted).- Its schedules and delayed jobs do not run, by the clock or by hand.
Every app claimed from an unclaimed preview starts restricted, because owning an app is not the same as trusting its code. A claim link can reach anyone, and the code behind it was deployed by nobody in particular. If claiming switched everything on, a job the deployer queued beforehand would run in your workspace the moment you claimed: it could read your member list and team tables, write to them, and send mail as your workspace, without you opening the app once. So claiming cancels every job that was queued before it, and leaves the workspace switched off.
Only an explicit decision by the owner or a workspace admin lifts the restriction. There are two ways to make it, and nothing else does:
- "Trust this app with my workspace" on the app's page or its Settings tab in the dashboard, or
fuast trust <slug>with that person's login. The API isPOST /api/apps/<slug>/trust, with the usualOrigincheck. App-scoped deploy tokens and preview edit tokens cannot call it, and no MCP tool does. - The box under Options on the claim page, "Let this app use my workspace". It is unticked by default.
A deploy never lifts it, whoever makes it and however: fuast deploy, the dashboard's Source tab, an app-scoped deploy token, the MCP deploy_app and update_app_files tools, or a rollback. Changing one line of an app a stranger built does not make the rest of it yours, and an agent's update can carry the anonymous code forward untouched, so shipping a deploy must not hand that code your workspace as a side effect. Deploys to a restricted app work normally. Every deploy result says the app is still restricted and how to change it: the API response has restricted: true and restrictedNotice, fuast deploy prints it, and the MCP tools return it as restricted_hint.
What trusting means: the app can send email on your workspace's quota, list the people in the workspace, and read and write its team tables. Team tables are shared by every app in the workspace, so a trusted app can read and change rows your other apps put there. Its schedules and jobs run without anyone watching. Jobs that were queued before the claim stay cancelled.
fuast untrust <slug>, or "Turn workspace access off" in Settings, restricts any app again, not only claimed ones. Use it to keep running an app you are unsure about. Trusting and restricting are written to the app's log and to the platform's audit trail.
What a restriction does not cover:
- Storage. A restricted app stores files like any other app, against its workspace's storage quota. A claimed app's files count toward your workspace from the moment you claim it.
- The app's own data and outbound
fetch, under the normal limits. - Identity. A restricted app that you share still learns who its signed-in visitors are, like any app.
Platform operators
The person who runs a fuast platform can turn on an operator API to administer it without shell access to the server. It is off unless the platform sets FUAST_OPERATOR_TOKEN, and the routes do not exist until then.
- What it can do. List apps, accounts, and workspaces across the platform, delete an app (the same deletion its owner can do, and only after repeating the slug), and mark an account's email as verified when the verification email did not arrive. For unclaimed previews: list them, read abuse reports, and put a suspended one back.
- What it cannot do. Sign in as someone, mint a token for an account, or read app data, stored files, logs, env values, or secrets. There are no routes for any of that.
- How it is reached. Only with
Authorization: Bearer <token>on the platform's own origin. It does not answer on an app subdomain or a custom domain, ignores cookies and query strings, sends no CORS headers, and locks an address out after repeated wrong tokens. - What is recorded. Every operator action goes to an audit log with the action, target, time, and address. The token is never logged, and it is removed from the request if someone sends it to an app by mistake, so endpoint code never sees it.
If you run your own platform, see operate a platform.
Reporting a vulnerability
Email support@fuast.com with "security" in the subject. Include the app or route, the steps, and what you could read or change. Please do not test against apps you do not own.