# Build an app every creator can connect

> An API key works for your own newsletter. To build something other creators use, they need a way to say yes without copying a secret into your app. Commune lets them connect your app from a consent screen, pick the newsletters it may reach and see exactly what it may do, and take it back whenever they like.

How: with the API (About an hour to a working connection).

What you will have:

- An app registered with Commune, with no form to fill in and nobody to wait for.
- A connect link that takes a creator through Commune's consent screen and back to your app holding a token for the newsletters they chose.
- Tokens that renew themselves in the background, and a disconnect that cleans up after itself.
- Every read an API key can make, made on behalf of each creator who connects.

**Warning:** The sign-in half of this happens on the Commune app rather than the API, so those endpoints are not in the API reference and not in the collection. The collection holds the three requests made with the token.

## With the API

Your app asks Commune for permission, the creator grants it on a screen Commune shows them, and your app receives an access token it uses exactly like an API key. Thirteen steps: choosing what to ask for, the eight that get and keep a token, three requests made with it, and letting go of it.

Before you start:

- An access token from the authorization code flow below, for an app that asks for: content: read. Export it as `ACCESS_TOKEN`.
- A Commune account that runs at least one newsletter, so you can be the creator on the other side of the consent screen while you build.
- Node 18 or newer, for the script at the end. Nothing to install.
- Some steps need more than this. Each one lists it where it starts.

Every request sends `Authorization: Bearer $ACCESS_TOKEN` and `Commune-Version: 2026-08-26`. The requests below are also a collection you can import into Postman, Insomnia, Bruno, Yaak or Hoppscotch: https://usecommune.dev/use-cases/build-an-integration/collection.json

### 1. Ask for the least you need

Permissions come in six families, `content`, `audience`, `sending`, `insights`, `settings` and `webhooks`, each at `read` or `write`, written `family:level` and separated by spaces. The consent screen shows the creator each family and level you ask for, and the same set applies to every newsletter they tick. **Ask only for what your app uses today.** `audience:read` carries every subscriber's email address, and `sending:write` puts real mail in front of real people: asking for either when you do not need it is the fastest way to a creator pressing cancel.

This walkthrough builds an app that reads a newsletter's published articles, so it asks for `content:read` and nothing else. A scope never names a newsletter. It says what may be done, and the creator says where.

Three rules worth knowing before you choose. Sending no `scope` at all means all six families at `read`, which is more than most apps need, so always send one. An unknown scope is refused with `invalid_scope`, never quietly dropped. And `account:read` is a separate axis: alone, it reaches the person's own account and no newsletter, and with a family it adds a switch to the screen that starts off, so the creator decides.

If your app needs more later, you send the creator through the flow again asking for the whole new set. A second consent replaces what the connection held rather than adding to it.

### 2. Follow the 401 to the authorization server

Call the API with no credential and the answer tells you where to go, in the `WWW-Authenticate` header: `resource_metadata` is the address of a document describing the API as a protected resource. Its `authorization_servers` field is the one you need. The API and the authorization server are different origins on purpose: issuing a credential means showing a signed-in person a screen, and that is the Commune app's job, not the API's.

Both documents are public, need no credential and are the same for everyone, so read them once when your app starts.

shell:

```sh
curl -i https://api.usecommune.com/newsletters
# HTTP/2 401
# www-authenticate: Bearer realm="Commune API", resource_metadata="https://api.usecommune.com/.well-known/oauth-protected-resource"

curl https://api.usecommune.com/.well-known/oauth-protected-resource
# {
#   "resource": "https://api.usecommune.com",
#   "authorization_servers": ["https://usecommune.com"],
#   "scopes_supported": [
#     "content:read", "content:write",
#     "audience:read", "audience:write",
#     "sending:read", "sending:write",
#     "insights:read", "insights:write",
#     "settings:read", "settings:write",
#     "webhooks:read", "webhooks:write",
#     "account:read"
#   ],
#   "bearer_methods_supported": ["header"],
#   "resource_documentation": "https://usecommune.dev"
# }
```

### 3. Read the authorization server's metadata

The authorization server describes itself at `/.well-known/oauth-authorization-server` on the origin the last document named. It holds every URL the rest of this walkthrough uses: read them from here rather than hardcoding them.

Three entries decide how you build. `code_challenge_methods_supported` is `S256` and only `S256`, so PKCE is not optional. `resource_indicators_supported` is `true`, so name the API when you ask for a token. And `registration_endpoint` is there, so your app registers itself.

There is no OpenID discovery document and no `id_token`. Tokens are opaque strings, which is what lets a creator's disconnect take effect on your very next request.

GET https://usecommune.com/.well-known/oauth-authorization-server:

```json
{
  "issuer": "https://usecommune.com",
  "authorization_endpoint": "https://usecommune.com/api/oauth/authorize",
  "token_endpoint": "https://usecommune.com/api/oauth/token",
  "userinfo_endpoint": "https://usecommune.com/api/oauth/userinfo",
  "registration_endpoint": "https://usecommune.com/api/oauth/register",
  "revocation_endpoint": "https://usecommune.com/api/oauth/revoke",
  "scopes_supported": [
    "content:read", "content:write",
    "audience:read", "audience:write",
    "sending:read", "sending:write",
    "insights:read", "insights:write",
    "settings:read", "settings:write",
    "webhooks:read", "webhooks:write",
    "account:read", "offline_access"
  ],
  "response_types_supported": ["code"],
  "response_modes_supported": ["query"],
  "grant_types_supported": ["authorization_code", "refresh_token"],
  "token_endpoint_auth_methods_supported": [
    "none", "client_secret_basic", "client_secret_post"
  ],
  "revocation_endpoint_auth_methods_supported": [
    "none", "client_secret_basic", "client_secret_post"
  ],
  "code_challenge_methods_supported": ["S256"],
  "claims_supported": [
    "sub", "name", "preferred_username", "picture", "email", "email_verified",
    "commune:newsletter_ids", "commune:grant_id", "commune:scopes"
  ],
  "resource_indicators_supported": true,
  "resource_documentation": "https://usecommune.dev",
  "commune:protected_resource": "https://api.usecommune.com"
}
```

### 4. Register your app

Registration is open and unauthenticated: one request, no form, no allowlist. `redirect_uris` is the only required field, up to ten of them, each `https`, or `http` on a loopback host such as `localhost` while you develop, and none with a fragment. The script at the end registers `http://localhost:3000/callback`.

**Keep the `client_id` that comes back.** A registration cannot be read back, updated or deleted afterwards, so there is no way to recover it. Register once, store it with your app's configuration, and reuse it for every creator.

`token_endpoint_auth_method` defaults to `none`, a public client with no secret, and that is the right choice for anything you distribute. Ask for `client_secret_basic` or `client_secret_post` only if your app is a server nobody else runs; the response then also carries a `client_secret` that never expires.

`client_name` is what the consent screen shows, in quotes, beside a line telling the creator the name is self-reported and that they should recognise it as the app they just started. Register the name your users know you by.

shell:

```sh
curl -sS https://usecommune.com/api/oauth/register \
  -H "Content-Type: application/json" \
  -d '{
    "client_name": "Drift Report",
    "client_uri": "https://driftreport.example",
    "redirect_uris": ["https://driftreport.example/callback"],
    "token_endpoint_auth_method": "none",
    "grant_types": ["authorization_code", "refresh_token"],
    "response_types": ["code"]
  }'

# HTTP/2 201
# {
#   "client_id": "cmn_cid_7Qk2Rm9xTp4Zb1Vw6Ys3Nd8Hc5Jf0Lg",
#   "client_id_issued_at": 1789159049,
#   "redirect_uris": ["https://driftreport.example/callback"],
#   "token_endpoint_auth_method": "none",
#   "grant_types": ["authorization_code", "refresh_token"],
#   "response_types": ["code"],
#   "client_name": "Drift Report",
#   "client_uri": "https://driftreport.example"
# }
```

### 5. Make a PKCE pair for each connection

Every authorization needs a fresh PKCE pair: a random `verifier` your app keeps, and a `challenge` derived from it that goes in the URL. Only the holder of the verifier can turn the code into tokens, so a code intercepted on its way back to you is worthless to whoever caught it.

The challenge method must be `S256`, and it must be sent. Leaving `code_challenge_method` out is refused rather than treated as `plain`, which matters because some OAuth libraries rely on that default. The server does not check the verifier's length, so keep to the 43 to 128 characters the specification asks for; 32 random bytes as base64url is exactly 43.

To check your code, RFC 7636's own pair is `dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk`, whose challenge is `E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM`.

pkce.mjs:

```js
import { createHash, randomBytes } from "node:crypto";

// 32 random bytes as base64url: 43 characters, the shortest legal verifier.
const verifier = randomBytes(32).toString("base64url");

// BASE64URL(SHA256(ASCII(verifier))), unpadded.
const challenge = createHash("sha256").update(verifier).digest("base64url");

// A value you can recognise when the creator comes back.
const state = randomBytes(16).toString("hex");

// Keep verifier against state until the callback: in your session or database.
pending.set(state, { verifier, startedAt: Date.now() });
```

### 6. Send the creator to the consent screen

Redirect the creator's browser to the authorization endpoint with the parameters below. `redirect_uri` must match one you registered byte for byte: no extra query string, no trailing slash. `resource` names the API, so the token cannot be replayed anywhere else. `state` is optional to the server and essential to you: it comes back on every redirect, success or failure, and it is how you know the callback is one you started.

If the creator is not signed in to Commune, they sign in first and come straight back to this URL; you do not handle that. Then they see the consent screen, which always appears, whatever your app sends. It asks which newsletters your app may reach, as a tick box per newsletter they run with **nothing ticked to begin with**, and shows what it may do on them, family by family, from the scopes you asked for. With `account:read` in the request it also shows a switch, off by default, for reading their account.

A request is good for ten minutes. After that the screen says it expired and the creator starts again from your app. A newsletter holds at most twenty live connections, and your app spends one on each newsletter ticked. If one of them is full, the screen refuses and names it, and the creator unticks it or disconnects something there.

open this in the creator's browser:

```text
https://usecommune.com/api/oauth/authorize
  ?response_type=code
  &client_id=cmn_cid_7Qk2Rm9xTp4Zb1Vw6Ys3Nd8Hc5Jf0Lg
  &redirect_uri=https%3A%2F%2Fdriftreport.example%2Fcallback
  &scope=content%3Aread
  &state=9f2c1b7e4a
  &code_challenge=E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM
  &code_challenge_method=S256
  &resource=https%3A%2F%2Fapi.usecommune.com
```

### 7. Handle the callback

The creator comes back to your `redirect_uri` either way. On allow, the query carries a `code` and your `state`. On cancel, it carries `error=access_denied`, an `error_description` and your `state`. The same three parameters also report a request the server refused before the screen: `invalid_request` for a missing or non-`S256` challenge, `invalid_scope`, `invalid_target` for a wrong `resource` and `unsupported_response_type`.

**Check `state` before you touch anything else**, on both branches, and drop a callback whose `state` you do not hold. Then look up the verifier you stored under it.

A problem with the `client_id` or the `redirect_uri` itself never reaches you: until the redirect URI is proven to be yours there is nowhere safe to send an error, so Commune shows it to the creator instead. If creators report an error page instead of arriving back, check those two first.

the two ways back:

```text
https://driftreport.example/callback
  ?code=cmn_ac_4Hd8Kf2Lm6Qp0Rs9Tv3Wx7Yz1Ab5Cd
  &state=9f2c1b7e4a

https://driftreport.example/callback
  ?error=access_denied
  &error_description=The%20newsletter%27s%20owner%20declined%20this%20request.
  &state=9f2c1b7e4a
```

### 8. Exchange the code for tokens

Post the code to the token endpoint straight away: it is single use and lives sixty seconds. Send it form encoded, with the same `redirect_uri` the authorization used, your `client_id`, the `code_verifier` you kept and the `resource`. A public client sends no secret.

The answer holds an access token that lives an hour (`expires_in`), a refresh token, and **the scopes actually granted**. Read `scope` off the response and believe it rather than assuming it echoes your request; the creator can narrow it. You get a refresh token whether or not you asked for `offline_access`.

Store the pair against the creator in your database before you do anything else with it. Every failure here is a `400` or `401` with an `error` such as `invalid_grant`, which covers a code that is unknown, expired, already used or issued to someone else, and a verifier that does not match.

shell:

```sh
curl -sS https://usecommune.com/api/oauth/token \
  -H "Content-Type: application/x-www-form-urlencoded" \
  -d grant_type=authorization_code \
  -d code=cmn_ac_4Hd8Kf2Lm6Qp0Rs9Tv3Wx7Yz1Ab5Cd \
  -d redirect_uri=https://driftreport.example/callback \
  -d client_id=cmn_cid_7Qk2Rm9xTp4Zb1Vw6Ys3Nd8Hc5Jf0Lg \
  -d code_verifier=dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk \
  -d resource=https://api.usecommune.com

# HTTP/2 200
# {
#   "access_token": "cmn_at_2Bc5Df8Gh1Jk4Lm7Np0Qr3St6Uv9Wx2Y",
#   "token_type": "Bearer",
#   "expires_in": 3600,
#   "refresh_token": "cmn_rt_5Ef8Hj1Km4Np7Qs0Tv3Wy6Za9Bd2Cg5H",
#   "scope": "content:read"
# }
```

### 9. See who connected

You will need:

- An access token from step 8. To try this and the next two steps before your app has a connection, an API key with `content: read` answers them exactly as an access token with the same permissions does.

From here on your app talks to the API, and **the bearer on every request is the access token**, sent exactly as an API key would be. Send `Commune-Version` as well: without it the connection is pinned to whichever version was current when the creator consented, a version your code never chose.

This request takes any credential and needs no scope, so it works whatever the creator granted. Use `id` as the key for this creator in your own database. `display_name` can be empty, so fall back to `username`.

`GET /me` ([reference](https://api-reference.usecommune.dev/operation/operation-getme))

```sh
curl "https://api.usecommune.com/me" \
  -H "Authorization: Bearer $ACCESS_TOKEN" \
  -H "Commune-Version: 2026-08-26"
```

Response `200`:

```json
{
  "object": "me",
  "id": "usr_2Nf8Kq1pWc",
  "username": "mira",
  "display_name": "Mira Okafor",
  "avatar": "https://cdn.example.com/avatars/mira.png",
  "email": "mira@example.com",
  "email_verified": true,
  "created_at": "2025-03-04T09:12:00Z"
}
```

### 10. Find the newsletters they ticked

One connection covers every newsletter the creator ticked, and this is where you learn which. It returns those newsletters and nothing else, so read it rather than assuming there is one. Keep each `handle` or `id` (either works in a path): every other request names one.

Call it again whenever it matters. What the token reaches is recomputed on every request from what the creator granted and what they can still do: if they leave a newsletter's team, or an owner cuts your app off one newsletter, it drops out of this list with nothing else changing.

`GET /newsletters` ([reference](https://api-reference.usecommune.dev/operation/operation-listnewsletters))

```sh
curl "https://api.usecommune.com/newsletters" \
  -H "Authorization: Bearer $ACCESS_TOKEN" \
  -H "Commune-Version: 2026-08-26"
```

Response `200`:

```json
{
  "object": "list",
  "data": [
    {
      "object": "newsletter",
      "id": "7d3f1c02-58a1-4a4e-9a0b-2f6d1c9e4411",
      "handle": "example-letter",
      "name": "The Example Letter",
      "description": "A weekly letter about how newsletters and communities fit together.",
      "esp": "commune",
      "image_url": "https://cdn.example.com/newsletters/example-letter/avatar.png",
      "website_url": "https://example.com",
      "social_links": {
        "twitter": "https://x.com/exampleletter",
        "bluesky": "https://bsky.app/profile/exampleletter.bsky.social"
      },
      "language": "en",
      "chat_create_permission": "subscribers",
      "allow_non_subscriber_chat": false,
      "owner": {
        "object": "user",
        "id": "usr_2Nf8Kq1pWc"
      },
      "featured_article": {
        "object": "article",
        "id": "4c9e2f81-0b7a-4d13-8e55-1a2b3c4d5e6f"
      },
      "created_at": "2025-03-04T10:00:00Z",
      "updated_at": "2026-08-26T09:32:11Z"
    }
  ],
  "pagination": {
    "has_more": false,
    "next_cursor": null
  }
}
```

### 11. Read what they granted you

Now the work your app exists for. This needs `content:read`, which is what it asked for, and returns the newsletter's published articles, newest first, 20 at a time; follow `pagination.next_cursor` for more.

A request that needs more than the creator granted answers `403` with `insufficient_scope`, and `allowed_values` names the scope that would have worked. The fix is not a retry: it is another trip through the consent screen asking for the full set you now need.

`GET /newsletters/{newsletter}/articles` ([reference](https://api-reference.usecommune.dev/operation/operation-listnewsletterarticles))

```sh
curl "https://api.usecommune.com/newsletters/example-letter/articles" \
  -H "Authorization: Bearer $ACCESS_TOKEN" \
  -H "Commune-Version: 2026-08-26"
```

Response `200`:

```json
{
  "object": "list",
  "data": [
    {
      "object": "article",
      "id": "4c9e2f81-0b7a-4d13-8e55-1a2b3c4d5e6f",
      "short_id": "k7Rm2xQp",
      "slug": "what-newsletters-get-wrong-about-community",
      "newsletter": {
        "object": "newsletter",
        "id": "7d3f1c02-58a1-4a4e-9a0b-2f6d1c9e4411"
      },
      "title": "What newsletters get wrong about community",
      "preview_text": "The moderation load is the product, not a tax on it.",
      "image_url": "https://cdn.example.com/articles/k7Rm2xQp/cover.png",
      "external_url": null,
      "status": "sent",
      "is_imported": false,
      "posted_at": "2026-08-26T09:32:11Z",
      "scheduled_for": null,
      "authors": [
        {
          "object": "user",
          "id": "usr_2Nf8Kq1pWc"
        }
      ],
      "thread": {
        "object": "thread",
        "id": "b1c2d3e4-f506-4718-8293-a4b5c6d7e8f9"
      },
      "stats": {
        "likes": 148,
        "comments": 27,
        "highlights": 63
      },
      "created_at": "2026-08-24T11:04:52Z",
      "updated_at": "2026-08-26T09:32:11Z"
    },
    {
      "object": "article",
      "id": "5b7a1d90-2c34-4e18-9f6b-8d0a1c2b3e4f",
      "short_id": "q4Ts9wLm",
      "slug": "the-week-we-stopped-chasing-opens",
      "newsletter": {
        "object": "newsletter",
        "id": "7d3f1c02-58a1-4a4e-9a0b-2f6d1c9e4411"
      },
      "title": "The week we stopped chasing opens",
      "preview_text": null,
      "image_url": null,
      "external_url": "https://example.com/p/the-week-we-stopped-chasing-opens",
      "status": "sent",
      "is_imported": true,
      "posted_at": "2026-08-19T09:30:00Z",
      "scheduled_for": null,
      "authors": [
        {
          "object": "user",
          "id": "usr_5Qw8Hn2vFd"
        }
      ],
      "thread": null,
      "stats": {
        "likes": 61,
        "comments": 9,
        "highlights": 14
      },
      "created_at": "2026-08-26T20:21:09Z",
      "updated_at": "2026-08-26T20:21:09Z"
    }
  ],
  "pagination": {
    "has_more": true,
    "next_cursor": "Y3Vyc29yOjE3NTY0MjM2MDAwMDA6MDE5MmM4"
  }
}
```

### 12. Refresh before the hour is up

Schedule a refresh against `expires_in` rather than waiting for a `401`. The answer has the same shape as the exchange: a new access token, **a new refresh token**, and the grant's scopes. The old refresh token is dead before the response reaches you, so write the new pair down before you use it.

**Never refresh the same token twice.** Presenting a refresh token that was already used revokes the creator's whole connection, because a replay and a theft look the same from the server, and the creator has to consent again from scratch. Two workers refreshing at once is exactly how that happens, so let one refresh run at a time per connection, as below, or hold a lock in your database.

A refresh token lives ninety days and each refresh restarts the clock, so a connection in regular use never lapses and one nobody has touched in a quarter does.

refresh.mjs:

```js
// One refresh in flight per connection. A second caller waits for the first.
const inFlight = new Map();

function refresh(connection) {
  if (!inFlight.has(connection.id)) {
    inFlight.set(connection.id, (async () => {
      const res = await fetch("https://usecommune.com/api/oauth/token", {
        method: "POST",
        headers: { "content-type": "application/x-www-form-urlencoded" },
        body: new URLSearchParams({
          grant_type: "refresh_token",
          refresh_token: connection.refreshToken,
          client_id: CLIENT_ID,
        }),
      });
      const body = await res.json();
      if (!res.ok) throw Object.assign(new Error(body.error_description), { code: body.error });
      // Persist first: a crash after this line must not leave you holding the spent token.
      await saveTokens(connection.id, {
        accessToken: body.access_token,
        refreshToken: body.refresh_token,
        scope: body.scope,
        expiresAt: Date.now() + body.expires_in * 1000,
      });
    })().finally(() => inFlight.delete(connection.id)));
  }
  return inFlight.get(connection.id);
}
```

### 13. Disconnect, and notice when the creator does

When a creator disconnects from your side, post the refresh token to the revocation endpoint with your `client_id`. The access token issued with it stops working too, since the two are one pair. It always answers `200` with an empty body, whether or not the token was live, so there is nothing to check. This ends your app's hold on the connection; the creator still sees the connection listed on their newsletter until they remove it there, and removing it there is theirs to do.

The creator can also cut your app off at any time, from each newsletter's [MCP page](https://usecommune.com/dashboard/mcp) in Commune, and that ends every token issued under the connection at once, from your very next request. You will see it one of two ways: a `401` that a refresh does not fix, or a refresh answering `invalid_grant` because the connection was revoked. Either way, mark the connection as disconnected and offer the connect link again. Do not retry.

disconnect.mjs:

```js
// Your side: the creator pressed Disconnect in your app.
await fetch("https://usecommune.com/api/oauth/revoke", {
  method: "POST",
  headers: { "content-type": "application/x-www-form-urlencoded" },
  body: new URLSearchParams({ token: connection.refreshToken, client_id: CLIENT_ID }),
});
await deleteTokens(connection.id);

// Their side: a refresh that fails with invalid_grant means start again.
try {
  await refresh(connection);
} catch (err) {
  if (err.code !== "invalid_grant") throw err;
  await deleteTokens(connection.id);
  await askToReconnect(connection.id);
}
```

### All together

A complete integration in one file, with no dependencies: it discovers the authorization server, registers itself on first run, serves `/connect` and `/callback`, keeps the tokens in memory, refreshes one at a time, and on connection shows who connected and each granted newsletter's latest articles. Run it with `node connect.mjs`, open `http://localhost:3000/connect` while signed in to Commune, and set `COMMUNE_CLIENT_ID` to the printed id so later runs reuse the registration. A real app keeps the pending states and the tokens in its database, per creator.

```js
import { createServer } from "node:http";
import { createHash, randomBytes } from "node:crypto";

const API = "https://api.usecommune.com";
const VERSION = "2026-08-26";
const PORT = 3000;
const REDIRECT_URI = `http://localhost:${PORT}/callback`;
const SCOPE = "content:read";

// The authorization server answers RFC 6749 errors; the API answers its own envelope.
async function read(res) {
  const body = await res.json();
  if (res.ok) return body;
  const detail = body.error?.message
    ? `${body.error.code}: ${body.error.message} (request ${body.error.request_id})`
    : `${body.error}: ${body.error_description}`;
  throw Object.assign(new Error(`${res.status} ${detail}`), { status: res.status, code: body.error });
}

// 1. Discovery: the API names its authorization server, which describes itself.
const resource = await read(await fetch(`${API}/.well-known/oauth-protected-resource`));
const server = await read(
  await fetch(`${resource.authorization_servers[0]}/.well-known/oauth-authorization-server`),
);

// 2. Registration, once. The client_id cannot be recovered, so keep it.
let clientId = process.env.COMMUNE_CLIENT_ID;
if (!clientId) {
  const client = await read(
    await fetch(server.registration_endpoint, {
      method: "POST",
      headers: { "content-type": "application/json" },
      body: JSON.stringify({
        client_name: "Drift Report",
        redirect_uris: [REDIRECT_URI],
        token_endpoint_auth_method: "none",
        grant_types: ["authorization_code", "refresh_token"],
        response_types: ["code"],
      }),
    }),
  );
  clientId = client.client_id;
  console.log(`Registered. Run with COMMUNE_CLIENT_ID=${clientId} to reuse it.`);
}

const pending = new Map(); // state -> { verifier, startedAt }
let tokens = null; // { access, refresh, scope, expiresAt }
let refreshing = null;

async function requestTokens(params) {
  const body = await read(
    await fetch(server.token_endpoint, {
      method: "POST",
      headers: { "content-type": "application/x-www-form-urlencoded" },
      body: new URLSearchParams({ client_id: clientId, ...params }),
    }),
  );
  tokens = {
    access: body.access_token,
    refresh: body.refresh_token,
    scope: body.scope,
    expiresAt: Date.now() + body.expires_in * 1000,
  };
}

// One refresh at a time: replaying a refresh token revokes the whole connection.
function refresh() {
  refreshing ??= requestTokens({ grant_type: "refresh_token", refresh_token: tokens.refresh })
    .catch((err) => {
      if (err.code === "invalid_grant") tokens = null; // revoked or lapsed: connect again
      throw err;
    })
    .finally(() => {
      refreshing = null;
    });
  return refreshing;
}

async function api(path, retried = false) {
  if (!tokens) throw new Error("Not connected. Open /connect.");
  if (Date.now() > tokens.expiresAt - 60_000) await refresh();
  const res = await fetch(API + path, {
    headers: { authorization: `Bearer ${tokens.access}`, "commune-version": VERSION },
  });
  if (res.status === 401 && !retried) {
    await refresh();
    return api(path, true);
  }
  if (res.status === 401) {
    tokens = null;
    throw new Error("The connection was revoked. Open /connect to connect again.");
  }
  return read(res);
}

createServer(async (req, res) => {
  const url = new URL(req.url, `http://localhost:${PORT}`);
  const reply = (status, text, headers = {}) =>
    res.writeHead(status, { "content-type": "text/plain; charset=utf-8", ...headers }).end(text);

  try {
    // 3 and 4. A fresh PKCE pair and state, then off to the consent screen.
    if (url.pathname === "/connect") {
      const verifier = randomBytes(32).toString("base64url");
      const challenge = createHash("sha256").update(verifier).digest("base64url");
      const state = randomBytes(16).toString("hex");
      pending.set(state, { verifier, startedAt: Date.now() });

      const authorize = new URL(server.authorization_endpoint);
      authorize.search = new URLSearchParams({
        response_type: "code",
        client_id: clientId,
        redirect_uri: REDIRECT_URI,
        scope: SCOPE,
        state,
        code_challenge: challenge,
        code_challenge_method: "S256",
        resource: API,
      }).toString();
      return reply(302, "", { location: authorize.href });
    }

    // 5. The way back: state first, then the error, then the code.
    if (url.pathname === "/callback") {
      const state = url.searchParams.get("state");
      const started = pending.get(state);
      pending.delete(state);
      if (!started || Date.now() - started.startedAt > 10 * 60_000) {
        return reply(400, "This callback was not started here, or it expired. Open /connect.");
      }
      if (url.searchParams.has("error")) {
        return reply(400, `Not connected: ${url.searchParams.get("error_description")}`);
      }

      // 6. The code lives sixty seconds, so exchange it now.
      await requestTokens({
        grant_type: "authorization_code",
        code: url.searchParams.get("code"),
        redirect_uri: REDIRECT_URI,
        code_verifier: started.verifier,
        resource: API,
      });

      // 7. Who connected, what they granted, and the work itself.
      const me = await api("/me");
      const { data: newsletters } = await api("/newsletters");
      const lines = [];
      for (const newsletter of newsletters) {
        const { data: articles } = await api(
          `/newsletters/${encodeURIComponent(newsletter.handle)}/articles?limit=5`,
        );
        lines.push(`${newsletter.name}`, ...articles.map((article) => `  ${article.title}`));
      }
      const name = me.display_name || me.username || me.id;
      return reply(200, `Connected as ${name}, granted ${tokens.scope}.\n\n${lines.join("\n")}\n`);
    }

    // 8. Your side of letting go: revoking the refresh token ends the pair.
    if (url.pathname === "/disconnect" && tokens) {
      await fetch(server.revocation_endpoint, {
        method: "POST",
        headers: { "content-type": "application/x-www-form-urlencoded" },
        body: new URLSearchParams({ token: tokens.refresh, client_id: clientId }),
      });
      tokens = null;
      return reply(200, "Disconnected.");
    }

    return reply(404, "Try /connect.");
  } catch (err) {
    return reply(500, err.message);
  }
}).listen(PORT, () => console.log(`Open http://localhost:${PORT}/connect`));
```
