SCSuper ConnectDocumentationOpen the admin panel →

How it works

This document explains how Super Connect is put together and why. Read it to get a mental model before integrating.

The four nouns

ProviderOAuth protocol or API key header + API host Integrationyour OAuth client + scopes Connectionone user's grant Invocationone proxy call or action

Configuration is split from credentials on purpose. Endpoint mistakes can be corrected without rotating secrets, and secrets can rotate without touching protocol details.

Organizations are the tenancy boundary

Every table above carries organization_id. An organization is a customer company from the Better Auth organization plugin. It owns its providers, integrations, connections, connect sessions, OAuth states, webhook events, invocations and API keys.

The organization is derived once per request:

  1. An Authorization: Bearer sc_… header is verified by the Better Auth apiKey plugin, whose referenceId is the organization.
  2. Otherwise the session cookie must carry an activeOrganizationId and a membership row.

No URL contains an organization. The credential implies it, so a key from one organization cannot name another organization's resources even by guessing IDs; the query simply finds nothing and the route answers 404.

End users are a separate axis. Your backend creates each one through POST /api/v1/users with its own id; Super Connect keeps them as Better Auth users with an end_user membership that no dashboard or API route accepts. X-User-Id names one by that id on connection routes. It is required with an organization key because a key alone would otherwise let any connection ID reach a provider.

Operators are platform users with user.role = 'admin' from the Better Auth admin plugin. They enter an organization by impersonating a member, so tenancy resolution never has a special case for them.

Request dispatch order

Requests are matched in a fixed order. The order is the security model:

  1. /api/auth/* → Better Auth
  2. /health
  3. /oauth/start, /oauth/callback → browser routes, authorized by the state row and a browser cookie
  4. The /api/v1 prefix is stripped. Anything else at the root other than /proxy/* answers 404, so the SPA owns the remaining paths
  5. /connect/session* → authorized by the connect session token
  6. /operator/* → authorized by an operator session
  7. Organization resolution happens here. Everything after it is organization-scoped.
  8. Providers, integrations, connect sessions, connections, actions, invocations
  9. /proxy/:id/* as the fallthrough

Anything that does not match answers 404. Thrown TenantError and ConfigError map to their status; anything else is 500.

Presets are read-only until used

Super Connect ships a few hundred built-in providers. GET /api/v1/providers lists only the organization's own rows. A preset becomes a row only when the organization saves it or an integration references it. Deleting a never-saved preset is 404 because there is nothing to delete.

This keeps the catalog free to update without migrating every organization, while a saved copy protects an organization from upstream changes.

Connect sessions and the hosted page

The browser never sees an organization key. Instead:

POST /api/v1/connect/sessions token, connectLink token openConnectUI, setSessionToken GET /api/v1/connect/session POST /api/v1/connect/session/authorize handoff URL popup: /oauth/start then consent /oauth/callback?code callback page posts result connect { connectionId } Backend Frontend Connect UI (iframe) Super Connect Provider

A session is scoped to one end user, an optional integration allowlist and a lifetime. Only its hash is stored, and it is consumed on a successful callback. A session minted with connectionId runs in reauthorize mode and inherits the connection's owner and integration, so it cannot be widened.

Consent opens in a popup because most providers refuse to be framed. The callback renders HTML rather than JSON so it can post to its opener and still read sensibly when a connect link is opened standalone.

OAuth state and browser binding

/api/v1/connect/:integrationId and authorize create an oauth_states row with a random state, the redirect URI, an optional PKCE verifier and a ten-minute expiry. /oauth/start claims the row for one browser by storing a hashed cookie value; a second browser gets "already used". /oauth/callback checks the cookie, deletes the row atomically, and only then exchanges the code.

Token lifecycle

consent completed refresh fails refresh succeeds (recovery) fourth failure day user reauthorizes (override) healthy failing needs_reauthorization

refreshing is an overlay, true whenever a refresh lease is active.

Two things trigger a refresh: the ten-minute cron sweep, which picks connections whose next_refresh_at has passed (15 minutes before expiry, or 24 hours after the last refresh, whichever is sooner), and a proxy request that finds the token within 15 minutes of expiry.

Both go through the same refresh routine, which:

  1. dedupes concurrent callers in-process with an inFlight map
  2. takes a 30-second lease with a conditional UPDATE … WHERE refresh_until < now AND access_token = ?, so only one isolate refreshes
  3. exchanges the refresh token and writes back under the same lease
  4. on failure records the error, schedules the next attempt for tomorrow, and counts one attempt per UTC day; the fourth day sets refresh_exhausted

A proxy caller that meets a live lease waits up to 12 seconds, then answers 409 connection_refresh_backoff.

Health is never stored. It is derived from refresh_until, refresh_exhausted and last_refresh_failure, so it cannot drift from the facts.

Webhook outbox

Refresh failures, recoveries and reauthorizations insert a webhook_events row in the same D1 batch as the connection update, guarded by the value that update wrote. The exact body is persisted before delivery, so retries send identical bytes. Delivery is attempted immediately via waitUntil and again by the cron sweep. Events for one connection go out in sequence order; a stuck event blocks only its own connection.

Inbound webhooks

Each integration owns a public receive URL, /webhook/<receive id>, and one encrypted inbound secret. A provider POST is verified against that secret by the webhook adapter of the provider's template, src/catalogs/<template>/webhook.ts, which is carried through the catalog port. Its receive is a pure function over the raw body and headers that either answers a handshake itself, rejects the request, or identifies the event. The provider is answered at that point. Then, in waitUntil, the adapter's recipients sees every connection of the integration with its metadata, filled either from a provider-declared token-response allowlist, such as Slack's team.id and authed_user.id, or by the customer over the API, as GitHub's repository and organization IDs are, and returns the connections to forward to. The log row and one outbox row per recipient are written in one batch, so a slow customer endpoint cannot cost the provider its acknowledgment. No recipients, or an adapter that throws, forwards nothing; there is no forward-to-everyone fallback (ADR 0001).

Every received request is logged in inbound_webhooks, verified or not. The raw body is kept for seven days and then nulled by the cron sweep; the rows stay.

Proxy and actions

One request path is the only way to reach a provider. It resolves the target against apiBase, rejects anything that leaves the origin or path prefix, refreshes if needed, injects the bearer token, and never follows redirects.

The proxy forwards the caller's request through it with a fixed header allowlist. Actions call it through a small adapter that validates input with the action's Zod schema, runs exec under a 30-second wall clock and a 50-subrequest cap, validates the output, and returns a discriminated ActionResult. actionResponse maps each result code to an HTTP status.

Catalogs load lazily per template through dynamic import(), so a Worker that never touches Slack never parses Slack's actions.

SDKs

@super-connect/client wraps every management route. On a backend it sends the organization key; in a browser without a key it sends the session cookie. The admin panel uses this same package, so the shipped client is the one the product exercises.

@super-connect/connect is the browser side of the connect flow: openConnectUI for the modal, createSessionClient for a custom picker against the two session routes.

Both packages are dependency-free and resolved from source in this repository through tsconfig paths and a Vite alias.

What is deliberately not here