# @leumas/connectors

Connectors — logged-in users link third-party accounts (databases, social/OAuth, SSH/SFTP). A per-provider connector registry + createConnectorsRouter (CRUD connections + test-connection). Absorbs...


Logged-in users **link third-party accounts** — databases (Mongo, SQL, Redis, Firebase),
media (Cloudinary), social/OAuth (Google, GitHub, Facebook, LinkedIn, YouTube, TikTok), and
SSH/SFTP hosts. One connector registry powers the API, the connect form, and the Studio page.

**Absorbs (DRY-consolidated from):** `leumas-middleware/database-connector` (Mongo/SQL/Redis/Firebase/
Cloudinary adapters) + `leumas-cursor-agent-mvp` (`databasesController`, `socialConnectionsController` =
OAuth, `sshProfilesController` = SSH/SFTP).

## The model

A **Connector** describes one integration a user can link — its `provider` id, `family`
(`database | media | social | ssh`), the form `fields`, which fields are `secret`, and a
`test(config)` probe. Drivers (`mongodb`, `redis`, `ssh2`, …) are **lazy-loaded inside `test()`** so
this engine has no hard driver dependency.

A **Connection** is a saved link: `{ id, owner, provider, name, config, lastTest }`. Secrets are
never returned to the client (masked as `••••••`).

## Usage (in leumas-api)

```js
import { createConnectorsRouter } from '@leumas/connectors';
import { requireAuth } from '@leumas/auth';

// `store` persists the per-user `connections` collection (back it with @leumas/middleware).
app.use('/api/connectors', createConnectorsRouter({ store, requireAuth }));
```

### The `store` contract

```
store.list({ owner })                → Connection[]
store.get(id, { owner })             → Connection | null
store.create(connection, { owner })  → Connection
store.update(id, changes, { owner }) → Connection
store.remove(id, { owner })          → void
```

`createMemoryStore()` is a zero-dep default for dev/tests.

## Routes

| Method | Path | Purpose |
| --- | --- | --- |
| GET | `/providers` | connector manifests (fields, family, auth) |
| GET | `/` | this user's connections (secrets masked) |
| POST | `/` | create `{ provider, name, config }` |
| GET/PUT/DELETE | `/:id` | read / update / remove a connection |
| POST | `/:id/test` | test a saved connection |
| POST | `/test` | test an unsaved `{ provider, config }` |
| GET | `/:provider/authorize` | OAuth authorize URL (social connectors) |

## Add a provider

```js
import { defineConnector } from '@leumas/connectors';

export const stripe = defineConnector({
  provider: 'stripe', family: 'media', name: 'Stripe',
  fields: [{ name: 'secretKey', type: 'password', required: true }],
  async test(config) { /* probe */ return { ok: true }; },
});
```

Pass it in `connectors: [...allConnectors, stripe]` to `createConnectorsRouter`, or add it to
`src/providers/index.js`.

## Operations — what a connection can actually DO

A connector carries an **`.integration`** payload (`{ baseUrl, auth, operations }`) and that one
property is what makes it callable: `executeOperation`, `createIntegrationRunner`, the
`/:id/operations` route, the grid's `integration:` cell and `@leumas/invoke`'s `integration` kind all
look for it and nothing else.

- `api`-family connectors get it from `defineApiIntegration` (`src/providers/api/catalog.js`).
- **OAuth connectors get it from `src/providers/operations/<provider>.js`** — one pure-data file per
  provider, assembled by `operations/index.js` and attached in `providers/social.js`. 156 operations
  across 27 providers.
- A provider with no catalog is listed in `UNCATALOGUED` with the **sentence saying why**, which the
  Studio panel shows instead of an empty list. The rule: a provider gets a catalog when a bearer
  token is enough to call it; when it is not, it gets a sentence.

Declare parameters with the compact spec in `operations/_shared.js`:

```js
params: args({
  '*id': 'string|Required — the star means required.',
  part:  'string=snippet|A default, applied before the call.',
  mine:  'boolean=true|Typed: this is a real boolean, not "true".',
})
```

An operation may also declare `scopes: [...]` (needs more than the read-ish defaults),
`bodyStyle: 'json' | 'form' | 'multipart'`, and `uploadStyle: 'google-resumable'`.

> [warning] **A declared scope must be in the provider descriptor's `scopes` or `optionalScopes`.** If it is
> in neither, the authorize URL never asks for it — so it can never be granted and the operation can
> never work, on any account, with no error anywhere. `pnpm smoke:connector-ops` asserts this.

## Environment

| Variable | What it does |
| --- | --- |
| `LEUMAS_SECRET_KEY` | Seals every credential at rest (aes-256-gcm, `lsx1:` prefix). **Without it tokens and database passwords are stored in clear text**, and the Connections page says so on the page rather than leaving you to infer it. |
| `LEUMAS_OAUTH_<PROVIDER>_CLIENT_ID` / `_SECRET` | This deployment's registered OAuth app. `<PROVIDER>` is the provider id uppercased with dashes as underscores — `google-sheets` → `LEUMAS_OAUTH_GOOGLE_SHEETS_CLIENT_ID`. The Google family falls back to the plain `GOOGLE` pair, so one registered Google app serves Docs, Drive, Sheets, Gmail, Calendar and YouTube. |
| `LEUMAS_OAUTH_SHARED_APPS` | `1` makes those env apps **shared defaults a tenant may override** with their own, instead of **pinned** (the default, where env wins and the UI locks the form). See below. |
| `LEUMAS_OAUTH_REDIRECT_URI` | Pins the callback URI. Otherwise it is derived from the address the **browser** is at — env → `X-Forwarded-Host` → `Origin` → `Referer` → `Host` — never from the listening socket, because in dev the browser is on Vite and `127.0.0.1` and `localhost` are different origins to Google. |

### Pinned vs shared OAuth apps

Two modes, opposite failure modes, both real — so it is a flag rather than a flip:

- **Pinned (default).** Env wins outright and the provider shows as `locked`. An operator must not be
  able to silently repoint a fleet's or a vendor's Google app by saving a row over it.
- **Shared (`LEUMAS_OAUTH_SHARED_APPS=1`).** Env is *Leumas's own* registered app, offered so a new
  Studio can connect an account with no OAuth paperwork — and a tenant that registers their own app
  **beats it**. `status()` reports `shared: true` (works today, not locked) or `overrides: 'shared'`
  (your own app is the one in use).

A tenant's own app is the better one whenever it exists: their name on the consent screen, their
quota, and no shared-app suspension taking them down with it.


---
Source: shared/engines/connectors/README.md
Canonical: https://docs.leumas.tech/p/engines/connectors
