# Permissions

> Declaring, requesting and losing permissions — why the purpose string matters and why host permissions never flow to a mini app.

Source: https://miniapp.sollar.com/guides/permissions/

---

Two rules define this system, and both are load-bearing:

1. **A permission must be declared in the signed manifest before it can be requested.**
2. **A permission granted to the Sollar app does not flow to a mini app.** Each mini app asks for its
   own. Apple's Guideline 4.7.3 requires this, and Sollar enforces it on both platforms.

## Declaring

```json title="manifest.json"
"permissions": [
  {
    "scope": "identity.basic",
    "purpose": "Shows your name on the approval you are about to sign."
  },
  {
    "scope": "camera",
    "purpose": "Photographs a paper receipt so you do not have to type it in."
  }
]
```

### The purpose string is not decoration

The user reads it in the prompt. A reviewer holds you to it. And it converts the permission grant
from a technical checkbox into a **contestable claim** — "you asked for the camera to photograph
receipts; this app has no receipt feature" is a rejection with a specific reason attached.

| | |
|---|---|
| ✗ | "For app functionality" |
| ✗ | "Required" |
| ✗ | "To improve your experience" |
| ✓ | "Photographs a paper receipt so you do not have to type it in." |

Write what the user gets, not what your code needs.

## Requesting

Request at the moment of use, with the reason visible on screen.

```js
async function scanReceipt() {
  const state = await sollar.permissions.query('camera')

  if (state === 'denied') {
    showManualEntryForm()          // degrade; do not re-prompt in a loop
    return
  }

  if (state === 'prompt') {
    const result = await sollar.permissions.request('camera')
    if (result !== 'granted') { showManualEntryForm(); return }
  }

  const stream = await navigator.mediaDevices.getUserMedia({ video: true })
  // …
}
```

A wall of prompts at launch is both a worse experience and a review finding. The user has no context
at launch and will decline; at the moment they tap "Scan receipt", they will accept.

Requesting an undeclared scope throws `ERR_SCOPE_NOT_DECLARED`. It does not prompt — a scope missing
from the manifest is a build mistake, and surfacing it as a runtime prompt would hide it.

## Two prompts for one capability

For `camera`, `microphone` and `geolocation`, there are **two** gates, and both must pass:

1. **The Sollar gate** — does the manifest declare the scope, and has the user granted it to this
   mini app?
2. **The engine gate** — the WebView's own prompt, which the operating system controls.

```js
await sollar.permissions.request('camera')                      // gate 1
await navigator.mediaDevices.getUserMedia({ video: true })      // gate 2
```

This is not redundancy. The engine's prompt is the one the platform guarantees, and Sollar cannot
suppress it. Sollar's gate is what stops a mini app reaching the engine prompt at all when the app
never declared the capability.

## Where the decision is made

**Always in native code, against the signed manifest. Never in JavaScript.**

```
mini app JS  →  [sollar.* shim]  →  native bridge  →  ┌────────────────────────┐
                                                      │ 1. does the manifest   │
                                                      │    declare this scope? │
                                                      │ 2. has the user        │
                                                      │    granted it?         │
                                                      │ 3. does tenant policy  │
                                                      │    allow it?           │
                                                      │ 4. within quota?       │
                                                      └───────────┬────────────┘
                                                                  ▼
                                                             capability
```

The JavaScript shim is ergonomics. If the only thing between a call and a capability were a check in
JavaScript, there would be no check at all — a mini app controls its own JS environment by
definition.

This matters to you as a developer for one practical reason: **you cannot patch around a denied
permission.** There is no client-side workaround, so build the degraded path.

## Losing a permission

Permissions are revocable at any time, by the user or by a tenant administrator.

```js
try {
  await sollar.files.pick({ accept: ['application/pdf'] })
} catch (e) {
  if (e.code === 'ERR_PERMISSION_REVOKED') {
    // granted earlier, gone now. Re-request at the point of use.
  }
  if (e.code === 'ERR_TENANT_POLICY') {
    // the administrator disabled it organisation-wide.
    // Re-requesting will not help. Say so, plainly.
  }
}
```

`ERR_PERMISSION_DENIED` and `ERR_TENANT_POLICY` need different responses. The first may be worth
asking about again later, in context. The second will never succeed, and prompting the user for it
is asking them to override their employer, which they cannot do.

## Tenant policy

An administrator can disable scopes organisation-wide, independently of what any mini app declares
or any user grants. A tenant that forbids `camera` forbids it for every mini app in the
organisation.

There is no appeal path from inside a mini app. Detect it, explain it, and offer whatever still
works.

## Scopes

| Scope | Grants | Engine prompt too? |
|---|---|---|
| `identity.basic` | Display name, avatar | no |
| `identity.email` | Email within the tenant | no |
| `room.context` | Which room the app was opened from | no |
| `room.send` | Send as the user, confirmed per message | no |
| `files.read` | Open a file the user picks from Sollar storage | no |
| `files.write` | Write into Sollar storage at the user's direction | no |
| `notifications` | Post a notification through the host | yes |
| `camera` | Web `getUserMedia` video | **yes** |
| `microphone` | Web `getUserMedia` audio | **yes** |
| `geolocation` | Web Geolocation | **yes** |
| `clipboard.write` | Write to the clipboard | no |
| `contacts.directory` | Search the tenant user directory | no |

`contacts.directory` searches the **organisation's directory**, not the device address book. There is
no scope for the device address book, and there will not be one — that is a native platform API, and
exposing it would fall under Guideline 4.7.2.

## Consistency across environments

The permission code path is **identical in development, trial and production**. What differs between
environments is data and endpoints, never the authorisation check.

This is deliberate. "Permission behaves differently across environments" is one of the six
vulnerability categories documented across nine mini-program ecosystems in the research this
platform is built against — a hole that exists in only one environment is a hole nobody finds until
it is exploited. The conformance suite runs the same permission tests against all three.

## What reviewers check

For store submissions:

1. Every declared scope has a plausible `purpose` for the app's stated function.
2. No scope is declared that the code never uses.
3. Prompts appear at the point of use, not in a launch-time wall.
4. Denial produces a working degraded path, not a dead end or a re-prompt loop.
5. The privacy policy covers what the permissions actually collect.

Point 2 catches more submissions than any other. Declaring a scope "in case we need it later" reads
as over-collection, and it is.
