Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
73 changes: 73 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ No architecture to learn — just five words, because all the heavy lifting happ
| Hit an endpoint not yet modeled as an action | `proxy` | Passthrough to the upstream API, with the connection's credentials injected by the gateway. |
| Feed actions to an LLM / build dynamic forms | `catalog` | Runtime JSON Schema (2020-12) for any action or provider — `catalog.action` / `catalog.actions` / `catalog.providers`. |
| Discover what's connected | `apps.list` | Read-only list of the connections you've already linked. |
| Let *your* users connect *their* accounts | `ProjectConnector` | A separate project-scoped client to connect accounts on behalf of your end-users and run actions for them. See [Connect accounts for your users](#connect-accounts-for-your-users). |

Provider and action coverage comes from the gateway, not this package. Discover it at runtime with `oomol.catalog.providers()`, and see [`@oomol-lab/connector-types`](https://github.com/oomol-lab/connector-types) for the providers with precise compile-time types.

Expand Down Expand Up @@ -145,6 +146,77 @@ const { status, data } = await oomol.proxy("github", {
});
```

## Connect accounts for your users

`Connector` runs actions on **your** connections. **`ProjectConnector`** is the other half of the product, for building a SaaS platform on OOMOL: each of **your** end-users links **their own** Gmail / Slack / GitHub / … account through your app, and you run actions on their behalf — the [composio](https://composio.dev) / [pipedream](https://pipedream.com/docs/connect) "managed auth" model.

It's a **separate client**, constructed with a **project API key** (`oo_proj_…`). It exposes only project-scoped operations — completely distinct from the personal `Connector` (different key, methods, and types), so there's nothing to mix up:

```ts
import { ProjectConnector } from "@oomol-lab/connector";

const project = new ProjectConnector({ apiKey: process.env.OOMOL_PROJECT_API_KEY! }); // oo_proj_...
```

Identify each end-user with an opaque `externalUserId` you choose.

### OAuth — create a link, then await completion

```ts
// Returns a pending connection request — send your user to `.authorizationUrl` to authorize.
const request = await project.connect.oauth("user_42", { service: "gmail", connectionName: "work" });
redirectUserTo(request.authorizationUrl);

// Poll until the user finishes (or it fails / expires); returns the final connection request.
const connected = await project.waitForConnection(request);
```

### API key / custom credential — synchronous, no waiting

```ts
const account = await project.connect.apiKey("user_42", { service: "openai", apiKey: "sk-..." });
await project.connect.customCredential("user_42", { service: "jira", values: { email, token } });
```

### Execute on the user's behalf

```ts
// The provider service is derived from the actionId prefix; the user's latest active account is used
// unless you pass `connectionName` (or `connectedAccountId`).
const out = await project.execute(
"user_42",
"gmail.search_threads",
{ query: "is:unread" },
{ connectionName: "work" },
);
```

### Scope to one user

```ts
const user = project.forUser("user_42"); // bind the end-user once; drop the repeated id
await user.connect.oauth({ service: "gmail" });
await user.execute("gmail.search_threads", { query: "from:ceo" });
```

`project.execute` reuses the same [`@oomol-lab/connector-types`](https://github.com/oomol-lab/connector-types) registry as the core path — registered actions get precise input/output, the rest stay loosely callable.

> [!NOTE]
> `connectionName` is the single name for a connection: you assign it on `connect.*`, then pass it back as `execute`'s `connectionName` to target that account (the gateway's wire field is `alias`). The end-user is always `externalUserId`. Connecting by API key or custom credential is synchronous — only OAuth needs `waitForConnection`.

**Coming from composio / pipedream?**

| composio / pipedream | `@oomol-lab/connector` |
| --- | --- |
| `userId` / `external_user_id` | `externalUserId` |
| `connectedAccounts.initiate` / `createConnectToken` (OAuth) | `project.connect.oauth` |
| `connectedAccounts.initiate` + `AuthScheme.APIKey` | `project.connect.apiKey` |
| `waitForConnection()` | `project.waitForConnection()` |
| `tools.execute(slug, { userId, arguments })` | `project.execute(externalUserId, actionId, input)` |
| `composio.getEntity(userId)` | `project.forUser(externalUserId)` |

Full runnable lifecycle — [`examples/project.ts`](./examples/project.ts).

## Why this SDK?

- **Zero runtime dependencies** — `sideEffects: false`, ships only `dist`. It's an in-process HTTP client, nothing more.
Expand All @@ -158,6 +230,7 @@ const { status, data } = await oomol.proxy("github", {
- **`oomol.catalog.action / .actions / .providers`** — runtime JSON Schema for dynamic UIs, validation, or LLM tools.
- **`oomol.apps.list()`** — read-only introspection of your connected apps.
- **`oomol.executeRaw(...)`** — like `execute`, but returns `{ data, executionId, actionId, message }`.
- **`ProjectConnector`** — a separate client (project API key) to build a SaaS platform: `connect.oauth` / `connect.apiKey` / `connect.customCredential`, `waitForConnection`, `execute` / `executeRaw` on a user's behalf, and `forUser` to scope to one user. See [Connect accounts for your users](#connect-accounts-for-your-users).

See [`examples/`](./examples) for runnable, type-checked usage of every method.

Expand Down
1 change: 1 addition & 0 deletions examples/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ OOMOL_API_KEY=api_... bun run examples/basic.ts
| [`feedback-to-notion.ts`](./feedback-to-notion.ts) | Scenario: a Web-standard `/feedback` route → `notion.append_block` appends each note to a Notion page |
| [`catalog.ts`](./catalog.ts) | `catalog.providers` (incl. `{ service, q }` filter), `catalog.actions`, `catalog.action` (JSON Schema) |
| [`apps.ts`](./apps.ts) | `apps.list` (read-only); reading `id` / `service` / `status` / `connectionName` |
| [`project.ts`](./project.ts) | `ProjectConnector` (separate client, project API key): `connect.{oauth,apiKey,customCredential}`, `waitForConnection`, `execute`, `forUser` — connect accounts for your end-users and act on their behalf |
| [`proxy.ts`](./proxy.ts) | `proxy` passthrough — typed GET/POST, `endpoint` / `query` / `headers` / `body` |
| [`scoping-and-options.ts`](./scoping-and-options.ts) | `new Connector({...})`, `using()`, per-call options, `AbortSignal`, timeout/retries, custom `fetch` |
| [`error-handling.ts`](./error-handling.ts) | `ConnectorError` fields, `err.code` discrimination, `isRetryable`, client codes |
Expand Down
74 changes: 74 additions & 0 deletions examples/project.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
/**
* ProjectConnector — connect third-party accounts for YOUR end-users, then act on their behalf.
*
* This is the composio / pipedream "managed auth" model: you are a platform built on OOMOL, and
* each of your end-users (an opaque `externalUserId` you choose) links their own Gmail / Slack /
* GitHub / … account through your app. `ProjectConnector` is a SEPARATE client from the personal
* `Connector` — construct it with a PROJECT API key (`oo_proj_…`); it exposes only project-scoped
* operations.
*
* OOMOL_PROJECT_API_KEY=oo_proj_... bun run examples/project.ts
*
* For precise per-action input/output types + JSDoc on `project.execute`, install
* `@oomol-lab/connector-types` and add one side-effect import per provider (e.g.
* `import "@oomol-lab/connector-types/gmail";`). Without it, every action stays loosely typed.
*/
import { ConnectorError, ProjectConnector } from "@oomol-lab/connector";

// Construct with a PROJECT API key (oo_proj_...). Same Bearer transport as the personal client.
const project = new ProjectConnector({ apiKey: process.env.OOMOL_PROJECT_API_KEY! });

// Your own identifier for the end-user you're connecting accounts for.
const EXTERNAL_USER_ID = "user_42";

async function main() {
// --- OAuth: create a link, send the user to it, await completion --------------------------------
// Returns a PENDING connection request — its `authorizationUrl` is where your user authorizes.
const request = await project.connect.oauth(EXTERNAL_USER_ID, {
service: "gmail",
connectionName: "work", // the name to assign; reuse it later to target this account
returnUri: "https://app.example.com/connected", // where the gateway returns the user afterwards
});
console.log("send your user to:", request.authorizationUrl);

// Poll until the user finishes (or it fails / expires). api-key & custom-credential connects are
// synchronous and need NO waiting — only OAuth does.
const connected = await project.waitForConnection(request, { maxWaitMs: 5 * 60_000 });
console.log("status:", connected.status, "account:", connected.connectedAccountId);

// --- Non-OAuth: connect by API key (synchronous; returns a ready account) -----------------------
const account = await project.connect.apiKey(EXTERNAL_USER_ID, {
service: "openai",
apiKey: "sk-the-end-users-own-key",
connectionName: "default",
});
console.log("connected account:", account.connectedAccountId, "available:", account.available);

// --- Execute an action on the user's behalf -----------------------------------------------------
// The provider service is derived from the actionId prefix ("gmail"); the user's latest active
// account is used unless you pass `connectionName` / `connectedAccountId`.
const out = await project.execute(
EXTERNAL_USER_ID,
"gmail.search_threads",
{ query: "is:unread" },
{ connectionName: "work" },
);
console.log("output:", out);

// --- Scoped sub-client: bind the end-user once, drop the repeated id ----------------------------
const user = project.forUser(EXTERNAL_USER_ID);
const slack = await user.connect.oauth({ service: "slack" });
await user.waitForConnection(slack);
const raw = await user.executeRaw("slack.post_message", { channel: "#general", text: "shipped" });
console.log("executionId:", raw.executionId, "data:", raw.data);
}

main().catch((err) => {
if (err instanceof ConnectorError) {
// e.g. "provider_config_not_found", "connection_alias_conflict", "app_not_ready",
// or the client-only "client_wait_timeout" when the user never finishes OAuth in time.
console.error(`[${err.code}] ${err.message}`);
} else {
throw err;
}
});
16 changes: 15 additions & 1 deletion fixtures/consumer/partial-b/index.test-d.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
// State 3 — partial registration (only gmail). Unregistered services stay loose,
// while the registered service stays precise — both in the SAME program.
import { expectType, expectError } from "tsd";
import { Connector } from "@oomol-lab/connector";
import { Connector, ProjectConnector } from "@oomol-lab/connector";
import "./augment";

const oomol = new Connector({ apiKey: "k" });
const project = new ProjectConnector({ apiKey: "oo_proj_k" });

type SearchOut = { threads: Array<{ threadId: string; snippet: string }> };

Expand All @@ -30,3 +31,16 @@ expectType<Promise<Record<string, any>>>(

// Unregistered loose path accepts arbitrary fields (no schema to check against).
oomol.notion.create_page({ anything: 1, goes: "here" });

// ProjectConnector.execute mirrors the core path: registered → precise, unregistered → loose Record.
expectType<Promise<SearchOut>>(project.execute("u", "gmail.search_threads", { query: "x" }));
expectType<Promise<Record<string, any>>>(
project.execute("u", "notion.create_page", { title: "x" }),
);

// Separation: ProjectConnector exposes ONLY project-scoped operations — never the personal surface.
expectError(project.proxy);
expectError(project.catalog);
expectError(project.apps);
expectError(project.using);
expectError(project.gmail); // closed ProjectApi has no service namespaces
16 changes: 15 additions & 1 deletion fixtures/consumer/with-b/index.test-d.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
// State 2 — B installed AND the action under test is registered (see ./augment.ts).
// The registered action must be precise on both paths; wrong input must error.
import { expectType, expectError } from "tsd";
import { Connector } from "@oomol-lab/connector";
import { Connector, ProjectConnector } from "@oomol-lab/connector";
import "./augment";

const oomol = new Connector({ apiKey: "k" });
const project = new ProjectConnector({ apiKey: "oo_proj_k" });

type SearchOut = { threads: Array<{ threadId: string; snippet: string }> };

Expand Down Expand Up @@ -33,3 +34,16 @@ expectError(oomol.execute(123, { query: "x" }));
// Still open: an UNREGISTERED action remains loose-callable (forward-compat),
// so a never-registered service stays a Record.
expectType<Promise<Record<string, any>>>(oomol.execute("slack.post_message", { text: "hi" }));

// ProjectConnector.execute reuses the SAME registry seam — precise for registered actions, on both forms.
expectType<Promise<SearchOut>>(project.execute("u", "gmail.search_threads", { query: "x" }));
expectType<Promise<SearchOut>>(project.forUser("u").execute("gmail.search_threads", { query: "x" }));
expectError(project.execute("u", "gmail.search_threads", {})); // missing required `query`
expectError(project.execute("u", "gmail.search_threads", { query: 123 })); // wrong type

// Separation: ProjectConnector exposes ONLY project-scoped operations — never the personal surface.
expectError(project.proxy);
expectError(project.catalog);
expectError(project.apps);
expectError(project.using);
expectError(project.gmail); // closed ProjectApi has no service namespaces
12 changes: 10 additions & 2 deletions scripts/test-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
* Exits non-zero on any failure.
*/
import { spawnSync } from "node:child_process";
import { existsSync, mkdirSync, symlinkSync } from "node:fs";
import { existsSync, lstatSync, mkdirSync, realpathSync, rmSync, symlinkSync } from "node:fs";
import { fileURLToPath } from "node:url";
import { dirname, join } from "node:path";

Expand All @@ -24,7 +24,15 @@ const tsd = join(root, "node_modules", ".bin", "tsd");
function ensureSelfLink(): void {
const scopeDir = join(root, "node_modules", "@oomol-lab");
const link = join(scopeDir, "connector");
if (existsSync(link)) return;
// Must be a SYMLINK that resolves to the repo root so fixtures resolve the LIVE build. A real
// directory — or a symlink pointing at a DIFFERENT checkout — is a stale copy (e.g. a prior
// `bun add @oomol-lab/connector`) that would silently shadow dist with old declarations, making
// the type-acceptance suite pass against outdated types. Replace anything that isn't already the
// symlink to this repo.
if (existsSync(link)) {
if (lstatSync(link).isSymbolicLink() && realpathSync(link) === realpathSync(root)) return;
rmSync(link, { recursive: true, force: true });
}
mkdirSync(scopeDir, { recursive: true });
symlinkSync("../..", link, "dir");
}
Expand Down
15 changes: 3 additions & 12 deletions src/connector.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
*/

import { ConnectorError } from "./errors";
import { defaultTransport, send, type RequestSpec, type Transport } from "./http";
import { assertHeadersSafe, defaultTransport, send, type RequestSpec, type Transport } from "./http";
import type {
ActionId,
InputOf,
Expand Down Expand Up @@ -207,17 +207,8 @@ class ConnectorImpl implements ConnectorMethods {
headers["x-oo-connector-alias"] = resolved.connectionName;
}

// Reject CR/LF in any header name or value up front with a non-retryable client error,
// rather than letting `fetch` throw a TypeError that the status-0 retry path would then
// retry for the full budget. CRLF in a header is always a response-splitting attempt.
for (const [name, value] of Object.entries(headers)) {
if (/[\r\n]/.test(name) || /[\r\n]/.test(value)) {
throw new ConnectorError("header names and values must not contain CR or LF characters", {
code: "client_invalid_request",
status: 0,
});
}
}
// Reject CR/LF in any header name or value (response-splitting) before fetch sees them.
assertHeadersSafe(headers);

return {
method,
Expand Down
9 changes: 8 additions & 1 deletion src/errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,13 +16,16 @@ export type ConnectorErrorCode =
| "app_auth_type_mismatch"
| "provider_not_found"
| "provider_not_configured"
| "provider_config_not_found"
| "provider_error"
| "credential_expired"
| "scope_missing"
| "user_oauth_client_required"
| "connection_ambiguous"
| "connection_account_conflict"
| "connection_alias_conflict"
| "connection_request_not_found"
| "connected_account_not_found"
| "rate_limited"
| "proxy_not_supported"
| "proxy_upstream_error"
Expand All @@ -40,6 +43,7 @@ export type ConnectorErrorCode =
| "client_invalid_request" // local precheck failure (e.g. missing apiKey, illegal header) — not sent
| "client_timeout" // request exceeded the client-side `timeoutMs`
| "client_network_error" // transport-level failure (DNS/connection/fetch threw)
| "client_wait_timeout" // `ProjectConnector.waitForConnection` exceeded its overall `maxWaitMs` (NOT a per-request timeout)
// Forward-compat for new backend codes:
| (string & {});

Expand Down Expand Up @@ -94,7 +98,10 @@ const RETRYABLE_CODES: ReadonlySet<string> = new Set([
*/
export function isRetryable(err: unknown): boolean {
if (!(err instanceof ConnectorError)) return false;
if (err.code === "client_invalid_request") return false;
// Client-side terminal conditions: a local precheck never sent, and a `waitForConnection`
// wall-clock cap. Both carry status 0 but must NOT fall through to the status-0 retry heuristic
// below — retrying neither helps (the request was invalid / the user never finished authorizing).
if (err.code === "client_invalid_request" || err.code === "client_wait_timeout") return false;
if (RETRYABLE_CODES.has(err.code)) return true;
if (err.status === 429) return true;
if (err.status >= 500 && err.status <= 599) return true;
Expand Down
18 changes: 17 additions & 1 deletion src/http.ts
Original file line number Diff line number Diff line change
Expand Up @@ -92,12 +92,28 @@ function retryAfterMs(res: Response): number | undefined {
}

/** The idiomatic abort error carried by a signal (its reason, or a standard AbortError). */
function abortErrorFrom(signal: AbortSignal): unknown {
export function abortErrorFrom(signal: AbortSignal): unknown {
return signal.reason instanceof Error
? signal.reason
: new DOMException("The operation was aborted", "AbortError");
}

/**
* Reject CR/LF in any header name or value up front with a non-retryable client error, rather than
* letting `fetch` throw a TypeError that the status-0 retry path would then retry for the full
* budget. CRLF in a header is always a response-splitting attempt. Shared by both clients.
*/
export function assertHeadersSafe(headers: Record<string, string>): void {
for (const [name, value] of Object.entries(headers)) {
if (/[\r\n]/.test(name) || /[\r\n]/.test(value)) {
throw new ConnectorError("header names and values must not contain CR or LF characters", {
code: "client_invalid_request",
status: 0,
});
}
}
}

/** Sleep for `ms`, but resolve early if the caller's signal aborts mid-wait. */
function sleepOrAbort(
ms: number,
Expand Down
20 changes: 20 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,3 +41,23 @@ export type {
AppsApi,
ConnectedApp,
} from "./types";

// ProjectConnector — a SEPARATE client (project API key) to connect accounts for your end-users
// and run actions on their behalf. Fully distinct from the personal `Connector` above.
export { ProjectConnector } from "./project";
export type {
ProjectApi,
ProjectUser,
ProjectConnectorConfig,
ProjectCallOptions,
ProjectExecuteOptions,
ConnectionRequest,
ConnectedAccount,
ConnectionRequestStatus,
ConnectedAccountStatus,
ProviderSelector,
OAuthConnectInput,
ApiKeyConnectInput,
CustomCredentialConnectInput,
WaitForConnectionOptions,
} from "./project";
Loading