# Beans Connect partner preview

Status: implemented and verified with fictional partners, 23 October 2026. The approved Phase 0–1 increment is a native invitation/consent flow, a narrow partner API, a durable delivery worker, and two synthetic provider registrations. The schema and owner API are deployed; partner HTTP activation and the worker remain disabled in production. It does not connect to a real clinic, insurer, or Chewy account. See the [deployment receipt](../../../docs/evidence/beans-connect-deployment.json) and [Build 44 release](../../../docs/release/testflight-build-44.md).

Start with the [OpenAPI contract](partner-openapi.json), [server reference client](partner-client.mjs), its [TypeScript declarations](partner-client.d.mts), and [owner/worker contract](CONTRACT.md). The [approved plan](../../../docs/plans/beans-connect-ecosystem.plan.md) and [validation evidence](../../../docs/evidence/beans-connect-foundation.md) explain the expansion path and current proof.

## Run a complete local example

Use Node 24, Docker, and the `postgres:17-alpine` image. Run from `Services/LiveBroker`:

```sh
npm ci --ignore-scripts
docker pull postgres:17-alpine
npm run connect:sandbox
```

Open `http://127.0.0.1:18789/sandbox`. The script creates its own disposable database, applies the current schema, seeds one fictional family/pet/clinic, and runs the real HTTP service and worker with separate restricted database roles. It ignores production database settings. Stop with Ctrl-C; it removes only its own container. The sandbox skips migrations 021–022, which provision hosted Supabase extensions/scheduling; all application migrations through 032 run. A crashed process may leave its labeled Docker container for targeted cleanup.

Click **Create invitation**, **Review test access**, and **Approve test access**, then import a record and request a summary. The web reviewer simulates the Human; it does not authenticate a real Beans account. **Process reviewed delivery** runs one worker iteration; an empty queue produces no delivery. To send a summary through native stores, use the simulator test below. The web page deliberately cannot auto-send a summary just because a partner requested one.

The regular app uses its configured hosted API. Opening this web page does not redirect the app to localhost. The automated simulator test injects only a test transport and fictional identity; it uses the real native stores and real HTTP response bytes.

For a self-contained API/database/reference-client/worker check, with the interactive sandbox stopped:

```sh
npm run test:connect-roundtrip
```

This imports a record, receives a partner-started request, submits exact reviewed text through the owner API, confirms a durable synthetic receipt, exercises a second provider registration (`synthetic_lab`) using the same native contract, revokes access, and confirms subsequent partner reads fail. Both adapters simulate receipts; this does not prove any real vendor mapping.

## Authorization and custody

1. Trusted administration registers a partner application against one organization and an exact callback URL, provisions the pet/patient connection, and creates a short-lived single-use invitation. There is no self-service registration endpoint.
2. The partner server calls `POST /partner/v1/authorize` with that invitation, a random state, and an S256 PKCE challenge. It privately retains verifier/state, bound to its initiating session. It displays only the returned `requestID` to the Human.
3. In Beans, the current family owner selects the pet, opens **Connections → Beans Connect**, enters the request ID, and reviews the named partner, clinic, pet, and permissions. The existing authenticated Beans session supplies authority. A request for a different pet is denied.
4. Approval returns to the exact registered partner callback with `code` and `state`. The partner validates state and exchanges the code with the original verifier. Tokens stay on the partner server; the iPhone never receives them.
5. Every API call rechecks the current application, organization, pet mapping, connection revision, owner authority, effective grant, and token lifetime. Permission changes invalidate earlier authorizations. Disconnect is terminal for that connection; reconnection requires fresh provisioning and consent.

This preview uses invitation-based authorization-code exchange with PKCE. Its authorization and token endpoints accept **JSON**, and it does not expose OAuth discovery, a general browser authorization endpoint, client credentials, dynamic registration, or arbitrary scopes. Do not describe it as a drop-in standards-complete OAuth server. A production standards/interoperability review belongs before public partner onboarding.

Callbacks require HTTPS; explicit numeric loopback HTTP is allowed for this synthetic preview and DEBUG native tests. Callback queries, fragments, and URL credentials are rejected at registration use. No server fetches a supplied callback URL. The reference client refuses HTTP redirects and validates callback origin, path, state, and credential-free URLs.

| Item | Lifetime / behavior |
|---|---|
| Provisioning invitation | Administrator sets expiry; used once to create an authorization |
| Authorization request | 10 minutes |
| Authorization code | At most 5 minutes, bounded by request expiry; single use |
| Access token | 10 minutes; opaque, stored as a SHA-256 hash |
| Refresh token | At most 7 days from first exchange; rotates on every use without extending the original deadline |
| Owner grant | At most 7 days from approval, or an earlier administrative cap |
| Replayed refresh token | Revokes all token generations for that authorization |

Persist rotated credentials atomically. Serialize refreshes per authorization: a retry of a consumed refresh token intentionally revokes its family. If a code or refresh response is lost, obtain fresh authorization rather than repeatedly replaying credentials. Retrying the same owner approval before code consumption is supported and returns the same callback. Raw invitations, codes, access/refresh tokens, verifier/state, callback queries, and record bodies must stay out of logs.

## Direction and scope

Capabilities retain the owner-facing foundation names. They are bound to one verified connection, not a household-wide API:

| Capability | Partner actions | Human action |
|---|---|---|
| `record:read` | Send source records into Beans and page through that connection's imported source records, including deletion tombstones | Read authorized imported records |
| `summary:submit` | Request a summary and read only delivered summaries for that connection | Compose, review exact text and recipient, then submit |

`record:read` does not expose general Beans notes or other pets. `summary:submit` never permits a partner to send a summary on a Human's behalf or inspect drafts/queued work. A request's instructions are untrusted plain text. The composer starts with an editable title and an empty body; it does not follow instructions automatically.

## Routes and data

All POST bodies are JSON, exact fields only, at most 16,384 bytes. Partner routes require server clients and reject any `Origin` header. They do not use cookies or browser CORS. Owner routes use existing Beans session authentication and origin restrictions. Responses are private/no-store. The service returns no more than 1 MB for a partner response.

| Route | Purpose |
|---|---|
| `POST /partner/v1/authorize` | Create a request from a pre-provisioned invitation |
| `POST /partner/v1/token` | Exchange a code or rotate a refresh token |
| `GET /partner/v1/connections/{id}/capabilities` | Confirm current effective authorization |
| `POST /partner/v1/connections/{id}/records` | Ingest an idempotent source event |
| `GET /partner/v1/connections/{id}/records` | Page through this connection's current source records and tombstones |
| `POST /partner/v1/connections/{id}/summary-requests` | Ask the Human to review a summary response |
| `GET /partner/v1/connections/{id}/deliveries` | Page through delivered summaries and receipt IDs |
| `POST /v1/integrations/authorization` | Native owner preview/approval; never uses partner tokens |
| `GET /v1/integrations/requests?animalID={id}` | Native authorized family view of partner requests |

The OpenAPI file documents partner routes. Owner routes and reviewed sends are described in [CONTRACT.md](CONTRACT.md); the native implementation supplies their exact authorization review shape.

`eventID` on record ingestion is a stable source event string of at most 180 UTF-8 bytes. Retries retain the identical ID and payload; conflicting content is rejected. A record includes `externalID`, positive Int32 `sourceRevision`, `title`, `body`, `sourceLabel`, `authorRole` (`clinic` or `lab`), ISO-8601 `observedAt`, and `deleted`. Patient and organization mappings come from trusted provisioning, never from these inputs. A higher revision corrects the logical source record; tombstones require empty title/body and prevent an older event from resurrecting it. Real vendors with opaque versions need a separately verified mapping/reconciliation strategy.

Summary requests use a UUID `eventID`, title (160 UTF-8 bytes), and instructions (2,048 UTF-8 bytes). Identical retries return the same request; changed content conflicts. The preview permits up to 100 unresolved, unexpired requests per connection. Requests expire after 30 days. The Human may dismiss a request, or bind an exact reviewed send to its `partnerRequestID`. GET on summary-requests returns awaiting review, response queued, completed, dismissed, expired, needs attention, or delivery unknown, together with the associated delivery ID. Completion requires a durable recipient receipt. A repeated event retains its original identity and current status; completed or dismissed history does not consume the pending allowance.

Record and delivery pages contain up to 50 items, sorted by UUID, with nullable `nextCursor`. Follow it within a scan. These cursors are **not a change feed**: a later insert or correction may sort before a stored cursor. Start subsequent scans without a cursor and deduplicate by ID/source revision (or delivery ID). Use `GET /partner/v1/connections/{id}/events` for incremental synchronization. Its signed cursor is connection- and authorization-bound, lasts 24 hours, and reports corrections, tombstones, and delivered summaries. The per-connection counter is transactional, so commit ordering cannot skip an earlier event. For bootstrap, capture a feed checkpoint, scan records, then replay the feed from that checkpoint and deduplicate. Store a checkpoint only after consuming its page; follow `hasMore`. A 410 requires a new bootstrap. There are no hosted webhook subscriptions, attachments, policy/claim/order endpoints, or arbitrary vendor proxies in this increment.

Delivered means the configured adapter returned a durable recipient receipt. The two synthetic adapters simulate that receipt in PostgreSQL. It never means a veterinarian reviewed the summary, an insurer accepted a claim, or a store placed an order. In-flight uncertain outcomes remain `delivery_unknown` in the owner interface; workers reconcile instead of blindly resending.

## Server reference client

```js
import { BeansConnectClient } from './integrations/partner-client.mjs';

const client = new BeansConnectClient(serviceOrigin, registeredClientID);
const pending = await client.begin(singleUseInvitation, registeredCallback);
// Store pending privately, bound to the initiating session. Display pending.requestID.
const tokens = await client.exchange(receivedCallbackURL, pending);
// Store tokens in protected server custody; do not return them to the browser.
const permissions = await client.capabilities(tokens.connection_id, tokens.access_token);
const records = await client.records(tokens.connection_id, tokens.access_token);
```

Equivalent token exchange (values supplied by the partner's secure server environment; do not paste real credentials into shell history):

```sh
curl --fail-with-body "$BEANS_CONNECT_ORIGIN/partner/v1/token" \
  -H 'Content-Type: application/json' \
  --data-binary @private-token-request.json
```

The protected input file contains exactly `{client_id, grant_type:"authorization_code", code, code_verifier, redirect_uri}` or `{client_id, grant_type:"refresh_token", refresh_token}`. Avoid logging the response. The synthetic sandbox is the runnable example requiring no manually created credential files.

## Errors, limits, and retry decisions

| HTTP | Meaning / action |
|---|---|
| 400 | Invalid shape or `invalid_grant`; correct input or begin fresh authorization |
| 401 | Missing/malformed bearer token, or unavailable owner session |
| 403 | Current authority unavailable, expired/revoked token, unsupported origin, wrong pet/app/environment; stop sharing |
| 404 / 405 | Unknown route / unsupported method |
| 409 | Immutable request or connection revision changed; refresh and review |
| 429 | App budget or request-history limit reached; back off and inspect limits |
| 503 | Disabled/unconfigured module or internal availability failure; retain immutable event IDs and reconcile before retry |

The preview permits 120 accepted authenticated operations per application per minute (shared across its connections). Inputs are bounded independently. Fixed shared admission budgets additionally bound credential lookup attempts: 300 authorization starts/minute, 300 token exchanges/minute, and 3,000 resource attempts/minute. Failed credential lookups consume admission; 429 includes `Retry-After: 60`. These global budgets complement the existing per-app budget; deploy edge per-client controls before public production access. Request history now has completion/dismissal lifecycle and a pending-only cap.

## Deployment boundary

Application migrations 031–032 and the owner API were deployed on 23 October 2026. Production has no provisioned Connect organizations, connections, or partner credentials; the partner HTTP module and worker remain inactive. Connect tables have RLS and no direct runtime/worker table access. Only scoped SECURITY DEFINER functions are executable by the proper role. Existing account/pet deletion cascades erase the connection graph and tokens.

For a future reviewed synthetic environment, enable HTTP partner routes with `BEANS_CONNECT_SYNTHETIC_PARTNERS=1` and a dedicated 32-byte hex `BEANS_CONNECT_CODE_KEY` held in server secrets. Without both, partner routes return unavailable. The database independently rejects non-synthetic organizations; this flag cannot enable production vendor connections. Rotating the code key invalidates pending approval/code retries; perform a controlled drain and issue fresh invitations. Existing hashed tokens retain their ordinary grant/lifetime checks.

Run the worker separately with [worker.env.example](worker.env.example), a dedicated login belonging only to `beans_connect_worker`, verified database TLS, and `BEANS_CONNECT_ENABLE_SYNTHETIC=1`. The HTTP process does not start background loops. Disable a partner app/organization to deny new access. Drain/reconcile already-dispatched deliveries; disconnect cannot recall data already received.

Changes to partner schemas are versioned under `/partner/v1`. This is the initial preview contract; publish compatibility and migration notes before changing it. Production public OAuth, hosted webhook registration/delivery, self-service organization onboarding, hosted monitoring/retention activation, files, real vendor credentials, and native release acceptance remain launch gates, not enabled fallbacks.

## Reproduce validation

```sh
npm test
npm run test:postgres
npm run test:connect-postgres
npm run test:connect-roundtrip
```

For the real native-store round trip, start a fresh interactive sandbox on port 18789. From the repository root, generate the Xcode project and build tests for an available simulator:

```sh
xcodegen generate
xcodebuild build-for-testing -project BeansVet.xcodeproj -scheme BeansVetCare \
  -destination 'platform=iOS Simulator,id=<simulator-uuid>' \
  -derivedDataPath /tmp/beans-connect-derived CODE_SIGN_IDENTITY=-
```

Copy the generated `.xctestrun` file, add `BEANS_CONNECT_SANDBOX=1` under the `BeansVetTests` target's `EnvironmentVariables`, and execute the copy:

```sh
xcodebuild test-without-building -xctestrun /absolute/path/BeansConnectRoundTrip.xctestrun \
  -destination 'platform=iOS Simulator,id=<simulator-uuid>' \
  -only-testing:BeansVetTests/BeansConnectRoundTripTests CODE_SIGN_IDENTITY=-
```

Confirm the test ran and passed, rather than being skipped. It revokes the fixture connection at the end; restart the sandbox before rerunning. This is simulator integration evidence using a fictional identity, not physical-device, real Clerk login, or full UI acceptance.

## Hardening increment (migrations 033–035; not yet deployed)

The native send body may include `partnerRequestID`; it is part of the exact immutable request and receipt. `POST /v1/integrations/requests` with `{action:"dismiss",animalID,requestID,mutationID}` dismisses under current family/pet authority and returns the pending request page. Dismissal never cancels a response already being delivered.

Adapter calls receive `{signal}` and must honor cancellation and implement `findReceipt`. A 12-second deadline reserves database time within the 30-second lease; expiry becomes unknown delivery, never a successful send or automatic resend. The separate worker has a 50-second default batch budget; `BEANS_CONNECT_BATCH_BUDGET_MS` may be 40,000–300,000 in a suitably provisioned job runner.

`webhook-transport.mjs` contains tested signing, raw-body verification with an atomic replay claim, and bounded HTTPS egress. The signed JSON envelope must contain `id` equal to `Beans-Event-ID`. Verification accepts timestamps within five minutes. Every DNS answer must be public unicast; a selected address is pinned for the request while TLS verifies the registered hostname. Redirects are rejected. This is a transport building block; registration, encrypted signing-key custody, durable webhook outbox, and hosted delivery are still prerequisites before advertising webhooks.

Public partner documentation is built independently from `Services/ConnectPortal`. It includes the actual API/SDK downloads and a truthful availability matrix. It does not contain an authenticated organization dashboard or enable production onboarding.


The event log retains at least 30 days. A bounded worker sweep removes only contiguous prefixes and advances the retention floor atomically. An older cursor returns 410; a fresh feed request returns `resyncRequired` when retained history is incomplete. Save a new feed checkpoint **before** a full record reconciliation, then replay from that checkpoint. Compare the full authoritative record set, including tombstones, with your local copy; do not treat the retained feed as complete historical truth. Refresh token families remain available for reuse detection until at least 30 days after their final expiry. Source records, request and delivery history retain the existing account/pet erasure lifecycle; no new document-retention promise is made.

The separate `Services/ConnectWorker` deployment template adds an authenticated scheduler, bounded processing, retention, and aggregate operational status. It has no HTTP-runtime credentials. Its source and tests are present; no hosted cron, alert subscription, or production worker is active from this change. See `docs/plans/beans-connect-readiness/operations.md` for release gates.


## Free durable local integration lab

`npm run connect:lab -- --lab=partner-preview` retains its named synthetic database and private test state across restarts. Use `npm run connect:lab:worker -- --lab=partner-preview` in a second terminal to run an independent bounded worker batch. These commands ignore production database credentials and require local Docker. They incur no new hosted service charge. For fresh exercises after expiry/revoke, choose a new lab name; existing history is retained. See the operating contract for state location, permissions, recovery and cleanup. Validate restart behavior with `npm run test:connect-lab`. This is not an always-on cloud service or real-provider acceptance.

## Standards OAuth and registered webhooks

The routes above remain the compatible JSON preview. New integrations can use the separately versioned standards OAuth/PAR contract with directional scopes and registered, signed, durable webhooks. See [OAUTH-WEBHOOKS.md](OAUTH-WEBHOOKS.md), [oauth-openapi.json](oauth-openapi.json), and [oauth-client.mjs](oauth-client.mjs). Both protocols remain restricted to synthetic installations until real production acceptance.
