# Customer portal

> A package can publish some of its records on the web, for the customers of the firm that installed it. How the portal section of the manifest works, what nexxus checks, and what a module service answers.

Some packages serve people who never sign in: the guests of a holiday-home agency look for a house, check the dates and send a booking request. The **customer portal** is that public side. The package declares in its manifest which records and fields are public and which pages show them; a separate portal app draws the pages on the firm's own web address. The package brings no web server and no code of its own for this. The only code is in its [module service](/modules/services/), for what has to be computed: free dates, a price, a booking request.

## How it fits together

<Flow
  steps={[
    { icon: 'globe', title: 'Portal domain', text: 'The firm\'s own address, e.g. stay.agency.example, set up as a portal domain of the tenant.' },
    { icon: 'layout-template', title: 'Portal app', text: 'Renders the pages the package declares, on the server, so search engines read them.', tone: 'cyan' },
    { icon: 'shield-check', title: 'Public API of nexxus', text: 'Hands out only the public fields of the public records, for the tenant of that address.', tone: 'lavender' },
    { icon: 'server-cog', title: 'Module service', text: 'Answers what has to be computed: availability, a price, a booking request.' },
  ]}
  caption="nexxus stands between the visitor and the package: every answer is filtered or checked on the server."
/>

### Portal domains

A tenant's domain has a role. Its own domains (<Ui>Own domains</Ui>) carry the app and its sign-in. Its **portal domains** (<Ui>Portal domains</Ui>) carry only the portal: on such an address nobody can sign in, and the administration, its API and its live channel answer `404` as if they did not exist. A superadmin sets both under <MenuPath to="Administration > Company > Tenants" />. Links that nexxus writes for staff always point to the own domains.

Each answer of the portal API belongs to the tenant of the address it was called on. A page on `stay.agency.example` cannot read another tenant's records.

One package with a portal per tenant: if a tenant installs two, the one installed first is the site.

## The portal section of the manifest

The example is the holiday-home package: guests browse listings, open one and send a request.

```json
"portal": {
  "name": { "en": "Stays", "de": "Unterkünfte" },
  "siteNameSetting": "company_name",
  "public": {
    "listing": {
      "filter": { "status": "active" },
      "fields": ["name", "kind", "region", "city", "description", "bedrooms", "max_guests",
                 "amenities", "base_price", "photos", "location"],
      "geo": { "field": "location", "precision": "approximate" }
    }
  },
  "pages": [
    { "key": "stays", "type": "catalog", "entity": "listing", "path": "",
      "title": { "en": "Holiday homes", "de": "Ferienhäuser" },
      "card": { "titleField": "name", "subtitleFields": ["city", "region"], "imageField": "photos",
                "facts": ["bedrooms", "max_guests"], "priceField": "base_price",
                "priceSuffix": { "en": "per night", "de": "pro Nacht" } },
      "filters": [ { "field": "region" }, { "field": "kind" },
                   { "field": "max_guests", "op": "gte", "label": { "en": "Guests", "de": "Gäste" } } ],
      "dates": { "quote": "quote" }, "map": true, "detail": "stay" },
    { "key": "stay", "type": "detail", "entity": "listing", "path": "stays/:slug", "slugField": "name",
      "title": { "en": "Stay", "de": "Unterkunft" },
      "sections": [
        { "type": "gallery", "field": "photos" },
        { "type": "facts", "fields": ["kind", "bedrooms", "max_guests"] },
        { "type": "text", "field": "description" },
        { "type": "chips", "field": "amenities" },
        { "type": "map", "field": "location" },
        { "type": "availability", "endpoint": "availability" },
        { "type": "booking", "quote": "quote", "request": "booking_request" } ] }
  ],
  "endpoints": [
    { "key": "availability", "method": "GET", "service": "concierge", "params": ["record", "from", "to"] },
    { "key": "quote", "method": "GET", "service": "concierge", "params": ["record", "check_in", "check_out", "guests"] },
    { "key": "booking_request", "method": "POST", "service": "concierge", "params": ["record"],
      "form": [ { "key": "name", "type": "string", "required": true },
                { "key": "email", "type": "email", "required": true },
                { "key": "guests", "type": "number", "required": true },
                { "key": "check_in", "type": "date", "required": true },
                { "key": "check_out", "type": "date", "required": true },
                { "key": "message", "type": "text" },
                { "key": "consent", "type": "consent", "required": true,
                  "label": { "en": "I agree that my details are used to answer my request.",
                             "de": "Ich bin einverstanden, dass meine Angaben zur Beantwortung meiner Anfrage verwendet werden." } } ],
      "rateLimit": { "perIpPerHour": 10 } }
  ]
}
```

The pages around a listing come from the same package. The detail page gets three more sections, and four pages join it: one to message the host, a contact page and two legal pages. Shortened from `higgins.rentals` 1.2.1, whose listings also publish `house_rules`, `checkin_time`, `checkout_time`, `min_nights` and `cleaning_fee` for the FAQ:

```jsonc
"portal": {
  "public": {
    // Settings the site may show: footer, contact card, host and legal pages
    "settings": ["company_name", "company_address", "portal_phone", "portal_email", "portal_hours",
                 "portal_host_intro", "portal_hosting_since", "agency_languages",
                 "cancellation_policy", "privacy_text", "legal_text"],
    "listing": { /* as above */ },
    // The reviews: a second public entity, with a display name, never the guest's name
    "review": { "fields": ["display_name", "rating", "received_at", "text"] }
  },
  "pages": [
    { "key": "stay", "type": "detail", /* as above */
      "sections": [
        /* the sections above, with amenities instead of chips */
        { "type": "amenities", "field": "amenities" },
        { "type": "reviews", "entity": "review", "via": "listing",
          "fields": ["display_name", "rating", "received_at", "text"],
          "nameField": "display_name", "dateField": "received_at",
          "ratingField": "rating", "textField": "text" },
        { "type": "host", "setting": "portal_host_intro",
          "fields": ["portal_hosting_since", "agency_languages"], "page": "inquiry" } ] },
    { "key": "inquiry", "type": "inquiry", "entity": "listing", "path": "stays/:slug/contact",
      "title": { "en": "Message the host", "de": "Gastgeber kontaktieren" },
      "sections": [
        { "type": "faq", "amenities": "amenities", "groups": [
          { "title": { "en": "Arrival and departure", "de": "Anreise und Abreise" },
            "items": [ { "field": "checkin_time" }, { "field": "checkout_time" },
                       { "amenity": "parking",
                         "text": { "en": "Parking at the house", "de": "Parkplatz am Haus" } } ] },
          { "title": { "en": "Price and availability", "de": "Preis und Verfügbarkeit" },
            "items": [ { "field": "min_nights" }, { "field": "cleaning_fee" },
                       { "setting": "cancellation_policy" } ] } ] },
        { "type": "form", "endpoint": "contact" } ] },
    { "key": "contact", "type": "contact", "path": "contact", "nav": true,
      "title": { "en": "Contact", "de": "Kontakt" },
      "sections": [
        { "type": "contact",
          "fields": ["company_address", "portal_phone", "portal_email", "portal_hours"] },
        { "type": "form", "endpoint": "contact" } ] },
    { "key": "privacy", "type": "info", "path": "privacy", "footer": true,
      "title": { "en": "Privacy notice", "de": "Datenschutz" }, "setting": "privacy_text" },
    { "key": "legal", "type": "info", "path": "legal", "footer": true,
      "title": { "en": "Legal notice", "de": "Impressum" }, "setting": "legal_text" }
  ],
  "endpoints": [
    // For both forms; only the inquiry page sends a listing as record
    { "key": "contact", "method": "POST", "service": "concierge", "params": ["record"],
      "form": [ { "key": "name", "type": "string", "required": true },
                { "key": "email", "type": "email", "required": true },
                { "key": "message", "type": "text", "required": true, "maxLength": 2000 },
                { "key": "consent", "type": "consent", "required": true,
                  "label": { "en": "I agree that my details are used to answer my message.",
                             "de": "Ich bin einverstanden, dass meine Angaben zur Beantwortung meiner Nachricht verwendet werden." } } ],
      "rateLimit": { "perIpPerHour": 10 } }
  ]
}
```

### What is public

`public.<entity>` is the only data the portal ever gets from that entity.

| Key | Meaning |
|---|---|
| `filter` | Field and value (or a list of values) every public record has, e.g. only active listings. The fields must be `indexed`. A visitor's filter never lifts it |
| `fields` | The fields the portal receives, nothing else. Allowed are `string`, `text`, `number`, `boolean`, `date`, `datetime`, `enum`, `geo` and a `ref` to `core:dmsDocument` (images). A reference to a contact or a user, and `json`, are refused |
| `geo` | `field` and `precision`: `approximate` (the default for every public geo field) rounds the coordinates on the server to two decimals, about 1 km; `exact` passes them unchanged |

`public.settings` is a list of installation settings the site may show, such as the phone number, the address or the privacy notice. Only settings of type `string`, `text` or `enum` are allowed; every other setting of the installation stays private.

A `ref` to `core:dmsDocument` in `fields` makes those documents public images. The portal gets them as `{ "id", "url", "alt" }`, with `alt` from the document's description. The address is signed and bound to tenant, record and document; nexxus serves the image only while a public field of a public record still names it, and only JPEG, PNG, WebP, GIF or AVIF. With `?w=400`, `800`, `1200` or `1600` it serves the image scaled down to that width, for `srcset` on phones.

### Pages

| Key | Meaning |
|---|---|
| `key`, `title` | Name of the page and its heading |
| `type` | `catalog` (a list with cards, filters and an optional map), `detail` (one record), `inquiry` (a question about one record, with the slug of the detail page), `contact` (the agency's contact page, no record) or `info` (a text page such as the privacy notice, no record) |
| `entity` | An entity of `public`; not on `contact` and `info` pages |
| `path` | Below the site's root, unique; `""` is the start page. A `detail` or `inquiry` page contains `:slug` exactly once, the other types none |
| `nav`, `footer` | `true` puts a link to the page into the header or the footer |
| `setting` | `info` page: a public `string` or `text` setting, shown as paragraphs (a blank line starts a new one) |
| `slugField` | Detail page: a public `string` field the address is made of (`Ocean View Villa` becomes `ocean-view-villa`; a second record of the same name gets its id appended) |
| `card` | Catalog: `titleField`, `subtitleFields`, `imageField` (images; without it the card is text only), `facts`, `priceField` (a number), `priceSuffix`. Without `priceField` and `dates.quote` the card has no price line |
| `filters` | Catalog: public `indexed` fields; `op` `eq` (default), `gte` or `lte` (numbers and dates). Enum fields become chips; text fields with values to offer, a `gte` number (guests) and `dates` go into the search bar, which is left out when it has none of them |
| `dates.quote` | Catalog: a `GET` endpoint that prices a stay once dates are set |
| `map`, `detail` | Catalog: show a map (needs a public geo field); the key of the detail page. A marker carries the price of `card.priceField` when that field has a `currency`, and the quoted total once dates are set and the stay is free. A detail page leads back to the catalog of its own `entity` |
| `sections` | `detail` and `inquiry` pages: `gallery` (images), `facts`, `text`, `chips`, `amenities` (an enum field, shown with icons), `map` (geo), `availability` (a `GET` endpoint), `booking` (`quote`: `GET`, `request`: `POST`), `reviews`, `host`, `faq`. Only on `detail` pages: `slots` (`endpoint`: `GET`, `request`: `POST`). Any page: `contact` (public settings in `fields`) and `form` (a `POST` endpoint's form inline) |

Every field and setting a page names must be public.

The newer sections in more detail:

| Section | Keys |
|---|---|
| `amenities` | `field`: a public enum field. A value of it may carry `icon`, a short key such as `wifi` from the portal's icon set; without one the portal shows a neutral check mark |
| `reviews` | `entity` (another public entity), `via` (its reference to the page's entity), `fields` (public fields of that entity), and which of them is the name, date, rating and text: `nameField`, `dateField`, `ratingField`, `textField`. The portal shows the newest first, with the average rating. Publish a display name such as "Anna K.", never a guest's full name or e-mail address |
| `host` | `setting` (an introduction text), `fields` (more public settings, such as "hosting since"), `page` (the `inquiry` page that "Message the host" leads to; without it the button is left out), `endpoint` (accepted, but the portal app does not use it: the inquiry page brings its own `form`) |
| `faq` | `groups`, each with a `title` and `items`. An item is one of `{"field": ...}` (a public field of the record), `{"setting": ...}` (a public setting) or `{"amenity": ..., "text": {...}}` (a text shown when the record has that value of the enum field the section names in `amenities`); every item may carry its own `label` |
| `slots` | Free appointment times instead of a range of nights. `endpoint`: a `GET` endpoint with the params `record`, `from` and `to`, which lists the times. `request`: a `POST` endpoint with the params `record` and the `slotParam`, whose form the visitor fills in for the chosen time. `slotParam`: the name the chosen time is sent under, never `record`, `lang` or `website`. `days`: how far ahead a visitor can look, 7 to 90 days, 28 by default. `title` replaces the heading <Ui>Choose a time</Ui> |

A practice whose patients ask for an appointment declares `slots` on the treatment page. The package declares `"platform": ">=0.13.0 <1.0.0"`, the first platform version with this section:

```jsonc
"pages": [
  { "key": "treatment", "type": "detail", "entity": "treatment", "path": "treatments/:slug", "slugField": "name",
    "title": { "en": "Treatment", "de": "Behandlung" },
    "sections": [
      { "type": "gallery", "field": "photos" },
      { "type": "text", "field": "description" },
      { "type": "slots", "endpoint": "slots", "request": "appointment", "slotParam": "slot", "days": 28,
        "title": { "en": "Choose a time", "de": "Termin wählen" } } ] }
],
"endpoints": [
  { "key": "slots", "method": "GET", "service": "practice", "params": ["record", "from", "to"] },
  { "key": "appointment", "method": "POST", "service": "practice", "params": ["record", "slot"],
    "form": [ { "key": "name", "type": "string", "required": true },
              { "key": "email", "type": "email", "required": true },
              { "key": "consent", "type": "consent", "required": true,
                "label": { "en": "I agree that my details are used to arrange the appointment.",
                           "de": "Ich bin einverstanden, dass meine Angaben zur Vereinbarung des Termins verwendet werden." } } ],
    "rateLimit": { "perIpPerHour": 10 } }
]
```

A detail page with `slots` and without `booking` has no booking card beside it.

### Endpoints

An endpoint is something the portal may ask the package's service, through nexxus.

| Key | Meaning |
|---|---|
| `key` | Name, unique in the portal |
| `method` | `GET` or `POST` |
| `service` | A service of the package (`services`) |
| `params` | Query parameters that are passed on. `record` must name a public record of the entity of the pages that use the endpoint; nexxus checks that before it calls. `lang` is reserved |
| `form` | `POST` only. Fields with `type` `string` (up to 500 characters), `text` (up to 5000), `email`, `number`, `date` (`YYYY-MM-DD`), `consent` (must be ticked when `required`) or `boolean` (a box to tick, sent as `true` or `false`); `required`, `label`, `maxLength`. `website` is reserved |
| `rateLimit.perIpPerHour` | Calls per visitor address and hour, up to 10000; without it 2000 for `GET` (a catalog with dates asks one quote per card) and 20 for `POST` |

Before anything reaches the service, nexxus counts the call against the rate limit, drops every parameter that is not declared, checks the record, and checks the form: unknown fields, missing required ones, wrong types, an invalid e-mail address or an unticked consent are refused with the fields named. The portal app renders a hidden field `website` that people never see; a request that fills it gets a friendly answer and goes nowhere.

## What visitors see

The portal app renders every page on the server first, so search engines read the content, and then continues in the browser. It stores nothing on the visitor's device: no cookies, no local storage, no other browser storage. A site built on it needs no cookie banner. Say so in the privacy notice instead, as the holiday-home package does.

Every portal offers German and English, the site's default language first. It offers Polish as well when the tenant's language is Polish or the package brings Polish for its portal (a `pl` text in `portal.name`); a site that never chose Polish keeps its two languages. The visitor's language comes from `?lang=`, else from the browser (the first offered language of its Accept-Language header), else it is the site's default. The portal's own texts exist in all three. The package's texts appear in the visitor's language, else in the site's default language, else in English, so a package without Polish texts shows a Polish visitor the default language's ones.

### Catalog

The search bar holds the filters, the dates and the number of guests; enum filters become chips above the list. Once dates are set, every card shows the price of that stay or that it is not free.

With `map: true`, list and map sit side by side on wide screens, and the list follows the map: when the visitor drags or zooms it, the list shows only what lies in the visible area, 400 ms after the map comes to rest, with the count <Ui>8 results in this map area</Ui>. <Ui>Search as I move the map</Ui> switches that off for the visit; then a moved map offers <Ui>Search this area</Ui>. <Ui>Show all</Ui> brings every result back and fits the map to them. On phones the map is a view of its own (<Ui>Show map</Ui>); after moving it, <Ui>Search this area</Ui> applies the area. The area never becomes part of the address: a link someone shares and the page search engines see always show every result.

nexxus filters on the server. The records request takes `bbox=W,S,E,N` in degrees; a west edge greater than the east edge means the area crosses the date line. It compares the box with the same rounded location the markers show, so the list and the markers agree.

### Listing page

- <Ui>Share</Ui> next to the title opens the phone's share sheet; where there is none, it copies the link and says <Ui>Link copied</Ui>.
- The dates open <Ui>Select dates</Ui>, with check-in and check-out at the top. Past and booked days are struck through.
- `amenities`: under <Ui>What this place offers</Ui> the values with their icons, in two columns from tablet width. After ten, a button like <Ui>Show all 13 amenities</Ui> opens the full list.
- `reviews`: the heading shows the average rating and the number of reviews. Up to six reviews follow with name, month, stars and text; a long text stops after four lines with <Ui>Show more</Ui>. A button like <Ui>Show all 8 reviews</Ui> opens every review. A review without a display name appears as <Ui>Guest</Ui>; a listing without reviews has no reviews section.
- `host`: <Ui>Meet your host</Ui> shows the firm's symbol or logo (or its initial), its name, the settings of `fields` with their labels, the introduction and <Ui>Message the host</Ui>. That button opens the `inquiry` page and takes the dates and guests the visitor chose along.

<Screenshot src={reviewsHost} url="/stays/villa-salt-rock" alt="Three reviews with names, months, stars and texts, then Meet your host with the agency's logo, its facts and introduction" caption="The reviews and the host on a listing page. The booking card stays beside them." />

### Free times

A `slots` section shows the record's free times one week at a time, Monday to Sunday, starting with the current week and never with a time that has passed. <Ui>Previous week</Ui> and <Ui>Next week</Ui> page up to `days` ahead. On wide screens the week is seven columns; on phones it is a list of the days that have free times. A time carries its `label` under it, such as the dentist. A week without free times says <Ui>No free appointments this week</Ui> and offers the next one. The times are shown in the zone the service names; when that is not the visitor's own zone, a line under the week says which it is.

A chosen time opens <Ui>Your appointment</Ui> with the date and time and the form of `request`; <Ui>Choose another time</Ui> goes back and keeps what was typed. <Ui>Request appointment</Ui> sends it. After that the visitor sees <Ui>Appointment requested</Ui> with the reference the service returned. If someone else took the time in the meantime, the portal says <Ui>This time was just taken.</Ui> and loads the week again. On phones the bar at the bottom leads to the times.

### Inquiry page

The `inquiry` page (in the holiday-home package `stays/:slug/contact`) is headed <Ui>Contact Fynbos & Tide Holiday Homes</Ui> with the site's name, shows the host's facts and the `faq` groups under <Ui>Most guests ask about</Ui>, then the form under <Ui>Still have questions? Message the host</Ui>. Beside it a card shows the listing's photo and name with the dates and guests; <Ui>Check availability</Ui> goes back to the listing with them. After sending, the visitor sees the reference the service returned. The page is not in the sitemap, and search engines are asked not to index it.

<Screenshot src={inquiry} url="/stays/villa-salt-rock/contact" alt="Contact Fynbos & Tide Holiday Homes, with the host's facts and the questions most guests ask, beside a card with the listing's photo and dates" caption="The inquiry page. The form follows below the questions." />

### Contact page, info pages and footer

Pages with `nav: true` appear in the header; on phones they are in <Ui>Menu</Ui>. The `contact` page shows the firm's card from the `contact` section (an address, a phone number to call, an e-mail address to write to, office hours) and the form of the `form` section; after sending it says <Ui>Message sent</Ui> with the reference. An `info` page shows its setting's text as paragraphs; while that text is empty, the page answers `404` and its link disappears from the footer.

<Screenshot src={contact} url="/contact" alt="The contact page with the agency's card on the left and the message form on the right" caption="The contact page: the contact section on the left, the form section on the right." />

The footer is on every page: the site's name and its address, phone number and e-mail address, the links to the `footer: true` pages and the copyright line with the current year. The portal finds them among the public settings by their key (it contains `address`; `phone`, `tel` or `mobile`; `email` or `e-mail`) and, for phone and e-mail, checks that the value looks like one.

### Amenity icons

A value of an `amenities` field takes `"icon": "<key>"` in the manifest. The keys below are kept stable: new ones are added, none is renamed or removed. An unknown or missing key shows a check mark.

`accessible`, `aircon`, `baby`, `balcony`, `bath`, `bbq`, `bed`, `bike`, `boat`, `car`, `check`, `coffee`, `dishwasher`, `dryer`, `fireplace`, `fridge`, `garden`, `gym`, `heating`, `hot-tub`, `kitchen`, `mountain-view`, `microwave`, `no-smoking`, `parking`, `pets`, `ev-charger`, `pool`, `safe`, `sauna`, `sea-view`, `self-checkin`, `shower`, `terrace`, `tv`, `beach`, `washer`, `wifi`, `winter`, `workspace`

## What the service receives and answers

nexxus calls `POST <service url>/portal/<endpoint key>` with an invocation token whose `trigger` is `portal:<endpoint key>`. There is no person behind it: when the service calls back, it acts as the installation's service account, like for an event. The body:

```json
{
  "endpoint": "quote",
  "method": "GET",
  "params": { "record": 12, "check_in": "2026-12-20", "check_out": "2026-12-27", "guests": "4" },
  "form": null,
  "locale": "en",
  "installation": { "id": 7, "version": "1.2.0", "settings": { "company_name": "Higgins Stays" } }
}
```

`locale` is the visitor's language, `de`, `en` or `pl`. `params` are the declared parameters as the visitor sent them, `record` as a number. For a `POST`, `form` holds the checked fields with their types: numbers as numbers, `consent` as `true`.

For a `slots` section the portal asks the `endpoint` one week at a time: `from` and `to` are dates (`YYYY-MM-DD`), both included. The service answers with the free times of those days in the zone they are shown in; a day without free times may be missing or have an empty list, and a time in the past must not appear. When two people offer the same time, each of the two carries an `id` of its own, any string of up to 200 characters; the portal does not read it:

```json
{ "timeZone": "Europe/Berlin",
  "days": [ { "date": "2026-10-22",
              "slots": [ { "id": "example-0830", "start": "2026-10-22T08:30:00+02:00",
                           "end": "2026-10-22T09:00:00+02:00", "label": "Dr. Example" },
                         { "id": "sample-0830", "start": "2026-10-22T08:30:00+02:00",
                           "end": "2026-10-22T09:00:00+02:00", "label": "Dr. Sample" } ] } ] }
```

The `request` endpoint receives the chosen time's `id`, or its `start` when it has none, exactly as the service gave it, as the parameter named by `slotParam`. Without ids, two times with the same `start` would arrive as the same value, so the portal shows only the first of them. The service checks the time again: if it is still free, it answers like a booking request (`{ "ok": true, "reference": "T-0042", "message": { "de", "en" } }`); if it has been taken, it answers `409`, and the portal shows its own message and loads the week again.

The service answers with JSON, an object or a list, at most 64 KB. nexxus passes it to the portal unchanged. To refuse, the service answers `400`, `404`, `409` or `422` with `{ "errorCode", "message", "messages": { "de", "en", "pl" }, "fieldErrors": { "<field>": "..." } }`, `pl` optional. nexxus passes these keys on as short plain text (a message given as `{ "de", "en", "pl" }` in the visitor's language, else in English, else in German) and further keys only as short codes, numbers or yes/no, such as `"reason": "booked"`; everything else is dropped. A service that cannot be reached or takes longer than 10 seconds becomes `503 SERVICE_UNAVAILABLE`, any other failure `502 SERVICE_ERROR`. The visitor never sees the service's own error text.

## Customer area

The portal also has a signed-in side: the clients of a broker see their own contracts, the owners of a holiday home their bookings. They are **members**, not users of the app. Each member belongs to one record of the package (their firm, their property), sees only what the package declares for their audience, and acts with the rights of the staff member who invited them, their **sponsor**. The record history shows the sponsor; the module log names the member.

The manifest declares an audience under `portal.members`, with the entity a member belongs to (`scope`), the records and fields they read, what they may create, their actions, their views and roles:

```json
"members": {
  "kunde": {
    "label": { "de": "Kundenbereich", "en": "Client area" },
    "scope": "firma",
    "start": "konto.start",
    "records": {
      "firma":   { "fields": ["name", "ampel"] },
      "vertrag": { "via": "firma", "fields": ["titel", "sparte", "ablauf", "dokument"] }
    },
    "views": ["konto.start", "konto.vertraege"],
    "actions": ["nachweis-einreichen"],
    "uploads": { "folder": ["Kundenbereich", "{{scope.title}}"], "maxMb": 20 },
    "roles": {
      "leitung": { "label": { "de": "Geschäftsführung", "en": "Management" }, "admin": true, "secondFactor": true },
      "technik": { "label": { "de": "Technik", "en": "Facilities" }, "views": ["konto.start"] }
    }
  }
}
```

- **Sign-in** is on the portal domain at `/konto`, by a link sent by e-mail, no password. A role with `secondFactor` adds an authenticator code. Staff invite someone on the record's page, tab <Ui>Client area</Ui>, or ask the assistant to; an `admin` member manages their colleagues themselves. An invitation carries a language, `de`, `en` or `pl` (default the tenant's), and the invitation and sign-in mails exist in all three.
- **Every page after sign-in is a view of the package** with the slot `member`, filling the window. nexxus draws only the sign-in pages around it, with the firm's logo, so the area looks like the package and not like nexxus. What such a view may call is in [Views and the view SDK](/modules/views/).
- **What a member reads is checked on the server** for every call: the path `via` from a record to their scope record, the audience's filters, their role's fields. A record outside it answers `404`, as if it did not exist.

## Consent at install

A package with a portal asks for the consent `portal:public`, one with a customer area also for `portal:members` (who, which data, what they may do). The install dialog shows what becomes public in one sentence that nexxus builds from the manifest: which records, which fields, that images and a rounded location go along, and which service visitors can call. An update that publishes something else (another field, another filter, another endpoint) asks for the consent again; without it the package stays on its version.

## Rules the package check enforces

The upload is refused, with one line per problem, when a portal names an entity, field, page, endpoint or service that does not exist, publishes a field of a type that may not be public, filters on a field that is not `indexed`, repeats a page path, uses a page section with a field of the wrong type, or calls an endpoint with the wrong method. For `slots` it also checks that the section is on a `detail` page, that `endpoint` declares `record`, `from` and `to`, that `request` declares `record` and the `slotParam`, and that `days` lies between 7 and 90.