From 2afc4f50c46648403d90e247dd1c2c790348eeab Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?batuhan=20i=C3=A7=C3=B6z?= Date: Fri, 13 Nov 2026 20:07:56 +0000 Subject: [PATCH 1/5] Document Connectors: live data from your team's tools on any Space page Add the Connectors guide at /docs/connectors, marked Private Beta. It covers declaring roles in spacefast.config.ts or sf.jsonc, the team, personal and visitor roles, calling them with @spacefast/client (core, React, no-build), sf client, the response states, pinned arguments, shared reads, site-serving requests, dashboard overrides, Functions, createConnectorRun, and First. Link it from the API overview, Functions, and the sf.jsonc reference. --- content/(dynamic)/connectors.mdx | 310 ++++++++++++++++++++++++++++ content/(dynamic)/functions.mdx | 2 + content/(dynamic)/meta.ts | 2 +- content/(reference)/config-file.mdx | 8 + content/api/index.mdx | 2 +- 5 files changed, 322 insertions(+), 2 deletions(-) create mode 100644 content/(dynamic)/connectors.mdx diff --git a/content/(dynamic)/connectors.mdx b/content/(dynamic)/connectors.mdx new file mode 100644 index 00000000..f832d2ed --- /dev/null +++ b/content/(dynamic)/connectors.mdx @@ -0,0 +1,310 @@ +--- +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. + +| 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 blocked tool answers `connector_tool_blocked`, and a call the policy 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, answer.connectUrl); +``` + +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 ; + if (deals.connectUrl) return {deals.message}; + 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. The route answers only the Space's own pages: a read from another site and a write without same-origin proof both answer `browser_origin_not_allowed`. 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 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` | +| `reconnect_required` | The account's authorization expired | Link to `connectUrl` when it's there | +| `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, 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 tool | +| [`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'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. + +Space owners see who connected with [`listVisitorConnectionUsers`](/api/reference/spaces/listvisitorconnectionusers) and remove one with [`revokeVisitorConnection`](/api/reference/spaces/revokevisitorconnection). + +## 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`** on an `ok` answer says when a shared value was fetched. + +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 answers only the Space's own pages. 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). From 5166591db8416c1f7704d8ce1dfefa09cae37d03 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?batuhan=20i=C3=A7=C3=B6z?= Date: Fri, 13 Nov 2026 20:09:36 +0000 Subject: [PATCH 2/5] Say which policy refusal a page sees for reads and writes --- content/(dynamic)/connectors.mdx | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/content/(dynamic)/connectors.mdx b/content/(dynamic)/connectors.mdx index f832d2ed..5e5dffd8 100644 --- a/content/(dynamic)/connectors.mdx +++ b/content/(dynamic)/connectors.mdx @@ -95,7 +95,7 @@ That's why two roles have extra rules: 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 blocked tool answers `connector_tool_blocked`, and a call the policy holds for approval answers `approval_pending`. +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 @@ -190,7 +190,7 @@ A call answers `{ state, data }`. When `state` is `ok`, `data` holds the tool's | `ok` | `data` is the provider's payload. An HTTP-backed tool adds `http` with the upstream `status` and `headers`, and 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` | -| `reconnect_required` | The account's authorization expired | Link to `connectUrl` when it's there | +| `reconnect_required` | The account's authorization expired | On a `visitor` role, link to `connectUrl`. A shared account is reconnected by the team | | `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 | @@ -198,12 +198,12 @@ Failures a page can't wait out come back as an RFC 9457 problem with a stable `c | Code | Status | When | | --- | --- | --- | -| [`connection_forbidden`](/errors/connection_forbidden) | 403 | The team's policy blocks the connector, the account doesn't serve sites, or the Space is too open for the role | +| [`connection_forbidden`](/errors/connection_forbidden) | 403 | The team's policy blocks the connector or a read tool, 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 tool | +| [`connector_tool_blocked`](/errors/connector_tool_blocked) | 403 | The team's connector policy blocks this write tool | | [`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 | @@ -212,7 +212,7 @@ Failures a page can't wait out come back as an RFC 9457 problem with a stable `c 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. -Space owners see who connected with [`listVisitorConnectionUsers`](/api/reference/spaces/listvisitorconnectionusers) and remove one with [`revokeVisitorConnection`](/api/reference/spaces/revokevisitorconnection). +[`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 From dda65d07d3afb3eb60a5a687f12ba54a70f04ff9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?batuhan=20i=C3=A7=C3=B6z?= Date: Fri, 13 Nov 2026 22:20:37 +0000 Subject: [PATCH 3/5] Narrow the connectors route's origin rule, and note visitor OAuth and cachedAt Other sites' pages can't call the route with a viewer's cookies; a request without fetch metadata still passes the Space's access check. Visitor roles need a connector with OAuth sign-in. cachedAt appears only on answers served from the shared value. Tool names on the page are examples. --- content/(dynamic)/connectors.mdx | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/content/(dynamic)/connectors.mdx b/content/(dynamic)/connectors.mdx index 5e5dffd8..f3105241 100644 --- a/content/(dynamic)/connectors.mdx +++ b/content/(dynamic)/connectors.mdx @@ -48,6 +48,8 @@ Or in `sf.jsonc`, with the same shape: 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 | @@ -167,7 +169,7 @@ const response = await fetch(`/__spacefast/connectors/crm/list_deals?__args=${ar const { state, data } = await response.json(); ``` -A `POST` sends its arguments as a JSON body of at most 64 KiB. The route answers only the Space's own pages: a read from another site and a write without same-origin proof both answer `browser_origin_not_allowed`. Every answer is `private, no-store`. +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`, and so does a write without same-origin proof. A request that sends neither header passes that check but still goes through the Space's access check. Every answer is `private, no-store`. ## The typed client @@ -187,7 +189,7 @@ A call answers `{ state, data }`. When `state` is `ok`, `data` holds the tool's | 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 a shared read adds `cachedAt` | Render it | +| `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` | | `reconnect_required` | The account's authorization expired | On a `visitor` role, link to `connectUrl`. A shared account is reconnected by the team | @@ -210,6 +212,8 @@ Failures a page can't wait out come back as an RFC 9457 problem with a stable `c ### 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. @@ -240,7 +244,7 @@ A `team` or `personal` role shows every viewer the same account, so every viewer - **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`** on an `ok` answer says when a shared value was fetched. +- **`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. @@ -294,7 +298,7 @@ The role proxy returns the tool's result, or throws a `ConnectorCallError` whose ## Call it from outside the Space -The page route answers only the Space's own pages. 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): +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 \ From 6f8fd0c7d6c2db02a359c2a6da8197e4eb20ee12 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?batuhan=20i=C3=A7=C3=B6z?= Date: Fri, 13 Nov 2026 22:24:16 +0000 Subject: [PATCH 4/5] Show connectUrl only for visitor roles, and say which roles get each policy refusal --- content/(dynamic)/connectors.mdx | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/content/(dynamic)/connectors.mdx b/content/(dynamic)/connectors.mdx index f3105241..5705f777 100644 --- a/content/(dynamic)/connectors.mdx +++ b/content/(dynamic)/connectors.mdx @@ -114,7 +114,7 @@ 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, answer.connectUrl); +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. @@ -139,7 +139,6 @@ 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 ; - if (deals.connectUrl) return {deals.message}; return

{deals.message ?? "Loading…"}

; } ``` @@ -191,8 +190,8 @@ A call answers `{ state, data }`. When `state` is `ok`, `data` holds the tool's | --- | --- | --- | | `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` | -| `reconnect_required` | The account's authorization expired | On a `visitor` role, link to `connectUrl`. A shared account is reconnected by the team | +| `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 | @@ -200,12 +199,12 @@ Failures a page can't wait out come back as an RFC 9457 problem with a stable `c | Code | Status | When | | --- | --- | --- | -| [`connection_forbidden`](/errors/connection_forbidden) | 403 | The team's policy blocks the connector or a read tool, the account doesn't serve sites, or the Space is too open for the role | +| [`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 | +| [`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 | From 7142b853bfa88b8ae78503c40e4a3be84ca8ddda Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?batuhan=20i=C3=A7=C3=B6z?= Date: Fri, 13 Nov 2026 22:27:46 +0000 Subject: [PATCH 5/5] Say that only header-less reads pass the origin check --- content/(dynamic)/connectors.mdx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/content/(dynamic)/connectors.mdx b/content/(dynamic)/connectors.mdx index 5705f777..5c7a5fa6 100644 --- a/content/(dynamic)/connectors.mdx +++ b/content/(dynamic)/connectors.mdx @@ -168,7 +168,7 @@ const response = await fetch(`/__spacefast/connectors/crm/list_deals?__args=${ar 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`, and so does a write without same-origin proof. A request that sends neither header passes that check but still goes through the Space's access check. Every answer is `private, no-store`. +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