Queries & Speckle server

Queries & Speckle server

How Clone authenticates, sends queries, and downloads model data. This section describes the current implementation.

Signing in

Sign-in is delegated entirely to Speckle over OAuth 2.0 with PKCE. Clone never sees a password.

OAuth 2.0 with PKCE
  • Browser
  • Clone
  • Speckle
  1. Step 1, Browser to Clone: Signing in calls a server action. It creates a PKCE challenge, stores it in a cookie and returns Speckle's authorisation URL.

  2. Step 2, Browser to Speckle: You sign in and approve the application on Speckle. Clone never sees the password.

  3. Step 3, Speckle to Clone: Speckle redirects to /api/speckle-connect with an access code.

  4. Step 4, Clone to Speckle: The route exchanges the code, the app credentials and the stored challenge for tokens.

  5. Step 5, Clone to Browser: Tokens are written as httpOnly cookies: speckle_access_token and speckle_refresh_token.

If SPECKLE_APP_ID or SPECKLE_APP_SECRET are missing, the server action in the first step returns a configuration error instead of a broken redirect.

One honest caveat about the last step. The cookies are httpOnly, but the token value is read on the server and handed to client components, because most GraphQL queries are issued from the browser with a Bearer header. So the token does reach the browser's JavaScript — the httpOnly flag protects the cookie, not the credential. The one place it stays server-side is the model-data pipeline described next, which reads the cookie itself and never returns it.

Signing out

Every sign-out control, whether the dashboard's handleSignOut server action or /api/speckle-disconnect, first calls Speckle's POST /auth/logout with the access and refresh tokens, which deletes both on the server that issued them. Only then are the cookies cleared. Because the token can reach browser JavaScript, clearing cookies alone would leave any copied token valid.

Revocation is best effort, with a five-second timeout: if Speckle cannot be reached, the local session is still cleared and a warning is logged. /api/speckle-disconnect accepts only POST and refuses requests the browser marks as cross-site, so another site cannot sign you out.

When a session ends

Speckle checks the bearer token before any GraphQL or REST handler runs. A token it cannot find, one that was revoked, and one past its lifespan (which Speckle revokes on the spot) all get the same answer: 403 with { "error": "Your token is not valid." }. A 403 about missing permissions reads differently and is left as an ordinary error.

Clone recognises that answer in the browser GraphQL client, in /api/model-data (which turns it into 401 with code: "session_expired"), and in the scene's model-data reader, and raises SessionExpiredError. The query client does not retry it: it replaces the page with /api/session-expired, which asks Speckle for the active user with the cookie's token and then:

  • Token invalid: clears both cookies and redirects to /login?session=expired, where the page explains what happened.
  • Speckle unreachable: keeps the cookies and redirects to /login?session=unverified. The proxy lets that through, and signing in replaces the session.
  • Token valid: redirects back to /dashboard without touching anything, so a link from another site cannot sign you out.

Every dashboard page asks for the active user as it loads, so a dead token is caught on the first page it reaches, with no network call added in front of navigation.

Server addresses

Browser queries use context/graphql-fetcher.ts, which currently sends requests to https://app.speckle.systems/graphql. OAuth and /api/model-data use SERVER_URL, defaulting to https://app.speckle.systems. Changing SERVER_URL alone does not switch the browser GraphQL client to a different server; its endpoint must be updated too.

Which address each part talks to
  • Browser GraphQL clientcontext/graphql-fetcher.ts
    https://app.speckle.systems/graphqlHard-coded
  • OAuth and model data/api/speckle-connect, /api/model-data
    SERVER_URLConfigurable

    Defaults to https://app.speckle.systems.

Pointing Clone at another server means changing both. Setting only SERVER_URL signs you in to one server while the dashboard still queries the other.

Both matter as soon as you point Clone at a self-hosted Speckle server.

Sending a GraphQL request

The shared graphqlFetcher<TData, TVariables> sends a POST to Speckle's GraphQL endpoint with JSON { query, variables }, Content-Type: application/json, and an Authorization: Bearer <token> header when a token is provided. It returns json.data and throws the first GraphQL error message when json.errors is present. It throws a SessionExpiredError when Speckle refuses the token and a plain error for any other failed response. It does not implement a timeout or token refresh.

This is the active-user query used by fetchUser in query/queries.ts:

query {
  activeUser {
    id
    email
    name
    bio
    avatar
  }
}

Query helpers receive the session token from their caller. A model request also supplies variables, keeping identifiers separate from the query text:

const data = await graphqlFetcher<
  { project: { models: ProjectModels } },
  { projectId: string }
>(GET_MODELS, { projectId }, token)

Query reference

The dashboard query definitions and their fetch helpers live together in query/queries.ts.

HelperReadsInputs and limits
fetchUserActive user profileToken; query key user, 60-second stale time
fetchProjectsPersonal and workspace projectsToken; pages of 100, deduplicated by project ID, newest first
fetchWorkspacesActive user's workspacesToken; pages of 100
fetchModelsModel names, descriptions and automation statusToken and project ID; server-default page size
fetchLayersModel version and referenced object's childrenToken, project ID and model ID; first returned version
fetchRecentCommentsProject comment threadsToken and project ID; returned threads filtered to the last year
fetchProjectDetailTeam, models and latest version summariesToken and project ID; up to 100 models and one version per model
fetchProjectAutomationsAutomations, triggers and function runsToken and project ID; up to 25 automations and 10 runs each

Project and workspace collection helpers follow cursors until none remain and reject repeated cursors to avoid an endless loop. The other helpers use their explicit limits or the server defaults; they do not retrieve every page.

fetchLayers first resolves the selected model's referencedObject, then queries the object's children. Its catch handler logs an error and returns an empty array. Comment filtering also applies only to the returned page, so it is not a complete one-year history.

Server-side model requests

The browser calls Clone's /api/model-data endpoint with projectId and modelId. The route reads the session cookie on the server, validates identifiers, and sends an authenticated GraphQL request to resolve the version.

GET /api/model-data?projectId=PROJECT_ID&modelId=MODEL_ID
GET /api/model-data?projectId=PROJECT_ID&modelId=MODEL_ID&history=1
GET /api/model-data?projectId=PROJECT_ID&modelId=MODEL_ID&versionId=VERSION_ID

Without versionId, the route returns the latest version's ID, referenced object and server URL. With history=1, it returns up to 100 version summaries. With versionId, it verifies that the version belongs to the model and loads its data.

For a legacy object, the route calls Speckle's REST API: it POSTs { "format": "json" } to /api/v1/projects/{projectId}/models/{modelId}/versions/{versionId}/eav/query. It normalises the export into records. A 404 or an empty normalised export falls back to the loaded viewer tree.

For a bundle. referenced object, the route calls readBundle. Manifest requests are authenticated; pre-signed artifact URLs are fetched over HTTPS without forwarding the account token. See Model data for decoding and size limits.

Errors and caching

Model-data responses use Cache-Control: private, no-store. Missing sessions return 401, malformed IDs or a mismatched model/version return 400, and missing versions return 404. Caught upstream failures return 502. GraphQL calls have a 30-second timeout; the property export has a 60-second timeout.

Model-data responses
  • 400Malformed IDs, or a version that does not belong to the model
  • 401No session
  • 404The version does not exist
  • 502A caught upstream failure
  • 30 s

    GraphQL calls

  • 60 s

    Property export

  • 120 s

    Bundle read

TanStack Query holds client-side results. Model-intelligence filtering runs locally against the normalised dataset, so changing a rule does not send another GraphQL query. The model-data pipeline keeps its token server-side; dashboard browser queries use the token in JavaScript as described under signing in.