diff --git a/README.md b/README.md index 152b13e..3578027 100644 --- a/README.md +++ b/README.md @@ -52,6 +52,7 @@ No architecture to learn — just five words, because all the heavy lifting happ | 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). | +| Run the open-source server yourself | `OpenConnector` | Both call paths (`execute` and `open..`) + `catalog` / `apps` / `health` against your self-hosted runtime. See [Self-hosted runtime](#self-hosted-runtime). | 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. @@ -217,6 +218,35 @@ await user.execute("gmail.search_threads", { query: "from:ceo" }); Full runnable lifecycle — [`examples/project.ts`](./examples/project.ts). +## Self-hosted runtime + +Running the open-source Connector server yourself (localhost, Docker, your own infra)? **`OpenConnector`** is the personal client for it — both call paths you know (everything except `proxy` and `using()`), pointed at your own server: + +```ts +import { OpenConnector } from "@oomol-lab/connector"; + +const open = new OpenConnector(); // defaults to http://localhost:3000; a fresh instance needs no auth + +await open.execute("hackernews.get_top_stories", {}); // path 1 — dynamic string +await open.gmail.search_threads({ query: "from:boss" }); // path 2 — namespace sugar, same registry types +await open.catalog.search("send email", { limit: 5 }); // runtime extras: search, services, health +await open.apps.list(); +``` + +Auth is a single optional **runtime token** (`oct_…`), minted in the runtime's web console: + +```ts +const open = new OpenConnector({ + baseUrl: "https://connect.internal.example.com", // the server ORIGIN — not a /v1 url + runtimeToken: process.env.OOMOL_CONNECT_RUNTIME_TOKEN, // omit while the instance has no tokens +}); +``` + +> [!NOTE] +> Connections, credentials, and OAuth setup are managed in the runtime's **web console** — that's server administration, deliberately outside this SDK. The client consumes what the console configured; connection selection has two layers (per-call `connectionName` over the client-level default — there is no `using()` scope and no `organization`). And as on the hosted client, a service id that collides with a member name (`execute` / `executeRaw` / `health` / `catalog` / `apps`) keeps working through `execute(".", …)` — only its namespace sugar is shadowed. + +Full runnable tour — [`examples/open.ts`](./examples/open.ts). + ## Why this SDK? - **Zero runtime dependencies** — `sideEffects: false`, ships only `dist`. It's an in-process HTTP client, nothing more. @@ -231,6 +261,7 @@ Full runnable lifecycle — [`examples/project.ts`](./examples/project.ts). - **`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). +- **`OpenConnector`** — the personal client for the open-source self-hosted runtime: both call paths (`execute` and `open..`), `catalog` / `apps` (+ `health`, `catalog.search` / `.services`, `apps.listByService` / `.authenticated`), authenticated by an optional runtime token. See [Self-hosted runtime](#self-hosted-runtime). See [`examples/`](./examples) for runnable, type-checked usage of every method. diff --git a/examples/README.md b/examples/README.md index 02f2414..e0651bf 100644 --- a/examples/README.md +++ b/examples/README.md @@ -14,6 +14,7 @@ OOMOL_API_KEY=api_... bun run examples/basic.ts | [`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 | +| [`open.ts`](./open.ts) | `OpenConnector` (separate client, open-source self-hosted runtime): `execute` + `open..` namespace sugar, `catalog` (incl. `search` / `services`), `apps`, `health` — the personal surface against your own server | | [`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 | diff --git a/examples/open.ts b/examples/open.ts new file mode 100644 index 0000000..022cfb5 --- /dev/null +++ b/examples/open.ts @@ -0,0 +1,62 @@ +/** + * OpenConnector — the personal client for the open-source, self-hosted Connector runtime. + * + * It mirrors the core `Connector` surface (execute + `open..` namespace sugar, + * catalog / apps / health) against the server YOU run. Connections and credentials are managed in + * the runtime's web console — the SDK only consumes them. Auth is a single optional runtime token + * (`oct_…`, minted in that console); a fresh instance answers without one. + * + * # start the runtime first, then: + * OOMOL_CONNECT_URL=http://localhost:3000 bun run examples/open.ts + * + * For precise per-action input/output types + JSDoc on BOTH call paths, 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, OpenConnector } from "@oomol-lab/connector"; + +// Every field is optional: a fresh runtime needs no auth at all. +const open = new OpenConnector({ + baseUrl: process.env.OOMOL_CONNECT_URL ?? "http://localhost:3000", // the server ORIGIN, not a /v1 url + runtimeToken: process.env.OOMOL_CONNECT_RUNTIME_TOKEN, // oct_... from the runtime's web console +}); + +async function main() { + // --- Probe the runtime ------------------------------------------------------------------------- + const health = await open.health(); + console.log("runtime:", health.runtime); + + // --- Browse the catalog ------------------------------------------------------------------------ + const services = await open.catalog.services(); + console.log("services with actions:", services.length); + const hits = await open.catalog.search("top stories", { limit: 3 }); + console.log("search:", hits.map((hit) => hit.id)); + const action = await open.catalog.action("hackernews.get_top_stories"); + console.log("input schema keys:", Object.keys(action.inputSchema)); + + // --- Execute actions --------------------------------------------------------------------------- + // No-auth providers (like hackernews) work with zero setup; others use the connections you made + // in the runtime's web console — select one by name with `connectionName` when you have several. + const stories = await open.execute("hackernews.get_top_stories", {}); // path 1 — dynamic string + console.log("output:", stories); + const user = await open.github.get_current_user({}, { connectionName: "work" }); // path 2 — namespace sugar + console.log("user:", user); + const raw = await open.executeRaw("github.get_current_user", {}, { connectionName: "work" }); + console.log("executionId:", raw.executionId); + + // --- Inspect what's connected ------------------------------------------------------------------ + const apps = await open.apps.list(); + console.log("connected apps:", apps.map((app) => `${app.service}:${app.connectionName}`)); + const authed = await open.apps.authenticated(["github", "notion"]); + console.log("with real credentials:", authed); +} + +main().catch((err) => { + if (err instanceof ConnectorError) { + // Same typed error model as the hosted clients — e.g. "unauthorized" (runtime token required), + // "connection_not_found", or "invalid_input" for an unknown action. + console.error(`[${err.code}] ${err.message}`); + } else { + throw err; + } +}); diff --git a/fixtures/consumer/no-b/index.test-d.ts b/fixtures/consumer/no-b/index.test-d.ts index dbf9c6c..863d81e 100644 --- a/fixtures/consumer/no-b/index.test-d.ts +++ b/fixtures/consumer/no-b/index.test-d.ts @@ -1,8 +1,9 @@ // State 1 — B NOT installed (ActionRegistry empty). Everything must be loose-callable. import { expectType, expectError, expectAssignable } from "tsd"; -import { Connector } from "@oomol-lab/connector"; +import { Connector, OpenConnector } from "@oomol-lab/connector"; const oomol = new Connector({ apiKey: "k" }); +const open = new OpenConnector(); // Dynamic execute compiles for ANY actionId, output is the loose Record. expectType>>(oomol.execute("anything.x", { a: 1 })); @@ -22,3 +23,14 @@ expectError(oomol.execute(123, {})); // using() returns a Connector. expectType(oomol.using({ connectionName: "work" })); + +// OpenConnector mirrors both paths with the SAME loose registry seam. +expectType>>(open.execute("anything.x", { a: 1 })); +expectType>>(open.foo.bar({})); +expectAssignable>>(open.anything.whatever()); +expectError(open.foo.bar(123)); // input is Record, not any +expectError(open.execute("anything.x", 123)); // execute input is Record, not any +expectError(open.execute(123, {})); // actionId must be a string +// Its namespace options are the open-runtime ones — no `organization` (single-user server). +open.foo.bar({}, { connectionName: "work" }); +expectError(open.foo.bar({}, { organization: "acme" })); diff --git a/fixtures/consumer/partial-b/index.test-d.ts b/fixtures/consumer/partial-b/index.test-d.ts index e972fd6..fc94106 100644 --- a/fixtures/consumer/partial-b/index.test-d.ts +++ b/fixtures/consumer/partial-b/index.test-d.ts @@ -1,11 +1,12 @@ // 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, ProjectConnector } from "@oomol-lab/connector"; +import { Connector, OpenConnector, ProjectConnector } from "@oomol-lab/connector"; import "./augment"; const oomol = new Connector({ apiKey: "k" }); const project = new ProjectConnector({ apiKey: "oo_proj_k" }); +const open = new OpenConnector(); type SearchOut = { threads: Array<{ threadId: string; snippet: string }> }; @@ -44,3 +45,13 @@ expectError(project.catalog); expectError(project.apps); expectError(project.using); expectError(project.gmail); // closed ProjectApi has no service namespaces + +// OpenConnector namespaces stay in lockstep: registered precise, unregistered loose — same program. +expectType>(open.gmail.search_threads({ query: "x" })); +expectError(open.gmail.search_threads({})); // still errors: missing required `query` +expectType>>(open.gmail.brand_new_action({ anything: 1 })); +expectType>>(open.notion.create_page({ title: "x" })); +// BOTH loose fallbacks of the augmented branch carry the open-runtime options — a regression to +// the default CallOptions would let the hosted-only `organization` slip through unnoticed. +expectError(open.gmail.brand_new_action({ anything: 1 }, { organization: "acme" })); +expectError(open.notion.create_page({ title: "x" }, { organization: "acme" })); diff --git a/fixtures/consumer/with-b/index.test-d.ts b/fixtures/consumer/with-b/index.test-d.ts index d640701..d66dfdf 100644 --- a/fixtures/consumer/with-b/index.test-d.ts +++ b/fixtures/consumer/with-b/index.test-d.ts @@ -1,11 +1,12 @@ // 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, ProjectConnector } from "@oomol-lab/connector"; +import { Connector, OpenConnector, ProjectConnector } from "@oomol-lab/connector"; import "./augment"; const oomol = new Connector({ apiKey: "k" }); const project = new ProjectConnector({ apiKey: "oo_proj_k" }); +const open = new OpenConnector(); type SearchOut = { threads: Array<{ threadId: string; snippet: string }> }; @@ -47,3 +48,20 @@ expectError(project.catalog); expectError(project.apps); expectError(project.using); expectError(project.gmail); // closed ProjectApi has no service namespaces + +// OpenConnector shares the registry seam — registered actions precise on BOTH paths. +expectType>(open.execute("gmail.search_threads", { query: "x" })); +expectType>(open.gmail.search_threads({ query: "x" })); +expectError(open.gmail.search_threads({})); // missing required `query` +expectError(open.gmail.search_threads({ query: 123 })); // wrong type +// The execute path is its own declaration — guard it as strictly as the hosted one. +expectError(open.execute("gmail.search_threads", {})); // missing required +expectError(open.execute("gmail.search_threads", { query: 123 })); // wrong type +expectError(open.execute("gmail.search_threads", { query: "x" }, { organization: "acme" })); +// Namespace options are the open-runtime ones: connectionName ok, `organization` rejected. +open.gmail.search_threads({ query: "x" }, { connectionName: "work" }); +expectError(open.gmail.search_threads({ query: "x" }, { organization: "acme" })); +// The open client has no hosted-only METHODS: a non-reserved name like `proxy` or `using` +// resolves to a service NAMESPACE (an object), so calling it as a function must error. +expectError(open.proxy("github", { endpoint: "/user", method: "GET" })); +expectError(open.using({ connectionName: "work" })); diff --git a/src/connector.ts b/src/connector.ts index f1b67c5..876a665 100644 --- a/src/connector.ts +++ b/src/connector.ts @@ -76,7 +76,11 @@ export interface ConnectorMethods { readonly apps: AppsApi; } -/** The public Connector type: methods + (precise/loose) service namespaces. */ +/** + * The public Connector type: methods + (precise/loose) service namespaces. A service id colliding + * with a reserved member (`execute` / `executeRaw` / `using` / `proxy` / `catalog` / `apps`) + * loses only its path-2 sugar — call it via `execute(".", …)`. + */ export type Connector = ConnectorMethods & ServiceNamespaces; interface ResolvedConfig { diff --git a/src/errors.ts b/src/errors.ts index f4a2e2d..c6590f2 100644 --- a/src/errors.ts +++ b/src/errors.ts @@ -38,12 +38,25 @@ export type ConnectorErrorCode = | "subscription_exists" | "subscription_cleaning_up" | "subscription_needs_recreate" + // Self-hosted runtime codes (the open-source backend an `OpenConnector` talks to): + | "unauthorized" + | "not_found" + | "internal_error" + | "invalid_json" + | "invalid_connection_name" + | "unknown_service" + | "connection_not_found" + | "action_not_allowed" + | "executor_unavailable" + | "authorization_failed" + | "oauth_token_expired" + | "oauth_refresh_unavailable" // Client-only extension codes (NOT in the backend enum; do not overlap with it). // Held by the `| (string & {})` open union. | "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) + | "client_wait_timeout" // a `waitForConnection` exceeded its overall `maxWaitMs` (NOT a per-request timeout) // Forward-compat for new backend codes: | (string & {}); diff --git a/src/http.ts b/src/http.ts index 983921a..e99540a 100644 --- a/src/http.ts +++ b/src/http.ts @@ -153,11 +153,35 @@ function toConnectorError( }); } +/** Recognize a gateway envelope by its boolean `success` discriminator. */ +function isEnvelope(parsed: unknown): parsed is Envelope { + return ( + typeof parsed === "object" && + parsed !== null && + !Array.isArray(parsed) && + typeof (parsed as { success?: unknown }).success === "boolean" + ); +} + +/** + * Extract the self-hosted runtime's non-envelope error body — `{ error: { code, message } }` — + * if present. Its middleware answers in this shape on every route (e.g. a 401), including the + * `/v1` envelope surface, so the transport must understand it wherever it appears. + */ +function runtimeErrorBody(parsed: unknown): { code: string; message: string } | undefined { + if (typeof parsed !== "object" || parsed === null) return undefined; + const error = (parsed as { error?: unknown }).error; + if (typeof error !== "object" || error === null) return undefined; + const { code, message } = error as { code?: unknown; message?: unknown }; + return typeof code === "string" && typeof message === "string" ? { code, message } : undefined; +} + async function parseBody(res: Response): Promise { const text = await res.text(); if (!text) return undefined; + let parsed: unknown; try { - return JSON.parse(text) as Envelope; + parsed = JSON.parse(text); } catch { // Non-JSON body. A 2xx payload is still a success — surface the raw text as `data` // (e.g. a plain-text "OK", or a 200 page injected by an intermediary). A non-2xx non-JSON @@ -167,6 +191,16 @@ async function parseBody(res: Response): Promise { ? { success: true, data: text } : { success: false, message: text, data: null }; } + if (isEnvelope(parsed)) return parsed; + // Non-envelope 2xx JSON (an intermediary, or a backend answering plainly): the body IS the data. + if (res.ok) return { success: true, data: parsed }; + // The self-hosted runtime's middleware failure shape — map it onto the envelope's error fields. + const error = runtimeErrorBody(parsed); + if (error) return { success: false, message: error.message, errorCode: error.code, data: null }; + // Unrecognized non-ok JSON (an intermediary's error page): keep any top-level `message` for the + // error text and carry the whole body as `data` for debugging. + const message = (parsed as { message?: unknown } | null)?.message; + return { success: false, message: typeof message === "string" ? message : undefined, data: parsed }; } /** diff --git a/src/index.ts b/src/index.ts index 3544cb1..015429a 100644 --- a/src/index.ts +++ b/src/index.ts @@ -61,3 +61,22 @@ export type { CustomCredentialConnectInput, WaitForConnectionOptions, } from "./project"; + +// OpenConnector — the personal client for the open-source, self-hosted runtime (the server YOU +// run). Mirrors the core `Connector` action surface: execute, catalog, apps — nothing else; the +// runtime's management API belongs to its web console, not this SDK. +export { OpenConnector } from "./open"; +export type { + OpenConnectorApi, + OpenConnectorConfig, + OpenCallOptions, + OpenExecuteOptions, + OpenCatalogApi, + OpenAppsApi, + OpenHealth, + OpenActionMetadata, + OpenActionFollowUp, + OpenActionAsyncLifecycle, + OpenActionSearchResult, + OpenSearchQuery, +} from "./open"; diff --git a/src/open.ts b/src/open.ts new file mode 100644 index 0000000..1db4811 --- /dev/null +++ b/src/open.ts @@ -0,0 +1,459 @@ +/** + * `OpenConnector` — the personal client for the open-source, self-hosted Connector runtime. + * + * The open-source backend is the self-hostable counterpart of the PERSONAL product: one user, one + * server, running actions on that user's own connections. `OpenConnector` therefore mirrors the + * core `Connector` surface — `execute` / `executeRaw` (path 1), the `open..(...)` + * namespace sugar (path 2, same two-layer Proxy), `catalog`, `apps` — plus the runtime's own + * `health` probe and catalog extras (`search`, `services`, per-service apps). + * + * What it deliberately does NOT cover: the runtime's management API (creating connections, OAuth + * client configs, minting tokens, run logs). Those are server administration, owned by the + * runtime's web console — not by this SDK layer. + * + * Auth is a single OPTIONAL runtime token (`oct_…`, minted in the runtime's web console): a fresh + * instance answers without any token, and once tokens exist the server enforces them. There is no + * `oo_…` API key and no organization here — the server is yours. + * + * Wire-name normalization: `/v1/apps*` responses spell the connection name `alias`; the SDK + * surface speaks `connectionName` everywhere, matching the core `Connector`. + */ + +import { ConnectorError } from "./errors"; +import { + assertHeadersSafe, + defaultTransport, + send, + type Envelope, + type RequestSpec, + type Transport, +} from "./http"; +import type { ActionId, InputOf, OutputOf, ServiceNamespaces } from "./registry"; +import type { ConnectedApp, ProviderMetadata, ProviderQuery, RawResult } from "./types"; + +/** + * Per-call options for open-runtime operations. No `organization` (the runtime is single-user); + * the connection selector rides on {@link OpenExecuteOptions.connectionName}. + */ +export interface OpenCallOptions { + /** Abort signal forwarded to fetch. */ + signal?: AbortSignal; + /** Override per-request timeout in ms. */ + timeoutMs?: number; + /** Override retry count for this call. */ + retries?: number; +} + +/** Options for `execute` / `executeRaw`. */ +export interface OpenExecuteOptions extends OpenCallOptions { + /** Target a named connection (wire header `x-oo-connector-alias`). Default: the client-level `connectionName`, else the runtime's `"default"`. */ + connectionName?: string; +} + +/** `GET /v1/health` payload — also a cheap connectivity/auth probe. */ +export interface OpenHealth { + ok: boolean; + /** Runtime identifier (the open-source backend reports `"oomol-connect"`). */ + runtime: string; +} + +/** A follow-up pointer on an action. The runtime wire shape is `{ actionId }` wrappers, NOT bare id strings. */ +export interface OpenActionFollowUp { + actionId: string; +} + +/** Action ids modeling a start/status/cancel async workflow. */ +export interface OpenActionAsyncLifecycle { + startActionId: string; + statusActionId: string; + cancelActionId?: string; +} + +/** + * Single action metadata (`GET /v1/actions/{actionId}`). The runtime's own shape — richer and + * stricter than the hosted `ActionMetadata` (every field present; `followUpActions` are + * `{ actionId }` wrappers; `asyncLifecycle` is `null` when absent). + */ +export interface OpenActionMetadata { + id: string; + service: string; + name: string; + description: string; + requiredScopes: string[]; + providerPermissions: string[]; + /** JSON Schema (2020-12) for the action input. */ + inputSchema: Record; + /** JSON Schema (2020-12) for the action output. */ + outputSchema: Record; + followUpActions: OpenActionFollowUp[]; + asyncLifecycle: OpenActionAsyncLifecycle | null; +} + +/** One `/v1/actions/search` hit — action metadata trimmed to what ranking returns. */ +export interface OpenActionSearchResult { + id: string; + service: string; + name: string; + description: string; + /** JSON Schema (2020-12) for the action input. */ + inputSchema: Record; + /** JSON Schema (2020-12) for the action output. */ + outputSchema: Record; +} + +/** Server-side filter for `catalog.search`. */ +export interface OpenSearchQuery { + /** Restrict hits to one provider service. */ + service?: string; + /** Max hits, 1–50. Default 10 (server-side). */ + limit?: number; +} + +/** Catalog / metadata introspection. */ +export interface OpenCatalogApi { + /** `GET /v1/actions/{actionId}` — full metadata for one action (404 ⇒ unknown action). */ + action(actionId: string, options?: OpenCallOptions): Promise; + /** `GET /v1/actions?service=X` — all actions of a service. */ + actions(service: string, options?: OpenCallOptions): Promise; + /** `GET /v1/actions` (no service) — every service id that has actions. */ + services(options?: OpenCallOptions): Promise; + /** `GET /v1/providers` — list providers, optionally narrowed by service id(s) / search query. */ + providers(query?: ProviderQuery, options?: OpenCallOptions): Promise; + /** `GET /v1/actions/search?q=…` — rank actions by free-text relevance. */ + search(q: string, query?: OpenSearchQuery, options?: OpenCallOptions): Promise; +} + +/** Connected-app introspection (read-only). */ +export interface OpenAppsApi { + /** `GET /v1/apps` — every connection the runtime can execute with. */ + list(options?: OpenCallOptions): Promise; + /** `GET /v1/apps/services/{service}` — one service's connections (404 ⇒ unknown service). */ + listByService(service: string, options?: OpenCallOptions): Promise; + /** `GET /v1/apps/authenticated` — which of the given services have a REAL credential stored (no-auth virtual connections don't count). */ + authenticated(services: string[], options?: OpenCallOptions): Promise; +} + +/** + * Methods of the open-runtime surface (path 1 + introspection). The full {@link OpenConnector} + * type also carries the path-2 service namespaces. + */ +export interface OpenConnectorApi { + /** Execute an action on the runtime's own connections. Returns the action output directly. */ + execute(actionId: A, input: InputOf, options?: OpenExecuteOptions): Promise>; + /** Like `execute`, but returns `{ data, executionId, actionId, message }`. */ + executeRaw( + actionId: A, + input: InputOf, + options?: OpenExecuteOptions, + ): Promise>>; + /** `GET /v1/health` — connectivity/auth probe. */ + health(options?: OpenCallOptions): Promise; + /** Catalog / metadata introspection. */ + readonly catalog: OpenCatalogApi; + /** Connected-app introspection (read-only). */ + readonly apps: OpenAppsApi; +} + +/** + * Top-level members that are NOT treated as service namespaces. A provider whose service id + * collides with one of these is still fully callable via `execute` — only its path-2 sugar is + * shadowed (the same caveat the core `Connector` carries for its reserved names). + */ +const RESERVED = new Set(["execute", "executeRaw", "health", "catalog", "apps"]); + +/** + * Build the second-layer Proxy for a service. Each property access returns a caller that + * forwards to `execute`. Returns `undefined` for thenable keys and any symbol so + * `await open.` never hangs (the thenable trap). + */ +function makeServiceProxy(api: OpenConnectorApi, service: string): unknown { + return new Proxy(Object.create(null) as object, { + get(_target, prop) { + if (typeof prop === "symbol") return undefined; + if (prop === "then" || prop === "catch" || prop === "finally") return undefined; + const action = `${service}.${prop}`; + return (input?: unknown, options?: OpenExecuteOptions) => + api.execute(action as ActionId, input as never, options); + }, + }); +} + +/** + * Wrap the plain surface in the top-level Proxy that resolves any non-member property to a + * service namespace, enabling `open..(...)`. + */ +function withServiceNamespaces(api: OpenConnectorApi): OpenConnectorApi { + return new Proxy(api, { + get(target, prop) { + if (typeof prop === "symbol") return Reflect.get(target, prop, target); + // Defense in depth: never let the top-level object be treated as thenable. + if (prop === "then") return undefined; + if (RESERVED.has(prop) || prop in target) return Reflect.get(target, prop, target); + // Otherwise: a service namespace. + return makeServiceProxy(target, prop); + }, + }); +} + +/** + * Internal seam between the {@link OpenConnector} class and the surface factory. `request` builds + * + sends one runtime request and returns the parsed envelope. + */ +interface OpenTransport { + request( + method: RequestSpec["method"], + path: string, + init: { + body?: unknown; + query?: Record; + options?: OpenCallOptions; + actionId?: string; + /** Connection selector for execute calls (wire header `x-oo-connector-alias`). */ + connectionName?: string; + }, + ): Promise; + /** Client-level default connection name (from config), if any. */ + defaultConnectionName?: string; +} + +/** Rename the wire `alias` to `connectionName`; preserve every other runtime field. */ +function toConnectedApps(data: unknown): ConnectedApp[] { + const raw = (data ?? []) as Array>; + return raw.map(({ alias, ...rest }) => ({ ...rest, connectionName: alias ?? null })) as ConnectedApp[]; +} + +/** Build the open-runtime surface over the request seam. */ +function createOpenApi(deps: OpenTransport): OpenConnectorApi { + const executeRaw = async ( + actionId: A, + input: InputOf, + options: OpenExecuteOptions = {}, + ): Promise>> => { + const id = String(actionId); + const envelope = await deps.request("POST", `/v1/actions/${encodeURIComponent(id)}`, { + body: { input }, + options, + actionId: id, + connectionName: options.connectionName ?? deps.defaultConnectionName, + }); + return { + data: envelope.data as OutputOf, + executionId: envelope.meta?.executionId, + actionId: envelope.meta?.actionId ?? id, + message: envelope.message, + }; + }; + + const execute = async ( + actionId: A, + input: InputOf, + options?: OpenExecuteOptions, + ): Promise> => (await executeRaw(actionId, input, options)).data; + + const health = async (options?: OpenCallOptions): Promise => { + const envelope = await deps.request("GET", "/v1/health", { options }); + return envelope.data as OpenHealth; + }; + + const catalog: OpenCatalogApi = { + action: async (actionId, options) => { + const envelope = await deps.request("GET", `/v1/actions/${encodeURIComponent(actionId)}`, { + options, + actionId, + }); + return envelope.data as OpenActionMetadata; + }, + actions: async (service, options) => { + const envelope = await deps.request("GET", "/v1/actions", { query: { service }, options }); + return envelope.data as OpenActionMetadata[]; + }, + services: async (options) => { + // Without ?service= the runtime returns service WRAPPERS ([{ service }]); flatten to ids. + const envelope = await deps.request("GET", "/v1/actions", { options }); + return ((envelope.data ?? []) as Array<{ service: string }>).map((entry) => entry.service); + }, + providers: async (query, options) => { + const envelope = await deps.request("GET", "/v1/providers", { + query: { service: query?.service, q: query?.q }, + options, + }); + return envelope.data as ProviderMetadata[]; + }, + search: async (q, query, options) => { + const envelope = await deps.request("GET", "/v1/actions/search", { + query: { q, service: query?.service, limit: query?.limit === undefined ? undefined : String(query.limit) }, + options, + }); + return envelope.data as OpenActionSearchResult[]; + }, + }; + + const apps: OpenAppsApi = { + list: async (options) => { + const envelope = await deps.request("GET", "/v1/apps", { options }); + return toConnectedApps(envelope.data); + }, + listByService: async (service, options) => { + const envelope = await deps.request("GET", `/v1/apps/services/${encodeURIComponent(service)}`, { options }); + return toConnectedApps(envelope.data); + }, + authenticated: async (services, options) => { + const envelope = await deps.request("GET", "/v1/apps/authenticated", { + query: { service: services }, + options, + }); + return envelope.data as string[]; + }, + }; + + // The sub-panels are frozen here; the constructor freezes the top level (after installing the + // class prototype) so a stray assignment (`open.catalog = …`) throws instead of silently + // replacing an API panel — matching how the hosted client's getter-only accessors reject it. + return { execute, executeRaw, health, catalog: Object.freeze(catalog), apps: Object.freeze(apps) }; +} + +declare const __PKG_VERSION__: string; +const USER_AGENT = `@oomol-lab/connector/${__PKG_VERSION__}`; +const DEFAULT_BASE_URL = "http://localhost:3000"; +const DEFAULT_TIMEOUT_MS = 30_000; +const DEFAULT_MAX_RETRIES = 2; + +/** Construction config for {@link OpenConnector}. Every field is optional — a fresh runtime needs no auth. */ +export interface OpenConnectorConfig { + /** + * The runtime server ORIGIN (e.g. `http://localhost:3000` or wherever you deployed it) — NOT a + * `/v1` url; the client adds the path prefix itself. Defaults to `http://localhost:3000`. + */ + baseUrl?: string; + /** + * Runtime token (`oct_…`, minted in the runtime's web console), sent as + * `Authorization: Bearer `. Optional: a runtime with no tokens answers openly. + */ + runtimeToken?: string; + /** Client-level default connection name, applied to `execute` calls (per-call option wins). */ + connectionName?: string; + /** Per-request timeout in ms. Default 30_000. */ + timeoutMs?: number; + /** Max retries for 429 / 5xx / network errors (exponential backoff + jitter). Default 2. */ + maxRetries?: number; + /** Injectable fetch for testing / custom agents. Defaults to global `fetch`. */ + fetch?: typeof fetch; +} + +interface ResolvedOpenConfig { + baseUrl: string; + runtimeToken?: string; + connectionName?: string; + timeoutMs: number; + maxRetries: number; +} + +/** Assemble one runtime request: optional bearer + standard headers + query string. */ +function buildOpenSpec( + cfg: ResolvedOpenConfig, + method: RequestSpec["method"], + path: string, + init: { + body?: unknown; + query?: Record; + options?: OpenCallOptions; + actionId?: string; + connectionName?: string; + }, +): RequestSpec { + const url = new URL(cfg.baseUrl + path); + for (const [k, v] of Object.entries(init.query ?? {})) { + if (v === undefined) continue; + if (Array.isArray(v)) for (const item of v) url.searchParams.append(k, item); + else url.searchParams.set(k, v); + } + + const headers: Record = { + "user-agent": USER_AGENT, + accept: "application/json", + }; + // A tokenless request is a first-class mode (fresh instance, auth not enabled) — the server is + // the authority on whether that suffices, so no header is sent rather than an empty one. + if (cfg.runtimeToken !== undefined) headers["authorization"] = `Bearer ${cfg.runtimeToken}`; + if (init.body !== undefined) headers["content-type"] = "application/json"; + // Connection name selector — carried as the client header the runtime reads (wire key stays `alias`). + if (init.connectionName !== undefined) headers["x-oo-connector-alias"] = init.connectionName; + assertHeadersSafe(headers); + + const options = init.options; + return { + method, + url: url.toString(), + headers, + body: init.body, + retries: options?.retries ?? cfg.maxRetries, + timeoutMs: options?.timeoutMs ?? cfg.timeoutMs, + signal: options?.signal, + actionId: init.actionId, + }; +} + +// The constructor RETURNS the open-runtime surface (factory idiom, mirroring `ProjectConnector`), +// so the class has only a constructor — a class is still needed for the `new OpenConnector(...)` +// call shape. +// eslint-disable-next-line @typescript-eslint/no-extraneous-class +class OpenConnectorImpl { + // The second `transport` arg is internal (test injection); the public type omits it. + constructor(config: OpenConnectorConfig = {}, transport?: Transport) { + // Reject a provided-but-empty (or non-string) token up front — it could only ever fail auth + // confusingly. Absent is fine: tokenless is how a fresh runtime runs. + if (config.runtimeToken !== undefined && (typeof config.runtimeToken !== "string" || config.runtimeToken.length === 0)) { + throw new ConnectorError("`runtimeToken` must be a non-empty string when provided", { + code: "client_invalid_request", + status: 0, + }); + } + const cfg: ResolvedOpenConfig = { + baseUrl: (config.baseUrl ?? DEFAULT_BASE_URL).replace(/\/+$/, ""), + runtimeToken: config.runtimeToken, + connectionName: config.connectionName, + timeoutMs: config.timeoutMs ?? DEFAULT_TIMEOUT_MS, + maxRetries: config.maxRetries ?? DEFAULT_MAX_RETRIES, + }; + // Reject an unparsable baseUrl up front as the SDK's typed error — otherwise it would only + // surface later, per call, as a raw TypeError from URL construction in the request builder. + try { + void new URL(cfg.baseUrl); + } catch { + throw new ConnectorError("`baseUrl` must be a valid absolute URL", { + code: "client_invalid_request", + status: 0, + }); + } + const t = transport ?? (config.fetch ? { ...defaultTransport, fetch: config.fetch } : defaultTransport); + const api = createOpenApi({ + request: (method, path, init) => send(buildOpenSpec(cfg, method, path, init), t), + defaultConnectionName: cfg.connectionName, + }); + // Install the class prototype (so `open instanceof OpenConnector`, `constructor.name`, and + // inspection match the hosted client — the factory would otherwise leave a plain object), + // THEN freeze: assignment to any member throws rather than silently replacing an API panel. + Object.setPrototypeOf(api, OpenConnectorImpl.prototype); + Object.freeze(api); + // The constructor RETURNS the runtime surface (wrapped in the namespace Proxy), so + // `new OpenConnector(...)` IS the api and `open..(...)` resolves at runtime. + return withServiceNamespaces(api) as unknown as OpenConnectorImpl; + } +} + +/** + * The open-source runtime client — point it at the self-hosted Connector server you run and use + * it like the personal {@link Connector}: execute actions (both `open.execute(...)` and + * `open..(...)`), browse the catalog, inspect connected apps. Connections and + * credentials are managed in the runtime's web console, not here. + */ +export const OpenConnector = OpenConnectorImpl as unknown as { + new (config?: OpenConnectorConfig): OpenConnector; +}; +/** + * An {@link OpenConnector} instance: methods + (precise/loose) service namespaces. The namespaces + * carry this client's own per-call options (no `organization` — the runtime is single-user). + * A service id colliding with a member name (`execute` / `executeRaw` / `health` / `catalog` / + * `apps`) loses only its path-2 sugar — call it via `execute(".", …)`. + */ +export type OpenConnector = OpenConnectorApi & ServiceNamespaces; diff --git a/src/registry.ts b/src/registry.ts index a32c611..22df15d 100644 --- a/src/registry.ts +++ b/src/registry.ts @@ -74,19 +74,24 @@ export type OutputOf = A extends keyof ActionRegistry type ServiceNameOf = K extends `${infer S}.${string}` ? S : never; type ServiceName = ServiceNameOf; -/** Methods of one registered service, keyed by the action's local name. */ -type ActionsOf = { +/** + * Methods of one registered service, keyed by the action's local name. `O` is the per-call + * options type of the CLIENT exposing the namespace — the hosted `Connector` passes its + * `CallOptions` (the default), the self-hosted `OpenConnector` its narrower options (no + * `organization`); the registry machinery itself is client-agnostic. + */ +type ActionsOf = { [K in keyof ActionRegistry as K extends `${S}.${infer N}` ? N : never]: ( input: InputOf, - options?: CallOptions, + options?: O, ) => Promise>; }; /** Loose fallback namespace for unregistered services. */ -export type LooseNamespace = { +export type LooseNamespace = { [action: string]: ( input?: Record, - options?: CallOptions, + options?: O, ) => Promise>; }; @@ -105,6 +110,6 @@ export type LooseNamespace = { * OTHER action name on that service resolves through the loose index signature. The outer * `& Record` does the same for entirely unregistered services. */ -export type ServiceNamespaces = RegistryEmpty extends true - ? Record - : { [S in ServiceName]: ActionsOf & LooseNamespace } & Record; +export type ServiceNamespaces = RegistryEmpty extends true + ? Record> + : { [S in ServiceName]: ActionsOf & LooseNamespace } & Record>; diff --git a/src/types.ts b/src/types.ts index 95d875f..efacbe9 100644 --- a/src/types.ts +++ b/src/types.ts @@ -132,7 +132,7 @@ export interface CatalogApi { /** A connected app (`GET /v1/apps`). */ export interface ConnectedApp { - /** Connection id (uuid) assigned by the gateway. */ + /** Connection id assigned by the backend (hosted gateway: uuid; self-hosted runtime: `service:connectionName`). */ id: string; service: string; status?: string; diff --git a/test/helpers.ts b/test/helpers.ts index 0619da4..f7c6d9e 100644 --- a/test/helpers.ts +++ b/test/helpers.ts @@ -1,5 +1,5 @@ -import { Connector, ProjectConnector } from "../src/index"; -import type { ClientConfig, ProjectConnectorConfig } from "../src/index"; +import { Connector, OpenConnector, ProjectConnector } from "../src/index"; +import type { ClientConfig, OpenConnectorConfig, ProjectConnectorConfig } from "../src/index"; export interface CapturedCall { url: string; @@ -20,6 +20,12 @@ export interface ProjectRecorder { sleeps: number[]; } +export interface OpenRecorder { + open: OpenConnector; + calls: CapturedCall[]; + sleeps: number[]; +} + type Handler = (call: CapturedCall, attempt: number) => Response | Promise; /** JSON success envelope helper. */ @@ -30,6 +36,14 @@ export function ok(data: unknown, meta?: Record, status = 200): ); } +/** The self-hosted runtime's non-envelope failure shape (`{ error: { code, message } }`). */ +export function runtimeFail(code: string, status: number, message = code): Response { + return new Response(JSON.stringify({ error: { code, message } }), { + status, + headers: { "content-type": "application/json" }, + }); +} + /** JSON failure envelope helper. */ export function fail( errorCode: string, @@ -132,6 +146,22 @@ export function recorder( return { oomol, calls, sleeps }; } +/** Build an OpenConnector backed by the same recording transport. */ +export function openRecorder( + handler: Handler, + config: OpenConnectorConfig = {}, + opts: { sleep?: (ms: number) => Promise } = {}, +): OpenRecorder { + const { transport, calls, sleeps } = makeRecordingTransport(handler, opts); + // Use the internal (config, transport) constructor arity for transport injection. + const Ctor = OpenConnector as unknown as new ( + config?: OpenConnectorConfig, + transport?: unknown, + ) => OpenConnector; + const open = new Ctor(config, transport); + return { open, calls, sleeps }; +} + /** Build a ProjectConnector backed by the same recording transport. */ export function projectRecorder( handler: Handler, diff --git a/test/open.test.ts b/test/open.test.ts new file mode 100644 index 0000000..98c143b --- /dev/null +++ b/test/open.test.ts @@ -0,0 +1,360 @@ +import { describe, expect, it, vi } from "vitest"; +import { ConnectorError, OpenConnector } from "../src/index"; +import { fail, ok, openRecorder, runtimeFail } from "./helpers"; + +const BASE = "http://localhost:3000"; + +/** A minimal runtime connected-app payload (wire shape: `alias`). */ +function appPayload(overrides: Record = {}) { + return { + id: "github:default", + service: "github", + status: "active", + alias: "default", + authType: "api_key", + displayName: "Octocat", + accountLabel: "Octocat", + isDefault: true, + scopes: ["repo"], + ...overrides, + }; +} + +describe("OpenConnector — config & auth", () => { + it("defaults the base url to the local runtime origin and sends NO auth header without a token", async () => { + const { open, calls } = openRecorder(() => ok({ ok: true, runtime: "oomol-connect" })); + await open.health(); + + expect(calls[0]!.url).toBe(`${BASE}/v1/health`); + expect(calls[0]!.headers["authorization"]).toBeUndefined(); + expect(calls[0]!.headers["user-agent"]).toMatch(/^@oomol-lab\/connector\//); + expect(calls[0]!.headers["accept"]).toBe("application/json"); + }); + + it("strips trailing slashes from a custom baseUrl", async () => { + const { open, calls } = openRecorder(() => ok({ ok: true, runtime: "oomol-connect" }), { + baseUrl: "http://192.168.1.7:3199//", + }); + await open.health(); + expect(calls[0]!.url).toBe("http://192.168.1.7:3199/v1/health"); + }); + + it("sends the runtime token as a bearer on every call", async () => { + const { open, calls } = openRecorder(() => ok([]), { runtimeToken: "oct_r" }); + await open.apps.list(); + expect(calls[0]!.headers["authorization"]).toBe("Bearer oct_r"); + }); + + it("throws a client_invalid_request for a provided-but-empty runtimeToken", () => { + expect(() => openRecorder(() => ok(null), { runtimeToken: "" })).toThrow(ConnectorError); + try { + openRecorder(() => ok(null), { runtimeToken: "" }); + } catch (err) { + expect(err).toMatchObject({ code: "client_invalid_request", status: 0 }); + } + }); + + it("throws a client_invalid_request for an unparsable baseUrl instead of a per-call TypeError", () => { + for (const baseUrl of ["not a url", "example.com"]) { + expect(() => openRecorder(() => ok(null), { baseUrl })).toThrow(ConnectorError); + try { + openRecorder(() => ok(null), { baseUrl }); + } catch (err) { + expect(err).toMatchObject({ code: "client_invalid_request", status: 0 }); + } + } + }); + + it("constructs with no config at all", () => { + expect(() => new OpenConnector()).not.toThrow(); + }); + + it("honors an injected `fetch`", async () => { + const fetchImpl = vi.fn(async () => ok({ ok: true, runtime: "oomol-connect" })); + const open = new OpenConnector({ fetch: fetchImpl as unknown as typeof fetch }); + const health = await open.health(); + expect(health.runtime).toBe("oomol-connect"); + expect(fetchImpl).toHaveBeenCalledOnce(); + }); +}); + +describe("OpenConnector — execute", () => { + it("POSTs the runtime action endpoint with `{ input }` and returns the envelope data", async () => { + const { open, calls } = openRecorder(() => + ok({ story_ids: [1, 2] }, { executionId: "run-1", actionId: "hackernews.get_top_stories" }), + ); + const out = await open.execute("hackernews.get_top_stories", { limit: 2 }); + + expect(calls[0]!.method).toBe("POST"); + expect(calls[0]!.url).toBe(`${BASE}/v1/actions/hackernews.get_top_stories`); + expect(calls[0]!.body).toEqual({ input: { limit: 2 } }); + expect(out).toEqual({ story_ids: [1, 2] }); + }); + + it("carries the connection selector header, per-call name beating the client default", async () => { + const { open, calls } = openRecorder(() => ok({}, { executionId: "e", actionId: "github.x" }), { + connectionName: "work", + }); + await open.execute("github.get_current_user", {}); + await open.execute("github.get_current_user", {}, { connectionName: "personal" }); + + expect(calls[0]!.headers["x-oo-connector-alias"]).toBe("work"); + expect(calls[1]!.headers["x-oo-connector-alias"]).toBe("personal"); + }); + + it("sends no selector header when neither per-call nor client-level name is set", async () => { + const { open, calls } = openRecorder(() => ok({}, { executionId: "e", actionId: "github.x" })); + await open.execute("github.get_current_user", {}); + expect(calls[0]!.headers["x-oo-connector-alias"]).toBeUndefined(); + }); + + it("executeRaw surfaces executionId / actionId / message from the envelope meta", async () => { + const { open } = openRecorder(() => ok({ n: 1 }, { executionId: "run-9", actionId: "github.get_current_user" })); + const raw = await open.executeRaw("github.get_current_user", {}); + + expect(raw.data).toEqual({ n: 1 }); + expect(raw.executionId).toBe("run-9"); + expect(raw.actionId).toBe("github.get_current_user"); + expect(raw.message).toBe("OK"); + }); + + it("executeRaw falls back to the path actionId when the response meta omits it", async () => { + const { open } = openRecorder(() => ok({ n: 1 })); + const raw = await open.executeRaw("github.get_current_user", {}); + expect(raw.actionId).toBe("github.get_current_user"); + expect(raw.executionId).toBeUndefined(); + }); + + it("maps a runtime failure envelope to a ConnectorError with its errorCode / meta", async () => { + const { open } = openRecorder(() => + fail("connection_not_found", 404, { + message: "github connection not found: work.", + meta: { actionId: "github.get_current_user" }, + }), + ); + await expect(open.execute("github.get_current_user", {}, { connectionName: "work" })).rejects.toMatchObject({ + code: "connection_not_found", + status: 404, + actionId: "github.get_current_user", + }); + }); +}); + +describe("OpenConnector — health & catalog", () => { + it("health GETs /v1/health and returns the payload", async () => { + const { open, calls } = openRecorder(() => ok({ ok: true, runtime: "oomol-connect" })); + const health = await open.health(); + expect(calls[0]!.method).toBe("GET"); + expect(calls[0]!.url).toBe(`${BASE}/v1/health`); + expect(health).toEqual({ ok: true, runtime: "oomol-connect" }); + }); + + it("catalog.action GETs one action by encoded id, preserving the runtime metadata shape", async () => { + // The runtime wraps follow-ups as `{ actionId }` objects and nulls an absent asyncLifecycle — + // the OpenActionMetadata contract, distinct from the hosted ActionMetadata. + const meta = { + id: "github.get_current_user", + service: "github", + name: "x", + description: "", + requiredScopes: [], + providerPermissions: [], + inputSchema: {}, + outputSchema: {}, + followUpActions: [{ actionId: "github.list_repos" }], + asyncLifecycle: null, + }; + const { open, calls } = openRecorder(() => ok(meta)); + const action = await open.catalog.action("github.get_current_user"); + expect(calls[0]!.url).toBe(`${BASE}/v1/actions/github.get_current_user`); + expect(action).toEqual(meta); + expect(action.followUpActions[0]!.actionId).toBe("github.list_repos"); + }); + + it("catalog.actions filters by ?service=; catalog.services flattens the bare list", async () => { + const { open, calls } = openRecorder((call) => + call.url.includes("service=") ? ok([{ id: "github.x" }]) : ok([{ service: "github" }, { service: "gmail" }]), + ); + const actions = await open.catalog.actions("github"); + const services = await open.catalog.services(); + + expect(calls[0]!.url).toBe(`${BASE}/v1/actions?service=github`); + expect(actions).toEqual([{ id: "github.x" }]); + expect(calls[1]!.url).toBe(`${BASE}/v1/actions`); + expect(services).toEqual(["github", "gmail"]); + }); + + it("catalog.providers repeats ?service= per id and passes ?q=", async () => { + const { open, calls } = openRecorder(() => ok([])); + await open.catalog.providers({ service: ["github", "gmail"], q: "git" }); + + const url = new URL(calls[0]!.url); + expect(url.pathname).toBe("/v1/providers"); + expect(url.searchParams.getAll("service")).toEqual(["github", "gmail"]); + expect(url.searchParams.get("q")).toBe("git"); + }); + + it("catalog.search sends q / service / stringified limit and returns hits", async () => { + const hit = { id: "github.x", service: "github", name: "x", description: "", inputSchema: {}, outputSchema: {} }; + const { open, calls } = openRecorder(() => ok([hit])); + const hits = await open.catalog.search("issues", { service: "github", limit: 5 }); + + const url = new URL(calls[0]!.url); + expect(url.pathname).toBe("/v1/actions/search"); + expect(url.searchParams.get("q")).toBe("issues"); + expect(url.searchParams.get("service")).toBe("github"); + expect(url.searchParams.get("limit")).toBe("5"); + expect(hits).toEqual([hit]); + }); +}); + +describe("OpenConnector — apps", () => { + it("apps.list renames the wire `alias` to `connectionName`", async () => { + const { open, calls } = openRecorder(() => ok([appPayload()])); + const apps = await open.apps.list(); + + expect(calls[0]!.url).toBe(`${BASE}/v1/apps`); + expect(apps[0]!.connectionName).toBe("default"); + expect("alias" in apps[0]!).toBe(false); + expect(apps[0]!.status).toBe("active"); + }); + + it("apps.listByService scopes to one encoded service and renames alias too", async () => { + const { open, calls } = openRecorder(() => ok([appPayload({ alias: null })])); + const apps = await open.apps.listByService("github enterprise"); + + expect(calls[0]!.url).toBe(`${BASE}/v1/apps/services/github%20enterprise`); + expect(apps[0]!.connectionName).toBeNull(); + }); + + it("apps.authenticated repeats ?service= and returns the authenticated subset", async () => { + const { open, calls } = openRecorder(() => ok(["github"])); + const authed = await open.apps.authenticated(["github", "hackernews"]); + + const url = new URL(calls[0]!.url); + expect(url.pathname).toBe("/v1/apps/authenticated"); + expect(url.searchParams.getAll("service")).toEqual(["github", "hackernews"]); + expect(authed).toEqual(["github"]); + }); +}); + +describe("OpenConnector — service namespaces (path 2)", () => { + it("constructor returns a Proxy; reserved members survive and are callable", () => { + const { open } = openRecorder(() => ok({})); + expect(typeof open.execute).toBe("function"); + expect(typeof open.executeRaw).toBe("function"); + expect(typeof open.health).toBe("function"); + expect(typeof open.catalog).toBe("object"); + expect(typeof open.catalog.action).toBe("function"); + expect(typeof open.apps).toBe("object"); + }); + + it("namespace call forwards to execute with `${service}.${action}` and threads options", async () => { + const { open, calls } = openRecorder(() => ok({ ok: 1 }, { executionId: "e", actionId: "gmail.search_threads" }), { + connectionName: "work", + }); + const data = await open.gmail.search_threads({ query: "from:boss" }); + await open.gmail.search_threads({ query: "x" }, { connectionName: "personal" }); + + expect(calls[0]!.method).toBe("POST"); + expect(calls[0]!.url).toBe(`${BASE}/v1/actions/gmail.search_threads`); + expect(calls[0]!.body).toEqual({ input: { query: "from:boss" } }); + expect(calls[0]!.headers["x-oo-connector-alias"]).toBe("work"); // client default + expect(calls[1]!.headers["x-oo-connector-alias"]).toBe("personal"); // per-call wins + expect(data).toEqual({ ok: 1 }); + }); + + it("top-level `then` is undefined (await on client does not hang)", () => { + const { open } = openRecorder(() => ok({})); + expect((open as unknown as Record).then).toBeUndefined(); + }); + + it("second-level service Proxy: `then`/`catch`/`finally` and symbols return undefined", () => { + const { open } = openRecorder(() => ok({})); + const svc = open.anyService as unknown as Record; + expect(svc.then).toBeUndefined(); + expect(svc.catch).toBeUndefined(); + expect(svc.finally).toBeUndefined(); + expect(svc[Symbol.iterator]).toBeUndefined(); + expect(svc[Symbol.toPrimitive]).toBeUndefined(); + // any normal key resolves to a callable + expect(typeof svc.anyAction).toBe("function"); + }); + + it("`await open.` resolves immediately and does NOT hang", async () => { + const { open } = openRecorder(() => ok({})); + const sentinel = Symbol("timeout"); + const result = await Promise.race([ + (async () => { + // If the second-level Proxy were thenable, this await would hang forever. + const svc = await (open.someService as unknown as Promise); + return svc; + })(), + new Promise((resolve) => setTimeout(() => resolve(sentinel), 200)), + ]); + expect(result).not.toBe(sentinel); + }); + + it("top-level symbol access routes through Reflect.get, not a service namespace", () => { + const { open } = openRecorder(() => ok({})); + expect((open as unknown as Record)[Symbol.iterator]).toBeUndefined(); + }); + + it("is an OpenConnector instance (the factory installs the class prototype)", () => { + const { open } = openRecorder(() => ok({})); + expect(open).toBeInstanceOf(OpenConnector); + expect(new OpenConnector()).toBeInstanceOf(OpenConnector); + }); + + it("members cannot be clobbered: assignment throws and the API panel survives", () => { + const { open } = openRecorder(() => ok({})); + const mutable = open as unknown as Record; + expect(() => { + mutable.catalog = 123; + }).toThrow(TypeError); + expect(() => { + mutable.apps = 123; + }).toThrow(TypeError); + expect(typeof open.catalog.action).toBe("function"); + expect(typeof open.apps.list).toBe("function"); + }); +}); + +describe("OpenConnector — runtime middleware error shapes", () => { + it("maps the non-envelope 401 the runtime's auth middleware emits on /v1 routes", async () => { + const { open } = openRecorder(() => runtimeFail("unauthorized", 401, "A valid local bearer token is required.")); + await expect(open.apps.list()).rejects.toMatchObject({ + code: "unauthorized", + status: 401, + message: "A valid local bearer token is required.", + }); + }); + + it("retries a non-envelope 500 by status and succeeds on the next attempt", async () => { + const { open, calls, sleeps } = openRecorder((_call, attempt) => + attempt === 0 ? runtimeFail("internal_error", 500, "Internal server error.") : ok([appPayload()]), + ); + const apps = await open.apps.list(); + + expect(apps).toHaveLength(1); + expect(calls).toHaveLength(2); + expect(sleeps).toHaveLength(1); // one backoff between the failed and successful attempts + }); + + it("honors per-call retries: 0 disabling the retry a 500 would otherwise get", async () => { + const { open, calls } = openRecorder(() => runtimeFail("internal_error", 500, "Internal server error.")); + await expect(open.apps.list({ retries: 0 })).rejects.toMatchObject({ + code: "internal_error", + status: 500, + }); + expect(calls).toHaveLength(1); + }); + + it("honors a client-level maxRetries: 0 the same way", async () => { + const { open, calls } = openRecorder(() => runtimeFail("internal_error", 500, "Internal server error."), { + maxRetries: 0, + }); + await expect(open.apps.list()).rejects.toMatchObject({ code: "internal_error", status: 500 }); + expect(calls).toHaveLength(1); + }); +}); diff --git a/test/transport.test.ts b/test/transport.test.ts index c8c2aa0..952754d 100644 --- a/test/transport.test.ts +++ b/test/transport.test.ts @@ -23,6 +23,68 @@ describe("transport — non-JSON bodies", () => { }); }); +describe("transport — non-envelope JSON normalization", () => { + it("treats a bare 2xx JSON object (no `success` discriminator) as the data payload itself", async () => { + const payload = { id: "github:default", configured: true }; + const { oomol } = recorder(() => new Response(JSON.stringify(payload), { status: 200 })); + const data = await oomol.execute("svc.act", {}); + expect(data).toEqual(payload); + }); + + it("treats bare 2xx JSON arrays and primitives as the data payload too", async () => { + const bodies = [JSON.stringify([1, 2, 3]), "true"]; + const { oomol } = recorder((call) => new Response(bodies[Number(call.url.includes("second"))], { status: 200 })); + expect(await oomol.execute("svc.act", {})).toEqual([1, 2, 3]); + expect(await oomol.execute("svc.second", {})).toBe(true); + }); + + it("maps the runtime middleware `{ error: { code, message } }` failure shape to its code and message", async () => { + const { oomol } = recorder( + () => + new Response(JSON.stringify({ error: { code: "unknown_service", message: "Unknown service: nope." } }), { + status: 404, + }), + { maxRetries: 0 }, + ); + await expect(oomol.execute("svc.act", {})).rejects.toMatchObject({ + code: "unknown_service", + status: 404, + message: "Unknown service: nope.", + }); + }); + + it("keeps a top-level `message` and carries the body as data for unrecognized non-2xx JSON", async () => { + const { oomol } = recorder( + () => new Response(JSON.stringify({ message: "gateway melted", hint: 42 }), { status: 503 }), + { maxRetries: 0 }, + ); + const err = await oomol.execute("svc.act", {}).catch((e) => e); + expect(err.code).toBe("provider_error"); // no code in the body; derived from the status family + expect(err.status).toBe(503); + expect(err.message).toBe("gateway melted"); + expect(err.data).toEqual({ message: "gateway melted", hint: 42 }); + }); + + it("rejects malformed runtime error shapes (non-string code, missing message) into the fallback path", async () => { + // Both near-misses of `{ error: { code, message } }` must degrade to the status-derived code + // with the body preserved as data — never a crash or a half-mapped error. + const bodies = [{ error: { code: 404, message: "x" } }, { error: { code: "x" } }]; + for (const body of bodies) { + const { oomol } = recorder(() => new Response(JSON.stringify(body), { status: 404 }), { maxRetries: 0 }); + const err = await oomol.execute("svc.act", {}).catch((e) => e); + expect(err.code).toBe("provider_error"); + expect(err.status).toBe(404); + expect(err.data).toEqual(body); + } + }); + + it("treats a 2xx body shaped like a runtime error as success data (error mapping is non-2xx only)", async () => { + const body = { error: { code: "x", message: "y" } }; + const { oomol } = recorder(() => new Response(JSON.stringify(body), { status: 200 })); + expect(await oomol.execute("svc.act", {})).toEqual(body); + }); +}); + describe("transport — retries clamping", () => { it("a negative retries count makes exactly one attempt (no fall-through to the final throw)", async () => { const { oomol, calls } = recorder(() => ok({ done: true }));