diff --git a/content/(dynamic)/connectors.mdx b/content/(dynamic)/connectors.mdx new file mode 100644 index 00000000..5c7a5fa6 --- /dev/null +++ b/content/(dynamic)/connectors.mdx @@ -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 +--- + +Private Beta + +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//`. 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 ; + return

{deals.message ?? "Loading…"}

; +} +``` + +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 + +``` + +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//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//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 `/`, 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. diff --git a/content/(dynamic)/functions.mdx b/content/(dynamic)/functions.mdx index 8f96dcb0..5e6b5e35 100644 --- a/content/(dynamic)/functions.mdx +++ b/content/(dynamic)/functions.mdx @@ -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". diff --git a/content/(dynamic)/meta.ts b/content/(dynamic)/meta.ts index d0c68f8e..92d186e2 100644 --- a/content/(dynamic)/meta.ts +++ b/content/(dynamic)/meta.ts @@ -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"], }); diff --git a/content/(reference)/config-file.mdx b/content/(reference)/config-file.mdx index 1e3dbb22..b4687663 100644 --- a/content/(reference)/config-file.mdx +++ b/content/(reference)/config-file.mdx @@ -208,6 +208,14 @@ See [Functions](/functions). `sf.jsonc` is the only writer. See [Crons](/crons) for the schedule grammar. +## Connectors + +| Key | Type | Default | What it does | +| --- | --- | --- | --- | +| `connectors` | object, up to 32 roles | none | Connector roles your pages call at `/__spacefast/connectors//` | + +See [Connectors](/connectors) for each role's keys. + ## Sharing diff --git a/content/api/index.mdx b/content/api/index.mdx index 2519d45b..346296f2 100644 --- a/content/api/index.mdx +++ b/content/api/index.mdx @@ -165,4 +165,4 @@ Stop when `data.status` is `succeeded`, `failed`, or `canceled`. Then read `spac Connectors make OpenAPI, GraphQL, and remote MCP tools available to a team. Register a connector, connect an account, then choose which tools a policy allows. Account credentials remain server-side. -Use the [connector reference](/api/reference/connectors/registerconnector) to register a source, [create a connection](/api/reference/connectors/createconnection), and [run a tool or program](/api/reference/connectors/createconnectorrun). A policy can require approval before a run proceeds. Read the run's result before reporting success. +Use the [connector reference](/api/reference/connectors/registerconnector) to register a source, [create a connection](/api/reference/connectors/createconnection), and [run a tool or program](/api/reference/connectors/createconnectorrun). A policy can require approval before a run proceeds. Read the run's result before reporting success. To show a connection's data on a Space's pages, declare a role instead; see [Connectors](/connectors).