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
313 changes: 313 additions & 0 deletions content/(dynamic)/connectors.mdx
Original file line number Diff line number Diff line change
@@ -0,0 +1,313 @@
---
title: Connectors
description: Show live data from your team's tools on any Space page. Declare a role in your config, call it from the page, and the account's credentials never reach the browser.
sidebar:
badge: Private Beta
---

<Badge variant="warning">Private Beta</Badge>

Connectors put live data from your team's tools on any page of a Space, with no backend to write and no keys in the page. You declare a role in your config, bind an account to it, and the page calls `/__spacefast/connectors/<role>/<tool>`. Spacefast runs the tool with the account's credentials on the server and answers the page with JSON.

Connectors are in Private Beta. A team without access gets `feature_unavailable`.

## Declare a role

A role names one connector, the tools a page may call through it, and whose account answers. Put it under `connectors` in `spacefast.config.ts`:

```ts spacefast.config.ts
import { defineConfig } from "spacefast/config";

export default defineConfig({
connectors: {
crm: {
connector: "hubspot",
tools: ["list_deals"],
args: { list_deals: { pipeline: "sales" } },
cache: "60s",
},
},
});
```

Or in `sf.jsonc`, with the same shape:

```jsonc sf.jsonc
{
"$schema": "https://spacefast.com/schemas/sf.json",
"connectors": {
"crm": {
"connector": "hubspot",
"tools": ["list_deals"],
"args": { "list_deals": { "pipeline": "sales" } },
"cache": "60s"
}
}
}
```

When both files exist, `spacefast.config.ts` wins. Publish to apply the roles: each version freezes the roles it was published with.

The tool names and arguments on this page, such as `list_deals` and `stage`, are examples. Use the names your connection lists: [`listConnectionTools`](/api/reference/connectors/listconnectiontools) returns each tool with its input schema.

| Key | Required | What it does |
| --- | --- | --- |
| `connector` | yes | The slug your team registered the connector under, such as `hubspot`. `sf connectors list` shows them |
| `tools` | yes | A list of tool names, or `"*"` for every tool on the connection, including tools it gains later |
| `as` | no | Whose account answers: `team` (the default), `personal`, or `visitor`. See [Who reads and who writes](#who-reads-and-who-writes) |
| `args` | no | Argument values pinned per tool, applied on the server. See [Pinned arguments](#pinned-arguments) |
| `cache` | no | How long viewers share one read: `"30s"` by default, up to a day (`"1440m"`). `"0"` turns sharing off. See [Shared reads](#shared-reads) |

Role names are camelCase and start with a lowercase letter, up to 64 characters. A Space declares at most 32 roles, and a role lists at most 200 tools. Tool names use letters, digits, `_`, `.`, `:`, and `-`. `connect`, `callback`, and `disconnect` are reserved.

## Bind an account

A `team` or `personal` role needs an account bound to it. Add the connector to your team and connect an account from the team's **Connectors** page, or from the CLI:

```bash
sf connectors create hubspot --slug hubspot
sf connectors connections connect hubspot
```

Publishing binds a role for you when exactly one eligible connection exists. The publish receipt lists the roles that still need one. Bind the rest yourself:

```bash
sf connectors bindings --space example
sf connectors bind crm cxn_abc123 --space example
```

In the dashboard, a team owner or admin opens the Space's **Integrations** and chooses **Bind account**. Through the API, it's [`bindSpaceConnector`](/api/reference/spaces/bindspaceconnector), and [`listSpaceConnectorBindings`](/api/reference/spaces/listspaceconnectorbindings) shows each role's state.

A `visitor` role binds nothing. Each viewer connects their own account.

## Who reads and who writes

| `as` | Whose account | Who reads through it | Who writes through it |
| --- | --- | --- | --- |
| `team` | A team connection with site serving on | Everyone who can open the Space | A signed-in member of the team or one of the Space's users |
| `personal` | The connection of the person who bound it | Everyone who can open the Space, which only works while the Space is private | A signed-in member of the team or one of the Space's users |
| `visitor` | Each viewer's own connection | That viewer | That viewer |

The connectors route sits behind the Space's own access check, so the data is never more public than the pages. A private Space's data stays private. A share link or password admits anyone with the share link or the password, and they read too.

That's why two roles have extra rules:

- **`team` on a Space anyone can open.** When the Space is public, or shared by link or password, a `team` role serves only after a team owner or admin allows it: `sf connectors public-serving set crm --enable --space example`. Until then, calls answer `connection_forbidden`. The allowance covers the account bound when it was given; rebinding the role takes it away.
- **`personal` needs a private Space.** A `personal` role works only while the Space is private and has no share link or password. Otherwise every call answers `connection_forbidden`.

Writes need a signed-in viewer. An anonymous visitor of a public Space reads through a `team` role and never writes through it: the call answers `connector_write_sign_in_required`. Send viewers to sign in with `space.access.signIn()`. A `visitor` role asks the viewer to sign in first, then to connect their account.

A team's connector policy still applies to every call. A tool it blocks is refused, and a call it holds for approval answers `approval_pending`.

## Call it from a page

Install the client:

```bash
npm install @spacefast/client
```

Then generate the typed module and call the role:

```ts
import { space } from "./spacefast.generated";

const answer = await space.connectors.crm.list_deals({ stage: "won" });
if (answer.state === "ok") render(answer.data);
else showNotice(answer.message);
```

Each tool knows its HTTP method. A read-only tool reads with `GET`, and a tool that writes sends a `POST`. Pass `{ idempotencyKey }` as the last argument to make a retried write run once.

To keep a read live, watch it. A watched read refreshes on an interval and when the page regains focus, and identical reads share one request. Only reads can be watched, since every refresh would run a write again.

```ts
const deals = space.connectors.crm.list_deals.watch({ stage: "won" }, { every: "60s" });
const stop = deals.subscribe(() => render(deals.getSnapshot()));
```

`every` takes milliseconds or `"30s"`, `"5m"`, `"1h"`, never under one second.

### React

`useConnector` from `@spacefast/client/react` watches a read:

```tsx
import { useConnector } from "@spacefast/client/react";
import { space } from "./spacefast.generated";

export function Deals() {
const deals = useConnector(space.connectors.crm.list_deals, { stage: "won" }, { every: "60s" });
if (deals.state === "ok") return <DealList deals={deals.data} />;
return <p>{deals.message ?? "Loading…"}</p>;
}
```

It returns `data`, `state`, `message`, `connectUrl`, `approvalUrl`, `cachedAt`, `refreshing`, `error`, and `refresh`. `data` keeps the last good answer while a refresh runs or fails. Components that ask the same question share one request.

### Pages without a build

A plain HTML page imports the same client from the Space itself:

```html
<script type="module">
import { space } from "/__spacefast/client.js";

const { state, data, message } = await space.connectors.crm.list_deals({ stage: "won" });
document.querySelector("#deals").textContent = state === "ok" ? JSON.stringify(data) : message;
</script>
```

Without generated types, every tool reads with `GET`. Pass `{ method: "POST" }` to a tool that writes. The published version's types are at `/__spacefast/client.d.ts`.

The route also answers plain `fetch`. Query parameters are string arguments (`?stage=won`), and `__args` takes a JSON object for typed values:

```js
const args = encodeURIComponent(JSON.stringify({ stage: "won", limit: 5 }));
const response = await fetch(`/__spacefast/connectors/crm/list_deals?__args=${args}`);
const { state, data } = await response.json();
```

A `POST` sends its arguments as a JSON body of at most 64 KiB. Other sites' pages can't call the route with a viewer's cookies. A browser read that says it came from elsewhere, through `Sec-Fetch-Site` or an `Origin` naming another host, answers `browser_origin_not_allowed`. A read that sends neither header passes that check but still goes through the Space's access check. A write always needs an `Origin` naming this host or `Sec-Fetch-Site: same-origin`, which browsers send for you. Every answer is `private, no-store`.

## The typed client

`sf client` writes `spacefast.generated.ts` next to your config file, typed from the connections behind your roles:

```bash
sf client
```

You rarely run it by hand. `sf dev` and `sf publish` regenerate the module before the build in any project that installs `@spacefast/client`. With roles declared, a publish whose regeneration fails stops rather than ship against stale types, and `sf dev` warns and keeps going. `--out` writes the module somewhere else.

In the generated module, pinned arguments are left out of each tool's type. A tool name that isn't an identifier becomes a quoted key, such as `space.connectors.github["issues.list"]()`. A tool whose source publishes no output schema returns `unknown`.

## What a page renders

A call answers `{ state, data }`. When `state` is `ok`, `data` holds the tool's result. Any other state carries `data: null` and a `message` you can show as is.

| State | What it means | What the page can do |
| --- | --- | --- |
| `ok` | `data` is the provider's payload. An HTTP-backed tool adds `http` with the upstream `status` and `headers`, and an answer served from a shared read adds `cachedAt` | Render it |
| `unbound` | No account serves this role yet | Tell the viewer it isn't set up. The Space's owner binds an account |
| `connect_required` | A `visitor` role needs this viewer's account | Link to `connectUrl`. See [Visitor accounts](#visitor-accounts) |
| `reconnect_required` | The account's authorization expired | On a `visitor` role, link to `connectUrl`. On a `team` or `personal` role, the team reconnects the account; the answer still carries a `connectUrl`, but it only works for `visitor` roles, so don't show it |
| `approval_pending` | The call is waiting for approval | Say so, and link to `approvalUrl` when it's there |
| `running` | The call is still going | Try again shortly. A watched read retries by itself |

Failures a page can't wait out come back as an RFC 9457 problem with a stable `code`, which the client throws as a `ConnectorError` (`status`, `code`, `message`). With `useConnector`, `state` is `error` and `error` holds it.

| Code | Status | When |
| --- | --- | --- |
| [`connection_forbidden`](/errors/connection_forbidden) | 403 | The team's policy blocks the connector, a read tool, or a write on a `visitor` role; an approval was denied; the account doesn't serve sites; or the Space is too open for the role |
| [`connector_role_unknown`](/errors/connector_role_unknown) | 404 | The live version doesn't declare this role |
| `connector_tool_not_allowed` | 403 | The role's `tools` doesn't list this tool |
| `connector_arg_pinned` | 422 | The call sent a different value for a pinned argument |
| `connector_write_sign_in_required` | 403 | A write through a `team` or `personal` role from a viewer who isn't signed in as a member |
| [`connector_tool_blocked`](/errors/connector_tool_blocked) | 403 | The team's connector policy blocks this write tool on a `team` or `personal` role |
| [`connector_write_in_read_handler`](/errors/connector_write_in_read_handler) | 403 | A `GET` to a tool that writes. Send a `POST` |
| [`browser_origin_not_allowed`](/errors/browser_origin_not_allowed) | 403 | The call came from another site, or a write without same-origin proof |
| `connectors_unavailable` | 502 or 503 | The provider or Spacefast couldn't answer. Try again |

### Visitor accounts

A `visitor` role needs a connector with an OAuth sign-in method, since each viewer signs in to their own account. A connector that only takes API keys can't serve one: connecting answers `connector_oauth_required`. When the connector needs an OAuth app your team hasn't registered, it answers `connector_oauth_app_required`.

A `visitor` role's `connectUrl` is `/__spacefast/connectors/<role>/connect`. It signs the viewer in if needed, then sends them to the provider and back. Add `?return_to=/some/path` to land somewhere other than `/`. A `POST` to `/__spacefast/connectors/<role>/disconnect` from the Space's own page forgets the viewer's account.

[`listVisitorConnectionUsers`](/api/reference/spaces/listvisitorconnectionusers) counts which of the Space's users connected each role, and [`revokeVisitorConnection`](/api/reference/spaces/revokevisitorconnection) disconnects one user's account for a role.

## Pinned arguments

`args` fixes argument values per tool on the server:

```jsonc sf.jsonc
{
"connectors": {
"crm": {
"connector": "hubspot",
"tools": ["list_deals"],
"args": { "list_deals": { "pipeline": "sales" } }
}
}
}
```

Every call to `list_deals` through `crm` runs with `pipeline: "sales"`, whatever the page sends. A call may repeat the pinned value. A different value is refused with `connector_arg_pinned`, and its `pointer` names the argument. Pin anything a viewer shouldn't choose, such as which pipeline, project, or account a public page shows.

## Shared reads

A `team` or `personal` role shows every viewer the same account, so every viewer asks the same question. Spacefast answers them together: one upstream read serves everyone for the role's `cache` window.

- **The key** is the Space, role, tool, and the final arguments with pins applied. Argument order doesn't matter.
- **One viewer fills a cold key.** Others arriving meanwhile wait for that read rather than starting their own.
- **The budget** is 60 upstream reads per role per rolling minute, for each Space.
- **Stale serving.** When the budget is spent, the last value keeps answering for up to an hour past its window. With no value to fall back on, the call answers `connectors_unavailable`.
- **`cachedAt`** appears on an `ok` answer served from the shared value and says when it was fetched. The read that fetches it answers without one.

Only successful reads are shared. `cache: "0"` turns sharing off, but each call still spends the budget. Writes, `visitor` roles, and tools the team's policy holds for approval are never shared: each call runs.

## Serving a team account on Spaces

A team connection reads for a Space's viewers only once site serving is on for it. Until then, the role's state is `needs_serving_grant` and calls answer `connection_forbidden`.

If you may turn serving on, do it from the connection in the dashboard, or with `sf connectors connections update cxn_abc123 --serving`. If you can't, ask:

```bash
sf connectors serving create crm --space example
```

The answer is a pending request with a `csr_` id, and asking again returns the same one. When you're allowed to grant serving yourself, it turns on at once and the request comes back `approved`.

Team owners and admins get a notification. They decide in the dashboard, or with:

```bash
sf connectors serving list
sf connectors serving approve csr_abc123
sf connectors serving deny csr_abc123
```

Approving turns serving on, and the role's state flips to ready. A team can keep serving decisions to owners and admins, and can limit which connectors may serve sites at all.

The API operations are `requestSpaceConnectorServing`, `listConnectorServingRequests`, `approveConnectorServingRequest`, and `denyConnectorServingRequest`. Public serving for a `team` role is `setSpaceConnectorPublicServing`.

## Change roles from the dashboard

The Space's **Integrations** page lists every role. An override lives with the Space, not in your config file, and replaces that one role:

- **Turn off** removes a role your config declares. It moves to **Removed roles**, where **Restore** brings it back.
- **Use config file** drops an override and returns the role to what the file says.

Saving re-applies the live version, so the change takes effect without a publish. The next publish keeps the overrides.

Through the API, overrides are `config.connectors` on [`updateSpace`](/api/reference/spaces/updatespace). An entry replaces the file's role of the same name, and `null` removes it. `config` replaces the Space's whole stored overlay, so read it from [`getSpace`](/api/reference/spaces/getspace) first and send it back with your change, along with its `settingsDigest` as `baseSettingsDigest`.

## Call it from Functions

A [Functions](/functions) worker whose version declares roles gets `env.CONNECTORS`:

```js functions/deals.js
export async function GET(request, { env }) {
const deals = await env.CONNECTORS.crm.list_deals({ stage: "won" });
return Response.json(deals);
}
```

The role proxy returns the tool's result, or throws a `ConnectorCallError` whose `state.code` says why, such as `connect_required` or `forbidden`. `env.CONNECTORS.call("crm", "list_deals", args)` returns `{ state, result }` instead of throwing. The worker forwards the request's signed-in viewer, so `visitor` roles and writes follow the same rules as a page. Calls from a worker are never shared reads.

## Call it from outside the Space

The page route is for the Space's own pages: it acts for the viewer, and other sites' pages can't use it. From anywhere else, such as a script, a CI job, or an agent, run the tool with your own API key through [`createConnectorRun`](/api/reference/connectors/createconnectorrun):

```bash
curl -X POST https://api.spacefast.com/v1/connectors/runs \
-H "Authorization: Bearer $SPACEFAST_API_KEY" \
-H "Content-Type: application/json" \
-d '{"input":{"kind":"tool","address":"cxn_abc123/list_deals","args":{"stage":"won"}},"waitMs":10000}'
```

The address is `<connectionId>/<tool>`, straight off [`listConnectionTools`](/api/reference/connectors/listconnectiontools). From the CLI, it's `sf connectors run --tool cxn_abc123/list_deals --args '{"stage":"won"}'`. A run answers to your credential and the team's policy, not to a role: no pins, no shared reads. Read the run's status and result before treating it as done.

## First

Pages in a [First](https://first-docs.space.fast/) app call roles with `@spacefast/client`, like any other page. Declare the roles in `spacefast.config.ts` or `sf.jsonc` as above. First's server code can't call connectors yet.
2 changes: 2 additions & 0 deletions content/(dynamic)/functions.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -130,6 +130,8 @@ Outbound requests from a worker leave from Cloudflare's shared network addresses

`env` carries the space's environment variables. See [Environment variables](/environment-variables) for how to set them.

`env.CONNECTORS` calls your Space's [connector roles](/connectors#call-it-from-functions) when the version declares any.

`env.DB` is always there. It is D1-shaped, so `env.DB.prepare(sql).bind(...).all()` is the call you already know. It reaches the space's own database through a broker, so no connection string ever lands in the bundle.

Two groups of variable names never reach a worker, dropped rather than blanked so your code reads them as absent instead of as "configured, but empty".
Expand Down
2 changes: 1 addition & 1 deletion content/(dynamic)/meta.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,5 +4,5 @@ export default defineMeta({
title: "Dynamic",
collapsed: false,
order: 5,
pages: ["zero-runtime", "functions", "database", "environment-variables", "crons", "object-storage", "logs", "wordpress"],
pages: ["zero-runtime", "functions", "connectors", "database", "environment-variables", "crons", "object-storage", "logs", "wordpress"],
});
Loading
Loading