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

MechanismWhere it's setWhat it opensrequest.caller your handler sees
api.noAuthapp.yamlOnly 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>/* routeanonymous for visitors, guardian for the signed-in owner — see Owner-only routes
Cloud email access (publicAccess.cloudEmailAccess)Per-app email allowlist in public-access settingsThe 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.requestAction reads 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 (default true) — pass false for 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:

APIPurpose
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-For header. The edge proxy stamps X-Forwarded-For on 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 fetch from your handler to /api/auth/session is itself an in-container loopback call — it reports every caller as the guardian, turning your gate into a hole. Use request.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 401

Then 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.

On this page