# Beans Connect OAuth and webhooks

The protocol and durable delivery implementation are available in the free local lab. Hosted identity, deployment, legitimate vendor access, and a real receiving system are **not** accepted by these tests. Production organizations remain disabled independently in SQL. The existing JSON preview endpoints remain compatible; they are not the OAuth endpoints.

## Run the complete local flow

With Node 24, Docker and the cached `postgres:17-alpine` image:

```sh
npm ci --ignore-scripts
npm run connect:lab -- --lab=partner-preview
```

Open `http://127.0.0.1:18789/oauth-lab`. Start OAuth, continue as the explicitly fictional Human, review permissions, prepare the local receiver, subscribe, import a fictional record, and deliver its event. The reference client runs on the lab server; its secrets never enter the page. The receiver uses a real local TLS socket and a generated private trust certificate. An exact test-only DNS/socket adapter maps `receiver.beans.test` to loopback; the deployed worker has no such override.

In a second terminal:

```sh
npm run connect:lab:worker -- --lab=partner-preview
# Optional explicit local loop, no installed daemon and no paid scheduler:
npm run connect:lab:watch -- --lab=partner-preview
```

The named lab retains OAuth token state, encrypted signing keys, queued events, recipient deduplication receipts, and organization configuration. A fresh local TLS certificate/port is persisted on each restart. Both the Mac and Docker must be running; this is not an always-on hosted service. Stop the loop with Control-C. The Vercel worker template deliberately has **no paid-frequency cron**.

## OAuth contract

The server uses `@node-oauth/oauth2-server` 5.3.1, and the independent reference client uses `oauth4webapi` 3.8.8. The contract supports OAuth authorization code and rotating refresh tokens, mandatory S256 PKCE, required pushed authorization requests (PAR), exact registered callbacks, pinned issuer metadata, an `iss` authorization response, and token revocation. It does not advertise OIDC, implicit, password, client credentials, dynamic registration, introspection, or arbitrary resource audiences.

| Route | Contract |
|---|---|
| `GET /.well-known/oauth-authorization-server` | Pinned configured issuer and supported methods |
| `POST /oauth/par` | Form-encoded authorization parameters plus single-use `invitation`; returns 90-second `request_uri` |
| `GET /oauth/authorize` | Exactly `client_id` and `request_uri`; consumes the reference once and redirects to the fixed consent page |
| `POST /oauth/interaction` | Beans owner session bearer token; JSON `preview`, `approve`, or `deny`; no cookie authentication |
| `POST /oauth/token` | Form-encoded code/PKCE or refresh exchange; opaque access and refresh tokens |
| `POST /oauth/revoke` | Form-encoded token revocation; 200 also for unknown tokens |

A PAR requires `client_id`, `response_type=code`, `redirect_uri`, `state` (32–256 base64url characters), `scope`, `code_challenge` (43 base64url characters), `code_challenge_method=S256`, and `invitation`. The invitation stays in the direct request body, never the authorization URL. The browser receives only a short-lived reference; the consent interaction is in a fragment, excluded from HTTP logs and referrers.

An operator registers `none` for a public server client or `client_secret_post` for a confidential client. Confidential clients supply their secret to PAR, token, refresh and revocation requests, in addition to PKCE. HTTP Basic and duplicate parameter names are rejected, matching discovery. No browser CORS is offered on partner token/resource routes. Use a backend for integrations. Native owner sessions are distinct from partner tokens.

Pending consent lasts ten minutes. An approved code lasts at most five minutes. Access tokens last ten minutes; the refresh family and underlying grant are bounded to seven days and refresh cannot extend that deadline. A failed PKCE exchange consumes the code. Code reuse or rotated refresh reuse revokes the entire family, including outstanding mint claims. Concurrent refreshes therefore require client serialization. After a lost token response, start a new authorization; do not blindly retry the old credential. Revocation is idempotent and invalidates the family and its webhook authority.

Only a current Human authorized for the exact pet can approve. A staff role is never a pet grant. Current organization, app revision, OAuth client revision, connection revision, Human authority, grant and expiry are rechecked. Changing client configuration or callbacks invalidates previous authorizations. Legacy JSON approval/token exchange cannot process an OAuth authorization.

### Directional scopes

At least one resource scope is required. The requested set must be within the operator-reviewed client scopes, the installation capabilities and the Human's consent. SQL applies the scope even when the client calls an older resource URL.

| Scope | Permitted operation |
|---|---|
| `records:read` | Read this connection's source records, including corrections/tombstones |
| `records:write` | Import/update source records through the existing validated ingestion contract |
| `summaries:request` | Create and inspect summary requests; never send a Human's private draft |
| `summaries:read` | Read summaries actually received by the service after exact Human review |
| `events:read` | Read change references, further filtered by the matching resource read scope |
| `webhooks:subscribe` | Subscribe this authorization to the app's verified endpoint; also needs `events:read` and a resource read scope |

The underlying legacy adapter capabilities still exist for native compatibility. They do not replace OAuth's directional checks. Token responses include the standard space-separated `scope` and the exact `connection_id`; they never include a household roster. Refresh can narrow scopes but cannot add them.

### Reference client

Install `oauth4webapi@3.8.8` alongside `oauth-client.mjs` and run it on your server:

```js
const client = new BeansOAuthClient(issuer, clientID, {clientSecret});
const pending = await client.begin({invitation, redirectURI,
  scopes: ['records:read', 'events:read', 'webhooks:subscribe']});
// Keep pending privately. Redirect the Human to pending.authorizationURL.
const tokens = await client.exchange(exactCallbackURL, pending);
// Persist the entire rotated pair atomically before using it.
const renewed = await client.refresh(tokens.refresh_token);
await client.revoke(renewed.refresh_token);
```

The client verifies exact callback, issuer and state, uses the maintained client's response validators, rejects cross-origin endpoint discovery, bounds responses to 64 KiB and requests to 15 seconds, and does not automatically retry credential exchanges.

## Registered webhooks

A verified organization administrator or developer registers one endpoint per app in `/workspace`. The endpoint must be public HTTPS with no credentials, query, fragment, alternate port or redirect. Each attempt re-resolves **all** DNS answers, rejects private/reserved/mapped internal addresses, pins the validated address into the socket, and verifies the original TLS hostname. DNS and response processing share a ten-second deadline. The deployed worker cannot select the lab transport through environment variables.

Registration generates a 32-byte signing key and shows it in a private receipt. Save it in the recipient's secret store. Only the same still-authorized member can recover the exact mutation receipt for ten minutes; routine workspace reads never return key material. Refreshing the page hides the key. The server stores an AES-256-GCM envelope, bound to the app and signing-key ID; the master key lives separately in private local state or deployment secrets. Neither raw signing keys nor OAuth tokens are stored in PostgreSQL.

Configure the receiver, then run the worker. Its signed `endpoint.verification` envelope contains a random challenge. A successful receiver verifies the signature and echoes the exact challenge in the `Beans-Verification` response header. A generic 200 page cannot activate an endpoint. The app must still be enabled and synthetic when the challenge completes.

Subscribe with the scoped OAuth bearer token:

```http
POST /partner/v1/connections/{connection_id}/webhooks
Content-Type: application/json
Authorization: Bearer <access token>

{"enabled":true}
```

`GET` on the same URL reports subscription and resync status. `enabled:false` cancels pending notifications for that authorization. Subscriptions begin at the returned revision. Bootstrap and reconcile with the event feed; registration is not a historical replay. A changed destination cancels old subscriptions and queued work, and requires new verification and reconciliation. Signing-key rotation pauses normal dispatch until the new key is installed and verified; existing event IDs are preserved. Disabling destroys the current encrypted key and cancels queued work/subscriptions.

### Delivery and receiver behavior

Events contain only version, event ID, event type, connection ID, ordered revision, resource ID, source revision and occurrence time. They contain no record bodies, Human names, global pet IDs, household IDs or drafts. Fetch current content through the resource API, which rechecks authority. Already delivered information cannot be recalled.

Headers are `Beans-Event-ID`, `Beans-Key-ID`, and `Beans-Signature: t=<seconds>,v1=<hex HMAC>`. Compute HMAC-SHA256 over the timestamp, a period, and the **raw body bytes**. Check the event ID matches the signed JSON, select the configured key by key ID, compare in constant time, and accept timestamps within five minutes. Never verify reserialized JSON.

`webhook-receiver.mjs` and `webhook-transport.mjs` (install `ipaddr.js@2.5.0` alongside them) provide verification and acceptance semantics. Its `commit(id, bodyHash, event)` callback must atomically apply the effect and store the identity/hash in the recipient database. Return `accepted` for new events, `duplicate` for the same authenticated event/hash, and reject a changed body. Acknowledge authenticated duplicates with 2xx: a lost acknowledgment must not apply an effect twice. Retain deduplication receipts for at least 30 days. The lab has a real durable PostgreSQL inbox demonstrating this contract.

Delivery is **at least once**, not exactly once. A timeout or lost response retries the immutable event ID. Transient network errors, HTTP 408/425/429 and 5xx use exponential backoff from 30 seconds with bounded jitter, capped at one hour. Other non-2xx responses go to the dead-letter state; redirects are never followed. Attempts stop after eight tries or 24 hours. An authorized staff member can request up to three explicit replays within seven days, keeping the same ID. Current authority is rechecked immediately before every dispatch. A revocation committed after that final check can race an already-started HTTP request; subsequent resource fetches still fail closed.

A 60-second lease and `SKIP LOCKED` claims prevent normal worker overlap. Lost completion persistence waits for lease recovery and may redeliver the same ID. Rotated/stale leases cannot activate an old verification challenge. The workspace shows queue/dead-letter counts, reconciliation requirements, and bounded failed IDs/statuses, without event bodies or pet IDs. Pending queues are bounded to 10,000 per endpoint and subscriptions to 1,000; overflow stops the affected subscription and requires reconciliation rather than silently pretending all changes were delivered. Delivered/dead/cancelled rows are retained for 30 days with bounded cleanup batches.

## Operator and deployment contract

Apply migrations 037 and 038 after 036 with the migration owner. Restricted runtime, worker, administrator and provisioner roles remain separate. The migration creates no login, production organization or real pet mapping. Keep feature switches off until role/identity acceptance. Operator commands are available through `npm run connect:provision`:

- `configure_oauth`: `organizationID`, `requestID`, `verificationReference`, `appID`, `expectedRevision`, `method`, `scopes`.
- `issue_invitation`: the same organization/request/reference/app identifiers plus the already verified `connectionID`. The invitation lasts 15 minutes and does not create or verify the pet mapping.
- `rewrap_webhook_keys`: `verificationReference`; reads only encrypted custody envelopes and rewraps them under the active master key.

Pass a JSON request on stdin. For a confidential client or invitation, provide an absolute `--credential-file=/private/path/receipt.json`; the CLI writes a new 0600 file before provisioning, reuses it on exact request recovery, and never prints the credential. Do not commit that file. `--lab=partner-preview` targets only the exact labeled local container. Hosted use requires the separate TLS provisioner credential. Existing operator approval and invitation limits still apply; no production enablement command exists.

API deployment configuration:

- Existing pinned owner Clerk issuer/public key/audience and session revocation authority.
- `BEANS_CONNECT_SYNTHETIC_PARTNERS=1`, dedicated 64-hex `BEANS_CONNECT_CODE_KEY`.
- `BEANS_CONNECT_OAUTH_ENABLED=1`, exact HTTPS origin `BEANS_CONNECT_OAUTH_ISSUER`, fixed HTTPS `BEANS_CONNECT_CONSENT_URL` ending `/consent`.
- The consent origin in `BEANS_ALLOWED_ORIGINS`. OAuth browser sessions require matching signed `azp`, `sid`, issuer, audience and expiry. The administrative audience remains separate.
- `BEANS_CONNECT_WEBHOOK_KEYRING` on the admin service and worker: `{"active":"v1","keys":{"v1":"<64 hex characters>"}}`. Never on the static portal or public browser config. Up to three versions permit rotation.

The portal accepts public owner values `BEANS_CONNECT_OWNER_PUBLISHABLE_KEY` and `BEANS_CONNECT_OWNER_API_ORIGIN`, separate from the workspace public configuration. Both owner identity frontend and API origins must already be pinned in `connect-src`; wildcard CSP is rejected. The maintained Clerk SDK manages session tokens. Use session-bound claims, not a detached JWT template. Hosted identity configuration must issue the correct audience for each surface; do not relax the audience/azp checks to make sign-in work.

Worker deployments use the dedicated worker database credential, existing synthetic enable flag and `CRON_SECRET` for authenticated calls. The optional local loop is the zero-cost scheduler. No paid Supabase branch, new hosted project, paid Vercel cron, or production database change is needed for this evidence.

### Rotation and restore

For master-key rotation, distribute a keyring containing old and new versions to both services, choose the new active version, run `rewrap_webhook_keys` with an operator review reference, and verify no current envelope references the old version. Keep the old version until ten-minute receipt recovery has expired and cleanup ran. Rewrapping preserves the signing secret and does not require receiver changes. Signing-key rotation is a different operation and does require receiver installation and verification.

Back up the database and the private lab/keyring state separately with restricted access. Restore into an isolated local database first, stop all but one worker, verify keys decrypt, and let expired leases retry with the original event IDs. The recipient inbox must also be restored or retained for duplicate safety. Losing the master key requires new signing keys and endpoint verification; a database dump cannot recover it. After restoring an older database, invalidate OAuth authorizations and reconcile recipients before dispatch, because a backup may precede revocation or acknowledgment.

The automated suite checks restricted-role execution, independent OAuth interoperability, wrong PKCE, concurrent refresh replay, directional scopes, key/destination changes, verification fencing, transient/permanent failures, receipt expiry, master-key rewrap, backup/restore and two durable lab restarts. Browser evidence covers desktop/mobile consent, registration state, signing-key rotation and a local TLS exchange. These are local synthetic proofs, not a real-vendor or hosted identity certification.

Protocol references: [maintained server PKCE documentation](https://github.com/node-oauth/node-oauth2-server/blob/master/docs/guide/pkce.md), [OAuth pushed authorization requests](https://www.rfc-editor.org/rfc/rfc9126), and the pinned packages' source and test receipts in this repository.
