# How the customer portal is built

> The parts behind a customer portal, what each of them knows and what it never sees, and the path of one request from the visitor's browser to the module service and back.

A holiday-home agency runs its booking site on nexxus. Guests search on a map, open a house, see the free dates and the price for their stay, and send a request that lands in the agency's inbox. Neither nexxus nor the portal app contains any code about holiday homes. What a listing is, which of its fields a guest may see and how a price is worked out all belong to the agency's package. Another package publishes its own records the same way, without a change to nexxus.

<Screenshot src={catalog} url="/" alt="A booking site with holiday homes as cards on the left and their locations on a map on the right" caption="The demo agency's booking site. Pages, fields, filters and the map come from the package's manifest." />

This page explains the parts and how they work together. How to write the `portal` section of a manifest is on [Customer portal](/modules/portal/).

## Four parts, one job each

| Part | What it does | What it never does |
|---|---|---|
| **nexxus** | Finds the tenant by the web address, keeps the records, decides what is public, serves images and the map, passes endpoint calls on to the service | Run code of a package. Let anyone sign in on a portal address |
| **The package** | Declares in its manifest the entities, the public fields and settings, the pages and the endpoints | Bring a web server, or code that runs inside nexxus |
| **The module service** | Works out what needs logic: free dates, a price, a booking request, a message to the host | Keep data or credentials of its own. It reads and writes through the record API of nexxus, with a token that expires within minutes |
| **The portal app** | Renders the pages the package declares, first on the server, then in the browser | Know about an industry. Store anything on the visitor's device |

The same records serve two audiences. The agency's staff work with them in nexxus, in the package's lists, calendars and map views, with their rights and the AI Assistant. Guests see the public part of them in the portal. There is no copy and no export in between: a house the agency switches to inactive leaves the site within a minute, because answers of the portal API may be kept for 60 seconds.

## One request, end to end

A guest opens a listing page and chooses dates:

<Sequence
  actors={[
    { name: 'Browser', icon: 'monitor' },
    { name: 'Portal app', icon: 'layout-template' },
    { name: 'nexxus', icon: 'server' },
    { name: 'Module service', icon: 'server-cog' },
  ]}
  steps={[
    { from: 0, to: 1, label: 'GET /stays/villa-silvertree' },
    { from: 1, to: 2, label: 'Site, page definition and the listing, with the visitor\'s host' },
    { from: 2, to: 2, label: 'Host to tenant, public record, public fields only' },
    { from: 1, to: 0, label: 'The finished page' },
    { from: 0, to: 2, label: 'GET /public/portal/<module>/endpoints/quote?record=12&check_in=...' },
    { from: 2, to: 2, label: 'Rate limit, declared parameters, record is public' },
    { from: 2, to: 3, label: 'POST /portal/quote with an invocation token (60 s)' },
    { from: 3, to: 2, label: 'Exchange the token, read the records the price needs' },
    { from: 3, to: 2, label: 'The quote as JSON' },
    { from: 2, to: 0, label: 'The quote, refusals cleaned up' },
  ]}
  caption="The browser talks to the portal app and to nexxus, never to the module service. The service needs no public address."
/>

- **The first answer is a finished page.** The portal app asks nexxus for the site, the page definition and the record, and sends the visitor's host along (`X-Forwarded-Host`), because nexxus picks the tenant by the host. Search engines read the page as it is. In the browser the app takes over without asking for the same data again.
- **Everything after that** goes from the browser to `/public/portal/...` on the same address: other pages, the next page of results, the map area, prices and forms.
- **Endpoints run through nexxus.** nexxus checks the call before the service sees it and checks the service's answer before the visitor sees it. A service that is down or takes longer than 10 seconds becomes `503` for the visitor, with no text of the service in it.

## One address, one tenant

A tenant's domains have a role. Its own domains carry the app and the sign-in; its portal domains carry the portal and nothing else. A superadmin sets both under <MenuPath to="Administration > Company > Tenants" />.

- **The tenant comes from the host alone**, never from a parameter, a cookie or a header that the reverse proxy did not set. nexxus takes `X-Forwarded-Host` only from proxies on private addresses.
- **A portal host answers two paths:** the portal API under `/public/portal/` and the map under `/public/map/`. The sign-in, the API of the app, the live channel and everything else answer `404` there, as if they did not exist. On every other host the portal API answers `404`.
- **The portal app renders pages only for the hosts it is configured for,** so a forged `Host` header cannot make it fetch data from another address.
- **Pages on other sites cannot read the portal API** in a browser: it allows cross-origin calls only from the tenant's own portal hosts, and never with cookies.

## What leaves nexxus, and who checks it

The portal only ever gets what the package declared as public, and nexxus checks that at four points.

1. **At upload.** The package check refuses a portal that publishes a contact, a person, a JSON field or a field that does not exist, filters on a field that is not indexed, or names a page, endpoint or service that is missing. See [Rules the package check enforces](/modules/portal/#rules-the-package-check-enforces).
2. **At install.** The administrator consents to `portal:public`, shown as one sentence that nexxus builds from the manifest: which records, which fields, that images and a rounded location go along, which service visitors can call. An update that publishes more asks again.
3. **On every answer.** nexxus returns only the records the entity's public filter selects and only the fields of `public.<entity>.fields`. It drops non-publishable types again, even for a manifest stored before a rule existed. It rounds a location to two decimals (about 1 km) unless the manifest says `exact`. Images get an address signed for tenant, installation, record and document; each request checks again that the record is public and still names the image, and only JPEG, PNG, WebP, GIF and AVIF go out.
4. **On every endpoint call.** nexxus counts the call against the rate limit first, then passes on only the declared parameters (at most 200 characters each), checks that `record` is a public record, refuses a body over 32 KB and checks the form field by field. A filled honeypot field gets a friendly answer and goes nowhere. From the service's answer the visitor gets at most 64 KB of JSON, or a refusal with its texts shortened to plain text.

The module service checks everything again, because the call comes from the internet.

## The module service holds nothing

The service is a separate process. nexxus calls it with an invocation token whose trigger is `portal:<endpoint>`, so a token for the price cannot send a booking request. A portal call has no person behind it: when the service calls back, it acts as the installation's service account, with the scopes the administrator consented to. How the tokens work is on [Module services](/modules/services/#the-tokens).

What the service writes appears in nexxus like any other record. In the holiday-home package a booking request becomes a guest (found by e-mail address, otherwise new), a reservation with the status inquiry and the guest's message in the agency's inbox. By default the service drafts a reply from the listing's own fields and holds back Wi-Fi and arrival details until the stay is booked. Staff read the draft, change it if needed and approve it.

## The map comes from nexxus

The map in the portal and the map views in nexxus use the same component and the same source: one [PMTiles](https://docs.protomaps.com/pmtiles/) archive with OpenStreetMap data, plus its fonts and symbols, served by nexxus under `/public/map/`. The browser reads the archive in byte ranges, so nexxus needs no tile server. No request goes from the visitor's browser to a map provider.

The map belongs to the installation, not to a tenant. An archive can hold the world at low zoom levels and the regions an installation serves in full detail; outside those regions the map stops zooming in instead of showing empty tiles.

Address search and `geocodeFrom` of package fields run on the server only. By default they ask the public Nominatim server of the OpenStreetMap Foundation, at most once a second as its usage policy asks, and keep each answer for 180 days (an address without a hit for 7 days).

## Nothing on the visitor's device

The portal app sets no cookies and uses no local storage, session storage or IndexedDB. Pages, data, images, map tiles and fonts all come from the portal's own address. <Ui>Search as I move the map</Ui> is held in memory for the visit. That is why a site built on it needs no cookie banner. Keep it that way when you build on the portal, and say so in the site's privacy notice, as the holiday-home package does.

## Running a portal

:::note[For self-hosters]
The portal app is its own server (Angular with server-side rendering on Node), one image for every tenant, configured at start:

| Variable | Purpose |
|---|---|
| `PORTAL_BACKEND_URL` | Where the server reaches nexxus, e.g. inside the cluster. When empty, it uses the visitor's own address, and only for the hosts in `NG_ALLOWED_HOSTS` |
| `NG_ALLOWED_HOSTS` | The hosts the app renders pages for, comma separated, `*.example.com` allowed. Every portal domain must be in it |
| `PORTAL_API_BASE` | Where browsers reach the portal API. Empty means the page's own address |
| `PORT` | Port of the server, `4000` by default. `GET /healthz` answers `ok` |

A portal address needs two routes at the reverse proxy: `/public` to nexxus, everything else to the portal app. The app answers `/robots.txt` and `/sitemap.xml` itself. A new portal address is a portal domain of the tenant, an entry in `NG_ALLOWED_HOSTS`, a route and a certificate.

The map needs a basemap file on the nexxus server: `nexxus.map.basemap` (the `.pmtiles` file) and `nexxus.map.assets` (the folder with `fonts/` and `sprites/`). Without them `/public/map/config` answers `available: false`. `nexxus.map.geocoder.provider` takes `nominatim` or `none`, and `nexxus.map.geocoder.base-url` points to your own Nominatim.

nexxus counts the rate limits of the endpoints in memory, per server process, and starts them over after a restart. They stop one visitor who sends too much. For protection beyond that, put a rate limit on the reverse proxy in front.
:::

## Next steps

<CardGrid>
  <LinkCard title="Customer portal" href="/modules/portal/" description="Write the portal section of a manifest: public data, pages, sections and endpoints." />
  <LinkCard title="Module services" href="/modules/services/" description="Build the service behind the endpoints, with the tokens and the record API." />
  <LinkCard title="Package structure" href="/modules/structure/" description="Entities, fields of type geo and the map view for staff." />
</CardGrid>