# Data access, permissions, and terms

Four blocks of the manifest disclose what an app does with data, who in the team may operate it, what it unlocks in the portal, and which terms an organization agrees to: `dataAccess`, `permissions`, `unlocks` with `connects`, and `legal`. For each block, the tables give the format, limits, and the effect in the portal.

> **Type:** reference
> **For:** Developers who describe an app so that the installation dialog gives the right disclosure.
> **Prerequisite:** a manifest according to [The manifest](/apps-sdk/manifest) and the `@szenera/apps-sdk` SDK from the platform’s source tree (not publicly released). The two queries at the end need only a credential from your organization.

## Four blocks, four questions

| Block | The organization’s question | Where the portal shows it |
|---|---|---|
| `dataAccess` | What does the app do with my data, before and after installation? | The **Data & access** section of the store page and the installation dialog |
| `permissions` | Who in my team may operate the app? | **Roles & permissions** — collapsed at the end of the store page, spelled out in the installation dialog |
| `unlocks`, `connects` | What does the app unlock in the portal, and which external account does it need? | **Unlocks in the portal** |
| `legal` | Which terms do I agree to? | **Important note** in the installation dialog, store page, footer of the widget |

The blocks are deliberately separate: `permissions` describes who in the team may **operate** the app, not what the app **accesses**. The portal tells the organization this at the permissions of the app: “They control who in your team may operate the app.” What the app accesses is shown separately under **Data & access**.

## dataAccess

If the block is missing, the most restrictive reading applies: the app reads nothing, fetches nothing, and keeps its own data on uninstalling. It is therefore needed only where there is something to say.

| Field | Format | Meaning | Label in the portal |
|---|---|---|---|
| `reads` | List of `tenant-profile`, `tenant-branding`, `media-library`, `team`, `player-location`, `fleet-locations`, `location-hours`, default empty | Existing data sets of the organization that the app reads. | **Reads existing data** — per data set **Organization details**, **Organization logo**, **Media library**, **Team and accounts**, **Display locations**, **All locations with address and position**, **Opening hours of the locations with public holidays** |
| `writes` | List, max. 8 entries, each entry `<dataset>:create-<thing>`, ≤ 60 characters, default empty | Write capabilities on data sets of other parties. Changing and deleting do not exist, and an app can only contribute. | **Adds data** — “Changing or deleting is impossible. An app can only contribute.” |
| `external` | List, max. 8 entries with `name` (1–60), `purpose` (1–200), and optionally `sends` (≤ 200) | External services the app reaches: whom, for what, and what goes out in the process. | **External connections** — “What is sent: …” or “No data of your organization is sent.” |
| `retention` | `kept` or `deleted`, default `kept` | What happens to the app’s own data on uninstalling. | **When you uninstall** — with the names of the `resources`: “Locations and schedules are kept. You can have them deleted for good afterwards.” or “Uninstalling deletes ….”, and without `resources` “This app leaves no data of its own behind.” |

**A fifth card appears in the portal block but does not come from this block.** Under **Data & access**, the portal lists the manifest’s **`resources`** before the four cards, the app’s own data areas that it creates for the organization, labeled **Creates for you** (see [The manifest](/apps-sdk/manifest)). The separation is intentional: `resources` says what is added, and `dataAccess` says what happens to what already exists. The store entry’s action card counts the areas as **Own data**.

Two rules connect the block to the code:

- **No access without a declaration.** Only if `reads` names a data set does the platform bind the token `APP_TENANT_DATA` to the backend (see [Backend: resolvers, fetchers, and jobs](/apps-sdk/backend#the-tokens)). If a class obtains the token without the manifest naming a data set, startup aborts with a plain-text message that names the missing line. `player-location` also binds the token: the location OF the display does arrive in the context of the widget resolver (see below), but a fixed, chosen location arrives through `location()` of this token. `location-hours` without `player-location` or `fleet-locations` also aborts startup, because opening hours belong to a location that the app would then not see.
- **No invented write capability.** Every entry under `writes` must appear in the platform’s catalog. The catalog is empty today, so the platform rejects every declared write capability, and that is the right disclosure: there are none.

An example from the weather app: it reads the organization details (for the language in which weather conditions and weekdays are labeled) and the display’s location, and it reaches an external service. The place comes from the widget configuration, either a fixed place or the display’s location.

```ts
dataAccess: {
  reads: ['tenant-profile', 'player-location'],
  external: [
    {
      name: 'OpenWeather',
      purpose: 'Wir holen Wetterlage und Vorhersage für den gewählten Ort oder den Standort des Displays.',
      sends: 'Nur die auf etwa einen Kilometer gerundeten Koordinaten des gewählten Orts oder des Standorts, an dem das Display steht.',
    },
  ],
},
```

Anyone who fetches an address that a user entered in the portal describes it as a service with a descriptive name (the RSS app calls it “The entered feed address”, the calendar app “The entered ICS address”) and fetches it in the backend only through `APP_GUARDED_FETCH`.

### The location of the display: `player-location`

A scene often runs in all branches of an organization. With `player-location`, a widget shows each display’s own place, such as the weather outside the door or the local time, without anyone maintaining a scene per branch. The location is whatever the organization has stored as the location for the display in the portal.

It does not come through `APP_TENANT_DATA` but in the widget resolver’s context as `ctx.location`, because only there is it known which display is being resolved for:

| Field | Content |
|---|---|
| `id` | Identifier of the location (`loc_…`), for example to link it with the app’s data |
| `name` | Name of the location as the organization maintains it (“Altona branch”) |
| `city`, `countryCode` | Place and country code (ISO 3166-1, `DE`) of the address, each `null` without an address. Street and postal code are never passed. |
| `latitude`, `longitude` | Coordinates of the location, together `null` without coordinates |
| `timeZone` | Time zone of the location (`Europe/Berlin`) |
| `hours` | only with `location-hours`, see [Opening hours](#opening-hours-location-hours) |

The coordinates come from geocoding the address and are accurate to the building. Anyone who sends them to an external service rounds them to what the purpose needs and says so in `sends` and in the terms: the weather app sends two decimal places, about a kilometer, the same cell as its cache key.

A resolver has to distinguish three states:

| `ctx.location` | When |
|---|---|
| absent | The app does not name `player-location` under `reads`, or the organization has not yet consented to the area with the installed version. Only those who read get anything. |
| `null` | The app may read, but there is no location: no display is involved (preview in the editor without a chosen display) or the display is not attached to any location. |
| Object | Display with a location, even without an address and without coordinates. Anyone who looks up a place checks `latitude` and treats a location without coordinates like `null`. |

If `player-location` is added with a new version, the version requires the organization’s consent like any new data access (**Update waiting**). Until it is accepted, `ctx.location` is absent.

For `null`, the widget needs a way out, such as a fallback place from its configuration or a placeholder with a notice, never an empty widget. The weather app uses the fallback place for a location without coordinates as well.

A fixed, chosen location of the organization itself, such as a substitute location for displays without a location, is read by the backend with `APP_TENANT_DATA.location(tenantId, locationId)` in the same shape. An identifier from another organization yields `null`. The preview in the editor resolves for a specific display when it sends `playerId` along (`POST /v1/widget-data/preview`). In tests, `mockWidgetResolverContext({ location: mockWidgetLocation() })` from `@szenera/apps-sdk/testing` sets a location, and `{ location: null }` sets the empty case.

### Opening hours: `location-hours`

Opening hours are master data of the location. The organization maintains them in the portal at the location (tab **Opening hours**), through `PATCH /v1/locations/{id}`, or with the MCP tool `set_location_opening_hours`, and every app that shows them reads the same ones. The area is separate from `player-location` because opening hours are not a place specification: an app that needs only the place does not get them, and a consent of its own stays understandable.

With `location-hours`, every location that the app reads (`ctx.location` and `location()`, and with `fleet-locations` also `locations()`) additionally carries `hours`:

| Field | Content |
|---|---|
| `region` | Federal state per ISO 3166-2 (`DE-HH`) or `null`. Without a federal state, there are no public holidays. |
| `weekly` | Weekly hours, for each weekday (`mon` … `sun`) the windows `{ opens, closes }`. If `closes` is not after `opens`, the window ends the next day, and identical times mean 24 hours. |
| `specialDays` | the special days that touch the period, `{ startDate, endDate, label, closed, windows }` |
| `holidays` | the statutory public holidays in the period, `{ date, id, name }`. `id` is stable (`easter-monday`) so that the display software can name the holiday in its language |
| `from`, `until` | the period: 21 days from today in the location’s time zone |

`hours` is absent without the area or without consent, and `null` means the app may read, but no hours are maintained at the location. What applies on a given day is computed by `openingDayOf` from `@szenera/contracts/fleet` with the precedence special day over public holiday (closed) over weekly hours. If the organization changes the hours, the federal state, or the time zone, the platform rebuilds the location’s displays. The app itself handles the change of day and the end of a window through the `validUntil` of its resolver.

```ts
dataAccess: {
  reads: ['player-location', 'location-hours'],
},
```

In tests, `mockWidgetLocation({ hours: … })` sets the hours and `mockWidgetLocation({ hours: null })` the location without hours.

### All locations: `fleet-locations`

A fetch that must know in advance for which places it retrieves data reads the directory of locations: `APP_TENANT_DATA.locations(tenantId)` returns all of the organization’s locations ordered by name, with the full address (`street`, `postalCode`, `city`, `countryCode`), coordinates, and time zone, and with `location-hours` including `hours`. A resolver may not reach outside, so a job needs the places in advance, the Pharmacies on duty app the postal codes, for example. Anyone who sends the address to an external service states in `dataAccess.external` exactly what goes out.

In tests, `createMockAppContainer({ locationsMock: [...] })` supplies the locations for `location()` and `locations()`.

## permissions

The installation enters every permission into the organization’s permission catalog. Two lists, the same shape:

| List | Who holds it | `defaultRoles` |
|---|---|---|
| `tenant` | The organization’s team | Roles that receive the permission with the installation, e.g., `['OWNER', 'MANAGER', 'MEMBER']` |
| `platform` | Platform operations | empty (`[]`) — no team member receives the permission |

Each entry is a quartet:

| Field | Format | Meaning |
|---|---|---|
| `key` | `<ns>.<verb>` or `<ns>.<resource>.<verb>` (`^[a-z][a-z0-9-]*(\.[a-z][a-z0-9-]*)+$`), e.g., `pharmacies-on-duty.location.read` | The key that the backend checks through `APP_PERMISSION_GUARD`. The platform prefixes it with `apps:<id>:`. |
| `label` | Text, at least 1 character | Label in the permission catalog and in the installation dialog. |
| `description` | Text, at least 1 character | One sentence on what the permission allows. |
| `defaultRoles` | List of roles | Starting assignment. The organization can change it in the team area. |

The helper `definePermission(key)` only checks the format of a key and returns it unchanged. The bundled apps write the quartets directly into the manifest.

**New permissions in a new version** are not adopted silently by the platform: as soon as the version is published as `stable`, the installation moves to **Update waiting**, the app’s widgets cannot be inserted in the scene editor, and the app’s **Update** tab shows the added permissions with **Accept** and **Decline**. A call that uses the app beforehand fails with `Grant.PendingReacceptance` (HTTP 409), and a consent to a different manifest version fails with `App.ManifestHashMismatch` (HTTP 409).

## unlocks and connects

An app does not have to bring a surface. `unlocks` and `connects` describe the second purpose of an app: not delivering something but unlocking something. The feature lives in the platform, and the app is the gate in front of it.

| Field | Format | Platform catalog | Triggers consent? |
|---|---|---|---|
| `unlocks` | max. 8 keys, lowercase with hyphens, ≤ 40 characters | `video-walls` (**Video walls**), `library-places` (**Places in the library**) | No — the row gives the organization something, takes nothing from it, and reads nothing from it. |
| `connects` | max. 4 keys, lowercase with hyphens, ≤ 40 characters | currently `microsoft-graph` | The organization makes the connection itself in the portal. Acquiring, renewing, and revoking the authorization are handled once, centrally, in the platform. The app sees neither the authorization nor the access token. |

A key that is not in the catalog keeps the app from loading, because otherwise an app could promise a feature or connection with nothing behind it. Both fields are omitted rather than set empty: an empty field would shift the canonical hash of every existing version.

The two gate apps show the patterns: `video-wall` declares `unlocks: ['video-walls']`, and `onedrive` declares `unlocks: ['library-places']` and `connects: ['microsoft-graph']`. The store entry shows the unlocking in the **Data & access** tab under **Unlocks in the portal**. An app that only unlocks a core feature of the platform does not appear in the store as an app: `video-wall` appears there as the tile **Core feature · Video wall** (`coreFeatures` of the overview) and is missing from “My apps.”

## legal

| Field | Required | Meaning |
|---|---|---|
| `disclaimerKey` | yes | Key of the version that a consent refers to (`<group>.bedingungen.v1`). A change to the substance of the notice bumps it (`v1` → `v2`). |
| `disclaimerSummary` | yes, max. 500 characters | Short summary in plain text. The installation dialog (**Important note**), store entry, and widget footer show it unchanged. It presents the version, and rewording it under the same key does not require consent. |
| `termsPath` | no | Path of the full text on the public website, ≤ 200 characters, lowercase letters, digits, hyphens, and slashes only (`/app-bedingungen/<group>`). The portal prepends its base and links it as **The app’s full terms**. Without `termsPath`, the dialog shows only the short summary. |
| `requireAcceptanceAt` | default `install` | `install`: consent at installation and afterward with every new version of the notice. `upgrade`: consent with every new version of the app, even if the notice is unchanged. `never`: no consent. The bundled apps carry `install`. |

Apps with the same legal topic point to the **same** sheet. The bundled apps use seven groups:

| Group (`termsPath`) | Apps |
|---|---|
| `/app-bedingungen/fremdinhalte` | `webpage`, `youtube`, `stream`, `embed` |
| `/app-bedingungen/eigene-inhalte` | `notice-board`, `ticker`, `qr-code`, `menu-board` |
| `/app-bedingungen/wetter` | `weather` |
| `/app-bedingungen/nachrichten` | `rss`, `tagesschau`, `bbc`, `bild`, `ndr`, `spiegel`, `deutschlandfunk` |
| `/app-bedingungen/verbindungen` | `calendar`, `onedrive` |
| `/app-bedingungen/notdienst` | `pharmacies-on-duty` |
| `/app-bedingungen/oeffnungszeiten` | `opening-hours` |

`clock`, `countdown`, `countup`, `world-clock`, `word-clock`, and `video-wall` remain without `legal`. The Opening hours app does show the organization’s own entries, but the platform computes the public holidays, and its terms cover that.

**A new version of the terms**, meaning a different `disclaimerKey`, makes a release subject to consent: as soon as the new version is published as `stable`, the installation moves to **Update waiting**, exactly as with new permissions. Anyone who changes the substance of the notice therefore bumps the key, in the same pass as the short summary, the full text, and the app version. The platform silently adopts a reworded short summary under the same key. A manifest change without a version bump does not reach existing installations.

## Query the state of an installation

An organization reads what has become of the manifest, permissions, and terms through two routes for its own installations. Both require a credential of the organization but **no permission of their own**: a valid sign-in suffices, and an API key reaches them regardless of which permissions are in its `scope`. An operator credential does not reach them, because both always answer about exactly one organization and reject a credential without an organization with `403 Permission.Denied`.

| Route | What the response contains |
|---|---|
| `GET /v1/me/apps` | Under `apps`, one row per installation: `grantId` (identifier `tag_…`), `appId`, `installedVersion`, `installedAt`, plus the presentation fields of the **installed** version (`displayName`, `tagline`, `iconUrl`, `coverUrl`, `category`, `tier`, `bundleSizeBytes`), the data areas as `resources`, and `sceneCount`, in how many scenes the app is actually used. Three states sit side by side: `status` (`active` or `pending-reacceptance`), `platformStatus` (`active`, `beta`, `disabled`), and `effect` with `status` (`active`, `locked`) and `reason` (`ok`, `pro-required`, `premium-required`, `booking-required`, `exempt`). Uninstalled apps are missing from the list, while switched-off apps and apps no longer included in the package remain in it, because they are part of the installed base. Name, tagline, and data areas appear in the language from `locale` or `Accept-Language`, where the version carries them. |
| `GET /v1/me/apps/{appId}/installed` | The page of the installed app: `entry` describes the installed version, `scenes` the scenes that use it, and `history` the installation in two blocks. `history.grant` carries `status`, `installedVersion`, `acceptedManifestHash`, `pendingManifestHash`, a decline with version and time (`declinedVersion`, `declinedAt`), and the accounts behind installation, consent, and uninstallation. `history.acceptanceHistory` is the append-only list of every consent, newest first, with each entry having `manifestHash`, the version with identifier and number (`releaseId`, `version`, both `null` if the version had already left the catalog before the number existed), `acceptedVia` (`manual` or `auto`), `reasons`, `acceptedAt`, and the lists `permissions` and `resourceTypes` that were consented to at the time. An app without an installation yields `404 App.NotInstalled`. |

`pendingManifestHash` is the machine-readable form of **Update waiting**: if a value is there, a new version requires consent, and `status` reports `pending-reacceptance`. Until the decision, the installation keeps running in its current version. `GET /v1/me/apps/updates` collects the installations that are waiting for it (with `state: "pending"`, and next to them offers of higher versions with `state: "offer"`). For each app, `GET /v1/me/apps/{appId}/update` delivers the decision in plain text: version from and to, reasons, new and removed permissions and data accesses, terms before and after, the deadline, and whether declining is still possible. Accepting is done with `POST /v1/me/apps/{appId}/update/accept` and declining with `POST /v1/me/apps/{appId}/update/reject`, both with the `manifestHash` of the version the update leads to and the permission `marketplace.app.accept`. A decline holds until the next higher version.

```bash
curl -sS "$SZENERA_API/v1/me/apps?locale=en-US" \
  -H "Authorization: Bearer $SZENERA_API_KEY"
```

The consent history is the record against which a changed version can be checked: each entry records which manifest hash was consented to, with which permissions and when, and whether a person did so or the platform adopted it automatically because neither permissions nor terms had moved.

## What the organization sees

The installation dialog carries the subtitle “Review what the app does with your data, then confirm the installation.” It shows the **Data & access** block, below it lists the permissions as **Roles & permissions** with their count, shows the short summary of the terms under **Important note**, links the full text as **The app’s full terms**, and requires the checkbox **I have read the permissions and the app’s terms and agree to them.** before **Install** takes effect.

The same information is already in the app’s entry in the store, in the **Data & access** tab. Whoever is weighing whether they want the app at all should be able to read its terms before the click. The tab names which areas of its own the app creates (from `resources`), which data sets it reads, what it adds, which connections it keeps to the outside and what is transferred in the process, and what happens to its data on uninstalling. Below that come **Privacy** with the recipients from `dataAccess.external` and the short summary of the terms with the link to the full text, which the portal sets from `legal.termsPath`. What the manifest leaves out, the portal states explicitly (“This app does not access any of your existing data.”) instead of omitting the card.

`GET /v1/me/apps/store/{appId}` delivers the entry as `StoreEntryView`, fully decided: the app’s situation for the organization, exactly one action, and the reason with its numbers. The portal forms the sentences for it, and calculation happens only on the server.

The handbook describes the organization’s view of installing, permissions, and consent: [Apps in the handbook](https://szenera.com/docs/apps).

## Related

- [The manifest](/apps-sdk/manifest) — the four blocks in the context of all fields.
- [Configure widgets](/apps-sdk/configure-widgets) — what the widget catalog reveals about a widget’s configuration, and how a client checks it.
- [What is an app, and how does it get onto a display?](/apps-sdk/about-apps) — the states of an installation in the lifecycle.
- [Apps in the handbook](https://szenera.com/docs/apps) — the organization’s view.
