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
235 changes: 219 additions & 16 deletions frontend/src/lib/connections.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,23 @@
// The catalog of third-party accounts a company can act through. This is the
// console's view of what *can* be connected; whether a host can actually run
// the OAuth handshake depends on its `/connections` surface (see the client).
//
// ## Two routes, one tile (issue #599)
//
// A tile can be connected two ways, and which one is live is a property of the
// *host*, not of the tile:
//
// - **Composio** — the hosted path. Composio runs the OAuth itself and the
// resulting connection is a tool belt the agents actually receive.
// - **Native** — this host's own registered provider application, the
// self-hosted hatch documented on `src/server/ops/connections.rs`. Reported
// as `credentialSource: "static"`.
//
// Until #599 every tile hard-routed to native. On a hosted tenant no
// `OPENCOMPANY_OAUTH_*` variable is injected, so all eleven Connect buttons
// 400'd with "provider is not enabled on this host" — a grid of buttons that
// could never succeed. [`connectRoute`] is now the single place that decides,
// and it can answer "neither", so a button that cannot work is never rendered.

export type ConnectionCategory =
| "Communication"
Expand All @@ -12,16 +29,32 @@ export type ConnectionCategory =

export interface ConnectionProvider {
/**
* The provider identity. This string MUST be identical end-to-end: the
* manifest `[[connection]] provider = "…"`, this catalog `id`, and the
* backend `well_known(provider)` key (`src/server/ops/connections.rs`) are the
* same token. A tile whose `id` has no matching backend key can never
* complete OAuth — `startConnection(id)` resolves no `provider_config` and the
* host answers "provider not enabled". Backend keys today: `slack`, `github`,
* `google`, `gmail`. Keep new tiles aligned to an existing key (or add the
* backend key first); do not invent console-only ids.
* The provider identity in *this host's* namespace: the manifest
* `[[connection]] provider = "…"` and the key `GET …/connections` reports
* status under.
*
* It is also the token the native hatch resolves — `startConnection(id)` needs
* a matching `well_known(id)` key in `src/server/ops/connections.rs` (today:
* `slack`, `github`, `google`, `gmail`). An id outside that set has **no
* native route**, which is no longer a dead tile: {@link connectRoute} sends
* it down the Composio path instead, and reports `unavailable` when neither
* route is open rather than rendering a Connect that 400s.
*/
id: string;
/**
* The Composio toolkit slug this tile authorizes against — the hosted route,
* and the only route on a hosted tenant.
*
* Composio slugs are lowercase and unpunctuated (`googlecalendar`) while ids
* here are hyphenated (`google-calendar`), and a few differ outright (`x` is
* `twitter`). Stated per tile rather than derived, because {@link toolkitSlug}
* normalization alone cannot produce `twitter` from `x`.
*
* Mirrors the backend's `toolkit_slug()`
* (`src/server/ops/connections_read.rs`), which is what reconciles the two
* namespaces into one row per provider.
*/
toolkit: string;
name: string;
description: string;
category: ConnectionCategory;
Expand All @@ -34,6 +67,7 @@ export interface ConnectionProvider {
export const CONNECTION_PROVIDERS: ConnectionProvider[] = [
{
id: "gmail",
toolkit: "gmail",
Comment thread
coderabbitai[bot] marked this conversation as resolved.
name: "Gmail",
description: "Send and read email from a connected inbox.",
category: "Communication",
Expand All @@ -42,19 +76,16 @@ export const CONNECTION_PROVIDERS: ConnectionProvider[] = [
},
{
id: "slack",
toolkit: "slack",
name: "Slack",
description: "Post updates and take requests from your workspace.",
category: "Communication",
color: "#4A154B",
glyph: "#",
},
{
// DEAD TILE until aligned: backend `well_known` has no `google-calendar`
// key — the closest existing key is `google`. Connecting this tile fails
// ("provider not enabled") until either the id is changed to `google` (or a
// dedicated `google-calendar` key + scopes are added to
// `well_known`/`provider_config` in src/server/ops/connections.rs).
id: "google-calendar",
toolkit: "googlecalendar",
name: "Google Calendar",
description: "Schedule and read events on a shared calendar.",
category: "Productivity",
Expand All @@ -63,17 +94,16 @@ export const CONNECTION_PROVIDERS: ConnectionProvider[] = [
},
{
id: "notion",
toolkit: "notion",
name: "Notion",
description: "Read and write docs and databases.",
category: "Productivity",
color: "#0F0F0F",
glyph: "N",
},
{
// DEAD TILE until aligned: backend `well_known` has no `google-drive` key
// (closest existing key is `google`). See the `google-calendar` note above —
// align the id to a backend key or add the key before this tile can connect.
id: "google-drive",
toolkit: "googledrive",
name: "Google Drive",
description: "Store and retrieve files and deliverables.",
category: "Storage",
Expand All @@ -82,6 +112,7 @@ export const CONNECTION_PROVIDERS: ConnectionProvider[] = [
},
{
id: "dropbox",
toolkit: "dropbox",
name: "Dropbox",
description: "Sync assets and shared folders.",
category: "Storage",
Expand All @@ -90,6 +121,7 @@ export const CONNECTION_PROVIDERS: ConnectionProvider[] = [
},
{
id: "github",
toolkit: "github",
name: "GitHub",
description: "Open issues and pull requests in your repos.",
category: "Developer",
Expand All @@ -98,6 +130,7 @@ export const CONNECTION_PROVIDERS: ConnectionProvider[] = [
},
{
id: "stripe",
toolkit: "stripe",
name: "Stripe",
description: "Create invoices and read payment activity.",
category: "Finance",
Expand All @@ -106,14 +139,18 @@ export const CONNECTION_PROVIDERS: ConnectionProvider[] = [
},
{
id: "hubspot",
toolkit: "hubspot",
name: "HubSpot",
description: "Sync contacts and deals in your CRM.",
category: "Finance",
color: "#FF7A59",
glyph: "H",
},
{
// Composio still spells this toolkit `twitter`; the tile keeps the current
// product name. Exactly the case a normalization rule cannot derive.
id: "x",
toolkit: "twitter",
name: "X",
description: "Publish posts and read mentions.",
category: "Social",
Expand All @@ -122,6 +159,7 @@ export const CONNECTION_PROVIDERS: ConnectionProvider[] = [
},
{
id: "linkedin",
toolkit: "linkedin",
name: "LinkedIn",
description: "Publish updates and manage your page.",
category: "Social",
Expand All @@ -138,3 +176,168 @@ export const CONNECTION_CATEGORY_ORDER: ConnectionCategory[] = [
"Social",
"Storage",
];

// ---------------------------------------------------------------------------
// Which route a tile's Connect takes (issue #599)
// ---------------------------------------------------------------------------

/**
* Normalize a provider id or toolkit slug to one comparable key.
*
* The console spells ids hyphenated (`google-calendar`), Composio spells slugs
* unpunctuated (`googlecalendar`), and `GET …/connections` returns rows keyed
* either way — manifest rows under the manifest's spelling, reconciled Composio
* rows under the Composio slug. Matching raw strings therefore misses a
* genuinely connected provider and leaves its tile showing Connect.
*
* Mirrors `toolkit_slug()` in `src/server/ops/connections_read.rs`; keep the two
* in step.
*/
export function toolkitSlug(value: string): string {
return value.replace(/[^a-zA-Z0-9]/g, "").toLowerCase();
}

/**
* The host's status row for a tile, matched across both spellings.
*
* The tile's own id is tried first — a manifest row is keyed that way — then
* normalized keys, so a reconciled Composio row keyed `googlecalendar` still
* finds the `google-calendar` tile.
*
* **A connected row wins over a disconnected one.** The host can emit *two* rows
* for one provider when its id and its Composio slug do not normalize to the
* same key: `toolkit_slug("x")` is `x`, not `twitter`, so a manifest declaring
* `provider = "x"` produces a disconnected `x` row while Composio's connected
* `twitter` state arrives as a separate appended row. Taking the first match
* would report that tile disconnected while the account is in fact connected —
* the same "two surfaces disagreeing" failure #316 set out to end. Connected
* beats not-connected for the same reason the host unions `native ||
* composio_connected` within a single row; this extends that union across the
* alias it cannot currently see.
*
* Direct-id precedence still decides when nothing is connected, so a manifest
* row remains the authority on a provider the host answered for.
*/
export function connectionStateFor<T extends { provider: string; connected?: boolean }>(
provider: ConnectionProvider,
states: Record<string, T>,
): T | undefined {
const wanted = new Set([toolkitSlug(provider.id), toolkitSlug(provider.toolkit)]);
const direct = states[provider.id];
const matches: T[] = [];
if (direct) matches.push(direct);
for (const state of Object.values(states)) {
if (state !== direct && wanted.has(toolkitSlug(state.provider))) matches.push(state);
}
return matches.find((state) => state.connected) ?? matches[0];
}

/** What this host offers for Composio, as far as routing a tile is concerned. */
export interface ComposioReach {
/** Whether the `composio` feature is compiled into this build. */
inBuild: boolean;
/** Whether the company explicitly grants `composio`. */
granted: boolean;
/**
* Whether a credential of **any** tier resolves; `none` means there is
* nothing to authorize against.
*
* Deliberately a boolean rather than the tier itself. Which credential the
* host reaches Composio with is the host's business, and the set of tiers
* grows — #586 adds `company` (the company's own TinyHumans key) alongside
* `attested` and `static`. Routing on "is there one at all" means a new tier
* is additive here by construction: a tenant that can only reach Composio
* through its company key gets the same working Connect as an attested pod,
* with no edit to this rule.
*/
hasCredential: boolean;
/** Open mode — the backend's own allowlist governs, so any slug it permits is reachable. */
openMode: boolean;
/** The toolkits offered as rows; the hard limit when not in open mode. */
effectiveToolkits: readonly string[];
}

/**
* Whether `toolkit` can actually be authorized against Composio on this host.
*
* The allowlist half matters as much as the credential half: outside open mode
* the manifest list is a real limit, so offering a Connect for a toolkit outside
* it would just move the 400 from one backend to the other. In open mode the
* effective list is a *display* list, not a limit — any slug the backend permits
* is reachable — so it is deliberately not consulted (issue #397).
*/
export function composioCanAuthorize(reach: ComposioReach | null, toolkit: string): boolean {
if (!reach || !reach.inBuild || !reach.granted || !reach.hasCredential) return false;
if (reach.openMode) return true;
const wanted = toolkitSlug(toolkit);
return reach.effectiveToolkits.some((slug) => toolkitSlug(slug) === wanted);
}

/**
* How a tile's Connect should behave.
*
* - `native` — this host has its own registered provider application (or the
* company already stored a token): the self-hosted hatch, unchanged.
* - `composio` — authorize `toolkit` through Composio's hosted OAuth.
* - `managed` — the platform runs connections for this instance and there is no
* Composio route either; nothing to do here.
* - `unavailable` — no route can succeed, so the tile says so instead of
* offering a button that fails.
*/
export type ConnectRoute =
| { kind: "native" }
| { kind: "composio"; toolkit: string }
| { kind: "managed" }
| { kind: "unavailable" };

/**
* Decide the route for one tile — the single rule the grid renders *and* acts
* on, so the button shown and the call made can never disagree.
*
* Precedence, and why:
*
* 1. **`static` → native.** The host registered a provider application for this
* provider, or the company stored its own token. Both are deliberate acts by
* the operator; preferring Composio here would quietly take away the hatch
* they configured. This is the self-hosted route staying supported.
* 2. **Composio, when it can authorize this toolkit.** The hosted path, and the
* only one on a tenant — which is injected no `OPENCOMPANY_OAUTH_*` variable
* at all. It is also the route that makes the connection a capability: a
* native connection is recorded against the company but no agent tool reads
* it (see the catalog advisory in `ConnectionsView`).
* 3. **`attested` → managed.** A platform-projected identity, so connections are
* the platform's to run and no local Connect could work.
* 4. **Otherwise unavailable.** Notably this is where an unknown provider lands
* on a host with no Composio: `credentialSource` is `undefined` because the
* manifest never declared it, and a Connect would 400.
*
* Step 4 is the bug #599 reports. The grid renders every catalog tile, but
* `GET …/connections` only answers for providers the manifest declares — so on
* a tenant that declares none, every tile had `state === undefined`, the
* `attested` guard never fired (there was no row to read it from), and all
* eleven fell through to a Connect that 400'd.
*
* ## Only two tier names appear here, on purpose
*
* `static` and `attested` are named because each answers a question about the
* *local* host: `static` means a native handshake can complete here, `attested`
* means no local one ever can. Every other tier — including `company` from
* #586 — is a statement about which credential the host presents to Composio,
* which this function reads through {@link ComposioReach.hasCredential} rather
* than by name. So the combination "the company credential is set but the
* provider is not in `states`" needs no case of its own: `state` is
* `undefined`, `hasCredential` is true, and the tile gets a working Composio
* Connect — which is exactly the outcome #599 is about.
*/
export function connectRoute(
provider: ConnectionProvider,
state: { credentialSource?: string } | undefined,
reach: ComposioReach | null,
): ConnectRoute {
if (state?.credentialSource === "static") return { kind: "native" };
if (composioCanAuthorize(reach, provider.toolkit)) {
return { kind: "composio", toolkit: provider.toolkit };
}
if (state?.credentialSource === "attested") return { kind: "managed" };
return { kind: "unavailable" };
}
Loading
Loading