Public Access & Caller Identity
Open your app to anonymous visitors, know who is calling your API, and keep owner-only routes private.
Agent-readable version: Markdown · llms.txt
By default a Rome instance is single-user: every page and every API route
requires the guardian's dashboard session. Apps that friends or the public
should use — shared quizzes, submission pages, games, booking forms — need
some surface opened up. Rome has three distinct mechanisms for that, and they
have very different security consequences. This page explains all three, and
how to keep owner-only routes private inside an otherwise public app with
request.caller.
The three access tiers
| Mechanism | Where it's set | What it opens | request.caller your handler sees |
|---|---|---|---|
api.noAuth | app.yaml | Only the listed subpaths, and only on the public /api/app-api/<appId>/... surface | { kind: "anonymous" } — treat the request as untrusted input |
Public app (publicAccess.allowedApps) | "Public" switch on the Apps page (persisted via PUT /api/public-access) | The app's page (/apps/<appId>) and its entire HTTP surface — every /api/apps/<appId>/*, /api/app-api/<appId>/*, and /app-assets/<appId>/* route | anonymous for visitors, guardian for the signed-in owner — see Owner-only routes |
Cloud email access (publicAccess.cloudEmailAccess) | Per-app email allowlist in public-access settings | The app for visitors who sign in with a Rome Cloud account on the allowlist | { kind: "visitor", accountId, email }, resolved server-side |
api.noAuth is covered in HTTP API → Public (noAuth)
endpoints.
It is meant for webhooks and machine callbacks: narrow paths, no UI.
Making the app public is the mechanism for human visitors. When the
guardian flips the app to public, anonymous visitors can open
/apps/<appId> in a browser and the app's web bundle loads without a login.
The visitors' fetchAppApi calls work because the edge gate lets the whole
app API surface through.
Cloud email access sits in between: the app is not anonymous-public, but named visitors can sign in with their Rome Cloud account and use it.
What "public" really means
The instance's edge proxy authorizes every request with a forward-auth probe.
For an app in allowedApps, the probe answers "allow" for any path under
that app — it does not know which of your routes are meant for visitors and
which are meant for the owner.
Watch out: flipping an app to public does not just expose the routes your UI happens to call. It removes the session gate from every route the app serves, on both API surfaces. If your handler has an owner-only route (history, settings, exports, moderation), the platform is no longer gating it at the edge — your handler decides, using
request.caller.
Caller identity: request.caller
Every request your handler receives carries a caller field, resolved by the
host before the request reaches your code — from the actual session
cookies and connection, never from anything the client claims about itself:
request.caller:
| { kind: "guardian"; userId: string; via: "cookie" | "loopback" }
| { kind: "visitor"; accountId: string; email: string }
| { kind: "anonymous" }guardian— the instance owner. Either a valid dashboard session (via: "cookie") or a trusted in-container caller such as the agent or the agent browser (via: "loopback").visitor— a verified Rome Cloud visitor from the cloud-email tier, resolved server-side from the visitor session. The session itself (including its favor token) stays host-side:ctx.favors.requestActionreads it from the request context automatically, so your handler never touches it.anonymous— everyone else. On a public app, that is any internet caller.
Do not derive identity from request headers. The host strips identity
headers (X-Rome-User-Id, X-Rome-Visitor-*) before your handler runs, so
there is nothing to read — and on a public app any identity header that did
survive would be attacker-controlled, since the edge waves public-app requests
through without touching them. request.caller is the only answer to trust.
Owner-only routes inside a public app
Gate the owner-only routes on request.caller before any of them match:
import type { RomeAppApiRequest } from "@rome-os/app-runtime";
async handle(request: RomeAppApiRequest): Promise<Response> {
const route = request.path.join("/");
if (route.startsWith("admin/") && request.caller.kind !== "guardian") {
return Response.json({ error: "unauthorized" }, { status: 401 });
}
// public routes ... admin routes ...
}On the client, the guardian and visitors share the same page. Use
useCaller() from @rome-os/app-web-sdk to render the owner section only for
the guardian:
import { useCaller } from "@rome-os/app-web-sdk";
const isOwner = useCaller()?.kind === "guardian";
// {isOwner && <AdminPanel />}useCaller() is advisory, for UI gating only — any client can run
arbitrary code, so hiding the panel protects nothing by itself. The server-side
request.caller check on the route is the enforcement; the hook just keeps
visitors from seeing UI that would only answer 401.
For per-visitor privacy between visitors (each submitter sees only their own
record), unguessable per-record ids (crypto.randomUUID()) held in client
state are usually enough — the id is the capability.
Visitor-only routes with requireVisitor
For routes that need a signed-in caller (a visitor or the owner) rather
than an owner-only gate, don't hand-roll the check — @rome-os/app-runtime
ships it:
import { requireVisitor } from "@rome-os/app-runtime";
async handle(request: RomeAppApiRequest): Promise<Response> {
const auth = requireVisitor(request);
if (!auth.ok) return auth.response; // standardized 401
// auth.caller is guardian | visitor from here on
}requireVisitor(request, opts?) returns a discriminated union:
{ ok: true, caller } for a visitor (and, by default, the guardian), or
{ ok: false, response } where response is a standardized
401 { error: "visitor_auth_required", message } body. Options:
allowGuardian(defaulttrue) — passfalsefor routes that only make sense for true visitors.message— override the human-readable hint in the 401 body.
The 401 body shape is a wire contract with the frontend: the web SDK's
sign-in UI (below) matches on error === "visitor_auth_required" to show a
sign-in prompt instead of a generic failure. visitorAuthRequired(message?)
builds the same response on its own — use it for the late failure path:
ctx.favors.requestAction returns
{ status: "error", error: "visitor_auth_required" } when the caller resolved
but holds no visitor session, and piping that through visitorAuthRequired()
keeps the frontend's 401 handling uniform. See
Favor Request Actions.
This is enforcement — it reads the host-resolved request.caller, never
headers. The client-side useCaller() remains advisory UI gating only.
Visitor sign-in UI
The web SDK ships the sign-in flow and the standard UI for it, painted only with host-theme tokens so it looks native in every theme:
| API | Purpose |
|---|---|
beginVisitorLogin(next?) | Starts the Rome Cloud sign-in redirect. After the visitor approves, they land back on next (default: the current location) with the visitor session set on the instance origin. Throws visitor_login_unavailable when the flow can't start (private app, preview mount). |
useVisitorSignIn() | Headless hook: { caller, signingIn, error, errorMessage, signIn }. Wraps beginVisitorLogin with pending/error state; build custom UI on top of it. |
<SignInWithRomeCloud /> | The standard "Sign in with Rome Cloud" button (solid/outline, two sizes). Renders nothing when the caller is already a visitor or the guardian, so drop it in unconditionally. Disabled with a tooltip on preview mounts. |
<CallerBadge /> | The full "who am I" strip: signed-in visitor pill, quiet owner badge, or hint + sign-in button for anonymous callers, plus an inline alert when sign-in can't start. |
import { CallerBadge, SignInWithRomeCloud } from "@rome-os/app-web-sdk";
// under the app title — handles visitor / guardian / anonymous states
<CallerBadge className="mt-2" />
// or just the button, e.g. next to a favor-charged control
<SignInWithRomeCloud variant="outline" size="sm" />Signing in on romeos.cc alone is not enough — the session cookie must be
established on the instance origin through this flow. The canonical need is
favor request actions: ctx.favors.requestAction fails with
visitor_auth_required until the visitor has a session on this instance.
How the host resolves the caller
You never need to reimplement this, but it helps to know what caller means:
- Guardian via cookie — the request carries a valid guardian dashboard session cookie, verified server-side.
- Guardian via loopback — the request came from inside the instance's
container (the agent, a local script, the agent browser): its TCP peer is a
loopback address and it carries no
X-Forwarded-Forheader. The edge proxy stampsX-Forwarded-Foron every proxied request and a client can only add the header (which demotes it), never make the proxy strip it — so this combination cannot be forged from outside. In-container callers are already trusted: the container is the security boundary and they have unrestricted API access anyway. - Visitor — the request carries a valid Rome Cloud visitor session cookie (established via the visitor sign-in flow), verified server-side.
- Anonymous — none of the above. A guardian who also holds a visitor
session resolves as
guardian.
Watch out: don't hand-roll an identity check by calling the host's auth endpoints from your handler. In particular, a
fetchfrom your handler to/api/auth/sessionis itself an in-container loopback call — it reports every caller as the guardian, turning your gate into a hole. Userequest.caller.
Verifying your access model
Before calling a public app done, probe it from outside with no cookies:
# Public routes: reachable anonymously
curl -i https://<instance>/api/apps/<appId>/public/state # expect 200
# Owner-only routes: rejected anonymously…
curl -i https://<instance>/api/apps/<appId>/admin/history # expect 401
# …and not fooled by identity or proxy headers
curl -i -H "X-Rome-User-Id: guardian" \
https://<instance>/api/apps/<appId>/admin/history # expect 401
curl -i -H "X-Forwarded-For: 127.0.0.1" \
https://<instance>/api/apps/<appId>/admin/history # expect 401Then load /apps/<appId> in a private browser window: the page should render,
the visitor flow should work, and the owner-only UI should not appear.