> ## Documentation Index
> Fetch the complete documentation index at: https://docs.neobank.proofof.tech/llms.txt
> Use this file to discover all available pages before exploring further.

# Perflo hosted origins

> Live mode pins the five reviewed Perflo service origins and validates hosted app URLs at every browser boundary.

# Perflo hosted origins

Live mode pins the reviewed app, Perfolio, fiat, cards, and agent origins in the API.
The hosted application origin, `https://app.perflo.ai`, is also shared by the browser URL
guard and edge security headers. A launching bank cannot point the deployment at another
Perflo tenant through configuration alone: startup validation rejects every unreviewed
service origin.

<Warning title="Do not change these origins unilaterally">
  Changing any reviewed origin without coordinating with Perflo breaks connection
  approval, hosted KYC, or hosted card reveal. The dashboard frames only connection
  approval. Hosted KYC and card detail reveal each open in a new tab with the opener
  severed, so Perflo's own origin governs the camera and the hosted page cannot script the
  banking tab. The connection iframe requires the Content-Security-Policy `frame-src`
  directive and the WebAuthn `Permissions-Policy` origins to match the origin Perflo
  actually serves. Every hosted flow that leaves the dashboard — a top-level navigation or
  a new tab, which is itself a top-level browsing context — depends on the same
  exact-origin validation, but not on `frame-src` or delegated WebAuthn permissions.
</Warning>

This page documents the current state. Parameterizing the origins across deployments is
future work — see [Adapters](architecture/adapters).

## Where the origin is pinned

### 1. The startup security boundary

`apps/api/src/neobank/config.py` defines the canonical origin as a module constant, and
the settings validator rejects any deployment that configures a different one:

```python apps/api/src/neobank/config.py theme={null}
PERFLO_APP_ORIGIN = "https://app.perflo.ai"
```

The settings validator compares all five configured origins with the reviewed constants for
every live workload, including the scheduler:

```python apps/api/src/neobank/config.py theme={null}
expected_urls = {
    "app": PERFLO_APP_ORIGIN,
    "perfolio": PERFLO_PERFOLIO_ORIGIN,
    "fiat": PERFLO_FIAT_ORIGIN,
    "cards": PERFLO_CARDS_ORIGIN,
    "agent": PERFLO_AGENT_ORIGIN,
}
if actual_urls != expected_urls:
    raise ValueError("the live adapter requires the reviewed Perflo origins")
```

Every live process refuses to start if any configured provider origin differs. This guard
prevents provider credentials from being sent to an unreviewed host.

### 2. The edge security headers

`apps/web/security-headers.conf` is the Caddy/nginx header layer served in front of the
web app. Two directives name `app.perflo.ai` explicitly:

```apps/web/security-headers.conf theme={null}
add_header Permissions-Policy "camera=(), microphone=(), geolocation=(), publickey-credentials-create=(self \"https://app.perflo.ai\"), publickey-credentials-get=(self \"https://app.perflo.ai\")" always;
add_header Content-Security-Policy "default-src 'self'; ... frame-src https://app.perflo.ai; frame-ancestors 'none'; ..." always;
```

* `Content-Security-Policy` `frame-src https://app.perflo.ai` — the only origin allowed to
  be framed by the customer app. The connection-approval iframe depends on this; hosted
  KYC and card reveal do not run in a frame.
* `Permissions-Policy` `publickey-credentials-create` and `publickey-credentials-get` —
  allow the framed Perflo page to perform WebAuthn ceremonies (passkey enrollment and
  assertion) on the bank's origin. Without both entries, the hosted Perflo WebAuthn UI
  cannot run inside the iframe.

### 3. The shared browser URL guard

`apps/web/src/api/client.ts` defines the browser constant and one exact-origin validator.
The connection dialog, KYC navigation, approval tracker, and card reveal flow reuse it:

```ts apps/web/src/api/client.ts theme={null}
export const PERFLO_APP_ORIGIN = "https://app.perflo.ai";

export function perfloAppUrl(value: string, errorMessage: string): string {
  let url: URL;
  try {
    url = new URL(value);
  } catch {
    throw new Error(errorMessage);
  }
  if (url.origin !== PERFLO_APP_ORIGIN) throw new Error(errorMessage);
  return url.href;
}
```

The iframe's `allow` attribute uses the same constant to delegate the two WebAuthn
permissions to the framed origin:

```tsx apps/web/src/routes/dashboard.tsx theme={null}
<iframe
  src={url}
  title="Perflo account login and approval"
  allow={`publickey-credentials-create ${PERFLO_APP_ORIGIN}; publickey-credentials-get ${PERFLO_APP_ORIGIN}`}
  sandbox="allow-forms allow-same-origin allow-scripts"
  referrerPolicy="no-referrer"
/>
```

### 4. Hosted card reveal

Card detail reveal returns a hosted Perflo URL. One shared predicate decides whether a
provider-minted URL is on the trusted origin; `perflo_app_url` is itself pinned to
`PERFLO_APP_ORIGIN` by the startup validator:

```python apps/api/src/neobank/hosted_urls.py theme={null}
def is_trusted_perflo_hosted_url(url: object, perflo_app_url: str) -> bool:
    ...
    return not (
        hosted is None
        or hosted.scheme != "https"
        or hosted.hostname != trusted.hostname
        or (hosted_port or 443) != (trusted_port or 443)
        or hosted.username is not None
        or hosted.password is not None
    )
```

The API refuses to return an untrusted URL to the client:

```python apps/api/src/neobank/api.py theme={null}
def _validate_perflo_hosted_url(url: object, perflo_app_url: str, detail: str) -> None:
    if not is_trusted_perflo_hosted_url(url, perflo_app_url):
        raise NeobankError(502, "perflo_response_invalid", "Provider response invalid", detail)
```

The same validator guards the connection action URL, the live adapter uses the same
predicate to replace an untrusted minted reveal URL with the trusted Perflo cards page
(PFR-013), and the worker applies it to mandate approval URLs. All surfaces use the
trusted Perflo origin, but the KYC and card reveal pages each open in a new tab whose
`opener` is nulled before navigation. The CSP `frame-src` and WebAuthn
`Permissions-Policy` entries govern only the connection iframe.

## Why the origin is pinned

Perflo runs the money rail. Hosted KYC, hosted connection approval, and hosted card
reveal are Perflo-controlled surfaces. The bank frames connection approval and opens KYC
and card reveal in new tabs — top-level browsing contexts on Perflo's own origin, opened
with `opener` severed so the hosted page cannot script the banking tab. Three guarantees
must hold simultaneously for the connection frame to work and remain constrained:

1. The framed origin is the one Perflo reviewed and serves (`PERFLO_APP_ORIGIN`).
2. The CSP allows that exact origin to be framed (`frame-src`).
3. The `Permissions-Policy` allows that exact origin to run WebAuthn
   (`publickey-credentials-create`, `publickey-credentials-get`).

If any one of these points at a different origin than the others, either the connection
frame or its WebAuthn ceremony is blocked. The top-level flows still require the API's
hosted-URL validation to agree with the same trusted origin. Coordinating a change across
the API constants, shared browser guard, edge policies, and Perflo's own hosting is the
only supported path.

## Related

* [Branding & theming](customize/branding) — the brand identity is editable; the Perflo origin is not.
* [Adapters](architecture/adapters) — adapter selection and the provider origin boundary.
