# Building an extension

This guide walks through an extension end to end: registration, sign-in, finding the community,
reading its members, notifying them, and going live. The [API reference](/docs/extensions/api) has the
exact shapes; this page explains the order and the reasoning.

## How it fits together

There are three parties and two tokens.

| Party | What it is |
|---|---|
| The member | A person in a Zen Social community, using their browser or the Zen Social app. |
| Zen Social | The community platform: the door, the identity provider, the API. |
| Your extension | Your site, on your servers, under your domain. |

| Token | How you get it | What it is for |
|---|---|---|
| A **user token** | Authorization code + PKCE, after the member consents | Acting as the member: who they are, which communities they can see. |
| An **app token** | `client_credentials`, from your server, with no member involved | Acting as yourself: background work, sending notifications. |

The flow, in order:

1. A community admin installs your extension. Zen Social creates an **installation** — that
   community's copy of your extension, with its own id.
2. Every member of that community now sees your door. Tapping it opens
   `https://your-site.example/start?installation=42&community=acme` in a new tab.
3. Your site sends the member to Zen Social to sign in (OpenID Connect).
4. They come back with a code; you exchange it for an ID token and an access token.
5. You call the API with the access token to confirm which installation they are entitled to, and
   to read the community.
6. Your server calls the API with its own app token to send notifications.

## Registration

Registration is done by Zen Social staff, once, in the backoffice. Send us:

| Field | Notes |
|---|---|
| Name | What the community sees on the door and on the consent screen. |
| Description | One or two sentences for the catalogue card. |
| Developer | The person or organisation behind it. Shown on the consent screen. |
| Launch URL | `https://…`. Must be https. The door opens it; notification links must live on its host. |
| Redirect URIs | Your OAuth callbacks, one per line. Matched exactly — no wildcards, no trailing-slash forgiveness. |
| Scopes | The most you will ever ask for: `openid`, `profile`, `members`, `notify`. |

You get back a **client id** (public) and a **client secret** (shown once, at registration — we
store only a hash of it, so we cannot show it to you again; ask for a rotation if you lose it).

Extensions are unlisted: a community only sees yours in its catalogue after staff grant that
community access. Tell us which communities you are working with.

## Sign-in with OpenID Connect

We are a standard OpenID Connect provider. If your framework has an OIDC client, point it at the
discovery document and skip to the claims:

```
https://zensocial.app/.well-known/openid-configuration
```

By hand, it is the authorization code flow with PKCE.

**1. Make a PKCE pair.** A random `code_verifier` (43–128 characters), and its challenge:

```
code_challenge = BASE64URL(SHA256(code_verifier))   # no padding
```

`S256` is the only method we accept, and PKCE is mandatory for every client — including
confidential ones that also send a client secret.

**2. Send the member to the authorization endpoint.**

```
https://zensocial.app/oauth/authorize
  ?client_id=YOUR_CLIENT_ID
  &redirect_uri=https://your-site.example/callback
  &response_type=code
  &scope=openid%20profile
  &state=RANDOM_STATE
  &nonce=RANDOM_NONCE
  &code_challenge=CHALLENGE
  &code_challenge_method=S256
```

`state` and `nonce` are yours to generate and to check on the way back. Ask only for the scopes you
need now; you can ask for more later, and the member is asked again when you do.

**3. The member signs in and consents.** If they are not signed in to Zen Social, we sign them in
first and then bring them back. The consent screen names your extension, its developer, and each
scope in plain language. Consent is remembered per scope set: a second sign-in with the same scopes
goes straight through, and a sign-in that asks for more stops and asks again.

If they say no, we send them back to your `redirect_uri` with `error=access_denied`.

**4. Exchange the code**, from your server. A code is single-use and lives 10 minutes.

```bash
curl -X POST https://zensocial.app/oauth/token \
  -d grant_type=authorization_code \
  -d code="$CODE" \
  -d redirect_uri=https://your-site.example/callback \
  -d client_id="$CLIENT_ID" \
  -d client_secret="$CLIENT_SECRET" \
  -d code_verifier="$CODE_VERIFIER"
```

```json
{
  "access_token": "…",
  "token_type": "Bearer",
  "expires_in": 7200,
  "refresh_token": "…",
  "scope": "openid profile",
  "id_token": "eyJ…"
}
```

**5. Verify the ID token.** RS256, against the keys at
`https://zensocial.app/oauth/discovery/keys`. Check `iss` is `https://zensocial.app`, `aud` is your
client id, `exp` is in the future and `nonce` is the one you sent.

| Claim | Type | Notes |
|---|---|---|
| `sub` | string | The member's stable Zen Social id: an opaque 12-character string of letters and digits, never an integer. Your primary key for them — store it as a string, and never parse it. |
| `name` | string | Their display name. |
| `picture` | string | Absolute URL of their avatar. Absent when they have not set one. |
| `locale` | string | Their language, e.g. `da` or `en`. Render your UI and your notifications in it. |
| `updated_at` | integer | When their profile last changed, in seconds since the epoch. |
| `nonce` | string | Echoed from your request. |

`name`, `picture`, `locale` and `updated_at` need the `profile` scope, and are also available from
`https://zensocial.app/oauth/userinfo` with the access token.

**There is no e-mail claim, and there never will be.** In Zen Social an address is a notification
setting, not an identity — many members have none at all, and the ones who do did not give it to
you. Identify members by `sub`. If your extension needs to write to someone, ask them for an
address yourself, or send them a Zen Social notification instead.

If Support merges two accounts, the surviving account keeps one `sub`; the other one stops resolving.

**Tokens and refresh.** An access token lasts 2 hours. Refresh with the `refresh_token` grant; both
tokens rotate, so store what you get back and throw the old pair away:

```bash
curl -X POST https://zensocial.app/oauth/token \
  -d grant_type=refresh_token \
  -d refresh_token="$REFRESH_TOKEN" \
  -d client_id="$CLIENT_ID" \
  -d client_secret="$CLIENT_SECRET"
```

You can hand a token back with `POST https://zensocial.app/oauth/revoke` when a member signs out of
your site for good.

## Knowing the community

The door's `installation` and `community` parameters are hints. They arrive in a URL, so a member
could change them, and your extension may well be installed in several communities at once. **The
API is the truth.**

After sign-in, ask what this member can see:

```bash
curl -H "Authorization: Bearer $ACCESS_TOKEN" \
     https://zensocial.app/api/extensions/v1/installations
```

You get one entry per community that has your extension installed *and* that the member belongs to.
Each entry carries the installation, the community, and the member's role:

```json
[
  {
    "installation": { "id": 42, "settings": {}, "position": 3, "color": null, "icon_url": null },
    "organization": { "id": 7, "name": "Acme", "slug": "acme", "display_color": "#2F6F62", "initials": "AC" },
    "role": "owner"
  }
]
```

If the id from the door is not in that list, the member is not entitled to it: show them the list
you did get, or an empty state. Never trust the parameter on its own.

`role` is the member's role in the community itself, as a string: `owner`, `admin`, `editor`,
`contributor`, `commenter` or `viewer`. It is `null` when they belong to the community only through
one of its sub-groups — that is a member in good standing with no role of their own, not an error.
`owner` and `admin` are the two that can manage the community.

The `organization` object is there so your pages can look like they belong: `name` for the heading,
`display_color` for an accent, `initials` for a mark when there is no logo.

## Reading members

With the `members` scope you can read a community's directory through its installation:

```bash
curl -H "Authorization: Bearer $TOKEN" \
     https://zensocial.app/api/extensions/v1/installations/42/members
```

```json
[
  { "id": "2h54Gfr675Dd", "name": "Ada Holm", "avatar_url": "https://zensocial.app/…/ada.jpg", "locale": "da" },
  { "id": "Kq7bZ1mXn4Pt", "name": "Bo Lund", "avatar_url": null, "locale": "en" }
]
```

These are the same people the community's own members page lists, in the same order. `avatar_url`
is absolute, or `null` for a member with no picture — draw their initials. `locale` is what to
render their name card, and any notification you send them, in.

Both an app token and a member's user token may read members. A user token only works for a member
of *that* community; anyone else gets `403 not_a_member`.

Store ids, not copies. A member who leaves the community disappears from this list, and that is the
only signal you get — re-read it rather than keeping a directory of your own.

## Sending notifications

A notification from your extension shows up in Zen Social's own notification list, reaches the
member's devices, and respects the notification settings they have already chosen.

Use an **app token** from your server. It needs no member and no member's consent:

```bash
curl -X POST https://zensocial.app/oauth/token \
  -d grant_type=client_credentials \
  -d client_id="$CLIENT_ID" \
  -d client_secret="$CLIENT_SECRET" \
  -d scope="members notify"
```

Then post to the installation:

```bash
curl -X POST https://zensocial.app/api/extensions/v1/installations/42/notifications \
  -H "Authorization: Bearer $APP_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
        "title": "Your shift starts tomorrow",
        "text": "Saturday 09:00-13:00 at the clubhouse.",
        "url": "https://your-site.example/shifts/17",
        "recipient_ids": ["2h54Gfr675Dd", "Kq7bZ1mXn4Pt"]
      }'
```

Rules worth knowing before you write the code:

- `title` is at most 80 characters, `text` at most 280. Both are shown exactly as you send them, so
  translate them yourself — use each recipient's `locale` and send one call per language.
- `url` must be `https` and on the **host of your registered launch URL**. A notification that leads
  off your own site is rejected with `422`.
- `recipient_ids` is 1–500 Zen Social member ids — the opaque `id` of a person, which is the same
  string as their `sub`. Ids that are unknown, or that are not members of that community, are
  dropped silently, so a stale id costs you nothing. Anything that is not a 12-character member id
  is rejected with `422`.
- A **user token** may only do this for a community `owner` or `admin`; any other member gets
  `403 admin_required`. Otherwise every member could broadcast to the whole community through you.
- 20 notification calls per hour per installation. Batch recipients into one call rather than
  looping.

## The door

The door is static and it is ours to draw. It shows exactly what you registered — your extension's
name and the host of your launch URL — painted in the community's own palette colour, and it opens
the launch URL with the `installation` and `community` parameters appended. There is no badge, no
unread count, no live content from your side: an extension cannot draw on the community's home
screen.

A community admin can add the door and remove it, and nothing else. For an external extension there
is no rename, no recolour and no reorder, which is why `installation.color` and `icon_url` come back
`null`. Members can do none of it: they see the door, or, if the extension is not installed, nothing
at all.

Design your landing page for a cold start. The member may never have seen your site, may not be
signed in to it, and may be arriving on a phone in a browser tab with no navigation of its own.
Send them to sign-in immediately and land them on something useful for the community named in the
`community` parameter.

## Going live checklist

- The launch URL is https and serves a real page for a signed-out visitor.
- Every redirect URI you use is registered, character for character.
- PKCE with `S256` on every authorization request.
- `state` and `nonce` generated per request, and verified on the way back.
- The ID token's signature, `iss`, `aud` and `exp` are verified against the JWKS.
- Members are keyed on `sub`, and nothing in your data model needs an e-mail address.
- The installation id is confirmed against `GET /installations`, never trusted from the URL.
- Refresh handled, with both rotated tokens stored.
- `401` retried once by re-authenticating, not in a loop.
- `429` respected — back off for the `Retry-After` you are given.
- Notification titles and texts are translated per recipient `locale`.
- Your client secret is on your server only, never in a browser or a mobile binary.

## Limits and errors

Everything is throttled per minute, and notifications per hour as well. All limits answer with
`429` and `Retry-After: 60`.

| What | Limit |
|---|---|
| Any call to the API, per access token | 60 per minute |
| Any call to the API, per IP address | 120 per minute |
| Notifications, per installation per token | 20 per hour |
| The token endpoint, per IP address | 30 per minute |
| The token endpoint, per client id | 60 per minute |
| The authorization endpoint, per IP address | 30 per minute |

| Status | Body | What happened |
|---|---|---|
| `401` | `{"error":"invalid_token"}` | Missing, expired, revoked, or belonging to a disabled extension. Get a new token. |
| `403` | `{"error":"insufficient_scope"}` | The token does not hold the scope this endpoint needs. |
| `403` | `{"error":"not_a_member"}` | A member's token, aimed at a community they do not belong to. |
| `403` | `{"error":"admin_required"}` | A member's token trying to notify without being an admin. |
| `403` | `{"error":"user_token_required"}` | An app token on an endpoint that is about a person. |
| `404` | `{"error":"not_found"}` | No such installation — or one that is not yours, or suspended. |
| `422` | `{"errors":{"title":["is too long (maximum is 80 characters)"]}}` | The notification body is invalid. Errors are per field. |
| `429` | `{"error":"Too many requests. Please try again later."}` | Throttled. Wait for `Retry-After`. |

`404` is deliberately the answer for an installation that belongs to another extension. We never
confirm that an id exists but is not yours.

## Disconnecting

Three things can end the relationship, and you should handle all of them as "the token stopped
working":

- **A member disconnects you** from their Zen Social profile, under Connected extensions. Every
  token and grant they hold for you is revoked at once.
- **An admin removes the extension** from their community. The installation stops existing, the
  door disappears, and calls about it answer `404`.
- **Zen Social disables the extension.** Every token and grant of your client, for every member and
  for your server, is revoked; every door disappears; and sign-in itself is refused — `/oauth/authorize`
  and `/oauth/token` answer `unauthorized_client` while it lasts. This is the kill switch; we would
  talk to you first unless something is on fire.

When a call answers `401`, send the member through sign-in again. When one answers `404`, drop the
installation from your side. Do not retry either in a loop.
