From ad8a8bf36a0aa7fa70535e821f9fd8cd9c4761c0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?batuhan=20i=C3=A7=C3=B6z?= Date: Sat, 24 Oct 2026 12:58:25 -0700 Subject: [PATCH 1/2] Document traffic rules, comments, connectors, team access, and feedback Add guides and references for shipped features the docs did not cover: - Traffic rules: /traffic-rules guide and /cli/rules reference. - Comments: /comments guide and /cli/comments reference. - Connectors: /connectors guide, /cli/connectors and /api/connectors references, and /zero-connectors for Space roles and Worker calls. - Team access: /identity-connections, /auto-enroll, and /subteams. - Feedback: /feedback for the CLI, API, dashboard, and docs paths. - Website copy: /migrate for sf migrate. - Project config: spacefast.config.ts discovery, precedence, and migration in the config reference. Also add sf spaces check, shell completion, Worker bindings, and fix stale sf feedback output. --- content/(account)/auto-enroll.mdx | 76 +++ content/(account)/identity-connections.mdx | 205 ++++++ content/(account)/meta.ts | 2 +- content/(account)/subteams.mdx | 98 +++ content/(concepts)/teams.mdx | 4 + content/(dynamic)/connectors.mdx | 195 ++++++ content/(dynamic)/functions.mdx | 31 + content/(dynamic)/meta.ts | 2 +- content/(dynamic)/zero-connectors.mdx | 209 ++++++ content/(publish)/meta.ts | 1 + content/(publish)/migrate.mdx | 131 ++++ content/(reference)/config-file.mdx | 212 ++++++- content/(reference)/feedback.mdx | 131 ++++ content/(reference)/meta.ts | 2 +- content/(serve)/access.mdx | 2 + content/(serve)/comments.mdx | 215 +++++++ content/(serve)/meta.ts | 2 + content/(serve)/routing.mdx | 2 + content/(serve)/traffic-rules.mdx | 243 +++++++ content/api/connectors.mdx | 345 ++++++++++ content/api/index.mdx | 4 +- content/api/meta.ts | 1 + content/cli/agent-commands.mdx | 7 +- content/cli/comments.mdx | 510 +++++++++++++++ content/cli/connectors.mdx | 702 +++++++++++++++++++++ content/cli/git.mdx | 6 +- content/cli/index.mdx | 21 +- content/cli/meta.ts | 3 + content/cli/project.mdx | 32 +- content/cli/rules.mdx | 331 ++++++++++ content/cli/spaces.mdx | 47 ++ content/troubleshooting.mdx | 2 +- redirects.ts | 2 +- styles/Spacefast/spelling-exceptions.txt | 1 + 34 files changed, 3726 insertions(+), 51 deletions(-) create mode 100644 content/(account)/auto-enroll.mdx create mode 100644 content/(account)/identity-connections.mdx create mode 100644 content/(account)/subteams.mdx create mode 100644 content/(dynamic)/connectors.mdx create mode 100644 content/(dynamic)/zero-connectors.mdx create mode 100644 content/(publish)/migrate.mdx create mode 100644 content/(reference)/feedback.mdx create mode 100644 content/(serve)/comments.mdx create mode 100644 content/(serve)/traffic-rules.mdx create mode 100644 content/api/connectors.mdx create mode 100644 content/cli/comments.mdx create mode 100644 content/cli/connectors.mdx create mode 100644 content/cli/rules.mdx diff --git a/content/(account)/auto-enroll.mdx b/content/(account)/auto-enroll.mdx new file mode 100644 index 00000000..9a7caaef --- /dev/null +++ b/content/(account)/auto-enroll.mdx @@ -0,0 +1,76 @@ +--- +title: Allowed email domains +description: Let people with a verified company email address find and join your team without an invitation +--- + +An allowed email domain offers your team to anyone whose verified email address is at that domain. Spacefast never adds anyone automatically. A person becomes a member only when they press **Join**. + +Only owners and admins can add or remove domains. An OAuth token needs the `teams:members.write` scope, including to list them. + +## Add a domain + +1. Open the team and go to **Members**. +2. Press **Allowed domains**. The **Allowed email domains** dialog opens. +3. Type the domain, for example `example.com`. +4. Press **Add domain**. + +Over HTTP, send [`createTeamAutoEnrollEntry`](/api/reference/teams/createteamautoenrollentry): + +```bash +curl https://api.spacefast.com/v1/teams//auto-enroll \ + -H "Authorization: Bearer sfa_xxxxxxxx" \ + -H "Content-Type: application/json" \ + -d '{"value":"example.com"}' +``` + +The `201` response holds the entry: `id`, `teamId`, `kind` (always `domain`), `value`, and `createdAt`. Adding a domain the team already has returns the existing entry. [`listTeamAutoEnrollEntries`](/api/reference/teams/listteamautoenrollentries) returns all entries, newest first. + +The credential must act for a person. A key or token with no user behind it gets `forbidden`. + +## Which domains Spacefast accepts + +A domain is accepted when all of these are true: + +- **A current member already uses it.** At least one member of this team must have a verified email address at exactly that domain. A subdomain does not count. +- **It is a domain, not an address.** A value with `@` is refused. Invite that person by email instead. +- **It is a registrable domain.** IP addresses, single labels such as `localhost`, and public suffixes such as `co.uk` are refused. +- **It is not a shared mailbox provider.** These are refused: `gmail.com`, `googlemail.com`, `outlook.com`, `hotmail.com`, `live.com`, `yahoo.com`, `proton.me`, `protonmail.com`, `icloud.com`, `me.com`, `aol.com`, `mail.com`, `gmx.com`, and `wordpress.com`. + +Spacefast stores the canonical form: lowercase ASCII, with international names converted to their `xn--` form. `Bücher.de` becomes `xn--bcher-kva.de`. Matching uses the same form, so either spelling works for people who join. + +## What a person sees + +A signed-in person with a verified address at the domain sees your team in **Teams you can join** on the **Teams** page. The row reads **Your email domain matches**. Their team's Spaces page also shows a **Teams you can join** notice with **View teams**. + +Every verified address on the account counts, including secondary addresses. The account does not need to use that address to sign in. + +Pressing **Join** makes them a member with the `member` role. Owners and admins can change the role later on **Members**. Joining needs a signed-in browser session. An API key or an agent cannot join for a person. The API operation is [`joinTeam`](/api/reference/teams/jointeam). + +## Seats and billing + +A person who joins through a domain takes a seat like any other member. On Plus, members beyond the included 10 are billed as extra seats. + +Free and Go allow one member, the owner. On those plans, **Join** fails with `team_member_quota_exceeded`, so nobody can join until the team moves to Plus. See [Seats](/billing#seats). + +## Remove a domain + +In **Allowed email domains**, press the remove button next to the domain. Confirm with **Remove domain**. + +Over HTTP, send [`deleteTeamAutoEnrollEntry`](/api/reference/teams/deleteteamautoenrollentry) with the entry `id`. It answers `204`. + +- **Existing members stay.** Removing a domain changes no membership and no role. +- **New people need an invitation.** The team stops appearing in their **Teams you can join** list. + +To take access away from someone who joined through a domain, remove them on **Members**. See [Teams and members](/teams#change-a-role-or-remove-someone). + +A member you remove cannot rejoin through the domain. To bring them back, send an invitation. A member who leaves on their own can rejoin through the domain. + +## Errors + +| Code | Status | Meaning | +| --- | --- | --- | +| `invalid_domain_name` | 422 | The value is not a registrable domain. IP addresses and public suffixes land here | +| `validation_error` | 422 | `details.reason` says why. `email_not_supported`: the value is an email address. `domain_not_allowed`: the domain is a shared mailbox provider. `domain_not_represented`: no member has a verified address there | +| `not_found` | 404 | The entry does not exist or belongs to another team | +| `forbidden` | 403 | Your role cannot manage members, or the credential has no person behind it | +| `team_member_quota_exceeded` | 409 | A person pressed **Join** while the team was at its member limit | diff --git a/content/(account)/identity-connections.mdx b/content/(account)/identity-connections.mdx new file mode 100644 index 00000000..938fdbac --- /dev/null +++ b/content/(account)/identity-connections.mdx @@ -0,0 +1,205 @@ +--- +title: Identity connections +description: Connect an OIDC provider or your own signer to a team, then admit its people to Spaces with grants +--- + +An identity connection lets visitors prove who they are with a sign-in system you already run. The connection admits no one by itself. A Space admits a person only through a grant that names the connection and that person's subject. + +| Type | `type` | Use it when | You provide | +| --- | --- | --- | --- | +| **OpenID Connect** | `oidc` | Your company signs people in through an OIDC provider | Issuer, client ID, and client secret | +| **White-label signer** | `signer` | You run your own portal and sign each visitor's identity yourself | Issuer, sign-in URL, and Ed25519 public keys | + +Owners and admins create, update, and revoke connections. Every team member can list them. An OAuth token needs the `teams:create` scope to change a connection and `teams:read` to list them. + +## Register Spacefast with your OIDC provider + +To use `oidc`, register Spacefast in your provider first as a web app with a client secret. Use these values: + +| Setting | Value | +| --- | --- | +| Redirect URI | `https://api.spacefast.com/identity/callback` | +| Grant type | Authorization code. Spacefast also sends a PKCE `S256` challenge and a nonce | +| Client authentication | HTTP Basic (`client_secret_basic`) | +| Scopes | `openid email profile` | +| ID token algorithms | `RS256`, `PS256`, `ES256`, or `EdDSA` | + +Spacefast reads `/.well-known/openid-configuration` over HTTPS. The `issuer` value in that document must match the issuer you enter. If your tenant sets `presentation.accessHandoffOrigin`, the redirect URI is `/identity/callback` on that origin instead. See [Partner API configuration](/platforms/partner-api/configuration). + +## Add a connection in the dashboard + +1. Open the team and go to **Team settings → Access**. +2. In **SSO providers**, press **Add identity provider**. The **Add SSO provider** dialog opens. +3. Pick a **Connection type**: **OpenID Connect** or **White-label signer**. +4. Enter a **Name** and the **Issuer**. +5. For OpenID Connect, enter the **Client ID** and **Client secret**. +6. For a signer, enter the **Sign-in URL**. Add the **Ed25519 public keys**, one `kid:base64url-key` per line. +7. Press **Add connection**. + +The connection appears as a row with an **OIDC** or **Signer** badge and its issuer. + +## Add a connection with the API or CLI + +Send [`createTeamIdentityConnection`](/api/reference/teams/createteamidentityconnection): + +```bash +curl https://api.spacefast.com/v1/teams//identity-connections \ + -H "Authorization: Bearer sfa_xxxxxxxx" \ + -H "Content-Type: application/json" \ + -d '{"type":"oidc","name":"Acme SSO","issuer":"https://login.example.com","clientId":"","clientSecret":""}' +``` + +A signer body uses `authorizeUrl` and `publicKeys` instead of the client fields: + +```json +{ + "type": "signer", + "name": "Customer portal", + "issuer": "https://portal.example.com", + "authorizeUrl": "https://portal.example.com/spacefast/authorize", + "publicKeys": [{ "kid": "2026-01", "publicKey": "<43-character-base64url-key>" }] +} +``` + +The `201` response holds the connection. Its `id` starts with `acn_`, and grants use that ID. A team can have one active connection per issuer. From a terminal, run `sf share identity create`. See [`sf share`](/cli/share#sf-share-identity-create). + +| Field | Applies to | Rules | +| --- | --- | --- | +| `type` | Both | `oidc` or `signer` | +| `name` | Both | 1 to 200 characters | +| `issuer` | Both | An absolute HTTPS URL, up to 1,024 characters. No user info and no fragment. Spacefast removes trailing slashes | +| `clientId` | `oidc` | Up to 512 characters | +| `clientSecret` | `oidc` | Up to 2,048 characters. Write-only. Responses show only `clientSecretSet` | +| `authorizeUrl` | `signer` | An absolute HTTPS URL, up to 2,048 characters. No user info and no fragment | +| `publicKeys` | `signer` | 1 to 5 entries of `kid` and `publicKey`. Each `kid` is unique and up to 64 characters. `publicKey` is a raw Ed25519 public key, base64url, 43 characters | +| `sessionTtlSeconds` | Both | Optional. `oidc`: 60 to 86,400, default 28,800. `signer`: 60 to 1,800, default 1,800 | + +## Admit people to a Space + +The connection proves identity. A grant on the Space decides what that identity can open. + +To add a grant in the dashboard, open the Space and go to **Access**. Open **Advanced**, then find **SSO and identity**. Pick the **Connection**, then fill in **Subject**, **Name**, **Paths**, and an optional **Expires**. Choose **What this identity can do** and press **Add identity grant**. If the team has no connection, the card shows **No SSO provider**. + +From a terminal, run `sf share identity grant`: + +```bash +sf share identity grant --connection acn_xxxxxxxx --subject alex@example.com --name "Alex (Acme SSO)" --path '/docs/**' --space my-site +``` + +Over HTTP, send [`createSpaceGrant`](/api/reference/spaces/createspacegrant) with an `external` audience: + +```json +{ + "name": "Alex (Acme SSO)", + "audience": { "kind": "external", "connectionId": "acn_xxxxxxxx", "subject": "alex@example.com" }, + "resources": { "include": ["/docs/**"] }, + "capabilities": ["page.view"], + "target": { "kind": "live" } +} +``` + +- **The subject must equal the `sub` claim.** Spacefast compares it exactly. It holds up to 255 characters. +- **A name is required.** Spacefast stores the subject as a hash, so the grant list cannot show it. +- **The connection must belong to the Space's team.** The Space must be claimed. +- **Verified email follows the provider.** A grant that requires a verified email admits an OIDC visitor only when the ID token has `email_verified: true`. A signer proof never counts as a verified email. + +Roles, paths, and targets work as they do for every other grant. See [Access and sharing](/access). + +A visitor who cannot open a page sees the Space's access page. It shows **Continue with** and the connection name for each connection that an active grant on that Space trusts. A connection with no grant on the Space does not appear there. + +## Build a signer + +A `signer` connection sends visitors to your portal, and your portal vouches for them. The round trip has four steps: + +1. The visitor presses **Continue with** on the access page. Spacefast redirects the browser to your `authorizeUrl` with the query parameters below. +2. Your portal signs the visitor in and signs a proof token. +3. Your server sends the proof to Spacefast and receives a short-lived token. +4. Your portal sends the browser to `https:///__sf/redeem?sf_token=&return=`. The Space sets a session cookie and opens the page. + +| Query parameter | What it holds | +| --- | --- | +| `spaceId` | The Space the visitor is opening | +| `host` | The hostname the visitor is on. The proof's `aud` must equal it | +| `return` | The path to open after sign-in | +| `browserState` | A 64-character hex value that binds the sign-in to this browser. Send it back unchanged | + +### The proof token + +The proof is a JWT signed with one of the Ed25519 keys on the connection. + +| Part | Requirement | +| --- | --- | +| `alg` header | `EdDSA` or `Ed25519` | +| `kid` header | The `kid` of a key on the connection | +| `iss` | The connection's issuer. A trailing slash is allowed | +| `sub` | The visitor's subject. Grants match this value | +| `aud` | The `host` query parameter | +| `iat` | Issue time in seconds. At most 300 seconds in the future | +| `exp` | Expiry in seconds. Later than `iat`. `exp` minus `iat` must not exceed the connection's `sessionTtlSeconds` or 1,800 | + +### Exchange the proof + +Send the proof from your server, not from the browser: + +```bash +curl https://api.spacefast.com/identity/acn_xxxxxxxx/exchange \ + -H "Authorization: Bearer " \ + -H "Content-Type: application/json" \ + -d '{"spaceId":"","host":"","resource":"","browserState":""}' +``` + +A `200` answers with `token` and `expiresAt`. A `403` means the proof failed or no grant admits that subject to `resource`. + +Redirect the browser to `/__sf/redeem` on the same `host` within 60 seconds. The token works once, and only in the browser that started the sign-in. + +## Update a connection + +In **SSO providers**, press **Update** on the connection's row. + +| Type | Fields | Button | +| --- | --- | --- | +| OpenID Connect | **Client ID**, **Replacement client secret**, **Session lifetime in minutes** | **Update connection** | +| White-label signer | **Replacement Ed25519 key set**, **Session lifetime in minutes** | **Rotate key set** | + +Leave **Replacement client secret** blank to keep the current secret. To rename a connection or change a signer's sign-in URL, use the API or the CLI. + +[`updateTeamIdentityConnection`](/api/reference/teams/updateteamidentityconnection) accepts `name`, `clientId`, `clientSecret`, `authorizeUrl`, `publicKeys`, and `sessionTtlSeconds`. It accepts `sessionTtlSeconds` from 60 to 86,400. For a signer, Spacefast lowers any value above 1,800 to 1,800. `publicKeys` replaces the whole key set. The `type` and `issuer` cannot change. To change them, create a new connection. + +Grants that use the connection keep working. A change to the client ID, client secret, keys, or session lifetime sets `rotatedAt`. + +To rotate a signer key without a sign-in gap: + +1. Send a key set that holds the old key and the new key. +2. Sign new proofs with the new key. +3. Send a key set that holds only the new key. + +## Revoke a connection + +Press **Revoke** on the row, then **Revoke connection** in the dialog. Over HTTP, send [`revokeTeamIdentityConnection`](/api/reference/teams/revoketeamidentityconnection). It answers `204`. From a terminal, run `sf share identity revoke`. + +Revoking removes the grants too. Spacefast revokes every grant on the team's Spaces that trusts the connection's issuer, then the connection itself. + +A revoked connection stays in [`listTeamIdentityConnections`](/api/reference/teams/listteamidentityconnections) with `revokedAt` set. It cannot be updated. To restore access, create a new connection and new grants. + +## Errors + +| Code | Status | Meaning | +| --- | --- | --- | +| `identity_connection_invalid_issuer` | 422 | The issuer is not an absolute HTTPS URL, or it holds user info or a fragment | +| `identity_connection_invalid_authorize_url` | 422 | The sign-in URL is not an absolute HTTPS URL, or it holds user info or a fragment | +| `identity_connection_duplicate_kid` | 422 | Two signer keys share a `kid` | +| `identity_connection_type_mismatch` | 422 | An update sent signer fields to an OIDC connection, or OIDC fields to a signer | +| `validation_error` | 422 | The body failed the schema, or an `external` grant has no `name` | +| `not_found` | 404 | The connection does not exist, belongs to another team, or is revoked | +| `forbidden` | 403 | Your role cannot change connections, or a signer proof was rejected | + +OIDC sign-in failures show to the visitor during the round trip: + +| Code | Status | Meaning | +| --- | --- | --- | +| `oidc_discovery_failed` | 503 or 422 | The discovery document did not load, or it is not valid | +| `oidc_discovery_issuer_mismatch` | 422 | The discovery document names a different issuer | +| `oidc_token_exchange_rejected` | 422 | The provider refused the authorization code or the client credentials | +| `oidc_issuer_mismatch` | 422 | The ID token `iss` does not match the connection | +| `oidc_audience_mismatch` | 422 | The ID token `aud` does not include the client ID | +| `oidc_state_expired` | 422 | The sign-in took longer than 10 minutes. Start again | diff --git a/content/(account)/meta.ts b/content/(account)/meta.ts index eff7013c..8a5b3dba 100644 --- a/content/(account)/meta.ts +++ b/content/(account)/meta.ts @@ -4,5 +4,5 @@ export default defineMeta({ title: "Account", collapsed: false, order: 6, - pages: ["authentication", "api-keys", "billing", "usage"], + pages: ["authentication", "api-keys", "identity-connections", "auto-enroll", "subteams", "billing", "usage"], }); diff --git a/content/(account)/subteams.mdx b/content/(account)/subteams.mdx new file mode 100644 index 00000000..b8ba4585 --- /dev/null +++ b/content/(account)/subteams.mdx @@ -0,0 +1,98 @@ +--- +title: Subteams +description: Split an Enterprise team into subteams, each with its own members and Spaces, on the parent's plan and seats +--- + +Enterprise + +A subteam is a team inside your team. It has its own name, URL, members, and Spaces. It uses the parent team's plan, seats, and billing. + +Subteams need an Enterprise parent team. On every other plan, subteam operations return `enterprise_required`. Plans are on [Plans and billing](/billing). + +## How subteams work + +- **The parent's owners and admins are in every subteam.** They hold the same role there. To change that role, change it on the parent team. +- **Parent members can join a subteam.** Every subteam appears in **Teams you can join** for parent members. Pressing **Join** makes them a subteam `member`, with no new seat. +- **Seats are shared.** Seats count across the parent and all its subteams. A person in several teams of the family uses one seat. +- **Subteams do not nest.** A subteam cannot have subteams of its own. +- **Leaving the parent leaves every subteam.** When someone leaves or is removed from the parent team, they also lose every subteam membership. + +Each subteam owns its Spaces. Access to them follows subteam membership and the member's subteam role. Teams, roles, and Space ownership work as described in [Teams and members](/teams). + +## Create a subteam + +Only owners and admins of the parent team can create a subteam. + +1. Open the parent team and go to **Subteams**. +2. Press **New subteam**. +3. Enter a **Name**, then press **Create subteam**. + +Spacefast creates the subteam and opens it. Its URL slug starts with the parent's slug, followed by the name. + +Over HTTP, send [`createSubTeam`](/api/reference/teams/createsubteam) with a `name` of 1 to 255 characters: + +```bash +curl https://api.spacefast.com/v1/teams//subteams \ + -H "Authorization: Bearer sfa_xxxxxxxx" \ + -H "Content-Type: application/json" \ + -d '{"name":"Design"}' +``` + +The `201` response holds the subteam: `id`, `teamId` (the parent), `name`, `slug`, `memberCount`, `createdAt`, and `updatedAt`. From a terminal, run `sf teams subteams create Design`. + +## List subteams + +[`listSubTeams`](/api/reference/teams/listsubteams) returns the parent's subteams, sorted by name. Every member of the parent team can list them. From a terminal, run `sf teams subteams ls`. + +The **Subteams** page shows the subteams you are in, each with **Open**. Below them are the subteams you can join, each with **Join**. + +## Rename a subteam + +Send [`updateSubTeam`](/api/reference/teams/updatesubteam) with a new `name`. The slug does not change. From a terminal: + +```bash +sf teams subteams rename "Brand design" +``` + +You can also open the subteam and rename it in **Team settings → General**. + +## Manage subteam members + +Adding and removing members needs an owner or admin of the parent team. + +```bash +sf teams subteams members ls +sf teams subteams members add +sf teams subteams members rm +``` + +| Operation | What it does | +| --- | --- | +| [`listSubTeamMembers`](/api/reference/teams/listsubteammembers) | Lists members with their `userId`, `name`, `email`, and `image`. Subteam members and the parent's owners and admins can read it | +| [`addSubTeamMember`](/api/reference/teams/addsubteammember) | Adds a parent member as a subteam `member`. No invitation is sent and no seat is used. Repeating the request keeps one membership | +| [`removeSubTeamMember`](/api/reference/teams/removesubteammember) | Removes the person from the subteam and its Spaces. They stay on the parent team | + +Find a user ID with `sf teams members ls --json` on the parent team. You can also invite someone from the **Members** page of the subteam. The subteam role picker offers **Admin** and **Member**. A subteam has no owner of its own. + +A person you remove from a subteam cannot rejoin it through **Teams you can join**. To bring them back, add them again. + +## Delete a subteam + +A subteam with Spaces cannot be deleted. Delete or move its Spaces first. See [Move a Space to another team](/teams#move-a-space-to-another-team). + +Send [`deleteSubTeam`](/api/reference/teams/deletesubteam), or run `sf teams subteams rm `. In the dashboard, open the subteam and go to **Team settings → Danger zone → Delete this team**. Deleting a subteam ends its memberships. + +A parent team cannot be deleted while it has subteams. Delete every subteam first. + +## Errors + +| Code | Status | Meaning | +| --- | --- | --- | +| `enterprise_required` | 403 | The parent team is not on Enterprise | +| `parent_authority_required` | 403 | Only an owner or admin of the parent team can do this | +| `subteam_nesting_unsupported` | 422 | The team is already a subteam, or it is a system or partner team | +| `role_managed_by_parent` | 409 | The person's subteam role comes from the parent team. Change it there | +| `team_has_spaces` | 409 | The subteam still has Spaces | +| `team_has_subteams` | 409 | The parent team still has subteams | +| `not_found` | 404 | The subteam does not exist, or the user is not a member of the parent team | +| `forbidden` | 403 | Your role cannot manage members, or you are not in the subteam whose members you asked for | diff --git a/content/(concepts)/teams.mdx b/content/(concepts)/teams.mdx index 5054c98b..20871d4e 100644 --- a/content/(concepts)/teams.mdx +++ b/content/(concepts)/teams.mdx @@ -19,6 +19,8 @@ Free and Go allow only the owner. Adding another member needs [Plus](/billing#se A team is the thing that owns work in a self-serve account. Spaces, custom domains, API keys, billing, and members all hang off it. A Space you claim belongs to the team you select, even when you are its only member. [Partner API customers](/platforms/partner-api/customers) use external principals instead. +An Enterprise team can split into [subteams](/subteams), each with its own members and Spaces. A team can also hold [identity connections](/identity-connections), so people from your own sign-in system can open its Spaces. + A team can also hold a [Design System](/design-system) for colors, type, voice, and brand files. Saving those standards does not change any Space until you apply them to its project. A team has a name and a slug. The slug is the first segment of its dashboard URLs. You can change it in **Team settings → General**, where a live check tells you whether the new one is free. @@ -77,6 +79,8 @@ Your team needs [Plus](/billing#seats) to add another member. Free and Go includ From a terminal, `sf teams invitations ls` lists invitations. Use the dashboard to send, resend, cancel, or accept one. +To skip invitations for coworkers, add your company's [allowed email domain](/auto-enroll). + Things that catch people out: - **Invitations expire after 7 days.** Resending re-stamps a fresh 7 days. diff --git a/content/(dynamic)/connectors.mdx b/content/(dynamic)/connectors.mdx new file mode 100644 index 00000000..9a7352f6 --- /dev/null +++ b/content/(dynamic)/connectors.mdx @@ -0,0 +1,195 @@ +--- +title: Connectors +description: Register an OpenAPI spec, GraphQL endpoint, or remote MCP server for your team, connect an account, decide what agents may call, and run tools +--- + +A connector is a source of tools that a team registers once: an OpenAPI spec, a GraphQL endpoint, or a remote MCP server. A connection is one account on that connector. Agents get the tools. The credential stays in Spacefast, and no endpoint ever returns it. + +## Before you start + +- Only team owners and admins can open the connectors pages. A member sees **Only team owners and admins can manage connectors**. +- An API key needs connector scopes. `connectors:read` reads everything. `connectors:write` registers connectors, connects accounts, and sets policy. `connectors:execute` runs tools. `write` and `execute` both include `read`, but neither includes the other. See [API keys](/api-keys). + +The pages live under **Connectors** in the team sidebar. A switch in the header moves between **Connectors**, **Runs**, and **Policies**. + +## Add a connector from the catalog + +The catalog lists curated presets, the public registry, and your team's own connectors. It has one row per surface, so a service with an API and an MCP server is two rows. + +1. Open **Connectors** in the team sidebar. +2. Click **Browse connectors**. +3. Search, or filter by **All**, **API**, **GraphQL**, or **MCP**. +4. Click **Add** on the row you want. + +The row changes to **View** when the connector exists. A row that needs a provider-specific endpoint shows **Set up** instead of **Add**, and opens the add form. + +From the CLI, pass a catalog slug: + +```bash +sf connectors add linear +``` + +## Add a connector from a URL + +Paste any URL the service published. Detection looks behind it, so the URL it registers can differ from the one you pasted. + +1. On **Browse**, paste the URL into the search box. +2. Click **Detect**. +3. Click **Continue** on the candidate you want. +4. Check **Name**, **Namespace**, and **Description**. +5. Click **Register**. + +**Namespace** is the connector slug. It takes lowercase letters, digits, and dashes, and starts with a letter or a digit. Every tool address of the connector starts with it. Agents read **Description** when they choose between connectors. + +You enter no credential here. Authentication comes from the source. Spacefast asks an MCP server how it authenticates, so a server that wants OAuth gets an OAuth connector. + +```bash +sf connectors detect https://api.example.com +sf connectors add https://api.example.com/openapi.json +``` + +## Connect an account + +1. On **Connectors**, open the connector row. +2. Click **Connect account**. +3. Under **Who can use it**, choose **Workspace** or **Personal**. +4. If the connector offers more than one method, choose one under **Method**. +5. Optional: enter a **Label**. Agents see it when a connector has more than one account. +6. Click **Connect**. + +**Workspace** shares the account with everyone on the team and every agent that acts for it. **Personal** keeps it yours, and only within this team. An API key can connect **Workspace** accounts only. + +Connecting ends in one of three ways: + +| Result | What to do | +| --- | --- | +| `connected` | Nothing. The account is ready | +| `authorization_required` | Sign in at the provider. In the dashboard, click **Open sign-in**. The CLI prints the URL. The callback finishes the connection, so there is nothing to paste back | +| `input_required` | Give the values the connector asks for, then connect again | + +For an `input_required` answer, the dashboard shows each field with the provider's own help when the registry has some: setup notes, steps, a **Create one** link, and a **Provider docs** link. The CLI prints the same help under each `--field`. The same help is available from [`getConnectorCredentialGuidance`](/api/reference/connectors/getconnectorcredentialguidance). + +```bash +sf connectors connections add stripe --api-key "$STRIPE_KEY" +sf connectors connections add linear --owner personal +``` + +A tool address is `/`. While exactly one usable account matches, `/` also works. Connect a second account and the short form fails with `connection_ambiguous`, so use the connection id. + +## Keep a connection working + +Open a connector, then the **Accounts** tab. Each account has an actions menu. + +| Action | What it does | CLI | +| --- | --- | --- | +| **Edit…** | Changes the **Label** and the agent note, **What it's for** | `sf connectors connections edit` | +| **Check health** | Probes the provider and reports what came back. It changes nothing | `sf connectors connections health` | +| **Re-sync tools** | Reads the tool list again, so new or dropped tools show up | `sf connectors connections refresh` | +| **Reconnect…** | Signs in again, or replaces the key. The account keeps its id, label, policies, and Space bindings | `sf connectors connections reconnect` | +| **Disconnect…** | Destroys the stored credential. Anything that uses the account stops working | `sf connectors connections rm` | + +An account in `needs_reauth` or `degraded` also shows a **Reconnect** button. Token refresh happens on its own, so reconnect only when the status asks for it. + +Credentials cannot be edited. To rotate a key, reconnect the account. A disconnected account cannot be reconnected. Connecting the service again makes a new account with a new id. + +## Decide what agents can call + +A policy rule pairs an address pattern with an action. + +| Pattern | Matches | +| --- | --- | +| `*` | Every tool | +| `/*` | Every tool on every account of one connector | +| `/` | One tool on every account of one connector | +| `/*` | Every tool of one account | +| `/` | One tool on one account | + +| Action | Dashboard label | When an agent calls a matching tool | +| --- | --- | --- | +| `approve` | **Runs freely** | The call runs | +| `require_approval` | **Ask first** | The run parks until a person decides | +| `block` | **Blocked** | The run fails with `connector_tool_blocked` | + +How rules combine: + +- The most specific pattern wins. Spacefast orders rules by specificity, not by the order you add them. +- A **Workspace** rule is a floor. A **Personal** rule can tighten it but never loosen it. +- When no rule matches, the tool's own default from its source applies. A tool can also ask for approval on its own. +- A rule's pattern and owner are fixed. To change either, delete the rule and add a new one. +- Deleting a rule hands its tools to the next matching rule. Deleting a block can open a tool again. + +To add a rule: + +1. Click **Policies** in the connectors header. +2. Type a **Pattern**. The hint below it counts the tools it matches today. +3. Choose an action under **Then**. +4. Choose **Workspace** or **Personal** under **Applies to**. +5. Click **Add rule**. + +A pattern that names an unknown connector or connection is refused. An API key can add **Workspace** rules only. + +To check one address without calling it, run `sf connectors policies explain`. It prints the effect, the rule that decided it, and the reason. The API equivalent is [`resolveConnectorPolicy`](/api/reference/connectors/resolveconnectorpolicy). + +```bash +sf connectors policies add '*' --action require_approval +sf connectors policies explain cxn_abc123/charges.create +``` + +## Find a tool + +Click **See tools** on a connector row, or open **Connector tools** from the command palette. The **Tools** page lists every tool the team's accounts reach. Each row shows the tool name, its summary, its resolved effect, and its safety: **Read-only**, **Writes**, or **Destructive**. Safety comes from the source and is not a setting. + +Open a tool to see its address, the rule that decided its effect, and its **Input schema**. The API equivalents are [`listConnectorTools`](/api/reference/connectors/listconnectortools) and [`listConnectionTools`](/api/reference/connectors/listconnectiontools). + +## Run a tool + +To try a tool yourself, open it on **Tools**, fill in **Arguments** as a form or as JSON, and click **Run**. Your run follows the same policy an agent gets. + +From the CLI, pass the tool address and its arguments: + +```bash +sf connectors run --tool cxn_abc123/issues.list --args '{"limit":5}' +``` + +A run is one tool call, or a program that calls several tools. Both get one run id (`cxr_…`) and the same statuses. + +| Status | Meaning | +| --- | --- | +| `running` | Still working. Read it again with `sf connectors run get` | +| `awaiting_approval` | Parked on a decision | +| `succeeded` | Done. The result is in `result` | +| `failed` | Done. The reason is in `error`, a problem document | +| `expired` | Parked too long. Submit it again | + +The request waits up to 30 seconds for a final status by default. Set `--wait` to change that, up to 120 seconds. A failed run is still a successful request, so read the run's status before you report success. The CLI exits `1` for a `failed` or `expired` run. + +## Decide a parked run + +A parked run carries an `approvalUrl` that opens the dashboard on the decision. You can also find it in two places: + +- **Approvals**, under **Connector calls**. +- **Runs**, with the **Waiting on you** filter. Click **Review** to see the rule, the arguments, and the deadline, then approve. **Deny** sits on the row. + +**Approve** runs the call once. **Deny** fails the run with `connector_tool_blocked`. A run left parked for 15 minutes expires. + +A run parks for a person. A CLI login or an API key cannot approve it, and an attempt fails with `human_approval_required`. After a person decides, read the outcome with `sf connectors run get`. + +To fail fast instead of parking when nobody is watching, pass `--deny-unattended`. The run then fails with `connector_approval_required`. + +## Let an agent use connectors + +On-device MCP, `sf mcp`, has one gateway tool, `search_connector_tools`. It searches the team's connected services, calls a tool, and resumes a parked call. Hosted MCP and `sf mcp proxy` do not offer it, and they have no connector operation tools. See [MCP server](/agents/mcp-server). + +The CLI and the [connectors API](/api/connectors) give an agent everything else. + +## Use a connection in a Space + +A Space can bind its connector roles to team connections. See [Zero connectors](/zero-connectors). + +## Remove a connector + +```bash +sf connectors rm linear --yes +``` + +Removing a connector disconnects every account on it, then deletes the connector and its tools. Built-in connectors cannot be removed. diff --git a/content/(dynamic)/functions.mdx b/content/(dynamic)/functions.mdx index 8f96dcb0..5c5934d2 100644 --- a/content/(dynamic)/functions.mdx +++ b/content/(dynamic)/functions.mdx @@ -139,6 +139,37 @@ Two groups of variable names never reach a worker, dropped rather than blanked s | `DATABASE_URL`, `DATABASE_URI`, `MYSQL_URL`, `MYSQL_URI`, `MYSQL_DSN`, `POSTGRES_URL`, `POSTGRES_URI`, `POSTGRESQL_URL`, `POSTGRESQL_URI` | A worker reaches the database through the broker or not at all | | Anything starting with `SPACEFAST_` or `ZERO_` | The platform's own namespace | +## Call connectors and your capsule + +Two more bindings appear on `env` when the version needs them. Run `sf functions` for a summary in the CLI. + +| Binding | Present when | Calls | +| --- | --- | --- | +| `env.CONNECTORS` | The version's capsule declares connector roles, or the Space has a connector binding | A connector role | +| `env.ZERO` | The version includes a [Zero](/zero-runtime) capsule | The capsule's queries and mutations | + +A Zero project ships a Worker when it also has a `functions/` directory or a root `handler.ts`. `sf publish` compiles the capsule and the Worker into one version. + +### `env.CONNECTORS` + +`env.CONNECTORS..(args)` calls a tool through a role and resolves to its result. `env.CONNECTORS.call(role, tool, args)` resolves to `{ state, run }` instead of throwing on a non-`ok` state. See [Call connectors from your Space](/zero-connectors) for roles, bindings, and call states. + +### `env.ZERO` + +```js handler.js +export default { + async fetch(request, env) { + const notes = await env.ZERO.query("notes"); + await env.ZERO.mutate("addNote", "Written by the Worker"); + return Response.json(notes); + }, +}; +``` + +`env.ZERO.query(name, ...args)` resolves to the query's data. `env.ZERO.mutate(name, ...args)` resolves to the mutation's return value. There is no call for actions. A failed call throws an `Error` with a `code`. The code is `zero_upstream_unavailable` when the capsule cannot be reached. + +Both bindings forward the request's cookie. The runtime verifies the signed Zero identity cookie before it acts for that visitor. When no visitor is signed in, `env.ZERO` runs as the Space's service principal. In the capsule handler, `ctx.auth.userId` is then `service:` and `ctx.auth.provider` is `service`. `ctx.auth.isAuthenticated` is `true`, so a sign-in check alone does not keep the Worker out. + ## Limits | Limit | Value | diff --git a/content/(dynamic)/meta.ts b/content/(dynamic)/meta.ts index d0c68f8e..12e46b95 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", "zero-connectors", "database", "environment-variables", "crons", "object-storage", "logs", "wordpress"], }); diff --git a/content/(dynamic)/zero-connectors.mdx b/content/(dynamic)/zero-connectors.mdx new file mode 100644 index 00000000..04cb19d0 --- /dev/null +++ b/content/(dynamic)/zero-connectors.mdx @@ -0,0 +1,209 @@ +--- +title: Call connectors from your Space +description: Declare connector roles in a Zero capsule, bind team accounts to them, and call them from Zero handlers and Workers +sidebar: + order: 1.6 +--- + +A Space's code calls a connected service through a **role**. The capsule declares the role by name. A person on the team binds a connection to that role on the Space. Code calls the role, never a connection id, so you can change the account without a republish. + +Set up the connection itself first. See [Connectors](/connectors) for registering a connector, connecting an account, and team policies. + +## Before you start + +- The Space is claimed and belongs to a team. An unclaimed Space cannot bind connectors. +- The Space runs a [Zero](/zero-runtime) capsule. Only a capsule declares roles. +- The team has a connection for the service in **Team connectors**. + +## Declare roles in the capsule + +Add a `connectors` map to `capsule()`. Each key is a role name. Each value is a `connector()` call. + +```ts server/index.ts +import { capsule, connector, query } from "@spacefast/zero/server"; + +export default capsule({ + name: "support", + connectors: { + tracker: connector({ as: "team", connector: "linear-app" }), + }, + queries: { + openIssues: query((ctx) => ctx.connectors.tracker.issues.list({ limit: 10 })), + }, +}); +``` + +| Key | Required | What it does | +| --- | --- | --- | +| `as` | yes | Whose account the role uses: `team` or `owner` | +| `connector` | no | The connector slug for the role. The dashboard offers only connections for this connector | + +| `as` | Account | Condition | +| --- | --- | --- | +| `team` | A team connection | The connection allows sites to use it | +| `owner` | Your own personal connection | The Space is private | + +A role name starts with a lowercase letter and holds only letters and digits, up to 64 characters. The compiler reads the `connectors` map from source without running it, so write it as a literal. A map that is not a literal object of roles fails the publish with "capsule() connectors must be a role map." + +Roles come from the live version. Publish the capsule before you bind its roles. + +## Bind a connection to a role + +A binding links one role on one Space to one connection. Binding a role that already has a connection replaces it. + +The connection must fit the role: + +- A `team` role needs a team connection that allows sites to use it. Turn that on with **Allow sites to use this account** in the dashboard, `sf connectors connections edit --serving`, or [`updateConnection`](/api/reference/connectors/updateconnection) with `serving: true`. +- An `owner` role needs a personal connection that you created, on a private Space. If the Space stops being private, Spacefast removes its owner bindings. + +The request must act for a user who can manage the Space. A credential that acts for no user gets `forbidden`. + +### Bind in the dashboard + +1. Open the Space and choose **Integrations** in the sidebar. The page title is **Connectors**. +2. Find the role and choose **Bind account**. +3. For a `team` role, turn on **Allow sites to use this account** for the connection. +4. Choose **Bind**. + +Each row shows the role, the bound connection, and its status. Choose **Change** to pick another connection or **Unbind** to remove it. If no connection matches, the dialog says **No matching accounts**. Connect one in **Team connectors** first. + +### Bind from the CLI + +```bash +sf connectors bindings --space my-app +sf connectors connections edit cxn_abc123 --serving +sf connectors bind tracker cxn_abc123 --space my-app +sf connectors unbind tracker --space my-app --yes +``` + +`--space` is required for `bindings`, `bind`, and `unbind`. `sf connectors bindings` prints `ROLE`, `AS`, `CONNECTION`, and `STATUS` for each role. `unbind` asks for confirmation unless you pass `--yes`. Unbinding removes the binding and keeps the connection. See [`sf connectors`](/cli/connectors) for every flag. + +### Bind through the API + +| Operation | What it does | +| --- | --- | +| [`listSpaceConnectorBindings`](/api/reference/spaces/listspaceconnectorbindings) | Lists the live version's roles with their bindings, plus the Space's `access`: `private`, `team`, or `public` | +| [`bindSpaceConnector`](/api/reference/spaces/bindspaceconnector) | Binds `connectionId` to a role | +| [`unbindSpaceConnector`](/api/reference/spaces/unbindspaceconnector) | Removes a role's binding without disconnecting the account | + +All three need the `connectors:write` scope. + +## Call a role from Zero + +`ctx.connectors.` is on every query, mutation, action, and endpoint context. The property path after the role is the tool name, joined with dots. `ctx.connectors.tracker.issues.create(args)` calls the `issues.create` tool on the bound connection. + +```ts server/index.ts +import { capsule, connector, mutation } from "@spacefast/zero/server"; + +export default capsule({ + connectors: { + tracker: connector({ as: "team", connector: "linear-app" }), + }, + mutations: { + reportBug: mutation((ctx, title: string) => ctx.connectors.tracker.issues.create({ title })), + }, +}); +``` + +The tool name is the part of a tool address after the connection id. List a connection's tools with [`listConnectionTools`](/api/reference/connectors/listconnectiontools). + +The call resolves to the tool's result, or `null` when the tool returned nothing. Any other outcome throws a `ConnectorCallError` that carries a [call state](#call-states) on `error.state`. + +Queries and read endpoints can call only read-only tools. A tool that writes answers `forbidden` there. Call it from a mutation, an action, or a write endpoint instead. + +### Show the state in the client + +When a `ConnectorCallError` leaves a query, mutation, or action, the runtime rolls back the handler's database writes. The client receives the call state instead of a result. + +`ConnectorGate` from `@spacefast/zero/kit` renders its children while the role works. Otherwise it shows the state message. It adds a **View approval** link while a call waits for approval and a **Try again** button when the service is unavailable. While a call waits, it retries every 3 seconds. + +```tsx client/issues.tsx +import { useQuery } from "@spacefast/zero/client"; +import { ConnectorGate } from "@spacefast/zero/kit"; + +export function Issues() { + const issues = useQuery<{ id: string; title: string }[]>("openIssues"); + return ( + +
    {Array.isArray(issues) && issues.map((issue) =>
  • {issue.title}
  • )}
+
+ ); +} +``` + +To read the state of one result yourself, call `useConnectorState(result)` from `@spacefast/zero/client`. + +## Call a role from a Worker + +A [Worker](/functions) gets `env.CONNECTORS` when the version's capsule declares roles or the Space has a binding. To ship a Worker beside a capsule, add a `functions/` directory or a root `handler.ts` to the Zero project. `sf publish` compiles both into one version. + +The role proxy works as it does in Zero. It resolves to the tool's result and throws a `ConnectorCallError` for any other state. + +```js handler.js +export default { + async fetch(request, env) { + const issue = await env.CONNECTORS.tracker.issues.create({ title: "Checkout is down" }); + return Response.json(issue); + }, +}; +``` + +To handle every state without a throw, call `env.CONNECTORS.call(role, tool, args)`. It resolves to `{ state, run }`. Check `state.code` before you read `run`. + +```js handler.js +export default { + async fetch(request, env) { + const { state, run } = await env.CONNECTORS.call("tracker", "issues.list", { limit: 10 }); + if (state.code !== "ok") return Response.json({ error: state.message }, { status: 503 }); + return Response.json(run.result); + }, +}; +``` + +`call()` throws only when the connector service refuses or cannot be reached. The error carries a `code`, for example `service_upstream_unavailable` or `connector_forbidden`. + +A Worker call can use any tool that policy allows. The read-only rule applies only to Zero read handlers. A binding stays usable from a Worker after a later version removes the capsule that declared the role. `env.CONNECTORS` forwards the request's signed visitor cookie. The runtime verifies the cookie before it acts for that visitor. + +## Call states + +Every call ends in one state. `state.message` carries the text below. + +| `code` | `message` | When | +| --- | --- | --- | +| `ok` | Connected. | The tool ran | +| `unbound` | The site owner needs to connect this service. | No usable connection is bound to the role, or the Space has no team. An owner binding stops working when its owner leaves the team | +| `forbidden` | You do not have permission to use this connection. | The bound connection does not fit the role, policy blocks the tool, or a Zero read handler called a tool that writes | +| `connect_required` | Connect your account to continue. | The bound connection is still waiting for authorization | +| `reconnect_required` | Reconnect your account to continue. | The bound connection was revoked or needs a new sign-in | +| `approval_pending` | This action is waiting for approval. | Policy parked the call. `pendingReason` is `approval`, and `approvalUrl` links to the decision | +| `approval_pending` | Still working on it. Try again in a moment. | The run did not finish within 25 seconds. `pendingReason` is `running` | +| `unavailable` | This service is unavailable. Try again. | The tool failed for another reason | + +A parked call carries `runId`. In the Zero client, `useConnectorState` reports a still-running call as `running`. + +## Policies and approvals + +A Space call runs under the same team policies as any other connector run. The policy for the tool address decides the outcome: + +| Policy action | Result in the Space | +| --- | --- | +| `approve` | The tool runs | +| `require_approval` | The run parks and the call returns `approval_pending`. A person decides it | +| `block` | The call returns `forbidden` | + +Each call is a connector run. Its origin is `zero` when the version includes a capsule and `function` when it does not. Its handler is the Zero handler name, or `fetch` for a Worker. To list one Space's calls, filter [`listConnectorRuns`](/api/reference/connectors/listconnectorruns) by `space` and `origin`. + +Each call has an idempotency key built from the version, the request id, the handler, the role, the tool, and the arguments. While a mutation's call waits, the Zero client retries the mutation with the same request id. The retry gets the same run back instead of a second call. After a person decides, the retry returns the outcome. + +## API errors + +`bindSpaceConnector` can fail with these codes. + +| Code | When | +| --- | --- | +| `space_must_be_claimed` | The Space does not belong to a team | +| `connector_role_unknown` | The live version does not declare the role | +| `connection_serving_not_granted` | A `team` role got a personal connection, or a team connection that does not allow sites to use it | +| `owner_binding_requires_private_space` | An `owner` role got a connection that is not your personal one, or the Space is not private | +| `forbidden` | The credential does not act for a user | +| `not_found` | The connection is revoked or does not exist | diff --git a/content/(publish)/meta.ts b/content/(publish)/meta.ts index a50756c1..65d259ca 100644 --- a/content/(publish)/meta.ts +++ b/content/(publish)/meta.ts @@ -8,6 +8,7 @@ export default defineMeta({ "publish", "frameworks", "anonymous-and-claim", + "migrate", "git", "ci", "wordpress-data-sources", diff --git a/content/(publish)/migrate.mdx b/content/(publish)/migrate.mdx new file mode 100644 index 00000000..9273737d --- /dev/null +++ b/content/(publish)/migrate.mdx @@ -0,0 +1,131 @@ +--- +title: Copy a website +description: Capture a public website with sf migrate, publish it as a static Space, and finish the move with a claim and a domain +sidebar: + order: 3.5 +--- + +`sf migrate` takes the URL of a live website and publishes a static copy of it to a new Space. It does not read your current directory, and the result is a new Space every time. + +## Copy a site + +```bash +sf migrate https://example.com +``` + +The CLI starts a capture build, follows it until the copy is published, and prints the result. A capture takes a while. The build can run for up to 75 minutes, and the CLI waits 15 minutes by default. To wait for the whole capture, raise the timeout: + +```bash +sf migrate https://example.com --wait-timeout 4500 +``` + +```text +sf migrate URL [--team ] [--name ] [--idempotency-key ] + [--wait | --no-wait] [--wait-timeout ] [--show-secret] +``` + +### Arguments + +| Argument | What it is | +| --- | --- | +| `URL` | The public `https://` address of the site to copy. Required | + +### Flags + +| Flag | Default | What it does | +| --- | --- | --- | +| `--team=` | default team | Team that receives the new Space. Requires a login or `--token` | +| `--name=` | source hostname | Title of the new Space | +| `--idempotency-key=` | new key | Recover an earlier attempt. See [Recover an attempt](#recover-an-attempt) | +| `--wait` / `--no-wait` | `--wait` | Wait until the copy is published before returning | +| `--wait-timeout=` | `900` | Seconds to wait for the capture and the publish. Minimum 1 | +| `--show-secret` | off | Print the **Open** and **Claim** links in JSON or non-interactive output | + +The global flags (`--api-url`, `--profile`, `--token`, `-y`, `--json`) also apply. See [the CLI overview](/cli). + +## Choose what the URL can be + +The URL must: + +- use `https://` +- have no user name, password, or `#fragment` +- resolve to a public address, not `localhost` or a private or loopback IP address +- not be a Spacefast hostname +- be at most 2,048 characters long + +## Know what the copy contains + +The capture renders the site as portable HTML and publishes those files as a static Space. If the capture produces no HTML file, the build fails. + +The copy is static files only. Server-side code, accounts, stored data, and domains stay with the old site. + +Without a login, the copy publishes as an anonymous Space and gets the anonymous limits: + +| Limit | Value | +| --- | --- | +| Files | 1,000 | +| Size of one file | 50 MB | +| Total size | 100 MB | + +If the copy is larger, the build fails with the matching code. To lift these limits, sign in and run `sf migrate` again. The copy then publishes to your team under its plan's limits. See [Limits](/limits). + +## Read the result + +Progress goes to stderr. The result goes to stdout: + +```text +Space spc_0123456789abcdef +Build bld_0123456789abcdef (succeeded) +Status https://api.spacefast.com/v1/builds/bld_0123456789abcdef/receipt +Replay key 5f0c6a1e-8a51-4c3b-9d1e-2a7f4b6c8d90 +Open https://swift-otter-4821.space.fast/... +Capture {"state":"preview_ready",...} +``` + +| Line | What it is | +| --- | --- | +| `Space` | The new Space ID | +| `Build` | The capture build ID and its status | +| `Status` | The URL that reports the build receipt | +| `Replay key` | The idempotency key for this attempt | +| `Open` | A link that opens the copy, or `Pending publication` | +| `Claim` | The claim link. Anonymous copies only | +| `Capture` | Capture progress and quality evidence | + +New Spaces are private. Use the **Open** link, or share the copy with [Access and sharing](/access). An unmeasured visual comparison in `Capture` is not a passing comparison. Open the copy and check it yourself. + +With `--json`, `data` is the build receipt plus `idempotencyKey`. The receipt carries `space`, `build`, `next`, `links`, and, when they exist, `version`, `access`, `claim`, and `diagnostics`. JSON and non-interactive output replace the access link, the claim link, and `access.token` with `[REDACTED]`. Pass `--show-secret` to print them. The space key in `claim.key` is always masked. + +## Recover an attempt + +Each run of `sf migrate` starts a new migration. To continue an earlier attempt instead, repeat it with the key from `Replay key`: + +```bash +sf migrate https://example.com --idempotency-key 5f0c6a1e-8a51-4c3b-9d1e-2a7f4b6c8d90 +``` + +Use the same URL, `--team`, `--name`, and credential as the first run. The CLI keeps each attempt under `~/.spacefast/migrations/`, and a repeat with matching inputs resumes it without a new capture. When a run fails after the request starts, the error prints the repeat command. + +To return at once and check later, pass `--no-wait`. The receipt has the build ID. Follow it with [`sf builds logs`](/cli/builds), or repeat the run with the key. + +## Finish the move + +1. To keep an anonymous copy, open the **Claim** link and pick a team. See [Publish without an account](/anonymous-and-claim). +2. To serve the copy on your own domain, attach the domain to the new Space. See [Custom domains](/domains). +3. To edit the copy after you claim it, download it with `sf spaces download`. Change the files, then publish them with `sf publish`. + +## Errors + +| Code | Exit | When | +| --- | --- | --- | +| `validation_error` | 2 | The URL is not `https://`, carries credentials or a fragment, or does not parse. Raised before the request | +| `validation_error` | 2 | `--team` without a login or `--token`, or no team matches it | +| `invalid_publish_payload` | 2 | The API refused the URL, for example a Spacefast hostname or `localhost` | +| `egress_target_denied` | 2 | The hostname resolves to a private, loopback, or other blocked address | +| `egress_target_unresolvable` | 2 | The hostname does not resolve | +| `build_failed` | 1 | The capture did not publish. `details.captureCode` names the capture failure | +| `manifest_too_many_files` | 1 | The copy has more files than the limit | +| `version_file_too_large` | 1 | One file in the copy is over the limit | +| `version_total_bytes_exceeded` | 1 | The copy is over the total size limit | +| `timeout_error` | 50 | `--wait-timeout` ran out before the build finished. Repeat with the key | +| `migration_state_invalid` | 2 | The saved attempt file for this key cannot be read. Repair it, or run without the key to start over | diff --git a/content/(reference)/config-file.mdx b/content/(reference)/config-file.mdx index cbc05dc4..afb93c20 100644 --- a/content/(reference)/config-file.mdx +++ b/content/(reference)/config-file.mdx @@ -1,14 +1,96 @@ --- -title: sf.jsonc reference -description: Every key sf.jsonc accepts, what it does, and which defaults the finalizer actually computes +title: Project config reference +description: Every key the project config accepts in spacefast.config.ts or sf.jsonc, how the CLI finds the file, and which defaults the finalizer actually computes sidebar: order: 1 --- -`sf.jsonc` is the project config Spacefast reads when a version finalizes. This page lists every key the schema accepts. After it you can write the file by hand and know which defaults are real. +The project config holds the settings Spacefast reads when a version finalizes. Write it as `spacefast.config.ts` or as `sf.jsonc`. Both formats accept the same keys, and both stay supported. `sf init` writes `spacefast.config.ts`. + +## File names and discovery order + +The CLI looks for the config in the project directory. The first file found wins, in this order. + +1. `spacefast.config.ts` +2. `sf.jsonc` +3. `spacefast.jsonc` +4. `spacefast.json` +5. `sf.json` +6. `.sf/sf.json` +7. `.sf/config.jsonc` +8. `.sf/config.json` + +No other TypeScript or JavaScript file name is read. `spacefast.config.mts` and `spacefast.config.js` are ignored. + +All eight files are private. They are never served. ## A full example +The two files below declare the same config. + + + + +```ts spacefast.config.ts +import { defineConfig } from "spacefast/config"; + +export default defineConfig({ + // Which Space this project publishes to, and what it is called. + space: "my-app", + name: "My App", + + // Serving shape. + index: "index.html", + fallback: { path: "index.html", status: 200 }, + cleanUrls: true, + listing: false, + + // Listing, sharing, and browser icon metadata. + meta: { + title: "My App", + description: "What this site is", + image: "/og.png", + favicon: "/favicon.ico", + }, + + // Visual tokens for Spacefast-authored pages (404, access, index). + theme: { accent: "#0d9488", background: "#ffffff", name: "My App" }, + + // Files that receive {{ vars.NAME }} substitution at finalize. + templates: ["index.html"], + + // Analytics tags and custom snippets. + superpowers: { + googleAnalytics: { enabled: true, measurementId: "G-XXXXXXX" }, + }, + + // Settings for builds the platform runs from a connected repository. + build: { + rootDirectory: "apps/web", + installCommand: "pnpm install", + buildCommand: "pnpm build", + outputDirectory: "dist", + timeoutSeconds: 900, + }, + + // Run code alongside the files. + runtime: { kind: "zero", server: "server/index.ts", client: "client/index.tsx" }, + + // Scheduled GETs against this space's own paths. + crons: [{ path: "/api/digest", schedule: "daily" }], + + // Paths that stay public even on a restricted Space. + access: { public: ["/health", "/assets/**"] }, + + // Routing rules; see the routing page. + redirects: [{ source: "/old", destination: "/new", status: 301 }], + headers: [{ source: "/assets/*", headers: [{ key: "X-Frame-Options", value: "DENY" }] }], +}); +``` + + + + ```jsonc sf.jsonc { "$schema": "https://spacefast.com/schemas/sf.json", @@ -66,21 +148,104 @@ sidebar: } ``` -## Where the file lives + + + +## TypeScript config -The canonical name is `sf.jsonc` at the root of the directory you publish. Six aliases are accepted too. The first file found wins, in this order. +`spacefast.config.ts` is an ES module. Its default export is the config. The export can be an object, a promise of an object, or a function that returns either. An async function works. -1. `sf.jsonc` -2. `spacefast.jsonc` -3. `spacefast.json` -4. `sf.json` -5. `.sf/sf.json` -6. `.sf/config.jsonc` -7. `.sf/config.json` +```ts spacefast.config.ts +import { defineConfig } from "spacefast/config"; +import { siteTitle } from "./site/meta"; + +export default defineConfig(async () => ({ + name: siteTitle, + cleanUrls: true, +})); +``` -All seven are private files. They are never served. Keep exactly one, since two config files in the same tree is an error. +The CLI bundles the file and its relative imports, then runs the bundle in its own process. Package imports and Node.js built-ins are not bundled. They resolve from the project directory when the config loads. -The file is JSON with comments, capped at 256 KiB. Point `$schema` at `https://spacefast.com/schemas/sf.json` for editor completion and validation. +`sf build`, `sf publish`, and hosted Git builds load the config before they run the install command. A package import therefore fails on a fresh checkout. Import only relative files and Node.js built-ins. + +The resolved value must be a JSON object. Strings, numbers, booleans, `null`, arrays, and plain objects are allowed. A key set to `undefined` is dropped. Any other value, for example a `Date`, fails the load. + +The config is code. It runs on your machine and in hosted builds. Do not put secrets in it, because the resolved values are stored with the version. + +### `defineConfig` and types + +The CLI provides the `spacefast/config` module. + +| Export | Kind | What it is | +| --- | --- | --- | +| `defineConfig` | function | Returns its argument unchanged. Its signature type-checks the default export | +| `SpacefastConfig` | type | The config object | +| `SpacefastConfigInput` | type | `SpacefastConfig`, a promise of it, or a function that returns either | + +The CLI supplies the module when it loads the file, so the project needs no dependency for it. + +:::note[Editor types] +The `spacefast` package on npm does not include `spacefast/config` yet. TypeScript in your editor cannot resolve the import, but the CLI still loads the file. +::: + +`defineConfig` is optional. A plain default export works the same way. + +```ts spacefast.config.ts +import type { SpacefastConfig } from "spacefast/config"; + +export default { name: "My App", cleanUrls: true } satisfies SpacefastConfig; +``` + +## Precedence and failure rules + +- The first file in the discovery order wins. The CLI prints a warning for each lower file it ignores. +- An explicit `--config` path skips discovery. The CLI reads that file only. +- The selected file must be valid. If it fails to load or validate, the command stops with `config_invalid` (exit 2). The CLI never falls back to a lower file. +- A selected file that exists but cannot be read also fails with `config_invalid`. +- A `--config` path that does not exist fails with `config_not_found` (exit 2). + +A TypeScript file fails to load when it throws, has no default export, imports a package that is not installed, or resolves to a value that is not JSON. A file that loads but sets an invalid key fails validation. For example, `cleanUrls: "yes"` reports `spacefast.config.ts is invalid: cleanUrls: Expected a boolean`. + +## Which commands read it + +Every command that selects a Space from the current directory reads the config. That includes commands that take `--space`, so an invalid config stops them too. `sf publish`, `sf build`, `sf dev`, `sf doctor`, and `sf routing inspect` also read it for their own work. `sf doctor` reports the selected file and any ignored files on its `project-config` line. + +Two command groups take an explicit path. + +| Command | Flag | What it reads | +| --- | --- | --- | +| `sf git connect`, `sf git update`, `sf git build` | `--config=`, env `SPACEFAST_CONFIG` | The `build` block, sent as repository build settings | +| `sf rules validate`, `sf rules test` | `--file=` | The `firewall` and `cache` blocks | + +## How the TypeScript config ships + +The server does not run TypeScript. When `sf publish` or `sf build` packs a version, the CLI evaluates `spacefast.config.ts`. It writes the result into the version as `sf.jsonc`, in place of any `sf.jsonc` on disk. Your source files do not change. + +Files that the config imports are ordinary files. If they are in the published directory, they are published and served. + +Hosted Git builds run the same CLI build, so they evaluate a committed `spacefast.config.ts` too. + +A publish that sends individual files through the API or MCP cannot evaluate the file. If those files include `spacefast.config.ts`, the publish fails. To publish a project with a TypeScript config, run `sf publish` from the project directory. + +## Migrate from sf.jsonc + +Migration is optional. `sf.jsonc` stays supported. If the project uses another JSON file name, use that file in step 1. + +1. Move `sf.jsonc` to `spacefast.config.ts` in the project directory. +2. Add `import { defineConfig } from "spacefast/config";` as the first line. +3. Put `export default defineConfig(` before the opening `{`. +4. Put `);` after the closing `}`. +5. Run `sf doctor`. +6. Make sure that the `project-config` line starts with `ok` and names `spacefast.config.ts`. + +Quoted keys, `//` comments, and trailing commas are valid TypeScript. The pasted object needs no other change. The `$schema` key is allowed but has no effect in TypeScript. Your Space link in `.spacefast/space.json` does not change. + +## File format + +`sf.jsonc` and the other JSON names are JSON with comments. Point `$schema` at `https://spacefast.com/schemas/sf.json` for editor completion and validation. The schema covers the JSON files only. + +The config is capped at 256 KiB. For `spacefast.config.ts`, the cap applies to the resolved JSON, not to the source file. ## Identity @@ -121,7 +286,7 @@ While `name` is set in the file, the API refuses to rename the Space. The file w Colors accept hex, `rgb()`, `rgba()`, `hsl()`, `hsla()`, and CSS color names. `theme` is a closed object, so an unknown key is an error. -A `theme.json` file next to `sf.jsonc` is an inference input rather than configuration. Only `settings.color.palette` and `settings.typography.fontFamilies` are read from it. A [Zero](/zero-runtime) capsule's Tailwind compile reads the same file. +A `theme.json` file next to the config is an inference input rather than configuration. Only `settings.color.palette` and `settings.typography.fontFamilies` are read from it. A [Zero](/zero-runtime) capsule's Tailwind compile reads the same file. What these tokens paint, and how to replace those pages outright, is on [Layout, theme, and site pages](/site-pages). @@ -206,7 +371,7 @@ See [Functions](/functions). | `crons[].path` | string, up to 512 chars | none | Path to GET. Starts with `/`, no query, no wildcards, no `:placeholders` | | `crons[].schedule` | string | none | A named schedule, a shorthand, or a five-field crontab | -`sf.jsonc` is the only writer. See [Crons](/crons) for the schedule grammar. +The project config is the only writer. See [Crons](/crons) for the schedule grammar. ## Access @@ -218,10 +383,19 @@ See [Functions](/functions). ## Routing -`redirects`, `rewrites`, and `headers` are real `sf.jsonc` keys, owned by the routing compiler and merged behind the `_redirects` and `_headers` files. Their grammar, precedence, and every rule field live on one page. +`redirects`, `rewrites`, and `headers` are real config keys, owned by the routing compiler and merged behind the `_redirects` and `_headers` files. Their grammar, precedence, and every rule field live on one page. See [Redirects, rewrites, and headers](/routing). +## Traffic rules + +| Key | Type | Default | What it does | +| --- | --- | --- | --- | +| `firewall` | array, up to 50 rules | none | Requests to block or challenge before they reach the Space | +| `cache.bypass` | array, up to 50 rules | none | Requests that skip the cache | + +A publish puts these rules at the edge. The Space settings can declare the same two keys, and their rules answer first. See [Traffic rules](/traffic-rules) for the rule fields. + ## Accepted but not yet enforced | Key | Type | What it does | @@ -246,7 +420,7 @@ A plan may lower these caps. None of them can be raised. ## The strict v1 shape -There is a second, stricter contract for the same filename, selected by a top-level `"version": 1`. Its schema is published separately at `https://spacefast.com/schemas/sf.v1.json`, and `sf routing inspect` compiles against it. +There is a second, stricter contract for the same files, selected by a top-level `"version": 1`. Its schema is published separately at `https://spacefast.com/schemas/sf.v1.json`, and `sf routing inspect` compiles against it. In that shape the serving keys move into a `serve` block. @@ -272,6 +446,6 @@ Under `version: 1` the flat spellings are rejected. Four of them get `config_unk | `cleanUrls` | `serve.cleanUrls` | | `meta` | `metadata` | -The v1 root accepts `$schema`, `version`, `build`, `serve`, `redirects`, `rewrites`, `headers`, `metadata`, `substitute`, `access`, and `crons`. Anything else is an error. `listing`, `superpowers`, `templates`, `theme`, `placement`, and `markdownNegotiation` report `config_key_removed`; `space` reports `config_identity_moved`. +The v1 root accepts `$schema`, `version`, `build`, `serve`, `redirects`, `rewrites`, `headers`, `firewall`, `cache`, `metadata`, `substitute`, `access`, and `crons`. Anything else is an error. `listing`, `superpowers`, `templates`, `theme`, `placement`, and `markdownNegotiation` report `config_key_removed`; `space` reports `config_identity_moved`. The flat shape above is what the finalizer reads and what the CLI writes. Use `version: 1` only if you want the stricter contract. diff --git a/content/(reference)/feedback.mdx b/content/(reference)/feedback.mdx new file mode 100644 index 00000000..7a3b4b1b --- /dev/null +++ b/content/(reference)/feedback.mdx @@ -0,0 +1,131 @@ +--- +title: Send feedback +description: Tell Spacefast what broke or what is missing, from the CLI, the API, an agent, or the docs, with the error code and request ID attached +sidebar: + order: 5 +--- + +Every note gets a reference, and a person reads it. The fastest report names what you tried, what happened, and the request ID from the error. + +## Send feedback from the CLI + +```bash +sf feedback -m "The continuation docs are unclear" --category docs +``` + +```text +sf feedback -m [--category ] [--error-code ] [--request-id ] [--space-id ] [--url ] +``` + +| Flag | Default | What it does | +| --- | --- | --- | +| `-m, --message=` | required | The feedback itself | +| `--category=` | `other` | One of `bug`, `docs`, `limit`, `idea`, or `other` | +| `--error-code=` | none | The `code` of the error you hit | +| `--request-id=` | none | The `requestId` of the failed request | +| `--space-id=` | none | The Space the feedback is about | +| `--url=` | none | A page or URL the feedback is about | + +`sf feedback` works without a login. When you are signed in, the note is filed under your account. + +The CLI prints the reference: + +```text +Feedback ref: fdb_0123456789abcdef +Thanks! We read every one. +``` + +With `--json`, `data` is `{ "ref": "fdb_…", "status": "stored" }`. + +## Attach an error to your feedback + +Every API error carries a stable `code` and a `requestId`. Send both, and the team can find the exact failed request. + +When an API call fails, the CLI prints the report command for you, with both values filled in: + +```text +To report this: sf feedback --error-code space_unclaimed --request-id 00000000-0000-4000-8000-000000000000 --message "Describe what happened" +``` + +With `--json`, the same line is the last entry of `error.suggestions`, and the values are in `error.code` and `error.requestId`. Copy the command, replace the message, and run it. + +```bash +sf feedback -m "Publish failed after claim" --category bug --error-code space_claimed_credential_available --request-id 00000000-0000-4000-8000-000000000000 +``` + +The dashboard shows the same values. An error screen lists the code, the HTTP status, and the request ID under **Error details**. **Copy** puts them on the clipboard with the page URL. + +## Read back what you sent + +```bash +sf feedback list +sf feedback list --limit 5 --json +sf feedback get fdb_0123456789abcdef +``` + +`sf feedback list` shows your notes, newest first, as a `REF | CATEGORY | SENT | MESSAGE` table. The message column shows the first 60 characters. `--limit` defaults to 20, with a maximum of 100. With nothing sent, it prints "No feedback sent yet." + +`sf feedback get` prints one note in full: the reference, the category, the time sent, any context, and the message. + +Only the person who sent a note can read it. Sign in with `sf login` or the dashboard first. Feedback sent without a login cannot be read back. + +## Send feedback through the API + +[`createFeedback`](/api/reference/spaces/createfeedback) takes the same fields as the CLI. It needs no credential. + +```bash +curl -X POST https://api.spacefast.com/v1/feedback \ + -H "Content-Type: application/json" \ + -d '{"message":"Publish failed after claim","category":"bug","context":{"errorCode":"space_claimed_credential_available","requestId":"00000000-0000-4000-8000-000000000000"}}' +``` + +The response is `201` with `{ "data": { "ref": "fdb_…" } }`. The body can also be form-encoded or multipart. + +| Field | Required | Limits | +| --- | --- | --- | +| `message` | yes | 1 to 4,000 characters after trimming | +| `category` | no | `bug`, `docs`, `limit`, `idea`, or `other`. Default `other` | +| `context.errorCode` | no | 1 to 128 characters | +| `context.requestId` | no | 1 to 128 characters | +| `context.spaceId` | no | 1 to 128 characters | +| `context.url` | no | An `http` or `https` URL, up to 2,000 characters | + +Spacefast identifies the sender from the credential on the request: + +| Credential | Sender | +| --- | --- | +| A login, or an API key a person created | That person | +| A space key (`sfc_…`) | The unclaimed Space the key belongs to | +| None | Anonymous | + +To read notes back, call [`listFeedback`](/api/reference/spaces/listfeedback) (`limit` 1 to 100, default 20) or [`getFeedback`](/api/reference/spaces/getfeedback) with the reference. Both need a credential that acts for the person who sent the note. + +## Send feedback from an agent + +Agents call the same `createFeedback` operation. On the [MCP server](/agents/mcp-server), the operation runs through `execute`. From a terminal, the agent runs `sf feedback`. + +The Spacefast agent instructions tell an agent to ask you once, after a task with friction, whether to send feedback. If you agree, the agent sends later notes in the same conversation without asking again and tells you what it sent. If you decline, it does not ask again. The agent includes the error code and request ID when it has them, and never includes credentials or private links. + +## Rate a docs page + +Each docs page ends with **Was this page helpful?** and the buttons **Yes** and **No**. To suggest a fix to the text, choose **Edit on GitHub** on the page. + +## Get help in the dashboard + +To talk to a person, choose **Support** in the dashboard sidebar. It opens chat, or an email to `support@spacefast.com` when chat is not available. Error screens have a **Contact support** button that does the same. + +## Limits and errors + +| Sender | Limit | +| --- | --- | +| Signed-in account | 100 notes per hour | +| Space key | 50 notes per hour for each Space | +| Anonymous | 5 notes per hour for each IP address | + +| Code | Exit | When | +| --- | --- | --- | +| `validation_error` | 2 | The message is empty or too long, a context field is out of range, or the category is unknown | +| `rate_limited` | 1 | You reached the limit for your sender type. Wait for the `Retry-After` time | +| `unauthorized` | 10 | `sf feedback list` or `get` with no credential | +| `forbidden` | 10 | `sf feedback list` or `get` with a space key, team key, OAuth client key, or partner key | +| `not_found` | 20 | The reference does not exist, or another person sent it | diff --git a/content/(reference)/meta.ts b/content/(reference)/meta.ts index 7745cf16..6c207182 100644 --- a/content/(reference)/meta.ts +++ b/content/(reference)/meta.ts @@ -4,5 +4,5 @@ export default defineMeta({ title: "Reference", collapsed: false, order: 7, - pages: ["glossary", "limits", "config-file", "errors"], + pages: ["glossary", "limits", "config-file", "errors", "feedback"], }); diff --git a/content/(serve)/access.mdx b/content/(serve)/access.mdx index cce97d12..6a6c0063 100644 --- a/content/(serve)/access.mdx +++ b/content/(serve)/access.mdx @@ -35,6 +35,8 @@ A role is a named bundle of capabilities. Every grant carries one. | `editor` | The above, plus publish | | `manager` | The above, plus manage access | +A `commenter` can read and leave [Comments](/comments) on the paths the grant covers. + Share links and passwords are forwardable secrets, so they cap at `viewer` or `commenter`. Neither can be given `manage`. ## In the dashboard diff --git a/content/(serve)/comments.mdx b/content/(serve)/comments.mdx new file mode 100644 index 00000000..4513c496 --- /dev/null +++ b/content/(serve)/comments.mdx @@ -0,0 +1,215 @@ +--- +title: Comments +description: Turn on Comments, leave feedback on a version or the live Space, reply, mention, react, resolve, and export threads +--- + +Comments put feedback threads on the page they are about. A reviewer pins a thread to an element, a whole page, or a file. You answer from the page, the dashboard, the CLI, or the API. + +## Before you start + +- Comments run only on claimed Spaces that belong to a team. Other Spaces get `team_required`. +- **Page enhancements** must be on. Turn it on in the Space's settings under **General**. With it off, the API answers `comments_disabled`. The switch is `superpowers.enabled` in [the config file](/config-file#superpowers). +- Who can comment comes from the Space's grants. A grant needs the `commenter` role or higher. See [Access and sharing](/access#roles). + +## Choose where Comments run + +| Setting | Dashboard label | Where people can comment | +| --- | --- | --- | +| `off` | **Off** | Nowhere. Spacefast keeps the threads, but nobody can read them until you turn Comments on again | +| `previews` | **Previews only** | Version preview URLs only. The live Space shows nothing | +| `everywhere` | **Everywhere** | Version preview URLs and the live Space | + +A Space gets **Previews only** at its first publish. A change applies right away, without a republish. While Comments are off, the **Comments** page shows **Turn on comments**, which sets **Previews only**. + +To change the setting in the dashboard: + +1. Open the Space and go to **Comments**. +2. Open the settings control in the page header. It shows the current setting. +3. Under **Where people can comment**, choose an option. + +To change it from the terminal, run `sf comments settings set --surface everywhere`. See [sf comments](/cli/comments#sf-comments-settings-set). + +Over HTTP, read the settings with [`getSpaceCollaboration`](/api/reference/spaces/getspacecollaboration). Then send only the settings to change to [`updateSpaceCollaboration`](/api/reference/spaces/updatespacecollaboration), with the `revision` you read as `expectedRevision`. If someone changed the settings after your read, the update returns 409 `resource_revision_changed` with the current settings in `details`. The dashboard shows "Comment settings changed elsewhere" with **Apply my change** and **Use latest**. + +:::note[Grants must match the setting] +With **Previews only**, a new `commenter` grant on the `live` target is refused with `comments_target_unavailable`. With **Off**, every new `commenter` grant is refused. Point the grant at a version, or change the setting. Existing grants do not change when you change the setting. +::: + +## Comment on a page + +1. Open a version preview URL. In the dashboard, **Open page to comment** opens one. With **Everywhere**, the live Space works too. +2. Select **Comments** on the page to open the toolbar. +3. Select **Comment**, then select the element or the page area. +4. Write the comment and select **Comment**. + +To draw on the page, select **Draw** in the toolbar. To add a file, select **Attach a file** in the composer. The **Sidebar** lists the threads on the page and on other pages. + +A visitor without a Spacefast account comments under a name that Spacefast picks. To change it, the visitor selects the name. In the same dialog, the visitor can give an email and confirm it from the inbox to get reply emails. + +## Comment on a version or the live Space + +A thread belongs to the version where someone left it. + +- A thread on the live version belongs to the page path. It stays on that page after you publish again. +- A thread on any other version stays with that version. It does not appear on the live Space. + +The version page in the dashboard has a **Comments on this version** section. Select a row to open the thread on the **Comments** page. + +To start a thread on a version from the CLI, pass `--version`. Without it, the thread goes on the live version: + +```bash +sf comments create --version v3 --page /pricing --body "The annual price is wrong." +``` + +Over HTTP, use [`createSpaceVersionComment`](/api/reference/versions/createspaceversioncomment) to start one and [`listSpaceVersionComments`](/api/reference/versions/listspaceversioncomments) to list one version's threads. + +## Comment from the CLI or the API + +A thread points at one of three anchors. + +| Anchor | CLI | API `target` | +| --- | --- | --- | +| A whole page | `--page /pricing` | Omit `target`. The API stores a page anchor | +| An element on a page | `--page / --selector "#hero-title"` | An element anchor with CSS `selectors` | +| A file, optionally some lines | `--file src/app.js --lines 7-14` | A file anchor. Its `file.path` becomes the thread's page path | + +Writing a comment needs a credential that acts as a person and carries `spaces:write`. Any other credential gets 403 "Comments require a credential that acts as a user." + +If a request can be retried, send the same `idempotencyKey` each time. A repeated reply returns `status: "existing"` instead of a second reply. + +## Reply and mention teammates + +To reply, write in the thread's reply box on the page or the **Comments** page. From the terminal, run `sf comments reply`. Over HTTP, call [`createSpaceCommentReply`](/api/reference/spaces/createspacecommentreply). + +A mention notifies one teammate. You can mention the members of the Space's team who can open the Space. The `listSpaceCommentMentionable` operation returns them. + +- A comment or reply takes up to 50 mentions, each person once. +- An ID outside that list fails with `validation_error` and a `pointer` such as `/mentions/0`. +- `sf comments create --mention` and `sf comments reply --mention` take an email, a name, or a user ID. +- On a page, only people with the `editor` or `manager` role see the list of people to mention. + +## React to a thread + +A thread takes six reactions: 👍, ❤️, 🎉, 👀, 🚀, and 🙏. Select one on the page or the **Comments** page. Select it again to take yours back. + +From the terminal, run `sf comments react` and `sf comments unreact`. Over HTTP, send `emoji` and `op` (`add` or `remove`) to [`updateSpaceCommentReaction`](/api/reference/spaces/updatespacecommentreaction). + +## Resolve and reopen a thread + +A resolved thread leaves the open list and keeps every reply. Reopen it to put it back. + +- On the page, you can resolve your own threads. People with the `editor` or `manager` role can resolve any thread. The **Sidebar** menu has **Resolve all**. +- On the **Comments** page, open the thread and select **Resolve** or **Reopen**. +- From the terminal, run `sf comments resolve` or `sf comments reopen`. +- Over HTTP, send `status: "archived"` to resolve or `status: "open"` to reopen with [`updateSpaceComment`](/api/reference/spaces/updatespacecomment). + +The API, its exports, and the CLI's JSON name the resolved state `archived`. The dashboard, the page, and the CLI's human output say resolved. + +## Track what you have read + +A thread is unread for you when it is open and someone else posted in it after your last read. A resolved thread is never unread. Each thread carries `unread` and `unreadCount`. + +| Where | How to mark a thread read | +| --- | --- | +| **Comments** page | Opening the thread | +| Page | **Mark all as read** in the **Sidebar** menu | +| CLI | `sf comments mark-read`. `sf comments get` and `sf comments list` never mark a thread read | +| API | [`markSpaceCommentRead`](/api/reference/spaces/markspacecommentread). Reading a thread with [`getSpaceComment`](/api/reference/spaces/getspacecomment) does not mark it | + +The **Comments** item in the Space's sidebar shows a badge with the unread count. + +## Use the Comments page + +Open the Space and go to **Comments**. The list is on the left and the open thread is on the right. + +| Control | What it does | +| --- | --- | +| Status | **Open**, **Resolved**, **All**, or **Spam**. Only team owners and admins see **Spam** | +| Version | **All versions**, or one version. The live version shows `· live` | +| Page | **All pages**, or one page path | +| Author | **Anyone**, or one author | +| **Search comments** | Text in thread and reply bodies | +| **Unread** | Only threads you have not read | +| **Download comments** | A Markdown file of every thread in the Space | + +Filters and the open thread live in the URL, so you can share a filtered view. `?thread=cmt_...` opens one thread. Each thread from the API and the CLI carries this link as `dashboardUrl`. + +A thread on a file opens in **Files**, at that file. Other threads open their page in a new tab. + +## Handle spam + +**Spam filtering** is on by default. It holds suspected spam out of the open list. + +Team owners and admins can review held threads in the **Spam** folder. Open a thread and select **Not spam** to release it. Select **Mark as spam** on any thread to hold it. + +Over HTTP, team owners and admins send `status: "spam"` to [`updateSpaceComment`](/api/reference/spaces/updatespacecomment) to hold a thread, and `status: "open"` to release one. Add `spamCommentId` to judge one held reply. List held threads with `filter=spam` on [`listSpaceComments`](/api/reference/spaces/listspacecomments). Other callers get 403. + +## Get notified + +Spacefast sends comment notifications to the dashboard **Notifications** panel and by email. + +| Event | Who hears about it | Setting that controls it | +| --- | --- | --- | +| New thread | The Space's creator. If the creator is not on the team, the team owners | `newThreads` | +| New reply | Everyone who follows the thread. Posting in a thread follows it | `newReplies` | +| Resolve or reopen | Everyone who follows the thread | `statusChanges` | +| Mention | The person mentioned | None. Mentions always notify | + +- Nobody hears about their own action. +- A recipient must still be able to read comments on that page. +- Only a mention from a member of the Space's team notifies. +- A visitor without an account gets reply emails only after they confirm an address. Each email has a **Stop reply emails** link. + +All three settings are on by default. In the settings control on the **Comments** page, under **Advanced**, **Notify on new comments** sets `newThreads` and `newReplies` together. **Notify on status changes** sets `statusChanges`. Over HTTP, send `notificationPreferences` to [`updateSpaceCollaboration`](/api/reference/spaces/updatespacecollaboration). Each key you leave out keeps its value. + +## Export comments + +| Where | Format | What it holds | +| --- | --- | --- | +| **Download comments** on the **Comments** page | Markdown, `spacefast-comments-.md` | Every thread in the Space | +| **Download comments** on a version page | JSON, `spacefast-comments-.json` | The version's threads | +| `sf comments export` | Markdown by default, or JSON | All threads, or one version's with `--version` | +| [`exportSpaceComments`](/api/reference/spaces/exportspacecomments) | `format=markdown` (default) or `format=json` | All threads | +| [`exportSpaceVersionComments`](/api/reference/versions/exportspaceversioncomments) | `format=markdown` (default) or `format=json` | One version's threads | + +Both endpoints and the CLI take `status` to export only open or only resolved threads. The JSON export is `{ "data": [...] }` with the same thread objects the list returns. + +The Markdown export starts with `# Spacefast comments`. Each thread is a `## ` section. The API writes `open` or `archived` there, and `sf comments export` writes `open` or `resolved`. A section lists the page, URL, permalink, author, `viewport`, `selectors`, and attachments, then the body and one `- Reply by : ` line per reply. + +## Let another site embed Comments + +A site that you list can run this Space's Comments for its own visitors. There is no dashboard control for the list. Use the CLI or [`updateSpaceCollaboration`](/api/reference/spaces/updatespacecollaboration): + +```bash +sf comments settings set --embed-allow https://docs.example.com +``` + +- Each entry is one exact `https` origin. Wildcards and paths are refused. +- A Space takes up to 20 origins. +- Each site gets its own threads. They are separate from the threads on your Space's own pages. +- An embedded site gets no screenshots and no attachments. +- Comments must be on, and the Space must have a ready live version. + +The site calls [`getSpaceCommentsEmbedConfig`](/api/reference/spaces/getspacecommentsembedconfig) and [`createSpaceCommentsEmbedTicket`](/api/reference/spaces/createspacecommentsembedticket) from the browser, with no credential. Both answer only to a listed origin. Every other caller gets the same 404. + +Allowing a site lets its visitors comment in this Space without an account. `sf comments settings set` asks for confirmation before it adds or removes a site. + +## Limits + +| Limit | Value | +| --- | --- | +| Comment or reply body | 1 to 16,000 characters | +| Page path | 2,048 characters | +| Mentions per comment or reply | 50 | +| Attachments per thread or reply | 10 | +| Attachment size | 5 MB | +| Screenshots | 1 per thread, none on replies | +| List page size | 1 to 100, default 20 | +| Search text, author filter | 160 characters | +| Embed origins | 20 | +| New threads and replies from visitors without an account | 10 per minute per network | +| Reactions | 60 per minute per person. Without an account, per network | +| Embed configuration reads | 120 per minute per IP address, 600 per minute per Space | +| Embed tickets | 30 per minute per IP address, 120 per minute per site, 600 per minute per Space | + +A request over an embed rate limit fails with `rate_limited`. diff --git a/content/(serve)/meta.ts b/content/(serve)/meta.ts index c3778f38..cb1c58b8 100644 --- a/content/(serve)/meta.ts +++ b/content/(serve)/meta.ts @@ -8,9 +8,11 @@ export default defineMeta({ "urls", "domains", "routing", + "traffic-rules", "caching", "stats", "access", + "comments", "customization", "site-pages", ], diff --git a/content/(serve)/routing.mdx b/content/(serve)/routing.mdx index 3e2344cc..501522a6 100644 --- a/content/(serve)/routing.mdx +++ b/content/(serve)/routing.mdx @@ -6,6 +6,8 @@ sidebar: { order: 3 } You can redirect, rewrite, proxy, and set response headers on a published Space. Redirect rules run in order, while matching header blocks accumulate. Rewrites and 404 rules can yield to real files. +To block, challenge, or skip the cache for requests, see [Traffic rules](/traffic-rules). + Nothing here is evaluated per request. Spacefast compiles your rules at publish time into a table the edge reads, so a rule that behaves unexpectedly is almost always a compile-time problem. `sf routing inspect` shows you the compiled result before you publish. ## Three sources, one order diff --git a/content/(serve)/traffic-rules.mdx b/content/(serve)/traffic-rules.mdx new file mode 100644 index 00000000..d4c845f5 --- /dev/null +++ b/content/(serve)/traffic-rules.mdx @@ -0,0 +1,243 @@ +--- +title: Traffic rules +description: Block or challenge requests, skip the cache for some paths, test a URL against your rules, and pause every rule at once +--- + +A traffic rule matches requests on facts such as the path, the country, the client address, or a header. A firewall rule blocks or challenges a matching request before it reaches your Space. A cache-bypass rule serves a matching request fresh instead of from the edge cache. + +The **Traffic Rules** page in the dashboard also lists your redirects, rewrites, and headers, with where each one runs. Their grammar is on [Redirects, rewrites, and headers](/routing). This page covers firewall and cache-bypass rules. + +## Pick where a rule lives + +You declare a rule in one of two places. Spacefast puts it at the edge for you. + +| Where | `origin` in the rule list | A change reaches the edge after | +| --- | --- | --- | +| The `firewall` and `cache.bypass` keys of your config file | `config` | A publish | +| The Space settings: the dashboard, `sf rules add`, or `updateSpace` | `overlay` | A settings save. No publish | + +The config file is `sf.jsonc`, one of its aliases, or `spacefast.config.ts`. See the [project config reference](/config-file). + +Settings rules answer before config file rules. A settings rule with the same name and kind as a config file rule replaces it at the edge. + +Keep rules that ship with your code in the config file. Put an urgent fix, such as a block on an abusive network, in the settings, because it applies without a publish. + +A Space gets its edge at its first publish. Publish once before you add rules. Until then, validation, pause, and resume fail with [`traffic_rules_unavailable`](/errors/traffic_rules_unavailable). + +## Write a rule + +```jsonc sf.jsonc +{ + "firewall": [ + { "name": "protect-admin", "action": "challenge", "match": { "path": "/admin/*" } }, + { "name": "block-logins", "action": "block", "status": 403, "match": { "path": "/wp-login.php*" } } + ], + "cache": { + "bypass": [ + { "name": "fresh-api", "match": { "path": "/api/*" } }, + { "name": "previews", "match": { "query": { "preview": { "op": "exists" } } } } + ] + } +} +``` + +The same keys work in `spacefast.config.ts`: + +```ts spacefast.config.ts +import { defineConfig } from "spacefast/config"; + +export default defineConfig({ + firewall: [{ name: "protect-admin", action: "challenge", match: { path: "/admin/*" } }], +}); +``` + +| Field | Type | Default | What it does | +| --- | --- | --- | --- | +| `name` | string | none | Lowercase letters, digits, `-`, and `_`, up to 61 characters. Not all digits and not a reserved word such as `add` or `validate`. The CLI and the API address the rule by it. Unique within its list | +| `description` | string, up to 255 characters | none | What the rule is for | +| `action` | `block` or `challenge` | required | Firewall rules only. `block` answers without reaching the Space. `challenge` asks for proof of a human | +| `status` | integer, 400 to 499 | `403` | The status of a blocked request. Only with `block` | +| `match` | object | required | The request facts to compare. At least one field | +| `any` | boolean | `false` | When `true`, one matching field is enough. Otherwise every field has to match | + +Each list answers with its first matching rule, so put the specific rule above the general one. + +### Match fields + +| Field | Compares with | +| --- | --- | +| `path`, `host` | `eq`, `neq`, `in`, `contains`, `startsWith`, `endsWith`, `wildcard` | +| `method`, `country`, `asn` | `eq`, `neq`, `in` | +| `ip` | `eq`, `neq`, `in`, `inCidr` | +| `userAgent`, `referer` | `eq`, `neq`, `in`, `contains`, `startsWith`, `endsWith`, `wildcard`, `exists` | +| `header`, `cookie`, `query` | The same as `userAgent`, per name | +| `extension` | `eq`, `neq`, `in`, `exists` | +| `ja3`, `ja4` | `eq`, `in`, `exists` | + +`country` takes two-letter codes. `extension` takes the extension without the dot. `header`, `cookie`, and `query` take an object of names, for example `{ "x-api-client": { "op": "exists" } }`. + +A value can be a shorthand or an explicit clause. + +| Value | Reads as | +| --- | --- | +| `"/admin/*"` | `wildcard`. A `*` matches any run of characters | +| `"GET"` | `eq`. Text fields ignore case | +| `["US", "CA"]` | `in`. Any of the values | +| `"203.0.113.0/24"` on `ip` | `inCidr`. A range | +| `{ "op": "startsWith", "value": "/admin/" }` | The named comparison | +| `{ "op": "in", "values": ["RU"], "not": true }` | The opposite of the comparison | + +`in` and `inCidr` take `values`. `exists` takes no value. Every other comparison takes `value`. A comparison that a field does not offer fails validation, and so does a `*` in a field without `wildcard`. + +[`getTrafficRuleCatalog`](/api/reference/spaces/gettrafficrulecatalog) returns this catalog of fields, comparisons, and limits as JSON. + +Firewall rules never match the platform's own transport paths, such as `/__spacefast/health.php` and paths under `/__stattic/blob/`. + +## Add a rule from the CLI + +To add a rule to the Space settings, run `sf rules add`: + +```bash +sf rules add --kind firewall --action challenge --path '/wp-login.php*' +sf rules add --kind firewall --action block --country US,CA --not country +sf rules add --kind cache-bypass --path '/api/*' --name fresh-api +``` + +The new rule goes first in its list. The command waits for the edge to answer for the rule, then prints the result, for example `Live at the edge.` Every flag is on [`sf rules`](/cli/rules). + +## Add a rule in the dashboard + +1. Open the Space and go to **Traffic Rules**. +2. Click **New rule**. +3. Pick **Firewall** or **Cache**. +4. Enter a **Path pattern**, or click **Match on something else** to compare other request facts. +5. For a firewall rule, pick the **Action** and, for **Block**, an optional **Status code**. +6. Click **Create rule**. + +The dashboard validates the whole list before it saves. A problem shows on the control that can fix it. + +A Space with no rules shows three starters. **Protect a path** opens a firewall rule that challenges everything under `/admin/`. + +To change the order of your dashboard rules, use **Move up** and **Move down** on the rule. + +## Test a URL against your rules + +To see which rule answers a request, run `sf rules test`: + +```bash +sf rules test /wp-login.php --country RU +sf rules test /api/orders --file sf.jsonc +``` + +The CLI evaluates the rules locally, the way the edge evaluates them. It sends no request to your Space and changes nothing. It prints the first matching rule of each list. With `--file`, it reads rules from a config file you did not publish yet. + +In the dashboard, open **Test a request against your rules** under the list. Enter a path and click **Test path**. **Add request details** sets a **Country**, an **IP address**, a **User agent**, a **Header**, or a **Cookie**. The dashboard tester has no method, ASN, referrer, JA3, or JA4 facts, so rules on those can match differently there than at the edge. + +Both testers check firewall and cache-bypass rules only. Redirects and headers have their own checker, `sf routing inspect`. See [Redirects, rewrites, and headers](/routing#checking-your-rules). + +## Validate rules before you publish + +To check the rules in your config file against the rule grammar and the edge, run: + +```bash +sf rules validate +``` + +It reports every problem with the field it is in, for example `firewall[0].match.country`, and exits `2`. It writes nothing. The same check is [`validateSpaceTrafficRules`](/api/reference/spaces/validatespacetrafficrules), which takes up to 50 rules per list. + +## Read what the edge holds + +```bash +sf rules ls +``` + +The list is in the order the edge reads the rules. Each rule shows its kind, its action, where it was declared, where it runs, and what the edge last said about it. [`listSpaceTrafficRules`](/api/reference/spaces/listspacetrafficrules) returns the same data. + +| Dashboard | `edgeState` or `desiredState` | Meaning | +| --- | --- | --- | +| **Live** | `live` | The edge answers with the rule | +| **Applying** | `pending` | The edge accepted the rule and is still applying it | +| **Over quota** | `over_quota` | The Space has more rules than the edge allows, so the edge left this one off | +| **Invalid**, **Failed** | `invalid`, `failed` | The edge refused the rule. `lastError` says why | +| **Paused** | `suspended` | Rules are paused | +| **Serving** | `unknown`, at the Space | The edge does not hold this rule. The Space answers it | + +The dashboard row also shows **Edge** or **Origin**. **Origin** means the Space answers the rule, and the row says why. + +The **Edge allowance** card shows **Rules at the edge**: how many rules the Space has at the edge, and how many the edge allows. The edge sets that allowance, and it can be lower than the 50 rules per list a Space may declare. When the edge does not answer, the card shows Spacefast's own count and **limit unknown**. + +A declared rule that duplicates an earlier rule, or reuses its name, never reaches the edge. The list reports it under the rules, and the API reports it in `collapsed`. + +## Remove a rule + +To remove a rule from the Space settings, run: + +```bash +sf rules rm block-logins --kind firewall +``` + +In the dashboard, click **Remove** on the rule, then **Remove rule**. + +A rule from your config file is not removable here. Delete it from the file and publish. To change it without a publish, click **Override in dashboard**. The dashboard copies the rule into the settings, where it answers first. + +## Pause and resume all rules + +To stop every rule at once, run `sf rules pause`, or click **Pause all edge rules** in the dashboard. The Space then serves as if it declared no rules. Requests that rules blocked or challenged reach the Space. Requests that skipped the cache can serve from it again. + +The rules stay saved. A rule you add while paused is parked when it arrives. + +To put the rules back, run `sf rules resume`, or click **Resume** in the **Traffic rules are paused** notice. Resume is safe on a Space that is not paused. + +Pause and resume apply at once, with no publish. Both need a Space with an edge. The API operations are [`pauseSpaceTrafficRules`](/api/reference/spaces/pausespacetrafficrules) and [`resumeSpaceTrafficRules`](/api/reference/spaces/resumespacetrafficrules). + +## Run redirects and headers at the edge + +By default your Space answers its own redirects and headers. To move the ones the edge can answer to the edge, set `config.placement.edge` to `true` with [`updateSpace`](/api/reference/spaces/updatespace). It takes effect at the next publish. + +Some rules stay at the Space anyway. Examples are a rule that captures part of the path, a proxy, a `404` rule, and a redirect with a status other than 301, 302, 307, or 308. The rule list names the reason for each one. + +While rules are paused, a publish that places redirects or headers at the edge fails with [`traffic_rules_placement_incomplete`](/errors/traffic_rules_placement_incomplete). Resume, then publish again. + +## Know what needs a publish + +| Change | Takes effect after | +| --- | --- | +| A firewall or cache-bypass rule in the config file | A publish | +| A firewall or cache-bypass rule in the Space settings | The settings save | +| A redirect, rewrite, or header in the Space settings | The settings apply on a Space with a live version. Otherwise the first publish | +| A rule in `_redirects`, `_headers`, or the config file `redirects`, `rewrites`, and `headers` | A publish | +| Pause or resume | Immediately | +| `placement.edge` | The next publish | + +## Limits + +| Limit | Value | +| --- | --- | +| Rules in one `firewall` or `cache.bypass` list | 50 | +| Comparisons in one firewall rule | 47 | +| Comparisons in one cache-bypass rule | 50 | +| Values in one `in` or `inCidr` | 1,000 | +| Characters in one value | 4,096 | +| Characters in a header, cookie, or query name | 128 | +| Characters in a `description` | 255 | +| Rules at the edge | Set by the edge. See **Edge allowance** | + +The Space settings and the config file each hold their own lists. A keyed field counts one comparison per name. A firewall rule with `any: true` takes one edge rule per comparison. + +## API operations + +| Operation | What it does | +| --- | --- | +| [`listSpaceTrafficRules`](/api/reference/spaces/listspacetrafficrules) | The rules, the edge allowance, the pause state, and the hostnames the rules answer on | +| [`validateSpaceTrafficRules`](/api/reference/spaces/validatespacetrafficrules) | Checks firewall and cache-bypass rules. Writes nothing | +| [`pauseSpaceTrafficRules`](/api/reference/spaces/pausespacetrafficrules) | Parks every rule | +| [`resumeSpaceTrafficRules`](/api/reference/spaces/resumespacetrafficrules) | Puts parked rules back | +| [`getTrafficRuleCatalog`](/api/reference/spaces/gettrafficrulecatalog) | The match fields, comparisons, and limits | +| [`updateSpace`](/api/reference/spaces/updatespace) | Writes settings rules in `config.firewall` and `config.cache.bypass` | + +```bash curl +curl -sS "https://api.spacefast.com/v1/spaces/$SPACE_ID/rules" \ + -H "Authorization: Bearer $SPACEFAST_API_KEY" +``` + +To write settings rules, read the Space with [`getSpace`](/api/reference/spaces/getspace). Send its whole `config` back with your lists changed, because `config` replaces the stored settings. Send its `settingsDigest` as `baseSettingsDigest`. The write then fails with `publish_base_changed` instead of overwriting a change saved in between. diff --git a/content/api/connectors.mdx b/content/api/connectors.mdx new file mode 100644 index 00000000..4bb8077a --- /dev/null +++ b/content/api/connectors.mdx @@ -0,0 +1,345 @@ +--- +title: Connectors +description: Register tool sources, connect accounts, set policy rules, and run connector tools over the API, with the scopes, shapes, and error codes each step uses +sidebar: + order: 9 +--- + +The connectors API lets a team register tool sources, connect accounts, set policy rules, and run tools. Every operation sits under `/v1/connectors` with the `Connectors` tag. For the concepts and the dashboard, see [Connectors](/connectors). + +## Scopes and team selection + +| Scope | Grants | +| --- | --- | +| `connectors:read` | Read the catalog, connectors, connections, tools, policy rules, and runs | +| `connectors:write` | Register and remove connectors, connect and disconnect accounts, set policy rules. Includes `connectors:read` | +| `connectors:execute` | Create and resume runs. Includes `connectors:read` | + +`connectors:write` does not include `connectors:execute`, and `connectors:execute` does not include `connectors:write`. A dashboard session has these actions for team owners and admins only. See [Authentication](/api/authentication). + +Connector routes are team-scoped but carry no team in the path. Pass `?team=` when your credential can act for more than one team. Without it, the request fails with `team_required`. A team with connectors turned off answers `feature_unavailable`. + +## Register a connector + + + +```bash curl +curl -sS -X POST "https://api.spacefast.com/v1/connectors?team=acme" \ + -H "Authorization: Bearer $SPACEFAST_API_KEY" \ + -H "Content-Type: application/json" \ + -d '{ + "slug": "widgets", + "title": "Widgets", + "source": { "kind": "openapi", "specUrl": "https://api.example.com/openapi.json" } + }' +``` + +```ts SDK +const connector = await client.post("/v1/connectors", { + query: { team: "acme" }, + body: { + slug: "widgets", + title: "Widgets", + source: { kind: "openapi", specUrl: "https://api.example.com/openapi.json" }, + }, +}); +``` + + + +| Field | Type | What it does | +| --- | --- | --- | +| `slug` | string | Team-unique slug and tool namespace. 1 to 64 characters matching `^[a-z0-9][a-z0-9-]*$`. Required | +| `title` | string | Display name, 1 to 255 characters. Required | +| `description` | string | Agent-visible note, up to 1,000 characters | +| `source` | object | `{ kind: "openapi", specUrl \| spec, baseUrl? }`, `{ kind: "graphql", endpoint }`, or `{ kind: "mcp", endpoint }`. Required unless `presetId` carries one | +| `presetId` | string | A catalog row `id`. It supplies the source and the authentication recipe | +| `authMethods` | array | Override the authentication the source declares. Items are `api_key`, `oauth2`, or `none` | +| `specOverrides` | array | Up to 200 RFC 6902 JSON Patch operations applied to the fetched spec | + +The call fetches the source, builds the tool list, and answers `201` with the connector. An MCP endpoint must be remote. Spacefast asks it how it authenticates, so a server that wants OAuth gets an `oauth2` method. + +```json +{ + "data": { + "slug": "widgets", + "title": "Widgets", + "description": null, + "source": "openapi", + "sourceUrl": "https://api.example.com/openapi.json", + "firstParty": false, + "presetId": null, + "authMethods": [{ "kind": "api_key", "slug": "api_key", "label": "API key" }], + "toolCount": 12, + "connections": [], + "specSyncedAt": "2026-10-24T09:14:02.111Z", + "createdAt": "2026-10-24T09:14:02.111Z", + "updatedAt": "2026-10-24T09:14:02.111Z" + } +} +``` + +To find what is behind a URL first, call `detectConnectorSource` with `{ "url": "..." }`. It answers an array of candidates, most confident first. Each carries `kind`, `confidence` (`high`, `medium`, or `low`), `endpoint`, `title`, `slug`, and `presetId`. Register the `endpoint`, which can differ from the URL you sent. + +The catalog lists curated presets, the public registry, and the team's own connectors. Filter it with `query`, `surface` (`openapi`, `graphql`, `mcp`), and `kind` (`preset`, `registry`, `team`). Each row carries `alreadyAdded` and `connectorSlug`. Pass a row's `id` as `presetId` to register it. A row with `endpointInputRequired: true` also needs a `source` with the provider-specific endpoint. + +| Operation | Path | Scope | +| --- | --- | --- | +| [`getConnectorCatalog`](/api/reference/connectors/getconnectorcatalog) | `GET /v1/connectors/catalog` | `connectors:read` | +| [`detectConnectorSource`](/api/reference/connectors/detectconnectorsource) | `POST /v1/connectors/detect` | `connectors:write` | +| [`registerConnector`](/api/reference/connectors/registerconnector) | `POST /v1/connectors` | `connectors:write` | +| [`listConnectors`](/api/reference/connectors/listconnectors) | `GET /v1/connectors` | `connectors:read` | +| [`getConnector`](/api/reference/connectors/getconnector) | `GET /v1/connectors/{connectorRef}` | `connectors:read` | +| [`updateConnector`](/api/reference/connectors/updateconnector) | `PATCH /v1/connectors/{connectorRef}` | `connectors:write` | +| [`deleteConnector`](/api/reference/connectors/deleteconnector) | `DELETE /v1/connectors/{connectorRef}` | `connectors:write` | +| [`getConnectorCatalogLogo`](/api/reference/connectors/getconnectorcataloglogo) | `GET /v1/connectors/catalog/logo/{domain}` | none | + +`updateConnector` accepts `title`, `description`, `refreshSpec`, and `specOverrides`. `refreshSpec: true` fetches the source again and rebuilds the tools. Connections and policy rules survive it. `deleteConnector` disconnects every account on the connector, then removes it. A connector with `firstParty: true` cannot be edited or removed. + +Registration, detection, and source refreshes share one budget of 60 per team per hour. Past it, requests fail with `429` and `rate_limited`. See [Rate limits](/api/rate-limits). + +## Connect an account + +```bash +curl -sS -X POST "https://api.spacefast.com/v1/connectors/connections?team=acme" \ + -H "Authorization: Bearer $SPACEFAST_API_KEY" \ + -H "Content-Type: application/json" \ + -d '{ + "connector": "widgets", + "owner": "team", + "credential": { "kind": "api_key", "value": "sk_example_key" } + }' +``` + +| Field | Type | What it does | +| --- | --- | --- | +| `connector` | string | Connector slug. Required | +| `owner` | `team` or `personal` | `team` shares it with the team. `personal` keeps it yours, within this team. Required | +| `displayName` | string | Label, 1 to 255 characters. Defaults to the connector's title | +| `description` | string | Agent-visible note, up to 1,000 characters | +| `authMethod` | string | Auth method slug. Required only when more than one method applies | +| `credential` | object | `{ kind: "api_key", value }` or `{ kind: "fields", values }`. Omit it and the server says what it needs | +| `returnPath` | string | Same-origin dashboard path to land on after authorization. Up to 512 characters, starting with `/` | + +Credentials are write-only. No endpoint ever returns one. Only a person can create a `personal` connection. An API key gets `connection_forbidden`. + +The `200` answer has one of three `status` values. Branch on it. + +```json +{ "data": { "status": "connected", "connection": { "id": "cxn_3f9c2d1a4b5e6f70", "status": "active" } } } +``` + +```json +{ + "data": { + "status": "authorization_required", + "connection": { "id": "cxn_3f9c2d1a4b5e6f70", "status": "pending_authorization" }, + "authorizeUrl": "https://provider.example.com/oauth/authorize?state=example", + "expiresAt": "2026-10-24T09:24:02.111Z" + } +} +``` + +```json +{ + "data": { + "status": "input_required", + "connector": "widgets", + "authMethod": "api_key", + "fields": [{ "name": "value", "label": "API key", "secret": true, "required": true, "guidance": null }] + } +} +``` + +The connection objects above are shortened. For `authorization_required`, send the person to `authorizeUrl`. The provider callback completes the connection, and the dashboard calls `completeConnection` with the `state` and `code`. For `input_required`, nothing was created. Collect `fields`, then call again with `credential.values` and the same `authMethod`. `getConnectorCredentialGuidance` returns the provider's help for a domain: `label`, `mintUrl`, `docsUrl`, `notes`, and `steps`. + +A connection carries `id`, `connector`, `owner`, `displayName`, `description`, `status`, `identityLabel`, `credentialConfigured`, `authMethod`, `scopes`, `isDefault`, `toolCount`, `createdBy`, `lastCheckedAt`, `revokedAt`, `createdAt`, and `updatedAt`. `status` is `pending_authorization`, `active`, `degraded`, `needs_reauth`, or `revoked`. `isDefault` marks the connection an agent gets when it names the connector alone. + +| Operation | Path | Scope | +| --- | --- | --- | +| [`createConnection`](/api/reference/connectors/createconnection) | `POST /v1/connectors/connections` | `connectors:write` | +| [`completeConnection`](/api/reference/connectors/completeconnection) | `POST /v1/connectors/connections/complete` | `connectors:write` | +| [`getConnectorCredentialGuidance`](/api/reference/connectors/getconnectorcredentialguidance) | `GET /v1/connectors/catalog/{domain}/guidance` | `connectors:read` | +| [`listConnections`](/api/reference/connectors/listconnections) | `GET /v1/connectors/connections` | `connectors:read` | +| [`getConnection`](/api/reference/connectors/getconnection) | `GET /v1/connectors/connections/{connectionId}` | `connectors:read` | +| [`updateConnection`](/api/reference/connectors/updateconnection) | `PATCH /v1/connectors/connections/{connectionId}` | `connectors:write` | +| [`checkConnectionHealth`](/api/reference/connectors/checkconnectionhealth) | `POST /v1/connectors/connections/{connectionId}/health` | `connectors:read` | +| [`refreshConnection`](/api/reference/connectors/refreshconnection) | `POST /v1/connectors/connections/{connectionId}/refresh` | `connectors:write` | +| [`reconnectConnection`](/api/reference/connectors/reconnectconnection) | `POST /v1/connectors/connections/{connectionId}/reconnect` | `connectors:write` | +| [`disconnectConnection`](/api/reference/connectors/disconnectconnection) | `POST /v1/connectors/connections/{connectionId}/disconnect` | `connectors:write` | + +`listConnections` filters by `owner` (`team`, `personal`, or `any`, the default), `connector`, `status`, and `includeRevoked`. `updateConnection` changes `displayName`, `description`, and `serving`. It cannot change a credential. `reconnectConnection` takes an optional `credential`, answers with the same three statuses as `createConnection`, and keeps the connection's id, label, policy rules, and Space bindings. `checkConnectionHealth` returns `connectionId`, `status`, `reachable`, `latencyMs`, `detail`, `toolCount`, and `checkedAt`. `refreshConnection` reads the provider's tools again. `disconnectConnection` purges the credential and marks the connection `revoked`. Calling it again changes nothing. + +A connection the credential may not use answers `404`, the same as one that does not exist. + +## Read tools and policy + +A tool address is `/`. While exactly one usable connection matches, `/` also works. A second connection turns the short form into `connection_ambiguous`. + +Each tool carries `address`, `name`, `summary`, `safety` (`read_only`, `write`, or `destructive`), `effect`, `decidedBy`, and `inputSchema`. `effect` is the policy result: `approve`, `require_approval`, or `block`. `decidedBy` is `{ ruleId, pattern, owner }`, or `null` when the tool's own default applied. + +A policy rule has `id`, `pattern`, `action`, `owner`, `position`, and `createdAt`. + +| `pattern` | Matches | +| --- | --- | +| `*` | Every tool | +| `/*` | Every tool on every connection of one connector | +| `/` | One tool on every connection of one connector | +| `/*` | Every tool of one connection | +| `/` | One tool on one connection | + +The server sets `position` from the pattern's specificity, so the most specific rule wins. A `team` rule is a floor that a `personal` rule can tighten but never loosen. Only a person can create a `personal` rule. A rule's `pattern` and `owner` are fixed. `updateConnectorPolicy` changes `action` only. + +```bash +curl -sS "https://api.spacefast.com/v1/connectors/policies/resolve?team=acme&address=cxn_3f9c2d1a4b5e6f70/charges.create" \ + -H "Authorization: Bearer $SPACEFAST_API_KEY" +``` + +```json +{ + "data": { + "address": "cxn_3f9c2d1a4b5e6f70/charges.create", + "effect": "require_approval", + "decidedBy": { "ruleId": "pol_example", "pattern": "*", "owner": "team" }, + "explanation": "The team rule \"*\" resolves this address to require_approval." + } +} +``` + +| Operation | Path | Scope | +| --- | --- | --- | +| [`listConnectorTools`](/api/reference/connectors/listconnectortools) | `GET /v1/connectors/{connectorRef}/tools` | `connectors:read` | +| [`listConnectionTools`](/api/reference/connectors/listconnectiontools) | `GET /v1/connectors/connections/{connectionId}/tools` | `connectors:read` | +| [`listConnectorPolicies`](/api/reference/connectors/listconnectorpolicies) | `GET /v1/connectors/policies` | `connectors:read` | +| [`createConnectorPolicy`](/api/reference/connectors/createconnectorpolicy) | `POST /v1/connectors/policies` | `connectors:write` | +| [`updateConnectorPolicy`](/api/reference/connectors/updateconnectorpolicy) | `PATCH /v1/connectors/policies/{policyRuleId}` | `connectors:write` | +| [`deleteConnectorPolicy`](/api/reference/connectors/deleteconnectorpolicy) | `DELETE /v1/connectors/policies/{policyRuleId}` | `connectors:write` | +| [`resolveConnectorPolicy`](/api/reference/connectors/resolveconnectorpolicy) | `GET /v1/connectors/policies/resolve` | `connectors:read` | + +The tool lists filter by `query` and `effect`. + +## Run a tool + +```bash +curl -sS -X POST "https://api.spacefast.com/v1/connectors/runs?team=acme" \ + -H "Authorization: Bearer $SPACEFAST_API_KEY" \ + -H "Content-Type: application/json" \ + -d '{ + "input": { "kind": "tool", "address": "cxn_3f9c2d1a4b5e6f70/issues.list", "args": { "limit": 5 } }, + "idempotencyKey": "list-issues-1" + }' +``` + +| Field | Type | What it does | +| --- | --- | --- | +| `input` | object | `{ kind: "tool", address, args? }` for one call, or `{ kind: "code", code }` for a program. Required | +| `waitMs` | integer | How long to wait for a final status, 0 to 120000. Defaults to 30000 | +| `idempotencyKey` | string | Retry with the same key to get the original run back, 1 to 320 characters | +| `unattendedApprovals` | `park` or `deny` | When a call needs approval: park it, the default, or fail at once | + +`args` is validated against the tool's `inputSchema` and defaults to `{}`. A program can call any tool the credential may use, and it runs for at most 10 seconds. + +A new run answers `201`. A retry with the same `idempotencyKey` and the same input answers `200` with the original run. The same key with a different input fails with `409` and `idempotency_key_reused`. + +A run that fails still answers `200`. The request succeeded and the run did not, so read `status`. + +```json +{ + "data": { + "id": "cxr_8c1d4a7f2e6b0c3d", + "status": "awaiting_approval", + "origin": "api", + "space": null, + "handler": null, + "visitor": false, + "input": { "kind": "tool", "address": "cxn_3f9c2d1a4b5e6f70/issues.create", "digest": "sha256-example" }, + "usage": { "toolCalls": 0, "egressBytes": 0, "durationMs": 41 }, + "createdAt": "2026-10-24T09:14:02.111Z", + "approval": { + "decisionId": null, + "mode": "human", + "address": "cxn_3f9c2d1a4b5e6f70/issues.create", + "preview": { "title": "Broken link on pricing page" }, + "reason": "policy_require_approval", + "matchedPattern": "*", + "approvalUrl": "https://my.spacefast.com/approvals?run=cxr_8c1d4a7f2e6b0c3d", + "expiresAt": "2026-10-24T09:29:02.111Z" + } + } +} +``` + +| `status` | Extra fields | Meaning | +| --- | --- | --- | +| `running` | none | Still working. Read the run again | +| `awaiting_approval` | `approval` | Parked on a decision | +| `succeeded` | `completedAt`, `result` | Done | +| `failed` | `completedAt`, `error` | Done. `error` is a problem document with `code`, `status`, and `detail` | +| `expired` | `completedAt` | Parked for 15 minutes with no decision. Submit it again | + +`input` never echoes the arguments. `approval.preview` masks values whose keys look like secrets. `approval.reason` is `policy_require_approval` when a rule parked the call, or `annotation_requires_approval` when the tool asked for approval itself. + +| Operation | Path | Scope | +| --- | --- | --- | +| [`createConnectorRun`](/api/reference/connectors/createconnectorrun) | `POST /v1/connectors/runs` | `connectors:execute` | +| [`getConnectorRun`](/api/reference/connectors/getconnectorrun) | `GET /v1/connectors/runs/{runId}` | `connectors:read` | +| [`listConnectorRuns`](/api/reference/connectors/listconnectorruns) | `GET /v1/connectors/runs` | `connectors:read` | +| [`resumeConnectorRun`](/api/reference/connectors/resumeconnectorrun) | `POST /v1/connectors/runs/{runId}/resume` | `connectors:execute` | + +`listConnectorRuns` returns the team's runs newest first, and filters by `status`, `origin`, `space`, and `connection`. `status=awaiting_approval` lists the runs waiting on a decision. + +Runs share a budget of 1,200 per team per hour. Past it, requests fail with `429` and `rate_limited`. + +## Resume a parked run + +`resumeConnectorRun` takes `action` (`accept`, `decline`, or `cancel`) and `content`, the values a parked call asked for. A `decline` fails the run with `connector_tool_blocked`. + +A run parked in `human` mode refuses a decision from an API key or an agent with `human_approval_required`. A person decides at `approvalUrl`. Then call `resumeConnectorRun` with no `action` to read back the outcome and let the run continue. While nobody has decided, that call fails with `connector_approval_required`. + +## Errors + +Every failure is an RFC 9457 problem document. See [Errors](/api/errors). + +| Code | Status | When | +| --- | --- | --- | +| `team_required` | 422 | The credential can act for several teams and `team` is missing | +| `feature_unavailable` | 422 | Connectors are turned off for the team | +| `connector_slug_taken` | 409 | The team already has a connector with that slug | +| `connector_source_unreachable` | 422 | The source did not load when registering or refreshing | +| `connector_source_blocked` | 422 | The source URL is not reachable from Spacefast | +| `connector_source_invalid` | 422 | `source.kind` does not match the catalog row named by `presetId` | +| `connector_not_found` | 422 | `presetId` matches no catalog row, or a policy pattern names an unknown connector or connection | +| `connector_first_party_immutable` | 409 | The connector is built into Spacefast | +| `connection_forbidden` | 422 | An API key asked for a `personal` connection or rule | +| `connection_needs_reauth` | 409 | A health probe found no credential, or the authorization is not finished | +| `connection_revoked` | 409 | The connection is disconnected | +| `connection_serving_not_granted` | 422 | `serving` was set on a personal connection | +| `malformed_policy_pattern` | 422 | The pattern does not fit the grammar | +| `malformed_tool_address` | 422 | `resolveConnectorPolicy` got an address that does not resolve | +| `idempotency_key_reused` | 409 | The run key was used with a different input | +| `human_approval_required` | 403 | A non-person tried to decide a run parked for a person | +| `connector_approval_required` | 422 | No decision is recorded yet | +| `connector_run_not_resumable` | 409 | The run already settled, or the decision was already claimed | +| `rate_limited` | 429 | The team passed the hourly budget for registrations or runs | + +These codes appear in a failed run's `error`, inside a `200` response: + +| Code | Status in `error` | When | +| --- | --- | --- | +| `connector_tool_blocked` | 403 | A `block` rule matched, or a person denied the call | +| `connector_approval_required` | 403 | The call needed approval and `unattendedApprovals` was `deny` | +| `connection_ambiguous` | 409 | A `/` address matched more than one connection | +| `connection_not_found` | 404 | The address names no connection the credential can use | +| `connection_forbidden` | 403 | The credential may not use that connection | +| `connection_needs_reauth` | 401 | The provider refused the stored credential. Reconnect it | +| `connector_tool_not_found` | 404 | The connection has no tool by that name | +| `connector_tool_failed` | varies | The provider call failed | +| `connector_program_failed` | 422 | A code program threw | +| `connector_run_budget_exhausted` | 429 | The run passed its budget of 400 outbound requests or 64 MiB read | +| `run_replay_diverged` | 409 | A resumed program made different calls than before the pause | + +## Space bindings and agents + +A Space binds its connector roles to team connections through `listSpaceConnectorBindings`, `bindSpaceConnector`, and `unbindSpaceConnector` under the `Spaces` tag. See [Zero connectors](/zero-connectors). + +Hosted MCP exposes no connector operations. On-device MCP, `sf mcp`, reaches connectors through one gateway tool. See [MCP server](/agents/mcp-server). diff --git a/content/api/index.mdx b/content/api/index.mdx index 6c7ee0b3..8ff0f6a6 100644 --- a/content/api/index.mdx +++ b/content/api/index.mdx @@ -163,6 +163,4 @@ Stop when `data.status` is `succeeded`, `failed`, or `canceled`. Then read `spac ## Connect external tools -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. +Connectors make OpenAPI, GraphQL, and remote MCP tools available to a team, with credentials that stay in Spacefast. The [connectors API guide](/api/connectors) covers the operations, scopes, and errors, and [Connectors](/connectors) covers the dashboard and the CLI. diff --git a/content/api/meta.ts b/content/api/meta.ts index df1f25eb..2110fa75 100644 --- a/content/api/meta.ts +++ b/content/api/meta.ts @@ -11,6 +11,7 @@ export default defineMeta({ "idempotency", "operations", "webhooks", + "connectors", "sdk", "reference", ], diff --git a/content/cli/agent-commands.mdx b/content/cli/agent-commands.mdx index 3c9990b2..84e3f300 100644 --- a/content/cli/agent-commands.mdx +++ b/content/cli/agent-commands.mdx @@ -278,11 +278,12 @@ Every failure receipt carries `error.code` and `error.requestId`. Passing both t ### Output ```text -Feedback ref: fb_0123456789abcdef -Delivered — thanks! +Feedback ref: fdb_0123456789abcdef +Thanks! We read every one. +Signed in with `sf login`? Read it back with `sf feedback get fdb_0123456789abcdef`. ``` -`data` carries `ref` and `delivered`. +`data` carries `ref` and `status`. `status` is `stored`. ## sf plugins diff --git a/content/cli/comments.mdx b/content/cli/comments.mdx new file mode 100644 index 00000000..35c05918 --- /dev/null +++ b/content/cli/comments.mdx @@ -0,0 +1,510 @@ +--- +title: sf comments +description: List, read, start, reply to, react to, resolve, and export comment threads, and set where Comments run +sidebar: + order: 13 +--- + +`sf comments` works with a Space's comment threads and its Comments settings. For what Comments do and who can comment, see [Comments](/comments). + +Every command here takes the global flags (`--api-url`, `--profile`, `--token`, `-y`/`--yes`, `--json`) and the space selection flags (`--space`, `-o`/`--team`, `--claim-token`). See [the CLI overview](/cli). + +`sf comments` with no subcommand prints the help for the group. + +Comments need a claimed Space that belongs to a team, with **Page enhancements** on. `create`, `reply`, `react`, `unreact`, `mark-read`, `resolve`, and `reopen` need a credential that acts as a person. + +## Common arguments and values + +| Value | What it is | +| --- | --- | +| `` | A thread ID, for example `cmt_123` | +| `--version=` | A version ID (`ver_...`), a ref such as `v3`, or a number. `3` matches `v3`. A ref or a number must be in the Space's 100 newest versions, or the command fails with `validation_error` (exit 2). An unknown `ver_` ID fails with `not_found` (exit 20) | +| `--status=` | `open` or `resolved` | + +The API names the resolved state `archived`. The CLI translates `resolved` to `archived` in requests, and `--json` output keeps `archived`. + +## sf comments list + +List comment threads across a Space. Aliased as `sf comments ls`. Listing never marks a thread read. + +```text +sf comments list [--status open|resolved] [--unread] [--path ] [--version ] + [--author ] [--search ] [--limit ] [--space ] +``` + +### Flags + +| Flag | Default | What it does | +| --- | --- | --- | +| `--status=` | both | Only `open` or only `resolved` threads | +| `--unread` | `false` | Only threads with comments you have not read | +| `--path=` | none | Only threads on this page or file path, for example `/pricing` | +| `--version=` | none | Only threads left on this version | +| `--author=` | none | Only threads started by this author name | +| `--search=` | none | Only threads whose text contains this | +| `--limit=` | every thread | Maximum threads to return. Minimum 1 | + +Without `--limit`, the command reads every page of results. + +```bash +sf comments list --unread +sf comments list --status open --path /pricing +sf comments list --version v3 +``` + +### Output + +A table of `ID | STATUS | ON | REPLIES | COMMENT`. Open threads come first. An unread open thread has `●` in front of its ID. `ON` is the anchor: `page `, `element on `, or `file :`. `COMMENT` is the first 60 characters of the body. + +With `--limit`, `More threads match. Raise --limit to see them.` follows the table when more threads match. + +With no threads: `No comments found.`, or `You're all caught up.` with `--unread`. + +With `--json`, the output is `{ data, pagination }`. `data` holds the threads in the [`listSpaceComments`](/api/reference/spaces/listspacecomments) shape. `pagination` is `{ nextCursor, hasMore }`. + +### Errors + +| Code | Exit | When | +| --- | --- | --- | +| `validation_error` | 2 | `--status` is not `open` or `resolved`, or `--limit` is below 1 | +| `validation_error` | 2 | `--version` matches no version in the Space | + +## sf comments get + +Show one thread with its replies and reactions. Reading a thread does not mark it read. + +```text +sf comments get [--space ] +``` + +### Arguments + +| Argument | What it is | +| --- | --- | +| `` | Thread ID, for example `cmt_123`. Required | + +No command-specific flags. + +```bash +sf comments get cmt_123 +``` + +### Output + +```text +cmt_123 open unread +On: element #hero-title on / +Author: Ana, 2026-10-12 17:03 UTC +Page: +Open: +Reactions: 👍 2 + + + +Milo replied 2026-10-12 17:20 UTC: + +``` + +Lines without a value are left out. `unread` shows only on an open thread. A thread or reply with files lists them under `Attachments:` as name, size, and kind. Attachment URLs appear only in `--json` output. + +With `--json`, `data` is the thread in the [`getSpaceComment`](/api/reference/spaces/getspacecomment) shape. + +### Errors + +| Code | Exit | When | +| --- | --- | --- | +| `not_found` | 20 | No thread has this ID, or Comments are off | + +## sf comments create + +Start a comment thread on a page, an element on a page, or a file. The thread goes on the live version unless `--version` picks another. + +```text +sf comments create --body [--page ] [--selector ] [--file ] + [--lines |-] [--mention ]... [--version ] + [--idempotency-key ] [--space ] +``` + +### Flags + +| Flag | Default | What it does | +| --- | --- | --- | +| `--body=` | required | What the comment says. 1 to 16,000 characters | +| `--page=` | `/` | Page path to comment on. A missing leading `/` is added | +| `--selector=` | none | CSS selector of the element to pin the comment to | +| `--file=` | none | File path to comment on, for example `src/app.js` | +| `--lines=` | none | A line such as `7` or a range such as `7-14` in `--file`. Lines start at 1 | +| `--mention=` | none | Teammate to notify, by email, name, or user ID. Repeatable | +| `--version=` | the live version | Version to comment on | +| `--idempotency-key=` | none | Stable key for retrying this comment safely | + +`--file` cannot be combined with `--page` or `--selector`. `--lines` needs `--file`. + +Without `--version`, the thread goes on the live version. If nothing is live, it goes on the newest ready version. + +`--mention` matches the email, the name, or the user ID of a person whom the `listSpaceCommentMentionable` operation lists. Case does not matter, and a leading `@` is ignored. + +```bash +sf comments create --page /pricing --body "The annual price is wrong." +sf comments create --page / --selector "#hero-title" --body "Too long for mobile." +sf comments create --file src/app.js --lines 7-14 --body "This loop never ends." +sf comments create --page /about.html --body "Can you check the photos?" --mention milo@example.com +``` + +### Output + +```text +Thread started on page /pricing. +ID: cmt_123 +Open: +``` + +With `--json`, `data` is the new thread in the [`createSpaceVersionComment`](/api/reference/versions/createspaceversioncomment) shape. + +### Errors + +| Code | Exit | When | +| --- | --- | --- | +| `validation_error` | 2 | `--lines` is not a line or a range, or the range ends before it starts | +| `validation_error` | 2 | `--file` is combined with `--page` or `--selector`, or `--lines` is passed without `--file` | +| `validation_error` | 2 | A `--mention` value matches nobody, or matches more than one person | +| `validation_error` | 2 | `--version` matches no version, or the Space has no versions | +| `comments_disabled` | 2 | Comments are off for the Space | + +## sf comments reply + +Reply to a comment thread. + +```text +sf comments reply --body [--mention ]... + [--idempotency-key ] [--space ] +``` + +### Arguments + +| Argument | What it is | +| --- | --- | +| `` | Thread ID, for example `cmt_123`. Required | + +### Flags + +| Flag | Default | What it does | +| --- | --- | --- | +| `--body=` | required | What the reply says. 1 to 16,000 characters | +| `--mention=` | none | Teammate to notify, by email, name, or user ID. Repeatable | +| `--idempotency-key=` | none | Stable key for retrying this reply safely | + +```bash +sf comments reply cmt_123 --body "Fixed in v4." +sf comments reply cmt_123 --body "Over to you." --mention milo@example.com +``` + +### Output + +`Reply posted on .` + +With `--json`, `data` is `{ status, comment }`. `status` is `inserted`, or `existing` when the idempotency key already created this reply. `comment` is the reply in the shape of a thread's `comments` entries. + +### Errors + +| Code | Exit | When | +| --- | --- | --- | +| `validation_error` | 2 | A `--mention` value matches nobody, or matches more than one person | +| `not_found` | 20 | No thread has this ID, or Comments are off | + +## sf comments react + +React to a comment thread with an emoji. + +```text +sf comments react [--space ] +``` + +### Arguments + +| Argument | What it is | +| --- | --- | +| `` | Thread ID, for example `cmt_123`. Required | +| `` | Required. A name or the emoji itself. See the table below | + +| Name | Emoji | +| --- | --- | +| `thumbsup` | 👍 | +| `heart` | ❤️ | +| `tada` | 🎉 | +| `eyes` | 👀 | +| `rocket` | 🚀 | +| `pray` | 🙏 | + +No command-specific flags. + +```bash +sf comments react cmt_123 thumbsup +sf comments react cmt_123 🎉 +``` + +### Output + +`Reacted on .`, then `Reactions: ...` with the thread's reaction counts. + +With `--json`, `data` is the thread in the [`updateSpaceCommentReaction`](/api/reference/spaces/updatespacecommentreaction) shape. + +### Errors + +| Code | Exit | When | +| --- | --- | --- | +| `validation_error` | 2 | `` is not one of the six reactions. Raised before the request | +| `not_found` | 20 | No thread has this ID, or Comments are off | + +## sf comments unreact + +Take back your emoji reaction on a comment thread. + +```text +sf comments unreact [--space ] +``` + +### Arguments + +| Argument | What it is | +| --- | --- | +| `` | Thread ID, for example `cmt_123`. Required | +| `` | Required. The reaction to take back, by name or emoji, as for `sf comments react` | + +No command-specific flags. + +```bash +sf comments unreact cmt_123 thumbsup +``` + +### Output + +`Took back .`, then the `Reactions:` line if the thread still has reactions. + +With `--json`, `data` is the thread. + +### Errors + +The same as `sf comments react`. + +## sf comments mark-read + +Mark a comment thread read for you. The thread then leaves `sf comments list --unread`. + +```text +sf comments mark-read [--space ] +``` + +### Arguments + +| Argument | What it is | +| --- | --- | +| `` | Thread ID, for example `cmt_123`. Required | + +No command-specific flags. + +```bash +sf comments mark-read cmt_123 +``` + +### Output + +`Marked read.` + +With `--json`, `data` is the thread with `unread: false` and `unreadCount: 0`. + +### Errors + +| Code | Exit | When | +| --- | --- | --- | +| `not_found` | 20 | No thread has this ID, or Comments are off | + +## sf comments resolve + +Mark a comment thread resolved. The thread leaves the open list and keeps every reply. + +```text +sf comments resolve [--space ] +``` + +### Arguments + +| Argument | What it is | +| --- | --- | +| `` | Thread ID, for example `cmt_123`. Required | + +No command-specific flags. + +```bash +sf comments resolve cmt_123 +``` + +### Output + +`Resolved .` + +With `--json`, `data` is the thread with `status: "archived"`. + +### Errors + +| Code | Exit | When | +| --- | --- | --- | +| `not_found` | 20 | No thread has this ID, or Comments are off | + +## sf comments reopen + +Reopen a resolved comment thread. + +```text +sf comments reopen [--space ] +``` + +### Arguments + +| Argument | What it is | +| --- | --- | +| `` | Thread ID, for example `cmt_123`. Required | + +No command-specific flags. + +```bash +sf comments reopen cmt_123 +``` + +### Output + +`Reopened .` + +With `--json`, `data` is the thread with `status: "open"`. + +### Errors + +| Code | Exit | When | +| --- | --- | --- | +| `not_found` | 20 | No thread has this ID, or Comments are off | + +## sf comments export + +Export comment threads as Markdown or JSON, for agents and scripts. The export holds every thread that matches. It does not page. + +```text +sf comments export [--format markdown|json] [--status open|resolved] [--version ] + [--space ] +``` + +### Flags + +| Flag | Default | What it does | +| --- | --- | --- | +| `--format=` | `markdown` | `markdown` or `json` | +| `--status=` | both | Only `open` or only `resolved` threads | +| `--version=` | every version | Only threads left on this version | + +```bash +sf comments export --status open --format markdown +sf comments export --version v3 --status open --format json +``` + +### Output + +The command writes the export to standard output. + +- Markdown starts with `# Spacefast comments`. Each thread is a `## ` section with `- Page:`, `- URL:`, `- Permalink:`, `- Author:`, `- Viewport:`, `- Selectors:`, and `- Attachment:` lines where they have values. The body follows, then one `- Reply by : ` line per reply. With no threads, the body is `No comments found.` +- JSON is `{ "data": [...] }` with the threads in the `sf comments list --json` shape. + +With `--json`, `data` is `{ format, content }`, and `content` is the export as one string. + +### Errors + +| Code | Exit | When | +| --- | --- | --- | +| `validation_error` | 2 | `--format` or `--status` has a value not in the list | +| `validation_error` | 2 | `--version` matches no version in the Space | + +## sf comments settings + +Show where Comments run for a Space and which other sites can embed them. + +```text +sf comments settings [--space ] +``` + +No command-specific flags. + +```bash +sf comments settings +``` + +### Output + +```text +Comments: previews — version previews only +Sites allowed to embed: none +Revision: 3 +``` + +The first line is one of these: + +| Setting | Meaning | +| --- | --- | +| `off` | Nobody can comment | +| `previews` | Version previews only | +| `everywhere` | Version previews and the live Space | + +With `--json`, `data` is the [`getSpaceCollaboration`](/api/reference/spaces/getspacecollaboration) response: `{ spaceId, revision, castResourceId, castResourceKey, settings, createdAt, updatedAt }`. `settings` holds `surface`, `embedOrigins`, and, when set, `spamFilterEnabled` and `notificationPreferences`. + +## sf comments settings set + +Change where Comments run and which other sites can embed them. The change applies without a republish. + +```text +sf comments settings set [--surface off|previews|everywhere] [--embed-allow ]... + [--embed-remove ]... [--space ] +``` + +### Flags + +| Flag | Default | What it does | +| --- | --- | --- | +| `--surface=` | unchanged | `off`, `previews`, or `everywhere` | +| `--embed-allow=` | none | Allow this site to embed the Space's Comments. Repeatable | +| `--embed-remove=` | none | Remove this site from the embed list. Repeatable | + +Pass at least one flag. An origin is one exact `https` origin, for example `https://docs.example.com`. Wildcards and paths are refused. A Space takes up to 20 origins. Allowing a site that is already on the list changes nothing. + +The command asks for confirmation before it turns Comments off, adds a site, or removes a site. An allowed site can create commenters without an account in this Space. In a non-interactive shell or in agent mode, pass `--yes`. + +```bash +sf comments settings set --surface everywhere +sf comments settings set --embed-allow https://docs.example.com +sf comments settings set --embed-remove https://docs.example.com --yes +``` + +### Output + +`Comments settings updated.`, then the three lines that `sf comments settings` prints. + +With `--json`, `data` is the updated settings in the `sf comments settings --json` shape. + +### Errors + +| Code | Exit | When | +| --- | --- | --- | +| `validation_error` | 2 | No flag passed. Raised before the request | +| `validation_error` | 2 | An origin is not one exact `https` origin. Raised before the request | +| `validation_error` | 2 | One origin is passed to both `--embed-allow` and `--embed-remove`. Raised before the request | +| `validation_error` | 2 | An `--embed-remove` origin is not on the list | +| `validation_error` | 2 | The list would hold more than 20 origins | +| `confirmation_required` | 2 | The change needs confirmation, and there is no terminal or the CLI is in agent mode. Pass `--yes` | +| `cancelled` | 130 | You declined the prompt | +| `resource_revision_changed` | 30 | Someone changed the settings while the command ran. Run it again | + +## Errors for every command + +| Code | Exit | When | +| --- | --- | --- | +| `team_required` | 2 | The Space does not belong to a team | +| `comments_disabled` | 2 | **Page enhancements** is off for the Space | +| `forbidden` | 10 | The command changes a thread, and the credential does not act as a person | + +The full command reference is at [/cli/reference](/cli/reference). diff --git a/content/cli/connectors.mdx b/content/cli/connectors.mdx new file mode 100644 index 00000000..94db65f6 --- /dev/null +++ b/content/cli/connectors.mdx @@ -0,0 +1,702 @@ +--- +title: sf connectors +description: Register tool sources for a team, connect accounts, set policy rules, and run connector tools from the terminal +sidebar: + order: 18 +--- + +`sf connectors` manages a team's connectors: tool sources, the accounts connected to them, the policy rules that decide what agents may call, and runs. For the concepts and the dashboard, see [Connectors](/connectors). + +Every command takes the global flags (`--api-url`, `--profile`, `--token`, `-y`/`--yes`, `--json`) and `-o`/`--team` (team slug, ID, or name, env `SPACEFAST_TEAM`). Without `--team`, the server picks the team your credential acts for. A credential that can act for several teams must name one. See [the CLI overview](/cli). + +Every command needs a credential. Without one, it fails with `validation_error` and exit 2 before any request. + +API errors map to exit codes by HTTP status: 401 and 403 exit 10, 404 exits 20, 409 exits 30, and 422 exits 2. The tables below list the codes that commands raise before the request. + +## sf connectors ls + +List the team's connectors, with the connections your credential can use on each. Aliased as `sf connectors list`. + +```text +sf connectors ls [--query ] [--source openapi|graphql|mcp] [--limit ] +``` + +### Flags + +| Flag | Default | What it does | +| --- | --- | --- | +| `--query=` | none | Filter over slug, title, and description | +| `--source=` | none | Show only `openapi`, `graphql`, or `mcp` connectors | +| `--limit=` | `50` | Maximum connectors to return. Minimum 1, maximum 100 | + +```bash +sf connectors ls +sf connectors ls --source mcp +``` + +### Output + +A table of `SLUG | TITLE | SOURCE | TOOLS | CONNECTIONS`. `CONNECTIONS` shows the count and the worst status among them, or `none`. An empty team prints "No connectors. Add one with `sf connectors add `." + +With `--json`, the receipt is the API list envelope `{ data, pagination }`. + +## sf connectors add + +Register a connector. A URL is detected first, and the most confident candidate is registered. Any other value is looked up in the catalog. + +```text +sf connectors add [--slug ] [--title ] [--description ] +``` + +### Arguments + +| Argument | What it is | +| --- | --- | +| `` | A spec, endpoint, or MCP URL, or a catalog slug. Required | + +### Flags + +| Flag | Default | What it does | +| --- | --- | --- | +| `--slug=` | detected or catalog slug | Connector slug. Also the namespace of its tools | +| `--title=` | the source's own name | Display name | +| `--description=` | none | Agent-visible note on what the connector reaches | + +A catalog lookup takes an exact id, registry id, or preset id match. Otherwise it takes the only search result. + +```bash +sf connectors add https://api.example.com/openapi.json +sf connectors add linear --slug linear +``` + +### Output + +`Registered () — <n> tools.`, then the command that connects an account. With `--json`, `data` is the connector. + +### Errors + +| Code | Exit | When | +| --- | --- | --- | +| `validation_error` | 2 | `<target>` is missing | +| `validation_error` | 2 | Nothing recognizable behind the URL | +| `validation_error` | 2 | No catalog row matches, or several do. The message lists up to five ids | +| `validation_error` | 2 | The team already has that catalog row. The message names its slug | +| `connector_slug_taken` | 30 | The team already has a connector with that slug | +| `connector_source_unreachable` | 2 | The source did not load | +| `connector_source_blocked` | 2 | The URL is not reachable from Spacefast | +| `rate_limited` | 1 | More than 60 registrations, detections, or source refreshes in one hour for the team | + +## sf connectors detect + +Probe a URL and list the connectors behind it, most confident first. Nothing is registered. + +```text +sf connectors detect <url> +``` + +### Arguments + +| Argument | What it is | +| --- | --- | +| `<url>` | Any `http` or `https` URL a service published. Required | + +```bash +sf connectors detect https://api.example.com +``` + +### Output + +A table of `KIND | CONFIDENCE | SLUG | TITLE | ENDPOINT`. `CONFIDENCE` is `high`, `medium`, or `low`. `ENDPOINT` is the URL to register, which can differ from the one you pasted. With `--json`, `data` is the array of candidates. Each carries `kind`, `confidence`, `endpoint`, `title`, `slug`, and `presetId`. + +### Errors + +| Code | Exit | When | +| --- | --- | --- | +| `validation_error` | 2 | `<url>` is missing, or is not an `http` or `https` URL | +| `rate_limited` | 1 | Shares the hourly budget of `sf connectors add` | + +## sf connectors rm + +Remove a connector and its tools. Aliased as `sf connectors remove` and `sf connectors delete`. Prompts unless you pass `--yes`. + +```text +sf connectors rm <connector> +``` + +### Arguments + +| Argument | What it is | +| --- | --- | +| `<connector>` | Connector slug. Required | + +Removing a connector disconnects every account on it first. + +```bash +sf connectors rm linear --yes +``` + +### Output + +`Removed connector <slug>.` With `--json`, `data` is `{ connector, removed: true }`. + +### Errors + +| Code | Exit | When | +| --- | --- | --- | +| `confirmation_required` | 2 | Non-interactive run without `--yes` | +| `connector_first_party_immutable` | 30 | The connector is built into Spacefast | + +## sf connectors connections ls + +List the connections your credential can use: the team's shared ones and your personal ones. Aliased as `sf connectors connections list`. + +```text +sf connectors connections ls [--owner team|personal|any] [--connector <slug>] + [--status <status>] [--include-revoked] [--limit <n>] +``` + +### Flags + +| Flag | Default | What it does | +| --- | --- | --- | +| `--owner=<value>` | `any` | `team`, `personal`, or `any` | +| `--connector=<slug>` | none | Show only connections of this connector | +| `--status=<value>` | none | `pending_authorization`, `active`, `degraded`, `needs_reauth`, or `revoked` | +| `--include-revoked` | `false` | Include disconnected connections | +| `--limit=<n>` | `50` | Maximum connections to return. Minimum 1, maximum 100 | + +```bash +sf connectors connections ls +sf connectors connections ls --owner personal +``` + +### Output + +A table of `ID | CONNECTOR | OWNER | NAME | STATUS | TOOLS`. `(default)` marks the connection an agent gets when it names the connector alone. With `--json`, the receipt is `{ data, pagination }`. + +## sf connectors connections add + +Connect an account. Aliased as `sf connectors connections connect`. + +```text +sf connectors connections add <connector> [--api-key <value> | --field <name=value>...] + [--owner team|personal] [--name <value>] [--description <value>] [--auth-method <slug>] +``` + +### Arguments + +| Argument | What it is | +| --- | --- | +| `<connector>` | Connector slug. Required | + +### Flags + +| Flag | Default | What it does | +| --- | --- | --- | +| `--api-key=<value>` | none | API key for a connector that takes one. Never echoed back | +| `--field=<name=value>` | none | One credential value. Repeat it for each field. Splits on the first `=` | +| `--owner=<value>` | `team` | `team` shares it with the team. `personal` keeps it yours, within this team | +| `--name=<value>` | the connector's title | Connection label | +| `--description=<value>` | none | Agent-visible note on what the connection is for | +| `--auth-method=<slug>` | chosen by the server | Auth method to use when the connector offers more than one | + +```bash +sf connectors connections add stripe --api-key "$STRIPE_KEY" +sf connectors connections add linear --owner personal +``` + +### Output + +The answer has one of three statuses. + +| Status | Human output | +| --- | --- | +| `connected` | `Connected <name> as <id> — <n> tools.` | +| `authorization_required` | `Open: <authorizeUrl>` and its expiry. The provider callback finishes the connection | +| `input_required` | One `--field <name>=<value>` line per value, with the provider's help when it has some, and the auth method to pass back with `--auth-method` | + +With `--json`, `data` carries `status` and the matching fields: `connection`, `authorizeUrl` and `expiresAt`, or `connector`, `authMethod`, and `fields`. + +### Errors + +| Code | Exit | When | +| --- | --- | --- | +| `validation_error` | 2 | `<connector>` is missing | +| `validation_error` | 2 | `--api-key` and `--field` are both set | +| `validation_error` | 2 | A `--field` value has no `name=` part | +| `connection_forbidden` | 2 | An API key asked for `--owner personal` | +| `connection_needs_reauth` | 2 | The credential was not stored, or the method does not fit the credential | + +## sf connectors connections edit + +Change a connection's label, its agent note, or its site-serving grant. Aliased as `sf connectors connections update`. + +```text +sf connectors connections edit <connection> [--name <value>] [--description <value>] + [--serving | --no-serving] +``` + +### Arguments + +| Argument | What it is | +| --- | --- | +| `<connection>` | Connection id, for example `cxn_abc123`. Required | + +### Flags + +| Flag | Default | What it does | +| --- | --- | --- | +| `--name=<value>` | unchanged | New label. Must not be blank | +| `--description=<value>` | unchanged | New agent note. An empty string clears it | +| `--serving`, `--no-serving` | unchanged | Let sites bound to this team connection act as this account. See [Zero connectors](/zero-connectors) | + +Credentials cannot be edited. To rotate one, run `sf connectors connections reconnect`. + +```bash +sf connectors connections edit cxn_abc123 --name "Stripe (prod)" +sf connectors connections edit cxn_abc123 --description "Billing, production" +``` + +### Output + +`Updated <name> (<id>).` With `--json`, `data` is the connection. + +### Errors + +| Code | Exit | When | +| --- | --- | --- | +| `validation_error` | 2 | No flag to change, or `--name` is blank | +| `connection_serving_not_granted` | 2 | `--serving` on a personal connection | + +## sf connectors connections health + +Probe a connection against the provider. It only reports. An expired token refreshes on its own. + +```text +sf connectors connections health <connection> +``` + +### Arguments + +| Argument | What it is | +| --- | --- | +| `<connection>` | Connection id. Required | + +```bash +sf connectors connections health cxn_abc123 +``` + +### Output + +`<id> <status> · <latency> · <n> tools`, then the provider's detail when something went wrong. A `needs_reauth` status adds the reconnect command. With `--json`, `data` carries `connectionId`, `status`, `reachable`, `latencyMs`, `detail`, `toolCount`, and `checkedAt`. + +### Errors + +| Code | Exit | When | +| --- | --- | --- | +| `connection_revoked` | 30 | The connection is disconnected | +| `connection_needs_reauth` | 30 | The connection has no credential, or its authorization is not finished | + +## sf connectors connections reconnect + +Sign in again, or replace the credential, on the same connection. It keeps its id, label, policies, and Space bindings. + +```text +sf connectors connections reconnect <connection> [--api-key <value> | --field <name=value>...] +``` + +### Arguments + +| Argument | What it is | +| --- | --- | +| `<connection>` | Connection id. Required | + +### Flags + +| Flag | Default | What it does | +| --- | --- | --- | +| `--api-key=<value>` | none | Replacement API key | +| `--field=<name=value>` | none | One replacement value. Repeat it for each field | + +Omit both flags for an OAuth connection. The answer is then an authorization URL. + +```bash +sf connectors connections reconnect cxn_abc123 +sf connectors connections reconnect cxn_abc123 --api-key "$STRIPE_KEY" +``` + +### Output + +The same three statuses as `sf connectors connections add`. `connected` prints `Reconnected <name> (<id>) — <n> tools.` + +### Errors + +| Code | Exit | When | +| --- | --- | --- | +| `validation_error` | 2 | `--api-key` and `--field` are both set, or a `--field` has no `name=` part | +| `connection_revoked` | 30 | The connection is disconnected. Connect the service again instead | +| `invalid_request` | 2 | The connector does not take the credential you passed | + +## sf connectors connections refresh + +Read the tool list behind a connection again, so tools the provider added or dropped show up. Aliased as `sf connectors connections sync`. + +```text +sf connectors connections refresh <connection> +``` + +### Arguments + +| Argument | What it is | +| --- | --- | +| `<connection>` | Connection id. Required | + +```bash +sf connectors connections refresh cxn_abc123 +``` + +### Output + +`Re-synced <name> (<id>) — <n> tools, <status>.` With `--json`, `data` is the connection. + +## sf connectors connections rm + +Disconnect a connection. The stored credential is purged and cannot be restored. Aliased as `sf connectors connections remove` and `sf connectors connections disconnect`. Prompts unless you pass `--yes`. + +```text +sf connectors connections rm <connection> +``` + +### Arguments + +| Argument | What it is | +| --- | --- | +| `<connection>` | Connection id. Required | + +Disconnecting an already disconnected connection changes nothing. + +```bash +sf connectors connections rm cxn_abc123 --yes +``` + +### Output + +`Disconnected <name> (<id>). The credential is purged.` With `--json`, `data` is the connection with `status` `revoked`. + +### Errors + +| Code | Exit | When | +| --- | --- | --- | +| `confirmation_required` | 2 | Non-interactive run without `--yes` | + +## sf connectors policies ls + +List the policy rules in force: the team's rules and your personal ones. Aliased as `sf connectors policies list`. + +```text +sf connectors policies ls +``` + +No command-specific flags. + +```bash +sf connectors policies ls +``` + +### Output + +A table of `ID | PATTERN | ACTION | OWNER`. With no rules, it prints "No rules. Every call falls through to the connector's own default." With `--json`, `data` is the array of rules. Each carries `id`, `pattern`, `action`, `owner`, `position`, and `createdAt`. + +## sf connectors policies add + +Add a policy rule. Its position comes from the pattern's specificity, so the more specific rule wins. + +```text +sf connectors policies add <pattern> --action <action> [--owner team|personal] +``` + +### Arguments + +| Argument | What it is | +| --- | --- | +| `<pattern>` | `*`, `<connector>/*`, `<connector>/<tool>`, `<connectionId>/*`, or `<connectionId>/<tool>`. Quote it, because the shell expands a bare `*`. Required | + +### Flags + +| Flag | Default | What it does | +| --- | --- | --- | +| `--action=<value>` | none, required | `approve` runs the call. `require_approval` parks it for a person. `block` refuses it | +| `--owner=<value>` | `team` | `team` applies to everyone. `personal` applies to your own calls in this team | + +A team rule is a floor. A personal rule can tighten it, never loosen it. + +```bash +sf connectors policies add '*' --action require_approval +sf connectors policies add 'cxn_abc123/charges.create' --action block +``` + +### Output + +`Added <owner> rule <id>: <pattern> → <action>.` With `--json`, `data` is the rule. + +### Errors + +| Code | Exit | When | +| --- | --- | --- | +| `validation_error` | 2 | `<pattern>` or `--action` is missing | +| `malformed_policy_pattern` | 2 | The pattern does not fit the grammar | +| `connector_not_found` | 2 | The pattern names a connector or connection the team does not have | +| `connection_forbidden` | 2 | An API key asked for `--owner personal` | + +## sf connectors policies set + +Change what a rule does. A rule's pattern and owner are fixed. To change those, remove the rule and add a new one. + +```text +sf connectors policies set <rule> --action <action> +``` + +### Arguments + +| Argument | What it is | +| --- | --- | +| `<rule>` | Rule id from `sf connectors policies ls`. Required | + +### Flags + +| Flag | Default | What it does | +| --- | --- | --- | +| `--action=<value>` | none, required | `approve`, `require_approval`, or `block` | + +```bash +sf connectors policies set pol_abc123 --action block +``` + +### Output + +`<id> is now <action> for <pattern>.` With `--json`, `data` is the rule. + +### Errors + +| Code | Exit | When | +| --- | --- | --- | +| `validation_error` | 2 | `<rule>` or `--action` is missing | + +## sf connectors policies rm + +Remove a rule. Its tools fall through to the next matching rule, or to the tool's own default, so removing a block can open a tool again. Aliased as `sf connectors policies remove` and `sf connectors policies delete`. Prompts unless you pass `--yes`. + +```text +sf connectors policies rm <rule> +``` + +### Arguments + +| Argument | What it is | +| --- | --- | +| `<rule>` | Rule id. Required | + +```bash +sf connectors policies rm pol_abc123 --yes +``` + +### Output + +`Removed rule <id>.` With `--json`, `data` is `{ policyRule, removed: true }`. + +### Errors + +| Code | Exit | When | +| --- | --- | --- | +| `confirmation_required` | 2 | Non-interactive run without `--yes` | + +## sf connectors policies explain + +Show what happens if you call one address, and why, without calling it. + +```text +sf connectors policies explain <address> +``` + +### Arguments + +| Argument | What it is | +| --- | --- | +| `<address>` | Tool address, `<connectionId>/<tool>`. Required | + +```bash +sf connectors policies explain cxn_abc123/charges.create +``` + +### Output + +`<address> → <effect>`, then the deciding rule or "No rule matched.", then the reason in one sentence. With `--json`, `data` carries `address`, `effect`, `decidedBy` (`ruleId`, `pattern`, `owner`, or `null`), and `explanation`. + +### Errors + +| Code | Exit | When | +| --- | --- | --- | +| `malformed_tool_address` | 2 | The address does not resolve to a connection you can use | + +## sf connectors run + +Run one tool, or a program that calls several. Both are one run with one id. + +```text +sf connectors run [code] [--tool <address>] [--args <json>] [--wait <seconds>] + [--idempotency-key <value>] [--deny-unattended] +``` + +### Arguments + +| Argument | What it is | +| --- | --- | +| `[code]` | A program to run in the sandbox. It can call any tool your credential may use. Omit it with `--tool` | + +### Flags + +| Flag | Default | What it does | +| --- | --- | --- | +| `--tool=<address>` | none | Tool address, `<connectionId>/<tool>`, or `<connector>/<tool>` when one connection matches | +| `--args=<json>` | `{}` | JSON object of tool arguments. Accepts `@file` or `-` for stdin. Only with `--tool` | +| `--wait=<seconds>` | the server's, 30 seconds | Seconds to wait for a final status. Minimum 0, maximum 120 | +| `--idempotency-key=<value>` | none | Retry with the same key to get the original run back instead of running it twice | +| `--deny-unattended` | `false` | Fail at once instead of parking when a call needs approval | + +The run needs a credential with `connectors:execute`. A program runs for at most 10 seconds. + +```bash +sf connectors run --tool cxn_abc123/issues.list --args '{"limit":5}' +sf connectors run --tool cxn_abc123/issues.create --args @issue.json +``` + +### Output + +| Status | Human output | Exit | +| --- | --- | --- | +| `succeeded` | `<id> succeeded · <n> tool call(s) · <ms>ms`, then the result as JSON | 0 | +| `failed` | `<id> failed`, then `<code>: <detail>` | 1 | +| `awaiting_approval` | The address, `Approve or deny: <approvalUrl>`, and the expiry | 0 | +| `expired` | `parked too long; submit it again.` | 1 | +| `running` | `still running.`, then the `sf connectors run get` command | 0 | + +With `--json`, `data` is the run. Every run carries `id`, `status`, `origin`, `input` (`kind`, `address`, `digest`), `usage` (`toolCalls`, `egressBytes`, `durationMs`), and `createdAt`. A finished run adds `completedAt`, then `result` or `error`. A parked run adds `approval` with `mode`, `address`, `preview`, `reason`, `matchedPattern`, `approvalUrl`, and `expiresAt`. + +A parked run expires after 15 minutes. + +### Errors + +| Code | Exit | When | +| --- | --- | --- | +| `validation_error` | 2 | Both `[code]` and `--tool`, or neither | +| `validation_error` | 2 | `--tool` has no `/` | +| `validation_error` | 2 | `--args` with a program, or `--args` is not a JSON object | +| `validation_error` | 2 | `--wait` outside 0 to 120 | +| `idempotency_key_reused` | 30 | The key was used with a different request | +| `rate_limited` | 1 | More than 1,200 runs in one hour for the team | + +A run that fails carries its reason in `error.code`, for example `connector_tool_blocked`, `connection_ambiguous`, `connection_needs_reauth`, or `connector_approval_required`. + +## sf connectors run get + +Read one run: its result, its failure, or the approval it waits on. Use it to follow a `running` run. + +```text +sf connectors run get <run> +``` + +### Arguments + +| Argument | What it is | +| --- | --- | +| `<run>` | Run id, for example `cxr_abc123`. Required | + +```bash +sf connectors run get cxr_abc123 +``` + +### Output + +The same as `sf connectors run`, with the same exit codes. + +## sf connectors run list + +List the team's runs, newest first. Aliased as `sf connectors run ls`. + +```text +sf connectors run list [--status <status>] [--connection <id>] [--limit <n>] +``` + +### Flags + +| Flag | Default | What it does | +| --- | --- | --- | +| `--status=<value>` | none | `running`, `awaiting_approval`, `succeeded`, `failed`, or `expired` | +| `--connection=<id>` | none | Show only runs that touched this connection | +| `--limit=<n>` | `50` | Maximum runs to return. Minimum 1, maximum 100 | + +```bash +sf connectors run list +sf connectors run list --status awaiting_approval +``` + +### Output + +A table of `ID | STATUS | KIND | ADDRESS | CALLS | CREATED`, or "No runs." With `--json`, the receipt is `{ data, pagination }`. + +## sf connectors run resume + +Decide a parked run, or read back the decision a person made. + +```text +sf connectors run resume <run> [--accept | --deny | --cancel] [--content <json>] +``` + +### Arguments + +| Argument | What it is | +| --- | --- | +| `<run>` | Run id. Required | + +### Flags + +| Flag | Default | What it does | +| --- | --- | --- | +| `--accept` | `false` | Approve the parked call | +| `--deny` | `false` | Refuse the parked call. The run fails with `connector_tool_blocked` | +| `--cancel` | `false` | Abandon the run | +| `--content=<json>` | none | JSON object of values the parked call asked for. Accepts `@file` or `-` for stdin | + +Omit the action to read back a decision a person recorded in the dashboard. A run parked for a person refuses a decision from the CLI. + +```bash +sf connectors run resume cxr_abc123 +``` + +### Output + +The same as `sf connectors run`, with the same exit codes. + +### Errors + +| Code | Exit | When | +| --- | --- | --- | +| `validation_error` | 2 | More than one of `--accept`, `--deny`, and `--cancel` | +| `validation_error` | 2 | `--content` is not a JSON object | +| `human_approval_required` | 10 | An action was passed, and a person must decide this run in the dashboard | +| `connector_approval_required` | 2 | No action, and nobody has decided yet | +| `connector_run_not_resumable` | 30 | The run already settled, or someone else claimed the decision | + +## Space bindings + +These three commands connect a Space's connector roles to team connections. They require `--space`. For roles and bindings, see [Zero connectors](/zero-connectors). + +| Command | What it does | +| --- | --- | +| `sf connectors bindings --space <ref>` | List the Space's roles: `ROLE | AS | CONNECTION | STATUS` | +| `sf connectors bind <role> <connection> --space <ref>` | Bind one role to a connection id | +| `sf connectors unbind <role> --space <ref>` | Remove one binding. Prompts unless you pass `--yes` | + +Without `--space`, each fails with `validation_error` and exit 2. + +See also the generated [command reference](/cli/reference). diff --git a/content/cli/git.mdx b/content/cli/git.mdx index 2144a9ce..a4d7f13e 100644 --- a/content/cli/git.mdx +++ b/content/cli/git.mdx @@ -45,7 +45,7 @@ sf git connect [--space <value>] [--provider github|gitlab|bitbucket|generic] | `--default-branch=<value>` | none | Repository default branch | | `--production-branch=<value>` | none | Branch that publishes to production | | `--ref=<value>` | none | Ref, branch, tag, or commit to sync or build after connecting. Env `SPACEFAST_BUILD_REF` | -| `--config=<path>` | none | Path to an `sf.jsonc` with repository build settings. Env `SPACEFAST_CONFIG` | +| `--config=<path>` | none | Path to a `spacefast.config.ts` or `sf.jsonc` file with repository build settings. Env `SPACEFAST_CONFIG` | | `--auto-deploy-production` / `--no-auto-deploy-production` | `true` | Automatically build pushes to the production branch | | `--auto-deploy-previews` / `--no-auto-deploy-previews` | `true` | Automatically build preview branches and pull requests | | `--root-directory=<value>` | none | App root directory | @@ -122,7 +122,7 @@ sf git update [--space <value>] [--connection-type connected|hosted] ... | `--clear-credential` | `false` | Clear the stored generic Git clone token | | `--auto-deploy-production` / `--no-auto-deploy-production` | unchanged | Automatically build pushes to the production branch | | `--auto-deploy-previews` / `--no-auto-deploy-previews` | unchanged | Automatically build preview branches and pull requests | -| `--config=<path>` | none | Path to an `sf.jsonc` with repository build settings. Env `SPACEFAST_CONFIG` | +| `--config=<path>` | none | Path to a `spacefast.config.ts` or `sf.jsonc` file with repository build settings. Env `SPACEFAST_CONFIG` | | `--root-directory=<value>` | unchanged | App root directory | | `--install-directory=<value>` | unchanged | Dependency install directory | | `--install-command=<value>` | unchanged | Install command | @@ -157,7 +157,7 @@ sf git build [--space <value>] [--connection-type connected|hosted] [--ref <valu | `--branch=<value>` | none | Branch name for preview alias and production branch selection. Env `SPACEFAST_GIT_BRANCH` | | `--commit=<sha>` | none | Commit SHA to build. Env `SPACEFAST_GIT_COMMIT` | | `--pull-request=<n>` | none | Pull request number for preview builds. Minimum 1. Env `SPACEFAST_PULL_REQUEST` | -| `--config=<path>` | none | Path to an `sf.jsonc` with repository build settings. Env `SPACEFAST_CONFIG` | +| `--config=<path>` | none | Path to a `spacefast.config.ts` or `sf.jsonc` file with repository build settings. Env `SPACEFAST_CONFIG` | | `--target=<production\|preview>` | from the branch | Force the build target | | `--root-directory=<value>` | saved setting | Override the app root directory for this build | | `--install-directory=<value>` | saved setting | Override the dependency install directory for this build | diff --git a/content/cli/index.mdx b/content/cli/index.mdx index b8a2a019..74ca5e1a 100644 --- a/content/cli/index.mdx +++ b/content/cli/index.mdx @@ -181,12 +181,28 @@ Other files the CLI owns: | `~/.spacefast/credentials/profile-*.json` | One credential file per profile | | `.spacefast/space.json` | Committable link: which Space, team, and API base URL | | `.spacefast/state.json` | Local cache and credentials. Never commit it, and `sf` adds it to `.gitignore` for you | -| `sf.jsonc` | Project config written by `sf init` | +| `spacefast.config.ts` or `sf.jsonc` | [Project config](/config-file). `sf init` writes `spacefast.config.ts` | ## Update checks The CLI checks npm for a newer version every 7 days and prints the exact upgrade command for how you installed it. It skips the check whenever the invocation looks machine-driven: `--json` anywhere in the arguments, an `mcp` invocation, or a non-TTY stderr. +## Shell completion + +The npm package completes commands and flags in `zsh`, `bash`, and PowerShell. The standalone binaries and Homebrew do not include completion. To set it up, run `sf autocomplete` with your shell: + +```bash +sf autocomplete zsh +``` + +The command builds the completion cache and prints the setup steps. For `zsh`, the step adds one line to `~/.zshrc`: + +```bash +printf "$(sf autocomplete script zsh)" >> ~/.zshrc; source ~/.zshrc +``` + +For `bash`, the line goes in `~/.bashrc`. PowerShell adds the script to `$PROFILE`. Without a shell argument, `sf autocomplete` detects your shell. To pick up new commands after an upgrade, run `sf autocomplete --refresh-cache`. + ## Command reference <CardGroup cols={2}> @@ -208,4 +224,7 @@ The CLI checks npm for a newer version every 7 days and prints the exact upgrade <Card title="Inspection and agent helpers" href="/cli/agent-commands" icon="wrench"> `inspect`, `fetch`, `apply`, `continue`, `map`, `docs`, `doctor`, `feedback`, `plugins` </Card> + <Card title="Send feedback" href="/feedback" icon="message-square"> + `feedback`, `feedback list`, `feedback get` + </Card> </CardGroup> diff --git a/content/cli/meta.ts b/content/cli/meta.ts index 8d9eb558..0ed21e7e 100644 --- a/content/cli/meta.ts +++ b/content/cli/meta.ts @@ -11,6 +11,7 @@ export default defineMeta({ "versions", "spaces", "domains", + "rules", "env", "db", "storage", @@ -18,6 +19,8 @@ export default defineMeta({ "source", "zero", "share", + "comments", + "connectors", "teams", "api-keys", "api", diff --git a/content/cli/project.mdx b/content/cli/project.mdx index 2b1df5b8..96da5a8a 100644 --- a/content/cli/project.mdx +++ b/content/cli/project.mdx @@ -49,32 +49,30 @@ sf init my-app --runtime zero --template guestbook ### What it writes -`sf init` writes `sf.jsonc`, commented out down to the keys you are likely to want: - -```jsonc sf.jsonc -{ - "$schema": "https://spacefast.com/schemas/sf.json", - // Project link: .spacefast/space.json (written by sf init --space, sf link, or sf publish). - // "index": "index.html", - // "fallback": "/index.html", // SPA fallback; or { "path": "/404.html", "status": 404 } - // "listing": false, - // "access": "public", // whole live space public; or { "public": ["/assets/**", "!/assets/private/**"] } - // "meta": { "title": "", "description": "", "image": "/og.png", "favicon": "/favicon.svg" }, - // "templates": [], // files that receive {{ vars.NAME }} substitution - // "build": { "rootDirectory": ".", "buildCommand": "npm run build", "outputDirectory": "dist" }, -} +`sf init` writes `spacefast.config.ts`, with the keys you are likely to want commented out: + +```ts spacefast.config.ts +import { defineConfig } from "spacefast/config"; + +export default defineConfig({ + // fallback: "/index.html", + // meta: { title: "", description: "", image: "/og.png" }, + // build: { buildCommand: "npm run build", outputDirectory: "dist" }, +}); ``` +The `zero` and `functions` runtimes also write `spacefast.config.ts`, with `name` and `runtime` set. + With `--space` it also writes `.spacefast/space.json` and refreshes `.spacefast/state.json`. If the cache write fails you get a warning, not a failure, because the committed link is the source of truth. -Other config filenames still work, in this precedence order: `sf.jsonc`, `spacefast.jsonc`, `spacefast.json`, `sf.json`, `.sf/sf.json`, `.sf/config.jsonc`, `.sf/config.json`. The highest one present wins and the rest produce a warning naming the winner. +`sf.jsonc` and the other JSON config names still work. `spacefast.config.ts` takes precedence over all of them. The highest one present wins and the rest produce a warning naming the winner. See [Project config reference](/config-file) for the full order. -`sf init` refuses to overwrite an existing config without `--force`, and refuses a symlinked or non-regular config outright. `sf init <name>` removes the directory it created if init fails. +`sf init` refuses to overwrite an existing config in any supported format without `--force`, and refuses a symlinked or non-regular config outright. With `--force`, it overwrites the selected config file and keeps its format. A forced `sf.jsonc` gets only the `$schema` line. `sf init <name>` removes the directory it created if init fails. ### Output ```text -{ "data": { "created": true, "configPath": "…/sf.jsonc" } } +{ "data": { "created": true, "configPath": "…/spacefast.config.ts" } } ``` With `--space`, `data` also carries `linkPath`, `space`, `statePath`, and `stateSaved`. diff --git a/content/cli/rules.mdx b/content/cli/rules.mdx new file mode 100644 index 00000000..59cfae77 --- /dev/null +++ b/content/cli/rules.mdx @@ -0,0 +1,331 @@ +--- +title: rules +description: List, add, remove, test, and validate a Space's firewall and cache-bypass rules, and pause or resume every rule at once +sidebar: + order: 19 +--- + +`sf rules` manages a Space's traffic rules: what it blocks, challenges, or serves past the cache. For what the rules do and where they live, see [Traffic rules](/traffic-rules). + +Every command here takes the global flags (`--api-url`, `--profile`, `--token`, `-y`/`--yes`, `--json`) and the space selection flags (`--space`, `-o`/`--team`, `--claim-token`). See [the CLI overview](/cli). + +`sf rules` with no subcommand prints the help for the group. + +## sf rules ls + +List the Space's traffic rules in the order the edge reads them. Aliased as `sf rules list`. + +```text +sf rules ls [--space <value>] +``` + +No command-specific flags. + +```bash +sf rules ls --space docs +``` + +### Output + +A table of `NAME | KIND | ACTION | ORIGIN | WHERE | STATE`. + +| Column | Values | +| --- | --- | +| `NAME` | The name the author gave the rule, otherwise its name at the edge | +| `KIND` | `firewall`, `cache-bypass`, `redirect`, `rewrite`, or `header` | +| `ACTION` | `block`, `block <status>`, `challenge`, `bypass`, or `-` | +| `ORIGIN` | `file`, `config`, `overlay`, or `platform` | +| `WHERE` | `edge` or `origin` | +| `STATE` | The edge state (`pending`, `live`, `invalid`, `over_quota`, `failed`, `unknown`), or `disabled` or `suspended` for a rule that is not enabled | + +Lines after the table: + +- `Rules are paused. This space serves as if it declared none.` before the table, while rules are paused. +- `<name>: <message>` for a rule the edge refused. +- `<name>: <reason>` for a rule that runs at the origin, with a code such as `edge_ineligible_capture`. +- `<name> was skipped: an earlier rule already has this name.` or `... already does the same thing.` for a rule the edge never got. +- `Answers on: <hostnames>`. +- `Edge rules: <used>/<limit>`. When the edge does not answer, the line says the count is Spacefast's own record or that the limit is unknown. + +With no rules: `` No traffic rules. Add one with `sf rules add`, or declare it in sf.jsonc. `` + +With `--json`, `data` is the [`listSpaceTrafficRules`](/api/reference/spaces/listspacetrafficrules) response: `{ rules, quota, paused, hostnames, collapsed }`. + +## sf rules add + +Add a firewall or cache-bypass rule to the Space settings. The rule goes first in its list, so it answers before the rules already there. The change reaches the edge without a publish. + +```text +sf rules add --kind firewall|cache-bypass [--action block|challenge] [--status <400-499>] + [--name <value>] [--description <value>] [--any] [match flags] [--no-wait] [--space <value>] +``` + +### Rule flags + +| Flag | Default | What it does | +| --- | --- | --- | +| `--kind=<option>` | required | `firewall` or `cache-bypass` | +| `--action=<option>` | none | `block` or `challenge`. Required for a firewall rule. Refused for a cache-bypass rule | +| `--status=<n>` | `403` | Status a blocked request gets, 400 to 499. Blocks only | +| `--name=<value>` | none | Lowercase letters, digits, `-`, and `_`, up to 61 characters. Not all digits and not a reserved word. `sf rules rm` addresses the rule by it | +| `--description=<value>` | none | What the rule is for | +| `--any` | `false` | Fire when any match field holds. By default all of them have to | +| `--[no-]wait` | `true` | Wait until the edge answers for the rule | + +### Match flags + +Give at least one. Every match flag repeats. + +| Flag | Matches | Commas | +| --- | --- | --- | +| `--path=<value>` | Request path, for example `'/wp-login.php*'` | Taken verbatim | +| `--host=<value>` | Request hostname | Taken verbatim | +| `--method=<value>` | HTTP method | Split | +| `--ip=<value>` | Client address, or a CIDR range like `203.0.113.0/24` | Split | +| `--country=<value>` | Two-letter country codes, for example `US,CA` | Split | +| `--asn=<value>` | The client network's ASN | Split | +| `--user-agent=<value>` | User agent, for example `'*bot*'` | Taken verbatim | +| `--referer=<value>` | The `Referer` header | Taken verbatim | +| `--header=<name=value>` | A header condition | Taken verbatim | +| `--cookie=<name=value>` | A cookie condition | Taken verbatim | +| `--query=<name=value>` | A query parameter condition | Taken verbatim | +| `--extension=<value>` | Path extension without the dot, for example `php` | Split | +| `--ja3=<value>` | TLS JA3 fingerprint | Split | +| `--ja4=<value>` | TLS JA4 fingerprint | Split | +| `--not=<field>` | Match the opposite of one field, for example `--not country`. A header, cookie, or query field names its key: `--not header:x-api-client` | | + +A value with a `*` matches as a wildcard. Several values match any of them. An `--ip` value with a `/` matches a range. Header names in `--not` ignore case. Cookie and query names do not. + +```bash +sf rules add --kind firewall --action challenge --path '/wp-login.php*' +sf rules add --kind firewall --action block --country US,CA --not country +sf rules add --kind cache-bypass --path '/api/*' +sf rules add --kind firewall --action block --status 429 --user-agent '*bot*' --name no-bots +``` + +### Output + +`Added <kind> rule <name>.`, then what the edge did: + +| Line | When | +| --- | --- | +| `Live at the edge.` | The edge answers with the rule | +| `The edge left this rule <state>: <message>` | The edge refused the rule or left it over quota | +| `` Still applying; run `sf rules ls` to check. `` | The wait ran out, or `--no-wait` was set and the edge has not answered yet | + +If the config file declares a rule with the same name and kind, the command also prints `<name> is also declared in <file>; this rule replaces it until you remove one.` + +A wait that runs out does not fail the command. The settings write already landed. + +With `--json`, `data` is `{ rule, space }`. `rule` is the rule as [`listSpaceTrafficRules`](/api/reference/spaces/listspacetrafficrules) reports it, or `null`. + +### Errors + +| Code | Exit | When | +| --- | --- | --- | +| `validation_error` | 2 | `--kind` is missing or not `firewall` or `cache-bypass` | +| `validation_error` | 2 | A firewall rule without `--action`, or a cache-bypass rule with `--action` or `--status` | +| `validation_error` | 2 | No match flag | +| `validation_error` | 2 | `--header`, `--cookie`, or `--query` is not `name=value` | +| `validation_error` | 2 | `--not` names a field that is not a match field, names a header, cookie, or query without its key, or names a field the rule does not match on | +| `validation_error` | 2 | `--name` breaks the name grammar, or the Space settings already declare a rule of this kind with that name | + +## sf rules rm + +Remove a rule the Space settings declare. Aliased as `sf rules remove` and `sf rules delete`. + +```text +sf rules rm <name> [--kind firewall|cache-bypass] [--no-wait] [--space <value>] +``` + +### Arguments + +| Argument | What it is | +| --- | --- | +| `<name>` | Name of the rule to remove. Required | + +### Flags + +| Flag | Default | What it does | +| --- | --- | --- | +| `--kind=<option>` | none | `firewall` or `cache-bypass`. Needed when both lists have a rule with this name | +| `--[no-]wait` | `true` | Wait until the edge no longer holds the rule | + +The command prompts for confirmation unless you pass `-y`. A rule that a config file declares is not removable here. Delete it from the file and publish. + +```bash +sf rules rm block-logins --kind firewall +sf rules rm fresh-api --yes +``` + +### Output + +`Removed <kind> rule <name>.`, then `Gone from the edge.` or `` Still applying; run `sf rules ls` to check. `` + +If the config file declares a rule with the same name, the command also prints `<name> is still declared in <file>; the edge keeps that rule.` + +With `--json`, `data` is `{ removed: { name, kind }, space }`. + +### Errors + +| Code | Exit | When | +| --- | --- | --- | +| `validation_error` | 2 | `--kind` is not `firewall` or `cache-bypass` | +| `validation_error` | 2 | The name is on a firewall rule and a cache-bypass rule, and `--kind` is absent | +| `validation_error` | 2 | `--kind` names one list, and the rule is in the other | +| `validation_error` | 2 | The config file declares the rule: `<name> is declared in <file>; remove it there and publish.` | +| `rule_not_found` | 20 | The Space declares no rule with this name | +| `confirmation_required` | 2 | No terminal to prompt on, and no `--yes` | +| `cancelled` | 130 | You answered no at the prompt | + +## sf rules pause + +Park every traffic rule at the edge. The Space then serves as if it declared none. The rules stay saved, and a rule added while paused is parked when it arrives. + +```text +sf rules pause [--space <value>] +``` + +No command-specific flags. + +```bash +sf rules pause --space docs +``` + +### Output + +`Paused this space's traffic rules. It now serves as if it declared none.` + +With `--json`, `data` is the [`pauseSpaceTrafficRules`](/api/reference/spaces/pausespacetrafficrules) response, the same shape as `sf rules ls`. + +### Errors + +| Code | Exit | When | +| --- | --- | --- | +| `traffic_rules_unavailable` | 30 | The Space has no edge yet. Publish it once | + +## sf rules resume + +Put every parked rule back into the state it was paused from. Safe to run on a Space that is not paused. + +```text +sf rules resume [--space <value>] +``` + +No command-specific flags. + +```bash +sf rules resume --space docs +``` + +### Output + +`Resumed this space's traffic rules.`, then the same listing as `sf rules ls`. + +With `--json`, `data` is the [`resumeSpaceTrafficRules`](/api/reference/spaces/resumespacetrafficrules) response, the same shape as `sf rules ls`. + +### Errors + +| Code | Exit | When | +| --- | --- | --- | +| `traffic_rules_unavailable` | 30 | The Space has no edge yet. Publish it once | + +## sf rules test + +Answer one request against the Space's traffic rules, and print the first rule of each list that matches. The CLI evaluates the rules the way the edge does. It sends no request to the Space and changes nothing. + +```text +sf rules test <url> [--file <path>] [--method <value>] [--ip <value>] [--country <value>] + [--asn <value>] [--user-agent <value>] [--referer <value>] [--ja3 <value>] [--ja4 <value>] + [--header <name=value>]... [--cookie <name=value>]... [--space <value>] +``` + +### Arguments + +| Argument | What it is | +| --- | --- | +| `<url>` | URL or path to answer, for example `/wp-login.php`. Required | + +### Flags + +| Flag | Default | What it does | +| --- | --- | --- | +| `--file=<path>` | none | Answer from this config file instead of the Space's live rules | +| `--method=<value>` | none | HTTP method. With none, no method condition matches | +| `--ip=<value>` | none | Client address | +| `--country=<value>` | none | Two-letter country code | +| `--asn=<value>` | none | The client network's ASN | +| `--user-agent=<value>` | none | User agent | +| `--referer=<value>` | none | The `Referer` header | +| `--ja3=<value>` | none | TLS JA3 fingerprint | +| `--ja4=<value>` | none | TLS JA4 fingerprint | +| `--header=<name=value>` | none | A request header. Repeatable | +| `--cookie=<name=value>` | none | A request cookie. Repeatable | + +Without `--file`, the command reads the enabled rules from [`listSpaceTrafficRules`](/api/reference/spaces/listspacetrafficrules). A bare path is then tested against the Space's first hostname. With `--file`, a bare path is tested against `localhost`. + +Firewall rules never match the platform's own transport paths, such as `/__spacefast/health.php`. + +```bash +sf rules test /wp-login.php --country RU +sf rules test https://docs.example.com/admin/ --ip 203.0.113.7 +sf rules test /api/orders --file spacefast.config.ts +``` + +### Output + +```text +Answered from the space's live rules. +No host given; answering as docs.example.com. +firewall: block-logins block 403 at the edge, from overlay +cache-bypass: no rule matches +``` + +While rules are paused, the output adds `Rules are paused, so the live space answers this request normally.` + +With `--json`, `data` is `{ request, source, paused, firewall, cacheBypass }`. `firewall` and `cacheBypass` are each `null` or `{ name, kind, action, placement, origin }`. + +### Errors + +| Code | Exit | When | +| --- | --- | --- | +| `validation_error` | 2 | `--header` or `--cookie` is not `name=value` | +| `validation_error` | 2 | A rule in the `--file` config does not parse. Run `sf rules validate` | +| `config_not_found` | 2 | `--file` names a JSONC or JSON file that does not exist | +| `config_invalid` | 2 | The `--file` config is not valid JSON, or its `firewall` or `cache` keys have the wrong shape | + +## sf rules validate + +Check the rules a local config file declares against the rule grammar and the edge. It reports every problem with the field it is in. It writes nothing. + +```text +sf rules validate [--file <path>] [--space <value>] +``` + +### Flags + +| Flag | Default | What it does | +| --- | --- | --- | +| `--file=<path>` | the config file in this directory | Config file to read | + +Without `--file`, the command reads the first config file it finds in the current directory: `spacefast.config.ts`, then `sf.jsonc` and its aliases. The check runs against the selected Space's edge. + +```bash +sf rules validate +sf rules validate --file build/spacefast.config.ts +``` + +### Output + +`<file> declares <n> traffic rules the edge accepts.`, or `<file> declares no traffic rules.` when the file has none. + +With `--json`, `data` is `{ valid, issues }`, the [`validateSpaceTrafficRules`](/api/reference/spaces/validatespacetrafficrules) response. + +### Errors + +| Code | Exit | When | +| --- | --- | --- | +| `traffic_rules_invalid` | 2 | One or more rules have problems. `details.issues` lists each `{ path, message }`, for example `firewall[0].match.country` | +| `config_not_found` | 2 | No config file in this directory, or `--file` names a JSONC or JSON file that does not exist | +| `config_invalid` | 2 | The config is not valid JSON, or its `firewall` or `cache` keys have the wrong shape | +| `traffic_rules_unavailable` | 30 | The Space has no edge yet. Publish it once | diff --git a/content/cli/spaces.mdx b/content/cli/spaces.mdx index 3bd9b1d0..829d8d81 100644 --- a/content/cli/spaces.mdx +++ b/content/cli/spaces.mdx @@ -85,6 +85,53 @@ Mode: website Then the live version summary, or `Live version: none`. An anonymous Space prints `Team: anonymous` and a `Claim expires:` line. `--json` returns `{ space, state, runtime, liveVersion }`. +## sf spaces check + +Check whether a space name is available. + +```text +sf spaces check NAME +``` + +Normalizes the name to a slug, then checks the slug and its managed hostname in the team. The answer is advisory: another create or rename can still take the name first. Reserved names fail, and so do slugs that contain `spacefast`, `stattic`, `automattic`, `wordpress`, or `paypal`. A space slug is at most 55 characters. Requires a login or `--token`. + +### Arguments + +| Argument | Default | What it is | +| --- | --- | --- | +| `<name>` | required | Space name or slug to check | + +### Flags + +| Flag | Default | What it does | +| --- | --- | --- | +| `-o, --team=<value>` | default team | Team to check the name in. Slug, ID, or name. Env `SPACEFAST_TEAM` | + +### Example + +```bash +sf spaces check my-site --team acme +``` + +### Output + +```text +Normalized name: my-site +Space slug: available +Space slug suggestion: none +Managed hostname (my-site.space.fast): available +Managed hostname suggestion: none +``` + +When a name is taken, the suggestion lines name the closest free slug and hostname label. A name with nothing usable prints `Normalized name: empty` and is unavailable. `--json` returns the [`getSlugAvailability`](/api/reference/spaces/getslugavailability) result unchanged: `kind`, `normalizedSlug`, `available`, `suggestion`, and the hostname fields. + +### Errors + +| Code | Exit | What to do | +| --- | --- | --- | +| `unauthorized` | 10 | No credential. Run `sf login` | +| `validation_error` | 2 | No accessible team matches `--team`. Run `sf teams ls` | + ## sf spaces add Create an empty space. Alias: `sf spaces create`. diff --git a/content/troubleshooting.mdx b/content/troubleshooting.mdx index fdcda0d5..adafdf8c 100644 --- a/content/troubleshooting.mdx +++ b/content/troubleshooting.mdx @@ -34,7 +34,7 @@ Every limit and its number is on [Limits](/limits). Every code, with whether ret ### The publish failed while provisioning -`publish_setup_failed` means provisioning for this publish did not complete. It is not a limit and not something in your files. Retry the publish. If it persists, contact support with the `requestId` from the error. +`publish_setup_failed` means provisioning for this publish did not complete. It is not a limit and not something in your files. Retry the publish. If it persists, contact support with the `requestId` from the error, or [send feedback](/feedback) with the code and request ID attached. `publish_session_expired` is different. Declared uploads are kept for 24 hours, and a publish resumed after that has to start again. diff --git a/redirects.ts b/redirects.ts index 482c4763..d64cd68c 100644 --- a/redirects.ts +++ b/redirects.ts @@ -44,7 +44,7 @@ export const redirects: { from: string; to: string; status: 301 }[] = [ { from: "/integrations/vite", to: "/frameworks", status: 301 }, { from: "/json-output", to: "/cli", status: 301 }, { from: "/login", to: "/cli/login", status: 301 }, - { from: "/migrate-from", to: "/publish", status: 301 }, + { from: "/migrate-from", to: "/migrate", status: 301 }, { from: "/partner/events", to: "/platforms/partner-api/configuration", status: 301 }, { from: "/partner/onboarding", to: "/platforms/partner-api", status: 301 }, { from: "/partner/principals", to: "/platforms/partner-api/customers", status: 301 }, diff --git a/styles/Spacefast/spelling-exceptions.txt b/styles/Spacefast/spelling-exceptions.txt index 3662d3bf..afa2fbf9 100644 --- a/styles/Spacefast/spelling-exceptions.txt +++ b/styles/Spacefast/spelling-exceptions.txt @@ -257,6 +257,7 @@ unoptimized Unparseable unparseable unpromoted +unreact unscoped unstage unstored From c6330e8cae627e85788ebfc8b8a50604a7278b8e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?batuhan=20i=C3=A7=C3=B6z?= <batuhan@icoz.co> Date: Sat, 24 Oct 2026 13:51:35 -0700 Subject: [PATCH 2/2] Address review findings on the feature-gap pages - Document visitor connector roles, the Connect button, and visitor_role_not_bindable. - Guard the Worker write examples by method and a bearer secret. - Quote shell placeholders as variables so copied commands run. - Encode the redeem URL with URLSearchParams. - Add steps to replace an identity connection without losing access. - State that removed members cannot rejoin a subteam with Join. - List every root Worker entry name. - State that a new connector run answers 201 even when it fails. - Separate unknown version IDs (exit 20) from unmatched refs (exit 2). - Pass --space explicitly after sf migrate, and state the claim deadline. - Match the sf feedback output sample to the CLI. --- content/(account)/auto-enroll.mdx | 4 +- content/(account)/identity-connections.mdx | 27 +++++++++---- content/(account)/subteams.mdx | 14 +++---- content/(dynamic)/functions.mdx | 14 ++++--- content/(dynamic)/zero-connectors.mdx | 44 ++++++++++++++++++---- content/(publish)/migrate.mdx | 4 +- content/(reference)/feedback.mdx | 1 + content/api/connectors.mdx | 4 +- content/cli/comments.mdx | 9 +++-- content/cli/connectors.mdx | 2 +- 10 files changed, 87 insertions(+), 36 deletions(-) diff --git a/content/(account)/auto-enroll.mdx b/content/(account)/auto-enroll.mdx index 9a7caaef..8be65a4f 100644 --- a/content/(account)/auto-enroll.mdx +++ b/content/(account)/auto-enroll.mdx @@ -17,8 +17,8 @@ Only owners and admins can add or remove domains. An OAuth token needs the `team Over HTTP, send [`createTeamAutoEnrollEntry`](/api/reference/teams/createteamautoenrollentry): ```bash -curl https://api.spacefast.com/v1/teams/<team-id>/auto-enroll \ - -H "Authorization: Bearer sfa_xxxxxxxx" \ +curl "https://api.spacefast.com/v1/teams/$TEAM_ID/auto-enroll" \ + -H "Authorization: Bearer $SPACEFAST_API_KEY" \ -H "Content-Type: application/json" \ -d '{"value":"example.com"}' ``` diff --git a/content/(account)/identity-connections.mdx b/content/(account)/identity-connections.mdx index 938fdbac..0e68113e 100644 --- a/content/(account)/identity-connections.mdx +++ b/content/(account)/identity-connections.mdx @@ -43,8 +43,8 @@ The connection appears as a row with an **OIDC** or **Signer** badge and its iss Send [`createTeamIdentityConnection`](/api/reference/teams/createteamidentityconnection): ```bash -curl https://api.spacefast.com/v1/teams/<team-id>/identity-connections \ - -H "Authorization: Bearer sfa_xxxxxxxx" \ +curl "https://api.spacefast.com/v1/teams/$TEAM_ID/identity-connections" \ + -H "Authorization: Bearer $SPACEFAST_API_KEY" \ -H "Content-Type: application/json" \ -d '{"type":"oidc","name":"Acme SSO","issuer":"https://login.example.com","clientId":"<client-id>","clientSecret":"<client-secret>"}' ``` @@ -114,7 +114,7 @@ A `signer` connection sends visitors to your portal, and your portal vouches for 1. The visitor presses **Continue with** on the access page. Spacefast redirects the browser to your `authorizeUrl` with the query parameters below. 2. Your portal signs the visitor in and signs a proof token. 3. Your server sends the proof to Spacefast and receives a short-lived token. -4. Your portal sends the browser to `https://<host>/__sf/redeem?sf_token=<token>&return=<return>`. The Space sets a session cookie and opens the page. +4. Your portal sends the browser to `/__sf/redeem` on `host`, with the `sf_token` and `return` query parameters. The Space sets a session cookie and opens the page. | Query parameter | What it holds | | --- | --- | @@ -143,14 +143,20 @@ Send the proof from your server, not from the browser: ```bash curl https://api.spacefast.com/identity/acn_xxxxxxxx/exchange \ - -H "Authorization: Bearer <proof-jwt>" \ + -H "Authorization: Bearer $PROOF_JWT" \ -H "Content-Type: application/json" \ -d '{"spaceId":"<spaceId>","host":"<host>","resource":"<return>","browserState":"<browserState>"}' ``` A `200` answers with `token` and `expiresAt`. A `403` means the proof failed or no grant admits that subject to `resource`. -Redirect the browser to `/__sf/redeem` on the same `host` within 60 seconds. The token works once, and only in the browser that started the sign-in. +Redirect the browser to `/__sf/redeem` on the same `host` within 60 seconds. The token works once, and only in the browser that started the sign-in. Encode each value as one query parameter. A `return` such as `/docs?tab=a&sort=b` holds `&` and `=`, so do not join the strings by hand: + +```js +const redeem = new URL(`https://${host}/__sf/redeem`); +redeem.searchParams.set("sf_token", token); +redeem.searchParams.set("return", returnPath); +``` ## Update a connection @@ -163,7 +169,7 @@ In **SSO providers**, press **Update** on the connection's row. Leave **Replacement client secret** blank to keep the current secret. To rename a connection or change a signer's sign-in URL, use the API or the CLI. -[`updateTeamIdentityConnection`](/api/reference/teams/updateteamidentityconnection) accepts `name`, `clientId`, `clientSecret`, `authorizeUrl`, `publicKeys`, and `sessionTtlSeconds`. It accepts `sessionTtlSeconds` from 60 to 86,400. For a signer, Spacefast lowers any value above 1,800 to 1,800. `publicKeys` replaces the whole key set. The `type` and `issuer` cannot change. To change them, create a new connection. +[`updateTeamIdentityConnection`](/api/reference/teams/updateteamidentityconnection) accepts `name`, `clientId`, `clientSecret`, `authorizeUrl`, `publicKeys`, and `sessionTtlSeconds`. It accepts `sessionTtlSeconds` from 60 to 86,400. For a signer, Spacefast lowers any value above 1,800 to 1,800. `publicKeys` replaces the whole key set. The `type` and `issuer` cannot change. Grants that use the connection keep working. A change to the client ID, client secret, keys, or session lifetime sets `rotatedAt`. @@ -173,13 +179,20 @@ To rotate a signer key without a sign-in gap: 2. Sign new proofs with the new key. 3. Send a key set that holds only the new key. +### Replace a connection + +A team can have one active connection per issuer. Revoking a connection also revokes its grants. Plan the order: + +- **To move to a new issuer**, create the new connection and its grants first. Then revoke the old connection. +- **To change the type for the same issuer**, revoke the old connection first, because the new one uses the same issuer. Then create the new connection and add its grants again. Visitors cannot sign in through that issuer until the new grants exist. + ## Revoke a connection Press **Revoke** on the row, then **Revoke connection** in the dialog. Over HTTP, send [`revokeTeamIdentityConnection`](/api/reference/teams/revoketeamidentityconnection). It answers `204`. From a terminal, run `sf share identity revoke`. Revoking removes the grants too. Spacefast revokes every grant on the team's Spaces that trusts the connection's issuer, then the connection itself. -A revoked connection stays in [`listTeamIdentityConnections`](/api/reference/teams/listteamidentityconnections) with `revokedAt` set. It cannot be updated. To restore access, create a new connection and new grants. +A revoked connection stays in [`listTeamIdentityConnections`](/api/reference/teams/listteamidentityconnections) with `revokedAt` set. It cannot be updated. To restore access, create a new connection and add its grants again. ## Errors diff --git a/content/(account)/subteams.mdx b/content/(account)/subteams.mdx index b8ba4585..231f08d5 100644 --- a/content/(account)/subteams.mdx +++ b/content/(account)/subteams.mdx @@ -12,7 +12,7 @@ Subteams need an Enterprise parent team. On every other plan, subteam operations ## How subteams work - **The parent's owners and admins are in every subteam.** They hold the same role there. To change that role, change it on the parent team. -- **Parent members can join a subteam.** Every subteam appears in **Teams you can join** for parent members. Pressing **Join** makes them a subteam `member`, with no new seat. +- **Parent members can join a subteam.** A subteam appears in **Teams you can join** for parent members. Pressing **Join** makes them a subteam `member`, with no new seat. A person removed from a subteam does not see it there. An owner or admin of the parent team must add them again. - **Seats are shared.** Seats count across the parent and all its subteams. A person in several teams of the family uses one seat. - **Subteams do not nest.** A subteam cannot have subteams of its own. - **Leaving the parent leaves every subteam.** When someone leaves or is removed from the parent team, they also lose every subteam membership. @@ -32,8 +32,8 @@ Spacefast creates the subteam and opens it. Its URL slug starts with the parent' Over HTTP, send [`createSubTeam`](/api/reference/teams/createsubteam) with a `name` of 1 to 255 characters: ```bash -curl https://api.spacefast.com/v1/teams/<team-id>/subteams \ - -H "Authorization: Bearer sfa_xxxxxxxx" \ +curl "https://api.spacefast.com/v1/teams/$TEAM_ID/subteams" \ + -H "Authorization: Bearer $SPACEFAST_API_KEY" \ -H "Content-Type: application/json" \ -d '{"name":"Design"}' ``` @@ -51,7 +51,7 @@ The **Subteams** page shows the subteams you are in, each with **Open**. Below t Send [`updateSubTeam`](/api/reference/teams/updatesubteam) with a new `name`. The slug does not change. From a terminal: ```bash -sf teams subteams rename <subteam-id> "Brand design" +sf teams subteams rename "$SUBTEAM_ID" "Brand design" ``` You can also open the subteam and rename it in **Team settings → General**. @@ -61,9 +61,9 @@ You can also open the subteam and rename it in **Team settings → General**. Adding and removing members needs an owner or admin of the parent team. ```bash -sf teams subteams members ls <subteam-id> -sf teams subteams members add <subteam-id> <user-id> -sf teams subteams members rm <subteam-id> <user-id> +sf teams subteams members ls "$SUBTEAM_ID" +sf teams subteams members add "$SUBTEAM_ID" "$USER_ID" +sf teams subteams members rm "$SUBTEAM_ID" "$USER_ID" ``` | Operation | What it does | diff --git a/content/(dynamic)/functions.mdx b/content/(dynamic)/functions.mdx index 5c5934d2..763b0e95 100644 --- a/content/(dynamic)/functions.mdx +++ b/content/(dynamic)/functions.mdx @@ -148,7 +148,7 @@ Two more bindings appear on `env` when the version needs them. Run `sf functions | `env.CONNECTORS` | The version's capsule declares connector roles, or the Space has a connector binding | A connector role | | `env.ZERO` | The version includes a [Zero](/zero-runtime) capsule | The capsule's queries and mutations | -A Zero project ships a Worker when it also has a `functions/` directory or a root `handler.ts`. `sf publish` compiles the capsule and the Worker into one version. +A Zero project ships a Worker when it also has a `functions/` directory or a root `handler.ts`, `handler.js`, or `handler.mjs`. `sf publish` compiles the capsule and the Worker into one version. ### `env.CONNECTORS` @@ -159,16 +159,20 @@ A Zero project ships a Worker when it also has a `functions/` directory or a roo ```js handler.js export default { async fetch(request, env) { - const notes = await env.ZERO.query("notes"); - await env.ZERO.mutate("addNote", "Written by the Worker"); - return Response.json(notes); + if (request.method !== "POST") return Response.json(await env.ZERO.query("notes")); + const secret = env.NOTES_SECRET; + if (!secret || request.headers.get("authorization") !== `Bearer ${secret}`) { + return new Response("Unauthorized", { status: 401 }); + } + const { body } = await request.json(); + return Response.json(await env.ZERO.mutate("addNote", body), { status: 201 }); }, }; ``` `env.ZERO.query(name, ...args)` resolves to the query's data. `env.ZERO.mutate(name, ...args)` resolves to the mutation's return value. There is no call for actions. A failed call throws an `Error` with a `code`. The code is `zero_upstream_unavailable` when the capsule cannot be reached. -Both bindings forward the request's cookie. The runtime verifies the signed Zero identity cookie before it acts for that visitor. When no visitor is signed in, `env.ZERO` runs as the Space's service principal. In the capsule handler, `ctx.auth.userId` is then `service:<spaceId>` and `ctx.auth.provider` is `service`. `ctx.auth.isAuthenticated` is `true`, so a sign-in check alone does not keep the Worker out. +Both bindings forward the request's cookie. The runtime verifies the signed Zero identity cookie before it acts for that visitor. When no visitor is signed in, `env.ZERO` runs as the Space's service principal. In the capsule handler, `ctx.auth.userId` is then `service:<spaceId>` and `ctx.auth.provider` is `service`. `ctx.auth.isAuthenticated` is `true`, so a sign-in check alone does not keep the Worker out. Check the caller in the Worker before it calls a mutation. The example requires a secret that you set with `sf env set NOTES_SECRET --value-from-stdin`. ## Limits diff --git a/content/(dynamic)/zero-connectors.mdx b/content/(dynamic)/zero-connectors.mdx index 04cb19d0..4104ec4c 100644 --- a/content/(dynamic)/zero-connectors.mdx +++ b/content/(dynamic)/zero-connectors.mdx @@ -35,13 +35,14 @@ export default capsule({ | Key | Required | What it does | | --- | --- | --- | -| `as` | yes | Whose account the role uses: `team` or `owner` | -| `connector` | no | The connector slug for the role. The dashboard offers only connections for this connector | +| `as` | yes | Whose account the role uses: `team`, `owner`, or `visitor` | +| `connector` | no | The connector slug for the role. The dashboard offers only connections for this connector. A `visitor` role needs it | | `as` | Account | Condition | | --- | --- | --- | | `team` | A team connection | The connection allows sites to use it | | `owner` | Your own personal connection | The Space is private | +| `visitor` | Each signed-in visitor's own account | The visitor connects it on the Space. See [Let visitors connect their own account](#let-visitors-connect-their-own-account) | A role name starts with a lowercase letter and holds only letters and digits, up to 64 characters. The compiler reads the `connectors` map from source without running it, so write it as a literal. A map that is not a literal object of roles fails the publish with "capsule() connectors must be a role map." @@ -55,6 +56,7 @@ The connection must fit the role: - A `team` role needs a team connection that allows sites to use it. Turn that on with **Allow sites to use this account** in the dashboard, `sf connectors connections edit <connection> --serving`, or [`updateConnection`](/api/reference/connectors/updateconnection) with `serving: true`. - An `owner` role needs a personal connection that you created, on a private Space. If the Space stops being private, Spacefast removes its owner bindings. +- A `visitor` role cannot be bound. Binding one fails with `visitor_role_not_bindable`. The request must act for a user who can manage the Space. A credential that acts for no user gets `forbidden`. @@ -88,6 +90,25 @@ sf connectors unbind tracker --space my-app --yes All three need the `connectors:write` scope. +## Let visitors connect their own account + +A `visitor` role calls the service as the signed-in visitor, with that visitor's own account. Nobody on the team binds it, and the dashboard row has no **Bind account** button. + +```ts server/index.ts +connectors: { + myTracker: connector({ as: "visitor", connector: "linear-app" }), +}, +``` + +1. A call returns `connect_required` until the visitor connects an account. +2. `ConnectorGate` shows a **Connect** button for that state. The button opens the service's sign-in in a popup. +3. A visitor who is not signed in to the Space signs in first. +4. After the service sign-in, the popup closes and the gate runs the call again. + +The connector must support OAuth sign-in. Each connection belongs to one visitor, one Space, and one role. For `reconnect_required`, the gate shows **Reconnect** instead. + +To disconnect, call `disconnect()` on the result of `useConnectorState(result, role)`. To see who connected, call [`listVisitorConnectionUsers`](/api/reference/spaces/listvisitorconnectionusers). It returns counts and roles per Space user, not the accounts. To disconnect one user, call [`revokeVisitorConnection`](/api/reference/spaces/revokevisitorconnection). + ## Call a role from Zero `ctx.connectors.<role>` is on every query, mutation, action, and endpoint context. The property path after the role is the tool name, joined with dots. `ctx.connectors.tracker.issues.create(args)` calls the `issues.create` tool on the bound connection. @@ -135,19 +156,27 @@ To read the state of one result yourself, call `useConnectorState(result)` from ## Call a role from a Worker -A [Worker](/functions) gets `env.CONNECTORS` when the version's capsule declares roles or the Space has a binding. To ship a Worker beside a capsule, add a `functions/` directory or a root `handler.ts` to the Zero project. `sf publish` compiles both into one version. +A [Worker](/functions) gets `env.CONNECTORS` when the version's capsule declares roles or the Space has a binding. To ship a Worker beside a capsule, add a `functions/` directory or a root `handler.ts`, `handler.js`, or `handler.mjs` to the Zero project. `sf publish` compiles both into one version. The role proxy works as it does in Zero. It resolves to the tool's result and throws a `ConnectorCallError` for any other state. ```js handler.js export default { async fetch(request, env) { - const issue = await env.CONNECTORS.tracker.issues.create({ title: "Checkout is down" }); - return Response.json(issue); + if (request.method !== "POST") return new Response("Method not allowed", { status: 405 }); + const secret = env.REPORT_SECRET; + if (!secret || request.headers.get("authorization") !== `Bearer ${secret}`) { + return new Response("Unauthorized", { status: 401 }); + } + const { title } = await request.json(); + const issue = await env.CONNECTORS.tracker.issues.create({ title }); + return Response.json(issue, { status: 201 }); }, }; ``` +A `team` role acts with the team's account for every request that reaches the Worker. Call a tool that writes only on a write method, and only after you check the caller. This example requires a secret that you set with `sf env set REPORT_SECRET --value-from-stdin`. + To handle every state without a throw, call `env.CONNECTORS.call(role, tool, args)`. It resolves to `{ state, run }`. Check `state.code` before you read `run`. ```js handler.js @@ -173,8 +202,8 @@ Every call ends in one state. `state.message` carries the text below. | `ok` | Connected. | The tool ran | | `unbound` | The site owner needs to connect this service. | No usable connection is bound to the role, or the Space has no team. An owner binding stops working when its owner leaves the team | | `forbidden` | You do not have permission to use this connection. | The bound connection does not fit the role, policy blocks the tool, or a Zero read handler called a tool that writes | -| `connect_required` | Connect your account to continue. | The bound connection is still waiting for authorization | -| `reconnect_required` | Reconnect your account to continue. | The bound connection was revoked or needs a new sign-in | +| `connect_required` | Connect your account to continue. | The bound connection is still waiting for authorization, or the visitor has not connected an account for a `visitor` role | +| `reconnect_required` | Reconnect your account to continue. | The connection was revoked or needs a new sign-in | | `approval_pending` | This action is waiting for approval. | Policy parked the call. `pendingReason` is `approval`, and `approvalUrl` links to the decision | | `approval_pending` | Still working on it. Try again in a moment. | The run did not finish within 25 seconds. `pendingReason` is `running` | | `unavailable` | This service is unavailable. Try again. | The tool failed for another reason | @@ -203,6 +232,7 @@ Each call has an idempotency key built from the version, the request id, the han | --- | --- | | `space_must_be_claimed` | The Space does not belong to a team | | `connector_role_unknown` | The live version does not declare the role | +| `visitor_role_not_bindable` | The role is a `visitor` role. Each visitor connects their own account | | `connection_serving_not_granted` | A `team` role got a personal connection, or a team connection that does not allow sites to use it | | `owner_binding_requires_private_space` | An `owner` role got a connection that is not your personal one, or the Space is not private | | `forbidden` | The credential does not act for a user | diff --git a/content/(publish)/migrate.mdx b/content/(publish)/migrate.mdx index 9273737d..b3259037 100644 --- a/content/(publish)/migrate.mdx +++ b/content/(publish)/migrate.mdx @@ -110,9 +110,9 @@ To return at once and check later, pass `--no-wait`. The receipt has the build I ## Finish the move -1. To keep an anonymous copy, open the **Claim** link and pick a team. See [Publish without an account](/anonymous-and-claim). +1. To keep an anonymous copy, open the **Claim** link and pick a team. An unclaimed copy stops serving after 33 hours and 20 minutes. See [The clock](/anonymous-and-claim#the-clock). 2. To serve the copy on your own domain, attach the domain to the new Space. See [Custom domains](/domains). -3. To edit the copy after you claim it, download it with `sf spaces download`. Change the files, then publish them with `sf publish`. +3. To edit the copy after you claim it, download it with `sf spaces download --space` and the ID from the `Space` line. Change the files, then publish them with `sf publish --space` and the same ID. ## Errors diff --git a/content/(reference)/feedback.mdx b/content/(reference)/feedback.mdx index 7a3b4b1b..dc5c82bc 100644 --- a/content/(reference)/feedback.mdx +++ b/content/(reference)/feedback.mdx @@ -33,6 +33,7 @@ The CLI prints the reference: ```text Feedback ref: fdb_0123456789abcdef Thanks! We read every one. +Signed in with `sf login`? Read it back with `sf feedback get fdb_0123456789abcdef`. ``` With `--json`, `data` is `{ "ref": "fdb_…", "status": "stored" }`. diff --git a/content/api/connectors.mdx b/content/api/connectors.mdx index 4bb8077a..270a5229 100644 --- a/content/api/connectors.mdx +++ b/content/api/connectors.mdx @@ -239,9 +239,9 @@ curl -sS -X POST "https://api.spacefast.com/v1/connectors/runs?team=acme" \ `args` is validated against the tool's `inputSchema` and defaults to `{}`. A program can call any tool the credential may use, and it runs for at most 10 seconds. -A new run answers `201`. A retry with the same `idempotencyKey` and the same input answers `200` with the original run. The same key with a different input fails with `409` and `idempotency_key_reused`. +A new run answers `201`, whatever its `status` when the wait ends. A retry with the same `idempotencyKey` and the same input answers `200` with the original run. The same key with a different input fails with `409` and `idempotency_key_reused`. -A run that fails still answers `200`. The request succeeded and the run did not, so read `status`. +A run that fails is not an HTTP error. The request succeeded and the run did not, so read `status`. ```json { diff --git a/content/cli/comments.mdx b/content/cli/comments.mdx index 35c05918..42c74aa1 100644 --- a/content/cli/comments.mdx +++ b/content/cli/comments.mdx @@ -67,7 +67,8 @@ With `--json`, the output is `{ data, pagination }`. `data` holds the threads in | Code | Exit | When | | --- | --- | --- | | `validation_error` | 2 | `--status` is not `open` or `resolved`, or `--limit` is below 1 | -| `validation_error` | 2 | `--version` matches no version in the Space | +| `validation_error` | 2 | `--version` is a ref or a number that matches none of the Space's 100 newest versions | +| `not_found` | 20 | `--version` is a `ver_` ID that does not exist in the Space | ## sf comments get @@ -168,7 +169,8 @@ With `--json`, `data` is the new thread in the [`createSpaceVersionComment`](/ap | `validation_error` | 2 | `--lines` is not a line or a range, or the range ends before it starts | | `validation_error` | 2 | `--file` is combined with `--page` or `--selector`, or `--lines` is passed without `--file` | | `validation_error` | 2 | A `--mention` value matches nobody, or matches more than one person | -| `validation_error` | 2 | `--version` matches no version, or the Space has no versions | +| `validation_error` | 2 | `--version` is a ref or a number that matches none of the Space's 100 newest versions, or the Space has no versions | +| `not_found` | 20 | `--version` is a `ver_` ID that does not exist in the Space | | `comments_disabled` | 2 | Comments are off for the Space | ## sf comments reply @@ -419,7 +421,8 @@ With `--json`, `data` is `{ format, content }`, and `content` is the export as o | Code | Exit | When | | --- | --- | --- | | `validation_error` | 2 | `--format` or `--status` has a value not in the list | -| `validation_error` | 2 | `--version` matches no version in the Space | +| `validation_error` | 2 | `--version` is a ref or a number that matches none of the Space's 100 newest versions | +| `not_found` | 20 | `--version` is a `ver_` ID that does not exist in the Space | ## sf comments settings diff --git a/content/cli/connectors.mdx b/content/cli/connectors.mdx index 94db65f6..c68d4652 100644 --- a/content/cli/connectors.mdx +++ b/content/cli/connectors.mdx @@ -11,7 +11,7 @@ Every command takes the global flags (`--api-url`, `--profile`, `--token`, `-y`/ Every command needs a credential. Without one, it fails with `validation_error` and exit 2 before any request. -API errors map to exit codes by HTTP status: 401 and 403 exit 10, 404 exits 20, 409 exits 30, and 422 exits 2. The tables below list the codes that commands raise before the request. +API errors map to exit codes by HTTP status: 401 and 403 exit 10, 404 exits 20, 409 exits 30, and 422 exits 2. The tables below list the codes each command raises, both before the request and from the API. ## sf connectors ls