Commune
API

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.

Before you start

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.

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 app that asks creators for content: read. The access token it gets back is exported 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.

Run it in your client

Every request below, in order, as a collection. Import the file into Postman, Insomnia, Bruno, Yaak or Hoppscotch, set accessToken, and send them one by one.

Download the collection

Every request sends Authorization: Bearer $ACCESS_TOKEN and Commune-Version: 2026-08-26.

  1. 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. 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
    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. 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
    {
      "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. 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
    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. 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
    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. 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
    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. 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
    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. 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
    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. 9

    See who connected

    You'll 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/meReference
    cURL
    curl "https://api.usecommune.com/me" \
      -H "Authorization: Bearer $ACCESS_TOKEN" \
      -H "Commune-Version: 2026-08-26"
    Response 200
    {
      "object": "me",
      "id": "usr_2Nf8Kq1pWc",
      "username": "mira",
      "display_name": "Mira Okafor",
      "avatar": "https://cdn.example.com/avatars/mira.png",
      "email": "[email protected]",
      "email_verified": true,
      "created_at": "2025-03-04T09:12:00Z"
    }
  10. 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/newslettersReference
    cURL
    curl "https://api.usecommune.com/newsletters" \
      -H "Authorization: Bearer $ACCESS_TOKEN" \
      -H "Commune-Version: 2026-08-26"
    Response 200
    {
      "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. 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}/articlesReference
    cURL
    curl "https://api.usecommune.com/newsletters/example-letter/articles" \
      -H "Authorization: Bearer $ACCESS_TOKEN" \
      -H "Commune-Version: 2026-08-26"
    Response 200
    {
      "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. 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
    // 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. 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 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
    // 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.

connect.mjs
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`));

Next use cases

See all