# Public Access & Caller Identity
URL: /docs/building-apps/public-access
Markdown: /docs/building-apps/public-access.md
Description: Open your app to anonymous visitors, know who is calling your API, and keep owner-only routes private.

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 [#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](#owner-only-routes-inside-a-public-app) |
| 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](/docs/building-apps/capabilities/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 [#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` [#caller-identity-requestcaller]

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:

```ts
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 [#owner-only-routes-inside-a-public-app]

Gate the owner-only routes on `request.caller` before any of them match:

```ts
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:

```tsx
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` [#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:

```ts
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](/docs/building-apps/capabilities/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 [#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.                                                                                                         |

```tsx
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 [#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 [#verifying-your-access-model]

Before calling a public app done, probe it from outside with no cookies:

```sh
# 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.